@openviking/opencode-plugin 0.2.4 → 2026.9.9-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.
@@ -1,4 +1,6 @@
1
1
  // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ import { applyInputFilters, compileInputFilters } from "./input-filters.mjs";
3
+
2
4
  const TEXT_BLOCK_TYPES = new Set(["text", "input_text", "output_text"]);
3
5
  const TOOL_CALL_TYPES = new Set([
4
6
  "tool_call",
@@ -17,6 +19,11 @@ const TOOL_RESULT_TYPES = new Set([
17
19
  "functioncalloutput",
18
20
  ]);
19
21
 
22
+ // Tool output is reported verbatim; the server owns truncation via
23
+ // tool_output_externalization (threshold_chars, default 20000). This cap only
24
+ // guards against pathological payloads.
25
+ const DEFAULT_TOOL_MAX_CHARS = 1000000;
26
+
20
27
  const ACK_RE = /^(?:ok|okay|k|yes|yep|no|nope|thanks|thank you|thx|done|收到|好的|好|嗯|可以|继续|不用|不需要|没了|好了)[.!?。!?\s]*$/i;
21
28
  const SLASH_COMMAND_RE = /^\/[a-z0-9_-]{1,64}\b/i;
22
29
  const METADATA_KEYS = [
@@ -97,6 +104,7 @@ function toolName(block) {
97
104
  block?.name ||
98
105
  block?.tool_name ||
99
106
  block?.toolName ||
107
+ block?.tool ||
100
108
  block?.function?.name ||
101
109
  block?.call?.name ||
102
110
  "",
@@ -107,6 +115,7 @@ function toolPayload(block, kind) {
107
115
  if (!block || typeof block !== "object") return "";
108
116
  if (kind === "call") {
109
117
  return block.input ??
118
+ block.state?.input ??
110
119
  block.arguments ??
111
120
  block.args ??
112
121
  block.params ??
@@ -117,8 +126,10 @@ function toolPayload(block, kind) {
117
126
  "";
118
127
  }
119
128
  return block.output ??
129
+ block.state?.output ??
120
130
  block.result ??
121
131
  block.error ??
132
+ block.state?.error ??
122
133
  block.data ??
123
134
  block.content ??
124
135
  block.text ??
@@ -129,6 +140,7 @@ function toolId(block) {
129
140
  return oneLine(
130
141
  block?.call_id ||
131
142
  block?.callId ||
143
+ block?.callID ||
132
144
  block?.tool_call_id ||
133
145
  block?.toolCallId ||
134
146
  block?.tool_use_id ||
@@ -142,12 +154,20 @@ function toolId(block) {
142
154
 
143
155
  function toolStatus(block, kind) {
144
156
  if (kind === "call") return "running";
145
- if (block?.is_error || block?.error) return "error";
146
- const status = oneLine(block?.status || "");
157
+ if (block?.is_error || block?.isError || block?.state?.isError || block?.error || block?.state?.error) return "error";
158
+ const status = oneLine(block?.status || block?.state?.status || "");
147
159
  return status || "completed";
148
160
  }
149
161
 
150
- function buildToolPart(block, kind, { toolMaxChars = 2000, toolNameById = {} } = {}) {
162
+ function setToolInput(part, payload) {
163
+ const input = parseMaybeJson(payload);
164
+ if (input === "" || input == null) return;
165
+ part.tool_input = typeof input === "object" && !Array.isArray(input)
166
+ ? input
167
+ : { value: input };
168
+ }
169
+
170
+ function buildToolPart(block, kind, { toolMaxChars = DEFAULT_TOOL_MAX_CHARS, toolNameById = {} } = {}) {
151
171
  const id = toolId(block);
152
172
  const name = toolName(block) || (id ? toolNameById[id] : "");
153
173
  const payload = toolPayload(block, kind);
@@ -158,13 +178,11 @@ function buildToolPart(block, kind, { toolMaxChars = 2000, toolNameById = {} } =
158
178
  tool_status: toolStatus(block, kind),
159
179
  };
160
180
  if (kind === "call") {
161
- const input = parseMaybeJson(payload);
162
- if (input !== "" && input != null) {
163
- part.tool_input = typeof input === "object" && !Array.isArray(input)
164
- ? input
165
- : { value: input };
166
- }
181
+ setToolInput(part, payload);
167
182
  } else {
183
+ if (block?.state?.input !== undefined) {
184
+ setToolInput(part, block.state.input);
185
+ }
168
186
  part.tool_output = stringifyCompact(payload, toolMaxChars);
169
187
  }
170
188
  return part;
@@ -201,7 +219,7 @@ function blockToText(block, options) {
201
219
  }
202
220
 
203
221
  export function extractTextFromContent(content, options = {}) {
204
- const opts = { toolMaxChars: 2000, ...options };
222
+ const opts = { toolMaxChars: DEFAULT_TOOL_MAX_CHARS, ...options };
205
223
  if (!content) return "";
206
224
  if (typeof content === "string") return content;
207
225
  if (Array.isArray(content)) {
@@ -221,7 +239,7 @@ export function extractTextFromPayload(payload, options = {}) {
221
239
  const chunks = [];
222
240
  const directType = normalizeType(payload.type || payload.kind || payload.role);
223
241
  if (TOOL_RESULT_TYPES.has(directType) || directType === "tool" || TOOL_CALL_TYPES.has(directType)) {
224
- const direct = blockToText(payload, { toolMaxChars: 2000, ...options });
242
+ const direct = blockToText(payload, { toolMaxChars: DEFAULT_TOOL_MAX_CHARS, ...options });
225
243
  if (direct) return direct;
226
244
  }
227
245
 
@@ -241,7 +259,7 @@ export function extractTextFromPayload(payload, options = {}) {
241
259
  }
242
260
 
243
261
  if (chunks.length === 0) {
244
- const direct = blockToText(payload, { toolMaxChars: 2000, ...options });
262
+ const direct = blockToText(payload, { toolMaxChars: DEFAULT_TOOL_MAX_CHARS, ...options });
245
263
  if (direct) chunks.push(direct);
246
264
  }
247
265
 
@@ -281,7 +299,7 @@ function collectToolNamesByIdFromPayload(payload, out) {
281
299
  }
282
300
 
283
301
  function extractPartsFromContent(content, options = {}) {
284
- const opts = { toolMaxChars: 2000, toolNameById: {}, ...options };
302
+ const opts = { toolMaxChars: DEFAULT_TOOL_MAX_CHARS, toolNameById: {}, ...options };
285
303
  const parts = [];
286
304
  if (!content) return parts;
287
305
  if (typeof content === "string") {
@@ -309,7 +327,7 @@ function extractPartsFromContent(content, options = {}) {
309
327
 
310
328
  export function extractPartsFromPayload(payload, options = {}) {
311
329
  if (!payload || typeof payload !== "object") return [];
312
- const opts = { toolMaxChars: 2000, toolNameById: {}, ...options };
330
+ const opts = { toolMaxChars: DEFAULT_TOOL_MAX_CHARS, toolNameById: {}, ...options };
313
331
  if (payload.message && typeof payload.message === "object") {
314
332
  return extractPartsFromPayload(payload.message, opts);
315
333
  }
@@ -338,6 +356,53 @@ export function extractPartsFromPayload(payload, options = {}) {
338
356
  return parts;
339
357
  }
340
358
 
359
+ /**
360
+ * Prune blank text parts and run `cfg.captureFilters` over the ones that remain.
361
+ *
362
+ * One drop verdict is taken per turn, on the aggregate of its text parts; the
363
+ * individual parts are then rewritten with the substitutions only, so a rule can
364
+ * never both keep and drop the same turn. Tool call/result parts are carried
365
+ * through untouched — filters match conversation text, not tool payloads — but a
366
+ * turn dropped on its text takes its tool parts with it.
367
+ */
368
+ export function filterCaptureParts(parts, role, cfg = {}) {
369
+ const kept = [];
370
+ for (const part of parts || []) {
371
+ if (!part) continue;
372
+ if (part.type !== "text") {
373
+ kept.push(part);
374
+ continue;
375
+ }
376
+ const text = typeof part.text === "string" ? part.text.trim() : "";
377
+ if (!text) continue;
378
+ kept.push(text === part.text ? part : { ...part, text });
379
+ }
380
+
381
+ const blank = { parts: kept, dropped: false };
382
+ const compiled = compileInputFilters(cfg?.captureFilters);
383
+ if (!compiled.rules.length || kept.length === 0) return blank;
384
+
385
+ const textParts = kept.filter((part) => part.type === "text");
386
+ if (textParts.length === 0) return blank;
387
+
388
+ const verdict = applyInputFilters(textParts.map((part) => part.text).join("\n\n"), compiled.rules, {
389
+ role,
390
+ });
391
+ if (verdict.dropped) return { parts: [], dropped: true };
392
+
393
+ const out = [];
394
+ for (const part of kept) {
395
+ if (part.type !== "text") {
396
+ out.push(part);
397
+ continue;
398
+ }
399
+ const shaped = applyInputFilters(part.text, compiled.rules, { role, substituteOnly: true });
400
+ if (!shaped.text) continue;
401
+ out.push(shaped.text === part.text ? part : { ...part, text: shaped.text });
402
+ }
403
+ return { parts: out, dropped: out.length === 0 };
404
+ }
405
+
341
406
  export function extractCaptureTurns(rolloutEntries, cfg = {}) {
342
407
  const toolNameById = collectToolNamesByIdFromEntries(rolloutEntries);
343
408
  const turns = [];
@@ -355,10 +420,15 @@ export function extractCaptureTurns(rolloutEntries, cfg = {}) {
355
420
  toolMaxChars: cfg.captureToolMaxChars,
356
421
  toolNameById,
357
422
  });
358
- const decision = shouldCaptureText(rawText, role, cfg);
359
- if (!decision.shouldCapture && parts.length === 0) continue;
423
+ const shaped = filterCaptureParts(parts, role, cfg);
424
+ if (shaped.dropped) continue;
425
+ // With parts on the wire the drop decision was already taken above, so the
426
+ // text path only runs the filters for the `content` fallback. That also
427
+ // keeps `turn.text` faithful for callers that scan it for trigger words.
428
+ const decision = shouldCaptureText(rawText, role, cfg, { filters: shaped.parts.length === 0 });
429
+ if (!decision.shouldCapture && shaped.parts.length === 0) continue;
360
430
  const text = decision.shouldCapture ? decision.text : "";
361
- turns.push({ role, text, parts });
431
+ turns.push({ role, text, parts: shaped.parts });
362
432
  }
363
433
  return turns;
364
434
  }
@@ -470,12 +540,21 @@ function isPunctuationOnly(text) {
470
540
  return !/[a-z0-9\u3400-\u9fff]/i.test(text);
471
541
  }
472
542
 
473
- export function shouldCaptureText(text, role, cfg = {}) {
543
+ export function shouldCaptureText(text, role, cfg = {}, { filters = true } = {}) {
474
544
  const maxLength = cfg.captureMaxLength || 24000;
475
545
  const sanitized = sanitizeCapturedText(text);
476
546
  if (!sanitized) return { shouldCapture: false, reason: "empty", text: "" };
477
547
 
478
- const capped = truncateCaptureText(sanitized, maxLength);
548
+ let capped = truncateCaptureText(sanitized, maxLength);
549
+ if (filters) {
550
+ const compiled = compileInputFilters(cfg?.captureFilters);
551
+ if (compiled.rules.length) {
552
+ const verdict = applyInputFilters(capped, compiled.rules, { role });
553
+ if (verdict.dropped) return { shouldCapture: false, reason: "filtered", text: "" };
554
+ capped = verdict.text;
555
+ if (!capped) return { shouldCapture: false, reason: "empty", text: "" };
556
+ }
557
+ }
479
558
  const compact = oneLine(capped);
480
559
  const isToolSummary = /^\[tool-(?:call|result)\b/i.test(compact);
481
560
 
@@ -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
+ }
@@ -0,0 +1,124 @@
1
+ // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ /**
3
+ * Shared config shaping for the stdio MCP proxy entrypoints.
4
+ *
5
+ * Every harness loads its credentials differently, but the shape
6
+ * `createOpenVikingMcpProxy` consumes is identical everywhere, and so is the
7
+ * set of files whose changes must trigger a credential reload. Keeping that
8
+ * here stops each entrypoint from re-deriving `~` expansion, URL trimming,
9
+ * timeout clamping, and the watch list.
10
+ */
11
+
12
+ import { homedir } from "node:os";
13
+ import { join, resolve as resolvePath } from "node:path";
14
+
15
+ export const DEFAULT_PROXY_TIMEOUT_MS = 15000;
16
+ const MIN_PROXY_TIMEOUT_MS = 1000;
17
+
18
+ export function trimSlash(value) {
19
+ return String(value || "").replace(/\/+$/, "");
20
+ }
21
+
22
+ /** Expand a leading `~` and resolve to an absolute path; "" stays "". */
23
+ export function normalizeConfigPath(value) {
24
+ const raw = String(value || "").trim();
25
+ if (!raw) return "";
26
+ if (raw === "~") return homedir();
27
+ if (raw.startsWith("~/")) return resolvePath(join(homedir(), raw.slice(2)));
28
+ return resolvePath(raw);
29
+ }
30
+
31
+ /**
32
+ * The credential files every harness must watch: the two `OPENVIKING_*_FILE`
33
+ * overrides and the two default locations under `~/.openviking`.
34
+ */
35
+ export function defaultCredentialPaths(env = process.env) {
36
+ return [
37
+ normalizeConfigPath(env.OPENVIKING_CLI_CONFIG_FILE),
38
+ normalizeConfigPath(env.OPENVIKING_CONFIG_FILE),
39
+ join(homedir(), ".openviking", "ovcli.conf"),
40
+ join(homedir(), ".openviking", "ov.conf"),
41
+ ].filter(Boolean);
42
+ }
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
+
78
+ function uniq(values) {
79
+ return [...new Set(values.filter(Boolean))];
80
+ }
81
+
82
+ /**
83
+ * Shape a harness's loaded config into the object `createOpenVikingMcpProxy`
84
+ * expects.
85
+ *
86
+ * `mcpUrl` wins over `baseUrl` when both are given, so a harness that lets the
87
+ * user pin an explicit MCP URL keeps that behavior. `watchedPaths` are the
88
+ * harness's own extra files; the shared defaults are appended and the result
89
+ * deduplicated.
90
+ */
91
+ export function buildMcpProxyConfig({
92
+ baseUrl = "",
93
+ mcpUrl = "",
94
+ apiKey = "",
95
+ account = "",
96
+ user = "",
97
+ peerId = "",
98
+ userAgent = "",
99
+ timeoutMs,
100
+ debug = false,
101
+ debugLogPath = "",
102
+ credentialSource = "auto",
103
+ credentialPath = "",
104
+ watchedPaths = [],
105
+ env = process.env,
106
+ } = {}) {
107
+ return {
108
+ mcpUrl: mcpUrl || `${trimSlash(baseUrl)}/mcp`,
109
+ apiKey: apiKey || "",
110
+ account: account || "",
111
+ user: user || "",
112
+ peerId: peerId || "",
113
+ userAgent: userAgent || "",
114
+ timeoutMs: Math.max(
115
+ MIN_PROXY_TIMEOUT_MS,
116
+ Number(timeoutMs) || DEFAULT_PROXY_TIMEOUT_MS,
117
+ ),
118
+ debug: debug === true,
119
+ debugLogPath,
120
+ credentialSource: credentialSource || "auto",
121
+ credentialPath: credentialPath || "",
122
+ watchedPaths: uniq([...watchedPaths, ...defaultCredentialPaths(env)]),
123
+ };
124
+ }
@@ -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
  }
@@ -409,6 +409,17 @@ export async function replayPending(fetchJSON, log) {
409
409
  res = { ok: false };
410
410
  }
411
411
 
412
+ if (entry.type === "commitSession") {
413
+ log("pending-queue", {
414
+ action: "commit-replay",
415
+ sessionId: entry.sessionId,
416
+ ok: Boolean(res?.ok),
417
+ status: res?.result?.status || res?.status,
418
+ trace_id: res?.traceId || res?.result?.trace_id,
419
+ error: res?.ok ? undefined : res?.error?.message || res?.error?.code,
420
+ });
421
+ }
422
+
412
423
  if (res?.ok) {
413
424
  await dequeue(claimedFilename);
414
425
  replayed++;