@@ -63,7 +63,7 @@ import {
6363 pendingTodos ,
6464} from "./opencode.ts" ;
6565import { accountPendingPromotions } from "./promotion-accounting.ts" ;
66- import { WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts" ;
66+ import { type LongTermMemoryEntry , WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts" ;
6767
6868/**
6969 * Build the complete compaction prompt.
@@ -162,6 +162,11 @@ function renderTodosForCompaction(todos: Array<{ content: string; status: string
162162 return lines . join ( "\n" ) ;
163163}
164164
165+ function warnMemoryHook ( scope : string , error : unknown ) : void {
166+ const message = error instanceof Error ? error . message : String ( error ) ;
167+ console . error ( `[memory] ${ scope } failed: ${ message } ` ) ;
168+ }
169+
165170export const MemoryV2Plugin : Plugin = async ( input ) => {
166171 const { directory, client } = input ;
167172
@@ -285,6 +290,22 @@ export const MemoryV2Plugin: Plugin = async (input) => {
285290 rememberProcessedUserMessage ( sessionID , latestMessage . id , processedForSession ) ;
286291 }
287292
293+ function dedupePendingPromotionMemories (
294+ memories : LongTermMemoryEntry [ ] ,
295+ ) : LongTermMemoryEntry [ ] {
296+ const seen = new Set < string > ( ) ;
297+ const deduped : LongTermMemoryEntry [ ] = [ ] ;
298+
299+ for ( const memory of memories ) {
300+ const key = memoryKey ( memory ) ;
301+ if ( seen . has ( key ) ) continue ;
302+ seen . add ( key ) ;
303+ deduped . push ( memory ) ;
304+ }
305+
306+ return deduped ;
307+ }
308+
288309 async function promotePendingMemories (
289310 sessionID ?: string ,
290311 options : { includeUnownedJournal ?: boolean ; includeOwnedJournal ?: boolean } = { } ,
@@ -302,10 +323,10 @@ export const MemoryV2Plugin: Plugin = async (input) => {
302323 return false ;
303324 } ) ;
304325
305- const pending = [
326+ const pending = dedupePendingPromotionMemories ( [
306327 ...( sessionState ?. pendingMemories ?? [ ] ) ,
307328 ...journalPending ,
308- ] ;
329+ ] ) ;
309330 if ( pending . length === 0 ) return ;
310331
311332 let beforeEntries : Awaited < ReturnType < typeof loadWorkspaceMemory > > [ "entries" ] = [ ] ;
@@ -462,38 +483,42 @@ export const MemoryV2Plugin: Plugin = async (input) => {
462483 const { sessionID } = hookInput ;
463484 if ( ! sessionID ) return ;
464485
465- pruneFrozenWorkspaceMemoryCache ( ) ;
466- pruneProcessedUserMessagesCache ( ) ;
486+ try {
487+ pruneFrozenWorkspaceMemoryCache ( ) ;
488+ pruneProcessedUserMessagesCache ( ) ;
467489
468- // Sub-agents are short-lived - skip memory system
469- if ( await isSubAgent ( sessionID ) ) return ;
490+ // Sub-agents are short-lived - skip memory system
491+ if ( await isSubAgent ( sessionID ) ) return ;
470492
471- // Process explicit user memory even on no-tool turns. Keep this after the
472- // sub-agent guard so child sessions never append to the parent journal.
473- await processLatestUserMessage ( sessionID ) ;
493+ // Process explicit user memory even on no-tool turns. Keep this after the
494+ // sub-agent guard so child sessions never append to the parent journal.
495+ await processLatestUserMessage ( sessionID ) ;
474496
475- // Before first snapshot in this session, promote durable unowned backlog from
476- // prior sessions. Current-turn owned explicit memory remains pending and only
477- // appears in hot state for this transform.
478- if ( ! frozenWorkspaceMemoryCache . has ( sessionID ) && await hasPendingJournalEntries ( directory ) ) {
479- await promotePendingMemories ( undefined , { includeUnownedJournal : true , includeOwnedJournal : false } ) ;
480- }
497+ // Before first snapshot in this session, promote durable unowned backlog from
498+ // prior sessions. Current-turn owned explicit memory remains pending and only
499+ // appears in hot state for this transform.
500+ if ( ! frozenWorkspaceMemoryCache . has ( sessionID ) && await hasPendingJournalEntries ( directory ) ) {
501+ await promotePendingMemories ( undefined , { includeUnownedJournal : true , includeOwnedJournal : false } ) ;
502+ }
481503
482- // Get frozen workspace memory snapshot (loaded and rendered once per session)
483- const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot ( directory , sessionID ) ;
504+ // Get frozen workspace memory snapshot (loaded and rendered once per session)
505+ const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot ( directory , sessionID ) ;
484506
485- // Get current hot session state
486- const sessionState = await loadSessionState ( directory , sessionID ) ;
507+ // Get current hot session state
508+ const sessionState = await loadSessionState ( directory , sessionID ) ;
487509
488- // Inject frozen workspace memory snapshot
489- if ( workspaceSnapshot . renderedPrompt ) {
490- output . system . push ( workspaceSnapshot . renderedPrompt ) ;
491- }
510+ // Inject frozen workspace memory snapshot
511+ if ( workspaceSnapshot . renderedPrompt ) {
512+ output . system . push ( workspaceSnapshot . renderedPrompt ) ;
513+ }
492514
493- // Render and inject hot session state
494- const hotPrompt = renderHotSessionState ( sessionState , directory ) ;
495- if ( hotPrompt ) {
496- output . system . push ( hotPrompt ) ;
515+ // Render and inject hot session state
516+ const hotPrompt = renderHotSessionState ( sessionState , directory ) ;
517+ if ( hotPrompt ) {
518+ output . system . push ( hotPrompt ) ;
519+ }
520+ } catch ( error ) {
521+ warnMemoryHook ( "chat.system.transform" , error ) ;
497522 }
498523 } ,
499524
@@ -506,50 +531,54 @@ export const MemoryV2Plugin: Plugin = async (input) => {
506531 // Sub-agents don't need memory tracking
507532 if ( await isSubAgent ( sessionID ) ) return ;
508533
509- await updateSessionState ( directory , sessionID , state => {
510- // Track active files from tool usage
511- if ( toolName === "read" || toolName === "edit" || toolName === "write" || toolName === "grep" ) {
512- const files = extractActiveFiles (
513- toolName ,
514- args as Record < string , unknown > ,
515- toolOutput ?? ""
516- ) ;
517- for ( const { path, action } of files ) {
518- touchActiveFile ( state , path , action ) ;
519- if ( action === "edit" || action === "write" ) {
520- markErrorsMaybeFixedForFile ( state , path , directory ) ;
534+ try {
535+ await updateSessionState ( directory , sessionID , state => {
536+ // Track active files from tool usage
537+ if ( toolName === "read" || toolName === "edit" || toolName === "write" || toolName === "grep" ) {
538+ const files = extractActiveFiles (
539+ toolName ,
540+ args as Record < string , unknown > ,
541+ toolOutput ?? ""
542+ ) ;
543+ for ( const { path, action } of files ) {
544+ touchActiveFile ( state , path , action ) ;
545+ if ( action === "edit" || action === "write" ) {
546+ markErrorsMaybeFixedForFile ( state , path , directory ) ;
547+ }
521548 }
522549 }
523- }
524550
525- // Track errors from failed bash commands
526- if ( toolName === "bash" ) {
527- const argsRecord = args as Record < string , unknown > ;
528- const command : string = typeof argsRecord ?. command === "string"
529- ? argsRecord . command
530- : "" ;
531- const outputText : string = toolOutput ?? "" ;
532-
533- // Check if command succeeded - clear errors for that category
534- const exitCode = bashExitCode ( hookOutput ) ;
535- if ( typeof exitCode !== "number" ) {
536- // Unknown exit status: do not extract and do not clear
537- } else if ( exitCode === 0 && command ) {
538- clearErrorsForSuccessfulCommand ( state , command ) ;
539- } else if ( command ) {
540- // Only extract errors for commands with explicit non-zero exit
541- const errors = extractErrorsFromBash ( command , outputText ) ;
542- for ( const error of errors ) {
543- upsertOpenError ( state , error ) ;
551+ // Track errors from failed bash commands
552+ if ( toolName === "bash" ) {
553+ const argsRecord = args as Record < string , unknown > ;
554+ const command : string = typeof argsRecord ?. command === "string"
555+ ? argsRecord . command
556+ : "" ;
557+ const outputText : string = toolOutput ?? "" ;
558+
559+ // Check if command succeeded - clear errors for that category
560+ const exitCode = bashExitCode ( hookOutput ) ;
561+ if ( typeof exitCode !== "number" ) {
562+ // Unknown exit status: do not extract and do not clear
563+ } else if ( exitCode === 0 && command ) {
564+ clearErrorsForSuccessfulCommand ( state , command ) ;
565+ } else if ( command ) {
566+ // Only extract errors for commands with explicit non-zero exit
567+ const errors = extractErrorsFromBash ( command , outputText ) ;
568+ for ( const error of errors ) {
569+ upsertOpenError ( state , error ) ;
570+ }
544571 }
545572 }
546- }
547- return state ;
548- } ) ;
573+ return state ;
574+ } ) ;
549575
550- // Process explicit memory from latest user message
551- // Only process once per message ID
552- await processLatestUserMessage ( sessionID ) ;
576+ // Process explicit memory from latest user message
577+ // Only process once per message ID
578+ await processLatestUserMessage ( sessionID ) ;
579+ } catch ( error ) {
580+ warnMemoryHook ( "tool.execute.after" , error ) ;
581+ }
553582 } ,
554583
555584 /**
@@ -567,95 +596,99 @@ export const MemoryV2Plugin: Plugin = async (input) => {
567596 const { sessionID } = hookInput ;
568597 if ( ! sessionID ) return ;
569598
570- // Sub-agents don't need compaction support
571- if ( await isSubAgent ( sessionID ) ) return ;
599+ try {
600+ // Sub-agents don't need compaction support
601+ if ( await isSubAgent ( sessionID ) ) return ;
572602
573- // Preserve context injected by other plugins that ran before us.
574- // Setting output.prompt bypasses the default prompt + context join,
575- // so we must explicitly carry forward any existing output.context.
576- const otherContext = output . context . filter ( Boolean ) . join ( "\n\n" ) ;
603+ // Preserve context injected by other plugins that ran before us.
604+ // Setting output.prompt bypasses the default prompt + context join,
605+ // so we must explicitly carry forward any existing output.context.
606+ const otherContext = output . context . filter ( Boolean ) . join ( "\n\n" ) ;
577607
578- // Build our private context (workspace memory, hot state, todos)
579- const contextParts : string [ ] = [ ] ;
608+ // Build our private context (workspace memory, hot state, todos)
609+ const contextParts : string [ ] = [ ] ;
580610
581611 // 1. Frozen workspace memory snapshot
582- const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot ( directory , sessionID ) ;
583- if ( workspaceSnapshot . renderedPrompt ) {
584- contextParts . push ( workspaceSnapshot . renderedPrompt ) ;
585- }
612+ const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot ( directory , sessionID ) ;
613+ if ( workspaceSnapshot . renderedPrompt ) {
614+ contextParts . push ( workspaceSnapshot . renderedPrompt ) ;
615+ }
586616
587- // 2. Hot session state
588- const sessionState = await loadSessionState ( directory , sessionID ) ;
589- const hotPrompt = renderHotSessionState ( sessionState , directory ) ;
590- if ( hotPrompt ) {
591- contextParts . push ( hotPrompt ) ;
592- }
617+ // 2. Hot session state
618+ const sessionState = await loadSessionState ( directory , sessionID ) ;
619+ const hotPrompt = renderHotSessionState ( sessionState , directory ) ;
620+ if ( hotPrompt ) {
621+ contextParts . push ( hotPrompt ) ;
622+ }
593623
594- // 3. Pending todos from OpenCode
595- const todos = await pendingTodos ( client , sessionID ) ;
596- const todosPrompt = renderTodosForCompaction ( todos ) ;
597- if ( todosPrompt ) {
598- contextParts . push ( todosPrompt ) ;
599- }
624+ // 3. Pending todos from OpenCode
625+ const todos = await pendingTodos ( client , sessionID ) ;
626+ const todosPrompt = renderTodosForCompaction ( todos ) ;
627+ if ( todosPrompt ) {
628+ contextParts . push ( todosPrompt ) ;
629+ }
600630
601- // Combine: other plugins' context first, then our private context
602- const privateContext = [ otherContext , ...contextParts ]
603- . filter ( Boolean )
604- . join ( "\n\n" ) ;
631+ // Combine: other plugins' context first, then our private context
632+ const privateContext = [ otherContext , ...contextParts ]
633+ . filter ( Boolean )
634+ . join ( "\n\n" ) ;
605635
606- // Replace the default prompt entirely with our ---free template
607- output . prompt = buildCompactionPrompt ( privateContext ) ;
636+ // Replace the default prompt entirely with our ---free template
637+ output . prompt = buildCompactionPrompt ( privateContext ) ;
608638
609- // Clear context array since we consumed it into output.prompt.
610- // Subsequent plugins that set output.prompt will also need to check
611- // output.context if they want to preserve other plugin contributions.
612- output . context . length = 0 ;
639+ // Clear context array since we consumed it into output.prompt.
640+ // Subsequent plugins that set output.prompt will also need to check
641+ // output.context if they want to preserve other plugin contributions.
642+ output . context . length = 0 ;
643+ } catch ( error ) {
644+ warnMemoryHook ( "session.compacting" , error ) ;
645+ }
613646 } ,
614647
615648 // Handle session events
616649 event : async ( { event } ) => {
617650 if ( event . type === "session.compacted" ) {
618- const sessionID = sessionIDFromEventProperties ( event . properties ) ;
619- if ( ! sessionID ) return ;
620-
621- // Sub-agents don't need post-compaction processing
622- if ( await isSubAgent ( sessionID ) ) return ;
623-
624- // Parse latest compaction summary for memory candidates, stage them into
625- // durable pending journal, then promote pending memories.
626- const summary = await latestCompactionSummary ( client , sessionID ) ;
627- const candidates = summary ? parseWorkspaceMemoryCandidates ( summary ) : [ ] ;
628- if ( candidates . length > 0 ) {
629- await appendPendingMemories ( directory , candidates ) ;
630- }
631-
632651 try {
652+ const sessionID = sessionIDFromEventProperties ( event . properties ) ;
653+ if ( ! sessionID ) return ;
654+
655+ // Sub-agents don't need post-compaction processing
656+ if ( await isSubAgent ( sessionID ) ) return ;
657+
658+ // Parse latest compaction summary for memory candidates, stage them into
659+ // durable pending journal, then promote pending memories.
660+ const summary = await latestCompactionSummary ( client , sessionID ) ;
661+ const candidates = summary ? parseWorkspaceMemoryCandidates ( summary ) : [ ] ;
662+ if ( candidates . length > 0 ) {
663+ await appendPendingMemories ( directory , candidates ) ;
664+ }
665+
633666 await promotePendingMemories ( sessionID , { includeUnownedJournal : true } ) ;
634- } catch {
667+ } catch ( error ) {
635668 // Keep pending memories in session/journal for retry on next event/session.
669+ warnMemoryHook ( "event.session.compacted" , error ) ;
636670 }
637671 }
638672
639673 if ( event . type === "session.deleted" ) {
640- const sessionID = sessionIDFromEventProperties ( event . properties ) ;
641- if ( sessionID ) {
674+ try {
675+ const sessionID = sessionIDFromEventProperties ( event . properties ) ;
676+ if ( sessionID ) {
642677 // Promote pending memories before deleting per-session state.
643678 // If promotion fails, leave session state and journal intact.
644- let promoted = false ;
645- try {
679+ let promoted = false ;
646680 await promotePendingMemories ( sessionID , { includeOwnedJournal : true , includeUnownedJournal : false } ) ;
647681 promoted = true ;
648- } catch {
649- return ;
650- } finally {
651682 if ( promoted ) {
652683 frozenWorkspaceMemoryCache . delete ( sessionID ) ;
653684 processedUserMessages . delete ( sessionID ) ;
654685 sessionParentCache . delete ( sessionID ) ;
655686 }
656- }
657687
658- await rm ( await sessionStatePath ( directory , sessionID ) , { force : true } ) ;
688+ await rm ( await sessionStatePath ( directory , sessionID ) , { force : true } ) ;
689+ }
690+ } catch ( error ) {
691+ warnMemoryHook ( "event.session.deleted" , error ) ;
659692 }
660693 }
661694 } ,
0 commit comments