opencode-claude-memory 1.7.6 → 1.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +104 -213
  2. package/dist/agents.d.ts +12 -0
  3. package/dist/agents.js +69 -0
  4. package/dist/config.d.ts +112 -0
  5. package/dist/config.js +79 -0
  6. package/dist/extraction/ExtractionCoordinator.d.ts +62 -0
  7. package/dist/extraction/ExtractionCoordinator.js +386 -0
  8. package/dist/extraction/autodream.d.ts +33 -0
  9. package/dist/extraction/autodream.js +85 -0
  10. package/dist/extraction/forkSession.d.ts +37 -0
  11. package/dist/extraction/forkSession.js +133 -0
  12. package/dist/extraction/lock.d.ts +29 -0
  13. package/dist/extraction/lock.js +191 -0
  14. package/dist/extraction/prompts.d.ts +5 -0
  15. package/dist/extraction/prompts.js +78 -0
  16. package/dist/extraction/state.d.ts +38 -0
  17. package/dist/extraction/state.js +159 -0
  18. package/dist/hooks/ignore.d.ts +6 -0
  19. package/dist/hooks/ignore.js +53 -0
  20. package/dist/hooks/messages.d.ts +14 -0
  21. package/dist/hooks/messages.js +96 -0
  22. package/dist/index.d.ts +7 -1
  23. package/dist/index.js +50 -633
  24. package/dist/prompt/sections.d.ts +7 -0
  25. package/dist/{prompt.js → prompt/sections.js} +16 -72
  26. package/dist/prompt/systemPrompt.d.ts +7 -0
  27. package/dist/prompt/systemPrompt.js +64 -0
  28. package/dist/recall/RecallCoordinator.d.ts +36 -0
  29. package/dist/recall/RecallCoordinator.js +140 -0
  30. package/dist/recall/format.d.ts +17 -0
  31. package/dist/{recall.js → recall/format.js} +13 -36
  32. package/dist/{recallSelector.d.ts → recall/selector.d.ts} +26 -16
  33. package/dist/recall/selector.js +92 -0
  34. package/dist/sdk.d.ts +12 -0
  35. package/dist/sdk.js +8 -0
  36. package/dist/store/MemoryStore.d.ts +41 -0
  37. package/dist/store/MemoryStore.js +105 -0
  38. package/dist/store/frontmatter.d.ts +25 -0
  39. package/dist/store/frontmatter.js +61 -0
  40. package/dist/store/indexFile.d.ts +15 -0
  41. package/dist/store/indexFile.js +153 -0
  42. package/dist/{paths.d.ts → store/paths.d.ts} +7 -6
  43. package/dist/store/paths.js +187 -0
  44. package/dist/store/scan.d.ts +29 -0
  45. package/dist/store/scan.js +112 -0
  46. package/dist/tools.d.ts +9 -0
  47. package/dist/tools.js +142 -0
  48. package/dist/util/exclusiveFile.d.ts +11 -0
  49. package/dist/util/exclusiveFile.js +108 -0
  50. package/dist/util/legacyShellHook.d.ts +3 -0
  51. package/dist/util/legacyShellHook.js +23 -0
  52. package/dist/util/log.d.ts +6 -0
  53. package/dist/util/log.js +29 -0
  54. package/dist/util/ownedSessions.d.ts +7 -0
  55. package/dist/util/ownedSessions.js +39 -0
  56. package/dist/util/timeout.d.ts +5 -0
  57. package/dist/util/timeout.js +39 -0
  58. package/package.json +15 -8
  59. package/bin/opencode-memory +0 -1436
  60. package/dist/memory.d.ts +0 -25
  61. package/dist/memory.js +0 -203
  62. package/dist/memoryScan.d.ts +0 -20
  63. package/dist/memoryScan.js +0 -106
  64. package/dist/nativeExtraction.d.ts +0 -2
  65. package/dist/nativeExtraction.js +0 -38
  66. package/dist/paths.js +0 -141
  67. package/dist/prompt.d.ts +0 -4
  68. package/dist/recall.d.ts +0 -12
  69. package/dist/recallSelector.js +0 -154
