@openviking/dsh-memory-plugin 0.4.3 → 0.5.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 +11 -8
- package/config.mjs +1 -1
- package/package.json +1 -1
- package/runtime.mjs +1 -0
- package/shared/config-schema.mjs +16 -1
- package/shared/plugin-config.mjs +7 -1
- package/shared/profile-inject.mjs +232 -31
- package/shared/recall-core.mjs +128 -87
- package/shared/uri-guard.mjs +38 -5
- package/skills/openviking-memory/SKILL.md +2 -0
- package/skills/openviking-skills/SKILL.md +198 -0
- package/skills.mjs +1 -1
- package/uri-guard.mjs +11 -5
package/README.md
CHANGED
|
@@ -196,14 +196,15 @@ The patch can also carry plugin config:
|
|
|
196
196
|
|
|
197
197
|
## Behavior
|
|
198
198
|
|
|
199
|
-
- `agent/session-start` injects the OpenViking profile
|
|
199
|
+
- `agent/session-start` injects the OpenViking profile, the available-memory index, and the `<available-skills>` catalog through `agent.inject()`.
|
|
200
200
|
- `agent/pre-step` retrieves with the current step input and appends a durable plugin message to that same step.
|
|
201
201
|
- `session/event` captures user, assistant, and optionally tool-result messages without scraping a transcript.
|
|
202
202
|
- `turn/end` checks the OpenViking pending-token threshold and commits when required.
|
|
203
203
|
- `skipSubagentSessions: true` excludes sessions marked with `header.origin: subagent` from automatic profile, recall, capture, and commit; it defaults to `false`.
|
|
204
204
|
- `syncTurns: false` stops every new write: no captured messages, no threshold or shutdown commit. Writes queued while the toggle was on are still replayed by the background drainer once the server recovers — they were captured with the toggle on. Profile injection and recall are unaffected; it defaults to `true`.
|
|
205
|
+
- `skillCatalog` (default `true`) and `skillCatalogTokenBudget` (default `1200`; `0` also turns the catalog off) govern `<available-skills>`. The catalog comes from one `GET /api/v1/skills?node_limit=200` call: the user's own skills first, then those shared under `viking://agent/skills` minus any whose name the user also owns, each description cut to about 40 tokens. Its budget is separate from `profileTokenBudget`. When the descriptions do not fit, the catalog lists names only (with a `... +N more` tail if even the names do not all fit); when not even one name fits, it shrinks to a one-line count; with no skills, or a server without the endpoint, it is omitted.
|
|
205
206
|
- Failed writes enter the shared OpenViking pending queue. A background drainer (default every 60s, `OPENVIKING_PENDING_DRAIN_INTERVAL_MS`) probes the server health and replays the queue in-process, so a transient write failure recovers without restarting dsh; it does not consume the session-start retry budget. Session-start replays keep consuming retries as before.
|
|
206
|
-
- `tools/pre-execute` denies a DSH filesystem tool (`read`, `glob`, `grep`, `edit`, `write`, `str_replace_editor`) whose path argument is a `viking://` URI, pointing the model at the bridged `mcp__openviking__*` tools instead. A `grep` whose pattern is `viking://` text still runs.
|
|
207
|
+
- `tools/pre-execute` denies a DSH filesystem tool (`read`, `glob`, `grep`, `edit`, `write`, `str_replace_editor`) whose path argument is a `viking://` URI, pointing the model at the bridged `mcp__openviking__*` tools instead. A `write` or `edit` under a skill directory (`viking://~/skills/...`, `viking://user/<id>/skills/...`, `viking://agent/skills/...`) points at `mcp__openviking__add_skill` instead, which creates or replaces a whole skill from its `SKILL.md` text. A `grep` whose pattern is `viking://` text still runs.
|
|
207
208
|
- `tools/post-execute` lets a `bash` command that carries a `viking://` URI run unchanged and attaches a notice for the model: use the bridged tools if it meant OpenViking content, or ignore the notice when the URI is intentional data such as an `ov` argument or an HTTP payload.
|
|
208
209
|
|
|
209
210
|
Each DSH session maps to `dsh-<session-id>` in OpenViking. Workspace-derived actor peers are resolved per session and sent on every session-specific request: the peer is the git identity of the session's workspace — the normalized `origin` URL (`git@github.com:volcengine/OpenViking.git` becomes `github.com-volcengine-openviking`), else the repository root path, that fallback keeping the older rule where every non-letter-or-digit character becomes `-`. Outside a git repository no peer is sent at all, and what is remembered there goes to the user-level space `viking://user/<you>/memories`. One repository therefore keeps one peer across subdirectories, worktrees, clones and machines, while a fork's different origin keeps it separate. DSH does not read workspace `.openviking/config.json` files, so a `peer.id` written there has no effect; pin a peer with `OPENVIKING_PEER_ID` instead. Memories written under the older path-derived peer stay reachable: the default `recallPeerScope: all` sweeps every peer under the user.
|
|
@@ -215,16 +216,18 @@ names — `mcp__openviking__search`, `mcp__openviking__read`,
|
|
|
215
216
|
`mcp__openviking__list`, `mcp__openviking__tree`, `mcp__openviking__grep`,
|
|
216
217
|
`mcp__openviking__glob`, `mcp__openviking__remember`,
|
|
217
218
|
`mcp__openviking__write`, `mcp__openviking__edit`,
|
|
218
|
-
`mcp__openviking__forget`, `mcp__openviking__add_resource`,
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
`mcp__openviking__forget`, `mcp__openviking__add_resource`,
|
|
220
|
+
`mcp__openviking__add_skill`, and the rest of whatever the connected server
|
|
221
|
+
advertises. The list re-syncs when the server announces a change, so a server
|
|
222
|
+
upgrade adds tools without a bundle release.
|
|
221
223
|
|
|
222
224
|
`mcp__openviking__forget` performs permanent deletion. The calling model should
|
|
223
225
|
use it only when the user explicitly requests deletion.
|
|
224
226
|
|
|
225
|
-
The bundle also serves the shared `openviking-memory`
|
|
226
|
-
through its own isolated `ctx.skills` provider, so the
|
|
227
|
-
guidance
|
|
227
|
+
The bundle also serves the shared `openviking-memory` and `openviking-skills`
|
|
228
|
+
skills from `skills/` through its own isolated `ctx.skills` provider, so the
|
|
229
|
+
model gets the same guidance the other integrations ship: when to search, read,
|
|
230
|
+
and write, and how to find, use, create, share, and migrate OpenViking skills.
|
|
228
231
|
|
|
229
232
|
## Testing
|
|
230
233
|
|
package/config.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildPluginConfig } from "./shared/plugin-config.mjs";
|
|
2
2
|
import { loadCredentialFiles } from "./shared/credentials.mjs";
|
|
3
3
|
|
|
4
|
-
export const PLUGIN_VERSION = "0.
|
|
4
|
+
export const PLUGIN_VERSION = "0.5.0";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Namespace for the bridged OpenViking MCP tools. DSH publishes every MCP tool
|
package/package.json
CHANGED
package/runtime.mjs
CHANGED
package/shared/config-schema.mjs
CHANGED
|
@@ -98,7 +98,7 @@ export const KNOBS = [
|
|
|
98
98
|
// off/client/server/auto through `normalizeRewriteMode`; Codex reads the same
|
|
99
99
|
// key as on/off. Both spellings stay declared so neither harness's users are
|
|
100
100
|
// told their config is a typo.
|
|
101
|
-
{ name: "recallCompress", type: "string", default: "auto", env: "OPENVIKING_RECALL_COMPRESS", aliases: ["recallRewrite"], capability: "recall" },
|
|
101
|
+
{ name: "recallCompress", type: "string", default: "off", harness: { claude_code: "auto", codex: "auto" }, env: "OPENVIKING_RECALL_COMPRESS", aliases: ["recallRewrite"], capability: "recall" },
|
|
102
102
|
{ name: "recallCompressModel", type: "string", default: "", env: "OPENVIKING_RECALL_COMPRESS_MODEL", capability: "recall" },
|
|
103
103
|
{ name: "recallCompressBaseUrl", type: "string", default: "", env: "OPENVIKING_RECALL_COMPRESS_BASE_URL", capability: "recall" },
|
|
104
104
|
{ name: "recallCompressThinking", type: "string", default: "", env: "OPENVIKING_RECALL_COMPRESS_THINKING", aliases: ["recallCompressReasoningEffort"], capability: "recall" },
|
|
@@ -152,6 +152,21 @@ export const KNOBS = [
|
|
|
152
152
|
{ name: "resumeArchiveMaxChars", type: "int", default: 6000, min: 1000, max: 200000, env: "OPENVIKING_RESUME_ARCHIVE_MAX_CHARS", capability: "session" },
|
|
153
153
|
{ name: "skillExperience", type: "bool", default: false, env: "OPENVIKING_SKILL_EXPERIENCE", capability: "session" },
|
|
154
154
|
{ name: "skillExperienceLimit", type: "int", default: 3, min: 1, max: 50, env: "OPENVIKING_SKILL_EXPERIENCE_LIMIT", capability: "session" },
|
|
155
|
+
{ name: "skillCatalog", type: "bool", default: true, env: "OPENVIKING_SKILL_CATALOG", capability: "session" },
|
|
156
|
+
{ name: "skillCatalogTokenBudget", type: "int", default: 1200, min: 0, max: 20000, env: "OPENVIKING_SKILL_CATALOG_TOKEN_BUDGET", capability: "session" },
|
|
157
|
+
// Claude Code saves hook context over 10,000 chars to a file and shows the
|
|
158
|
+
// model a 2 KB preview; Codex spills past ~10,000 bytes; ZCode drops stdout
|
|
159
|
+
// over 32 KB. 0 means no cap.
|
|
160
|
+
{
|
|
161
|
+
name: "sessionStartMaxBytes",
|
|
162
|
+
type: "int",
|
|
163
|
+
default: 0,
|
|
164
|
+
harness: { claude_code: 9500, codex: 9500, zcode: 20000 },
|
|
165
|
+
min: 0,
|
|
166
|
+
max: 1000000,
|
|
167
|
+
env: "OPENVIKING_SESSION_START_MAX_BYTES",
|
|
168
|
+
capability: "session",
|
|
169
|
+
},
|
|
155
170
|
{ name: "skipSubagentSessions", type: "bool", default: false, env: "OPENVIKING_SKIP_SUBAGENT_SESSIONS", capability: "session" },
|
|
156
171
|
{ name: "repoContext", type: "bool", default: true, capability: "session" },
|
|
157
172
|
{ name: "repoContextCacheTtlMs", type: "int", default: 60000, min: 1000, max: 3600000, capability: "session" },
|
package/shared/plugin-config.mjs
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
import { homedir } from "node:os";
|
|
30
30
|
import { join } from "node:path";
|
|
31
31
|
|
|
32
|
-
import { HARNESS_KEYS, KNOBS, harnessKey, resolveKnobs } from "./config-schema.mjs";
|
|
32
|
+
import { HARNESS_KEYS, KNOBS, harnessKey, knobDefault, resolveKnobs } from "./config-schema.mjs";
|
|
33
33
|
import {
|
|
34
34
|
buildUserAgent,
|
|
35
35
|
loadCredentialFiles,
|
|
@@ -224,9 +224,15 @@ export function buildPluginConfig(harness, {
|
|
|
224
224
|
env,
|
|
225
225
|
});
|
|
226
226
|
|
|
227
|
+
const recallRewrite = normalizeRewriteMode(
|
|
228
|
+
env.OPENVIKING_RECALL_COMPRESS ?? env.OPENVIKING_RECALL_REWRITE ?? settings.recallCompress,
|
|
229
|
+
knobDefault(KNOBS.find((knob) => knob.name === "recallCompress"), harnessKey(name)),
|
|
230
|
+
);
|
|
227
231
|
const config = {
|
|
228
232
|
...settings,
|
|
229
233
|
harness: name,
|
|
234
|
+
recallCompress: recallRewrite,
|
|
235
|
+
recallRewrite,
|
|
230
236
|
clientVersion,
|
|
231
237
|
userAgent: buildUserAgent(name, clientVersion),
|
|
232
238
|
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Session-start profile injection helper.
|
|
4
4
|
*
|
|
5
|
-
* Builds a <user-profile> + <available-memories> block from
|
|
5
|
+
* Builds a <user-profile> + <available-memories> (+ <available-skills>) block from
|
|
6
6
|
* viking://user/<space>/memories/profile.md
|
|
7
7
|
* viking://user/<space>/memories/preferences/ (ls with abstracts)
|
|
8
8
|
* viking://user/<space>/memories/entities/ (ls with abstracts)
|
|
9
|
+
* GET /api/v1/skills (own + account-shared skills)
|
|
9
10
|
*
|
|
10
11
|
* Budget enforced via the CJK-aware estimateTokens() below — codepoint >=
|
|
11
12
|
* 0x3000 counts at 1.5 tokens, else chars/4. The estimator is exported so
|
|
@@ -17,6 +18,10 @@
|
|
|
17
18
|
* alongside in a single context envelope.
|
|
18
19
|
*/
|
|
19
20
|
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { dirname } from "node:path";
|
|
24
|
+
|
|
20
25
|
const USER_RESERVED_DIRS = new Set(["memories"]);
|
|
21
26
|
let _userSpaceCache = null;
|
|
22
27
|
|
|
@@ -185,7 +190,9 @@ function elideProfile(content, maxTokens) {
|
|
|
185
190
|
return `${head}${ELLIPSIS}${lines.slice(tailStart).join("\n")}`;
|
|
186
191
|
}
|
|
187
192
|
|
|
188
|
-
|
|
193
|
+
const MEMORY_MORE_HINT = "use `memory_recall`";
|
|
194
|
+
|
|
195
|
+
function formatListing(headerUri, entries, budgetTokens, moreHint = MEMORY_MORE_HINT) {
|
|
189
196
|
if (entries.length === 0) return { lines: [], used: 0, dropped: 0 };
|
|
190
197
|
// Header is the full directory URI; child lines are relative paths so the
|
|
191
198
|
// agent can reconstruct each leaf's full URI by concatenation while the
|
|
@@ -194,10 +201,11 @@ function formatListing(headerUri, entries, budgetTokens) {
|
|
|
194
201
|
const headerTokens = estimateTokens(header);
|
|
195
202
|
// If the header alone busts the budget, emit just a one-line stub instead
|
|
196
203
|
// of silently violating the cap (Copilot review point).
|
|
197
|
-
|
|
198
|
-
const stub = ` ${headerUri}/ (${entries.length} entries, budget too tight;
|
|
204
|
+
const stubListing = () => {
|
|
205
|
+
const stub = ` ${headerUri}/ (${entries.length} entries, budget too tight; ${moreHint})`;
|
|
199
206
|
return { lines: [stub], used: estimateTokens(stub), dropped: entries.length };
|
|
200
|
-
}
|
|
207
|
+
};
|
|
208
|
+
if (headerTokens > budgetTokens) return stubListing();
|
|
201
209
|
const lines = [header];
|
|
202
210
|
let used = headerTokens;
|
|
203
211
|
for (let i = 0; i < entries.length; i++) {
|
|
@@ -208,11 +216,18 @@ function formatListing(headerUri, entries, budgetTokens) {
|
|
|
208
216
|
const line = ` - ${e.name}${desc}`;
|
|
209
217
|
const tokens = estimateTokens(line);
|
|
210
218
|
if (used + tokens > budgetTokens) {
|
|
211
|
-
|
|
212
|
-
const
|
|
219
|
+
let remaining = entries.length - i;
|
|
220
|
+
const tailFor = (n) => ` ... +${n} more, ${moreHint}`;
|
|
221
|
+
// A listing that stops without the tail hides that anything was cut, so
|
|
222
|
+
// give entries back until it fits.
|
|
223
|
+
while (used + estimateTokens(tailFor(remaining)) > budgetTokens && lines.length > 1) {
|
|
224
|
+
used -= estimateTokens(lines.pop());
|
|
225
|
+
remaining++;
|
|
226
|
+
}
|
|
227
|
+
// A bare header would read as an empty directory.
|
|
228
|
+
if (lines.length === 1) return stubListing();
|
|
229
|
+
const tail = tailFor(remaining);
|
|
213
230
|
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
231
|
if (used + tailTokens <= budgetTokens) {
|
|
217
232
|
lines.push(tail);
|
|
218
233
|
return { lines, used: used + tailTokens, dropped: remaining };
|
|
@@ -225,61 +240,196 @@ function formatListing(headerUri, entries, budgetTokens) {
|
|
|
225
240
|
return { lines, used, dropped: 0 };
|
|
226
241
|
}
|
|
227
242
|
|
|
243
|
+
const AGENT_SKILLS_ROOT = "viking://agent/skills";
|
|
244
|
+
// The server caps each skill root at node_limit, so the two roots together
|
|
245
|
+
// can return twice this many; the token budget below decides what fits.
|
|
246
|
+
const SKILL_CATALOG_NODE_LIMIT = 200;
|
|
247
|
+
const SKILL_DESCRIPTION_TOKENS = 40;
|
|
248
|
+
const SKILL_MORE_HINT = "search OpenViking skills to find the rest";
|
|
249
|
+
const SKILL_USAGE_LINE =
|
|
250
|
+
" OpenViking skills (stored in OpenViking, not local files). Before following one, read <dir>/<name>/SKILL.md with the OpenViking read tool.";
|
|
251
|
+
// Skill descriptions are user-written, and the shared root is account-wide:
|
|
252
|
+
// never let one close or open the envelope the catalog sits in.
|
|
253
|
+
const ENVELOPE_TAG_RE = /<\/?(?:openviking-context|available-skills|available-memories|user-profile|memory)\b[^>]*>/gi;
|
|
254
|
+
|
|
255
|
+
function sanitizeInline(text) {
|
|
256
|
+
return String(text || "")
|
|
257
|
+
.replace(/\s+/g, " ")
|
|
258
|
+
.trim()
|
|
259
|
+
.replace(ENVELOPE_TAG_RE, (tag) => tag.replace(/</g, "<").replace(/>/g, ">"));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function truncateToTokens(text, maxTokens) {
|
|
263
|
+
if (estimateTokens(text) <= maxTokens) return text;
|
|
264
|
+
return `${text.slice(0, Math.max(1, tokensToCharsBudget(text, maxTokens) - 1)).trimEnd()}…`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Both skill roots from one GET /api/v1/skills, grouped by root with the
|
|
269
|
+
* user's own skills first. A shared skill whose name the user also has is
|
|
270
|
+
* dropped: the private one is the one the agent should load. Servers without
|
|
271
|
+
* the endpoint, or any failure, yield no catalog.
|
|
272
|
+
*/
|
|
273
|
+
async function fetchSkillCatalog(fetchJSON, actorPeerId = "") {
|
|
274
|
+
const res = await fetchJSON(
|
|
275
|
+
`/api/v1/skills?node_limit=${SKILL_CATALOG_NODE_LIMIT}`,
|
|
276
|
+
{},
|
|
277
|
+
{ actorPeerId },
|
|
278
|
+
);
|
|
279
|
+
const skills = res.ok && Array.isArray(res.result?.skills) ? res.result.skills : [];
|
|
280
|
+
const own = [];
|
|
281
|
+
const shared = [];
|
|
282
|
+
for (const skill of skills) {
|
|
283
|
+
const name = typeof skill?.name === "string" ? skill.name.trim() : "";
|
|
284
|
+
const uri = typeof skill?.uri === "string" ? skill.uri.replace(/\/+$/, "") : "";
|
|
285
|
+
if (!name || !uri.includes("/")) continue;
|
|
286
|
+
const entry = {
|
|
287
|
+
name,
|
|
288
|
+
root: uri.slice(0, uri.lastIndexOf("/")),
|
|
289
|
+
abstract: truncateToTokens(sanitizeInline(skill.description), SKILL_DESCRIPTION_TOKENS),
|
|
290
|
+
};
|
|
291
|
+
(uri.startsWith(`${AGENT_SKILLS_ROOT}/`) ? shared : own).push(entry);
|
|
292
|
+
}
|
|
293
|
+
const ownNames = new Set(own.map((e) => e.name));
|
|
294
|
+
const byName = (a, b) => a.name.localeCompare(b.name);
|
|
295
|
+
return [
|
|
296
|
+
own.sort(byName),
|
|
297
|
+
shared.filter((e) => !ownNames.has(e.name)).sort(byName),
|
|
298
|
+
].filter((group) => group.length > 0);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function renderSkillGroups(groups, budgetTokens, withDescriptions) {
|
|
302
|
+
const lists = groups.map((g) => (withDescriptions ? g : g.map((e) => ({ ...e, abstract: "" }))));
|
|
303
|
+
const fullCost = lists.map((entries) => formatListing(entries[0].root, entries, Infinity, SKILL_MORE_HINT).used);
|
|
304
|
+
const lines = [];
|
|
305
|
+
let used = 0;
|
|
306
|
+
let dropped = 0;
|
|
307
|
+
for (let i = 0; i < lists.length; i++) {
|
|
308
|
+
// A group takes its fair share, or everything the later groups leave
|
|
309
|
+
// when listed in full, whichever is larger.
|
|
310
|
+
const remaining = budgetTokens - used;
|
|
311
|
+
const laterCost = fullCost.slice(i + 1).reduce((a, b) => a + b, 0);
|
|
312
|
+
const share = Math.max(Math.floor(remaining / (lists.length - i)), remaining - laterCost);
|
|
313
|
+
const block = formatListing(lists[i][0].root, lists[i], share, SKILL_MORE_HINT);
|
|
314
|
+
lines.push(...block.lines);
|
|
315
|
+
used += block.used;
|
|
316
|
+
dropped += block.dropped;
|
|
317
|
+
}
|
|
318
|
+
return { lines, used, dropped };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* <available-skills> within its own token budget: every entry with its
|
|
323
|
+
* description when that fits, otherwise names only (more skills stay
|
|
324
|
+
* visible, with a "+N more" tail), and a one-line count when not even one
|
|
325
|
+
* name fits.
|
|
326
|
+
*/
|
|
327
|
+
function formatSkillCatalog(groups, budgetTokens) {
|
|
328
|
+
const count = groups.reduce((n, g) => n + g.length, 0);
|
|
329
|
+
if (count === 0) return { lines: [], used: 0, dropped: 0, count };
|
|
330
|
+
const open = "<available-skills>";
|
|
331
|
+
const close = "</available-skills>";
|
|
332
|
+
const frameTokens = estimateTokens(open) + estimateTokens(SKILL_USAGE_LINE) + estimateTokens(close);
|
|
333
|
+
const listingBudget = Math.max(0, budgetTokens - frameTokens);
|
|
334
|
+
let body = renderSkillGroups(groups, listingBudget, true);
|
|
335
|
+
if (body.dropped > 0) body = renderSkillGroups(groups, listingBudget, false);
|
|
336
|
+
if (body.dropped >= count) {
|
|
337
|
+
const stub = `${open}${count} OpenViking skills; search OpenViking skills to find them.${close}`;
|
|
338
|
+
if (estimateTokens(stub) > budgetTokens) return { lines: [], used: 0, dropped: count, count };
|
|
339
|
+
return { lines: [stub], used: estimateTokens(stub), dropped: count, count };
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
lines: [open, SKILL_USAGE_LINE, ...body.lines, close],
|
|
343
|
+
used: frameTokens + body.used,
|
|
344
|
+
dropped: body.dropped,
|
|
345
|
+
count,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
228
349
|
/**
|
|
229
350
|
* Build the profile injection block.
|
|
230
351
|
*
|
|
231
|
-
* Returns null when neither profile.md
|
|
232
|
-
* The returned `block` is just the inner
|
|
233
|
-
* payload — the caller
|
|
352
|
+
* Returns null when neither profile.md, either listing, nor the skill catalog
|
|
353
|
+
* has any content. The returned `block` is just the inner
|
|
354
|
+
* <user-profile>/<available-memories>/<available-skills> payload — the caller
|
|
355
|
+
* wraps it in <openviking-context source="...">.
|
|
234
356
|
*
|
|
235
357
|
* @param {Function} fetchJSON ov-session.mjs:makeFetchJSON closure
|
|
236
|
-
* @param {number} totalBudgetTokens
|
|
358
|
+
* @param {number} totalBudgetTokens budget for the profile and memory listings
|
|
359
|
+
* @param {string} [actorPeerId]
|
|
360
|
+
* @param {{ skillCatalog?: boolean, skillCatalogTokenBudget?: number, sessionStartMaxBytes?: number }} [options]
|
|
361
|
+
* callers pass their resolved plugin config: its skillCatalog knob adds
|
|
362
|
+
* <available-skills>, budgeted separately by skillCatalogTokenBudget.
|
|
363
|
+
* Without it the block is unchanged.
|
|
237
364
|
* @returns {Promise<null | {
|
|
238
365
|
* block: string, chars: number, tokens: number, profileUri: string,
|
|
239
366
|
* profileChars: number, prefCount: number, entCount: number,
|
|
240
367
|
* droppedPref: number, droppedEnt: number,
|
|
368
|
+
* skillCount: number, droppedSkill: number, skillTokens: number,
|
|
241
369
|
* }>}
|
|
242
370
|
*/
|
|
243
|
-
export async function buildProfileBlock(fetchJSON, totalBudgetTokens, actorPeerId = "") {
|
|
371
|
+
export async function buildProfileBlock(fetchJSON, totalBudgetTokens, actorPeerId = "", options = {}) {
|
|
372
|
+
const { skillCatalog = false, skillCatalogTokenBudget = 0, sessionStartMaxBytes = 0 } = options;
|
|
373
|
+
// Hosts that spill (Claude Code, Codex) or drop (ZCode) oversized hook output
|
|
374
|
+
// get a byte cap. An estimated token is about 4 UTF-8 bytes at most, so the
|
|
375
|
+
// token budgets shrink to fit under it.
|
|
376
|
+
const capTokens = sessionStartMaxBytes > 0
|
|
377
|
+
? Math.max(0, Math.floor(sessionStartMaxBytes / 4) - 50)
|
|
378
|
+
: Infinity;
|
|
379
|
+
const skillBudget = Math.min(skillCatalogTokenBudget, Math.floor(capTokens / 4));
|
|
244
380
|
const space = await resolveUserSpace(fetchJSON, actorPeerId);
|
|
245
381
|
const profileUri = `viking://user/${space}/memories/profile.md`;
|
|
246
382
|
const prefUri = `viking://user/${space}/memories/preferences`;
|
|
247
383
|
const entUri = `viking://user/${space}/memories/entities`;
|
|
248
384
|
|
|
249
|
-
const [profile, prefs, ents] = await Promise.all([
|
|
385
|
+
const [profile, prefs, ents, skillGroups] = await Promise.all([
|
|
250
386
|
readProfile(fetchJSON, profileUri, actorPeerId),
|
|
251
387
|
lsDir(fetchJSON, prefUri, actorPeerId),
|
|
252
388
|
lsDir(fetchJSON, entUri, actorPeerId),
|
|
389
|
+
skillCatalog && skillBudget > 0
|
|
390
|
+
? fetchSkillCatalog(fetchJSON, actorPeerId)
|
|
391
|
+
: [],
|
|
253
392
|
]);
|
|
393
|
+
const skills = formatSkillCatalog(skillGroups, skillBudget);
|
|
254
394
|
|
|
255
|
-
if (!profile && prefs.length === 0 && ents.length === 0)
|
|
395
|
+
if (!profile && prefs.length === 0 && ents.length === 0 && skills.lines.length === 0) {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
256
398
|
|
|
399
|
+
const memoryBudget = Math.min(totalBudgetTokens, Math.max(0, capTokens - skills.used));
|
|
257
400
|
// Profile gets up to half the total budget; listings split the rest.
|
|
258
401
|
// Sub-cap protects against a runaway profile blowing the listing budget.
|
|
259
|
-
const profileBudget = Math.floor(
|
|
402
|
+
const profileBudget = Math.floor(memoryBudget / 2);
|
|
260
403
|
const profileTrunc = profile ? elideProfile(profile, profileBudget) : null;
|
|
261
404
|
const profileTokens = estimateTokens(profileTrunc || "");
|
|
262
405
|
|
|
263
|
-
const listingBudget = Math.max(0,
|
|
406
|
+
const listingBudget = Math.max(0, memoryBudget - profileTokens);
|
|
264
407
|
const halfListing = Math.floor(listingBudget / 2);
|
|
265
408
|
const prefBlock = formatListing(prefUri, prefs, halfListing);
|
|
266
409
|
const entBudget = Math.max(0, listingBudget - prefBlock.used);
|
|
267
410
|
const entBlock = formatListing(entUri, ents, entBudget);
|
|
268
411
|
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
lines.
|
|
412
|
+
const profileLines = profileTrunc
|
|
413
|
+
? [`<user-profile uri="${profileUri}">`, profileTrunc, `</user-profile>`]
|
|
414
|
+
: [];
|
|
415
|
+
let memoryLines = prefBlock.lines.length > 0 || entBlock.lines.length > 0
|
|
416
|
+
? [`<available-memories>`, ...prefBlock.lines, ...entBlock.lines, `</available-memories>`]
|
|
417
|
+
: [];
|
|
418
|
+
let skillLines = skills.lines;
|
|
419
|
+
const render = () => [...profileLines, ...memoryLines, ...skillLines].join("\n");
|
|
420
|
+
let block = render();
|
|
421
|
+
// The estimate can still run over on multi-byte punctuation; the index goes
|
|
422
|
+
// first, then the catalog.
|
|
423
|
+
if (sessionStartMaxBytes > 0 && utf8Bytes(block) > sessionStartMaxBytes) {
|
|
424
|
+
memoryLines = [];
|
|
425
|
+
block = render();
|
|
274
426
|
}
|
|
275
|
-
if (
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
lines.push(...entBlock.lines);
|
|
279
|
-
lines.push(`</available-memories>`);
|
|
427
|
+
if (sessionStartMaxBytes > 0 && utf8Bytes(block) > sessionStartMaxBytes) {
|
|
428
|
+
skillLines = [];
|
|
429
|
+
block = render();
|
|
280
430
|
}
|
|
431
|
+
if (!block) return null;
|
|
281
432
|
|
|
282
|
-
const block = lines.join("\n");
|
|
283
433
|
return {
|
|
284
434
|
block,
|
|
285
435
|
chars: block.length,
|
|
@@ -288,7 +438,58 @@ export async function buildProfileBlock(fetchJSON, totalBudgetTokens, actorPeerI
|
|
|
288
438
|
profileChars: profile?.length ?? 0,
|
|
289
439
|
prefCount: prefs.length,
|
|
290
440
|
entCount: ents.length,
|
|
291
|
-
droppedPref: prefBlock.dropped,
|
|
292
|
-
droppedEnt: entBlock.dropped,
|
|
441
|
+
droppedPref: memoryLines.length ? prefBlock.dropped : prefs.length,
|
|
442
|
+
droppedEnt: memoryLines.length ? entBlock.dropped : ents.length,
|
|
443
|
+
skillCount: skills.count,
|
|
444
|
+
droppedSkill: skillLines.length ? skills.dropped : skills.count,
|
|
445
|
+
skillTokens: skillLines.length ? skills.used : 0,
|
|
293
446
|
};
|
|
294
447
|
}
|
|
448
|
+
|
|
449
|
+
function utf8Bytes(text) {
|
|
450
|
+
return Buffer.byteLength(String(text || ""), "utf8");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Cut `text` to at most `maxBytes` UTF-8 bytes on a line boundary, marking the
|
|
455
|
+
* cut. A non-positive cap returns the text unchanged.
|
|
456
|
+
*/
|
|
457
|
+
export function truncateToBytes(text, maxBytes) {
|
|
458
|
+
const value = String(text || "");
|
|
459
|
+
if (!(maxBytes > 0) || utf8Bytes(value) <= maxBytes) return value;
|
|
460
|
+
const marker = "\n… [truncated]";
|
|
461
|
+
const lines = value.split("\n");
|
|
462
|
+
const kept = [];
|
|
463
|
+
let used = utf8Bytes(marker);
|
|
464
|
+
for (const line of lines) {
|
|
465
|
+
const cost = utf8Bytes(line) + 1;
|
|
466
|
+
if (used + cost > maxBytes) break;
|
|
467
|
+
kept.push(line);
|
|
468
|
+
used += cost;
|
|
469
|
+
}
|
|
470
|
+
return `${kept.join("\n")}${marker}`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Whether `block` is what this session was last given, recording it either
|
|
475
|
+
* way. Resume reuses history that already holds the earlier injection, so an
|
|
476
|
+
* unchanged block need not be sent again.
|
|
477
|
+
*/
|
|
478
|
+
export function isRepeatInjection(statePath, sessionId, block) {
|
|
479
|
+
if (!statePath || !sessionId) return false;
|
|
480
|
+
const digest = createHash("sha256").update(String(block || "")).digest("hex");
|
|
481
|
+
let seen = {};
|
|
482
|
+
try {
|
|
483
|
+
seen = JSON.parse(readFileSync(statePath, "utf-8")) || {};
|
|
484
|
+
} catch { /* first injection */ }
|
|
485
|
+
const repeat = seen[sessionId] === digest;
|
|
486
|
+
delete seen[sessionId];
|
|
487
|
+
seen[sessionId] = digest;
|
|
488
|
+
const ids = Object.keys(seen);
|
|
489
|
+
for (const id of ids.slice(0, Math.max(0, ids.length - 200))) delete seen[id];
|
|
490
|
+
try {
|
|
491
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
492
|
+
writeFileSync(statePath, JSON.stringify(seen));
|
|
493
|
+
} catch { /* best effort: a lost record only means one more injection */ }
|
|
494
|
+
return repeat;
|
|
495
|
+
}
|
package/shared/recall-core.mjs
CHANGED
|
@@ -309,21 +309,46 @@ async function resolveTargetUri(fetchJSON, targetUri, actorPeerId = "") {
|
|
|
309
309
|
return `viking://user/${space}/${parts.join("/")}`;
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
-
async function searchOneSource(fetchJSON, query, source, limit,
|
|
313
|
-
const
|
|
314
|
-
const
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
312
|
+
async function searchOneSource(fetchJSON, cfg, query, source, limit, options) {
|
|
313
|
+
const actorPeerId = options.actorPeerId || "";
|
|
314
|
+
const home = await resolveTargetUri(fetchJSON, source.uri, actorPeerId);
|
|
315
|
+
const targets = [...new Set(cfg.user
|
|
316
|
+
? [`viking://user/${cfg.user}/${source.bucket}`, home] : [home])];
|
|
317
|
+
const sessionId = String(options.sessionId || "").trim();
|
|
318
|
+
const search = async (target, session) => {
|
|
319
|
+
const body = { query, target_uri: target, limit, score_threshold: 0 };
|
|
320
|
+
if (session) body.session_id = session;
|
|
321
|
+
const init = { method: "POST", body: JSON.stringify(body) };
|
|
322
|
+
// Session-aware retrieval may decide no context is needed. Only after all
|
|
323
|
+
// targets are empty do we retry without the session. Old servers still
|
|
324
|
+
// support find; an unsupported search route can fall back to it.
|
|
325
|
+
let res = await fetchJSON(sessionId ? "/api/v1/search/search" : "/api/v1/search/find", init, { actorPeerId });
|
|
326
|
+
if (sessionId && !res.ok && (
|
|
327
|
+
!res.status || res.status === 404 || res.status === 405 || res.status >= 500
|
|
328
|
+
|| ((res.status === 400 || res.status === 422) && looksLikeUnknownField(res))
|
|
329
|
+
)) {
|
|
330
|
+
const { session_id, ...findBody } = body;
|
|
331
|
+
res = await fetchJSON("/api/v1/search/find", { method: "POST", body: JSON.stringify(findBody) }, { actorPeerId });
|
|
332
|
+
}
|
|
333
|
+
const items = res.ok && Array.isArray(res.result?.[source.bucket]) ? res.result[source.bucket] : [];
|
|
334
|
+
return items.map((item) => ({ ...item, _sourceType: source.type }));
|
|
335
|
+
};
|
|
336
|
+
for (const target of targets) {
|
|
337
|
+
const items = await search(target, sessionId);
|
|
338
|
+
if (items.length) return items;
|
|
339
|
+
}
|
|
340
|
+
if (sessionId) {
|
|
341
|
+
for (const target of targets) {
|
|
342
|
+
const items = await search(target, "");
|
|
343
|
+
if (items.length) return items;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return [];
|
|
322
347
|
}
|
|
323
348
|
|
|
324
|
-
async function searchAllSources(fetchJSON, query, perSourceLimit,
|
|
349
|
+
async function searchAllSources(fetchJSON, cfg, query, perSourceLimit, options, log = () => {}) {
|
|
325
350
|
const results = await Promise.all(
|
|
326
|
-
SOURCES.map((src) => searchOneSource(fetchJSON, query, src, perSourceLimit,
|
|
351
|
+
SOURCES.map((src) => searchOneSource(fetchJSON, cfg, query, src, perSourceLimit, options)),
|
|
327
352
|
);
|
|
328
353
|
const all = results.flat();
|
|
329
354
|
log("recall_search_summary", {
|
|
@@ -359,50 +384,30 @@ async function resolveItemContent(fetchJSON, item, cfg, actorPeerId = "") {
|
|
|
359
384
|
return content;
|
|
360
385
|
}
|
|
361
386
|
|
|
362
|
-
|
|
387
|
+
function formatFallback(items, cfg, log = () => {}) {
|
|
363
388
|
const budgetTotal = Math.max(200, Number(cfg.recallTokenBudget || 2000));
|
|
364
|
-
|
|
365
|
-
const lines = [
|
|
366
|
-
"<openviking-context>",
|
|
367
|
-
"Relevant context from OpenViking. Use the read MCP tool to expand URIs.",
|
|
368
|
-
];
|
|
389
|
+
const lines = [];
|
|
369
390
|
let contentCount = 0;
|
|
370
391
|
let hintCount = 0;
|
|
371
|
-
|
|
392
|
+
const maxChars = Math.max(50, Number(cfg.recallMaxContentChars || 500));
|
|
372
393
|
for (const item of items) {
|
|
373
394
|
const score = (clampScore(item.score) * 100).toFixed(0);
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
hintCount++;
|
|
384
|
-
} else {
|
|
385
|
-
lines.push(contentLine);
|
|
386
|
-
budgetRemaining -= lineTokens;
|
|
387
|
-
contentCount++;
|
|
388
|
-
}
|
|
389
|
-
} else {
|
|
395
|
+
const kind = item._sourceType || item.category || "memory";
|
|
396
|
+
const uriLine = `- [${kind} ${score}%] ${item.uri}`;
|
|
397
|
+
const text = String(item.text || item.uri);
|
|
398
|
+
const content = text.length > maxChars ? `${text.slice(0, maxChars)}...` : text;
|
|
399
|
+
const contentLine = `- [${kind} ${score}%] ${content}`;
|
|
400
|
+
if (estimateTokens(wrapContext([...lines, uriLine, contentLine].join("\n"))) <= budgetTotal) {
|
|
401
|
+
lines.push(uriLine, contentLine);
|
|
402
|
+
contentCount += 1;
|
|
403
|
+
} else if (estimateTokens(wrapContext([...lines, uriLine].join("\n"))) <= budgetTotal) {
|
|
390
404
|
lines.push(uriLine);
|
|
391
|
-
hintCount
|
|
405
|
+
hintCount += 1;
|
|
392
406
|
}
|
|
393
407
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const budgetUsed = budgetTotal - budgetRemaining;
|
|
398
|
-
log("recall_injection_built", {
|
|
399
|
-
contentItems: contentCount,
|
|
400
|
-
hintItems: hintCount,
|
|
401
|
-
budgetUsed,
|
|
402
|
-
budgetTotal,
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
return { block: lines.join("\n"), contentCount, hintCount, budgetUsed, stage: "ranked" };
|
|
408
|
+
const budgetUsed = lines.length ? estimateTokens(wrapContext(lines.join("\n"))) : 0;
|
|
409
|
+
log("recall_injection_built", { contentItems: contentCount, hintItems: hintCount, budgetUsed, budgetTotal });
|
|
410
|
+
return { rendered: lines.join("\n"), contentCount, hintCount, budgetUsed };
|
|
406
411
|
}
|
|
407
412
|
|
|
408
413
|
const LEGACY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
@@ -469,10 +474,14 @@ function looksLikeUnknownField(res) {
|
|
|
469
474
|
}
|
|
470
475
|
|
|
471
476
|
function wrapContext(body) {
|
|
477
|
+
// Retrieved text cannot introduce a second capture delimiter inside ours.
|
|
478
|
+
const text = String(body)
|
|
479
|
+
.replace(/<\/?relevant-memor(?:y|ies)\b[^>]*>/gi, "legacy memory wrapper")
|
|
480
|
+
.replace(/<\/?openviking-context\b[^>]*>/gi, "openviking context marker");
|
|
472
481
|
return [
|
|
473
482
|
"<openviking-context>",
|
|
474
483
|
"Relevant memory from OpenViking. Use the search/read MCP tools to expand URIs.",
|
|
475
|
-
|
|
484
|
+
text,
|
|
476
485
|
"</openviking-context>",
|
|
477
486
|
].join("\n");
|
|
478
487
|
}
|
|
@@ -488,7 +497,7 @@ export async function buildServerAssembledBlock(fetchJSON, cfg, query, options =
|
|
|
488
497
|
|
|
489
498
|
const block = await recallViaContextFace(fetchJSON, cfg, query, { ...options, actorPeerId }, log);
|
|
490
499
|
if (block !== null) return block;
|
|
491
|
-
return recallViaEndpoint(fetchJSON, cfg, query, actorPeerId, log);
|
|
500
|
+
return recallViaEndpoint(fetchJSON, cfg, query, { ...options, actorPeerId }, log);
|
|
492
501
|
}
|
|
493
502
|
|
|
494
503
|
/**
|
|
@@ -552,53 +561,65 @@ export function normalizeContextEntry(entry = {}) {
|
|
|
552
561
|
};
|
|
553
562
|
}
|
|
554
563
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
if (
|
|
564
|
+
/** Server relevance decisions and digest precedence are shared by every host. */
|
|
565
|
+
export function selectRecallContent(result = {}) {
|
|
566
|
+
if (String(result.stats?.rewrite || "").toLowerCase() === "no_relevant") return "";
|
|
567
|
+
return String(result.digest || "").trim() || String(result.rendered || "").trim();
|
|
568
|
+
}
|
|
558
569
|
|
|
559
|
-
|
|
560
|
-
let digest = assembled.digest;
|
|
570
|
+
function wantsLocalCompression(cfg, options) {
|
|
561
571
|
const mode = String(cfg.recallRewrite || "off").toLowerCase();
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
}
|
|
572
|
+
return (mode === "client" || mode === "auto")
|
|
573
|
+
&& options.localCompressorAvailable !== false
|
|
574
|
+
&& typeof options.runCompressor === "function";
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Context, legacy recall, and raw retrieval share the same result policy.
|
|
578
|
+
// The host callback only runs its model; it never chooses a fallback or digest.
|
|
579
|
+
async function finalizeRecall(result, cfg, query, options, log, shortContext) {
|
|
580
|
+
const selected = selectRecallContent(result);
|
|
581
|
+
if (String(result.stats?.rewrite || "").toLowerCase() === "no_relevant") return "";
|
|
582
|
+
const entries = (result.entries || []).map(normalizeContextEntry).filter((entry) => entry.uri);
|
|
583
|
+
const rendered = String(result.rendered || "").trim();
|
|
584
|
+
if (String(result.digest || "").trim() || !wantsLocalCompression(cfg, options)) return selected;
|
|
585
|
+
const fallback = shortContext ?? (entries.length ? formatFallback(entries, cfg).rendered : rendered);
|
|
586
|
+
const input = rendered || entries.map((entry) => `${entry.uri}\n${entry.text}`).join("\n");
|
|
587
|
+
if (!input) return "";
|
|
588
|
+
try {
|
|
589
|
+
const compression = await compressRecallContext({
|
|
590
|
+
query, rendered: input, shortContext: shortContext ?? (rendered || fallback),
|
|
591
|
+
entries, cfg, runCompressor: options.runCompressor,
|
|
592
|
+
cachePath: options.digestCachePath || stateFile("recall-digest.json"), now: Date.now(),
|
|
593
|
+
});
|
|
594
|
+
log("recall_local_compression", { status: compression.status });
|
|
595
|
+
if (compression.status === "ok") return compression.context;
|
|
596
|
+
if (compression.status === "empty") return "";
|
|
597
|
+
} catch (err) {
|
|
598
|
+
log("recall_local_compression_failed", { error: String(err?.message || err) });
|
|
584
599
|
}
|
|
600
|
+
return fallback;
|
|
601
|
+
}
|
|
585
602
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
603
|
+
async function recallViaContextFace(fetchJSON, cfg, query, options, log) {
|
|
604
|
+
const assembled = await fetchAssembledContext(fetchJSON, cfg, query, { ...options, log });
|
|
605
|
+
if (assembled === null) return null;
|
|
606
|
+
const text = await finalizeRecall(assembled, cfg, query, options, log);
|
|
607
|
+
return text ? wrapContext(text) : "";
|
|
589
608
|
}
|
|
590
609
|
|
|
591
|
-
async function recallViaEndpoint(fetchJSON, cfg, query,
|
|
610
|
+
async function recallViaEndpoint(fetchJSON, cfg, query, options, log) {
|
|
592
611
|
const body = buildRecallEndpointBody(cfg);
|
|
593
612
|
body.query = query;
|
|
594
|
-
|
|
613
|
+
if (wantsLocalCompression(cfg, options)) {
|
|
614
|
+
body.max_chars = Math.max(1000, Number(cfg.recallCompressMaxInputChars || 18000));
|
|
615
|
+
}
|
|
616
|
+
const res = await postRecall(fetchJSON, body, { actorPeerId: options.actorPeerId, log });
|
|
595
617
|
if (!res.ok) {
|
|
596
618
|
log("recall_endpoint_fallback", { status: res.status || 0 });
|
|
597
619
|
return null;
|
|
598
620
|
}
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
return wrapContext(rendered);
|
|
621
|
+
const text = await finalizeRecall(res.result || {}, cfg, query, options, log);
|
|
622
|
+
return text ? wrapContext(text) : "";
|
|
602
623
|
}
|
|
603
624
|
|
|
604
625
|
export async function postRecall(fetchJSON, body, opts = {}) {
|
|
@@ -711,7 +732,7 @@ async function recallForPeer(fetchJSON, cfg, query, options = {}) {
|
|
|
711
732
|
|
|
712
733
|
const recallLimit = Math.max(1, Number(cfg.recallLimit || DEFAULT_CONTEXT_LIMIT));
|
|
713
734
|
const perSourceLimit = Math.max(recallLimit * 2, 8);
|
|
714
|
-
const raw = await searchAllSources(fetchJSON, trimmed, perSourceLimit, actorPeerId, log);
|
|
735
|
+
const raw = await searchAllSources(fetchJSON, cfg, trimmed, perSourceLimit, { ...options, actorPeerId }, log);
|
|
715
736
|
if (raw.length === 0) return emptyRecall("no_results");
|
|
716
737
|
|
|
717
738
|
const profile = buildQueryProfile(trimmed);
|
|
@@ -727,5 +748,25 @@ async function recallForPeer(fetchJSON, cfg, query, options = {}) {
|
|
|
727
748
|
});
|
|
728
749
|
|
|
729
750
|
if (picked.length === 0) return emptyRecall("filtered_out");
|
|
730
|
-
|
|
751
|
+
const local = wantsLocalCompression(cfg, options);
|
|
752
|
+
const inputLimit = Math.max(1000, Number(cfg.recallCompressMaxInputChars || 18000));
|
|
753
|
+
const contentCfg = local ? { ...cfg, recallPreferAbstract: false, recallMaxContentChars: inputLimit } : cfg;
|
|
754
|
+
const entries = await Promise.all(picked.map(async (item) => ({
|
|
755
|
+
...item, text: await resolveItemContent(fetchJSON, item, contentCfg, actorPeerId),
|
|
756
|
+
})));
|
|
757
|
+
const fallback = formatFallback(entries, cfg, log);
|
|
758
|
+
let text = await finalizeRecall({
|
|
759
|
+
rendered: local ? entries.map((item) => `${item.uri}\n${item.text}`).join("\n").slice(0, inputLimit) : fallback.rendered,
|
|
760
|
+
entries,
|
|
761
|
+
}, cfg, trimmed, options, log, fallback.rendered);
|
|
762
|
+
if (estimateTokens(wrapContext(text)) > Math.max(200, Number(cfg.recallTokenBudget || 2000))) {
|
|
763
|
+
text = fallback.rendered;
|
|
764
|
+
}
|
|
765
|
+
if (!text) return emptyRecall("no_results");
|
|
766
|
+
const unchanged = text === fallback.rendered;
|
|
767
|
+
return {
|
|
768
|
+
block: wrapContext(text), contentCount: unchanged ? fallback.contentCount : 1,
|
|
769
|
+
hintCount: unchanged ? fallback.hintCount : 0,
|
|
770
|
+
budgetUsed: estimateTokens(wrapContext(text)), stage: "ranked",
|
|
771
|
+
};
|
|
731
772
|
}
|
package/shared/uri-guard.mjs
CHANGED
|
@@ -91,6 +91,33 @@ export function buildGuardNotice(uri, hint = {}) {
|
|
|
91
91
|
return lines.join("\n");
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
const SKILL_URI_RE = /^viking:\/\/(?:~|user\/[^/]+|agent)\/skills(?:\/|$)/i;
|
|
95
|
+
const SHARED_SKILL_URI_RE = /^viking:\/\/agent\/skills(?:\/|$)/i;
|
|
96
|
+
// The skills root, a skill directory, or its SKILL.md; anything deeper is a helper file.
|
|
97
|
+
const SKILL_MD_URI_RE = /^viking:\/\/(?:~|user\/[^/]+|agent)\/skills(?:\/[^/]+(?:\/SKILL\.md)?)?\/?$/i;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Skills are installed through the MCP add_skill tool: write and edit refuse
|
|
101
|
+
* the user's own skills subtree, and under the shared root they would bypass
|
|
102
|
+
* installation.
|
|
103
|
+
*/
|
|
104
|
+
export function isSkillUri(uri) {
|
|
105
|
+
return SKILL_URI_RE.test(String(uri || ""));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The add_skill call that replaces a write or edit aimed at `uri`. A shared
|
|
110
|
+
* skill goes back to the shared root: without target_uri, add_skill makes a
|
|
111
|
+
* private copy that then shadows it.
|
|
112
|
+
*/
|
|
113
|
+
export function addSkillExample(uri, { call = "add_skill", edited = false } = {}) {
|
|
114
|
+
const value = String(uri || "");
|
|
115
|
+
const shared = SHARED_SKILL_URI_RE.test(value) ? ', target_uri="viking://agent/skills"' : "";
|
|
116
|
+
return SKILL_MD_URI_RE.test(value)
|
|
117
|
+
? `${call}(data="<the full ${edited ? "edited " : ""}SKILL.md text>"${shared})`
|
|
118
|
+
: `${call}(path="<local skill folder or .zip with the changed files>"${shared})`;
|
|
119
|
+
}
|
|
120
|
+
|
|
94
121
|
/** The hints a host gets when it names no table of its own. */
|
|
95
122
|
export const DEFAULT_TOOL_HINTS = {
|
|
96
123
|
read: {
|
|
@@ -110,12 +137,18 @@ export const DEFAULT_TOOL_HINTS = {
|
|
|
110
137
|
),
|
|
111
138
|
},
|
|
112
139
|
edit: {
|
|
113
|
-
tool: "OpenViking MCP edit",
|
|
114
|
-
example: (uri) =>
|
|
140
|
+
tool: (uri) => (isSkillUri(uri) ? "OpenViking MCP add_skill" : "OpenViking MCP edit"),
|
|
141
|
+
example: (uri) => (
|
|
142
|
+
isSkillUri(uri)
|
|
143
|
+
? addSkillExample(uri, { edited: true })
|
|
144
|
+
: `edit(uri="${uri}", old_string="...", new_string="...")`
|
|
145
|
+
),
|
|
115
146
|
},
|
|
116
147
|
write: {
|
|
117
|
-
tool: "OpenViking MCP write",
|
|
118
|
-
example: (uri) =>
|
|
148
|
+
tool: (uri) => (isSkillUri(uri) ? "OpenViking MCP add_skill" : "OpenViking MCP write"),
|
|
149
|
+
example: (uri) => (
|
|
150
|
+
isSkillUri(uri) ? addSkillExample(uri) : `write(uri="${uri}", content="...")`
|
|
151
|
+
),
|
|
119
152
|
},
|
|
120
153
|
bash: {
|
|
121
154
|
tool: "OpenViking MCP read or search",
|
|
@@ -152,7 +185,7 @@ function resolveGuardedUri(toolName, input, { hints = DEFAULT_TOOL_HINTS } = {})
|
|
|
152
185
|
uri,
|
|
153
186
|
shell: SHELL_TOOL_NAMES.has(name),
|
|
154
187
|
hint: {
|
|
155
|
-
tool: hint.tool,
|
|
188
|
+
tool: typeof hint.tool === "function" ? hint.tool(uri, input) : hint.tool,
|
|
156
189
|
example: typeof hint.example === "function" ? hint.example(uri, input) : hint.example,
|
|
157
190
|
},
|
|
158
191
|
};
|
|
@@ -91,6 +91,8 @@ Do not invent other keys or commands for this: that file is the whole interface,
|
|
|
91
91
|
- Reusable task-execution write-ups (Experiences) have a dedicated tool pair,
|
|
92
92
|
`search_experience` / `read_experience`, described in the
|
|
93
93
|
`ov-experience-memory` skill.
|
|
94
|
+
- Skills stored in OpenViking (the `<available-skills>` list, `add_skill`,
|
|
95
|
+
moving local skills in) are covered by the `openviking-skills` skill.
|
|
94
96
|
|
|
95
97
|
## Beyond the MCP tools
|
|
96
98
|
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: openviking-skills
|
|
3
|
+
description: >
|
|
4
|
+
Find, use, create, install, share, update, and migrate agent skills stored in
|
|
5
|
+
OpenViking (viking://~/skills and viking://agent/skills). Use it when a
|
|
6
|
+
search result, or the session's <available-skills> list where the harness
|
|
7
|
+
injects one, names a skill that fits the task; when a task looks like one a
|
|
8
|
+
stored skill would cover; when the user asks to write, save, install, or share a skill from
|
|
9
|
+
text, a Git repository, or a local folder; when a skill should work in every
|
|
10
|
+
harness and on every machine; or when the user wants to move local skills
|
|
11
|
+
(~/.claude/skills, ~/.agents/skills, ~/.cursor/skills) into OpenViking —
|
|
12
|
+
"upload my skills", "save this as a skill", "迁移本地 skill", "把 skill 存到
|
|
13
|
+
OpenViking", "新建一个 skill". Covers the add_skill tool, running a skill's
|
|
14
|
+
helper files, and which local skills must stay local.
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# OpenViking Skills
|
|
18
|
+
|
|
19
|
+
OpenViking stores skills as data, the same way it stores memories, so a skill
|
|
20
|
+
saved there reaches every harness and machine connected to the same account.
|
|
21
|
+
Each skill is a directory holding `SKILL.md` plus optional helper files
|
|
22
|
+
(`scripts/`, `references/`, ...):
|
|
23
|
+
|
|
24
|
+
- `viking://~/skills/<name>/` — the user's own skills (`viking://~` expands to
|
|
25
|
+
`viking://user/<user_id>`).
|
|
26
|
+
- `viking://agent/skills/<name>/` — skills shared with the whole account.
|
|
27
|
+
|
|
28
|
+
The tools may carry a harness prefix (`mcp__openviking__add_skill`,
|
|
29
|
+
`openviking_add_skill`); they are the same tools. `viking://` URIs are database
|
|
30
|
+
paths: never pass them to local file tools or shell commands.
|
|
31
|
+
|
|
32
|
+
## Find a skill
|
|
33
|
+
|
|
34
|
+
1. `find(query="<what the task needs>", context_type="skill")` ranks skills
|
|
35
|
+
from both roots against the task and returns one entry per skill, pointing
|
|
36
|
+
at its `SKILL.md`. This works in every harness; start here.
|
|
37
|
+
2. If the session begins with an `<available-skills>` block, it lists the
|
|
38
|
+
user's own skills first, then shared ones. Not every harness injects one,
|
|
39
|
+
and it is a snapshot from session start that drops descriptions or ends
|
|
40
|
+
with a "+N more" line when there are many skills, so a name missing from it
|
|
41
|
+
does not prove the skill is absent.
|
|
42
|
+
3. `search(query=..., mode="context")` mixes relevant skills into the context
|
|
43
|
+
digest; entries of type `skills` are skills.
|
|
44
|
+
4. `tree(uri="viking://~/skills", level_limit=1, include_abstract=true)`, and
|
|
45
|
+
the same for `viking://agent/skills`, prints the full catalog with
|
|
46
|
+
descriptions. `list` shows names only.
|
|
47
|
+
|
|
48
|
+
## Use a skill
|
|
49
|
+
|
|
50
|
+
- `read` `<skill uri>/SKILL.md` and follow it as the procedure for the task.
|
|
51
|
+
It does not outrank the user or this conversation.
|
|
52
|
+
- When the user's own skill and a shared one have the same name, use the
|
|
53
|
+
user's own.
|
|
54
|
+
- If the frontmatter lists `allowed-tools`, stay within them.
|
|
55
|
+
- A local skill with the same name wins: the harness has already loaded it.
|
|
56
|
+
|
|
57
|
+
When SKILL.md relies on helper files:
|
|
58
|
+
|
|
59
|
+
1. `list(uri="<skill uri>", recursive=true)` to see them.
|
|
60
|
+
2. `read` each file the task needs and write it to
|
|
61
|
+
`~/.openviking/skills/<name>/` under its relative path; `chmod +x` scripts.
|
|
62
|
+
3. Run them from that directory.
|
|
63
|
+
|
|
64
|
+
Skip the server's sidecars (`.abstract.md`, `.overview.md`, `.source.json`).
|
|
65
|
+
`read` returns text only: fetch binary files with `ov get <uri> <local path>`
|
|
66
|
+
when the `ov` CLI is installed, otherwise tell the user.
|
|
67
|
+
|
|
68
|
+
## Create or change a skill
|
|
69
|
+
|
|
70
|
+
1. Draft the complete SKILL.md:
|
|
71
|
+
|
|
72
|
+
```markdown
|
|
73
|
+
---
|
|
74
|
+
name: pr-review
|
|
75
|
+
description: Review a pull request against the team checklist. Use when asked to review, approve, or check a PR.
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
# PR review
|
|
79
|
+
|
|
80
|
+
1. ...
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- `name`: ASCII letters, digits, `-` and `_`, at most 64 characters. It
|
|
84
|
+
becomes the directory name.
|
|
85
|
+
- `description`: the only text semantic search indexes. Say what the skill
|
|
86
|
+
does and when to use it, in the words a user would type.
|
|
87
|
+
- Only `name`, `description`, `allowed-tools`, `tags`, and `metadata`
|
|
88
|
+
survive in the frontmatter. Put anything else (version, author, ...)
|
|
89
|
+
under `metadata:` or it is dropped.
|
|
90
|
+
2. Check the name on the target root with
|
|
91
|
+
`read(uris="<root>/<name>/SKILL.md")`, where `<root>` is
|
|
92
|
+
`viking://~/skills`, or `viking://agent/skills` when sharing. If it
|
|
93
|
+
exists, `add_skill` replaces it without asking: show the user what would
|
|
94
|
+
change and install only after they confirm, or pick another name.
|
|
95
|
+
3. `add_skill(data="<the full SKILL.md text>")`. It goes to the user's own
|
|
96
|
+
skills unless `target_uri` says otherwise. The reply gives the new URI; the
|
|
97
|
+
skill can be read at once and shows up in search a few seconds later.
|
|
98
|
+
|
|
99
|
+
To change a skill, `read` its SKILL.md, edit the text, and pass the whole new
|
|
100
|
+
text to `add_skill` under the root the skill came from. For a shared skill
|
|
101
|
+
that means `target_uri="viking://agent/skills"`, and only after the user
|
|
102
|
+
confirms an account-wide change; without it, `add_skill` creates a private
|
|
103
|
+
copy that shadows the shared one. Do not use `write` or `edit` on skill
|
|
104
|
+
files: they refuse the user's skills subtree and would bypass installation
|
|
105
|
+
under the shared root. A reinstall does not delete helper files the new
|
|
106
|
+
version dropped.
|
|
107
|
+
|
|
108
|
+
## Install from a repository or a local folder
|
|
109
|
+
|
|
110
|
+
- **Git**: `add_skill(path="https://github.com/org/repo")`, or a
|
|
111
|
+
`.../tree/<branch>/<dir>` URL for one directory. For a repository with
|
|
112
|
+
several skills, call it with `list_only=true` first, then pass
|
|
113
|
+
`skills=["a", "b"]`. Install from a source the user did not name only after
|
|
114
|
+
asking them.
|
|
115
|
+
- **Local folder or zip**: `add_skill(path="/abs/path/to/skill")` returns a
|
|
116
|
+
one-time upload URL. Every file in the archive is stored with the skill,
|
|
117
|
+
so zip without VCS data and secrets:
|
|
118
|
+
`cd <parent> && rm -f /tmp/<name>.zip && zip -r /tmp/<name>.zip <name> -x '*/.git/*' '*/.env*' '*/node_modules/*' '*/.DS_Store'`,
|
|
119
|
+
then `curl -sS -F "file=@/tmp/<name>.zip" "<upload url>"` and delete the
|
|
120
|
+
archive. The response lists the installed URIs; nothing else to call. A
|
|
121
|
+
skill that is only a SKILL.md can go through `data=` instead.
|
|
122
|
+
- **`ov` CLI**, when installed: `ov skills add <path or URL>` (`-l` lists,
|
|
123
|
+
`-s a,b` selects, `-p viking://agent/skills` shares, `-y` skips the prompt).
|
|
124
|
+
|
|
125
|
+
## Share with the account
|
|
126
|
+
|
|
127
|
+
Pass `target_uri="viking://agent/skills"` to `add_skill`. Do this only when the
|
|
128
|
+
user asks for a team- or account-wide skill: everyone on the account sees it.
|
|
129
|
+
|
|
130
|
+
## Delete a skill
|
|
131
|
+
|
|
132
|
+
Prefer `ov skills remove <name>` or OpenViking Studio; both also drop the
|
|
133
|
+
skill's stored privacy values. `forget(uri="<skill uri>", recursive=true)`
|
|
134
|
+
removes the directory but leaves those values behind, so use it only after the
|
|
135
|
+
user confirms the exact URI.
|
|
136
|
+
|
|
137
|
+
To rename a skill, install it under the new name with `add_skill` and remove
|
|
138
|
+
the old one. Moving or copying the directory does not rename it: the skill's
|
|
139
|
+
own metadata still carries the old name.
|
|
140
|
+
|
|
141
|
+
## Move local skills into OpenViking
|
|
142
|
+
|
|
143
|
+
Run this only when the user asks. Nothing is uploaded without their approval
|
|
144
|
+
of that skill.
|
|
145
|
+
|
|
146
|
+
1. **List candidates.** Read each `*/SKILL.md` under:
|
|
147
|
+
- Claude Code: `~/.claude/skills/`, `<repo>/.claude/skills/`
|
|
148
|
+
- Codex: `~/.agents/skills/`, `<repo>/.agents/skills/`
|
|
149
|
+
- Cursor: `~/.cursor/skills/`
|
|
150
|
+
2. **Classify.** A skill is *environment-bound*, stays local, and is never
|
|
151
|
+
offered for upload when any of these holds:
|
|
152
|
+
- a plugin or marketplace ships it (its real path contains `/plugins/` or
|
|
153
|
+
`/marketplaces/`), so the plugin reinstalls it on update;
|
|
154
|
+
- the directory is a symlink or resolves outside the skills folder, the way
|
|
155
|
+
CLI installers such as lark-cli link their own skills;
|
|
156
|
+
- it needs a local binary, CLI, or service to work (for example
|
|
157
|
+
`metadata.<vendor>.requires.bins`, or steps that run a vendor CLI);
|
|
158
|
+
- its name carries a vendor prefix such as `lark-`, `volcengine-`, `ve-`,
|
|
159
|
+
`claude-`, or `mcp-` (a hint, not proof: check the body);
|
|
160
|
+
- it is `openviking-skills` or `openviking-memory`.
|
|
161
|
+
|
|
162
|
+
For the rest, list the whole folder, hidden files included
|
|
163
|
+
(`find <dir> -type f`), and check helper files as well as SKILL.md. Mark
|
|
164
|
+
a skill "clean up first" when any file holds credentials, tokens, `.env`
|
|
165
|
+
files, internal hostnames, or personal absolute paths. Also flag
|
|
166
|
+
frontmatter keys other than `name`, `description`, `allowed-tools`,
|
|
167
|
+
`tags` and `metadata` (for example `disable-model-invocation`,
|
|
168
|
+
`user-invocable`, `context`, `model`): OpenViking drops them, which can
|
|
169
|
+
change how the skill behaves. The rest are portable.
|
|
170
|
+
3. **Confirm.** Show a table of name, path, verdict, one-line reason, and
|
|
171
|
+
target (`viking://~/skills` unless the user wants sharing). The user may
|
|
172
|
+
flip any verdict. Upload only what they approve.
|
|
173
|
+
4. **Upload.** A skill that is only a SKILL.md goes through
|
|
174
|
+
`add_skill(data=...)`; a folder with helper files goes through the zip
|
|
175
|
+
upload above, or `ov skills add <dir>` when the CLI is installed
|
|
176
|
+
(`ov skills validate <dir>` first catches format errors). OpenViking
|
|
177
|
+
requires `name` and `description` in the frontmatter; when a local skill
|
|
178
|
+
lacks them, fix a copy in a temporary folder (the name is the folder
|
|
179
|
+
name), never the user's own file.
|
|
180
|
+
5. **Verify.** `tree(uri="viking://~/skills", level_limit=1, include_abstract=true)`
|
|
181
|
+
should list every uploaded name. Then `remember` which skills were migrated
|
|
182
|
+
and which were skipped, so a later session does not ask again.
|
|
183
|
+
6. **Keep the local copies** unless the user asks to remove them; a local
|
|
184
|
+
skill with the same name just takes precedence in that harness. Create new
|
|
185
|
+
skills with `add_skill` from now on.
|
|
186
|
+
|
|
187
|
+
## Boundaries
|
|
188
|
+
|
|
189
|
+
- Never upload environment-bound skills, and never delete the user's local
|
|
190
|
+
skill files on your own.
|
|
191
|
+
- Never put credentials in a SKILL.md; the shared root is visible to the whole
|
|
192
|
+
account.
|
|
193
|
+
- Memory search and writing are covered by the `openviking-memory` skill.
|
|
194
|
+
- If the server has no `add_skill` tool (an older OpenViking), use
|
|
195
|
+
`ov skills add` when the CLI is installed; otherwise tell the user the server
|
|
196
|
+
needs an upgrade. Do not fall back to `write`, `edit`, or `add_resource`:
|
|
197
|
+
under the user's own root they are refused, and under `viking://agent/skills`
|
|
198
|
+
a write lands as an ordinary file that never goes through installation.
|
package/skills.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import * as skillFilesystem from "@deepseek-ai/dsh-skill-filesystem";
|
|
|
4
4
|
/** Provider name on `ctx.skills`; must not collide with DSH's own `filesystem`. */
|
|
5
5
|
export const SKILL_PROVIDER_NAME = "openviking";
|
|
6
6
|
|
|
7
|
-
/** The shared `openviking-memory`
|
|
7
|
+
/** The shared `openviking-memory` and `openviking-skills` skills, vendored from examples/skills. */
|
|
8
8
|
export const SKILLS_DIR = fileURLToPath(new URL("./skills", import.meta.url));
|
|
9
9
|
|
|
10
10
|
export function buildSkillsConfig() {
|
package/uri-guard.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { boundContextSummary } from "@deepseek-ai/dsh-llm";
|
|
2
2
|
import { pluginMessage } from "./capture.mjs";
|
|
3
3
|
import { MCP_SERVER_NAME } from "./config.mjs";
|
|
4
|
-
import { evaluateUriGuard, evaluateUriNotice } from "./shared/uri-guard.mjs";
|
|
4
|
+
import { addSkillExample, evaluateUriGuard, evaluateUriNotice, isSkillUri } from "./shared/uri-guard.mjs";
|
|
5
5
|
|
|
6
6
|
/** Model-facing name of a bridged OpenViking MCP tool. */
|
|
7
7
|
const mcp = rawName => `mcp__${MCP_SERVER_NAME}__${rawName}`;
|
|
@@ -25,12 +25,18 @@ const GUARDED_TOOLS = {
|
|
|
25
25
|
example: uri => `${mcp("read")}(uris="${uri}")`,
|
|
26
26
|
},
|
|
27
27
|
edit: {
|
|
28
|
-
tool: mcp("edit"),
|
|
29
|
-
example: uri =>
|
|
28
|
+
tool: uri => (isSkillUri(uri) ? mcp("add_skill") : mcp("edit")),
|
|
29
|
+
example: uri =>
|
|
30
|
+
isSkillUri(uri)
|
|
31
|
+
? addSkillExample(uri, { call: mcp("add_skill"), edited: true })
|
|
32
|
+
: `${mcp("edit")}(uri="${uri}", old_string="...", new_string="...")`,
|
|
30
33
|
},
|
|
31
34
|
write: {
|
|
32
|
-
tool: mcp("write"),
|
|
33
|
-
example: uri =>
|
|
35
|
+
tool: uri => (isSkillUri(uri) ? mcp("add_skill") : mcp("write")),
|
|
36
|
+
example: uri =>
|
|
37
|
+
isSkillUri(uri)
|
|
38
|
+
? addSkillExample(uri, { call: mcp("add_skill") })
|
|
39
|
+
: `${mcp("write")}(uri="${uri}", content="...")`,
|
|
34
40
|
},
|
|
35
41
|
str_replace_editor: {
|
|
36
42
|
tool: "the OpenViking MCP tools",
|