11import type { Plugin } from "@opencode-ai/plugin"
22import { tool } from "@opencode-ai/plugin"
33import { buildMemorySystemPrompt } from "./prompt.js"
4- import { recallRelevantMemories , formatRecalledMemories } from "./recall.js"
4+ import { formatRecalledMemories , recallSelectedMemories , type RecalledMemory } from "./recall.js"
5+ import { selectRelevantMemoryFilenames , type SessionClient } from "./recallSelector.js"
6+ import { scanMemoryFiles , type MemoryHeader } from "./memoryScan.js"
57import {
68 saveMemory ,
79 deleteMemory ,
@@ -17,12 +19,22 @@ import { getMemoryDir } from "./paths.js"
1719// resets both alreadySurfaced and recentTools (the messages shrink after compact,
1820// so the derived state shrinks with them).
1921type TurnContext = {
22+ turnID : string
2023 query ?: string
2124 alreadySurfaced : Set < string >
2225 recentTools : string [ ]
26+ recallPrefetch ?: RecallPrefetch
27+ }
28+
29+ type RecallPrefetch = {
30+ turnID : string
31+ settled : boolean
32+ consumed : boolean
33+ result : RecalledMemory [ ]
2334}
2435
2536const turnContextBySession = new Map < string , TurnContext > ( )
37+ const selectorSessionIDs = new Set < string > ( )
2638
2739function shouldIgnoreMemoryContext ( query : string | undefined ) : boolean {
2840 if ( process . env . OPENCODE_MEMORY_IGNORE === "1" ) return true
@@ -64,17 +76,20 @@ function extractUserQuery(message: unknown): string | undefined {
6476 return undefined
6577}
6678
67- function getLastUserQuery ( messages : Array < { info ?: { role ?: unknown ; sessionID ?: unknown } ; parts ?: unknown } > ) : {
79+ function getLastUserQuery ( messages : Array < { info ?: { id ?: unknown ; role ?: unknown ; sessionID ?: unknown } ; parts ?: unknown } > ) : {
6880 query ?: string
6981 sessionID ?: string
82+ messageID ?: string
83+ messageIndex ?: number
7084} {
7185 for ( let i = messages . length - 1 ; i >= 0 ; i -- ) {
7286 const message = messages [ i ]
7387 if ( message ?. info ?. role !== "user" ) continue
7488
7589 const query = extractUserQuery ( message )
7690 const sessionID = typeof message . info ?. sessionID === "string" ? message . info . sessionID : undefined
77- return { query, sessionID }
91+ const messageID = typeof message . info ?. id === "string" ? message . info . id : undefined
92+ return { query, sessionID, messageID, messageIndex : i }
7893 }
7994
8095 return { }
@@ -123,6 +138,96 @@ function extractRecentTools(
123138 return tools
124139}
125140
141+ function getRecallAgent ( ) : string {
142+ return process . env . OPENCODE_MEMORY_RECALL_AGENT || "opencode-memory-recall"
143+ }
144+
145+ function getRecallModel ( ) : { providerID : string ; modelID : string } | undefined {
146+ const raw = process . env . OPENCODE_MEMORY_RECALL_MODEL
147+ if ( ! raw ) return undefined
148+
149+ const slashIdx = raw . indexOf ( "/" )
150+ if ( slashIdx <= 0 || slashIdx === raw . length - 1 ) return undefined
151+ return {
152+ providerID : raw . slice ( 0 , slashIdx ) ,
153+ modelID : raw . slice ( slashIdx + 1 ) ,
154+ }
155+ }
156+
157+ function isUsefulRecallQuery ( query : string | undefined ) : query is string {
158+ const trimmed = query ?. trim ( )
159+ if ( ! trimmed ) return false
160+ if ( / \s / . test ( trimmed ) ) return true
161+ return / [ \u3400 - \u9fff ] / . test ( trimmed ) && trimmed . length >= 4
162+ }
163+
164+ function buildTurnID (
165+ sessionID : string ,
166+ messageID : string | undefined ,
167+ messageIndex : number | undefined ,
168+ query : string | undefined ,
169+ ) : string {
170+ return `${ sessionID } :${ messageID ?? `${ messageIndex ?? - 1 } :${ query ?? "" } ` } `
171+ }
172+
173+ function alreadySurfacedKey ( header : MemoryHeader ) : string {
174+ return `${ header . name ?? header . filename . replace ( / \. m d $ / , "" ) . replace ( / .* \/ / , "" ) } |${ header . type ?? "user" } `
175+ }
176+
177+ function startRecallPrefetch ( input : {
178+ client : SessionClient | undefined
179+ directory : string
180+ worktree : string
181+ parentSessionID : string
182+ turnID : string
183+ query : string | undefined
184+ alreadySurfaced : ReadonlySet < string >
185+ recentTools : readonly string [ ]
186+ } ) : RecallPrefetch | undefined {
187+ if ( ! input . client || ! isUsefulRecallQuery ( input . query ) ) return undefined
188+
189+ const memoryDir = getMemoryDir ( input . worktree )
190+ const headers = scanMemoryFiles ( memoryDir ) . filter ( ( header ) => ! input . alreadySurfaced . has ( alreadySurfacedKey ( header ) ) )
191+ if ( headers . length === 0 ) return undefined
192+
193+ const handle : RecallPrefetch = {
194+ turnID : input . turnID ,
195+ settled : false ,
196+ consumed : false ,
197+ result : [ ] ,
198+ }
199+
200+ const promise = selectRelevantMemoryFilenames ( {
201+ client : input . client ,
202+ directory : input . directory ,
203+ parentSessionID : input . parentSessionID ,
204+ query : input . query ,
205+ memories : headers ,
206+ recentTools : input . recentTools ,
207+ selectorSessionIDs,
208+ agent : getRecallAgent ( ) ,
209+ model : getRecallModel ( ) ,
210+ } )
211+ . then ( ( selectedFilenames ) => recallSelectedMemories ( headers , selectedFilenames , input . alreadySurfaced ) )
212+ . catch ( ( ) => [ ] )
213+
214+ void promise . then ( ( result ) => {
215+ handle . result = result
216+ } ) . finally ( ( ) => {
217+ handle . settled = true
218+ } )
219+
220+ return handle
221+ }
222+
223+ function consumeRecallPrefetch ( ctx : TurnContext | undefined ) : RecalledMemory [ ] {
224+ const prefetch = ctx ?. recallPrefetch
225+ if ( ! prefetch || ! prefetch . settled || prefetch . consumed ) return [ ]
226+
227+ prefetch . consumed = true
228+ return prefetch . result
229+ }
230+
126231// Tracks how many memory entries a memory_list call saw so tool.execute.after
127232// can render a meaningful title without re-reading the filesystem. Keyed by
128233// callID, which uniquely identifies a single tool invocation.
@@ -174,18 +279,42 @@ function getCallID(ctx: unknown): string | undefined {
174279 return typeof v === "string" ? v : undefined
175280}
176281
177- export const MemoryPlugin : Plugin = async ( { worktree } ) => {
282+ export const MemoryPlugin : Plugin = async ( { worktree, directory, client } ) => {
283+ directory ??= worktree
178284 getMemoryDir ( worktree )
179285
180286 return {
287+ config : async ( config ) => {
288+ const agentName = getRecallAgent ( )
289+ const mutable = config as {
290+ agent ?: Record < string , Record < string , unknown > >
291+ }
292+ mutable . agent ??= { }
293+ mutable . agent [ agentName ] ??= {
294+ mode : "all" ,
295+ hidden : true ,
296+ prompt : "Select up to 5 relevant memory filenames for the current user query. Return only the requested structured output." ,
297+ }
298+ } ,
299+
300+ "chat.params" : async ( input , output ) => {
301+ if ( input . agent !== getRecallAgent ( ) ) return
302+ output . temperature = 0
303+ output . options = {
304+ ...output . options ,
305+ maxOutputTokens : 256 ,
306+ }
307+ } ,
308+
181309 "tool.execute.after" : async ( input , output ) => {
182310 if ( ! input . tool . startsWith ( "memory_" ) ) return
183311 const title = buildMemoryToolTitle ( input . tool , input . args , input . callID )
184312 if ( title ) output . title = title
185313 } ,
186314
187315 "experimental.chat.messages.transform" : async ( _input , output ) => {
188- const { query, sessionID } = getLastUserQuery ( output . messages )
316+ const { query, sessionID, messageID, messageIndex } = getLastUserQuery ( output . messages )
317+ if ( sessionID && selectorSessionIDs . has ( sessionID ) ) return
189318
190319 if ( sessionID ) {
191320 const alreadySurfaced = new Set < string > ( )
@@ -207,7 +336,26 @@ export const MemoryPlugin: Plugin = async ({ worktree }) => {
207336 output . messages as Array < { info ?: { role ?: unknown } ; parts ?: unknown [ ] } > ,
208337 )
209338
210- turnContextBySession . set ( sessionID , { query, alreadySurfaced, recentTools } )
339+ const turnID = buildTurnID ( sessionID , messageID , messageIndex , query )
340+ const existing = turnContextBySession . get ( sessionID )
341+ const ignoreMemoryContext = process . env . OPENCODE_MEMORY_IGNORE === "1" || shouldIgnoreMemoryContext ( query )
342+ let recallPrefetch : RecallPrefetch | undefined
343+ if ( ! ignoreMemoryContext ) {
344+ recallPrefetch = existing ?. turnID === turnID
345+ ? existing . recallPrefetch
346+ : startRecallPrefetch ( {
347+ client : client as unknown as SessionClient ,
348+ directory,
349+ worktree,
350+ parentSessionID : sessionID ,
351+ turnID,
352+ query,
353+ alreadySurfaced,
354+ recentTools,
355+ } )
356+ }
357+
358+ turnContextBySession . set ( sessionID , { turnID, query, alreadySurfaced, recentTools, recallPrefetch } )
211359 }
212360
213361 if ( shouldIgnoreMemoryContext ( query ) ) {
@@ -226,18 +374,17 @@ export const MemoryPlugin: Plugin = async ({ worktree }) => {
226374 "experimental.chat.system.transform" : async ( _input , output ) => {
227375 let sessionID : string | undefined
228376 if ( _input && typeof _input === "object" ) {
229- sessionID = ( typeof ( _input as { sessionID ?: unknown } ) . sessionID === "string"
377+ sessionID = typeof ( _input as { sessionID ?: unknown } ) . sessionID === "string"
230378 ? ( _input as { sessionID ?: string } ) . sessionID
231- : undefined )
379+ : undefined
232380 }
381+ if ( sessionID && selectorSessionIDs . has ( sessionID ) ) return
233382
234383 const ctx = sessionID ? turnContextBySession . get ( sessionID ) : undefined
235384 const query = ctx ?. query
236- const alreadySurfaced = ctx ?. alreadySurfaced ?? new Set < string > ( )
237- const recentTools = ctx ?. recentTools ?? [ ]
238385
239386 const ignoreMemoryContext = process . env . OPENCODE_MEMORY_IGNORE === "1" || shouldIgnoreMemoryContext ( query )
240- const recalled = ignoreMemoryContext ? [ ] : recallRelevantMemories ( worktree , query , alreadySurfaced , recentTools )
387+ const recalled = ignoreMemoryContext ? [ ] : consumeRecallPrefetch ( ctx )
241388
242389 const recalledSection = formatRecalledMemories ( recalled )
243390 const memoryPrompt = buildMemorySystemPrompt ( worktree , recalledSection , {
0 commit comments