opencode-claude-memory 1.6.5 → 1.7.1

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,13 @@
1
1
  import { tool } from "@opencode-ai/plugin";
2
+ import { parse, resolve } from "path";
2
3
  import { buildMemorySystemPrompt } from "./prompt.js";
3
- import { recallRelevantMemories, formatRecalledMemories } from "./recall.js";
4
+ import { formatRecalledMemories, recallSelectedMemories } from "./recall.js";
5
+ import { assertSupportedRecallSelectorClient, selectRelevantMemoryFilenames } from "./recallSelector.js";
6
+ import { scanMemoryFiles } from "./memoryScan.js";
4
7
  import { saveMemory, deleteMemory, listMemories, searchMemories, readMemory, MEMORY_TYPES, } from "./memory.js";
5
8
  import { getMemoryDir } from "./paths.js";
6
9
  const turnContextBySession = new Map();
10
+ const selectorSessionIDs = new Set();
7
11
  function shouldIgnoreMemoryContext(query) {
8
12
  if (process.env.OPENCODE_MEMORY_IGNORE === "1")
9
13
  return true;
@@ -50,7 +54,8 @@ function getLastUserQuery(messages) {
50
54
  continue;
51
55
  const query = extractUserQuery(message);
52
56
  const sessionID = typeof message.info?.sessionID === "string" ? message.info.sessionID : undefined;
53
- return { query, sessionID };
57
+ const messageID = typeof message.info?.id === "string" ? message.info.id : undefined;
58
+ return { query, sessionID, messageID, messageIndex: i };
54
59
  }
55
60
  return {};
56
61
  }
@@ -98,6 +103,85 @@ function extractRecentTools(messages) {
98
103
  }
99
104
  return tools;
100
105
  }
106
+ function getRecallAgent() {
107
+ return process.env.OPENCODE_MEMORY_RECALL_AGENT || "opencode-memory-recall";
108
+ }
109
+ function getRecallModel() {
110
+ const raw = process.env.OPENCODE_MEMORY_RECALL_MODEL;
111
+ if (!raw)
112
+ return undefined;
113
+ const slashIdx = raw.indexOf("/");
114
+ if (slashIdx <= 0 || slashIdx === raw.length - 1)
115
+ return undefined;
116
+ return {
117
+ providerID: raw.slice(0, slashIdx),
118
+ modelID: raw.slice(slashIdx + 1),
119
+ };
120
+ }
121
+ function isRootPath(path) {
122
+ const resolved = resolve(path);
123
+ return resolved === parse(resolved).root;
124
+ }
125
+ function resolveMemoryRoot(worktree, directory) {
126
+ if (isRootPath(worktree) && !isRootPath(directory))
127
+ return directory;
128
+ return worktree;
129
+ }
130
+ function isUsefulRecallQuery(query) {
131
+ const trimmed = query?.trim();
132
+ if (!trimmed)
133
+ return false;
134
+ if (/\s/.test(trimmed))
135
+ return true;
136
+ return /[\u3400-\u9fff]/.test(trimmed) && trimmed.length >= 4;
137
+ }
138
+ function buildTurnID(sessionID, messageID, messageIndex, query) {
139
+ return `${sessionID}:${messageID ?? `${messageIndex ?? -1}:${query ?? ""}`}`;
140
+ }
141
+ function alreadySurfacedKey(header) {
142
+ return `${header.name ?? header.filename.replace(/\.md$/, "").replace(/.*\//, "")}|${header.type ?? "user"}`;
143
+ }
144
+ function startRecallPrefetch(input) {
145
+ if (!input.client || !isUsefulRecallQuery(input.query))
146
+ return undefined;
147
+ assertSupportedRecallSelectorClient(input.client);
148
+ const memoryDir = getMemoryDir(input.worktree);
149
+ const headers = scanMemoryFiles(memoryDir).filter((header) => !input.alreadySurfaced.has(alreadySurfacedKey(header)));
150
+ if (headers.length === 0)
151
+ return undefined;
152
+ const handle = {
153
+ turnID: input.turnID,
154
+ settled: false,
155
+ consumed: false,
156
+ result: [],
157
+ };
158
+ const promise = selectRelevantMemoryFilenames({
159
+ client: input.client,
160
+ directory: input.directory,
161
+ parentSessionID: input.parentSessionID,
162
+ query: input.query,
163
+ memories: headers,
164
+ recentTools: input.recentTools,
165
+ selectorSessionIDs,
166
+ agent: getRecallAgent(),
167
+ model: getRecallModel(),
168
+ })
169
+ .then((selectedFilenames) => recallSelectedMemories(headers, selectedFilenames, input.alreadySurfaced))
170
+ .catch(() => []);
171
+ void promise.then((result) => {
172
+ handle.result = result;
173
+ }).finally(() => {
174
+ handle.settled = true;
175
+ });
176
+ return handle;
177
+ }
178
+ function consumeRecallPrefetch(ctx) {
179
+ const prefetch = ctx?.recallPrefetch;
180
+ if (!prefetch || !prefetch.settled || prefetch.consumed)
181
+ return [];
182
+ prefetch.consumed = true;
183
+ return prefetch.result;
184
+ }
101
185
  // Tracks how many memory entries a memory_list call saw so tool.execute.after
