@openviking/opencode-plugin 2026.9.22 → 2026.9.23-2

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/INSTALL-ZH.md CHANGED
@@ -113,6 +113,8 @@ export { OpenVikingPlugin, default } from "./openviking/index.mjs"
113
113
  "commitTokenThreshold": 20000,
114
114
  "commitKeepRecentCount": 10,
115
115
  "profileTokenBudget": 10000,
116
+ "skillCatalog": true,
117
+ "skillCatalogTokenBudget": 1200,
116
118
  "resumeContextBudget": 32000
117
119
  }
118
120
  }
@@ -124,6 +126,8 @@ export { OpenVikingPlugin, default } from "./openviking/index.mjs"
124
126
  `recallLimit` 是遗留的配额缩放输入,不是最终结果上限。显式设置为
125
127
  1 到 5 时,有效总配额仍为 6,因为六个 coding 分类会各保留一个检索槽位。
126
128
 
129
+ 每个 session 的第一条消息会带上一个隐藏的 `<openviking-context source="session-start">` 块,里面有你的 `profile.md`、`preferences/` 和 `entities/` 记忆索引,以及 `<available-skills>` skill 清单:先列你自己的 skill,再列 `viking://agent/skills` 下账号共享的 skill;共享 skill 与你自己的 skill 同名时不再列出。`profileTokenBudget` 只管 profile 和记忆索引,skill 清单有独立的预算 `skillCatalogTokenBudget`(默认 `1200`,环境变量 `OPENVIKING_SKILL_CATALOG_TOKEN_BUDGET`)。放不下描述时只列 skill 名,名字也列不全时末尾注明 `... +N more`;连一个名字都放不下时,只写一行 skill 总数。设置 `skillCatalog: false`(`OPENVIKING_SKILL_CATALOG=0`)或把预算设为 `0` 即可关闭 skill 清单;没有 skill,或服务端没有 `GET /api/v1/skills` 接口时,这一块会直接省略。
130
+
127
131
  推荐通过环境变量提供 API Key,而不是写入配置文件:
128
132
 
129
133
  ```bash
@@ -166,7 +170,6 @@ OpenCode 的 `mcp.openviking` 配置。
166
170
  - `openviking_read`、`openviking_list`、`openviking_tree`、`openviking_grep`、`openviking_glob`
167
171
  - `openviking_remember`、`openviking_write`、`openviking_edit`、`openviking_add_resource`、`openviking_add_skill`
168
172
  - `openviking_list_watches`、`openviking_cancel_watch`、`openviking_forget`、`openviking_health`
169
- - `openviking_list_watches`、`openviking_cancel_watch`
170
173
 
171
174
  如果行为异常,先查看运行时文件:
172
175
 
@@ -196,6 +199,7 @@ curl http://localhost:1933/health
196
199
  - `openviking_write`:创建、覆盖或追加 `viking://` 文件
197
200
  - `openviking_edit`:对 `viking://` 文件做精确字符串替换
198
201
  - `openviking_add_resource`:添加 URL、本地文件、sitemap 或 feed
202
+ - `openviking_add_skill`:用完整的 `SKILL.md` 文本(`data`)创建或替换 skill,或从 Git URL、本地 `SKILL.md`、skill 目录或 `.zip`(`path`)安装;传 `target_uri="viking://agent/skills"` 则共享给整个账号
199
203
  - `openviking_forget`:在用户明确确认后删除 `viking://` URI
200
204
  - `openviking_list_watches` / `openviking_cancel_watch`:查看或取消资源 watch
201
205
  - `openviking_health`:检查 OpenViking server 健康状态
@@ -207,6 +211,7 @@ curl http://localhost:1933/health
207
211
  - 枚举文件用 `openviking_glob`
208
212
  - 读取内容用 `openviking_read`
209
213
  - 探索目录结构用 `openviking_list`
214
+ - 照 `<available-skills>` 里的某个 skill 做事之前,先用 `openviking_read` 读它的 `SKILL.md`;创建、安装或共享 skill 用 `openviking_add_skill`
210
215
  - 删除前必须先获得用户明确确认,再调用 `openviking_forget`
211
216
  - 如果 agent 误用 OpenCode 本地 `read`、`glob`、`grep` 工具访问 `viking://` URI,插件会阻止这次本地文件系统调用,并提示改用 MCP 工具。
212
217
  - `bash` 命令里带 `viking://` URI 时照常执行,插件会在输出末尾附一段提示,建议改用 MCP 工具;URI 本来就是命令参数时,agent 可以忽略这段提示。
package/INSTALL.md CHANGED
@@ -113,6 +113,8 @@ Example configuration:
113
113
  "commitTokenThreshold": 20000,
