@openviking/dsh-memory-plugin 0.2.1 → 0.3.2

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.
@@ -192,9 +192,20 @@ export function resolveOpenVikingCredentials(env = process.env) {
192
192
  const explicitMcpUrl = str(env.OPENVIKING_MCP_URL, "");
193
193
  const mcpUrl = (mode !== "cli" && explicitMcpUrl) ? explicitMcpUrl : `${baseUrl.replace(/\/+$/, "")}/mcp`;
194
194
 
195
+ // The file that actually supplied the api_key, following the same chain —
196
+ // empty when the key came from the environment or was never found.
197
+ let credentialPath = "";
198
+ if (apiKey) {
199
+ if (useCli) credentialPath = files.cliPath;
200
+ else if (str(env.OPENVIKING_BEARER_TOKEN, str(env.OPENVIKING_API_KEY, ""))) credentialPath = "";
201
+ else if (str(files.cliFile.api_key, "")) credentialPath = files.cliPath;
202
+ else credentialPath = files.ovPath;
203
+ }
204
+
195
205
  return {
196
206
  ...files,
197
207
  credentialSource: useCli ? "ovcli" : ((mode === "env" || envHasCredentials) ? "env" : "auto"),
208
+ credentialPath,
198
209
  baseUrl,
199
210
  mcpUrl,
200
211
  apiKey,
@@ -0,0 +1,219 @@
1
+ // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ /**
3
+ * Ordered, operator-configured regex filters for memory plugin input.
4
+ *
5
+ * Rules are sed-style strings, applied in order to a single piece of text:
6
+ * s/^\s*ultrathink\s+//i substitute (add `g` to replace every match)
7
+ * d|^\s*[/!]| drop the text when the pattern matches
8
+ * k/^\?ov\b/ keep the text only when the pattern matches
9
+ * user:d/^\s*\/clear\b/ apply to one role only
10
+ *
11
+ * A malformed rule becomes an entry in `errors` and is skipped rather than
12
+ * throwing, so a typo in a config file cannot take a hook down.
13
+ */
14
+
15
+ const OPS = new Set(["s", "d", "k"]);
16
+ const SCOPES = ["user", "assistant"];
17
+ const ALLOWED_FLAGS = "imsug";
18
+
19
+ export const INPUT_FILTER_KNOBS = [
20
+ {
21
+ key: "recallQueryFilters",
22
+ env: "OPENVIKING_RECALL_QUERY_FILTERS",
23
+ label: "recall query filters",
24
+ },
25
+ {
26
+ key: "captureFilters",
27
+ env: "OPENVIKING_CAPTURE_FILTERS",
28
+ label: "capture filters",
29
+ },
30
+ ];
31
+
32
+ /**
33
+ * Read one delimited field. `\` always consumes the next character, and only
34
+ * `\<delim>` is un-escaped, so `\d` / `\b` / `\x2c` reach RegExp verbatim.
35
+ * Returns the index of the closing delimiter, or -1 when it is missing.
36
+ */
37
+ function scanField(source, start, delim) {
38
+ let out = "";
39
+ let i = start;
40
+ while (i < source.length) {
41
+ const c = source[i];
42
+ if (c === "\\") {
43
+ const next = source[i + 1];
44
+ if (next === undefined) {
45
+ out += "\\";
46
+ i += 1;
47
+ continue;
48
+ }
49
+ out += next === delim ? next : "\\" + next;
50
+ i += 2;
51
+ continue;
52
+ }
53
+ if (c === delim) return { value: out, end: i };
54
+ out += c;
55
+ i += 1;
56
+ }
57
+ return { value: out, end: -1 };
58
+ }
59
+
60
+ export function parseInputFilterRule(source) {
61
+ if (typeof source !== "string") {
62
+ return { source: String(source ?? ""), error: "rule must be a string" };
63
+ }
64
+ const raw = source.trim();
65
+ if (!raw) return { source: raw, error: "rule is empty" };
66
+
67
+ let scope = "";
68
+ let rest = raw;
69
+ for (const candidate of SCOPES) {
70
+ if (rest.startsWith(`${candidate}:`)) {
71
+ scope = candidate;
72
+ rest = rest.slice(candidate.length + 1);
73
+ break;
74
+ }
75
+ }
76
+
77
+ const op = rest[0] || "";
78
+ if (!OPS.has(op)) {
79
+ return {
80
+ source: raw,
81
+ error: `unknown operation "${op}" — expected s (substitute), d (drop) or k (keep)`,
82
+ };
83
+ }
84
+ const delim = rest[1];
85
+ if (delim === undefined) {
86
+ return { source: raw, error: `missing delimiter after "${op}"` };
87
+ }
88
+ if (/[A-Za-z0-9\s\\]/.test(delim)) {
89
+ return {
90
+ source: raw,
91
+ error: `invalid delimiter ${JSON.stringify(delim)} — use punctuation such as / | # or :`,
92
+ };
93
+ }
94
+
95
+ const first = scanField(rest, 2, delim);
96
+ const pattern = first.value;
97
+ let replacement = null;
98
+ let flagsRaw = "";
99
+ if (op === "s") {
100
+ if (first.end < 0) {
101
+ return { source: raw, error: `missing ${JSON.stringify(delim)} after the pattern` };
102
+ }
103
+ const second = scanField(rest, first.end + 1, delim);
104
+ if (second.end < 0) {
105
+ return {
106
+ source: raw,
107
+ error: `missing ${JSON.stringify(delim)} after the replacement — write s${delim}pattern${delim}replacement${delim}`,
108
+ };
109
+ }
110
+ replacement = second.value;
111
+ flagsRaw = rest.slice(second.end + 1);
112
+ } else {
113
+ flagsRaw = first.end < 0 ? "" : rest.slice(first.end + 1);
114
+ }
115
+
116
+ if (!pattern) return { source: raw, error: "pattern is empty" };
117
+
118
+ const flags = new Set();
119
+ for (const flag of flagsRaw) {
120
+ if (!ALLOWED_FLAGS.includes(flag)) {
121
+ return { source: raw, error: `unknown flag "${flag}" — allowed flags are i, m, s, u, g` };
122
+ }
123
+ if (flags.has(flag)) return { source: raw, error: `duplicate flag "${flag}"` };
124
+ flags.add(flag);
125
+ }
126
+ // d/k decide with .test(), which advances lastIndex on a global regex and
127
+ // would then alternate between calls. `g` means nothing there, so drop it.
128
+ if (op !== "s") flags.delete("g");
129
+
130
+ let re;
131
+ try {
132
+ re = new RegExp(pattern, [...flags].join(""));
133
+ } catch (err) {
134
+ // V8 already says "Invalid regular expression: <pattern>: <why>"; keep that
135
+ // detail without stuttering the prefix the doctors key off.
136
+ const message = err?.message || String(err);
137
+ return {
138
+ source: raw,
139
+ error: /^invalid regular expression/i.test(message)
140
+ ? `invalid${message.slice("Invalid".length)}`
141
+ : `invalid regular expression: ${message}`,
142
+ };
143
+ }
144
+
145
+ return { op, scope, re, replacement, source: raw };
146
+ }
147
+
148
+ export function compileInputFilters(rules) {
149
+ const compiled = [];
150
+ const errors = [];
151
+ const list = Array.isArray(rules) ? rules : [];
152
+ list.forEach((entry, index) => {
153
+ if (typeof entry !== "string") {
154
+ errors.push({ index, source: String(entry ?? ""), message: "rule must be a string" });
155
+ return;
156
+ }
157
+ if (!entry.trim()) return;
158
+ const parsed = parseInputFilterRule(entry);
159
+ if (parsed.error) errors.push({ index, source: parsed.source, message: parsed.error });
160
+ else compiled.push({ ...parsed, index });
161
+ });
162
+ return { rules: compiled, errors };
163
+ }
164
+
165
+ /**
166
+ * Run compiled rules over `text`.
167
+ *
168
+ * `substituteOnly` skips the d/k operations, so a caller that already took one
169
+ * drop decision for a turn can rewrite its individual pieces without taking a
170
+ * second, possibly contradictory one.
171
+ */
172
+ export function applyInputFilters(text, compiled, { role = "", substituteOnly = false } = {}) {
173
+ const original = typeof text === "string" ? text : String(text ?? "");
174
+ let current = original;
175
+ for (const rule of Array.isArray(compiled) ? compiled : []) {
176
+ if (rule.scope && rule.scope !== role) continue;
177
+ if (rule.op === "s") {
178
+ current = current.replace(rule.re, rule.replacement);
179
+ continue;
180
+ }
181
+ if (substituteOnly) continue;
182
+ const matched = rule.re.test(current);
183
+ if ((rule.op === "d" && matched) || (rule.op === "k" && !matched)) {
184
+ return { text: "", changed: false, dropped: true, ruleIndex: rule.index, op: rule.op };
185
+ }
186
+ }
187
+ const finalText = current.trim();
188
+ return {
189
+ text: finalText,
190
+ changed: finalText !== original,
191
+ dropped: false,
192
+ ruleIndex: -1,
193
+ op: "",
194
+ };
195
+ }
196
+
197
+ /** Doctor-facing summary of both filter knobs on a resolved plugin config. */
198
+ export function describeInputFilters(cfg = {}) {
199
+ return INPUT_FILTER_KNOBS.map(({ key, env, label }) => {
200
+ const configured = Array.isArray(cfg?.[key]) ? cfg[key] : [];
201
+ const compiled = compileInputFilters(configured);
202
+ const ops = { s: 0, d: 0, k: 0 };
203
+ for (const rule of compiled.rules) ops[rule.op] += 1;
204
+ const bits = [];
205
+ if (ops.s) bits.push(`${ops.s} substitute`);
206
+ if (ops.d) bits.push(`${ops.d} drop`);
207
+ if (ops.k) bits.push(`${ops.k} keep-only`);
208
+ const active = compiled.rules.length;
209
+ return {
210
+ key,
211
+ env,
212
+ label,
213
+ total: configured.length,
214
+ active,
215
+ summary: `${active} rule${active === 1 ? "" : "s"}${bits.length ? ` (${bits.join(", ")})` : ""}`,
216
+ errors: compiled.errors,
217
+ };
218
+ });
219
+ }
@@ -41,6 +41,40 @@ export function defaultCredentialPaths(env = process.env) {
41
41
  ].filter(Boolean);
42
42
  }
43
43
 
44
+ /**
45
+ * Resolve the actor peer header for a long-lived MCP proxy process.
46
+ *
47
+ * MCP servers may start in the plugin directory rather than the active
48
+ * workspace, so their process cwd is not a reliable peer identity — and unlike
49
+ * a hook, a long-lived proxy cannot re-derive one per turn. Broad recall
50
+ * intentionally spans the authenticated user's peer workspaces and therefore
51
+ * leaves the actor header unset. Actor-scoped recall needs an explicit peer so
52
+ * isolation never depends on the proxy's launch directory.
53
+ *
54
+ * A missing one degrades to broad recall with a warning rather than refusing to
55
+ * start: the proxy is what carries every memory tool, and taking those away
56
+ * because a scope preference cannot be honoured costs the user far more than
57
+ * the wider search does. Only the harness whose parent process injects the peer
58
+ * (dsh, `mcp.mjs:27`) can satisfy this without the user setting it.
59
+ */
60
+ export function resolveMcpActorPeerId({
61
+ peerId = "",
62
+ recallPeerScope = "all",
63
+ onWarn = null,
64
+ } = {}) {
65
+ if (recallPeerScope !== "actor") return "";
66
+
67
+ const explicitPeerId = String(peerId || "").trim();
68
+ if (explicitPeerId) return explicitPeerId;
69
+
70
+ const message = "OpenViking MCP: actor-scoped recall needs an explicit peer id, which an MCP proxy cannot "
71
+ + "derive from its launch directory. Falling back to broad recall across every peer under this user. "
72
+ + "Set actor_peer_id in ovcli.conf, or OPENVIKING_PEER_ID in the MCP server environment, to scope it.";
73
+ if (typeof onWarn === "function") onWarn(message);
74
+ else process.stderr.write(`${message}\n`);
75
+ return "";
76
+ }
77
+
44
78
  function uniq(values) {
45
79
  return [...new Set(values.filter(Boolean))];
46
80
  }
@@ -292,10 +292,22 @@ export function createOpenVikingMcpProxy({
292
292
  );
293
293
  }
294
294
  const msg = err instanceof Error ? err.message : String(err);
295
+ if (err && err.name === "AbortError") {
296
+ // Client-side timeout, not an outage: the server may be healthy and
297
+ // still computing (rerank-inclusive find/search can legitimately take
298
+ // longer than the default budget). Keep -32001 for genuine
299
+ // connection failures so the two are not misdiagnosed as each other.
300
+ return errorResponse(
301
+ id,
302
+ -32004,
303
+ `OpenViking MCP request timed out after ${proxyConfig.timeoutMs}ms (${proxyConfig.mcpUrl}). The server may still be processing (rerank can be slow) — check /health or raise OPENVIKING_TIMEOUT_MS.`,
304
+ { timeoutMs: proxyConfig.timeoutMs, mcpUrl: proxyConfig.mcpUrl, cause: msg },
305
+ );
306
+ }
295
307
  return errorResponse(
296
308
  id,
297
309
  -32001,
298
- `OpenViking MCP request failed. Check the configured URL (${proxyConfig.mcpUrl}) and that 'ov serve' is running.`,
310
+ `OpenViking MCP request failed. Check the configured URL (${proxyConfig.mcpUrl}) and that the OpenViking server (\`openviking-server\`) is reachable.`,
299
311
  { cause: msg },
300
312
  );
301
313
  }