package/dist/index.js CHANGED
@@ -1,643 +1,60 @@
1
- import { tool } from "@opencode-ai/plugin";
2
- import { parse, resolve } from "path";
3
- import { buildMemorySystemPrompt } from "./prompt.js";
4
- import { formatRecalledMemories, recallSelectedMemories } from "./recall.js";
5
- import { isSupportedRecallSelectorClient, selectRelevantMemoryFilenames } from "./recallSelector.js";
6
- import { scanMemoryFiles } from "./memoryScan.js";
7
- import { saveMemory, deleteMemory, listMemories, searchMemories, readMemory, MEMORY_TYPES, } from "./memory.js";
8
- import { getMemoryDir } from "./paths.js";
9
- import { getNativeExtractTimeoutMs, logNativeExtractionFailure } from "./nativeExtraction.js";
10
- const turnContextBySession = new Map();
11
- const selectorSessionIDs = new Set();
12
- function shouldIgnoreMemoryContext(query) {
13
- if (process.env.OPENCODE_MEMORY_IGNORE === "1")
14
- return true;
15
- if (!query)
16
- return false;
17
- const normalized = query.toLowerCase();
18
- return (/(ignore|don't use|do not use|without|skip)\s+(the\s+)?memory/.test(normalized) ||
19
- /memory\s+(should be|must be)?\s*ignored/.test(normalized));
20
- }
21
- function extractUserQuery(message) {
22
- if (!message || typeof message !== "object")
23
- return undefined;
24
- if ("content" in message) {
25
- const content = message.content;
26
- if (typeof content === "string")
27
- return content;
28
- if (content !== undefined)
29
- return JSON.stringify(content);
30
- }
31
- if ("parts" in message) {
32
- const parts = message.parts;
33
- if (Array.isArray(parts)) {
34
- const text = parts
35
- .map((part) => {
36
- if (!part || typeof part !== "object")
37
- return "";
38
- return typeof part.text === "string"
39
- ? part.text
40
- : "";
41
- })
42
- .filter(Boolean)
43
- .join("\n")
44
- .trim();
45
- if (text)
46
- return text;
47
- }
48
- }
49
- return undefined;
50
- }
51
- function getLastUserQuery(messages) {
52
- for (let i = messages.length - 1; i >= 0; i--) {
53
- const message = messages[i];
54
- if (message?.info?.role !== "user")
55
- continue;
56
- const query = extractUserQuery(message);
57
- const sessionID = typeof message.info?.sessionID === "string" ? message.info.sessionID : undefined;
58
- const messageID = typeof message.info?.id === "string" ? message.info.id : undefined;
59
- return { query, sessionID, messageID, messageIndex: i };
60
- }
61
- return {};
62
- }
63
- function isAutoMemoryPart(part) {
64
- if (!part || typeof part !== "object")
65
- return false;
66
- return typeof part.text === "string" &&
67
- part.text.includes("# Auto Memory");
68
- }
69
- // Parses "### <name> (<type>)" headers from the ## Recalled Memories section
70
- // of system prompts. After compaction old system messages disappear, so
71
- // the returned set naturally shrinks — no manual reset needed.
72
- function extractSurfacedMemoryKeys(systemText) {
73
- const keys = new Set();
74
- const recalledSection = systemText.indexOf("## Recalled Memories");
75
- if (recalledSection === -1)
76
- return keys;
77
- const headerPattern = /^### (.+?) \((\w+)\)/gm;
78
- const section = systemText.slice(recalledSection);
79
- for (let match = headerPattern.exec(section); match !== null; match = headerPattern.exec(section)) {
80
- keys.add(`${match[1]}|${match[2]}`);
81
- }
82
- return keys;
83
- }
84
- // Only completed tools — matches Claude Code's collectRecentSuccessfulTools().
85
- function extractRecentTools(messages) {
86
- const tools = [];
87
- const seen = new Set();
88
- for (const message of messages) {
89
- if (!message.parts || !Array.isArray(message.parts))
90
- continue;
91
- for (const part of message.parts) {
92
- if (!part || typeof part !== "object")
93
- continue;
94
- const p = part;
95
- if (p.type !== "tool" || !p.tool)
96
- continue;
97
- if (p.state?.status !== "completed")
98
- continue;
99
- if (seen.has(p.tool))
100
- continue;
101
- seen.add(p.tool);
102
- tools.push(p.tool);
103
- }
104
- }
105
- return tools;
106
- }
107
- function getRecallAgent() {
108
- return process.env.OPENCODE_MEMORY_RECALL_AGENT || "opencode-memory-recall";
109
- }
110
- function getRecallModel() {
111
- const raw = process.env.OPENCODE_MEMORY_RECALL_MODEL;
112
- if (!raw)
113
- return undefined;
114
- const slashIdx = raw.indexOf("/");
115
- if (slashIdx <= 0 || slashIdx === raw.length - 1)
116
- return undefined;
1
+ import { AgentRegistry } from "./agents.js";
2
+ import { parseConfig } from "./config.js";
3
+ import { ExtractionCoordinator } from "./extraction/ExtractionCoordinator.js";
4
+ import { buildMemorySystemPrompt } from "./prompt/systemPrompt.js";
5
+ import { formatRecalledMemories } from "./recall/format.js";
6
+ import { RecallCoordinator } from "./recall/RecallCoordinator.js";
7
+ import { MemoryStore } from "./store/MemoryStore.js";
8
+ import { resolveMemoryRoot } from "./store/paths.js";
9
+ import { buildMemoryTools } from "./tools.js";
10
+ import { createLogger } from "./util/log.js";
11
+ import { OwnedSessions } from "./util/ownedSessions.js";
12
+ export const PLUGIN_ID = "opencode-claude-memory";
13
+ // Assembly only. Every piece of mutable state lives on the coordinators created here, so OpenCode's
14
+ // multi-directory `serve` gets fully isolated instances. `env` and `homeDir` are injectable so tests
15
+ // never touch the real environment (CLAUDE_CONFIG_DIR is the only variable read, see config.ts).
16
+ export const createMemoryPlugin = (env, homeDir) => async ({ worktree, directory, client }, options) => {
17
+ const config = parseConfig(options, env, homeDir);
18
+ const dir = directory ?? worktree;
19
+ const store = new MemoryStore(resolveMemoryRoot(worktree, dir), config);
20
+ const log = createLogger(client, dir);
21
+ const owned = new OwnedSessions();
22
+ const agents = new AgentRegistry(config.agents);
23
+ const deps = { store, config, client, directory: dir, owned, agents, log };
24
+ const recall = new RecallCoordinator(deps);
25
+ const extraction = new ExtractionCoordinator(deps);
117
26
  return {
118
- providerID: raw.slice(0, slashIdx),
119
- modelID: raw.slice(slashIdx + 1),
120
- };
121
- }
122
- function isRootPath(path) {
123
- const resolved = resolve(path);
124
- return resolved === parse(resolved).root;
125
- }
126
- function resolveMemoryRoot(worktree, directory) {
127
- if (isRootPath(worktree) && !isRootPath(directory))
128
- return directory;
129
- return worktree;
130
- }
131
- function isUsefulRecallQuery(query) {
132
- const trimmed = query?.trim();
133
- if (!trimmed)
134
- return false;
135
- if (/\s/.test(trimmed))
136
- return true;
137
- return /[\u3400-\u9fff]/.test(trimmed) && trimmed.length >= 4;
138
- }
139
- function buildTurnID(sessionID, messageID, messageIndex, query) {
140
- return `${sessionID}:${messageID ?? `${messageIndex ?? -1}:${query ?? ""}`}`;
141
- }
142
- function alreadySurfacedKey(header) {
143
- return `${header.name ?? header.filename.replace(/\.md$/, "").replace(/.*\//, "")}|${header.type ?? "user"}`;
144
- }
145
- function startRecallPrefetch(input) {
146
- if (!input.client || !isUsefulRecallQuery(input.query))
147
- return undefined;
148
- if (!isSupportedRecallSelectorClient(input.client))
149
- return undefined;
150
- const memoryDir = getMemoryDir(input.worktree);
151
- const headers = scanMemoryFiles(memoryDir).filter((header) => !input.alreadySurfaced.has(alreadySurfacedKey(header)));
152
- if (headers.length === 0)
153
- return undefined;
154
- const handle = {
155
- turnID: input.turnID,
156
- settled: false,
157
- consumed: false,
158
- result: [],
159
- };
160
- const promise = selectRelevantMemoryFilenames({
161
- client: input.client,
162
- directory: input.directory,
163
- parentSessionID: input.parentSessionID,
164
- query: input.query,
165
- memories: headers,
166
- recentTools: input.recentTools,
167
- selectorSessionIDs,
168
- agent: getRecallAgent(),
169
- model: getRecallModel(),
170
- })
171
- .then((selectedFilenames) => recallSelectedMemories(headers, selectedFilenames, input.alreadySurfaced))
172
- .catch(() => []);
173
- void promise.then((result) => {
174
- handle.result = result;
175
- }).finally(() => {
176
- handle.settled = true;
177
- });
178
- return handle;
179
- }
180
- function consumeRecallPrefetch(ctx) {
181
- const prefetch = ctx?.recallPrefetch;
182
- if (!prefetch || !prefetch.settled || prefetch.consumed)
183
- return [];
184
- prefetch.consumed = true;
185
- return prefetch.result;
186
- }
187
- // Tracks how many memory entries a memory_list call saw so tool.execute.after
188
- // can render a meaningful title without re-reading the filesystem. Keyed by
189
- // callID, which uniquely identifies a single tool invocation.
190
- const memoryListCountByCallID = new Map();
191
- const memorySearchCountByCallID = new Map();
192
- function buildMemoryToolTitle(toolID, args, callID) {
193
- switch (toolID) {
194
- case "memory_save": {
195
- const type = typeof args?.type === "string" ? args.type : "";
196
- const name = typeof args?.name === "string" ? args.name : "";
197
- if (type && name)
198
- return `${type}: ${name}`;
199
- if (name)
200
- return name;
201
- return undefined;
202
- }
203
- case "memory_delete":
204
- case "memory_read": {
205
- const fileName = typeof args?.file_name === "string" ? args.file_name : "";
206
- return fileName || undefined;
207
- }
208
- case "memory_list": {
209
- const count = callID ? memoryListCountByCallID.get(callID) : undefined;
210
- if (callID)
211
- memoryListCountByCallID.delete(callID);
212
- if (count === undefined)
213
- return "list memories";
214
- return `${count} ${count === 1 ? "memory" : "memories"}`;
215
- }
216
- case "memory_search": {
217
- const query = typeof args?.query === "string" ? args.query : "";
218
- const count = callID ? memorySearchCountByCallID.get(callID) : undefined;
219
- if (callID)
220
- memorySearchCountByCallID.delete(callID);
221
- if (query && count !== undefined) {
222
- return `"${query}" · ${count} ${count === 1 ? "match" : "matches"}`;
223
- }
224
- if (query)
225
- return `"${query}"`;
226
- return undefined;
227
- }
228
- default:
229
- return undefined;
230
- }
231
- }
232
- function getCallID(ctx) {
233
- if (!ctx || typeof ctx !== "object")
234
- return undefined;
235
- const v = ctx.callID;
236
- return typeof v === "string" ? v : undefined;
237
- }
238
- // ─── Native post-session extraction (optional) ───────────────────────────────
239
- // Architecture follows opencode-mem's proven pattern: hook `session.idle`,
240
- // fetch the conversation via ctx.client.session.messages(), then run extraction
241
- // in an isolated sub-session (create + prompt + delete via ctx.client). No shell,
242
- // no `opencode run --fork` subprocess — those don't work cross-platform from a
243
- // plugin and `ctx.$` is not reliably populated.
244
- // Enabled by default on Windows (bash wrapper can't run there); opt-in elsewhere
245
- // via OPENCODE_MEMORY_NATIVE_EXTRACT=1; =0 force-disables.
246
- // 10s debounce collapses rapid idle events. Recursion guard via an in-process
247
- // Set of sub-session IDs. Extraction is additive only (memory_save).
248
- const EXTRACT_PROMPT = `You are now acting as the memory extraction subagent. The conversation below is reviewed for anything worth remembering for future sessions.
249
-
250
- ## What to save
251
-
252
- Use the \`memory_save\` tool to persist memories. There are four types:
253
-
254
- 1. **user** — Who the user is: role, expertise, preferences, communication style. Helps tailor future interactions.
255
- 2. **feedback** — Guidance on how to work: corrections ("don't do X"), confirmations ("yes, keep doing that"), approach preferences. Include *why* so edge cases can be judged.
256
- 3. **project** — Ongoing work context: goals, deadlines, initiatives, decisions, bugs. NOT derivable from code/git. Convert relative dates to absolute.
257
- 4. **reference** — Pointers to external resources: URLs, tool names, where to find information outside the codebase.
258
-
259
- ## What NOT to save
260
-
261
- - Code patterns, architecture, file structure — derivable from the codebase
262
- - Git history, recent changes — use \`git log\`/\`git blame\`
263
- - Debugging solutions — the fix is in the code
264
- - Anything already in AGENTS.md / project config files
265
- - Ephemeral task details or current conversation context
266
- - Information that was already saved in a previous extraction
267
-
268
- ## How to save
269
-
270
- For each memory worth saving, call \`memory_save\` with:
271
- - \`file_name\`: descriptive slug (e.g., \`user_role\`, \`feedback_testing_approach\`)
272
- - \`name\`: short title
273
- - \`description\`: one-line description (used for relevance matching in future sessions)
274
- - \`type\`: one of user, feedback, project, reference
275
- - \`content\`: the memory content. For feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines.
276
-
277
- ## Instructions
278
-
279
- 1. Analyze the conversation for memorable information
280
- 2. Check existing memories first (use \`memory_list\`) to avoid duplicates — update existing ones if needed
281
- 3. Save each distinct memory as a separate entry
282
- 4. If the conversation was trivial (e.g., just "hello" or a quick lookup), save nothing — that's fine
283
- 5. Be selective: 0-3 memories per session is typical. Quality over quantity.
284
- 6. Do NOT save a memory about the extraction process itself.`;
285
- const NATIVE_EXTRACT_DEBOUNCE_MS = 10000;
286
- const NATIVE_EXTRACT_MAX_CONV_CHARS = 60000;
287
- const NATIVE_EXTRACT_GRACE_MS = 60000; // keep forkID in the guard after delete — covers the idle-race window
288
- const nativeIdleTimer = new Map();
289
- const nativeExtractionSessions = new Set(); // sub-session IDs → skip their idle AND shield from transforms
290
- const nativeExtractionInFlight = new Set(); // parent sessionIDs with an extraction running → no overlap
291
- function extractIDFromResponse(response) {
292
- const data = response?.data ?? response;
293
- const obj = data;
294
- const v = obj?.id ?? obj?.sessionID;
295
- return typeof v === "string" ? v : null;
296
- }
297
- function getNativeExtractAgent() {
298
- return process.env.OPENCODE_MEMORY_AGENT || "opencode-memory-extract";
299
- }
300
- // Parse OPENCODE_MEMORY_MODEL into the SDK's ModelRef shape (providerID/modelID), same as getRecallModel().
301
- function getNativeExtractModel() {
302
- const raw = process.env.OPENCODE_MEMORY_MODEL;
303
- if (!raw)
304
- return undefined;
305
- const slashIdx = raw.indexOf("/");
306
- if (slashIdx <= 0 || slashIdx === raw.length - 1)
307
- return undefined;
308
- return { providerID: raw.slice(0, slashIdx), modelID: raw.slice(slashIdx + 1) };
309
- }
310
- function buildConversationForExtraction(messages) {
311
- const lines = [];
312
- for (const m of messages) {
313
- const role = m?.info?.role;
314
- if (!role || !Array.isArray(m?.parts))
315
- continue;
316
- for (const p of m.parts) {
317
- const part = p;
318
- if (part.type === "text" && typeof part.text === "string" && !part.synthetic) {
319
- lines.push(`### ${role === "user" ? "User" : "Assistant"}\n${part.text}`);
320
- }
321
- else if (part.type === "tool" && part.tool) {
322
- if (part.state?.status === "completed" && typeof part.state?.output === "string") {
323
- const out = part.state.output.length > 300 ? part.state.output.slice(0, 300) + "…" : part.state.output;
324
- lines.push(`_[tool ${part.tool}: ${out}]_`);
325
- }
326
- }
327
- }
328
- }
329
- let text = lines.join("\n\n");
330
- // Keep the TAIL (newest turns carry the new facts worth extracting), drop the oldest head.
331
- if (text.length > NATIVE_EXTRACT_MAX_CONV_CHARS) {
332
- text = "…[older turns truncated]\n\n" + text.slice(-NATIVE_EXTRACT_MAX_CONV_CHARS);
333
- }
334
- return text;
335
- }
336
- // Security: the extraction fork runs on raw, potentially-untrusted transcript content (fetched web
337
- // pages, tool output). It MUST be sandboxed to the memory tools only (no bash/edit/write) and capped
338
- // by a timeout so a hung prompt stops blocking best-effort sub-session cleanup.
339
- async function runNativeExtraction(client, sessionID, directory) {
340
- const c = client;
341
- if (!c?.session?.messages || !c.session.create || !c.session.prompt)
342
- return;
343
- if (nativeExtractionInFlight.has(sessionID))
344
- return; // no overlapping runs against the same session
345
- nativeExtractionInFlight.add(sessionID);
346
- let forkID = null;
347
- try {
348
- // query.directory is required for correct resolution under multi-directory serve.
349
- const resp = await c.session.messages({ path: { id: sessionID }, query: { directory } });
350
- const messages = (resp.data ?? []);
351
- const convText = buildConversationForExtraction(messages);
352
- if (convText.trim().length < 20)
353
- return;
354
- const created = await c.session.create({
355
- body: { parentID: sessionID, title: "opencode-memory extraction" },
356
- query: { directory },
357
- });
358
- forkID = extractIDFromResponse(created);
359
- if (!forkID)
360
- return;
361
- nativeExtractionSessions.add(forkID); // shield the fork from the plugin's own recall/transform hooks
362
- const body = {
363
- agent: getNativeExtractAgent(),
364
- system: EXTRACT_PROMPT, // always pass explicitly — ??= agent registration can miss if the agent already exists
365
- tools: { "*": false, memory_save: true, memory_list: true }, // wildcard deny first; findLast → memory allows win
366
- parts: [{ type: "text", text: convText }],
367
- };
368
- const extractModel = getNativeExtractModel();
369
- if (extractModel)
370
- body.model = extractModel;
371
- // Race the prompt against a timeout so a hung/permission-gated fork stops blocking cleanup.
372
- let timer;
373
- const timeout = new Promise((_, reject) => {
374
- timer = setTimeout(() => reject(new Error("native extraction timed out")), getNativeExtractTimeoutMs());
375
- });
376
- try {
377
- await Promise.race([
378
- c.session.prompt({ path: { id: forkID }, query: { directory }, body }),
379
- timeout,
380
- ]);
381
- }
382
- finally {
383
- if (timer)
384
- clearTimeout(timer);
385
- }
386
- }
387
- catch (e) {
388
- logNativeExtractionFailure(client, directory, sessionID, e);
389
- }
390
- finally {
391
- if (forkID) {
392
- await c.session?.delete?.({ path: { id: forkID }, query: { directory } }).catch(() => { });
393
- // Hold the guard past delete: the fork's session.idle can arrive on a separate channel after
394
- // the delete HTTP call resolves, and would otherwise re-trigger extraction of the fork itself.
395
- const id = forkID;
396
- setTimeout(() => nativeExtractionSessions.delete(id), NATIVE_EXTRACT_GRACE_MS);
397
- }
398
- nativeExtractionInFlight.delete(sessionID);
399
- }
400
- }
401
- // Native extraction is the bash-wrapper replacement. Default-on for Windows (where the wrapper can't
402
- // run); opt-in elsewhere. Honors the wrapper's documented OPENCODE_MEMORY_EXTRACT=0 opt-out so a user
403
- // who disabled extraction gets it disabled here too (not silently re-enabled by the win32 default).
404
- // OPENCODE_MEMORY_NATIVE_EXTRACT=0/1 force-disables/enables regardless of platform.
405
- function nativeExtractionEnabled() {
406
- if (process.env.OPENCODE_MEMORY_EXTRACT === "0")
407
- return false;
408
- const flag = process.env.OPENCODE_MEMORY_NATIVE_EXTRACT;
409
- if (flag === "0")
410
- return false;
411
- if (flag === "1")
412
- return true;
413
- return process.platform === "win32";
414
- }
415
- export const MemoryPlugin = async ({ worktree, directory, client }) => {
416
- directory ??= worktree;
417
- const memoryRoot = resolveMemoryRoot(worktree, directory);
418
- getMemoryDir(memoryRoot);
419
- return {
420
- config: async (config) => {
421
- const agentName = getRecallAgent();
422
- const mutable = config;
423
- mutable.agent ??= {};
424
- mutable.agent[agentName] ??= {
425
- mode: "all",
426
- hidden: true,
427
- prompt: "Select up to 5 relevant memory filenames for the current user query. Return only the requested structured output.",
428
- };
429
- // Dedicated hidden agent for native extraction: its system prompt IS the extraction instruction,
430
- // so the fork runs with an explicit system (not the build agent's) and tool-restricted to memory_*.
431
- mutable.agent[getNativeExtractAgent()] ??= {
432
- mode: "all",
433
- hidden: true,
434
- prompt: EXTRACT_PROMPT,
435
- };
27
+ config: async (cfg) => {
28
+ agents.register(cfg);
29
+ // Runs once the agent sandbox is known; failures are logged inside catchUp().
30
+ void extraction.catchUp();
436
31
  },
437
- // Native post-session extraction. Enabled by default on Windows (where the
438
- // bash wrapper can't run) and via OPENCODE_MEMORY_NATIVE_EXTRACT=1 elsewhere;
439
- // =0 force-disables. Hooks `session.idle` (the established idle signal — used
440
- // by opencode-mem and others). 10s debounce collapses rapid idle into one
441
- // capture; an in-process Set of sub-session IDs prevents recursion. All work
442
- // goes through ctx.client — no shell, no fork.
443
- event: async (input) => {
444
- if (!nativeExtractionEnabled())
445
- return;
446
- const evt = (input && typeof input === "object" && "event" in input
447
- ? input.event
448
- : input);
449
- if (evt?.type !== "session.idle")
450
- return;
451
- const sessionID = evt.properties?.sessionID;
452
- if (!sessionID)
453
- return;
454
- if (nativeExtractionSessions.has(sessionID))
455
- return; // skip our own extraction sub-sessions
456
- if (selectorSessionIDs.has(sessionID))
457
- return; // skip recall-selector child sessions (same guard the transforms use)
458
- if (nativeIdleTimer.has(sessionID))
459
- clearTimeout(nativeIdleTimer.get(sessionID));
460
- nativeIdleTimer.set(sessionID, setTimeout(() => {
461
- nativeIdleTimer.delete(sessionID);
462
- void runNativeExtraction(client, sessionID, directory).catch((e) => {
463
- logNativeExtractionFailure(client, directory, sessionID, e);
464
- });
465
- }, NATIVE_EXTRACT_DEBOUNCE_MS));
466
- },
467
- "chat.params": async (input, output) => {
468
- if (input.agent !== getRecallAgent())
469
- return;
470
- output.temperature = 0;
471
- output.options = {
472
- ...output.options,
473
- };
474
- },
475
- "tool.execute.after": async (input, output) => {
476
- if (!input.tool.startsWith("memory_"))
477
- return;
478
- const title = buildMemoryToolTitle(input.tool, input.args, input.callID);
479
- if (title)
480
- output.title = title;
32
+ event: async ({ event }) => {
33
+ if (event.type === "session.deleted")
34
+ owned.release(event.properties.info.id, 5_000);
35
+ recall.onEvent(event);
36
+ extraction.onEvent(event);
481
37
  },
482
38
  "experimental.chat.messages.transform": async (_input, output) => {
483
- const { query, sessionID, messageID, messageIndex } = getLastUserQuery(output.messages);
484
- if (sessionID && (selectorSessionIDs.has(sessionID) || nativeExtractionSessions.has(sessionID)))
485
- return;
486
- if (sessionID) {
487
- const alreadySurfaced = new Set();
488
- for (const message of output.messages) {
489
- const role = String(message.info.role);
490
- if (role !== "system")
491
- continue;
492
- for (const part of message.parts) {
493
- if (!part || typeof part !== "object")
494
- continue;
495
- const text = part.text;
496
- if (typeof text === "string") {
497
- for (const key of extractSurfacedMemoryKeys(text)) {
498
- alreadySurfaced.add(key);
499
- }
500
- }
501
- }
502
- }
503
- const recentTools = extractRecentTools(output.messages);
504
- const turnID = buildTurnID(sessionID, messageID, messageIndex, query);
505
- const existing = turnContextBySession.get(sessionID);
506
- const ignoreMemoryContext = process.env.OPENCODE_MEMORY_IGNORE === "1" || shouldIgnoreMemoryContext(query);
507
- let recallPrefetch;
508
- if (!ignoreMemoryContext) {
509
- recallPrefetch = existing?.turnID === turnID
510
- ? existing.recallPrefetch
511
- : startRecallPrefetch({
512
- client: client,
513
- directory,
514
- worktree: memoryRoot,
515
- parentSessionID: sessionID,
516
- turnID,
517
- query,
518
- alreadySurfaced,
519
- recentTools,
520
- });
521
- }
522
- turnContextBySession.set(sessionID, { turnID, query, alreadySurfaced, recentTools, recallPrefetch });
523
- }
524
- if (shouldIgnoreMemoryContext(query)) {
525
- output.messages = output.messages
526
- .map((message) => {
527
- const role = String(message.info.role);
528
- if (role !== "system")
529
- return message;
530
- const parts = message.parts.filter((part) => !isAutoMemoryPart(part));
531
- return { ...message, parts };
532
- })
533
- .filter((message) => message.parts.length > 0);
534
- }
39
+ recall.onMessagesTransform(output);
535
40
  },
536
- "experimental.chat.system.transform": async (_input, output) => {
537
- let sessionID;
538
- if (_input && typeof _input === "object") {
539
- sessionID = typeof _input.sessionID === "string"
540
- ? _input.sessionID
541
- : undefined;
542
- }
543
- if (sessionID && (selectorSessionIDs.has(sessionID) || nativeExtractionSessions.has(sessionID)))
41
+ "experimental.chat.system.transform": async ({ sessionID }, output) => {
42
+ if (owned.has(sessionID))
544
43
  return;
545
- const ctx = sessionID ? turnContextBySession.get(sessionID) : undefined;
546
- const query = ctx?.query;
547
- const ignoreMemoryContext = process.env.OPENCODE_MEMORY_IGNORE === "1" || shouldIgnoreMemoryContext(query);
548
- const recalled = ignoreMemoryContext ? [] : consumeRecallPrefetch(ctx);
549
- const recalledSection = formatRecalledMemories(recalled);
550
- const memoryPrompt = buildMemorySystemPrompt(memoryRoot, recalledSection, {
551
- includeIndex: !ignoreMemoryContext,
552
- });
553
- output.system.push(memoryPrompt);
44
+ const recalled = await recall.takeRecalled(sessionID);
45
+ output.system.push(buildMemorySystemPrompt(store, formatRecalledMemories(recalled), {
46
+ includeIndex: !recall.isIgnored(sessionID),
47
+ }));
554
48
  },
555
- tool: {
556
- memory_save: tool({
557
- description: "Save or update a memory for future conversations. " +
558
- "Each memory is stored as a markdown file with frontmatter. " +
559
- "Use this when the user explicitly asks you to remember something, " +
560
- "or when you observe important information worth preserving across sessions " +
561
- "(user preferences, feedback, project context, external references). " +
562
- "Check existing memories first with memory_list or memory_search to avoid duplicates.",
563
- args: {
564
- file_name: tool.schema
565
- .string()
566
- .describe('File name for the memory (without .md extension). Use snake_case, e.g. "user_role", "feedback_testing_style", "project_auth_rewrite"'),
567
- name: tool.schema.string().describe("Human-readable name for this memory"),
568
- description: tool.schema
569
- .string()
570
- .describe("One-line description — used to decide relevance in future conversations, so be specific"),
571
- type: tool.schema
572
- .enum(MEMORY_TYPES)
573
- .describe("Memory type: user (about the person), feedback (guidance on approach), project (ongoing work context), reference (pointers to external systems)"),
574
- content: tool.schema
575
- .string()
576
- .describe("Memory content. For feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines"),
577
- },
578
- async execute(args, _ctx) {
579
- const filePath = saveMemory(memoryRoot, args.file_name, args.name, args.description, args.type, args.content);
580
- return `Memory saved to ${filePath}`;
581
- },
582
- }),
583
- memory_delete: tool({
584
- description: "Delete a memory that is outdated, wrong, or no longer relevant. Also removes it from the index.",
585
- args: {
586
- file_name: tool.schema.string().describe("File name of the memory to delete (with or without .md extension)"),
587
- },
588
- async execute(args, _ctx) {
589
- const deleted = deleteMemory(memoryRoot, args.file_name);
590
- return deleted ? `Memory "${args.file_name}" deleted.` : `Memory "${args.file_name}" not found.`;
591
- },
592
- }),
593
- memory_list: tool({
594
- description: "List all saved memories with their names, types, and descriptions. " +
595
- "Use this to check what memories exist before saving a new one (to avoid duplicates) " +
596
- "or when you need to recall what's been stored.",
597
- args: {},
598
- async execute(_args, ctx) {
599
- const entries = listMemories(memoryRoot);
600
- const callID = getCallID(ctx);
601
- if (callID)
602
- memoryListCountByCallID.set(callID, entries.length);
603
- if (entries.length === 0) {
604
- return "No memories saved yet.";
605
- }
606
- const lines = entries.map((e) => `- **${e.name}** (${e.type}) [${e.fileName}]: ${e.description}`);
607
- return `${entries.length} memories found:\n${lines.join("\n")}`;
608
- },
609
- }),
610
- memory_search: tool({
611
- description: "Search memories by keyword. Searches across names, descriptions, and content. " +
612
- "Use this to find relevant memories before answering questions or when the user references past conversations.",
613
- args: {
614
- query: tool.schema.string().describe("Search query — searches across name, description, and content"),
615
- },
616
- async execute(args, ctx) {
617
- const results = searchMemories(memoryRoot, args.query);
618
- const callID = getCallID(ctx);
619
- if (callID)
620
- memorySearchCountByCallID.set(callID, results.length);
621
- if (results.length === 0) {
622
- return `No memories matching "${args.query}".`;
623
- }
624
- const lines = results.map((e) => `- **${e.name}** (${e.type}) [${e.fileName}]: ${e.description}\n Content: ${e.content.slice(0, 200)}${e.content.length > 200 ? "..." : ""}`);
625
- return `${results.length} matches for "${args.query}":\n${lines.join("\n")}`;
626
- },
627
- }),
628
- memory_read: tool({
629
- description: "Read the full content of a specific memory file.",
630
- args: {
631
- file_name: tool.schema.string().describe("File name of the memory to read (with or without .md extension)"),
632
- },
633
- async execute(args, _ctx) {
634
- const entry = readMemory(memoryRoot, args.file_name);
635
- if (!entry) {
636
- return `Memory "${args.file_name}" not found.`;
637
- }
638
- return `# ${entry.name}\n**Type:** ${entry.type}\n**Description:** ${entry.description}\n\n${entry.content}`;
639
- },
640
- }),
49
+ tool: buildMemoryTools(store, extraction),
50
+ dispose: async () => {
51
+ extraction.dispose();
52
+ owned.dispose();
641
53
  },
642
54
  };
643
55
  };
56
+ export const MemoryPlugin = createMemoryPlugin();
57
+ const plugin = { id: PLUGIN_ID, server: MemoryPlugin };
58
+ export default plugin;
59
+ export { MEMORY_AGENTS, MemoryOptionsSchema } from "./config.js";
60
+ export { MemoryStore } from "./store/MemoryStore.js";