mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(intelligence): add Knowledge Freshness (#2932)
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
d23671c45b
commit
26f418af2d
@@ -0,0 +1,77 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { computeFreshness } from '../../lib/memory/memoryFreshness';
|
||||
import type { GraphRelation } from '../../utils/tauriCommands/memory';
|
||||
import MemoryFreshnessPanel from './MemoryFreshnessPanel';
|
||||
|
||||
const NOW = 1_700_000_000;
|
||||
const DAY = 86400;
|
||||
|
||||
function rel(subject: string, object: string, agoDays: number): GraphRelation {
|
||||
return {
|
||||
namespace: 'n',
|
||||
subject,
|
||||
predicate: 'likes',
|
||||
object,
|
||||
attrs: {},
|
||||
updatedAt: NOW - agoDays * DAY,
|
||||
evidenceCount: 1,
|
||||
orderIndex: null,
|
||||
documentIds: [],
|
||||
chunkIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const report = computeFreshness(
|
||||
[rel('You', 'Berlin', 0), rel('You', 'coffee', 30), rel('You', 'guitar', 90)],
|
||||
NOW
|
||||
);
|
||||
|
||||
describe('<MemoryFreshnessPanel />', () => {
|
||||
it('renders the loading skeleton', () => {
|
||||
render(<MemoryFreshnessPanel report={null} loading />);
|
||||
expect(screen.getByTestId('memory-freshness-loading')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the empty state when there are no facts', () => {
|
||||
render(<MemoryFreshnessPanel report={computeFreshness([], NOW)} />);
|
||||
expect(screen.getByText('No knowledge graph yet.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an error with a working retry button', () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<MemoryFreshnessPanel 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 status tiles and the re-confirm queue (non-fresh facts only)', () => {
|
||||
render(<MemoryFreshnessPanel report={report} />);
|
||||
expect(screen.getByText('Fresh')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fading')).toBeInTheDocument();
|
||||
expect(screen.getByText('Stale')).toBeInTheDocument();
|
||||
expect(screen.getByText('Re-confirm queue')).toBeInTheDocument();
|
||||
// The stale 'guitar' fact and fading 'coffee' fact are in the queue...
|
||||
expect(screen.getByText(/guitar/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/coffee/)).toBeInTheDocument();
|
||||
// ...but the fresh 'Berlin' fact is not.
|
||||
expect(screen.queryByText(/Berlin/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the all-fresh message when nothing needs re-confirming', () => {
|
||||
const allFresh = computeFreshness([rel('You', 'Berlin', 0)], NOW);
|
||||
render(<MemoryFreshnessPanel report={allFresh} />);
|
||||
expect(
|
||||
screen.getByText('Every fact is still fresh — nothing to re-confirm.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('notes when the re-confirm queue is truncated past the row cap', () => {
|
||||
// 60 stale facts -> the queue is capped at 50 and a "showing 50 of 60" note appears.
|
||||
const many = Array.from({ length: 60 }, (_, i) => rel('You', `fact${i}`, 365));
|
||||
render(<MemoryFreshnessPanel report={computeFreshness(many, NOW)} />);
|
||||
expect(screen.getByText('Showing 50 of 60 — address these first.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Knowledge Freshness — presentational view. Pure: renders the freshness report
|
||||
* (status tiles + the re-confirm queue). No data fetching, no clock, no RNG.
|
||||
*/
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { FactFreshness, FreshnessReport } from '../../lib/memory/memoryFreshness';
|
||||
|
||||
const MAX_QUEUE_ROWS = 50;
|
||||
|
||||
interface MemoryFreshnessPanelProps {
|
||||
report: FreshnessReport | null;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
const STATUS_BAR: Record<FactFreshness['status'], string> = {
|
||||
fresh: 'bg-sage-400/70',
|
||||
fading: 'bg-amber-400/70',
|
||||
stale: 'bg-coral-400/70',
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<FactFreshness['status'], string> = {
|
||||
fresh: 'bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300',
|
||||
fading: 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300',
|
||||
stale: 'bg-coral-100 dark:bg-coral-500/20 text-coral-700 dark:text-coral-300',
|
||||
};
|
||||
|
||||
const pct = (fraction: number): number => Math.round(fraction * 100);
|
||||
|
||||
const MemoryFreshnessPanel = ({ report, loading, error, onRetry }: MemoryFreshnessPanelProps) => {
|
||||
const { t } = useT();
|
||||
|
||||
const intro = (
|
||||
<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('memoryFreshness.title')}</p>
|
||||
<p>{t('memoryFreshness.intro')}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{intro}
|
||||
<div
|
||||
className="space-y-3"
|
||||
role="status"
|
||||
aria-label={t('memoryFreshness.loading')}
|
||||
data-testid="memory-freshness-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-8"
|
||||
/>
|
||||
))}
|
||||
</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('memoryFreshness.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('memoryFreshness.retry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!report || report.total === 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('memoryFreshness.empty')}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
|
||||
{t('memoryFreshness.emptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const queue = report.staleQueue.slice(0, MAX_QUEUE_ROWS);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{intro}
|
||||
|
||||
{/* Status tiles */}
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{[
|
||||
{ label: t('memoryFreshness.metricFresh'), value: report.freshCount },
|
||||
{ label: t('memoryFreshness.metricFading'), value: report.fadingCount },
|
||||
{ label: t('memoryFreshness.metricStale'), value: report.staleCount },
|
||||
].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>
|
||||
<p className="text-[11px] text-stone-500 dark:text-neutral-400 tabular-nums">
|
||||
{t('memoryFreshness.recallCaption')
|
||||
.replace('{recall}', String(pct(report.averageRecall)))
|
||||
.replace('{total}', String(report.total))}
|
||||
</p>
|
||||
|
||||
{/* Re-confirm queue */}
|
||||
<section aria-labelledby="memory-freshness-heading" className="space-y-1">
|
||||
<h3
|
||||
id="memory-freshness-heading"
|
||||
className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
|
||||
{t('memoryFreshness.queueHeading')}
|
||||
</h3>
|
||||
{queue.length === 0 ? (
|
||||
<p className="text-xs text-stone-500 dark:text-neutral-400">
|
||||
{t('memoryFreshness.allFresh')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{queue.map(fact => (
|
||||
<li
|
||||
key={fact.id}
|
||||
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-2"
|
||||
title={t('memoryFreshness.recallTitle')
|
||||
.replace('{recall}', String(pct(fact.recall)))
|
||||
.replace('{halfLife}', String(Math.round(fact.halfLifeDays)))}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="min-w-0 text-sm text-stone-800 dark:text-neutral-100 break-words">
|
||||
{fact.subject} {fact.predicate} {fact.object}
|
||||
</p>
|
||||
<span
|
||||
className={`shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider ${STATUS_BADGE[fact.status]}`}>
|
||||
{fact.status === 'stale'
|
||||
? t('memoryFreshness.statusStale')
|
||||
: t('memoryFreshness.statusFading')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] tabular-nums">
|
||||
<div className="flex-1 h-2 rounded bg-stone-100 dark:bg-neutral-800 overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${STATUS_BAR[fact.status]}`}
|
||||
style={{ width: `${pct(fact.recall)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-10 shrink-0 text-right text-stone-500 dark:text-neutral-400">
|
||||
{pct(fact.recall)}%
|
||||
</span>
|
||||
<span className="w-16 shrink-0 text-right text-stone-400 dark:text-neutral-500">
|
||||
{t('memoryFreshness.ageLabel').replace(
|
||||
'{days}',
|
||||
String(Math.round(fact.ageDays))
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{report.staleQueue.length > MAX_QUEUE_ROWS && (
|
||||
<p className="text-center text-xs text-stone-400 dark:text-neutral-500">
|
||||
{t('memoryFreshness.queueTruncated')
|
||||
.replace('{shown}', String(MAX_QUEUE_ROWS))
|
||||
.replace('{total}', String(report.staleQueue.length))}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoryFreshnessPanel;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { computeFreshness } from '../../lib/memory/memoryFreshness';
|
||||
import type { GraphRelation } from '../../utils/tauriCommands/memory';
|
||||
import MemoryFreshnessTab from './MemoryFreshnessTab';
|
||||
|
||||
const mockLoadFreshness = vi.fn();
|
||||
const mockLoadNamespaces = vi.fn();
|
||||
|
||||
vi.mock('../../services/api/memoryFreshnessApi', () => ({
|
||||
loadFreshness: (...args: unknown[]) => mockLoadFreshness(...args),
|
||||
loadNamespaces: (...args: unknown[]) => mockLoadNamespaces(...args),
|
||||
}));
|
||||
|
||||
const NOW = 1_700_000_000;
|
||||
|
||||
function rel(subject: string, object: string): GraphRelation {
|
||||
return {
|
||||
namespace: 'n',
|
||||
subject,
|
||||
predicate: 'p',
|
||||
object,
|
||||
attrs: {},
|
||||
updatedAt: NOW,
|
||||
evidenceCount: 1,
|
||||
orderIndex: null,
|
||||
documentIds: [],
|
||||
chunkIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const report = computeFreshness([rel('You', 'Berlin')], NOW);
|
||||
|
||||
describe('<MemoryFreshnessTab />', () => {
|
||||
beforeEach(() => {
|
||||
mockLoadFreshness.mockReset();
|
||||
mockLoadNamespaces.mockReset();
|
||||
mockLoadFreshness.mockResolvedValue(report);
|
||||
mockLoadNamespaces.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('loads freshness (all namespaces) on mount and renders the result', async () => {
|
||||
render(<MemoryFreshnessTab />);
|
||||
expect(mockLoadFreshness).toHaveBeenCalledTimes(1);
|
||||
// Called with (nowSeconds, undefined-namespace).
|
||||
expect(mockLoadFreshness.mock.calls[0][1]).toBeUndefined();
|
||||
await waitFor(() => expect(screen.getByText('Re-confirm queue')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows the namespace selector and re-queries on change', async () => {
|
||||
mockLoadNamespaces.mockResolvedValueOnce(['work', 'personal']);
|
||||
render(<MemoryFreshnessTab />);
|
||||
await waitFor(() => screen.getByRole('combobox'));
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'work' } });
|
||||
await waitFor(() => expect(mockLoadFreshness).toHaveBeenCalledTimes(2));
|
||||
expect(mockLoadFreshness.mock.calls[1][1]).toBe('work');
|
||||
});
|
||||
|
||||
it('surfaces an error when the load fails', async () => {
|
||||
mockLoadFreshness.mockReset();
|
||||
mockLoadFreshness.mockRejectedValueOnce(new Error('graph unavailable'));
|
||||
render(<MemoryFreshnessTab />);
|
||||
await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Knowledge Freshness tab (container). Owns load-on-mount, the namespace
|
||||
* selector, and minting `nowSeconds` (in handlers, never during render).
|
||||
* Delegates all rendering to the pure <MemoryFreshnessPanel>. Read-only.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { FreshnessReport } from '../../lib/memory/memoryFreshness';
|
||||
import { loadFreshness, loadNamespaces } from '../../services/api/memoryFreshnessApi';
|
||||
import MemoryFreshnessPanel from './MemoryFreshnessPanel';
|
||||
|
||||
const nowSeconds = (): number => Math.floor(Date.now() / 1000);
|
||||
|
||||
const MemoryFreshnessTab = () => {
|
||||
const { t } = useT();
|
||||
const [report, setReport] = useState<FreshnessReport | 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, so
|
||||
// an out-of-order resolution can never overwrite the latest result.
|
||||
const latestRequestId = useRef(0);
|
||||
|
||||
const load = useCallback(async (ns: string) => {
|
||||
const requestId = (latestRequestId.current += 1);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await loadFreshness(nowSeconds(), ns || undefined);
|
||||
if (requestId !== latestRequestId.current) return;
|
||||
setReport(next);
|
||||
} catch (err) {
|
||||
if (requestId !== latestRequestId.current) return;
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (requestId === latestRequestId.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Namespaces are optional UI sugar; a failure to list them must not block
|
||||
// the freshness view, so swallow that error specifically.
|
||||
loadNamespaces()
|
||||
.then(setNamespaces)
|
||||
.catch(() => setNamespaces([]));
|
||||
void load('');
|
||||
}, [load]);
|
||||
|
||||
const handleNamespace = (next: string): void => {
|
||||
setNamespace(next);
|
||||
void load(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<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('memoryFreshness.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('memoryFreshness.namespaceAll')}</option>
|
||||
{namespaces.map(ns => (
|
||||
<option key={ns} value={ns}>
|
||||
{ns}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<MemoryFreshnessPanel
|
||||
report={report}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRetry={() => void load(namespace)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoryFreshnessTab;
|
||||
@@ -301,6 +301,29 @@ const messages: TranslationMap = {
|
||||
'graphCentrality.bridgeBadge': 'التوصيل',
|
||||
'graphCentrality.bridgeTitle': 'موصّل — أكثر تأثيرًا مما يوحي به عدد روابطه',
|
||||
'graphCentrality.degreeTitle': 'Xqx0xxx في ×1x',
|
||||
'memory.tab.freshness': 'Freshness',
|
||||
'memoryFreshness.title': 'Knowledge Freshness',
|
||||
'memoryFreshness.intro':
|
||||
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
|
||||
'memoryFreshness.loading': 'Scoring freshness…',
|
||||
'memoryFreshness.errorPrefix': 'Could not load the graph:',
|
||||
'memoryFreshness.retry': 'Retry',
|
||||
'memoryFreshness.empty': 'No knowledge graph yet.',
|
||||
'memoryFreshness.emptyHint':
|
||||
'As the assistant records facts about you, their freshness will be tracked here.',
|
||||
'memoryFreshness.namespaceLabel': 'Namespace',
|
||||
'memoryFreshness.namespaceAll': 'All namespaces',
|
||||
'memoryFreshness.metricFresh': 'Fresh',
|
||||
'memoryFreshness.metricFading': 'Fading',
|
||||
'memoryFreshness.metricStale': 'Stale',
|
||||
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
|
||||
'memoryFreshness.queueHeading': 'Re-confirm queue',
|
||||
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
|
||||
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
|
||||
'memoryFreshness.statusFading': 'fading',
|
||||
'memoryFreshness.statusStale': 'stale',
|
||||
'memoryFreshness.ageLabel': '{days}d old',
|
||||
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
|
||||
'memoryTree.status.title': 'شجرة الذاكرة',
|
||||
'memoryTree.status.autoSyncLabel': 'النظام الآلي',
|
||||
'memoryTree.status.autoSyncDescription': 'توقف عن الابتلاع (ويكي) الحالي يبقى قابلاً للتساؤل',
|
||||
|
||||
@@ -305,6 +305,29 @@ const messages: TranslationMap = {
|
||||
'graphCentrality.bridgeBadge': 'স্বয়ংক্রিয়',
|
||||
'graphCentrality.bridgeTitle': 'Connector — এর লিংক গণনায় আরও প্রভাবশালী',
|
||||
'graphCentrality.degreeTitle': 'xqxqx × xx11x এর জন্য',
|
||||
'memory.tab.freshness': 'Freshness',
|
||||
'memoryFreshness.title': 'Knowledge Freshness',
|
||||
'memoryFreshness.intro':
|
||||
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
|
||||
'memoryFreshness.loading': 'Scoring freshness…',
|
||||
'memoryFreshness.errorPrefix': 'Could not load the graph:',
|
||||
'memoryFreshness.retry': 'Retry',
|
||||
'memoryFreshness.empty': 'No knowledge graph yet.',
|
||||
'memoryFreshness.emptyHint':
|
||||
'As the assistant records facts about you, their freshness will be tracked here.',
|
||||
'memoryFreshness.namespaceLabel': 'Namespace',
|
||||
'memoryFreshness.namespaceAll': 'All namespaces',
|
||||
'memoryFreshness.metricFresh': 'Fresh',
|
||||
'memoryFreshness.metricFading': 'Fading',
|
||||
'memoryFreshness.metricStale': 'Stale',
|
||||
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
|
||||
'memoryFreshness.queueHeading': 'Re-confirm queue',
|
||||
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
|
||||
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
|
||||
'memoryFreshness.statusFading': 'fading',
|
||||
'memoryFreshness.statusStale': 'stale',
|
||||
'memoryFreshness.ageLabel': '{days}d old',
|
||||
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
|
||||
'memoryTree.status.title': 'মেমরি ট্রি',
|
||||
'memoryTree.status.autoSyncLabel': 'স্বয়ংক্রিয়-sync',
|
||||
'memoryTree.status.autoSyncDescription':
|
||||
|
||||
@@ -316,6 +316,29 @@ const messages: TranslationMap = {
|
||||
'graphCentrality.bridgeTitle':
|
||||
'Connector – einflussreicher als die Anzahl der Links vermuten lässt',
|
||||
'graphCentrality.degreeTitle': '{in} rein · {out} raus',
|
||||
'memory.tab.freshness': 'Freshness',
|
||||
'memoryFreshness.title': 'Knowledge Freshness',
|
||||
'memoryFreshness.intro':
|
||||
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
|
||||
'memoryFreshness.loading': 'Scoring freshness…',
|
||||
'memoryFreshness.errorPrefix': 'Could not load the graph:',
|
||||
'memoryFreshness.retry': 'Retry',
|
||||
'memoryFreshness.empty': 'No knowledge graph yet.',
|
||||
'memoryFreshness.emptyHint':
|
||||
'As the assistant records facts about you, their freshness will be tracked here.',
|
||||
'memoryFreshness.namespaceLabel': 'Namespace',
|
||||
'memoryFreshness.namespaceAll': 'All namespaces',
|
||||
'memoryFreshness.metricFresh': 'Fresh',
|
||||
'memoryFreshness.metricFading': 'Fading',
|
||||
'memoryFreshness.metricStale': 'Stale',
|
||||
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
|
||||
'memoryFreshness.queueHeading': 'Re-confirm queue',
|
||||
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
|
||||
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
|
||||
'memoryFreshness.statusFading': 'fading',
|
||||
'memoryFreshness.statusStale': 'stale',
|
||||
'memoryFreshness.ageLabel': '{days}d old',
|
||||
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
|
||||
'memoryTree.status.title': 'Speicherbaum',
|
||||
'memoryTree.status.autoSyncLabel': 'Auto Sync',
|
||||
'memoryTree.status.autoSyncDescription':
|
||||
|
||||
@@ -330,6 +330,29 @@ const en: TranslationMap = {
|
||||
'graphCentrality.bridgeBadge': 'connector',
|
||||
'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests',
|
||||
'graphCentrality.degreeTitle': '{in} in · {out} out',
|
||||
'memory.tab.freshness': 'Freshness',
|
||||
'memoryFreshness.title': 'Knowledge Freshness',
|
||||
'memoryFreshness.intro':
|
||||
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
|
||||
'memoryFreshness.loading': 'Scoring freshness…',
|
||||
'memoryFreshness.errorPrefix': 'Could not load the graph:',
|
||||
'memoryFreshness.retry': 'Retry',
|
||||
'memoryFreshness.empty': 'No knowledge graph yet.',
|
||||
'memoryFreshness.emptyHint':
|
||||
'As the assistant records facts about you, their freshness will be tracked here.',
|
||||
'memoryFreshness.namespaceLabel': 'Namespace',
|
||||
'memoryFreshness.namespaceAll': 'All namespaces',
|
||||
'memoryFreshness.metricFresh': 'Fresh',
|
||||
'memoryFreshness.metricFading': 'Fading',
|
||||
'memoryFreshness.metricStale': 'Stale',
|
||||
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
|
||||
'memoryFreshness.queueHeading': 'Re-confirm queue',
|
||||
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
|
||||
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
|
||||
'memoryFreshness.statusFading': 'fading',
|
||||
'memoryFreshness.statusStale': 'stale',
|
||||
'memoryFreshness.ageLabel': '{days}d old',
|
||||
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
|
||||
|
||||
// Memory Tree status panel (#1856 Part 1)
|
||||
'memoryTree.status.title': 'Memory Tree',
|
||||
|
||||
@@ -315,6 +315,29 @@ const messages: TranslationMap = {
|
||||
'graphCentrality.bridgeBadge': 'conector',
|
||||
'graphCentrality.bridgeTitle': 'Conector: más influyente de lo que sugiere su número de enlaces',
|
||||
'graphCentrality.degreeTitle': '{in} entra · {out} sale',
|
||||
'memory.tab.freshness': 'Freshness',
|
||||
'memoryFreshness.title': 'Knowledge Freshness',
|
||||
'memoryFreshness.intro':
|
||||
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
|
||||
'memoryFreshness.loading': 'Scoring freshness…',
|
||||
'memoryFreshness.errorPrefix': 'Could not load the graph:',
|
||||
'memoryFreshness.retry': 'Retry',
|
||||
'memoryFreshness.empty': 'No knowledge graph yet.',
|
||||
'memoryFreshness.emptyHint':
|
||||
'As the assistant records facts about you, their freshness will be tracked here.',
|
||||
'memoryFreshness.namespaceLabel': 'Namespace',
|
||||
'memoryFreshness.namespaceAll': 'All namespaces',
|
||||
'memoryFreshness.metricFresh': 'Fresh',
|
||||
'memoryFreshness.metricFading': 'Fading',
|
||||
'memoryFreshness.metricStale': 'Stale',
|
||||
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
|
||||
'memoryFreshness.queueHeading': 'Re-confirm queue',
|
||||
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
|
||||
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
|
||||
'memoryFreshness.statusFading': 'fading',
|
||||
'memoryFreshness.statusStale': 'stale',
|
||||
'memoryFreshness.ageLabel': '{days}d old',
|
||||
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
|
||||
'memoryTree.status.title': 'Árbol de la memoria',
|
||||
'memoryTree.status.autoSyncLabel': 'Auto-sincronización',
|
||||
'memoryTree.status.autoSyncDescription':
|
||||
|
||||
@@ -313,6 +313,29 @@ const messages: TranslationMap = {
|
||||
'graphCentrality.bridgeBadge': 'connecteur',
|
||||
'graphCentrality.bridgeTitle': 'Connecteur — plus influent que ne le suggère son nombre de liens',
|
||||
'graphCentrality.degreeTitle': '{in} en · {out} hors',
|
||||
'memory.tab.freshness': 'Freshness',
|
||||
'memoryFreshness.title': 'Knowledge Freshness',
|
||||
'memoryFreshness.intro':
|
||||
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
|
||||
'memoryFreshness.loading': 'Scoring freshness…',
|
||||
'memoryFreshness.errorPrefix': 'Could not load the graph:',
|
||||
'memoryFreshness.retry': 'Retry',
|
||||
'memoryFreshness.empty': 'No knowledge graph yet.',
|
||||
'memoryFreshness.emptyHint':
|
||||
'As the assistant records facts about you, their freshness will be tracked here.',
|
||||
'memoryFreshness.namespaceLabel': 'Namespace',
|
||||
'memoryFreshness.namespaceAll': 'All namespaces',
|
||||
'memoryFreshness.metricFresh': 'Fresh',
|
||||
'memoryFreshness.metricFading': 'Fading',
|
||||
'memoryFreshness.metricStale': 'Stale',
|
||||
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
|
||||
'memoryFreshness.queueHeading': 'Re-confirm queue',
|
||||
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
|
||||
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
|
||||
'memoryFreshness.statusFading': 'fading',
|
||||
'memoryFreshness.statusStale': 'stale',
|
||||
'memoryFreshness.ageLabel': '{days}d old',
|
||||
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
|
||||
'memoryTree.status.title': 'Arbre de mémoire',
|
||||
'memoryTree.status.autoSyncLabel': 'Auto-synchronisation',
|
||||
'memoryTree.status.autoSyncDescription':
|
||||
|
||||
@@ -304,6 +304,29 @@ const messages: TranslationMap = {
|
||||
'graphCentrality.bridgeBadge': 'कनेक्टर',
|
||||
'graphCentrality.bridgeTitle': 'कनेक्टर - इसके लिंक गिनती से अधिक प्रभावशाली सुझाव देते हैं',
|
||||
'graphCentrality.degreeTitle': '{in}',
|
||||
'memory.tab.freshness': 'Freshness',
|
||||
'memoryFreshness.title': 'Knowledge Freshness',
|
||||
'memoryFreshness.intro':
|
||||
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
|
||||
'memoryFreshness.loading': 'Scoring freshness…',
|
||||
'memoryFreshness.errorPrefix': 'Could not load the graph:',
|
||||
'memoryFreshness.retry': 'Retry',
|
||||
'memoryFreshness.empty': 'No knowledge graph yet.',
|
||||
'memoryFreshness.emptyHint':
|
||||
'As the assistant records facts about you, their freshness will be tracked here.',
|
||||
'memoryFreshness.namespaceLabel': 'Namespace',
|
||||
'memoryFreshness.namespaceAll': 'All namespaces',
|
||||
'memoryFreshness.metricFresh': 'Fresh',
|
||||
'memoryFreshness.metricFading': 'Fading',
|
||||
'memoryFreshness.metricStale': 'Stale',
|
||||
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
|
||||
'memoryFreshness.queueHeading': 'Re-confirm queue',
|
||||
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
|
||||
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
|
||||
'memoryFreshness.statusFading': 'fading',
|
||||
'memoryFreshness.statusStale': 'stale',
|
||||
'memoryFreshness.ageLabel': '{days}d old',
|
||||
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
|
||||
'memoryTree.status.title': 'मेमोरी ट्री',
|
||||
'memoryTree.status.autoSyncLabel': 'ऑटो सिंक',
|
||||
'memoryTree.status.autoSyncDescription':
|
||||
|
||||
@@ -307,6 +307,29 @@ const messages: TranslationMap = {
|
||||
'graphCentrality.bridgeTitle':
|
||||
'Konektor - lebih berpengaruh daripada jumlah link yang menunjukkan',
|
||||
'graphCentrality.degreeTitle': '{in} keluar',
|
||||
'memory.tab.freshness': 'Freshness',
|
||||
'memoryFreshness.title': 'Knowledge Freshness',
|
||||
'memoryFreshness.intro':
|
||||
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
|
||||
'memoryFreshness.loading': 'Scoring freshness…',
|
||||
'memoryFreshness.errorPrefix': 'Could not load the graph:',
|
||||
'memoryFreshness.retry': 'Retry',
|
||||
'memoryFreshness.empty': 'No knowledge graph yet.',
|
||||
'memoryFreshness.emptyHint':
|
||||
'As the assistant records facts about you, their freshness will be tracked here.',
|
||||
'memoryFreshness.namespaceLabel': 'Namespace',
|
||||
'memoryFreshness.namespaceAll': 'All namespaces',
|
||||
'memoryFreshness.metricFresh': 'Fresh',
|
||||
'memoryFreshness.metricFading': 'Fading',
|
||||
'memoryFreshness.metricStale': 'Stale',
|
||||
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
|
||||
'memoryFreshness.queueHeading': 'Re-confirm queue',
|
||||
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
|
||||
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
|
||||
'memoryFreshness.statusFading': 'fading',
|
||||
'memoryFreshness.statusStale': 'stale',
|
||||
'memoryFreshness.ageLabel': '{days}d old',
|
||||
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
|
||||
'memoryTree.status.title': 'Pohon Memori',
|
||||
'memoryTree.status.autoSyncLabel': 'Sinkronisasi otomatis',
|
||||
'memoryTree.status.autoSyncDescription':
|
||||
|
||||
@@ -313,6 +313,29 @@ const messages: TranslationMap = {
|
||||
'graphCentrality.bridgeTitle':
|
||||
'Connettore — più influente di quanto suggerisca il suo numero di collegamenti',
|
||||
'graphCentrality.degreeTitle': '{in} in entrata · {out} in uscita',
|
||||
'memory.tab.freshness': 'Freshness',
|
||||
'memoryFreshness.title': 'Knowledge Freshness',
|
||||
'memoryFreshness.intro':
|
||||
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
|
||||
'memoryFreshness.loading': 'Scoring freshness…',
|
||||
'memoryFreshness.errorPrefix': 'Could not load the graph:',
|
||||
'memoryFreshness.retry': 'Retry',
|
||||
'memoryFreshness.empty': 'No knowledge graph yet.',
|
||||
'memoryFreshness.emptyHint':
|
||||
'As the assistant records facts about you, their freshness will be tracked here.',
|
||||
'memoryFreshness.namespaceLabel': 'Namespace',
|
||||
'memoryFreshness.namespaceAll': 'All namespaces',
|
||||
'memoryFreshness.metricFresh': 'Fresh',
|
||||
'memoryFreshness.metricFading': 'Fading',
|
||||
'memoryFreshness.metricStale': 'Stale',
|
||||
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
|
||||
'memoryFreshness.queueHeading': 'Re-confirm queue',
|
||||
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
|
||||
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
|
||||
'memoryFreshness.statusFading': 'fading',
|
||||
'memoryFreshness.statusStale': 'stale',
|
||||
'memoryFreshness.ageLabel': '{days}d old',
|
||||
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
|
||||
'memoryTree.status.title': 'Albero della memoria',
|
||||
'memoryTree.status.autoSyncLabel': 'Sincronizzazione automatica',
|
||||
'memoryTree.status.autoSyncDescription':
|
||||
|
||||
@@ -304,6 +304,29 @@ const messages: TranslationMap = {
|
||||
'graphCentrality.bridgeBadge': '커넥터',
|
||||
'graphCentrality.bridgeTitle': '커넥터 - 링크 수보다 더 큰 영향력을 가진 엔터티',
|
||||
'graphCentrality.degreeTitle': '들어옴 {in} · 나감 {out}',
|
||||
'memory.tab.freshness': 'Freshness',
|
||||
'memoryFreshness.title': 'Knowledge Freshness',
|
||||
'memoryFreshness.intro':
|
||||
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
|
||||
'memoryFreshness.loading': 'Scoring freshness…',
|
||||
'memoryFreshness.errorPrefix': 'Could not load the graph:',
|
||||
'memoryFreshness.retry': 'Retry',
|
||||
'memoryFreshness.empty': 'No knowledge graph yet.',
|
||||
'memoryFreshness.emptyHint':
|
||||
'As the assistant records facts about you, their freshness will be tracked here.',
|
||||
'memoryFreshness.namespaceLabel': 'Namespace',
|
||||
'memoryFreshness.namespaceAll': 'All namespaces',
|
||||
'memoryFreshness.metricFresh': 'Fresh',
|
||||
'memoryFreshness.metricFading': 'Fading',
|
||||
'memoryFreshness.metricStale': 'Stale',
|
||||
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
|
||||
'memoryFreshness.queueHeading': 'Re-confirm queue',
|
||||
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
|
||||
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
|
||||
'memoryFreshness.statusFading': 'fading',
|
||||
'memoryFreshness.statusStale': 'stale',
|
||||
'memoryFreshness.ageLabel': '{days}d old',
|
||||
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
|
||||
'memoryTree.status.title': '메모리 트리',
|
||||
'memoryTree.status.autoSyncLabel': '자동 동기화',
|
||||
'memoryTree.status.autoSyncDescription':
|
||||
|
||||
@@ -309,6 +309,29 @@ const messages: TranslationMap = {
|
||||
'graphCentrality.bridgeBadge': 'łącznik',
|
||||
'graphCentrality.bridgeTitle': 'Łącznik — bardziej wpływowy, niż sugeruje liczba linków',
|
||||
'graphCentrality.degreeTitle': '{in} wej. · {out} wyj.',
|
||||
'memory.tab.freshness': 'Freshness',
|
||||
'memoryFreshness.title': 'Knowledge Freshness',
|
||||
'memoryFreshness.intro':
|
||||
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
|
||||
'memoryFreshness.loading': 'Scoring freshness…',
|
||||
'memoryFreshness.errorPrefix': 'Could not load the graph:',
|
||||
'memoryFreshness.retry': 'Retry',
|
||||
'memoryFreshness.empty': 'No knowledge graph yet.',
|
||||
'memoryFreshness.emptyHint':
|
||||
'As the assistant records facts about you, their freshness will be tracked here.',
|
||||
'memoryFreshness.namespaceLabel': 'Namespace',
|
||||
'memoryFreshness.namespaceAll': 'All namespaces',
|
||||
'memoryFreshness.metricFresh': 'Fresh',
|
||||
'memoryFreshness.metricFading': 'Fading',
|
||||
'memoryFreshness.metricStale': 'Stale',
|
||||
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
|
||||
'memoryFreshness.queueHeading': 'Re-confirm queue',
|
||||
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
|
||||
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
|
||||
'memoryFreshness.statusFading': 'fading',
|
||||
'memoryFreshness.statusStale': 'stale',
|
||||
'memoryFreshness.ageLabel': '{days}d old',
|
||||
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
|
||||
'memoryTree.status.title': 'Drzewo pamięci',
|
||||
'memoryTree.status.autoSyncLabel': 'Automatyczna synchronizacja',
|
||||
'memoryTree.status.autoSyncDescription':
|
||||
|
||||
@@ -314,6 +314,29 @@ const messages: TranslationMap = {
|
||||
'graphCentrality.bridgeBadge': 'conector',
|
||||
'graphCentrality.bridgeTitle': 'Conector — mais influente do que seu número de links sugere',
|
||||
'graphCentrality.degreeTitle': '{in} entrando · {out} saindo',
|
||||
'memory.tab.freshness': 'Freshness',
|
||||
'memoryFreshness.title': 'Knowledge Freshness',
|
||||
'memoryFreshness.intro':
|
||||
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
|
||||
'memoryFreshness.loading': 'Scoring freshness…',
|
||||
'memoryFreshness.errorPrefix': 'Could not load the graph:',
|
||||
'memoryFreshness.retry': 'Retry',
|
||||
'memoryFreshness.empty': 'No knowledge graph yet.',
|
||||
'memoryFreshness.emptyHint':
|
||||
'As the assistant records facts about you, their freshness will be tracked here.',
|
||||
'memoryFreshness.namespaceLabel': 'Namespace',
|
||||
'memoryFreshness.namespaceAll': 'All namespaces',
|
||||
'memoryFreshness.metricFresh': 'Fresh',
|
||||
'memoryFreshness.metricFading': 'Fading',
|
||||
'memoryFreshness.metricStale': 'Stale',
|
||||
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
|
||||
'memoryFreshness.queueHeading': 'Re-confirm queue',
|
||||
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
|
||||
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
|
||||
'memoryFreshness.statusFading': 'fading',
|
||||
'memoryFreshness.statusStale': 'stale',
|
||||
'memoryFreshness.ageLabel': '{days}d old',
|
||||
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
|
||||
'memoryTree.status.title': 'Árvore da Memória',
|
||||
'memoryTree.status.autoSyncLabel': 'Auto-sincronização',
|
||||
'memoryTree.status.autoSyncDescription':
|
||||
|
||||
@@ -307,6 +307,29 @@ const messages: TranslationMap = {
|
||||
'graphCentrality.bridgeTitle':
|
||||
'Коннектор — более влиятельный, чем предполагает количество ссылок.',
|
||||
'graphCentrality.degreeTitle': '{in} вход · {out} выход',
|
||||
'memory.tab.freshness': 'Freshness',
|
||||
'memoryFreshness.title': 'Knowledge Freshness',
|
||||
'memoryFreshness.intro':
|
||||
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
|
||||
'memoryFreshness.loading': 'Scoring freshness…',
|
||||
'memoryFreshness.errorPrefix': 'Could not load the graph:',
|
||||
'memoryFreshness.retry': 'Retry',
|
||||
'memoryFreshness.empty': 'No knowledge graph yet.',
|
||||
'memoryFreshness.emptyHint':
|
||||
'As the assistant records facts about you, their freshness will be tracked here.',
|
||||
'memoryFreshness.namespaceLabel': 'Namespace',
|
||||
'memoryFreshness.namespaceAll': 'All namespaces',
|
||||
'memoryFreshness.metricFresh': 'Fresh',
|
||||
'memoryFreshness.metricFading': 'Fading',
|
||||
'memoryFreshness.metricStale': 'Stale',
|
||||
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
|
||||
'memoryFreshness.queueHeading': 'Re-confirm queue',
|
||||
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
|
||||
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
|
||||
'memoryFreshness.statusFading': 'fading',
|
||||
'memoryFreshness.statusStale': 'stale',
|
||||
'memoryFreshness.ageLabel': '{days}d old',
|
||||
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
|
||||
'memoryTree.status.title': 'Дерево памяти',
|
||||
'memoryTree.status.autoSyncLabel': 'Автосинхронизация',
|
||||
'memoryTree.status.autoSyncDescription':
|
||||
|
||||
@@ -293,6 +293,29 @@ const messages: TranslationMap = {
|
||||
'graphCentrality.bridgeBadge': '连接点',
|
||||
'graphCentrality.bridgeTitle': '连接点,影响力高于其链接数量所暗示的程度',
|
||||
'graphCentrality.degreeTitle': '{in} 入 · {out} 出',
|
||||
'memory.tab.freshness': 'Freshness',
|
||||
'memoryFreshness.title': 'Knowledge Freshness',
|
||||
'memoryFreshness.intro':
|
||||
'Facts the assistant has not reconfirmed lately decay along a forgetting curve. This surfaces what is going stale and worth re-checking, so old facts are not treated as still-certain.',
|
||||
'memoryFreshness.loading': 'Scoring freshness…',
|
||||
'memoryFreshness.errorPrefix': 'Could not load the graph:',
|
||||
'memoryFreshness.retry': 'Retry',
|
||||
'memoryFreshness.empty': 'No knowledge graph yet.',
|
||||
'memoryFreshness.emptyHint':
|
||||
'As the assistant records facts about you, their freshness will be tracked here.',
|
||||
'memoryFreshness.namespaceLabel': 'Namespace',
|
||||
'memoryFreshness.namespaceAll': 'All namespaces',
|
||||
'memoryFreshness.metricFresh': 'Fresh',
|
||||
'memoryFreshness.metricFading': 'Fading',
|
||||
'memoryFreshness.metricStale': 'Stale',
|
||||
'memoryFreshness.recallCaption': 'Average recall {recall}% across {total} facts',
|
||||
'memoryFreshness.queueHeading': 'Re-confirm queue',
|
||||
'memoryFreshness.queueTruncated': 'Showing {shown} of {total} — address these first.',
|
||||
'memoryFreshness.allFresh': 'Every fact is still fresh — nothing to re-confirm.',
|
||||
'memoryFreshness.statusFading': 'fading',
|
||||
'memoryFreshness.statusStale': 'stale',
|
||||
'memoryFreshness.ageLabel': '{days}d old',
|
||||
'memoryFreshness.recallTitle': '{recall}% recall · half-life {halfLife}d',
|
||||
'memoryTree.status.title': '记忆树',
|
||||
'memoryTree.status.autoSyncLabel': '自动同步',
|
||||
'memoryTree.status.autoSyncDescription': '暂停后将停止新的摄取。现有 wiki 仍可查询。',
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DAY, NOW, rel } from '../../test/memoryRelationFactory';
|
||||
import type { GraphRelation } from '../../utils/tauriCommands/memory';
|
||||
import { classify, computeFreshness, recallProbability, strengthFactor } from './memoryFreshness';
|
||||
|
||||
describe('strengthFactor', () => {
|
||||
it('scales the half-life by evidence with diminishing returns (log2)', () => {
|
||||
expect(strengthFactor(1)).toBeCloseTo(1, 12); // 1 + log2(1)
|
||||
expect(strengthFactor(2)).toBeCloseTo(2, 12); // 1 + log2(2)
|
||||
expect(strengthFactor(4)).toBeCloseTo(3, 12); // 1 + log2(4)
|
||||
expect(strengthFactor(8)).toBeCloseTo(4, 12);
|
||||
});
|
||||
|
||||
it('clamps evidence <= 1 / non-finite to a factor of 1', () => {
|
||||
expect(strengthFactor(0)).toBe(1);
|
||||
expect(strengthFactor(-3)).toBe(1);
|
||||
expect(strengthFactor(Number.NaN)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recallProbability', () => {
|
||||
it('is 1 at age 0 and halves every half-life', () => {
|
||||
expect(recallProbability(0, 30)).toBe(1);
|
||||
expect(recallProbability(30, 30)).toBeCloseTo(0.5, 12);
|
||||
expect(recallProbability(60, 30)).toBeCloseTo(0.25, 12);
|
||||
expect(recallProbability(90, 30)).toBeCloseTo(0.125, 12);
|
||||
});
|
||||
|
||||
it('treats negative age as fresh (recall 1)', () => {
|
||||
expect(recallProbability(-5, 30)).toBe(1);
|
||||
});
|
||||
|
||||
it('degrades gracefully for a non-positive half-life', () => {
|
||||
expect(recallProbability(0, 0)).toBe(1);
|
||||
expect(recallProbability(5, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classify', () => {
|
||||
it('bands recall into fresh / fading / stale', () => {
|
||||
expect(classify(0.95)).toBe('fresh');
|
||||
expect(classify(0.7)).toBe('fresh'); // boundary inclusive
|
||||
expect(classify(0.5)).toBe('fading');
|
||||
expect(classify(0.3)).toBe('fading'); // boundary inclusive
|
||||
expect(classify(0.2)).toBe('stale');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeFreshness', () => {
|
||||
it('returns an empty/zero report for no relations', () => {
|
||||
const r = computeFreshness([], NOW);
|
||||
expect(r.facts).toEqual([]);
|
||||
expect(r.staleQueue).toEqual([]);
|
||||
expect(r.total).toBe(0);
|
||||
expect(r.averageRecall).toBe(0);
|
||||
expect(r.freshCount + r.fadingCount + r.staleCount).toBe(0);
|
||||
});
|
||||
|
||||
it('scores a fresh / fading / stale mix and orders most-stale-first', () => {
|
||||
const r = computeFreshness(
|
||||
[
|
||||
rel('You', 'Berlin', 0), // recall 1 -> fresh
|
||||
rel('You', 'coffee', 30), // recall 0.5 -> fading
|
||||
rel('You', 'guitar', 90), // recall 0.125 -> stale
|
||||
],
|
||||
NOW
|
||||
);
|
||||
expect(r.total).toBe(3);
|
||||
expect(r.freshCount).toBe(1);
|
||||
expect(r.fadingCount).toBe(1);
|
||||
expect(r.staleCount).toBe(1);
|
||||
expect(r.averageRecall).toBeCloseTo((1 + 0.5 + 0.125) / 3, 9);
|
||||
// most stale first
|
||||
expect(r.facts.map(f => f.object)).toEqual(['guitar', 'coffee', 'Berlin']);
|
||||
expect(r.facts[0].recall).toBeCloseTo(0.125, 9);
|
||||
// staleQueue excludes the fresh fact
|
||||
expect(r.staleQueue.map(f => f.object)).toEqual(['guitar', 'coffee']);
|
||||
});
|
||||
|
||||
it('decays more evidence-rich facts more slowly', () => {
|
||||
const r = computeFreshness(
|
||||
[
|
||||
rel('A', 'weak', 60, 1), // halfLife 30 -> recall 0.25 (stale)
|
||||
rel('A', 'strong', 60, 2), // halfLife 60 -> recall 0.5 (fading)
|
||||
],
|
||||
NOW
|
||||
);
|
||||
const byObject = Object.fromEntries(r.facts.map(f => [f.object, f]));
|
||||
expect(byObject.weak.recall).toBeCloseTo(0.25, 9);
|
||||
expect(byObject.strong.recall).toBeCloseTo(0.5, 9);
|
||||
expect(byObject.strong.recall).toBeGreaterThan(byObject.weak.recall);
|
||||
});
|
||||
|
||||
it('treats a future updatedAt as fully fresh (no negative age)', () => {
|
||||
const future: GraphRelation = { ...rel('A', 'B', 0), updatedAt: NOW + 10 * DAY };
|
||||
const r = computeFreshness([future], NOW);
|
||||
expect(r.facts[0].ageDays).toBe(0);
|
||||
expect(r.facts[0].recall).toBe(1);
|
||||
expect(r.facts[0].status).toBe('fresh');
|
||||
});
|
||||
|
||||
it('collapses a duplicate triple to its freshest occurrence', () => {
|
||||
const r = computeFreshness(
|
||||
[
|
||||
rel('You', 'Berlin', 90), // stale copy
|
||||
rel('You', 'Berlin', 0), // fresh copy (same triple)
|
||||
],
|
||||
NOW
|
||||
);
|
||||
expect(r.total).toBe(1);
|
||||
expect(r.facts[0].recall).toBe(1); // kept the freshest
|
||||
});
|
||||
|
||||
it('drops a malformed relation with a non-string endpoint', () => {
|
||||
const malformed = { ...rel('A', 'B', 0), predicate: null as unknown as string };
|
||||
const r = computeFreshness([rel('A', 'B', 0), malformed, rel('C', 'D', 0)], NOW);
|
||||
expect(r.total).toBe(2);
|
||||
});
|
||||
|
||||
it('exposes the evidence-scaled half-life on each fact', () => {
|
||||
const r = computeFreshness([rel('A', 'B', 0, 4)], NOW);
|
||||
expect(r.facts[0].halfLifeDays).toBeCloseTo(90, 9); // 30 * (1 + log2(4))
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Knowledge Freshness — pure decay-scoring engine.
|
||||
*
|
||||
* Every fact the assistant remembers is a (subject)-[predicate]->(object) triple
|
||||
* that was last reinforced at `updatedAt`. Human memory of an un-rehearsed fact
|
||||
* decays along a forgetting curve; this engine applies the same idea to the
|
||||
* assistant's stored facts so the UI can surface what is going STALE and should
|
||||
* be re-confirmed, rather than treating every stored fact as equally certain.
|
||||
*
|
||||
* recall(t) = 2 ^ (-ageDays / halfLifeDays)
|
||||
* - ageDays = days since the fact was last reinforced (updatedAt)
|
||||
* - halfLifeDays = DEFAULT_HALF_LIFE_DAYS * (1 + log2(max(1, evidenceCount)))
|
||||
*
|
||||
* A fact corroborated by more evidence decays more slowly (a longer half-life),
|
||||
* with diminishing returns (log2). recall is 1.0 the moment a fact is recorded
|
||||
* and approaches 0 as it ages without reinforcement.
|
||||
*
|
||||
* Everything here is PURE and DETERMINISTIC. The engine never reads the clock:
|
||||
* the reference time `nowSeconds` is injected by the caller, so the same inputs
|
||||
* always yield the same report and every branch is unit-testable.
|
||||
*/
|
||||
import type { GraphRelation } from '../../utils/tauriCommands/memory';
|
||||
|
||||
export type FreshnessStatus = 'fresh' | 'fading' | 'stale';
|
||||
|
||||
export interface FactFreshness {
|
||||
id: string; // stable composite key (subject/predicate/object), JSON-encoded
|
||||
subject: string;
|
||||
predicate: string;
|
||||
object: string;
|
||||
evidenceCount: number;
|
||||
updatedAt: number; // epoch seconds the fact was last reinforced
|
||||
ageDays: number; // days since updatedAt (>= 0)
|
||||
halfLifeDays: number; // evidence-scaled half-life
|
||||
recall: number; // 0..1 recall probability now
|
||||
status: FreshnessStatus;
|
||||
}
|
||||
|
||||
export interface FreshnessReport {
|
||||
facts: FactFreshness[]; // all facts, most stale first (recall asc, id asc)
|
||||
staleQueue: FactFreshness[]; // non-fresh facts only, same order (re-confirm queue)
|
||||
freshCount: number;
|
||||
fadingCount: number;
|
||||
staleCount: number;
|
||||
total: number;
|
||||
averageRecall: number; // mean recall across all facts (0 when none)
|
||||
}
|
||||
|
||||
export interface FreshnessOptions {
|
||||
halfLifeDays?: number; // base half-life for an evidenceCount of 1
|
||||
freshThreshold?: number; // recall >= this => 'fresh'
|
||||
fadingThreshold?: number; // recall >= this (and < fresh) => 'fading', else 'stale'
|
||||
}
|
||||
|
||||
export const DEFAULT_HALF_LIFE_DAYS = 30;
|
||||
export const FRESH_THRESHOLD = 0.7;
|
||||
export const FADING_THRESHOLD = 0.3;
|
||||
const SECONDS_PER_DAY = 86400;
|
||||
|
||||
/** Evidence multiplier on the half-life: more corroboration decays slower. */
|
||||
export function strengthFactor(evidenceCount: number): number {
|
||||
const ec = Number.isFinite(evidenceCount) && evidenceCount > 1 ? evidenceCount : 1;
|
||||
return 1 + Math.log2(ec);
|
||||
}
|
||||
|
||||
/** Recall probability for a given age and half-life; clamped to [0, 1]. */
|
||||
export function recallProbability(ageDays: number, halfLifeDays: number): number {
|
||||
if (!(halfLifeDays > 0)) return ageDays <= 0 ? 1 : 0;
|
||||
const age = ageDays > 0 ? ageDays : 0;
|
||||
const recall = 2 ** (-age / halfLifeDays);
|
||||
if (recall > 1) return 1;
|
||||
if (recall < 0) return 0;
|
||||
return recall;
|
||||
}
|
||||
|
||||
/** Classify a recall probability into a freshness band. */
|
||||
export function classify(
|
||||
recall: number,
|
||||
freshThreshold = FRESH_THRESHOLD,
|
||||
fadingThreshold = FADING_THRESHOLD
|
||||
): FreshnessStatus {
|
||||
if (recall >= freshThreshold) return 'fresh';
|
||||
if (recall >= fadingThreshold) return 'fading';
|
||||
return 'stale';
|
||||
}
|
||||
|
||||
/** Stable, collision-free key for a triple (no raw separators). */
|
||||
function factKey(subject: string, predicate: string, object: string): string {
|
||||
return JSON.stringify([subject, predicate, object]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the freshness report. Pure function of (relations, nowSeconds).
|
||||
* Duplicate triples are collapsed to the freshest occurrence (max updatedAt,
|
||||
* then max evidenceCount) so a fact is scored once at its strongest signal.
|
||||
*/
|
||||
export function computeFreshness(
|
||||
relations: GraphRelation[],
|
||||
nowSeconds: number,
|
||||
options: FreshnessOptions = {}
|
||||
): FreshnessReport {
|
||||
const baseHalfLife = options.halfLifeDays ?? DEFAULT_HALF_LIFE_DAYS;
|
||||
const freshThreshold = options.freshThreshold ?? FRESH_THRESHOLD;
|
||||
const fadingThreshold = options.fadingThreshold ?? FADING_THRESHOLD;
|
||||
|
||||
// 1. Collapse duplicate triples to their freshest, strongest occurrence.
|
||||
const bestByKey = new Map<string, GraphRelation>();
|
||||
for (const relation of relations) {
|
||||
const { subject, predicate, object } = relation;
|
||||
if (
|
||||
typeof subject !== 'string' ||
|
||||
typeof predicate !== 'string' ||
|
||||
typeof object !== 'string'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const key = factKey(subject, predicate, object);
|
||||
const existing = bestByKey.get(key);
|
||||
if (
|
||||
!existing ||
|
||||
relation.updatedAt > existing.updatedAt ||
|
||||
(relation.updatedAt === existing.updatedAt && relation.evidenceCount > existing.evidenceCount)
|
||||
) {
|
||||
bestByKey.set(key, relation);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Score each fact.
|
||||
const facts: FactFreshness[] = [];
|
||||
let recallSum = 0;
|
||||
let freshCount = 0;
|
||||
let fadingCount = 0;
|
||||
let staleCount = 0;
|
||||
for (const [key, relation] of bestByKey) {
|
||||
const evidenceCount =
|
||||
Number.isFinite(relation.evidenceCount) && relation.evidenceCount > 0
|
||||
? relation.evidenceCount
|
||||
: 1;
|
||||
const halfLifeDays = baseHalfLife * strengthFactor(evidenceCount);
|
||||
const ageDays = Math.max(0, (nowSeconds - relation.updatedAt) / SECONDS_PER_DAY);
|
||||
const recall = recallProbability(ageDays, halfLifeDays);
|
||||
const status = classify(recall, freshThreshold, fadingThreshold);
|
||||
recallSum += recall;
|
||||
if (status === 'fresh') freshCount += 1;
|
||||
else if (status === 'fading') fadingCount += 1;
|
||||
else staleCount += 1;
|
||||
facts.push({
|
||||
id: key,
|
||||
subject: relation.subject,
|
||||
predicate: relation.predicate,
|
||||
object: relation.object,
|
||||
evidenceCount,
|
||||
updatedAt: relation.updatedAt,
|
||||
ageDays,
|
||||
halfLifeDays,
|
||||
recall,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Sort most-stale-first (recall asc), stable id tie-break.
|
||||
facts.sort((a, b) => a.recall - b.recall || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
||||
|
||||
const total = facts.length;
|
||||
return {
|
||||
facts,
|
||||
staleQueue: facts.filter(f => f.status !== 'fresh'),
|
||||
freshCount,
|
||||
fadingCount,
|
||||
staleCount,
|
||||
total,
|
||||
averageRecall: total === 0 ? 0 : recallSum / total,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import DiagramViewerTab from '../components/intelligence/DiagramViewerTab';
|
||||
import GraphCentralityTab from '../components/intelligence/GraphCentralityTab';
|
||||
import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab';
|
||||
import IntelligenceTasksTab from '../components/intelligence/IntelligenceTasksTab';
|
||||
import MemoryFreshnessTab from '../components/intelligence/MemoryFreshnessTab';
|
||||
import { MemoryWorkspace } from '../components/intelligence/MemoryWorkspace';
|
||||
import { ToastContainer } from '../components/intelligence/Toast';
|
||||
import PillTabBar from '../components/PillTabBar';
|
||||
@@ -20,7 +21,14 @@ import type {
|
||||
} from '../types/intelligence';
|
||||
import AgentWorkflows from './AgentWorkflows';
|
||||
|
||||
type IntelligenceTab = 'memory' | 'subconscious' | 'tasks' | 'workflows' | 'diagram' | 'centrality';
|
||||
type IntelligenceTab =
|
||||
| 'memory'
|
||||
| 'subconscious'
|
||||
| 'tasks'
|
||||
| 'workflows'
|
||||
| 'diagram'
|
||||
| 'centrality'
|
||||
| 'freshness';
|
||||
|
||||
export default function Intelligence() {
|
||||
const { t } = useT();
|
||||
@@ -100,6 +108,7 @@ export default function Intelligence() {
|
||||
},
|
||||
{ id: 'diagram', label: t('memory.tab.diagram') },
|
||||
{ id: 'centrality', label: t('memory.tab.centrality') },
|
||||
{ id: 'freshness', label: t('memory.tab.freshness') },
|
||||
];
|
||||
const activeTabDef = tabs.find(tab => tab.id === activeTab);
|
||||
|
||||
@@ -190,6 +199,8 @@ export default function Intelligence() {
|
||||
{activeTab === 'diagram' && <DiagramViewerTab />}
|
||||
|
||||
{activeTab === 'centrality' && <GraphCentralityTab />}
|
||||
|
||||
{activeTab === 'freshness' && <MemoryFreshnessTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { computeFreshness } from '../../lib/memory/memoryFreshness';
|
||||
import { NOW, rel } from '../../test/memoryRelationFactory';
|
||||
import { loadFreshness, loadNamespaces, memoryFreshnessApi } from './memoryFreshnessApi';
|
||||
|
||||
const mockGraphQuery = vi.fn();
|
||||
const mockListNamespaces = vi.fn();
|
||||
|
||||
vi.mock('../../utils/tauriCommands/memory', () => ({
|
||||
memoryGraphQuery: (...args: unknown[]) => mockGraphQuery(...args),
|
||||
memoryListNamespaces: (...args: unknown[]) => mockListNamespaces(...args),
|
||||
}));
|
||||
|
||||
describe('memoryFreshnessApi.loadFreshness', () => {
|
||||
beforeEach(() => {
|
||||
mockGraphQuery.mockReset();
|
||||
});
|
||||
|
||||
it('passes the namespace through and returns the engine report for those facts', async () => {
|
||||
const facts = [rel('You', 'Berlin', 0), rel('You', 'guitar', 90)];
|
||||
mockGraphQuery.mockResolvedValueOnce(facts);
|
||||
const out = await loadFreshness(NOW, 'work');
|
||||
expect(mockGraphQuery).toHaveBeenCalledWith('work');
|
||||
expect(out).toEqual(computeFreshness(facts, NOW));
|
||||
});
|
||||
|
||||
it('queries all namespaces when none is given', async () => {
|
||||
mockGraphQuery.mockResolvedValueOnce([]);
|
||||
const out = await loadFreshness(NOW);
|
||||
expect(mockGraphQuery).toHaveBeenCalledWith(undefined);
|
||||
expect(out.total).toBe(0);
|
||||
});
|
||||
|
||||
it('propagates query errors', async () => {
|
||||
mockGraphQuery.mockRejectedValueOnce(new Error('graph unavailable'));
|
||||
await expect(loadFreshness(NOW)).rejects.toThrow('graph unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('memoryFreshnessApi.loadNamespaces', () => {
|
||||
beforeEach(() => {
|
||||
mockListNamespaces.mockReset();
|
||||
});
|
||||
|
||||
it('returns the namespace list from the RPC', async () => {
|
||||
mockListNamespaces.mockResolvedValueOnce(['work', 'personal']);
|
||||
expect(await loadNamespaces()).toEqual(['work', 'personal']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('memoryFreshnessApi object', () => {
|
||||
it('exposes the public surface', () => {
|
||||
expect(typeof memoryFreshnessApi.loadFreshness).toBe('function');
|
||||
expect(typeof memoryFreshnessApi.loadNamespaces).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* RPC facade for Knowledge Freshness.
|
||||
*
|
||||
* Adds ZERO new core surface. Composes two already-shipped JSON-RPC wrappers:
|
||||
* - memoryGraphQuery (openhuman.memory_graph_query) — the facts
|
||||
* - memoryListNamespaces (openhuman.memory_list_namespaces) — the selector
|
||||
* and delegates all scoring to the pure, deterministic engine. The caller mints
|
||||
* `nowSeconds` (in an event handler, never during render) so the engine stays
|
||||
* clock-free and testable. Read-only — nothing is persisted.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
|
||||
import { computeFreshness, type FreshnessReport } from '../../lib/memory/memoryFreshness';
|
||||
import { memoryGraphQuery, memoryListNamespaces } from '../../utils/tauriCommands/memory';
|
||||
|
||||
const log = debug('memory-freshness:api');
|
||||
|
||||
/** Fetch the facts for a namespace (or all) and score their freshness as of `nowSeconds`. */
|
||||
export async function loadFreshness(
|
||||
nowSeconds: number,
|
||||
namespace?: string
|
||||
): Promise<FreshnessReport> {
|
||||
const relations = await memoryGraphQuery(namespace);
|
||||
log('loadFreshness namespace=%s relations=%d', namespace ?? '(all)', relations.length);
|
||||
return computeFreshness(relations, nowSeconds);
|
||||
}
|
||||
|
||||
/** List the namespaces available for the namespace selector. */
|
||||
export async function loadNamespaces(): Promise<string[]> {
|
||||
return memoryListNamespaces();
|
||||
}
|
||||
|
||||
export const memoryFreshnessApi = { loadFreshness, loadNamespaces };
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Shared `GraphRelation` test factory for the Knowledge Freshness suites.
|
||||
*
|
||||
* Keeps the relation shape (and the `NOW` / `DAY` anchors) in one place so the
|
||||
* pure-engine tests (`memoryFreshness.test.ts`) and the API-facade tests
|
||||
* (`memoryFreshnessApi.test.ts`) stay in sync as `GraphRelation` evolves.
|
||||
*/
|
||||
import type { GraphRelation } from '../utils/tauriCommands/memory';
|
||||
|
||||
/** Fixed reference instant (epoch seconds) so every scored report is deterministic. */
|
||||
export const NOW = 1_700_000_000;
|
||||
/** Seconds in a day — `agoDays` is multiplied by this to age a relation. */
|
||||
export const DAY = 86400;
|
||||
|
||||
/**
|
||||
* Build a `GraphRelation` aged `agoDays` days before {@link NOW}. The unused
|
||||
* graph fields (attrs / ids) are filled with inert defaults; callers override
|
||||
* via spread when a specific field matters.
|
||||
*/
|
||||
export function rel(
|
||||
subject: string,
|
||||
object: string,
|
||||
agoDays: number,
|
||||
evidenceCount = 1,
|
||||
predicate = 'p',
|
||||
namespace = 'n'
|
||||
): GraphRelation {
|
||||
return {
|
||||
namespace,
|
||||
subject,
|
||||
predicate,
|
||||
object,
|
||||
attrs: {},
|
||||
updatedAt: NOW - agoDays * DAY,
|
||||
evidenceCount,
|
||||
orderIndex: null,
|
||||
documentIds: [],
|
||||
chunkIds: [],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user