Skip to content

Commit bc0847e

Browse files
committed
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
1 parent 6a81fc3 commit bc0847e

11 files changed

Lines changed: 880 additions & 50 deletions

src/evidence-log.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { createHash } from "node:crypto";
22
import { existsSync } from "node:fs";
33
import { appendFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
4-
import { dirname } from "node:path";
5-
import { workspaceEvidenceLogPath, workspaceKey } from "./paths.ts";
4+
import { dirname, join } from "node:path";
5+
import { dataHome, workspaceEvidenceLogPath, workspaceKey } from "./paths.ts";
66
import { redactCredentials } from "./redaction.ts";
77

88
export type EvidenceEventType =
@@ -311,6 +311,17 @@ export async function appendEvidenceEvents(root: string, events: EvidenceEventIn
311311
return records;
312312
}
313313

314+
export async function appendEvidenceEventForWorkspaceKey(
315+
workspaceKeyValue: string,
316+
event: EvidenceEventInput,
317+
): Promise<EvidenceEventV1> {
318+
const path = join(dataHome(), "opencode-working-memory", "workspaces", workspaceKeyValue, "evidence", "events.jsonl");
319+
const record = buildEvidenceEvent(event, workspaceKeyValue, workspaceKeyValue);
320+
await safeAppendEvidenceLine(path, JSON.stringify(record));
321+
await maybePruneEvidenceLog(path);
322+
return record;
323+
}
324+
314325
type ParsedEvidenceLine = {
315326
event: EvidenceEventV1;
316327
index: number;

src/extractors.ts

Lines changed: 164 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { LONG_TERM_LIMITS } from "./types.ts";
66
import { assessMemoryQuality } from "./memory-quality.ts";
77
import { extractionRejectionLogPath } from "./paths.ts";
88
import { redactCredentials } from "./redaction.ts";
9+
import type { EvidenceEventInput } from "./evidence-log.ts";
910

1011
function id(prefix: string): string {
1112
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
@@ -46,6 +47,34 @@ function isNegatedMemoryRequest(text: string, matchIndex: number): boolean {
4647
}
4748

4849
export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
50+
return extractExplicitMemoriesWithEvidence(text).entries;
51+
}
52+
53+
export type WorkspaceMemoryParseResult = {
54+
entries: LongTermMemoryEntry[];
55+
evidence: EvidenceEventInput[];
56+
};
57+
58+
function evidenceTextPreview(text: string, maxChars = 120): string {
59+
return redactCredentials(text).replace(/\s+/g, " ").trim().slice(0, maxChars);
60+
}
61+
62+
function memoryEvidence(memory: LongTermMemoryEntry): EvidenceEventInput["memory"] {
63+
return {
64+
memoryId: memory.id,
65+
type: memory.type,
66+
source: memory.source,
67+
status: memory.status,
68+
};
69+
}
70+
71+
function extractionEvidence(
72+
input: Pick<EvidenceEventInput, "type" | "phase" | "outcome" | "reasonCodes" | "textPreview" | "memory" | "details">,
73+
): EvidenceEventInput {
74+
return input;
75+
}
76+
77+
export function extractExplicitMemoriesWithEvidence(text: string): WorkspaceMemoryParseResult {
4978
// 注意:所有pattern必須有 g flag,因為使用 matchAll()
5079
// Pattern 必須在行首匹配,避免匹配到句子中間的非指令式用法
5180
const patterns = [
@@ -71,29 +100,74 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
71100
const nowMs = Date.now();
72101
const now = new Date(nowMs).toISOString();
73102
const entries: LongTermMemoryEntry[] = [];
103+
const evidence: EvidenceEventInput[] = [];
74104
const seen = new Set<string>();
105+
const negatedLinePattern = /(?:^|\n)\s*(?:(?:please\s+)?(?:do\s+not|don't|dont|never)\s+remember(?:\s+(?:this|that))?|\s*(?:|)|\s*(?:|)|\s*(?:|))[:,]?\s*(.+)$/gim;
106+
for (const match of text.matchAll(negatedLinePattern)) {
107+
evidence.push(extractionEvidence({
108+
type: "explicit_memory_ignored",
109+
phase: "explicit",
110+
outcome: "rejected",
111+
reasonCodes: ["negated_request"],
112+
textPreview: evidenceTextPreview(match[1] ?? match[0], 80),
113+
}));
114+
}
75115

76116
for (const pattern of patterns) {
77117
for (const match of text.matchAll(pattern)) {
78118
const body = match[1]?.trim();
79-
if (!body || body.length < 8) continue;
119+
if (body && /^(||later|next time)$/i.test(body)) {
120+
evidence.push(extractionEvidence({
121+
type: "explicit_memory_ignored",
122+
phase: "explicit",
123+
outcome: "rejected",
124+
reasonCodes: ["deferral"],
125+
textPreview: evidenceTextPreview(body, 80),
126+
}));
127+
continue;
128+
}
129+
if (!body || body.length < 8) {
130+
evidence.push(extractionEvidence({
131+
type: "explicit_memory_ignored",
132+
phase: "explicit",
133+
outcome: "rejected",
134+
reasonCodes: ["too_short"],
135+
textPreview: evidenceTextPreview(body ?? match[0], 80),
136+
}));
137+
continue;
138+
}
80139

81140
// Calculate actual trigger position (after possible newline)
82141
const triggerIndex = match.index! + (match[0].match(/^[\s\n]*/)?.[0]?.length || 0);
83142

84143
// Check if this is a negated request (e.g., "不要記住")
85-
if (isNegatedMemoryRequest(text, triggerIndex)) continue;
144+
if (isNegatedMemoryRequest(text, triggerIndex)) {
145+
evidence.push(extractionEvidence({
146+
type: "explicit_memory_ignored",
147+
phase: "explicit",
148+
outcome: "rejected",
149+
reasonCodes: ["negated_request"],
150+
textPreview: evidenceTextPreview(body, 80),
151+
}));
152+
continue;
153+
}
86154

87-
// Check if it's a deferral (e.g., "later", "next time")
88-
if (/^(||later|next time)$/i.test(body)) continue;
89-
90155
// Dedupe by canonical body
91156
const key = body.toLowerCase().replace(/\s+/g, " ").trim();
92-
if (seen.has(key)) continue;
157+
if (seen.has(key)) {
158+
evidence.push(extractionEvidence({
159+
type: "explicit_memory_ignored",
160+
phase: "explicit",
161+
outcome: "rejected",
162+
reasonCodes: ["duplicate_in_message"],
163+
textPreview: evidenceTextPreview(body, 80),
164+
}));
165+
continue;
166+
}
93167
seen.add(key);
94168

95169
const type = classifyExplicitMemory(body);
96-
entries.push({
170+
const memory: LongTermMemoryEntry = {
97171
id: id("mem"),
98172
type,
99173
text: body.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
@@ -104,11 +178,20 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
104178
updatedAt: now,
105179
retentionClock: nowMs,
106180
staleAfterDays: staleAfterDaysFor(type),
107-
});
181+
};
182+
entries.push(memory);
183+
evidence.push(extractionEvidence({
184+
type: "explicit_memory_detected",
185+
phase: "explicit",
186+
outcome: "accepted",
187+
reasonCodes: ["explicit_trigger_matched"],
188+
memory: memoryEvidence(memory),
189+
textPreview: evidenceTextPreview(memory.text),
190+
}));
108191
}
109192
}
110193

111-
return entries;
194+
return { entries, evidence };
112195
}
113196

114197
function classifyExplicitMemory(text: string): LongTermType {
@@ -251,30 +334,30 @@ async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promi
251334
}
252335
}
253336

254-
function shouldAcceptWorkspaceMemoryCandidate(
337+
function evaluateWorkspaceMemoryCandidate(
255338
entry: {
256339
type: LongTermType;
257340
text: string;
258341
},
259342
options: {
260343
fromMemoryTrigger?: boolean;
261344
} = {},
262-
): boolean {
345+
): { accepted: boolean; reasons: string[] } {
263346
const text = entry.text.trim();
264347
const minLength = options.fromMemoryTrigger ? 6 : 20;
265348

266349
// Too short (with type-specific allowlist for stable config values)
267350
if (entry.type === "reference" && /\b(?:admin\s+)?pin\s|scrypt|n=\d+|r=\d+|p=\d+/i.test(text)) {
268351
// Stable config values can be short — allow below generic min length
269352
} else if (text.length < minLength) {
270-
return false;
353+
return { accepted: false, reasons: ["too_short"] };
271354
}
272355

273356
// Indirect Prompt Injection / Adversarial Instructions
274357
// Rejects attempts to overwrite system behavior or "ignore" rules.
275358
// comparative "instead of" is allowed.
276-
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;
277-
if (/\b(ignore|instruction|overwrite)\b/i.test(text) && /\b(previous|all|rules|behavior|prompt|system)\b/i.test(text)) return false;
359+
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"] };
360+
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"] };
278361

279362
const quality = assessMemoryQuality({ type: entry.type, text, source: "compaction" });
280363
if (!quality.accepted) {
@@ -285,10 +368,22 @@ function shouldAcceptWorkspaceMemoryCandidate(
285368
reasons: quality.reasons,
286369
source: "compaction",
287370
});
288-
return false;
371+
return { accepted: false, reasons: quality.reasons };
289372
}
290373

291-
return true;
374+
return { accepted: true, reasons: ["quality_gate_passed"] };
375+
}
376+
377+
function shouldAcceptWorkspaceMemoryCandidate(
378+
entry: {
379+
type: LongTermType;
380+
text: string;
381+
},
382+
options: {
383+
fromMemoryTrigger?: boolean;
384+
} = {},
385+
): boolean {
386+
return evaluateWorkspaceMemoryCandidate(entry, options).accepted;
292387
}
293388

294389
/**
@@ -316,12 +411,17 @@ function extractCandidateBlock(summary: string): string | null {
316411
}
317412

318413
export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryEntry[] {
414+
return parseWorkspaceMemoryCandidatesWithEvidence(summary).entries;
415+
}
416+
417+
export function parseWorkspaceMemoryCandidatesWithEvidence(summary: string): WorkspaceMemoryParseResult {
319418
const block = extractCandidateBlock(summary);
320-
if (!block) return [];
419+
if (!block) return { entries: [], evidence: [] };
321420

322421
const nowMs = Date.now();
323422
const now = new Date(nowMs).toISOString();
324423
const entries: LongTermMemoryEntry[] = [];
424+
const evidence: EvidenceEventInput[] = [];
325425

326426
for (const line of block.split("\n")) {
327427
// Accept both "- [type] text" (bracketed) and "- type text" (bracketless)
@@ -331,18 +431,49 @@ export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryE
331431
if (!item) continue;
332432
const type = (item[1] ?? item[2]).toLowerCase() as LongTermType;
333433
const normalizedBody = normalizeCandidateBody(item[3]);
334-
if (!normalizedBody) continue;
434+
if (!normalizedBody) {
435+
evidence.push(extractionEvidence({
436+
type: "extraction_candidate_rejected",
437+
phase: "extraction",
438+
outcome: "rejected",
439+
reasonCodes: ["negated_request"],
440+
memory: { type, source: "compaction" },
441+
textPreview: evidenceTextPreview(item[3], 80),
442+
}));
443+
continue;
444+
}
335445

336446
const minLength = normalizedBody.hadTrigger ? 6 : 12;
337-
if (normalizedBody.text.length < minLength) continue;
447+
if (normalizedBody.text.length < minLength) {
448+
evidence.push(extractionEvidence({
449+
type: "extraction_candidate_rejected",
450+
phase: "extraction",
451+
outcome: "rejected",
452+
reasonCodes: ["too_short"],
453+
memory: { type, source: "compaction" },
454+
textPreview: evidenceTextPreview(normalizedBody.text, 80),
455+
}));
456+
continue;
457+
}
338458

339459
// Apply quality gate
340-
if (!shouldAcceptWorkspaceMemoryCandidate(
460+
const quality = evaluateWorkspaceMemoryCandidate(
341461
{ type, text: normalizedBody.text },
342462
{ fromMemoryTrigger: normalizedBody.hadTrigger },
343-
)) continue;
463+
);
464+
if (!quality.accepted) {
465+
evidence.push(extractionEvidence({
466+
type: "extraction_candidate_rejected",
467+
phase: "extraction",
468+
outcome: "rejected",
469+
reasonCodes: quality.reasons,
470+
memory: { type, source: "compaction" },
471+
textPreview: evidenceTextPreview(normalizedBody.text, 80),
472+
}));
473+
continue;
474+
}
344475

345-
entries.push({
476+
const memory: LongTermMemoryEntry = {
346477
id: id("mem"),
347478
type,
348479
text: normalizedBody.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
@@ -353,8 +484,17 @@ export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryE
353484
updatedAt: now,
354485
retentionClock: nowMs,
355486
staleAfterDays: staleAfterDaysFor(type),
356-
});
487+
};
488+
entries.push(memory);
489+
evidence.push(extractionEvidence({
490+
type: "extraction_candidate_accepted",
491+
phase: "extraction",
492+
outcome: "accepted",
493+
reasonCodes: ["quality_gate_passed", "valid_candidate_format"],
494+
memory: memoryEvidence(memory),
495+
textPreview: evidenceTextPreview(memory.text),
496+
}));
357497
}
358498

359-
return entries;
499+
return { entries, evidence };
360500
}

0 commit comments

Comments
 (0)