/** * Temporal decay — Phase 2. * * Ported from upstream `extensions/memory-core/src/memory/temporal-decay.ts`. * Ages dated memory files (`memory/YYYY-MM-DD.md`) using exponential decay * `multiplier = exp(-ln(2) / halfLifeDays * ageInDays)`. At half-life, the * score is exactly halved. * * Evergreen files (MEMORY.md, memory/topics.md, any non-dated file inside * memory/) do NOT decay — they represent durable knowledge and should stay * hot regardless of age. This mirrors upstream's `isEvergreenMemoryPath`. * * Since our pgvector rows carry `createdAt`, we don't need filesystem stat * fallback — the row timestamp is authoritative for any file without a * date in the path. */ export interface TemporalDecayConfig { enabled: boolean; halfLifeDays: number; } export const DEFAULT_TEMPORAL_DECAY_CONFIG: TemporalDecayConfig = { enabled: false, halfLifeDays: 30, }; const DAY_MS = 24 * 60 * 60 * 1000; const DATED_MEMORY_PATH_RE = /(?:^|\/)memory\/(\d{4})-(\d{2})-(\d{2})\.md$/; export function toDecayLambda(halfLifeDays: number): number { if (!Number.isFinite(halfLifeDays) || halfLifeDays <= 0) return 0; return Math.LN2 / halfLifeDays; } export function calculateTemporalDecayMultiplier(params: { ageInDays: number; halfLifeDays: number; }): number { const lambda = toDecayLambda(params.halfLifeDays); const age = Math.max(0, params.ageInDays); if (lambda <= 0 || !Number.isFinite(age)) return 1; return Math.exp(-lambda * age); } export function applyTemporalDecayToScore(params: { score: number; ageInDays: number; halfLifeDays: number; }): number { return params.score * calculateTemporalDecayMultiplier(params); } function normalizePath(p: string): string { return (p ?? '').replace(/\\/g, '/').replace(/^\.\//, ''); } /** Parse a date out of `memory/YYYY-MM-DD.md` — returns null on non-match or invalid date. */ export function parseMemoryDateFromPath(filePath: string): Date | null { const m = DATED_MEMORY_PATH_RE.exec(normalizePath(filePath)); if (!m) return null; const y = Number(m[1]); const mo = Number(m[2]); const d = Number(m[3]); if (!Number.isInteger(y) || !Number.isInteger(mo) || !Number.isInteger(d)) return null; const ts = Date.UTC(y, mo - 1, d); const parsed = new Date(ts); if ( parsed.getUTCFullYear() !== y || parsed.getUTCMonth() !== mo - 1 || parsed.getUTCDate() !== d ) { return null; } return parsed; } /** * Evergreen = durable knowledge file that should not decay. * - `MEMORY.md` / `memory.md` at root * - anything inside `memory/` that is NOT a dated `YYYY-MM-DD.md` file */ export function isEvergreenMemoryPath(filePath: string): boolean { const n = normalizePath(filePath); if (n === 'MEMORY.md' || n === 'memory.md') return true; if (!n.startsWith('memory/')) return false; return !DATED_MEMORY_PATH_RE.test(n); } function ageInDays(timestamp: Date, nowMs: number): number { return Math.max(0, nowMs - timestamp.getTime()) / DAY_MS; } export interface DecayCandidate { path: string; score: number; createdAt?: Date; } /** * Apply temporal decay to a list of memory hits. * * Priority for the effective timestamp: * 1. Dated path (`memory/YYYY-MM-DD.md`) — use the date in the path * 2. Otherwise, if the path is evergreen — NO decay * 3. Otherwise, use the row's `createdAt` */ export function applyTemporalDecayToHits( hits: T[], config: Partial = {}, nowMs: number = Date.now() ): T[] { const merged = { ...DEFAULT_TEMPORAL_DECAY_CONFIG, ...config }; if (!merged.enabled) return [...hits]; return hits.map((h) => { const datedTs = parseMemoryDateFromPath(h.path); let ts: Date | null = datedTs; if (!ts) { if (isEvergreenMemoryPath(h.path)) return h; ts = h.createdAt ?? null; } if (!ts) return h; return { ...h, score: applyTemporalDecayToScore({ score: h.score, ageInDays: ageInDays(ts, nowMs), halfLifeDays: merged.halfLifeDays, }), }; }); }