opencode-claude-memory 1.7.2 → 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.
- package/dist/index.js +217 -3
- 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())
|
|
@@ -255,7 +470,6 @@ export const MemoryPlugin = async ({ worktree, directory, client }) => {
|
|
|
255
470
|
output.temperature = 0;
|
|
256
471
|
output.options = {
|
|
257
472
|
...output.options,
|
|
258
|
-
maxOutputTokens: 256,
|
|
259
473
|
};
|
|
260
474
|
},
|
|
261
475
|
"tool.execute.after": async (input, output) => {
|
|
@@ -267,7 +481,7 @@ export const MemoryPlugin = async ({ worktree, directory, client }) => {
|
|
|
267
481
|
},
|
|
268
482
|
"experimental.chat.messages.transform": async (_input, output) => {
|
|
269
483
|
const { query, sessionID, messageID, messageIndex } = getLastUserQuery(output.messages);
|
|
270
|
-
if (sessionID && selectorSessionIDs.has(sessionID))
|
|
484
|
+
if (sessionID && (selectorSessionIDs.has(sessionID) || nativeExtractionSessions.has(sessionID)))
|
|
271
485
|
return;
|
|
272
486
|
if (sessionID) {
|
|
273
487
|
const alreadySurfaced = new Set();
|
|
@@ -326,7 +540,7 @@ export const MemoryPlugin = async ({ worktree, directory, client }) => {
|
|
|
326
540
|
? _input.sessionID
|
|
327
541
|
: undefined;
|
|
328
542
|
}
|
|
329
|
-
if (sessionID && selectorSessionIDs.has(sessionID))
|
|
543
|
+
if (sessionID && (selectorSessionIDs.has(sessionID) || nativeExtractionSessions.has(sessionID)))
|
|
330
544
|
return;
|
|
331
545
|
const ctx = sessionID ? turnContextBySession.get(sessionID) : undefined;
|
|
332
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
|
+
"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",
|