opencode-claude-memory 1.6.5 → 1.7.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.
package/README.md CHANGED
@@ -165,7 +165,7 @@ Key modules ported from Claude Code's `src/memdir/`:
165
165
  | Module | Source | Purpose |
166
166
  |---|---|---|
167
167
  | `memoryScan.ts` | `memoryScan.ts` | Recursive directory scan + frontmatter header parsing |
168
- | `recall.ts` | `findRelevantMemories.ts` | Memory recall via keyword scoring (heuristic, no LLM side-query) |
168
+ | `recall.ts` + `recallSelector.ts` | `findRelevantMemories.ts` | LLM-selected memory recall + selected memory formatting |
169
169
  | `prompt.ts` | `memoryTypes.ts` + `memdir.ts` | System prompt sections, type taxonomy, truncation |
170
170
  | `memory.ts` | `memdir.ts` | `truncateEntrypoint()` aligned with `truncateEntrypointContent()` |
171
171
 
@@ -213,6 +213,8 @@ Yes. Set `OPENCODE_MEMORY_AUTODREAM=0`. You can also tune gates with:
213
213
  - `OPENCODE_MEMORY_TERMINAL_LOG` (default `foreground-only`): set `1` to force terminal logs on, `0` to force them off
214
214
  - `OPENCODE_MEMORY_MODEL`: override model used for extraction
215
215
  - `OPENCODE_MEMORY_AGENT`: override agent used for extraction
216
+ - `OPENCODE_MEMORY_RECALL_MODEL`: override model used for LLM memory recall selection
217
+ - `OPENCODE_MEMORY_RECALL_AGENT` (default `opencode-memory-recall`): override agent used for LLM memory recall selection
216
218
  - `OPENCODE_MEMORY_AUTODREAM` (default `1`): set `0` to disable auto-dream consolidation
217
219
  - `OPENCODE_MEMORY_AUTODREAM_MIN_HOURS` (default `24`): min hours between consolidation runs
218
220
  - `OPENCODE_MEMORY_AUTODREAM_MIN_SESSIONS` (default `5`): min touched sessions since last consolidation
@@ -441,7 +441,7 @@ has_new_memories() {
441
441
  }
442
442
 
443
443
  cleanup_timestamp() {
444
- rm -f "$TIMESTAMP_FILE"
444
+ rm -f "$TIMESTAMP_FILE" "${TRANSCRIPT_CHECKPOINT_FILE:-}"
445
445
  }
446
446
 
