@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.
@@ -1,9 +1,11 @@
1
- import { pickMemoriesForInjection, postProcessMemories, summarizeInjectionMemories, toJsonLog, } from "./memory-ranking.js";
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 toTraceResults(items, resourceType) {
139
- return items.map((item) => ({
140
- uri: item.uri,
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: item.category,
143
- score: item.score,
144
- level: item.level,
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 traceSearches = searchPlan.skipped.map((skipped) => ({
224
- resourceType: skipped.resourceType,
225
- limit: candidateLimit,
226
- scoreThreshold: 0,
227
- durationMs: 0,
228
- total: 0,
229
- results: [],
230
- error: skipped.reason,
231
- }));
232
- const autoRecallPromises = searchPlan.searches.map(async (search) => {
233
- const start = Date.now();
234
- const result = await client.find(queryText, {
235
- targetUri: search.targetUri,
236
- limit: candidateLimit,
237
- scoreThreshold: 0,
238
- contextType: search.contextType,
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
- const uniqueMemories = allMemories.filter((memory, index, self) => index === self.findIndex((m) => m.uri === memory.uri));
295
- const leafOnly = uniqueMemories.filter((m) => !m.level || m.level === 2);
296
- const processed = postProcessMemories(leafOnly, {
297
- limit: candidateLimit,
298
- scoreThreshold,
299
- });
300
- const memories = pickMemoriesForInjection(processed, recallLimit, queryText, scoreThreshold, queryConfig ? {
301
- weights: queryConfig.rankingWeights,
302
- categoryWeights: queryConfig.categoryWeights,
303
- resourceTypeWeights: queryConfig.resourceTypeWeights,
304
- } : undefined);
305
- const recordTrace = async (injectedMemories, injectedCount, estimatedTokens) => {
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: injectedMemories.map((memory) => ({
325
- uri: memory.uri,
326
- resourceType: memory.uri.startsWith("viking://resources") ? "resource" : undefined,
327
- category: memory.category,
328
- score: memory.score,
329
- abstractPreview: preview(memory.abstract ?? memory.overview, cfg.traceRecallPreviewChars),
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: allMemories.length,
334
- selectedCount: injectedMemories.length,
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
- if (memories.length === 0) {
343
- await recordTrace([], 0, 0);
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 block = buildRecallContextBlock(memoryLines);
357
- verbose?.(`openviking: injecting ${memoryLines.length} memories (${block.length} chars, ~${estimatedTokens} tokens, maxInjectedChars=${maxInjectedChars})`);
358
- verbose?.(`openviking: inject-detail ${toJsonLog({ count: memories.length, memories: summarizeInjectionMemories(memories) })}`);
359
- await recordTrace(memories.slice(0, memoryLines.length), memoryLines.length, estimatedTokens);
360
- return { block, memoryCount: memoryLines.length, estimatedTokens };
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
- const DEFAULT_AUTO_RECALL_TIMEOUT_MS = 5000;
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 whole auto-recall flow, including search, ranking, and memory reads.",
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: "Maximum total characters for auto-recall memory injection. Complete memories that do not fit are skipped, not truncated.",
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: "Use memory abstract instead of fetching full content when abstract is available. Reduces token usage.",
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
+ }