diff --git a/src/evidence-log.ts b/src/evidence-log.ts new file mode 100644 index 0000000..da75ee2 --- /dev/null +++ b/src/evidence-log.ts @@ -0,0 +1,515 @@ +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { appendFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { workspaceEvidenceLogPath, workspaceKey } from "./paths.ts"; +import { redactCredentials } from "./redaction.ts"; + +export type EvidenceEventType = + | "extraction_candidate_accepted" + | "extraction_candidate_rejected" + | "explicit_memory_detected" + | "explicit_memory_ignored" + | "pending_memory_appended" + | "pending_memory_cleared" + | "promotion_promoted" + | "promotion_absorbed_exact" + | "promotion_absorbed_identity" + | "promotion_superseded" + | "promotion_rejected_capacity" + | "promotion_retry_scheduled" + | "promotion_retry_exhausted" + | "memory_reinforced" + | "render_selected" + | "render_omitted" + | "storage_corrupt_json_quarantined" + | "storage_stale_lock_recovered" + | "storage_lock_timeout" + | "hook_failed"; + +export type EvidencePhase = + | "extraction" + | "explicit" + | "pending_journal" + | "promotion" + | "reinforcement" + | "render" + | "storage" + | "hook"; + +export type EvidenceOutcome = + | "accepted" + | "rejected" + | "promoted" + | "absorbed" + | "superseded" + | "rendered" + | "omitted" + | "retried" + | "exhausted" + | "reinforced" + | "quarantined" + | "failed" + | "recovered"; + +export type MemoryEvidenceRef = { + memoryId?: string; + memoryKeyHash?: string; + identityKeyHash?: string; + type?: "feedback" | "project" | "decision" | "reference"; + source?: "explicit" | "compaction" | "manual"; + status?: "active" | "superseded"; +}; + +export type EvidenceRelation = { + role: + | "candidate" + | "pending" + | "promoted" + | "retained" + | "absorbed" + | "superseded" + | "superseded_by" + | "reinforced" + | "reinforced_by" + | "rendered" + | "omitted"; + memory?: MemoryEvidenceRef; +}; + +export type EvidenceDetailValue = string | number | boolean | null | string[] | number[]; + +export type EvidenceEventV1 = { + version: 1; + eventId: string; + createdAt: string; + workspaceKey: string; + workspaceRootHash: string; + sessionHash?: string; + messageHash?: string; + type: EvidenceEventType; + phase: EvidencePhase; + outcome: EvidenceOutcome; + memory?: MemoryEvidenceRef; + relations?: EvidenceRelation[]; + reasonCodes: string[]; + details?: Record; + textPreview?: string; +}; + +export type EvidenceEventInput = Omit< + EvidenceEventV1, + "version" | "eventId" | "createdAt" | "workspaceKey" | "workspaceRootHash" +>; + +export type EvidenceQuery = { + since?: string; + until?: string; + types?: EvidenceEventType[]; + phases?: EvidencePhase[]; + outcomes?: EvidenceOutcome[]; + memoryId?: string; + memoryKeyHash?: string; + identityKeyHash?: string; + sessionHash?: string; + limit?: number; + newestFirst?: boolean; +}; + +export type MemoryEvidenceSummary = { + memoryId?: string; + memoryKeyHash?: string; + latestOutcome?: EvidenceOutcome; + latestRenderStatus?: "rendered" | "omitted"; + reasonCodes: string[]; + eventIds: string[]; + lastEventAt?: string; +}; + +export type MemoryLifecycleTrace = { + memoryId?: string; + memoryKeyHash?: string; + identityKeyHash?: string; + events: EvidenceEventV1[]; + createdBy?: EvidenceEventV1; + acceptedBy?: EvidenceEventV1; + promotedBy?: EvidenceEventV1; + absorbedBy?: EvidenceEventV1; + supersededBy?: EvidenceEventV1; + reinforcedBy: EvidenceEventV1[]; + latestRender?: EvidenceEventV1; + currentStatus: + | "accepted" + | "pending" + | "promoted" + | "absorbed" + | "superseded" + | "rendered" + | "omitted" + | "rejected" + | "unknown"; +}; + +export const EVIDENCE_LOG_LIMITS = { + maxAgeDays: 90, + maxEventsPerWorkspace: 5000, + maxBytesPerWorkspace: 2 * 1024 * 1024, + pruneEveryAppendCount: 100, +} as const; + +const appendCounts = new Map(); +const HASH_PATTERN = /^[a-f0-9]{16}$/i; +const DAY_MS = 24 * 60 * 60 * 1000; +const MAX_DETAIL_STRING_CHARS = 240; +const MAX_DETAIL_ARRAY_ITEMS = 25; + +function evidenceHash(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 16); +} + +function normalizeHashValue(value: string | undefined): string | undefined { + if (!value) return undefined; + return HASH_PATTERN.test(value) ? value.toLowerCase() : evidenceHash(value); +} + +async function resolvedRoot(root: string): Promise { + return realpath(root).catch(() => root); +} + +function evidenceTextPreview(text: string, maxChars = 120): string { + return redactCredentials(text).replace(/\s+/g, " ").trim().slice(0, maxChars); +} + +function sanitizeReasonCode(reason: string): string { + return reason.replace(/[^a-zA-Z0-9_.:-]/g, "_").slice(0, 120); +} + +function sanitizeMemoryRef(memory: MemoryEvidenceRef | undefined): MemoryEvidenceRef | undefined { + if (!memory) return undefined; + const sanitized: MemoryEvidenceRef = {}; + if (typeof memory.memoryId === "string" && memory.memoryId) sanitized.memoryId = memory.memoryId.slice(0, 160); + if (memory.memoryKeyHash) sanitized.memoryKeyHash = normalizeHashValue(memory.memoryKeyHash); + if (memory.identityKeyHash) sanitized.identityKeyHash = normalizeHashValue(memory.identityKeyHash); + if (memory.type) sanitized.type = memory.type; + if (memory.source) sanitized.source = memory.source; + if (memory.status) sanitized.status = memory.status; + return Object.keys(sanitized).length > 0 ? sanitized : undefined; +} + +function sanitizeRelations(relations: EvidenceRelation[] | undefined): EvidenceRelation[] | undefined { + if (!relations) return undefined; + const sanitized = relations + .map(relation => ({ + role: relation.role, + memory: sanitizeMemoryRef(relation.memory), + })) + .slice(0, 25); + return sanitized.length > 0 ? sanitized : undefined; +} + +function sanitizeDetailString(value: string): string { + return evidenceTextPreview(value, MAX_DETAIL_STRING_CHARS); +} + +function sanitizeDetails(details: EvidenceEventInput["details"]): EvidenceEventV1["details"] { + if (!details) return undefined; + const sanitized: Record = {}; + + for (const [rawKey, rawValue] of Object.entries(details).slice(0, 50)) { + const key = rawKey.replace(/[^a-zA-Z0-9_.:-]/g, "_").slice(0, 80); + if (!key) continue; + + if (typeof rawValue === "string") { + sanitized[key] = sanitizeDetailString(rawValue); + } else if (typeof rawValue === "number") { + if (Number.isFinite(rawValue)) sanitized[key] = rawValue; + } else if (typeof rawValue === "boolean" || rawValue === null) { + sanitized[key] = rawValue; + } else if (Array.isArray(rawValue)) { + if (rawValue.every(item => typeof item === "string")) { + sanitized[key] = rawValue.slice(0, MAX_DETAIL_ARRAY_ITEMS).map(item => sanitizeDetailString(item)); + } else if (rawValue.every(item => typeof item === "number" && Number.isFinite(item))) { + sanitized[key] = rawValue.slice(0, MAX_DETAIL_ARRAY_ITEMS) as number[]; + } + } + } + + return Object.keys(sanitized).length > 0 ? sanitized : undefined; +} + +function buildEvidenceEvent( + input: EvidenceEventInput, + workspaceKeyValue: string, + workspaceRootHash: string, +): EvidenceEventV1 { + const textPreviewMax = input.type === "extraction_candidate_rejected" ? 80 : 120; + const event: EvidenceEventV1 = { + version: 1, + eventId: `evt_${Date.now()}_${Math.random().toString(36).slice(2, 10).padEnd(8, "0")}`, + createdAt: new Date().toISOString(), + workspaceKey: workspaceKeyValue, + workspaceRootHash, + type: input.type, + phase: input.phase, + outcome: input.outcome, + reasonCodes: input.reasonCodes.map(sanitizeReasonCode).filter(Boolean).slice(0, 25), + }; + + const memory = sanitizeMemoryRef(input.memory); + const relations = sanitizeRelations(input.relations); + const details = sanitizeDetails(input.details); + if (input.sessionHash) event.sessionHash = normalizeHashValue(input.sessionHash); + if (input.messageHash) event.messageHash = normalizeHashValue(input.messageHash); + if (memory) event.memory = memory; + if (relations) event.relations = relations; + if (details) event.details = details; + if (input.textPreview) event.textPreview = evidenceTextPreview(input.textPreview, textPreviewMax); + + return event; +} + +async function safeAppendEvidenceLine(path: string, line: string): Promise { + try { + await mkdir(dirname(path), { recursive: true }); + await appendFile(path, `${line}\n`, "utf8"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[memory] failed to write evidence event: ${message}`); + } +} + +async function maybePruneEvidenceLog(path: string): Promise { + const nextCount = (appendCounts.get(path) ?? 0) + 1; + appendCounts.set(path, nextCount); + if (nextCount % EVIDENCE_LOG_LIMITS.pruneEveryAppendCount !== 0) return; + + try { + await pruneEvidenceLogPath(path); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[memory] failed to prune evidence log: ${message}`); + } +} + +export async function appendEvidenceEvent(root: string, event: EvidenceEventInput): Promise { + const records = await appendEvidenceEvents(root, [event]); + return records[0]; +} + +export async function appendEvidenceEvents(root: string, events: EvidenceEventInput[]): Promise { + const path = await workspaceEvidenceLogPath(root); + const rootPath = await resolvedRoot(root); + const workspaceRootHash = evidenceHash(rootPath); + const workspaceKeyValue = await workspaceKey(root); + const records = events.map(event => buildEvidenceEvent(event, workspaceKeyValue, workspaceRootHash)); + + for (const record of records) { + await safeAppendEvidenceLine(path, JSON.stringify(record)); + await maybePruneEvidenceLog(path); + } + + return records; +} + +type ParsedEvidenceLine = { + event: EvidenceEventV1; + index: number; +}; + +function parseEvidenceLine(line: string): EvidenceEventV1 | null { + try { + const parsed = JSON.parse(line) as Partial; + if (parsed.version !== 1 || !parsed.eventId || !parsed.createdAt || !parsed.type) return null; + return parsed as EvidenceEventV1; + } catch { + return null; + } +} + +async function readEvidenceLines(path: string, warnInvalid: boolean): Promise<{ valid: ParsedEvidenceLine[]; invalid: string[] }> { + if (!existsSync(path)) return { valid: [], invalid: [] }; + const raw = await readFile(path, "utf8"); + const valid: ParsedEvidenceLine[] = []; + const invalid: string[] = []; + + raw.split(/\n/).forEach((line, index) => { + if (!line.trim()) return; + const event = parseEvidenceLine(line); + if (event) { + valid.push({ event, index }); + } else { + invalid.push(line); + if (warnInvalid) console.warn(`[memory] skipped invalid evidence log line ${index + 1}`); + } + }); + + return { valid, invalid }; +} + +function eventTimeMs(event: EvidenceEventV1): number { + const ms = new Date(event.createdAt).getTime(); + return Number.isFinite(ms) ? ms : 0; +} + +async function atomicWriteText(path: string, text: string): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 10)}.tmp`; + try { + await writeFile(tmp, text, { encoding: "utf8", mode: 0o600 }); + await rename(tmp, path); + } catch (error) { + await rm(tmp, { force: true }).catch(() => undefined); + throw error; + } +} + +function serializeEvents(events: EvidenceEventV1[]): string { + return events.map(event => JSON.stringify(event)).join("\n") + (events.length > 0 ? "\n" : ""); +} + +function trimEventsToByteLimit(events: EvidenceEventV1[]): EvidenceEventV1[] { + let kept = [...events]; + while (kept.length > 0 && Buffer.byteLength(serializeEvents(kept), "utf8") > EVIDENCE_LOG_LIMITS.maxBytesPerWorkspace) { + kept = kept.slice(1); + } + return kept; +} + +async function pruneEvidenceLogPath(path: string): Promise { + if (!existsSync(path)) return; + const stats = await stat(path); + if (stats.isDirectory()) return; + + const { valid, invalid } = await readEvidenceLines(path, false); + if (invalid.length > 0) { + const corruptPath = `${path}.corrupt-lines-${Date.now()}.jsonl`; + await writeFile(corruptPath, invalid.join("\n") + "\n", { encoding: "utf8", mode: 0o600 }).catch(error => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[memory] failed to quarantine invalid evidence lines: ${message}`); + }); + } + + const cutoff = Date.now() - EVIDENCE_LOG_LIMITS.maxAgeDays * DAY_MS; + let events = valid + .filter(item => eventTimeMs(item.event) >= cutoff) + .sort((a, b) => eventTimeMs(a.event) - eventTimeMs(b.event) || a.index - b.index) + .map(item => item.event); + + if (events.length > EVIDENCE_LOG_LIMITS.maxEventsPerWorkspace) { + events = events.slice(events.length - EVIDENCE_LOG_LIMITS.maxEventsPerWorkspace); + } + + events = trimEventsToByteLimit(events); + await atomicWriteText(path, serializeEvents(events)); +} + +function memoryRefMatches(memory: MemoryEvidenceRef | undefined, query: Pick): boolean { + if (!memory) return false; + const memoryKeyHash = normalizeHashValue(query.memoryKeyHash); + const identityKeyHash = normalizeHashValue(query.identityKeyHash); + if (query.memoryId && memory.memoryId === query.memoryId) return true; + if (memoryKeyHash && memory.memoryKeyHash === memoryKeyHash) return true; + if (identityKeyHash && memory.identityKeyHash === identityKeyHash) return true; + return false; +} + +function eventMatchesMemory(event: EvidenceEventV1, query: Pick): boolean { + if (!query.memoryId && !query.memoryKeyHash && !query.identityKeyHash) return true; + if (memoryRefMatches(event.memory, query)) return true; + return (event.relations ?? []).some(relation => memoryRefMatches(relation.memory, query)); +} + +export async function queryEvidenceEvents( + root: string, + query: EvidenceQuery = {}, +): Promise { + const path = await workspaceEvidenceLogPath(root); + const { valid } = await readEvidenceLines(path, true); + const sinceMs = query.since ? new Date(query.since).getTime() : undefined; + const untilMs = query.until ? new Date(query.until).getTime() : undefined; + const sessionHash = normalizeHashValue(query.sessionHash); + + let events = valid.map(item => item.event).filter(event => { + const createdAtMs = eventTimeMs(event); + if (Number.isFinite(sinceMs) && createdAtMs < sinceMs) return false; + if (Number.isFinite(untilMs) && createdAtMs > untilMs) return false; + if (query.types && !query.types.includes(event.type)) return false; + if (query.phases && !query.phases.includes(event.phase)) return false; + if (query.outcomes && !query.outcomes.includes(event.outcome)) return false; + if (sessionHash && event.sessionHash !== sessionHash) return false; + if (!eventMatchesMemory(event, query)) return false; + return true; + }); + + if (query.newestFirst) events = events.slice().reverse(); + if (typeof query.limit === "number" && query.limit >= 0) events = events.slice(0, query.limit); + return events; +} + +export async function summarizeMemoryEvidence( + root: string, + input: { memoryId?: string; memoryKeyHash?: string }, +): Promise { + const events = await queryEvidenceEvents(root, input); + const latest = events.at(-1); + const latestRender = events.filter(event => event.phase === "render").at(-1); + const reasonCodes = new Set(); + for (const event of events) { + for (const reason of event.reasonCodes) reasonCodes.add(reason); + } + + return { + memoryId: input.memoryId, + memoryKeyHash: normalizeHashValue(input.memoryKeyHash), + latestOutcome: latest?.outcome, + latestRenderStatus: latestRender?.outcome === "rendered" || latestRender?.outcome === "omitted" + ? latestRender.outcome + : undefined, + reasonCodes: [...reasonCodes], + eventIds: events.map(event => event.eventId), + lastEventAt: latest?.createdAt, + }; +} + +function currentStatusFromEvent(event: EvidenceEventV1 | undefined): MemoryLifecycleTrace["currentStatus"] { + if (!event) return "unknown"; + if (event.outcome === "accepted") return "accepted"; + if (event.outcome === "promoted") return "promoted"; + if (event.outcome === "absorbed") return "absorbed"; + if (event.outcome === "superseded") return "superseded"; + if (event.outcome === "rendered") return "rendered"; + if (event.outcome === "omitted") return "omitted"; + if (event.outcome === "rejected") return "rejected"; + if (event.type === "pending_memory_appended") return "pending"; + return "unknown"; +} + +export async function traceMemoryLifecycle( + root: string, + input: { memoryId?: string; memoryKeyHash?: string; identityKeyHash?: string }, +): Promise { + const events = await queryEvidenceEvents(root, input); + const createdBy = events.find(event => event.type === "explicit_memory_detected" || event.type === "pending_memory_appended"); + const acceptedBy = events.find(event => event.type === "extraction_candidate_accepted" || event.type === "explicit_memory_detected"); + const promotedBy = events.find(event => event.type === "promotion_promoted"); + const absorbedBy = events.find(event => event.outcome === "absorbed"); + const supersededBy = events.find(event => event.outcome === "superseded"); + const reinforcedBy = events.filter(event => event.type === "memory_reinforced" || event.outcome === "reinforced"); + const latestRender = events.filter(event => event.phase === "render").at(-1); + const latest = events.at(-1); + + return { + memoryId: input.memoryId, + memoryKeyHash: normalizeHashValue(input.memoryKeyHash), + identityKeyHash: normalizeHashValue(input.identityKeyHash), + events, + createdBy, + acceptedBy, + promotedBy, + absorbedBy, + supersededBy, + reinforcedBy, + latestRender, + currentStatus: currentStatusFromEvent(latest), + }; +} diff --git a/src/paths.ts b/src/paths.ts index 85c4ae5..08ac976 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -24,6 +24,10 @@ export async function workspacePendingJournalPath(root: string): Promise return join(await memoryRoot(root), "workspace-pending-journal.json"); } +export async function workspaceEvidenceLogPath(root: string): Promise { + return join(await memoryRoot(root), "evidence", "events.jsonl"); +} + 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`); diff --git a/tests/evidence-log.test.ts b/tests/evidence-log.test.ts new file mode 100644 index 0000000..c8c252c --- /dev/null +++ b/tests/evidence-log.test.ts @@ -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 { + return mkdtemp(join(tmpdir(), "opencode-evidence-log-")); +} + +function eventInput(overrides: Partial = {}): 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 { + 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 { + 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[number]["role"]> = [ + "candidate", + "pending", + "promoted", + "retained", + "absorbed", + "superseded", + "superseded_by", + "reinforced", + "reinforced_by", + "rendered", + "omitted", + ]; + + assert.equal(allowedRoles.includes("candidate"), true); +});