@openviking/openclaw-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.
- package/README.md +11 -0
- package/auto-recall.ts +6 -3
- package/client.ts +2 -0
- package/config.ts +10 -0
- package/dist/auto-recall.js +6 -3
- package/dist/client.js +1 -0
- package/dist/config.js +8 -0
- package/dist/shared/recall-core.mjs +135 -87
- package/install-manifest.json +1 -1
- package/openclaw.plugin.json +6 -0
- package/package.json +1 -1
- package/shared/recall-core.d.mts +6 -0
- package/shared/recall-core.mjs +128 -87
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
|
|
479
|
-
|
|
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,
|
package/dist/auto-recall.js
CHANGED
|
@@ -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
|
|
339
|
-
|
|
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,
|
|
304
|
-
const
|
|
305
|
-
const
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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,
|
|
316
|
-
const results = await Promise.all(SOURCES.map((src) => searchOneSource(fetchJSON, query, src, perSourceLimit,
|
|
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
|
-
|
|
372
|
+
function formatFallback(items, cfg, log = () => { }) {
|
|
348
373
|
const budgetTotal = Math.max(200, Number(cfg.recallTokenBudget || 2000));
|
|
349
|
-
|
|
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
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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.
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
if (
|
|
528
|
-
return
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
533
|
-
|
|
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
|
-
|
|
537
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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,
|
|
589
|
+
async function recallViaEndpoint(fetchJSON, cfg, query, options, log) {
|
|
564
590
|
const body = buildRecallEndpointBody(cfg);
|
|
565
591
|
body.query = query;
|
|
566
|
-
|
|
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
|
|
572
|
-
|
|
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
|
-
|
|
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
|
}
|
package/install-manifest.json
CHANGED
package/openclaw.plugin.json
CHANGED
|
@@ -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.21-2",
|
|
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",
|
package/shared/recall-core.d.mts
CHANGED
|
@@ -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;
|
package/shared/recall-core.mjs
CHANGED
|
@@ -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,
|
|
313
|
-
const
|
|
314
|
-
const
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
387
|
+
function formatFallback(items, cfg, log = () => {}) {
|
|
363
388
|
const budgetTotal = Math.max(200, Number(cfg.recallTokenBudget || 2000));
|
|
364
|
-
|
|
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
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
if (
|
|
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
|
-
|
|
560
|
-
let digest = assembled.digest;
|
|
570
|
+
function wantsLocalCompression(cfg, options) {
|
|
561
571
|
const mode = String(cfg.recallRewrite || "off").toLowerCase();
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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,
|
|
610
|
+
async function recallViaEndpoint(fetchJSON, cfg, query, options, log) {
|
|
592
611
|
const body = buildRecallEndpointBody(cfg);
|
|
593
612
|
body.query = query;
|
|
594
|
-
|
|
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
|
|
600
|
-
|
|
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
|
-
|
|
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
|
}
|