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
This commit is contained in:
Ralph Chang
2026-04-30 17:54:13 +08:00
parent 6a81fc384c
commit bc0847e3ed
11 changed files with 880 additions and 50 deletions
+13 -2
View File
@@ -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<EvidenceEventV1> {
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;
+164 -24
View File
@@ -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, "type" | "phase" | "outcome" | "reasonCodes" | "textPreview" | "memory" | "details">,
): 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<string>();
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 };
}
+92 -13
View File
@@ -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<void> {
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<string, Set<string>>();
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);
}
}
},
+116
View File
@@ -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<string>;
@@ -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<string>;
}): EvidenceEventInput[] {
const terminalByKey = new Map((input.events ?? []).map(event => [event.memoryKey, event]));
const exhaustedRejectedKeys = input.exhaustedRejectedKeys ?? new Set<string>();
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;
}
+48
View File
@@ -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<string, Promise<unknown>>();
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<typeof appendEvidenceEventForWorkspaceKey>[1]): Promise<void> {
const key = workspaceKeyFromStorePath(path);
if (!key) return;
await appendEvidenceEventForWorkspaceKey(key, event).catch(() => undefined);
}
async function quarantineCorruptJSON(path: string): Promise<string | null> {
const quarantinePath = `${path}.corrupt-${Date.now()}-${process.pid}-${randomUUID()}`;
@@ -31,6 +49,16 @@ export async function readJSON<T>(path: string, fallback: () => T): Promise<T> {
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<T>(path: string, fn: () => Promise<T>): Promise<T> {
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}`);
}
+119 -6
View File
@@ -11,6 +11,7 @@ import {
calculateRetentionStrength,
reinforceMemory,
} from "./retention.ts";
import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
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<Record<LongTermMemoryEntry["type"], number>> = {};
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 {
+56 -1
View File
@@ -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<string> {
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
+69 -1
View File
@@ -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<string, Function>)["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<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "evidence-life-session", model: {} },
{ system: ["base header"] },
);
await (plugin as Record<string, Function>)["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<string, Function>)["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-"));
+55 -1
View File
@@ -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));
});
+64 -2
View File
@@ -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 {
+84
View File
@@ -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", () => {