@@ -4,9 +4,10 @@
4
4
  *
5
5
  * When the OpenViking server is temporarily unreachable, write operations
6
6
  * (addMessage, commitSession) serialize their payloads to
7
- * `~/.openviking/pending/` as JSON files. On the next session-start, the
8
- * queue is replayed in small batches. This is a session-start-triggered retry
9
- * path with maxRetries/TTL, not a long-running background worker.
7
+ * `~/.openviking/pending/` as JSON files. The queue is replayed in small
8
+ * batches, either at session-start (consuming retry budgets, with
9
+ * maxRetries/TTL) or by a long-running drainer that passes
10
+ * `consumeRetries: false` so transient failures stay retryable.
10
11
  *
11
12
  * Each file contains: { type, sessionId, payload, createdAt, retries, dedupKey }
12
13
  *
@@ -274,6 +275,23 @@ export async function claimForReplay(filename) {
274
275
  }
275
276
  }
276
277
 
278
+ /**
279
+ * Release a claimed file back to the queue without consuming a retry. The
280
+ * entry keeps its original filename, retry count, and createdAt position, so a
281
+ * later run retries it as if this attempt never happened.
282
+ */
283
+ export async function releaseClaim(claimedFilename) {
284
+ if (!claimedFilename.endsWith(".processing")) return null;
285
+ const dir = getPendingDir();
286
+ const restored = pendingFromProcessingFilename(claimedFilename);
287
+ try {
288
+ await rename(join(dir, claimedFilename), join(dir, restored));
289
+ return restored;
290
+ } catch {
291
+ return null;
292
+ }
293
+ }
294
+
277
295
  /**
278
296
  * Remove a pending entry after successful replay.
279
297
  */
