@openviking/openclaw-plugin 2026.9.21 → 2026.9.23

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/README.md CHANGED
@@ -385,3 +385,14 @@ ov tui
385
385
  For installation, upgrade, and uninstall operations, use [INSTALL.md](./INSTALL.md).
386
386
 
387
387
  </details>
388
+
389
+
390
+ ### Cloud recall compression
391
+
392
+ Set `OPENVIKING_RECALL_COMPRESS=server` to enable cloud recall compression, or
393
+ `auto` to let the server decide whether to rewrite. Both use search
394
+ `mode=context`; `server` sends `rewrite=true`, and `auto` sends `rewrite="auto"`.
395
+ The server digest takes precedence over raw rendered context, and `no_relevant`
396
+ suppresses injection. The default remains `off`; no local compressor is launched.
397
+
398
+ The plugin config equivalent is `recallCompress: "server"`.
package/auto-recall.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  type RecallTraceResult,
13
13
  } from "./recall-trace.js";
14
14
  import { sanitizeUserTextForCapture } from "./text-utils.js";
15
+ import { selectRecallContent } from "./shared/recall-core.mjs";
15
16
  import { estimateTextTokens } from "./token-estimator.js";
16
17
 
17
18
  const RECALL_QUERY_MAX_CHARS = 4_000;
