@openviking/openclaw-plugin 2026.9.1 → 2026.9.4-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALL-AGENT.md +25 -4
- package/INSTALL-ZH.md +22 -4
- package/INSTALL.md +22 -4
- package/README.md +19 -15
- package/auto-recall.ts +126 -135
- package/client.ts +95 -0
- package/commands/setup.ts +22 -8
- package/config.ts +31 -16
- package/context-engine.ts +2 -2
- package/dist/auto-recall.js +113 -114
- package/dist/client.js +54 -0
- package/dist/commands/setup.js +15 -6
- package/dist/config.js +18 -11
- package/dist/plugin/openviking-memory-tools.js +1 -1
- package/dist/plugin/openviking-session-routing-runtime.js +2 -2
- package/dist/routing/identity-routing.js +5 -5
- package/dist/services/context-lifecycle-service.js +4 -4
- package/dist/shared/recall-compress-core.mjs +188 -0
- package/dist/shared/recall-core.mjs +645 -0
- package/install-manifest.json +1 -0
- package/openclaw.plugin.json +9 -8
- package/package.json +2 -1
- package/plugin/openviking-client-runtime.ts +1 -1
- package/plugin/openviking-memory-tools.ts +1 -1
- package/plugin/openviking-session-routing-runtime.ts +2 -2
- package/routing/identity-routing.ts +8 -8
- package/services/context-lifecycle-service.ts +4 -4
- package/services/setup/setup-flow.ts +8 -6
- package/shared/recall-compress-core.mjs +195 -0
- package/shared/recall-core.d.mts +39 -0
- package/shared/recall-core.mjs +678 -0
- package/skills/install-openviking-memory/SKILL.md +33 -10
- package/skills/openviking-context-database/SKILL.md +2 -2
package/dist/client.js
CHANGED
|
@@ -7,6 +7,7 @@ import { basename, dirname, join, relative } from "node:path";
|
|
|
7
7
|
import { Zip, ZipDeflate } from "fflate";
|
|
8
8
|
import { defaultHttpTransport } from "./adapters/http-transport.js";
|
|
9
9
|
import { defaultResourcePackager, } from "./adapters/resource-packager.js";
|
|
10
|
+
import { buildContextSearchBody, contextRequestTimeoutMs, normalizeContextEntry, } from "./shared/recall-core.mjs";
|
|
10
11
|
function userSessionUri(sessionId) {
|
|
11
12
|
return `viking://~/sessions/${encodeURIComponent(sessionId)}`;
|
|
12
13
|
}
|
|
@@ -215,6 +216,59 @@ export class OpenVikingClient {
|
|
|
215
216
|
body: JSON.stringify(body),
|
|
216
217
|
}, undefined, actorPeerId);
|
|
217
218
|
}
|
|
219
|
+
async searchContext(query, options = {}) {
|
|
220
|
+
const contractConfig = {
|
|
221
|
+
recallLimit: options.limit,
|
|
222
|
+
recallLimitConfigured: options.limit !== undefined,
|
|
223
|
+
recallMaxTokens: options.maxTokens,
|
|
224
|
+
recallMaxTokensConfigured: options.maxTokens !== undefined,
|
|
225
|
+
scoreThreshold: options.scoreThreshold,
|
|
226
|
+
recallQueryExpansion: options.queryExpansion,
|
|
227
|
+
recallQueryExpansionConfigured: options.queryExpansion !== undefined,
|
|
228
|
+
recallDedupTurns: options.dedupTurns,
|
|
229
|
+
recallPeerScope: options.peerScope,
|
|
230
|
+
recallContextTimeoutMs: options.requestTimeoutMs,
|
|
231
|
+
timeoutMs: this.timeoutMs,
|
|
232
|
+
};
|
|
233
|
+
const body = {
|
|
234
|
+
...buildContextSearchBody(contractConfig, { sessionId: options.sessionId }),
|
|
235
|
+
query,
|
|
236
|
+
...(options.contextType !== undefined ? { context_type: options.contextType } : {}),
|
|
237
|
+
...(options.detail !== undefined ? { detail: options.detail } : {}),
|
|
238
|
+
};
|
|
239
|
+
const requestTimeoutMs = contextRequestTimeoutMs(contractConfig, body);
|
|
240
|
+
const actorPeerId = this.resolveActorPeerHeader(options.actorPeerId);
|
|
241
|
+
const tenantHeaders = this.resolveTenantHeaders();
|
|
242
|
+
this.routingDebugLog?.(`openviking: context search POST ${this.baseUrl}/api/v1/search/search ` +
|
|
243
|
+
JSON.stringify({
|
|
244
|
+
X_OpenViking_Account: tenantHeaders.accountId ?? null,
|
|
245
|
+
X_OpenViking_User: tenantHeaders.userId ?? null,
|
|
246
|
+
X_OpenViking_Actor_Peer: actorPeerId ?? null,
|
|
247
|
+
session_id: body.session_id ?? null,
|
|
248
|
+
query: query.length > 4000
|
|
249
|
+
? `${query.slice(0, 4000)}…(+${query.length - 4000} more chars)`
|
|
250
|
+
: query,
|
|
251
|
+
purpose: body.purpose,
|
|
252
|
+
quotas: body.quotas ?? null,
|
|
253
|
+
score_threshold: body.score_threshold,
|
|
254
|
+
context_type: body.context_type ?? null,
|
|
255
|
+
query_expansion: body.query_expansion ?? null,
|
|
256
|
+
max_tokens: body.max_tokens ?? null,
|
|
257
|
+
detail: body.detail ?? null,
|
|
258
|
+
dedup_turns: body.dedup_turns ?? 0,
|
|
259
|
+
peer_scope: body.peer_scope ?? null,
|
|
260
|
+
}));
|
|
261
|
+
const result = await this.request("/api/v1/search/search", {
|
|
262
|
+
method: "POST",
|
|
263
|
+
body: JSON.stringify(body),
|
|
264
|
+
}, requestTimeoutMs, actorPeerId);
|
|
265
|
+
return {
|
|
266
|
+
...result,
|
|
267
|
+
entries: Array.isArray(result.entries)
|
|
268
|
+
? result.entries.map((entry) => ({ ...entry, ...normalizeContextEntry(entry) }))
|
|
269
|
+
: result.entries,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
218
272
|
async read(uri, actorPeerId) {
|
|
219
273
|
return this.request(`/api/v1/content/read?uri=${encodeURIComponent(uri)}`, {}, undefined, actorPeerId);
|
|
220
274
|
}
|
package/dist/commands/setup.js
CHANGED
|
@@ -76,7 +76,7 @@ const CONFIG_KEYS_TO_PRESERVE = [
|
|
|
76
76
|
"emitStandardDiagnostics",
|
|
77
77
|
"logFindRequests",
|
|
78
78
|
];
|
|
79
|
-
const DEFAULT_SETUP_PEER_ROLE = "
|
|
79
|
+
const DEFAULT_SETUP_PEER_ROLE = "none";
|
|
80
80
|
const ALLOWED_RECALL_TARGET_TYPES = ["resource", "user", "agent"];
|
|
81
81
|
function tr(langZh, en, zh) {
|
|
82
82
|
return langZh ? zh : en;
|
|
@@ -98,7 +98,9 @@ function normalizePeerRole(value) {
|
|
|
98
98
|
if (typeof value !== "string")
|
|
99
99
|
return undefined;
|
|
100
100
|
const role = value.trim().toLowerCase();
|
|
101
|
-
if (role === "
|
|
101
|
+
if (role === "person")
|
|
102
|
+
return "sender";
|
|
103
|
+
if (role === "none" || role === "assistant" || role === "sender")
|
|
102
104
|
return role;
|
|
103
105
|
return undefined;
|
|
104
106
|
}
|
|
@@ -121,7 +123,7 @@ function resolveSetupPeerRole(value) {
|
|
|
121
123
|
const role = normalizePeerRole(value);
|
|
122
124
|
if (role)
|
|
123
125
|
return role;
|
|
124
|
-
throw new Error('peer_role must be "none", "assistant", or "person"');
|
|
126
|
+
throw new Error('peer_role must be "none", "assistant", or "sender" (legacy alias: "person")');
|
|
125
127
|
}
|
|
126
128
|
function normalizeSetupRecallTargetTypes(value) {
|
|
127
129
|
if (value === undefined || value === null || value === "") {
|
|
@@ -172,12 +174,17 @@ function preserveCurrentConfig(existing) {
|
|
|
172
174
|
return config;
|
|
173
175
|
}
|
|
174
176
|
async function askPeerRole(zh, q, defaultValue) {
|
|
177
|
+
console.log(` ${tr(zh, "Memory scope — none (default): viking://user/<user_id>/memories, shared across all conversations; " +
|
|
178
|
+
"assistant: viking://user/<user_id>/peers/<assistant_id>/memories; " +
|
|
179
|
+
"sender: viking://user/<user_id>/peers/<sender_id>/memories (legacy alias: person).", "记忆归属 —— none(默认):viking://user/<user_id>/memories,所有对话共享;" +
|
|
180
|
+
"assistant:viking://user/<user_id>/peers/<assistant_id>/memories;" +
|
|
181
|
+
"sender:viking://user/<user_id>/peers/<sender_id>/memories(兼容旧值 person)。")}`);
|
|
175
182
|
while (true) {
|
|
176
|
-
const value = await q(tr(zh, "
|
|
183
|
+
const value = await q(tr(zh, "Memory scope (none/assistant/sender)", "记忆归属(none/assistant/sender)"), defaultValue);
|
|
177
184
|
const role = normalizePeerRole(value);
|
|
178
185
|
if (role)
|
|
179
186
|
return role;
|
|
180
|
-
console.log(` ✗ ${tr(zh, '
|
|
187
|
+
console.log(` ✗ ${tr(zh, 'Memory scope must be "none", "assistant", or "sender" (legacy "person" is also accepted).', '记忆归属必须是 "none"、"assistant" 或 "sender"(也兼容旧值 "person")。')}`);
|
|
181
188
|
}
|
|
182
189
|
}
|
|
183
190
|
async function askPeerPrefix(zh, q, defaultValue) {
|
|
@@ -473,7 +480,7 @@ export function registerSetupCli(api) {
|
|
|
473
480
|
.option("--zh", "Chinese prompts")
|
|
474
481
|
.option("--base-url <url>", "OpenViking server URL (enables non-interactive mode)")
|
|
475
482
|
.option("--api-key <key>", "API key for authentication")
|
|
476
|
-
.option("--peer-role <role>", "
|
|
483
|
+
.option("--peer-role <role>", "Memory scope: none (shared), assistant (per assistant), or sender (per sender); person is a legacy alias")
|
|
477
484
|
.option("--peer-prefix <prefix>", "Prefix for assistant peer_id values")
|
|
478
485
|
.option("--account-id <id>", "Account ID (required for root API keys)")
|
|
479
486
|
.option("--user-id <id>", "User ID (required for root API keys)")
|
|
@@ -952,6 +959,8 @@ async function setupRemote(zh, configPath, existing, q) {
|
|
|
952
959
|
export const __test__ = {
|
|
953
960
|
isLegacyLocalMode,
|
|
954
961
|
isValidPeerPrefixInput,
|
|
962
|
+
normalizePeerRole,
|
|
963
|
+
resolveSetupPeerRole,
|
|
955
964
|
normalizeSetupRecallTargetTypes,
|
|
956
965
|
activateContextEngineSlot,
|
|
957
966
|
isContextEngineSlotActive,
|
package/dist/config.js
CHANGED
|
@@ -6,7 +6,9 @@ const DEFAULT_TARGET_URI = "viking://~/memories";
|
|
|
6
6
|
const DEFAULT_TIMEOUT_MS = 15000;
|
|
7
7
|
const DEFAULT_CAPTURE_MODE = "semantic";
|
|
8
8
|
const DEFAULT_CAPTURE_MAX_LENGTH = 24000;
|
|
9
|
-
|
|
9
|
+
// Session-aware context search may spend up to 5 seconds on query expansion
|
|
10
|
+
// before returning its server-side fallback, so leave headroom for retrieval.
|
|
11
|
+
const DEFAULT_AUTO_RECALL_TIMEOUT_MS = 15000;
|
|
10
12
|
const DEFAULT_RECALL_LIMIT = 6;
|
|
11
13
|
const DEFAULT_RECALL_SCORE_THRESHOLD = 0.15;
|
|
12
14
|
const DEFAULT_RECALL_MAX_CONTENT_CHARS = 5000;
|
|
@@ -16,7 +18,7 @@ const DEFAULT_COMMIT_TOKEN_THRESHOLD_RATIO = 0.5;
|
|
|
16
18
|
const DEFAULT_COMMIT_KEEP_RECENT_COUNT = 10;
|
|
17
19
|
const DEFAULT_BYPASS_SESSION_PATTERNS = [];
|
|
18
20
|
const DEFAULT_EMIT_STANDARD_DIAGNOSTICS = false;
|
|
19
|
-
const DEFAULT_PEER_ROLE = "
|
|
21
|
+
const DEFAULT_PEER_ROLE = "none";
|
|
20
22
|
const DEFAULT_PEER_PREFIX = "";
|
|
21
23
|
const DEFAULT_TRACE_RECALL_DIR = "~/.openclaw/openviking/recall-traces";
|
|
22
24
|
const DEFAULT_TRACE_RECALL_RETENTION_DAYS = 14;
|
|
@@ -142,13 +144,16 @@ function resolveSecret(value, label) {
|
|
|
142
144
|
function resolvePeerRole(configured) {
|
|
143
145
|
if (typeof configured === "string") {
|
|
144
146
|
const role = configured.trim().toLowerCase();
|
|
145
|
-
if (role === "none" || role === "assistant" || role === "
|
|
147
|
+
if (role === "none" || role === "assistant" || role === "sender") {
|
|
146
148
|
return role;
|
|
147
149
|
}
|
|
148
|
-
|
|
150
|
+
if (role === "person") {
|
|
151
|
+
return "sender";
|
|
152
|
+
}
|
|
153
|
+
throw new Error(`openviking peer_role must be "none", "assistant", or "sender" (legacy alias: "person")`);
|
|
149
154
|
}
|
|
150
155
|
if (configured !== undefined) {
|
|
151
|
-
throw new Error(`openviking peer_role must be "none", "assistant", or "person"`);
|
|
156
|
+
throw new Error(`openviking peer_role must be "none", "assistant", or "sender" (legacy alias: "person")`);
|
|
152
157
|
}
|
|
153
158
|
return DEFAULT_PEER_ROLE;
|
|
154
159
|
}
|
|
@@ -479,14 +484,16 @@ export const memoryOpenVikingConfigSchema = {
|
|
|
479
484
|
help: "HTTP URL when mode is remote (or use ${OPENVIKING_BASE_URL})",
|
|
480
485
|
},
|
|
481
486
|
peer_role: {
|
|
482
|
-
label: "
|
|
487
|
+
label: "Memory Scope (peer_role)",
|
|
483
488
|
placeholder: DEFAULT_PEER_ROLE,
|
|
484
|
-
help: '
|
|
489
|
+
help: 'Where peer-scoped memories are stored. "none" (default): viking://user/<user_id>/memories. ' +
|
|
490
|
+
'"assistant": viking://user/<user_id>/peers/<assistant_id>/memories. ' +
|
|
491
|
+
'"sender": viking://user/<user_id>/peers/<sender_id>/memories. Legacy "person" is accepted as "sender".',
|
|
485
492
|
},
|
|
486
493
|
peer_prefix: {
|
|
487
494
|
label: "Peer Prefix",
|
|
488
495
|
placeholder: "optional-prefix",
|
|
489
|
-
help: "
|
|
496
|
+
help: 'Only used when Memory Scope is "assistant". Prefix added to the peer id derived from the OpenClaw agent id.',
|
|
490
497
|
},
|
|
491
498
|
apiKey: {
|
|
492
499
|
label: "OpenViking API Key",
|
|
@@ -547,7 +554,7 @@ export const memoryOpenVikingConfigSchema = {
|
|
|
547
554
|
label: "Auto-Recall Timeout (ms)",
|
|
548
555
|
placeholder: String(DEFAULT_AUTO_RECALL_TIMEOUT_MS),
|
|
549
556
|
advanced: true,
|
|
550
|
-
help: "Outer time budget for the
|
|
557
|
+
help: "Outer time budget for the server-assembled context search, including session-aware query expansion.",
|
|
551
558
|
},
|
|
552
559
|
recallResources: {
|
|
553
560
|
label: "Recall Resources",
|
|
@@ -574,7 +581,7 @@ export const memoryOpenVikingConfigSchema = {
|
|
|
574
581
|
label: "Recall Max Injected Chars",
|
|
575
582
|
placeholder: String(DEFAULT_RECALL_MAX_INJECTED_CHARS),
|
|
576
583
|
advanced: true,
|
|
577
|
-
help: "
|
|
584
|
+
help: "Legacy auto-recall character budget. It is converted to the server context token budget at four characters per token.",
|
|
578
585
|
},
|
|
579
586
|
recallMaxContentChars: {
|
|
580
587
|
label: "Deprecated Recall Max Content Chars",
|
|
@@ -585,7 +592,7 @@ export const memoryOpenVikingConfigSchema = {
|
|
|
585
592
|
recallPreferAbstract: {
|
|
586
593
|
label: "Recall Prefer Abstract",
|
|
587
594
|
advanced: true,
|
|
588
|
-
help: "
|
|
595
|
+
help: "Pin server-assembled auto-recall entries to abstract detail. When disabled, the server chooses each category's default detail tier.",
|
|
589
596
|
},
|
|
590
597
|
recallTokenBudget: {
|
|
591
598
|
label: "Deprecated Recall Token Budget",
|
|
@@ -45,7 +45,7 @@ export function registerOpenVikingMemoryTools(deps) {
|
|
|
45
45
|
const peerId = resolveOpenVikingMessagePeerId({
|
|
46
46
|
peerRole: deps.peerRole,
|
|
47
47
|
role,
|
|
48
|
-
|
|
48
|
+
senderPeerId: session.actorPeerId,
|
|
49
49
|
assistantPeerId: session.actorPeerId,
|
|
50
50
|
});
|
|
51
51
|
await client.addSessionMessage(sessionId, role, [{ type: "text", text }], undefined, undefined, peerId);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createSessionAgentResolver, openClawSessionToOvStorageId, resolveOpenVikingActorPeerId, sanitizeOpenVikingPeerId, } from "../routing/identity-routing.js";
|
|
2
2
|
export function createOpenVikingSessionRoutingRuntime(options) {
|
|
3
|
-
const peerRole = options.peerRole ?? "
|
|
3
|
+
const peerRole = options.peerRole ?? "none";
|
|
4
4
|
const sessionAgentResolver = createSessionAgentResolver(options.peerPrefix);
|
|
5
5
|
const rememberSessionAgentId = (ctx) => {
|
|
6
6
|
sessionAgentResolver.remember(ctx);
|
|
@@ -48,7 +48,7 @@ export function createOpenVikingSessionRoutingRuntime(options) {
|
|
|
48
48
|
agentId,
|
|
49
49
|
actorPeerId: resolveOpenVikingActorPeerId({
|
|
50
50
|
peerRole,
|
|
51
|
-
|
|
51
|
+
senderPeerId: sanitizeOpenVikingPeerId(ctx?.requesterSenderId ?? ctx?.senderId),
|
|
52
52
|
assistantPeerId: agentId,
|
|
53
53
|
}),
|
|
54
54
|
};
|
|
@@ -12,8 +12,8 @@ export function sanitizeOpenVikingPeerId(raw) {
|
|
|
12
12
|
return normalized || undefined;
|
|
13
13
|
}
|
|
14
14
|
export function resolveOpenVikingMessagePeerId(params) {
|
|
15
|
-
if (params.peerRole === "
|
|
16
|
-
return params.
|
|
15
|
+
if (params.peerRole === "sender" && params.role === "user") {
|
|
16
|
+
return params.senderPeerId;
|
|
17
17
|
}
|
|
18
18
|
if (params.peerRole === "assistant" && params.role === "assistant") {
|
|
19
19
|
return params.assistantPeerId;
|
|
@@ -23,10 +23,10 @@ export function resolveOpenVikingMessagePeerId(params) {
|
|
|
23
23
|
export function resolveOpenVikingActorPeerId(params) {
|
|
24
24
|
const actorPeerId = resolveOpenVikingMessagePeerId({
|
|
25
25
|
...params,
|
|
26
|
-
role: params.peerRole === "
|
|
26
|
+
role: params.peerRole === "sender" ? "user" : "assistant",
|
|
27
27
|
});
|
|
28
|
-
if (params.peerRole === "
|
|
29
|
-
throw new Error("openviking: peer_role=
|
|
28
|
+
if (params.peerRole === "sender" && !actorPeerId) {
|
|
29
|
+
throw new Error("openviking: peer_role=sender requires a sender identity");
|
|
30
30
|
}
|
|
31
31
|
return actorPeerId;
|
|
32
32
|
}
|
|
@@ -243,8 +243,8 @@ export async function assembleOpenVikingSession({ sessionId, sessionKey, message
|
|
|
243
243
|
const routingRef = sessionId ?? sessionKey ?? ovSessionId;
|
|
244
244
|
const agentId = resolveAgentId(routingRef, sessionKey, ovSessionId);
|
|
245
245
|
const actorPeerId = resolveOpenVikingActorPeerId({
|
|
246
|
-
peerRole: cfg.peer_role ?? "
|
|
247
|
-
|
|
246
|
+
peerRole: cfg.peer_role ?? "none",
|
|
247
|
+
senderPeerId: sanitizeOpenVikingPeerId(sender.senderId),
|
|
248
248
|
assistantPeerId: agentId,
|
|
249
249
|
});
|
|
250
250
|
const queryConfig = await queryConfigStore?.getEffective({
|
|
@@ -595,9 +595,9 @@ export async function afterTurnOpenVikingSession({ sessionId, sessionKey, messag
|
|
|
595
595
|
});
|
|
596
596
|
if (ovParts.length > 0) {
|
|
597
597
|
await client.addSessionMessage(ovSessionId, msg.role, ovParts, undefined, createdAt, resolveOpenVikingMessagePeerId({
|
|
598
|
-
peerRole: cfg.peer_role ?? "
|
|
598
|
+
peerRole: cfg.peer_role ?? "none",
|
|
599
599
|
role: msg.role,
|
|
600
|
-
|
|
600
|
+
senderPeerId: senderRoleId,
|
|
601
601
|
assistantPeerId: agentId,
|
|
602
602
|
}));
|
|
603
603
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
export const NO_RELEVANT_MEMORY = "NO_RELEVANT_MEMORY";
|
|
6
|
+
export const DIGEST_HEADER = "OpenViking memory digest:";
|
|
7
|
+
const COMPRESS_OK = "ok";
|
|
8
|
+
const COMPRESS_EMPTY = "empty";
|
|
9
|
+
const COMPRESS_FAILED = "failed";
|
|
10
|
+
// Medium-constraint prompt: state the goal and two structural floors, but no hard
|
|
11
|
+
// bullet-length contract. Hard per-bullet limits pin the digest to headline
|
|
12
|
+
// density; leaving it unconstrained lets small models rewrite long URIs into dead
|
|
13
|
+
// links, which the URI repair below cleans up.
|
|
14
|
+
export function buildRecallCompressionPrompt({ query, rendered, maxBullets = 6 }) {
|
|
15
|
+
return `You are a memory relevance compressor utility.
|
|
16
|
+
Do not use any tools. Do not investigate. Only transform the given text.
|
|
17
|
+
|
|
18
|
+
User query:
|
|
19
|
+
${query}
|
|
20
|
+
|
|
21
|
+
Retrieved OpenViking context fragments:
|
|
22
|
+
${rendered}
|
|
23
|
+
|
|
24
|
+
Write a memory digest for a coding agent about to answer that query. Keep the
|
|
25
|
+
concrete facts (paths, identifiers, decisions, constraints); drop pleasantries
|
|
26
|
+
and conversational filler.
|
|
27
|
+
|
|
28
|
+
Format rules:
|
|
29
|
+
- Group related facts by topic, one bullet per topic, at most ${maxBullets} bullets.
|
|
30
|
+
- Start every bullet with "- ".
|
|
31
|
+
- End every bullet with its source, copied verbatim from the fragments above:
|
|
32
|
+
"来源:viking://..." or "source: viking://...". Never edit, shorten, or invent a URI.
|
|
33
|
+
- Output the digest body only. No preamble, no closing remark.
|
|
34
|
+
|
|
35
|
+
If nothing above is relevant to the query, output exactly: ${NO_RELEVANT_MEMORY}`;
|
|
36
|
+
}
|
|
37
|
+
function editDistance(a, b) {
|
|
38
|
+
if (a === b)
|
|
39
|
+
return 0;
|
|
40
|
+
const rows = a.length + 1;
|
|
41
|
+
const cols = b.length + 1;
|
|
42
|
+
let prev = Array.from({ length: cols }, (_, i) => i);
|
|
43
|
+
for (let i = 1; i < rows; i += 1) {
|
|
44
|
+
const cur = [i];
|
|
45
|
+
for (let j = 1; j < cols; j += 1) {
|
|
46
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
47
|
+
cur[j] = Math.min(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
48
|
+
}
|
|
49
|
+
prev = cur;
|
|
50
|
+
}
|
|
51
|
+
return prev[cols - 1];
|
|
52
|
+
}
|
|
53
|
+
function nearestUri(candidate, validUris) {
|
|
54
|
+
let best = "";
|
|
55
|
+
let bestDistance = Infinity;
|
|
56
|
+
for (const uri of validUris) {
|
|
57
|
+
const distance = editDistance(candidate, uri);
|
|
58
|
+
if (distance < bestDistance) {
|
|
59
|
+
best = uri;
|
|
60
|
+
bestDistance = distance;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Only repair near-misses; an unrelated hallucination is dropped instead.
|
|
64
|
+
const tolerance = Math.max(4, Math.floor(candidate.length * 0.25));
|
|
65
|
+
return bestDistance <= tolerance ? best : "";
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Small models occasionally mangle long URIs. Snap every cited URI back onto the
|
|
69
|
+
* set the server actually returned, and drop bullets whose citation cannot be
|
|
70
|
+
* recovered so the digest never carries a dead link.
|
|
71
|
+
*/
|
|
72
|
+
export function repairDigestUris(digest, validUris = []) {
|
|
73
|
+
const text = String(digest || "");
|
|
74
|
+
if (!text)
|
|
75
|
+
return "";
|
|
76
|
+
const valid = validUris.map((uri) => String(uri || "").trim()).filter(Boolean);
|
|
77
|
+
if (!valid.length)
|
|
78
|
+
return text;
|
|
79
|
+
const validSet = new Set(valid);
|
|
80
|
+
const lines = [];
|
|
81
|
+
for (const line of text.split(/\r?\n/)) {
|
|
82
|
+
if (!line.trim().startsWith("- ")) {
|
|
83
|
+
lines.push(line);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
let dropped = false;
|
|
87
|
+
const repaired = line.replace(/viking:\/\/[^\s<>"')\]]+/g, (uri) => {
|
|
88
|
+
if (validSet.has(uri))
|
|
89
|
+
return uri;
|
|
90
|
+
const nearest = nearestUri(uri, valid);
|
|
91
|
+
if (nearest)
|
|
92
|
+
return nearest;
|
|
93
|
+
dropped = true;
|
|
94
|
+
return uri;
|
|
95
|
+
});
|
|
96
|
+
if (!dropped)
|
|
97
|
+
lines.push(repaired);
|
|
98
|
+
}
|
|
99
|
+
return lines.join("\n").trim();
|
|
100
|
+
}
|
|
101
|
+
export function normalizeCompressedContext(raw, maxChars = 4000, maxBullets = 6) {
|
|
102
|
+
const text = String(raw || "").trim();
|
|
103
|
+
if (!text)
|
|
104
|
+
return null;
|
|
105
|
+
if (text.toUpperCase() === NO_RELEVANT_MEMORY)
|
|
106
|
+
return "";
|
|
107
|
+
const bullets = text.split(/\r?\n/)
|
|
108
|
+
.map((line) => line.trim())
|
|
109
|
+
.filter((line) => /^[-*]\s+/.test(line) && line.includes("viking://"))
|
|
110
|
+
.slice(0, Math.max(1, maxBullets))
|
|
111
|
+
.map((line) => `- ${line.replace(/^[-*]\s+/, "").slice(0, 500).trim()}`);
|
|
112
|
+
if (!bullets.length)
|
|
113
|
+
return null;
|
|
114
|
+
return (`${DIGEST_HEADER}\n${bullets.join("\n")}`).slice(0, Math.max(100, maxChars));
|
|
115
|
+
}
|
|
116
|
+
export function recallDigestCacheKey({ query = "", rendered = "", entries = [], maxInputChars = 18000, maxBullets = 6, } = {}) {
|
|
117
|
+
const uris = entries.map((entry) => String(entry?.uri || "").trim()).filter(Boolean).sort();
|
|
118
|
+
const source = JSON.stringify({
|
|
119
|
+
version: 2,
|
|
120
|
+
query: String(query),
|
|
121
|
+
rendered: String(rendered).slice(0, maxInputChars),
|
|
122
|
+
uris,
|
|
123
|
+
maxInputChars,
|
|
124
|
+
maxBullets,
|
|
125
|
+
});
|
|
126
|
+
return createHash("sha256").update(source).digest("hex");
|
|
127
|
+
}
|
|
128
|
+
async function readCache(path) {
|
|
129
|
+
if (!path)
|
|
130
|
+
return null;
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function writeCache(path, value) {
|
|
139
|
+
if (!path)
|
|
140
|
+
return;
|
|
141
|
+
try {
|
|
142
|
+
await mkdir(dirname(path), { recursive: true });
|
|
143
|
+
const tmp = `${path}.tmp`;
|
|
144
|
+
await writeFile(tmp, JSON.stringify(value));
|
|
145
|
+
await rename(tmp, path);
|
|
146
|
+
}
|
|
147
|
+
catch { /* best effort */ }
|
|
148
|
+
}
|
|
149
|
+
export async function compressRecallContext({ query, rendered, entries = [], cfg = {}, runCompressor, cachePath = "", now = 0, }) {
|
|
150
|
+
const input = String(rendered || "").trim();
|
|
151
|
+
if (!input)
|
|
152
|
+
return { status: COMPRESS_EMPTY, context: "" };
|
|
153
|
+
const minChars = Math.max(0, Number(cfg.recallCompressMinInputChars ?? 1500));
|
|
154
|
+
if (input.length < minChars)
|
|
155
|
+
return { status: COMPRESS_OK, context: input };
|
|
156
|
+
const maxInputChars = Math.max(1000, Number(cfg.recallCompressMaxInputChars || 18000));
|
|
157
|
+
const maxBullets = Math.max(1, Number(cfg.recallCompressMaxBullets || 6));
|
|
158
|
+
const key = recallDigestCacheKey({
|
|
159
|
+
query,
|
|
160
|
+
rendered: input,
|
|
161
|
+
entries,
|
|
162
|
+
maxInputChars,
|
|
163
|
+
maxBullets,
|
|
164
|
+
});
|
|
165
|
+
const cached = await readCache(cachePath);
|
|
166
|
+
if (cached?.key === key && typeof cached.digest === "string") {
|
|
167
|
+
return { status: COMPRESS_OK, context: cached.digest };
|
|
168
|
+
}
|
|
169
|
+
const prompt = buildRecallCompressionPrompt({
|
|
170
|
+
query,
|
|
171
|
+
rendered: input.slice(0, maxInputChars),
|
|
172
|
+
maxBullets,
|
|
173
|
+
});
|
|
174
|
+
const raw = await runCompressor(prompt);
|
|
175
|
+
const normalized = normalizeCompressedContext(raw, 4000, maxBullets);
|
|
176
|
+
if (normalized === null)
|
|
177
|
+
return { status: COMPRESS_FAILED, context: "" };
|
|
178
|
+
if (!normalized)
|
|
179
|
+
return { status: COMPRESS_EMPTY, context: "" };
|
|
180
|
+
const validUris = entries.map((entry) => entry?.uri).filter(Boolean);
|
|
181
|
+
const digest = repairDigestUris(normalized, validUris.length
|
|
182
|
+
? validUris
|
|
183
|
+
: (input.match(/viking:\/\/[^\s<>"']+/g) || []));
|
|
184
|
+
if (!digest)
|
|
185
|
+
return { status: COMPRESS_FAILED, context: "" };
|
|
186
|
+
await writeCache(cachePath, { key, digest, updatedAt: now || 0 });
|
|
187
|
+
return { status: COMPRESS_OK, context: digest };
|
|
188
|
+
}
|