@openviking/opencode-plugin 2026.9.22 → 2026.9.23
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 +6 -1
- package/INSTALL.md +6 -2
- package/README.md +6 -1
- package/lib/session-inject.mjs +1 -1
- package/lib/shared/config-schema.mjs +16 -0
- package/lib/shared/profile-inject.mjs +232 -31
- package/lib/shared/uri-guard.mjs +38 -5
- package/package.json +2 -2
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
|
|
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
|
|
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.
|
package/lib/session-inject.mjs
CHANGED
|
@@ -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
|
-
|
|
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/lib/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
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openviking/opencode-plugin",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.23",
|
|
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": "
|
|
48
|
+
"openvikingSourceCommit": "03391bae4335eacf440a62d942f3951de6a63cbe"
|
|
49
49
|
}
|