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 +3 -1
- package/bin/opencode-memory +159 -28
- package/dist/index.js +124 -10
- package/dist/recall.d.ts +2 -1
- package/dist/recall.js +30 -69
- package/dist/recallSelector.d.ts +27 -0
- package/dist/recallSelector.js +148 -0
- package/package.json +1 -1
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` |
|
|
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
|
package/bin/opencode-memory
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
1429
|
+
run_post_session_for_invocation
|
|
1299
1430
|
else
|
|
1300
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 ? [] :
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
86
|
-
|
|
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
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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