@@ -6,6 +6,7 @@ import { LONG_TERM_LIMITS } from "./types.ts";
66import { assessMemoryQuality } from "./memory-quality.ts" ;
77import { extractionRejectionLogPath } from "./paths.ts" ;
88import { redactCredentials } from "./redaction.ts" ;
9+ import type { EvidenceEventInput } from "./evidence-log.ts" ;
910
1011function 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
4849export 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 * (?: (?: p l e a s e \s + ) ? (?: d o \s + n o t | d o n ' t | d o n t | n e v e r ) \s + r e m e m b e r (?: \s + (?: t h i s | t h a t ) ) ? | 不 要 \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 && / ^ ( 再 说 | 再 說 | l a t e r | n e x t t i m e ) $ / 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 ( / ^ ( 再 说 | 再 說 | l a t e r | n e x t t i m e ) $ / 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
114197function 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 (?: a d m i n \s + ) ? p i n \s | s c r y p t | 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 ( i g n o r e \s + a l l | i g n o r e \s + p r e v i o u s | i g n o r e \s + i n s t r u c t i o n | o v e r w r i t e \s + s y s t e m | o v e r w r i t e \s + r u l e s | f o r g e t \s + a l l | d e l e t e \s + r o o t ) \b / i. test ( text ) ) return false ;
277- if ( / \b ( i g n o r e | i n s t r u c t i o n | o v e r w r i t e ) \b / i. test ( text ) && / \b ( p r e v i o u s | a l l | r u l e s | b e h a v i o r | p r o m p t | s y s t e m ) \b / i. test ( text ) ) return false ;
359+ if ( / \b ( i g n o r e \s + a l l | i g n o r e \s + p r e v i o u s | i g n o r e \s + i n s t r u c t i o n | o v e r w r i t e \s + s y s t e m | o v e r w r i t e \s + r u l e s | f o r g e t \s + a l l | d e l e t e \s + r o o t ) \b / i. test ( text ) ) return { accepted : false , reasons : [ "prompt_injection" ] } ;
360+ if ( / \b ( i g n o r e | i n s t r u c t i o n | o v e r w r i t e ) \b / i. test ( text ) && / \b ( p r e v i o u s | a l l | r u l e s | b e h a v i o r | p r o m p t | s y s t e m ) \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
318413export 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