@openviking/openclaw-plugin 2026.9.11 → 2026.9.15
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 +8 -0
- package/client.ts +6 -1
- package/commands/setup.ts +1 -0
- package/config.ts +20 -3
- package/dist/client.js +5 -1
- package/dist/commands/setup.js +1 -0
- package/dist/config.js +14 -1
- package/dist/services/context-lifecycle-service.js +3 -1
- package/dist/shared/recall-core.mjs +65 -14
- package/openclaw.plugin.json +10 -1
- package/package.json +2 -2
- package/services/context-lifecycle-service.ts +4 -1
- package/shared/recall-core.d.mts +42 -0
- package/shared/recall-core.mjs +66 -13
package/README.md
CHANGED
|
@@ -227,6 +227,14 @@ After that, the plugin checks `pending_tokens`. Once it reaches `commitTokenThre
|
|
|
227
227
|
|
|
228
228
|
Memory extraction on this automatic path is commit-dependent. Short but important facts can stay only in the live session until a threshold commit, `/compact`, or an explicit store happens.
|
|
229
229
|
|
|
230
|
+
Auto-commit keeps the most recent 10 messages by default (`commitKeepRecentCount`). This count-based window can start mid-turn. With a server that supports turn-aware retention, set `"commitRetentionMode": "turn_budget"` in the plugin config to opt in:
|
|
231
|
+
|
|
232
|
+
- `commitKeepRecentCount` is ignored. The server defaults apply: up to 3 recent user turns, a 12,000-token retention budget, and at least the final assistant/tool step.
|
|
233
|
+
- For an oversized newest turn, the server retains its user question and recent steps and checkpoints the archived prefix. The mandatory tail can exceed the retention budget.
|
|
234
|
+
- `pending_tokens` counts only messages that will leave the live window, not a user question shared with the archive.
|
|
235
|
+
|
|
236
|
+
Manual commit and `/compact` still archive everything. Leave the option unset (or use `"message_count"`) to preserve existing behavior.
|
|
237
|
+
|
|
230
238
|
### Explicit long-term memory writes
|
|
231
239
|
|
|
232
240
|
When the user explicitly asks the agent to remember, save, or store an important long-term fact, preference, project, or decision, prefer `memory_store` over waiting for normal auto-capture. `memory_store` writes the text to an OpenViking session and calls `commit(wait=true)`, so it is the reliable integration-side path for facts that should be available as long-term memory as soon as possible.
|
package/client.ts
CHANGED
|
@@ -848,6 +848,8 @@ export class OpenVikingClient {
|
|
|
848
848
|
keepRecentCount?: number;
|
|
849
849
|
/** Start empty context in the same session after archiving. */
|
|
850
850
|
resetContext?: boolean;
|
|
851
|
+
/** Opt in to the server's turn-aware defaults instead of message-count retention. */
|
|
852
|
+
retentionMode?: "turn_budget";
|
|
851
853
|
agentId?: string;
|
|
852
854
|
},
|
|
853
855
|
): Promise<CommitSessionResult> {
|
|
@@ -862,11 +864,14 @@ export class OpenVikingClient {
|
|
|
862
864
|
sessionId,
|
|
863
865
|
wait: options?.wait ?? false,
|
|
864
866
|
keepRecentCount,
|
|
867
|
+
retentionMode: options?.retentionMode,
|
|
865
868
|
},
|
|
866
869
|
options?.agentId,
|
|
867
870
|
);
|
|
868
871
|
const body: Record<string, unknown> = {};
|
|
869
|
-
if (
|
|
872
|
+
if (options?.retentionMode === "turn_budget") {
|
|
873
|
+
body.retention_mode = "turn_budget";
|
|
874
|
+
} else if (keepRecentCount > 0) {
|
|
870
875
|
body.keep_recent_count = keepRecentCount;
|
|
871
876
|
}
|
|
872
877
|
if (options?.resetContext) {
|
package/commands/setup.ts
CHANGED
package/config.ts
CHANGED
|
@@ -68,11 +68,13 @@ export type MemoryOpenVikingConfig = {
|
|
|
68
68
|
* compatibility). Set to 0 to commit every turn.
|
|
69
69
|
*/
|
|
70
70
|
commitTokenThresholdRatio?: number;
|
|
71
|
+
/** Auto-commit retention: legacy message count (default) or the server's turn-budget policy. */
|
|
72
|
+
commitRetentionMode?: "message_count" | "turn_budget";
|
|
71
73
|
/**
|
|
72
74
|
* WM v2: number of most-recent messages to keep live after an afterTurn
|
|
73
75
|
* commit so the next turn still has immediate context. Forwarded to the
|
|
74
|
-
* server as `keep_recent_count`. Default 10.
|
|
75
|
-
* value and always passes 0.
|
|
76
|
+
* server as `keep_recent_count`. Default 10. Ignored in turn_budget mode.
|
|
77
|
+
* The compact path ignores this value and always passes 0.
|
|
76
78
|
*/
|
|
77
79
|
commitKeepRecentCount?: number;
|
|
78
80
|
bypassSessionPatterns?: string[];
|
|
@@ -538,6 +540,7 @@ export const memoryOpenVikingConfigSchema = {
|
|
|
538
540
|
"recallTokenBudget",
|
|
539
541
|
"commitTokenThreshold",
|
|
540
542
|
"commitTokenThresholdRatio",
|
|
543
|
+
"commitRetentionMode",
|
|
541
544
|
"commitKeepRecentCount",
|
|
542
545
|
"bypassSessionPatterns",
|
|
543
546
|
"ingestReplyAssist",
|
|
@@ -588,6 +591,14 @@ export const memoryOpenVikingConfigSchema = {
|
|
|
588
591
|
? (cfg.apiKey as string | OpenVikingSecretRef)
|
|
589
592
|
: getEnv("OPENVIKING_API_KEY") || undefined;
|
|
590
593
|
const captureMode = cfg.captureMode;
|
|
594
|
+
const commitRetentionMode = cfg.commitRetentionMode;
|
|
595
|
+
if (
|
|
596
|
+
commitRetentionMode !== undefined &&
|
|
597
|
+
commitRetentionMode !== "message_count" &&
|
|
598
|
+
commitRetentionMode !== "turn_budget"
|
|
599
|
+
) {
|
|
600
|
+
throw new Error('openviking commitRetentionMode must be "message_count" or "turn_budget"');
|
|
601
|
+
}
|
|
591
602
|
if (
|
|
592
603
|
typeof captureMode !== "undefined" &&
|
|
593
604
|
captureMode !== "semantic" &&
|
|
@@ -666,6 +677,7 @@ export const memoryOpenVikingConfigSchema = {
|
|
|
666
677
|
0,
|
|
667
678
|
Math.min(1, toNumber(cfg.commitTokenThresholdRatio, DEFAULT_COMMIT_TOKEN_THRESHOLD_RATIO)),
|
|
668
679
|
),
|
|
680
|
+
commitRetentionMode: commitRetentionMode ?? "message_count",
|
|
669
681
|
commitKeepRecentCount: Math.max(
|
|
670
682
|
0,
|
|
671
683
|
Math.min(
|
|
@@ -916,13 +928,18 @@ export const memoryOpenVikingConfigSchema = {
|
|
|
916
928
|
advanced: true,
|
|
917
929
|
help: "Auto-commit triggers once estimated pending tokens reach this fraction (0-1) of the model context window (e.g. 0.5 = 50%). Set to 0 to commit every turn.",
|
|
918
930
|
},
|
|
931
|
+
commitRetentionMode: {
|
|
932
|
+
label: "Commit Retention Mode",
|
|
933
|
+
advanced: true,
|
|
934
|
+
help: "Auto-commit only: message_count (default) keeps recent messages; turn_budget uses the server's turn-aware defaults. Manual commit and compact still archive everything.",
|
|
935
|
+
},
|
|
919
936
|
commitKeepRecentCount: {
|
|
920
937
|
label: "Commit Keep Recent Count",
|
|
921
938
|
placeholder: String(DEFAULT_COMMIT_KEEP_RECENT_COUNT),
|
|
922
939
|
advanced: true,
|
|
923
940
|
help:
|
|
924
941
|
"Number of most-recent messages to keep live after an afterTurn commit. " +
|
|
925
|
-
"Forwarded as keep_recent_count to the server. Compact path always uses 0.",
|
|
942
|
+
"Forwarded as keep_recent_count to the server in message_count mode; ignored in turn_budget mode. Compact path always uses 0.",
|
|
926
943
|
},
|
|
927
944
|
emitStandardDiagnostics: {
|
|
928
945
|
label: "Standard diagnostics (diag JSON lines)",
|
package/dist/client.js
CHANGED
|
@@ -410,9 +410,13 @@ export class OpenVikingClient {
|
|
|
410
410
|
sessionId,
|
|
411
411
|
wait: options?.wait ?? false,
|
|
412
412
|
keepRecentCount,
|
|
413
|
+
retentionMode: options?.retentionMode,
|
|
413
414
|
}, options?.agentId);
|
|
414
415
|
const body = {};
|
|
415
|
-
if (
|
|
416
|
+
if (options?.retentionMode === "turn_budget") {
|
|
417
|
+
body.retention_mode = "turn_budget";
|
|
418
|
+
}
|
|
419
|
+
else if (keepRecentCount > 0) {
|
|
416
420
|
body.keep_recent_count = keepRecentCount;
|
|
417
421
|
}
|
|
418
422
|
if (options?.resetContext) {
|
package/dist/commands/setup.js
CHANGED
package/dist/config.js
CHANGED
|
@@ -354,6 +354,7 @@ export const memoryOpenVikingConfigSchema = {
|
|
|
354
354
|
"recallTokenBudget",
|
|
355
355
|
"commitTokenThreshold",
|
|
356
356
|
"commitTokenThresholdRatio",
|
|
357
|
+
"commitRetentionMode",
|
|
357
358
|
"commitKeepRecentCount",
|
|
358
359
|
"bypassSessionPatterns",
|
|
359
360
|
"ingestReplyAssist",
|
|
@@ -396,6 +397,12 @@ export const memoryOpenVikingConfigSchema = {
|
|
|
396
397
|
? cfg.apiKey
|
|
397
398
|
: getEnv("OPENVIKING_API_KEY") || undefined;
|
|
398
399
|
const captureMode = cfg.captureMode;
|
|
400
|
+
const commitRetentionMode = cfg.commitRetentionMode;
|
|
401
|
+
if (commitRetentionMode !== undefined &&
|
|
402
|
+
commitRetentionMode !== "message_count" &&
|
|
403
|
+
commitRetentionMode !== "turn_budget") {
|
|
404
|
+
throw new Error('openviking commitRetentionMode must be "message_count" or "turn_budget"');
|
|
405
|
+
}
|
|
399
406
|
if (typeof captureMode !== "undefined" &&
|
|
400
407
|
captureMode !== "semantic" &&
|
|
401
408
|
captureMode !== "keyword") {
|
|
@@ -437,6 +444,7 @@ export const memoryOpenVikingConfigSchema = {
|
|
|
437
444
|
recallMaxInjectedChars,
|
|
438
445
|
recallTokenBudget: recallMaxInjectedChars,
|
|
439
446
|
commitTokenThresholdRatio: Math.max(0, Math.min(1, toNumber(cfg.commitTokenThresholdRatio, DEFAULT_COMMIT_TOKEN_THRESHOLD_RATIO))),
|
|
447
|
+
commitRetentionMode: commitRetentionMode ?? "message_count",
|
|
440
448
|
commitKeepRecentCount: Math.max(0, Math.min(1_000, Math.floor(toNumber(cfg.commitKeepRecentCount, DEFAULT_COMMIT_KEEP_RECENT_COUNT)))),
|
|
441
449
|
bypassSessionPatterns: toStringArray(cfg.bypassSessionPatterns, toStringArray(cfg.ingestReplyAssistIgnoreSessionPatterns, DEFAULT_BYPASS_SESSION_PATTERNS)),
|
|
442
450
|
emitStandardDiagnostics: typeof cfg.emitStandardDiagnostics === "boolean"
|
|
@@ -612,12 +620,17 @@ export const memoryOpenVikingConfigSchema = {
|
|
|
612
620
|
advanced: true,
|
|
613
621
|
help: "Auto-commit triggers once estimated pending tokens reach this fraction (0-1) of the model context window (e.g. 0.5 = 50%). Set to 0 to commit every turn.",
|
|
614
622
|
},
|
|
623
|
+
commitRetentionMode: {
|
|
624
|
+
label: "Commit Retention Mode",
|
|
625
|
+
advanced: true,
|
|
626
|
+
help: "Auto-commit only: message_count (default) keeps recent messages; turn_budget uses the server's turn-aware defaults. Manual commit and compact still archive everything.",
|
|
627
|
+
},
|
|
615
628
|
commitKeepRecentCount: {
|
|
616
629
|
label: "Commit Keep Recent Count",
|
|
617
630
|
placeholder: String(DEFAULT_COMMIT_KEEP_RECENT_COUNT),
|
|
618
631
|
advanced: true,
|
|
619
632
|
help: "Number of most-recent messages to keep live after an afterTurn commit. " +
|
|
620
|
-
"Forwarded as keep_recent_count to the server. Compact path always uses 0.",
|
|
633
|
+
"Forwarded as keep_recent_count to the server in message_count mode; ignored in turn_budget mode. Compact path always uses 0.",
|
|
621
634
|
},
|
|
622
635
|
emitStandardDiagnostics: {
|
|
623
636
|
label: "Standard diagnostics (diag JSON lines)",
|
|
@@ -657,7 +657,9 @@ export async function afterTurnOpenVikingSession({ throwOnError = false, session
|
|
|
657
657
|
}
|
|
658
658
|
const commitResult = await client.commitSession(ovSessionId, {
|
|
659
659
|
wait: false,
|
|
660
|
-
|
|
660
|
+
...(cfg.commitRetentionMode === "turn_budget"
|
|
661
|
+
? { retentionMode: "turn_budget" }
|
|
662
|
+
: { keepRecentCount: cfg.commitKeepRecentCount }),
|
|
661
663
|
});
|
|
662
664
|
logger.info(`openviking: committed session=${ovSessionId}, ` +
|
|
663
665
|
`status=${commitResult.status}, archived=${commitResult.archived ?? false}, ` +
|
|
@@ -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
|
-
|
|
327
|
-
|
|
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 =
|
|
379
|
+
const budgetUsed = budgetTotal - budgetRemaining;
|
|
359
380
|
log("recall_injection_built", {
|
|
360
381
|
contentItems: contentCount,
|
|
361
382
|
hintItems: hintCount,
|
|
362
383
|
budgetUsed,
|
|
363
|
-
budgetTotal
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
694
|
+
return emptyRecall("filtered_out");
|
|
644
695
|
return buildFallbackInjectionBlock(fetchJSON, picked, cfg, actorPeerId, log);
|
|
645
696
|
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -204,11 +204,16 @@
|
|
|
204
204
|
"advanced": true,
|
|
205
205
|
"help": "Auto-commit triggers once estimated pending tokens reach this fraction (0-1) of the model context window (e.g. 0.5 = 50%). Set to 0 to commit every turn."
|
|
206
206
|
},
|
|
207
|
+
"commitRetentionMode": {
|
|
208
|
+
"label": "Commit Retention Mode",
|
|
209
|
+
"advanced": true,
|
|
210
|
+
"help": "Auto-commit only: message_count (default) keeps recent messages; turn_budget uses the server's turn-aware defaults. Manual commit and compact still archive everything."
|
|
211
|
+
},
|
|
207
212
|
"commitKeepRecentCount": {
|
|
208
213
|
"label": "Commit Keep Recent Count",
|
|
209
214
|
"placeholder": "10",
|
|
210
215
|
"advanced": true,
|
|
211
|
-
"help": "
|
|
216
|
+
"help": "Number of most-recent messages kept live after an afterTurn commit in message_count mode; ignored in turn_budget mode. Compact path always uses 0."
|
|
212
217
|
},
|
|
213
218
|
"emitStandardDiagnostics": {
|
|
214
219
|
"label": "Standard diagnostics (diag JSON lines)",
|
|
@@ -405,6 +410,10 @@
|
|
|
405
410
|
"minimum": 0,
|
|
406
411
|
"maximum": 1
|
|
407
412
|
},
|
|
413
|
+
"commitRetentionMode": {
|
|
414
|
+
"type": "string",
|
|
415
|
+
"enum": ["message_count", "turn_budget"]
|
|
416
|
+
},
|
|
408
417
|
"commitKeepRecentCount": {
|
|
409
418
|
"type": "number",
|
|
410
419
|
"minimum": 0,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openviking/openclaw-plugin",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.15",
|
|
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"
|
|
@@ -140,6 +140,7 @@ export type AfterTurnOpenVikingSessionParams = {
|
|
|
140
140
|
autoCapture: boolean;
|
|
141
141
|
commitTokenThresholdRatio: number;
|
|
142
142
|
commitKeepRecentCount: number;
|
|
143
|
+
commitRetentionMode?: "message_count" | "turn_budget";
|
|
143
144
|
logFindRequests: boolean;
|
|
144
145
|
peer_role?: OpenVikingPeerRole;
|
|
145
146
|
};
|
|
@@ -987,7 +988,9 @@ export async function afterTurnOpenVikingSession({
|
|
|
987
988
|
|
|
988
989
|
const commitResult = await client.commitSession(ovSessionId, {
|
|
989
990
|
wait: false,
|
|
990
|
-
|
|
991
|
+
...(cfg.commitRetentionMode === "turn_budget"
|
|
992
|
+
? { retentionMode: "turn_budget" as const }
|
|
993
|
+
: { keepRecentCount: cfg.commitKeepRecentCount }),
|
|
991
994
|
});
|
|
992
995
|
logger.info(
|
|
993
996
|
`openviking: committed session=${ovSessionId}, ` +
|
package/shared/recall-core.d.mts
CHANGED
|
@@ -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;
|
package/shared/recall-core.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
397
|
+
const budgetUsed = budgetTotal - budgetRemaining;
|
|
377
398
|
log("recall_injection_built", {
|
|
378
399
|
contentItems: contentCount,
|
|
379
400
|
hintItems: hintCount,
|
|
380
401
|
budgetUsed,
|
|
381
|
-
budgetTotal
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
729
|
+
if (picked.length === 0) return emptyRecall("filtered_out");
|
|
677
730
|
return buildFallbackInjectionBlock(fetchJSON, picked, cfg, actorPeerId, log);
|
|
678
731
|
}
|