diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index 037f093..1039d83 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -2,10 +2,12 @@ import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts"; import { LONG_TERM_LIMITS } from "./types.ts"; import { workspaceKey, workspaceMemoryPath } from "./paths.ts"; import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts"; +import { assessMemoryQuality } from "./memory-quality.ts"; // Minimum length for workspace_memory envelope: \n...\n const MIN_ENVELOPE_LENGTH = 80; const MIGRATION_ID = "2026-04-26-p0-cleanup"; +const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup"; const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`; @@ -188,6 +190,7 @@ export async function normalizeWorkspaceMemoryWithAccounting( // One-time migration for legacy snapshot violations result = runMigrationP0Cleanup(result, nowIso); + result = runMigrationQualityCleanup(result, nowIso); // P0 accounting only considers active entries. Entries that were already // superseded before this normalization are preserved in storage; entries that @@ -282,7 +285,7 @@ export function runMigrationP0Cleanup( } const entries = store.entries.map(entry => { - if (entry.source === "explicit") return entry; + if (entry.source !== "compaction") return entry; if (entry.type !== "project") return entry; if (isProjectSnapshotViolation(entry.text)) { @@ -304,6 +307,45 @@ export function runMigrationP0Cleanup( }; } +function runMigrationQualityCleanup( + store: WorkspaceMemoryStore, + nowIso: string, +): WorkspaceMemoryStore { + if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) { + return store; + } + + let changed = false; + const entries = store.entries.map(entry => { + if (entry.source !== "compaction") return entry; + if (entry.status === "superseded") return entry; + + const quality = assessMemoryQuality(entry); + if (quality.accepted) return entry; + + changed = true; + const tags = new Set([ + ...(entry.tags ?? []), + "quality_cleanup", + ...quality.reasons.map(reason => `quality:${reason}`), + ]); + + return { + ...entry, + status: "superseded" as const, + updatedAt: nowIso, + tags: [...tags], + }; + }); + + return { + ...store, + entries, + migrations: [...(store.migrations ?? []), QUALITY_CLEANUP_MIGRATION_ID], + updatedAt: changed ? nowIso : store.updatedAt, + }; +} + function sourcePriority(source: LongTermMemoryEntry["source"]): number { if (source === "explicit") return 3; if (source === "manual") return 2; diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index 615f2cc..a906767 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -5,7 +5,7 @@ import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts"; import { LONG_TERM_LIMITS } from "../src/types.ts"; -import { workspaceMemoryPath } from "../src/paths.ts"; +import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts"; import { renderWorkspaceMemory, enforceLongTermLimits, @@ -21,6 +21,7 @@ import { saveWorkspaceMemory, updateWorkspaceMemoryWithAccounting, } from "../src/workspace-memory.ts"; +import { reviewerCurrent28Fixture, expectedAcceptedFixtureIds } from "./fixtures/memory-quality-current-28.ts"; function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry { const now = new Date().toISOString(); @@ -953,6 +954,133 @@ test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs o assert.equal(twice.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.entries.find(e => e.id === "project-snapshot")?.updatedAt); }); +test("quality cleanup migration supersedes low-quality compaction memories from current-28 fixture", async () => { + const root = await mkdtemp(join(tmpdir(), "wm-quality-cleanup-")); + try { + const now = new Date().toISOString(); + await saveWorkspaceMemory(root, { + version: 1, + workspace: { root, key: await workspaceKey(root) }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: reviewerCurrent28Fixture, + migrations: [], + updatedAt: now, + }); + + const loaded = await loadWorkspaceMemory(root); + const activeIds = new Set(loaded.entries.filter(entry => entry.status === "active").map(entry => entry.id)); + const supersededIds = new Set(loaded.entries.filter(entry => entry.status === "superseded").map(entry => entry.id)); + + for (const entry of reviewerCurrent28Fixture) { + if (expectedAcceptedFixtureIds.has(entry.id)) { + assert.equal(activeIds.has(entry.id), true, `${entry.id} should remain active`); + } else { + assert.equal(supersededIds.has(entry.id), true, `${entry.id} should be superseded`); + } + } + + assert.ok(loaded.migrations?.includes("2026-04-28-quality-cleanup")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("quality cleanup migration dedupes tags", async () => { + const root = await mkdtemp(join(tmpdir(), "wm-quality-tags-")); + try { + const now = new Date().toISOString(); + await saveWorkspaceMemory(root, { + version: 1, + workspace: { root, key: await workspaceKey(root) }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [{ + id: "bad_with_tags", + type: "feedback", + text: "Wave 1 completed successfully and all tests passed", + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: now, + updatedAt: now, + tags: ["quality_cleanup", "quality:progress_snapshot"], + }], + migrations: [], + updatedAt: now, + }); + + const loaded = await loadWorkspaceMemory(root); + const tags = loaded.entries[0].tags ?? []; + assert.equal(tags.filter(tag => tag === "quality_cleanup").length, 1); + assert.equal(tags.filter(tag => tag === "quality:progress_snapshot").length, 1); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("quality cleanup migration does not supersede explicit memories", async () => { + const root = await mkdtemp(join(tmpdir(), "wm-quality-explicit-")); + try { + const now = new Date().toISOString(); + const explicitBadShape = { + id: "explicit_progress_like", + type: "feedback" as const, + text: "Wave 1 completed successfully and all tests passed", + source: "explicit" as const, + confidence: 1, + status: "active" as const, + createdAt: now, + updatedAt: now, + }; + + await saveWorkspaceMemory(root, { + version: 1, + workspace: { root, key: await workspaceKey(root) }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [explicitBadShape], + migrations: [], + updatedAt: now, + }); + + const loaded = await loadWorkspaceMemory(root); + assert.equal(loaded.entries[0].status, "active"); + assert.equal(loaded.entries[0].source, "explicit"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("quality cleanup migration does not supersede manual memories", async () => { + const root = await mkdtemp(join(tmpdir(), "wm-quality-manual-")); + try { + const now = new Date().toISOString(); + const manualBadShape = { + id: "manual_progress_like", + type: "feedback" as const, + text: "Wave 1 completed successfully and all tests passed", + source: "manual" as const, + confidence: 0.9, + status: "active" as const, + createdAt: now, + updatedAt: now, + }; + + await saveWorkspaceMemory(root, { + version: 1, + workspace: { root, key: await workspaceKey(root) }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [manualBadShape], + migrations: [], + updatedAt: now, + }); + + const loaded = await loadWorkspaceMemory(root); + assert.equal(loaded.entries[0].status, "active"); + assert.equal(loaded.entries[0].source, "manual"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("renderWorkspaceMemory excludes superseded entries", () => { const now = new Date().toISOString(); const store: WorkspaceMemoryStore = {