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 };