@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.
@@ -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
+ }