@openviking/openclaw-plugin 2026.9.2 → 2026.9.4

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.
@@ -397,6 +397,29 @@ export async function isContextFaceLegacy(path = stateFile("context-face.json"),
397
397
  export async function markContextFaceLegacy(path = stateFile("context-face.json"), now = Date.now()) {
398
398
  await writeJsonFile(path, { legacyUntil: now + LEGACY_CACHE_TTL_MS });
399
399
  }
400
+ /**
401
+ * A `peer_scope` the server rejects is remembered the same way, but unlike the
402
+ * context face this one is not a silent capability probe: dropping the field
403
+ * widens recall from the caller's own peer to the whole user root, so doctor
404
+ * reads this file back and warns.
405
+ */
406
+ export function peerScopeMemoPath() {
407
+ return stateFile("peer-scope.json");
408
+ }
409
+ export async function readPeerScopeDowngrade(path = peerScopeMemoPath(), now = Date.now()) {
410
+ const cached = await readJsonFile(path);
411
+ if (!cached?.legacyUntil || Number(cached.legacyUntil) <= now)
412
+ return null;
413
+ return cached;
414
+ }
415
+ export async function markPeerScopeDowngrade(scope, status, path = peerScopeMemoPath(), now = Date.now()) {
416
+ await writeJsonFile(path, {
417
+ legacyUntil: now + LEGACY_CACHE_TTL_MS,
418
+ scope: String(scope || ""),
419
+ status: Number(status) || 0,
420
+ at: now,
421
+ });
422
+ }
400
423
  function looksLikeUnknownField(res) {
401
424
  const text = JSON.stringify(res?.error ?? res?.result ?? res?.detail ?? "").toLowerCase();
402
425
  return text.includes("extra") || text.includes("mode") || text.includes("unexpected");
@@ -532,7 +555,13 @@ async function recallViaEndpoint(fetchJSON, cfg, query, actorPeerId = "", log =
532
555
  export async function postRecall(fetchJSON, body, opts = {}) {
533
556
  const actorPeerId = opts.actorPeerId || "";
534
557
  const log = opts.log || (() => { });
558
+ const memoPath = opts.peerScopeMemoPath;
535
559
  const request = { ...body };
560
+ // A remembered downgrade is still a downgrade: recall runs wider than the
561
+ // caller asked for, so the memo doubles as the doctor's evidence.
562
+ if (request.peer_scope && await readPeerScopeDowngrade(memoPath)) {
563
+ delete request.peer_scope;
564
+ }
536
565
  const res = await fetchJSON("/api/v1/search/recall", {
537
566
  method: "POST",
538
567
  body: JSON.stringify(request),
@@ -540,15 +569,46 @@ export async function postRecall(fetchJSON, body, opts = {}) {
540
569
  if (!request.peer_scope || (res.status !== 400 && res.status !== 422)) {
541
570
  return res;
542
571
  }
572
+ // Only an unknown-field rejection means "this server predates peer_scope".
573
+ // Retrying every other 400/422 without it silently widens the search from
574
+ // the caller's own peer to the whole user root.
575
+ if (!looksLikeUnknownField(res)) {
576
+ log("recall_peer_scope_error", { status: res.status || 0 });
577
+ return res;
578
+ }
543
579
  const downgraded = { ...request };
544
580
  delete downgraded.peer_scope;
581
+ await markPeerScopeDowngrade(String(request.peer_scope), res.status || 0, memoPath);
545
582
  log("recall_peer_scope_downgrade", { status: res.status || 0 });
546
583
  return fetchJSON("/api/v1/search/recall", {
547
584
  method: "POST",
548
585
  body: JSON.stringify(downgraded),
549
586
  }, { actorPeerId });
550
587
  }
588
+ /**
589
+ * Recall, plus whatever is still filed under the peer this workspace used
590
+ * before the identity rule changed.
591
+ *
592
+ * Under the default `peer_scope: "all"` this costs nothing: the server's own
593
+ * sweep of `{user_root}/peers` already reaches the old peer's memories. Under
594
+ * `"actor"` that sweep is off by definition, so the old peer is asked for
595
+ * separately — as itself, which is both cheaper and wider than a bare
596
+ * cross-peer read (that would need the user id, and reaches memories only).
597
+ */
551
598
  export async function buildRecallBlock(fetchJSON, cfg, query, options = {}) {
599
+ const primary = await recallForPeer(fetchJSON, cfg, query, options);
600
+ const legacyPeerId = String(options.legacyPeerId || "").trim();
601
+ const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
602
+ if (cfg.recallPeerScope !== "actor" || !legacyPeerId || legacyPeerId === actorPeerId)
603
+ return primary;
604
+ const log = options.log || (() => { });
605
+ const legacy = await recallForPeer(fetchJSON, cfg, query, { ...options, actorPeerId: legacyPeerId });
606
+ if (!legacy)
607
+ return primary;
608
+ log("recall_legacy_peer_hit", { legacyPeerId });
609
+ return primary ? `${primary}\n${legacy}` : legacy;
610
+ }
611
+ async function recallForPeer(fetchJSON, cfg, query, options = {}) {
552
612
  const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
553
613
  const log = options.log || (() => { });
554
614
  const trimmed = String(query || "").trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openviking/openclaw-plugin",
3
- "version": "2026.9.2",
3
+ "version": "2026.9.4",
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",
@@ -417,6 +417,31 @@ export async function markContextFaceLegacy(path = stateFile("context-face.json"
417
417
  await writeJsonFile(path, { legacyUntil: now + LEGACY_CACHE_TTL_MS });
418
418
  }
419
419
 
420
+ /**
421
+ * A `peer_scope` the server rejects is remembered the same way, but unlike the
422
+ * context face this one is not a silent capability probe: dropping the field
423
+ * widens recall from the caller's own peer to the whole user root, so doctor
424
+ * reads this file back and warns.
425
+ */
426
+ export function peerScopeMemoPath() {
427
+ return stateFile("peer-scope.json");
428
+ }
429
+
430
+ export async function readPeerScopeDowngrade(path = peerScopeMemoPath(), now = Date.now()) {
431
+ const cached = await readJsonFile(path);
432
+ if (!cached?.legacyUntil || Number(cached.legacyUntil) <= now) return null;
433
+ return cached;
434
+ }
435
+
436
+ export async function markPeerScopeDowngrade(scope, status, path = peerScopeMemoPath(), now = Date.now()) {
437
+ await writeJsonFile(path, {
438
+ legacyUntil: now + LEGACY_CACHE_TTL_MS,
439
+ scope: String(scope || ""),
440
+ status: Number(status) || 0,
441
+ at: now,
442
+ });
443
+ }
444
+
420
445
  function looksLikeUnknownField(res) {
421
446
  const text = JSON.stringify(res?.error ?? res?.result ?? res?.detail ?? "").toLowerCase();
422
447
  return text.includes("extra") || text.includes("mode") || text.includes("unexpected");
@@ -558,7 +583,15 @@ async function recallViaEndpoint(fetchJSON, cfg, query, actorPeerId = "", log =
558
583
  export async function postRecall(fetchJSON, body, opts = {}) {
559
584
  const actorPeerId = opts.actorPeerId || "";
560
585
  const log = opts.log || (() => {});
586
+ const memoPath = opts.peerScopeMemoPath;
561
587
  const request = { ...body };
588
+
589
+ // A remembered downgrade is still a downgrade: recall runs wider than the
590
+ // caller asked for, so the memo doubles as the doctor's evidence.
591
+ if (request.peer_scope && await readPeerScopeDowngrade(memoPath)) {
592
+ delete request.peer_scope;
593
+ }
594
+
562
595
  const res = await fetchJSON("/api/v1/search/recall", {
563
596
  method: "POST",
564
597
  body: JSON.stringify(request),
@@ -566,9 +599,17 @@ export async function postRecall(fetchJSON, body, opts = {}) {
566
599
  if (!request.peer_scope || (res.status !== 400 && res.status !== 422)) {
567
600
  return res;
568
601
  }
602
+ // Only an unknown-field rejection means "this server predates peer_scope".
603
+ // Retrying every other 400/422 without it silently widens the search from
604
+ // the caller's own peer to the whole user root.
605
+ if (!looksLikeUnknownField(res)) {
606
+ log("recall_peer_scope_error", { status: res.status || 0 });
607
+ return res;
608
+ }
569
609
 
570
610
  const downgraded = { ...request };
571
611
  delete downgraded.peer_scope;
612
+ await markPeerScopeDowngrade(String(request.peer_scope), res.status || 0, memoPath);
572
613
  log("recall_peer_scope_downgrade", { status: res.status || 0 });
573
614
  return fetchJSON("/api/v1/search/recall", {
574
615
  method: "POST",
@@ -576,7 +617,31 @@ export async function postRecall(fetchJSON, body, opts = {}) {
576
617
  }, { actorPeerId });
577
618
  }
578
619
 
620
+ /**
621
+ * Recall, plus whatever is still filed under the peer this workspace used
622
+ * before the identity rule changed.
623
+ *
624
+ * Under the default `peer_scope: "all"` this costs nothing: the server's own
625
+ * sweep of `{user_root}/peers` already reaches the old peer's memories. Under
626
+ * `"actor"` that sweep is off by definition, so the old peer is asked for
627
+ * separately — as itself, which is both cheaper and wider than a bare
628
+ * cross-peer read (that would need the user id, and reaches memories only).
629
+ */
579
630
  export async function buildRecallBlock(fetchJSON, cfg, query, options = {}) {
631
+ const primary = await recallForPeer(fetchJSON, cfg, query, options);
632
+
633
+ const legacyPeerId = String(options.legacyPeerId || "").trim();
634
+ const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
635
+ if (cfg.recallPeerScope !== "actor" || !legacyPeerId || legacyPeerId === actorPeerId) return primary;
636
+
637
+ const log = options.log || (() => {});
638
+ const legacy = await recallForPeer(fetchJSON, cfg, query, { ...options, actorPeerId: legacyPeerId });
639
+ if (!legacy) return primary;
640
+ log("recall_legacy_peer_hit", { legacyPeerId });
641
+ return primary ? `${primary}\n${legacy}` : legacy;
642
+ }
643
+
644
+ async function recallForPeer(fetchJSON, cfg, query, options = {}) {
580
645
  const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
581
646
  const log = options.log || (() => {});
582
647
  const trimmed = String(query || "").trim();