@@ -43,6 +43,7 @@ import {
4343 hasPendingJournalEntries ,
4444 loadPendingJournal ,
4545 memoryKey ,
46+ recordPromotionRejections ,
4647} from "./pending-journal.ts" ;
4748import {
4849 loadSessionState ,
@@ -61,6 +62,7 @@ import {
6162 pendingTodos ,
6263} from "./opencode.ts" ;
6364import { accountPendingPromotions } from "./promotion-accounting.ts" ;
65+ import { WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts" ;
6466
6567/**
6668 * Build the complete compaction prompt.
@@ -203,13 +205,67 @@ export const MemoryV2Plugin: Plugin = async (input) => {
203205 // Cache for processed user message IDs (to avoid duplicate processing)
204206 const processedUserMessages = new Map < string , Set < string > > ( ) ;
205207
208+ function pruneFrozenWorkspaceMemoryCache ( now = Date . now ( ) ) : void {
209+ for ( const [ sessionID , cached ] of frozenWorkspaceMemoryCache ) {
210+ if ( now - cached . loadedAt > WORKSPACE_MEMORY_CACHE_LIMITS . frozenTtlMs ) {
211+ frozenWorkspaceMemoryCache . delete ( sessionID ) ;
212+ }
213+ }
214+
215+ while ( frozenWorkspaceMemoryCache . size > WORKSPACE_MEMORY_CACHE_LIMITS . maxFrozenSessions ) {
216+ const oldest = [ ...frozenWorkspaceMemoryCache . entries ( ) ]
217+ . sort ( ( a , b ) => a [ 1 ] . loadedAt - b [ 1 ] . loadedAt ) [ 0 ] ?. [ 0 ] ;
218+ if ( ! oldest ) break ;
219+ frozenWorkspaceMemoryCache . delete ( oldest ) ;
220+ }
221+ }
222+
223+ function pruneProcessedUserMessagesCache ( ) : void {
224+ for ( const [ sessionID , messages ] of processedUserMessages ) {
225+ while ( messages . size > WORKSPACE_MEMORY_CACHE_LIMITS . maxProcessedMessagesPerSession ) {
226+ const oldest = messages . values ( ) . next ( ) . value as string | undefined ;
227+ if ( ! oldest ) break ;
228+ messages . delete ( oldest ) ;
229+ }
230+
231+ if ( messages . size === 0 ) {
232+ processedUserMessages . delete ( sessionID ) ;
233+ }
234+ }
235+
236+ while ( processedUserMessages . size > WORKSPACE_MEMORY_CACHE_LIMITS . maxProcessedSessionIDs ) {
237+ const oldestSessionID = processedUserMessages . keys ( ) . next ( ) . value as string | undefined ;
238+ if ( ! oldestSessionID ) break ;
239+ processedUserMessages . delete ( oldestSessionID ) ;
240+ }
241+ }
242+
243+ function rememberProcessedUserMessage ( sessionID : string , messageID : string , processedForSession : Set < string > ) : void {
244+ processedForSession . add ( messageID ) ;
245+ while ( processedForSession . size > WORKSPACE_MEMORY_CACHE_LIMITS . maxProcessedMessagesPerSession ) {
246+ const oldest = processedForSession . values ( ) . next ( ) . value as string | undefined ;
247+ if ( ! oldest ) break ;
248+ processedForSession . delete ( oldest ) ;
249+ }
250+
251+ if ( processedUserMessages . has ( sessionID ) ) {
252+ processedUserMessages . delete ( sessionID ) ;
253+ }
254+ processedUserMessages . set ( sessionID , processedForSession ) ;
255+ pruneProcessedUserMessagesCache ( ) ;
256+ }
257+
206258 async function processLatestUserMessage ( sessionID : string ) : Promise < void > {
207259 const processedForSession = processedUserMessages . get ( sessionID ) ?? new Set < string > ( ) ;
208260 const latestMessage = await latestUserText ( client , sessionID ) ;
209261
210262 if ( ! latestMessage ?. id || processedForSession . has ( latestMessage . id ) ) return ;
211263
212- const memories = extractExplicitMemories ( latestMessage . text ) ;
264+ const memories = extractExplicitMemories ( latestMessage . text ) . map ( memory => ( {
265+ ...memory ,
266+ pendingOwnerSessionID : sessionID ,
267+ pendingMessageID : latestMessage . id ,
268+ } ) ) ;
213269 const decisions = memories . filter ( memory => memory . type === "decision" ) ;
214270
215271 if ( memories . length > 0 ) {
@@ -233,19 +289,29 @@ export const MemoryV2Plugin: Plugin = async (input) => {
233289 } ) ;
234290 }
235291
236- processedForSession . add ( latestMessage . id ) ;
237- processedUserMessages . set ( sessionID , processedForSession ) ;
292+ rememberProcessedUserMessage ( sessionID , latestMessage . id , processedForSession ) ;
238293 }
239294
240- async function promotePendingMemories ( sessionID ?: string ) : Promise < void > {
295+ async function promotePendingMemories (
296+ sessionID ?: string ,
297+ options : { includeUnownedJournal ?: boolean ; includeOwnedJournal ?: boolean } = { } ,
298+ ) : Promise < void > {
299+ const includeUnownedJournal = options . includeUnownedJournal ?? ! sessionID ;
300+ const includeOwnedJournal = options . includeOwnedJournal ?? Boolean ( sessionID ) ;
241301 const [ journal , sessionState ] = await Promise . all ( [
242302 loadPendingJournal ( directory ) ,
243303 sessionID ? loadSessionState ( directory , sessionID ) : Promise . resolve ( undefined ) ,
244304 ] ) ;
245305
306+ const journalPending = journal . entries . filter ( memory => {
307+ if ( sessionID && includeOwnedJournal && memory . pendingOwnerSessionID === sessionID ) return true ;
308+ if ( includeUnownedJournal && ! memory . pendingOwnerSessionID ) return true ;
309+ return false ;
310+ } ) ;
311+
246312 const pending = [
247313 ...( sessionState ?. pendingMemories ?? [ ] ) ,
248- ...journal . entries ,
314+ ...journalPending ,
249315 ] ;
250316 if ( pending . length === 0 ) return ;
251317
@@ -277,16 +343,39 @@ export const MemoryV2Plugin: Plugin = async (input) => {
277343 events : updateResult . events ,
278344 } ) ;
279345
346+ const exhaustedRejectedKeys = await recordPromotionRejections (
347+ directory ,
348+ accounting . retryableRejectedKeys ,
349+ "rejected_capacity" ,
350+ { ownerSessionID : sessionID } ,
351+ ) ;
352+
353+ const sessionRemovalKeys = new Set ( [
354+ ...accounting . clearableKeys ,
355+ ...exhaustedRejectedKeys ,
356+ ] ) ;
357+
280358 if ( sessionID ) {
281359 await updateSessionState ( directory , sessionID , state => {
282- state . pendingMemories = state . pendingMemories . filter ( memory => ! accounting . clearableKeys . has ( memoryKey ( memory ) ) ) ;
360+ state . pendingMemories = state . pendingMemories . filter ( memory => {
361+ const key = memoryKey ( memory ) ;
362+ if ( ! sessionRemovalKeys . has ( key ) ) return true ;
363+
364+ if ( accounting . clearableKeys . has ( key ) ) return false ;
365+ if ( exhaustedRejectedKeys . has ( key ) ) return false ;
366+
367+ return true ;
368+ } ) ;
283369 return state ;
284370 } ) ;
285371 clearFrozenWorkspaceMemoryCache ( sessionID ) ;
286372 }
287373
288374 if ( accounting . clearableKeys . size > 0 ) {
289- await clearPendingMemories ( directory , accounting . clearableKeys ) ;
375+ await clearPendingMemories ( directory , accounting . clearableKeys , {
376+ ownerSessionID : sessionID ,
377+ clearUnowned : ! sessionID || includeUnownedJournal === true ,
378+ } ) ;
290379 }
291380 }
292381
@@ -324,6 +413,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
324413 renderedPrompt : string ;
325414 } > {
326415 const now = Date . now ( ) ;
416+ pruneFrozenWorkspaceMemoryCache ( now ) ;
327417 const cached = frozenWorkspaceMemoryCache . get ( sessionID ) ;
328418
329419 // Cache is valid for the current session cache epoch.
@@ -336,6 +426,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
336426 const store = await loadWorkspaceMemory ( root ) ;
337427 const renderedPrompt = renderWorkspaceMemory ( store ) ;
338428 frozenWorkspaceMemoryCache . set ( sessionID , { store, renderedPrompt, loadedAt : now } ) ;
429+ pruneFrozenWorkspaceMemoryCache ( now ) ;
339430 return { store, renderedPrompt } ;
340431 }
341432
@@ -357,19 +448,23 @@ export const MemoryV2Plugin: Plugin = async (input) => {
357448 const { sessionID } = hookInput ;
358449 if ( ! sessionID ) return ;
359450
451+ pruneFrozenWorkspaceMemoryCache ( ) ;
452+ pruneProcessedUserMessagesCache ( ) ;
453+
360454 // Sub-agents are short-lived - skip memory system
361455 if ( await isSubAgent ( sessionID ) ) return ;
362456
363- // Before first snapshot in this session, promote durable pending memories from
364- // prior sessions. Keep this before processing latest user text so current-turn
365- // explicit memory remains pending (not immediately frozen into system[1]).
457+ // Process explicit user memory even on no-tool turns. Keep this after the
458+ // sub-agent guard so child sessions never append to the parent journal.
459+ await processLatestUserMessage ( sessionID ) ;
460+
461+ // Before first snapshot in this session, promote durable unowned backlog from
462+ // prior sessions. Current-turn owned explicit memory remains pending and only
463+ // appears in hot state for this transform.
366464 if ( ! frozenWorkspaceMemoryCache . has ( sessionID ) && await hasPendingJournalEntries ( directory ) ) {
367- await promotePendingMemories ( ) ;
465+ await promotePendingMemories ( undefined , { includeUnownedJournal : true , includeOwnedJournal : false } ) ;
368466 }
369467
370- // Process explicit user memory even on no-tool turns.
371- await processLatestUserMessage ( sessionID ) ;
372-
373468 // Get frozen workspace memory snapshot (loaded and rendered once per session)
374469 const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot ( directory , sessionID ) ;
375470
@@ -521,7 +616,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
521616 }
522617
523618 try {
524- await promotePendingMemories ( sessionID ) ;
619+ await promotePendingMemories ( sessionID , { includeUnownedJournal : true } ) ;
525620 } catch {
526621 // Keep pending memories in session/journal for retry on next event/session.
527622 }
@@ -532,16 +627,20 @@ export const MemoryV2Plugin: Plugin = async (input) => {
532627 if ( sessionID ) {
533628 // Promote pending memories before deleting per-session state.
534629 // If promotion fails, leave session state and journal intact.
630+ let promoted = false ;
535631 try {
536- await promotePendingMemories ( sessionID ) ;
632+ await promotePendingMemories ( sessionID , { includeOwnedJournal : true , includeUnownedJournal : false } ) ;
633+ promoted = true ;
537634 } catch {
538635 return ;
636+ } finally {
637+ if ( promoted ) {
638+ frozenWorkspaceMemoryCache . delete ( sessionID ) ;
639+ processedUserMessages . delete ( sessionID ) ;
640+ sessionParentCache . delete ( sessionID ) ;
641+ }
539642 }
540643
541- // Clean up caches
542- frozenWorkspaceMemoryCache . delete ( sessionID ) ;
543- processedUserMessages . delete ( sessionID ) ;
544- sessionParentCache . delete ( sessionID ) ;
545644 await rm ( await sessionStatePath ( directory , sessionID ) , { force : true } ) ;
546645 }
547646 }
0 commit comments