Skip to content

Commit 7427221

Browse files
committed
feat(memory): add local quality cleanup audit logs
1 parent 9991c95 commit 7427221

5 files changed

Lines changed: 189 additions & 12 deletions

File tree

src/extractors.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { createHash } from "crypto";
2+
import { appendFile, mkdir } from "node:fs/promises";
3+
import { dirname } from "node:path";
24
import type { ActiveFile, LongTermMemoryEntry, LongTermType, OpenError } from "./types.ts";
35
import { LONG_TERM_LIMITS } from "./types.ts";
46
import { assessMemoryQuality } from "./memory-quality.ts";
7+
import { extractionRejectionLogPath } from "./paths.ts";
58

69
function id(prefix: string): string {
710
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
@@ -227,6 +230,24 @@ function extractFirstPath(text: string): string | undefined {
227230
* Acceptance gate for workspace memory candidates.
228231
* Keeps extraction-specific checks local and delegates memory quality rules to memory-quality.ts.
229232
*/
233+
type ExtractionRejectionLogEntry = {
234+
timestamp: string;
235+
type: LongTermType;
236+
text: string;
237+
reasons: string[];
238+
source: "compaction";
239+
};
240+
241+
async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promise<void> {
242+
try {
243+
const path = extractionRejectionLogPath();
244+
await mkdir(dirname(path), { recursive: true });
245+
await appendFile(path, JSON.stringify(entry) + "\n", "utf8");
246+
} catch (error) {
247+
console.error("[memory] failed to write extraction rejection log:", error);
248+
}
249+
}
250+
230251
function shouldAcceptWorkspaceMemoryCandidate(
231252
entry: {
232253
type: LongTermType;
@@ -253,7 +274,16 @@ function shouldAcceptWorkspaceMemoryCandidate(
253274
if (/\b(ignore|instruction|overwrite)\b/i.test(text) && /\b(previous|all|rules|behavior|prompt|system)\b/i.test(text)) return false;
254275

255276
const quality = assessMemoryQuality({ type: entry.type, text, source: "compaction" });
256-
if (!quality.accepted) return false;
277+
if (!quality.accepted) {
278+
void logExtractionRejection({
279+
timestamp: new Date().toISOString(),
280+
type: entry.type,
281+
text,
282+
reasons: quality.reasons,
283+
source: "compaction",
284+
});
285+
return false;
286+
}
257287

258288
return true;
259289
}

src/paths.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,11 @@ export async function sessionStatePath(root: string, sessionID: string): Promise
2828
const safeSessionID = createHash("sha256").update(sessionID).digest("hex").slice(0, 32);
2929
return join(await memoryRoot(root), "sessions", `${safeSessionID}.json`);
3030
}
31+
32+
export function migrationLogPath(migrationId: string): string {
33+
return join(dataHome(), "opencode-working-memory", "migration-logs", `${migrationId}.jsonl`);
34+
}
35+
36+
export function extractionRejectionLogPath(): string {
37+
return join(dataHome(), "opencode-working-memory", "extraction-rejections.jsonl");
38+
}

src/workspace-memory.ts

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { appendFile, mkdir } from "node:fs/promises";
2+
import { dirname } from "node:path";
13
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
24
import { LONG_TERM_LIMITS } from "./types.ts";
3-
import { workspaceKey, workspaceMemoryPath } from "./paths.ts";
5+
import { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts";
46
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
57
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts";
68

@@ -50,6 +52,21 @@ export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & {
5052
events: MemoryConsolidationEvent[];
5153
};
5254

55+
export type QualityCleanupMigrationLogEntry = {
56+
migrationId: string;
57+
timestamp: string;
58+
workspaceKey: string;
59+
workspaceRoot: string;
60+
entryId: string;
61+
type: LongTermMemoryEntry["type"];
62+
source: LongTermMemoryEntry["source"];
63+
text: string;
64+
reasons: string[];
65+
hardReasons: string[];
66+
beforeStatus: "active";
67+
afterStatus: "superseded";
68+
};
69+
5370
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
5471
return {
5572
version: 1,
@@ -191,7 +208,13 @@ export async function normalizeWorkspaceMemoryWithAccounting(
191208
// One-time migrations for legacy/low-quality snapshot violations.
192209
// Run quality cleanup first so hard violations receive quality audit tags
193210
// before the older P0 project-only cleanup marks progress snapshots.
194-
result = runMigrationQualityCleanup(result, nowIso);
211+
const qualityCleanup = runMigrationQualityCleanup(result, nowIso);
212+
result = qualityCleanup.store;
213+
if (qualityCleanup.events.length > 0) {
214+
await appendQualityCleanupMigrationLog(qualityCleanup.events).catch(error => {
215+
console.error("[memory] failed to write quality cleanup migration log:", error);
216+
});
217+
}
195218
result = runMigrationP0Cleanup(result, nowIso);
196219

197220
// P0 accounting only considers active entries. Entries that were already
@@ -287,14 +310,22 @@ export function runMigrationP0Cleanup(
287310
};
288311
}
289312

313+
async function appendQualityCleanupMigrationLog(events: QualityCleanupMigrationLogEntry[]): Promise<void> {
314+
if (events.length === 0) return;
315+
const path = migrationLogPath(QUALITY_CLEANUP_MIGRATION_ID);
316+
await mkdir(dirname(path), { recursive: true });
317+
await appendFile(path, events.map(event => JSON.stringify(event)).join("\n") + "\n", "utf8");
318+
}
319+
290320
export function runMigrationQualityCleanup(
291321
store: WorkspaceMemoryStore,
292322
nowIso: string,
293-
): WorkspaceMemoryStore {
323+
): { store: WorkspaceMemoryStore; events: QualityCleanupMigrationLogEntry[] } {
294324
if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) {
295-
return store;
325+
return { store, events: [] };
296326
}
297327

328+
const events: QualityCleanupMigrationLogEntry[] = [];
298329
let changed = false;
299330
const entries = store.entries.map(entry => {
300331
if (entry.source !== "compaction") return entry;
@@ -307,6 +338,21 @@ export function runMigrationQualityCleanup(
307338
if (hardReasons.length === 0) return entry;
308339

309340
changed = true;
341+
events.push({
342+
migrationId: QUALITY_CLEANUP_MIGRATION_ID,
343+
timestamp: nowIso,
344+
workspaceKey: store.workspace.key,
345+
workspaceRoot: store.workspace.root,
346+
entryId: entry.id,
347+
type: entry.type,
348+
source: entry.source,
349+
text: entry.text,
350+
reasons: quality.reasons,
351+
hardReasons,
352+
beforeStatus: "active",
353+
afterStatus: "superseded",
354+
});
355+
310356
const tags = new Set([
311357
...(entry.tags ?? []),
312358
"quality_cleanup",
@@ -322,10 +368,13 @@ export function runMigrationQualityCleanup(
322368
});
323369

324370
return {
325-
...store,
326-
entries,
327-
migrations: [...(store.migrations ?? []), QUALITY_CLEANUP_MIGRATION_ID],
328-
updatedAt: changed ? nowIso : store.updatedAt,
371+
store: {
372+
...store,
373+
entries,
374+
migrations: [...(store.migrations ?? []), QUALITY_CLEANUP_MIGRATION_ID],
375+
updatedAt: changed ? nowIso : store.updatedAt,
376+
},
377+
events,
329378
};
330379
}
331380

tests/extractors.test.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
import test from "node:test";
22
import assert from "node:assert/strict";
3-
import { extractErrorsFromBash, extractExplicitMemories } from "../src/extractors.ts";
3+
import { mkdtemp, readFile, rm } from "node:fs/promises";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { extractErrorsFromBash, extractExplicitMemories, parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
7+
8+
async function waitForFile(path: string, attempts = 20): Promise<string> {
9+
let lastError: unknown;
10+
for (let i = 0; i < attempts; i += 1) {
11+
try {
12+
return await readFile(path, "utf8");
13+
} catch (error) {
14+
lastError = error;
15+
await new Promise(resolve => setTimeout(resolve, 10));
16+
}
17+
}
18+
throw lastError;
19+
}
420

521
// ============================================
622
// Task 1: extractErrorsFromBash tests
@@ -129,8 +145,6 @@ test("extractExplicitMemories captures multiple memories in same message", () =>
129145
// Task 7: Compaction quality gate tests
130146
// ============================================
131147

132-
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
133-
134148
test("parseWorkspaceMemoryCandidates rejects short text", () => {
135149
const summary = `
136150
## Memory Candidates
@@ -281,6 +295,35 @@ Memory candidates:
281295
assert.equal(items.length, 0, "Exact test counts are session snapshots, not durable memory");
282296
});
283297

298+
test("parseWorkspaceMemoryCandidates logs quality gate rejections locally", async () => {
299+
const dataHome = await mkdtemp(join(tmpdir(), "wm-extraction-reject-data-"));
300+
const previousXdgDataHome = process.env.XDG_DATA_HOME;
301+
process.env.XDG_DATA_HOME = dataHome;
302+
303+
try {
304+
const summary = `
305+
Memory candidates:
306+
- feedback Wave 1 completed successfully and all tests passed
307+
`;
308+
309+
const items = parseWorkspaceMemoryCandidates(summary);
310+
311+
assert.equal(items.length, 0);
312+
const logPath = join(dataHome, "opencode-working-memory", "extraction-rejections.jsonl");
313+
const lines = (await waitForFile(logPath)).trim().split("\n");
314+
assert.equal(lines.length, 1);
315+
const event = JSON.parse(lines[0]);
316+
assert.equal(event.type, "feedback");
317+
assert.equal(event.text, "Wave 1 completed successfully and all tests passed");
318+
assert.deepEqual(event.reasons, ["progress_snapshot", "bad_feedback"]);
319+
assert.equal(event.source, "compaction");
320+
} finally {
321+
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
322+
else process.env.XDG_DATA_HOME = previousXdgDataHome;
323+
await rm(dataHome, { recursive: true, force: true });
324+
}
325+
});
326+
284327
test("parseWorkspaceMemoryCandidates rejects exact file count snapshots", () => {
285328
const summary = `
286329
Memory candidates:

tests/workspace-memory.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,53 @@ test("quality cleanup migration supersedes hard quality violations", async () =>
10311031
}
10321032
});
10331033

1034+
test("quality cleanup migration writes audit log for hard supersedes", async () => {
1035+
const root = await mkdtemp(join(tmpdir(), "wm-quality-audit-root-"));
1036+
const dataHome = await mkdtemp(join(tmpdir(), "wm-quality-audit-data-"));
1037+
const previousXdgDataHome = process.env.XDG_DATA_HOME;
1038+
process.env.XDG_DATA_HOME = dataHome;
1039+
1040+
try {
1041+
const now = new Date().toISOString();
1042+
await saveWorkspaceMemory(root, {
1043+
version: 1,
1044+
workspace: { root, key: await workspaceKey(root) },
1045+
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
1046+
entries: [{
1047+
id: "hard_progress",
1048+
type: "project",
1049+
text: "測試套件:1237 tests pass, 226 suites",
1050+
source: "compaction",
1051+
confidence: 0.75,
1052+
status: "active",
1053+
createdAt: now,
1054+
updatedAt: now,
1055+
staleAfterDays: 60,
1056+
}],
1057+
migrations: [],
1058+
updatedAt: now,
1059+
});
1060+
1061+
await loadWorkspaceMemory(root);
1062+
1063+
const logPath = join(dataHome, "opencode-working-memory", "migration-logs", "2026-04-28-quality-cleanup.jsonl");
1064+
const lines = (await readFile(logPath, "utf8")).trim().split("\n");
1065+
assert.equal(lines.length, 1);
1066+
const event = JSON.parse(lines[0]);
1067+
assert.equal(event.migrationId, "2026-04-28-quality-cleanup");
1068+
assert.equal(event.entryId, "hard_progress");
1069+
assert.deepEqual(event.hardReasons, ["progress_snapshot"]);
1070+
assert.equal(event.beforeStatus, "active");
1071+
assert.equal(event.afterStatus, "superseded");
1072+
assert.equal(event.text, "測試套件:1237 tests pass, 226 suites");
1073+
} finally {
1074+
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
1075+
else process.env.XDG_DATA_HOME = previousXdgDataHome;
1076+
await rm(root, { recursive: true, force: true });
1077+
await rm(dataHome, { recursive: true, force: true });
1078+
}
1079+
});
1080+
10341081
test("quality cleanup migration supersedes only hard violations from current fixture", async () => {
10351082
const root = await mkdtemp(join(tmpdir(), "wm-quality-cleanup-"));
10361083
try {

0 commit comments

Comments
 (0)