@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.
- package/INSTALL-ZH.md +22 -5
- package/INSTALL.md +23 -5
- package/README.md +46 -11
- package/index.mjs +9 -2
- package/lib/config.mjs +17 -3
- package/lib/mcp-config.mjs +2 -1
- package/lib/memory-recall.mjs +5 -2
- package/lib/memory-session.mjs +53 -9
- package/lib/shared/capture-utils.mjs +98 -19
- package/lib/shared/credentials.mjs +11 -0
- package/lib/shared/input-filters.mjs +219 -0
- package/lib/shared/mcp-proxy-config.mjs +124 -0
- package/lib/shared/mcp-proxy-core.mjs +13 -1
- package/lib/shared/pending-queue.mjs +11 -0
- package/lib/shared/recall-compress-core.mjs +17 -6
- package/lib/shared/recall-core.mjs +74 -5
- package/lib/shared/setup-wizard.mjs +14 -11
- package/lib/shared/uri-guard.mjs +28 -6
- package/lib/shared/workspace-identity.mjs +334 -0
- package/lib/shared/workspace-peer.mjs +126 -8
- package/lib/utils.mjs +7 -1
- package/package.json +3 -2
- package/servers/mcp-proxy.mjs +16 -40
- package/lib/shared/async-writer.mjs +0 -78
- package/lib/shared/plugin-config.mjs +0 -90
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
359
|
-
if (
|
|
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
|
-
|
|
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
|
|
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++;
|