/** * Cascade Forget — Directed forgetting with association propagation. * * Agent-First reasoning: when a user says "forget X", the primary memory * gets deleted/archived. But if related memories (meeting notes, decisions, * preferences mentioning X) keep their original importance, X still * surfaces indirectly through retrieval. Cascade forget finds related * entries and demotes them so X doesn't leak back via associations. * * Brain-science label: "directed forgetting" — suppressing a memory also * weakens retrieval of associated memories. We keep the name for branding; * the real mechanism is vector search + importance reduction. * * Rules: * - Does NOT delete related entries — only demotes importance and tier * - Demotion is proportional to similarity (higher sim = bigger demotion) * - Writes metadata breadcrumb for auditability * - No LLM calls — pure vector search + arithmetic */ import type { MemoryStore, MemoryEntry } from "./store.js"; import { isActiveMemory } from "./memory-evolution.js"; // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- export interface CascadeForgetConfig { /** Minimum vector similarity to consider "related" (default: 0.70) */ similarityThreshold: number; /** Maximum entries to demote per forget operation (default: 10) */ maxDemotePerForget: number; /** Maximum importance reduction (at similarity=1.0) (default: 0.3) */ maxDemotion: number; /** Floor: importance never drops below this (default: 0.05) */ importanceFloor: number; } export const DEFAULT_CASCADE_FORGET_CONFIG: CascadeForgetConfig = { similarityThreshold: 0.70, maxDemotePerForget: 10, maxDemotion: 0.3, importanceFloor: 0.05, }; // --------------------------------------------------------------------------- // Core // --------------------------------------------------------------------------- export interface CascadeForgetResult { demotedCount: number; demotedIds: string[]; } /** * After deleting/archiving a memory, cascade-demote related entries. * * @param store - Memory store * @param forgottenEntry - The entry that was just deleted/archived (need its vector + scope) * @param config - Cascade config */ export async function cascadeForget( store: MemoryStore, forgottenEntry: Pick, config: CascadeForgetConfig = DEFAULT_CASCADE_FORGET_CONFIG, ): Promise { if (!forgottenEntry.vector?.length) { return { demotedCount: 0, demotedIds: [] }; } // Find related entries in the same scope const candidates = await store.vectorSearch( forgottenEntry.vector, config.maxDemotePerForget * 2, // fetch extra, filter down config.similarityThreshold, [forgottenEntry.scope], ); const demotedIds: string[] = []; for (const candidate of candidates) { if (demotedIds.length >= config.maxDemotePerForget) break; const entry = candidate.entry; // Skip the forgotten entry itself (may still be in index) if (entry.id === forgottenEntry.id) continue; // Skip already-archived/superseded entries (unified via evolution.status) if (!isActiveMemory(entry.metadata)) continue; let meta: Record = {}; try { meta = JSON.parse(entry.metadata || "{}"); } catch { /* skip */ } // Proportional demotion: higher similarity → bigger cut // At sim=1.0 → full maxDemotion, at threshold → near-zero const simRange = 1.0 - config.similarityThreshold; const simNormalized = simRange > 0 ? (candidate.score - config.similarityThreshold) / simRange : 1.0; const demotion = config.maxDemotion * simNormalized; const newImportance = Math.max( entry.importance - demotion, config.importanceFloor, ); // Skip if no meaningful change if (Math.abs(newImportance - entry.importance) < 0.01) continue; // Audit trail if (!Array.isArray(meta.cascade_forget)) meta.cascade_forget = []; meta.cascade_forget.push({ forgottenId: forgottenEntry.id.slice(0, 8), from: entry.importance, to: newImportance, similarity: candidate.score, date: new Date().toISOString().slice(0, 10), }); // Demote tier if importance drops below working threshold if (meta.tier === "working" && newImportance < 0.5) { meta.tier = "peripheral"; } else if (meta.tier === "core" && newImportance < 0.8) { meta.tier = "working"; } await store.update(entry.id, { importance: newImportance, metadata: JSON.stringify(meta), }, [forgottenEntry.scope]); demotedIds.push(entry.id); } return { demotedCount: demotedIds.length, demotedIds }; }