@@ -369,6 +370,7 @@ export async function buildAutoRecallContext(params: {
369
370
  contextResult = await client.searchContext(queryText, {
370
371
  sessionId: params.ovSessionId,
371
372
  limit: recallLimit,
373
+ recallCompress: cfg.recallCompress,
372
374
  scoreThreshold,
373
375
  contextType: contextTypes.length === 1 ? contextTypes[0] : contextTypes,
374
376
  queryExpansion: "auto",
@@ -475,14 +477,15 @@ export async function buildAutoRecallContext(params: {
475
477
  params.traceRecorder?.record(entry);
476
478
  };
477
479
 
478
- const rendered = contextResult?.rendered?.trim() ?? "";
479
- if (requestError || entries.length === 0 || !rendered) {
480
+ const digest = contextResult?.digest?.trim() ?? "";
481
+ const rendered = selectRecallContent(contextResult);
482
+ if (requestError || !rendered) {
480
483
  await recordTrace([], 0, 0);
481
484
  return { memoryCount: 0, estimatedTokens: 0 };
482
485
  }
483
486
 
484
487
  const usedTokens = contextResult?.stats?.used_tokens;
485
- const estimatedTokens = typeof usedTokens === "number" && Number.isFinite(usedTokens)
488
+ const estimatedTokens = !digest && typeof usedTokens === "number" && Number.isFinite(usedTokens)
486
489
  ? Math.max(0, Math.ceil(usedTokens))
487
490
  : estimateTokenCount(rendered);
488
491
  const block = buildRecallContextBlock([rendered]);
package/client.ts CHANGED
@@ -48,6 +48,7 @@ export type SearchContextOptions = {
48
48
  scoreThreshold?: number;
49
49
  contextType?: string | string[];
50
50
  queryExpansion?: "off" | "auto";
51
+ recallCompress?: "off" | "server" | "auto";
51
52
  maxTokens?: number;
52
53
  detail?: "abstract" | "overview" | "full";
53
54
  dedupTurns?: number;
@@ -496,6 +497,7 @@ export class OpenVikingClient {
496
497
  ): Promise<SearchContextResult> {
497
498
  const contractConfig = {
498
499
  recallLimit: options.limit,
500
+ recallRewrite: options.recallCompress,
499
501
  recallLimitConfigured: options.limit !== undefined,
500
502
  recallMaxTokens: options.maxTokens,
501
503
  recallMaxTokensConfigured: options.maxTokens !== undefined,
package/config.ts CHANGED
@@ -48,6 +48,8 @@ export type MemoryOpenVikingConfig = {
48
48
  autoRecall?: boolean;
49
49
  /** Outer time budget for the whole server-assembled auto-recall flow. */
50
50
  autoRecallTimeoutMs?: number;
51
+ /** Cloud recall compression; no local compressor is shipped. Default off. */
52
+ recallCompress?: "off" | "server" | "auto";
51
53
  /** Include resources in auto-recall and default memory_recall search. Default false. */
52
54
  recallResources?: boolean;
53
55
  recallLimit?: number;
@@ -531,6 +533,7 @@ export const memoryOpenVikingConfigSchema = {
531
533
  "captureMaxLength",
532
534
  "autoRecall",
533
535
  "autoRecallTimeoutMs",
536
+ "recallCompress",
534
537
  "recallResources",
535
538
  "recallLimit",
536
539
  "recallScoreThreshold",
@@ -628,6 +631,12 @@ export const memoryOpenVikingConfigSchema = {
628
631
  ),
629
632
  ),
630
633
  );
634
+ const rawCompress = String(getEnv("OPENVIKING_RECALL_COMPRESS") ?? cfg.recallCompress ?? "off").trim().toLowerCase();
635
+ const recallCompress = ["1", "true", "yes"].includes(rawCompress) ? "auto"
636
+ : ["0", "false", "no"].includes(rawCompress) ? "off" : rawCompress;
637
+ if (recallCompress !== "off" && recallCompress !== "server" && recallCompress !== "auto") {
638
+ throw new Error("openviking recallCompress must be server, auto or off (no local compressor)");
639
+ }
631
640
  const recallResources = cfg.recallResources === true || envFlag("OPENVIKING_RECALL_RESOURCES");
632
641
  const recallTargetTypes = normalizeRecallTargetTypes(
633
642
  cfg.recallTargetTypes,
@@ -751,6 +760,7 @@ export const memoryOpenVikingConfigSchema = {
751
760
  traceRecallIncludeContentByDefault: cfg.traceRecallIncludeContentByDefault === true,
752
761
  traceRecallIncludeRawUserPreview: cfg.traceRecallIncludeRawUserPreview === true,
753
762
  recallTargetTypes,
763
+ recallCompress,
754
764
  enableAddResourceTool: cfg.enableAddResourceTool === true,
755
765
  enabledTools,
756
766
  disabledTools,
@@ -2,6 +2,7 @@ import { toJsonLog } from "./memory-ranking.js";
2
2
  import { quickRecallPrecheck, withTimeout } from "./process-manager.js";
3
3
  import { resolveRecallSearchPlan, } from "./registries/recall-resource-types.js";
4
4
  import { sanitizeUserTextForCapture } from "./text-utils.js";
5
+ import { selectRecallContent } from "./shared/recall-core.mjs";
5
6
  import { estimateTextTokens } from "./token-estimator.js";
6
7
  const RECALL_QUERY_MAX_CHARS = 4_000;
7
8
  const AUTO_RECALL_DEDUP_TURNS = 5;
@@ -239,6 +240,7 @@ export async function buildAutoRecallContext(params) {
239
240
  contextResult = await client.searchContext(queryText, {
240
241
  sessionId: params.ovSessionId,
241
242
  limit: recallLimit,
243
+ recallCompress: cfg.recallCompress,
242
244
  scoreThreshold,
243
245
  contextType: contextTypes.length === 1 ? contextTypes[0] : contextTypes,
244
246
  queryExpansion: "auto",
@@ -335,13 +337,14 @@ export async function buildAutoRecallContext(params) {
335
337
  // Trace persistence is diagnostic best-effort; never put JSONL flush latency on the auto-recall critical path.
336
338
  params.traceRecorder?.record(entry);
337
339
  };
338
- const rendered = contextResult?.rendered?.trim() ?? "";
339
- if (requestError || entries.length === 0 || !rendered) {
340
+ const digest = contextResult?.digest?.trim() ?? "";
341
+ const rendered = selectRecallContent(contextResult);
342
+ if (requestError || !rendered) {
340
343
  await recordTrace([], 0, 0);
341
344
  return { memoryCount: 0, estimatedTokens: 0 };
342
345
  }
343
346
  const usedTokens = contextResult?.stats?.used_tokens;
344
- const estimatedTokens = typeof usedTokens === "number" && Number.isFinite(usedTokens)
347
+ const estimatedTokens = !digest && typeof usedTokens === "number" && Number.isFinite(usedTokens)
345
348
  ? Math.max(0, Math.ceil(usedTokens))
346
349
  : estimateTokenCount(rendered);
347
350
  const block = buildRecallContextBlock([rendered]);
package/dist/client.js CHANGED
@@ -192,6 +192,7 @@ export class OpenVikingClient {
192
192
  async searchContext(query, options = {}) {
193
193
  const contractConfig = {
194
194
  recallLimit: options.limit,
195
+ recallRewrite: options.recallCompress,
195
196
  recallLimitConfigured: options.limit !== undefined,
196
197
  recallMaxTokens: options.maxTokens,
197
198
  recallMaxTokensConfigured: options.maxTokens !== undefined,
package/dist/config.js CHANGED
@@ -345,6 +345,7 @@ export const memoryOpenVikingConfigSchema = {
345
345
  "captureMaxLength",
346
346
  "autoRecall",
347
347
  "autoRecallTimeoutMs",
348
+ "recallCompress",
348
349
  "recallResources",
349
350
  "recallLimit",
350
351
  "recallScoreThreshold",
@@ -415,6 +416,12 @@ export const memoryOpenVikingConfigSchema = {
415
416
  ? cfg.userId.trim()
416
417
  : (getEnv("OPENVIKING_USER_ID")?.trim() || "");
417
418
  const recallMaxInjectedChars = Math.max(100, Math.min(50000, Math.floor(toNumber(cfg.recallMaxInjectedChars, toNumber(cfg.recallTokenBudget, DEFAULT_RECALL_MAX_INJECTED_CHARS)))));
419
+ const rawCompress = String(getEnv("OPENVIKING_RECALL_COMPRESS") ?? cfg.recallCompress ?? "off").trim().toLowerCase();
420
+ const recallCompress = ["1", "true", "yes"].includes(rawCompress) ? "auto"
421
+ : ["0", "false", "no"].includes(rawCompress) ? "off" : rawCompress;
422
+ if (recallCompress !== "off" && recallCompress !== "server" && recallCompress !== "auto") {
423
+ throw new Error("openviking recallCompress must be server, auto or off (no local compressor)");
424
+ }
418
425
  const recallResources = cfg.recallResources === true || envFlag("OPENVIKING_RECALL_RESOURCES");
419
426
  const recallTargetTypes = normalizeRecallTargetTypes(cfg.recallTargetTypes, !("recallTargetTypes" in cfg) && recallResources);
420
427
  const { enabledTools, disabledTools } = normalizeEnabledTools(cfg);
@@ -468,6 +475,7 @@ export const memoryOpenVikingConfigSchema = {
468
475
  traceRecallIncludeContentByDefault: cfg.traceRecallIncludeContentByDefault === true,
469
476
  traceRecallIncludeRawUserPreview: cfg.traceRecallIncludeRawUserPreview === true,
470
477
  recallTargetTypes,
478
+ recallCompress,
471
479
  enableAddResourceTool: cfg.enableAddResourceTool === true,
472
480
  enabledTools,
473
481
  disabledTools,
@@ -300,20 +300,45 @@ async function resolveTargetUri(fetchJSON, targetUri, actorPeerId = "") {
300
300
  const space = await resolveUserSpace(fetchJSON, actorPeerId);
301
301
  return `viking://user/${space}/${parts.join("/")}`;
302
302
  }
303
- async function searchOneSource(fetchJSON, query, source, limit, actorPeerId = "") {
304
- const resolvedUri = await resolveTargetUri(fetchJSON, source.uri, actorPeerId);
305
- const body = { query, target_uri: resolvedUri, limit, score_threshold: 0 };
306
- const res = await fetchJSON("/api/v1/search/find", {
307
- method: "POST",
308
- body: JSON.stringify(body),
309
- }, { actorPeerId });
310
- if (!res.ok)
311
- return [];
312
- const items = res.result?.[source.bucket] || [];
313
- return items.map((item) => ({ ...item, _sourceType: source.type }));
303
+ async function searchOneSource(fetchJSON, cfg, query, source, limit, options) {
304
+ const actorPeerId = options.actorPeerId || "";
305
+ const home = await resolveTargetUri(fetchJSON, source.uri, actorPeerId);
306
+ const targets = [...new Set(cfg.user
307
+ ? [`viking://user/${cfg.user}/${source.bucket}`, home] : [home])];
308
+ const sessionId = String(options.sessionId || "").trim();
309
+ const search = async (target, session) => {
310
+ const body = { query, target_uri: target, limit, score_threshold: 0 };
311
+ if (session)
312
+ body.session_id = session;
313
+ const init = { method: "POST", body: JSON.stringify(body) };
314
+ // Session-aware retrieval may decide no context is needed. Only after all
315
+ // targets are empty do we retry without the session. Old servers still
316
+ // support find; an unsupported search route can fall back to it.
317
+ let res = await fetchJSON(sessionId ? "/api/v1/search/search" : "/api/v1/search/find", init, { actorPeerId });
318
+ if (sessionId && !res.ok && (!res.status || res.status === 404 || res.status === 405 || res.status >= 500
319
+ || ((res.status === 400 || res.status === 422) && looksLikeUnknownField(res)))) {
320
+ const { session_id, ...findBody } = body;
321
+ res = await fetchJSON("/api/v1/search/find", { method: "POST", body: JSON.stringify(findBody) }, { actorPeerId });
322
+ }
323
+ const items = res.ok && Array.isArray(res.result?.[source.bucket]) ? res.result[source.bucket] : [];
324
+ return items.map((item) => ({ ...item, _sourceType: source.type }));
325
+ };
326
+ for (const target of targets) {
327
+ const items = await search(target, sessionId);
328
+ if (items.length)
329
+ return items;
330
+ }
331
+ if (sessionId) {
332
+ for (const target of targets) {
333
+ const items = await search(target, "");
334
+ if (items.length)
335
+ return items;
336
+ }
337
+ }
338
+ return [];
314
339
  }
315
- async function searchAllSources(fetchJSON, query, perSourceLimit, actorPeerId = "", log = () => { }) {
316
- const results = await Promise.all(SOURCES.map((src) => searchOneSource(fetchJSON, query, src, perSourceLimit, actorPeerId)));
340
+ async function searchAllSources(fetchJSON, cfg, query, perSourceLimit, options, log = () => { }) {
341
+ const results = await Promise.all(SOURCES.map((src) => searchOneSource(fetchJSON, cfg, query, src, perSourceLimit, options)));
317
342
  const all = results.flat();
318
343
  log("recall_search_summary", {
319
344
  counts: SOURCES.map((src, i) => ({ type: src.type, uri: src.uri, count: results[i].length })),
@@ -344,46 +369,31 @@ async function resolveItemContent(fetchJSON, item, cfg, actorPeerId = "") {
344
369
  content = `${content.slice(0, maxChars)}...`;
345
370
  return content;
346
371
  }
347
- async function buildFallbackInjectionBlock(fetchJSON, items, cfg, actorPeerId = "", log = () => { }) {
372
+ function formatFallback(items, cfg, log = () => { }) {
348
373
  const budgetTotal = Math.max(200, Number(cfg.recallTokenBudget || 2000));
349
- let budgetRemaining = budgetTotal;
350
- const lines = [
351
- "<openviking-context>",
352
- "Relevant context from OpenViking. Use the read MCP tool to expand URIs.",
353
- ];
374
+ const lines = [];
354
375
  let contentCount = 0;
355
376
  let hintCount = 0;
377
+ const maxChars = Math.max(50, Number(cfg.recallMaxContentChars || 500));
356
378
  for (const item of items) {
357
379
  const score = (clampScore(item.score) * 100).toFixed(0);
358
- const uriLine = `- [${item._sourceType} ${score}%] ${item.uri}`;
359
- if (budgetRemaining > 0) {
360
- const content = await resolveItemContent(fetchJSON, item, cfg, actorPeerId);
361
- const contentLine = `- [${item._sourceType} ${score}%] ${content}`;
362
- const lineTokens = estimateTokens(contentLine);
363
- if (lineTokens > budgetRemaining && contentCount > 0) {
364
- lines.push(uriLine);
365
- hintCount++;
366
- }
367
- else {
368
- lines.push(contentLine);
369
- budgetRemaining -= lineTokens;
370
- contentCount++;
371
- }
380
+ const kind = item._sourceType || item.category || "memory";
381
+ const uriLine = `- [${kind} ${score}%] ${item.uri}`;
382
+ const text = String(item.text || item.uri);
383
+ const content = text.length > maxChars ? `${text.slice(0, maxChars)}...` : text;
384
+ const contentLine = `- [${kind} ${score}%] ${content}`;
385
+ if (estimateTokens(wrapContext([...lines, uriLine, contentLine].join("\n"))) <= budgetTotal) {
386
+ lines.push(uriLine, contentLine);
387
+ contentCount += 1;
372
388
  }
373
- else {
389
+ else if (estimateTokens(wrapContext([...lines, uriLine].join("\n"))) <= budgetTotal) {
374
390
  lines.push(uriLine);
375
- hintCount++;
391
+ hintCount += 1;
376
392
  }
377
393
  }
378
- lines.push("</openviking-context>");
379
- const budgetUsed = budgetTotal - budgetRemaining;
380
- log("recall_injection_built", {
381
- contentItems: contentCount,
382
- hintItems: hintCount,
383
- budgetUsed,
384
- budgetTotal,
385
- });
386
- return { block: lines.join("\n"), contentCount, hintCount, budgetUsed, stage: "ranked" };
394
+ const budgetUsed = lines.length ? estimateTokens(wrapContext(lines.join("\n"))) : 0;
395
+ log("recall_injection_built", { contentItems: contentCount, hintItems: hintCount, budgetUsed, budgetTotal });
396
+ return { rendered: lines.join("\n"), contentCount, hintCount, budgetUsed };
387
397
  }
388
398
  const LEGACY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
389
399
  function stateFile(name) {
@@ -446,10 +456,14 @@ function looksLikeUnknownField(res) {
446
456
  return text.includes("extra") || text.includes("mode") || text.includes("unexpected");
447
457
  }
448
458
  function wrapContext(body) {
459
+ // Retrieved text cannot introduce a second capture delimiter inside ours.
460
+ const text = String(body)
461
+ .replace(/<\/?relevant-memor(?:y|ies)\b[^>]*>/gi, "legacy memory wrapper")
462
+ .replace(/<\/?openviking-context\b[^>]*>/gi, "openviking context marker");
449
463
  return [
450
464
  "<openviking-context>",
451
465
  "Relevant memory from OpenViking. Use the search/read MCP tools to expand URIs.",
452
- body,
466
+ text,
453
467
  "</openviking-context>",
454
468
  ].join("\n");
455
469
  }
@@ -464,7 +478,7 @@ export async function buildServerAssembledBlock(fetchJSON, cfg, query, options =
464
478
  const block = await recallViaContextFace(fetchJSON, cfg, query, { ...options, actorPeerId }, log);
465
479
  if (block !== null)
466
480
  return block;
467
- return recallViaEndpoint(fetchJSON, cfg, query, actorPeerId, log);
481
+ return recallViaEndpoint(fetchJSON, cfg, query, { ...options, actorPeerId }, log);
468
482
  }
469
483
  /**
470
484
  * Raw server-assembled context, or null when the deployment has no context face.
@@ -522,56 +536,69 @@ export function normalizeContextEntry(entry = {}) {
522
536
  text: String(entry.text || entry.content || entry.summary || entry.abstract || entry.uri || "").trim(),
523
537
  };
524
538
  }
525
- async function recallViaContextFace(fetchJSON, cfg, query, options, log) {
526
- const assembled = await fetchAssembledContext(fetchJSON, cfg, query, { ...options, log });
527
- if (assembled === null)
528
- return null;
529
- const { rendered, entries } = assembled;
530
- let digest = assembled.digest;
539
+ /** Server relevance decisions and digest precedence are shared by every host. */
540
+ export function selectRecallContent(result = {}) {
541
+ if (String(result.stats?.rewrite || "").toLowerCase() === "no_relevant")
542
+ return "";
543
+ return String(result.digest || "").trim() || String(result.rendered || "").trim();
544
+ }
545
+ function wantsLocalCompression(cfg, options) {
531
546
  const mode = String(cfg.recallRewrite || "off").toLowerCase();
532
- if (String(assembled.stats?.rewrite || "").toLowerCase() === "no_relevant") {
533
- log("recall_server_compression", { status: "empty" });
547
+ return (mode === "client" || mode === "auto")
548
+ && options.localCompressorAvailable !== false
549
+ && typeof options.runCompressor === "function";
550
+ }
551
+ // Context, legacy recall, and raw retrieval share the same result policy.
552
+ // The host callback only runs its model; it never chooses a fallback or digest.
553
+ async function finalizeRecall(result, cfg, query, options, log, shortContext) {
554
+ const selected = selectRecallContent(result);
555
+ if (String(result.stats?.rewrite || "").toLowerCase() === "no_relevant")
534
556
  return "";
557
+ const entries = (result.entries || []).map(normalizeContextEntry).filter((entry) => entry.uri);
558
+ const rendered = String(result.rendered || "").trim();
559
+ if (String(result.digest || "").trim() || !wantsLocalCompression(cfg, options))
560
+ return selected;
561
+ const fallback = shortContext ?? (entries.length ? formatFallback(entries, cfg).rendered : rendered);
562
+ const input = rendered || entries.map((entry) => `${entry.uri}\n${entry.text}`).join("\n");
563
+ if (!input)
564
+ return "";
565
+ try {
566
+ const compression = await compressRecallContext({
567
+ query, rendered: input, shortContext: shortContext ?? (rendered || fallback),
568
+ entries, cfg, runCompressor: options.runCompressor,
569
+ cachePath: options.digestCachePath || stateFile("recall-digest.json"), now: Date.now(),
570
+ });
571
+ log("recall_local_compression", { status: compression.status });
572
+ if (compression.status === "ok")
573
+ return compression.context;
574
+ if (compression.status === "empty")
575
+ return "";
535
576
  }
536
- const wantsLocal = mode === "client" || (mode === "auto" && !digest);
537
- if (wantsLocal && rendered && typeof options.runCompressor === "function") {
538
- try {
539
- const compression = await compressRecallContext({
540
- query,
541
- rendered,
542
- entries,
543
- cfg,
544
- runCompressor: options.runCompressor,
545
- cachePath: options.digestCachePath || stateFile("recall-digest.json"),
546
- now: Date.now(),
547
- });
548
- log("recall_local_compression", { status: compression.status });
549
- if (compression.status === "ok")
550
- digest = compression.context;
551
- if (compression.status === "empty")
552
- return "";
553
- }
554
- catch (err) {
555
- log("recall_local_compression_failed", { error: String(err?.message || err) });
556
- }
577
+ catch (err) {
578
+ log("recall_local_compression_failed", { error: String(err?.message || err) });
557
579
  }
558
- const injected = digest || rendered;
559
- if (!injected)
560
- return "";
561
- return wrapContext(injected);
580
+ return fallback;
581
+ }
582
+ async function recallViaContextFace(fetchJSON, cfg, query, options, log) {
583
+ const assembled = await fetchAssembledContext(fetchJSON, cfg, query, { ...options, log });
584
+ if (assembled === null)
585
+ return null;
586
+ const text = await finalizeRecall(assembled, cfg, query, options, log);
587
+ return text ? wrapContext(text) : "";
562
588
  }
563
- async function recallViaEndpoint(fetchJSON, cfg, query, actorPeerId = "", log = () => { }) {
589
+ async function recallViaEndpoint(fetchJSON, cfg, query, options, log) {
564
590
  const body = buildRecallEndpointBody(cfg);
565
591
  body.query = query;
566
- const res = await postRecall(fetchJSON, body, { actorPeerId, log });
592
+ if (wantsLocalCompression(cfg, options)) {
593
+ body.max_chars = Math.max(1000, Number(cfg.recallCompressMaxInputChars || 18000));
594
+ }
595
+ const res = await postRecall(fetchJSON, body, { actorPeerId: options.actorPeerId, log });
567
596
  if (!res.ok) {
568
597
  log("recall_endpoint_fallback", { status: res.status || 0 });
569
598
  return null;
570
599
  }
571
- const rendered = String(res.result?.rendered || "").trim();
572
- if (!rendered)
573
- return "";
574
- return wrapContext(rendered);
600
+ const text = await finalizeRecall(res.result || {}, cfg, query, options, log);
601
+ return text ? wrapContext(text) : "";
575
602
  }
576
603
  export async function postRecall(fetchJSON, body, opts = {}) {
577
604
  const actorPeerId = opts.actorPeerId || "";
@@ -676,7 +703,7 @@ async function recallForPeer(fetchJSON, cfg, query, options = {}) {
676
703
  }
677
704
  const recallLimit = Math.max(1, Number(cfg.recallLimit || DEFAULT_CONTEXT_LIMIT));
678
705
  const perSourceLimit = Math.max(recallLimit * 2, 8);
679
- const raw = await searchAllSources(fetchJSON, trimmed, perSourceLimit, actorPeerId, log);
706
+ const raw = await searchAllSources(fetchJSON, cfg, trimmed, perSourceLimit, { ...options, actorPeerId }, log);
680
707
  if (raw.length === 0)
681
708
  return emptyRecall("no_results");
682
709
  const profile = buildQueryProfile(trimmed);
@@ -692,5 +719,26 @@ async function recallForPeer(fetchJSON, cfg, query, options = {}) {
692
719
  });
693
720
  if (picked.length === 0)
694
721
  return emptyRecall("filtered_out");
695
- return buildFallbackInjectionBlock(fetchJSON, picked, cfg, actorPeerId, log);
722
+ const local = wantsLocalCompression(cfg, options);
723
+ const inputLimit = Math.max(1000, Number(cfg.recallCompressMaxInputChars || 18000));
724
+ const contentCfg = local ? { ...cfg, recallPreferAbstract: false, recallMaxContentChars: inputLimit } : cfg;
725
+ const entries = await Promise.all(picked.map(async (item) => ({
726
+ ...item, text: await resolveItemContent(fetchJSON, item, contentCfg, actorPeerId),
727
+ })));
728
+ const fallback = formatFallback(entries, cfg, log);
729
+ let text = await finalizeRecall({
730
+ rendered: local ? entries.map((item) => `${item.uri}\n${item.text}`).join("\n").slice(0, inputLimit) : fallback.rendered,
731
+ entries,
732
+ }, cfg, trimmed, options, log, fallback.rendered);
733
+ if (estimateTokens(wrapContext(text)) > Math.max(200, Number(cfg.recallTokenBudget || 2000))) {
734
+ text = fallback.rendered;
735
+ }
736
+ if (!text)
737
+ return emptyRecall("no_results");
738
+ const unchanged = text === fallback.rendered;
739
+ return {
740
+ block: wrapContext(text), contentCount: unchanged ? fallback.contentCount : 1,
741
+ hintCount: unchanged ? fallback.hintCount : 0,
742
+ budgetUsed: estimateTokens(wrapContext(text)), stage: "ranked",
743
+ };
696
744
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "manifestVersion": "1.0",
3
- "pluginVersion": "2026.6.18",
3
+ "pluginVersion": "2026.9.21",
4
4
  "plugin": {
5
5
  "id": "openviking",
6
6
  "kind": "context-engine",
@@ -272,6 +272,12 @@
272
272
  "type": "object",
273
273
  "additionalProperties": false,
274
274
  "properties": {
275
+ "recallCompress": {
276
+ "type": "string",
277
+ "enum": ["off", "server", "auto"],
278
+ "default": "off",
279
+ "description": "Cloud recall compression. auto delegates to the server; off preserves raw context."
280
+ },
275
281
  "mode": {
276
282
  "type": "string",
277
283
  "enum": ["local", "remote"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openviking/openclaw-plugin",
3
- "version": "2026.9.21",
3
+ "version": "2026.9.23",
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",
@@ -52,6 +52,10 @@ export type RecallOptions = {
52
52
  legacyPeerId?: string;
53
53
  sessionId?: string;
54
54
  log?: (stage: string, data?: any) => void;
55
+ localCompressorAvailable?: boolean;
56
+ runCompressor?: ((prompt: string) => Promise<string | null>) | null;
57
+ digestCachePath?: string;
58
+ legacyCachePath?: string;
55
59
  };
56
60
 
57
61
  export type DetailedRecall = {
@@ -79,3 +83,5 @@ export function buildRecallBlock(
79
83
  export function buildRecallEndpointBody(cfg?: Record<string, any>): Record<string, any>;
80
84
  export function estimateTokens(text: string): number;
81
85
  export function isRecallEnabled(cfg?: Record<string, any>): boolean;
86
+
87
+ export function selectRecallContent(result?: { rendered?: string; digest?: string; stats?: Record<string, unknown> }): string;
@@ -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
  }