opencode-claude-memory 1.7.7 → 2.0.0

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 -214
  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 -665
  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 -31
  61. package/dist/memory.js +0 -224
  62. package/dist/memoryScan.d.ts +0 -20
  63. package/dist/memoryScan.js +0 -106
  64. package/dist/nativeExtraction.d.ts +0 -9
  65. package/dist/nativeExtraction.js +0 -80
  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,675 +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 { saveMemoryDetailed, deleteMemory, listMemories, searchMemories, readMemory, MEMORY_TYPES, } from "./memory.js";
8
- import { getMemoryDir } from "./paths.js";
9
- import { formatMemorySaveResult, getNativeExtractMaxSteps, 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
- function getSessionID(ctx) {
239
- if (!ctx || typeof ctx !== "object")
240
- return undefined;
241
- const v = ctx.sessionID;
242
- return typeof v === "string" ? v : undefined;
243
- }
244
- // ─── Native post-session extraction (optional) ───────────────────────────────
245
- // Architecture follows opencode-mem's proven pattern: hook `session.idle`,
246
- // fetch the conversation via ctx.client.session.messages(), then run extraction
247
- // in an isolated sub-session (create + prompt + delete via ctx.client). No shell,
248
- // no `opencode run --fork` subprocess — those don't work cross-platform from a
249
- // plugin and `ctx.$` is not reliably populated.
250
- // Enabled by default on Windows (bash wrapper can't run there); opt-in elsewhere
251
- // via OPENCODE_MEMORY_NATIVE_EXTRACT=1; =0 force-disables.
252
- // 10s debounce collapses rapid idle events. Recursion guard via an in-process
253
- // Set of sub-session IDs. Extraction is additive only (memory_save).
254
- const EXTRACT_PROMPT = `You are now acting as the memory extraction subagent. The conversation below is reviewed for anything worth remembering for future sessions.
255
-
256
- ## What to save
257
-
258
- Use the \`memory_save\` tool to persist memories. There are four types:
259
-
260
- 1. **user** — Who the user is: role, expertise, preferences, communication style. Helps tailor future interactions.
261
- 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.
262
- 3. **project** — Ongoing work context: goals, deadlines, initiatives, decisions, bugs. NOT derivable from code/git. Convert relative dates to absolute.
263
- 4. **reference** — Pointers to external resources: URLs, tool names, where to find information outside the codebase.
264
-
265
- ## What NOT to save
266
-
267
- - Code patterns, architecture, file structure — derivable from the codebase
268
- - Git history, recent changes — use \`git log\`/\`git blame\`
269
- - Debugging solutions — the fix is in the code
270
- - Anything already in AGENTS.md / project config files
271
- - Ephemeral task details or current conversation context
272
- - Information that was already saved in a previous extraction
273
-
274
- ## How to save
275
-
276
- For each memory worth saving, call \`memory_save\` with:
277
- - \`file_name\`: descriptive slug (e.g., \`user_role\`, \`feedback_testing_approach\`)
278
- - \`name\`: short title
279
- - \`description\`: one-line description (used for relevance matching in future sessions)
280
- - \`type\`: one of user, feedback, project, reference
281
- - \`content\`: the memory content. For feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines.
282
-
283
- ## Instructions
284
-
285
- 1. Analyze the conversation for memorable information
286
- 2. Check existing memories first (use \`memory_list\`) to avoid duplicates — update existing ones if needed
287
- 3. Save each distinct memory as a separate entry
288
- 4. If the conversation was trivial (e.g., just "hello" or a quick lookup), save nothing — that's fine
289
- 5. Be selective: 0-3 memories per session is typical. Quality over quantity.
290
- 6. Do NOT save a memory about the extraction process itself.
291
- 7. Each \`memory_save\` call persists immediately and its result lists everything saved so far in this run. Never save the same \`file_name\` twice in one run. When nothing else is worth saving, stop calling tools and reply with a one-line summary.`;
292
- const NATIVE_EXTRACT_DEBOUNCE_MS = 10000;
293
- const NATIVE_EXTRACT_MAX_CONV_CHARS = 60000;
294
- const NATIVE_EXTRACT_GRACE_MS = 60000; // keep forkID in the guard after delete — covers the idle-race window
295
- const nativeIdleTimer = new Map();
296
- const nativeExtractionSessions = new Set(); // sub-session IDs → skip their idle AND shield from transforms
297
- const nativeExtractionInFlight = new Set(); // parent sessionIDs with an extraction running → no overlap
298
- const nativeExtractionSavedFiles = new Map(); // fork sessionID → file names memory_save wrote this run (#35)
299
- function extractIDFromResponse(response) {
300
- const data = response?.data ?? response;
301
- const obj = data;
302
- const v = obj?.id ?? obj?.sessionID;
303
- return typeof v === "string" ? v : null;
304
- }
305
- function getNativeExtractAgent() {
306
- return process.env.OPENCODE_MEMORY_AGENT || "opencode-memory-extract";
307
- }
308
- // Parse OPENCODE_MEMORY_MODEL into the SDK's ModelRef shape (providerID/modelID), same as getRecallModel().
309
- function getNativeExtractModel() {
310
- const raw = process.env.OPENCODE_MEMORY_MODEL;
311
- if (!raw)
312
- return undefined;
313
- const slashIdx = raw.indexOf("/");
314
- if (slashIdx <= 0 || slashIdx === raw.length - 1)
315
- return undefined;
316
- return { providerID: raw.slice(0, slashIdx), modelID: raw.slice(slashIdx + 1) };
317
- }
318
- function buildConversationForExtraction(messages) {
319
- const lines = [];
320
- for (const m of messages) {
321
- const role = m?.info?.role;
322
- if (!role || !Array.isArray(m?.parts))
323
- continue;
324
- for (const p of m.parts) {
325
- const part = p;
326
- if (part.type === "text" && typeof part.text === "string" && !part.synthetic) {
327
- lines.push(`### ${role === "user" ? "User" : "Assistant"}\n${part.text}`);
328
- }
329
- else if (part.type === "tool" && part.tool) {
330
- if (part.state?.status === "completed" && typeof part.state?.output === "string") {
331
- const out = part.state.output.length > 300 ? part.state.output.slice(0, 300) + "…" : part.state.output;
332
- lines.push(`_[tool ${part.tool}: ${out}]_`);
333
- }
334
- }
335
- }
336
- }
337
- let text = lines.join("\n\n");
338
- // Keep the TAIL (newest turns carry the new facts worth extracting), drop the oldest head.
339
- if (text.length > NATIVE_EXTRACT_MAX_CONV_CHARS) {
340
- text = "…[older turns truncated]\n\n" + text.slice(-NATIVE_EXTRACT_MAX_CONV_CHARS);
341
- }
342
- return text;
343
- }
344
- // Security: the extraction fork runs on raw, potentially-untrusted transcript content (fetched web
345
- // pages, tool output). It MUST be sandboxed to the memory tools only (no bash/edit/write) and capped
346
- // by a timeout so a hung prompt stops blocking best-effort sub-session cleanup.
347
- async function runNativeExtraction(client, sessionID, directory) {
348
- const c = client;
349
- if (!c?.session?.messages || !c.session.create || !c.session.prompt)
350
- return;
351
- if (nativeExtractionInFlight.has(sessionID))
352
- return; // no overlapping runs against the same session
353
- nativeExtractionInFlight.add(sessionID);
354
- let forkID = null;
355
- let promptSettled = false;
356
- try {
357
- // query.directory is required for correct resolution under multi-directory serve.
358
- const resp = await c.session.messages({ path: { id: sessionID }, query: { directory } });
359
- const messages = (resp.data ?? []);
360
- const convText = buildConversationForExtraction(messages);
361
- if (convText.trim().length < 20)
362
- return;
363
- const created = await c.session.create({
364
- body: { parentID: sessionID, title: "opencode-memory extraction" },
365
- query: { directory },
366
- });
367
- forkID = extractIDFromResponse(created);
368
- if (!forkID)
369
- return;
370
- nativeExtractionSessions.add(forkID); // shield the fork from the plugin's own recall/transform hooks
371
- const body = {
372
- agent: getNativeExtractAgent(),
373
- system: EXTRACT_PROMPT, // always pass explicitly — ??= agent registration can miss if the agent already exists
374
- tools: { "*": false, memory_save: true, memory_list: true }, // wildcard deny first; findLast → memory allows win
375
- parts: [{ type: "text", text: convText }],
376
- };
377
- const extractModel = getNativeExtractModel();
378
- if (extractModel)
379
- body.model = extractModel;
380
- // Race the prompt against a timeout so a hung/permission-gated fork stops blocking cleanup.
381
- let timer;
382
- const timeout = new Promise((_, reject) => {
383
- timer = setTimeout(() => reject(new Error("native extraction timed out")), getNativeExtractTimeoutMs());
384
- });
385
- try {
386
- await Promise.race([
387
- c.session.prompt({ path: { id: forkID }, query: { directory }, body }),
388
- timeout,
389
- ]);
390
- promptSettled = true;
391
- }
392
- finally {
393
- if (timer)
394
- clearTimeout(timer);
395
- }
396
- }
397
- catch (e) {
398
- logNativeExtractionFailure(client, directory, sessionID, e);
399
- }
400
- finally {
401
- if (forkID) {
402
- // On timeout the server is still running the fork; stop it before deleting the session so it
403
- // does not keep inserting parts/messages for a row that no longer exists (FK constraint errors).
404
- if (!promptSettled) {
405
- await c.session?.abort?.({ path: { id: forkID }, query: { directory } }).catch(() => { });
406
- }
407
- await c.session?.delete?.({ path: { id: forkID }, query: { directory } }).catch(() => { });
408
- // Hold the guard past delete: the fork's session.idle can arrive on a separate channel after
409
- // the delete HTTP call resolves, and would otherwise re-trigger extraction of the fork itself.
410
- const id = forkID;
411
- setTimeout(() => {
412
- nativeExtractionSessions.delete(id);
413
- nativeExtractionSavedFiles.delete(id);
414
- }, NATIVE_EXTRACT_GRACE_MS);
415
- }
416
- nativeExtractionInFlight.delete(sessionID);
417
- }
418
- }
419
- // Native extraction is the bash-wrapper replacement. Default-on for Windows (where the wrapper can't
420
- // run); opt-in elsewhere. Honors the wrapper's documented OPENCODE_MEMORY_EXTRACT=0 opt-out so a user
421
- // who disabled extraction gets it disabled here too (not silently re-enabled by the win32 default).
422
- // OPENCODE_MEMORY_NATIVE_EXTRACT=0/1 force-disables/enables regardless of platform.
423
- function nativeExtractionEnabled() {
424
- if (process.env.OPENCODE_MEMORY_EXTRACT === "0")
425
- return false;
426
- const flag = process.env.OPENCODE_MEMORY_NATIVE_EXTRACT;
427
- if (flag === "0")
428
- return false;
429
- if (flag === "1")
430
- return true;
431
- return process.platform === "win32";
432
- }
433
- export const MemoryPlugin = async ({ worktree, directory, client }) => {
434
- directory ??= worktree;
435
- const memoryRoot = resolveMemoryRoot(worktree, directory);
436
- getMemoryDir(memoryRoot);
437
- return {
438
- config: async (config) => {
439
- const agentName = getRecallAgent();
440
- const mutable = config;
441
- mutable.agent ??= {};
442
- mutable.agent[agentName] ??= {
443
- mode: "all",
444
- hidden: true,
445
- prompt: "Select up to 5 relevant memory filenames for the current user query. Return only the requested structured output.",
446
- };
447
- // Dedicated hidden agent for native extraction: its system prompt IS the extraction instruction,
448
- // so the fork runs with an explicit system (not the build agent's) and tool-restricted to memory_*.
449
- // `steps` caps the fork's agentic iterations so a model that keeps re-saving the same memories
450
- // terminates cleanly (OpenCode forces a text-only reply) instead of spinning until the timeout (#35).
451
- const extractAgent = (mutable.agent[getNativeExtractAgent()] ??= {
452
- mode: "all",
453
- hidden: true,
454
- prompt: EXTRACT_PROMPT,
455
- });
456
- const maxSteps = getNativeExtractMaxSteps();
457
- if (maxSteps !== undefined && extractAgent.steps === undefined && extractAgent.maxSteps === undefined) {
458
- extractAgent.steps = maxSteps;
459
- }
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();
460
31
  },
461
- // Native post-session extraction. Enabled by default on Windows (where the
462
- // bash wrapper can't run) and via OPENCODE_MEMORY_NATIVE_EXTRACT=1 elsewhere;
463
- // =0 force-disables. Hooks `session.idle` (the established idle signal — used
464
- // by opencode-mem and others). 10s debounce collapses rapid idle into one
465
- // capture; an in-process Set of sub-session IDs prevents recursion. All work
466
- // goes through ctx.client — no shell, no fork.
467
- event: async (input) => {
468
- if (!nativeExtractionEnabled())
469
- return;
470
- const evt = (input && typeof input === "object" && "event" in input
471
- ? input.event
472
- : input);
473
- if (evt?.type !== "session.idle")
474
- return;
475
- const sessionID = evt.properties?.sessionID;
476
- if (!sessionID)
477
- return;
478
- if (nativeExtractionSessions.has(sessionID))
479
- return; // skip our own extraction sub-sessions
480
- if (selectorSessionIDs.has(sessionID))
481
- return; // skip recall-selector child sessions (same guard the transforms use)
482
- if (nativeIdleTimer.has(sessionID))
483
- clearTimeout(nativeIdleTimer.get(sessionID));
484
- nativeIdleTimer.set(sessionID, setTimeout(() => {
485
- nativeIdleTimer.delete(sessionID);
486
- void runNativeExtraction(client, sessionID, directory).catch((e) => {
487
- logNativeExtractionFailure(client, directory, sessionID, e);
488
- });
489
- }, NATIVE_EXTRACT_DEBOUNCE_MS));
490
- },
491
- "chat.params": async (input, output) => {
492
- if (input.agent !== getRecallAgent())
493
- return;
494
- output.temperature = 0;
495
- output.options = {
496
- ...output.options,
497
- };
498
- },
499
- "tool.execute.after": async (input, output) => {
500
- if (!input.tool.startsWith("memory_"))
501
- return;
502
- const title = buildMemoryToolTitle(input.tool, input.args, input.callID);
503
- if (title)
504
- 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);
505
37
  },
506
38
  "experimental.chat.messages.transform": async (_input, output) => {
507
- const { query, sessionID, messageID, messageIndex } = getLastUserQuery(output.messages);
508
- if (sessionID && (selectorSessionIDs.has(sessionID) || nativeExtractionSessions.has(sessionID)))
509
- return;
510
- if (sessionID) {
511
- const alreadySurfaced = new Set();
512
- for (const message of output.messages) {
513
- const role = String(message.info.role);
514
- if (role !== "system")
515
- continue;
516
- for (const part of message.parts) {
517
- if (!part || typeof part !== "object")
518
- continue;
519
- const text = part.text;
520
- if (typeof text === "string") {
521
- for (const key of extractSurfacedMemoryKeys(text)) {
522
- alreadySurfaced.add(key);
523
- }
524
- }
525
- }
526
- }
527
- const recentTools = extractRecentTools(output.messages);
528
- const turnID = buildTurnID(sessionID, messageID, messageIndex, query);
529
- const existing = turnContextBySession.get(sessionID);
530
- const ignoreMemoryContext = process.env.OPENCODE_MEMORY_IGNORE === "1" || shouldIgnoreMemoryContext(query);
531
- let recallPrefetch;
532
- if (!ignoreMemoryContext) {
533
- recallPrefetch = existing?.turnID === turnID
534
- ? existing.recallPrefetch
535
- : startRecallPrefetch({
536
- client: client,
537
- directory,
538
- worktree: memoryRoot,
539
- parentSessionID: sessionID,
540
- turnID,
541
- query,
542
- alreadySurfaced,
543
- recentTools,
544
- });
545
- }
546
- turnContextBySession.set(sessionID, { turnID, query, alreadySurfaced, recentTools, recallPrefetch });
547
- }
548
- if (shouldIgnoreMemoryContext(query)) {
549
- output.messages = output.messages
550
- .map((message) => {
551
- const role = String(message.info.role);
552
- if (role !== "system")
553
- return message;
554
- const parts = message.parts.filter((part) => !isAutoMemoryPart(part));
555
- return { ...message, parts };
556
- })
557
- .filter((message) => message.parts.length > 0);
558
- }
39
+ recall.onMessagesTransform(output);
559
40
  },
560
- "experimental.chat.system.transform": async (_input, output) => {
561
- let sessionID;
562
- if (_input && typeof _input === "object") {
563
- sessionID = typeof _input.sessionID === "string"
564
- ? _input.sessionID
565
- : undefined;
566
- }
567
- if (sessionID && (selectorSessionIDs.has(sessionID) || nativeExtractionSessions.has(sessionID)))
41
+ "experimental.chat.system.transform": async ({ sessionID }, output) => {
42
+ if (owned.has(sessionID))
568
43
  return;
569
- const ctx = sessionID ? turnContextBySession.get(sessionID) : undefined;
570
- const query = ctx?.query;
571
- const ignoreMemoryContext = process.env.OPENCODE_MEMORY_IGNORE === "1" || shouldIgnoreMemoryContext(query);
572
- const recalled = ignoreMemoryContext ? [] : consumeRecallPrefetch(ctx);
573
- const recalledSection = formatRecalledMemories(recalled);
574
- const memoryPrompt = buildMemorySystemPrompt(memoryRoot, recalledSection, {
575
- includeIndex: !ignoreMemoryContext,
576
- });
577
- 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
+ }));
578
48
  },
