diff --git a/app/src/components/intelligence/MemoryTimelinePanel.test.tsx b/app/src/components/intelligence/MemoryTimelinePanel.test.tsx new file mode 100644 index 0000000000..d5e2a234e6 --- /dev/null +++ b/app/src/components/intelligence/MemoryTimelinePanel.test.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { computeTimeline } from '../../lib/memory/memoryTimeline'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import MemoryTimelinePanel from './MemoryTimelinePanel'; + +const NOW = 1_700_000_000; + +function utc(year: number, month: number, day = 1): number { + return Math.floor(Date.UTC(year, month - 1, day) / 1000); +} + +function rel(updatedAt: number): GraphRelation { + return { + namespace: 'n', + subject: 'You', + predicate: 'p', + object: 'x', + attrs: {}, + updatedAt, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +const report = computeTimeline( + [rel(utc(2023, 1, 10)), rel(utc(2023, 1, 20)), rel(utc(2023, 3, 5))], + NOW +); + +describe('', () => { + it('renders the loading skeleton', () => { + render(); + expect(screen.getByTestId('memory-timeline-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 summary tiles, the busiest caption, and per-month bars', () => { + render(); + expect(screen.getByText('Facts')).toBeInTheDocument(); + expect(screen.getByText('Active months')).toBeInTheDocument(); + expect(screen.getByText('Last 30 days')).toBeInTheDocument(); + expect(screen.getByText('Facts learned per month')).toBeInTheDocument(); + expect(screen.getByText('2023-01')).toBeInTheDocument(); + expect(screen.getByText('2023-03')).toBeInTheDocument(); + expect(screen.getByText('Busiest: 2023-01 (2)')).toBeInTheDocument(); + }); + + it('notes undated facts when present', () => { + const withUndated = computeTimeline([rel(utc(2023, 5, 1)), rel(0), rel(0)], NOW); + render(); + expect(screen.getByText('2 fact(s) have no recorded date.')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/MemoryTimelinePanel.tsx b/app/src/components/intelligence/MemoryTimelinePanel.tsx new file mode 100644 index 0000000000..bf7a028a71 --- /dev/null +++ b/app/src/components/intelligence/MemoryTimelinePanel.tsx @@ -0,0 +1,175 @@ +/** + * Memory Timeline — presentational view. Pure: renders the per-month fact + * histogram + summary tiles. No data fetching, no clock, no RNG. + */ +import { useT } from '../../lib/i18n/I18nContext'; +import type { TimelineReport } from '../../lib/memory/memoryTimeline'; + +const MAX_BARS = 24; + +interface MemoryTimelinePanelProps { + report: TimelineReport | null; + loading?: boolean; + error?: string | null; + onRetry?: () => void; +} + +const MemoryTimelinePanel = ({ report, loading, error, onRetry }: MemoryTimelinePanelProps) => { + const { t } = useT(); + + const intro = ( +
+

{t('memoryTimeline.title')}

+

{t('memoryTimeline.intro')}

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

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

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

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

+

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

+
+
+ ); + } + + const maxCount = report.busiest?.count ?? 1; + const shown = report.buckets.slice(-MAX_BARS); + const truncated = report.buckets.length > MAX_BARS; + + return ( +
+ {intro} + + {/* Summary tiles */} +
+ {[ + { label: t('memoryTimeline.metricTotal'), value: report.total }, + { label: t('memoryTimeline.metricMonths'), value: report.buckets.length }, + { label: t('memoryTimeline.metricRecent'), value: report.recentCount }, + ].map(tile => ( +
+
+ {tile.label} +
+
+ {tile.value} +
+
+ ))} +
+ + {report.busiest && ( +

+ {t('memoryTimeline.busiestCaption') + .replace('{period}', report.busiest.period) + .replace('{count}', String(report.busiest.count))} +

+ )} + + {/* Per-month histogram */} + {shown.length > 0 && ( +
+

+ {t('memoryTimeline.heading')} +

+
    + {shown.map(bucket => ( +
  • + + {bucket.period} + +
    +
    +
    + + {bucket.count} + +
  • + ))} +
+ {truncated && ( +

+ {t('memoryTimeline.truncated') + .replace('{shown}', String(shown.length)) + .replace('{total}', String(report.buckets.length))} +

+ )} +
+ )} + + {report.undated > 0 && ( +

+ {t('memoryTimeline.undatedNote').replace('{count}', String(report.undated))} +

+ )} +
+ ); +}; + +export default MemoryTimelinePanel; diff --git a/app/src/components/intelligence/MemoryTimelineTab.test.tsx b/app/src/components/intelligence/MemoryTimelineTab.test.tsx new file mode 100644 index 0000000000..e7182dbf22 --- /dev/null +++ b/app/src/components/intelligence/MemoryTimelineTab.test.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeTimeline } from '../../lib/memory/memoryTimeline'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import MemoryTimelineTab from './MemoryTimelineTab'; + +const mockLoadTimeline = vi.fn(); +const mockLoadNamespaces = vi.fn(); + +vi.mock('../../services/api/memoryTimelineApi', () => ({ + loadTimeline: (...args: unknown[]) => mockLoadTimeline(...args), + loadNamespaces: (...args: unknown[]) => mockLoadNamespaces(...args), +})); + +const NOW = 1_700_000_000; + +function rel(updatedAt: number): GraphRelation { + return { + namespace: 'n', + subject: 'You', + predicate: 'p', + object: 'x', + attrs: {}, + updatedAt, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +const report = computeTimeline([rel(Math.floor(Date.UTC(2023, 0, 10) / 1000))], NOW); + +describe('', () => { + beforeEach(() => { + mockLoadTimeline.mockReset(); + mockLoadNamespaces.mockReset(); + mockLoadTimeline.mockResolvedValue(report); + mockLoadNamespaces.mockResolvedValue([]); + }); + + it('loads the timeline on mount and renders it', async () => { + render(); + expect(mockLoadTimeline).toHaveBeenCalledTimes(1); + expect(mockLoadTimeline.mock.calls[0][1]).toBeUndefined(); // (nowSeconds, undefined-namespace) + await waitFor(() => expect(screen.getByText('Facts learned per month')).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(mockLoadTimeline).toHaveBeenCalledTimes(2)); + expect(mockLoadTimeline.mock.calls[1][1]).toBe('work'); + }); + + it('surfaces an error when the load fails', async () => { + mockLoadTimeline.mockReset(); + mockLoadTimeline.mockRejectedValueOnce(new Error('graph unavailable')); + render(); + await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/)); + }); +}); diff --git a/app/src/components/intelligence/MemoryTimelineTab.tsx b/app/src/components/intelligence/MemoryTimelineTab.tsx new file mode 100644 index 0000000000..b4fe8bbfd9 --- /dev/null +++ b/app/src/components/intelligence/MemoryTimelineTab.tsx @@ -0,0 +1,82 @@ +/** + * Memory Timeline tab (container). Load-on-mount, namespace selector, and mints + * `nowSeconds` (in handlers, never during render) for the recency window. + * Delegates rendering to the pure . Read-only. + */ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import type { TimelineReport } from '../../lib/memory/memoryTimeline'; +import { loadNamespaces, loadTimeline } from '../../services/api/memoryTimelineApi'; +import MemoryTimelinePanel from './MemoryTimelinePanel'; + +const nowSeconds = (): number => Math.floor(Date.now() / 1000); + +const MemoryTimelineTab = () => { + 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. + const latestRequestId = useRef(0); + + const load = useCallback(async (ns: string) => { + const requestId = (latestRequestId.current += 1); + setLoading(true); + setError(null); + try { + const next = await loadTimeline(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(() => { + 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 MemoryTimelineTab; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 09282256f4..e1466efeaf 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -275,8 +275,27 @@ const messages: TranslationMap = { 'memory.tab.calls': 'المكالمات', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'الإعدادات', 'memory.analyzeNow': 'تحليل الآن', + 'memoryTimeline.title': 'Memory Timeline', + 'memoryTimeline.intro': + 'When the assistant learned about you — facts grouped by the month they were last reinforced. Surfaces growth, bursts of activity, and quiet stretches.', + 'memoryTimeline.loading': 'Building the timeline…', + 'memoryTimeline.errorPrefix': 'Could not load the graph:', + 'memoryTimeline.retry': 'Retry', + 'memoryTimeline.namespaceLabel': 'Namespace', + 'memoryTimeline.namespaceAll': 'All namespaces', + 'memoryTimeline.empty': 'No knowledge graph yet.', + 'memoryTimeline.emptyHint': + 'As the assistant records facts about you, their timeline will appear here.', + 'memoryTimeline.metricTotal': 'Facts', + 'memoryTimeline.metricMonths': 'Active months', + 'memoryTimeline.metricRecent': 'Last 30 days', + 'memoryTimeline.heading': 'Facts learned per month', + 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', + 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', + 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', 'graphCentrality.title': 'مركز المعرفة', 'graphCentrality.intro': 'ويظهر الإرسال على صور ذاكرتك مراكز الحمل - والكيانات الموصلة التي تربط المجموعات المنفصلة عن بعضها البعض، والتي لا يمكن لإحصاء الترددات الخام أن يكشف عنها.', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 366c43c2aa..350b251178 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -278,8 +278,27 @@ const messages: TranslationMap = { 'memory.tab.calls': 'কল', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'সেটিংস', 'memory.analyzeNow': 'এখনই বিশ্লেষণ করুন', + 'memoryTimeline.title': 'Memory Timeline', + 'memoryTimeline.intro': + 'When the assistant learned about you — facts grouped by the month they were last reinforced. Surfaces growth, bursts of activity, and quiet stretches.', + 'memoryTimeline.loading': 'Building the timeline…', + 'memoryTimeline.errorPrefix': 'Could not load the graph:', + 'memoryTimeline.retry': 'Retry', + 'memoryTimeline.namespaceLabel': 'Namespace', + 'memoryTimeline.namespaceAll': 'All namespaces', + 'memoryTimeline.empty': 'No knowledge graph yet.', + 'memoryTimeline.emptyHint': + 'As the assistant records facts about you, their timeline will appear here.', + 'memoryTimeline.metricTotal': 'Facts', + 'memoryTimeline.metricMonths': 'Active months', + 'memoryTimeline.metricRecent': 'Last 30 days', + 'memoryTimeline.heading': 'Facts learned per month', + 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', + 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', + 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', 'graphCentrality.title': 'জ্ঞান গ্রাফ', 'graphCentrality.intro': 'আপনার মেমরি গ্রাফের উপর প্রদর্শিত পেজের ছবি- এবং সংযোগকারী সত্তার সাথে সংযুক্ত একটি সংযুক্ত চক্রের সংযোগ রয়েছে, যা একটি raw ফ্রিকোয়েন্সি গণনা প্রকাশ করতে পারে না।', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 07e6088b09..847444f7c1 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -287,8 +287,27 @@ const messages: TranslationMap = { 'memory.tab.calls': 'Anrufe', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'Einstellungen', 'memory.analyzeNow': 'Jetzt analysieren', + 'memoryTimeline.title': 'Memory Timeline', + 'memoryTimeline.intro': + 'When the assistant learned about you — facts grouped by the month they were last reinforced. Surfaces growth, bursts of activity, and quiet stretches.', + 'memoryTimeline.loading': 'Building the timeline…', + 'memoryTimeline.errorPrefix': 'Could not load the graph:', + 'memoryTimeline.retry': 'Retry', + 'memoryTimeline.namespaceLabel': 'Namespace', + 'memoryTimeline.namespaceAll': 'All namespaces', + 'memoryTimeline.empty': 'No knowledge graph yet.', + 'memoryTimeline.emptyHint': + 'As the assistant records facts about you, their timeline will appear here.', + 'memoryTimeline.metricTotal': 'Facts', + 'memoryTimeline.metricMonths': 'Active months', + 'memoryTimeline.metricRecent': 'Last 30 days', + 'memoryTimeline.heading': 'Facts learned per month', + 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', + 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', + 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', 'graphCentrality.title': 'Zentralität des Wissensgraphen', 'graphCentrality.intro': 'Der PageRank über Ihr Speicherdiagramm zeigt die tragenden Knotenpunkte auf – und die Konnektorentitäten, die ansonsten getrennte Cluster verbinden, die eine reine Häufigkeitszählung nicht aufdecken kann.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 3b11f550b9..4c397faf91 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -303,8 +303,27 @@ const en: TranslationMap = { 'memory.tab.calls': 'Calls', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'Settings', 'memory.analyzeNow': 'Analyze Now', + 'memoryTimeline.title': 'Memory Timeline', + 'memoryTimeline.intro': + 'When the assistant learned about you — facts grouped by the month they were last reinforced. Surfaces growth, bursts of activity, and quiet stretches.', + 'memoryTimeline.loading': 'Building the timeline…', + 'memoryTimeline.errorPrefix': 'Could not load the graph:', + 'memoryTimeline.retry': 'Retry', + 'memoryTimeline.namespaceLabel': 'Namespace', + 'memoryTimeline.namespaceAll': 'All namespaces', + 'memoryTimeline.empty': 'No knowledge graph yet.', + 'memoryTimeline.emptyHint': + 'As the assistant records facts about you, their timeline will appear here.', + 'memoryTimeline.metricTotal': 'Facts', + 'memoryTimeline.metricMonths': 'Active months', + 'memoryTimeline.metricRecent': 'Last 30 days', + 'memoryTimeline.heading': 'Facts learned per month', + 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', + 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', + 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', 'graphCentrality.title': 'Knowledge Graph Centrality', 'graphCentrality.intro': 'PageRank over your memory graph surfaces the load-bearing hubs — and the connector entities that link otherwise-separate clusters, which a raw frequency count cannot reveal.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 7303dcf87f..ffd05bad1d 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -287,8 +287,27 @@ const messages: TranslationMap = { 'memory.tab.calls': 'Llamadas', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'Configuración', 'memory.analyzeNow': 'Analizar ahora', + 'memoryTimeline.title': 'Memory Timeline', + 'memoryTimeline.intro': + 'When the assistant learned about you — facts grouped by the month they were last reinforced. Surfaces growth, bursts of activity, and quiet stretches.', + 'memoryTimeline.loading': 'Building the timeline…', + 'memoryTimeline.errorPrefix': 'Could not load the graph:', + 'memoryTimeline.retry': 'Retry', + 'memoryTimeline.namespaceLabel': 'Namespace', + 'memoryTimeline.namespaceAll': 'All namespaces', + 'memoryTimeline.empty': 'No knowledge graph yet.', + 'memoryTimeline.emptyHint': + 'As the assistant records facts about you, their timeline will appear here.', + 'memoryTimeline.metricTotal': 'Facts', + 'memoryTimeline.metricMonths': 'Active months', + 'memoryTimeline.metricRecent': 'Last 30 days', + 'memoryTimeline.heading': 'Facts learned per month', + 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', + 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', + 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', 'graphCentrality.title': 'Centralidad del gráfico de conocimiento', 'graphCentrality.intro': 'El PageRank sobre su gráfico de memoria muestra los centros de carga y las entidades conectoras que vinculan grupos que de otro modo estarían separados, que un recuento de frecuencia sin procesar no puede revelar.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index fcf71877bd..54b1396082 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -286,8 +286,27 @@ const messages: TranslationMap = { 'memory.tab.calls': 'Appels', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'Paramètres', 'memory.analyzeNow': 'Analyser maintenant', + 'memoryTimeline.title': 'Memory Timeline', + 'memoryTimeline.intro': + 'When the assistant learned about you — facts grouped by the month they were last reinforced. Surfaces growth, bursts of activity, and quiet stretches.', + 'memoryTimeline.loading': 'Building the timeline…', + 'memoryTimeline.errorPrefix': 'Could not load the graph:', + 'memoryTimeline.retry': 'Retry', + 'memoryTimeline.namespaceLabel': 'Namespace', + 'memoryTimeline.namespaceAll': 'All namespaces', + 'memoryTimeline.empty': 'No knowledge graph yet.', + 'memoryTimeline.emptyHint': + 'As the assistant records facts about you, their timeline will appear here.', + 'memoryTimeline.metricTotal': 'Facts', + 'memoryTimeline.metricMonths': 'Active months', + 'memoryTimeline.metricRecent': 'Last 30 days', + 'memoryTimeline.heading': 'Facts learned per month', + 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', + 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', + 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', 'graphCentrality.title': 'Centralité du graphe de connaissances', 'graphCentrality.intro': "PageRank sur votre graphe de mémoire met en évidence les hubs porteurs de charge — et les entités connectrices qui relient des clusters autrement séparés, ce qu'un simple comptage de fréquence ne peut révéler.", diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index cc1119df6b..ffe423890e 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -277,8 +277,27 @@ const messages: TranslationMap = { 'memory.tab.calls': 'कॉल्स', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'सेटिंग्स', 'memory.analyzeNow': 'अभी एनालाइज़ करें', + 'memoryTimeline.title': 'Memory Timeline', + 'memoryTimeline.intro': + 'When the assistant learned about you — facts grouped by the month they were last reinforced. Surfaces growth, bursts of activity, and quiet stretches.', + 'memoryTimeline.loading': 'Building the timeline…', + 'memoryTimeline.errorPrefix': 'Could not load the graph:', + 'memoryTimeline.retry': 'Retry', + 'memoryTimeline.namespaceLabel': 'Namespace', + 'memoryTimeline.namespaceAll': 'All namespaces', + 'memoryTimeline.empty': 'No knowledge graph yet.', + 'memoryTimeline.emptyHint': + 'As the assistant records facts about you, their timeline will appear here.', + 'memoryTimeline.metricTotal': 'Facts', + 'memoryTimeline.metricMonths': 'Active months', + 'memoryTimeline.metricRecent': 'Last 30 days', + 'memoryTimeline.heading': 'Facts learned per month', + 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', + 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', + 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', 'graphCentrality.title': 'ज्ञान ग्राफ केंद्रीयता', 'graphCentrality.intro': 'अपने मेमोरी ग्राफ़ पर पेजरैंक लोड-असर हब सतहों - और कनेक्टर इकाइयां जो अन्यथा अलग-अलग समूहों को जोड़ती हैं, जो एक कच्चे आवृत्ति गिनती प्रकट नहीं हो सकती हैं।', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 376a423f32..022f061114 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -279,8 +279,27 @@ const messages: TranslationMap = { 'memory.tab.calls': 'Panggilan', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'Pengaturan', 'memory.analyzeNow': 'Analisis Sekarang', + 'memoryTimeline.title': 'Memory Timeline', + 'memoryTimeline.intro': + 'When the assistant learned about you — facts grouped by the month they were last reinforced. Surfaces growth, bursts of activity, and quiet stretches.', + 'memoryTimeline.loading': 'Building the timeline…', + 'memoryTimeline.errorPrefix': 'Could not load the graph:', + 'memoryTimeline.retry': 'Retry', + 'memoryTimeline.namespaceLabel': 'Namespace', + 'memoryTimeline.namespaceAll': 'All namespaces', + 'memoryTimeline.empty': 'No knowledge graph yet.', + 'memoryTimeline.emptyHint': + 'As the assistant records facts about you, their timeline will appear here.', + 'memoryTimeline.metricTotal': 'Facts', + 'memoryTimeline.metricMonths': 'Active months', + 'memoryTimeline.metricRecent': 'Last 30 days', + 'memoryTimeline.heading': 'Facts learned per month', + 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', + 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', + 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', 'graphCentrality.title': 'Centralitas Grafik Pengetahuan', 'graphCentrality.intro': 'PageRank melalui grafik memori Anda permukaan hub load- bantalan - dan entiti konektor yang menghubungkan lain-terpisah cluster, yang jumlah frekuensi mentah tidak dapat mengungkapkan.', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 93882adb50..d54d86bcf4 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -284,8 +284,27 @@ const messages: TranslationMap = { 'memory.tab.calls': 'Chiamate', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'Impostazioni', 'memory.analyzeNow': 'Analizza ora', + 'memoryTimeline.title': 'Memory Timeline', + 'memoryTimeline.intro': + 'When the assistant learned about you — facts grouped by the month they were last reinforced. Surfaces growth, bursts of activity, and quiet stretches.', + 'memoryTimeline.loading': 'Building the timeline…', + 'memoryTimeline.errorPrefix': 'Could not load the graph:', + 'memoryTimeline.retry': 'Retry', + 'memoryTimeline.namespaceLabel': 'Namespace', + 'memoryTimeline.namespaceAll': 'All namespaces', + 'memoryTimeline.empty': 'No knowledge graph yet.', + 'memoryTimeline.emptyHint': + 'As the assistant records facts about you, their timeline will appear here.', + 'memoryTimeline.metricTotal': 'Facts', + 'memoryTimeline.metricMonths': 'Active months', + 'memoryTimeline.metricRecent': 'Last 30 days', + 'memoryTimeline.heading': 'Facts learned per month', + 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', + 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', + 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', 'graphCentrality.title': 'Centralità del Grafo della Conoscenza', 'graphCentrality.intro': 'PageRank sul tuo grafo della memoria mette in evidenza gli hub portanti — e le entità di collegamento che connettono cluster altrimenti separati, cosa che un semplice conteggio di frequenza non può rivelare.', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 6ae6993367..b750c096dd 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -277,8 +277,27 @@ const messages: TranslationMap = { 'memory.tab.calls': '통화', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': '설정', 'memory.analyzeNow': '지금 분석', + 'memoryTimeline.title': 'Memory Timeline', + 'memoryTimeline.intro': + 'When the assistant learned about you — facts grouped by the month they were last reinforced. Surfaces growth, bursts of activity, and quiet stretches.', + 'memoryTimeline.loading': 'Building the timeline…', + 'memoryTimeline.errorPrefix': 'Could not load the graph:', + 'memoryTimeline.retry': 'Retry', + 'memoryTimeline.namespaceLabel': 'Namespace', + 'memoryTimeline.namespaceAll': 'All namespaces', + 'memoryTimeline.empty': 'No knowledge graph yet.', + 'memoryTimeline.emptyHint': + 'As the assistant records facts about you, their timeline will appear here.', + 'memoryTimeline.metricTotal': 'Facts', + 'memoryTimeline.metricMonths': 'Active months', + 'memoryTimeline.metricRecent': 'Last 30 days', + 'memoryTimeline.heading': 'Facts learned per month', + 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', + 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', + 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', 'graphCentrality.title': '지식 그래프 중심성', 'graphCentrality.intro': '메모리 그래프에 PageRank를 적용해 핵심 허브와, 단순 빈도 계산으로는 드러나지 않는 분리된 클러스터를 연결하는 엔터티를 드러냅니다.', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 91efc8cab8..c00505eb95 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -282,8 +282,27 @@ const messages: TranslationMap = { 'memory.tab.calls': 'Połączenia', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'Ustawienia', 'memory.analyzeNow': 'Analizuj teraz', + 'memoryTimeline.title': 'Memory Timeline', + 'memoryTimeline.intro': + 'When the assistant learned about you — facts grouped by the month they were last reinforced. Surfaces growth, bursts of activity, and quiet stretches.', + 'memoryTimeline.loading': 'Building the timeline…', + 'memoryTimeline.errorPrefix': 'Could not load the graph:', + 'memoryTimeline.retry': 'Retry', + 'memoryTimeline.namespaceLabel': 'Namespace', + 'memoryTimeline.namespaceAll': 'All namespaces', + 'memoryTimeline.empty': 'No knowledge graph yet.', + 'memoryTimeline.emptyHint': + 'As the assistant records facts about you, their timeline will appear here.', + 'memoryTimeline.metricTotal': 'Facts', + 'memoryTimeline.metricMonths': 'Active months', + 'memoryTimeline.metricRecent': 'Last 30 days', + 'memoryTimeline.heading': 'Facts learned per month', + 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', + 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', + 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', 'graphCentrality.title': 'Centralność grafu wiedzy', 'graphCentrality.intro': 'PageRank nad grafem pamięci pokazuje kluczowe węzły oraz encje-łączniki, które spajają oddzielne klastry, czego nie ujawnia proste zliczanie częstotliwości.', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index d36a9fa1e7..fd61a39cb3 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -286,8 +286,27 @@ const messages: TranslationMap = { 'memory.tab.calls': 'Chamadas', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'Configurações', 'memory.analyzeNow': 'Analisar Agora', + 'memoryTimeline.title': 'Memory Timeline', + 'memoryTimeline.intro': + 'When the assistant learned about you — facts grouped by the month they were last reinforced. Surfaces growth, bursts of activity, and quiet stretches.', + 'memoryTimeline.loading': 'Building the timeline…', + 'memoryTimeline.errorPrefix': 'Could not load the graph:', + 'memoryTimeline.retry': 'Retry', + 'memoryTimeline.namespaceLabel': 'Namespace', + 'memoryTimeline.namespaceAll': 'All namespaces', + 'memoryTimeline.empty': 'No knowledge graph yet.', + 'memoryTimeline.emptyHint': + 'As the assistant records facts about you, their timeline will appear here.', + 'memoryTimeline.metricTotal': 'Facts', + 'memoryTimeline.metricMonths': 'Active months', + 'memoryTimeline.metricRecent': 'Last 30 days', + 'memoryTimeline.heading': 'Facts learned per month', + 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', + 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', + 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', 'graphCentrality.title': 'Centralidade do Grafo de Conhecimento', 'graphCentrality.intro': 'O PageRank sobre seu grafo de memória revela os hubs que suportam carga — e as entidades conectoras que ligam clusters que, de outra forma, seriam separados, algo que uma contagem de frequência bruta não consegue revelar.', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 3fafacff91..f1afacc5ee 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -279,8 +279,27 @@ const messages: TranslationMap = { 'memory.tab.calls': 'Звонки', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'Настройки', 'memory.analyzeNow': 'Анализировать сейчас', + 'memoryTimeline.title': 'Memory Timeline', + 'memoryTimeline.intro': + 'When the assistant learned about you — facts grouped by the month they were last reinforced. Surfaces growth, bursts of activity, and quiet stretches.', + 'memoryTimeline.loading': 'Building the timeline…', + 'memoryTimeline.errorPrefix': 'Could not load the graph:', + 'memoryTimeline.retry': 'Retry', + 'memoryTimeline.namespaceLabel': 'Namespace', + 'memoryTimeline.namespaceAll': 'All namespaces', + 'memoryTimeline.empty': 'No knowledge graph yet.', + 'memoryTimeline.emptyHint': + 'As the assistant records facts about you, their timeline will appear here.', + 'memoryTimeline.metricTotal': 'Facts', + 'memoryTimeline.metricMonths': 'Active months', + 'memoryTimeline.metricRecent': 'Last 30 days', + 'memoryTimeline.heading': 'Facts learned per month', + 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', + 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', + 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', 'graphCentrality.title': 'Централизованность графа знаний', 'graphCentrality.intro': 'PageRank по вашему графику памяти отображает несущие нагрузку концентраторы — и объекты-соединители, которые связывают отдельные кластеры, которые не может выявить необработанный подсчет частоты.', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 2afeb6d642..02c4c1cdd5 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -267,8 +267,27 @@ const messages: TranslationMap = { 'memory.tab.calls': '调用记录', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': '设置', 'memory.analyzeNow': '立即分析', + 'memoryTimeline.title': 'Memory Timeline', + 'memoryTimeline.intro': + 'When the assistant learned about you — facts grouped by the month they were last reinforced. Surfaces growth, bursts of activity, and quiet stretches.', + 'memoryTimeline.loading': 'Building the timeline…', + 'memoryTimeline.errorPrefix': 'Could not load the graph:', + 'memoryTimeline.retry': 'Retry', + 'memoryTimeline.namespaceLabel': 'Namespace', + 'memoryTimeline.namespaceAll': 'All namespaces', + 'memoryTimeline.empty': 'No knowledge graph yet.', + 'memoryTimeline.emptyHint': + 'As the assistant records facts about you, their timeline will appear here.', + 'memoryTimeline.metricTotal': 'Facts', + 'memoryTimeline.metricMonths': 'Active months', + 'memoryTimeline.metricRecent': 'Last 30 days', + 'memoryTimeline.heading': 'Facts learned per month', + 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', + 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', + 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', 'graphCentrality.title': '知识图谱中心性', 'graphCentrality.intro': '对你的记忆图谱运行 PageRank,可找出关键枢纽以及连接原本分离聚类的连接实体,这是单纯频次统计无法揭示的。', diff --git a/app/src/lib/memory/memoryTimeline.test.ts b/app/src/lib/memory/memoryTimeline.test.ts new file mode 100644 index 0000000000..3582788ed4 --- /dev/null +++ b/app/src/lib/memory/memoryTimeline.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { computeTimeline } from './memoryTimeline'; + +const NOW = 1_700_000_000; // 2023-11-14T22:13:20Z +const DAY = 86400; + +/** Epoch seconds for a UTC calendar date (month is 1-based). */ +function utc(year: number, month: number, day = 1): number { + return Math.floor(Date.UTC(year, month - 1, day) / 1000); +} + +function rel(updatedAt: number, subject = 'You', object = 'x'): GraphRelation { + return { + namespace: 'n', + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +describe('computeTimeline', () => { + it('returns an empty report for no relations', () => { + const r = computeTimeline([], NOW); + expect(r.buckets).toEqual([]); + expect(r.total).toBe(0); + expect(r.undated).toBe(0); + expect(r.firstAt).toBeNull(); + expect(r.lastAt).toBeNull(); + expect(r.busiest).toBeNull(); + expect(r.recentCount).toBe(0); + }); + + it('buckets facts by UTC month in chronological order', () => { + const r = computeTimeline( + [rel(utc(2023, 1, 15)), rel(utc(2023, 1, 20)), rel(utc(2023, 3, 10))], + NOW + ); + expect(r.buckets).toEqual([ + { period: '2023-01', count: 2 }, + { period: '2023-03', count: 1 }, + ]); + expect(r.total).toBe(3); + expect(r.firstAt).toBe(utc(2023, 1, 15)); + expect(r.lastAt).toBe(utc(2023, 3, 10)); + }); + + it('orders months across year boundaries', () => { + const r = computeTimeline([rel(utc(2023, 1, 5)), rel(utc(2022, 12, 25))], NOW); + expect(r.buckets.map(b => b.period)).toEqual(['2022-12', '2023-01']); + }); + + it('identifies the busiest month, resolving ties to the earliest', () => { + const r = computeTimeline( + [ + rel(utc(2023, 1, 1)), + rel(utc(2023, 1, 2)), + rel(utc(2023, 2, 1)), + rel(utc(2023, 2, 2)), + rel(utc(2023, 3, 9)), + ], + NOW + ); + // Jan and Feb both have 2; the earliest (Jan) wins the tie. + expect(r.busiest).toEqual({ period: '2023-01', count: 2 }); + }); + + it('counts undated facts separately and excludes them from buckets', () => { + const r = computeTimeline([rel(utc(2023, 5, 1)), rel(0), rel(Number.NaN), rel(-10)], NOW); + expect(r.total).toBe(1); + expect(r.undated).toBe(3); + expect(r.buckets).toEqual([{ period: '2023-05', count: 1 }]); + }); + + it('counts facts updated within the last 30 days', () => { + const r = computeTimeline( + [rel(NOW - 5 * DAY), rel(NOW - 29 * DAY), rel(NOW - 60 * DAY), rel(utc(2023, 1, 1))], + NOW + ); + expect(r.recentCount).toBe(2); // the -5d and -29d facts; -60d and Jan are older + }); +}); diff --git a/app/src/lib/memory/memoryTimeline.ts b/app/src/lib/memory/memoryTimeline.ts new file mode 100644 index 0000000000..cfef119797 --- /dev/null +++ b/app/src/lib/memory/memoryTimeline.ts @@ -0,0 +1,96 @@ +/** + * Memory Timeline — pure temporal-aggregation engine. + * + * Buckets the facts the assistant has recorded by the calendar month they were + * last reinforced (`updatedAt`), so the UI can show WHEN the assistant learned + * about the user — growth, bursts of activity, and quiet stretches — rather than + * only what it knows. A different lens from the structural/scoring views. + * + * Everything here is PURE and DETERMINISTIC. The month label is derived with + * `new Date(updatedAt * 1000)` using UTC accessors — this reads the *data* + * timestamp, never the wall clock, so the same records always bucket the same + * way regardless of machine timezone. The only injected time is `nowSeconds` + * (for the "last 30 days" recency count), passed by the caller — the engine + * itself never calls Date.now(). + */ +import type { GraphRelation } from '../../utils/tauriCommands/memory'; + +export interface TimelineBucket { + period: string; // 'YYYY-MM' (UTC) + count: number; +} + +export interface TimelineReport { + buckets: TimelineBucket[]; // chronological, active months only (gaps visible via labels) + total: number; // facts with a valid timestamp + undated: number; // facts with a missing/invalid updatedAt + firstAt: number | null; // earliest updatedAt (epoch seconds) + lastAt: number | null; // latest updatedAt (epoch seconds) + busiest: TimelineBucket | null; // month with the most facts (ties -> earliest) + recentCount: number; // facts updated within the last 30 days of nowSeconds +} + +const SECONDS_PER_DAY = 86400; +const RECENT_WINDOW_DAYS = 30; + +const EMPTY_REPORT: TimelineReport = { + buckets: [], + total: 0, + undated: 0, + firstAt: null, + lastAt: null, + busiest: null, + recentCount: 0, +}; + +/** 'YYYY-MM' (UTC) for an epoch-seconds timestamp. */ +function monthKey(epochSeconds: number): string { + const date = new Date(epochSeconds * 1000); + const year = date.getUTCFullYear(); + const month = date.getUTCMonth() + 1; + return `${year}-${month < 10 ? '0' : ''}${month}`; +} + +/** + * Build the timeline report. Pure function of (relations, nowSeconds). + * Facts without a finite, positive `updatedAt` are counted as `undated` and + * excluded from the buckets (their month is unknown). + */ +export function computeTimeline(relations: GraphRelation[], nowSeconds: number): TimelineReport { + if (relations.length === 0) return EMPTY_REPORT; + + const counts = new Map(); + let total = 0; + let undated = 0; + let firstAt: number | null = null; + let lastAt: number | null = null; + let recentCount = 0; + const recentThreshold = nowSeconds - RECENT_WINDOW_DAYS * SECONDS_PER_DAY; + + for (const relation of relations) { + const at = relation.updatedAt; + if (!Number.isFinite(at) || at <= 0) { + undated += 1; + continue; + } + total += 1; + const key = monthKey(at); + counts.set(key, (counts.get(key) ?? 0) + 1); + if (firstAt === null || at < firstAt) firstAt = at; + if (lastAt === null || at > lastAt) lastAt = at; + if (at >= recentThreshold) recentCount += 1; + } + + // Chronological order: 'YYYY-MM' strings sort lexicographically by date. + const buckets: TimelineBucket[] = [...counts.entries()] + .map(([period, count]) => ({ period, count })) + .sort((a, b) => (a.period < b.period ? -1 : a.period > b.period ? 1 : 0)); + + let busiest: TimelineBucket | null = null; + for (const bucket of buckets) { + // Ties resolve to the earliest month because buckets are already sorted. + if (busiest === null || bucket.count > busiest.count) busiest = bucket; + } + + return { buckets, total, undated, firstAt, lastAt, busiest, recentCount }; +} diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx index 171f1e994a..371b105d8d 100644 --- a/app/src/pages/Intelligence.tsx +++ b/app/src/pages/Intelligence.tsx @@ -6,6 +6,7 @@ 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 MemoryTimelineTab from '../components/intelligence/MemoryTimelineTab'; import { MemoryWorkspace } from '../components/intelligence/MemoryWorkspace'; import { ToastContainer } from '../components/intelligence/Toast'; import PillTabBar from '../components/PillTabBar'; @@ -28,7 +29,8 @@ type IntelligenceTab = | 'workflows' | 'diagram' | 'centrality' - | 'freshness'; + | 'freshness' + | 'timeline'; export default function Intelligence() { const { t } = useT(); @@ -109,6 +111,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') }, + { id: 'timeline', label: t('memory.tab.timeline') }, ]; const activeTabDef = tabs.find(tab => tab.id === activeTab); @@ -201,6 +204,8 @@ export default function Intelligence() { {activeTab === 'centrality' && } {activeTab === 'freshness' && } + + {activeTab === 'timeline' && }
diff --git a/app/src/services/api/memoryTimelineApi.test.ts b/app/src/services/api/memoryTimelineApi.test.ts new file mode 100644 index 0000000000..2dbde37509 --- /dev/null +++ b/app/src/services/api/memoryTimelineApi.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeTimeline } from '../../lib/memory/memoryTimeline'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { loadNamespaces, loadTimeline, memoryTimelineApi } from './memoryTimelineApi'; + +const mockGraphQuery = vi.fn(); +const mockListNamespaces = vi.fn(); + +vi.mock('../../utils/tauriCommands/memory', () => ({ + memoryGraphQuery: (...args: unknown[]) => mockGraphQuery(...args), + memoryListNamespaces: (...args: unknown[]) => mockListNamespaces(...args), +})); + +const NOW = 1_700_000_000; + +function rel(updatedAt: number): GraphRelation { + return { + namespace: 'work', + subject: 'You', + predicate: 'p', + object: 'x', + attrs: {}, + updatedAt, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +describe('memoryTimelineApi.loadTimeline', () => { + beforeEach(() => { + mockGraphQuery.mockReset(); + }); + + it('passes the namespace through and returns the engine report for those facts', async () => { + const facts = [rel(Math.floor(Date.UTC(2023, 0, 5) / 1000))]; + mockGraphQuery.mockResolvedValueOnce(facts); + const out = await loadTimeline(NOW, 'work'); + expect(mockGraphQuery).toHaveBeenCalledWith('work'); + expect(out).toEqual(computeTimeline(facts, NOW)); + }); + + it('queries all namespaces when none is given', async () => { + mockGraphQuery.mockResolvedValueOnce([]); + const out = await loadTimeline(NOW); + expect(mockGraphQuery).toHaveBeenCalledWith(undefined); + expect(out.total).toBe(0); + }); + + it('propagates query errors', async () => { + mockGraphQuery.mockRejectedValueOnce(new Error('graph unavailable')); + await expect(loadTimeline(NOW)).rejects.toThrow('graph unavailable'); + }); +}); + +describe('memoryTimelineApi.loadNamespaces', () => { + beforeEach(() => { + mockListNamespaces.mockReset(); + }); + + it('returns the namespace list from the RPC', async () => { + mockListNamespaces.mockResolvedValueOnce(['work', 'personal']); + expect(await loadNamespaces()).toEqual(['work', 'personal']); + }); +}); + +describe('memoryTimelineApi object', () => { + it('exposes the public surface', () => { + expect(typeof memoryTimelineApi.loadTimeline).toBe('function'); + expect(typeof memoryTimelineApi.loadNamespaces).toBe('function'); + }); +}); diff --git a/app/src/services/api/memoryTimelineApi.ts b/app/src/services/api/memoryTimelineApi.ts new file mode 100644 index 0000000000..b9180b22c7 --- /dev/null +++ b/app/src/services/api/memoryTimelineApi.ts @@ -0,0 +1,33 @@ +/** + * RPC facade for Memory Timeline. + * + * 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 aggregation to the pure engine. The caller mints `nowSeconds` + * (in an event handler, never during render) for the recency window, so the + * engine stays clock-free. Read-only — nothing is persisted. + */ +import debug from 'debug'; + +import { computeTimeline, type TimelineReport } from '../../lib/memory/memoryTimeline'; +import { memoryGraphQuery, memoryListNamespaces } from '../../utils/tauriCommands/memory'; + +const log = debug('memory-timeline:api'); + +/** Fetch the facts for a namespace (or all) and bucket them into a timeline. */ +export async function loadTimeline( + nowSeconds: number, + namespace?: string +): Promise { + const relations = await memoryGraphQuery(namespace); + log('loadTimeline namespace=%s relations=%d', namespace ?? '(all)', relations.length); + return computeTimeline(relations, nowSeconds); +} + +/** List the namespaces available for the namespace selector. */ +export async function loadNamespaces(): Promise { + return memoryListNamespaces(); +} + +export const memoryTimelineApi = { loadTimeline, loadNamespaces };