Skip to content

Commit b6f195d

Browse files
committed
feat: add llm memory recall prefetch
1 parent 6310f24 commit b6f195d

6 files changed

Lines changed: 815 additions & 30 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,8 @@ Yes. Set `OPENCODE_MEMORY_AUTODREAM=0`. You can also tune gates with:
213213
- `OPENCODE_MEMORY_TERMINAL_LOG` (default `foreground-only`): set `1` to force terminal logs on, `0` to force them off
214214
- `OPENCODE_MEMORY_MODEL`: override model used for extraction
215215
- `OPENCODE_MEMORY_AGENT`: override agent used for extraction
216+
- `OPENCODE_MEMORY_RECALL_MODEL`: override model used for LLM memory recall selection
217+
- `OPENCODE_MEMORY_RECALL_AGENT` (default `opencode-memory-recall`): override agent used for LLM memory recall selection
216218
- `OPENCODE_MEMORY_AUTODREAM` (default `1`): set `0` to disable auto-dream consolidation
217219
- `OPENCODE_MEMORY_AUTODREAM_MIN_HOURS` (default `24`): min hours between consolidation runs
218220
- `OPENCODE_MEMORY_AUTODREAM_MIN_SESSIONS` (default `5`): min touched sessions since last consolidation

src/index.ts

Lines changed: 158 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { Plugin } from "@opencode-ai/plugin"
22
import { tool } from "@opencode-ai/plugin"
33
import { 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"
57
import {
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).
1921
type 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

2536
const turnContextBySession = new Map<string, TurnContext>()
37+
const selectorSessionIDs = new Set<string>()
2638

2739
function 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(/\.md$/, "").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

Comments
 (0)