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 +3 -1
- package/bin/opencode-memory +159 -28
- package/dist/index.js +142 -17
- 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,13 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { parse, resolve } from "path";
|
|
2
3
|
import { buildMemorySystemPrompt } from "./prompt.js";
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 ? [] :
|
|
333
|
+
const recalled = ignoreMemoryContext ? [] : consumeRecallPrefetch(ctx);
|
|
209
334
|
const recalledSection = formatRecalledMemories(recalled);
|
|
210
|
-
const memoryPrompt = buildMemorySystemPrompt(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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