opencode-claude-memory 1.7.6 → 1.7.7

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/README.md CHANGED
@@ -218,6 +218,7 @@ Yes. Set `OPENCODE_MEMORY_AUTODREAM=0`. You can also tune gates with:
218
218
  - `OPENCODE_MEMORY_MODEL`: override model used for extraction
219
219
  - `OPENCODE_MEMORY_AGENT`: override agent used for extraction
220
220
  - `OPENCODE_MEMORY_EXTRACT_TIMEOUT_MS` (default `120000`): positive integer timeout for a native extraction request in milliseconds
221
+ - `OPENCODE_MEMORY_EXTRACT_MAX_STEPS` (default `30`): max agentic steps for a native extraction fork before OpenCode forces a text-only reply; set `0` to remove the cap (the timeout is then the only backstop)
221
222
  - `OPENCODE_MEMORY_RECALL_MODEL`: override model used for LLM memory recall selection
222
223
  - `OPENCODE_MEMORY_RECALL_AGENT` (default `opencode-memory-recall`): override agent used for LLM memory recall selection
223
224
  - `OPENCODE_MEMORY_AUTODREAM` (default `1`): set `0` to disable auto-dream consolidation
package/dist/index.js CHANGED
@@ -4,9 +4,9 @@ import { buildMemorySystemPrompt } from "./prompt.js";
4
4
  import { formatRecalledMemories, recallSelectedMemories } from "./recall.js";
5
5
  import { isSupportedRecallSelectorClient, selectRelevantMemoryFilenames } from "./recallSelector.js";
6
6
  import { scanMemoryFiles } from "./memoryScan.js";
7
- import { saveMemory, deleteMemory, listMemories, searchMemories, readMemory, MEMORY_TYPES, } from "./memory.js";
7
+ import { saveMemoryDetailed, deleteMemory, listMemories, searchMemories, readMemory, MEMORY_TYPES, } from "./memory.js";
8
8
  import { getMemoryDir } from "./paths.js";
9
- import { getNativeExtractTimeoutMs, logNativeExtractionFailure } from "./nativeExtraction.js";
9
+ import { formatMemorySaveResult, getNativeExtractMaxSteps, getNativeExtractTimeoutMs, logNativeExtractionFailure, } from "./nativeExtraction.js";
10
10
  const turnContextBySession = new Map();
11
11
  const selectorSessionIDs = new Set();
12
12
  function shouldIgnoreMemoryContext(query) {
@@ -235,6 +235,12 @@ function getCallID(ctx) {
235
235
  const v = ctx.callID;
236
236
  return typeof v === "string" ? v : undefined;
237
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
+ }
238
244
  // ─── Native post-session extraction (optional) ───────────────────────────────
239
245
  // Architecture follows opencode-mem's proven pattern: hook `session.idle`,
240
246
  // fetch the conversation via ctx.client.session.messages(), then run extraction
@@ -281,13 +287,15 @@ For each memory worth saving, call \`memory_save\` with:
281
287
  3. Save each distinct memory as a separate entry
282
288
  4. If the conversation was trivial (e.g., just "hello" or a quick lookup), save nothing — that's fine
283
289
  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.`;
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.`;
285
292
  const NATIVE_EXTRACT_DEBOUNCE_MS = 10000;
286
293
  const NATIVE_EXTRACT_MAX_CONV_CHARS = 60000;
287
294
  const NATIVE_EXTRACT_GRACE_MS = 60000; // keep forkID in the guard after delete — covers the idle-race window
288
295
  const nativeIdleTimer = new Map();
289
296
  const nativeExtractionSessions = new Set(); // sub-session IDs → skip their idle AND shield from transforms
290
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)
291
299
  function extractIDFromResponse(response) {
292
300
  const data = response?.data ?? response;
293
301
  const obj = data;
@@ -344,6 +352,7 @@ async function runNativeExtraction(client, sessionID, directory) {
344
352
  return; // no overlapping runs against the same session
345
353
  nativeExtractionInFlight.add(sessionID);
346
354
  let forkID = null;
355
+ let promptSettled = false;
347
356
  try {
348
357
  // query.directory is required for correct resolution under multi-directory serve.
349
358
  const resp = await c.session.messages({ path: { id: sessionID }, query: { directory } });
@@ -378,6 +387,7 @@ async function runNativeExtraction(client, sessionID, directory) {
378
387
  c.session.prompt({ path: { id: forkID }, query: { directory }, body }),
379
388
  timeout,
380
389
  ]);
390
+ promptSettled = true;
381
391
  }
382
392
  finally {
383
393
  if (timer)
@@ -389,11 +399,19 @@ async function runNativeExtraction(client, sessionID, directory) {
389
399
  }
390
400
  finally {
391
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
+ }
392
407
  await c.session?.delete?.({ path: { id: forkID }, query: { directory } }).catch(() => { });
393
408
  // Hold the guard past delete: the fork's session.idle can arrive on a separate channel after
394
409
  // the delete HTTP call resolves, and would otherwise re-trigger extraction of the fork itself.
395
410
  const id = forkID;
396
- setTimeout(() => nativeExtractionSessions.delete(id), NATIVE_EXTRACT_GRACE_MS);
411
+ setTimeout(() => {
412
+ nativeExtractionSessions.delete(id);
413
+ nativeExtractionSavedFiles.delete(id);
414
+ }, NATIVE_EXTRACT_GRACE_MS);
397
415
  }
398
416
  nativeExtractionInFlight.delete(sessionID);
399
417
  }
@@ -428,11 +446,17 @@ export const MemoryPlugin = async ({ worktree, directory, client }) => {
428
446
  };
429
447
  // Dedicated hidden agent for native extraction: its system prompt IS the extraction instruction,
430
448
  // so the fork runs with an explicit system (not the build agent's) and tool-restricted to memory_*.
431
- mutable.agent[getNativeExtractAgent()] ??= {
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()] ??= {
432
452
  mode: "all",
433
453
  hidden: true,
434
454
  prompt: EXTRACT_PROMPT,
435
- };
455
+ });
456
+ const maxSteps = getNativeExtractMaxSteps();
457
+ if (maxSteps !== undefined && extractAgent.steps === undefined && extractAgent.maxSteps === undefined) {
458
+ extractAgent.steps = maxSteps;
459
+ }
436
460
  },
437
461
  // Native post-session extraction. Enabled by default on Windows (where the
438
462
  // bash wrapper can't run) and via OPENCODE_MEMORY_NATIVE_EXTRACT=1 elsewhere;
@@ -575,9 +599,17 @@ export const MemoryPlugin = async ({ worktree, directory, client }) => {
575
599
  .string()
576
600
  .describe("Memory content. For feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines"),
577
601
  },
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}`;
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);
581
613
  },