102
186
  // can render a meaningful title without re-reading the filesystem. Keyed by
103
187
  // callID, which uniquely identifies a single tool invocation.
@@ -149,9 +233,30 @@ function getCallID(ctx) {
149
233
  const v = ctx.callID;
150
234
  return typeof v === "string" ? v : undefined;
151
235
  }
152
- export const MemoryPlugin = async ({ worktree }) => {
153
- getMemoryDir(worktree);
236
+ export const MemoryPlugin = async ({ worktree, directory, client }) => {
237
+ directory ??= worktree;
238
+ const memoryRoot = resolveMemoryRoot(worktree, directory);
239
+ getMemoryDir(memoryRoot);
154
240
  return {
241
+ config: async (config) => {
242
+ const agentName = getRecallAgent();
243
+ const mutable = config;
244
+ mutable.agent ??= {};
245
+ mutable.agent[agentName] ??= {
246
+ mode: "all",
247
+ hidden: true,
248
+ prompt: "Select up to 5 relevant memory filenames for the current user query. Return only the requested structured output.",
249
+ };
250
+ },
251
+ "chat.params": async (input, output) => {
252
+ if (input.agent !== getRecallAgent())
253
+ return;
254
+ output.temperature = 0;
255
+ output.options = {
256
+ ...output.options,
257
+ maxOutputTokens: 256,
258
+ };
259
+ },
155
260
  "tool.execute.after": async (input, output) => {
156
261
  if (!input.tool.startsWith("memory_"))
157
262
  return;
@@ -160,7 +265,9 @@ export const MemoryPlugin = async ({ worktree }) => {
160
265
  output.title = title;
161
266
  },
162
267
  "experimental.chat.messages.transform": async (_input, output) => {
163
- const { query, sessionID } = getLastUserQuery(output.messages);
268
+ const { query, sessionID, messageID, messageIndex } = getLastUserQuery(output.messages);
269
+ if (sessionID && selectorSessionIDs.has(sessionID))
270
+ return;
164
271
  if (sessionID) {
165
272
  const alreadySurfaced = new Set();
166
273
  for (const message of output.messages) {
@@ -179,7 +286,25 @@ export const MemoryPlugin = async ({ worktree }) => {
179
286
  }
180
287
  }
181
288
  const recentTools = extractRecentTools(output.messages);
182
- turnContextBySession.set(sessionID, { query, alreadySurfaced, recentTools });
289
+ const turnID = buildTurnID(sessionID, messageID, messageIndex, query);
290
+ const existing = turnContextBySession.get(sessionID);
291
+ const ignoreMemoryContext = process.env.OPENCODE_MEMORY_IGNORE === "1" || shouldIgnoreMemoryContext(query);
292
+ let recallPrefetch;
293
+ if (!ignoreMemoryContext) {
294
+ recallPrefetch = existing?.turnID === turnID
295
+ ? existing.recallPrefetch
296
+ : startRecallPrefetch({
297
+ client: client,
298
+ directory,
299
+ worktree: memoryRoot,
300
+ parentSessionID: sessionID,
301
+ turnID,
302
+ query,
303
+ alreadySurfaced,
304
+ recentTools,
305
+ });
306
+ }
307
+ turnContextBySession.set(sessionID, { turnID, query, alreadySurfaced, recentTools, recallPrefetch });
183
308
  }
184
309
  if (shouldIgnoreMemoryContext(query)) {
185
310
  output.messages = output.messages
@@ -196,18 +321,18 @@ export const MemoryPlugin = async ({ worktree }) => {
196
321
  "experimental.chat.system.transform": async (_input, output) => {
197
322
  let sessionID;
198
323
  if (_input && typeof _input === "object") {
199
- sessionID = (typeof _input.sessionID === "string"
324
+ sessionID = typeof _input.sessionID === "string"
200
325
  ? _input.sessionID
201
- : undefined);
326
+ : undefined;
202
327
  }
328
+ if (sessionID && selectorSessionIDs.has(sessionID))
329
+ return;
203
330
  const ctx = sessionID ? turnContextBySession.get(sessionID) : undefined;
204
331
  const query = ctx?.query;
205
- const alreadySurfaced = ctx?.alreadySurfaced ?? new Set();
206
- const recentTools = ctx?.recentTools ?? [];
207
332
  const ignoreMemoryContext = process.env.OPENCODE_MEMORY_IGNORE === "1" || shouldIgnoreMemoryContext(query);
208
- const recalled = ignoreMemoryContext ? [] : recallRelevantMemories(worktree, query, alreadySurfaced, recentTools);
333
+ const recalled = ignoreMemoryContext ? [] : consumeRecallPrefetch(ctx);
209
334
  const recalledSection = formatRecalledMemories(recalled);
210
- const memoryPrompt = buildMemorySystemPrompt(worktree, recalledSection, {
335
+ const memoryPrompt = buildMemorySystemPrompt(memoryRoot, recalledSection, {
211
336
  includeIndex: !ignoreMemoryContext,
212
337
  });
213
338
  output.system.push(memoryPrompt);
@@ -236,7 +361,7 @@ export const MemoryPlugin = async ({ worktree }) => {
236
361
  .describe("Memory content. For feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines"),
237
362
  },
238
363
  async execute(args, _ctx) {
239
- const filePath = saveMemory(worktree, args.file_name, args.name, args.description, args.type, args.content);
364
+ const filePath = saveMemory(memoryRoot, args.file_name, args.name, args.description, args.type, args.content);
240
365
  return `Memory saved to ${filePath}`;
241
366
  },
242
367
  }),
@@ -246,7 +371,7 @@ export const MemoryPlugin = async ({ worktree }) => {
246
371
  file_name: tool.schema.string().describe("File name of the memory to delete (with or without .md extension)"),
247
372
  },
248
373
  async execute(args, _ctx) {
249
- const deleted = deleteMemory(worktree, args.file_name);
374
+ const deleted = deleteMemory(memoryRoot, args.file_name);
250
375
  return deleted ? `Memory "${args.file_name}" deleted.` : `Memory "${args.file_name}" not found.`;
251
376
  },
252
377
  }),
@@ -256,7 +381,7 @@ export const MemoryPlugin = async ({ worktree }) => {
256
381
  "or when you need to recall what's been stored.",
257
382
  args: {},
258
383
  async execute(_args, ctx) {
259
- const entries = listMemories(worktree);
384
+ const entries = listMemories(memoryRoot);
260
385
  const callID = getCallID(ctx);
261
386
  if (callID)
262
387
  memoryListCountByCallID.set(callID, entries.length);
@@ -274,7 +399,7 @@ export const MemoryPlugin = async ({ worktree }) => {
274
399
  query: tool.schema.string().describe("Search query — searches across name, description, and content"),
275
400
  },
276
401
  async execute(args, ctx) {
277
- const results = searchMemories(worktree, args.query);
402
+ const results = searchMemories(memoryRoot, args.query);
278
403
  const callID = getCallID(ctx);
279
404
  if (callID)
280
405
  memorySearchCountByCallID.set(callID, results.length);
@@ -291,7 +416,7 @@ export const MemoryPlugin = async ({ worktree }) => {
291
416
  file_name: tool.schema.string().describe("File name of the memory to read (with or without .md extension)"),
292
417
  },
293
418
  async execute(args, _ctx) {
294
- const entry = readMemory(worktree, args.file_name);
419
+ const entry = readMemory(memoryRoot, args.file_name);
295
420
  if (!entry) {
296
421
  return `Memory "${args.file_name}" not found.`;
297
422
  }
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.1",
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",