@openviking/opencode-plugin 0.2.2 → 0.2.4

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.
@@ -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
 
@@ -381,6 +420,13 @@ export function createOpenVikingMcpProxy({
381
420
  }
382
421
 
383
422
  try {
423
+ const localResult = await callLocalTool(message);
424
+ if (localResult !== null) {
425
+ if (expectsResponse) {
426
+ await writeMessage({ jsonrpc: "2.0", id: message.id, result: localResult });
427
+ }
428
+ return;
429
+ }
384
430
  const send = sendWithRetry(message, { expectsResponse });
385
431
  if (message.method === "initialize") {
386
432
  initializeInFlight = send
@@ -396,7 +442,7 @@ export function createOpenVikingMcpProxy({
396
442
  return;
397
443
  }
398
444
  for (const outbound of result.messages) {
399
- await writeMessage(outbound);
445
+ await writeMessage(appendLocalTools(message, outbound));
400
446
  }
401
447
  } catch (err) {
402
448
  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) {
@@ -182,10 +181,12 @@ async function recoverStaleProcessing(dir) {
182
181
  * @param {string} type - "addMessage" or "commitSession"
183
182
  * @param {string} sessionId - OV session ID
184
183
  * @param {object} payload - the data that failed to send
184
+ * @param {object} options
185
+ * @param {number} options.createdAt - optional queue timestamp override
185
186
  */
186
- export async function enqueue(type, sessionId, payload) {
187
+ export async function enqueue(type, sessionId, payload, options = {}) {
187
188
  const dir = getPendingDir();
188
- const now = Date.now();
189
+ const now = Number.isFinite(options.createdAt) ? options.createdAt : Date.now();
189
190
  const dedupKey = makeDedupKey(type, sessionId, payload);
190
191
  const filename = pendingFilename(dedupKey, 0);
191
192
  const entry = {
@@ -0,0 +1,90 @@
1
+ // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ /**
3
+ * Per-harness plugin settings from ovcli.conf.
4
+ *
5
+ * ovcli.conf carried connection fields only, so every harness had to keep its
6
+ * tuning knobs in ov.conf's harness section — a server-side file that a
7
+ * client-side plugin has no business editing. The `plugin` section fixes that:
8
+ * shared keys apply to every harness that reads them, and a per-harness object
9
+ * overrides them.
10
+ *
11
+ * {
12
+ * "url": "...", "api_key": "...",
13
+ * "plugin": {
14
+ * "recallQueryExpansion": "off",
15
+ * "recallCompress": "auto"
16
+ * }
17
+ * }
18
+ *
19
+ * Resolution stays env → ovcli.conf plugin.<harness> → ovcli.conf plugin →
20
+ * ov.conf harness section (legacy) → defaults.
21
+ *
22
+ * Consumers: Claude Code and Codex only. The other harnesses ship this module
23
+ * through `sync.mjs` but still read their knobs from the environment, so a
24
+ * `plugin` entry named after them is inert. `HARNESS_KEYS` lists what a harness
25
+ * loader actually consumes today — add a key here as its loader starts calling
26
+ * `loadPluginSettings`, not before, so the section never promises a knob that
27
+ * silently does nothing.
28
+ */
29
+
30
+ import { readFileSync } from "node:fs";
31
+ import { homedir } from "node:os";
32
+ import { join, resolve as resolvePath } from "node:path";
33
+
34
+ const DEFAULT_OVCLI_CONF_PATH = join(homedir(), ".openviking", "ovcli.conf");
35
+
36
+ export const HARNESS_KEYS = {
37
+ claudeCode: "claude_code",
38
+ codex: "codex",
39
+ };
40
+
41
+ function tryLoadJson(path) {
42
+ try {
43
+ return JSON.parse(readFileSync(path, "utf-8"));
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Merge the shared plugin section with the harness-specific override.
51
+ * Returns a flat settings object; unknown keys pass through untouched so a
52
+ * harness can add its own knobs without touching this module.
53
+ */
54
+ export function loadPluginSettings(harness, env = process.env) {
55
+ const path = resolvePath(
56
+ (env.OPENVIKING_CLI_CONFIG_FILE || DEFAULT_OVCLI_CONF_PATH).replace(/^~/, homedir()),
57
+ );
58
+ const file = tryLoadJson(path);
59
+ const plugin = file && typeof file.plugin === "object" && file.plugin ? file.plugin : {};
60
+
61
+ const shared = {};
62
+ for (const [key, value] of Object.entries(plugin)) {
63
+ if (value && typeof value === "object" && !Array.isArray(value)) continue;
64
+ shared[key] = value;
65
+ }
66
+ const scoped = harness && plugin[harness] && typeof plugin[harness] === "object"
67
+ ? plugin[harness]
68
+ : {};
69
+
70
+ return { ...shared, ...scoped };
71
+ }
72
+
73
+ const REWRITE_MODES = new Set(["off", "client", "server", "auto"]);
74
+
75
+ /**
76
+ * Normalize the tri-state rewrite knob.
77
+ *
78
+ * off — never compress
79
+ * client — always compress locally through the host CLI
80
+ * server — always ask the server for a digest
81
+ * auto — prefer local (cost lands on the user's own subscription), fall back
82
+ * to the server when no healthy host CLI is available
83
+ */
84
+ export function normalizeRewriteMode(value, fallback = "off") {
85
+ const raw = String(value ?? "").trim().toLowerCase();
86
+ if (REWRITE_MODES.has(raw)) return raw;
87
+ if (raw === "1" || raw === "true" || raw === "yes") return "auto";
88
+ if (raw === "0" || raw === "false" || raw === "no") return "off";
89
+ return fallback;
90
+ }
@@ -0,0 +1,195 @@
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 dropped = false;
90
+ const repaired = line.replace(/viking:\/\/[^\s<>"')\]]+/g, (uri) => {
91
+ if (validSet.has(uri)) return uri;
92
+ const nearest = nearestUri(uri, valid);
93
+ if (nearest) return nearest;
94
+ dropped = true;
95
+ return uri;
96
+ });
97
+ if (!dropped) lines.push(repaired);
98
+ }
99
+ return lines.join("\n").trim();
100
+ }
101
+
102
+ export function normalizeCompressedContext(raw, maxChars = 4000, maxBullets = 6) {
103
+ const text = String(raw || "").trim();
104
+ if (!text) return null;
105
+ if (text.toUpperCase() === NO_RELEVANT_MEMORY) return "";
106
+ const bullets = text.split(/\r?\n/)
107
+ .map((line) => line.trim())
108
+ .filter((line) => /^[-*]\s+/.test(line) && line.includes("viking://"))
109
+ .slice(0, Math.max(1, maxBullets))
110
+ .map((line) => `- ${line.replace(/^[-*]\s+/, "").slice(0, 500).trim()}`);
111
+ if (!bullets.length) return null;
112
+ return (`${DIGEST_HEADER}\n${bullets.join("\n")}`).slice(0, Math.max(100, maxChars));
113
+ }
114
+
115
+ export function recallDigestCacheKey({
116
+ query = "",
117
+ rendered = "",
118
+ entries = [],
119
+ maxInputChars = 18000,
120
+ maxBullets = 6,
121
+ } = {}) {
122
+ const uris = entries.map((entry) => String(entry?.uri || "").trim()).filter(Boolean).sort();
123
+ const source = JSON.stringify({
124
+ version: 2,
125
+ query: String(query),
126
+ rendered: String(rendered).slice(0, maxInputChars),
127
+ uris,
128
+ maxInputChars,
129
+ maxBullets,
130
+ });
131
+ return createHash("sha256").update(source).digest("hex");
132
+ }
133
+
134
+ async function readCache(path) {
135
+ if (!path) return null;
136
+ try { return JSON.parse(await readFile(path, "utf8")); } catch { return null; }
137
+ }
138
+
139
+ async function writeCache(path, value) {
140
+ if (!path) return;
141
+ try {
142
+ await mkdir(dirname(path), { recursive: true });
143
+ const tmp = `${path}.tmp`;
144
+ await writeFile(tmp, JSON.stringify(value));
145
+ await rename(tmp, path);
146
+ } catch { /* best effort */ }
147
+ }
148
+
149
+ export async function compressRecallContext({
150
+ query,
151
+ rendered,
152
+ entries = [],
153
+ cfg = {},
154
+ runCompressor,
155
+ cachePath = "",
156
+ now = 0,
157
+ }) {
158
+ const input = String(rendered || "").trim();
159
+ if (!input) return { status: COMPRESS_EMPTY, context: "" };
160
+ const minChars = Math.max(0, Number(cfg.recallCompressMinInputChars ?? 1500));
161
+ if (input.length < minChars) return { status: COMPRESS_OK, context: input };
162
+
163
+ const maxInputChars = Math.max(1000, Number(cfg.recallCompressMaxInputChars || 18000));
164
+ const maxBullets = Math.max(1, Number(cfg.recallCompressMaxBullets || 6));
165
+ const key = recallDigestCacheKey({
166
+ query,
167
+ rendered: input,
168
+ entries,
169
+ maxInputChars,
170
+ maxBullets,
171
+ });
172
+ const cached = await readCache(cachePath);
173
+ if (cached?.key === key && typeof cached.digest === "string") {
174
+ return { status: COMPRESS_OK, context: cached.digest };
175
+ }
176
+
177
+ const prompt = buildRecallCompressionPrompt({
178
+ query,
179
+ rendered: input.slice(0, maxInputChars),
180
+ maxBullets,
181
+ });
182
+ const raw = await runCompressor(prompt);
183
+ const normalized = normalizeCompressedContext(raw, 4000, maxBullets);
184
+ if (normalized === null) return { status: COMPRESS_FAILED, context: "" };
185
+ if (!normalized) return { status: COMPRESS_EMPTY, context: "" };
186
+
187
+ const validUris = entries.map((entry) => entry?.uri).filter(Boolean);
188
+ const digest = repairDigestUris(normalized, validUris.length
189
+ ? validUris
190
+ : (input.match(/viking:\/\/[^\s<>"']+/g) || []));
191
+ if (!digest) return { status: COMPRESS_FAILED, context: "" };
192
+
193
+ await writeCache(cachePath, { key, digest, updatedAt: now || 0 });
194
+ return { status: COMPRESS_OK, context: digest };
195
+ }