mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
co-authored by
Claude Opus 4.7
Steven Enamakel
parent
26f418af2d
commit
dd1439e2dc
@@ -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;
|
||||
@@ -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':
|
||||
'ويظهر الإرسال على صور ذاكرتك مراكز الحمل - والكيانات الموصلة التي تربط المجموعات المنفصلة عن بعضها البعض، والتي لا يمكن لإحصاء الترددات الخام أن يكشف عنها.',
|
||||
|
||||
@@ -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 ফ্রিকোয়েন্সি গণনা প্রকাশ করতে পারে না।',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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':
|
||||
'अपने मेमोरी ग्राफ़ पर पेजरैंक लोड-असर हब सतहों - और कनेक्टर इकाइयां जो अन्यथा अलग-अलग समूहों को जोड़ती हैं, जो एक कच्चे आवृत्ति गिनती प्रकट नहीं हो सकती हैं।',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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를 적용해 핵심 허브와, 단순 빈도 계산으로는 드러나지 않는 분리된 클러스터를 연결하는 엔터티를 드러냅니다.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 по вашему графику памяти отображает несущие нагрузку концентраторы — и объекты-соединители, которые связывают отдельные кластеры, которые не может выявить необработанный подсчет частоты.',
|
||||
|
||||
@@ -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,可找出关键枢纽以及连接原本分离聚类的连接实体,这是单纯频次统计无法揭示的。',
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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,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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user