import { appendFile, mkdir } from "node:fs/promises"; import { dirname } from "node:path"; import type { CompactionMemoryRef, LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts"; import { LONG_TERM_LIMITS } from "./types.ts"; import { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts"; import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts"; import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts"; import { redactCredentials } from "./redaction.ts"; import { REINFORCEMENT_MAX_COUNT, RETENTION_TYPE_MAX, calculateRetentionStrength, tryReinforceMemory, type ReinforcementDecision, } from "./retention.ts"; import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts"; import { appendEvidenceEvents } from "./evidence-log.ts"; import { MEMORY_TYPE_ORDER } from "./memory-kind-policy.ts"; // Minimum length for workspace_memory envelope: \n...\n const MIN_ENVELOPE_LENGTH = 80; const MIGRATION_ID = "2026-04-26-p0-cleanup"; const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup"; const RETENTION_CLOCK_BACKFILL_MIGRATION_ID = "2026-05-01-retention-clock-backfill"; export type MemoryConsolidationReason = | "promoted" | "absorbed_exact" | "absorbed_identity" | "superseded_existing" | "rejected_capacity"; export type MemoryConsolidationEvent = { memoryKey: string; identityKey: string; memory: LongTermMemoryEntry; reason: MemoryConsolidationReason; retainedId?: string; supersededId?: string; }; export type LongTermLimitResult = { kept: LongTermMemoryEntry[]; dropped: MemoryConsolidationEvent[]; absorbed: MemoryConsolidationEvent[]; superseded: MemoryConsolidationEvent[]; evidence: EvidenceEventInput[]; }; export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & { store: WorkspaceMemoryStore; events: MemoryConsolidationEvent[]; }; export type WorkspaceMemoryRenderAccounting = { rendered: LongTermMemoryEntry[]; omitted: Array<{ memory: LongTermMemoryEntry; reason: "superseded" | "type_cap" | "global_cap" | "char_budget" | "empty_render_budget"; }>; evidence: EvidenceEventInput[]; prompt: string; }; export type WorkspaceMemoryCompactionRefsAccounting = WorkspaceMemoryRenderAccounting & { refs: CompactionMemoryRef[]; }; type WorkspaceMemoryRenderSelection = { active: LongTermMemoryEntry[]; omitted: WorkspaceMemoryRenderAccounting["omitted"]; maxChars: number; }; export type QualityCleanupMigrationLogEntry = { migrationId: string; timestamp: string; workspaceKey: string; workspaceRoot: string; entryId: string; type: LongTermMemoryEntry["type"]; source: LongTermMemoryEntry["source"]; text: string; reasons: string[]; hardReasons: string[]; beforeStatus: "active"; afterStatus: "superseded"; }; export type P0CleanupMigrationResult = { store: WorkspaceMemoryStore; events: EvidenceEventInput[]; }; export type QualityCleanupMigrationResult = { store: WorkspaceMemoryStore; events: QualityCleanupMigrationLogEntry[]; evidence: EvidenceEventInput[]; }; export async function emptyWorkspaceMemory(root: string): Promise { const nowIso = new Date().toISOString(); return { version: 1, workspace: { root, key: await workspaceKey(root) }, limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries, }, entries: [], migrations: [], updatedAt: nowIso, lastActivityAt: nowIso, }; } export async function loadWorkspaceMemory(root: string): Promise { const path = await workspaceMemoryPath(root); const fallback = await emptyWorkspaceMemory(root); const loaded = await readJSON(path, () => fallback) as Partial; const store: WorkspaceMemoryStore = { version: loaded.version ?? 1, workspace: loaded.workspace ?? { root, key: await workspaceKey(root) }, limits: { maxRenderedChars: loaded.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars, maxEntries: loaded.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries, }, entries: Array.isArray(loaded.entries) ? loaded.entries : [], migrations: Array.isArray(loaded.migrations) ? loaded.migrations : [], updatedAt: loaded.updatedAt ?? fallback.updatedAt, lastActivityAt: loaded.lastActivityAt ?? loaded.updatedAt ?? fallback.lastActivityAt, }; // Always normalize on load so redaction/migrations are always-on. const normalized = await normalizeWorkspaceMemoryWithAccounting(root, store); // Persist security/correctness mutations, but avoid read-time maintenance // writes for ordering/capacity/timestamp-only normalization. if (hasSecurityOrMigrationChange(store, normalized.store)) { await atomicWriteJSON(path, normalized.store); // loadWorkspaceMemory has a narrow migration side effect: when a first-load // migration actually supersedes entries, append evidence for those storage // changes. Migration IDs keep this idempotent on repeated loads. const migrationEvidence = normalized.evidence.filter(event => event.type === "memory_migration_superseded"); if (migrationEvidence.length > 0) { await appendEvidenceEvents(root, migrationEvidence); } } return normalized.store; } function hasSecurityOrMigrationChange( before: WorkspaceMemoryStore, after: WorkspaceMemoryStore, ): boolean { const beforeById = new Map((before.entries ?? []).map(entry => [entry.id, entry])); for (const afterEntry of after.entries ?? []) { const beforeEntry = beforeById.get(afterEntry.id); if (!beforeEntry) continue; if (beforeEntry.text !== afterEntry.text) return true; if ((beforeEntry.rationale ?? "") !== (afterEntry.rationale ?? "")) return true; if (beforeEntry.status !== afterEntry.status) return true; if ((beforeEntry.retentionClock ?? null) !== (afterEntry.retentionClock ?? null)) return true; } const beforeMigrations = JSON.stringify(before.migrations ?? []); const afterMigrations = JSON.stringify(after.migrations ?? []); if ((before.lastActivityAt ?? "") !== (after.lastActivityAt ?? "")) return true; return beforeMigrations !== afterMigrations; } export async function saveWorkspaceMemory(root: string, store: WorkspaceMemoryStore): Promise { const normalized = await normalizeWorkspaceMemory(root, store); await atomicWriteJSON(await workspaceMemoryPath(root), normalized); } export async function updateWorkspaceMemory( root: string, updater: (store: WorkspaceMemoryStore) => WorkspaceMemoryStore | Promise, ): Promise { return (await updateWorkspaceMemoryWithAccounting(root, updater)).store; } export async function updateWorkspaceMemoryWithAccounting( root: string, updater: (store: WorkspaceMemoryStore) => WorkspaceMemoryStore | Promise, ): Promise { const path = await workspaceMemoryPath(root); const fallback = await emptyWorkspaceMemory(root); let finalResult: WorkspaceMemoryNormalizationResult | undefined; const store = await updateJSON(path, () => fallback, async current => { const currentNormalization = await normalizeWorkspaceMemoryWithAccounting(root, current); const currentMigrationEvidence = currentNormalization.evidence.filter(event => event.type === "memory_migration_superseded"); finalResult = await normalizeWorkspaceMemoryWithAccounting(root, await updater(currentNormalization.store)); if (currentMigrationEvidence.length > 0) { finalResult = { ...finalResult, evidence: [...currentMigrationEvidence, ...finalResult.evidence], }; } return finalResult.store; }); return finalResult ?? { store, kept: store.entries.filter(entry => entry.status !== "superseded"), dropped: [], absorbed: [], superseded: [], evidence: [], events: [], }; } export async function normalizeWorkspaceMemory( root: string, store: WorkspaceMemoryStore, ): Promise { return (await normalizeWorkspaceMemoryWithAccounting(root, store)).store; } export async function normalizeWorkspaceMemoryWithAccounting( root: string, store: WorkspaceMemoryStore, ): Promise { const nowMs = Date.now(); const nowIso = new Date(nowMs).toISOString(); let result: WorkspaceMemoryStore = { ...store, workspace: { root, key: await workspaceKey(root) }, limits: { maxRenderedChars: store.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars, maxEntries: store.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries, }, entries: Array.isArray(store.entries) ? store.entries : [], migrations: Array.isArray(store.migrations) ? store.migrations : [], updatedAt: nowIso, }; // Always-on credential redaction result.entries = result.entries.map(entry => { const text = redactCredentials(entry.text); const rationale = entry.rationale ? redactCredentials(entry.rationale) : undefined; if (text === entry.text && rationale === entry.rationale) { return entry; } return { ...entry, text, rationale, updatedAt: nowIso, }; }); // One-time migrations for legacy/low-quality snapshot violations. // Run quality cleanup first so hard violations receive quality audit tags // before the older P0 project-only cleanup marks progress snapshots. const beforeQualityCleanup = result; const qualityCleanup = runMigrationQualityCleanup(result, nowIso); result = qualityCleanup.store; let migrationEvidence: EvidenceEventInput[] = []; let skipRemainingMigrations = false; if (qualityCleanup.events.length > 0) { try { await appendQualityCleanupMigrationLog(qualityCleanup.events); migrationEvidence = [...migrationEvidence, ...qualityCleanup.evidence]; } catch (error) { console.error("[memory] failed to write quality cleanup migration log:", error); console.error("[memory] aborting migration to maintain audit trail integrity"); result = beforeQualityCleanup; skipRemainingMigrations = true; } } if (!skipRemainingMigrations) { const p0Cleanup = runMigrationP0Cleanup(result, nowIso); result = p0Cleanup.store; migrationEvidence = [...migrationEvidence, ...p0Cleanup.events]; } result.entries = result.entries.map(entry => backfillRetentionClock(entry, nowMs)); if (!result.migrations.includes(RETENTION_CLOCK_BACKFILL_MIGRATION_ID)) { result = { ...result, migrations: [...result.migrations, RETENTION_CLOCK_BACKFILL_MIGRATION_ID], updatedAt: nowIso, }; } // P0 accounting only considers active entries. Entries that were already // superseded before this normalization are preserved in storage; entries that // lose during this enforcement are reported via accounting events but are not // archived as superseded records in this wave. const activeEntries = result.entries.filter(entry => entry.status !== "superseded"); const supersededEntries = result.entries.filter(entry => entry.status === "superseded"); const accounting = enforceLongTermLimitsWithAccounting(activeEntries, result); const normalizedStore = { ...result, entries: [...accounting.kept, ...supersededEntries], updatedAt: nowIso, lastActivityAt: nowIso, }; return { store: normalizedStore, kept: accounting.kept, dropped: accounting.dropped, absorbed: accounting.absorbed, superseded: accounting.superseded, evidence: [...migrationEvidence, ...accounting.evidence], events: [...accounting.dropped, ...accounting.absorbed, ...accounting.superseded], }; } function backfillRetentionClock(entry: LongTermMemoryEntry, nowMs: number): LongTermMemoryEntry { if (Number.isFinite(entry.retentionClock)) { return entry; } const createdAtMs = new Date(entry.createdAt).getTime(); if (Number.isFinite(createdAtMs)) { return { ...entry, retentionClock: createdAtMs }; } const updatedAtMs = new Date(entry.updatedAt).getTime(); if (Number.isFinite(updatedAtMs)) { return { ...entry, retentionClock: updatedAtMs }; } return { ...entry, retentionClock: nowMs }; } export function runMigrationP0Cleanup( store: WorkspaceMemoryStore, nowIso: string, ): P0CleanupMigrationResult { if (store.migrations?.includes(MIGRATION_ID)) { return { store, events: [] }; } const events: EvidenceEventInput[] = []; const entries = store.entries.map(entry => { if (entry.source !== "compaction") return entry; if (entry.type !== "project") return entry; if (entry.status === "superseded") return entry; if (isProgressSnapshotViolation(entry.text)) { const superseded = { ...entry, status: "superseded" as const, updatedAt: nowIso, }; events.push(migrationSupersededEvidence(superseded, ["migration:p0_cleanup"], MIGRATION_ID)); return superseded; } return entry; }); return { store: { ...store, entries, migrations: [...(store.migrations || []), MIGRATION_ID], updatedAt: nowIso, }, events, }; } async function appendQualityCleanupMigrationLog(events: QualityCleanupMigrationLogEntry[]): Promise { if (events.length === 0) return; const path = migrationLogPath(QUALITY_CLEANUP_MIGRATION_ID); await mkdir(dirname(path), { recursive: true }); await appendFile(path, events.map(event => JSON.stringify(event)).join("\n") + "\n", "utf8"); } export function runMigrationQualityCleanup( store: WorkspaceMemoryStore, nowIso: string, ): QualityCleanupMigrationResult { if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) { return { store, events: [], evidence: [] }; } const events: QualityCleanupMigrationLogEntry[] = []; const evidence: EvidenceEventInput[] = []; let changed = false; const entries = store.entries.map(entry => { if (entry.source !== "compaction") return entry; if (entry.status === "superseded") return entry; const quality = assessMemoryQuality(entry); if (quality.accepted) return entry; const hardReasons = quality.reasons.filter(isHardQualityReason); if (hardReasons.length === 0) return entry; changed = true; events.push({ migrationId: QUALITY_CLEANUP_MIGRATION_ID, timestamp: nowIso, workspaceKey: store.workspace.key, workspaceRoot: store.workspace.root, entryId: entry.id, type: entry.type, source: entry.source, text: entry.text, reasons: quality.reasons, hardReasons, beforeStatus: "active", afterStatus: "superseded", }); const tags = new Set([ ...(entry.tags ?? []), "quality_cleanup", ...hardReasons.map(reason => `quality:${reason}`), ]); const superseded = { ...entry, status: "superseded" as const, updatedAt: nowIso, tags: [...tags], }; evidence.push(migrationSupersededEvidence( superseded, ["migration:quality_cleanup", ...hardReasons.map(reason => `quality:${reason}`)], QUALITY_CLEANUP_MIGRATION_ID, { hardReasons }, )); return superseded; }); return { store: { ...store, entries, migrations: [...(store.migrations ?? []), QUALITY_CLEANUP_MIGRATION_ID], updatedAt: changed ? nowIso : store.updatedAt, }, events, evidence, }; } function sourcePriority(source: LongTermMemoryEntry["source"]): number { if (source === "explicit") return 3; if (source === "manual") return 2; return 1; } function canonicalMemoryText(text: string): string { return text .normalize("NFKC") .toLowerCase() .replace(/[\s\p{P}]+/gu, " ") .trim(); } export function workspaceMemoryExactKey(entry: Pick): string { return `${entry.type}:${canonicalMemoryText(entry.text)}`; } function normalizeUrlIdentity(raw: string): string | null { const cleaned = raw.replace(/[),.;:!?]+$/g, ""); try { const url = new URL(cleaned); if (url.protocol !== "http:" && url.protocol !== "https:") return null; url.protocol = url.protocol.toLowerCase(); url.hostname = url.hostname.toLowerCase(); url.hash = ""; if (url.pathname.length > 1) { url.pathname = url.pathname.replace(/\/+$/g, ""); } return `url:${url.toString()}`; } catch { return null; } } function normalizePathIdentity(raw: string): string | null { const unwrapped = raw .trim() .replace(/^[`"']+|[`"']+$/g, "") .replace(/[),.;:!?]+$/g, "") .replace(/\\+/g, "/"); if (!unwrapped) return null; const collapsed = unwrapped.startsWith("/") ? `/${unwrapped.slice(1).replace(/\/+$/g, "/").replace(/\/+/g, "/")}` : unwrapped.replace(/\/+/g, "/"); const withoutTrailingSlash = collapsed.length > 1 ? collapsed.replace(/\/+$/g, "") : collapsed; return `path:${withoutTrailingSlash}`; } function isConcretePathIdentity(pathIdentity: string): boolean { const path = pathIdentity.slice("path:".length); if (!path || path === "." || path === "..") return false; if (path.startsWith("/")) return true; if (/^\.\.?\//.test(path)) return true; if (/^\.[A-Za-z0-9_.-]+\//.test(path)) return true; if (/^[A-Za-z0-9_.-]+\//.test(path)) return true; return /\.(?:json|jsonc|ts|tsx|js|jsx|mjs|cjs|md|yaml|yml|toml|lock|config)$/i.test(path); } function normalizeConcretePathIdentity(raw: string): string | null { const pathIdentity = normalizePathIdentity(raw); if (!pathIdentity) return null; return isConcretePathIdentity(pathIdentity) ? pathIdentity : null; } function extractConcreteIdentityKey(text: string): string | null { const urlMatch = text.match(/https?:\/\/[^\s`"'<>]+/i); if (urlMatch) { const urlIdentity = normalizeUrlIdentity(urlMatch[0]); if (urlIdentity) return urlIdentity; } const wrappedPathPattern = /[`"']([^`"']+)[`"']/g; for (const match of text.matchAll(wrappedPathPattern)) { const pathIdentity = normalizeConcretePathIdentity(match[1]); if (pathIdentity) return pathIdentity; } const pathMatch = text.match(/(?:\/[^\0\s`"'<>]+|(?:\.{1,2}[\\/]|[A-Za-z0-9_.-]+[\\/])[^\s`"'<>]+|[A-Za-z0-9_.-]+\.(?:json|jsonc|ts|tsx|js|jsx|mjs|cjs|md|yaml|yml|toml|lock|config))(?:\b|$)/); if (!pathMatch) return null; return normalizeConcretePathIdentity(pathMatch[0]); } export function workspaceMemoryIdentityKey(entry: Pick): string { if (entry.type === "project" || entry.type === "reference") { return `${entry.type}:${extractConcreteIdentityKey(entry.text) ?? canonicalMemoryText(entry.text)}`; } return workspaceMemoryExactKey(entry); } function consolidationEvent( memory: LongTermMemoryEntry, reason: MemoryConsolidationReason, retained?: LongTermMemoryEntry, ): MemoryConsolidationEvent { return { memoryKey: workspaceMemoryExactKey(memory), identityKey: workspaceMemoryIdentityKey(memory), memory, reason, retainedId: retained?.id, supersededId: reason === "superseded_existing" ? memory.id : undefined, }; } function capacityRemovalEvidence( memory: LongTermMemoryEntry, reason: "type_cap" | "global_cap", details: { strengthAtRemoval: number; rankAtRemoval: number; typeRankAtRemoval: number; ageDaysAtRemoval: number; }, ): EvidenceEventInput { return { type: "memory_removed_capacity", phase: "storage", outcome: "removed", reasonCodes: [reason], memory: memoryEvidenceRef(memory), relations: [{ role: "removed", memory: memoryEvidenceRef(memory), }], details: { type: memory.type, globalCap: LONG_TERM_LIMITS.maxEntries, ...(reason === "type_cap" ? { typeCap: RETENTION_TYPE_MAX[memory.type] } : {}), ...(typeof memory.retentionClock === "number" && Number.isFinite(memory.retentionClock) ? { retentionClock: memory.retentionClock } : {}), ...(memory.createdAt ? { createdAt: memory.createdAt } : {}), ...(memory.source ? { source: memory.source } : {}), ...details, }, }; } function migrationSupersededEvidence( memory: LongTermMemoryEntry, reasonCodes: string[], migrationId: string, details: EvidenceEventInput["details"] = {}, ): EvidenceEventInput { return { type: "memory_migration_superseded", phase: "storage", outcome: "superseded", memory: memoryEvidenceRef(memory), relations: [{ role: "superseded", memory: memoryEvidenceRef(memory) }], reasonCodes, details: { migrationId, type: memory.type, source: memory.source, ...details, }, textPreview: memory.text, }; } /** Choose better memory when identity/topic keys conflict */ function chooseBetterMemory( a: LongTermMemoryEntry, b: LongTermMemoryEntry, mode: "entity" | "supersession" = "entity", ): LongTermMemoryEntry { // Source priority: explicit > manual > compaction if (sourcePriority(a.source) !== sourcePriority(b.source)) { return sourcePriority(a.source) > sourcePriority(b.source) ? a : b; } // Higher confidence wins if (a.confidence !== b.confidence) { return a.confidence > b.confidence ? a : b; } // For entity dedup: longer (more specific) beats shorter // For supersession: newer beats older (and thus longer is not preferred) if (mode === "supersession") { // Newer wins for same-topic supersession if (new Date(a.createdAt).getTime() !== new Date(b.createdAt).getTime()) { return new Date(a.createdAt) > new Date(b.createdAt) ? a : b; } return a.text.length > b.text.length ? a : b; } // Entity mode: longer text means more specific if (Math.abs(a.text.length - b.text.length) > 10) { return a.text.length > b.text.length ? a : b; } // Freshness tie-breaker return new Date(a.createdAt) > new Date(b.createdAt) ? a : b; } export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] { return enforceLongTermLimitsWithAccounting(entries).kept; } export function enforceLongTermLimitsWithAccounting( entries: LongTermMemoryEntry[], store?: WorkspaceMemoryStore, ): LongTermLimitResult { const now = Date.now(); const lastActivityAt = store?.lastActivityAt; // Phase 1: filter active entries and trim text. Retention removal is by // strength/cap competition, not hard stale pruning. const phase1: LongTermMemoryEntry[] = []; for (const entry of entries) { if (entry.status === "superseded") continue; phase1.push({ ...entry, text: entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars) }); } const dedupeResult = dedupeLongTermEntriesWithAccounting(phase1); const sorted = [...dedupeResult.kept].sort((a, b) => compareLongTermMemoryForRetention(a, b, now, lastActivityAt)); const capped = applyTypeMaxCaps(sorted); const kept = capped.slice(0, LONG_TERM_LIMITS.maxEntries); const keptIds = new Set(kept.map(entry => entry.id)); const cappedIds = new Set(capped.map(entry => entry.id)); const typeCapLosers = sorted.filter(entry => !cappedIds.has(entry.id)); const globalCapLosers = capped.filter(entry => !keptIds.has(entry.id)); const capacityEvidence: EvidenceEventInput[] = [ ...typeCapLosers.map(entry => capacityRemovalEvidence(entry, "type_cap", capacityRemovalSnapshot(entry, sorted, now, lastActivityAt))), ...globalCapLosers.map(entry => capacityRemovalEvidence(entry, "global_cap", capacityRemovalSnapshot(entry, sorted, now, lastActivityAt))), ]; const capacityDropped = sorted .filter(entry => !keptIds.has(entry.id)) .map(entry => consolidationEvent(entry, "rejected_capacity")); return { kept, dropped: [...dedupeResult.dropped, ...capacityDropped], absorbed: dedupeResult.absorbed, superseded: dedupeResult.superseded, evidence: [...dedupeResult.evidence, ...capacityEvidence], }; } function capacityRemovalSnapshot( memory: LongTermMemoryEntry, sorted: LongTermMemoryEntry[], now: number, lastActivityAt?: string, ): { strengthAtRemoval: number; rankAtRemoval: number; typeRankAtRemoval: number; ageDaysAtRemoval: number; } { const createdAtMs = new Date(memory.createdAt).getTime(); const rank = sorted.findIndex(entry => entry.id === memory.id); const typeRank = sorted.filter(entry => entry.type === memory.type).findIndex(entry => entry.id === memory.id); return { strengthAtRemoval: calculateRetentionStrength(memory, now, lastActivityAt), rankAtRemoval: rank >= 0 ? rank + 1 : -1, typeRankAtRemoval: typeRank >= 0 ? typeRank + 1 : -1, ageDaysAtRemoval: Number.isFinite(createdAtMs) ? Math.floor(Math.max(0, now - createdAtMs) / 86_400_000) : 0, }; } function applyTypeMaxCaps(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] { return applyTypeMaxCapsWithOmissions(entries).kept; } function applyTypeMaxCapsWithOmissions(entries: LongTermMemoryEntry[]): { kept: LongTermMemoryEntry[]; omitted: LongTermMemoryEntry[] } { const capped: LongTermMemoryEntry[] = []; const omitted: LongTermMemoryEntry[] = []; const typeCounts: Partial> = {}; for (const entry of entries) { const count = typeCounts[entry.type] ?? 0; const max = RETENTION_TYPE_MAX[entry.type] ?? Infinity; if (count >= max) { omitted.push(entry); continue; } capped.push(entry); typeCounts[entry.type] = count + 1; } return { kept: capped, omitted }; } export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult { const now = Date.now(); const absorbed: MemoryConsolidationEvent[] = []; const superseded: MemoryConsolidationEvent[] = []; const evidence: EvidenceEventInput[] = []; // For project/reference/feedback: dedupe by concrete identity or exact canonical text. // Feedback is grouped with project/reference for entity dedupe, but // workspaceMemoryIdentityKey() returns exact key for feedback (no concrete // identity extraction). This means feedback absorbed_identity is currently // impossible. When identity key extraction is extended to all types, feedback // with matching concrete identifiers will correctly produce absorbed_identity. const projectRefEntries = entries.filter(e => e.type === "project" || e.type === "reference" || e.type === "feedback"); // Build identity key dedup for project/reference/feedback. const entityDeduped = new Map(); for (const entry of projectRefEntries) { const key = workspaceMemoryIdentityKey(entry); const existing = entityDeduped.get(key); if (!existing) { entityDeduped.set(key, entry); } else { const retained = chooseBetterMemory(entry, existing, "entity"); const dropped = retained === entry ? existing : entry; const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing) ? "absorbed_exact" as const : "absorbed_identity" as const; const decision = tryReinforceMemory( retained, reinforcementSessionId(retained, dropped), now, ); const reinforced = decision.memory; const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason); if (reinforcedEvent) evidence.push(reinforcedEvent); absorbed.push(consolidationEvent(dropped, reason, reinforced)); entityDeduped.set(key, reinforced); } } // For decisions: exact canonical duplicates only. const decisionEntries = entries.filter(e => e.type === "decision"); const decisionDeduped = new Map(); for (const entry of decisionEntries) { const key = workspaceMemoryIdentityKey(entry); const existing = decisionDeduped.get(key); if (!existing) { decisionDeduped.set(key, entry); } else { const retained = chooseBetterMemory(entry, existing, "supersession"); const dropped = retained === entry ? existing : entry; const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing) ? "absorbed_exact" as const : "superseded_existing" as const; // v1.5.4 placeholder: unreachable until numbered refs const decision = tryReinforceMemory( retained, reinforcementSessionId(retained, dropped), now, ); const reinforced = decision.memory; const reinforcedEvent = reinforcementEvidence(retained, dropped, decision, reason); if (reinforcedEvent) evidence.push(reinforcedEvent); if (reason === "superseded_existing") { superseded.push(consolidationEvent(dropped, reason, reinforced)); } else { absorbed.push(consolidationEvent(dropped, reason, reinforced)); } decisionDeduped.set(key, reinforced); } } // Merge deduped entries const phaseFinal = new Map(); for (const entry of [...entityDeduped.values(), ...decisionDeduped.values()]) { phaseFinal.set(entry.id, entry); } return { kept: [...phaseFinal.values()], dropped: [], absorbed, superseded, evidence, }; } function memoryEvidenceRef(memory: LongTermMemoryEntry): MemoryEvidenceRef { return { memoryId: memory.id, memoryKeyHash: workspaceMemoryExactKey(memory), identityKeyHash: workspaceMemoryIdentityKey(memory), type: memory.type, source: memory.source, status: memory.status, }; } function reinforcementEvidence( retained: LongTermMemoryEntry, dropped: LongTermMemoryEntry, decision: ReinforcementDecision, reason: "absorbed_exact" | "absorbed_identity" | "superseded_existing", ): EvidenceEventInput | undefined { const duplicateReason = reason === "absorbed_identity" ? "duplicate_identity" : "duplicate_exact"; if (decision.outcome === "blocked") { return { type: "memory_reinforced", phase: "reinforcement", outcome: "rejected", memory: memoryEvidenceRef(retained), relations: [ { role: "target", memory: memoryEvidenceRef(retained) }, { role: "reinforced_by", memory: memoryEvidenceRef(dropped) }, ], reasonCodes: [duplicateReason, "reinforcement_window_blocked", `reinforcement_block_${decision.blockReason}`], details: { memoryId: retained.id, droppedMemoryId: dropped.id, blockReason: decision.blockReason, ...reinforcementDecisionTimingDetails(decision), reinforcementCount: decision.reinforcementCount, maxReinforcementCount: decision.maxReinforcementCount, }, textPreview: retained.text, }; } const reinforced = decision.memory; const reasonCodes = [duplicateReason, "reinforcement_window_allowed"]; if (decision.reinforcementMode === "refresh_only") { reasonCodes.push("reinforcement_saturation_refresh"); } return { type: "memory_reinforced", phase: "reinforcement", outcome: "reinforced", memory: memoryEvidenceRef(reinforced), relations: [ { role: "reinforced", memory: memoryEvidenceRef(reinforced) }, { role: "reinforced_by", memory: memoryEvidenceRef(dropped) }, ], reasonCodes, details: { memoryId: reinforced.id, droppedMemoryId: dropped.id, reinforcementOutcome: decision.reinforcementMode === "refresh_only" ? "refreshed" : "reinforced", reinforcementMode: decision.reinforcementMode, ...reinforcementDecisionTimingDetails(decision), previousReinforcementCount: decision.previousReinforcementCount, newReinforcementCount: decision.newReinforcementCount, reinforcementCount: decision.newReinforcementCount, maxReinforcementCount: REINFORCEMENT_MAX_COUNT, }, textPreview: reinforced.text, }; } function reinforcementDecisionTimingDetails(decision: ReinforcementDecision): EvidenceEventInput["details"] { return { attemptedAtMs: decision.attemptedAt, attemptedAtIso: new Date(decision.attemptedAt).toISOString(), ...(decision.lastReinforcedAt !== undefined ? { lastReinforcedAtMs: decision.lastReinforcedAt, lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(), } : {}), ...(decision.elapsedMs !== undefined ? { elapsedMs: decision.elapsedMs } : {}), requiredElapsedMs: decision.requiredElapsedMs, sameSession: decision.sameSession, ...(decision.legacyMissingTimestamp ? { legacyMissingTimestamp: true } : {}), }; } function reinforcementSessionId(retained: LongTermMemoryEntry, dropped: LongTermMemoryEntry): string { return dropped.pendingOwnerSessionID ?? retained.pendingOwnerSessionID ?? "workspace-dedupe"; } function compareLongTermMemoryForRetention( a: LongTermMemoryEntry, b: LongTermMemoryEntry, now: number, lastActivityAt?: string, ): number { const strengthA = calculateRetentionStrength(a, now, lastActivityAt); const strengthB = calculateRetentionStrength(b, now, lastActivityAt); if (strengthB !== strengthA) return strengthB - strengthA; const sourceDiff = sourcePriority(b.source) - sourcePriority(a.source); if (sourceDiff !== 0) return sourceDiff; const createdDiff = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); if (createdDiff !== 0) return createdDiff; return a.id.localeCompare(b.id); } export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string { return accountWorkspaceMemoryRender(store).prompt; } function selectWorkspaceMemoryForRender(store: WorkspaceMemoryStore): WorkspaceMemoryRenderSelection { const now = Date.now(); const maxChars = Math.min( store.limits.maxRenderedChars, LONG_TERM_LIMITS.maxRenderedChars ); const omitted: WorkspaceMemoryRenderAccounting["omitted"] = []; for (const entry of store.entries) { if (entry.status === "superseded") { omitted.push({ memory: entry, reason: "superseded" }); } } const activeEntries = store.entries.filter(entry => entry.status !== "superseded"); const phase1 = activeEntries.map(entry => ({ ...entry, text: entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars) })); const dedupeResult = dedupeLongTermEntriesWithAccounting(phase1); const sorted = [...dedupeResult.kept].sort((a, b) => compareLongTermMemoryForRetention(a, b, now, store.lastActivityAt)); const typeCapResult = applyTypeMaxCapsWithOmissions(sorted); for (const memory of typeCapResult.omitted) omitted.push({ memory, reason: "type_cap" }); const active = typeCapResult.kept.slice(0, LONG_TERM_LIMITS.maxEntries); for (const memory of typeCapResult.kept.slice(LONG_TERM_LIMITS.maxEntries)) omitted.push({ memory, reason: "global_cap" }); return { active, omitted, maxChars }; } export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): WorkspaceMemoryRenderAccounting { const { active, omitted, maxChars } = selectWorkspaceMemoryForRender(store); const evidence: EvidenceEventInput[] = []; if (active.length === 0) { for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason)); return { rendered: [], omitted, evidence, prompt: "" }; } // If maxChars smaller than minimum envelope, return empty string if (maxChars < MIN_ENVELOPE_LENGTH) { for (const memory of active) omitted.push({ memory, reason: "empty_render_budget" }); for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason)); return { rendered: [], omitted, evidence, prompt: "" }; } const lines: string[] = [ "Workspace memory (cross-session, verify if stale):", ]; const rendered: LongTermMemoryEntry[] = []; for (const type of MEMORY_TYPE_ORDER) { const items = active.filter(entry => entry.type === type); if (items.length === 0) continue; const sectionLines: string[] = [`${type}:`]; for (const item of items) { const line = `- ${renderEntry(item)}`; if ([...lines, ...sectionLines, line].join("\n").length <= maxChars) { sectionLines.push(line); rendered.push(item); } else { omitted.push({ memory: item, reason: "char_budget" }); } } if (sectionLines.length > 1) { lines.push(...sectionLines); } } for (const memory of rendered) evidence.push(renderEvidence(memory, "rendered")); for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason)); return { rendered, omitted, evidence, prompt: lines.join("\n") }; } export function accountWorkspaceMemoryCompactionRefs(store: WorkspaceMemoryStore): WorkspaceMemoryCompactionRefsAccounting { const { active, omitted, maxChars } = selectWorkspaceMemoryForRender(store); const evidence: EvidenceEventInput[] = []; const originalById = new Map(store.entries.map(entry => [entry.id, entry])); if (active.length === 0) { for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason)); return { rendered: [], omitted, evidence, prompt: "", refs: [] }; } const lines: string[] = [ "Existing workspace memories available for consolidation:", ]; const rendered: LongTermMemoryEntry[] = []; const refs: CompactionMemoryRef[] = []; const capturedAt = Date.now(); for (const type of MEMORY_TYPE_ORDER) { const items = active.filter(entry => entry.type === type); if (items.length === 0) continue; const sectionLines: string[] = [`${type}:`]; for (const item of items) { const ref = `M${refs.length + 1}`; const line = `[${ref}] ${item.text}`; if ([...lines, ...sectionLines, line].join("\n").length <= maxChars) { const original = originalById.get(item.id) ?? item; sectionLines.push(line); rendered.push(item); refs.push({ ref, memoryId: item.id, type: original.type, source: original.source, exactKey: workspaceMemoryExactKey(original), identityKey: workspaceMemoryIdentityKey(original), textPreview: item.text, capturedAt, }); } else { omitted.push({ memory: item, reason: "char_budget" }); } } if (sectionLines.length > 1) { lines.push(...sectionLines); } } for (const memory of rendered) evidence.push(renderEvidence(memory, "rendered")); for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason)); return { rendered, omitted, evidence, prompt: rendered.length > 0 ? lines.join("\n") : "", refs, }; } function renderEvidence( memory: LongTermMemoryEntry, outcome: "rendered" | "omitted", reason?: WorkspaceMemoryRenderAccounting["omitted"][number]["reason"], ): EvidenceEventInput { return { type: outcome === "rendered" ? "render_selected" : "render_omitted", phase: "render", outcome, memory: memoryEvidenceRef(memory), relations: [{ role: outcome === "rendered" ? "rendered" : "omitted", memory: memoryEvidenceRef(memory) }], reasonCodes: outcome === "rendered" ? ["within_caps", "within_char_budget"] : [reason ?? "char_budget"], textPreview: memory.text, }; } function renderEntry(entry: LongTermMemoryEntry): string { const ageDays = Math.floor((Date.now() - new Date(entry.createdAt).getTime()) / 86_400_000); const stale = entry.staleAfterDays && ageDays > entry.staleAfterDays ? ` [${ageDays}d old, verify]` : ""; const rationale = entry.rationale ? ` Why: ${entry.rationale.slice(0, LONG_TERM_LIMITS.maxRationaleChars)}` : ""; return `${entry.text}${rationale}${stale}`; }