114
114
  "commitKeepRecentCount": 10,
115
115
  "profileTokenBudget": 10000,
116
+ "skillCatalog": true,
117
+ "skillCatalogTokenBudget": 1200,
116
118
  "resumeContextBudget": 32000
117
119
  }
118
120
  }
@@ -125,6 +127,8 @@ Keys in `plugin` apply to every harness; keys in `plugin.opencode` apply to this
125
127
  Explicit values from 1 through 5 produce an effective total quota of 6 because
126
128
  each coding category keeps one retrieval slot.
127
129
 
130
+ The first message of each session carries a hidden `<openviking-context source="session-start">` block with your `profile.md`, the `preferences/` and `entities/` memory indexes, and an `<available-skills>` catalog: your own skills first, then the account-shared ones under `viking://agent/skills`, leaving out a shared skill that has the same name as one of yours. `profileTokenBudget` covers the profile and memory indexes; the catalog has its own budget, `skillCatalogTokenBudget` (default `1200`, `OPENVIKING_SKILL_CATALOG_TOKEN_BUDGET`). When the descriptions do not fit, the catalog lists names only (with a `... +N more` tail if even the names do not all fit), and when not even one name fits, a one-line count. `skillCatalog: false` (`OPENVIKING_SKILL_CATALOG=0`) or a budget of `0` turns the catalog off; with no skills, or on a server without `GET /api/v1/skills`, it is left out.
131
+
128
132
  It is recommended to provide the API key through an environment variable instead of writing it into the configuration file:
129
133
 
130
134
  ```bash
@@ -163,7 +167,6 @@ In a new OpenCode session, ask the agent to browse OpenViking memory or search f
163
167
  - `openviking_read`, `openviking_list`, `openviking_tree`, `openviking_grep`, `openviking_glob`
164
168
  - `openviking_remember`, `openviking_write`, `openviking_edit`, `openviking_add_resource`, `openviking_add_skill`
165
169
  - `openviking_list_watches`, `openviking_cancel_watch`, `openviking_forget`, `openviking_health`
166
- - `openviking_list_watches`, `openviking_cancel_watch`
167
170
 
168
171
  If anything looks wrong, check the runtime files:
169
172
 
@@ -193,7 +196,7 @@ The plugin registers OpenViking's stdio MCP proxy through OpenCode config. The s
193
196
  - `openviking_write`: create, overwrite, or append to a `viking://` file.
194
197
  - `openviking_edit`: exact string replacement in a `viking://` file.
195
198
  - `openviking_add_resource`: add a URL, local file, sitemap, or feed.
196
- - `openviking_add_skill`: create, install, or replace an agent skill.
199
+ - `openviking_add_skill`: create or replace a skill from its full `SKILL.md` text (`data`), or install one from a Git URL or a local `SKILL.md`, skill directory, or `.zip` (`path`); `target_uri="viking://agent/skills"` shares it with the account.
197
200
  - `openviking_forget`: delete a `viking://` URI after explicit user confirmation.
198
201
  - `openviking_list_watches` / `openviking_cancel_watch`: inspect or cancel resource watches.
199
202
  - `openviking_health`: check OpenViking server health.
@@ -205,6 +208,7 @@ Usage guidance:
205
208
  - Use `openviking_glob` to enumerate files.
206
209
  - Use `openviking_read` to read content.
207
210
  - Use `openviking_list` to explore directory structure.
211
+ - Before following a skill from `<available-skills>`, read its `SKILL.md` with `openviking_read`; create, install, or share a skill with `openviking_add_skill`.
208
212
  - Before deleting anything, obtain explicit user confirmation first; then call `openviking_forget`.
209
213
  - If an agent tries to use OpenCode's local `read`, `glob`, or `grep` tools on a `viking://` URI, the plugin blocks that call and points it to the MCP tools.
210
214
  - A `bash` command that contains a `viking://` URI still runs; the plugin appends a notice pointing to the MCP tools to its output, which the agent can ignore when the URI is intentional.
package/README.md CHANGED
@@ -13,6 +13,7 @@ The plugin uses OpenCode hooks for lifecycle behavior and registers OpenViking's
13
13
  ## What It Does
14
14
 
15
15
  - Injects indexed `viking://resources/` repositories into the system prompt.
16
+ - On the first message of each session, injects your profile, memory indexes, and an `<available-skills>` catalog of your own and account-shared OpenViking skills.
16
17
  - Exposes the same OpenViking MCP tools used by the Claude Code and Codex memory plugins.
17
18
  - Maps each OpenCode session to an OpenViking session.
18
19
  - Captures user and assistant text messages into OpenViking.
@@ -139,6 +140,8 @@ Behaviour knobs live in `~/.openviking/ovcli.conf` beside the connection fields,
139
140
  "commitTokenThreshold": 20000,
