@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.
@@ -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 'ov serve' is running.`,
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
- if (!res || res.ok) return false;
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
+ }