feat(memory): add local quality cleanup audit logs

This commit is contained in:
Ralph Chang
2026-04-28 14:17:17 +08:00
parent 12eddc2f8c
commit 7de10c5808
5 changed files with 189 additions and 12 deletions
+31 -1
View File
@@ -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<void> {
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;
}
+8
View File
@@ -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");
}
+57 -8
View File
@@ -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<WorkspaceMemoryStore> {
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<void> {
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,
};
}
+46 -3
View File
@@ -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<string> {
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:
+47
View File
@@ -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 {