diff --git a/src/plugin.ts b/src/plugin.ts index 6b0462f..7e1137e 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -36,6 +36,7 @@ import { updateWorkspaceMemory, updateWorkspaceMemoryWithAccounting, renderWorkspaceMemory, + reinforceMemory, } from "./workspace-memory.ts"; import { appendPendingMemories, @@ -311,21 +312,35 @@ export const MemoryV2Plugin: Plugin = async (input) => { const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => { beforeEntries = [...workspaceMemory.entries]; - const existingKeys = new Set( - workspaceMemory.entries - .filter(memory => memory.status !== "superseded") - .map(memory => memoryKey(memory)), - ); + const existingByKey = new Map(); + workspaceMemory.entries.forEach((memory, index) => { + if (memory.status === "superseded") return; + existingByKey.set(memoryKey(memory), { memory, index }); + }); const promotedAt = Date.now(); for (const memory of pending) { const key = memoryKey(memory); - if (!existingKeys.has(key)) { + const existing = existingByKey.get(key); + if (existing) { + const reinforced = reinforceMemory( + existing.memory, + sessionID ?? memory.pendingOwnerSessionID ?? "workspace-promotion", + promotedAt, + ); + if (reinforced !== existing.memory) { + workspaceMemory.entries[existing.index] = reinforced; + existingByKey.set(key, { memory: reinforced, index: existing.index }); + } + } else { workspaceMemory.entries.push({ ...memory, retentionClock: memory.retentionClock ?? promotedAt, }); - existingKeys.add(key); + existingByKey.set(key, { + memory: workspaceMemory.entries[workspaceMemory.entries.length - 1], + index: workspaceMemory.entries.length - 1, + }); } } diff --git a/src/promotion-accounting.ts b/src/promotion-accounting.ts index bf2438a..462b740 100644 --- a/src/promotion-accounting.ts +++ b/src/promotion-accounting.ts @@ -59,7 +59,7 @@ export function accountPendingPromotions(input: { continue; } - if (terminal.reason === "rejected_capacity" || terminal.reason === "rejected_stale") { + if (terminal.reason === "rejected_capacity") { rejectedKeys.add(key); continue; } @@ -80,10 +80,7 @@ export function accountPendingPromotions(input: { ...input.pending .filter(memory => { const terminal = terminalEventByKey.get(memoryKey(memory)); - return memory.source === "compaction" && ( - terminal?.reason === "rejected_capacity" || - terminal?.reason === "rejected_stale" - ); + return memory.source === "compaction" && terminal?.reason === "rejected_capacity"; }) .map(memory => memoryKey(memory)), ]); diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index 591cd7b..8437580 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -19,6 +19,7 @@ const REINFORCEMENT_MAX_COUNT = 6; const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour const WORKSPACE_DORMANT_AFTER_DAYS = 14; const DORMANT_DECAY_MULTIPLIER = 0.25; +const DAY_MS = 24 * 60 * 60 * 1000; const TYPE_FACTOR = { reference: 1.0, @@ -69,7 +70,7 @@ export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number export function calculateRetentionStrength( memory: LongTermMemoryEntry, now: number, - dormantDays: number, + lastActivityAt?: string, ): number { const initialStrength = calculateInitialStrength(memory); const effectiveHalfLife = calculateEffectiveHalfLife(memory); @@ -77,15 +78,7 @@ export function calculateRetentionStrength( // Use retentionClock if available, fallback to updatedAt. const retentionStart = memory.retentionClock ?? memory.updatedAt; const createdAtMs = new Date(retentionStart).getTime(); - const ageMs = now - createdAtMs; - const ageDays = ageMs / (24 * 60 * 60 * 1000); - - // Apply dormant boost to effective age. - let effectiveAgeDays = ageDays; - if (dormantDays > 0) { - // Dormant workspace ages faster. - effectiveAgeDays += dormantDays * DORMANT_DECAY_MULTIPLIER; - } + const effectiveAgeDays = calculateEffectiveAgeDays(createdAtMs, now, lastActivityAt); // Calculate strength using exponential decay. const strength = initialStrength * Math.pow(2, -effectiveAgeDays / effectiveHalfLife); @@ -99,8 +92,28 @@ export function calculateDormantDays(store: WorkspaceMemoryStore, now: number): : now; if (!Number.isFinite(lastActivity)) return 0; - const daysSinceActivity = (now - lastActivity) / (24 * 60 * 60 * 1000); - return Math.max(0, daysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS); + const daysSinceActivity = (now - lastActivity) / DAY_MS; + return Math.max(0, daysSinceActivity); +} + +export function calculateEffectiveAgeDays( + entryStartMs: number, + now: number, + lastActivityAt?: string, +): number { + const wallAgeDays = Math.max(0, (now - entryStartMs) / DAY_MS); + + if (!lastActivityAt) return wallAgeDays; + + const lastActivityMs = new Date(lastActivityAt).getTime(); + if (!Number.isFinite(lastActivityMs)) return wallAgeDays; + + const dormantStartMs = lastActivityMs + WORKSPACE_DORMANT_AFTER_DAYS * DAY_MS; + const overlapStartMs = Math.max(entryStartMs, dormantStartMs); + const dormantOverlapDays = Math.max(0, (now - overlapStartMs) / DAY_MS); + const activeDays = wallAgeDays - dormantOverlapDays; + + return activeDays + dormantOverlapDays * DORMANT_DECAY_MULTIPLIER; } export function reinforceMemory( @@ -125,6 +138,7 @@ export function reinforceMemory( reinforcementCount: (memory.reinforcementCount ?? 0) + 1, lastReinforcedAt: now, lastReinforcedSessionID: sessionId, + retentionClock: now, }; } @@ -133,8 +147,7 @@ export type MemoryConsolidationReason = | "absorbed_exact" | "absorbed_identity" | "superseded_existing" - | "rejected_capacity" - | "rejected_stale"; + | "rejected_capacity"; export type MemoryConsolidationEvent = { memoryKey: string; @@ -568,19 +581,6 @@ function consolidationEvent( }; } -/** Check if entry should be pruned by age (for compaction/manual entries only) */ -function isPrunableByAge(entry: LongTermMemoryEntry, now: number): boolean { - // Never prune feedback or explicit entries - if (entry.type === "feedback") return false; - if (entry.source === "explicit") return false; - if (!entry.staleAfterDays) return false; - - const createdAt = new Date(entry.createdAt).getTime(); - const ageDays = (now - createdAt) / 86400000; - const grace = 30; // 30-day grace period - return ageDays > entry.staleAfterDays + grace; -} - /** Choose better memory when identity/topic keys conflict */ function chooseBetterMemory( a: LongTermMemoryEntry, @@ -621,22 +621,18 @@ export function enforceLongTermLimitsWithAccounting( store?: WorkspaceMemoryStore, ): LongTermLimitResult { const now = Date.now(); - const dormantDays = store ? calculateDormantDays(store, now) : 0; - const staleDropped: MemoryConsolidationEvent[] = []; + const lastActivityAt = store?.lastActivityAt; - // Phase 1: filter active, prune by age + // 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; - if (isPrunableByAge(entry, now)) { - staleDropped.push(consolidationEvent(entry, "rejected_stale")); - 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, dormantDays)); + 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)); @@ -646,7 +642,7 @@ export function enforceLongTermLimitsWithAccounting( return { kept, - dropped: [...staleDropped, ...dedupeResult.dropped, ...capacityDropped], + dropped: [...dedupeResult.dropped, ...capacityDropped], absorbed: dedupeResult.absorbed, superseded: dedupeResult.superseded, }; @@ -674,6 +670,7 @@ function applyTypeMaxCaps(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] } export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult { + const now = Date.now(); const absorbed: MemoryConsolidationEvent[] = []; const superseded: MemoryConsolidationEvent[] = []; @@ -694,12 +691,14 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing) ? "absorbed_exact" as const : "absorbed_identity" as const; + const reinforced = reinforceMemory( + retained, + reinforcementSessionId(retained, dropped), + now, + ); - absorbed.push(consolidationEvent(dropped, reason, retained)); - - if (retained === entry) { - entityDeduped.set(key, entry); - } + absorbed.push(consolidationEvent(dropped, reason, reinforced)); + entityDeduped.set(key, reinforced); } } @@ -718,16 +717,19 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing) ? "absorbed_exact" as const : "superseded_existing" as const; + const reinforced = reinforceMemory( + retained, + reinforcementSessionId(retained, dropped), + now, + ); if (reason === "superseded_existing") { - superseded.push(consolidationEvent(dropped, reason, retained)); + superseded.push(consolidationEvent(dropped, reason, reinforced)); } else { - absorbed.push(consolidationEvent(dropped, reason, retained)); + absorbed.push(consolidationEvent(dropped, reason, reinforced)); } - if (retained === entry) { - decisionDeduped.set(key, entry); - } + decisionDeduped.set(key, reinforced); } } @@ -745,14 +747,18 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry }; } +function reinforcementSessionId(retained: LongTermMemoryEntry, dropped: LongTermMemoryEntry): string { + return dropped.pendingOwnerSessionID ?? retained.pendingOwnerSessionID ?? "workspace-dedupe"; +} + function compareLongTermMemoryForRetention( a: LongTermMemoryEntry, b: LongTermMemoryEntry, now: number, - dormantDays: number, + lastActivityAt?: string, ): number { - const strengthA = calculateRetentionStrength(a, now, dormantDays); - const strengthB = calculateRetentionStrength(b, now, dormantDays); + 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); diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 07f8f66..46749cf 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -782,6 +782,64 @@ test("session.compacted promotes pending memories to workspace memory and clears } }); +test("session.compacted reinforces existing exact workspace memory", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + const now = new Date().toISOString(); + const oldRetentionClock = Date.now() - 10 * 24 * 60 * 60 * 1000; + await updateWorkspaceMemory(tmpDir, store => { + store.entries.push({ + id: "existing-memory", + type: "decision", + text: "Use frozen rendered snapshots for cache stability.", + source: "explicit", + confidence: 1, + status: "active", + createdAt: now, + updatedAt: now, + retentionClock: oldRetentionClock, + }); + return store; + }); + + await saveSessionState(tmpDir, { + version: 1, + sessionID: "reinforce-existing-session", + turn: 1, + updatedAt: now, + activeFiles: [], + openErrors: [], + recentDecisions: [], + pendingMemories: [{ + id: "pending-duplicate", + type: "decision", + text: "Use frozen rendered snapshots for cache stability.", + source: "explicit", + confidence: 1, + status: "active", + createdAt: now, + updatedAt: now, + }], + }); + + const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() }); + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "reinforce-existing-session" } }, + }); + + const workspace = await loadWorkspaceMemory(tmpDir); + const existing = workspace.entries.find(entry => entry.id === "existing-memory"); + assert.equal(workspace.entries.filter(entry => /frozen rendered/.test(entry.text)).length, 1); + assert.equal(existing?.reinforcementCount, 1); + assert.equal(existing?.lastReinforcedSessionID, "reinforce-existing-session"); + assert.ok((existing?.retentionClock ?? 0) > oldRetentionClock); + assert.equal((await loadSessionState(tmpDir, "reinforce-existing-session")).pendingMemories.length, 0); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + test("integration: explicit memory flows from user message through pending journal into workspace", async () => { const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); diff --git a/tests/promotion-accounting.test.ts b/tests/promotion-accounting.test.ts index 930cc77..ac308c0 100644 --- a/tests/promotion-accounting.test.ts +++ b/tests/promotion-accounting.test.ts @@ -229,20 +229,3 @@ test("accountPendingPromotions marks manual capacity rejection as retryable", () assert.equal(result.clearableKeys.size, 0); assert.deepEqual([...result.retryableRejectedKeys], [memoryKey(pending[0])]); }); - -test("accountPendingPromotions clears compaction stale rejection from accounting", () => { - const pending = [mem("pending_stale", "Stale compaction reference should be terminal.", { - type: "reference", - source: "compaction", - })]; - - const result = accountPendingPromotions({ - pending, - before: [], - after: [], - events: [event(pending[0], "rejected_stale")], - }); - - assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]); - assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]); -}); diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index f2ecb1d..80b164f 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -23,6 +23,7 @@ import { calculateEffectiveHalfLife, calculateRetentionStrength, calculateDormantDays, + calculateEffectiveAgeDays, reinforceMemory, } from "../src/workspace-memory.ts"; import { redactCredentials } from "../src/redaction.ts"; @@ -286,20 +287,32 @@ test("calculateEffectiveHalfLife clamps reinforcement count at configured maximu ); }); -test("calculateRetentionStrength decays from retentionClock and applies dormant age", () => { +test("calculateRetentionStrength slows decay for dormancy after activity grace", () => { const now = Date.UTC(2026, 3, 29); - const fiveDaysAgo = now - 5 * 24 * 60 * 60 * 1000; + const nineteenDaysAgo = now - 19 * 24 * 60 * 60 * 1000; const memory: LongTermMemoryEntry = { ...entry("retention-clock", "Use TypeScript for plugin code", "reference"), source: "compaction", - retentionClock: fiveDaysAgo, + retentionClock: nineteenDaysAgo, updatedAt: new Date(now).toISOString(), }; - const expectedEffectiveAgeDays = 5 + 4 * 0.25; + // 19 wall-clock days since last activity: 14 active days + 5 dormant days at 25% = 15.25 effective days. + const lastActivityAt = new Date(nineteenDaysAgo).toISOString(); + const expectedEffectiveAgeDays = 14 + 5 * 0.25; const expected = Math.pow(2, -expectedEffectiveAgeDays / 45); - assert.equal(calculateRetentionStrength(memory, now, 4), expected); + assert.equal(calculateRetentionStrength(memory, now, lastActivityAt), expected); +}); + +test("calculateEffectiveAgeDays matches plan worked dormant example", () => { + const now = Date.UTC(2026, 3, 29); + const twentyEightDaysAgo = now - 28 * 24 * 60 * 60 * 1000; + + assert.equal( + calculateEffectiveAgeDays(twentyEightDaysAgo, now, new Date(twentyEightDaysAgo).toISOString()), + 17.5, + ); }); test("calculateRetentionStrength falls back to updatedAt when retentionClock is absent", () => { @@ -311,7 +324,7 @@ test("calculateRetentionStrength falls back to updatedAt when retentionClock is }; const initialStrength = 2.25 * 1.4; - assert.equal(calculateRetentionStrength(memory, now, 0), initialStrength / 2); + assert.equal(calculateRetentionStrength(memory, now), initialStrength / 2); }); test("calculateDormantDays applies fourteen day workspace activity grace", () => { @@ -329,14 +342,14 @@ test("calculateDormantDays applies fourteen day workspace activity grace", () => lastActivityAt: new Date(now - 20 * 24 * 60 * 60 * 1000).toISOString(), }; - assert.equal(calculateDormantDays(activeWithinGrace, now), 0); - assert.equal(calculateDormantDays(dormantPastGrace, now), 6); + assert.equal(calculateDormantDays(activeWithinGrace, now), 13); + assert.equal(calculateDormantDays(dormantPastGrace, now), 20); }); test("normalizeWorkspaceMemoryWithAccounting uses dormant workspace days for strength ordering", async () => { const now = Date.now(); const reinforcedOldReference: LongTermMemoryEntry = { - ...entry("reinforced-old", "Reinforced legacy docs live at https://example.com/legacy", "reference"), + ...entry("reinforced-old", "Project uses the legacy local plugin architecture", "project"), retentionClock: now - 100 * 24 * 60 * 60 * 1000, reinforcementCount: 6, }; @@ -367,6 +380,7 @@ test("reinforceMemory enforces session interval and max guards", () => { assert.equal(reinforced.reinforcementCount, 1); assert.equal(reinforced.lastReinforcedAt, now); assert.equal(reinforced.lastReinforcedSessionID, "session-a"); + assert.equal(reinforced.retentionClock, now); assert.equal(reinforceMemory(reinforced, "session-a", now + 2 * 60 * 60 * 1000), reinforced); assert.equal(reinforceMemory(reinforced, "session-b", now + 30 * 60 * 1000), reinforced); @@ -379,6 +393,30 @@ test("reinforceMemory enforces session interval and max guards", () => { assert.equal(reinforceMemory(atMax, "session-c", now), atMax); }); +test("dedupeLongTermEntriesWithAccounting reinforces absorbed exact duplicates", () => { + const now = Date.now(); + const retained: LongTermMemoryEntry = { + ...entry("retained", "Use pnpm for package management", "decision"), + retentionClock: now - 10 * 24 * 60 * 60 * 1000, + createdAt: new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(), + updatedAt: new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(), + }; + const duplicate: LongTermMemoryEntry = { + ...entry("duplicate", "use pnpm for package management!!!", "decision"), + pendingOwnerSessionID: "reinforce-session", + createdAt: new Date(now).toISOString(), + updatedAt: new Date(now).toISOString(), + }; + + const result = dedupeLongTermEntriesWithAccounting([retained, duplicate]); + + assert.equal(result.kept.length, 1); + assert.equal(result.kept[0].id, "duplicate"); + assert.equal(result.kept[0].reinforcementCount, 1); + assert.equal(result.kept[0].lastReinforcedSessionID, "reinforce-session"); + assert.ok(typeof result.kept[0].retentionClock === "number"); +}); + test("enforceLongTermLimits orders entries by retention strength", () => { const now = Date.now(); const freshFeedback: LongTermMemoryEntry = { @@ -631,7 +669,7 @@ test("normalizeWorkspaceMemoryWithAccounting reports overflow capacity drops", a assert.equal(result.dropped.filter(event => event.reason === "rejected_capacity").length, 1); }); -test("normalizeWorkspaceMemoryWithAccounting reports stale entry removal", async () => { +test("normalizeWorkspaceMemoryWithAccounting retains stale entries for cap competition", async () => { const root = "/repo"; const now = new Date().toISOString(); const stale = agedEntry( @@ -651,10 +689,10 @@ test("normalizeWorkspaceMemoryWithAccounting reports stale entry removal", async const result = await normalizeWorkspaceMemoryWithAccounting(root, store); - assert.equal(result.kept.length, 0); - assert.equal(result.store.entries.length, 0); - assert.deepEqual(result.dropped.map(event => event.reason), ["rejected_stale"]); - assert.equal(result.dropped[0].memory.id, "stale_normalize"); + assert.equal(result.kept.length, 1); + assert.equal(result.store.entries.length, 1); + assert.equal(result.dropped.length, 0); + assert.equal(result.kept[0].id, "stale_normalize"); }); test("updateWorkspaceMemoryWithAccounting emits accounting events for persisted updates", async () => { @@ -809,14 +847,13 @@ test("enforceLongTermLimits feedback: exact canonical duplicates still collapse" assert.equal(feedbackEntries.length, 1, "Exact canonical feedback duplicates should still collapse"); }); -test("enforceLongTermLimits stale: compaction entry older than staleAfterDays+grace is pruned", () => { - // decision with staleAfterDays=45, 76 days old (> 45+30 grace=75) +test("enforceLongTermLimits stale: old compaction entry remains eligible for cap competition", () => { const entries = [ agedEntry("stale", "Compaction output contract changed from XML to HTML comments to avoid Markdown rendering issues", "decision", { daysAgo: 76, staleAfterDays: 45 }), ]; const kept = enforceLongTermLimits(entries); - assert.equal(kept.length, 0, "Stale compaction entry should be pruned"); + assert.equal(kept.length, 1, "Stale compaction entry should decay but not be hard-pruned"); }); test("enforceLongTermLimits stale: explicit entry is retained even if old", () => {