@openviking/openclaw-plugin 2026.9.13 → 2026.9.15-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.
@@ -27,6 +27,28 @@ const CODING_QUOTA_WEIGHTS = {
27
27
  skills: 2,
28
28
  };
29
29
  let userSpaceCache = "";
30
+ /**
31
+ * Is recall on for this config?
32
+ *
33
+ * The switch has four spellings in the wild: a boolean `autoRecall`, opencode's
34
+ * `{ enabled }` object, dsh and pi's `syncTurns`, and the global `enabled`
35
+ * that turns the whole plugin off. Reading it here rather than in each loader
36
+ * is what keeps it from drifting a fifth time — and any spelling that says off
37
+ * wins, so a config that disables recall under an older name still disables it.
38
+ */
39
+ export function isRecallEnabled(cfg = {}) {
40
+ return isSwitchOn([cfg.enabled, cfg.autoRecall, cfg.recall, cfg.syncRecall]);
41
+ }
42
+ /** Any recognised spelling that says off wins; everything else means on. */
43
+ function isSwitchOn(values) {
44
+ for (const value of values) {
45
+ if (value === false)
46
+ return false;
47
+ if (value && typeof value === "object" && !Array.isArray(value) && value.enabled === false)
48
+ return false;
49
+ }
50
+ return true;
51
+ }
30
52
  export function estimateTokens(text) {
31
53
  return text ? Math.ceil(String(text).length / 4) : 0;
32
54
  }
@@ -323,9 +345,8 @@ async function resolveItemContent(fetchJSON, item, cfg, actorPeerId = "") {
323
345
  return content;
324
346
  }
325
347
  async function buildFallbackInjectionBlock(fetchJSON, items, cfg, actorPeerId = "", log = () => { }) {
326
- if (items.length === 0)
327
- return null;
328
- let budgetRemaining = Math.max(200, Number(cfg.recallTokenBudget || 2000));
348
+ const budgetTotal = Math.max(200, Number(cfg.recallTokenBudget || 2000));
349
+ let budgetRemaining = budgetTotal;
329
350
  const lines = [
330
351
  "<openviking-context>",
331
352
  "Relevant context from OpenViking. Use the read MCP tool to expand URIs.",
@@ -355,14 +376,14 @@ async function buildFallbackInjectionBlock(fetchJSON, items, cfg, actorPeerId =
355
376
  }
356
377
  }
357
378
  lines.push("</openviking-context>");
358
- const budgetUsed = Math.max(200, Number(cfg.recallTokenBudget || 2000)) - budgetRemaining;
379
+ const budgetUsed = budgetTotal - budgetRemaining;
359
380
  log("recall_injection_built", {
360
381
  contentItems: contentCount,
361
382
  hintItems: hintCount,
362
383
  budgetUsed,
363
- budgetTotal: Math.max(200, Number(cfg.recallTokenBudget || 2000)),
384
+ budgetTotal,
364
385
  });
365
- return lines.join("\n");
386
+ return { block: lines.join("\n"), contentCount, hintCount, budgetUsed, stage: "ranked" };
366
387
  }
367
388
  const LEGACY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
