@openviking/openclaw-plugin 2026.9.1 → 2026.9.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 +3 -10
- package/auto-recall.ts +126 -135
- package/client.ts +95 -0
- package/config.ts +8 -6
- package/dist/auto-recall.js +113 -114
- package/dist/client.js +54 -0
- package/dist/config.js +6 -4
- package/dist/shared/recall-compress-core.mjs +188 -0
- package/dist/shared/recall-core.mjs +585 -0
- package/install-manifest.json +1 -0
- package/openclaw.plugin.json +2 -2
- package/package.json +2 -1
- package/shared/recall-compress-core.mjs +195 -0
- package/shared/recall-core.d.mts +39 -0
- package/shared/recall-core.mjs +613 -0
package/dist/auto-recall.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
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
5
|
import { estimateTextTokens } from "./token-estimator.js";
|
|
6
6
|
const RECALL_QUERY_MAX_CHARS = 4_000;
|
|
7
|
+
const AUTO_RECALL_DEDUP_TURNS = 5;
|
|
8
|
+
const LEGACY_CHARS_PER_TOKEN = 4;
|
|
7
9
|
export const AUTO_RECALL_SOURCE_MARKER = "Source: openviking-auto-recall";
|
|
8
10
|
const WRITE_OR_EFFECT_RE = /\b(write|edit|modify|delete|remove|migrate|deploy|release|publish|configure|patch)\b|写|改|修改|删除|迁移|部署|发布|配置|打补丁/i;
|
|
9
11
|
const EXECUTION_RE = /\b(fix|debug|test|build|run|implement|refactor|integrate|repair|troubleshoot)\b|修复|调试|测试|构建|运行|实现|重构|对接|排查/i;
|
|
@@ -135,16 +137,25 @@ function preview(value, maxChars) {
|
|
|
135
137
|
return undefined;
|
|
136
138
|
return trimmed.length > maxChars ? trimmed.slice(0, maxChars) : trimmed;
|
|
137
139
|
}
|
|
138
|
-
function
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
function traceResourceType(entry) {
|
|
141
|
+
if (entry.category === "resources" || entry.uri.startsWith("viking://resources")) {
|
|
142
|
+
return "resource";
|
|
143
|
+
}
|
|
144
|
+
if (entry.origin === "actor_peer") {
|
|
145
|
+
return "agent";
|
|
146
|
+
}
|
|
147
|
+
return "user";
|
|
148
|
+
}
|
|
149
|
+
function toTraceResult(entry) {
|
|
150
|
+
const resourceType = traceResourceType(entry);
|
|
151
|
+
return {
|
|
152
|
+
uri: entry.uri,
|
|
141
153
|
resourceType,
|
|
142
|
-
category:
|
|
143
|
-
score:
|
|
144
|
-
|
|
145
|
-
abstractPreview: preview(item.abstract ?? item.overview, 240),
|
|
154
|
+
category: entry.category,
|
|
155
|
+
score: entry.score,
|
|
156
|
+
abstractPreview: preview(entry.text, 240),
|
|
146
157
|
resultType: resourceType === "resource" ? "resource" : "memory",
|
|
147
|
-
}
|
|
158
|
+
};
|
|
148
159
|
}
|
|
149
160
|
function boundTraceQuery(query, maxChars) {
|
|
150
161
|
if (query.length <= maxChars) {
|
|
@@ -211,7 +222,6 @@ export async function buildAutoRecallContext(params) {
|
|
|
211
222
|
return { memoryCount: 0, estimatedTokens: 0 };
|
|
212
223
|
}
|
|
213
224
|
return withTimeout((async () => {
|
|
214
|
-
const candidateLimit = queryConfig?.candidateLimit ?? Math.max(cfg.recallLimit * 4, 20);
|
|
215
225
|
const scoreThreshold = queryConfig?.scoreThreshold ?? cfg.recallScoreThreshold;
|
|
216
226
|
const recallLimit = queryConfig?.recallLimit ?? cfg.recallLimit;
|
|
217
227
|
const maxInjectedChars = queryConfig?.maxInjectedChars ?? cfg.recallMaxInjectedChars;
|
|
@@ -220,89 +230,75 @@ export async function buildAutoRecallContext(params) {
|
|
|
220
230
|
ovSessionId: params.ovSessionId,
|
|
221
231
|
agentId,
|
|
222
232
|
});
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
233
|
+
const contextTypes = [...new Set(searchPlan.searches.map((search) => search.contextType))];
|
|
234
|
+
const maxTokens = Math.min(32_000, Math.max(64, Math.round(maxInjectedChars / LEGACY_CHARS_PER_TOKEN)));
|
|
235
|
+
const startedAt = Date.now();
|
|
236
|
+
let contextResult;
|
|
237
|
+
let requestError;
|
|
238
|
+
try {
|
|
239
|
+
contextResult = await client.searchContext(queryText, {
|
|
240
|
+
sessionId: params.ovSessionId,
|
|
241
|
+
limit: recallLimit,
|
|
242
|
+
scoreThreshold,
|
|
243
|
+
contextType: contextTypes.length === 1 ? contextTypes[0] : contextTypes,
|
|
244
|
+
queryExpansion: "auto",
|
|
245
|
+
maxTokens,
|
|
246
|
+
detail: recallPreferAbstract ? "abstract" : undefined,
|
|
247
|
+
dedupTurns: params.ovSessionId ? AUTO_RECALL_DEDUP_TURNS : undefined,
|
|
248
|
+
peerScope: "actor",
|
|
239
249
|
actorPeerId,
|
|
250
|
+
requestTimeoutMs: cfg.autoRecallTimeoutMs,
|
|
240
251
|
});
|
|
241
|
-
return {
|
|
242
|
-
resourceType: search.resourceType,
|
|
243
|
-
targetUri: search.targetUri,
|
|
244
|
-
result,
|
|
245
|
-
durationMs: Date.now() - start,
|
|
246
|
-
};
|
|
247
|
-
});
|
|
248
|
-
const autoRecallSettled = await Promise.allSettled(autoRecallPromises);
|
|
249
|
-
const allMemories = [];
|
|
250
|
-
for (const s of autoRecallSettled) {
|
|
251
|
-
if (s.status === "fulfilled") {
|
|
252
|
-
const result = s.value.result ?? {};
|
|
253
|
-
const memories = result.memories ?? [];
|
|
254
|
-
const resources = result.resources ?? [];
|
|
255
|
-
allMemories.push(...memories, ...resources);
|
|
256
|
-
traceSearches.push({
|
|
257
|
-
resourceType: s.value.resourceType,
|
|
258
|
-
targetUriInput: s.value.targetUri,
|
|
259
|
-
limit: candidateLimit,
|
|
260
|
-
scoreThreshold: 0,
|
|
261
|
-
durationMs: s.value.durationMs,
|
|
262
|
-
total: result.total ?? memories.length + resources.length + (result.skills?.length ?? 0),
|
|
263
|
-
results: [
|
|
264
|
-
...toTraceResults(memories, s.value.resourceType),
|
|
265
|
-
...toTraceResults(resources, "resource"),
|
|
266
|
-
...(result.skills ?? []).map((item) => ({
|
|
267
|
-
uri: item.uri,
|
|
268
|
-
resourceType: s.value.resourceType,
|
|
269
|
-
category: item.category,
|
|
270
|
-
score: item.score,
|
|
271
|
-
level: item.level,
|
|
272
|
-
abstractPreview: preview(item.abstract ?? item.overview, 240),
|
|
273
|
-
resultType: "skill",
|
|
274
|
-
})),
|
|
275
|
-
].slice(0, cfg.traceRecallMaxResultsPerSearch),
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
else {
|
|
279
|
-
logger.warn?.(`openviking: auto-recall search failed: ${String(s.reason)}`);
|
|
280
|
-
const failedIndex = traceSearches.length;
|
|
281
|
-
const search = searchPlan.searches[failedIndex - searchPlan.skipped.length];
|
|
282
|
-
traceSearches.push({
|
|
283
|
-
resourceType: search?.resourceType ?? "user",
|
|
284
|
-
targetUriInput: search?.targetUri,
|
|
285
|
-
limit: candidateLimit,
|
|
286
|
-
scoreThreshold: 0,
|
|
287
|
-
durationMs: 0,
|
|
288
|
-
total: 0,
|
|
289
|
-
results: [],
|
|
290
|
-
error: String(s.reason),
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
252
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
253
|
+
catch (error) {
|
|
254
|
+
requestError = error;
|
|
255
|
+
}
|
|
256
|
+
const durationMs = Date.now() - startedAt;
|
|
257
|
+
const entries = (contextResult?.entries ?? []).filter((entry) => Boolean(entry.uri));
|
|
258
|
+
const rawRetrievalErrors = contextResult?.stats?.retrieval_errors;
|
|
259
|
+
const retrievalErrors = Array.isArray(rawRetrievalErrors)
|
|
260
|
+
? rawRetrievalErrors.map((error) => String(error))
|
|
261
|
+
: [];
|
|
262
|
+
const searchError = requestError
|
|
263
|
+
? String(requestError)
|
|
264
|
+
: retrievalErrors.length > 0
|
|
265
|
+
? retrievalErrors.join("; ")
|
|
266
|
+
: undefined;
|
|
267
|
+
if (searchError) {
|
|
268
|
+
logger.warn?.(`openviking: auto-recall context search failed: ${searchError}`);
|
|
269
|
+
}
|
|
270
|
+
const traceSearches = [
|
|
271
|
+
...searchPlan.skipped.map((skipped) => ({
|
|
272
|
+
resourceType: skipped.resourceType,
|
|
273
|
+
limit: recallLimit,
|
|
274
|
+
scoreThreshold,
|
|
275
|
+
durationMs: 0,
|
|
276
|
+
total: 0,
|
|
277
|
+
results: [],
|
|
278
|
+
error: skipped.reason,
|
|
279
|
+
})),
|
|
280
|
+
...searchPlan.searches.map((search) => {
|
|
281
|
+
const matchingEntries = entries.filter((entry) => search.contextType === "resource"
|
|
282
|
+
? traceResourceType(entry) === "resource"
|
|
283
|
+
: traceResourceType(entry) !== "resource");
|
|
284
|
+
return {
|
|
285
|
+
resourceType: search.resourceType,
|
|
286
|
+
contextType: search.contextType,
|
|
287
|
+
limit: recallLimit,
|
|
288
|
+
scoreThreshold,
|
|
289
|
+
durationMs,
|
|
290
|
+
total: matchingEntries.length,
|
|
291
|
+
results: matchingEntries
|
|
292
|
+
.map(toTraceResult)
|
|
293
|
+
.slice(0, cfg.traceRecallMaxResultsPerSearch),
|
|
294
|
+
error: searchError,
|
|
295
|
+
};
|
|
296
|
+
}),
|
|
297
|
+
];
|
|
298
|
+
const candidateCount = typeof contextResult?.stats?.candidates === "number"
|
|
299
|
+
? contextResult.stats.candidates
|
|
300
|
+
: entries.length;
|
|
301
|
+
const recordTrace = async (injectedEntries, injectedCount, estimatedTokens) => {
|
|
306
302
|
const entry = {
|
|
307
303
|
schemaVersion: "1.0",
|
|
308
304
|
traceId: newTraceId(),
|
|
@@ -321,17 +317,17 @@ export async function buildAutoRecallContext(params) {
|
|
|
321
317
|
queryTruncated: params.queryTruncated || queryText.length > cfg.traceRecallQueryMaxChars,
|
|
322
318
|
},
|
|
323
319
|
searches: traceSearches,
|
|
324
|
-
selected:
|
|
325
|
-
uri:
|
|
326
|
-
resourceType:
|
|
327
|
-
category:
|
|
328
|
-
score:
|
|
329
|
-
abstractPreview: preview(
|
|
320
|
+
selected: injectedEntries.map((contextEntry) => ({
|
|
321
|
+
uri: contextEntry.uri,
|
|
322
|
+
resourceType: traceResourceType(contextEntry),
|
|
323
|
+
category: contextEntry.category,
|
|
324
|
+
score: contextEntry.score,
|
|
325
|
+
abstractPreview: preview(contextEntry.text, cfg.traceRecallPreviewChars),
|
|
330
326
|
injected: true,
|
|
331
327
|
})),
|
|
332
328
|
stats: {
|
|
333
|
-
candidateCount
|
|
334
|
-
selectedCount:
|
|
329
|
+
candidateCount,
|
|
330
|
+
selectedCount: injectedEntries.length,
|
|
335
331
|
injectedCount,
|
|
336
332
|
estimatedTokens,
|
|
337
333
|
},
|
|
@@ -339,24 +335,27 @@ export async function buildAutoRecallContext(params) {
|
|
|
339
335
|
// Trace persistence is diagnostic best-effort; never put JSONL flush latency on the auto-recall critical path.
|
|
340
336
|
params.traceRecorder?.record(entry);
|
|
341
337
|
};
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
return { memoryCount: 0, estimatedTokens: 0 };
|
|
345
|
-
}
|
|
346
|
-
const { lines: memoryLines, estimatedTokens } = await buildMemoryLinesWithBudget(memories, (uri) => client.read(uri, actorPeerId), {
|
|
347
|
-
recallPreferAbstract,
|
|
348
|
-
recallMaxInjectedChars: maxInjectedChars,
|
|
349
|
-
includeUri: true,
|
|
350
|
-
});
|
|
351
|
-
if (memoryLines.length === 0) {
|
|
352
|
-
verbose?.(`openviking: skipping auto-recall injection; no complete memories fit maxInjectedChars=${maxInjectedChars}`);
|
|
338
|
+
const rendered = contextResult?.rendered?.trim() ?? "";
|
|
339
|
+
if (requestError || entries.length === 0 || !rendered) {
|
|
353
340
|
await recordTrace([], 0, 0);
|
|
354
341
|
return { memoryCount: 0, estimatedTokens: 0 };
|
|
355
342
|
}
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
343
|
+
const usedTokens = contextResult?.stats?.used_tokens;
|
|
344
|
+
const estimatedTokens = typeof usedTokens === "number" && Number.isFinite(usedTokens)
|
|
345
|
+
? Math.max(0, Math.ceil(usedTokens))
|
|
346
|
+
: estimateTokenCount(rendered);
|
|
347
|
+
const block = buildRecallContextBlock([rendered]);
|
|
348
|
+
verbose?.(`openviking: injecting ${entries.length} memories (${block.length} chars, ~${estimatedTokens} tokens, serverMaxTokens=${maxTokens})`);
|
|
349
|
+
verbose?.(`openviking: inject-detail ${toJsonLog({
|
|
350
|
+
count: entries.length,
|
|
351
|
+
entries: entries.map((contextEntry) => ({
|
|
352
|
+
uri: contextEntry.uri,
|
|
353
|
+
category: contextEntry.category,
|
|
354
|
+
score: contextEntry.score,
|
|
355
|
+
detail: contextEntry.detail,
|
|
356
|
+
})),
|
|
357
|
+
})}`);
|
|
358
|
+
await recordTrace(entries, entries.length, estimatedTokens);
|
|
359
|
+
return { block, memoryCount: entries.length, estimatedTokens };
|
|
361
360
|
})(), cfg.autoRecallTimeoutMs, "openviking: auto-recall search timeout");
|
|
362
361
|
}
|
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/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;
|
|
@@ -547,7 +549,7 @@ export const memoryOpenVikingConfigSchema = {
|
|
|
547
549
|
label: "Auto-Recall Timeout (ms)",
|
|
548
550
|
placeholder: String(DEFAULT_AUTO_RECALL_TIMEOUT_MS),
|
|
549
551
|
advanced: true,
|
|
550
|
-
help: "Outer time budget for the
|
|
552
|
+
help: "Outer time budget for the server-assembled context search, including session-aware query expansion.",
|
|
551
553
|
},
|
|
552
554
|
recallResources: {
|
|
553
555
|
label: "Recall Resources",
|
|
@@ -574,7 +576,7 @@ export const memoryOpenVikingConfigSchema = {
|
|
|
574
576
|
label: "Recall Max Injected Chars",
|
|
575
577
|
placeholder: String(DEFAULT_RECALL_MAX_INJECTED_CHARS),
|
|
576
578
|
advanced: true,
|
|
577
|
-
help: "
|
|
579
|
+
help: "Legacy auto-recall character budget. It is converted to the server context token budget at four characters per token.",
|
|
578
580
|
},
|
|
579
581
|
recallMaxContentChars: {
|
|
580
582
|
label: "Deprecated Recall Max Content Chars",
|
|
@@ -585,7 +587,7 @@ export const memoryOpenVikingConfigSchema = {
|
|
|
585
587
|
recallPreferAbstract: {
|
|
586
588
|
label: "Recall Prefer Abstract",
|
|
587
589
|
advanced: true,
|
|
588
|
-
help: "
|
|
590
|
+
help: "Pin server-assembled auto-recall entries to abstract detail. When disabled, the server chooses each category's default detail tier.",
|
|
589
591
|
},
|
|
590
592
|
recallTokenBudget: {
|
|
591
593
|
label: "Deprecated Recall Token Budget",
|
|
@@ -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
|
+
}
|