mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +02:00
feat(evidence): add evidence infrastructure - types, append, query, retention
Phase 3 Task 3.1: - Create src/evidence-log.ts with EvidenceEventType, EvidencePhase, EvidenceOutcome, MemoryEvidenceRef, EvidenceRelation, EvidenceEventV1, EvidenceEventInput types - Add appendEvidenceEvent/appendEvidenceEvents with safe write, privacy hashing (SHA-256 truncated), textPreview redaction, bounded retention - Add queryEvidenceEvents, summarizeMemoryEvidence, traceMemoryLifecycle - Add workspaceEvidenceLogPath to src/paths.ts - Add 8 evidence-log tests: round-trip, privacy, query, resilience, retention - Relations limited to wiring roles only (no kind/derived_from/validates) - 253 tests pass
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
EVIDENCE_LOG_LIMITS,
|
||||
appendEvidenceEvent,
|
||||
appendEvidenceEvents,
|
||||
queryEvidenceEvents,
|
||||
summarizeMemoryEvidence,
|
||||
traceMemoryLifecycle,
|
||||
type EvidenceEventInput,
|
||||
type EvidenceEventV1,
|
||||
} from "../src/evidence-log.ts";
|
||||
import { workspaceEvidenceLogPath, workspaceKey } from "../src/paths.ts";
|
||||
|
||||
async function tempRoot(): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), "opencode-evidence-log-"));
|
||||
}
|
||||
|
||||
function eventInput(overrides: Partial<EvidenceEventInput> = {}): EvidenceEventInput {
|
||||
return {
|
||||
type: "promotion_promoted",
|
||||
phase: "promotion",
|
||||
outcome: "promoted",
|
||||
reasonCodes: ["new_workspace_entry"],
|
||||
memory: { memoryId: "mem-a", type: "decision", source: "explicit", status: "active" },
|
||||
textPreview: "Use npm test before release",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function readLog(root: string): Promise<string> {
|
||||
return readFile(await workspaceEvidenceLogPath(root), "utf8");
|
||||
}
|
||||
|
||||
function privacyHash(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
async function workspaceRootHash(root: string): Promise<string> {
|
||||
const resolved = await realpath(root).catch(() => root);
|
||||
return privacyHash(resolved);
|
||||
}
|
||||
|
||||
function manualEvent(rootKey: string, rootHash: string, id: string, createdAt: string): EvidenceEventV1 {
|
||||
return {
|
||||
version: 1,
|
||||
eventId: id,
|
||||
createdAt,
|
||||
workspaceKey: rootKey,
|
||||
workspaceRootHash: rootHash,
|
||||
type: "render_selected",
|
||||
phase: "render",
|
||||
outcome: "rendered",
|
||||
memory: { memoryId: id, type: "decision" },
|
||||
reasonCodes: ["within_caps", "within_char_budget"],
|
||||
};
|
||||
}
|
||||
|
||||
test("appendEvidenceEvent writes versioned JSONL with workspace hashes", async () => {
|
||||
const root = await tempRoot();
|
||||
try {
|
||||
const event = await appendEvidenceEvent(root, eventInput({ sessionHash: "session-1", messageHash: "message-1" }));
|
||||
const raw = await readLog(root);
|
||||
const stored = JSON.parse(raw.trim()) as EvidenceEventV1;
|
||||
|
||||
assert.equal(stored.version, 1);
|
||||
assert.match(stored.eventId, /^evt_\d+_[a-z0-9]{8}$/);
|
||||
assert.equal(stored.eventId, event.eventId);
|
||||
assert.equal(stored.workspaceKey, await workspaceKey(root));
|
||||
assert.equal(stored.workspaceRootHash, await workspaceRootHash(root));
|
||||
assert.equal(stored.sessionHash, privacyHash("session-1"));
|
||||
assert.equal(stored.messageHash, privacyHash("message-1"));
|
||||
assert.equal(stored.type, "promotion_promoted");
|
||||
assert.equal(stored.outcome, "promoted");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("appendEvidenceEvent redacts text previews before writing", async () => {
|
||||
const root = await tempRoot();
|
||||
try {
|
||||
await appendEvidenceEvent(root, eventInput({
|
||||
type: "extraction_candidate_rejected",
|
||||
phase: "extraction",
|
||||
outcome: "rejected",
|
||||
reasonCodes: ["raw_error"],
|
||||
textPreview: "password: sushi\nAdmin PIN 是 456123\nBearer abc.def.ghi\nThis candidate is rejected and should be short",
|
||||
details: {
|
||||
note: "password: sushi Admin PIN 是 456123 Bearer abc.def.ghi",
|
||||
},
|
||||
}));
|
||||
|
||||
const raw = await readLog(root);
|
||||
const stored = JSON.parse(raw.trim()) as EvidenceEventV1;
|
||||
assert.ok(!raw.includes("sushi"));
|
||||
assert.ok(!raw.includes("456123"));
|
||||
assert.ok(!raw.includes("abc.def.ghi"));
|
||||
assert.ok(stored.textPreview?.includes("[REDACTED]"));
|
||||
assert.ok((stored.textPreview?.length ?? 0) <= 80);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("queryEvidenceEvents filters by type outcome and memory id", async () => {
|
||||
const root = await tempRoot();
|
||||
try {
|
||||
await appendEvidenceEvents(root, [
|
||||
eventInput({ type: "promotion_promoted", outcome: "promoted", memory: { memoryId: "mem-a" } }),
|
||||
eventInput({ type: "render_omitted", phase: "render", outcome: "omitted", reasonCodes: ["type_cap"], memory: { memoryId: "mem-a" } }),
|
||||
eventInput({ type: "render_omitted", phase: "render", outcome: "omitted", reasonCodes: ["global_cap"], memory: { memoryId: "mem-b" } }),
|
||||
]);
|
||||
|
||||
const result = await queryEvidenceEvents(root, {
|
||||
types: ["render_omitted"],
|
||||
outcomes: ["omitted"],
|
||||
memoryId: "mem-a",
|
||||
});
|
||||
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].type, "render_omitted");
|
||||
assert.equal(result[0].memory?.memoryId, "mem-a");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("queryEvidenceEvents supports newestFirst and limit", async () => {
|
||||
const root = await tempRoot();
|
||||
try {
|
||||
const events = await appendEvidenceEvents(root, [
|
||||
eventInput({ memory: { memoryId: "oldest" } }),
|
||||
eventInput({ memory: { memoryId: "middle" } }),
|
||||
eventInput({ memory: { memoryId: "newest" } }),
|
||||
]);
|
||||
|
||||
const result = await queryEvidenceEvents(root, { newestFirst: true, limit: 2 });
|
||||
|
||||
assert.deepEqual(result.map(event => event.eventId), [events[2].eventId, events[1].eventId]);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("appendEvidenceEvent returns a record when appendFile fails", async () => {
|
||||
const root = await tempRoot();
|
||||
try {
|
||||
const path = await workspaceEvidenceLogPath(root);
|
||||
await mkdir(path, { recursive: true });
|
||||
|
||||
const event = await appendEvidenceEvent(root, eventInput());
|
||||
|
||||
assert.equal(event.version, 1);
|
||||
assert.match(event.eventId, /^evt_\d+_[a-z0-9]{8}$/);
|
||||
assert.equal(event.workspaceKey, await workspaceKey(root));
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("evidence log pruning drops old events, caps count, and quarantines invalid lines", async () => {
|
||||
const root = await tempRoot();
|
||||
try {
|
||||
const path = await workspaceEvidenceLogPath(root);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const rootKey = await workspaceKey(root);
|
||||
const rootHash = await workspaceRootHash(root);
|
||||
const old = manualEvent(rootKey, rootHash, "old-event", new Date(Date.now() - 91 * 24 * 60 * 60 * 1000).toISOString());
|
||||
const recentEvents = Array.from({ length: EVIDENCE_LOG_LIMITS.maxEventsPerWorkspace + 1 }, (_, i) =>
|
||||
manualEvent(rootKey, rootHash, `recent-${i}`, new Date(Date.now() - (EVIDENCE_LOG_LIMITS.maxEventsPerWorkspace - i) * 1000).toISOString())
|
||||
);
|
||||
await writeFile(path, [
|
||||
JSON.stringify(old),
|
||||
"{not valid json",
|
||||
...recentEvents.map(event => JSON.stringify(event)),
|
||||
].join("\n") + "\n", "utf8");
|
||||
|
||||
const appended = await appendEvidenceEvents(root, Array.from({ length: EVIDENCE_LOG_LIMITS.pruneEveryAppendCount }, (_, i) =>
|
||||
eventInput({ memory: { memoryId: `appended-${i}` }, reasonCodes: ["new_workspace_entry"] })
|
||||
));
|
||||
|
||||
const events = await queryEvidenceEvents(root);
|
||||
const memoryIds = new Set(events.map(event => event.memory?.memoryId));
|
||||
const files = await readdir(dirname(path));
|
||||
|
||||
assert.ok(events.length <= EVIDENCE_LOG_LIMITS.maxEventsPerWorkspace);
|
||||
assert.equal(memoryIds.has("old-event"), false);
|
||||
assert.equal(memoryIds.has("recent-0"), false, "oldest events over the count cap should be pruned");
|
||||
assert.equal(memoryIds.has(appended.at(-1)?.memory?.memoryId), true);
|
||||
assert.ok(files.some(file => file.startsWith("events.jsonl.corrupt-lines-")));
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("memory evidence summary and lifecycle trace derive latest status", async () => {
|
||||
const root = await tempRoot();
|
||||
try {
|
||||
await appendEvidenceEvents(root, [
|
||||
eventInput({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted", reasonCodes: ["quality_gate_passed"], memory: { memoryId: "mem-life", memoryKeyHash: "raw-key" } }),
|
||||
eventInput({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", reasonCodes: ["new_workspace_entry"], memory: { memoryId: "mem-life", memoryKeyHash: "raw-key" } }),
|
||||
eventInput({ type: "memory_reinforced", phase: "reinforcement", outcome: "reinforced", reasonCodes: ["duplicate_exact"], relations: [{ role: "reinforced", memory: { memoryId: "mem-life", memoryKeyHash: "raw-key" } }] }),
|
||||
eventInput({ type: "render_selected", phase: "render", outcome: "rendered", reasonCodes: ["within_caps", "within_char_budget"], memory: { memoryId: "mem-life", memoryKeyHash: "raw-key" } }),
|
||||
]);
|
||||
|
||||
const summary = await summarizeMemoryEvidence(root, { memoryId: "mem-life" });
|
||||
const trace = await traceMemoryLifecycle(root, { memoryId: "mem-life" });
|
||||
|
||||
assert.equal(summary.latestOutcome, "rendered");
|
||||
assert.equal(summary.latestRenderStatus, "rendered");
|
||||
assert.ok(summary.reasonCodes.includes("duplicate_exact"));
|
||||
assert.equal(trace.currentStatus, "rendered");
|
||||
assert.ok(trace.acceptedBy);
|
||||
assert.ok(trace.promotedBy);
|
||||
assert.equal(trace.reinforcedBy.length, 1);
|
||||
assert.equal(trace.latestRender?.type, "render_selected");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("evidence relation roles reject sublimation placeholders at compile-time surface", () => {
|
||||
const allowedRoles: Array<NonNullable<EvidenceEventInput["relations"]>[number]["role"]> = [
|
||||
"candidate",
|
||||
"pending",
|
||||
"promoted",
|
||||
"retained",
|
||||
"absorbed",
|
||||
"superseded",
|
||||
"superseded_by",
|
||||
"reinforced",
|
||||
"reinforced_by",
|
||||
"rendered",
|
||||
"omitted",
|
||||
];
|
||||
|
||||
assert.equal(allowedRoles.includes("candidate"), true);
|
||||
});
|
||||
Reference in New Issue
Block a user