368
389
  function stateFile(name) {
@@ -594,8 +615,13 @@ export async function postRecall(fetchJSON, body, opts = {}) {
594
615
  * `"actor"` that sweep is off by definition, so the old peer is asked for
595
616
  * separately — as itself, which is both cheaper and wider than a bare
596
617
  * cross-peer read (that would need the user id, and reaches memories only).
618
+ *
619
+ * `stage` names the path the block came from, which is what a host needs to
620
+ * tell "the server had nothing" (`no_results`) from "the score threshold
621
+ * dropped everything" (`filtered_out`) when the block is empty, and a
622
+ * `server_assembled` block from a `ranked` one when it is not.
597
623
  */
598
- export async function buildRecallBlock(fetchJSON, cfg, query, options = {}) {
624
+ export async function buildRecallBlockDetailed(fetchJSON, cfg, query, options = {}) {
599
625
  const primary = await recallForPeer(fetchJSON, cfg, query, options);
600
626
  const legacyPeerId = String(options.legacyPeerId || "").trim();
601
627
  const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
@@ -603,17 +629,31 @@ export async function buildRecallBlock(fetchJSON, cfg, query, options = {}) {
603
629
  return primary;
604
630
  const log = options.log || (() => { });
605
631
  const legacy = await recallForPeer(fetchJSON, cfg, query, { ...options, actorPeerId: legacyPeerId });
606
- if (!legacy)
632
+ if (!legacy.block)
607
633
  return primary;
608
634
  log("recall_legacy_peer_hit", { legacyPeerId });
609
- return primary ? `${primary}\n${legacy}` : legacy;
635
+ return {
636
+ block: primary.block ? `${primary.block}\n${legacy.block}` : legacy.block,
637
+ contentCount: primary.contentCount + legacy.contentCount,
638
+ hintCount: primary.hintCount + legacy.hintCount,
639
+ budgetUsed: primary.budgetUsed + legacy.budgetUsed,
640
+ stage: primary.block ? primary.stage : legacy.stage,
641
+ };
642
+ }
643
+ /** The block alone, or null when nothing was injectable. */
644
+ export async function buildRecallBlock(fetchJSON, cfg, query, options = {}) {
645
+ const { block } = await buildRecallBlockDetailed(fetchJSON, cfg, query, options);
646
+ return block || null;
647
+ }
648
+ function emptyRecall(stage) {
649
+ return { block: "", contentCount: 0, hintCount: 0, budgetUsed: 0, stage };
610
650
  }
611
651
  async function recallForPeer(fetchJSON, cfg, query, options = {}) {
612
652
  const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
613
653
  const log = options.log || (() => { });
614
654
  const trimmed = String(query || "").trim();
615
655
  if (!trimmed)
616
- return null;
656
+ return emptyRecall("no_results");
617
657
  // Assembly happens server-side when the deployment offers the context face;
618
658
  // older servers fall through to /recall, then to raw find.
619
659
  const serverBlock = await buildServerAssembledBlock(fetchJSON, cfg, trimmed, {
@@ -621,13 +661,24 @@ async function recallForPeer(fetchJSON, cfg, query, options = {}) {
621
661
  actorPeerId,
622
662
  log,
623
663
  });
624
- if (serverBlock !== null)
625
- return serverBlock || null;
664
+ if (serverBlock !== null) {
665
+ if (!serverBlock)
666
+ return emptyRecall("no_results");
667
+ // The server rendered one budgeted unit, so nothing here degraded to a
668
+ // URI-only hint and the block's own estimate is what it cost.
669
+ return {
670
+ block: serverBlock,
671
+ contentCount: 1,
672
+ hintCount: 0,
673
+ budgetUsed: estimateTokens(serverBlock),
674
+ stage: "server_assembled",
675
+ };
676
+ }
626
677
  const recallLimit = Math.max(1, Number(cfg.recallLimit || DEFAULT_CONTEXT_LIMIT));
627
678
  const perSourceLimit = Math.max(recallLimit * 2, 8);
628
679
  const raw = await searchAllSources(fetchJSON, trimmed, perSourceLimit, actorPeerId, log);
629
680
  if (raw.length === 0)
630
- return null;
681
+ return emptyRecall("no_results");
631
682
  const profile = buildQueryProfile(trimmed);
632
683
  const scoreThreshold = Number.isFinite(Number(cfg.scoreThreshold)) ? Number(cfg.scoreThreshold) : 0.35;
633
684
  const filtered = raw.filter((it) => clampScore(it.score) >= scoreThreshold);
@@ -640,6 +691,6 @@ async function recallForPeer(fetchJSON, cfg, query, options = {}) {
640
691
  items: picked.map((it) => ({ type: it._sourceType, uri: it.uri, score: clampScore(it.score) })),
641
692
  });
642
693
  if (picked.length === 0)
643
- return null;
694
+ return emptyRecall("filtered_out");
644
695
  return buildFallbackInjectionBlock(fetchJSON, picked, cfg, actorPeerId, log);
645
696
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openviking/openclaw-plugin",
3
- "version": "2026.9.13",
3
+ "version": "2026.9.15-1",
4
4
  "displayName": "OpenViking",
5
5
  "description": "OpenClaw context-engine plugin — OpenViking AI memory, long-term memory, knowledge base, semantic search, RAG, context engine. 长期记忆 · 知识库 · 语义检索 · 上下文引擎",
6
6
  "type": "module",
@@ -42,7 +42,7 @@
42
42
  ],
43
43
  "scripts": {
44
44
  "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json",
45
- "prepack": "npm run build",
45
+ "prepack": "node ../memory-plugin-shared/sync.mjs && npm run build",
46
46
  "test": "vitest run",
47
47
  "typecheck": "tsc -p tsconfig.json",
48
48
  "test:watch": "vitest"
@@ -1,3 +1,6 @@
1
+ // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ import type { OvHttpRequestOptions } from "./ov-http.mjs";
3
+
1
4
  export type ContextSearchBody = {
2
5
  query: string;
3
6
  mode: "context";
@@ -37,3 +40,42 @@ export function contextRequestTimeoutMs(
37
40
  ): number | undefined;
38
41
 
39
42
  export function normalizeContextEntry(entry?: unknown): NormalizedContextEntry;
43
+
44
+ export type RecallFetchJSON = (
45
+ path: string,
46
+ init?: RequestInit,
47
+ options?: OvHttpRequestOptions,
48
+ ) => Promise<{ ok: boolean; status?: number; result?: any; error?: any }>;
49
+
50
+ export type RecallOptions = {
51
+ actorPeerId?: string;
52
+ legacyPeerId?: string;
53
+ sessionId?: string;
54
+ log?: (stage: string, data?: any) => void;
55
+ };
56
+
57
+ export type DetailedRecall = {
58
+ block: string;
59
+ contentCount: number;
60
+ hintCount: number;
61
+ budgetUsed: number;
62
+ stage: "server_assembled" | "ranked" | "no_results" | "filtered_out";
63
+ };
64
+
65
+ export function buildRecallBlockDetailed(
66
+ fetchJSON: RecallFetchJSON,
67
+ cfg: Record<string, any>,
68
+ query: string,
69
+ options?: RecallOptions,
70
+ ): Promise<DetailedRecall>;
71
+
72
+ export function buildRecallBlock(
73
+ fetchJSON: RecallFetchJSON,
74
+ cfg: Record<string, any>,
75
+ query: string,
76
+ options?: RecallOptions,
77
+ ): Promise<string | null>;
78
+
79
+ export function buildRecallEndpointBody(cfg?: Record<string, any>): Record<string, any>;
80
+ export function estimateTokens(text: string): number;
81
+ export function isRecallEnabled(cfg?: Record<string, any>): boolean;
@@ -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
- if (items.length === 0) return null;
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 = Math.max(200, Number(cfg.recallTokenBudget || 2000)) - budgetRemaining;
397
+ const budgetUsed = budgetTotal - budgetRemaining;
377
398
  log("recall_injection_built", {
378
399
  contentItems: contentCount,
379
400
  hintItems: hintCount,
380
401
  budgetUsed,
381
- budgetTotal: Math.max(200, Number(cfg.recallTokenBudget || 2000)),
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 buildRecallBlock(fetchJSON, cfg, query, options = {}) {
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 primary ? `${primary}\n${legacy}` : legacy;
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 null;
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) return 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 null;
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 null;
729
+ if (picked.length === 0) return emptyRecall("filtered_out");
677
730
  return buildFallbackInjectionBlock(fetchJSON, picked, cfg, actorPeerId, log);
678
731
  }