/** * Forget Engine — Ethics-aware memory deletion with full cascade. * * Philosophy: "The right to forget" — memories can be explicitly removed, * but the process must be auditable, reversible (via evidence export), * and propagated to all derived artifacts (KG triples, pins, evolution chains). * * Sequence: * 1. Fetch target memory * 2. Privacy tier check (durable requires explicit confirm) * 3. Evidence export (snapshot before deletion for audit trail) * 4. KG triple cleanup (via KGStore.deleteBySource) * 5. Pin archive (mark related pins as forgotten) * 6. Cascade demote (related memories via cascade-forget.ts) * 7. Primary delete (remove from LanceDB) * 8. Audit log (record the forget operation) */ import type { MemoryStore, MemoryEntry } from "./store.js"; import type { KGStore } from "./kg-store.js"; import type { AuditLogger } from "./audit-log.js"; import { parsePrivacyTier, type PrivacyTier } from "./memory-schema.js"; import { parseEvolution, patchEvolution } from "./memory-evolution.js"; import { cascadeForget, type CascadeForgetConfig, DEFAULT_CASCADE_FORGET_CONFIG } from "./cascade-forget.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface ForgetRequest { /** Memory ID to forget (full UUID or 8+ hex prefix) */ memoryId: string; /** Explicit confirmation — required for "durable" tier memories */ confirm: boolean; /** Reason for forgetting (audit trail) */ reason?: string; /** Scope filter for permission check */ scopeFilter?: string[]; } export interface ForgetEvidence { /** Snapshot of the memory before deletion */ entry: MemoryEntry; /** Privacy tier at time of deletion */ privacyTier: PrivacyTier; /** Evolution chain snapshot */ evolution: ReturnType; /** Timestamp of forget operation */ forgottenAt: string; /** Reason provided */ reason?: string; } export interface ForgetResult { /** Whether the memory was successfully forgotten */ success: boolean; /** ID of the forgotten memory */ memoryId: string; /** Evidence snapshot (for audit/undo) */ evidence?: ForgetEvidence; /** Number of KG triples removed */ kgTriplesRemoved: boolean; /** Cascade demote results */ cascadeResult: { demotedCount: number; demotedIds: string[] }; /** Error message if failed */ error?: string; } export interface ForgetByIdDeps { store: MemoryStore; kgStore?: KGStore | null; auditLogger?: AuditLogger | null; cascadeConfig?: CascadeForgetConfig; } // --------------------------------------------------------------------------- // Core: Forget a single memory // --------------------------------------------------------------------------- export async function forgetMemory( deps: ForgetByIdDeps, request: ForgetRequest, ): Promise { const { store, kgStore, auditLogger, cascadeConfig } = deps; const { memoryId, confirm, reason, scopeFilter } = request; // 1. Fetch target const entry = await store.get(memoryId, scopeFilter); if (!entry) { return { success: false, memoryId, kgTriplesRemoved: false, cascadeResult: { demotedCount: 0, demotedIds: [] }, error: `Memory ${memoryId} not found`, }; } // 2. Privacy tier check const privacyTier = parsePrivacyTier(entry.metadata); if (privacyTier === "durable" && !confirm) { return { success: false, memoryId: entry.id, kgTriplesRemoved: false, cascadeResult: { demotedCount: 0, demotedIds: [] }, error: `Memory ${entry.id} has privacy tier "durable" — set confirm=true to proceed`, }; } // 3. Evidence export (snapshot before deletion) const evolution = parseEvolution(entry.metadata, entry.timestamp); const evidence: ForgetEvidence = { entry: { ...entry }, privacyTier, evolution, forgottenAt: new Date().toISOString(), reason, }; // 4. KG triple cleanup let kgTriplesRemoved = false; if (kgStore) { try { await kgStore.deleteBySource(entry.id); kgTriplesRemoved = true; } catch (err) { console.error("[recallnest] KG cleanup failed during forget:", err instanceof Error ? err.message : String(err)); } } // 5. Cascade demote (related memories get importance reduction) let cascadeResult = { demotedCount: 0, demotedIds: [] as string[] }; try { cascadeResult = await cascadeForget( store, { id: entry.id, vector: entry.vector, scope: entry.scope }, cascadeConfig ?? DEFAULT_CASCADE_FORGET_CONFIG, ); } catch (err) { console.error("[recallnest] Cascade demote failed during forget:", err instanceof Error ? err.message : String(err)); } // 6. Mark evolution status as "forgotten" before delete (audit breadcrumb) try { const patchedMetadata = patchEvolution(entry.metadata, { status: "archived" as any, evolutionNote: `forgotten: ${reason || "user request"}`, }); await store.update(entry.id, { metadata: patchedMetadata }, scopeFilter); } catch (err) { console.error("[recallnest] Evolution patch failed during forget:", err instanceof Error ? err.message : String(err)); } // 7. Primary delete try { await store.delete(entry.id, scopeFilter); } catch (err) { return { success: false, memoryId: entry.id, evidence, kgTriplesRemoved, cascadeResult, error: `Delete failed: ${err instanceof Error ? err.message : String(err)}`, }; } // 8. Audit log try { auditLogger?.log({ operation: "forget", scope: entry.scope, memoryId: entry.id, actor: "system", details: `tier=${privacyTier} reason=${reason || "none"} cascade=${cascadeResult.demotedCount}`, }); if (cascadeResult.demotedCount > 0) { auditLogger?.log({ operation: "cascade_forget", scope: entry.scope, memoryId: entry.id, actor: "system", details: `demoted ${cascadeResult.demotedCount} related memories: ${cascadeResult.demotedIds.map(id => id.slice(0, 8)).join(",")}`, }); } } catch (err) { console.error("[recallnest] Audit log failed during forget:", err instanceof Error ? err.message : String(err)); } return { success: true, memoryId: entry.id, evidence, kgTriplesRemoved, cascadeResult, }; } // --------------------------------------------------------------------------- // Bulk: Forget all memories in a scope // --------------------------------------------------------------------------- export interface ForgetByScopeResult { forgottenCount: number; failedCount: number; kgScopeCleared: boolean; totalCascadeDemoted: number; } export async function forgetByScope( deps: ForgetByIdDeps, scope: string, confirm: boolean, reason?: string, ): Promise { if (!confirm) { return { forgottenCount: 0, failedCount: 0, kgScopeCleared: false, totalCascadeDemoted: 0 }; } const { store, kgStore, auditLogger } = deps; let forgottenCount = 0; let failedCount = 0; let totalCascadeDemoted = 0; // Fetch all entries in scope. // // scopeMatch: "exact" —— **破坏性操作绝不做前缀展开**(2026-08-16 互审 Codex R3 提出)。 // 默认 family 语义下 `forgetByScope("memory")` 会连 `memory:pivot` 一起遗忘,而下面 // 的 KG 清理走的是精确 `scope = 'memory'`(kg-store.ts:506)—— 记忆行按家族删、KG 只 // 清一个 scope,两边还会不一致。当前无生产 caller,正因如此更要在有 caller 之前定死语义: // 要清一整个家族,就显式枚举那些 scope 逐个调用。 const entries = await store.list([scope], undefined, 5000, 0, "exact"); for (const entry of entries) { const result = await forgetMemory(deps, { memoryId: entry.id, confirm: true, reason: reason || `scope-level forget: ${scope}`, scopeFilter: [scope], }); if (result.success) { forgottenCount++; totalCascadeDemoted += result.cascadeResult.demotedCount; } else { failedCount++; } } // Bulk KG scope cleanup let kgScopeCleared = false; if (kgStore) { try { await kgStore.deleteByScope(scope); kgScopeCleared = true; } catch (err) { console.error("[recallnest] KG scope cleanup failed:", err instanceof Error ? err.message : String(err)); } } // Audit the bulk operation try { auditLogger?.log({ operation: "forget", scope, actor: "system", details: `scope-forget: ${forgottenCount} deleted, ${failedCount} failed, ${totalCascadeDemoted} cascade-demoted`, }); } catch (err) { console.error("[recallnest] Audit log failed during scope forget:", err instanceof Error ? err.message : String(err)); } return { forgottenCount, failedCount, kgScopeCleared, totalCascadeDemoted }; }