579
- tool: {
580
- memory_save: tool({
581
- description: "Save or update a memory for future conversations. " +
582
- "Each memory is stored as a markdown file with frontmatter. " +
583
- "Use this when the user explicitly asks you to remember something, " +
584
- "or when you observe important information worth preserving across sessions " +
585
- "(user preferences, feedback, project context, external references). " +
586
- "Check existing memories first with memory_list or memory_search to avoid duplicates.",
587
- args: {
588
- file_name: tool.schema
589
- .string()
590
- .describe('File name for the memory (without .md extension). Use snake_case, e.g. "user_role", "feedback_testing_style", "project_auth_rewrite"'),
591
- name: tool.schema.string().describe("Human-readable name for this memory"),
592
- description: tool.schema
593
- .string()
594
- .describe("One-line description — used to decide relevance in future conversations, so be specific"),
595
- type: tool.schema
596
- .enum(MEMORY_TYPES)
597
- .describe("Memory type: user (about the person), feedback (guidance on approach), project (ongoing work context), reference (pointers to external systems)"),
598
- content: tool.schema
599
- .string()
600
- .describe("Memory content. For feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines"),
601
- },
602
- async execute(args, ctx) {
603
- const outcome = saveMemoryDetailed(memoryRoot, args.file_name, args.name, args.description, args.type, args.content);
604
- const sessionID = getSessionID(ctx);
605
- if (!sessionID || !nativeExtractionSessions.has(sessionID))
606
- return formatMemorySaveResult(outcome);
607
- // Extraction fork: record every save so the tool result can carry a durable done-signal.
608
- let saved = nativeExtractionSavedFiles.get(sessionID);
609
- if (!saved)
610
- nativeExtractionSavedFiles.set(sessionID, (saved = []));
611
- saved.push(outcome.fileName);
612
- return formatMemorySaveResult(outcome, saved);
613
- },
614
- }),
615
- memory_delete: tool({
616
- description: "Delete a memory that is outdated, wrong, or no longer relevant. Also removes it from the index.",
617
- args: {
618
- file_name: tool.schema.string().describe("File name of the memory to delete (with or without .md extension)"),
619
- },
620
- async execute(args, _ctx) {
621
- const deleted = deleteMemory(memoryRoot, args.file_name);
622
- return deleted ? `Memory "${args.file_name}" deleted.` : `Memory "${args.file_name}" not found.`;
623
- },
624
- }),
625
- memory_list: tool({
626
- description: "List all saved memories with their names, types, and descriptions. " +
627
- "Use this to check what memories exist before saving a new one (to avoid duplicates) " +
628
- "or when you need to recall what's been stored.",
629
- args: {},
630
- async execute(_args, ctx) {
631
- const entries = listMemories(memoryRoot);
632
- const callID = getCallID(ctx);
633
- if (callID)
634
- memoryListCountByCallID.set(callID, entries.length);
635
- if (entries.length === 0) {
636
- return "No memories saved yet.";
637
- }
638
- const lines = entries.map((e) => `- **${e.name}** (${e.type}) [${e.fileName}]: ${e.description}`);
639
- return `${entries.length} memories found:\n${lines.join("\n")}`;
640
- },
641
- }),
642
- memory_search: tool({
643
- description: "Search memories by keyword. Searches across names, descriptions, and content. " +
644
- "Use this to find relevant memories before answering questions or when the user references past conversations.",
645
- args: {
646
- query: tool.schema.string().describe("Search query — searches across name, description, and content"),
647
- },
648
- async execute(args, ctx) {
649
- const results = searchMemories(memoryRoot, args.query);
650
- const callID = getCallID(ctx);
651
- if (callID)
652
- memorySearchCountByCallID.set(callID, results.length);
653
- if (results.length === 0) {
654
- return `No memories matching "${args.query}".`;
655
- }
656
- const lines = results.map((e) => `- **${e.name}** (${e.type}) [${e.fileName}]: ${e.description}\n Content: ${e.content.slice(0, 200)}${e.content.length > 200 ? "..." : ""}`);
657
- return `${results.length} matches for "${args.query}":\n${lines.join("\n")}`;
658
- },
659
- }),
660
- memory_read: tool({
661
- description: "Read the full content of a specific memory file.",
662
- args: {
663
- file_name: tool.schema.string().describe("File name of the memory to read (with or without .md extension)"),
664
- },
665
- async execute(args, _ctx) {
666
- const entry = readMemory(memoryRoot, args.file_name);
667
- if (!entry) {
668
- return `Memory "${args.file_name}" not found.`;
669
- }
670
- return `# ${entry.name}\n**Type:** ${entry.type}\n**Description:** ${entry.description}\n\n${entry.content}`;
671
- },
672
- }),
49
+ tool: buildMemoryTools(store, extraction),
50
+ dispose: async () => {
51
+ extraction.dispose();
52
+ owned.dispose();
673
53
  },
674
54
  };
675
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";