@openviking/opencode-plugin 2026.9.21-1 → 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.
@@ -98,7 +98,7 @@ export const KNOBS = [
98
98
  // off/client/server/auto through `normalizeRewriteMode`; Codex reads the same
99
99
  // key as on/off. Both spellings stay declared so neither harness's users are
100
100
  // told their config is a typo.
101
- { name: "recallCompress", type: "string", default: "auto", env: "OPENVIKING_RECALL_COMPRESS", aliases: ["recallRewrite"], capability: "recall" },
101
+ { name: "recallCompress", type: "string", default: "off", harness: { claude_code: "auto", codex: "auto" }, env: "OPENVIKING_RECALL_COMPRESS", aliases: ["recallRewrite"], capability: "recall" },
102
102
  { name: "recallCompressModel", type: "string", default: "", env: "OPENVIKING_RECALL_COMPRESS_MODEL", capability: "recall" },
103
103
  { name: "recallCompressBaseUrl", type: "string", default: "", env: "OPENVIKING_RECALL_COMPRESS_BASE_URL", capability: "recall" },
104
104
  { name: "recallCompressThinking", type: "string", default: "", env: "OPENVIKING_RECALL_COMPRESS_THINKING", aliases: ["recallCompressReasoningEffort"], capability: "recall" },
@@ -29,7 +29,7 @@
29
29
  import { homedir } from "node:os";
30
30
  import { join } from "node:path";
31
31
 
32
- import { HARNESS_KEYS, KNOBS, harnessKey, resolveKnobs } from "./config-schema.mjs";
32
+ import { HARNESS_KEYS, KNOBS, harnessKey, knobDefault, resolveKnobs } from "./config-schema.mjs";
33
33
  import {
34
34
  buildUserAgent,
35
35
  loadCredentialFiles,
@@ -224,9 +224,15 @@ export function buildPluginConfig(harness, {
224
224
  env,
225
225
  });
226
226
 
227
+ const recallRewrite = normalizeRewriteMode(
228
+ env.OPENVIKING_RECALL_COMPRESS ?? env.OPENVIKING_RECALL_REWRITE ?? settings.recallCompress,
229
+ knobDefault(KNOBS.find((knob) => knob.name === "recallCompress"), harnessKey(name)),
230
+ );
227
231
  const config = {
228
232
  ...settings,
229
233
  harness: name,
234
+ recallCompress: recallRewrite,
235
+ recallRewrite,
230
236
  clientVersion,
231
237
  userAgent: buildUserAgent(name, clientVersion),
232
238
 
@@ -309,21 +309,46 @@ async function resolveTargetUri(fetchJSON, targetUri, actorPeerId = "") {
309
309
  return `viking://user/${space}/${parts.join("/")}`;
310
310
  }
311
311
 
312
- async function searchOneSource(fetchJSON, query, source, limit, actorPeerId = "") {
313
- const resolvedUri = await resolveTargetUri(fetchJSON, source.uri, actorPeerId);
314
- const body = { query, target_uri: resolvedUri, limit, score_threshold: 0 };
315
- const res = await fetchJSON("/api/v1/search/find", {
316
- method: "POST",
317
- body: JSON.stringify(body),
318
- }, { actorPeerId });
319
- if (!res.ok) return [];
320
- const items = res.result?.[source.bucket] || [];
321
- 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 [];
322
347
  }
323
348
 
324
- async function searchAllSources(fetchJSON, query, perSourceLimit, actorPeerId = "", log = () => {}) {
349
+ async function searchAllSources(fetchJSON, cfg, query, perSourceLimit, options, log = () => {}) {
325
350
  const results = await Promise.all(
326
- SOURCES.map((src) => searchOneSource(fetchJSON, query, src, perSourceLimit, actorPeerId)),
351
+ SOURCES.map((src) => searchOneSource(fetchJSON, cfg, query, src, perSourceLimit, options)),
327
352
  );
328
353
  const all = results.flat();
329
354
  log("recall_search_summary", {
@@ -359,50 +384,30 @@ async function resolveItemContent(fetchJSON, item, cfg, actorPeerId = "") {
359
384
  return content;
360
385
  }
361
386
 
362
- async function buildFallbackInjectionBlock(fetchJSON, items, cfg, actorPeerId = "", log = () => {}) {
387
+ function formatFallback(items, cfg, log = () => {}) {
363
388
  const budgetTotal = Math.max(200, Number(cfg.recallTokenBudget || 2000));
364
- let budgetRemaining = budgetTotal;
365
- const lines = [
366
- "<openviking-context>",
367
- "Relevant context from OpenViking. Use the read MCP tool to expand URIs.",
368
- ];
389
+ const lines = [];
369
390
  let contentCount = 0;
370
391
  let hintCount = 0;
371
-
392
+ const maxChars = Math.max(50, Number(cfg.recallMaxContentChars || 500));
372
393
  for (const item of items) {
373
394
  const score = (clampScore(item.score) * 100).toFixed(0);
374
- const uriLine = `- [${item._sourceType} ${score}%] ${item.uri}`;
375
-
376
- if (budgetRemaining > 0) {
377
- const content = await resolveItemContent(fetchJSON, item, cfg, actorPeerId);
378
- const contentLine = `- [${item._sourceType} ${score}%] ${content}`;
379
- const lineTokens = estimateTokens(contentLine);
380
-
381
- if (lineTokens > budgetRemaining && contentCount > 0) {
382
- lines.push(uriLine);
383
- hintCount++;
384
- } else {
385
- lines.push(contentLine);
386
- budgetRemaining -= lineTokens;
387
- contentCount++;
388
- }
389
- } 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) {
390
404
  lines.push(uriLine);
391
- hintCount++;
405
+ hintCount += 1;
392
406
  }
393
407
  }
394
-
395
- lines.push("</openviking-context>");
396
-
397
- const budgetUsed = budgetTotal - budgetRemaining;
398
- log("recall_injection_built", {
399
- contentItems: contentCount,
400
- hintItems: hintCount,
401
- budgetUsed,
402
- budgetTotal,
403
- });
404
-
405
- return { block: lines.join("\n"), contentCount, hintCount, budgetUsed, stage: "ranked" };
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 };
406
411
  }
407
412
 
408
413
  const LEGACY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
@@ -469,10 +474,14 @@ function looksLikeUnknownField(res) {
469
474
  }
470
475
 
471
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");
472
481
  return [
473
482
  "<openviking-context>",
474
483
  "Relevant memory from OpenViking. Use the search/read MCP tools to expand URIs.",
475
- body,
484
+ text,
476
485
  "</openviking-context>",
477
486
  ].join("\n");
478
487
  }
@@ -488,7 +497,7 @@ export async function buildServerAssembledBlock(fetchJSON, cfg, query, options =
488
497
 
489
498
  const block = await recallViaContextFace(fetchJSON, cfg, query, { ...options, actorPeerId }, log);
490
499
  if (block !== null) return block;
491
- return recallViaEndpoint(fetchJSON, cfg, query, actorPeerId, log);
500
+ return recallViaEndpoint(fetchJSON, cfg, query, { ...options, actorPeerId }, log);
492
501
  }
493
502
 
494
503
  /**
@@ -552,53 +561,65 @@ export function normalizeContextEntry(entry = {}) {
552
561
  };
553
562
  }
554
563
 
555
- async function recallViaContextFace(fetchJSON, cfg, query, options, log) {
556
- const assembled = await fetchAssembledContext(fetchJSON, cfg, query, { ...options, log });
557
- 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
+ }
558
569
 
559
- const { rendered, entries } = assembled;
560
- let digest = assembled.digest;
570
+ function wantsLocalCompression(cfg, options) {
561
571
  const mode = String(cfg.recallRewrite || "off").toLowerCase();
562
- if (String(assembled.stats?.rewrite || "").toLowerCase() === "no_relevant") {
563
- log("recall_server_compression", { status: "empty" });
564
- return "";
565
- }
566
- const wantsLocal = mode === "client" || (mode === "auto" && !digest);
567
- if (wantsLocal && rendered && typeof options.runCompressor === "function") {
568
- try {
569
- const compression = await compressRecallContext({
570
- query,
571
- rendered,
572
- entries,
573
- cfg,
574
- runCompressor: options.runCompressor,
575
- cachePath: options.digestCachePath || stateFile("recall-digest.json"),
576
- now: Date.now(),
577
- });
578
- log("recall_local_compression", { status: compression.status });
579
- if (compression.status === "ok") digest = compression.context;
580
- if (compression.status === "empty") return "";
581
- } catch (err) {
582
- log("recall_local_compression_failed", { error: String(err?.message || err) });
583
- }
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) });
584
599
  }
600
+ return fallback;
601
+ }
585
602
 
586
- const injected = digest || rendered;
587
- if (!injected) return "";
588
- 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) : "";
589
608
  }
590
609
 
591
- async function recallViaEndpoint(fetchJSON, cfg, query, actorPeerId = "", log = () => {}) {
610
+ async function recallViaEndpoint(fetchJSON, cfg, query, options, log) {
592
611
  const body = buildRecallEndpointBody(cfg);
593
612
  body.query = query;
594
- 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 });
595
617
  if (!res.ok) {
596
618
  log("recall_endpoint_fallback", { status: res.status || 0 });
597
619
  return null;
598
620
  }
599
- const rendered = String(res.result?.rendered || "").trim();
600
- if (!rendered) return "";
601
- return wrapContext(rendered);
621
+ const text = await finalizeRecall(res.result || {}, cfg, query, options, log);
622
+ return text ? wrapContext(text) : "";
602
623
  }
603
624
 
604
625
  export async function postRecall(fetchJSON, body, opts = {}) {
@@ -711,7 +732,7 @@ async function recallForPeer(fetchJSON, cfg, query, options = {}) {
711
732
 
712
733
  const recallLimit = Math.max(1, Number(cfg.recallLimit || DEFAULT_CONTEXT_LIMIT));
713
734
  const perSourceLimit = Math.max(recallLimit * 2, 8);
714
- const raw = await searchAllSources(fetchJSON, trimmed, perSourceLimit, actorPeerId, log);
735
+ const raw = await searchAllSources(fetchJSON, cfg, trimmed, perSourceLimit, { ...options, actorPeerId }, log);
715
736
  if (raw.length === 0) return emptyRecall("no_results");
716
737
 
717
738
  const profile = buildQueryProfile(trimmed);
@@ -727,5 +748,25 @@ async function recallForPeer(fetchJSON, cfg, query, options = {}) {
727
748
  });
728
749
 
729
750
  if (picked.length === 0) return emptyRecall("filtered_out");
730
- return buildFallbackInjectionBlock(fetchJSON, picked, cfg, actorPeerId, log);
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
+ };
731
772
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openviking/opencode-plugin",
3
- "version": "2026.9.21-1",
3
+ "version": "2026.9.21-2",
4
4
  "author": "@openviking",
5
5
  "description": "Unified OpenCode plugin for OpenViking repository retrieval and long-term memory",
6
6
  "type": "module",
@@ -45,5 +45,5 @@
45
45
  "code-search"
46
46
  ],
47
47
  "license": "Apache-2.0",
48
- "openvikingSourceCommit": "9b0ce3dea5d8874fc373dbf40d8314945dc10234"
48
+ "openvikingSourceCommit": "e44ea6e11add1c7b3d4accdbfaf16e900a6049df"
49
49
  }