diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..afe7539 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.3.0] - 2026-04-27 + +### Added + +- P0 consolidation accounting for workspace memory promotion. +- Accounting-aware deduplication (`dedupeLongTermEntriesWithAccounting`). +- Accounting-aware normalization (`normalizeWorkspaceMemoryWithAccounting`). +- Promotion classification: promoted, absorbed, superseded, rejected. +- Clear terminal low-value compaction candidates after promotion review. + +### Fixed + +- Active vs superseded boundary when promoting pending memories (superseded entries no longer block promotion of same-key active memories). +- Removed unused `rejected_duplicate_lower_quality` type. + +### Changed + +- Deferred pending journal safety cap implementation (see TODO in `src/pending-journal.ts`). +- Clarified superseded accounting semantics: P0 emits events only, does not archive newly superseded records. + +## [1.2.3] - 2026-04-26 + +### Fixed + +- Account for absorbed pending memories in promotion accounting. +- Clarify cache epoch semantics and add regression tests. + +## [1.2.0] - 2026-04-25 + +### Added + +- Memory quality evaluation fixtures (5 accepted, 7 rejected cases). +- Compaction prompt examples for better memory extraction. +- Promotion accounting for pending memories. + +## [1.1.0] - 2026-04-24 + +### Added + +- Workspace memory cache optimization with frozen snapshots. +- Pending journal durability for same-session visibility. +- Credential redaction always-on. + +## [1.0.0] - 2026-04-23 + +### Added + +- Initial release with three-layer memory architecture. +- Hot session state, workspace memory, pending journal. +- Memory extraction from user messages and compaction summaries. \ No newline at end of file diff --git a/package.json b/package.json index bacb7dd..1034e5d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-working-memory", - "version": "1.2.3", + "version": "1.3.0", "description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state", "type": "module", "main": "index.ts", diff --git a/src/pending-journal.ts b/src/pending-journal.ts index 0c5e49a..7455ac7 100644 --- a/src/pending-journal.ts +++ b/src/pending-journal.ts @@ -44,6 +44,10 @@ function normalizeJournal( return workspaceKey(root).then(key => ({ version: 1, workspace: { root, key }, + // TODO(memory-consolidation follow-up): add the deferred pending journal + // safety cap (max entries and old compaction pruning). P0 currently relies + // on promotion accounting to clear terminal compaction candidates without + // changing journal capacity behavior. entries: dedupeByText(Array.isArray(store.entries) ? store.entries : []), updatedAt: new Date().toISOString(), })); diff --git a/src/plugin.ts b/src/plugin.ts index 91c879c..fa21798 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -34,6 +34,7 @@ import { import { loadWorkspaceMemory, updateWorkspaceMemory, + updateWorkspaceMemoryWithAccounting, renderWorkspaceMemory, } from "./workspace-memory.ts"; import { @@ -250,9 +251,13 @@ export const MemoryV2Plugin: Plugin = async (input) => { let beforeEntries: Awaited>["entries"] = []; - const updatedWorkspaceMemory = await updateWorkspaceMemory(directory, workspaceMemory => { + const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => { beforeEntries = [...workspaceMemory.entries]; - const existingKeys = new Set(workspaceMemory.entries.map(memory => memoryKey(memory))); + const existingKeys = new Set( + workspaceMemory.entries + .filter(memory => memory.status !== "superseded") + .map(memory => memoryKey(memory)), + ); for (const memory of pending) { const key = memoryKey(memory); @@ -268,7 +273,8 @@ export const MemoryV2Plugin: Plugin = async (input) => { const accounting = accountPendingPromotions({ pending, before: beforeEntries, - after: updatedWorkspaceMemory.entries, + after: updateResult.store.entries, + events: updateResult.events, }); if (sessionID) { diff --git a/src/promotion-accounting.ts b/src/promotion-accounting.ts index d8d9187..7f65212 100644 --- a/src/promotion-accounting.ts +++ b/src/promotion-accounting.ts @@ -1,10 +1,12 @@ import type { LongTermMemoryEntry } from "./types.ts"; import { memoryKey } from "./pending-journal.ts"; +import type { MemoryConsolidationEvent } from "./workspace-memory.ts"; import { workspaceMemoryIdentityKey } from "./workspace-memory.ts"; export type PendingPromotionAccounting = { promotedKeys: Set; absorbedKeys: Set; + supersededKeys: Set; rejectedKeys: Set; clearableKeys: Set; }; @@ -13,13 +15,18 @@ export function accountPendingPromotions(input: { pending: LongTermMemoryEntry[]; before: LongTermMemoryEntry[]; after: LongTermMemoryEntry[]; + events?: MemoryConsolidationEvent[]; }): PendingPromotionAccounting { - const beforeExactKeys = new Set(input.before.map(entry => memoryKey(entry))); - const afterExactKeys = new Set(input.after.map(entry => memoryKey(entry))); - const afterIdentityKeys = new Set(input.after.map(entry => workspaceMemoryIdentityKey(entry))); + const beforeActive = input.before.filter(entry => entry.status !== "superseded"); + const afterActive = input.after.filter(entry => entry.status !== "superseded"); + const beforeExactKeys = new Set(beforeActive.map(entry => memoryKey(entry))); + const afterExactKeys = new Set(afterActive.map(entry => memoryKey(entry))); + const afterIdentityKeys = new Set(afterActive.map(entry => workspaceMemoryIdentityKey(entry))); + const terminalEventByKey = new Map((input.events ?? []).map(event => [event.memoryKey, event])); const promotedKeys = new Set(); const absorbedKeys = new Set(); + const supersededKeys = new Set(); const rejectedKeys = new Set(); for (const memory of input.pending) { @@ -36,6 +43,27 @@ export function accountPendingPromotions(input: { continue; } + const terminal = terminalEventByKey.get(key); + if (terminal) { + if ( + terminal.reason === "absorbed_exact" || + terminal.reason === "absorbed_identity" + ) { + absorbedKeys.add(key); + continue; + } + + if (terminal.reason === "superseded_existing") { + supersededKeys.add(key); + continue; + } + + if (terminal.reason === "rejected_capacity" || terminal.reason === "rejected_stale") { + rejectedKeys.add(key); + continue; + } + } + if (afterIdentityKeys.has(identityKey)) { absorbedKeys.add(key); continue; @@ -47,7 +75,21 @@ export function accountPendingPromotions(input: { return { promotedKeys, absorbedKeys, + supersededKeys, rejectedKeys, - clearableKeys: new Set([...promotedKeys, ...absorbedKeys]), + clearableKeys: new Set([ + ...promotedKeys, + ...absorbedKeys, + ...supersededKeys, + ...input.pending + .filter(memory => { + const terminal = terminalEventByKey.get(memoryKey(memory)); + return memory.source === "compaction" && ( + terminal?.reason === "rejected_capacity" || + terminal?.reason === "rejected_stale" + ); + }) + .map(memory => memoryKey(memory)), + ]), }; } diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index 86c13b5..bb0d678 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -16,6 +16,35 @@ const PIN_PREFIX = String.raw`(\bPIN\b(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::]) const PASSWORD_PREFIX = String.raw`((?:${PASSWORD_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`; const USERNAME_PREFIX = String.raw`((?:${USERNAME_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`; +export type MemoryConsolidationReason = + | "promoted" + | "absorbed_exact" + | "absorbed_identity" + | "superseded_existing" + | "rejected_capacity" + | "rejected_stale"; + +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[]; +}; + +export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & { + store: WorkspaceMemoryStore; + events: MemoryConsolidationEvent[]; +}; + export async function emptyWorkspaceMemory(root: string): Promise { return { version: 1, @@ -83,18 +112,43 @@ export async function updateWorkspaceMemory( root: string, updater: (store: WorkspaceMemoryStore) => WorkspaceMemoryStore | Promise, ): Promise { - const path = await workspaceMemoryPath(root); - const fallback = await emptyWorkspaceMemory(root); - return updateJSON(path, () => fallback, async current => { - const normalized = await normalizeWorkspaceMemory(root, current); - return normalizeWorkspaceMemory(root, await updater(normalized)); - }); + return (await updateWorkspaceMemoryWithAccounting(root, updater)).store; } -async function normalizeWorkspaceMemory( +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 normalized = await normalizeWorkspaceMemory(root, current); + finalResult = await normalizeWorkspaceMemoryWithAccounting(root, await updater(normalized)); + return finalResult.store; + }); + + return finalResult ?? { + store, + kept: store.entries.filter(entry => entry.status !== "superseded"), + dropped: [], + absorbed: [], + superseded: [], + 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 nowIso = new Date().toISOString(); let result: WorkspaceMemoryStore = { @@ -129,15 +183,27 @@ async function normalizeWorkspaceMemory( // One-time migration for legacy snapshot violations result = runMigrationP0Cleanup(result, nowIso); - // Enforce limits on active entries while preserving superseded entries in storage + // 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 processedActive = enforceLongTermLimits(activeEntries); + const accounting = enforceLongTermLimitsWithAccounting(activeEntries); + + const normalizedStore = { + ...result, + entries: [...accounting.kept, ...supersededEntries], + updatedAt: nowIso, + }; return { - ...result, - entries: [...processedActive, ...supersededEntries], - updatedAt: nowIso, + store: normalizedStore, + kept: accounting.kept, + dropped: accounting.dropped, + absorbed: accounting.absorbed, + superseded: accounting.superseded, + events: [...accounting.dropped, ...accounting.absorbed, ...accounting.superseded], }; } @@ -235,6 +301,10 @@ function canonicalMemoryText(text: string): string { .trim(); } +export function workspaceMemoryExactKey(entry: Pick): string { + return `${entry.type}:${canonicalMemoryText(entry.text)}`; +} + /** Extract entity/destination keys for project and reference dedup */ function extractEntityKey(text: string): string | null { const normalized = canonicalMemoryText(text); @@ -303,6 +373,21 @@ export function workspaceMemoryIdentityKey(entry: Pick entry.status !== "superseded") - .filter(entry => !isPrunableByAge(entry, now)) - .map(entry => ({ ...entry, text: entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars) })); + 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(compareLongTermMemoryForRetention); + const kept = sorted.slice(0, LONG_TERM_LIMITS.maxEntries); + const keptIds = new Set(kept.map(entry => entry.id)); + const capacityDropped = sorted + .filter(entry => !keptIds.has(entry.id)) + .map(entry => consolidationEvent(entry, "rejected_capacity")); + + return { + kept, + dropped: [...staleDropped, ...dedupeResult.dropped, ...capacityDropped], + absorbed: dedupeResult.absorbed, + superseded: dedupeResult.superseded, + }; +} + +export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult { + const absorbed: MemoryConsolidationEvent[] = []; + const superseded: MemoryConsolidationEvent[] = []; // For project/reference/feedback: detect entity keys FIRST, then dedupe by entity OR canonical - const projectRefEntries = phase1.filter(e => e.type === "project" || e.type === "reference" || e.type === "feedback"); + const projectRefEntries = entries.filter(e => e.type === "project" || e.type === "reference" || e.type === "feedback"); // Build entity key dedup for project/reference/feedback const entityDeduped = new Map(); for (const entry of projectRefEntries) { const key = workspaceMemoryIdentityKey(entry); - const hasTopicIdentity = key !== `${entry.type}:${canonicalMemoryText(entry.text)}`; + const hasTopicIdentity = key !== workspaceMemoryExactKey(entry); const existing = entityDeduped.get(key); if (!existing) { @@ -371,14 +486,28 @@ export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermM } else { // Feedback topic conflicts use supersession mode (newer beats longer) const mode = entry.type === "feedback" && hasTopicIdentity ? "supersession" as const : "entity" as const; - if (chooseBetterMemory(entry, existing, mode) === entry) { + const retained = chooseBetterMemory(entry, existing, mode); + const dropped = retained === entry ? existing : entry; + const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing) + ? "absorbed_exact" as const + : mode === "supersession" + ? "superseded_existing" as const + : "absorbed_identity" as const; + + if (reason === "superseded_existing") { + superseded.push(consolidationEvent(dropped, reason, retained)); + } else { + absorbed.push(consolidationEvent(dropped, reason, retained)); + } + + if (retained === entry) { entityDeduped.set(key, entry); } } } // For decisions: detect topic keys for supersession, or use canonical - const decisionEntries = phase1.filter(e => e.type === "decision"); + const decisionEntries = entries.filter(e => e.type === "decision"); const decisionDeduped = new Map(); for (const entry of decisionEntries) { const key = workspaceMemoryIdentityKey(entry); @@ -386,8 +515,22 @@ export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermM const existing = decisionDeduped.get(key); if (!existing) { decisionDeduped.set(key, entry); - } else if (chooseBetterMemory(entry, existing, "supersession") === entry) { - 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; + + if (reason === "superseded_existing") { + superseded.push(consolidationEvent(dropped, reason, retained)); + } else { + absorbed.push(consolidationEvent(dropped, reason, retained)); + } + + if (retained === entry) { + decisionDeduped.set(key, entry); + } } } @@ -397,17 +540,21 @@ export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermM phaseFinal.set(entry.id, entry); } - // Phase 6: sort and trim - return [...phaseFinal.values()] - .sort((a, b) => { - const pA = priorityWithFreshness(a); - const pB = priorityWithFreshness(b); - if (pB !== pA) return pB - pA; - const sourceDiff = sourcePriority(b.source) - sourcePriority(a.source); - if (sourceDiff !== 0) return sourceDiff; - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - }) - .slice(0, LONG_TERM_LIMITS.maxEntries); + return { + kept: [...phaseFinal.values()], + dropped: [], + absorbed, + superseded, + }; +} + +function compareLongTermMemoryForRetention(a: LongTermMemoryEntry, b: LongTermMemoryEntry): number { + const pA = priorityWithFreshness(a); + const pB = priorityWithFreshness(b); + if (pB !== pA) return pB - pA; + const sourceDiff = sourcePriority(b.source) - sourcePriority(a.source); + if (sourceDiff !== 0) return sourceDiff; + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); } function priority(entry: LongTermMemoryEntry): number { diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index ecd5e68..62574a4 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -9,7 +9,7 @@ import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts"; import type { OpenError } from "../src/types.ts"; import { workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts"; import { loadPendingJournal, savePendingJournal } from "../src/pending-journal.ts"; -import { updateWorkspaceMemory } from "../src/workspace-memory.ts"; +import { loadWorkspaceMemory, updateWorkspaceMemory } from "../src/workspace-memory.ts"; // Mock client for root session (not a sub-agent) function mockRootClient() { @@ -39,6 +39,23 @@ function mockClientWithLatestUser(text: string, messageID: string) { }; } +function mockClientWithCompactionSummary(summary: string) { + return { + session: { + get: async () => ({ data: { parentID: null } }), + messages: async () => ({ + data: [ + { + info: { role: "assistant", summary: true }, + parts: [{ type: "text", text: summary }], + }, + ], + }), + todo: async () => ({ data: [] }), + }, + }; +} + // Helper: create session state with pre-populated open error function createSessionWithError(sessionID: string, error: OpenError) { return { @@ -562,6 +579,106 @@ test("session.compacted promotes pending memories to workspace memory and clears } }); +test("integration: explicit memory flows from user message through pending journal into workspace", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + const plugin = await MemoryV2Plugin({ + directory: tmpDir, + client: mockClientWithLatestUser("remember this: Prefer deterministic consolidation accounting.", "msg-explicit-flow"), + }); + + await (plugin as Record)["experimental.chat.system.transform"]( + { sessionID: "explicit-flow-session", model: {} }, + { system: ["base header"] }, + ); + + assert.equal((await loadSessionState(tmpDir, "explicit-flow-session")).pendingMemories.length, 1); + assert.equal((await loadPendingJournal(tmpDir)).entries.length, 1); + + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "explicit-flow-session" } }, + }); + + assert.equal((await loadSessionState(tmpDir, "explicit-flow-session")).pendingMemories.length, 0); + assert.equal((await loadPendingJournal(tmpDir)).entries.length, 0); + + const output = { system: ["base header"] }; + await (plugin as Record)["experimental.chat.system.transform"]( + { sessionID: "explicit-flow-session", model: {} }, + output, + ); + + assert.match(output.system.join("\n"), /Prefer deterministic consolidation accounting/); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("integration: compaction candidate flows through journal promotion and clears pending journal", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + const summary = ` +## Goal +Continue durable memory work. + +## Memory Candidates +- [decision] Use accounting events to classify promoted absorbed superseded and rejected memories. +`; + const plugin = await MemoryV2Plugin({ + directory: tmpDir, + client: mockClientWithCompactionSummary(summary), + }); + + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "compaction-flow-session" } }, + }); + + assert.equal((await loadPendingJournal(tmpDir)).entries.length, 0, + "compaction candidate should be promoted and then cleared from pending journal"); + + const output = { system: ["base header"] }; + await (plugin as Record)["experimental.chat.system.transform"]( + { sessionID: "after-compaction-flow", model: {} }, + output, + ); + + assert.match(output.system.join("\n"), /accounting events to classify promoted absorbed superseded and rejected memories/); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("integration: next session promotes prior explicit journal and leaves journal clean", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + const firstPlugin = await MemoryV2Plugin({ + directory: tmpDir, + client: mockClientWithLatestUser("remember this: Cross-session promotion keeps the journal clean.", "msg-cross-session"), + }); + await (firstPlugin as Record)["experimental.chat.system.transform"]( + { sessionID: "first-cross-session", model: {} }, + { system: ["base header"] }, + ); + + assert.equal((await loadPendingJournal(tmpDir)).entries.length, 1); + + const secondPlugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() }); + const output = { system: ["base header"] }; + await (secondPlugin as Record)["experimental.chat.system.transform"]( + { sessionID: "second-cross-session", model: {} }, + output, + ); + + assert.equal((await loadPendingJournal(tmpDir)).entries.length, 0); + assert.match(output.system.join("\n"), /Cross-session promotion keeps the journal clean/); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + test("same-session explicit memory does not mutate frozen system[1]", async () => { const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); @@ -779,6 +896,65 @@ test("session.compacted clears pending memory absorbed by existing workspace dup } }); +test("session.compacted promotes pending memory when exact existing entry is only superseded", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + const now = new Date().toISOString(); + await updateWorkspaceMemory(tmpDir, store => { + store.entries.push({ + id: "mem_superseded_duplicate", + type: "decision", + text: "Revive superseded exact memories when remembered again.", + source: "explicit", + confidence: 1, + status: "superseded", + createdAt: now, + updatedAt: now, + }); + return store; + }); + + await saveSessionState(tmpDir, { + version: 1, + sessionID: "superseded-boundary-session", + turn: 0, + updatedAt: now, + activeFiles: [], + openErrors: [], + recentDecisions: [], + pendingMemories: [{ + id: "mem_pending_revive", + type: "decision", + text: "Revive superseded exact memories when remembered again.", + 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: "superseded-boundary-session" } }, + }); + + const state = await loadSessionState(tmpDir, "superseded-boundary-session"); + assert.equal(state.pendingMemories.length, 0, + "revived pending memory should be cleared after successful promotion"); + + const workspace = await loadWorkspaceMemory(tmpDir); + const activeMatches = workspace.entries.filter(entry => + entry.status === "active" && /Revive superseded exact memories/.test(entry.text) + ); + assert.equal(activeMatches.length, 1, + "pending memory should become active even when only matching prior entry is superseded"); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + test("session.compacted clears pending memory absorbed by existing workspace identity", async () => { const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); @@ -831,7 +1007,7 @@ test("session.compacted clears pending memory absorbed by existing workspace ide } }); -test("session.compacted keeps pending memory rejected by workspace entry cap", async () => { +test("session.compacted clears compaction pending memory rejected by workspace entry cap", async () => { const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); try { @@ -878,15 +1054,69 @@ test("session.compacted keeps pending memory rejected by workspace entry cap", a }); const state = await loadSessionState(tmpDir, "rejected-cap-session"); - assert.equal(state.pendingMemories.length, 1, - "pending memory rejected by workspace cap should remain pending for retry"); - assert.match(state.pendingMemories[0].text, /Low priority reference/); + assert.equal(state.pendingMemories.length, 0, + "compaction pending memory rejected by workspace cap should be terminal and clearable"); } finally { await rm(tmpDir, { recursive: true, force: true }); } }); -test("session.compacted keeps pending memories when all rejected by workspace cap", async () => { +test("session.compacted keeps explicit pending memory rejected by workspace entry cap", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + const now = new Date().toISOString(); + await updateWorkspaceMemory(tmpDir, store => { + for (let i = 0; i < 28; i += 1) { + store.entries.push({ + id: `mem_high_explicit_reject_${i}`, + type: "feedback", + text: `Pinned high priority feedback for explicit reject ${i}.`, + source: "explicit", + confidence: 1, + status: "active", + createdAt: now, + updatedAt: now, + }); + } + return store; + }); + + await saveSessionState(tmpDir, { + version: 1, + sessionID: "explicit-rejected-cap-session", + turn: 0, + updatedAt: now, + activeFiles: [], + openErrors: [], + recentDecisions: [], + pendingMemories: [{ + id: "mem_explicit_low_priority_reference", + type: "reference", + text: "Explicit reference should remain pending when capacity rejected.", + source: "explicit", + confidence: 0.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: "explicit-rejected-cap-session" } }, + }); + + const state = await loadSessionState(tmpDir, "explicit-rejected-cap-session"); + assert.equal(state.pendingMemories.length, 1, + "explicit pending memory rejected by workspace cap should remain pending for retry"); + assert.match(state.pendingMemories[0].text, /Explicit reference/); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("session.compacted clears compaction pending memories when all rejected by workspace cap", async () => { const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); try { @@ -946,19 +1176,18 @@ test("session.compacted keeps pending memories when all rejected by workspace ca }); const state = await loadSessionState(tmpDir, "all-rejected-session"); - assert.equal(state.pendingMemories.length, 1, - "session pending memory must remain when all pending memories are rejected"); + assert.equal(state.pendingMemories.length, 0, + "compaction session pending memory should clear when rejected by capacity accounting"); const pendingAfter = await loadPendingJournal(tmpDir); - assert.equal(pendingAfter.entries.length, 1, - "journal pending memories must not be cleared when accounting.clearableKeys is empty"); - assert.match(pendingAfter.entries[0].text, /another session/); + assert.equal(pendingAfter.entries.length, 0, + "compaction journal pending memories should clear when rejected by capacity accounting"); } finally { await rm(tmpDir, { recursive: true, force: true }); } }); -test("session.compacted keeps rejected journal memories when no workspace entries survive", async () => { +test("session.compacted clears stale rejected compaction journal memories", async () => { const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); try { @@ -983,9 +1212,8 @@ test("session.compacted keeps rejected journal memories when no workspace entrie }); const pendingAfter = await loadPendingJournal(tmpDir); - assert.equal(pendingAfter.entries.length, 1, - "rejected journal memory must not be cleared when accounting.clearableKeys is empty"); - assert.match(pendingAfter.entries[0].text, /Stale journal/); + assert.equal(pendingAfter.entries.length, 0, + "stale rejected compaction journal memory should be terminal and clearable"); } finally { await rm(tmpDir, { recursive: true, force: true }); } diff --git a/tests/promotion-accounting.test.ts b/tests/promotion-accounting.test.ts index 52febda..de055df 100644 --- a/tests/promotion-accounting.test.ts +++ b/tests/promotion-accounting.test.ts @@ -3,6 +3,8 @@ import assert from "node:assert/strict"; import type { LongTermMemoryEntry } from "../src/types.ts"; import { accountPendingPromotions } from "../src/promotion-accounting.ts"; import { memoryKey } from "../src/pending-journal.ts"; +import type { MemoryConsolidationEvent } from "../src/workspace-memory.ts"; +import { workspaceMemoryExactKey, workspaceMemoryIdentityKey } from "../src/workspace-memory.ts"; function mem( id: string, @@ -26,6 +28,18 @@ function mem( }; } +function event( + memory: LongTermMemoryEntry, + reason: MemoryConsolidationEvent["reason"], +): MemoryConsolidationEvent { + return { + memoryKey: workspaceMemoryExactKey(memory), + identityKey: workspaceMemoryIdentityKey(memory), + memory, + reason, + }; +} + test("accountPendingPromotions marks exact retained pending memory as promoted", () => { const pending = [mem("pending", "Use frozen rendered snapshots for cache stability.")]; const before: LongTermMemoryEntry[] = []; @@ -67,6 +81,24 @@ test("accountPendingPromotions marks same exact key present before promotion as assert.equal(result.rejectedKeys.size, 0); }); +test("accountPendingPromotions ignores superseded exact keys when detecting existing absorption", () => { + const superseded = mem("superseded", "Revive this memory when it is remembered again.", { + source: "explicit", + status: "superseded", + }); + const pending = [mem("pending", "Revive this memory when it is remembered again.", { + source: "explicit", + })]; + const before = [superseded]; + const after = [superseded, pending[0]]; + + const result = accountPendingPromotions({ pending, before, after }); + + assert.deepEqual([...result.promotedKeys], [memoryKey(pending[0])]); + assert.equal(result.absorbedKeys.size, 0); + assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]); +}); + test("accountPendingPromotions marks same-topic decision represented after normalization as absorbed", () => { const existing = mem("existing", "Parser supports 2 candidate formats.", { type: "decision", @@ -107,3 +139,91 @@ test("accountPendingPromotions keeps pending memory rejected when no equivalent assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]); assert.equal(result.clearableKeys.size, 0); }); + +test("accountPendingPromotions clears accounting absorbed identity events", () => { + const pending = [mem("pending_identity", "This repo uses opencode-agenthub plugin system", { + type: "project", + source: "compaction", + })]; + + const result = accountPendingPromotions({ + pending, + before: [], + after: [], + events: [event(pending[0], "absorbed_identity")], + }); + + assert.deepEqual([...result.absorbedKeys], [memoryKey(pending[0])]); + assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]); + assert.equal(result.rejectedKeys.size, 0); +}); + +test("accountPendingPromotions separates accounting superseded events", () => { + const pending = [mem("pending_topic", "Parser supports 3 candidate formats.", { + type: "decision", + source: "compaction", + })]; + + const result = accountPendingPromotions({ + pending, + before: [], + after: [], + events: [event(pending[0], "superseded_existing")], + }); + + assert.deepEqual([...result.supersededKeys], [memoryKey(pending[0])]); + assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]); + assert.equal(result.absorbedKeys.size, 0); + assert.equal(result.rejectedKeys.size, 0); +}); + +test("accountPendingPromotions clears compaction capacity rejection from accounting", () => { + const pending = [mem("pending_capacity", "Weak compaction reference that should lose capacity review.", { + type: "reference", + source: "compaction", + })]; + + const result = accountPendingPromotions({ + pending, + before: [], + after: [], + events: [event(pending[0], "rejected_capacity")], + }); + + assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]); + assert.deepEqual([...result.clearableKeys], [memoryKey(pending[0])]); +}); + +test("accountPendingPromotions keeps explicit capacity rejection pending", () => { + const pending = [mem("pending_explicit_capacity", "Explicit reference should retry if capacity rejected.", { + type: "reference", + source: "explicit", + })]; + + const result = accountPendingPromotions({ + pending, + before: [], + after: [], + events: [event(pending[0], "rejected_capacity")], + }); + + assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]); + assert.equal(result.clearableKeys.size, 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 e48931d..882caa5 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -9,11 +9,16 @@ import { workspaceMemoryPath } from "../src/paths.ts"; import { renderWorkspaceMemory, enforceLongTermLimits, + dedupeLongTermEntriesWithAccounting, + enforceLongTermLimitsWithAccounting, + normalizeWorkspaceMemoryWithAccounting, + workspaceMemoryExactKey, redactCredentials, isProjectSnapshotViolation, runMigrationP0Cleanup, loadWorkspaceMemory, saveWorkspaceMemory, + updateWorkspaceMemoryWithAccounting, } from "../src/workspace-memory.ts"; function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry { @@ -243,6 +248,241 @@ test("enforceLongTermLimits respects maxEntries limit", () => { assert.ok(kept.length <= 28, `Should respect maxEntries. Got: ${kept.length}`); }); +test("dedupeLongTermEntriesWithAccounting reports exact duplicates as absorbed", () => { + const now = new Date().toISOString(); + const lower: LongTermMemoryEntry = { + id: "lower", + type: "decision", + text: "OpenCode uses NPM CACHE for plugin loading", + source: "compaction", + confidence: 0.7, + status: "active", + createdAt: now, + updatedAt: now, + }; + const higher: LongTermMemoryEntry = { + id: "higher", + type: "decision", + text: "opencode uses npm cache for plugin loading!!!", + source: "compaction", + confidence: 0.8, + status: "active", + createdAt: now, + updatedAt: now, + }; + + const result = dedupeLongTermEntriesWithAccounting([lower, higher]); + + assert.equal(result.kept.length, 1); + assert.equal(result.kept[0].id, "higher"); + assert.deepEqual(result.absorbed.map(event => event.reason), ["absorbed_exact"]); + assert.equal(result.absorbed[0].memory.id, "lower"); +}); + +test("dedupeLongTermEntriesWithAccounting reports identity duplicates as absorbed", () => { + const older = agedEntry( + "older", + "This repo uses opencode-agenthub plugin system at /Users/sd_wo/work/opencode-working-memory/", + "project", + { daysAgo: 5 }, + ); + const newer = agedEntry( + "newer", + "此 repo 在開發時使用 opencode-agenthub 插件系統,目錄位於 /Users/sd_wo/work/opencode-working-memory/.opencode-agenthub/", + "project", + { daysAgo: 0 }, + ); + + const result = dedupeLongTermEntriesWithAccounting([older, newer]); + + assert.equal(result.kept.length, 1); + assert.equal(result.absorbed.length, 1); + assert.equal(result.absorbed[0].reason, "absorbed_identity"); + assert.equal(result.absorbed[0].retainedId, result.kept[0].id); +}); + +test("dedupeLongTermEntriesWithAccounting reports topic duplicates as superseded", () => { + const older = agedEntry( + "older", + "Parser supports 3 formats: HTML comment, Markdown section, legacy XML", + "decision", + { daysAgo: 5 }, + ); + const newer = agedEntry( + "newer", + "Parser supports 4 formats: plain text label, Markdown section, legacy section name, legacy XML", + "decision", + { daysAgo: 0 }, + ); + + const result = dedupeLongTermEntriesWithAccounting([older, newer]); + + assert.equal(result.kept.length, 1); + assert.equal(result.kept[0].id, "newer"); + assert.equal(result.superseded.length, 1); + assert.equal(result.superseded[0].reason, "superseded_existing"); + assert.equal(result.superseded[0].supersededId, "older"); + assert.equal(result.superseded[0].retainedId, "newer"); +}); + +test("enforceLongTermLimitsWithAccounting reports capacity drops", () => { + const now = new Date().toISOString(); + const entries = Array.from({ length: LONG_TERM_LIMITS.maxEntries + 2 }, (_, i) => ({ + id: `mem_${i}`, + type: "reference" as const, + text: `Unique low priority reference ${i}`, + source: "compaction" as const, + confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1, + status: "active" as const, + createdAt: now, + updatedAt: now, + })); + + const result = enforceLongTermLimitsWithAccounting(entries); + + assert.equal(result.kept.length, LONG_TERM_LIMITS.maxEntries); + assert.equal(result.dropped.filter(event => event.reason === "rejected_capacity").length, 2); + assert.ok(result.dropped.every(event => event.memory.source === "compaction")); +}); + +test("workspaceMemoryExactKey uses pending-compatible canonical semantics", () => { + const now = new Date().toISOString(); + const entry: LongTermMemoryEntry = { + id: "key_alignment", + type: "decision", + text: "OpenCode uses NPM CACHE for plugin loading!!!", + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: now, + updatedAt: now, + }; + + assert.equal(workspaceMemoryExactKey(entry), "decision:opencode uses npm cache for plugin loading"); +}); + +test("normalizeWorkspaceMemoryWithAccounting redacts credentials before accounting", async () => { + const root = "/repo"; + const now = new Date().toISOString(); + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root, key: "abc" }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + migrations: ["2026-04-26-p0-cleanup"], + entries: [{ + id: "cred", + type: "reference", + text: "Admin PIN 是 456123", + rationale: "password: sushi", + source: "explicit", + confidence: 1, + status: "active", + createdAt: now, + updatedAt: now, + }], + updatedAt: now, + }; + + const result = await normalizeWorkspaceMemoryWithAccounting(root, store); + + assert.equal(result.kept.length, 1); + assert.equal(result.kept[0].text, "Admin PIN 是 [REDACTED]"); + assert.equal(result.kept[0].rationale, "password: [REDACTED]"); + assert.equal(result.store.entries[0].text, "Admin PIN 是 [REDACTED]"); +}); + +test("normalizeWorkspaceMemoryWithAccounting reports overflow capacity drops", async () => { + const root = "/repo"; + const now = new Date().toISOString(); + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root, key: "abc" }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + migrations: ["2026-04-26-p0-cleanup"], + entries: Array.from({ length: LONG_TERM_LIMITS.maxEntries + 1 }, (_, i) => ({ + id: `overflow_${i}`, + type: "reference" as const, + text: `Overflow reference ${i}`, + source: "compaction" as const, + confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1, + status: "active" as const, + createdAt: now, + updatedAt: now, + })), + updatedAt: now, + }; + + const result = await normalizeWorkspaceMemoryWithAccounting(root, store); + + assert.equal(result.kept.length, LONG_TERM_LIMITS.maxEntries); + assert.equal(result.store.entries.filter(entry => entry.status === "active").length, LONG_TERM_LIMITS.maxEntries); + assert.equal(result.dropped.filter(event => event.reason === "rejected_capacity").length, 1); +}); + +test("normalizeWorkspaceMemoryWithAccounting reports stale entry removal", async () => { + const root = "/repo"; + const now = new Date().toISOString(); + const stale = agedEntry( + "stale_normalize", + "Old compaction decision should be removed by normalization accounting", + "decision", + { daysAgo: 90, staleAfterDays: 1 }, + ); + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root, key: "abc" }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + migrations: ["2026-04-26-p0-cleanup"], + entries: [stale], + updatedAt: now, + }; + + 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"); +}); + +test("updateWorkspaceMemoryWithAccounting emits accounting events for persisted updates", async () => { + const sandbox = await mkdtemp(join(tmpdir(), "wm-accounting-update-")); + const dataHome = join(sandbox, "xdg-data-home"); + const root = join(sandbox, "workspace"); + const previousXdgDataHome = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = dataHome; + + try { + const now = new Date().toISOString(); + const result = await updateWorkspaceMemoryWithAccounting(root, store => { + store.entries.push(...Array.from({ length: LONG_TERM_LIMITS.maxEntries + 1 }, (_, i) => ({ + id: `persisted_${i}`, + type: "reference" as const, + text: `Persisted accounting reference ${i}`, + source: "compaction" as const, + confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1, + status: "active" as const, + createdAt: now, + updatedAt: now, + }))); + return store; + }); + + assert.equal(result.store.entries.filter(entry => entry.status === "active").length, LONG_TERM_LIMITS.maxEntries); + assert.equal(result.events.filter(event => event.reason === "rejected_capacity").length, 1); + + const persisted = await loadWorkspaceMemory(root); + assert.equal(persisted.entries.filter(entry => entry.status === "active").length, LONG_TERM_LIMITS.maxEntries); + } finally { + if (previousXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = previousXdgDataHome; + } + await rm(sandbox, { recursive: true, force: true }); + } +}); + // ============================================ // P0d: identity-key dedup, supersession, staleness // ============================================