From bc0847e3ed49f23da9bf9a5e8f29dcbc915291b0 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Thu, 30 Apr 2026 17:54:13 +0800 Subject: [PATCH] feat(evidence): wire evidence events into extraction, promotion, reinforcement, render, storage, and hook lifecycle Phase 3 Tasks 3.2-3.6: - Extraction evidence: accepted/rejected/explicit_detected/explicit_ignored - Promotion evidence with relation edges (superseded/superseded_by, absorbed/retained) - Reinforcement evidence with reinforced/reinforced_by relations - Render accounting helper with render_selected/render_omitted evidence - Storage evidence: corrupt_json_quarantined, stale_lock_recovered, lock_timeout - Hook failure evidence in plugin - All evidence failures swallowed, never throw into memory behavior - Privacy-safe textPreview (redacted + truncated) - 266 tests pass, typecheck pass --- src/evidence-log.ts | 15 ++- src/extractors.ts | 188 +++++++++++++++++++++++++---- src/plugin.ts | 105 ++++++++++++++-- src/promotion-accounting.ts | 116 ++++++++++++++++++ src/storage.ts | 48 ++++++++ src/workspace-memory.ts | 125 ++++++++++++++++++- tests/extractors.test.ts | 57 ++++++++- tests/plugin.test.ts | 70 ++++++++++- tests/promotion-accounting.test.ts | 56 ++++++++- tests/storage.test.ts | 66 +++++++++- tests/workspace-memory.test.ts | 84 +++++++++++++ 11 files changed, 880 insertions(+), 50 deletions(-) diff --git a/src/evidence-log.ts b/src/evidence-log.ts index da75ee2..41acd46 100644 --- a/src/evidence-log.ts +++ b/src/evidence-log.ts @@ -1,8 +1,8 @@ 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 { dirname, join } from "node:path"; +import { dataHome, workspaceEvidenceLogPath, workspaceKey } from "./paths.ts"; import { redactCredentials } from "./redaction.ts"; export type EvidenceEventType = @@ -311,6 +311,17 @@ export async function appendEvidenceEvents(root: string, events: EvidenceEventIn return records; } +export async function appendEvidenceEventForWorkspaceKey( + workspaceKeyValue: string, + event: EvidenceEventInput, +): Promise { + const path = join(dataHome(), "opencode-working-memory", "workspaces", workspaceKeyValue, "evidence", "events.jsonl"); + const record = buildEvidenceEvent(event, workspaceKeyValue, workspaceKeyValue); + await safeAppendEvidenceLine(path, JSON.stringify(record)); + await maybePruneEvidenceLog(path); + return record; +} + type ParsedEvidenceLine = { event: EvidenceEventV1; index: number; diff --git a/src/extractors.ts b/src/extractors.ts index b06ac15..c1ff71d 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -6,6 +6,7 @@ import { LONG_TERM_LIMITS } from "./types.ts"; import { assessMemoryQuality } from "./memory-quality.ts"; import { extractionRejectionLogPath } from "./paths.ts"; import { redactCredentials } from "./redaction.ts"; +import type { EvidenceEventInput } from "./evidence-log.ts"; function id(prefix: string): string { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; @@ -46,6 +47,34 @@ function isNegatedMemoryRequest(text: string, matchIndex: number): boolean { } export function extractExplicitMemories(text: string): LongTermMemoryEntry[] { + return extractExplicitMemoriesWithEvidence(text).entries; +} + +export type WorkspaceMemoryParseResult = { + entries: LongTermMemoryEntry[]; + evidence: EvidenceEventInput[]; +}; + +function evidenceTextPreview(text: string, maxChars = 120): string { + return redactCredentials(text).replace(/\s+/g, " ").trim().slice(0, maxChars); +} + +function memoryEvidence(memory: LongTermMemoryEntry): EvidenceEventInput["memory"] { + return { + memoryId: memory.id, + type: memory.type, + source: memory.source, + status: memory.status, + }; +} + +function extractionEvidence( + input: Pick, +): EvidenceEventInput { + return input; +} + +export function extractExplicitMemoriesWithEvidence(text: string): WorkspaceMemoryParseResult { // 注意:所有pattern必須有 g flag,因為使用 matchAll() // Pattern 必須在行首匹配,避免匹配到句子中間的非指令式用法 const patterns = [ @@ -71,29 +100,74 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] { const nowMs = Date.now(); const now = new Date(nowMs).toISOString(); const entries: LongTermMemoryEntry[] = []; + const evidence: EvidenceEventInput[] = []; const seen = new Set(); + const negatedLinePattern = /(?:^|\n)\s*(?:(?:please\s+)?(?:do\s+not|don't|dont|never)\s+remember(?:\s+(?:this|that))?|不要\s*(?:記住|记住)|別\s*(?:記住|记住)|别\s*(?:記住|记住))[::,,]?\s*(.+)$/gim; + for (const match of text.matchAll(negatedLinePattern)) { + evidence.push(extractionEvidence({ + type: "explicit_memory_ignored", + phase: "explicit", + outcome: "rejected", + reasonCodes: ["negated_request"], + textPreview: evidenceTextPreview(match[1] ?? match[0], 80), + })); + } for (const pattern of patterns) { for (const match of text.matchAll(pattern)) { const body = match[1]?.trim(); - if (!body || body.length < 8) continue; + if (body && /^(再说|再說|later|next time)$/i.test(body)) { + evidence.push(extractionEvidence({ + type: "explicit_memory_ignored", + phase: "explicit", + outcome: "rejected", + reasonCodes: ["deferral"], + textPreview: evidenceTextPreview(body, 80), + })); + continue; + } + if (!body || body.length < 8) { + evidence.push(extractionEvidence({ + type: "explicit_memory_ignored", + phase: "explicit", + outcome: "rejected", + reasonCodes: ["too_short"], + textPreview: evidenceTextPreview(body ?? match[0], 80), + })); + continue; + } // Calculate actual trigger position (after possible newline) const triggerIndex = match.index! + (match[0].match(/^[\s\n]*/)?.[0]?.length || 0); // Check if this is a negated request (e.g., "不要記住") - if (isNegatedMemoryRequest(text, triggerIndex)) continue; + if (isNegatedMemoryRequest(text, triggerIndex)) { + evidence.push(extractionEvidence({ + type: "explicit_memory_ignored", + phase: "explicit", + outcome: "rejected", + reasonCodes: ["negated_request"], + textPreview: evidenceTextPreview(body, 80), + })); + continue; + } - // Check if it's a deferral (e.g., "later", "next time") - if (/^(再说|再說|later|next time)$/i.test(body)) continue; - // Dedupe by canonical body const key = body.toLowerCase().replace(/\s+/g, " ").trim(); - if (seen.has(key)) continue; + if (seen.has(key)) { + evidence.push(extractionEvidence({ + type: "explicit_memory_ignored", + phase: "explicit", + outcome: "rejected", + reasonCodes: ["duplicate_in_message"], + textPreview: evidenceTextPreview(body, 80), + })); + continue; + } seen.add(key); const type = classifyExplicitMemory(body); - entries.push({ + const memory: LongTermMemoryEntry = { id: id("mem"), type, text: body.slice(0, LONG_TERM_LIMITS.maxEntryTextChars), @@ -104,11 +178,20 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] { updatedAt: now, retentionClock: nowMs, staleAfterDays: staleAfterDaysFor(type), - }); + }; + entries.push(memory); + evidence.push(extractionEvidence({ + type: "explicit_memory_detected", + phase: "explicit", + outcome: "accepted", + reasonCodes: ["explicit_trigger_matched"], + memory: memoryEvidence(memory), + textPreview: evidenceTextPreview(memory.text), + })); } } - return entries; + return { entries, evidence }; } function classifyExplicitMemory(text: string): LongTermType { @@ -251,7 +334,7 @@ async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promi } } -function shouldAcceptWorkspaceMemoryCandidate( +function evaluateWorkspaceMemoryCandidate( entry: { type: LongTermType; text: string; @@ -259,7 +342,7 @@ function shouldAcceptWorkspaceMemoryCandidate( options: { fromMemoryTrigger?: boolean; } = {}, -): boolean { +): { accepted: boolean; reasons: string[] } { const text = entry.text.trim(); const minLength = options.fromMemoryTrigger ? 6 : 20; @@ -267,14 +350,14 @@ function shouldAcceptWorkspaceMemoryCandidate( if (entry.type === "reference" && /\b(?:admin\s+)?pin\s|scrypt|n=\d+|r=\d+|p=\d+/i.test(text)) { // Stable config values can be short — allow below generic min length } else if (text.length < minLength) { - return false; + return { accepted: false, reasons: ["too_short"] }; } // Indirect Prompt Injection / Adversarial Instructions // Rejects attempts to overwrite system behavior or "ignore" rules. // comparative "instead of" is allowed. - if (/\b(ignore\s+all|ignore\s+previous|ignore\s+instruction|overwrite\s+system|overwrite\s+rules|forget\s+all|delete\s+root)\b/i.test(text)) return false; - if (/\b(ignore|instruction|overwrite)\b/i.test(text) && /\b(previous|all|rules|behavior|prompt|system)\b/i.test(text)) return false; + if (/\b(ignore\s+all|ignore\s+previous|ignore\s+instruction|overwrite\s+system|overwrite\s+rules|forget\s+all|delete\s+root)\b/i.test(text)) return { accepted: false, reasons: ["prompt_injection"] }; + if (/\b(ignore|instruction|overwrite)\b/i.test(text) && /\b(previous|all|rules|behavior|prompt|system)\b/i.test(text)) return { accepted: false, reasons: ["prompt_injection"] }; const quality = assessMemoryQuality({ type: entry.type, text, source: "compaction" }); if (!quality.accepted) { @@ -285,10 +368,22 @@ function shouldAcceptWorkspaceMemoryCandidate( reasons: quality.reasons, source: "compaction", }); - return false; + return { accepted: false, reasons: quality.reasons }; } - return true; + return { accepted: true, reasons: ["quality_gate_passed"] }; +} + +function shouldAcceptWorkspaceMemoryCandidate( + entry: { + type: LongTermType; + text: string; + }, + options: { + fromMemoryTrigger?: boolean; + } = {}, +): boolean { + return evaluateWorkspaceMemoryCandidate(entry, options).accepted; } /** @@ -316,12 +411,17 @@ function extractCandidateBlock(summary: string): string | null { } export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryEntry[] { + return parseWorkspaceMemoryCandidatesWithEvidence(summary).entries; +} + +export function parseWorkspaceMemoryCandidatesWithEvidence(summary: string): WorkspaceMemoryParseResult { const block = extractCandidateBlock(summary); - if (!block) return []; + if (!block) return { entries: [], evidence: [] }; const nowMs = Date.now(); const now = new Date(nowMs).toISOString(); const entries: LongTermMemoryEntry[] = []; + const evidence: EvidenceEventInput[] = []; for (const line of block.split("\n")) { // Accept both "- [type] text" (bracketed) and "- type text" (bracketless) @@ -331,18 +431,49 @@ export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryE if (!item) continue; const type = (item[1] ?? item[2]).toLowerCase() as LongTermType; const normalizedBody = normalizeCandidateBody(item[3]); - if (!normalizedBody) continue; + if (!normalizedBody) { + evidence.push(extractionEvidence({ + type: "extraction_candidate_rejected", + phase: "extraction", + outcome: "rejected", + reasonCodes: ["negated_request"], + memory: { type, source: "compaction" }, + textPreview: evidenceTextPreview(item[3], 80), + })); + continue; + } const minLength = normalizedBody.hadTrigger ? 6 : 12; - if (normalizedBody.text.length < minLength) continue; + if (normalizedBody.text.length < minLength) { + evidence.push(extractionEvidence({ + type: "extraction_candidate_rejected", + phase: "extraction", + outcome: "rejected", + reasonCodes: ["too_short"], + memory: { type, source: "compaction" }, + textPreview: evidenceTextPreview(normalizedBody.text, 80), + })); + continue; + } // Apply quality gate - if (!shouldAcceptWorkspaceMemoryCandidate( + const quality = evaluateWorkspaceMemoryCandidate( { type, text: normalizedBody.text }, { fromMemoryTrigger: normalizedBody.hadTrigger }, - )) continue; + ); + if (!quality.accepted) { + evidence.push(extractionEvidence({ + type: "extraction_candidate_rejected", + phase: "extraction", + outcome: "rejected", + reasonCodes: quality.reasons, + memory: { type, source: "compaction" }, + textPreview: evidenceTextPreview(normalizedBody.text, 80), + })); + continue; + } - entries.push({ + const memory: LongTermMemoryEntry = { id: id("mem"), type, text: normalizedBody.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars), @@ -353,8 +484,17 @@ export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryE updatedAt: now, retentionClock: nowMs, staleAfterDays: staleAfterDaysFor(type), - }); + }; + entries.push(memory); + evidence.push(extractionEvidence({ + type: "extraction_candidate_accepted", + phase: "extraction", + outcome: "accepted", + reasonCodes: ["quality_gate_passed", "valid_candidate_format"], + memory: memoryEvidence(memory), + textPreview: evidenceTextPreview(memory.text), + })); } - return entries; + return { entries, evidence }; } diff --git a/src/plugin.ts b/src/plugin.ts index a366916..56db781 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -26,16 +26,17 @@ import { rm } from "fs/promises"; import type { Plugin } from "@opencode-ai/plugin"; import { - extractExplicitMemories, + extractExplicitMemoriesWithEvidence, extractActiveFiles, extractErrorsFromBash, - parseWorkspaceMemoryCandidates, + parseWorkspaceMemoryCandidatesWithEvidence, } from "./extractors.ts"; import { loadWorkspaceMemory, updateWorkspaceMemory, updateWorkspaceMemoryWithAccounting, - renderWorkspaceMemory, + accountWorkspaceMemoryRender, + workspaceMemoryIdentityKey, } from "./workspace-memory.ts"; import { reinforceMemory } from "./retention.ts"; import { @@ -62,7 +63,8 @@ import { latestCompactionSummary, pendingTodos, } from "./opencode.ts"; -import { accountPendingPromotions } from "./promotion-accounting.ts"; +import { accountPendingPromotions, promotionAccountingEvidenceEvents } from "./promotion-accounting.ts"; +import { appendEvidenceEvent, appendEvidenceEvents, type EvidenceEventInput, type MemoryEvidenceRef } from "./evidence-log.ts"; import { type LongTermMemoryEntry, WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts"; /** @@ -162,9 +164,23 @@ function renderTodosForCompaction(todos: Array<{ content: string; status: string return lines.join("\n"); } -function warnMemoryHook(scope: string, error: unknown): void { +function safeErrorMessage(error: unknown): string { const message = error instanceof Error ? error.message : String(error); + return message.replace(/\s+/g, " ").slice(0, 240); +} + +async function warnMemoryHook(scope: string, error: unknown, root?: string): Promise { + const message = safeErrorMessage(error); console.error(`[memory] ${scope} failed: ${message}`); + if (root) { + await appendEvidenceEvent(root, { + type: "hook_failed", + phase: "hook", + outcome: "failed", + reasonCodes: [scope], + details: { message }, + }).catch(() => undefined); + } } export const MemoryV2Plugin: Plugin = async (input) => { @@ -203,6 +219,29 @@ export const MemoryV2Plugin: Plugin = async (input) => { // Cache for processed user message IDs (to avoid duplicate processing) const processedUserMessages = new Map>(); + function memoryEvidenceRef(memory: LongTermMemoryEntry): MemoryEvidenceRef { + return { + memoryId: memory.id, + memoryKeyHash: memoryKey(memory), + identityKeyHash: workspaceMemoryIdentityKey(memory), + type: memory.type, + source: memory.source, + status: memory.status, + }; + } + + function pendingAppendedEvidence(memory: LongTermMemoryEntry): EvidenceEventInput { + return { + type: "pending_memory_appended", + phase: "pending_journal", + outcome: "accepted", + memory: memoryEvidenceRef(memory), + relations: [{ role: "pending", memory: memoryEvidenceRef(memory) }], + reasonCodes: ["pending_journal_append"], + textPreview: memory.text, + }; + } + function pruneFrozenWorkspaceMemoryCache(now = Date.now()): void { for (const [sessionID, cached] of frozenWorkspaceMemoryCache) { if (now - cached.loadedAt > WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs) { @@ -259,7 +298,14 @@ export const MemoryV2Plugin: Plugin = async (input) => { if (!latestMessage?.id || processedForSession.has(latestMessage.id)) return; - const memories = extractExplicitMemories(latestMessage.text).map(memory => ({ + const extraction = extractExplicitMemoriesWithEvidence(latestMessage.text); + await appendEvidenceEvents(directory, extraction.evidence.map(event => ({ + ...event, + sessionHash: sessionID, + messageHash: latestMessage.id, + }))); + + const memories = extraction.entries.map(memory => ({ ...memory, pendingOwnerSessionID: sessionID, pendingMessageID: latestMessage.id, @@ -272,6 +318,11 @@ export const MemoryV2Plugin: Plugin = async (input) => { return state; }); await appendPendingMemories(directory, memories); + await appendEvidenceEvents(directory, memories.map(memory => ({ + ...pendingAppendedEvidence(memory), + sessionHash: sessionID, + messageHash: latestMessage.id, + }))); } if (decisions.length > 0) { @@ -385,6 +436,20 @@ export const MemoryV2Plugin: Plugin = async (input) => { }, ); + await appendEvidenceEvents(directory, [ + ...updateResult.evidence, + ...promotionAccountingEvidenceEvents({ + pending, + after: updateResult.store.entries, + events: updateResult.events, + accounting, + exhaustedRejectedKeys, + }), + ].map(event => ({ + ...event, + sessionHash: sessionID, + }))); + const sessionRemovalKeys = new Set([ ...accounting.clearableKeys, ...exhaustedRejectedKeys, @@ -459,7 +524,12 @@ export const MemoryV2Plugin: Plugin = async (input) => { } const store = await loadWorkspaceMemory(root); - const renderedPrompt = renderWorkspaceMemory(store); + const renderAccounting = accountWorkspaceMemoryRender(store); + const renderedPrompt = renderAccounting.prompt; + await appendEvidenceEvents(root, renderAccounting.evidence.map(event => ({ + ...event, + sessionHash: sessionID, + }))); frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now }); pruneFrozenWorkspaceMemoryCache(now); return { store, renderedPrompt }; @@ -518,7 +588,7 @@ export const MemoryV2Plugin: Plugin = async (input) => { output.system.push(hotPrompt); } } catch (error) { - warnMemoryHook("chat.system.transform", error); + await warnMemoryHook("chat.system.transform", error, directory); } }, @@ -577,7 +647,7 @@ export const MemoryV2Plugin: Plugin = async (input) => { // Only process once per message ID await processLatestUserMessage(sessionID); } catch (error) { - warnMemoryHook("tool.execute.after", error); + await warnMemoryHook("tool.execute.after", error, directory); } }, @@ -641,7 +711,7 @@ export const MemoryV2Plugin: Plugin = async (input) => { // output.context if they want to preserve other plugin contributions. output.context.length = 0; } catch (error) { - warnMemoryHook("session.compacting", error); + await warnMemoryHook("session.compacting", error, directory); } }, @@ -658,15 +728,24 @@ export const MemoryV2Plugin: Plugin = async (input) => { // Parse latest compaction summary for memory candidates, stage them into // durable pending journal, then promote pending memories. const summary = await latestCompactionSummary(client, sessionID); - const candidates = summary ? parseWorkspaceMemoryCandidates(summary) : []; + const parseResult = summary ? parseWorkspaceMemoryCandidatesWithEvidence(summary) : { entries: [], evidence: [] }; + await appendEvidenceEvents(directory, parseResult.evidence.map(event => ({ + ...event, + sessionHash: sessionID, + }))); + const candidates = parseResult.entries; if (candidates.length > 0) { await appendPendingMemories(directory, candidates); + await appendEvidenceEvents(directory, candidates.map(memory => ({ + ...pendingAppendedEvidence(memory), + sessionHash: sessionID, + }))); } await promotePendingMemories(sessionID, { includeUnownedJournal: true }); } catch (error) { // Keep pending memories in session/journal for retry on next event/session. - warnMemoryHook("event.session.compacted", error); + await warnMemoryHook("event.session.compacted", error, directory); } } @@ -688,7 +767,7 @@ export const MemoryV2Plugin: Plugin = async (input) => { await rm(await sessionStatePath(directory, sessionID), { force: true }); } } catch (error) { - warnMemoryHook("event.session.deleted", error); + await warnMemoryHook("event.session.deleted", error, directory); } } }, diff --git a/src/promotion-accounting.ts b/src/promotion-accounting.ts index 462b740..382e975 100644 --- a/src/promotion-accounting.ts +++ b/src/promotion-accounting.ts @@ -2,6 +2,7 @@ import type { LongTermMemoryEntry } from "./types.ts"; import { memoryKey } from "./pending-journal.ts"; import type { MemoryConsolidationEvent } from "./workspace-memory.ts"; import { workspaceMemoryIdentityKey } from "./workspace-memory.ts"; +import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts"; export type PendingPromotionAccounting = { promotedKeys: Set; @@ -105,3 +106,118 @@ export function accountPendingPromotions(input: { clearableKeys, }; } + +function memoryRef(memory: LongTermMemoryEntry | undefined): MemoryEvidenceRef | undefined { + if (!memory) return undefined; + return { + memoryId: memory.id, + memoryKeyHash: memoryKey(memory), + identityKeyHash: workspaceMemoryIdentityKey(memory), + type: memory.type, + source: memory.source, + status: memory.status, + }; +} + +function retainedMemoryFor( + pending: LongTermMemoryEntry, + event: MemoryConsolidationEvent | undefined, + after: LongTermMemoryEntry[], +): LongTermMemoryEntry | undefined { + if (event?.retainedId) { + const byId = after.find(memory => memory.id === event.retainedId); + if (byId) return byId; + } + + const exactKey = memoryKey(pending); + const identityKey = workspaceMemoryIdentityKey(pending); + return after.find(memory => memory.status !== "superseded" && ( + memoryKey(memory) === exactKey || workspaceMemoryIdentityKey(memory) === identityKey + )); +} + +function promotionEventBase( + type: EvidenceEventInput["type"], + outcome: EvidenceEventInput["outcome"], + memory: LongTermMemoryEntry, + reasonCodes: string[], +): EvidenceEventInput { + return { + type, + phase: "promotion", + outcome, + memory: memoryRef(memory), + reasonCodes, + textPreview: memory.text, + }; +} + +export function promotionAccountingEvidenceEvents(input: { + pending: LongTermMemoryEntry[]; + after: LongTermMemoryEntry[]; + events?: MemoryConsolidationEvent[]; + accounting: PendingPromotionAccounting; + exhaustedRejectedKeys?: Set; +}): EvidenceEventInput[] { + const terminalByKey = new Map((input.events ?? []).map(event => [event.memoryKey, event])); + const exhaustedRejectedKeys = input.exhaustedRejectedKeys ?? new Set(); + const evidence: EvidenceEventInput[] = []; + + for (const pending of input.pending) { + const key = memoryKey(pending); + const terminal = terminalByKey.get(key); + const retained = retainedMemoryFor(pending, terminal, input.after); + + if (input.accounting.promotedKeys.has(key)) { + evidence.push({ + ...promotionEventBase("promotion_promoted", "promoted", pending, ["new_workspace_entry"]), + relations: [ + { role: "promoted", memory: memoryRef(retained ?? pending) }, + ], + }); + continue; + } + + if (input.accounting.absorbedKeys.has(key)) { + const exact = terminal?.reason !== "absorbed_identity"; + evidence.push({ + ...promotionEventBase( + exact ? "promotion_absorbed_exact" : "promotion_absorbed_identity", + "absorbed", + pending, + [exact ? "same_exact_key" : "same_identity_key"], + ), + relations: [ + { role: "absorbed" as const, memory: memoryRef(pending) }, + { role: "retained" as const, memory: memoryRef(retained) }, + ].filter(relation => relation.memory), + }); + continue; + } + + if (input.accounting.supersededKeys.has(key)) { + evidence.push({ + ...promotionEventBase("promotion_superseded", "superseded", pending, ["superseded_existing"]), + relations: [ + { role: "superseded" as const, memory: memoryRef(pending) }, + { role: "superseded_by" as const, memory: memoryRef(retained) }, + ].filter(relation => relation.memory), + }); + continue; + } + + if (input.accounting.rejectedKeys.has(key)) { + evidence.push(promotionEventBase("promotion_rejected_capacity", "rejected", pending, ["capacity_rejected"])); + if (input.accounting.retryableRejectedKeys.has(key)) { + evidence.push(promotionEventBase( + exhaustedRejectedKeys.has(key) ? "promotion_retry_exhausted" : "promotion_retry_scheduled", + exhaustedRejectedKeys.has(key) ? "exhausted" : "retried", + pending, + [exhaustedRejectedKeys.has(key) ? "max_attempts_reached" : "retryable_capacity_rejection"], + )); + } + } + } + + return evidence; +} diff --git a/src/storage.ts b/src/storage.ts index e4a283f..b1e8cc9 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -3,12 +3,30 @@ import { randomUUID } from "crypto"; import { mkdir, open, readFile, rename, rm, stat, writeFile } from "fs/promises"; import type { FileHandle } from "fs/promises"; import { dirname } from "path"; +import { appendEvidenceEventForWorkspaceKey } from "./evidence-log.ts"; const fileLocks = new Map>(); const LOCK_WAIT_TIMEOUT_MS = 5000; const LOCK_STALE_MS = 30_000; const LOCK_HEARTBEAT_MS = 1_000; +function workspaceKeyFromStorePath(path: string): string | undefined { + return path.match(/[\\/]opencode-working-memory[\\/]workspaces[\\/]([a-f0-9]{16})[\\/]/i)?.[1]; +} + +function storeKindFromPath(path: string): string { + if (path.endsWith("workspace-memory.json")) return "workspace_memory"; + if (path.endsWith("workspace-pending-journal.json")) return "pending_journal"; + if (path.includes(`${"/"}sessions${"/"}`) || path.includes(`${"\\"}sessions${"\\"}`)) return "session_state"; + return "unknown"; +} + +async function emitStorageEvidence(path: string, event: Parameters[1]): Promise { + const key = workspaceKeyFromStorePath(path); + if (!key) return; + await appendEvidenceEventForWorkspaceKey(key, event).catch(() => undefined); +} + async function quarantineCorruptJSON(path: string): Promise { const quarantinePath = `${path}.corrupt-${Date.now()}-${process.pid}-${randomUUID()}`; @@ -31,6 +49,16 @@ export async function readJSON(path: string, fallback: () => T): Promise { if (quarantinePath) { console.error(`[memory] invalid JSON in ${path}; quarantined to ${quarantinePath}: ${message}`); + await emitStorageEvidence(path, { + type: "storage_corrupt_json_quarantined", + phase: "storage", + outcome: "quarantined", + reasonCodes: ["invalid_json"], + details: { + storeKind: storeKindFromPath(path), + quarantined: true, + }, + }); } else { console.error(`[memory] invalid JSON in ${path}; using fallback without quarantine: ${message}`); } @@ -103,10 +131,30 @@ async function withFileLock(path: string, fn: () => Promise): Promise { if (await isLockStale(lockPath)) { await rm(lockPath, { force: true }); + await emitStorageEvidence(path, { + type: "storage_stale_lock_recovered", + phase: "storage", + outcome: "recovered", + reasonCodes: ["stale_lock"], + details: { + storeKind: storeKindFromPath(path), + waitMs: Date.now() - started, + }, + }); continue; } if (Date.now() - started > LOCK_WAIT_TIMEOUT_MS) { + await emitStorageEvidence(path, { + type: "storage_lock_timeout", + phase: "storage", + outcome: "failed", + reasonCodes: ["lock_wait_timeout"], + details: { + storeKind: storeKindFromPath(path), + waitMs: LOCK_WAIT_TIMEOUT_MS, + }, + }); throw new Error(`Timed out waiting for lock ${lockPath}`); } diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index a5fbdc5..5c15447 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -11,6 +11,7 @@ import { calculateRetentionStrength, reinforceMemory, } from "./retention.ts"; +import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts"; // Minimum length for workspace_memory envelope: \n...\n const MIN_ENVELOPE_LENGTH = 80; @@ -38,6 +39,7 @@ export type LongTermLimitResult = { dropped: MemoryConsolidationEvent[]; absorbed: MemoryConsolidationEvent[]; superseded: MemoryConsolidationEvent[]; + evidence: EvidenceEventInput[]; }; export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & { @@ -45,6 +47,16 @@ export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & { events: MemoryConsolidationEvent[]; }; +export type WorkspaceMemoryRenderAccounting = { + rendered: LongTermMemoryEntry[]; + omitted: Array<{ + memory: LongTermMemoryEntry; + reason: "superseded" | "type_cap" | "global_cap" | "char_budget" | "empty_render_budget"; + }>; + evidence: EvidenceEventInput[]; + prompt: string; +}; + export type QualityCleanupMigrationLogEntry = { migrationId: string; timestamp: string; @@ -156,6 +168,7 @@ export async function updateWorkspaceMemoryWithAccounting( dropped: [], absorbed: [], superseded: [], + evidence: [], events: [], }; } @@ -244,6 +257,7 @@ export async function normalizeWorkspaceMemoryWithAccounting( dropped: accounting.dropped, absorbed: accounting.absorbed, superseded: accounting.superseded, + evidence: accounting.evidence, events: [...accounting.dropped, ...accounting.absorbed, ...accounting.superseded], }; } @@ -520,29 +534,39 @@ export function enforceLongTermLimitsWithAccounting( dropped: [...dedupeResult.dropped, ...capacityDropped], absorbed: dedupeResult.absorbed, superseded: dedupeResult.superseded, + evidence: dedupeResult.evidence, }; } function applyTypeMaxCaps(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] { + return applyTypeMaxCapsWithOmissions(entries).kept; +} + +function applyTypeMaxCapsWithOmissions(entries: LongTermMemoryEntry[]): { kept: LongTermMemoryEntry[]; omitted: LongTermMemoryEntry[] } { const capped: LongTermMemoryEntry[] = []; + const omitted: LongTermMemoryEntry[] = []; const typeCounts: Partial> = {}; for (const entry of entries) { const count = typeCounts[entry.type] ?? 0; const max = RETENTION_TYPE_MAX[entry.type] ?? Infinity; - if (count >= max) continue; + if (count >= max) { + omitted.push(entry); + continue; + } capped.push(entry); typeCounts[entry.type] = count + 1; } - return capped; + return { kept: capped, omitted }; } export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult { const now = Date.now(); const absorbed: MemoryConsolidationEvent[] = []; const superseded: MemoryConsolidationEvent[] = []; + const evidence: EvidenceEventInput[] = []; // For project/reference/feedback: dedupe by concrete identity or exact canonical text. const projectRefEntries = entries.filter(e => e.type === "project" || e.type === "reference" || e.type === "feedback"); @@ -566,6 +590,8 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry reinforcementSessionId(retained, dropped), now, ); + const reinforcedEvent = reinforcementEvidence(retained, dropped, reinforced, reason); + if (reinforcedEvent) evidence.push(reinforcedEvent); absorbed.push(consolidationEvent(dropped, reason, reinforced)); entityDeduped.set(key, reinforced); @@ -592,6 +618,8 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry reinforcementSessionId(retained, dropped), now, ); + const reinforcedEvent = reinforcementEvidence(retained, dropped, reinforced, reason); + if (reinforcedEvent) evidence.push(reinforcedEvent); if (reason === "superseded_existing") { superseded.push(consolidationEvent(dropped, reason, reinforced)); @@ -614,6 +642,40 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry dropped: [], absorbed, superseded, + evidence, + }; +} + +function memoryEvidenceRef(memory: LongTermMemoryEntry): MemoryEvidenceRef { + return { + memoryId: memory.id, + memoryKeyHash: workspaceMemoryExactKey(memory), + identityKeyHash: workspaceMemoryIdentityKey(memory), + type: memory.type, + source: memory.source, + status: memory.status, + }; +} + +function reinforcementEvidence( + retained: LongTermMemoryEntry, + dropped: LongTermMemoryEntry, + reinforced: LongTermMemoryEntry, + reason: "absorbed_exact" | "absorbed_identity" | "superseded_existing", +): EvidenceEventInput | undefined { + if ((reinforced.reinforcementCount ?? 0) <= (retained.reinforcementCount ?? 0)) return undefined; + const duplicateReason = reason === "absorbed_identity" ? "duplicate_identity" : "duplicate_exact"; + return { + type: "memory_reinforced", + phase: "reinforcement", + outcome: "reinforced", + memory: memoryEvidenceRef(reinforced), + relations: [ + { role: "reinforced", memory: memoryEvidenceRef(reinforced) }, + { role: "reinforced_by", memory: memoryEvidenceRef(dropped) }, + ], + reasonCodes: [duplicateReason, "reinforcement_window_allowed"], + textPreview: reinforced.text, }; } @@ -648,20 +710,49 @@ function wouldFit( } export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string { - const active = enforceLongTermLimitsWithAccounting(store.entries, store).kept; - if (active.length === 0) return ""; + return accountWorkspaceMemoryRender(store).prompt; +} +export function accountWorkspaceMemoryRender(store: WorkspaceMemoryStore): WorkspaceMemoryRenderAccounting { + const now = Date.now(); const maxChars = Math.min( store.limits.maxRenderedChars, LONG_TERM_LIMITS.maxRenderedChars ); + const omitted: WorkspaceMemoryRenderAccounting["omitted"] = []; + const evidence: EvidenceEventInput[] = []; + + for (const entry of store.entries) { + if (entry.status === "superseded") { + omitted.push({ memory: entry, reason: "superseded" }); + } + } + + const activeEntries = store.entries.filter(entry => entry.status !== "superseded"); + const phase1 = activeEntries.map(entry => ({ ...entry, text: entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars) })); + const dedupeResult = dedupeLongTermEntriesWithAccounting(phase1); + const sorted = [...dedupeResult.kept].sort((a, b) => compareLongTermMemoryForRetention(a, b, now, store.lastActivityAt)); + const typeCapResult = applyTypeMaxCapsWithOmissions(sorted); + for (const memory of typeCapResult.omitted) omitted.push({ memory, reason: "type_cap" }); + const active = typeCapResult.kept.slice(0, LONG_TERM_LIMITS.maxEntries); + for (const memory of typeCapResult.kept.slice(LONG_TERM_LIMITS.maxEntries)) omitted.push({ memory, reason: "global_cap" }); + + if (active.length === 0) { + for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason)); + return { rendered: [], omitted, evidence, prompt: "" }; + } // If maxChars smaller than minimum envelope, return empty string - if (maxChars < MIN_ENVELOPE_LENGTH) return ""; + if (maxChars < MIN_ENVELOPE_LENGTH) { + for (const memory of active) omitted.push({ memory, reason: "empty_render_budget" }); + for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason)); + return { rendered: [], omitted, evidence, prompt: "" }; + } const lines: string[] = [ "Workspace memory (cross-session, verify if stale):", ]; + const rendered: LongTermMemoryEntry[] = []; for (const type of ["feedback", "project", "decision", "reference"] as const) { const items = active.filter(entry => entry.type === type); @@ -673,6 +764,9 @@ export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string { const line = `- ${renderEntry(item)}`; if ([...lines, ...sectionLines, line].join("\n").length <= maxChars) { sectionLines.push(line); + rendered.push(item); + } else { + omitted.push({ memory: item, reason: "char_budget" }); } } @@ -681,7 +775,26 @@ export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string { } } - return lines.join("\n"); + for (const memory of rendered) evidence.push(renderEvidence(memory, "rendered")); + for (const item of omitted) evidence.push(renderEvidence(item.memory, "omitted", item.reason)); + + return { rendered, omitted, evidence, prompt: lines.join("\n") }; +} + +function renderEvidence( + memory: LongTermMemoryEntry, + outcome: "rendered" | "omitted", + reason?: WorkspaceMemoryRenderAccounting["omitted"][number]["reason"], +): EvidenceEventInput { + return { + type: outcome === "rendered" ? "render_selected" : "render_omitted", + phase: "render", + outcome, + memory: memoryEvidenceRef(memory), + relations: [{ role: outcome === "rendered" ? "rendered" : "omitted", memory: memoryEvidenceRef(memory) }], + reasonCodes: outcome === "rendered" ? ["within_caps", "within_char_budget"] : [reason ?? "char_budget"], + textPreview: memory.text, + }; } function renderEntry(entry: LongTermMemoryEntry): string { diff --git a/tests/extractors.test.ts b/tests/extractors.test.ts index 957bb48..f22cdcd 100644 --- a/tests/extractors.test.ts +++ b/tests/extractors.test.ts @@ -3,7 +3,13 @@ import assert from "node:assert/strict"; 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"; +import { + extractErrorsFromBash, + extractExplicitMemories, + extractExplicitMemoriesWithEvidence, + parseWorkspaceMemoryCandidates, + parseWorkspaceMemoryCandidatesWithEvidence, +} from "../src/extractors.ts"; async function waitForFile(path: string, attempts = 20): Promise { let lastError: unknown; @@ -145,6 +151,20 @@ test("extractExplicitMemories captures multiple memories in same message", () => assert.equal(items.length, 2); }); +test("explicit memory extraction returns detected and ignored evidence", () => { + const result = extractExplicitMemoriesWithEvidence([ + "remember this: Prefer deterministic tests.", + "don't remember this: temporary password: sushi", + "remember this: later", + ].join("\n")); + + assert.equal(result.entries.length, 1); + assert.ok(result.evidence.some(event => event.type === "explicit_memory_detected")); + assert.ok(result.evidence.some(event => event.type === "explicit_memory_ignored" && event.reasonCodes.includes("negated_request"))); + assert.ok(result.evidence.some(event => event.type === "explicit_memory_ignored" && event.reasonCodes.includes("deferral"))); + assert.equal(JSON.stringify(result.evidence).includes("sushi"), false); +}); + // ============================================ // Task 7: Compaction quality gate tests // ============================================ @@ -176,6 +196,41 @@ test("parseWorkspaceMemoryCandidates rejects raw error", () => { assert.equal(items.length, 0); }); +test("compaction accepted candidate returns privacy-safe extraction evidence", () => { + const summary = ` +Memory candidates: +- [decision] Use accounting evidence events to explain promoted memories in diagnostics. +`; + + const result = parseWorkspaceMemoryCandidatesWithEvidence(summary); + + assert.equal(result.entries.length, 1); + assert.equal(result.evidence.length, 1); + assert.equal(result.evidence[0].type, "extraction_candidate_accepted"); + assert.ok(result.evidence[0].reasonCodes.includes("quality_gate_passed")); + assert.ok(result.evidence[0].reasonCodes.includes("valid_candidate_format")); + assert.match(result.evidence[0].textPreview ?? "", /accounting evidence events/); +}); + +test("compaction rejected candidate returns rejection evidence without secrets", () => { + const summary = ` +Memory candidates: +- [feedback] password: sushi Admin PIN 是 456123 Bearer abc.def.ghi TypeError: Cannot read property x +`; + + const result = parseWorkspaceMemoryCandidatesWithEvidence(summary); + const raw = JSON.stringify(result.evidence); + + assert.equal(result.entries.length, 0); + assert.equal(result.evidence.length, 1); + assert.equal(result.evidence[0].type, "extraction_candidate_rejected"); + assert.ok(result.evidence[0].reasonCodes.length > 0); + assert.equal(raw.includes("sushi"), false); + assert.equal(raw.includes("456123"), false); + assert.equal(raw.includes("abc.def.ghi"), false); + assert.ok((result.evidence[0].textPreview?.length ?? 0) <= 80); +}); + test("parseWorkspaceMemoryCandidates rejects stack trace", () => { const summary = ` ## Memory Candidates diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 42b6bcb..bad4fa9 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -8,9 +8,10 @@ import { loadSessionState, saveSessionState } from "../src/session-state.ts"; import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts"; import type { OpenError } from "../src/types.ts"; import { PROMOTION_RETRY_LIMITS, WORKSPACE_MEMORY_CACHE_LIMITS } from "../src/types.ts"; -import { workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts"; +import { sessionStatePath, workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts"; import { loadPendingJournal, savePendingJournal, memoryKey } from "../src/pending-journal.ts"; import { loadWorkspaceMemory, updateWorkspaceMemory } from "../src/workspace-memory.ts"; +import { queryEvidenceEvents } from "../src/evidence-log.ts"; // Mock client for root session (not a sub-agent) function mockRootClient() { @@ -477,6 +478,35 @@ test("chat system transform degrades gracefully when workspace memory JSON is co } }); +test("hook failure emits hook_failed evidence without raw tool output", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + const statePath = await sessionStatePath(tmpDir, "hook-failure-session"); + await mkdir(dirname(statePath), { recursive: true }); + await mkdir(statePath, { recursive: true }); + + const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() }); + await (plugin as Record)["tool.execute.after"]( + { + tool: "bash", + sessionID: "hook-failure-session", + args: { command: "cat secret-output.txt" }, + }, + { output: "raw tool output password: sushi should not be in evidence", exitCode: 1 }, + ); + + const events = await queryEvidenceEvents(tmpDir, { types: ["hook_failed"] }); + const raw = JSON.stringify(events); + assert.equal(events.length, 1); + assert.equal(events[0].reasonCodes.includes("tool.execute.after"), true); + assert.equal(raw.includes("raw tool output"), false); + assert.equal(raw.includes("sushi"), false); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + test("no compaction: owned explicit memory is not promoted by unrelated next session start", async () => { const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); @@ -903,6 +933,44 @@ test("integration: explicit memory flows from user message through pending journ } }); +test("explicit memory lifecycle emits detected appended and promoted evidence", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); + + try { + const plugin = await MemoryV2Plugin({ + directory: tmpDir, + client: mockClientWithLatestUser("remember this: Prefer evidence-backed lifecycle tests.", "msg-evidence-life"), + }); + + await (plugin as Record)["experimental.chat.system.transform"]( + { sessionID: "evidence-life-session", model: {} }, + { system: ["base header"] }, + ); + + await (plugin as Record)["event"]({ + event: { type: "session.compacted", properties: { sessionID: "evidence-life-session" } }, + }); + + const events = await queryEvidenceEvents(tmpDir, { newestFirst: false }); + const eventTypes = events.map(event => event.type); + + assert.ok(eventTypes.includes("explicit_memory_detected")); + assert.ok(eventTypes.includes("pending_memory_appended")); + assert.ok(eventTypes.includes("promotion_promoted")); + + const output = { system: ["base header"] }; + await (plugin as Record)["experimental.chat.system.transform"]( + { sessionID: "evidence-life-session", model: {} }, + output, + ); + + const finalEvents = await queryEvidenceEvents(tmpDir, { newestFirst: false }); + assert.ok(finalEvents.map(event => event.type).includes("render_selected")); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +}); + test("session.compacted promotes first-time explicit memory without self-reinforcement", async () => { const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-")); diff --git a/tests/promotion-accounting.test.ts b/tests/promotion-accounting.test.ts index ac308c0..fe6bf90 100644 --- a/tests/promotion-accounting.test.ts +++ b/tests/promotion-accounting.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import type { LongTermMemoryEntry } from "../src/types.ts"; -import { accountPendingPromotions } from "../src/promotion-accounting.ts"; +import { accountPendingPromotions, promotionAccountingEvidenceEvents } from "../src/promotion-accounting.ts"; import { memoryKey } from "../src/pending-journal.ts"; import type { MemoryConsolidationEvent } from "../src/workspace-memory.ts"; import { workspaceMemoryExactKey, workspaceMemoryIdentityKey } from "../src/workspace-memory.ts"; @@ -229,3 +229,57 @@ test("accountPendingPromotions marks manual capacity rejection as retryable", () assert.equal(result.clearableKeys.size, 0); assert.deepEqual([...result.retryableRejectedKeys], [memoryKey(pending[0])]); }); + +test("promotionAccountingEvidenceEvents maps every promotion outcome with relations", () => { + const promoted = mem("promoted", "Promoted memory should produce evidence.", { source: "explicit" }); + const absorbed = mem("absorbed", "Absorbed memory should produce evidence.", { source: "explicit" }); + const retained = mem("retained", "absorbed memory should produce evidence.", { source: "explicit" }); + const identityAbsorbed = mem("identity-absorbed", "Project config lives in `src/config.ts`", { type: "reference" }); + const identityRetained = mem("identity-retained", "Project config lives in `./src/config.ts`", { type: "reference" }); + const superseded = mem("superseded", "Parser supports 3 formats.", { source: "compaction" }); + const replacement = mem("replacement", "Parser supports 4 formats.", { source: "compaction" }); + const capacity = mem("capacity", "Capacity rejected explicit memory should retry.", { source: "explicit", type: "reference" }); + const exhausted = mem("exhausted", "Exhausted explicit memory should stop retrying.", { source: "explicit", type: "reference" }); + const pending = [promoted, absorbed, identityAbsorbed, superseded, capacity, exhausted]; + const accounting = { + promotedKeys: new Set([memoryKey(promoted)]), + absorbedKeys: new Set([memoryKey(absorbed), memoryKey(identityAbsorbed)]), + supersededKeys: new Set([memoryKey(superseded)]), + rejectedKeys: new Set([memoryKey(capacity), memoryKey(exhausted)]), + retryableRejectedKeys: new Set([memoryKey(capacity), memoryKey(exhausted)]), + clearableKeys: new Set([memoryKey(promoted), memoryKey(absorbed), memoryKey(identityAbsorbed), memoryKey(superseded), memoryKey(exhausted)]), + }; + const events = [ + { ...event(absorbed, "absorbed_exact"), retainedId: retained.id }, + { ...event(identityAbsorbed, "absorbed_identity"), retainedId: identityRetained.id }, + { ...event(superseded, "superseded_existing"), retainedId: replacement.id, supersededId: superseded.id }, + event(capacity, "rejected_capacity"), + event(exhausted, "rejected_capacity"), + ]; + + const evidence = promotionAccountingEvidenceEvents({ + pending, + after: [promoted, retained, identityRetained, replacement], + events, + accounting, + exhaustedRejectedKeys: new Set([memoryKey(exhausted)]), + }); + + const expectedPromotionEventTypes = new Set([ + "promotion_promoted", + "promotion_absorbed_exact", + "promotion_absorbed_identity", + "promotion_superseded", + "promotion_rejected_capacity", + "promotion_retry_scheduled", + "promotion_retry_exhausted", + ]); + + assert.deepEqual(new Set(evidence.map(event => event.type)), expectedPromotionEventTypes); + const absorbedEvent = evidence.find(event => event.type === "promotion_absorbed_exact"); + assert.ok(absorbedEvent?.relations?.some(relation => relation.role === "absorbed" && relation.memory?.memoryId === absorbed.id)); + assert.ok(absorbedEvent?.relations?.some(relation => relation.role === "retained" && relation.memory?.memoryId === retained.id)); + const supersededEvent = evidence.find(event => event.type === "promotion_superseded"); + assert.ok(supersededEvent?.relations?.some(relation => relation.role === "superseded" && relation.memory?.memoryId === superseded.id)); + assert.ok(supersededEvent?.relations?.some(relation => relation.role === "superseded_by" && relation.memory?.memoryId === replacement.id)); +}); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index d6f0e3b..8ba4a46 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -1,11 +1,13 @@ import test from "node:test"; import assert from "node:assert/strict"; import { existsSync } from "node:fs"; -import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { spawn } from "node:child_process"; import { readJSON, updateJSON } from "../src/storage.ts"; +import { queryEvidenceEvents } from "../src/evidence-log.ts"; +import { workspaceMemoryPath } from "../src/paths.ts"; test("updateJSON serializes concurrent increments", async () => { const root = await mkdtemp(join(tmpdir(), "wm-storage-")); @@ -56,6 +58,25 @@ test("readJSON quarantines corrupt JSON and returns fallback", async () => { } }); +test("readJSON emits corrupt JSON quarantine evidence for workspace stores", async () => { + const root = await mkdtemp(join(tmpdir(), "wm-storage-evidence-corrupt-")); + try { + const path = await workspaceMemoryPath(root); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, "{ invalid json", "utf8"); + + const loaded = await readJSON(path, () => ({ ok: true })); + const events = await queryEvidenceEvents(root, { types: ["storage_corrupt_json_quarantined"] }); + + assert.deepEqual(loaded, { ok: true }); + assert.equal(events.length, 1); + assert.equal(events[0].reasonCodes.includes("invalid_json"), true); + assert.equal(JSON.stringify(events).includes("invalid json"), false); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("updateJSON recovers stale lock files left by crashed process", async () => { const root = await mkdtemp(join(tmpdir(), "wm-storage-stale-lock-")); try { @@ -71,6 +92,47 @@ test("updateJSON recovers stale lock files left by crashed process", async () => } }); +test("updateJSON emits stale lock recovery evidence for workspace stores", async () => { + const root = await mkdtemp(join(tmpdir(), "wm-storage-evidence-stale-lock-")); + try { + const path = await workspaceMemoryPath(root); + const lockPath = `${path}.lock`; + await mkdir(dirname(path), { recursive: true }); + await writeFile(lockPath, `999999\n0\n`, "utf8"); + + await updateJSON(path, () => ({ count: 0 }), current => ({ count: current.count + 1 })); + const events = await queryEvidenceEvents(root, { types: ["storage_stale_lock_recovered"] }); + + assert.equal(events.length, 1); + assert.equal(events[0].reasonCodes.includes("stale_lock"), true); + assert.equal(JSON.stringify(events).includes("999999"), false); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("updateJSON emits lock timeout evidence and still throws", async () => { + const root = await mkdtemp(join(tmpdir(), "wm-storage-evidence-timeout-")); + try { + const path = await workspaceMemoryPath(root); + const lockPath = `${path}.lock`; + await mkdir(dirname(path), { recursive: true }); + await writeFile(lockPath, `${process.pid}\n${Date.now()}\n`, "utf8"); + + await assert.rejects( + updateJSON(path, () => ({ count: 0 }), current => current), + /Timed out waiting for lock/, + ); + const events = await queryEvidenceEvents(root, { types: ["storage_lock_timeout"] }); + + assert.equal(events.length, 1); + assert.equal(events[0].reasonCodes.includes("lock_wait_timeout"), true); + assert.equal(JSON.stringify(events).includes(String(process.pid)), false); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("updateJSON serializes writes across separate node processes", async () => { const root = await mkdtemp(join(tmpdir(), "wm-storage-xproc-")); try { diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index b592d34..dd13824 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -8,6 +8,7 @@ import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "../src/types.ts"; import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts"; import { renderWorkspaceMemory, + accountWorkspaceMemoryRender, enforceLongTermLimits, dedupeLongTermEntriesWithAccounting, enforceLongTermLimitsWithAccounting, @@ -157,6 +158,47 @@ test("renderWorkspaceMemory returns empty for no entries", () => { assert.equal(rendered, ""); }); +test("accountWorkspaceMemoryRender reports rendered and omitted reasons", () => { + const store: WorkspaceMemoryStore = { + version: 1, + workspace: { root: "/repo", key: "abc" }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [ + ...Array.from({ length: 12 }, (_, i) => entry(`feedback-render-${i}`, `Unique rendered feedback preference ${i}`, "feedback")), + { ...entry("superseded-render", "Old superseded memory", "decision"), status: "superseded" as const }, + ], + updatedAt: new Date().toISOString(), + }; + + const accounting = accountWorkspaceMemoryRender(store); + + assert.equal(accounting.rendered.length, 10); + assert.ok(accounting.omitted.some(item => item.reason === "type_cap")); + assert.ok(accounting.omitted.some(item => item.reason === "superseded")); + assert.ok(accounting.evidence.some(event => event.type === "render_selected")); + assert.ok(accounting.evidence.some(event => event.type === "render_omitted" && event.reasonCodes.includes("type_cap"))); +}); + +test("accountWorkspaceMemoryRender reports char budget and empty budget omissions", () => { + const charBudgetStore: WorkspaceMemoryStore = { + version: 1, + workspace: { root: "/repo", key: "abc" }, + limits: { maxRenderedChars: 180, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: Array.from({ length: 3 }, (_, i) => entry(`char-budget-${i}`, `Long rendered memory ${i} `.repeat(20), "decision")), + updatedAt: new Date().toISOString(), + }; + const emptyBudgetStore: WorkspaceMemoryStore = { + ...charBudgetStore, + limits: { maxRenderedChars: 10, maxEntries: LONG_TERM_LIMITS.maxEntries }, + }; + + const charBudget = accountWorkspaceMemoryRender(charBudgetStore); + const emptyBudget = accountWorkspaceMemoryRender(emptyBudgetStore); + + assert.ok(charBudget.omitted.some(item => item.reason === "char_budget")); + assert.ok(emptyBudget.omitted.some(item => item.reason === "empty_render_budget")); +}); + // ============================================ // PR-2 Task 5 tests (for enforceLongTermLimits) // ============================================ @@ -485,6 +527,30 @@ test("dedupeLongTermEntriesWithAccounting reinforces absorbed exact duplicates", assert.equal(result.kept[0].reinforcementCount, 1); assert.equal(result.kept[0].lastReinforcedSessionID, "reinforce-session"); assert.ok(typeof result.kept[0].retentionClock === "number"); + assert.ok(result.evidence.some(event => + event.type === "memory_reinforced" && + event.reasonCodes.includes("duplicate_exact") && + event.relations?.some(relation => relation.role === "reinforced" && relation.memory?.memoryId === "duplicate") && + event.relations?.some(relation => relation.role === "reinforced_by" && relation.memory?.memoryId === "retained") + )); +}); + +test("dedupeLongTermEntriesWithAccounting emits identity reinforcement evidence", () => { + const now = Date.now(); + const retained: LongTermMemoryEntry = { + ...entry("retained-identity", "OpenCode plugin config location: `.opencode-agenthub/current/xdg/opencode/opencode.json` in workspace", "reference"), + retentionClock: now - 10 * DAY_MS, + }; + const duplicate: LongTermMemoryEntry = { + ...entry("duplicate-identity", "OpenCode plugin config: .opencode-agenthub/current/xdg/opencode/opencode.json in workspace", "reference"), + pendingOwnerSessionID: "identity-session", + }; + + const result = dedupeLongTermEntriesWithAccounting([retained, duplicate]); + + assert.ok(result.evidence.some(event => + event.type === "memory_reinforced" && event.reasonCodes.includes("duplicate_identity") + )); }); test("reinforced memory with same initial strength and age ranks above unreinforced memory", () => { @@ -530,6 +596,7 @@ test("dedupe reinforcement does not increment for same session", () => { assert.ok(retained, "existing manual memory should be retained"); assert.equal(retained.reinforcementCount, 1); assert.equal(retained.lastReinforcedSessionID, "same-session"); + assert.equal(result.evidence.some(event => event.type === "memory_reinforced"), false); }); test("dedupe reinforcement does not increment under one hour", () => { @@ -552,6 +619,23 @@ test("dedupe reinforcement does not increment under one hour", () => { assert.ok(retained, "existing manual memory should be retained"); assert.equal(retained.reinforcementCount, 1); assert.equal(retained.lastReinforcedSessionID, "old-session"); + assert.equal(result.evidence.some(event => event.type === "memory_reinforced"), false); +}); + +test("dedupe reinforcement does not emit evidence at max reinforcement count", () => { + const existing: LongTermMemoryEntry = { + ...entry("existing-max", "Prefer deterministic consolidation accounting", "feedback"), + source: "manual", + reinforcementCount: 6, + }; + const duplicate: LongTermMemoryEntry = { + ...entry("duplicate-max", "prefer deterministic consolidation accounting!!!", "feedback"), + pendingOwnerSessionID: "new-session", + }; + + const result = dedupeLongTermEntriesWithAccounting([existing, duplicate]); + + assert.equal(result.evidence.some(event => event.type === "memory_reinforced"), false); }); test("enforceLongTermLimits orders entries by retention strength", () => {