Skip to content

Commit 6a80f4b

Browse files
committed
fix: auto-supersede low-quality compaction memories
1 parent b21347c commit 6a80f4b

2 files changed

Lines changed: 172 additions & 2 deletions

File tree

src/workspace-memory.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
22
import { LONG_TERM_LIMITS } from "./types.ts";
33
import { workspaceKey, workspaceMemoryPath } from "./paths.ts";
44
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
5+
import { assessMemoryQuality } from "./memory-quality.ts";
56

67
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
78
const MIN_ENVELOPE_LENGTH = 80;
89
const MIGRATION_ID = "2026-04-26-p0-cleanup";
10+
const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup";
911

1012
const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`;
1113

@@ -188,6 +190,7 @@ export async function normalizeWorkspaceMemoryWithAccounting(
188190

189191
// One-time migration for legacy snapshot violations
190192
result = runMigrationP0Cleanup(result, nowIso);
193+
result = runMigrationQualityCleanup(result, nowIso);
191194

192195
// P0 accounting only considers active entries. Entries that were already
193196
// superseded before this normalization are preserved in storage; entries that
@@ -282,7 +285,7 @@ export function runMigrationP0Cleanup(
282285
}
283286

284287
const entries = store.entries.map(entry => {
285-
if (entry.source === "explicit") return entry;
288+
if (entry.source !== "compaction") return entry;
286289
if (entry.type !== "project") return entry;
287290

288291
if (isProjectSnapshotViolation(entry.text)) {
@@ -304,6 +307,45 @@ export function runMigrationP0Cleanup(
304307
};
305308
}
306309

310+
function runMigrationQualityCleanup(
311+
store: WorkspaceMemoryStore,
312+
nowIso: string,
313+
): WorkspaceMemoryStore {
314+
if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) {
315+
return store;
316+
}
317+
318+
let changed = false;
319+
const entries = store.entries.map(entry => {
320+
if (entry.source !== "compaction") return entry;
321+
if (entry.status === "superseded") return entry;
322+
323+
const quality = assessMemoryQuality(entry);
324+
if (quality.accepted) return entry;
325+
326+
changed = true;
327+
const tags = new Set([
328+
...(entry.tags ?? []),
329+
"quality_cleanup",
330+
...quality.reasons.map(reason => `quality:${reason}`),
331+
]);
332+
333+
return {
334+
...entry,
335+
status: "superseded" as const,
336+
updatedAt: nowIso,
337+
tags: [...tags],
338+
};
339+
});
340+
341+
return {
342+
...store,
343+
entries,
344+
migrations: [...(store.migrations ?? []), QUALITY_CLEANUP_MIGRATION_ID],
345+
updatedAt: changed ? nowIso : store.updatedAt,
346+
};
347+
}
348+
307349
function sourcePriority(source: LongTermMemoryEntry["source"]): number {
308350
if (source === "explicit") return 3;
309351
if (source === "manual") return 2;

tests/workspace-memory.test.ts

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { join, dirname } from "node:path";
55
import { tmpdir } from "node:os";
66
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
77
import { LONG_TERM_LIMITS } from "../src/types.ts";
8-
import { workspaceMemoryPath } from "../src/paths.ts";
8+
import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts";
99
import {
1010
renderWorkspaceMemory,
1111
enforceLongTermLimits,
@@ -21,6 +21,7 @@ import {
2121
saveWorkspaceMemory,
2222
updateWorkspaceMemoryWithAccounting,
2323
} from "../src/workspace-memory.ts";
24+
import { reviewerCurrent28Fixture, expectedAcceptedFixtureIds } from "./fixtures/memory-quality-current-28.ts";
2425

2526
function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry {
2627
const now = new Date().toISOString();
@@ -953,6 +954,133 @@ test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs o
953954
assert.equal(twice.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.entries.find(e => e.id === "project-snapshot")?.updatedAt);
954955
});
955956

957+
test("quality cleanup migration supersedes low-quality compaction memories from current-28 fixture", async () => {
958+
const root = await mkdtemp(join(tmpdir(), "wm-quality-cleanup-"));
959+
try {
960+
const now = new Date().toISOString();
961+
await saveWorkspaceMemory(root, {
962+
version: 1,
963+
workspace: { root, key: await workspaceKey(root) },
964+
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
965+
entries: reviewerCurrent28Fixture,
966+
migrations: [],
967+
updatedAt: now,
968+
});
969+
970+
const loaded = await loadWorkspaceMemory(root);
971+
const activeIds = new Set(loaded.entries.filter(entry => entry.status === "active").map(entry => entry.id));
972+
const supersededIds = new Set(loaded.entries.filter(entry => entry.status === "superseded").map(entry => entry.id));
973+
974+
for (const entry of reviewerCurrent28Fixture) {
975+
if (expectedAcceptedFixtureIds.has(entry.id)) {
976+
assert.equal(activeIds.has(entry.id), true, `${entry.id} should remain active`);
977+
} else {
978+
assert.equal(supersededIds.has(entry.id), true, `${entry.id} should be superseded`);
979+
}
980+
}
981+
982+
assert.ok(loaded.migrations?.includes("2026-04-28-quality-cleanup"));
983+
} finally {
984+
await rm(root, { recursive: true, force: true });
985+
}
986+
});
987+
988+
test("quality cleanup migration dedupes tags", async () => {
989+
const root = await mkdtemp(join(tmpdir(), "wm-quality-tags-"));
990+
try {
991+
const now = new Date().toISOString();
992+
await saveWorkspaceMemory(root, {
993+
version: 1,
994+
workspace: { root, key: await workspaceKey(root) },
995+
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
996+
entries: [{
997+
id: "bad_with_tags",
998+
type: "feedback",
999+
text: "Wave 1 completed successfully and all tests passed",
1000+
source: "compaction",
1001+
confidence: 0.75,
1002+
status: "active",
1003+
createdAt: now,
1004+
updatedAt: now,
1005+
tags: ["quality_cleanup", "quality:progress_snapshot"],
1006+
}],
1007+
migrations: [],
1008+
updatedAt: now,
1009+
});
1010+
1011+
const loaded = await loadWorkspaceMemory(root);
1012+
const tags = loaded.entries[0].tags ?? [];
1013+
assert.equal(tags.filter(tag => tag === "quality_cleanup").length, 1);
1014+
assert.equal(tags.filter(tag => tag === "quality:progress_snapshot").length, 1);
1015+
} finally {
1016+
await rm(root, { recursive: true, force: true });
1017+
}
1018+
});
1019+
1020+
test("quality cleanup migration does not supersede explicit memories", async () => {
1021+
const root = await mkdtemp(join(tmpdir(), "wm-quality-explicit-"));
1022+
try {
1023+
const now = new Date().toISOString();
1024+
const explicitBadShape = {
1025+
id: "explicit_progress_like",
1026+
type: "feedback" as const,
1027+
text: "Wave 1 completed successfully and all tests passed",
1028+
source: "explicit" as const,
1029+
confidence: 1,
1030+
status: "active" as const,
1031+
createdAt: now,
1032+
updatedAt: now,
1033+
};
1034+
1035+
await saveWorkspaceMemory(root, {
1036+
version: 1,
1037+
workspace: { root, key: await workspaceKey(root) },
1038+
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
1039+
entries: [explicitBadShape],
1040+
migrations: [],
1041+
updatedAt: now,
1042+
});
1043+
1044+
const loaded = await loadWorkspaceMemory(root);
1045+
assert.equal(loaded.entries[0].status, "active");
1046+
assert.equal(loaded.entries[0].source, "explicit");
1047+
} finally {
1048+
await rm(root, { recursive: true, force: true });
1049+
}
1050+
});
1051+
1052+
test("quality cleanup migration does not supersede manual memories", async () => {
1053+
const root = await mkdtemp(join(tmpdir(), "wm-quality-manual-"));
1054+
try {
1055+
const now = new Date().toISOString();
1056+
const manualBadShape = {
1057+
id: "manual_progress_like",
1058+
type: "feedback" as const,
1059+
text: "Wave 1 completed successfully and all tests passed",
1060+
source: "manual" as const,
1061+
confidence: 0.9,
1062+
status: "active" as const,
1063+
createdAt: now,
1064+
updatedAt: now,
1065+
};
1066+
1067+
await saveWorkspaceMemory(root, {
1068+
version: 1,
1069+
workspace: { root, key: await workspaceKey(root) },
1070+
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
1071+
entries: [manualBadShape],
1072+
migrations: [],
1073+
updatedAt: now,
1074+
});
1075+
1076+
const loaded = await loadWorkspaceMemory(root);
1077+
assert.equal(loaded.entries[0].status, "active");
1078+
assert.equal(loaded.entries[0].source, "manual");
1079+
} finally {
1080+
await rm(root, { recursive: true, force: true });
1081+
}
1082+
});
1083+
9561084
test("renderWorkspaceMemory excludes superseded entries", () => {
9571085
const now = new Date().toISOString();
9581086
const store: WorkspaceMemoryStore = {

0 commit comments

Comments
 (0)