From 7de10c5808e7a13f99c5a485565fec28e3386bf3 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Tue, 28 Apr 2026 14:17:17 +0800 Subject: [PATCH] feat(memory): add local quality cleanup audit logs --- src/extractors.ts | 32 ++++++++++++++++- src/paths.ts | 8 +++++ src/workspace-memory.ts | 65 +++++++++++++++++++++++++++++----- tests/extractors.test.ts | 49 +++++++++++++++++++++++-- tests/workspace-memory.test.ts | 47 ++++++++++++++++++++++++ 5 files changed, 189 insertions(+), 12 deletions(-) diff --git a/src/extractors.ts b/src/extractors.ts index d06babd..1fd4514 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -1,7 +1,10 @@ import { createHash } from "crypto"; +import { appendFile, mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; import type { ActiveFile, LongTermMemoryEntry, LongTermType, OpenError } from "./types.ts"; import { LONG_TERM_LIMITS } from "./types.ts"; import { assessMemoryQuality } from "./memory-quality.ts"; +import { extractionRejectionLogPath } from "./paths.ts"; function id(prefix: string): string { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; @@ -227,6 +230,24 @@ function extractFirstPath(text: string): string | undefined { * Acceptance gate for workspace memory candidates. * Keeps extraction-specific checks local and delegates memory quality rules to memory-quality.ts. */ +type ExtractionRejectionLogEntry = { + timestamp: string; + type: LongTermType; + text: string; + reasons: string[]; + source: "compaction"; +}; + +async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promise { + try { + const path = extractionRejectionLogPath(); + await mkdir(dirname(path), { recursive: true }); + await appendFile(path, JSON.stringify(entry) + "\n", "utf8"); + } catch (error) { + console.error("[memory] failed to write extraction rejection log:", error); + } +} + function shouldAcceptWorkspaceMemoryCandidate( entry: { type: LongTermType; @@ -253,7 +274,16 @@ function shouldAcceptWorkspaceMemoryCandidate( if (/\b(ignore|instruction|overwrite)\b/i.test(text) && /\b(previous|all|rules|behavior|prompt|system)\b/i.test(text)) return false; const quality = assessMemoryQuality({ type: entry.type, text, source: "compaction" }); - if (!quality.accepted) return false; + if (!quality.accepted) { + void logExtractionRejection({ + timestamp: new Date().toISOString(), + type: entry.type, + text, + reasons: quality.reasons, + source: "compaction", + }); + return false; + } return true; } diff --git a/src/paths.ts b/src/paths.ts index 1d11c29..85c4ae5 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -28,3 +28,11 @@ export async function sessionStatePath(root: string, sessionID: string): Promise const safeSessionID = createHash("sha256").update(sessionID).digest("hex").slice(0, 32); return join(await memoryRoot(root), "sessions", `${safeSessionID}.json`); } + +export function migrationLogPath(migrationId: string): string { + return join(dataHome(), "opencode-working-memory", "migration-logs", `${migrationId}.jsonl`); +} + +export function extractionRejectionLogPath(): string { + return join(dataHome(), "opencode-working-memory", "extraction-rejections.jsonl"); +} diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index 3000d7f..15a978e 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -1,6 +1,8 @@ +import { appendFile, mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts"; import { LONG_TERM_LIMITS } from "./types.ts"; -import { workspaceKey, workspaceMemoryPath } from "./paths.ts"; +import { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts"; import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts"; import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts"; @@ -50,6 +52,21 @@ export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & { events: MemoryConsolidationEvent[]; }; +export type QualityCleanupMigrationLogEntry = { + migrationId: string; + timestamp: string; + workspaceKey: string; + workspaceRoot: string; + entryId: string; + type: LongTermMemoryEntry["type"]; + source: LongTermMemoryEntry["source"]; + text: string; + reasons: string[]; + hardReasons: string[]; + beforeStatus: "active"; + afterStatus: "superseded"; +}; + export async function emptyWorkspaceMemory(root: string): Promise { return { version: 1, @@ -191,7 +208,13 @@ export async function normalizeWorkspaceMemoryWithAccounting( // One-time migrations for legacy/low-quality snapshot violations. // Run quality cleanup first so hard violations receive quality audit tags // before the older P0 project-only cleanup marks progress snapshots. - result = runMigrationQualityCleanup(result, nowIso); + const qualityCleanup = runMigrationQualityCleanup(result, nowIso); + result = qualityCleanup.store; + if (qualityCleanup.events.length > 0) { + await appendQualityCleanupMigrationLog(qualityCleanup.events).catch(error => { + console.error("[memory] failed to write quality cleanup migration log:", error); + }); + } result = runMigrationP0Cleanup(result, nowIso); // P0 accounting only considers active entries. Entries that were already @@ -287,14 +310,22 @@ export function runMigrationP0Cleanup( }; } +async function appendQualityCleanupMigrationLog(events: QualityCleanupMigrationLogEntry[]): Promise { + if (events.length === 0) return; + const path = migrationLogPath(QUALITY_CLEANUP_MIGRATION_ID); + await mkdir(dirname(path), { recursive: true }); + await appendFile(path, events.map(event => JSON.stringify(event)).join("\n") + "\n", "utf8"); +} + export function runMigrationQualityCleanup( store: WorkspaceMemoryStore, nowIso: string, -): WorkspaceMemoryStore { +): { store: WorkspaceMemoryStore; events: QualityCleanupMigrationLogEntry[] } { if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) { - return store; + return { store, events: [] }; } + const events: QualityCleanupMigrationLogEntry[] = []; let changed = false; const entries = store.entries.map(entry => { if (entry.source !== "compaction") return entry; @@ -307,6 +338,21 @@ export function runMigrationQualityCleanup( if (hardReasons.length === 0) return entry; changed = true; + events.push({ + migrationId: QUALITY_CLEANUP_MIGRATION_ID, + timestamp: nowIso, + workspaceKey: store.workspace.key, + workspaceRoot: store.workspace.root, + entryId: entry.id, + type: entry.type, + source: entry.source, + text: entry.text, + reasons: quality.reasons, + hardReasons, + beforeStatus: "active", + afterStatus: "superseded", + }); + const tags = new Set([ ...(entry.tags ?? []), "quality_cleanup", @@ -322,10 +368,13 @@ export function runMigrationQualityCleanup( }); return { - ...store, - entries, - migrations: [...(store.migrations ?? []), QUALITY_CLEANUP_MIGRATION_ID], - updatedAt: changed ? nowIso : store.updatedAt, + store: { + ...store, + entries, + migrations: [...(store.migrations ?? []), QUALITY_CLEANUP_MIGRATION_ID], + updatedAt: changed ? nowIso : store.updatedAt, + }, + events, }; } diff --git a/tests/extractors.test.ts b/tests/extractors.test.ts index a348f1f..6c53563 100644 --- a/tests/extractors.test.ts +++ b/tests/extractors.test.ts @@ -1,6 +1,22 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { extractErrorsFromBash, extractExplicitMemories } from "../src/extractors.ts"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { extractErrorsFromBash, extractExplicitMemories, parseWorkspaceMemoryCandidates } from "../src/extractors.ts"; + +async function waitForFile(path: string, attempts = 20): Promise { + let lastError: unknown; + for (let i = 0; i < attempts; i += 1) { + try { + return await readFile(path, "utf8"); + } catch (error) { + lastError = error; + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + throw lastError; +} // ============================================ // Task 1: extractErrorsFromBash tests @@ -129,8 +145,6 @@ test("extractExplicitMemories captures multiple memories in same message", () => // Task 7: Compaction quality gate tests // ============================================ -import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts"; - test("parseWorkspaceMemoryCandidates rejects short text", () => { const summary = ` ## Memory Candidates @@ -281,6 +295,35 @@ Memory candidates: assert.equal(items.length, 0, "Exact test counts are session snapshots, not durable memory"); }); +test("parseWorkspaceMemoryCandidates logs quality gate rejections locally", async () => { + const dataHome = await mkdtemp(join(tmpdir(), "wm-extraction-reject-data-")); + const previousXdgDataHome = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = dataHome; + + try { + const summary = ` +Memory candidates: +- feedback Wave 1 completed successfully and all tests passed +`; + + const items = parseWorkspaceMemoryCandidates(summary); + + assert.equal(items.length, 0); + const logPath = join(dataHome, "opencode-working-memory", "extraction-rejections.jsonl"); + const lines = (await waitForFile(logPath)).trim().split("\n"); + assert.equal(lines.length, 1); + const event = JSON.parse(lines[0]); + assert.equal(event.type, "feedback"); + assert.equal(event.text, "Wave 1 completed successfully and all tests passed"); + assert.deepEqual(event.reasons, ["progress_snapshot", "bad_feedback"]); + assert.equal(event.source, "compaction"); + } finally { + if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = previousXdgDataHome; + await rm(dataHome, { recursive: true, force: true }); + } +}); + test("parseWorkspaceMemoryCandidates rejects exact file count snapshots", () => { const summary = ` Memory candidates: diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index 04ac928..266ed8a 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -1031,6 +1031,53 @@ test("quality cleanup migration supersedes hard quality violations", async () => } }); +test("quality cleanup migration writes audit log for hard supersedes", async () => { + const root = await mkdtemp(join(tmpdir(), "wm-quality-audit-root-")); + const dataHome = await mkdtemp(join(tmpdir(), "wm-quality-audit-data-")); + const previousXdgDataHome = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = dataHome; + + 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: "hard_progress", + type: "project", + text: "測試套件:1237 tests pass, 226 suites", + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: now, + updatedAt: now, + staleAfterDays: 60, + }], + migrations: [], + updatedAt: now, + }); + + await loadWorkspaceMemory(root); + + const logPath = join(dataHome, "opencode-working-memory", "migration-logs", "2026-04-28-quality-cleanup.jsonl"); + const lines = (await readFile(logPath, "utf8")).trim().split("\n"); + assert.equal(lines.length, 1); + const event = JSON.parse(lines[0]); + assert.equal(event.migrationId, "2026-04-28-quality-cleanup"); + assert.equal(event.entryId, "hard_progress"); + assert.deepEqual(event.hardReasons, ["progress_snapshot"]); + assert.equal(event.beforeStatus, "active"); + assert.equal(event.afterStatus, "superseded"); + assert.equal(event.text, "測試套件:1237 tests pass, 226 suites"); + } finally { + if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = previousXdgDataHome; + await rm(root, { recursive: true, force: true }); + await rm(dataHome, { recursive: true, force: true }); + } +}); + test("quality cleanup migration supersedes only hard violations from current fixture", async () => { const root = await mkdtemp(join(tmpdir(), "wm-quality-cleanup-")); try {