@@ -350,9 +368,16 @@ export async function cleanStale() {
350
368
  *
351
369
  * @param {Function} fetchJSON - the configured fetchJSON from makeFetchJSON
352
370
  * @param {Function} log - logger function
371
+ * @param {object} [options]
372
+ * @param {boolean} [options.consumeRetries=true] - when false, retryable
373
+ * failures release their claim instead of incrementing the retry count, so
374
+ * a background drainer can keep retrying a transient failure without
375
+ * burning the session-start retry budget. Exhausted and non-retryable
376
+ * entries are deleted exactly as in the default mode.
353
377
  * @returns {{ replayed: number, failed: number, skipped: number, deferred: number }}
354
378
  */
355
- export async function replayPending(fetchJSON, log) {
379
+ export async function replayPending(fetchJSON, log, options = {}) {
380
+ const consumeRetries = options.consumeRetries !== false;
356
381
  const pending = await listPending();
357
382
 
358
383
  if (pending.length === 0) {
@@ -427,7 +452,18 @@ export async function replayPending(fetchJSON, log) {
427
452
  await dequeue(claimedFilename);
428
453
  skipped++;
429
454
  } else {
430
- await incrementRetry(claimedFilename, entry);
455
+ if (consumeRetries) {
456
+ await incrementRetry(claimedFilename, entry);
457
+ } else {
458
+ const released = await releaseClaim(claimedFilename);
459
+ log("pending-queue", {
460
+ action: released ? "replay-deferred" : "release-failed",
461
+ sessionId: entry.sessionId,
462
+ type: entry.type,
463
+ status: res?.result?.status || res?.status,
464
+ retries: entry.retries || 0,
465
+ });
466
+ }
431
467
  failed++;
432
468
  if (entry.type === "addMessage") {
433
469
  deferred += Math.max(0, pending.length - processed);
@@ -86,10 +86,16 @@ export function repairDigestUris(digest, validUris = []) {
86
86
  lines.push(line);
87
87
  continue;
88
88
  }
89
+ let tokenized = line;
90
+ for (const uri of [...valid].sort((a, b) => b.length - a.length)) {
91
+ if (/\s/.test(uri)) tokenized = tokenized.split(uri).join(encodeURI(uri));
92
+ }
89
93
  let dropped = false;
90
- const repaired = line.replace(/viking:\/\/[^\s<>"')\]]+/g, (uri) => {
91
- if (validSet.has(uri)) return uri;
92
- const nearest = nearestUri(uri, valid);
94
+ const repaired = tokenized.replace(/viking:\/\/[^\s<>"')\]]+/g, (uri) => {
95
+ let decoded = uri;
96
+ try { decoded = decodeURI(uri); } catch { /* keep the original candidate */ }
97
+ if (validSet.has(decoded)) return decoded;
98
+ const nearest = nearestUri(decoded, valid);
93
99
  if (nearest) return nearest;
94
100
  dropped = true;
95
101
  return uri;
@@ -149,6 +155,7 @@ async function writeCache(path, value) {
149
155
  export async function compressRecallContext({
150
156
  query,
151
157
  rendered,
158
+ shortContext = rendered,
152
159
  entries = [],
153
160
  cfg = {},
154
161
  runCompressor,
@@ -158,7 +165,9 @@ export async function compressRecallContext({
158
165
  const input = String(rendered || "").trim();
159
166
  if (!input) return { status: COMPRESS_EMPTY, context: "" };
160
167
  const minChars = Math.max(0, Number(cfg.recallCompressMinInputChars ?? 1500));
161
- if (input.length < minChars) return { status: COMPRESS_OK, context: input };
168
+ if (input.length < minChars) {
169
+ return { status: COMPRESS_OK, context: String(shortContext ?? input).trim() };
170
+ }
162
171
 
163
172
  const maxInputChars = Math.max(1000, Number(cfg.recallCompressMaxInputChars || 18000));
164
173
  const maxBullets = Math.max(1, Number(cfg.recallCompressMaxBullets || 6));
@@ -171,7 +180,8 @@ export async function compressRecallContext({
171
180
  });
172
181
  const cached = await readCache(cachePath);
173
182
  if (cached?.key === key && typeof cached.digest === "string") {
174
- return { status: COMPRESS_OK, context: cached.digest };
183
+ const digest = normalizeCompressedContext(cached.digest, 4000, maxBullets);
184
+ if (digest) return { status: COMPRESS_OK, context: digest };
175
185
  }
176
186
 
177
187
  const prompt = buildRecallCompressionPrompt({
@@ -185,9 +195,10 @@ export async function compressRecallContext({
185
195
  if (!normalized) return { status: COMPRESS_EMPTY, context: "" };
186
196
 
187
197
  const validUris = entries.map((entry) => entry?.uri).filter(Boolean);
188
- const digest = repairDigestUris(normalized, validUris.length
198
+ const repaired = repairDigestUris(normalized, validUris.length
189
199
  ? validUris
190
200
  : (input.match(/viking:\/\/[^\s<>"']+/g) || []));
201
+ const digest = normalizeCompressedContext(repaired, 4000, maxBullets);
191
202
  if (!digest) return { status: COMPRESS_FAILED, context: "" };
192
203
 
193
204
  await writeCache(cachePath, { key, digest, updatedAt: now || 0 });
@@ -14,8 +14,8 @@ const STOPWORDS = new Set([
14
14
  ]);
15
15
  const USER_RESERVED_DIRS = new Set(["memories", "skills"]);
16
16
  const SOURCES = [
17
- { type: "memory", uri: "viking://user/memories", bucket: "memories" },
18
- { type: "skill", uri: "viking://user/skills", bucket: "skills" },
17
+ { type: "memory", uri: "viking://~/memories", bucket: "memories" },
18
+ { type: "skill", uri: "viking://~/skills", bucket: "skills" },
19
19
  ];
20
20
  const DEFAULT_CONTEXT_LIMIT = 10;
21
21
  const DEFAULT_CONTEXT_MAX_TOKENS = 1600;
@@ -164,10 +164,9 @@ export function contextRequestTimeoutMs(cfg = {}, body = {}) {
164
164
  // `query_expansion` defaults to "auto" server-side, so only an explicit "off"
165
165
  // takes the expansion fuse back out of the budget.
166
166
  const wantsExpansion = Boolean(body.session_id) && body.query_expansion !== "off";
167
- if (!wantsRewrite && !wantsExpansion) return undefined;
168
-
169
167
  const configured = Number(cfg.recallContextTimeoutMs);
170
168
  if (Number.isFinite(configured) && configured > 0) return Math.max(1000, Math.floor(configured));
169
+ if (!wantsRewrite && !wantsExpansion) return undefined;
171
170
  const floor = wantsRewrite ? SERVER_REWRITE_REQUEST_TIMEOUT_MS : EXPANSION_REQUEST_TIMEOUT_MS;
172
171
  return Math.max(Number(cfg.timeoutMs) || 0, floor);
173
172
  }
@@ -272,6 +271,11 @@ async function resolveUserSpace(fetchJSON, actorPeerId = "") {
272
271
 
273
272
  async function resolveTargetUri(fetchJSON, targetUri, actorPeerId = "") {
274
273
  const trimmed = targetUri.trim().replace(/\/+$/, "");
274
+ // viking://~ is the home alias: the server expands it to the caller's own user
275
+ // space, so it needs no client-side rewrite.
276
+ if (trimmed === "viking://~" || trimmed.startsWith("viking://~/")) return trimmed;
277
+ // Legacy compat: uid-less viking://user/<reserved> URIs may still sit in plugin
278
+ // configs. Newer servers reject them, so rewrite to an explicit-uid URI here.
275
279
  const m = trimmed.match(/^viking:\/\/user(?:\/(.*))?$/);
276
280
  if (!m) return trimmed;
277
281
  const rawRest = (m[1] ?? "").trim();
@@ -413,6 +417,31 @@ export async function markContextFaceLegacy(path = stateFile("context-face.json"
413
417
  await writeJsonFile(path, { legacyUntil: now + LEGACY_CACHE_TTL_MS });
414
418
  }
415
419
 
420
+ /**
421
+ * A `peer_scope` the server rejects is remembered the same way, but unlike the
422
+ * context face this one is not a silent capability probe: dropping the field
423
+ * widens recall from the caller's own peer to the whole user root, so doctor
424
+ * reads this file back and warns.
425
+ */
426
+ export function peerScopeMemoPath() {
427
+ return stateFile("peer-scope.json");
428
+ }
429
+
430
+ export async function readPeerScopeDowngrade(path = peerScopeMemoPath(), now = Date.now()) {
431
+ const cached = await readJsonFile(path);
432
+ if (!cached?.legacyUntil || Number(cached.legacyUntil) <= now) return null;
433
+ return cached;
434
+ }
435
+
436
+ export async function markPeerScopeDowngrade(scope, status, path = peerScopeMemoPath(), now = Date.now()) {
437
+ await writeJsonFile(path, {
438
+ legacyUntil: now + LEGACY_CACHE_TTL_MS,
439
+ scope: String(scope || ""),
440
+ status: Number(status) || 0,
441
+ at: now,
442
+ });
443
+ }
444
+
416
445
  function looksLikeUnknownField(res) {
417
446
  const text = JSON.stringify(res?.error ?? res?.result ?? res?.detail ?? "").toLowerCase();
418
447
  return text.includes("extra") || text.includes("mode") || text.includes("unexpected");
@@ -554,7 +583,15 @@ async function recallViaEndpoint(fetchJSON, cfg, query, actorPeerId = "", log =
554
583
  export async function postRecall(fetchJSON, body, opts = {}) {
555
584
  const actorPeerId = opts.actorPeerId || "";
556
585
  const log = opts.log || (() => {});
586
+ const memoPath = opts.peerScopeMemoPath;
557
587
  const request = { ...body };
588
+
589
+ // A remembered downgrade is still a downgrade: recall runs wider than the
590
+ // caller asked for, so the memo doubles as the doctor's evidence.
591
+ if (request.peer_scope && await readPeerScopeDowngrade(memoPath)) {
592
+ delete request.peer_scope;
593
+ }
594
+
558
595
  const res = await fetchJSON("/api/v1/search/recall", {
559
596
  method: "POST",
560
597
  body: JSON.stringify(request),
@@ -562,9 +599,17 @@ export async function postRecall(fetchJSON, body, opts = {}) {
562
599
  if (!request.peer_scope || (res.status !== 400 && res.status !== 422)) {
563
600
  return res;
564
601
  }
602
+ // Only an unknown-field rejection means "this server predates peer_scope".
603
+ // Retrying every other 400/422 without it silently widens the search from
604
+ // the caller's own peer to the whole user root.
605
+ if (!looksLikeUnknownField(res)) {
606
+ log("recall_peer_scope_error", { status: res.status || 0 });
607
+ return res;
608
+ }
565
609
 
566
610
  const downgraded = { ...request };
567
611
  delete downgraded.peer_scope;
612
+ await markPeerScopeDowngrade(String(request.peer_scope), res.status || 0, memoPath);
568
613
  log("recall_peer_scope_downgrade", { status: res.status || 0 });
569
614
  return fetchJSON("/api/v1/search/recall", {
570
615
  method: "POST",
@@ -572,7 +617,31 @@ export async function postRecall(fetchJSON, body, opts = {}) {
572
617
  }, { actorPeerId });
573
618
  }
574
619
 
620
+ /**
621
+ * Recall, plus whatever is still filed under the peer this workspace used
622
+ * before the identity rule changed.
623
+ *
624
+ * Under the default `peer_scope: "all"` this costs nothing: the server's own
625
+ * sweep of `{user_root}/peers` already reaches the old peer's memories. Under
626
+ * `"actor"` that sweep is off by definition, so the old peer is asked for
627
+ * separately — as itself, which is both cheaper and wider than a bare
628
+ * cross-peer read (that would need the user id, and reaches memories only).
629
+ */
575
630
  export async function buildRecallBlock(fetchJSON, cfg, query, options = {}) {
631
+ const primary = await recallForPeer(fetchJSON, cfg, query, options);
632
+
633
+ const legacyPeerId = String(options.legacyPeerId || "").trim();
634
+ const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
635
+ if (cfg.recallPeerScope !== "actor" || !legacyPeerId || legacyPeerId === actorPeerId) return primary;
636
+
637
+ const log = options.log || (() => {});
638
+ const legacy = await recallForPeer(fetchJSON, cfg, query, { ...options, actorPeerId: legacyPeerId });
639
+ if (!legacy) return primary;
640
+ log("recall_legacy_peer_hit", { legacyPeerId });
641
+ return primary ? `${primary}\n${legacy}` : legacy;
642
+ }
643
+
644
+ async function recallForPeer(fetchJSON, cfg, query, options = {}) {
576
645
  const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
577
646
  const log = options.log || (() => {});
578
647
  const trimmed = String(query || "").trim();
@@ -14,30 +14,52 @@ export function normalizeToolName(value) {
14
14
  return String(value || "").trim().toLowerCase();
15
15
  }
16
16
 
17
- export function findVikingUri(args = {}, keys = DEFAULT_URI_KEYS) {
17
+ // Arguments that carry file CONTENT rather than a location. The sweep below
18
+ // looks past the known path keys so an unusual one (`paths`, a nested target)
19
+ // is still caught, but text a tool is asked to WRITE is not a path: a local
20
+ // `write` whose body merely mentions viking://user/default/ was denied, and no
21
+ // file was created. Skipped by name at any depth.
22
+ const DEFAULT_CONTENT_KEYS = [
23
+ "content",
24
+ "contents",
25
+ "text",
26
+ "body",
27
+ "old_string",
28
+ "oldString",
29
+ "new_string",
30
+ "newString",
31
+ "old_str",
32
+ "new_str",
33
+ "file_text",
34
+ "insert_line",
35
+ "replacement",
36
+ ];
37
+
38
+ export function findVikingUri(args = {}, keys = DEFAULT_URI_KEYS, contentKeys = DEFAULT_CONTENT_KEYS) {
18
39
  if (!args || typeof args !== "object") return null;
19
40
  for (const key of keys) {
20
41
  const uri = findVikingUriInValue(args[key]);
21
42
  if (uri) return uri;
22
43
  }
23
- return findVikingUriInValue(args);
44
+ return findVikingUriInValue(args, new Set(contentKeys));
24
45
  }
25
46
 
26
- export function findVikingUriInValue(value) {
47
+ export function findVikingUriInValue(value, skipKeys) {
27
48
  if (typeof value === "string") {
28
49
  const match = value.match(/\bviking:\/\/[^\s"'`<>)]*/i);
29
50
  return match?.[0] || null;
30
51
  }
31
52
  if (Array.isArray(value)) {
32
53
  for (const item of value) {
33
- const uri = findVikingUriInValue(item);
54
+ const uri = findVikingUriInValue(item, skipKeys);
34
55
  if (uri) return uri;
35
56
  }
36
57
  return null;
37
58
  }
38
59
  if (value && typeof value === "object") {
39
- for (const item of Object.values(value)) {
40
- const uri = findVikingUriInValue(item);
60
+ for (const [key, item] of Object.entries(value)) {
61
+ if (skipKeys?.has(key)) continue;
62
+ const uri = findVikingUriInValue(item, skipKeys);
41
63
  if (uri) return uri;
42
64
  }
43
65
  }