140
141
  "commitKeepRecentCount": 10,
141
142
  "profileTokenBudget": 10000,
143
+ "skillCatalog": true,
144
+ "skillCatalogTokenBudget": 1200,
142
145
  "resumeContextBudget": 32000
143
146
  }
144
147
  }
@@ -152,6 +155,8 @@ Explicit values from 1 through 5 produce an effective total quota of 6 because
152
155
  each coding category keeps one retrieval slot. Use Context `quotas` directly
153
156
  when exact category ceilings are required.
154
157
 
158
+ `profileTokenBudget` covers the profile and memory indexes in the session-start block. The `<available-skills>` catalog comes from one `GET /api/v1/skills` and has its own budget, `skillCatalogTokenBudget` (default `1200`, `OPENVIKING_SKILL_CATALOG_TOKEN_BUDGET`). Your own skills are listed first, then the ones shared under `viking://agent/skills`, leaving out a shared skill that has the same name as one of yours; each description is cut to about 40 tokens. When the descriptions do not fit, the catalog lists names only (with a `... +N more` tail if even the names do not all fit), and when not even one name fits, a one-line count. The agent reads a skill's `SKILL.md` with `openviking_read` before following it. `skillCatalog: false` (`OPENVIKING_SKILL_CATALOG=0`) or a budget of `0` turns the catalog off; with no skills, or on a server without `GET /api/v1/skills`, it is left out.
159
+
155
160
  API keys are resolved from environment variables or `~/.openviking/ovcli.conf` and sent as `Authorization: Bearer ...` by both hooks and the MCP proxy. Recall goes through the server-side context face (`POST /api/v1/search/search` with `mode="context"`), falling back to the deprecated `/api/v1/search/recall` on older deployments. `account` and `user` are trusted-mode identity
156
161
  headers sent as `X-OpenViking-Account` and `X-OpenViking-User`; an `api_key`
157
162
  server reads both out of the key, so the plugin withholds them there.
@@ -212,7 +217,7 @@ OpenCode sees the OpenViking MCP server as `openviking`, so tool names are names
212
217
  - `openviking_write`: create, overwrite, or append to a `viking://` file.
213
218
  - `openviking_edit`: exact string replacement in a `viking://` file.
214
219
  - `openviking_add_resource`: add a URL, local file, sitemap, or feed.
215
- - `openviking_add_skill`: create, install, or replace an agent skill.
220
+ - `openviking_add_skill`: create or replace a skill from its full `SKILL.md` text (`data`), or install one from a Git URL or a local `SKILL.md`, skill directory, or `.zip` (`path`); `target_uri="viking://agent/skills"` shares it with the account.
216
221
  - `openviking_forget`: delete a `viking://` URI after explicit user confirmation.
217
222
  - `openviking_list_watches` / `openviking_cancel_watch`: inspect or cancel resource watches.
218
223
  - `openviking_health`: check OpenViking server health.
@@ -18,7 +18,7 @@ export function createSessionInject({ config, sessionManager }) {
18
18
  fetchJSON(config, endpoint, init, { ...options, actorPeerId, timeoutMs: 10000 })
19
19
 
20
20
  const parts = []
21
- const profile = await buildProfileBlock(clientFetch, config.profileTokenBudget, actorPeerId)
21
+ const profile = await buildProfileBlock(clientFetch, config.profileTokenBudget, actorPeerId, config)
22
22
  if (profile?.block) parts.push(profile.block)
23
23
 
24
24
  const archive = await fetchArchiveBlock(clientFetch, ovSessionId, config.resumeContextBudget)
@@ -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" },
@@ -228,6 +243,7 @@ export const HARNESS_KEYS = {
228
243
  claudeCode: "claude_code",
229
244
  codex: "codex",
230
245
  cursor: "cursor",
246
+ kimicode: "kimicode",
231
247
  trae: "trae",
232
248
  traeCn: "trae_cn",
233
249
  zcode: "zcode",
@@ -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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openviking/opencode-plugin",
3
- "version": "2026.9.22",
3
+ "version": "2026.9.23-2",
4
4
  "author": "@openviking",
5
5
  "description": "Unified OpenCode plugin for OpenViking repository retrieval and long-term memory",
6
6
  "type": "module",
@@ -45,5 +45,5 @@
45
45
  "code-search"
46
46
  ],
47
47
  "license": "Apache-2.0",
48
- "openvikingSourceCommit": "6b127eb92ccdb627eb343a959f0e4fe0dbf9a16c"
48
+ "openvikingSourceCommit": "1c04d1cd94c8ccd615626e2e859f2d3be9eced5a"
49
49
  }