@openviking/dsh-memory-plugin 0.4.4 → 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 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 and available-memory index through `agent.inject()`.
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`, and the rest of
219
- whatever the connected server advertises. The list re-syncs when the server
220
- announces a change, so a server upgrade adds tools without a bundle release.
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` skill from `skills/`
226
- through its own isolated `ctx.skills` provider, so the model gets the same
227
- guidance on when to search, read, and write that the other integrations ship.
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.4";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openviking/dsh-memory-plugin",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "OpenViking memory and context bundle for DeepSeek Harness",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
package/runtime.mjs CHANGED
@@ -102,6 +102,7 @@ export class OpenVikingRuntime {
102
102
  (path, init, options) => this.client.fetchJSON(path, init, options),
103
103
  state.config.profileTokenBudget,
104
104
  state.config.peerId,
105
+ state.config,
105
106
  );
106
107
  state.profileBlock = profile?.block
107
108
  ? [
@@ -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" },
@@ -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
- function formatListing(headerUri, entries, budgetTokens) {
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
- if (headerTokens > budgetTokens) {
198
- const stub = ` ${headerUri}/ (${entries.length} entries, budget too tight; use \`memory_recall\`)`;
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
- const remaining = entries.length - i;
212
- const tail = ` ... +${remaining} more, use \`memory_recall\``;
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, "&lt;").replace(/>/g, "&gt;"));
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 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="...">.
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 chars/4 budget, total for the whole block
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) return null;
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(totalBudgetTokens / 2);
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, totalBudgetTokens - profileTokens);
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 lines = [];
270
- if (profileTrunc) {
271
- lines.push(`<user-profile uri="${profileUri}">`);
272
- lines.push(profileTrunc);
273
- lines.push(`</user-profile>`);
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 (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>`);
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
+ }
@@ -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) => `edit(uri="${uri}", old_string="...", new_string="...")`,
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) => `write(uri="${uri}", content="...")`,
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` skill, vendored from examples/skills. */
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 => `${mcp("edit")}(uri="${uri}", old_string="...", new_string="...")`,
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 => `${mcp("write")}(uri="${uri}", content="...")`,
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",