@openviking/opencode-plugin 2026.9.9 → 2026.9.21-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 +42 -28
- package/INSTALL.md +41 -27
- package/README.md +47 -33
- package/index.mjs +5 -9
- package/lib/config.mjs +30 -307
- package/lib/memory-recall.mjs +2 -2
- package/lib/memory-session.mjs +6 -5
- package/lib/repo-context.mjs +2 -3
- package/lib/runtime.mjs +12 -19
- package/lib/shared/capture-utils.mjs +145 -6
- package/lib/shared/config-schema.mjs +359 -0
- package/lib/shared/credentials.mjs +238 -102
- package/lib/shared/input-filters.mjs +219 -0
- package/lib/shared/mcp-proxy-config.mjs +154 -2
- package/lib/shared/mcp-proxy-core.mjs +23 -15
- package/lib/shared/ov-http.mjs +112 -0
- package/lib/shared/pending-queue.mjs +41 -5
- package/lib/shared/plugin-config.mjs +300 -0
- package/lib/shared/recall-core.mjs +66 -13
- package/lib/shared/uri-guard.mjs +173 -1
- package/lib/shared/workspace-config.mjs +433 -0
- package/lib/shared/workspace-peer.mjs +63 -0
- package/lib/shared/workspace-registry.mjs +87 -0
- package/lib/utils.mjs +34 -177
- package/lib/viking-uri-guard.mjs +23 -8
- package/package.json +4 -3
- package/servers/mcp-proxy.mjs +7 -27
- package/servers/mcp-proxy.test.mjs +0 -102
|
@@ -0,0 +1,300 @@
|
|
|
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
|
+
* Every harness resolves through `resolveSettings()` below, and assembles its
|
|
23
|
+
* whole configuration through `buildPluginConfig()`, so one `plugin` section
|
|
24
|
+
* configures all of them and `ov config switch` moves behaviour along with
|
|
25
|
+
* credentials. The knobs themselves are declared once in `config-schema.mjs`;
|
|
26
|
+
* this module decides which files supply them and what is derived from them.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { homedir } from "node:os";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
|
|
32
|
+
import { HARNESS_KEYS, KNOBS, harnessKey, resolveKnobs } from "./config-schema.mjs";
|
|
33
|
+
import {
|
|
34
|
+
buildUserAgent,
|
|
35
|
+
loadCredentialFiles,
|
|
36
|
+
readManifestVersion,
|
|
37
|
+
resolveConnection,
|
|
38
|
+
} from "./credentials.mjs";
|
|
39
|
+
import {
|
|
40
|
+
announcedOverrides,
|
|
41
|
+
loadWorkspaceLayers,
|
|
42
|
+
mergeConfigLayers,
|
|
43
|
+
normalizeWorkspaceConfig,
|
|
44
|
+
projectWorkspaceSettings,
|
|
45
|
+
} from "./workspace-config.mjs";
|
|
46
|
+
import { findWorkspaceRoot, resolveWorkspaceIdentity } from "./workspace-identity.mjs";
|
|
47
|
+
import { resolveEffectivePeerId, resolvePluginPeerId } from "./workspace-peer.mjs";
|
|
48
|
+
import { readEntry } from "./workspace-registry.mjs";
|
|
49
|
+
|
|
50
|
+
const OVCLI_LAYER = "ovcli.conf";
|
|
51
|
+
const OV_CONF_LAYER = "ov.conf";
|
|
52
|
+
|
|
53
|
+
export { HARNESS_KEYS, harnessKey };
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Merge the shared plugin section with the harness-specific override.
|
|
57
|
+
* Returns a flat settings object; unknown keys pass through untouched so a
|
|
58
|
+
* harness can add its own knobs without touching this module.
|
|
59
|
+
*
|
|
60
|
+
* `cliFile` is ovcli.conf already parsed. Callers that have it hand it over so
|
|
61
|
+
* the file is read once per build and, more importantly, so the `plugin`
|
|
62
|
+
* section comes out of the same file the credential chain read — an
|
|
63
|
+
* `OPENVIKING_CONFIG_FILE` that is ovcli-shaped is ovcli.conf, and only
|
|
64
|
+
* `loadCredentialFiles` knows that.
|
|
65
|
+
*/
|
|
66
|
+
export function loadPluginSettings(harness, env = process.env, options = {}) {
|
|
67
|
+
const file = options.cliFile || loadCredentialFiles(env).cliFile;
|
|
68
|
+
const plugin = file && typeof file.plugin === "object" && file.plugin ? file.plugin : {};
|
|
69
|
+
|
|
70
|
+
const shared = {};
|
|
71
|
+
for (const [key, value] of Object.entries(plugin)) {
|
|
72
|
+
if (value && typeof value === "object" && !Array.isArray(value)) continue;
|
|
73
|
+
shared[key] = value;
|
|
74
|
+
}
|
|
75
|
+
// Both spellings of the harness name find the same override, so copying
|
|
76
|
+
// `claude-code` out of the installer is not a silent no-op.
|
|
77
|
+
const key = harnessKey(harness);
|
|
78
|
+
const scoped = {};
|
|
79
|
+
for (const candidate of [key, key.replace(/_/g, "-")]) {
|
|
80
|
+
const value = candidate && plugin[candidate];
|
|
81
|
+
if (value && typeof value === "object" && !Array.isArray(value)) Object.assign(scoped, value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const settings = { ...shared, ...scoped };
|
|
85
|
+
const cwd = String(options.cwd || "").trim();
|
|
86
|
+
if (!cwd) return settings;
|
|
87
|
+
return { ...settings, ...resolveWorkspaceSettings(cwd, env, options).settings };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Every knob for one harness, resolved through the whole layer stack.
|
|
92
|
+
*
|
|
93
|
+
* `legacy` is that harness's section in ov.conf, which is where tuning used to
|
|
94
|
+
* live: it stays the lowest configured layer so an existing deployment keeps
|
|
95
|
+
* working, and everything above it comes from files a client may edit.
|
|
96
|
+
*
|
|
97
|
+
* Returns the settings, the names some layer actually supplied (what the
|
|
98
|
+
* `*Configured` flags report), and the raw ovcli.conf merge — a couple of
|
|
99
|
+
* callers still need to know a value came from ovcli.conf rather than ov.conf.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveSettings(harness, options = {}) {
|
|
102
|
+
const { env = process.env, cwd = "", legacy = {}, clientVersion = "", cliFile = null } = options;
|
|
103
|
+
const key = harnessKey(harness);
|
|
104
|
+
const plugin = loadPluginSettings(key, env, { cwd, clientVersion, cliFile });
|
|
105
|
+
const { settings, configured, sources } = resolveKnobs({
|
|
106
|
+
harness: key,
|
|
107
|
+
layers: [
|
|
108
|
+
{ name: OV_CONF_LAYER, data: legacy },
|
|
109
|
+
{ name: OVCLI_LAYER, data: plugin },
|
|
110
|
+
],
|
|
111
|
+
env,
|
|
112
|
+
});
|
|
113
|
+
return { settings, configured, sources, plugin };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The workspace layers for one cwd, flattened into harness knobs.
|
|
118
|
+
*
|
|
119
|
+
* Takes the cwd explicitly because a hook's `loadConfig()` runs at module top
|
|
120
|
+
* level, before the payload on stdin has said which directory the session is
|
|
121
|
+
* actually in — the caller resolves this again once it knows.
|
|
122
|
+
*/
|
|
123
|
+
export function resolveWorkspaceSettings(cwd, env = process.env, { clientVersion = "" } = {}) {
|
|
124
|
+
const empty = { settings: {}, root: "", provenance: {}, warnings: [], announced: [] };
|
|
125
|
+
try {
|
|
126
|
+
const { root } = findWorkspaceRoot(cwd, env);
|
|
127
|
+
if (!root) return empty;
|
|
128
|
+
|
|
129
|
+
const { layers, warnings } = loadWorkspaceLayers(root, { clientVersion });
|
|
130
|
+
// The identity is what makes the registry's negative evidence work: without
|
|
131
|
+
// it a directory reused by a different repository inherits the old peer.
|
|
132
|
+
const identity = resolveWorkspaceIdentity({ cwd, env });
|
|
133
|
+
const registry = readEntry(root, { identity, env });
|
|
134
|
+
warnings.push(...registry.warnings);
|
|
135
|
+
if (registry.entry?.settings) layers.push({ layer: "registry", data: registry.entry.settings });
|
|
136
|
+
if (registry.entry?.peer) layers.push({ layer: "registry", data: { peer: registry.entry.peer } });
|
|
137
|
+
if (!layers.length) return { ...empty, root, warnings };
|
|
138
|
+
|
|
139
|
+
const { value, provenance } = mergeConfigLayers(layers, warnings);
|
|
140
|
+
normalizeWorkspaceConfig(value, warnings);
|
|
141
|
+
return {
|
|
142
|
+
settings: projectWorkspaceSettings(value),
|
|
143
|
+
root,
|
|
144
|
+
provenance,
|
|
145
|
+
warnings,
|
|
146
|
+
announced: announcedOverrides(provenance),
|
|
147
|
+
value,
|
|
148
|
+
};
|
|
149
|
+
} catch {
|
|
150
|
+
// A hook must never die over a config file. An unreadable layer is no layer.
|
|
151
|
+
return empty;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const SEND_ONLY_WHEN_CONFIGURED = KNOBS.filter((knob) => knob.sendOnlyWhenConfigured);
|
|
156
|
+
|
|
157
|
+
function str(value) {
|
|
158
|
+
return typeof value === "string" && value.trim() ? value.trim() : "";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The ov.conf block named after this harness, the lowest layer of every chain. */
|
|
162
|
+
function ovConfSection(ovFile, key) {
|
|
163
|
+
const section = key ? ovFile?.[key] : null;
|
|
164
|
+
return section && typeof section === "object" && !Array.isArray(section) ? section : {};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* One harness's whole configuration: the files, the knobs, the peer, and the
|
|
169
|
+
* handful of fields derived from them.
|
|
170
|
+
*
|
|
171
|
+
* Every loader used to write this sequence out by hand and each wrote a
|
|
172
|
+
* slightly different subset, which is how ov.conf's `<harness>.authMode` came
|
|
173
|
+
* to apply on three harnesses only, and why two of the four knobs the server
|
|
174
|
+
* defaults for itself were reported as configured on two harnesses and nowhere
|
|
175
|
+
* else. What is left in a loader after this call is what only that harness
|
|
176
|
+
* knows.
|
|
177
|
+
*
|
|
178
|
+
* The connection — server, key, identity, auth mode — is not a knob: it comes
|
|
179
|
+
* from `resolveConnection`, the same call an MCP proxy's connection comes
|
|
180
|
+
* from, and the `plugin` keys and ov.conf block that name it are read there.
|
|
181
|
+
*
|
|
182
|
+
* `legacy` replaces the ov.conf block this harness would otherwise get for its
|
|
183
|
+
* knobs (dsh's host hands it one of its own); `hostInput` is what an embedding
|
|
184
|
+
* host named for this process, which is the more specific answer and outranks
|
|
185
|
+
* every file; `logFile` is the log basename this harness has always used, and
|
|
186
|
+
* a harness that never had a default log path does not get one invented for
|
|
187
|
+
* it; `rootKeyFallback` is for the harnesses whose chain predates the ovcli.conf
|
|
188
|
+
* pinning and has always ended at `server.root_api_key`.
|
|
189
|
+
*/
|
|
190
|
+
export function buildPluginConfig(harness, {
|
|
191
|
+
cwd = "",
|
|
192
|
+
env = process.env,
|
|
193
|
+
legacy = null,
|
|
194
|
+
manifestUrl = "",
|
|
195
|
+
version = "",
|
|
196
|
+
hostInput = {},
|
|
197
|
+
logFile = "",
|
|
198
|
+
rootKeyFallback = false,
|
|
199
|
+
deriveEffectivePeer = false,
|
|
200
|
+
} = {}) {
|
|
201
|
+
const name = String(harness || "");
|
|
202
|
+
const key = harnessKey(name);
|
|
203
|
+
const workspaceCwd = str(cwd) || process.cwd();
|
|
204
|
+
const files = loadCredentialFiles(env);
|
|
205
|
+
// The version answers two questions: what the User-Agent reports, and whether
|
|
206
|
+
// a workspace file asking for a newer client gets to say so.
|
|
207
|
+
const clientVersion = str(version) || (manifestUrl ? readManifestVersion(manifestUrl) : "");
|
|
208
|
+
const { settings, configured, sources } = resolveSettings(key, {
|
|
209
|
+
env,
|
|
210
|
+
cwd: workspaceCwd,
|
|
211
|
+
legacy: legacy || ovConfSection(files.ovFile, key),
|
|
212
|
+
cliFile: files.cliFile,
|
|
213
|
+
clientVersion,
|
|
214
|
+
});
|
|
215
|
+
const connection = resolveConnection(key, { env, files, hostInput, rootKeyFallback });
|
|
216
|
+
const { baseUrl, account, user } = connection;
|
|
217
|
+
const timeoutMs = settings.timeoutMs;
|
|
218
|
+
const peerId = resolvePluginPeerId({
|
|
219
|
+
settings,
|
|
220
|
+
configured,
|
|
221
|
+
sources,
|
|
222
|
+
credentials: connection,
|
|
223
|
+
hostInput: hostInput?.peerId,
|
|
224
|
+
env,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const config = {
|
|
228
|
+
...settings,
|
|
229
|
+
harness: name,
|
|
230
|
+
clientVersion,
|
|
231
|
+
userAgent: buildUserAgent(name, clientVersion),
|
|
232
|
+
|
|
233
|
+
baseUrl,
|
|
234
|
+
mcpUrl: connection.mcpUrl,
|
|
235
|
+
apiKey: connection.apiKey,
|
|
236
|
+
account,
|
|
237
|
+
user,
|
|
238
|
+
authMode: connection.authMode,
|
|
239
|
+
sendIdentityHeaders: connection.sendIdentityHeaders,
|
|
240
|
+
|
|
241
|
+
credentialSource: connection.credentialSource,
|
|
242
|
+
apiKeySource: connection.apiKeySource,
|
|
243
|
+
credentialPath: connection.credentialPath,
|
|
244
|
+
hasApiKey: connection.hasApiKey,
|
|
245
|
+
configPath: connection.cliPath || connection.ovPath || null,
|
|
246
|
+
cliConfigPath: connection.cliPath,
|
|
247
|
+
ovConfigPath: connection.ovPath,
|
|
248
|
+
cliPath: connection.cliPath,
|
|
249
|
+
ovPath: connection.ovPath,
|
|
250
|
+
|
|
251
|
+
peerId,
|
|
252
|
+
explicitPeerId: peerId,
|
|
253
|
+
|
|
254
|
+
// A write gets a longer budget than a read, and a digest has to finish
|
|
255
|
+
// inside the recall request it belongs to, so both fall back to a knob.
|
|
256
|
+
captureTimeoutMs: settings.captureTimeoutMs || Math.max(timeoutMs * 2, 30000),
|
|
257
|
+
recallCompressTimeoutMs: settings.recallCompressTimeoutMs
|
|
258
|
+
|| Math.max(1000, settings.recallTimeoutMs - 10000),
|
|
259
|
+
debugLogPath: settings.debugLogPath
|
|
260
|
+
|| (logFile ? join(homedir(), ".openviking", "logs", logFile) : ""),
|
|
261
|
+
|
|
262
|
+
// The spellings these fields carried before every harness shared a loader.
|
|
263
|
+
endpoint: baseUrl,
|
|
264
|
+
accountId: account,
|
|
265
|
+
userId: user,
|
|
266
|
+
requestTimeoutMs: timeoutMs,
|
|
267
|
+
};
|
|
268
|
+
for (const knob of SEND_ONLY_WHEN_CONFIGURED) {
|
|
269
|
+
config[`${knob.name}Configured`] = configured.has(knob.name);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Only for the harnesses that resolve the peer once at load. The rest derive
|
|
273
|
+
// it per session, against the directory the payload names, and an MCP proxy
|
|
274
|
+
// must not derive one from wherever it happened to be launched.
|
|
275
|
+
if (deriveEffectivePeer) {
|
|
276
|
+
const effectivePeer = resolveEffectivePeerId({ cfg: config, cwd: workspaceCwd, env });
|
|
277
|
+
config.effectivePeer = effectivePeer;
|
|
278
|
+
config.legacyPeerId = effectivePeer.legacyPeerId;
|
|
279
|
+
}
|
|
280
|
+
return config;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const REWRITE_MODES = new Set(["off", "client", "server", "auto"]);
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Normalize the tri-state rewrite knob.
|
|
287
|
+
*
|
|
288
|
+
* off — never compress
|
|
289
|
+
* client — always compress locally through the host CLI
|
|
290
|
+
* server — always ask the server for a digest
|
|
291
|
+
* auto — prefer local (cost lands on the user's own subscription), fall back
|
|
292
|
+
* to the server when no healthy host CLI is available
|
|
293
|
+
*/
|
|
294
|
+
export function normalizeRewriteMode(value, fallback = "off") {
|
|
295
|
+
const raw = String(value ?? "").trim().toLowerCase();
|
|
296
|
+
if (REWRITE_MODES.has(raw)) return raw;
|
|
297
|
+
if (raw === "1" || raw === "true" || raw === "yes") return "auto";
|
|
298
|
+
if (raw === "0" || raw === "false" || raw === "no") return "off";
|
|
299
|
+
return fallback;
|
|
300
|
+
}
|
|
@@ -31,6 +31,28 @@ const CODING_QUOTA_WEIGHTS = {
|
|
|
31
31
|
|
|
32
32
|
let userSpaceCache = "";
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Is recall on for this config?
|
|
36
|
+
*
|
|
37
|
+
* The switch has four spellings in the wild: a boolean `autoRecall`, opencode's
|
|
38
|
+
* `{ enabled }` object, dsh and pi's `syncTurns`, and the global `enabled`
|
|
39
|
+
* that turns the whole plugin off. Reading it here rather than in each loader
|
|
40
|
+
* is what keeps it from drifting a fifth time — and any spelling that says off
|
|
41
|
+
* wins, so a config that disables recall under an older name still disables it.
|
|
42
|
+
*/
|
|
43
|
+
export function isRecallEnabled(cfg = {}) {
|
|
44
|
+
return isSwitchOn([cfg.enabled, cfg.autoRecall, cfg.recall, cfg.syncRecall]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Any recognised spelling that says off wins; everything else means on. */
|
|
48
|
+
function isSwitchOn(values) {
|
|
49
|
+
for (const value of values) {
|
|
50
|
+
if (value === false) return false;
|
|
51
|
+
if (value && typeof value === "object" && !Array.isArray(value) && value.enabled === false) return false;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
34
56
|
export function estimateTokens(text) {
|
|
35
57
|
return text ? Math.ceil(String(text).length / 4) : 0;
|
|
36
58
|
}
|
|
@@ -338,9 +360,8 @@ async function resolveItemContent(fetchJSON, item, cfg, actorPeerId = "") {
|
|
|
338
360
|
}
|
|
339
361
|
|
|
340
362
|
async function buildFallbackInjectionBlock(fetchJSON, items, cfg, actorPeerId = "", log = () => {}) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
let budgetRemaining = Math.max(200, Number(cfg.recallTokenBudget || 2000));
|
|
363
|
+
const budgetTotal = Math.max(200, Number(cfg.recallTokenBudget || 2000));
|
|
364
|
+
let budgetRemaining = budgetTotal;
|
|
344
365
|
const lines = [
|
|
345
366
|
"<openviking-context>",
|
|
346
367
|
"Relevant context from OpenViking. Use the read MCP tool to expand URIs.",
|
|
@@ -373,15 +394,15 @@ async function buildFallbackInjectionBlock(fetchJSON, items, cfg, actorPeerId =
|
|
|
373
394
|
|
|
374
395
|
lines.push("</openviking-context>");
|
|
375
396
|
|
|
376
|
-
const budgetUsed =
|
|
397
|
+
const budgetUsed = budgetTotal - budgetRemaining;
|
|
377
398
|
log("recall_injection_built", {
|
|
378
399
|
contentItems: contentCount,
|
|
379
400
|
hintItems: hintCount,
|
|
380
401
|
budgetUsed,
|
|
381
|
-
budgetTotal
|
|
402
|
+
budgetTotal,
|
|
382
403
|
});
|
|
383
404
|
|
|
384
|
-
return lines.join("\n");
|
|
405
|
+
return { block: lines.join("\n"), contentCount, hintCount, budgetUsed, stage: "ranked" };
|
|
385
406
|
}
|
|
386
407
|
|
|
387
408
|
const LEGACY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
@@ -626,8 +647,13 @@ export async function postRecall(fetchJSON, body, opts = {}) {
|
|
|
626
647
|
* `"actor"` that sweep is off by definition, so the old peer is asked for
|
|
627
648
|
* separately — as itself, which is both cheaper and wider than a bare
|
|
628
649
|
* cross-peer read (that would need the user id, and reaches memories only).
|
|
650
|
+
*
|
|
651
|
+
* `stage` names the path the block came from, which is what a host needs to
|
|
652
|
+
* tell "the server had nothing" (`no_results`) from "the score threshold
|
|
653
|
+
* dropped everything" (`filtered_out`) when the block is empty, and a
|
|
654
|
+
* `server_assembled` block from a `ranked` one when it is not.
|
|
629
655
|
*/
|
|
630
|
-
export async function
|
|
656
|
+
export async function buildRecallBlockDetailed(fetchJSON, cfg, query, options = {}) {
|
|
631
657
|
const primary = await recallForPeer(fetchJSON, cfg, query, options);
|
|
632
658
|
|
|
633
659
|
const legacyPeerId = String(options.legacyPeerId || "").trim();
|
|
@@ -636,16 +662,32 @@ export async function buildRecallBlock(fetchJSON, cfg, query, options = {}) {
|
|
|
636
662
|
|
|
637
663
|
const log = options.log || (() => {});
|
|
638
664
|
const legacy = await recallForPeer(fetchJSON, cfg, query, { ...options, actorPeerId: legacyPeerId });
|
|
639
|
-
if (!legacy) return primary;
|
|
665
|
+
if (!legacy.block) return primary;
|
|
640
666
|
log("recall_legacy_peer_hit", { legacyPeerId });
|
|
641
|
-
return
|
|
667
|
+
return {
|
|
668
|
+
block: primary.block ? `${primary.block}\n${legacy.block}` : legacy.block,
|
|
669
|
+
contentCount: primary.contentCount + legacy.contentCount,
|
|
670
|
+
hintCount: primary.hintCount + legacy.hintCount,
|
|
671
|
+
budgetUsed: primary.budgetUsed + legacy.budgetUsed,
|
|
672
|
+
stage: primary.block ? primary.stage : legacy.stage,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** The block alone, or null when nothing was injectable. */
|
|
677
|
+
export async function buildRecallBlock(fetchJSON, cfg, query, options = {}) {
|
|
678
|
+
const { block } = await buildRecallBlockDetailed(fetchJSON, cfg, query, options);
|
|
679
|
+
return block || null;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function emptyRecall(stage) {
|
|
683
|
+
return { block: "", contentCount: 0, hintCount: 0, budgetUsed: 0, stage };
|
|
642
684
|
}
|
|
643
685
|
|
|
644
686
|
async function recallForPeer(fetchJSON, cfg, query, options = {}) {
|
|
645
687
|
const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
|
|
646
688
|
const log = options.log || (() => {});
|
|
647
689
|
const trimmed = String(query || "").trim();
|
|
648
|
-
if (!trimmed) return
|
|
690
|
+
if (!trimmed) return emptyRecall("no_results");
|
|
649
691
|
|
|
650
692
|
// Assembly happens server-side when the deployment offers the context face;
|
|
651
693
|
// older servers fall through to /recall, then to raw find.
|
|
@@ -654,12 +696,23 @@ async function recallForPeer(fetchJSON, cfg, query, options = {}) {
|
|
|
654
696
|
actorPeerId,
|
|
655
697
|
log,
|
|
656
698
|
});
|
|
657
|
-
if (serverBlock !== null)
|
|
699
|
+
if (serverBlock !== null) {
|
|
700
|
+
if (!serverBlock) return emptyRecall("no_results");
|
|
701
|
+
// The server rendered one budgeted unit, so nothing here degraded to a
|
|
702
|
+
// URI-only hint and the block's own estimate is what it cost.
|
|
703
|
+
return {
|
|
704
|
+
block: serverBlock,
|
|
705
|
+
contentCount: 1,
|
|
706
|
+
hintCount: 0,
|
|
707
|
+
budgetUsed: estimateTokens(serverBlock),
|
|
708
|
+
stage: "server_assembled",
|
|
709
|
+
};
|
|
710
|
+
}
|
|
658
711
|
|
|
659
712
|
const recallLimit = Math.max(1, Number(cfg.recallLimit || DEFAULT_CONTEXT_LIMIT));
|
|
660
713
|
const perSourceLimit = Math.max(recallLimit * 2, 8);
|
|
661
714
|
const raw = await searchAllSources(fetchJSON, trimmed, perSourceLimit, actorPeerId, log);
|
|
662
|
-
if (raw.length === 0) return
|
|
715
|
+
if (raw.length === 0) return emptyRecall("no_results");
|
|
663
716
|
|
|
664
717
|
const profile = buildQueryProfile(trimmed);
|
|
665
718
|
const scoreThreshold = Number.isFinite(Number(cfg.scoreThreshold)) ? Number(cfg.scoreThreshold) : 0.35;
|
|
@@ -673,6 +726,6 @@ async function recallForPeer(fetchJSON, cfg, query, options = {}) {
|
|
|
673
726
|
items: picked.map((it) => ({ type: it._sourceType, uri: it.uri, score: clampScore(it.score) })),
|
|
674
727
|
});
|
|
675
728
|
|
|
676
|
-
if (picked.length === 0) return
|
|
729
|
+
if (picked.length === 0) return emptyRecall("filtered_out");
|
|
677
730
|
return buildFallbackInjectionBlock(fetchJSON, picked, cfg, actorPeerId, log);
|
|
678
731
|
}
|
package/lib/shared/uri-guard.mjs
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
2
6
|
const DEFAULT_URI_KEYS = [
|
|
3
7
|
"filePath",
|
|
4
8
|
"file_path",
|
|
@@ -7,7 +11,6 @@ const DEFAULT_URI_KEYS = [
|
|
|
7
11
|
"uri",
|
|
8
12
|
"target_uri",
|
|
9
13
|
"targetUri",
|
|
10
|
-
"pattern",
|
|
11
14
|
];
|
|
12
15
|
|
|
13
16
|
export function normalizeToolName(value) {
|
|
@@ -76,3 +79,172 @@ export function buildGuardMessage(uri, hint = {}) {
|
|
|
76
79
|
if (example) lines.push(`Example: ${example}`);
|
|
77
80
|
return lines.join("\n");
|
|
78
81
|
}
|
|
82
|
+
|
|
83
|
+
export function buildGuardNotice(uri, hint = {}) {
|
|
84
|
+
const lines = [
|
|
85
|
+
`[OpenViking memory plugin] URI guard: this shell command contains the viking:// URI ${uri}.`,
|
|
86
|
+
"viking:// URIs are OpenViking virtual paths, not local files, so cat, ls, grep and other file commands cannot open them.",
|
|
87
|
+
`If you meant to read or search OpenViking content, use ${hint.tool || "the OpenViking MCP tools"} instead.`,
|
|
88
|
+
];
|
|
89
|
+
if (hint.example) lines.push(`Example: ${hint.example}`);
|
|
90
|
+
lines.push("If the URI is intentional data (an ov CLI argument, an HTTP payload, a search pattern), ignore this notice.");
|
|
91
|
+
return lines.join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The hints a host gets when it names no table of its own. */
|
|
95
|
+
export const DEFAULT_TOOL_HINTS = {
|
|
96
|
+
read: {
|
|
97
|
+
tool: "OpenViking MCP read",
|
|
98
|
+
example: (uri) => `read(uris="${uri}")`,
|
|
99
|
+
},
|
|
100
|
+
glob: {
|
|
101
|
+
tool: "OpenViking MCP glob or list",
|
|
102
|
+
example: (uri, input = {}) => (
|
|
103
|
+
`glob(pattern="${String(input.pattern ?? "**/*").replaceAll('"', '\\"')}", uri="${uri}")`
|
|
104
|
+
),
|
|
105
|
+
},
|
|
106
|
+
grep: {
|
|
107
|
+
tool: "OpenViking MCP grep or search",
|
|
108
|
+
example: (uri, input = {}) => (
|
|
109
|
+
`grep(uri="${uri}", pattern="${String(input.pattern ?? "").replaceAll('"', '\\"')}")`
|
|
110
|
+
),
|
|
111
|
+
},
|
|
112
|
+
edit: {
|
|
113
|
+
tool: "OpenViking MCP edit",
|
|
114
|
+
example: (uri) => `edit(uri="${uri}", old_string="...", new_string="...")`,
|
|
115
|
+
},
|
|
116
|
+
write: {
|
|
117
|
+
tool: "OpenViking MCP write",
|
|
118
|
+
example: (uri) => `write(uri="${uri}", content="...")`,
|
|
119
|
+
},
|
|
120
|
+
bash: {
|
|
121
|
+
tool: "OpenViking MCP read or search",
|
|
122
|
+
example: (uri) => `read(uris="${uri}")`,
|
|
123
|
+
},
|
|
124
|
+
runcommand: {
|
|
125
|
+
tool: "OpenViking MCP read or search",
|
|
126
|
+
example: (uri) => `read(uris="${uri}")`,
|
|
127
|
+
},
|
|
128
|
+
shell: {
|
|
129
|
+
tool: "OpenViking MCP read or search",
|
|
130
|
+
example: (uri) => `read(uris="${uri}")`,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Tools whose argument is a command line rather than a location. A viking:// URI
|
|
135
|
+
// in one is data (an `ov` argument, an HTTP payload, a grep pattern) as often as
|
|
136
|
+
// a path the model hoped to open, so the command runs and the model gets a notice.
|
|
137
|
+
const SHELL_TOOL_NAMES = new Set(["bash", "shell", "runcommand"]);
|
|
138
|
+
|
|
139
|
+
// `pattern` is where glob looks but what grep looks for: a grep for the text
|
|
140
|
+
// "viking://" in a local tree is not a path. The generic sweep still reaches
|
|
141
|
+
// glob's pattern.
|
|
142
|
+
const TEXT_ARGS_BY_TOOL = { grep: ["pattern"] };
|
|
143
|
+
|
|
144
|
+
function resolveGuardedUri(toolName, input, { hints = DEFAULT_TOOL_HINTS } = {}) {
|
|
145
|
+
const name = normalizeToolName(toolName);
|
|
146
|
+
const hint = hints[name];
|
|
147
|
+
if (!hint) return null;
|
|
148
|
+
const textArgs = TEXT_ARGS_BY_TOOL[name];
|
|
149
|
+
const uri = findVikingUri(input, DEFAULT_URI_KEYS, textArgs ? [...DEFAULT_CONTENT_KEYS, ...textArgs] : DEFAULT_CONTENT_KEYS);
|
|
150
|
+
if (!uri) return null;
|
|
151
|
+
return {
|
|
152
|
+
uri,
|
|
153
|
+
shell: SHELL_TOOL_NAMES.has(name),
|
|
154
|
+
hint: {
|
|
155
|
+
tool: hint.tool,
|
|
156
|
+
example: typeof hint.example === "function" ? hint.example(uri, input) : hint.example,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The deny decision for one tool call, or null when the call may proceed.
|
|
163
|
+
*
|
|
164
|
+
* A file tool whose path is a viking:// URI cannot succeed, and a write would
|
|
165
|
+
* leave a junk local file, so it is denied. A shell tool is never denied here;
|
|
166
|
+
* `evaluateUriNotice` covers it. `hints` carries the host's replacement tool
|
|
167
|
+
* names and example calls, and a tool without a hint is not guarded.
|
|
168
|
+
*/
|
|
169
|
+
export function evaluateUriGuard(toolName, input = {}, opts = {}) {
|
|
170
|
+
const match = resolveGuardedUri(toolName, input, opts);
|
|
171
|
+
if (!match || match.shell) return null;
|
|
172
|
+
return { uri: match.uri, reason: buildGuardMessage(match.uri, match.hint) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The notice for a shell command that carries a viking:// URI, or null. The command still runs. */
|
|
176
|
+
export function evaluateUriNotice(toolName, input = {}, opts = {}) {
|
|
177
|
+
const match = resolveGuardedUri(toolName, input, opts);
|
|
178
|
+
if (!match?.shell) return null;
|
|
179
|
+
return { uri: match.uri, reason: buildGuardNotice(match.uri, match.hint) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The PreToolUse deny envelope claude-code, trae and zcode all read. */
|
|
183
|
+
export function denyHookSpecificOutput(reason) {
|
|
184
|
+
return {
|
|
185
|
+
hookSpecificOutput: {
|
|
186
|
+
hookEventName: "PreToolUse",
|
|
187
|
+
permissionDecision: "deny",
|
|
188
|
+
permissionDecisionReason: reason,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The PreToolUse notice envelope: no permissionDecision, so the host's own permission flow still runs. */
|
|
194
|
+
export function noticeHookSpecificOutput(reason) {
|
|
195
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: reason } };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A hook event's tool name and input, under any of the spellings the hosts use. */
|
|
199
|
+
export function readToolEvent(event = {}) {
|
|
200
|
+
return {
|
|
201
|
+
toolName: event.tool_name ?? event.toolName ?? event.name ?? event.tool,
|
|
202
|
+
toolInput: event.tool_input ?? event.toolInput ?? event.input ?? {},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The whole PreToolUse guard: a deny envelope, a notice envelope, or {}. */
|
|
207
|
+
export function preToolUseOutput(event = {}, opts = {}) {
|
|
208
|
+
const { toolName, toolInput } = readToolEvent(event);
|
|
209
|
+
const denied = evaluateUriGuard(toolName, toolInput, opts);
|
|
210
|
+
if (denied) return denyHookSpecificOutput(denied.reason);
|
|
211
|
+
const notice = evaluateUriNotice(toolName, toolInput, opts);
|
|
212
|
+
return notice ? noticeHookSpecificOutput(notice.reason) : {};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Cursor's beforeReadFile deny envelope. */
|
|
216
|
+
export function denyCursorPermission(reason) {
|
|
217
|
+
return { permission: "deny", user_message: reason };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function readHookInput() {
|
|
221
|
+
try {
|
|
222
|
+
const raw = readFileSync(0, "utf8").trim();
|
|
223
|
+
return raw ? JSON.parse(raw) : {};
|
|
224
|
+
} catch {
|
|
225
|
+
return {};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Run a guard as a hook process: read the event off stdin, print the envelope.
|
|
231
|
+
*
|
|
232
|
+
* A guard is also imported by its harness tests, so the body only runs when the
|
|
233
|
+
* module is the process entrypoint. An empty envelope prints nothing — every
|
|
234
|
+
* host treats unrecognized or empty output as "no opinion", and one of them
|
|
235
|
+
* rejects any key it does not know.
|
|
236
|
+
*/
|
|
237
|
+
function isEntrypoint(moduleUrl) {
|
|
238
|
+
if (!process.argv[1]) return false;
|
|
239
|
+
try {
|
|
240
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(moduleUrl));
|
|
241
|
+
} catch {
|
|
242
|
+
return resolve(process.argv[1]) === fileURLToPath(moduleUrl);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function runUriGuardHook(moduleUrl, evaluate) {
|
|
247
|
+
if (!isEntrypoint(moduleUrl)) return;
|
|
248
|
+
const output = evaluate(readHookInput());
|
|
249
|
+
if (Object.keys(output).length > 0) process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
250
|
+
}
|