mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-01 22:11:08 +02:00
feat(memory): add local quality cleanup audit logs
This commit is contained in:
+31
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user