@openviking/dsh-memory-plugin 0.1.0
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 +129 -0
- package/capture.mjs +104 -0
- package/client.mjs +206 -0
- package/config.mjs +161 -0
- package/cordis.patch.yml +9 -0
- package/index.mjs +56 -0
- package/lifecycle.mjs +8 -0
- package/package.json +60 -0
- package/runtime.mjs +316 -0
- package/shared/capture-utils.mjs +515 -0
- package/shared/credentials.mjs +228 -0
- package/shared/debug-log.mjs +58 -0
- package/shared/pending-queue.mjs +451 -0
- package/shared/plugin-config.mjs +90 -0
- package/shared/profile-inject.mjs +294 -0
- package/shared/recall-compress-core.mjs +195 -0
- package/shared/recall-core.mjs +609 -0
- package/shared/retryable.mjs +9 -0
- package/shared/session-model.mjs +57 -0
- package/shared/setup-wizard.mjs +111 -0
- package/shared/uri-guard.mjs +56 -0
- package/shared/workspace-peer.mjs +16 -0
- package/tools.mjs +246 -0
- package/uri-guard.mjs +51 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
|
|
2
|
+
/**
|
|
3
|
+
* Session-start profile injection helper.
|
|
4
|
+
*
|
|
5
|
+
* Builds a <user-profile> + <available-memories> block from
|
|
6
|
+
* viking://user/<space>/memories/profile.md
|
|
7
|
+
* viking://user/<space>/memories/preferences/ (ls with abstracts)
|
|
8
|
+
* viking://user/<space>/memories/entities/ (ls with abstracts)
|
|
9
|
+
*
|
|
10
|
+
* Budget enforced via the CJK-aware estimateTokens() below — codepoint >=
|
|
11
|
+
* 0x3000 counts at 1.5 tokens, else chars/4. The estimator is exported so
|
|
12
|
+
* callers (e.g. session-start.mjs) can log token counts that match what
|
|
13
|
+
* the budget logic actually sees.
|
|
14
|
+
*
|
|
15
|
+
* Returned block is the *inner* content only (no outer <openviking-context>);
|
|
16
|
+
* session-start.mjs composes the outer wrapper so the archive block can sit
|
|
17
|
+
* alongside in a single context envelope.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const USER_RESERVED_DIRS = new Set(["memories"]);
|
|
21
|
+
let _userSpaceCache = null;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Mirrors auto-recall.mjs resolveScopeSpace for scope="user" (lines 123-147).
|
|
25
|
+
* Duplicated rather than imported to avoid coupling session-start to
|
|
26
|
+
* auto-recall's module-level fetchJSON closure.
|
|
27
|
+
*/
|
|
28
|
+
async function resolveUserSpace(fetchJSON, actorPeerId = "") {
|
|
29
|
+
if (_userSpaceCache) return _userSpaceCache;
|
|
30
|
+
|
|
31
|
+
let fallbackSpace = "default";
|
|
32
|
+
const status = await fetchJSON("/api/v1/system/status");
|
|
33
|
+
if (status.ok && typeof status.result?.user === "string" && status.result.user.trim()) {
|
|
34
|
+
fallbackSpace = status.result.user.trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const lsRes = await fetchJSON(
|
|
38
|
+
`/api/v1/fs/ls?uri=${encodeURIComponent("viking://user")}&output=original`,
|
|
39
|
+
{},
|
|
40
|
+
{ actorPeerId },
|
|
41
|
+
);
|
|
42
|
+
if (lsRes.ok && Array.isArray(lsRes.result)) {
|
|
43
|
+
const spaces = lsRes.result
|
|
44
|
+
.filter((e) => e?.isDir)
|
|
45
|
+
.map((e) => (typeof e.name === "string" ? e.name.trim() : ""))
|
|
46
|
+
.filter((n) => n && !n.startsWith(".") && !USER_RESERVED_DIRS.has(n));
|
|
47
|
+
if (spaces.length > 0) {
|
|
48
|
+
if (spaces.includes(fallbackSpace)) { _userSpaceCache = fallbackSpace; return fallbackSpace; }
|
|
49
|
+
if (spaces.includes("default")) { _userSpaceCache = "default"; return "default"; }
|
|
50
|
+
if (spaces.length === 1) { _userSpaceCache = spaces[0]; return spaces[0]; }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
_userSpaceCache = fallbackSpace;
|
|
54
|
+
return fallbackSpace;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Token estimate that splits CJK from the rest. The rest of the plugin uses a
|
|
59
|
+
* flat chars/4 heuristic, which silently undercounts CJK content by 4-6× and
|
|
60
|
+
* makes a "5000 token budget" really worth ~1k real tokens for Chinese text.
|
|
61
|
+
*
|
|
62
|
+
* Rule:
|
|
63
|
+
* - codepoint >= 0x3000 → CJK / Hiragana / Katakana / Hangul / CJK
|
|
64
|
+
* punctuation / fullwidth ASCII; counted at 1.5 tokens/char (empirical
|
|
65
|
+
* average for cl100k_base on Chinese; Claude's tokenizer is in the same
|
|
66
|
+
* ballpark).
|
|
67
|
+
* - everything else → chars/4 (the standard English-ish heuristic).
|
|
68
|
+
*
|
|
69
|
+
* Single linear pass, no dependencies. Errs on the side of overcounting CJK
|
|
70
|
+
* by ~10-20% in the worst case (some common Chinese phrases compress better
|
|
71
|
+
* than 1.5 tokens/char), which is the safe direction for budget enforcement.
|
|
72
|
+
*/
|
|
73
|
+
export function estimateTokens(text) {
|
|
74
|
+
if (!text) return 0;
|
|
75
|
+
let cjk = 0;
|
|
76
|
+
for (let i = 0; i < text.length; i++) {
|
|
77
|
+
if (text.charCodeAt(i) >= 0x3000) cjk++;
|
|
78
|
+
}
|
|
79
|
+
const other = text.length - cjk;
|
|
80
|
+
return Math.ceil(cjk * 1.5 + other / 4);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Convert a token budget to a max-chars budget that respects this content's
|
|
85
|
+
* actual CJK density. Avoids the chars/4 trap where a "5000 token" sub-cap
|
|
86
|
+
* yields 20000 chars of pure-CJK text → 30000 actual tokens (6× over).
|
|
87
|
+
*
|
|
88
|
+
* For an empty/missing string, returns 0.
|
|
89
|
+
*/
|
|
90
|
+
function tokensToCharsBudget(content, maxTokens) {
|
|
91
|
+
if (!content) return 0;
|
|
92
|
+
let cjk = 0;
|
|
93
|
+
for (let i = 0; i < content.length; i++) {
|
|
94
|
+
if (content.charCodeAt(i) >= 0x3000) cjk++;
|
|
95
|
+
}
|
|
96
|
+
const ratio = cjk / content.length;
|
|
97
|
+
const tokensPerChar = ratio * 1.5 + (1 - ratio) * 0.25;
|
|
98
|
+
return Math.floor(maxTokens / Math.max(tokensPerChar, 0.25));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function readProfile(fetchJSON, profileUri, actorPeerId = "") {
|
|
102
|
+
const res = await fetchJSON(
|
|
103
|
+
`/api/v1/content/read?uri=${encodeURIComponent(profileUri)}`,
|
|
104
|
+
{},
|
|
105
|
+
{ actorPeerId },
|
|
106
|
+
);
|
|
107
|
+
if (!res.ok || typeof res.result !== "string") return null;
|
|
108
|
+
const trimmed = res.result.trim();
|
|
109
|
+
return trimmed || null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Recursive ls of a memory directory, flattening to .md leaves.
|
|
114
|
+
*
|
|
115
|
+
* Memory layout under preferences/ and entities/ is two-level:
|
|
116
|
+
* <dir>/<owner_name>/<topic>.md
|
|
117
|
+
* so we use the server's recursive=true flag and filter to leaf .md files.
|
|
118
|
+
* `rel_path` (e.g. "zhengxiao.wu/pr_workflow.md") is preserved as display name
|
|
119
|
+
* to keep owner-namespacing visible and unambiguous when multiple owners exist.
|
|
120
|
+
*/
|
|
121
|
+
async function lsDir(fetchJSON, dirUri, actorPeerId = "") {
|
|
122
|
+
const url = `/api/v1/fs/ls?uri=${encodeURIComponent(dirUri)}&output=agent&recursive=true&abs_limit=512&node_limit=512`;
|
|
123
|
+
const res = await fetchJSON(url, {}, { actorPeerId });
|
|
124
|
+
if (!res.ok || !Array.isArray(res.result)) return [];
|
|
125
|
+
return res.result
|
|
126
|
+
.filter((e) => !e.isDir)
|
|
127
|
+
.map((e) => {
|
|
128
|
+
const rel = typeof e.rel_path === "string" && e.rel_path
|
|
129
|
+
? e.rel_path
|
|
130
|
+
: (typeof e.name === "string" ? e.name : "");
|
|
131
|
+
return {
|
|
132
|
+
name: rel,
|
|
133
|
+
abstract: typeof e.abstract === "string" ? e.abstract.trim() : "",
|
|
134
|
+
};
|
|
135
|
+
})
|
|
136
|
+
.filter((e) => e.name && e.name.endsWith(".md"))
|
|
137
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* When profile exceeds its sub-cap, keep the head (identity block + first
|
|
142
|
+
* timeline events) and the tail (most-recent events), drop the middle. This
|
|
143
|
+
* preserves both stable identity facts (top of file) and most-recent activity
|
|
144
|
+
* (bottom of file) — only the noisy middle timeline is sacrificed.
|
|
145
|
+
*
|
|
146
|
+
* Layout:
|
|
147
|
+
* <first HEAD_LINES lines>
|
|
148
|
+
* ... [profile middle elided] ...
|
|
149
|
+
* <as many trailing lines as fit in remaining budget>
|
|
150
|
+
*
|
|
151
|
+
* Falls back to head-only truncate when the file is too short to elide
|
|
152
|
+
* meaningfully (<HEAD_LINES + 4 lines) or the budget is too tight to fit
|
|
153
|
+
* both head and a useful tail.
|
|
154
|
+
*/
|
|
155
|
+
function elideProfile(content, maxTokens) {
|
|
156
|
+
// Compute char budget from token budget using *this* content's CJK density,
|
|
157
|
+
// not chars/4 — otherwise the truncated string can still blow the token cap
|
|
158
|
+
// for CJK-heavy profiles (Copilot review point).
|
|
159
|
+
const maxChars = Math.max(400, tokensToCharsBudget(content, maxTokens));
|
|
160
|
+
if (estimateTokens(content) <= maxTokens) return content;
|
|
161
|
+
|
|
162
|
+
const HEAD_LINES = 8;
|
|
163
|
+
const ELLIPSIS = "\n... [profile middle elided] ...\n";
|
|
164
|
+
const lines = content.split("\n");
|
|
165
|
+
|
|
166
|
+
const fallbackHeadTruncate = () =>
|
|
167
|
+
content.slice(0, maxChars).trimEnd() + "\n... [profile truncated]";
|
|
168
|
+
|
|
169
|
+
if (lines.length <= HEAD_LINES + 4) return fallbackHeadTruncate();
|
|
170
|
+
|
|
171
|
+
const head = lines.slice(0, HEAD_LINES).join("\n");
|
|
172
|
+
const reserveForTail = maxChars - head.length - ELLIPSIS.length;
|
|
173
|
+
if (reserveForTail < 200) return fallbackHeadTruncate();
|
|
174
|
+
|
|
175
|
+
let tailChars = 0;
|
|
176
|
+
let tailStart = lines.length;
|
|
177
|
+
for (let i = lines.length - 1; i > HEAD_LINES; i--) {
|
|
178
|
+
const lineLen = lines[i].length + 1;
|
|
179
|
+
if (tailChars + lineLen > reserveForTail) break;
|
|
180
|
+
tailChars += lineLen;
|
|
181
|
+
tailStart = i;
|
|
182
|
+
}
|
|
183
|
+
if (tailStart >= lines.length - 1) return fallbackHeadTruncate();
|
|
184
|
+
|
|
185
|
+
return `${head}${ELLIPSIS}${lines.slice(tailStart).join("\n")}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function formatListing(headerUri, entries, budgetTokens) {
|
|
189
|
+
if (entries.length === 0) return { lines: [], used: 0, dropped: 0 };
|
|
190
|
+
// Header is the full directory URI; child lines are relative paths so the
|
|
191
|
+
// agent can reconstruct each leaf's full URI by concatenation while the
|
|
192
|
+
// listing itself stays compact.
|
|
193
|
+
const header = ` ${headerUri}/`;
|
|
194
|
+
const headerTokens = estimateTokens(header);
|
|
195
|
+
// If the header alone busts the budget, emit just a one-line stub instead
|
|
196
|
+
// of silently violating the cap (Copilot review point).
|
|
197
|
+
if (headerTokens > budgetTokens) {
|
|
198
|
+
const stub = ` ${headerUri}/ (${entries.length} entries, budget too tight; use \`memory_recall\`)`;
|
|
199
|
+
return { lines: [stub], used: estimateTokens(stub), dropped: entries.length };
|
|
200
|
+
}
|
|
201
|
+
const lines = [header];
|
|
202
|
+
let used = headerTokens;
|
|
203
|
+
for (let i = 0; i < entries.length; i++) {
|
|
204
|
+
const e = entries[i];
|
|
205
|
+
const desc = e.abstract
|
|
206
|
+
? ` — ${e.abstract.replace(/\s+/g, " ").slice(0, 200)}`
|
|
207
|
+
: "";
|
|
208
|
+
const line = ` - ${e.name}${desc}`;
|
|
209
|
+
const tokens = estimateTokens(line);
|
|
210
|
+
if (used + tokens > budgetTokens) {
|
|
211
|
+
const remaining = entries.length - i;
|
|
212
|
+
const tail = ` ... +${remaining} more, use \`memory_recall\``;
|
|
213
|
+
const tailTokens = estimateTokens(tail);
|
|
214
|
+
// Only emit the tail if it fits; otherwise the listing closes silently
|
|
215
|
+
// rather than violating the cap to advertise its own truncation.
|
|
216
|
+
if (used + tailTokens <= budgetTokens) {
|
|
217
|
+
lines.push(tail);
|
|
218
|
+
return { lines, used: used + tailTokens, dropped: remaining };
|
|
219
|
+
}
|
|
220
|
+
return { lines, used, dropped: remaining };
|
|
221
|
+
}
|
|
222
|
+
lines.push(line);
|
|
223
|
+
used += tokens;
|
|
224
|
+
}
|
|
225
|
+
return { lines, used, dropped: 0 };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Build the profile injection block.
|
|
230
|
+
*
|
|
231
|
+
* Returns null when neither profile.md nor either listing has any content.
|
|
232
|
+
* The returned `block` is just the inner <user-profile>/<available-memories>
|
|
233
|
+
* payload — the caller wraps it in <openviking-context source="...">.
|
|
234
|
+
*
|
|
235
|
+
* @param {Function} fetchJSON ov-session.mjs:makeFetchJSON closure
|
|
236
|
+
* @param {number} totalBudgetTokens chars/4 budget, total for the whole block
|
|
237
|
+
* @returns {Promise<null | {
|
|
238
|
+
* block: string, chars: number, tokens: number, profileUri: string,
|
|
239
|
+
* profileChars: number, prefCount: number, entCount: number,
|
|
240
|
+
* droppedPref: number, droppedEnt: number,
|
|
241
|
+
* }>}
|
|
242
|
+
*/
|
|
243
|
+
export async function buildProfileBlock(fetchJSON, totalBudgetTokens, actorPeerId = "") {
|
|
244
|
+
const space = await resolveUserSpace(fetchJSON, actorPeerId);
|
|
245
|
+
const profileUri = `viking://user/${space}/memories/profile.md`;
|
|
246
|
+
const prefUri = `viking://user/${space}/memories/preferences`;
|
|
247
|
+
const entUri = `viking://user/${space}/memories/entities`;
|
|
248
|
+
|
|
249
|
+
const [profile, prefs, ents] = await Promise.all([
|
|
250
|
+
readProfile(fetchJSON, profileUri, actorPeerId),
|
|
251
|
+
lsDir(fetchJSON, prefUri, actorPeerId),
|
|
252
|
+
lsDir(fetchJSON, entUri, actorPeerId),
|
|
253
|
+
]);
|
|
254
|
+
|
|
255
|
+
if (!profile && prefs.length === 0 && ents.length === 0) return null;
|
|
256
|
+
|
|
257
|
+
// Profile gets up to half the total budget; listings split the rest.
|
|
258
|
+
// Sub-cap protects against a runaway profile blowing the listing budget.
|
|
259
|
+
const profileBudget = Math.floor(totalBudgetTokens / 2);
|
|
260
|
+
const profileTrunc = profile ? elideProfile(profile, profileBudget) : null;
|
|
261
|
+
const profileTokens = estimateTokens(profileTrunc || "");
|
|
262
|
+
|
|
263
|
+
const listingBudget = Math.max(0, totalBudgetTokens - profileTokens);
|
|
264
|
+
const halfListing = Math.floor(listingBudget / 2);
|
|
265
|
+
const prefBlock = formatListing(prefUri, prefs, halfListing);
|
|
266
|
+
const entBudget = Math.max(0, listingBudget - prefBlock.used);
|
|
267
|
+
const entBlock = formatListing(entUri, ents, entBudget);
|
|
268
|
+
|
|
269
|
+
const lines = [];
|
|
270
|
+
if (profileTrunc) {
|
|
271
|
+
lines.push(`<user-profile uri="${profileUri}">`);
|
|
272
|
+
lines.push(profileTrunc);
|
|
273
|
+
lines.push(`</user-profile>`);
|
|
274
|
+
}
|
|
275
|
+
if (prefBlock.lines.length > 0 || entBlock.lines.length > 0) {
|
|
276
|
+
lines.push(`<available-memories>`);
|
|
277
|
+
lines.push(...prefBlock.lines);
|
|
278
|
+
lines.push(...entBlock.lines);
|
|
279
|
+
lines.push(`</available-memories>`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const block = lines.join("\n");
|
|
283
|
+
return {
|
|
284
|
+
block,
|
|
285
|
+
chars: block.length,
|
|
286
|
+
tokens: estimateTokens(block),
|
|
287
|
+
profileUri,
|
|
288
|
+
profileChars: profile?.length ?? 0,
|
|
289
|
+
prefCount: prefs.length,
|
|
290
|
+
entCount: ents.length,
|
|
291
|
+
droppedPref: prefBlock.dropped,
|
|
292
|
+
droppedEnt: entBlock.dropped,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
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
|
+
|
|
6
|
+
export const NO_RELEVANT_MEMORY = "NO_RELEVANT_MEMORY";
|
|
7
|
+
export const DIGEST_HEADER = "OpenViking memory digest:";
|
|
8
|
+
const COMPRESS_OK = "ok";
|
|
9
|
+
const COMPRESS_EMPTY = "empty";
|
|
10
|
+
const COMPRESS_FAILED = "failed";
|
|
11
|
+
|
|
12
|
+
// Medium-constraint prompt: state the goal and two structural floors, but no hard
|
|
13
|
+
// bullet-length contract. Hard per-bullet limits pin the digest to headline
|
|
14
|
+
// density; leaving it unconstrained lets small models rewrite long URIs into dead
|
|
15
|
+
// links, which the URI repair below cleans up.
|
|
16
|
+
export function buildRecallCompressionPrompt({ query, rendered, maxBullets = 6 }) {
|
|
17
|
+
return `You are a memory relevance compressor utility.
|
|
18
|
+
Do not use any tools. Do not investigate. Only transform the given text.
|
|
19
|
+
|
|
20
|
+
User query:
|
|
21
|
+
${query}
|
|
22
|
+
|
|
23
|
+
Retrieved OpenViking context fragments:
|
|
24
|
+
${rendered}
|
|
25
|
+
|
|
26
|
+
Write a memory digest for a coding agent about to answer that query. Keep the
|
|
27
|
+
concrete facts (paths, identifiers, decisions, constraints); drop pleasantries
|
|
28
|
+
and conversational filler.
|
|
29
|
+
|
|
30
|
+
Format rules:
|
|
31
|
+
- Group related facts by topic, one bullet per topic, at most ${maxBullets} bullets.
|
|
32
|
+
- Start every bullet with "- ".
|
|
33
|
+
- End every bullet with its source, copied verbatim from the fragments above:
|
|
34
|
+
"来源:viking://..." or "source: viking://...". Never edit, shorten, or invent a URI.
|
|
35
|
+
- Output the digest body only. No preamble, no closing remark.
|
|
36
|
+
|
|
37
|
+
If nothing above is relevant to the query, output exactly: ${NO_RELEVANT_MEMORY}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function editDistance(a, b) {
|
|
41
|
+
if (a === b) return 0;
|
|
42
|
+
const rows = a.length + 1;
|
|
43
|
+
const cols = b.length + 1;
|
|
44
|
+
let prev = Array.from({ length: cols }, (_, i) => i);
|
|
45
|
+
for (let i = 1; i < rows; i += 1) {
|
|
46
|
+
const cur = [i];
|
|
47
|
+
for (let j = 1; j < cols; j += 1) {
|
|
48
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
49
|
+
cur[j] = Math.min(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
50
|
+
}
|
|
51
|
+
prev = cur;
|
|
52
|
+
}
|
|
53
|
+
return prev[cols - 1];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function nearestUri(candidate, validUris) {
|
|
57
|
+
let best = "";
|
|
58
|
+
let bestDistance = Infinity;
|
|
59
|
+
for (const uri of validUris) {
|
|
60
|
+
const distance = editDistance(candidate, uri);
|
|
61
|
+
if (distance < bestDistance) {
|
|
62
|
+
best = uri;
|
|
63
|
+
bestDistance = distance;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Only repair near-misses; an unrelated hallucination is dropped instead.
|
|
67
|
+
const tolerance = Math.max(4, Math.floor(candidate.length * 0.25));
|
|
68
|
+
return bestDistance <= tolerance ? best : "";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Small models occasionally mangle long URIs. Snap every cited URI back onto the
|
|
73
|
+
* set the server actually returned, and drop bullets whose citation cannot be
|
|
74
|
+
* recovered so the digest never carries a dead link.
|
|
75
|
+
*/
|
|
76
|
+
export function repairDigestUris(digest, validUris = []) {
|
|
77
|
+
const text = String(digest || "");
|
|
78
|
+
if (!text) return "";
|
|
79
|
+
const valid = validUris.map((uri) => String(uri || "").trim()).filter(Boolean);
|
|
80
|
+
if (!valid.length) return text;
|
|
81
|
+
const validSet = new Set(valid);
|
|
82
|
+
|
|
83
|
+
const lines = [];
|
|
84
|
+
for (const line of text.split(/\r?\n/)) {
|
|
85
|
+
if (!line.trim().startsWith("- ")) {
|
|
86
|
+
lines.push(line);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
let dropped = false;
|
|
90
|
+
const repaired = line.replace(/viking:\/\/[^\s<>"')\]]+/g, (uri) => {
|
|
91
|
+
if (validSet.has(uri)) return uri;
|
|
92
|
+
const nearest = nearestUri(uri, valid);
|
|
93
|
+
if (nearest) return nearest;
|
|
94
|
+
dropped = true;
|
|
95
|
+
return uri;
|
|
96
|
+
});
|
|
97
|
+
if (!dropped) lines.push(repaired);
|
|
98
|
+
}
|
|
99
|
+
return lines.join("\n").trim();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function normalizeCompressedContext(raw, maxChars = 4000, maxBullets = 6) {
|
|
103
|
+
const text = String(raw || "").trim();
|
|
104
|
+
if (!text) return null;
|
|
105
|
+
if (text.toUpperCase() === NO_RELEVANT_MEMORY) return "";
|
|
106
|
+
const bullets = text.split(/\r?\n/)
|
|
107
|
+
.map((line) => line.trim())
|
|
108
|
+
.filter((line) => /^[-*]\s+/.test(line) && line.includes("viking://"))
|
|
109
|
+
.slice(0, Math.max(1, maxBullets))
|
|
110
|
+
.map((line) => `- ${line.replace(/^[-*]\s+/, "").slice(0, 500).trim()}`);
|
|
111
|
+
if (!bullets.length) return null;
|
|
112
|
+
return (`${DIGEST_HEADER}\n${bullets.join("\n")}`).slice(0, Math.max(100, maxChars));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function recallDigestCacheKey({
|
|
116
|
+
query = "",
|
|
117
|
+
rendered = "",
|
|
118
|
+
entries = [],
|
|
119
|
+
maxInputChars = 18000,
|
|
120
|
+
maxBullets = 6,
|
|
121
|
+
} = {}) {
|
|
122
|
+
const uris = entries.map((entry) => String(entry?.uri || "").trim()).filter(Boolean).sort();
|
|
123
|
+
const source = JSON.stringify({
|
|
124
|
+
version: 2,
|
|
125
|
+
query: String(query),
|
|
126
|
+
rendered: String(rendered).slice(0, maxInputChars),
|
|
127
|
+
uris,
|
|
128
|
+
maxInputChars,
|
|
129
|
+
maxBullets,
|
|
130
|
+
});
|
|
131
|
+
return createHash("sha256").update(source).digest("hex");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function readCache(path) {
|
|
135
|
+
if (!path) return null;
|
|
136
|
+
try { return JSON.parse(await readFile(path, "utf8")); } catch { return null; }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function writeCache(path, value) {
|
|
140
|
+
if (!path) 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
|
+
} catch { /* best effort */ }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function compressRecallContext({
|
|
150
|
+
query,
|
|
151
|
+
rendered,
|
|
152
|
+
entries = [],
|
|
153
|
+
cfg = {},
|
|
154
|
+
runCompressor,
|
|
155
|
+
cachePath = "",
|
|
156
|
+
now = 0,
|
|
157
|
+
}) {
|
|
158
|
+
const input = String(rendered || "").trim();
|
|
159
|
+
if (!input) return { status: COMPRESS_EMPTY, context: "" };
|
|
160
|
+
const minChars = Math.max(0, Number(cfg.recallCompressMinInputChars ?? 1500));
|
|
161
|
+
if (input.length < minChars) return { status: COMPRESS_OK, context: input };
|
|
162
|
+
|
|
163
|
+
const maxInputChars = Math.max(1000, Number(cfg.recallCompressMaxInputChars || 18000));
|
|
164
|
+
const maxBullets = Math.max(1, Number(cfg.recallCompressMaxBullets || 6));
|
|
165
|
+
const key = recallDigestCacheKey({
|
|
166
|
+
query,
|
|
167
|
+
rendered: input,
|
|
168
|
+
entries,
|
|
169
|
+
maxInputChars,
|
|
170
|
+
maxBullets,
|
|
171
|
+
});
|
|
172
|
+
const cached = await readCache(cachePath);
|
|
173
|
+
if (cached?.key === key && typeof cached.digest === "string") {
|
|
174
|
+
return { status: COMPRESS_OK, context: cached.digest };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const prompt = buildRecallCompressionPrompt({
|
|
178
|
+
query,
|
|
179
|
+
rendered: input.slice(0, maxInputChars),
|
|
180
|
+
maxBullets,
|
|
181
|
+
});
|
|
182
|
+
const raw = await runCompressor(prompt);
|
|
183
|
+
const normalized = normalizeCompressedContext(raw, 4000, maxBullets);
|
|
184
|
+
if (normalized === null) return { status: COMPRESS_FAILED, context: "" };
|
|
185
|
+
if (!normalized) return { status: COMPRESS_EMPTY, context: "" };
|
|
186
|
+
|
|
187
|
+
const validUris = entries.map((entry) => entry?.uri).filter(Boolean);
|
|
188
|
+
const digest = repairDigestUris(normalized, validUris.length
|
|
189
|
+
? validUris
|
|
190
|
+
: (input.match(/viking:\/\/[^\s<>"']+/g) || []));
|
|
191
|
+
if (!digest) return { status: COMPRESS_FAILED, context: "" };
|
|
192
|
+
|
|
193
|
+
await writeCache(cachePath, { key, digest, updatedAt: now || 0 });
|
|
194
|
+
return { status: COMPRESS_OK, context: digest };
|
|
195
|
+
}
|