@openviking/opencode-plugin 0.2.3 → 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 +25 -7
- package/INSTALL.md +27 -7
- package/README.md +52 -13
- package/index.mjs +10 -3
- package/lib/config.mjs +50 -8
- package/lib/mcp-config.mjs +2 -1
- package/lib/memory-recall.mjs +19 -7
- package/lib/memory-session.mjs +61 -18
- package/lib/runtime.mjs +1 -0
- package/lib/shared/batch-send.mjs +2 -3
- package/lib/shared/capture-utils.mjs +99 -21
- package/lib/shared/credentials.mjs +32 -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 +60 -2
- package/lib/shared/pending-queue.mjs +13 -3
- package/lib/shared/recall-compress-core.mjs +206 -0
- package/lib/shared/recall-core.mjs +394 -18
- package/lib/shared/retryable.mjs +9 -0
- 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 +8 -1
- package/lib/viking-uri-guard.mjs +1 -1
- package/package.json +3 -2
- package/servers/mcp-proxy.mjs +16 -39
- package/servers/mcp-proxy.test.mjs +2 -0
- package/lib/shared/async-writer.mjs +0 -78
|
@@ -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
|
+
}
|
|
@@ -142,6 +142,7 @@ export function createOpenVikingMcpProxy({
|
|
|
142
142
|
readConfig,
|
|
143
143
|
loggerFactory,
|
|
144
144
|
fetchImpl = globalThis.fetch,
|
|
145
|
+
localToolProvider = null,
|
|
145
146
|
} = {}) {
|
|
146
147
|
if (typeof fetchImpl !== "function") {
|
|
147
148
|
throw new Error("global fetch is required; use Node.js 18 or newer");
|
|
@@ -179,6 +180,43 @@ export function createOpenVikingMcpProxy({
|
|
|
179
180
|
} catch { /* debug logging must never affect protocol IO */ }
|
|
180
181
|
}
|
|
181
182
|
|
|
183
|
+
function localTools() {
|
|
184
|
+
if (!localToolProvider || typeof localToolProvider.listTools !== "function") return [];
|
|
185
|
+
const tools = localToolProvider.listTools();
|
|
186
|
+
return Array.isArray(tools) ? tools : [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function appendLocalTools(message, outbound) {
|
|
190
|
+
if (message.method !== "tools/list" || !Array.isArray(outbound?.result?.tools)) {
|
|
191
|
+
return outbound;
|
|
192
|
+
}
|
|
193
|
+
const additions = localTools().filter((tool) => tool?.name);
|
|
194
|
+
if (additions.length === 0) return outbound;
|
|
195
|
+
const localNames = new Set(additions.map((tool) => tool.name));
|
|
196
|
+
const upstreamTools = outbound.result.tools.filter((tool) => !localNames.has(tool?.name));
|
|
197
|
+
return {
|
|
198
|
+
...outbound,
|
|
199
|
+
result: {
|
|
200
|
+
...outbound.result,
|
|
201
|
+
tools: [...upstreamTools, ...additions],
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function callLocalTool(message) {
|
|
207
|
+
if (
|
|
208
|
+
message.method !== "tools/call"
|
|
209
|
+
|| !localToolProvider
|
|
210
|
+
|| typeof localToolProvider.callTool !== "function"
|
|
211
|
+
) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
const name = message.params?.name;
|
|
215
|
+
if (!localTools().some((tool) => tool?.name === name)) return null;
|
|
216
|
+
reloadIfCredentialFilesChanged("local_tool_call");
|
|
217
|
+
return localToolProvider.callTool(message.params, { config: proxyConfig });
|
|
218
|
+
}
|
|
219
|
+
|
|
182
220
|
function reloadConfig(reason) {
|
|
183
221
|
proxyConfig = readConfig();
|
|
184
222
|
logger = loggerFactory("mcp-proxy", proxyConfig);
|
|
@@ -215,6 +253,7 @@ export function createOpenVikingMcpProxy({
|
|
|
215
253
|
if (proxyConfig.account) headers["X-OpenViking-Account"] = proxyConfig.account;
|
|
216
254
|
if (proxyConfig.user) headers["X-OpenViking-User"] = proxyConfig.user;
|
|
217
255
|
if (proxyConfig.peerId) headers["X-OpenViking-Actor-Peer"] = proxyConfig.peerId;
|
|
256
|
+
if (proxyConfig.userAgent) headers["User-Agent"] = proxyConfig.userAgent;
|
|
218
257
|
return headers;
|
|
219
258
|
}
|
|
220
259
|
|
|
@@ -253,10 +292,22 @@ export function createOpenVikingMcpProxy({
|
|
|
253
292
|
);
|
|
254
293
|
}
|
|
255
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
|
+
}
|
|
256
307
|
return errorResponse(
|
|
257
308
|
id,
|
|
258
309
|
-32001,
|
|
259
|
-
`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.`,
|
|
260
311
|
{ cause: msg },
|
|
261
312
|
);
|
|
262
313
|
}
|
|
@@ -381,6 +432,13 @@ export function createOpenVikingMcpProxy({
|
|
|
381
432
|
}
|
|
382
433
|
|
|
383
434
|
try {
|
|
435
|
+
const localResult = await callLocalTool(message);
|
|
436
|
+
if (localResult !== null) {
|
|
437
|
+
if (expectsResponse) {
|
|
438
|
+
await writeMessage({ jsonrpc: "2.0", id: message.id, result: localResult });
|
|
439
|
+
}
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
384
442
|
const send = sendWithRetry(message, { expectsResponse });
|
|
385
443
|
if (message.method === "initialize") {
|
|
386
444
|
initializeInFlight = send
|
|
@@ -396,7 +454,7 @@ export function createOpenVikingMcpProxy({
|
|
|
396
454
|
return;
|
|
397
455
|
}
|
|
398
456
|
for (const outbound of result.messages) {
|
|
399
|
-
await writeMessage(outbound);
|
|
457
|
+
await writeMessage(appendLocalTools(message, outbound));
|
|
400
458
|
}
|
|
401
459
|
} catch (err) {
|
|
402
460
|
if (expectsResponse) {
|
|
@@ -24,6 +24,7 @@ import { mkdir, readdir, readFile, rename, writeFile, unlink, stat, chmod } from
|
|
|
24
24
|
import { createHash } from "node:crypto";
|
|
25
25
|
import { join } from "node:path";
|
|
26
26
|
import { homedir } from "node:os";
|
|
27
|
+
import { isRetryableFailure } from "./retryable.mjs";
|
|
27
28
|
|
|
28
29
|
const DEFAULT_MAX_RETRIES = 3;
|
|
29
30
|
const DEFAULT_TTL_DAYS = 7;
|
|
@@ -109,9 +110,7 @@ function pendingFromProcessingFilename(filename) {
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
function isRetryableReplayFailure(res) {
|
|
112
|
-
|
|
113
|
-
const status = Number(res.status || 0);
|
|
114
|
-
return !status || status >= 500 || status === 408 || status === 429;
|
|
113
|
+
return isRetryableFailure(res);
|
|
115
114
|
}
|
|
116
115
|
|
|
117
116
|
async function readEntry(dir, filename) {
|
|
@@ -410,6 +409,17 @@ export async function replayPending(fetchJSON, log) {
|
|
|
410
409
|
res = { ok: false };
|
|
411
410
|
}
|
|
412
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
|
+
|
|
413
423
|
if (res?.ok) {
|
|
414
424
|
await dequeue(claimedFilename);
|
|
415
425
|
replayed++;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
|
|
6
|
+
export const NO_RELEVANT_MEMORY = "NO_RELEVANT_MEMORY";
|
|
7
|
+
export const DIGEST_HEADER = "OpenViking memory digest:";
|
|
8
|
+
const COMPRESS_OK = "ok";
|
|
9
|
+
const COMPRESS_EMPTY = "empty";
|
|
10
|
+
const COMPRESS_FAILED = "failed";
|
|
11
|
+
|
|
12
|
+
// Medium-constraint prompt: state the goal and two structural floors, but no hard
|
|
13
|
+
// bullet-length contract. Hard per-bullet limits pin the digest to headline
|
|
14
|
+
// density; leaving it unconstrained lets small models rewrite long URIs into dead
|
|
15
|
+
// links, which the URI repair below cleans up.
|
|
16
|
+
export function buildRecallCompressionPrompt({ query, rendered, maxBullets = 6 }) {
|
|
17
|
+
return `You are a memory relevance compressor utility.
|
|
18
|
+
Do not use any tools. Do not investigate. Only transform the given text.
|
|
19
|
+
|
|
20
|
+
User query:
|
|
21
|
+
${query}
|
|
22
|
+
|
|
23
|
+
Retrieved OpenViking context fragments:
|
|
24
|
+
${rendered}
|
|
25
|
+
|
|
26
|
+
Write a memory digest for a coding agent about to answer that query. Keep the
|
|
27
|
+
concrete facts (paths, identifiers, decisions, constraints); drop pleasantries
|
|
28
|
+
and conversational filler.
|
|
29
|
+
|
|
30
|
+
Format rules:
|
|
31
|
+
- Group related facts by topic, one bullet per topic, at most ${maxBullets} bullets.
|
|
32
|
+
- Start every bullet with "- ".
|
|
33
|
+
- End every bullet with its source, copied verbatim from the fragments above:
|
|
34
|
+
"来源:viking://..." or "source: viking://...". Never edit, shorten, or invent a URI.
|
|
35
|
+
- Output the digest body only. No preamble, no closing remark.
|
|
36
|
+
|
|
37
|
+
If nothing above is relevant to the query, output exactly: ${NO_RELEVANT_MEMORY}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function editDistance(a, b) {
|
|
41
|
+
if (a === b) return 0;
|
|
42
|
+
const rows = a.length + 1;
|
|
43
|
+
const cols = b.length + 1;
|
|
44
|
+
let prev = Array.from({ length: cols }, (_, i) => i);
|
|
45
|
+
for (let i = 1; i < rows; i += 1) {
|
|
46
|
+
const cur = [i];
|
|
47
|
+
for (let j = 1; j < cols; j += 1) {
|
|
48
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
49
|
+
cur[j] = Math.min(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
50
|
+
}
|
|
51
|
+
prev = cur;
|
|
52
|
+
}
|
|
53
|
+
return prev[cols - 1];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function nearestUri(candidate, validUris) {
|
|
57
|
+
let best = "";
|
|
58
|
+
let bestDistance = Infinity;
|
|
59
|
+
for (const uri of validUris) {
|
|
60
|
+
const distance = editDistance(candidate, uri);
|
|
61
|
+
if (distance < bestDistance) {
|
|
62
|
+
best = uri;
|
|
63
|
+
bestDistance = distance;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Only repair near-misses; an unrelated hallucination is dropped instead.
|
|
67
|
+
const tolerance = Math.max(4, Math.floor(candidate.length * 0.25));
|
|
68
|
+
return bestDistance <= tolerance ? best : "";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Small models occasionally mangle long URIs. Snap every cited URI back onto the
|
|
73
|
+
* set the server actually returned, and drop bullets whose citation cannot be
|
|
74
|
+
* recovered so the digest never carries a dead link.
|
|
75
|
+
*/
|
|
76
|
+
export function repairDigestUris(digest, validUris = []) {
|
|
77
|
+
const text = String(digest || "");
|
|
78
|
+
if (!text) return "";
|
|
79
|
+
const valid = validUris.map((uri) => String(uri || "").trim()).filter(Boolean);
|
|
80
|
+
if (!valid.length) return text;
|
|
81
|
+
const validSet = new Set(valid);
|
|
82
|
+
|
|
83
|
+
const lines = [];
|
|
84
|
+
for (const line of text.split(/\r?\n/)) {
|
|
85
|
+
if (!line.trim().startsWith("- ")) {
|
|
86
|
+
lines.push(line);
|
|
87
|
+
continue;
|
|
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
|
+
}
|
|
93
|
+
let dropped = false;
|
|
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);
|
|
99
|
+
if (nearest) return nearest;
|
|
100
|
+
dropped = true;
|
|
101
|
+
return uri;
|
|
102
|
+
});
|
|
103
|
+
if (!dropped) lines.push(repaired);
|
|
104
|
+
}
|
|
105
|
+
return lines.join("\n").trim();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function normalizeCompressedContext(raw, maxChars = 4000, maxBullets = 6) {
|
|
109
|
+
const text = String(raw || "").trim();
|
|
110
|
+
if (!text) return null;
|
|
111
|
+
if (text.toUpperCase() === NO_RELEVANT_MEMORY) return "";
|
|
112
|
+
const bullets = text.split(/\r?\n/)
|
|
113
|
+
.map((line) => line.trim())
|
|
114
|
+
.filter((line) => /^[-*]\s+/.test(line) && line.includes("viking://"))
|
|
115
|
+
.slice(0, Math.max(1, maxBullets))
|
|
116
|
+
.map((line) => `- ${line.replace(/^[-*]\s+/, "").slice(0, 500).trim()}`);
|
|
117
|
+
if (!bullets.length) return null;
|
|
118
|
+
return (`${DIGEST_HEADER}\n${bullets.join("\n")}`).slice(0, Math.max(100, maxChars));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function recallDigestCacheKey({
|
|
122
|
+
query = "",
|
|
123
|
+
rendered = "",
|
|
124
|
+
entries = [],
|
|
125
|
+
maxInputChars = 18000,
|
|
126
|
+
maxBullets = 6,
|
|
127
|
+
} = {}) {
|
|
128
|
+
const uris = entries.map((entry) => String(entry?.uri || "").trim()).filter(Boolean).sort();
|
|
129
|
+
const source = JSON.stringify({
|
|
130
|
+
version: 2,
|
|
131
|
+
query: String(query),
|
|
132
|
+
rendered: String(rendered).slice(0, maxInputChars),
|
|
133
|
+
uris,
|
|
134
|
+
maxInputChars,
|
|
135
|
+
maxBullets,
|
|
136
|
+
});
|
|
137
|
+
return createHash("sha256").update(source).digest("hex");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function readCache(path) {
|
|
141
|
+
if (!path) return null;
|
|
142
|
+
try { return JSON.parse(await readFile(path, "utf8")); } catch { return null; }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function writeCache(path, value) {
|
|
146
|
+
if (!path) return;
|
|
147
|
+
try {
|
|
148
|
+
await mkdir(dirname(path), { recursive: true });
|
|
149
|
+
const tmp = `${path}.tmp`;
|
|
150
|
+
await writeFile(tmp, JSON.stringify(value));
|
|
151
|
+
await rename(tmp, path);
|
|
152
|
+
} catch { /* best effort */ }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function compressRecallContext({
|
|
156
|
+
query,
|
|
157
|
+
rendered,
|
|
158
|
+
shortContext = rendered,
|
|
159
|
+
entries = [],
|
|
160
|
+
cfg = {},
|
|
161
|
+
runCompressor,
|
|
162
|
+
cachePath = "",
|
|
163
|
+
now = 0,
|
|
164
|
+
}) {
|
|
165
|
+
const input = String(rendered || "").trim();
|
|
166
|
+
if (!input) return { status: COMPRESS_EMPTY, context: "" };
|
|
167
|
+
const minChars = Math.max(0, Number(cfg.recallCompressMinInputChars ?? 1500));
|
|
168
|
+
if (input.length < minChars) {
|
|
169
|
+
return { status: COMPRESS_OK, context: String(shortContext ?? input).trim() };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const maxInputChars = Math.max(1000, Number(cfg.recallCompressMaxInputChars || 18000));
|
|
173
|
+
const maxBullets = Math.max(1, Number(cfg.recallCompressMaxBullets || 6));
|
|
174
|
+
const key = recallDigestCacheKey({
|
|
175
|
+
query,
|
|
176
|
+
rendered: input,
|
|
177
|
+
entries,
|
|
178
|
+
maxInputChars,
|
|
179
|
+
maxBullets,
|
|
180
|
+
});
|
|
181
|
+
const cached = await readCache(cachePath);
|
|
182
|
+
if (cached?.key === key && typeof cached.digest === "string") {
|
|
183
|
+
const digest = normalizeCompressedContext(cached.digest, 4000, maxBullets);
|
|
184
|
+
if (digest) return { status: COMPRESS_OK, context: digest };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const prompt = buildRecallCompressionPrompt({
|
|
188
|
+
query,
|
|
189
|
+
rendered: input.slice(0, maxInputChars),
|
|
190
|
+
maxBullets,
|
|
191
|
+
});
|
|
192
|
+
const raw = await runCompressor(prompt);
|
|
193
|
+
const normalized = normalizeCompressedContext(raw, 4000, maxBullets);
|
|
194
|
+
if (normalized === null) return { status: COMPRESS_FAILED, context: "" };
|
|
195
|
+
if (!normalized) return { status: COMPRESS_EMPTY, context: "" };
|
|
196
|
+
|
|
197
|
+
const validUris = entries.map((entry) => entry?.uri).filter(Boolean);
|
|
198
|
+
const repaired = repairDigestUris(normalized, validUris.length
|
|
199
|
+
? validUris
|
|
200
|
+
: (input.match(/viking:\/\/[^\s<>"']+/g) || []));
|
|
201
|
+
const digest = normalizeCompressedContext(repaired, 4000, maxBullets);
|
|
202
|
+
if (!digest) return { status: COMPRESS_FAILED, context: "" };
|
|
203
|
+
|
|
204
|
+
await writeCache(cachePath, { key, digest, updatedAt: now || 0 });
|
|
205
|
+
return { status: COMPRESS_OK, context: digest };
|
|
206
|
+
}
|