feat(intelligence): add Knowledge Freshness (#2932)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Aashir Athar
2026-05-30 07:43:01 -07:00
committed by GitHub
co-authored by Claude Opus 4.7 Steven Enamakel
parent d23671c45b
commit 26f418af2d
24 changed files with 1194 additions and 1 deletions
@@ -0,0 +1,77 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { computeFreshness } from '../../lib/memory/memoryFreshness';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import MemoryFreshnessPanel from './MemoryFreshnessPanel';
const NOW = 1_700_000_000;
const DAY = 86400;
function rel(subject: string, object: string, agoDays: number): GraphRelation {
return {
namespace: 'n',
subject,
predicate: 'likes',
object,
attrs: {},
updatedAt: NOW - agoDays * DAY,
evidenceCount: 1,
orderIndex: null,
documentIds: [],
chunkIds: [],
};
}
const report = computeFreshness(
[rel('You', 'Berlin', 0), rel('You', 'coffee', 30), rel('You', 'guitar', 90)],
NOW
);
describe('<MemoryFreshnessPanel />', () => {
it('renders the loading skeleton', () => {
render(<MemoryFreshnessPanel report={null} loading />);
expect(screen.getByTestId('memory-freshness-loading')).toBeInTheDocument();
});
it('renders the empty state when there are no facts', () => {
render(<MemoryFreshnessPanel report={computeFreshness([], NOW)} />);
expect(screen.getByText('No knowledge graph yet.')).toBeInTheDocument();
});
it('renders an error with a working retry button', () => {
const onRetry = vi.fn();
render(<MemoryFreshnessPanel report={null} error="graph unavailable" onRetry={onRetry} />);
expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/);
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
it('renders status tiles and the re-confirm queue (non-fresh facts only)', () => {
render(<MemoryFreshnessPanel report={report} />);
expect(screen.getByText('Fresh')).toBeInTheDocument();
expect(screen.getByText('Fading')).toBeInTheDocument();
expect(screen.getByText('Stale')).toBeInTheDocument();
expect(screen.getByText('Re-confirm queue')).toBeInTheDocument();
// The stale 'guitar' fact and fading 'coffee' fact are in the queue...
expect(screen.getByText(/guitar/)).toBeInTheDocument();
expect(screen.getByText(/coffee/)).toBeInTheDocument();
// ...but the fresh 'Berlin' fact is not.
expect(screen.queryByText(/Berlin/)).not.toBeInTheDocument();
});
it('shows the all-fresh message when nothing needs re-confirming', () => {
const allFresh = computeFreshness([rel('You', 'Berlin', 0)], NOW);
render(<MemoryFreshnessPanel report={allFresh} />);
expect(
screen.getByText('Every fact is still fresh — nothing to re-confirm.')
).toBeInTheDocument();
});
it('notes when the re-confirm queue is truncated past the row cap', () => {
// 60 stale facts -> the queue is capped at 50 and a "showing 50 of 60" note appears.
const many = Array.from({ length: 60 }, (_, i) => rel('You', `fact${i}`, 365));
render(<MemoryFreshnessPanel report={computeFreshness(many, NOW)} />);
expect(screen.getByText('Showing 50 of 60 — address these first.')).toBeInTheDocument();
});
});
@@ -0,0 +1,203 @@
/**
* Knowledge Freshness — presentational view. Pure: renders the freshness report
* (status tiles + the re-confirm queue). No data fetching, no clock, no RNG.
*/
import { useT } from '../../lib/i18n/I18nContext';
import type { FactFreshness, FreshnessReport } from '../../lib/memory/memoryFreshness';
const MAX_QUEUE_ROWS = 50;
interface MemoryFreshnessPanelProps {
report: FreshnessReport | null;
loading?: boolean;
error?: string | null;
onRetry?: () => void;
}
const STATUS_BAR: Record<FactFreshness['status'], string> = {
fresh: 'bg-sage-400/70',
fading: 'bg-amber-400/70',
stale: 'bg-coral-400/70',
};
const STATUS_BADGE: Record<FactFreshness['status'], string> = {
fresh: 'bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300',
fading: 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300',
stale: 'bg-coral-100 dark:bg-coral-500/20 text-coral-700 dark:text-coral-300',
};
const pct = (fraction: number): number => Math.round(fraction * 100);
const MemoryFreshnessPanel = ({ report, loading, error, onRetry }: MemoryFreshnessPanelProps) => {
const { t } = useT();
const intro = (
<div
role="note"
className="rounded-lg border border-primary-200 dark:border-primary-500/30 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-xs text-stone-700 dark:text-neutral-200">
<p className="font-medium mb-1">{t('memoryFreshness.title')}</p>
<p>{t('memoryFreshness.intro')}</p>
</div>
);
if (loading) {
return (
<div className="space-y-4">
{intro}
<div
className="space-y-3"
role="status"
aria-label={t('memoryFreshness.loading')}
data-testid="memory-freshness-loading">
<div className="grid gap-2 sm:grid-cols-3">
{[0, 1, 2].map(i => (
<div
key={i}
className="animate-pulse rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 h-16"
/>
))}
</div>
{[0, 1, 2, 3].map(i => (
<div
key={i}
className="animate-pulse rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 h-8"
/>
))}
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-4">
{intro}
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 p-4 text-center">
<p role="alert" className="text-xs text-coral-700 dark:text-coral-300">
{t('memoryFreshness.errorPrefix')} {error}
</p>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-2 rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-600">
{t('memoryFreshness.retry')}
</button>
)}
</div>
</div>
);
}
if (!report || report.total === 0) {
return (
<div className="space-y-4">
{intro}
<div className="py-8 text-center">
<h3 className="text-sm font-semibold text-stone-700 dark:text-neutral-200">
{t('memoryFreshness.empty')}
</h3>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
{t('memoryFreshness.emptyHint')}
</p>
</div>
</div>
);
}
const queue = report.staleQueue.slice(0, MAX_QUEUE_ROWS);
return (
<div className="space-y-4">
{intro}
{/* Status tiles */}
<div className="grid gap-2 sm:grid-cols-3">
{[
{ label: t('memoryFreshness.metricFresh'), value: report.freshCount },
{ label: t('memoryFreshness.metricFading'), value: report.fadingCount },
{ label: t('memoryFreshness.metricStale'), value: report.staleCount },
].map(tile => (
<div
key={tile.label}
className="rounded-lg border border-stone-200 dark:border-neutral-800 p-3">
<div className="text-[10px] uppercase tracking-wider text-stone-400 dark:text-neutral-500">
{tile.label}
</div>
<div className="text-lg font-semibold tabular-nums text-stone-900 dark:text-neutral-100">
{tile.value}
</div>
</div>
))}
</div>
<p className="text-[11px] text-stone-500 dark:text-neutral-400 tabular-nums">
{t('memoryFreshness.recallCaption')
.replace('{recall}', String(pct(report.averageRecall)))
.replace('{total}', String(report.total))}
</p>
{/* Re-confirm queue */}
<section aria-labelledby="memory-freshness-heading" className="space-y-1">
<h3
id="memory-freshness-heading"
className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{t('memoryFreshness.queueHeading')}
</h3>
{queue.length === 0 ? (
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('memoryFreshness.allFresh')}
</p>
) : (
<ul className="space-y-1.5">
{queue.map(fact => (
<li
key={fact.id}
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-2"
title={t('memoryFreshness.recallTitle')
.replace('{recall}', String(pct(fact.recall)))
.replace('{halfLife}', String(Math.round(fact.halfLifeDays)))}>
<div className="flex items-center justify-between gap-2">
<p className="min-w-0 text-sm text-stone-800 dark:text-neutral-100 break-words">
{fact.subject} {fact.predicate} {fact.object}
</p>
<span
className={`shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider ${STATUS_BADGE[fact.status]}`}>
{fact.status === 'stale'
? t('memoryFreshness.statusStale')
: t('memoryFreshness.statusFading')}
</span>
</div>
<div className="mt-1 flex items-center gap-2 text-[11px] tabular-nums">
<div className="flex-1 h-2 rounded bg-stone-100 dark:bg-neutral-800 overflow-hidden">
<div
className={`h-full ${STATUS_BAR[fact.status]}`}
style={{ width: `${pct(fact.recall)}%` }}
/>
</div>
<span className="w-10 shrink-0 text-right text-stone-500 dark:text-neutral-400">
{pct(fact.recall)}%
</span>
<span className="w-16 shrink-0 text-right text-stone-400 dark:text-neutral-500">
{t('memoryFreshness.ageLabel').replace(
'{days}',
String(Math.round(fact.ageDays))
)}
</span>
</div>
</li>
))}
</ul>
)}
{report.staleQueue.length > MAX_QUEUE_ROWS && (
<p className="text-center text-xs text-stone-400 dark:text-neutral-500">
{t('memoryFreshness.queueTruncated')
.replace('{shown}', String(MAX_QUEUE_ROWS))
.replace('{total}', String(report.staleQueue.length))}
</p>
)}
</section>
</div>
);
};
export default MemoryFreshnessPanel;
@@ -0,0 +1,66 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { computeFreshness } from '../../lib/memory/memoryFreshness';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import MemoryFreshnessTab from './MemoryFreshnessTab';
const mockLoadFreshness = vi.fn();
const mockLoadNamespaces = vi.fn();
vi.mock('../../services/api/memoryFreshnessApi', () => ({
loadFreshness: (...args: unknown[]) => mockLoadFreshness(...args),
loadNamespaces: (...args: unknown[]) => mockLoadNamespaces(...args),
}));
const NOW = 1_700_000_000;
function rel(subject: string, object: string): GraphRelation {
return {
namespace: 'n',
subject,
predicate: 'p',
object,
attrs: {},
updatedAt: NOW,
evidenceCount: 1,
orderIndex: null,
documentIds: [],
chunkIds: [],
};
}
const report = computeFreshness([rel('You', 'Berlin')], NOW);
describe('<MemoryFreshnessTab />', () => {
beforeEach(() => {
mockLoadFreshness.mockReset();
mockLoadNamespaces.mockReset();
mockLoadFreshness.mockResolvedValue(report);
mockLoadNamespaces.mockResolvedValue([]);
});
it('loads freshness (all namespaces) on mount and renders the result', async () => {
render(<MemoryFreshnessTab />);
expect(mockLoadFreshness).toHaveBeenCalledTimes(1);
// Called with (nowSeconds, undefined-namespace).
expect(mockLoadFreshness.mock.calls[0][1]).toBeUndefined();
await waitFor(() => expect(screen.getByText('Re-confirm queue')).toBeInTheDocument());
});
it('shows the namespace selector and re-queries on change', async () => {
mockLoadNamespaces.mockResolvedValueOnce(['work', 'personal']);
render(<MemoryFreshnessTab />);
await waitFor(() => screen.getByRole('combobox'));
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'work' } });
await waitFor(() => expect(mockLoadFreshness).toHaveBeenCalledTimes(2));
expect(mockLoadFreshness.mock.calls[1][1]).toBe('work');
});
it('surfaces an error when the load fails', async () => {
mockLoadFreshness.mockReset();
mockLoadFreshness.mockRejectedValueOnce(new Error('graph unavailable'));
render(<MemoryFreshnessTab />);
await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/));
});
});
@@ -0,0 +1,85 @@
/**
* Knowledge Freshness tab (container). Owns load-on-mount, the namespace
* selector, and minting `nowSeconds` (in handlers, never during render).
* Delegates all rendering to the pure <MemoryFreshnessPanel>. Read-only.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import type { FreshnessReport } from '../../lib/memory/memoryFreshness';
import { loadFreshness, loadNamespaces } from '../../services/api/memoryFreshnessApi';
import MemoryFreshnessPanel from './MemoryFreshnessPanel';
const nowSeconds = (): number => Math.floor(Date.now() / 1000);
const MemoryFreshnessTab = () => {
const { t } = useT();
const [report, setReport] = useState<FreshnessReport | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [namespaces, setNamespaces] = useState<string[]>([]);
const [namespace, setNamespace] = useState('');
// Monotonic token: ignore a response if a newer load has since started, so
// an out-of-order resolution can never overwrite the latest result.
const latestRequestId = useRef(0);
const load = useCallback(async (ns: string) => {
const requestId = (latestRequestId.current += 1);
setLoading(true);
setError(null);
try {
const next = await loadFreshness(nowSeconds(), ns || undefined);
if (requestId !== latestRequestId.current) return;
setReport(next);
} catch (err) {
if (requestId !== latestRequestId.current) return;
setError(err instanceof Error ? err.message : String(err));
} finally {
if (requestId === latestRequestId.current) setLoading(false);
}
}, []);
useEffect(() => {
// Namespaces are optional UI sugar; a failure to list them must not block
// the freshness view, so swallow that error specifically.
loadNamespaces()
.then(setNamespaces)
.catch(() => setNamespaces([]));
void load('');
}, [load]);
const handleNamespace = (next: string): void => {
setNamespace(next);
void load(next);
};
return (
<div className="space-y-4">
{namespaces.length > 0 && (
<label className="flex items-center gap-2 text-xs text-stone-600 dark:text-neutral-300">
{t('memoryFreshness.namespaceLabel')}
<select
value={namespace}
onChange={e => handleNamespace(e.target.value)}
className="rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-sm text-stone-800 dark:text-neutral-100">
<option value="">{t('memoryFreshness.namespaceAll')}</option>
{namespaces.map(ns => (
<option key={ns} value={ns}>
{ns}
</option>
))}
</select>
</label>
)}
<MemoryFreshnessPanel
report={report}
loading={loading}
error={error}
onRetry={() => void load(namespace)}
/>
</div>
);
};
export default MemoryFreshnessTab;
+23
View File
@@ -301,6 +301,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeBadge': 'التوصيل',
'graphCentrality.bridgeTitle': 'موصّل — أكثر تأثيرًا مما يوحي به عدد روابطه',
'graphCentrality.degreeTitle': 'Xqx0xxx في ×1x',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
'memoryFreshness.loading': 'Scoring freshness…',
'memoryFreshness.errorPrefix': 'Could not load the graph:',
'memoryFreshness.retry': 'Retry',
'memoryFreshness.empty': 'No knowledge graph yet.',
'memoryFreshness.emptyHint':
'As the assistant records facts about you, their freshness will be tracked here.',
'memoryFreshness.namespaceLabel': 'Namespace',
'memoryFreshness.namespaceAll': 'All namespaces',
'memoryFreshness.metricFresh': 'Fresh',
'memoryFreshness.metricFading': 'Fading',
'memoryFreshness.metricStale': 'Stale',
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
'memoryFreshness.queueHeading': 'Re-confirm queue',
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
'memoryFreshness.statusFading': 'fading',
'memoryFreshness.statusStale': 'stale',
'memoryFreshness.ageLabel': '{days}d old',
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
'memoryTree.status.title': 'شجرة الذاكرة',
'memoryTree.status.autoSyncLabel': 'النظام الآلي',
'memoryTree.status.autoSyncDescription': 'توقف عن الابتلاع (ويكي) الحالي يبقى قابلاً للتساؤل',
+23
View File
@@ -305,6 +305,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeBadge': 'স্বয়ংক্রিয়',
'graphCentrality.bridgeTitle': 'Connector — এর লিংক গণনায় আরও প্রভাবশালী',
'graphCentrality.degreeTitle': 'xqxqx × xx11x এর জন্য',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
'memoryFreshness.loading': 'Scoring freshness…',
'memoryFreshness.errorPrefix': 'Could not load the graph:',
'memoryFreshness.retry': 'Retry',
'memoryFreshness.empty': 'No knowledge graph yet.',
'memoryFreshness.emptyHint':
'As the assistant records facts about you, their freshness will be tracked here.',
'memoryFreshness.namespaceLabel': 'Namespace',
'memoryFreshness.namespaceAll': 'All namespaces',
'memoryFreshness.metricFresh': 'Fresh',
'memoryFreshness.metricFading': 'Fading',
'memoryFreshness.metricStale': 'Stale',
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
'memoryFreshness.queueHeading': 'Re-confirm queue',
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
'memoryFreshness.statusFading': 'fading',
'memoryFreshness.statusStale': 'stale',
'memoryFreshness.ageLabel': '{days}d old',
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
'memoryTree.status.title': 'মেমরি ট্রি',
'memoryTree.status.autoSyncLabel': 'স্বয়ংক্রিয়-sync',
'memoryTree.status.autoSyncDescription':
+23
View File
@@ -316,6 +316,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeTitle':
'Connector einflussreicher als die Anzahl der Links vermuten lässt',
'graphCentrality.degreeTitle': '{in} rein · {out} raus',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
'memoryFreshness.loading': 'Scoring freshness…',
'memoryFreshness.errorPrefix': 'Could not load the graph:',
'memoryFreshness.retry': 'Retry',
'memoryFreshness.empty': 'No knowledge graph yet.',
'memoryFreshness.emptyHint':
'As the assistant records facts about you, their freshness will be tracked here.',
'memoryFreshness.namespaceLabel': 'Namespace',
'memoryFreshness.namespaceAll': 'All namespaces',
'memoryFreshness.metricFresh': 'Fresh',
'memoryFreshness.metricFading': 'Fading',
'memoryFreshness.metricStale': 'Stale',
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
'memoryFreshness.queueHeading': 'Re-confirm queue',
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
'memoryFreshness.statusFading': 'fading',
'memoryFreshness.statusStale': 'stale',
'memoryFreshness.ageLabel': '{days}d old',
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
'memoryTree.status.title': 'Speicherbaum',
'memoryTree.status.autoSyncLabel': 'Auto Sync',
'memoryTree.status.autoSyncDescription':
+23
View File
@@ -330,6 +330,29 @@ const en: TranslationMap = {
'graphCentrality.bridgeBadge': 'connector',
'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests',
'graphCentrality.degreeTitle': '{in} in · {out} out',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
'memoryFreshness.loading': 'Scoring freshness…',
'memoryFreshness.errorPrefix': 'Could not load the graph:',
'memoryFreshness.retry': 'Retry',
'memoryFreshness.empty': 'No knowledge graph yet.',
'memoryFreshness.emptyHint':
'As the assistant records facts about you, their freshness will be tracked here.',
'memoryFreshness.namespaceLabel': 'Namespace',
'memoryFreshness.namespaceAll': 'All namespaces',
'memoryFreshness.metricFresh': 'Fresh',
'memoryFreshness.metricFading': 'Fading',
'memoryFreshness.metricStale': 'Stale',
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
'memoryFreshness.queueHeading': 'Re-confirm queue',
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
'memoryFreshness.statusFading': 'fading',
'memoryFreshness.statusStale': 'stale',
'memoryFreshness.ageLabel': '{days}d old',
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
// Memory Tree status panel (#1856 Part 1)
'memoryTree.status.title': 'Memory Tree',
+23
View File
@@ -315,6 +315,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeBadge': 'conector',
'graphCentrality.bridgeTitle': 'Conector: más influyente de lo que sugiere su número de enlaces',
'graphCentrality.degreeTitle': '{in} entra · {out} sale',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
'memoryFreshness.loading': 'Scoring freshness…',
'memoryFreshness.errorPrefix': 'Could not load the graph:',
'memoryFreshness.retry': 'Retry',
'memoryFreshness.empty': 'No knowledge graph yet.',
'memoryFreshness.emptyHint':
'As the assistant records facts about you, their freshness will be tracked here.',
'memoryFreshness.namespaceLabel': 'Namespace',
'memoryFreshness.namespaceAll': 'All namespaces',
'memoryFreshness.metricFresh': 'Fresh',
'memoryFreshness.metricFading': 'Fading',
'memoryFreshness.metricStale': 'Stale',
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
'memoryFreshness.queueHeading': 'Re-confirm queue',
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
'memoryFreshness.statusFading': 'fading',
'memoryFreshness.statusStale': 'stale',
'memoryFreshness.ageLabel': '{days}d old',
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
'memoryTree.status.title': 'Árbol de la memoria',
'memoryTree.status.autoSyncLabel': 'Auto-sincronización',
'memoryTree.status.autoSyncDescription':
+23
View File
@@ -313,6 +313,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeBadge': 'connecteur',
'graphCentrality.bridgeTitle': 'Connecteur — plus influent que ne le suggère son nombre de liens',
'graphCentrality.degreeTitle': '{in} en · {out} hors',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
'memoryFreshness.loading': 'Scoring freshness…',
'memoryFreshness.errorPrefix': 'Could not load the graph:',
'memoryFreshness.retry': 'Retry',
'memoryFreshness.empty': 'No knowledge graph yet.',
'memoryFreshness.emptyHint':
'As the assistant records facts about you, their freshness will be tracked here.',
'memoryFreshness.namespaceLabel': 'Namespace',
'memoryFreshness.namespaceAll': 'All namespaces',
'memoryFreshness.metricFresh': 'Fresh',
'memoryFreshness.metricFading': 'Fading',
'memoryFreshness.metricStale': 'Stale',
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
'memoryFreshness.queueHeading': 'Re-confirm queue',
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
'memoryFreshness.statusFading': 'fading',
'memoryFreshness.statusStale': 'stale',
'memoryFreshness.ageLabel': '{days}d old',
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
'memoryTree.status.title': 'Arbre de mémoire',
'memoryTree.status.autoSyncLabel': 'Auto-synchronisation',
'memoryTree.status.autoSyncDescription':
+23
View File
@@ -304,6 +304,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeBadge': 'कनेक्टर',
'graphCentrality.bridgeTitle': 'कनेक्टर - इसके लिंक गिनती से अधिक प्रभावशाली सुझाव देते हैं',
'graphCentrality.degreeTitle': '{in}',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
'memoryFreshness.loading': 'Scoring freshness…',
'memoryFreshness.errorPrefix': 'Could not load the graph:',
'memoryFreshness.retry': 'Retry',
'memoryFreshness.empty': 'No knowledge graph yet.',
'memoryFreshness.emptyHint':
'As the assistant records facts about you, their freshness will be tracked here.',
'memoryFreshness.namespaceLabel': 'Namespace',
'memoryFreshness.namespaceAll': 'All namespaces',
'memoryFreshness.metricFresh': 'Fresh',
'memoryFreshness.metricFading': 'Fading',
'memoryFreshness.metricStale': 'Stale',
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
'memoryFreshness.queueHeading': 'Re-confirm queue',
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
'memoryFreshness.statusFading': 'fading',
'memoryFreshness.statusStale': 'stale',
'memoryFreshness.ageLabel': '{days}d old',
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
'memoryTree.status.title': 'मेमोरी ट्री',
'memoryTree.status.autoSyncLabel': 'ऑटो सिंक',
'memoryTree.status.autoSyncDescription':
+23
View File
@@ -307,6 +307,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeTitle':
'Konektor - lebih berpengaruh daripada jumlah link yang menunjukkan',
'graphCentrality.degreeTitle': '{in} keluar',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
'memoryFreshness.loading': 'Scoring freshness…',
'memoryFreshness.errorPrefix': 'Could not load the graph:',
'memoryFreshness.retry': 'Retry',
'memoryFreshness.empty': 'No knowledge graph yet.',
'memoryFreshness.emptyHint':
'As the assistant records facts about you, their freshness will be tracked here.',
'memoryFreshness.namespaceLabel': 'Namespace',
'memoryFreshness.namespaceAll': 'All namespaces',
'memoryFreshness.metricFresh': 'Fresh',
'memoryFreshness.metricFading': 'Fading',
'memoryFreshness.metricStale': 'Stale',
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
'memoryFreshness.queueHeading': 'Re-confirm queue',
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
'memoryFreshness.statusFading': 'fading',
'memoryFreshness.statusStale': 'stale',
'memoryFreshness.ageLabel': '{days}d old',
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
'memoryTree.status.title': 'Pohon Memori',
'memoryTree.status.autoSyncLabel': 'Sinkronisasi otomatis',
'memoryTree.status.autoSyncDescription':
+23
View File
@@ -313,6 +313,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeTitle':
'Connettore — più influente di quanto suggerisca il suo numero di collegamenti',
'graphCentrality.degreeTitle': '{in} in entrata · {out} in uscita',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
'memoryFreshness.loading': 'Scoring freshness…',
'memoryFreshness.errorPrefix': 'Could not load the graph:',
'memoryFreshness.retry': 'Retry',
'memoryFreshness.empty': 'No knowledge graph yet.',
'memoryFreshness.emptyHint':
'As the assistant records facts about you, their freshness will be tracked here.',
'memoryFreshness.namespaceLabel': 'Namespace',
'memoryFreshness.namespaceAll': 'All namespaces',
'memoryFreshness.metricFresh': 'Fresh',
'memoryFreshness.metricFading': 'Fading',
'memoryFreshness.metricStale': 'Stale',
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
'memoryFreshness.queueHeading': 'Re-confirm queue',
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
'memoryFreshness.statusFading': 'fading',
'memoryFreshness.statusStale': 'stale',
'memoryFreshness.ageLabel': '{days}d old',
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
'memoryTree.status.title': 'Albero della memoria',
'memoryTree.status.autoSyncLabel': 'Sincronizzazione automatica',
'memoryTree.status.autoSyncDescription':
+23
View File
@@ -304,6 +304,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeBadge': '커넥터',
'graphCentrality.bridgeTitle': '커넥터 - 링크 수보다 더 큰 영향력을 가진 엔터티',
'graphCentrality.degreeTitle': '들어옴 {in} · 나감 {out}',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
'memoryFreshness.loading': 'Scoring freshness…',
'memoryFreshness.errorPrefix': 'Could not load the graph:',
'memoryFreshness.retry': 'Retry',
'memoryFreshness.empty': 'No knowledge graph yet.',
'memoryFreshness.emptyHint':
'As the assistant records facts about you, their freshness will be tracked here.',
'memoryFreshness.namespaceLabel': 'Namespace',
'memoryFreshness.namespaceAll': 'All namespaces',
'memoryFreshness.metricFresh': 'Fresh',
'memoryFreshness.metricFading': 'Fading',
'memoryFreshness.metricStale': 'Stale',
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
'memoryFreshness.queueHeading': 'Re-confirm queue',
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
'memoryFreshness.statusFading': 'fading',
'memoryFreshness.statusStale': 'stale',
'memoryFreshness.ageLabel': '{days}d old',
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
'memoryTree.status.title': '메모리 트리',
'memoryTree.status.autoSyncLabel': '자동 동기화',
'memoryTree.status.autoSyncDescription':
+23
View File
@@ -309,6 +309,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeBadge': 'łącznik',
'graphCentrality.bridgeTitle': 'Łącznik — bardziej wpływowy, niż sugeruje liczba linków',
'graphCentrality.degreeTitle': '{in} wej. · {out} wyj.',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
'memoryFreshness.loading': 'Scoring freshness…',
'memoryFreshness.errorPrefix': 'Could not load the graph:',
'memoryFreshness.retry': 'Retry',
'memoryFreshness.empty': 'No knowledge graph yet.',
'memoryFreshness.emptyHint':
'As the assistant records facts about you, their freshness will be tracked here.',
'memoryFreshness.namespaceLabel': 'Namespace',
'memoryFreshness.namespaceAll': 'All namespaces',
'memoryFreshness.metricFresh': 'Fresh',
'memoryFreshness.metricFading': 'Fading',
'memoryFreshness.metricStale': 'Stale',
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
'memoryFreshness.queueHeading': 'Re-confirm queue',
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
'memoryFreshness.statusFading': 'fading',
'memoryFreshness.statusStale': 'stale',
'memoryFreshness.ageLabel': '{days}d old',
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
'memoryTree.status.title': 'Drzewo pamięci',
'memoryTree.status.autoSyncLabel': 'Automatyczna synchronizacja',
'memoryTree.status.autoSyncDescription':
+23
View File
@@ -314,6 +314,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeBadge': 'conector',
'graphCentrality.bridgeTitle': 'Conector — mais influente do que seu número de links sugere',
'graphCentrality.degreeTitle': '{in} entrando · {out} saindo',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
'memoryFreshness.loading': 'Scoring freshness…',
'memoryFreshness.errorPrefix': 'Could not load the graph:',
'memoryFreshness.retry': 'Retry',
'memoryFreshness.empty': 'No knowledge graph yet.',
'memoryFreshness.emptyHint':
'As the assistant records facts about you, their freshness will be tracked here.',
'memoryFreshness.namespaceLabel': 'Namespace',
'memoryFreshness.namespaceAll': 'All namespaces',
'memoryFreshness.metricFresh': 'Fresh',
'memoryFreshness.metricFading': 'Fading',
'memoryFreshness.metricStale': 'Stale',
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
'memoryFreshness.queueHeading': 'Re-confirm queue',
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
'memoryFreshness.statusFading': 'fading',
'memoryFreshness.statusStale': 'stale',
'memoryFreshness.ageLabel': '{days}d old',
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
'memoryTree.status.title': 'Árvore da Memória',
'memoryTree.status.autoSyncLabel': 'Auto-sincronização',
'memoryTree.status.autoSyncDescription':
+23
View File
@@ -307,6 +307,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeTitle':
'Коннектор — более влиятельный, чем предполагает количество ссылок.',
'graphCentrality.degreeTitle': '{in} вход · {out} выход',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
'memoryFreshness.loading': 'Scoring freshness…',
'memoryFreshness.errorPrefix': 'Could not load the graph:',
'memoryFreshness.retry': 'Retry',
'memoryFreshness.empty': 'No knowledge graph yet.',
'memoryFreshness.emptyHint':
'As the assistant records facts about you, their freshness will be tracked here.',
'memoryFreshness.namespaceLabel': 'Namespace',
'memoryFreshness.namespaceAll': 'All namespaces',
'memoryFreshness.metricFresh': 'Fresh',
'memoryFreshness.metricFading': 'Fading',
'memoryFreshness.metricStale': 'Stale',
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
'memoryFreshness.queueHeading': 'Re-confirm queue',
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
'memoryFreshness.statusFading': 'fading',
'memoryFreshness.statusStale': 'stale',
'memoryFreshness.ageLabel': '{days}d old',
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
'memoryTree.status.title': 'Дерево памяти',
'memoryTree.status.autoSyncLabel': 'Автосинхронизация',
'memoryTree.status.autoSyncDescription':
+23
View File
@@ -293,6 +293,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeBadge': '连接点',
'graphCentrality.bridgeTitle': '连接点,影响力高于其链接数量所暗示的程度',
'graphCentrality.degreeTitle': '{in} 入 · {out} 出',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
'memoryFreshness.loading': 'Scoring freshness…',
'memoryFreshness.errorPrefix': 'Could not load the graph:',
'memoryFreshness.retry': 'Retry',
'memoryFreshness.empty': 'No knowledge graph yet.',
'memoryFreshness.emptyHint':
'As the assistant records facts about you, their freshness will be tracked here.',
'memoryFreshness.namespaceLabel': 'Namespace',
'memoryFreshness.namespaceAll': 'All namespaces',
'memoryFreshness.metricFresh': 'Fresh',
'memoryFreshness.metricFading': 'Fading',
'memoryFreshness.metricStale': 'Stale',
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
'memoryFreshness.queueHeading': 'Re-confirm queue',
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
'memoryFreshness.statusFading': 'fading',
'memoryFreshness.statusStale': 'stale',
'memoryFreshness.ageLabel': '{days}d old',
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
'memoryTree.status.title': '记忆树',
'memoryTree.status.autoSyncLabel': '自动同步',
'memoryTree.status.autoSyncDescription': '暂停后将停止新的摄取。现有 wiki 仍可查询。',
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest';
import { DAY, NOW, rel } from '../../test/memoryRelationFactory';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import { classify, computeFreshness, recallProbability, strengthFactor } from './memoryFreshness';
describe('strengthFactor', () => {
it('scales the half-life by evidence with diminishing returns (log2)', () => {
expect(strengthFactor(1)).toBeCloseTo(1, 12); // 1 + log2(1)
expect(strengthFactor(2)).toBeCloseTo(2, 12); // 1 + log2(2)
expect(strengthFactor(4)).toBeCloseTo(3, 12); // 1 + log2(4)
expect(strengthFactor(8)).toBeCloseTo(4, 12);
});
it('clamps evidence <= 1 / non-finite to a factor of 1', () => {
expect(strengthFactor(0)).toBe(1);
expect(strengthFactor(-3)).toBe(1);
expect(strengthFactor(Number.NaN)).toBe(1);
});
});
describe('recallProbability', () => {
it('is 1 at age 0 and halves every half-life', () => {
expect(recallProbability(0, 30)).toBe(1);
expect(recallProbability(30, 30)).toBeCloseTo(0.5, 12);
expect(recallProbability(60, 30)).toBeCloseTo(0.25, 12);
expect(recallProbability(90, 30)).toBeCloseTo(0.125, 12);
});
it('treats negative age as fresh (recall 1)', () => {
expect(recallProbability(-5, 30)).toBe(1);
});
it('degrades gracefully for a non-positive half-life', () => {
expect(recallProbability(0, 0)).toBe(1);
expect(recallProbability(5, 0)).toBe(0);
});
});
describe('classify', () => {
it('bands recall into fresh / fading / stale', () => {
expect(classify(0.95)).toBe('fresh');
expect(classify(0.7)).toBe('fresh'); // boundary inclusive
expect(classify(0.5)).toBe('fading');
expect(classify(0.3)).toBe('fading'); // boundary inclusive
expect(classify(0.2)).toBe('stale');
});
});
describe('computeFreshness', () => {
it('returns an empty/zero report for no relations', () => {
const r = computeFreshness([], NOW);
expect(r.facts).toEqual([]);
expect(r.staleQueue).toEqual([]);
expect(r.total).toBe(0);
expect(r.averageRecall).toBe(0);
expect(r.freshCount + r.fadingCount + r.staleCount).toBe(0);
});
it('scores a fresh / fading / stale mix and orders most-stale-first', () => {
const r = computeFreshness(
[
rel('You', 'Berlin', 0), // recall 1 -> fresh
rel('You', 'coffee', 30), // recall 0.5 -> fading
rel('You', 'guitar', 90), // recall 0.125 -> stale
],
NOW
);
expect(r.total).toBe(3);
expect(r.freshCount).toBe(1);
expect(r.fadingCount).toBe(1);
expect(r.staleCount).toBe(1);
expect(r.averageRecall).toBeCloseTo((1 + 0.5 + 0.125) / 3, 9);
// most stale first
expect(r.facts.map(f => f.object)).toEqual(['guitar', 'coffee', 'Berlin']);
expect(r.facts[0].recall).toBeCloseTo(0.125, 9);
// staleQueue excludes the fresh fact
expect(r.staleQueue.map(f => f.object)).toEqual(['guitar', 'coffee']);
});
it('decays more evidence-rich facts more slowly', () => {
const r = computeFreshness(
[
rel('A', 'weak', 60, 1), // halfLife 30 -> recall 0.25 (stale)
rel('A', 'strong', 60, 2), // halfLife 60 -> recall 0.5 (fading)
],
NOW
);
const byObject = Object.fromEntries(r.facts.map(f => [f.object, f]));
expect(byObject.weak.recall).toBeCloseTo(0.25, 9);
expect(byObject.strong.recall).toBeCloseTo(0.5, 9);
expect(byObject.strong.recall).toBeGreaterThan(byObject.weak.recall);
});
it('treats a future updatedAt as fully fresh (no negative age)', () => {
const future: GraphRelation = { ...rel('A', 'B', 0), updatedAt: NOW + 10 * DAY };
const r = computeFreshness([future], NOW);
expect(r.facts[0].ageDays).toBe(0);
expect(r.facts[0].recall).toBe(1);
expect(r.facts[0].status).toBe('fresh');
});
it('collapses a duplicate triple to its freshest occurrence', () => {
const r = computeFreshness(
[
rel('You', 'Berlin', 90), // stale copy
rel('You', 'Berlin', 0), // fresh copy (same triple)
],
NOW
);
expect(r.total).toBe(1);
expect(r.facts[0].recall).toBe(1); // kept the freshest
});
it('drops a malformed relation with a non-string endpoint', () => {
const malformed = { ...rel('A', 'B', 0), predicate: null as unknown as string };
const r = computeFreshness([rel('A', 'B', 0), malformed, rel('C', 'D', 0)], NOW);
expect(r.total).toBe(2);
});
it('exposes the evidence-scaled half-life on each fact', () => {
const r = computeFreshness([rel('A', 'B', 0, 4)], NOW);
expect(r.facts[0].halfLifeDays).toBeCloseTo(90, 9); // 30 * (1 + log2(4))
});
});
+174
View File
@@ -0,0 +1,174 @@
/**
* Knowledge Freshness pure decay-scoring engine.
*
* Every fact the assistant remembers is a (subject)-[predicate]->(object) triple
* that was last reinforced at `updatedAt`. Human memory of an un-rehearsed fact
* decays along a forgetting curve; this engine applies the same idea to the
* assistant's stored facts so the UI can surface what is going STALE and should
* be re-confirmed, rather than treating every stored fact as equally certain.
*
* recall(t) = 2 ^ (-ageDays / halfLifeDays)
* - ageDays = days since the fact was last reinforced (updatedAt)
* - halfLifeDays = DEFAULT_HALF_LIFE_DAYS * (1 + log2(max(1, evidenceCount)))
*
* A fact corroborated by more evidence decays more slowly (a longer half-life),
* with diminishing returns (log2). recall is 1.0 the moment a fact is recorded
* and approaches 0 as it ages without reinforcement.
*
* Everything here is PURE and DETERMINISTIC. The engine never reads the clock:
* the reference time `nowSeconds` is injected by the caller, so the same inputs
* always yield the same report and every branch is unit-testable.
*/
import type { GraphRelation } from '../../utils/tauriCommands/memory';
export type FreshnessStatus = 'fresh' | 'fading' | 'stale';
export interface FactFreshness {
id: string; // stable composite key (subject/predicate/object), JSON-encoded
subject: string;
predicate: string;
object: string;
evidenceCount: number;
updatedAt: number; // epoch seconds the fact was last reinforced
ageDays: number; // days since updatedAt (>= 0)
halfLifeDays: number; // evidence-scaled half-life
recall: number; // 0..1 recall probability now
status: FreshnessStatus;
}
export interface FreshnessReport {
facts: FactFreshness[]; // all facts, most stale first (recall asc, id asc)
staleQueue: FactFreshness[]; // non-fresh facts only, same order (re-confirm queue)
freshCount: number;
fadingCount: number;
staleCount: number;
total: number;
averageRecall: number; // mean recall across all facts (0 when none)
}
export interface FreshnessOptions {
halfLifeDays?: number; // base half-life for an evidenceCount of 1
freshThreshold?: number; // recall >= this => 'fresh'
fadingThreshold?: number; // recall >= this (and < fresh) => 'fading', else 'stale'
}
export const DEFAULT_HALF_LIFE_DAYS = 30;
export const FRESH_THRESHOLD = 0.7;
export const FADING_THRESHOLD = 0.3;
const SECONDS_PER_DAY = 86400;
/** Evidence multiplier on the half-life: more corroboration decays slower. */
export function strengthFactor(evidenceCount: number): number {
const ec = Number.isFinite(evidenceCount) && evidenceCount > 1 ? evidenceCount : 1;
return 1 + Math.log2(ec);
}
/** Recall probability for a given age and half-life; clamped to [0, 1]. */
export function recallProbability(ageDays: number, halfLifeDays: number): number {
if (!(halfLifeDays > 0)) return ageDays <= 0 ? 1 : 0;
const age = ageDays > 0 ? ageDays : 0;
const recall = 2 ** (-age / halfLifeDays);
if (recall > 1) return 1;
if (recall < 0) return 0;
return recall;
}
/** Classify a recall probability into a freshness band. */
export function classify(
recall: number,
freshThreshold = FRESH_THRESHOLD,
fadingThreshold = FADING_THRESHOLD
): FreshnessStatus {
if (recall >= freshThreshold) return 'fresh';
if (recall >= fadingThreshold) return 'fading';
return 'stale';
}
/** Stable, collision-free key for a triple (no raw separators). */
function factKey(subject: string, predicate: string, object: string): string {
return JSON.stringify([subject, predicate, object]);
}
/**
* Compute the freshness report. Pure function of (relations, nowSeconds).
* Duplicate triples are collapsed to the freshest occurrence (max updatedAt,
* then max evidenceCount) so a fact is scored once at its strongest signal.
*/
export function computeFreshness(
relations: GraphRelation[],
nowSeconds: number,
options: FreshnessOptions = {}
): FreshnessReport {
const baseHalfLife = options.halfLifeDays ?? DEFAULT_HALF_LIFE_DAYS;
const freshThreshold = options.freshThreshold ?? FRESH_THRESHOLD;
const fadingThreshold = options.fadingThreshold ?? FADING_THRESHOLD;
// 1. Collapse duplicate triples to their freshest, strongest occurrence.
const bestByKey = new Map<string, GraphRelation>();
for (const relation of relations) {
const { subject, predicate, object } = relation;
if (
typeof subject !== 'string' ||
typeof predicate !== 'string' ||
typeof object !== 'string'
) {
continue;
}
const key = factKey(subject, predicate, object);
const existing = bestByKey.get(key);
if (
!existing ||
relation.updatedAt > existing.updatedAt ||
(relation.updatedAt === existing.updatedAt && relation.evidenceCount > existing.evidenceCount)
) {
bestByKey.set(key, relation);
}
}
// 2. Score each fact.
const facts: FactFreshness[] = [];
let recallSum = 0;
let freshCount = 0;
let fadingCount = 0;
let staleCount = 0;
for (const [key, relation] of bestByKey) {
const evidenceCount =
Number.isFinite(relation.evidenceCount) && relation.evidenceCount > 0
? relation.evidenceCount
: 1;
const halfLifeDays = baseHalfLife * strengthFactor(evidenceCount);
const ageDays = Math.max(0, (nowSeconds - relation.updatedAt) / SECONDS_PER_DAY);
const recall = recallProbability(ageDays, halfLifeDays);
const status = classify(recall, freshThreshold, fadingThreshold);
recallSum += recall;
if (status === 'fresh') freshCount += 1;
else if (status === 'fading') fadingCount += 1;
else staleCount += 1;
facts.push({
id: key,
subject: relation.subject,
predicate: relation.predicate,
object: relation.object,
evidenceCount,
updatedAt: relation.updatedAt,
ageDays,
halfLifeDays,
recall,
status,
});
}
// 3. Sort most-stale-first (recall asc), stable id tie-break.
facts.sort((a, b) => a.recall - b.recall || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
const total = facts.length;
return {
facts,
staleQueue: facts.filter(f => f.status !== 'fresh'),
freshCount,
fadingCount,
staleCount,
total,
averageRecall: total === 0 ? 0 : recallSum / total,
};
}
+12 -1
View File
@@ -5,6 +5,7 @@ import DiagramViewerTab from '../components/intelligence/DiagramViewerTab';
import GraphCentralityTab from '../components/intelligence/GraphCentralityTab';
import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab';
import IntelligenceTasksTab from '../components/intelligence/IntelligenceTasksTab';
import MemoryFreshnessTab from '../components/intelligence/MemoryFreshnessTab';
import { MemoryWorkspace } from '../components/intelligence/MemoryWorkspace';
import { ToastContainer } from '../components/intelligence/Toast';
import PillTabBar from '../components/PillTabBar';
@@ -20,7 +21,14 @@ import type {
} from '../types/intelligence';
import AgentWorkflows from './AgentWorkflows';
type IntelligenceTab = 'memory' | 'subconscious' | 'tasks' | 'workflows' | 'diagram' | 'centrality';
type IntelligenceTab =
| 'memory'
| 'subconscious'
| 'tasks'
| 'workflows'
| 'diagram'
| 'centrality'
| 'freshness';
export default function Intelligence() {
const { t } = useT();
@@ -100,6 +108,7 @@ export default function Intelligence() {
},
{ id: 'diagram', label: t('memory.tab.diagram') },
{ id: 'centrality', label: t('memory.tab.centrality') },
{ id: 'freshness', label: t('memory.tab.freshness') },
];
const activeTabDef = tabs.find(tab => tab.id === activeTab);
@@ -190,6 +199,8 @@ export default function Intelligence() {
{activeTab === 'diagram' && <DiagramViewerTab />}
{activeTab === 'centrality' && <GraphCentralityTab />}
{activeTab === 'freshness' && <MemoryFreshnessTab />}
</div>
</div>
</div>
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { computeFreshness } from '../../lib/memory/memoryFreshness';
import { NOW, rel } from '../../test/memoryRelationFactory';
import { loadFreshness, loadNamespaces, memoryFreshnessApi } from './memoryFreshnessApi';
const mockGraphQuery = vi.fn();
const mockListNamespaces = vi.fn();
vi.mock('../../utils/tauriCommands/memory', () => ({
memoryGraphQuery: (...args: unknown[]) => mockGraphQuery(...args),
memoryListNamespaces: (...args: unknown[]) => mockListNamespaces(...args),
}));
describe('memoryFreshnessApi.loadFreshness', () => {
beforeEach(() => {
mockGraphQuery.mockReset();
});
it('passes the namespace through and returns the engine report for those facts', async () => {
const facts = [rel('You', 'Berlin', 0), rel('You', 'guitar', 90)];
mockGraphQuery.mockResolvedValueOnce(facts);
const out = await loadFreshness(NOW, 'work');
expect(mockGraphQuery).toHaveBeenCalledWith('work');
expect(out).toEqual(computeFreshness(facts, NOW));
});
it('queries all namespaces when none is given', async () => {
mockGraphQuery.mockResolvedValueOnce([]);
const out = await loadFreshness(NOW);
expect(mockGraphQuery).toHaveBeenCalledWith(undefined);
expect(out.total).toBe(0);
});
it('propagates query errors', async () => {
mockGraphQuery.mockRejectedValueOnce(new Error('graph unavailable'));
await expect(loadFreshness(NOW)).rejects.toThrow('graph unavailable');
});
});
describe('memoryFreshnessApi.loadNamespaces', () => {
beforeEach(() => {
mockListNamespaces.mockReset();
});
it('returns the namespace list from the RPC', async () => {
mockListNamespaces.mockResolvedValueOnce(['work', 'personal']);
expect(await loadNamespaces()).toEqual(['work', 'personal']);
});
});
describe('memoryFreshnessApi object', () => {
it('exposes the public surface', () => {
expect(typeof memoryFreshnessApi.loadFreshness).toBe('function');
expect(typeof memoryFreshnessApi.loadNamespaces).toBe('function');
});
});
@@ -0,0 +1,33 @@
/**
* RPC facade for Knowledge Freshness.
*
* Adds ZERO new core surface. Composes two already-shipped JSON-RPC wrappers:
* - memoryGraphQuery (openhuman.memory_graph_query) the facts
* - memoryListNamespaces (openhuman.memory_list_namespaces) the selector
* and delegates all scoring to the pure, deterministic engine. The caller mints
* `nowSeconds` (in an event handler, never during render) so the engine stays
* clock-free and testable. Read-only nothing is persisted.
*/
import debug from 'debug';
import { computeFreshness, type FreshnessReport } from '../../lib/memory/memoryFreshness';
import { memoryGraphQuery, memoryListNamespaces } from '../../utils/tauriCommands/memory';
const log = debug('memory-freshness:api');
/** Fetch the facts for a namespace (or all) and score their freshness as of `nowSeconds`. */
export async function loadFreshness(
nowSeconds: number,
namespace?: string
): Promise<FreshnessReport> {
const relations = await memoryGraphQuery(namespace);
log('loadFreshness namespace=%s relations=%d', namespace ?? '(all)', relations.length);
return computeFreshness(relations, nowSeconds);
}
/** List the namespaces available for the namespace selector. */
export async function loadNamespaces(): Promise<string[]> {
return memoryListNamespaces();
}
export const memoryFreshnessApi = { loadFreshness, loadNamespaces };
+40
View File
@@ -0,0 +1,40 @@
/**
* Shared `GraphRelation` test factory for the Knowledge Freshness suites.
*
* Keeps the relation shape (and the `NOW` / `DAY` anchors) in one place so the
* pure-engine tests (`memoryFreshness.test.ts`) and the API-facade tests
* (`memoryFreshnessApi.test.ts`) stay in sync as `GraphRelation` evolves.
*/
import type { GraphRelation } from '../utils/tauriCommands/memory';
/** Fixed reference instant (epoch seconds) so every scored report is deterministic. */
export const NOW = 1_700_000_000;
/** Seconds in a day — `agoDays` is multiplied by this to age a relation. */
export const DAY = 86400;
/**
* Build a `GraphRelation` aged `agoDays` days before {@link NOW}. The unused
* graph fields (attrs / ids) are filled with inert defaults; callers override
* via spread when a specific field matters.
*/
export function rel(
subject: string,
object: string,
agoDays: number,
evidenceCount = 1,
predicate = 'p',
namespace = 'n'
): GraphRelation {
return {
namespace,
subject,
predicate,
object,
attrs: {},
updatedAt: NOW - agoDays * DAY,
evidenceCount,
orderIndex: null,
documentIds: [],
chunkIds: [],
};
}