@openviking/opencode-plugin 2026.9.10 → 2026.9.21-2
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 +77 -1
- package/lib/shared/config-schema.mjs +359 -0
- package/lib/shared/credentials.mjs +238 -102
- 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/plugin-config.mjs +306 -0
- package/lib/shared/recall-core.mjs +190 -96
- 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
|
@@ -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
|
}
|
|
@@ -287,21 +309,46 @@ async function resolveTargetUri(fetchJSON, targetUri, actorPeerId = "") {
|
|
|
287
309
|
return `viking://user/${space}/${parts.join("/")}`;
|
|
288
310
|
}
|
|
289
311
|
|
|
290
|
-
async function searchOneSource(fetchJSON, query, source, limit,
|
|
291
|
-
const
|
|
292
|
-
const
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
312
|
+
async function searchOneSource(fetchJSON, cfg, query, source, limit, options) {
|
|
313
|
+
const actorPeerId = options.actorPeerId || "";
|
|
314
|
+
const home = await resolveTargetUri(fetchJSON, source.uri, actorPeerId);
|
|
315
|
+
const targets = [...new Set(cfg.user
|
|
316
|
+
? [`viking://user/${cfg.user}/${source.bucket}`, home] : [home])];
|
|
317
|
+
const sessionId = String(options.sessionId || "").trim();
|
|
318
|
+
const search = async (target, session) => {
|
|
319
|
+
const body = { query, target_uri: target, limit, score_threshold: 0 };
|
|
320
|
+
if (session) body.session_id = session;
|
|
321
|
+
const init = { method: "POST", body: JSON.stringify(body) };
|
|
322
|
+
// Session-aware retrieval may decide no context is needed. Only after all
|
|
323
|
+
// targets are empty do we retry without the session. Old servers still
|
|
324
|
+
// support find; an unsupported search route can fall back to it.
|
|
325
|
+
let res = await fetchJSON(sessionId ? "/api/v1/search/search" : "/api/v1/search/find", init, { actorPeerId });
|
|
326
|
+
if (sessionId && !res.ok && (
|
|
327
|
+
!res.status || res.status === 404 || res.status === 405 || res.status >= 500
|
|
328
|
+
|| ((res.status === 400 || res.status === 422) && looksLikeUnknownField(res))
|
|
329
|
+
)) {
|
|
330
|
+
const { session_id, ...findBody } = body;
|
|
331
|
+
res = await fetchJSON("/api/v1/search/find", { method: "POST", body: JSON.stringify(findBody) }, { actorPeerId });
|
|
332
|
+
}
|
|
333
|
+
const items = res.ok && Array.isArray(res.result?.[source.bucket]) ? res.result[source.bucket] : [];
|
|
334
|
+
return items.map((item) => ({ ...item, _sourceType: source.type }));
|
|
335
|
+
};
|
|
336
|
+
for (const target of targets) {
|
|
337
|
+
const items = await search(target, sessionId);
|
|
338
|
+
if (items.length) return items;
|
|
339
|
+
}
|
|
340
|
+
if (sessionId) {
|
|
341
|
+
for (const target of targets) {
|
|
342
|
+
const items = await search(target, "");
|
|
343
|
+
if (items.length) return items;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return [];
|
|
300
347
|
}
|
|
301
348
|
|
|
302
|
-
async function searchAllSources(fetchJSON, query, perSourceLimit,
|
|
349
|
+
async function searchAllSources(fetchJSON, cfg, query, perSourceLimit, options, log = () => {}) {
|
|
303
350
|
const results = await Promise.all(
|
|
304
|
-
SOURCES.map((src) => searchOneSource(fetchJSON, query, src, perSourceLimit,
|
|
351
|
+
SOURCES.map((src) => searchOneSource(fetchJSON, cfg, query, src, perSourceLimit, options)),
|
|
305
352
|
);
|
|
306
353
|
const all = results.flat();
|
|
307
354
|
log("recall_search_summary", {
|
|
@@ -337,51 +384,30 @@ async function resolveItemContent(fetchJSON, item, cfg, actorPeerId = "") {
|
|
|
337
384
|
return content;
|
|
338
385
|
}
|
|
339
386
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
let budgetRemaining = Math.max(200, Number(cfg.recallTokenBudget || 2000));
|
|
344
|
-
const lines = [
|
|
345
|
-
"<openviking-context>",
|
|
346
|
-
"Relevant context from OpenViking. Use the read MCP tool to expand URIs.",
|
|
347
|
-
];
|
|
387
|
+
function formatFallback(items, cfg, log = () => {}) {
|
|
388
|
+
const budgetTotal = Math.max(200, Number(cfg.recallTokenBudget || 2000));
|
|
389
|
+
const lines = [];
|
|
348
390
|
let contentCount = 0;
|
|
349
391
|
let hintCount = 0;
|
|
350
|
-
|
|
392
|
+
const maxChars = Math.max(50, Number(cfg.recallMaxContentChars || 500));
|
|
351
393
|
for (const item of items) {
|
|
352
394
|
const score = (clampScore(item.score) * 100).toFixed(0);
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
hintCount++;
|
|
363
|
-
} else {
|
|
364
|
-
lines.push(contentLine);
|
|
365
|
-
budgetRemaining -= lineTokens;
|
|
366
|
-
contentCount++;
|
|
367
|
-
}
|
|
368
|
-
} else {
|
|
395
|
+
const kind = item._sourceType || item.category || "memory";
|
|
396
|
+
const uriLine = `- [${kind} ${score}%] ${item.uri}`;
|
|
397
|
+
const text = String(item.text || item.uri);
|
|
398
|
+
const content = text.length > maxChars ? `${text.slice(0, maxChars)}...` : text;
|
|
399
|
+
const contentLine = `- [${kind} ${score}%] ${content}`;
|
|
400
|
+
if (estimateTokens(wrapContext([...lines, uriLine, contentLine].join("\n"))) <= budgetTotal) {
|
|
401
|
+
lines.push(uriLine, contentLine);
|
|
402
|
+
contentCount += 1;
|
|
403
|
+
} else if (estimateTokens(wrapContext([...lines, uriLine].join("\n"))) <= budgetTotal) {
|
|
369
404
|
lines.push(uriLine);
|
|
370
|
-
hintCount
|
|
405
|
+
hintCount += 1;
|
|
371
406
|
}
|
|
372
407
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const budgetUsed = Math.max(200, Number(cfg.recallTokenBudget || 2000)) - budgetRemaining;
|
|
377
|
-
log("recall_injection_built", {
|
|
378
|
-
contentItems: contentCount,
|
|
379
|
-
hintItems: hintCount,
|
|
380
|
-
budgetUsed,
|
|
381
|
-
budgetTotal: Math.max(200, Number(cfg.recallTokenBudget || 2000)),
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
return lines.join("\n");
|
|
408
|
+
const budgetUsed = lines.length ? estimateTokens(wrapContext(lines.join("\n"))) : 0;
|
|
409
|
+
log("recall_injection_built", { contentItems: contentCount, hintItems: hintCount, budgetUsed, budgetTotal });
|
|
410
|
+
return { rendered: lines.join("\n"), contentCount, hintCount, budgetUsed };
|
|
385
411
|
}
|
|
386
412
|
|
|
387
413
|
const LEGACY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
@@ -448,10 +474,14 @@ function looksLikeUnknownField(res) {
|
|
|
448
474
|
}
|
|
449
475
|
|
|
450
476
|
function wrapContext(body) {
|
|
477
|
+
// Retrieved text cannot introduce a second capture delimiter inside ours.
|
|
478
|
+
const text = String(body)
|
|
479
|
+
.replace(/<\/?relevant-memor(?:y|ies)\b[^>]*>/gi, "legacy memory wrapper")
|
|
480
|
+
.replace(/<\/?openviking-context\b[^>]*>/gi, "openviking context marker");
|
|
451
481
|
return [
|
|
452
482
|
"<openviking-context>",
|
|
453
483
|
"Relevant memory from OpenViking. Use the search/read MCP tools to expand URIs.",
|
|
454
|
-
|
|
484
|
+
text,
|
|
455
485
|
"</openviking-context>",
|
|
456
486
|
].join("\n");
|
|
457
487
|
}
|
|
@@ -467,7 +497,7 @@ export async function buildServerAssembledBlock(fetchJSON, cfg, query, options =
|
|
|
467
497
|
|
|
468
498
|
const block = await recallViaContextFace(fetchJSON, cfg, query, { ...options, actorPeerId }, log);
|
|
469
499
|
if (block !== null) return block;
|
|
470
|
-
return recallViaEndpoint(fetchJSON, cfg, query, actorPeerId, log);
|
|
500
|
+
return recallViaEndpoint(fetchJSON, cfg, query, { ...options, actorPeerId }, log);
|
|
471
501
|
}
|
|
472
502
|
|
|
473
503
|
/**
|
|
@@ -531,53 +561,65 @@ export function normalizeContextEntry(entry = {}) {
|
|
|
531
561
|
};
|
|
532
562
|
}
|
|
533
563
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
if (
|
|
564
|
+
/** Server relevance decisions and digest precedence are shared by every host. */
|
|
565
|
+
export function selectRecallContent(result = {}) {
|
|
566
|
+
if (String(result.stats?.rewrite || "").toLowerCase() === "no_relevant") return "";
|
|
567
|
+
return String(result.digest || "").trim() || String(result.rendered || "").trim();
|
|
568
|
+
}
|
|
537
569
|
|
|
538
|
-
|
|
539
|
-
let digest = assembled.digest;
|
|
570
|
+
function wantsLocalCompression(cfg, options) {
|
|
540
571
|
const mode = String(cfg.recallRewrite || "off").toLowerCase();
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}
|
|
572
|
+
return (mode === "client" || mode === "auto")
|
|
573
|
+
&& options.localCompressorAvailable !== false
|
|
574
|
+
&& typeof options.runCompressor === "function";
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Context, legacy recall, and raw retrieval share the same result policy.
|
|
578
|
+
// The host callback only runs its model; it never chooses a fallback or digest.
|
|
579
|
+
async function finalizeRecall(result, cfg, query, options, log, shortContext) {
|
|
580
|
+
const selected = selectRecallContent(result);
|
|
581
|
+
if (String(result.stats?.rewrite || "").toLowerCase() === "no_relevant") return "";
|
|
582
|
+
const entries = (result.entries || []).map(normalizeContextEntry).filter((entry) => entry.uri);
|
|
583
|
+
const rendered = String(result.rendered || "").trim();
|
|
584
|
+
if (String(result.digest || "").trim() || !wantsLocalCompression(cfg, options)) return selected;
|
|
585
|
+
const fallback = shortContext ?? (entries.length ? formatFallback(entries, cfg).rendered : rendered);
|
|
586
|
+
const input = rendered || entries.map((entry) => `${entry.uri}\n${entry.text}`).join("\n");
|
|
587
|
+
if (!input) return "";
|
|
588
|
+
try {
|
|
589
|
+
const compression = await compressRecallContext({
|
|
590
|
+
query, rendered: input, shortContext: shortContext ?? (rendered || fallback),
|
|
591
|
+
entries, cfg, runCompressor: options.runCompressor,
|
|
592
|
+
cachePath: options.digestCachePath || stateFile("recall-digest.json"), now: Date.now(),
|
|
593
|
+
});
|
|
594
|
+
log("recall_local_compression", { status: compression.status });
|
|
595
|
+
if (compression.status === "ok") return compression.context;
|
|
596
|
+
if (compression.status === "empty") return "";
|
|
597
|
+
} catch (err) {
|
|
598
|
+
log("recall_local_compression_failed", { error: String(err?.message || err) });
|
|
563
599
|
}
|
|
600
|
+
return fallback;
|
|
601
|
+
}
|
|
564
602
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
603
|
+
async function recallViaContextFace(fetchJSON, cfg, query, options, log) {
|
|
604
|
+
const assembled = await fetchAssembledContext(fetchJSON, cfg, query, { ...options, log });
|
|
605
|
+
if (assembled === null) return null;
|
|
606
|
+
const text = await finalizeRecall(assembled, cfg, query, options, log);
|
|
607
|
+
return text ? wrapContext(text) : "";
|
|
568
608
|
}
|
|
569
609
|
|
|
570
|
-
async function recallViaEndpoint(fetchJSON, cfg, query,
|
|
610
|
+
async function recallViaEndpoint(fetchJSON, cfg, query, options, log) {
|
|
571
611
|
const body = buildRecallEndpointBody(cfg);
|
|
572
612
|
body.query = query;
|
|
573
|
-
|
|
613
|
+
if (wantsLocalCompression(cfg, options)) {
|
|
614
|
+
body.max_chars = Math.max(1000, Number(cfg.recallCompressMaxInputChars || 18000));
|
|
615
|
+
}
|
|
616
|
+
const res = await postRecall(fetchJSON, body, { actorPeerId: options.actorPeerId, log });
|
|
574
617
|
if (!res.ok) {
|
|
575
618
|
log("recall_endpoint_fallback", { status: res.status || 0 });
|
|
576
619
|
return null;
|
|
577
620
|
}
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
return wrapContext(rendered);
|
|
621
|
+
const text = await finalizeRecall(res.result || {}, cfg, query, options, log);
|
|
622
|
+
return text ? wrapContext(text) : "";
|
|
581
623
|
}
|
|
582
624
|
|
|
583
625
|
export async function postRecall(fetchJSON, body, opts = {}) {
|
|
@@ -626,8 +668,13 @@ export async function postRecall(fetchJSON, body, opts = {}) {
|
|
|
626
668
|
* `"actor"` that sweep is off by definition, so the old peer is asked for
|
|
627
669
|
* separately — as itself, which is both cheaper and wider than a bare
|
|
628
670
|
* cross-peer read (that would need the user id, and reaches memories only).
|
|
671
|
+
*
|
|
672
|
+
* `stage` names the path the block came from, which is what a host needs to
|
|
673
|
+
* tell "the server had nothing" (`no_results`) from "the score threshold
|
|
674
|
+
* dropped everything" (`filtered_out`) when the block is empty, and a
|
|
675
|
+
* `server_assembled` block from a `ranked` one when it is not.
|
|
629
676
|
*/
|
|
630
|
-
export async function
|
|
677
|
+
export async function buildRecallBlockDetailed(fetchJSON, cfg, query, options = {}) {
|
|
631
678
|
const primary = await recallForPeer(fetchJSON, cfg, query, options);
|
|
632
679
|
|
|
633
680
|
const legacyPeerId = String(options.legacyPeerId || "").trim();
|
|
@@ -636,16 +683,32 @@ export async function buildRecallBlock(fetchJSON, cfg, query, options = {}) {
|
|
|
636
683
|
|
|
637
684
|
const log = options.log || (() => {});
|
|
638
685
|
const legacy = await recallForPeer(fetchJSON, cfg, query, { ...options, actorPeerId: legacyPeerId });
|
|
639
|
-
if (!legacy) return primary;
|
|
686
|
+
if (!legacy.block) return primary;
|
|
640
687
|
log("recall_legacy_peer_hit", { legacyPeerId });
|
|
641
|
-
return
|
|
688
|
+
return {
|
|
689
|
+
block: primary.block ? `${primary.block}\n${legacy.block}` : legacy.block,
|
|
690
|
+
contentCount: primary.contentCount + legacy.contentCount,
|
|
691
|
+
hintCount: primary.hintCount + legacy.hintCount,
|
|
692
|
+
budgetUsed: primary.budgetUsed + legacy.budgetUsed,
|
|
693
|
+
stage: primary.block ? primary.stage : legacy.stage,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** The block alone, or null when nothing was injectable. */
|
|
698
|
+
export async function buildRecallBlock(fetchJSON, cfg, query, options = {}) {
|
|
699
|
+
const { block } = await buildRecallBlockDetailed(fetchJSON, cfg, query, options);
|
|
700
|
+
return block || null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function emptyRecall(stage) {
|
|
704
|
+
return { block: "", contentCount: 0, hintCount: 0, budgetUsed: 0, stage };
|
|
642
705
|
}
|
|
643
706
|
|
|
644
707
|
async function recallForPeer(fetchJSON, cfg, query, options = {}) {
|
|
645
708
|
const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
|
|
646
709
|
const log = options.log || (() => {});
|
|
647
710
|
const trimmed = String(query || "").trim();
|
|
648
|
-
if (!trimmed) return
|
|
711
|
+
if (!trimmed) return emptyRecall("no_results");
|
|
649
712
|
|
|
650
713
|
// Assembly happens server-side when the deployment offers the context face;
|
|
651
714
|
// older servers fall through to /recall, then to raw find.
|
|
@@ -654,12 +717,23 @@ async function recallForPeer(fetchJSON, cfg, query, options = {}) {
|
|
|
654
717
|
actorPeerId,
|
|
655
718
|
log,
|
|
656
719
|
});
|
|
657
|
-
if (serverBlock !== null)
|
|
720
|
+
if (serverBlock !== null) {
|
|
721
|
+
if (!serverBlock) return emptyRecall("no_results");
|
|
722
|
+
// The server rendered one budgeted unit, so nothing here degraded to a
|
|
723
|
+
// URI-only hint and the block's own estimate is what it cost.
|
|
724
|
+
return {
|
|
725
|
+
block: serverBlock,
|
|
726
|
+
contentCount: 1,
|
|
727
|
+
hintCount: 0,
|
|
728
|
+
budgetUsed: estimateTokens(serverBlock),
|
|
729
|
+
stage: "server_assembled",
|
|
730
|
+
};
|
|
731
|
+
}
|
|
658
732
|
|
|
659
733
|
const recallLimit = Math.max(1, Number(cfg.recallLimit || DEFAULT_CONTEXT_LIMIT));
|
|
660
734
|
const perSourceLimit = Math.max(recallLimit * 2, 8);
|
|
661
|
-
const raw = await searchAllSources(fetchJSON, trimmed, perSourceLimit, actorPeerId, log);
|
|
662
|
-
if (raw.length === 0) return
|
|
735
|
+
const raw = await searchAllSources(fetchJSON, cfg, trimmed, perSourceLimit, { ...options, actorPeerId }, log);
|
|
736
|
+
if (raw.length === 0) return emptyRecall("no_results");
|
|
663
737
|
|
|
664
738
|
const profile = buildQueryProfile(trimmed);
|
|
665
739
|
const scoreThreshold = Number.isFinite(Number(cfg.scoreThreshold)) ? Number(cfg.scoreThreshold) : 0.35;
|
|
@@ -673,6 +747,26 @@ async function recallForPeer(fetchJSON, cfg, query, options = {}) {
|
|
|
673
747
|
items: picked.map((it) => ({ type: it._sourceType, uri: it.uri, score: clampScore(it.score) })),
|
|
674
748
|
});
|
|
675
749
|
|
|
676
|
-
if (picked.length === 0) return
|
|
677
|
-
|
|
750
|
+
if (picked.length === 0) return emptyRecall("filtered_out");
|
|
751
|
+
const local = wantsLocalCompression(cfg, options);
|
|
752
|
+
const inputLimit = Math.max(1000, Number(cfg.recallCompressMaxInputChars || 18000));
|
|
753
|
+
const contentCfg = local ? { ...cfg, recallPreferAbstract: false, recallMaxContentChars: inputLimit } : cfg;
|
|
754
|
+
const entries = await Promise.all(picked.map(async (item) => ({
|
|
755
|
+
...item, text: await resolveItemContent(fetchJSON, item, contentCfg, actorPeerId),
|
|
756
|
+
})));
|
|
757
|
+
const fallback = formatFallback(entries, cfg, log);
|
|
758
|
+
let text = await finalizeRecall({
|
|
759
|
+
rendered: local ? entries.map((item) => `${item.uri}\n${item.text}`).join("\n").slice(0, inputLimit) : fallback.rendered,
|
|
760
|
+
entries,
|
|
761
|
+
}, cfg, trimmed, options, log, fallback.rendered);
|
|
762
|
+
if (estimateTokens(wrapContext(text)) > Math.max(200, Number(cfg.recallTokenBudget || 2000))) {
|
|
763
|
+
text = fallback.rendered;
|
|
764
|
+
}
|
|
765
|
+
if (!text) return emptyRecall("no_results");
|
|
766
|
+
const unchanged = text === fallback.rendered;
|
|
767
|
+
return {
|
|
768
|
+
block: wrapContext(text), contentCount: unchanged ? fallback.contentCount : 1,
|
|
769
|
+
hintCount: unchanged ? fallback.hintCount : 0,
|
|
770
|
+
budgetUsed: estimateTokens(wrapContext(text)), stage: "ranked",
|
|
771
|
+
};
|
|
678
772
|
}
|
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
|
+
}
|