447
447
  get_session_list_json() {
@@ -1126,6 +1126,122 @@ session_has_conversation() {
1126
1126
  return 0
1127
1127
  }
1128
1128
 
1129
+ transcript_fingerprint() {
1130
+ local transcript_file="$1"
1131
+ local stat_output
1132
+
1133
+ if [ ! -f "$transcript_file" ]; then
1134
+ return 1
1135
+ fi
1136
+
1137
+ if command -v python3 >/dev/null 2>&1; then
1138
+ python3 - "$transcript_file" <<'PY'
1139
+ import os
1140
+ import sys
1141
+
1142
+ path = sys.argv[1]
1143
+
1144
+ try:
1145
+ file_stat = os.stat(path)
1146
+ except OSError:
1147
+ raise SystemExit(1)
1148
+
1149
+ mtime = getattr(file_stat, "st_mtime_ns", int(file_stat.st_mtime * 1_000_000_000))
1150
+ print(f"{file_stat.st_size}\t{mtime}")
1151
+ PY
1152
+ return $?
1153
+ fi
1154
+
1155
+ if stat_output=$(stat -f '%z %m' "$transcript_file" 2>/dev/null); then
1156
+ printf '%s\n' "$stat_output"
1157
+ return 0
1158
+ fi
1159
+
1160
+ if stat_output=$(stat -c '%s %Y' "$transcript_file" 2>/dev/null); then
1161
+ printf '%s\n' "$stat_output"
1162
+ return 0
1163
+ fi
1164
+
1165
+ return 1
1166
+ }
1167
+
1168
+ write_transcript_checkpoint() {
1169
+ local output_file="$1"
1170
+ local transcripts_dir
1171
+ transcripts_dir="$(get_transcripts_dir)"
1172
+
1173
+ : > "$output_file"
1174
+ [ -d "$transcripts_dir" ] || return 0
1175
+
1176
+ if command -v python3 >/dev/null 2>&1; then
1177
+ python3 - "$transcripts_dir" > "$output_file" <<'PY'
1178
+ import os
1179
+ import stat
1180
+ import sys
1181
+
1182
+ transcripts_dir = sys.argv[1]
1183
+
1184
+ try:
1185
+ filenames = os.listdir(transcripts_dir)
1186
+ except OSError:
1187
+ raise SystemExit(0)
1188
+
1189
+ for filename in filenames:
1190
+ if not filename.endswith(".jsonl"):
1191
+ continue
1192
+ path = os.path.join(transcripts_dir, filename)
1193
+ try:
1194
+ file_stat = os.stat(path)
1195
+ except OSError:
1196
+ continue
1197
+ if not stat.S_ISREG(file_stat.st_mode):
1198
+ continue
1199
+ session_id = filename[:-6]
1200
+ if not session_id:
1201
+ continue
1202
+ mtime = getattr(file_stat, "st_mtime_ns", int(file_stat.st_mtime * 1_000_000_000))
1203
+ print(f"{session_id}\t{file_stat.st_size}\t{mtime}")
1204
+ PY
1205
+ return 0
1206
+ fi
1207
+
1208
+ find "$transcripts_dir" -maxdepth 1 -type f -name '*.jsonl' -print 2>/dev/null | while IFS= read -r transcript_file; do
1209
+ local filename session_id fingerprint
1210
+ filename="$(basename "$transcript_file")"
1211
+ session_id="${filename%.jsonl}"
1212
+ fingerprint="$(transcript_fingerprint "$transcript_file" || true)"
1213
+ [ -n "$session_id" ] && [ -n "$fingerprint" ] || continue
1214
+ printf '%s\t%s\n' "$session_id" "$fingerprint"
1215
+ done > "$output_file"
1216
+ }
1217
+
1218
+ read_transcript_checkpoint() {
1219
+ local session_id="$1"
1220
+
1221
+ [ -f "$TRANSCRIPT_CHECKPOINT_FILE" ] || return 1
1222
+ awk -F '\t' -v id="$session_id" '$1 == id { print $2 "\t" $3; found=1; exit } END { exit found ? 0 : 1 }' "$TRANSCRIPT_CHECKPOINT_FILE"
1223
+ }
1224
+
1225
+ session_has_incremental_activity() {
1226
+ local session_id="$1"
1227
+ local transcript_file previous current
1228
+ transcript_file="$(get_transcripts_dir)/${session_id}.jsonl"
1229
+
1230
+ if [ -f "$transcript_file" ]; then
1231
+ current="$(transcript_fingerprint "$transcript_file" || true)"
1232
+ previous="$(read_transcript_checkpoint "$session_id" || true)"
1233
+ if [ -z "$previous" ]; then
1234
+ return 0
1235
+ fi
1236
+ [ "$current" != "$previous" ]
1237
+ return $?
1238
+ fi
1239
+
1240
+ # If no transcript is available, keep the existing conservative behavior:
1241
+ # session_diff/session-list discovery may still have found a valid new turn.
1242
+ return 0
1243
+ }
1244
+
1129
1245
  run_extraction_if_needed() {
1130
1246
  local session_id="$1"
1131
1247
  local memory_written_during_session="$2"
@@ -1246,12 +1362,51 @@ run_post_session_tasks() {
1246
1362
  run_autodream_if_needed "$session_id"
1247
1363
  }
1248
1364
 
1365
+ run_post_session_for_invocation() {
1366
+ local session_id
1367
+ local memory_written_during_session
1368
+
1369
+ # Capture the session ID for this invocation. In background mode this wait
1370
+ # must not delay the visible exit of the wrapped opencode process.
1371
+ session_id=$(wait_for_session_target_id "$PRE_SESSION_JSON" "$SESSION_CAPTURE_STARTED_AT_MS" "$SESSION_WAIT_SECONDS" || true)
1372
+ if [ -z "$session_id" ]; then
1373
+ log "No session found, skipping post-session memory maintenance"
1374
+ cleanup_timestamp
1375
+ return 0
1376
+ fi
1377
+
1378
+ # Skip if session had no real conversation (e.g. user opened TUI and exited).
1379
+ if ! session_has_conversation "$session_id"; then
1380
+ log "Session $session_id has no conversation, skipping post-session memory maintenance"
1381
+ cleanup_timestamp
1382
+ return 0
1383
+ fi
1384
+
1385
+ if ! session_has_incremental_activity "$session_id"; then
1386
+ log "Session $session_id has no new transcript activity, skipping post-session memory maintenance"
1387
+ cleanup_timestamp
1388
+ return 0
1389
+ fi
1390
+
1391
+ memory_written_during_session=0
1392
+ if has_new_memories; then
1393
+ memory_written_during_session=1
1394
+ fi
1395
+
1396
+ # Timestamp file is no longer needed after the check above.
1397
+ cleanup_timestamp
1398
+
1399
+ run_post_session_tasks "$session_id" "$memory_written_during_session"
1400
+ }
1401
+
1249
1402
  # ============================================================================
1250
1403
  # Main
1251
1404
  # ============================================================================
1252
1405
 
1253
1406
  # Step 0: Create timestamp marker before running opencode
1254
1407
  TIMESTAMP_FILE=$(mktemp)
1408
+ TRANSCRIPT_CHECKPOINT_FILE=$(mktemp)
1409
+ write_transcript_checkpoint "$TRANSCRIPT_CHECKPOINT_FILE"
1255
1410
  SESSION_CAPTURE_STARTED_AT_MS=$(( $(date +%s) * 1000 ))
1256
1411
  PRE_SESSION_JSON=$(get_session_list_json "$AUTODREAM_SCAN_LIMIT" 2>/dev/null || true)
1257
1412
 
@@ -1269,35 +1424,11 @@ if [ "$EXTRACT_ENABLED" = "0" ] && [ "$AUTODREAM_ENABLED" = "0" ]; then
1269
1424
  exit $opencode_exit
1270
1425
  fi
1271
1426
 
1272
- # Step 3: Capture the session ID for this invocation
1273
- session_id=$(wait_for_session_target_id "$PRE_SESSION_JSON" "$SESSION_CAPTURE_STARTED_AT_MS" "$SESSION_WAIT_SECONDS" || true)
1274
- if [ -z "$session_id" ]; then
1275
- log "No session found, skipping post-session memory maintenance"
1276
- cleanup_timestamp
1277
- exit $opencode_exit
1278
- fi
1279
-
1280
- # Step 3.5: Skip if session had no real conversation (e.g. user opened TUI and exited)
1281
- if ! session_has_conversation "$session_id"; then
1282
- log "Session $session_id has no conversation, skipping post-session memory maintenance"
1283
- cleanup_timestamp
1284
- exit $opencode_exit
1285
- fi
1286
-
1287
- # Step 4: Check whether main session already wrote memory files
1288
- memory_written_during_session=0
1289
- if has_new_memories; then
1290
- memory_written_during_session=1
1291
- fi
1292
-
1293
- # Timestamp file is no longer needed after the check above.
1294
- cleanup_timestamp
1295
-
1296
- # Step 5: Run tasks (foreground for debug, background by default)
1427
+ # Step 3: Run post-session maintenance (foreground for debug, background by default).
1297
1428
  if [ "$FOREGROUND" = "1" ]; then
1298
- run_post_session_tasks "$session_id" "$memory_written_during_session"
1429
+ run_post_session_for_invocation
1299
1430
  else
1300
- run_post_session_tasks "$session_id" "$memory_written_during_session" &
1431
+ run_post_session_for_invocation &
1301
1432
  disown
1302
1433
  log "Post-session memory maintenance started in background (PID $!)"
1303
1434
  fi
package/dist/index.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { tool } from "@opencode-ai/plugin";
2
2
  import { buildMemorySystemPrompt } from "./prompt.js";
3
- import { recallRelevantMemories, formatRecalledMemories } from "./recall.js";
3
+ import { formatRecalledMemories, recallSelectedMemories } from "./recall.js";
4
+ import { assertSupportedRecallSelectorClient, selectRelevantMemoryFilenames } from "./recallSelector.js";
5
+ import { scanMemoryFiles } from "./memoryScan.js";
4
6
  import { saveMemory, deleteMemory, listMemories, searchMemories, readMemory, MEMORY_TYPES, } from "./memory.js";
5
7
  import { getMemoryDir } from "./paths.js";
6
8
  const turnContextBySession = new Map();
9
+ const selectorSessionIDs = new Set();
7
10
  function shouldIgnoreMemoryContext(query) {
8
11
  if (process.env.OPENCODE_MEMORY_IGNORE === "1")
9
12
  return true;
@@ -50,7 +53,8 @@ function getLastUserQuery(messages) {
50
53
  continue;
51
54
  const query = extractUserQuery(message);
52
55
  const sessionID = typeof message.info?.sessionID === "string" ? message.info.sessionID : undefined;
53
- return { query, sessionID };
56
+ const messageID = typeof message.info?.id === "string" ? message.info.id : undefined;
57
+ return { query, sessionID, messageID, messageIndex: i };
54
58
  }
55
59
  return {};
56
60
  }
@@ -98,6 +102,76 @@ function extractRecentTools(messages) {
98
102
  }
99
103
  return tools;
100
104
  }
105
+ function getRecallAgent() {
106
+ return process.env.OPENCODE_MEMORY_RECALL_AGENT || "opencode-memory-recall";
107
+ }
108
+ function getRecallModel() {
109
+ const raw = process.env.OPENCODE_MEMORY_RECALL_MODEL;
110
+ if (!raw)
111
+ return undefined;
112
+ const slashIdx = raw.indexOf("/");
113
+ if (slashIdx <= 0 || slashIdx === raw.length - 1)
114
+ return undefined;
115
+ return {
116
+ providerID: raw.slice(0, slashIdx),
117
+ modelID: raw.slice(slashIdx + 1),
118
+ };
119
+ }
120
+ function isUsefulRecallQuery(query) {
121
+ const trimmed = query?.trim();
122
+ if (!trimmed)
123
+ return false;
124
+ if (/\s/.test(trimmed))
125
+ return true;
126
+ return /[\u3400-\u9fff]/.test(trimmed) && trimmed.length >= 4;
127
+ }
128
+ function buildTurnID(sessionID, messageID, messageIndex, query) {
129
+ return `${sessionID}:${messageID ?? `${messageIndex ?? -1}:${query ?? ""}`}`;
130
+ }
131
+ function alreadySurfacedKey(header) {
132
+ return `${header.name ?? header.filename.replace(/\.md$/, "").replace(/.*\//, "")}|${header.type ?? "user"}`;
133
+ }
134
+ function startRecallPrefetch(input) {
135
+ if (!input.client || !isUsefulRecallQuery(input.query))
136
+ return undefined;
137
+ assertSupportedRecallSelectorClient(input.client);
138
+ const memoryDir = getMemoryDir(input.worktree);
139
+ const headers = scanMemoryFiles(memoryDir).filter((header) => !input.alreadySurfaced.has(alreadySurfacedKey(header)));
140
+ if (headers.length === 0)
141
+ return undefined;
142
+ const handle = {
143
+ turnID: input.turnID,
144
+ settled: false,
145
+ consumed: false,
146
+ result: [],
147
+ };
148
+ const promise = selectRelevantMemoryFilenames({
149
+ client: input.client,
150
+ directory: input.directory,
151
+ parentSessionID: input.parentSessionID,
152
+ query: input.query,
153
+ memories: headers,
154
+ recentTools: input.recentTools,
155
+ selectorSessionIDs,
156
+ agent: getRecallAgent(),
157
+ model: getRecallModel(),
158
+ })
159
+ .then((selectedFilenames) => recallSelectedMemories(headers, selectedFilenames, input.alreadySurfaced))
160
+ .catch(() => []);
161
+ void promise.then((result) => {
162
+ handle.result = result;
163
+ }).finally(() => {
164
+ handle.settled = true;
165
+ });
166
+ return handle;
167
+ }
168
+ function consumeRecallPrefetch(ctx) {
169
+ const prefetch = ctx?.recallPrefetch;
170
+ if (!prefetch || !prefetch.settled || prefetch.consumed)
171
+ return [];
172
+ prefetch.consumed = true;
173
+ return prefetch.result;
174
+ }
101
175
  // Tracks how many memory entries a memory_list call saw so tool.execute.after
102
176
  // can render a meaningful title without re-reading the filesystem. Keyed by
103
177
  // callID, which uniquely identifies a single tool invocation.
@@ -149,9 +223,29 @@ function getCallID(ctx) {
149
223
  const v = ctx.callID;
150
224
  return typeof v === "string" ? v : undefined;
151
225
  }
152
- export const MemoryPlugin = async ({ worktree }) => {
226
+ export const MemoryPlugin = async ({ worktree, directory, client }) => {
227
+ directory ??= worktree;
153
228
  getMemoryDir(worktree);
154
229
  return {
230
+ config: async (config) => {
231
+ const agentName = getRecallAgent();
232
+ const mutable = config;
233
+ mutable.agent ??= {};
234
+ mutable.agent[agentName] ??= {
235
+ mode: "all",
236
+ hidden: true,
237
+ prompt: "Select up to 5 relevant memory filenames for the current user query. Return only the requested structured output.",
238
+ };
239
+ },
240
+ "chat.params": async (input, output) => {
241
+ if (input.agent !== getRecallAgent())
242
+ return;
243
+ output.temperature = 0;
244
+ output.options = {
245
+ ...output.options,
246
+ maxOutputTokens: 256,
247
+ };
248
+ },
155
249
  "tool.execute.after": async (input, output) => {
156
250
  if (!input.tool.startsWith("memory_"))
157
251
  return;
@@ -160,7 +254,9 @@ export const MemoryPlugin = async ({ worktree }) => {
160
254
  output.title = title;
161
255
  },
162
256
  "experimental.chat.messages.transform": async (_input, output) => {
163
- const { query, sessionID } = getLastUserQuery(output.messages);
257
+ const { query, sessionID, messageID, messageIndex } = getLastUserQuery(output.messages);
258
+ if (sessionID && selectorSessionIDs.has(sessionID))
259
+ return;
164
260
  if (sessionID) {
165
261
  const alreadySurfaced = new Set();
166
262
  for (const message of output.messages) {
@@ -179,7 +275,25 @@ export const MemoryPlugin = async ({ worktree }) => {
179
275
  }
180
276
  }
181
277
  const recentTools = extractRecentTools(output.messages);
182
- turnContextBySession.set(sessionID, { query, alreadySurfaced, recentTools });
278
+ const turnID = buildTurnID(sessionID, messageID, messageIndex, query);
279
+ const existing = turnContextBySession.get(sessionID);
280
+ const ignoreMemoryContext = process.env.OPENCODE_MEMORY_IGNORE === "1" || shouldIgnoreMemoryContext(query);
281
+ let recallPrefetch;
282
+ if (!ignoreMemoryContext) {
283
+ recallPrefetch = existing?.turnID === turnID
284
+ ? existing.recallPrefetch
285
+ : startRecallPrefetch({
286
+ client: client,
287
+ directory,
288
+ worktree,
289
+ parentSessionID: sessionID,
290
+ turnID,
291
+ query,
292
+ alreadySurfaced,
293
+ recentTools,
294
+ });
295
+ }
296
+ turnContextBySession.set(sessionID, { turnID, query, alreadySurfaced, recentTools, recallPrefetch });
183
297
  }
184
298
  if (shouldIgnoreMemoryContext(query)) {
185
299
  output.messages = output.messages
@@ -196,16 +310,16 @@ export const MemoryPlugin = async ({ worktree }) => {
196
310
  "experimental.chat.system.transform": async (_input, output) => {
197
311
  let sessionID;
198
312
  if (_input && typeof _input === "object") {
199
- sessionID = (typeof _input.sessionID === "string"
313
+ sessionID = typeof _input.sessionID === "string"
200
314
  ? _input.sessionID
201
- : undefined);
315
+ : undefined;
202
316
  }
317
+ if (sessionID && selectorSessionIDs.has(sessionID))
318
+ return;
203
319
  const ctx = sessionID ? turnContextBySession.get(sessionID) : undefined;
204
320
  const query = ctx?.query;
205
- const alreadySurfaced = ctx?.alreadySurfaced ?? new Set();
206
- const recentTools = ctx?.recentTools ?? [];
207
321
  const ignoreMemoryContext = process.env.OPENCODE_MEMORY_IGNORE === "1" || shouldIgnoreMemoryContext(query);
208
- const recalled = ignoreMemoryContext ? [] : recallRelevantMemories(worktree, query, alreadySurfaced, recentTools);
322
+ const recalled = ignoreMemoryContext ? [] : consumeRecallPrefetch(ctx);
209
323
  const recalledSection = formatRecalledMemories(recalled);
210
324
  const memoryPrompt = buildMemorySystemPrompt(worktree, recalledSection, {
211
325
  includeIndex: !ignoreMemoryContext,
package/dist/recall.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { MemoryHeader } from "./memoryScan.js";
1
2
  export type RecalledMemory = {
2
3
  fileName: string;
3
4
  filePath: string;
@@ -7,5 +8,5 @@ export type RecalledMemory = {
7
8
  content: string;
8
9
  ageInDays: number;
9
10
  };
10
- export declare function recallRelevantMemories(worktree: string, query?: string, alreadySurfaced?: ReadonlySet<string>, recentTools?: readonly string[]): RecalledMemory[];
11
+ export declare function recallSelectedMemories(headers: readonly MemoryHeader[], selectedFilenames: readonly string[], alreadySurfaced?: ReadonlySet<string>): RecalledMemory[];
11
12
  export declare function formatRecalledMemories(memories: RecalledMemory[]): string;
package/dist/recall.js CHANGED
@@ -1,13 +1,8 @@
1
1
  import { readFileSync } from "fs";
2
- import { scanMemoryFiles } from "./memoryScan.js";
3
- import { getMemoryDir } from "./paths.js";
4
2
  const MAX_RECALLED_MEMORIES = 5;
5
3
  const MAX_MEMORY_LINES = 200;
6
4
  const MAX_MEMORY_BYTES = 4096;
7
5
  const encoder = new TextEncoder();
8
- function tokenizeQuery(query) {
9
- return [...new Set(query.toLowerCase().split(/\s+/).map((token) => token.trim()).filter((token) => token.length >= 2))];
10
- }
11
6
  function readMemoryContent(filePath) {
12
7
  try {
13
8
  const raw = readFileSync(filePath, "utf-8");
@@ -28,26 +23,6 @@ function readMemoryContent(filePath) {
28
23
  return "";
29
24
  }
30
25
  }
31
- function scoreHeader(header, content, terms) {
32
- if (terms.length === 0)
33
- return 0;
34
- const nameHaystack = (header.name ?? "").toLowerCase();
35
- const descHaystack = (header.description ?? "").toLowerCase();
36
- const filenameHaystack = header.filename.toLowerCase();
37
- const contentHaystack = content.toLowerCase();
38
- let score = 0;
39
- for (const term of terms) {
40
- if (nameHaystack.includes(term))
41
- score += 3;
42
- if (descHaystack.includes(term))
43
- score += 3;
44
- if (filenameHaystack.includes(term))
45
- score += 1;
46
- if (contentHaystack.includes(term))
47
- score += 1;
48
- }
49
- return score;
50
- }
51
26
  function truncateMemoryContent(content) {
52
27
  const maxLines = content.split("\n").slice(0, MAX_MEMORY_LINES);
53
28
  const lineTruncated = maxLines.join("\n");
@@ -67,54 +42,40 @@ function truncateMemoryContent(content) {
67
42
  }
68
43
  return kept.join("\n");
69
44
  }
70
- // Port of Claude Code's findRelevantMemories pattern, adapted for
71
- // keyword-based selection (no LLM side query available in plugin context).
72
- function isToolReferenceMemory(header, content, recentTools) {
73
- if (recentTools.length === 0)
74
- return false;
75
- const type = header.type;
76
- if (type !== "reference")
77
- return false;
78
- const haystack = `${header.name ?? ""}\n${header.description ?? ""}\n${content}`.toLowerCase();
79
- const warningSignals = ["warning", "gotcha", "issue", "bug", "caveat", "pitfall", "known issue"];
80
- if (warningSignals.some((w) => haystack.includes(w)))
81
- return false;
82
- const toolHaystack = recentTools.map((t) => t.toLowerCase());
83
- return toolHaystack.some((tool) => haystack.includes(tool));
45
+ function memorySurfaceKey(header) {
46
+ return `${header.name ?? header.filename.replace(/\.md$/, "").replace(/.*\//, "")}|${header.type ?? "user"}`;
47
+ }
48
+ function recalledMemoryFromHeader(header, content, now) {
49
+ const nameFromFilename = header.filename.replace(/\.md$/, "").replace(/.*\//, "");
50
+ return {
51
+ fileName: header.filename,
52
+ filePath: header.filePath,
53
+ name: header.name ?? nameFromFilename,
54
+ type: header.type ?? "user",
55
+ description: header.description ?? "",
56
+ content: truncateMemoryContent(content),
57
+ ageInDays: Math.max(0, Math.floor((now - header.mtimeMs) / (1000 * 60 * 60 * 24))),
58
+ };
84
59
  }
85
- export function recallRelevantMemories(worktree, query, alreadySurfaced = new Set(), recentTools = []) {
86
- const memoryDir = getMemoryDir(worktree);
87
- const headers = scanMemoryFiles(memoryDir).filter((h) => !alreadySurfaced.has(`${h.name ?? h.filename.replace(/\.md$/, "").replace(/.*\//, "")}|${h.type ?? "user"}`));
88
- if (headers.length === 0)
60
+ export function recallSelectedMemories(headers, selectedFilenames, alreadySurfaced = new Set()) {
61
+ if (selectedFilenames.length === 0)
89
62
  return [];
90
63
  const now = Date.now();
91
- const terms = query ? tokenizeQuery(query) : [];
92
- const scored = headers.map((header) => {
93
- const content = readMemoryContent(header.filePath);
94
- return {
95
- header,
96
- content,
97
- score: scoreHeader(header, content, terms),
98
- };
99
- }).filter(({ header, content }) => !isToolReferenceMemory(header, content, recentTools));
100
- if (terms.length > 0 && scored.some((s) => s.score > 0)) {
101
- scored.sort((a, b) => b.score - a.score || b.header.mtimeMs - a.header.mtimeMs);
102
- }
103
- else {
104
- scored.sort((a, b) => b.header.mtimeMs - a.header.mtimeMs);
64
+ const byFilename = new Map(headers.map((header) => [header.filename, header]));
65
+ const recalled = [];
66
+ const seen = new Set();
67
+ for (const filename of selectedFilenames) {
68
+ if (seen.has(filename))
69
+ continue;
70
+ seen.add(filename);
71
+ const header = byFilename.get(filename);
72
+ if (!header || alreadySurfaced.has(memorySurfaceKey(header)))
73
+ continue;
74
+ recalled.push(recalledMemoryFromHeader(header, readMemoryContent(header.filePath), now));
75
+ if (recalled.length >= MAX_RECALLED_MEMORIES)
76
+ break;
105
77
  }
106
- return scored.slice(0, MAX_RECALLED_MEMORIES).map(({ header, content }) => {
107
- const nameFromFilename = header.filename.replace(/\.md$/, "").replace(/.*\//, "");
108
- return {
109
- fileName: header.filename,
110
- filePath: header.filePath,
111
- name: header.name ?? nameFromFilename,
112
- type: header.type ?? "user",
113
- description: header.description ?? "",
114
- content: truncateMemoryContent(content),
115
- ageInDays: Math.max(0, Math.floor((now - header.mtimeMs) / (1000 * 60 * 60 * 24))),
116
- };
117
- });
78
+ return recalled;
118
79
  }
119
80
  function formatAgeWarning(ageInDays) {
120
81
  if (ageInDays <= 1)
@@ -0,0 +1,27 @@
1
+ import { type MemoryHeader } from "./memoryScan.js";
2
+ export declare const SELECT_MEMORIES_SYSTEM_PROMPT = "You are selecting memories that will be useful to OpenCode as it processes a user's query. You will be given the user's query and a list of available memory files with their filenames and descriptions.\n\nReturn a list of filenames for the memories that will clearly be useful to OpenCode as it processes the user's query (up to 5). Only include memories that you are certain will be helpful based on their name and description.\n- If you are unsure if a memory will be useful in processing the user's query, then do not include it in your list. Be selective and discerning.\n- If there are no memories in the list that would clearly be useful, feel free to return an empty list.\n- If a list of recently-used tools is provided, do not select memories that are usage reference or API documentation for those tools (OpenCode is already exercising them). DO still select memories containing warnings, gotchas, or known issues about those tools \u2014 active use is exactly when those matter.\n";
3
+ export declare const UNSUPPORTED_RECALL_SELECTOR_CLIENT_MESSAGE = "opencode-claude-memory LLM recall requires an OpenCode SDK with structured output session.prompt support. Please upgrade OpenCode/@opencode-ai/plugin.";
4
+ export type SessionClient = {
5
+ session?: {
6
+ create?: (...args: unknown[]) => Promise<unknown>;
7
+ prompt?: (...args: unknown[]) => Promise<unknown>;
8
+ delete?: (...args: unknown[]) => Promise<unknown>;
9
+ };
10
+ };
11
+ export type SelectRelevantMemoryFilenamesInput = {
12
+ client: SessionClient | undefined;
13
+ directory: string;
14
+ parentSessionID: string;
15
+ query: string;
16
+ memories: MemoryHeader[];
17
+ recentTools: readonly string[];
18
+ selectorSessionIDs: Set<string>;
19
+ agent: string;
20
+ model?: {
21
+ providerID: string;
22
+ modelID: string;
23
+ };
24
+ };
25
+ export declare function isSupportedRecallSelectorClient(client: SessionClient | undefined): boolean;
26
+ export declare function assertSupportedRecallSelectorClient(client: SessionClient | undefined): asserts client is SessionClient;
27
+ export declare function selectRelevantMemoryFilenames(input: SelectRelevantMemoryFilenamesInput): Promise<string[]>;
@@ -0,0 +1,148 @@
1
+ import { formatMemoryManifest } from "./memoryScan.js";
2
+ export const SELECT_MEMORIES_SYSTEM_PROMPT = `You are selecting memories that will be useful to OpenCode as it processes a user's query. You will be given the user's query and a list of available memory files with their filenames and descriptions.
3
+
4
+ Return a list of filenames for the memories that will clearly be useful to OpenCode as it processes the user's query (up to 5). Only include memories that you are certain will be helpful based on their name and description.
5
+ - If you are unsure if a memory will be useful in processing the user's query, then do not include it in your list. Be selective and discerning.
6
+ - If there are no memories in the list that would clearly be useful, feel free to return an empty list.
7
+ - If a list of recently-used tools is provided, do not select memories that are usage reference or API documentation for those tools (OpenCode is already exercising them). DO still select memories containing warnings, gotchas, or known issues about those tools — active use is exactly when those matter.
8
+ `;
9
+ const SELECT_MEMORIES_FORMAT = {
10
+ type: "json_schema",
11
+ schema: {
12
+ type: "object",
13
+ properties: {
14
+ selected_memories: { type: "array", items: { type: "string" } },
15
+ },
16
+ required: ["selected_memories"],
17
+ additionalProperties: false,
18
+ },
19
+ };
20
+ export const UNSUPPORTED_RECALL_SELECTOR_CLIENT_MESSAGE = "opencode-claude-memory LLM recall requires an OpenCode SDK with structured output session.prompt support. Please upgrade OpenCode/@opencode-ai/plugin.";
21
+ function unwrapData(response) {
22
+ if (!response || typeof response !== "object")
23
+ return response;
24
+ if ("data" in response)
25
+ return response.data;
26
+ return response;
27
+ }
28
+ function extractSessionID(response) {
29
+ const data = unwrapData(response);
30
+ if (!data || typeof data !== "object")
31
+ return undefined;
32
+ const id = data.id ?? data.sessionID;
33
+ return typeof id === "string" ? id : undefined;
34
+ }
35
+ function tryParseSelectedMemories(raw) {
36
+ try {
37
+ const parsed = JSON.parse(raw);
38
+ if (!Array.isArray(parsed.selected_memories))
39
+ return undefined;
40
+ return parsed.selected_memories.filter((item) => typeof item === "string");
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ function extractSelectedMemories(response) {
47
+ const data = unwrapData(response);
48
+ if (!data || typeof data !== "object")
49
+ return [];
50
+ const structured = data.info?.structured;
51
+ if (structured && typeof structured === "object") {
52
+ const selected = structured.selected_memories;
53
+ if (Array.isArray(selected)) {
54
+ return selected.filter((item) => typeof item === "string");
55
+ }
56
+ }
57
+ const parts = data.parts;
58
+ if (!Array.isArray(parts))
59
+ return [];
60
+ for (const part of parts) {
61
+ if (!part || typeof part !== "object")
62
+ continue;
63
+ const text = part.text;
64
+ if (typeof text !== "string")
65
+ continue;
66
+ const parsed = tryParseSelectedMemories(text);
67
+ if (parsed)
68
+ return parsed;
69
+ }
70
+ return [];
71
+ }
72
+ export function isSupportedRecallSelectorClient(client) {
73
+ const session = client?.session;
74
+ return Boolean(session?.create &&
75
+ session?.prompt &&
76
+ session?.delete &&
77
+ session.create.length >= 2 &&
78
+ session.prompt.length >= 2 &&
79
+ session.delete.length >= 2);
80
+ }
81
+ export function assertSupportedRecallSelectorClient(client) {
82
+ if (!isSupportedRecallSelectorClient(client)) {
83
+ throw new Error(UNSUPPORTED_RECALL_SELECTOR_CLIENT_MESSAGE);
84
+ }
85
+ }
86
+ async function createSelectorSession(client, directory, parentSessionID) {
87
+ if (!client.session?.create)
88
+ return undefined;
89
+ const response = await client.session.create({
90
+ directory,
91
+ parentID: parentSessionID,
92
+ title: "opencode-memory recall selector",
93
+ });
94
+ return extractSessionID(response);
95
+ }
96
+ async function promptSelectorSession(client, sessionID, directory, agent, model, content) {
97
+ if (!client.session?.prompt)
98
+ return undefined;
99
+ const body = {
100
+ agent,
101
+ ...(model ? { model } : {}),
102
+ tools: {},
103
+ system: SELECT_MEMORIES_SYSTEM_PROMPT,
104
+ format: SELECT_MEMORIES_FORMAT,
105
+ parts: [{ type: "text", text: content }],
106
+ };
107
+ return client.session.prompt({ sessionID, directory, ...body });
108
+ }
109
+ async function deleteSelectorSession(client, sessionID, directory) {
110
+ if (!client.session?.delete)
111
+ return;
112
+ try {
113
+ await client.session.delete({ sessionID, directory });
114
+ }
115
+ catch {
116
+ // Best-effort cleanup. A failed selector deletion should not affect recall.
117
+ }
118
+ }
119
+ export async function selectRelevantMemoryFilenames(input) {
120
+ if (input.memories.length === 0)
121
+ return [];
122
+ assertSupportedRecallSelectorClient(input.client);
123
+ let selectorSessionID;
124
+ try {
125
+ selectorSessionID = await createSelectorSession(input.client, input.directory, input.parentSessionID);
126
+ if (!selectorSessionID)
127
+ return [];
128
+ input.selectorSessionIDs.add(selectorSessionID);
129
+ const toolsSection = input.recentTools.length > 0
130
+ ? `\n\nRecently used tools: ${input.recentTools.join(", ")}`
131
+ : "";
132
+ const manifest = formatMemoryManifest(input.memories);
133
+ const response = await promptSelectorSession(input.client, selectorSessionID, input.directory, input.agent, input.model, `Query: ${input.query}\n\nAvailable memories:\n${manifest}${toolsSection}`);
134
+ const validFilenames = new Set(input.memories.map((memory) => memory.filename));
135
+ return extractSelectedMemories(response)
136
+ .filter((filename) => validFilenames.has(filename))
137
+ .slice(0, 5);
138
+ }
139
+ catch {
140
+ return [];
141
+ }
142
+ finally {
143
+ if (selectorSessionID) {
144
+ input.selectorSessionIDs.delete(selectorSessionID);
145
+ await deleteSelectorSession(input.client, selectorSessionID, input.directory);
146
+ }
147
+ }
148
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-claude-memory",
3
- "version": "1.6.5",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "description": "Claude Code-compatible memory compatibility layer for OpenCode — zero config, local-first, no migration",
6
6
  "main": "dist/index.js",