@openviking/opencode-plugin 0.1.5 → 0.2.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,282 @@
1
+ // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ const PREFERENCE_QUERY_RE = /prefer|preference|favorite|favourite|like|偏好|喜欢|爱好|更倾向/i;
3
+ const TEMPORAL_QUERY_RE = /when|what time|date|day|month|year|yesterday|today|tomorrow|last|next|什么时候|何时|哪天|几月|几年|昨天|今天|明天/i;
4
+ const QUERY_TOKEN_RE = /[a-z0-9一-龥]{2,}/gi;
5
+ const STOPWORDS = new Set([
6
+ "what", "when", "where", "which", "who", "whom", "whose", "why", "how", "did", "does",
7
+ "is", "are", "was", "were", "the", "and", "for", "with", "from", "that", "this", "your", "you",
8
+ ]);
9
+ const USER_RESERVED_DIRS = new Set(["memories", "skills"]);
10
+ const SOURCES = [
11
+ { type: "memory", uri: "viking://user/memories", bucket: "memories" },
12
+ { type: "skill", uri: "viking://user/skills", bucket: "skills" },
13
+ ];
14
+
15
+ let userSpaceCache = "";
16
+
17
+ export function estimateTokens(text) {
18
+ return text ? Math.ceil(String(text).length / 4) : 0;
19
+ }
20
+
21
+ export function buildRecallEndpointBody(cfg = {}) {
22
+ const limit = Math.max(Number(cfg.recallLimit || 0), 1);
23
+ return {
24
+ query: "",
25
+ quotas: {
26
+ events: limit,
27
+ entities: limit,
28
+ preferences: Math.max(1, Math.min(limit, 3)),
29
+ experiences: 0,
30
+ },
31
+ max_chars: Math.max(Number(cfg.recallMaxContentChars || 0) * limit, 1000),
32
+ min_score: Number.isFinite(Number(cfg.scoreThreshold)) ? Number(cfg.scoreThreshold) : 0.35,
33
+ render: true,
34
+ };
35
+ }
36
+
37
+ function clampScore(v) {
38
+ if (typeof v !== "number" || Number.isNaN(v)) return 0;
39
+ return Math.max(0, Math.min(1, v));
40
+ }
41
+
42
+ function buildQueryProfile(query) {
43
+ const text = query.trim();
44
+ const allTokens = text.toLowerCase().match(QUERY_TOKEN_RE) || [];
45
+ return {
46
+ tokens: allTokens.filter((t) => !STOPWORDS.has(t)),
47
+ wantsPreference: PREFERENCE_QUERY_RE.test(text),
48
+ wantsTemporal: TEMPORAL_QUERY_RE.test(text),
49
+ };
50
+ }
51
+
52
+ function lexicalOverlapBoost(tokens, text) {
53
+ if (tokens.length === 0 || !text) return 0;
54
+ const haystack = ` ${text.toLowerCase()} `;
55
+ let matched = 0;
56
+ for (const token of tokens.slice(0, 8)) {
57
+ if (haystack.includes(token)) matched += 1;
58
+ }
59
+ return Math.min(0.2, (matched / Math.min(tokens.length, 4)) * 0.2);
60
+ }
61
+
62
+ function rankItem(item, profile) {
63
+ const base = clampScore(item.score);
64
+ const abstract = (item.abstract || item.overview || "").trim();
65
+ const cat = (item.category || "").toLowerCase();
66
+ const uri = (item.uri || "").toLowerCase();
67
+ const leafBoost = (item.level === 2 || uri.endsWith(".md")) ? 0.12 : 0;
68
+ const eventBoost = profile.wantsTemporal && (cat === "events" || uri.includes("/events/")) ? 0.1 : 0;
69
+ const prefBoost = profile.wantsPreference && (cat === "preferences" || uri.includes("/preferences/")) ? 0.08 : 0;
70
+ const overlapBoost = lexicalOverlapBoost(profile.tokens, `${item.uri} ${abstract}`);
71
+ return base + leafBoost + eventBoost + prefBoost + overlapBoost;
72
+ }
73
+
74
+ function isEventOrCaseItem(item) {
75
+ const cat = (item.category || "").toLowerCase();
76
+ const uri = (item.uri || "").toLowerCase();
77
+ return cat === "events" || cat === "cases" || uri.includes("/events/") || uri.includes("/cases/");
78
+ }
79
+
80
+ function dedupeItems(items) {
81
+ const seen = new Set();
82
+ const out = [];
83
+ for (const item of items) {
84
+ const key = isEventOrCaseItem(item)
85
+ ? `uri:${item.uri}`
86
+ : ((item.abstract || item.overview || "").trim().toLowerCase() || `uri:${item.uri}`);
87
+ if (seen.has(key)) continue;
88
+ seen.add(key);
89
+ out.push(item);
90
+ }
91
+ return out;
92
+ }
93
+
94
+ async function resolveUserSpace(fetchJSON, actorPeerId = "") {
95
+ if (userSpaceCache) return userSpaceCache;
96
+
97
+ let fallbackSpace = "default";
98
+ const status = await fetchJSON("/api/v1/system/status");
99
+ if (status.ok && typeof status.result?.user === "string" && status.result.user.trim()) {
100
+ fallbackSpace = status.result.user.trim();
101
+ }
102
+
103
+ const lsRes = await fetchJSON(
104
+ `/api/v1/fs/ls?uri=${encodeURIComponent("viking://user")}&output=original`,
105
+ {},
106
+ { actorPeerId },
107
+ );
108
+ if (lsRes.ok && Array.isArray(lsRes.result)) {
109
+ const spaces = lsRes.result
110
+ .filter((e) => e?.isDir)
111
+ .map((e) => (typeof e.name === "string" ? e.name.trim() : ""))
112
+ .filter((n) => n && !n.startsWith(".") && !USER_RESERVED_DIRS.has(n));
113
+ if (spaces.length > 0) {
114
+ if (spaces.includes(fallbackSpace)) { userSpaceCache = fallbackSpace; return fallbackSpace; }
115
+ if (spaces.includes("default")) { userSpaceCache = "default"; return "default"; }
116
+ if (spaces.length === 1) { userSpaceCache = spaces[0]; return spaces[0]; }
117
+ }
118
+ }
119
+ userSpaceCache = fallbackSpace;
120
+ return fallbackSpace;
121
+ }
122
+
123
+ async function resolveTargetUri(fetchJSON, targetUri, actorPeerId = "") {
124
+ const trimmed = targetUri.trim().replace(/\/+$/, "");
125
+ const m = trimmed.match(/^viking:\/\/user(?:\/(.*))?$/);
126
+ if (!m) return trimmed;
127
+ const rawRest = (m[1] ?? "").trim();
128
+ if (!rawRest) return trimmed;
129
+ const parts = rawRest.split("/").filter(Boolean);
130
+ if (parts.length === 0) return trimmed;
131
+ if (!USER_RESERVED_DIRS.has(parts[0])) return trimmed;
132
+ const space = await resolveUserSpace(fetchJSON, actorPeerId);
133
+ return `viking://user/${space}/${parts.join("/")}`;
134
+ }
135
+
136
+ async function searchOneSource(fetchJSON, query, source, limit, actorPeerId = "") {
137
+ const resolvedUri = await resolveTargetUri(fetchJSON, source.uri, actorPeerId);
138
+ const body = { query, target_uri: resolvedUri, limit, score_threshold: 0 };
139
+ const res = await fetchJSON("/api/v1/search/find", {
140
+ method: "POST",
141
+ body: JSON.stringify(body),
142
+ }, { actorPeerId });
143
+ if (!res.ok) return [];
144
+ const items = res.result?.[source.bucket] || [];
145
+ return items.map((item) => ({ ...item, _sourceType: source.type }));
146
+ }
147
+
148
+ async function searchAllSources(fetchJSON, query, perSourceLimit, actorPeerId = "", log = () => {}) {
149
+ const results = await Promise.all(
150
+ SOURCES.map((src) => searchOneSource(fetchJSON, query, src, perSourceLimit, actorPeerId)),
151
+ );
152
+ const all = results.flat();
153
+ log("recall_search_summary", {
154
+ counts: SOURCES.map((src, i) => ({ type: src.type, uri: src.uri, count: results[i].length })),
155
+ total: all.length,
156
+ });
157
+ return all;
158
+ }
159
+
160
+ async function resolveItemContent(fetchJSON, item, cfg, actorPeerId = "") {
161
+ let content;
162
+
163
+ if (cfg.recallPreferAbstract && (item.abstract || item.overview || "").trim()) {
164
+ content = (item.abstract || item.overview).trim();
165
+ } else if (item.level === 2) {
166
+ try {
167
+ const res = await fetchJSON(
168
+ `/api/v1/content/read?uri=${encodeURIComponent(item.uri)}`,
169
+ {},
170
+ { actorPeerId },
171
+ );
172
+ const body = res.ok && typeof res.result === "string" ? res.result.trim() : "";
173
+ content = body || (item.abstract || item.overview || "").trim() || item.uri;
174
+ } catch {
175
+ content = (item.abstract || item.overview || "").trim() || item.uri;
176
+ }
177
+ } else {
178
+ content = (item.abstract || item.overview || "").trim() || item.uri;
179
+ }
180
+
181
+ const maxChars = Math.max(50, Number(cfg.recallMaxContentChars || 500));
182
+ if (content.length > maxChars) content = `${content.slice(0, maxChars)}...`;
183
+ return content;
184
+ }
185
+
186
+ async function buildFallbackInjectionBlock(fetchJSON, items, cfg, actorPeerId = "", log = () => {}) {
187
+ if (items.length === 0) return null;
188
+
189
+ let budgetRemaining = Math.max(200, Number(cfg.recallTokenBudget || 2000));
190
+ const lines = [
191
+ "<openviking-context>",
192
+ "Relevant context from OpenViking. Use the read MCP tool to expand URIs.",
193
+ ];
194
+ let contentCount = 0;
195
+ let hintCount = 0;
196
+
197
+ for (const item of items) {
198
+ const score = (clampScore(item.score) * 100).toFixed(0);
199
+ const uriLine = `- [${item._sourceType} ${score}%] ${item.uri}`;
200
+
201
+ if (budgetRemaining > 0) {
202
+ const content = await resolveItemContent(fetchJSON, item, cfg, actorPeerId);
203
+ const contentLine = `- [${item._sourceType} ${score}%] ${content}`;
204
+ const lineTokens = estimateTokens(contentLine);
205
+
206
+ if (lineTokens > budgetRemaining && contentCount > 0) {
207
+ lines.push(uriLine);
208
+ hintCount++;
209
+ } else {
210
+ lines.push(contentLine);
211
+ budgetRemaining -= lineTokens;
212
+ contentCount++;
213
+ }
214
+ } else {
215
+ lines.push(uriLine);
216
+ hintCount++;
217
+ }
218
+ }
219
+
220
+ lines.push("</openviking-context>");
221
+
222
+ const budgetUsed = Math.max(200, Number(cfg.recallTokenBudget || 2000)) - budgetRemaining;
223
+ log("recall_injection_built", {
224
+ contentItems: contentCount,
225
+ hintItems: hintCount,
226
+ budgetUsed,
227
+ budgetTotal: Math.max(200, Number(cfg.recallTokenBudget || 2000)),
228
+ });
229
+
230
+ return lines.join("\n");
231
+ }
232
+
233
+ async function recallViaEndpoint(fetchJSON, cfg, query, actorPeerId = "", log = () => {}) {
234
+ const body = buildRecallEndpointBody(cfg);
235
+ body.query = query;
236
+ const res = await fetchJSON("/api/v1/search/recall", {
237
+ method: "POST",
238
+ body: JSON.stringify(body),
239
+ }, { actorPeerId });
240
+ if (!res.ok) {
241
+ log("recall_endpoint_fallback", { status: res.status || 0 });
242
+ return null;
243
+ }
244
+ const rendered = String(res.result?.rendered || "").trim();
245
+ if (!rendered) return "";
246
+ return [
247
+ "<openviking-context>",
248
+ "Relevant memory from OpenViking. Use the recall/read MCP tools to expand URIs.",
249
+ rendered,
250
+ "</openviking-context>",
251
+ ].join("\n");
252
+ }
253
+
254
+ export async function buildRecallBlock(fetchJSON, cfg, query, options = {}) {
255
+ const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
256
+ const log = options.log || (() => {});
257
+ const trimmed = String(query || "").trim();
258
+ if (!trimmed) return null;
259
+
260
+ const endpointBlock = await recallViaEndpoint(fetchJSON, cfg, trimmed, actorPeerId, log);
261
+ if (endpointBlock !== null) return endpointBlock || null;
262
+
263
+ const recallLimit = Math.max(1, Number(cfg.recallLimit || 6));
264
+ const perSourceLimit = Math.max(recallLimit * 2, 8);
265
+ const raw = await searchAllSources(fetchJSON, trimmed, perSourceLimit, actorPeerId, log);
266
+ if (raw.length === 0) return null;
267
+
268
+ const profile = buildQueryProfile(trimmed);
269
+ const scoreThreshold = Number.isFinite(Number(cfg.scoreThreshold)) ? Number(cfg.scoreThreshold) : 0.35;
270
+ const filtered = raw.filter((it) => clampScore(it.score) >= scoreThreshold);
271
+ filtered.sort((a, b) => rankItem(b, profile) - rankItem(a, profile));
272
+ const picked = dedupeItems(filtered).slice(0, recallLimit);
273
+ log("recall_picked", {
274
+ rawCount: raw.length,
275
+ filteredCount: filtered.length,
276
+ pickedCount: picked.length,
277
+ items: picked.map((it) => ({ type: it._sourceType, uri: it.uri, score: clampScore(it.score) })),
278
+ });
279
+
280
+ if (picked.length === 0) return null;
281
+ return buildFallbackInjectionBlock(fetchJSON, picked, cfg, actorPeerId, log);
282
+ }
@@ -0,0 +1,57 @@
1
+ // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ /**
3
+ * Shared OpenViking session-id helpers for memory plugin harnesses.
4
+ */
5
+
6
+ /**
7
+ * Glob -> RegExp. Minimal implementation: supports `*`, `**`, and literals.
8
+ */
9
+ function globToRe(glob) {
10
+ let re = "^";
11
+ for (let i = 0; i < glob.length; i++) {
12
+ const c = glob[i];
13
+ if (c === "*") {
14
+ if (glob[i + 1] === "*") { re += ".*"; i++; }
15
+ else re += "[^/]*";
16
+ } else if (/[.+?^${}()|[\]\\]/.test(c)) {
17
+ re += "\\" + c;
18
+ } else {
19
+ re += c;
20
+ }
21
+ }
22
+ re += "$";
23
+ return new RegExp(re);
24
+ }
25
+
26
+ export function isBypassed(cfg, { sessionId, cwd } = {}) {
27
+ if (cfg.bypassSession) return true;
28
+ const patterns = cfg.bypassSessionPatterns || [];
29
+ if (patterns.length === 0) return false;
30
+ const haystacks = [sessionId, cwd].filter(Boolean);
31
+ for (const pat of patterns) {
32
+ const re = globToRe(pat);
33
+ if (haystacks.some((h) => re.test(h))) return true;
34
+ }
35
+ return false;
36
+ }
37
+
38
+ function safeId(value, replacement = "_") {
39
+ return String(value || "unknown").replace(/[^A-Za-z0-9._-]/g, replacement);
40
+ }
41
+
42
+ export function deriveHarnessSessionId(prefix, sessionId, suffix = "") {
43
+ if (!prefix || typeof prefix !== "string") {
44
+ throw new Error("deriveHarnessSessionId requires a non-empty prefix");
45
+ }
46
+ if (!sessionId || typeof sessionId !== "string") {
47
+ throw new Error("deriveHarnessSessionId requires a non-empty sessionId");
48
+ }
49
+ const base = `${prefix}${sessionId}`;
50
+ if (!suffix) return base;
51
+ const normalized = String(suffix).replace(/:/g, "-").replace(/[^A-Za-z0-9._-]/g, "-");
52
+ return `${base}__${normalized}`;
53
+ }
54
+
55
+ export function deriveCodexSessionId(codexSessionId) {
56
+ return `cx-${safeId(codexSessionId, "_")}`;
57
+ }