feat(intelligence): add Memory Timeline (#2950)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Aashir Athar
2026-05-30 08:16:11 -07:00
committed by GitHub
co-authored by Claude Opus 4.7 Steven Enamakel
parent 26f418af2d
commit dd1439e2dc
23 changed files with 955 additions and 1 deletions
@@ -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('<MemoryTimelinePanel />', () => {
it('renders the loading skeleton', () => {
render(<MemoryTimelinePanel report={null} loading />);
expect(screen.getByTestId('memory-timeline-loading')).toBeInTheDocument();
});
it('renders the empty state when there are no facts', () => {
render(<MemoryTimelinePanel report={computeTimeline([], NOW)} />);
expect(screen.getByText('No knowledge graph yet.')).toBeInTheDocument();
});
it('renders an error with a working retry button', () => {
const onRetry = vi.fn();
render(<MemoryTimelinePanel report={null} error="graph unavailable" onRetry={onRetry} />);
expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/);
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
it('renders summary tiles, the busiest caption, and per-month bars', () => {
render(<MemoryTimelinePanel report={report} />);
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(<MemoryTimelinePanel report={withUndated} />);
expect(screen.getByText('2 fact(s) have no recorded date.')).toBeInTheDocument();
});
});
@@ -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 = (
<div
role="note"
className="rounded-lg border border-primary-200 dark:border-primary-500/30 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-xs text-stone-700 dark:text-neutral-200">
<p className="font-medium mb-1">{t('memoryTimeline.title')}</p>
<p>{t('memoryTimeline.intro')}</p>
</div>
);
if (loading) {
return (
<div className="space-y-4">
{intro}
<div
className="space-y-3"
role="status"
aria-label={t('memoryTimeline.loading')}
data-testid="memory-timeline-loading">
<div className="grid gap-2 sm:grid-cols-3">
{[0, 1, 2].map(i => (
<div
key={i}
className="animate-pulse rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 h-16"
/>
))}
</div>
{[0, 1, 2, 3].map(i => (
<div
key={i}
className="animate-pulse rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 h-6"
/>
))}
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-4">
{intro}
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 p-4 text-center">
<p role="alert" className="text-xs text-coral-700 dark:text-coral-300">
{t('memoryTimeline.errorPrefix')} {error}
</p>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-2 rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-600">
{t('memoryTimeline.retry')}
</button>
)}
</div>
</div>
);
}
if (!report || (report.total === 0 && report.undated === 0)) {
return (
<div className="space-y-4">
{intro}
<div className="py-8 text-center">
<h3 className="text-sm font-semibold text-stone-700 dark:text-neutral-200">
{t('memoryTimeline.empty')}
</h3>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
{t('memoryTimeline.emptyHint')}
</p>
</div>
</div>
);
}
const maxCount = report.busiest?.count ?? 1;
const shown = report.buckets.slice(-MAX_BARS);
const truncated = report.buckets.length > MAX_BARS;
return (
<div className="space-y-4">
{intro}
{/* Summary tiles */}
<div className="grid gap-2 sm:grid-cols-3">
{[
{ label: t('memoryTimeline.metricTotal'), value: report.total },
{ label: t('memoryTimeline.metricMonths'), value: report.buckets.length },
{ label: t('memoryTimeline.metricRecent'), value: report.recentCount },
].map(tile => (
<div
key={tile.label}
className="rounded-lg border border-stone-200 dark:border-neutral-800 p-3">
<div className="text-[10px] uppercase tracking-wider text-stone-400 dark:text-neutral-500">
{tile.label}
</div>
<div className="text-lg font-semibold tabular-nums text-stone-900 dark:text-neutral-100">
{tile.value}
</div>
</div>
))}
</div>
{report.busiest && (
<p className="text-[11px] text-stone-500 dark:text-neutral-400 tabular-nums">
{t('memoryTimeline.busiestCaption')
.replace('{period}', report.busiest.period)
.replace('{count}', String(report.busiest.count))}
</p>
)}
{/* Per-month histogram */}
{shown.length > 0 && (
<section aria-labelledby="memory-timeline-heading" className="space-y-1">
<h3
id="memory-timeline-heading"
className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{t('memoryTimeline.heading')}
</h3>
<ul className="space-y-1">
{shown.map(bucket => (
<li key={bucket.period} className="flex items-center gap-2 text-[11px] tabular-nums">
<span className="w-16 shrink-0 text-stone-400 dark:text-neutral-500">
{bucket.period}
</span>
<div className="flex-1 h-3 rounded bg-stone-100 dark:bg-neutral-800 overflow-hidden">
<div
className="h-full bg-primary-400/70"
style={{ width: `${(bucket.count / maxCount) * 100}%` }}
/>
</div>
<span className="w-8 shrink-0 text-right text-stone-500 dark:text-neutral-400">
{bucket.count}
</span>
</li>
))}
</ul>
{truncated && (
<p className="text-center text-xs text-stone-400 dark:text-neutral-500">
{t('memoryTimeline.truncated')
.replace('{shown}', String(shown.length))
.replace('{total}', String(report.buckets.length))}
</p>
)}
</section>
)}
{report.undated > 0 && (
<p className="text-[11px] text-stone-400 dark:text-neutral-500">
{t('memoryTimeline.undatedNote').replace('{count}', String(report.undated))}
</p>
)}
</div>
);
};
export default MemoryTimelinePanel;
@@ -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('<MemoryTimelineTab />', () => {
beforeEach(() => {
mockLoadTimeline.mockReset();
mockLoadNamespaces.mockReset();
mockLoadTimeline.mockResolvedValue(report);
mockLoadNamespaces.mockResolvedValue([]);
});
it('loads the timeline on mount and renders it', async () => {
render(<MemoryTimelineTab />);
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(<MemoryTimelineTab />);
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(<MemoryTimelineTab />);
await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/));
});
});
@@ -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 <MemoryTimelinePanel>. 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<TimelineReport | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [namespaces, setNamespaces] = useState<string[]>([]);
const [namespace, setNamespace] = useState('');
// Monotonic token: ignore a response if a newer load has since started.
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 (
<div className="space-y-4">
{namespaces.length > 0 && (
<label className="flex items-center gap-2 text-xs text-stone-600 dark:text-neutral-300">
{t('memoryTimeline.namespaceLabel')}
<select
value={namespace}
onChange={e => handleNamespace(e.target.value)}
className="rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-sm text-stone-800 dark:text-neutral-100">
<option value="">{t('memoryTimeline.namespaceAll')}</option>
{namespaces.map(ns => (
<option key={ns} value={ns}>
{ns}
</option>
))}
</select>
</label>
)}
<MemoryTimelinePanel
report={report}
loading={loading}
error={error}
onRetry={() => void load(namespace)}
/>
</div>
);
};
export default MemoryTimelineTab;
+19
View File
@@ -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':
'ويظهر الإرسال على صور ذاكرتك مراكز الحمل - والكيانات الموصلة التي تربط المجموعات المنفصلة عن بعضها البعض، والتي لا يمكن لإحصاء الترددات الخام أن يكشف عنها.',
+19
View File
@@ -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 ফ্রিকোয়েন্সি গণনা প্রকাশ করতে পারে না।',
+19
View File
@@ -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.',
+19
View File
@@ -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.',
+19
View File
@@ -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.',
+19
View File
@@ -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.",
+19
View File
@@ -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':
'अपने मेमोरी ग्राफ़ पर पेजरैंक लोड-असर हब सतहों - और कनेक्टर इकाइयां जो अन्यथा अलग-अलग समूहों को जोड़ती हैं, जो एक कच्चे आवृत्ति गिनती प्रकट नहीं हो सकती हैं।',
+19
View File
@@ -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.',
+19
View File
@@ -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.',
+19
View File
@@ -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를 적용해 핵심 허브와, 단순 빈도 계산으로는 드러나지 않는 분리된 클러스터를 연결하는 엔터티를 드러냅니다.',
+19
View File
@@ -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.',
+19
View File
@@ -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.',
+19
View File
@@ -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 по вашему графику памяти отображает несущие нагрузку концентраторы — и объекты-соединители, которые связывают отдельные кластеры, которые не может выявить необработанный подсчет частоты.',
+19
View File
@@ -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,可找出关键枢纽以及连接原本分离聚类的连接实体,这是单纯频次统计无法揭示的。',
+89
View File
@@ -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
});
});
+96
View File
@@ -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<string, number>();
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 };
}
+6 -1
View File
@@ -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' && <GraphCentralityTab />}
{activeTab === 'freshness' && <MemoryFreshnessTab />}
{activeTab === 'timeline' && <MemoryTimelineTab />}
</div>
</div>
</div>
@@ -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');
});
});
+33
View File
@@ -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<TimelineReport> {
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<string[]> {
return memoryListNamespaces();
}
export const memoryTimelineApi = { loadTimeline, loadNamespaces };