582
614
  }),
583
615
  memory_delete: tool({
package/dist/memory.d.ts CHANGED
@@ -11,7 +11,13 @@ export type MemoryEntry = {
11
11
  };
12
12
  export declare function listMemories(worktree: string): MemoryEntry[];
13
13
  export declare function readMemory(worktree: string, fileName: string): MemoryEntry | null;
14
+ export type SaveMemoryResult = {
15
+ filePath: string;
16
+ fileName: string;
17
+ unchanged: boolean;
18
+ };
14
19
  export declare function saveMemory(worktree: string, fileName: string, name: string, description: string, type: MemoryType, content: string): string;
20
+ export declare function saveMemoryDetailed(worktree: string, fileName: string, name: string, description: string, type: MemoryType, content: string): SaveMemoryResult;
15
21
  export declare function deleteMemory(worktree: string, fileName: string): boolean;
16
22
  export declare function searchMemories(worktree: string, query: string): MemoryEntry[];
17
23
  export declare function readIndex(worktree: string): string;
package/dist/memory.js CHANGED
@@ -97,6 +97,11 @@ export function readMemory(worktree, fileName) {
97
97
  }
98
98
  }
99
99
  export function saveMemory(worktree, fileName, name, description, type, content) {
100
+ return saveMemoryDetailed(worktree, fileName, name, description, type, content).filePath;
101
+ }
102
+ // Same as saveMemory() but reports whether anything was actually written. An identical re-save is a
103
+ // no-op: the extraction fork uses that signal to tell the model it is repeating itself (see #35).
104
+ export function saveMemoryDetailed(worktree, fileName, name, description, type, content) {
100
105
  const safeName = validateMemoryFileName(fileName);
101
106
  if (typeof name !== "string" || !name.trim()) {
102
107
  throw new Error("Memory name is required");
@@ -107,9 +112,22 @@ export function saveMemory(worktree, fileName, name, description, type, content)
107
112
  if (Buffer.byteLength(fileContent, "utf-8") > MAX_MEMORY_FILE_BYTES) {
108
113
  throw new Error(`Memory file content exceeds the ${MAX_MEMORY_FILE_BYTES}-byte limit`);
109
114
  }
115
+ if (isMemoryUnchanged(worktree, filePath, fileContent, buildIndexPointer(safeName, name, description))) {
116
+ return { filePath, fileName: safeName, unchanged: true };
117
+ }
110
118
  writeFileSync(filePath, fileContent, "utf-8");
111
119
  updateIndex(worktree, safeName, name, description);
112
- return filePath;
120
+ return { filePath, fileName: safeName, unchanged: false };
121
+ }
122
+ function isMemoryUnchanged(worktree, filePath, fileContent, pointer) {
123
+ try {
124
+ if (readFileSync(filePath, "utf-8") !== fileContent)
125
+ return false;
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ return readIndex(worktree).split("\n").some((line) => line.trimEnd() === pointer);
113
131
  }
114
132
  export function deleteMemory(worktree, fileName) {
115
133
  const safeName = validateMemoryFileName(fileName);
@@ -140,11 +158,14 @@ export function readIndex(worktree) {
140
158
  return "";
141
159
  }
142
160
  }
161
+ function buildIndexPointer(fileName, name, description) {
162
+ return `- [${name}](${fileName}) — ${description}`;
163
+ }
143
164
  function updateIndex(worktree, fileName, name, description) {
144
165
  const entrypoint = getMemoryEntrypoint(worktree);
145
166
  const existing = readIndex(worktree);
146
167
  const lines = existing.split("\n").filter((l) => l.trim());
147
- const pointer = `- [${name}](${fileName}) — ${description}`;
168
+ const pointer = buildIndexPointer(fileName, name, description);
148
169
  const existingIdx = lines.findIndex((l) => l.includes(`(${fileName})`));
149
170
  if (existingIdx >= 0) {
150
171
  lines[existingIdx] = pointer;
@@ -1,2 +1,9 @@
1
1
  export declare function getNativeExtractTimeoutMs(raw?: string | undefined): number;
2
+ export declare function getNativeExtractMaxSteps(raw?: string | undefined): number | undefined;
3
+ export type MemorySaveOutcome = {
4
+ filePath: string;
5
+ fileName: string;
6
+ unchanged: boolean;
7
+ };
8
+ export declare function formatMemorySaveResult(outcome: MemorySaveOutcome, savedThisRun?: readonly string[]): string;
2
9
  export declare function logNativeExtractionFailure(client: unknown, directory: string, sessionID: string, error: unknown): void;
@@ -1,5 +1,8 @@
1
1
  const DEFAULT_NATIVE_EXTRACT_TIMEOUT_MS = 120_000;
2
2
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
3
+ // A legitimate extraction is 1 memory_list + a handful of memory_save calls; 30 agentic steps leaves
4
+ // generous headroom while still terminating a model that keeps re-saving the same files (#35).
5
+ const DEFAULT_NATIVE_EXTRACT_MAX_STEPS = 30;
3
6
  function getErrorMessage(error) {
4
7
  if (error instanceof Error)
5
8
  return error.message;
@@ -16,6 +19,45 @@ export function getNativeExtractTimeoutMs(raw = process.env.OPENCODE_MEMORY_EXTR
16
19
  ? timeout
17
20
  : DEFAULT_NATIVE_EXTRACT_TIMEOUT_MS;
18
21
  }
22
+ // Step cap for the extraction agent (OpenCode `agent.<name>.steps`). Unset/invalid → default;
23
+ // "0" → undefined, i.e. no cap (the wall-clock timeout becomes the only backstop).
24
+ export function getNativeExtractMaxSteps(raw = process.env.OPENCODE_MEMORY_EXTRACT_MAX_STEPS) {
25
+ const trimmed = raw?.trim();
26
+ // Only a plain non-negative integer counts; "" / " " must not become Number("") === 0 (no cap).
27
+ if (!trimmed || !/^\d+$/.test(trimmed))
28
+ return DEFAULT_NATIVE_EXTRACT_MAX_STEPS;
29
+ const steps = Number(trimmed);
30
+ if (!Number.isSafeInteger(steps))
31
+ return DEFAULT_NATIVE_EXTRACT_MAX_STEPS;
32
+ return steps === 0 ? undefined : steps;
33
+ }
34
+ // Tool result for memory_save. Inside an extraction fork, `savedThisRun` (file names already saved
35
+ // by this fork, in order) is appended as an explicit done-signal so the model does not lose track on
36
+ // long transcripts and re-save the same memories until the timeout kills it (#35).
37
+ export function formatMemorySaveResult(outcome, savedThisRun) {
38
+ const inExtractionRun = savedThisRun !== undefined;
39
+ // The current file is recorded before formatting, so "earlier" means it appeared before this call.
40
+ const savedEarlierThisRun = inExtractionRun && savedThisRun.indexOf(outcome.fileName) < savedThisRun.length - 1;
41
+ let head;
42
+ if (outcome.unchanged) {
43
+ head = savedEarlierThisRun
44
+ ? `Skipped: "${outcome.fileName}" was already saved earlier in this extraction run with identical content — nothing written.`
45
+ : `Skipped: "${outcome.fileName}" already exists with identical content — nothing written (${outcome.filePath}).`;
46
+ }
47
+ else if (savedEarlierThisRun) {
48
+ head = `Updated "${outcome.fileName}" (first saved earlier in this extraction run) at ${outcome.filePath}`;
49
+ }
50
+ else {
51
+ head = `Memory saved to ${outcome.filePath}`;
52
+ }
53
+ if (!inExtractionRun)
54
+ return head;
55
+ const unique = Array.from(new Set(savedThisRun));
56
+ return (`${head}\n\n` +
57
+ `Saved so far in this extraction run (${unique.length}): ${unique.join(", ")}\n` +
58
+ "These memories are already persisted — do not call memory_save for them again. " +
59
+ "Once every distinct memory worth keeping is saved, stop calling tools and reply with a one-line summary.");
60
+ }
19
61
  export function logNativeExtractionFailure(client, directory, sessionID, error) {
20
62
  const c = client;
21
63
  if (!c?.app?.log)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-claude-memory",
3
- "version": "1.7.6",
3
+ "version": "1.7.7",
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",