feat: add consolidation accounting for workspace memory promotion

P0 implementation with four waves:

Wave 1: Dedup with accounting
- Add dedupeLongTermEntriesWithAccounting()
- Classify exact duplicate, identity duplicate, topic duplicate

Wave 2: Normalization with accounting
- Add normalizeWorkspaceMemoryWithAccounting()
- Chain redaction → migration → enforceLongTermLimitsWithAccounting

Wave 3: Promotion accounting integration
- Update accountPendingPromotions() to use new accounting API
- Add supersededKeys to classification
- Distinguish promoted / absorbed / superseded / rejected

Wave 4: Integration tests
- End-to-end tests covering full pipeline

Bug fixes:
- Fix active vs superseded boundary (superseded entries no longer block promotion)
- Remove unused rejected_duplicate_lower_quality type
- Defer pending journal safety cap (TODO added)

Tests: 135 passing (up from 115)
This commit is contained in:
Ralph Chang
2026-04-27 16:45:55 +08:00
parent 1c748f3ee2
commit 3cc6dff7ae
9 changed files with 900 additions and 56 deletions
+243 -15
View File
@@ -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<string, Function>)["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<string, Function>)["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<string, Function>)["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<string, Function>)["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<string, Function>)["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<string, Function>)["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<string, Function>)["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<string, Function>)["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<string, Function>)["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 });
}
+120
View File
@@ -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])]);
});
+240
View File
@@ -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
// ============================================