diff --git a/app/src/components/intelligence/MemoryFreshnessPanel.test.tsx b/app/src/components/intelligence/MemoryFreshnessPanel.test.tsx new file mode 100644 index 000000000..30b6b8832 --- /dev/null +++ b/app/src/components/intelligence/MemoryFreshnessPanel.test.tsx @@ -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('', () => { + it('renders the loading skeleton', () => { + render(); + expect(screen.getByTestId('memory-freshness-loading')).toBeInTheDocument(); + }); + + it('renders the empty state when there are no facts', () => { + render(); + expect(screen.getByText('No knowledge graph yet.')).toBeInTheDocument(); + }); + + it('renders an error with a working retry button', () => { + const onRetry = vi.fn(); + render(); + 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(); + 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(); + 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(); + expect(screen.getByText('Showing 50 of 60 — address these first.')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/MemoryFreshnessPanel.tsx b/app/src/components/intelligence/MemoryFreshnessPanel.tsx new file mode 100644 index 000000000..b3dbb2c3c --- /dev/null +++ b/app/src/components/intelligence/MemoryFreshnessPanel.tsx @@ -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 = { + fresh: 'bg-sage-400/70', + fading: 'bg-amber-400/70', + stale: 'bg-coral-400/70', +}; + +const STATUS_BADGE: Record = { + 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 = ( +
+

{t('memoryFreshness.title')}

+

{t('memoryFreshness.intro')}

+
+ ); + + if (loading) { + return ( +
+ {intro} +
+
+ {[0, 1, 2].map(i => ( +
+ ))} +
+ {[0, 1, 2, 3].map(i => ( +
+ ))} +
+
+ ); + } + + if (error) { + return ( +
+ {intro} +
+

+ {t('memoryFreshness.errorPrefix')} {error} +

+ {onRetry && ( + + )} +
+
+ ); + } + + if (!report || report.total === 0) { + return ( +
+ {intro} +
+

+ {t('memoryFreshness.empty')} +

+

+ {t('memoryFreshness.emptyHint')} +

+
+
+ ); + } + + const queue = report.staleQueue.slice(0, MAX_QUEUE_ROWS); + + return ( +
+ {intro} + + {/* Status tiles */} +
+ {[ + { label: t('memoryFreshness.metricFresh'), value: report.freshCount }, + { label: t('memoryFreshness.metricFading'), value: report.fadingCount }, + { label: t('memoryFreshness.metricStale'), value: report.staleCount }, + ].map(tile => ( +
+
+ {tile.label} +
+
+ {tile.value} +
+
+ ))} +
+

+ {t('memoryFreshness.recallCaption') + .replace('{recall}', String(pct(report.averageRecall))) + .replace('{total}', String(report.total))} +

+ + {/* Re-confirm queue */} +
+

+ {t('memoryFreshness.queueHeading')} +

+ {queue.length === 0 ? ( +

+ {t('memoryFreshness.allFresh')} +

+ ) : ( +
    + {queue.map(fact => ( +
  • +
    +

    + {fact.subject} {fact.predicate} {fact.object} +

    + + {fact.status === 'stale' + ? t('memoryFreshness.statusStale') + : t('memoryFreshness.statusFading')} + +
    +
    +
    +
    +
    + + {pct(fact.recall)}% + + + {t('memoryFreshness.ageLabel').replace( + '{days}', + String(Math.round(fact.ageDays)) + )} + +
    +
  • + ))} +
+ )} + {report.staleQueue.length > MAX_QUEUE_ROWS && ( +

+ {t('memoryFreshness.queueTruncated') + .replace('{shown}', String(MAX_QUEUE_ROWS)) + .replace('{total}', String(report.staleQueue.length))} +

+ )} +
+
+ ); +}; + +export default MemoryFreshnessPanel; diff --git a/app/src/components/intelligence/MemoryFreshnessTab.test.tsx b/app/src/components/intelligence/MemoryFreshnessTab.test.tsx new file mode 100644 index 000000000..e6381bab4 --- /dev/null +++ b/app/src/components/intelligence/MemoryFreshnessTab.test.tsx @@ -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('', () => { + beforeEach(() => { + mockLoadFreshness.mockReset(); + mockLoadNamespaces.mockReset(); + mockLoadFreshness.mockResolvedValue(report); + mockLoadNamespaces.mockResolvedValue([]); + }); + + it('loads freshness (all namespaces) on mount and renders the result', async () => { + render(); + 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(); + 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(); + await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/)); + }); +}); diff --git a/app/src/components/intelligence/MemoryFreshnessTab.tsx b/app/src/components/intelligence/MemoryFreshnessTab.tsx new file mode 100644 index 000000000..694c52235 --- /dev/null +++ b/app/src/components/intelligence/MemoryFreshnessTab.tsx @@ -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 . 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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [namespaces, setNamespaces] = useState([]); + 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 ( +
+ {namespaces.length > 0 && ( + + )} + + void load(namespace)} + /> +
+ ); +}; + +export default MemoryFreshnessTab; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 0ba3afa03..09282256f 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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': 'توقف عن الابتلاع (ويكي) الحالي يبقى قابلاً للتساؤل', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 83d440fa6..366c43c2a 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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': diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 356c9b108..07e6088b0 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 8714bf6df..3b11f550b 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 4474b96ea..7303dcf87 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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': diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index ec4053fdb..fcf71877b 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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': diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 9ce76ac99..cc1119df6 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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': diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index ccf7ca080..376a423f3 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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': diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index eb94e61c8..93882adb5 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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': diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 37c4ac39e..6ae699336 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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': diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 0405fd2c4..91efc8cab 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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': diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 9c0946c2b..d36a9fa1e 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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': diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index c2ea2dd03..3fafacff9 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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': diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 1838ec38a..2afeb6d64 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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 仍可查询。', diff --git a/app/src/lib/memory/memoryFreshness.test.ts b/app/src/lib/memory/memoryFreshness.test.ts new file mode 100644 index 000000000..6bd45abc9 --- /dev/null +++ b/app/src/lib/memory/memoryFreshness.test.ts @@ -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)) + }); +}); diff --git a/app/src/lib/memory/memoryFreshness.ts b/app/src/lib/memory/memoryFreshness.ts new file mode 100644 index 000000000..f849ae927 --- /dev/null +++ b/app/src/lib/memory/memoryFreshness.ts @@ -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(); + 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, + }; +} diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx index 130be3123..171f1e994 100644 --- a/app/src/pages/Intelligence.tsx +++ b/app/src/pages/Intelligence.tsx @@ -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' && } {activeTab === 'centrality' && } + + {activeTab === 'freshness' && }
diff --git a/app/src/services/api/memoryFreshnessApi.test.ts b/app/src/services/api/memoryFreshnessApi.test.ts new file mode 100644 index 000000000..2e300ce9f --- /dev/null +++ b/app/src/services/api/memoryFreshnessApi.test.ts @@ -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'); + }); +}); diff --git a/app/src/services/api/memoryFreshnessApi.ts b/app/src/services/api/memoryFreshnessApi.ts new file mode 100644 index 000000000..06a41a3fc --- /dev/null +++ b/app/src/services/api/memoryFreshnessApi.ts @@ -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 { + 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 { + return memoryListNamespaces(); +} + +export const memoryFreshnessApi = { loadFreshness, loadNamespaces }; diff --git a/app/src/test/memoryRelationFactory.ts b/app/src/test/memoryRelationFactory.ts new file mode 100644 index 000000000..1368dc092 --- /dev/null +++ b/app/src/test/memoryRelationFactory.ts @@ -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: [], + }; +}