@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.
@@ -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, actorPeerId = "") {
291
- const resolvedUri = await resolveTargetUri(fetchJSON, source.uri, actorPeerId);
292
- const body = { query, target_uri: resolvedUri, limit, score_threshold: 0 };
293
- const res = await fetchJSON("/api/v1/search/find", {
294
- method: "POST",
295
- body: JSON.stringify(body),
296
- }, { actorPeerId });
297
- if (!res.ok) return [];
298
- const items = res.result?.[source.bucket] || [];
299
- return items.map((item) => ({ ...item, _sourceType: source.type }));
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, actorPeerId = "", log = () => {}) {
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, actorPeerId)),
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
- async function buildFallbackInjectionBlock(fetchJSON, items, cfg, actorPeerId = "", log = () => {}) {
341
- if (items.length === 0) return null;
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 uriLine = `- [${item._sourceType} ${score}%] ${item.uri}`;
354
-
355
- if (budgetRemaining > 0) {
356
- const content = await resolveItemContent(fetchJSON, item, cfg, actorPeerId);
357
- const contentLine = `- [${item._sourceType} ${score}%] ${content}`;
358
- const lineTokens = estimateTokens(contentLine);
359
-
360
- if (lineTokens > budgetRemaining && contentCount > 0) {
361
- lines.push(uriLine);
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
- lines.push("</openviking-context>");
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
- body,
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
- async function recallViaContextFace(fetchJSON, cfg, query, options, log) {
535
- const assembled = await fetchAssembledContext(fetchJSON, cfg, query, { ...options, log });
536
- if (assembled === null) return null;
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
- const { rendered, entries } = assembled;
539
- let digest = assembled.digest;
570
+ function wantsLocalCompression(cfg, options) {
540
571
  const mode = String(cfg.recallRewrite || "off").toLowerCase();
541
- if (String(assembled.stats?.rewrite || "").toLowerCase() === "no_relevant") {
542
- log("recall_server_compression", { status: "empty" });
543
- return "";
544
- }
545
- const wantsLocal = mode === "client" || (mode === "auto" && !digest);
546
- if (wantsLocal && rendered && typeof options.runCompressor === "function") {
547
- try {
548
- const compression = await compressRecallContext({
549
- query,
550
- rendered,
551
- entries,
552
- cfg,
553
- runCompressor: options.runCompressor,
554
- cachePath: options.digestCachePath || stateFile("recall-digest.json"),
555
- now: Date.now(),
556
- });
557
- log("recall_local_compression", { status: compression.status });
558
- if (compression.status === "ok") digest = compression.context;
559
- if (compression.status === "empty") return "";
560
- } catch (err) {
561
- log("recall_local_compression_failed", { error: String(err?.message || err) });
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
- const injected = digest || rendered;
566
- if (!injected) return "";
567
- return wrapContext(injected);
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, actorPeerId = "", log = () => {}) {
610
+ async function recallViaEndpoint(fetchJSON, cfg, query, options, log) {
571
611
  const body = buildRecallEndpointBody(cfg);
572
612
  body.query = query;
573
- const res = await postRecall(fetchJSON, body, { actorPeerId, log });
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 rendered = String(res.result?.rendered || "").trim();
579
- if (!rendered) return "";
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 buildRecallBlock(fetchJSON, cfg, query, options = {}) {
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 primary ? `${primary}\n${legacy}` : legacy;
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 null;
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) return 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 null;
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 null;
677
- return buildFallbackInjectionBlock(fetchJSON, picked, cfg, actorPeerId, log);
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
  }
@@ -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
+ }