opencode-claude-memory 1.7.5 → 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 +2 -0
- package/dist/index.js +46 -14
- package/dist/memory.d.ts +6 -0
- package/dist/memory.js +23 -2
- package/dist/nativeExtraction.d.ts +9 -0
- package/dist/nativeExtraction.js +80 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -217,6 +217,8 @@ Yes. Set `OPENCODE_MEMORY_AUTODREAM=0`. You can also tune gates with:
|
|
|
217
217
|
- `OPENCODE_MEMORY_TERMINAL_LOG` (default `foreground-only`): set `1` to force terminal logs on, `0` to force them off
|
|
218
218
|
- `OPENCODE_MEMORY_MODEL`: override model used for extraction
|
|
219
219
|
- `OPENCODE_MEMORY_AGENT`: override agent used for extraction
|
|
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)
|
|
220
222
|
- `OPENCODE_MEMORY_RECALL_MODEL`: override model used for LLM memory recall selection
|
|
221
223
|
- `OPENCODE_MEMORY_RECALL_AGENT` (default `opencode-memory-recall`): override agent used for LLM memory recall selection
|
|
222
224
|
- `OPENCODE_MEMORY_AUTODREAM` (default `1`): set `0` to disable auto-dream consolidation
|
package/dist/index.js
CHANGED
|
@@ -4,8 +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 {
|
|
7
|
+
import { saveMemoryDetailed, deleteMemory, listMemories, searchMemories, readMemory, MEMORY_TYPES, } from "./memory.js";
|
|
8
8
|
import { getMemoryDir } from "./paths.js";
|
|
9
|
+
import { formatMemorySaveResult, getNativeExtractMaxSteps, getNativeExtractTimeoutMs, logNativeExtractionFailure, } from "./nativeExtraction.js";
|
|
9
10
|
const turnContextBySession = new Map();
|
|
10
11
|
const selectorSessionIDs = new Set();
|
|
11
12
|
function shouldIgnoreMemoryContext(query) {
|
|
@@ -234,6 +235,12 @@ function getCallID(ctx) {
|
|
|
234
235
|
const v = ctx.callID;
|
|
235
236
|
return typeof v === "string" ? v : undefined;
|
|
236
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
|
+
}
|
|
237
244
|
// ─── Native post-session extraction (optional) ───────────────────────────────
|
|
238
245
|
// Architecture follows opencode-mem's proven pattern: hook `session.idle`,
|
|
239
246
|
// fetch the conversation via ctx.client.session.messages(), then run extraction
|
|
@@ -280,14 +287,15 @@ For each memory worth saving, call \`memory_save\` with:
|
|
|
280
287
|
3. Save each distinct memory as a separate entry
|
|
281
288
|
4. If the conversation was trivial (e.g., just "hello" or a quick lookup), save nothing — that's fine
|
|
282
289
|
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
|
|
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.`;
|
|
284
292
|
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
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;
|
|
@@ -335,7 +343,7 @@ function buildConversationForExtraction(messages) {
|
|
|
335
343
|
}
|
|
336
344
|
// Security: the extraction fork runs on raw, potentially-untrusted transcript content (fetched web
|
|
337
345
|
// pages, tool output). It MUST be sandboxed to the memory tools only (no bash/edit/write) and capped
|
|
338
|
-
// by a timeout so a
|
|
346
|
+
// by a timeout so a hung prompt stops blocking best-effort sub-session cleanup.
|
|
339
347
|
async function runNativeExtraction(client, sessionID, directory) {
|
|
340
348
|
const c = client;
|
|
341
349
|
if (!c?.session?.messages || !c.session.create || !c.session.prompt)
|
|
@@ -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 } });
|
|
@@ -368,16 +377,17 @@ async function runNativeExtraction(client, sessionID, directory) {
|
|
|
368
377
|
const extractModel = getNativeExtractModel();
|
|
369
378
|
if (extractModel)
|
|
370
379
|
body.model = extractModel;
|
|
371
|
-
// Race the prompt against a
|
|
380
|
+
// Race the prompt against a timeout so a hung/permission-gated fork stops blocking cleanup.
|
|
372
381
|
let timer;
|
|
373
382
|
const timeout = new Promise((_, reject) => {
|
|
374
|
-
timer = setTimeout(() => reject(new Error("native extraction timed out")),
|
|
383
|
+
timer = setTimeout(() => reject(new Error("native extraction timed out")), getNativeExtractTimeoutMs());
|
|
375
384
|
});
|
|
376
385
|
try {
|
|
377
386
|
await Promise.race([
|
|
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)
|
|
@@ -385,15 +395,23 @@ async function runNativeExtraction(client, sessionID, directory) {
|
|
|
385
395
|
}
|
|
386
396
|
}
|
|
387
397
|
catch (e) {
|
|
388
|
-
|
|
398
|
+
logNativeExtractionFailure(client, directory, sessionID, e);
|
|
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(() =>
|
|
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
|
-
|
|
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;
|
|
@@ -460,7 +484,7 @@ export const MemoryPlugin = async ({ worktree, directory, client }) => {
|
|
|
460
484
|
nativeIdleTimer.set(sessionID, setTimeout(() => {
|
|
461
485
|
nativeIdleTimer.delete(sessionID);
|
|
462
486
|
void runNativeExtraction(client, sessionID, directory).catch((e) => {
|
|
463
|
-
|
|
487
|
+
logNativeExtractionFailure(client, directory, sessionID, e);
|
|
464
488
|
});
|
|
465
489
|
}, NATIVE_EXTRACT_DEBOUNCE_MS));
|
|
466
490
|
},
|
|
@@ -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,
|
|
579
|
-
const
|
|
580
|
-
|
|
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 =
|
|
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;
|
|
@@ -0,0 +1,9 @@
|
|
|
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;
|
|
9
|
+
export declare function logNativeExtractionFailure(client: unknown, directory: string, sessionID: string, error: unknown): void;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const DEFAULT_NATIVE_EXTRACT_TIMEOUT_MS = 120_000;
|
|
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;
|
|
6
|
+
function getErrorMessage(error) {
|
|
7
|
+
if (error instanceof Error)
|
|
8
|
+
return error.message;
|
|
9
|
+
if (error && typeof error === "object") {
|
|
10
|
+
const message = error.message;
|
|
11
|
+
if (typeof message === "string")
|
|
12
|
+
return message;
|
|
13
|
+
}
|
|
14
|
+
return String(error);
|
|
15
|
+
}
|
|
16
|
+
export function getNativeExtractTimeoutMs(raw = process.env.OPENCODE_MEMORY_EXTRACT_TIMEOUT_MS) {
|
|
17
|
+
const timeout = Number(raw);
|
|
18
|
+
return Number.isSafeInteger(timeout) && timeout > 0 && timeout <= MAX_TIMER_DELAY_MS
|
|
19
|
+
? timeout
|
|
20
|
+
: DEFAULT_NATIVE_EXTRACT_TIMEOUT_MS;
|
|
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
|
+
}
|
|
61
|
+
export function logNativeExtractionFailure(client, directory, sessionID, error) {
|
|
62
|
+
const c = client;
|
|
63
|
+
if (!c?.app?.log)
|
|
64
|
+
return;
|
|
65
|
+
try {
|
|
66
|
+
const message = getErrorMessage(error);
|
|
67
|
+
void c.app.log({
|
|
68
|
+
body: {
|
|
69
|
+
service: "opencode-claude-memory",
|
|
70
|
+
level: "error",
|
|
71
|
+
message: "Native extraction failed",
|
|
72
|
+
extra: { error: message, sessionID },
|
|
73
|
+
},
|
|
74
|
+
query: { directory },
|
|
75
|
+
}).catch(() => { });
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Logging must stay best-effort: stderr is rendered into the OpenCode chat UI.
|
|
79
|
+
}
|
|
80
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-claude-memory",
|
|
3
|
-
"version": "1.7.
|
|
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",
|