opencode-claude-memory 1.7.3 → 1.7.4

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 (2) hide show
  1. package/dist/index.js +217 -2
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -234,6 +234,184 @@ function getCallID(ctx) {
234
234
  const v = ctx.callID;
235
235
  return typeof v === "string" ? v : undefined;
236
236
  }
237
+ // ─── Native post-session extraction (optional) ───────────────────────────────
238
+ // Architecture follows opencode-mem's proven pattern: hook `session.idle`,
239
+ // fetch the conversation via ctx.client.session.messages(), then run extraction
240
+ // in an isolated sub-session (create + prompt + delete via ctx.client). No shell,
241
+ // no `opencode run --fork` subprocess — those don't work cross-platform from a
242
+ // plugin and `ctx.$` is not reliably populated.
243
+ // Enabled by default on Windows (bash wrapper can't run there); opt-in elsewhere
244
+ // via OPENCODE_MEMORY_NATIVE_EXTRACT=1; =0 force-disables.
245
+ // 10s debounce collapses rapid idle events. Recursion guard via an in-process
246
+ // Set of sub-session IDs. Extraction is additive only (memory_save).
247
+ const EXTRACT_PROMPT = `You are now acting as the memory extraction subagent. The conversation below is reviewed for anything worth remembering for future sessions.
248
+
249
+ ## What to save
250
+
251
+ Use the \`memory_save\` tool to persist memories. There are four types:
252
+
253
+ 1. **user** — Who the user is: role, expertise, preferences, communication style. Helps tailor future interactions.
254
+ 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.
255
+ 3. **project** — Ongoing work context: goals, deadlines, initiatives, decisions, bugs. NOT derivable from code/git. Convert relative dates to absolute.
256
+ 4. **reference** — Pointers to external resources: URLs, tool names, where to find information outside the codebase.
257
+
258
+ ## What NOT to save
259
+
260
+ - Code patterns, architecture, file structure — derivable from the codebase
261
+ - Git history, recent changes — use \`git log\`/\`git blame\`
262
+ - Debugging solutions — the fix is in the code
263
+ - Anything already in AGENTS.md / project config files
264
+ - Ephemeral task details or current conversation context
265
+ - Information that was already saved in a previous extraction
266
+
267
+ ## How to save
268
+
269
+ For each memory worth saving, call \`memory_save\` with:
270
+ - \`file_name\`: descriptive slug (e.g., \`user_role\`, \`feedback_testing_approach\`)
271
+ - \`name\`: short title
272
+ - \`description\`: one-line description (used for relevance matching in future sessions)
273
+ - \`type\`: one of user, feedback, project, reference
274
+ - \`content\`: the memory content. For feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines.
275
+
276
+ ## Instructions
277
+
278
+ 1. Analyze the conversation for memorable information
279
+ 2. Check existing memories first (use \`memory_list\`) to avoid duplicates — update existing ones if needed
280
+ 3. Save each distinct memory as a separate entry
281
+ 4. If the conversation was trivial (e.g., just "hello" or a quick lookup), save nothing — that's fine
282
+ 5. Be selective: 0-3 memories per session is typical. Quality over quantity.
283
+ 6. Do NOT save a memory about the extraction process itself.`;
284
+ const NATIVE_EXTRACT_DEBOUNCE_MS = 10000;
285
+ const NATIVE_EXTRACT_TIMEOUT_MS = 120000; // hard cap on a single extraction fork (prevents permanent leak on hang)
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 hang can't leak the sub-session forever.
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 hard timeout so a hung/permission-gated fork can't leak.
372
+ let timer;
373
+ const timeout = new Promise((_, reject) => {
374
+ timer = setTimeout(() => reject(new Error("native extraction timed out")), NATIVE_EXTRACT_TIMEOUT_MS);
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
+ console.error("[opencode-claude-memory] native extraction failed:", e?.message ?? 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
+ }
237
415
  export const MemoryPlugin = async ({ worktree, directory, client }) => {
238
416
  directory ??= worktree;
239
417
  const memoryRoot = resolveMemoryRoot(worktree, directory);
@@ -248,6 +426,43 @@ export const MemoryPlugin = async ({ worktree, directory, client }) => {
248
426
  hidden: true,
249
427
  prompt: "Select up to 5 relevant memory filenames for the current user query. Return only the requested structured output.",
250
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
+ };
436
+ },
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
+ console.error("[opencode-claude-memory] native extraction failed:", e?.message ?? e);
464
+ });
465
+ }, NATIVE_EXTRACT_DEBOUNCE_MS));
251
466
  },
252
467
  "chat.params": async (input, output) => {
253
468
  if (input.agent !== getRecallAgent())
@@ -266,7 +481,7 @@ export const MemoryPlugin = async ({ worktree, directory, client }) => {
266
481
  },
267
482
  "experimental.chat.messages.transform": async (_input, output) => {
268
483
  const { query, sessionID, messageID, messageIndex } = getLastUserQuery(output.messages);
269
- if (sessionID && selectorSessionIDs.has(sessionID))
484
+ if (sessionID && (selectorSessionIDs.has(sessionID) || nativeExtractionSessions.has(sessionID)))
270
485
  return;
271
486
  if (sessionID) {
272
487
  const alreadySurfaced = new Set();
@@ -325,7 +540,7 @@ export const MemoryPlugin = async ({ worktree, directory, client }) => {
325
540
  ? _input.sessionID
326
541
  : undefined;
327
542
  }
328
- if (sessionID && selectorSessionIDs.has(sessionID))
543
+ if (sessionID && (selectorSessionIDs.has(sessionID) || nativeExtractionSessions.has(sessionID)))
329
544
  return;
330
545
  const ctx = sessionID ? turnContextBySession.get(sessionID) : undefined;
331
546
  const query = ctx?.query;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-claude-memory",
3
- "version": "1.7.3",
3
+ "version": "1.7.4",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for Claude Code memory: persistent, local-first shared memory with Claude Code-compatible Markdown files, auto extraction, and auto-dream",
6
6
  "main": "dist/index.js",