opencode-skills-doctor 0.9.3 → 0.9.6
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 +21 -10
- package/docs/README.zh-CN.md +6 -5
- package/docs/REFERENCE.md +4 -4
- package/package.json +1 -1
- package/src/discover.mjs +2 -0
- package/src/fix.mjs +35 -7
- package/src/i18n.mjs +2 -2
- package/src/lint.mjs +53 -32
- package/src/parse.mjs +91 -4
- package/src/plan.mjs +49 -19
package/README.md
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<h1 align="center">skills-doctor</h1>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
<b>Audit
|
|
9
|
-
|
|
8
|
+
<b>Audit the token cost your coding agent pays for skills on every step.</b><br>
|
|
9
|
+
A word-piece estimate, boilerplate-aware overlap, and a plan that keeps the routing sentence and hides the redundant skill first.
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
12
|
<p align="center">
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
<a href="https://www.npmjs.com/package/opencode-skills-doctor"><img src="https://img.shields.io/npm/dw/opencode-skills-doctor?style=for-the-badge&color=2ea043" alt="npm downloads"></a>
|
|
15
15
|
<a href="https://github.com/wangyuchen5110/opencodeplugginprogram/stargazers"><img src="https://img.shields.io/github/stars/wangyuchen5110/opencodeplugginprogram?style=for-the-badge&color=58a6ff" alt="GitHub stars"></a>
|
|
16
16
|
<a href="https://github.com/wangyuchen5110/opencodeplugginprogram/actions/workflows/test.yml"><img src="https://github.com/wangyuchen5110/opencodeplugginprogram/actions/workflows/test.yml/badge.svg" alt="tests"></a>
|
|
17
|
+
<a href="https://github.com/marketplace/actions/skills-doctor"><img src="https://img.shields.io/badge/GitHub%20Marketplace-skills--doctor-3fb950?style=for-the-badge&logo=github" alt="GitHub Marketplace"></a>
|
|
17
18
|
</p>
|
|
18
19
|
|
|
19
20
|
<p align="center">
|
|
@@ -47,7 +48,7 @@ Every agent skill with a `description` is advertised to the model on **every sin
|
|
|
47
48
|
| | |
|
|
48
49
|
|---|---|
|
|
49
50
|
| **Find** | token budget, shadowed duplicates, flat/dir collisions, long descriptions, oversized bodies, broken references, invalid frontmatter, stale skills |
|
|
50
|
-
| **Fix** | archive shadowed definitions (reversible), hide
|
|
51
|
+
| **Fix** | archive shadowed definitions (reversible), hide redundant skills before unique ones, keep the routing sentence in the plan |
|
|
51
52
|
| **Update** | sync skills from HTTP catalogs with a lockfile, drift detection and `--prune` |
|
|
52
53
|
| **Report** | terminal, JSON, or a self-contained HTML report |
|
|
53
54
|
| **Run it** | CLI · OpenCode plugin (1.x/2.x/Desktop) · MCP server · DeepSeek Harness skill |
|
|
@@ -141,7 +142,8 @@ Nothing to configure for **skills discovery**: it reads `.opencode/skills`, `.cl
|
|
|
141
142
|
## Use it in CI (GitHub Action)
|
|
142
143
|
|
|
143
144
|
A composite action ships in this repo: no install step, no runtime dependencies —
|
|
144
|
-
it runs the checked-out code directly.
|
|
145
|
+
it runs the checked-out code directly. It is listed on the
|
|
146
|
+
[GitHub Marketplace](https://github.com/marketplace/actions/skills-doctor).
|
|
145
147
|
|
|
146
148
|
```yaml
|
|
147
149
|
name: skills
|
|
@@ -156,7 +158,7 @@ jobs:
|
|
|
156
158
|
runs-on: ubuntu-latest
|
|
157
159
|
steps:
|
|
158
160
|
- uses: actions/checkout@v4
|
|
159
|
-
- uses: wangyuchen5110/opencodeplugginprogram@v0.9.
|
|
161
|
+
- uses: wangyuchen5110/opencodeplugginprogram@v0.9.6
|
|
160
162
|
with:
|
|
161
163
|
budget: "1200"
|
|
162
164
|
fail-on: warn # error | warn | never
|
|
@@ -165,16 +167,25 @@ jobs:
|
|
|
165
167
|
```
|
|
166
168
|
|
|
167
169
|
It writes a summary to the job page, uploads the HTML report, optionally comments
|
|
168
|
-
on the pull request, and fails the job according to `fail-on`.
|
|
169
|
-
|
|
170
|
-
|
|
170
|
+
on the pull request, and fails the job according to `fail-on`.
|
|
171
|
+
|
|
172
|
+
| Input | Default | Purpose |
|
|
173
|
+
|---|---|---|
|
|
174
|
+
| `root` | `.` | Directory to audit |
|
|
175
|
+
| `budget` | `1200` | Description tokens paid per model step |
|
|
176
|
+
| `fail-on` | `error` | Fail on `error`, `warn`, or `never` |
|
|
177
|
+
| `extra-args` | — | Extra `check` flags, e.g. `--no-global` |
|
|
178
|
+
| `html` | — | HTML report path; uploaded as an artifact when set |
|
|
179
|
+
| `comment` | `false` | Post/append the summary as a pull request comment |
|
|
180
|
+
|
|
181
|
+
Outputs: `exit-code`, `error-count`, `warning-count`, `advertised-tokens`.
|
|
171
182
|
|
|
172
183
|
## Commands
|
|
173
184
|
|
|
174
185
|
| Command | What it does |
|
|
175
186
|
|---|---|
|
|
176
187
|
| `check` | audit sources, conflicts, budget and broken references (default) |
|
|
177
|
-
| `plan` |
|
|
188
|
+
| `plan` | savings from keeping the first sentence, then hiding redundant skills first, projected over N steps |
|
|
178
189
|
| `scan` | every skill with its source, precedence and cost |
|
|
179
190
|
| `fix` | archive shadowed definitions (reversible) or hide low-frequency skills |
|
|
180
191
|
| `update` | sync skills from HTTP catalogs, verify hashes, detect drift |
|
|
@@ -208,7 +219,7 @@ All commands accept `--json`, `--lang en|zh`, `--budget`, `--root` and `--no-glo
|
|
|
208
219
|
## Development
|
|
209
220
|
|
|
210
221
|
```powershell
|
|
211
|
-
npm test #
|
|
222
|
+
npm test # 73 tests (node test/doctor.test.mjs)
|
|
212
223
|
npm run bench # lint timing for 100/500/1000 skills
|
|
213
224
|
```
|
|
214
225
|
|
package/docs/README.zh-CN.md
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<h1 align="center">skills-doctor</h1>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
<b
|
|
9
|
-
|
|
8
|
+
<b>量出 coding agent 每一步为 skill 付出的 token。</b><br>
|
|
9
|
+
按词估算,丢掉套话重叠,方案只留路由用的第一句,并先藏重复的 skill。
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
12
|
<p align="center">
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
<a href="https://www.npmjs.com/package/opencode-skills-doctor"><img src="https://img.shields.io/npm/dw/opencode-skills-doctor?style=for-the-badge&color=2ea043" alt="downloads"></a>
|
|
15
15
|
<a href="https://github.com/wangyuchen5110/opencodeplugginprogram/stargazers"><img src="https://img.shields.io/github/stars/wangyuchen5110/opencodeplugginprogram?style=for-the-badge&color=58a6ff" alt="stars"></a>
|
|
16
16
|
<a href="https://github.com/wangyuchen5110/opencodeplugginprogram/actions/workflows/test.yml"><img src="https://github.com/wangyuchen5110/opencodeplugginprogram/actions/workflows/test.yml/badge.svg" alt="tests"></a>
|
|
17
|
+
<a href="https://github.com/marketplace/actions/skills-doctor"><img src="https://img.shields.io/badge/GitHub%20Marketplace-skills--doctor-3fb950?style=for-the-badge&logo=github" alt="GitHub Marketplace"></a>
|
|
17
18
|
</p>
|
|
18
19
|
|
|
19
20
|
<p align="center">
|
|
@@ -47,7 +48,7 @@ OpenCode skill 的**本地检测器 + 更新器 + 修复器**,并附带 **Open
|
|
|
47
48
|
| **形态** | CLI · OpenCode 插件(1.x / 2.x)· MCP server(Claude Code、Codex、DSH…)· 兼容 Grok CLI |
|
|
48
49
|
| **来源** | `.opencode/skills`、`.claude/skills`、`.agents/skills`、`.grok/skills`、`.cursor/skills`、`.hermes/skills`、`.kimi-code/skills`、`.dsh/skills`(含 `$DSH_HOME`)、`opencode.json(c)` 配置、HTTP catalog |
|
|
49
50
|
| **能查** | 同 ID 遮蔽、重复、超长描述、超大正文、每步 token 预算、断链引用、非法 frontmatter、空正文、陈旧 skill |
|
|
50
|
-
| **能修** |
|
|
51
|
+
| **能修** | 归档遮蔽定义(可逆)、先藏重复的 skill、方案只留路由用的第一句 |
|
|
51
52
|
| **报告** | 终端、JSON,或自包含 HTML(`--html [file] --open`) |
|
|
52
53
|
| **规划** | 精确 token 节省:精简描述 / 隐藏 skill,按 N 步折算 |
|
|
53
54
|
| **成本** | CLI 0 token;插件工具定义约 191/步,另有每会话一次预警 |
|
|
@@ -487,7 +488,7 @@ npx -y opencode-skills-doctor install --client kimi
|
|
|
487
488
|
| `shadowed` | warn | 同一 ID 多处定义,只有一份生效,其余白占体积且容易改错 |
|
|
488
489
|
| `flat-dir-collision` | warn | 同一来源同时存在 `<id>.md` 和 `<id>/SKILL.md` |
|
|
489
490
|
| `duplicate-content` | warn | 不同 skill 正文完全相同 |
|
|
490
|
-
| `near-duplicate-description` | warn |
|
|
491
|
+
| `near-duplicate-description` | warn | 去掉套话后,特征词重叠 ≥ 72%,或较短的触发词有 ≥ 85% 被另一条包含 |
|
|
491
492
|
| `missing-description` | info | 没有 description,模型看不到它 |
|
|
492
493
|
| `long-description` | warn | description > 240 字符(每个模型步骤都占上下文) |
|
|
493
494
|
| `large-body` | warn | 正文 > 4000 tokens(调用时整段进上下文) |
|
|
@@ -560,7 +561,7 @@ npx -y opencode-skills-doctor install --client kimi
|
|
|
560
561
|
**度量**
|
|
561
562
|
|
|
562
563
|
- 默认 `budget`(1200 tokens)是建议值,不是硬限制;用 `.skills-doctor.json` 或 `--budget` 调整。
|
|
563
|
-
- token
|
|
564
|
+
- token 按词估算(短英文词 1 token,每个汉字 1 token,更长的词每多 4 个字符加 1)。用来比较,不是精确计费。
|
|
564
565
|
- `check` 不联网;要看 catalog 新版本用 `update --check-only`。
|
|
565
566
|
- 配置优先级假设:全局配置 < 项目配置 < `skills` 数组顺序。
|
|
566
567
|
|
package/docs/REFERENCE.md
CHANGED
|
@@ -17,7 +17,7 @@ Local scanner, linter, fixer, updater and **OpenCode plugin** for agent skills
|
|
|
17
17
|
| **Plan** | exact token savings: trim descriptions and/or hide skills, projected over N steps |
|
|
18
18
|
| **Cost** | CLI: 0 tokens. Plugin: ~191 tokens/step for the tool definition, plus a one-time warning per session |
|
|
19
19
|
| **Requirements** | Node ≥ 20, zero runtime dependencies |
|
|
20
|
-
| **Quality** |
|
|
20
|
+
| **Quality** | 73 tests, CI on ubuntu/macos/windows × Node 20/22/24 |
|
|
21
21
|
|
|
22
22
|
## Why
|
|
23
23
|
|
|
@@ -438,7 +438,7 @@ It merges `skills-doctor` into `~/.kimi-code/mcp.json` (or `$KIMI_CODE_HOME/mcp.
|
|
|
438
438
|
| `shadowed` | warn | Same ID defined in multiple sources; only one wins, the rest is dead weight and easy to edit by mistake |
|
|
439
439
|
| `flat-dir-collision` | warn | Both `<id>.md` and `<id>/SKILL.md` exist in the same source |
|
|
440
440
|
| `duplicate-content` | warn | Two skills share identical body text |
|
|
441
|
-
| `near-duplicate-description` | warn |
|
|
441
|
+
| `near-duplicate-description` | warn | Distinctive words overlap by ≥ 72%, or a shorter trigger is ≥ 85% contained in a longer one. Shared boilerplate ("use this skill when…") is ignored |
|
|
442
442
|
| `missing-description` | info | No description, so the model never discovers it |
|
|
443
443
|
| `long-description` | warn | Description > 240 chars (paid on every model step) |
|
|
444
444
|
| `large-body` | warn | Body > 4000 tokens (pasted into context when invoked) |
|
|
@@ -517,7 +517,7 @@ See [SECURITY.md](../SECURITY.md) for the full threat model, hardening checklist
|
|
|
517
517
|
**Measurement**
|
|
518
518
|
|
|
519
519
|
- The default `budget` (1200 tokens) is a recommendation, not a hard limit. Tune it with `.skills-doctor.json` or `--budget`.
|
|
520
|
-
- Token counts are
|
|
520
|
+
- Token counts are a word-piece estimate (a short English word is 1 token, each CJK character is 1, longer words add 1 per 4 extra characters). Good for comparisons, not for billing.
|
|
521
521
|
- `check` is offline; ask a catalog for new versions with `update --check-only`.
|
|
522
522
|
- Config precedence is assumed: global config < project config < `skills` array order.
|
|
523
523
|
|
|
@@ -552,7 +552,7 @@ Executing code is free, but the tool definition is part of the model's context.
|
|
|
552
552
|
## Development and contributing
|
|
553
553
|
|
|
554
554
|
```powershell
|
|
555
|
-
npm test #
|
|
555
|
+
npm test # 73 tests (node test/doctor.test.mjs)
|
|
556
556
|
npm run bench # lint timing for 100/500/1000 skills
|
|
557
557
|
```
|
|
558
558
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-skills-doctor",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.6",
|
|
4
4
|
"description": "Audit, fix and update agent skills: token cost, broken references, shadowed duplicates, HTML reports, catalog sync. CLI, OpenCode plugin, MCP server; works with OpenCode, Claude Code, Codex, Grok CLI, Cursor, Hermes Agent, Kimi Code and DeepSeek Harness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/src/discover.mjs
CHANGED
|
@@ -341,6 +341,7 @@ function buildSkill({ id, file, source, form }) {
|
|
|
341
341
|
const description = typeof data.description === "string" ? data.description.trim() : ""
|
|
342
342
|
const displayName = typeof data.name === "string" && data.name.trim() ? data.name.trim() : id
|
|
343
343
|
const hidden = autoinvoke === false || disabled === true
|
|
344
|
+
const stepTokens = estimateTokens(`${id} ${displayName} ${description}`)
|
|
344
345
|
let mtime = 0
|
|
345
346
|
try {
|
|
346
347
|
mtime = fs.statSync(file).mtimeMs
|
|
@@ -366,6 +367,7 @@ function buildSkill({ id, file, source, form }) {
|
|
|
366
367
|
bodyTokens: estimateTokens(body),
|
|
367
368
|
bytes,
|
|
368
369
|
descriptionTokens: estimateTokens(description),
|
|
370
|
+
stepTokens,
|
|
369
371
|
hash: sha1(body.replace(/\s+/g, " ").trim()),
|
|
370
372
|
mtime,
|
|
371
373
|
}
|
package/src/fix.mjs
CHANGED
|
@@ -96,11 +96,39 @@ export function applyFixes(plan, { dryRun = false } = {}) {
|
|
|
96
96
|
return { moved, errors }
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
/**
|
|
99
|
+
/** 先写临时文件再替换,避免隐藏 skill 时把 SKILL.md 写到一半。 */
|
|
100
|
+
function writeFileAtomic(target, data) {
|
|
101
|
+
const tmp = `${target}.${process.pid}-${Math.random().toString(16).slice(2)}.tmp`
|
|
102
|
+
fs.writeFileSync(tmp, data)
|
|
103
|
+
try {
|
|
104
|
+
fs.renameSync(tmp, target)
|
|
105
|
+
return
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// Windows refuses to rename onto an existing file (EPERM / EEXIST).
|
|
108
|
+
if (err?.code !== "EPERM" && err?.code !== "EEXIST") {
|
|
109
|
+
try {
|
|
110
|
+
fs.rmSync(tmp, { force: true })
|
|
111
|
+
} catch {}
|
|
112
|
+
throw err
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
fs.copyFileSync(tmp, target)
|
|
117
|
+
} finally {
|
|
118
|
+
try {
|
|
119
|
+
fs.rmSync(tmp, { force: true })
|
|
120
|
+
} catch {}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 在 frontmatter 里把 metadata.opencode/autoinvoke 设为 false。保留原来的换行符。 */
|
|
100
125
|
export function setAutoinvokeFalse(text) {
|
|
101
|
-
const
|
|
126
|
+
const source = String(text ?? "")
|
|
127
|
+
const newline = source.includes("\r\n") ? "\r\n" : "\n"
|
|
128
|
+
const lines = source.split(/\r?\n/)
|
|
102
129
|
const snippet = ["metadata:", " opencode/autoinvoke: false"]
|
|
103
|
-
|
|
130
|
+
const join = (parts) => parts.join(newline)
|
|
131
|
+
if (lines[0]?.trim() !== "---") return join(["---", ...snippet, "---", ...lines])
|
|
104
132
|
|
|
105
133
|
let end = -1
|
|
106
134
|
for (let i = 1; i < lines.length; i++) {
|
|
@@ -109,12 +137,12 @@ export function setAutoinvokeFalse(text) {
|
|
|
109
137
|
break
|
|
110
138
|
}
|
|
111
139
|
}
|
|
112
|
-
if (end === -1) return ["---", ...snippet, "---", ...lines]
|
|
140
|
+
if (end === -1) return join(["---", ...snippet, "---", ...lines])
|
|
113
141
|
|
|
114
142
|
const fm = lines.slice(1, end)
|
|
115
143
|
const metaIdx = fm.findIndex((line) => /^metadata\s*:/.test(line.trim()))
|
|
116
144
|
if (metaIdx === -1) {
|
|
117
|
-
return [lines[0], ...fm, ...snippet, ...lines.slice(end)]
|
|
145
|
+
return join([lines[0], ...fm, ...snippet, ...lines.slice(end)])
|
|
118
146
|
}
|
|
119
147
|
|
|
120
148
|
const metaIndent = fm[metaIdx].length - fm[metaIdx].trimStart().length
|
|
@@ -131,7 +159,7 @@ export function setAutoinvokeFalse(text) {
|
|
|
131
159
|
}
|
|
132
160
|
if (!replaced) block.unshift(" ".repeat(metaIndent + 2) + "opencode/autoinvoke: false")
|
|
133
161
|
|
|
134
|
-
return [lines[0], ...fm.slice(0, metaIdx + 1), ...block, ...fm.slice(j), ...lines.slice(end)]
|
|
162
|
+
return join([lines[0], ...fm.slice(0, metaIdx + 1), ...block, ...fm.slice(j), ...lines.slice(end)])
|
|
135
163
|
}
|
|
136
164
|
|
|
137
165
|
/**
|
|
@@ -154,7 +182,7 @@ export function hideSkills(skills, ids, { dryRun = false } = {}) {
|
|
|
154
182
|
try {
|
|
155
183
|
if (!dryRun) {
|
|
156
184
|
const text = fs.readFileSync(skill.file, "utf8")
|
|
157
|
-
|
|
185
|
+
writeFileAtomic(skill.file, setAutoinvokeFalse(text))
|
|
158
186
|
}
|
|
159
187
|
changed.push({ id, file: skill.file })
|
|
160
188
|
} catch (err) {
|
package/src/i18n.mjs
CHANGED
|
@@ -193,7 +193,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
193
193
|
'Both {flat} and {dir} exist in the same source (ID "{id}"), semantics are ambiguous',
|
|
194
194
|
"lint.duplicateContent": "These skills have identical bodies: {ids}",
|
|
195
195
|
"lint.nearDuplicate":
|
|
196
|
-
'"{a}" and "{b}"
|
|
196
|
+
'"{a}" and "{b}" overlap {score}% on {terms}; the model may pick the wrong one',
|
|
197
197
|
"lint.missingDescription":
|
|
198
198
|
'"{id}" has no description, so the model will not discover it (only explicit @{id})',
|
|
199
199
|
"lint.longDescription": '"{id}" description is {chars} chars; it is paid on every model step',
|
|
@@ -420,7 +420,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
|
|
|
420
420
|
'ID "{id}" 有 {count} 处定义,实际生效的是 {winner};其余 {losers} 处被遮蔽(白占体积、容易改错文件)',
|
|
421
421
|
"lint.flatDirCollision": '同一来源同时存在 {flat} 与 {dir}(ID 都是 "{id}"),语义不明确',
|
|
422
422
|
"lint.duplicateContent": "这些 skill 的正文完全相同:{ids}",
|
|
423
|
-
"lint.nearDuplicate": '"{a}" 与 "{b}"
|
|
423
|
+
"lint.nearDuplicate": '"{a}" 与 "{b}" 在 {terms} 上重叠 {score}%,模型容易选错',
|
|
424
424
|
"lint.missingDescription": '"{id}" 没有 description,模型不会发现它(只能 @{id} 手动加载)',
|
|
425
425
|
"lint.longDescription": '"{id}" 的 description 有 {chars} 字符,每个模型步骤都会占上下文',
|
|
426
426
|
"lint.hiddenSkill": '"{id}" 被隐藏(autoinvoke/disable-model-invocation),不会出现在模型列表里',
|
package/src/lint.mjs
CHANGED
|
@@ -1,27 +1,36 @@
|
|
|
1
1
|
import fs from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
|
-
import {
|
|
3
|
+
import { advertisedStepTokens, distinctiveTokenSet } from "./parse.mjs"
|
|
4
4
|
import { extractReferences } from "./refs.mjs"
|
|
5
5
|
import { translator } from "./i18n.mjs"
|
|
6
6
|
|
|
7
7
|
export const SEVERITY_ORDER = { error: 0, warn: 1, info: 2 }
|
|
8
8
|
|
|
9
9
|
const ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
|
10
|
-
const
|
|
10
|
+
const JACCARD_THRESHOLD = 0.72
|
|
11
|
+
const CONTAINMENT_THRESHOLD = 0.85
|
|
12
|
+
const MIN_SHARED = 3
|
|
13
|
+
const MIN_SIZE_RATIO = 0.5
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return
|
|
15
|
+
/** 两个描述有多容易让模型选错。套话已在 distinctiveTokenSet 里去掉。 */
|
|
16
|
+
export function pairOverlap(a, b) {
|
|
17
|
+
if (!a?.size || !b?.size) return { jaccard: 0, containment: 0, shared: 0, terms: [] }
|
|
18
|
+
const small = a.size <= b.size ? a : b
|
|
19
|
+
const large = small === a ? b : a
|
|
20
|
+
let shared = 0
|
|
21
|
+
const terms = []
|
|
22
|
+
for (const token of small) {
|
|
23
|
+
if (!large.has(token)) continue
|
|
24
|
+
shared++
|
|
25
|
+
if (terms.length < 4) terms.push(token)
|
|
26
|
+
}
|
|
27
|
+
if (!shared) return { jaccard: 0, containment: 0, shared: 0, terms: [] }
|
|
28
|
+
return {
|
|
29
|
+
jaccard: shared / (a.size + b.size - shared),
|
|
30
|
+
containment: shared / small.size,
|
|
31
|
+
shared,
|
|
32
|
+
terms,
|
|
33
|
+
}
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
function byId(skills) {
|
|
@@ -48,6 +57,7 @@ export function resolveWinners(skills) {
|
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
export function lint(skills, options = {}) {
|
|
60
|
+
existsCache.clear()
|
|
51
61
|
const t = options.t ?? translator("en")
|
|
52
62
|
const budget = options.budget ?? 1200
|
|
53
63
|
const staleDays = options.staleDays ?? 0
|
|
@@ -146,7 +156,7 @@ export function lint(skills, options = {}) {
|
|
|
146
156
|
for (const ref of refs) {
|
|
147
157
|
const target = path.resolve(base, ref)
|
|
148
158
|
if (target !== base && !target.startsWith(base + path.sep)) outsideRefs.push(ref)
|
|
149
|
-
if (!
|
|
159
|
+
if (!pathExists(target)) missingRefs.push(ref)
|
|
150
160
|
}
|
|
151
161
|
if (missingRefs.length) {
|
|
152
162
|
add("broken-reference", "warn", t("lint.brokenReference", { id: skill.id, refs: missingRefs.slice(0, 5).join(", ") }), {
|
|
@@ -178,10 +188,7 @@ export function lint(skills, options = {}) {
|
|
|
178
188
|
// 4) 上下文预算(只算会被模型看到的)
|
|
179
189
|
const advertised = [...winners.values()].filter((s) => s.advertised)
|
|
180
190
|
const described = advertised.filter((s) => s.description)
|
|
181
|
-
const advertisedTokens = described.reduce(
|
|
182
|
-
(sum, s) => sum + estimateTokens(`${s.id} ${s.displayName} ${s.description}`),
|
|
183
|
-
0,
|
|
184
|
-
)
|
|
191
|
+
const advertisedTokens = described.reduce((sum, s) => sum + advertisedStepTokens(s), 0)
|
|
185
192
|
|
|
186
193
|
if (advertisedTokens > budget * 2) {
|
|
187
194
|
add("token-budget", "error", t("lint.tokenBudgetError", { tokens: advertisedTokens, budget }), {
|
|
@@ -203,28 +210,34 @@ export function lint(skills, options = {}) {
|
|
|
203
210
|
})
|
|
204
211
|
}
|
|
205
212
|
|
|
206
|
-
// 5)
|
|
213
|
+
// 5) 描述近似:丢掉套话后看 Jaccard,以及短描述是否被长描述包含。
|
|
214
|
+
// 按特征数升序,|a|/|b| 低于 MIN_SIZE_RATIO 后不可能再达到任一门槛。
|
|
207
215
|
const withDesc = described
|
|
208
|
-
.map((skill) => ({ skill, tokens:
|
|
209
|
-
.filter((entry) => entry.tokens.size
|
|
210
|
-
.sort((x, y) => x.tokens.size - y.tokens.size)
|
|
216
|
+
.map((skill) => ({ skill, tokens: distinctiveTokenSet(skill.description) }))
|
|
217
|
+
.filter((entry) => entry.tokens.size >= MIN_SHARED)
|
|
218
|
+
.sort((x, y) => x.tokens.size - y.tokens.size || x.skill.id.localeCompare(y.skill.id))
|
|
211
219
|
const pairs = []
|
|
212
220
|
for (let i = 0; i < withDesc.length; i++) {
|
|
213
221
|
const a = withDesc[i]
|
|
214
222
|
for (let j = i + 1; j < withDesc.length; j++) {
|
|
215
223
|
const b = withDesc[j]
|
|
216
224
|
if (a.skill.id === b.skill.id) continue
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
225
|
+
if (a.tokens.size / b.tokens.size < MIN_SIZE_RATIO) break
|
|
226
|
+
const overlap = pairOverlap(a.tokens, b.tokens)
|
|
227
|
+
if (overlap.shared < MIN_SHARED) continue
|
|
228
|
+
const byJaccard = overlap.jaccard >= JACCARD_THRESHOLD
|
|
229
|
+
const byContainment = overlap.containment >= CONTAINMENT_THRESHOLD
|
|
230
|
+
if (!byJaccard && !byContainment) continue
|
|
231
|
+
const score = Math.max(overlap.jaccard, overlap.containment)
|
|
232
|
+
pairs.push({ a: a.skill.id, b: b.skill.id, score, terms: overlap.terms.join(", ") })
|
|
221
233
|
}
|
|
222
234
|
}
|
|
235
|
+
pairs.sort((x, y) => y.score - x.score || x.a.localeCompare(y.a))
|
|
223
236
|
for (const pair of pairs.slice(0, 15)) {
|
|
224
237
|
add(
|
|
225
238
|
"near-duplicate-description",
|
|
226
239
|
"warn",
|
|
227
|
-
t("lint.nearDuplicate", { a: pair.a, b: pair.b, score: (pair.score * 100).toFixed(0) }),
|
|
240
|
+
t("lint.nearDuplicate", { a: pair.a, b: pair.b, score: (pair.score * 100).toFixed(0), terms: pair.terms }),
|
|
228
241
|
{ skills: [pair.a, pair.b] },
|
|
229
242
|
)
|
|
230
243
|
}
|
|
@@ -234,6 +247,16 @@ export function lint(skills, options = {}) {
|
|
|
234
247
|
|
|
235
248
|
const SEVERITIES = new Set(["error", "warn", "info"])
|
|
236
249
|
|
|
250
|
+
const existsCache = new Map()
|
|
251
|
+
|
|
252
|
+
function pathExists(target) {
|
|
253
|
+
const cached = existsCache.get(target)
|
|
254
|
+
if (cached !== undefined) return cached
|
|
255
|
+
const ok = fs.existsSync(target)
|
|
256
|
+
existsCache.set(target, ok)
|
|
257
|
+
return ok
|
|
258
|
+
}
|
|
259
|
+
|
|
237
260
|
function applyOverrides(findings, options) {
|
|
238
261
|
const overrides = options.ruleOverrides ?? {}
|
|
239
262
|
const ignore = new Set(options.ignore ?? [])
|
|
@@ -261,9 +284,7 @@ export function summarize(skills) {
|
|
|
261
284
|
advertised: advertised.length,
|
|
262
285
|
hidden: [...winners.values()].filter((s) => s.hidden).length,
|
|
263
286
|
shadowed: skills.length - winners.size,
|
|
264
|
-
advertisedTokens: advertised
|
|
265
|
-
.filter((s) => s.description)
|
|
266
|
-
.reduce((sum, s) => sum + estimateTokens(`${s.id} ${s.displayName} ${s.description}`), 0),
|
|
287
|
+
advertisedTokens: advertised.filter((s) => s.description).reduce((sum, s) => sum + advertisedStepTokens(s), 0),
|
|
267
288
|
bodyTokens: skills.reduce((sum, s) => sum + s.bodyTokens, 0),
|
|
268
289
|
}
|
|
269
290
|
}
|
package/src/parse.mjs
CHANGED
|
@@ -105,13 +105,100 @@ export function parseYamlLite(lines) {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
const CJK_CHAR = /[\u2e80-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/
|
|
108
|
-
const CJK_ALL = /[\u2e80-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/g
|
|
109
108
|
|
|
110
|
-
|
|
109
|
+
function isCjkUnit(code) {
|
|
110
|
+
return (
|
|
111
|
+
(code >= 0x2e80 && code <= 0x9fff) ||
|
|
112
|
+
(code >= 0xac00 && code <= 0xd7af) ||
|
|
113
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
114
|
+
(code >= 0xff00 && code <= 0xffef)
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isAsciiWord(code) {
|
|
119
|
+
return (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 一个英文词在 BPE 里通常是 1 个 token;超过 6 个字符后每 4 个字符再加 1。 */
|
|
123
|
+
function latinWordTokens(length) {
|
|
124
|
+
if (length <= 6) return 1
|
|
125
|
+
return 1 + Math.ceil((length - 6) / 4)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 按词切分的 token 估算,比「字符数 / 4」接近模型的真实切法:
|
|
130
|
+
* - 空白不计
|
|
131
|
+
* - 每个 CJK 字符 1 token(Grok / Claude / GPT-4o 这一档)
|
|
132
|
+
* - 英文词 1 token 起,长词和长标识符按每 4 个额外字符加 1
|
|
133
|
+
* - 连字符会把标识符切开,标点各自计 1
|
|
134
|
+
*/
|
|
111
135
|
export function estimateTokens(text) {
|
|
112
136
|
const str = String(text ?? "")
|
|
113
|
-
|
|
114
|
-
|
|
137
|
+
let tokens = 0
|
|
138
|
+
for (let i = 0; i < str.length; i++) {
|
|
139
|
+
const code = str.charCodeAt(i)
|
|
140
|
+
if (code === 32 || code === 9 || code === 10 || code === 13) continue
|
|
141
|
+
if (isCjkUnit(code)) {
|
|
142
|
+
tokens++
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
if (isAsciiWord(code)) {
|
|
146
|
+
let length = 0
|
|
147
|
+
while (i < str.length && isAsciiWord(str.charCodeAt(i))) {
|
|
148
|
+
length++
|
|
149
|
+
i++
|
|
150
|
+
}
|
|
151
|
+
i--
|
|
152
|
+
tokens += latinWordTokens(length)
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
tokens++
|
|
156
|
+
}
|
|
157
|
+
return tokens
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const STOPWORDS = new Set([
|
|
161
|
+
"a", "an", "the", "and", "or", "to", "of", "for", "in", "on", "with", "when", "use", "using", "used",
|
|
162
|
+
"this", "that", "skill", "skills", "user", "users", "want", "wants", "wanted", "please", "from", "into",
|
|
163
|
+
"by", "as", "is", "are", "be", "it", "its", "their", "your", "you", "if", "then", "than", "via", "over",
|
|
164
|
+
"about", "how", "what", "which", "can", "will", "just", "also", "not", "but", "at", "we", "our", "them",
|
|
165
|
+
"的", "了", "和", "与", "或", "在", "为", "用", "使用", "当", "技能", "用户", "一个", "进行", "可以", "这个", "以及", "把", "将", "对", "从",
|
|
166
|
+
])
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 描述里真正用来路由的词。丢掉套话(use this skill when…)和过短的英文词。
|
|
170
|
+
* 中文没有空格,用相邻二字作为特征,避免整句变成一个 token。
|
|
171
|
+
*/
|
|
172
|
+
export function distinctiveTokenSet(text) {
|
|
173
|
+
const set = new Set()
|
|
174
|
+
const str = String(text ?? "").toLowerCase()
|
|
175
|
+
let prevCjk = ""
|
|
176
|
+
for (let i = 0; i < str.length; i++) {
|
|
177
|
+
const code = str.charCodeAt(i)
|
|
178
|
+
if (isCjkUnit(code)) {
|
|
179
|
+
const ch = str[i]
|
|
180
|
+
if (prevCjk && !STOPWORDS.has(prevCjk) && !STOPWORDS.has(ch)) set.add(prevCjk + ch)
|
|
181
|
+
prevCjk = STOPWORDS.has(ch) ? "" : ch
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
prevCjk = ""
|
|
185
|
+
if (!isAsciiWord(code)) continue
|
|
186
|
+
let j = i
|
|
187
|
+
while (j < str.length && isAsciiWord(str.charCodeAt(j))) j++
|
|
188
|
+
const word = str.slice(i, j)
|
|
189
|
+
if (word.length >= 3 && !STOPWORDS.has(word)) set.add(word)
|
|
190
|
+
i = j - 1
|
|
191
|
+
}
|
|
192
|
+
return set
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 一条会被展示给模型的 skill,每步付出的描述 token。
|
|
197
|
+
* 扫描时已经算过的 `stepTokens` 直接复用;description 被改写(例如精简预览)时重算。
|
|
198
|
+
*/
|
|
199
|
+
export function advertisedStepTokens(skill, description = skill?.description ?? "") {
|
|
200
|
+
if (skill && description === skill.description && Number.isFinite(skill.stepTokens)) return skill.stepTokens
|
|
201
|
+
return estimateTokens(`${skill?.id ?? ""} ${skill?.displayName ?? ""} ${description}`)
|
|
115
202
|
}
|
|
116
203
|
|
|
117
204
|
/** 终端显示宽度(CJK 记 2 列) */
|
package/src/plan.mjs
CHANGED
|
@@ -1,38 +1,64 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { advertisedStepTokens, distinctiveTokenSet } from "./parse.mjs"
|
|
2
2
|
import { resolveWinners } from "./lint.mjs"
|
|
3
3
|
|
|
4
4
|
export const DEFAULT_DESC_TARGET = 160
|
|
5
5
|
export const DEFAULT_STEPS = 100
|
|
6
6
|
|
|
7
7
|
function costOf(skill, description = skill.description) {
|
|
8
|
-
return
|
|
8
|
+
return advertisedStepTokens(skill, description)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function firstSentenceEnd(value) {
|
|
12
|
+
const match = /[.!?。!?]/.exec(value)
|
|
13
|
+
return match ? match.index + 1 : -1
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
17
|
+
* 目录里只需要路由用的第一句。第一句放得进上限就只留它;
|
|
18
|
+
* 第一句本身超长时,再在分句标点处断开,最后才按字符硬切。
|
|
19
|
+
* 纯函数:相同输入永远得到相同输出。
|
|
14
20
|
*/
|
|
15
21
|
export function trimDescription(text, limit = DEFAULT_DESC_TARGET) {
|
|
16
|
-
const value = String(text ?? "")
|
|
22
|
+
const value = String(text ?? "").trim()
|
|
17
23
|
if (value.length <= limit) return value
|
|
24
|
+
const sentenceEnd = firstSentenceEnd(value)
|
|
25
|
+
if (sentenceEnd > 0 && sentenceEnd <= limit) return value.slice(0, sentenceEnd).trim()
|
|
18
26
|
const slice = value.slice(0, limit)
|
|
19
27
|
const cut = Math.max(
|
|
20
|
-
slice.lastIndexOf("
|
|
21
|
-
slice.lastIndexOf("
|
|
22
|
-
slice.lastIndexOf("!"),
|
|
23
|
-
slice.lastIndexOf("?"),
|
|
28
|
+
slice.lastIndexOf(","),
|
|
29
|
+
slice.lastIndexOf(","),
|
|
24
30
|
slice.lastIndexOf(";"),
|
|
25
31
|
slice.lastIndexOf(";"),
|
|
32
|
+
slice.lastIndexOf("、"),
|
|
26
33
|
slice.lastIndexOf("\n"),
|
|
27
34
|
)
|
|
28
|
-
const trimmed = cut >= limit * 0.5 ? value.slice(0, cut
|
|
35
|
+
const trimmed = cut >= limit * 0.5 ? value.slice(0, cut).trim() : slice.trim()
|
|
29
36
|
return trimmed || slice.trim()
|
|
30
37
|
}
|
|
31
38
|
|
|
32
|
-
/**
|
|
33
|
-
function
|
|
39
|
+
/** 这条描述有多少已经被别的 skill 说过。1 = 触发词是别人的子集,0 = 完全独特。 */
|
|
40
|
+
function redundancyOf(id, sets) {
|
|
41
|
+
const mine = sets.get(id)
|
|
42
|
+
if (!mine || mine.size < 3) return 0.35
|
|
43
|
+
let best = 0
|
|
44
|
+
for (const [other, tokens] of sets) {
|
|
45
|
+
if (other === id || !tokens?.size) continue
|
|
46
|
+
let shared = 0
|
|
47
|
+
for (const token of mine) if (tokens.has(token)) shared++
|
|
48
|
+
const covered = shared / mine.size
|
|
49
|
+
if (covered > best) best = covered
|
|
50
|
+
}
|
|
51
|
+
return best
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 选一组隐藏项盖住缺口。排序不是「谁最贵藏谁」,而是节省 × 重复度:
|
|
56
|
+
* 触发词已经被别的 skill 覆盖的,优先藏;独特的留到最后。
|
|
57
|
+
*/
|
|
58
|
+
function pickPreserving(items, gap) {
|
|
34
59
|
if (gap <= 0) return { count: 0, savings: 0, items: [], enough: true }
|
|
35
|
-
const
|
|
60
|
+
const score = (item) => item.tokens * (0.15 + 0.85 * item.redundancy)
|
|
61
|
+
const sorted = [...items].sort((a, b) => score(b) - score(a) || a.id.localeCompare(b.id))
|
|
36
62
|
const picked = []
|
|
37
63
|
let savings = 0
|
|
38
64
|
for (const item of sorted) {
|
|
@@ -49,8 +75,8 @@ function pickMinimal(items, gap) {
|
|
|
49
75
|
* 规则:
|
|
50
76
|
* - 只有会被「展示给模型」的定义才有成本;被遮蔽的重复定义省不出 token(诚实很重要)。
|
|
51
77
|
* - 精简 description 不改变可发现性;隐藏(autoinvoke: false)省掉整条成本,但仍可用 @id 加载。
|
|
52
|
-
* -
|
|
53
|
-
*
|
|
78
|
+
* - 推荐方案先把超长描述收成第一句,再优先隐藏「触发词已经被别的 skill 覆盖」的条目。
|
|
79
|
+
* 被隐藏的技能若也被精简过,其节省按精简后的成本计,不会把同一份 token 算两次。
|
|
54
80
|
*/
|
|
55
81
|
export function planBudget(
|
|
56
82
|
audit,
|
|
@@ -86,23 +112,27 @@ export function planBudget(
|
|
|
86
112
|
trims.sort((a, b) => b.saving - a.saving || a.id.localeCompare(b.id))
|
|
87
113
|
const trimSavings = trims.reduce((sum, item) => sum + item.saving, 0)
|
|
88
114
|
|
|
89
|
-
|
|
115
|
+
const tokenSets = new Map(advertised.map((skill) => [skill.id, distinctiveTokenSet(skill.description)]))
|
|
116
|
+
|
|
117
|
+
// 2) 只隐藏(不精简)的方案。重复的先藏。
|
|
90
118
|
const hideCandidates = advertised.map((skill) => ({
|
|
91
119
|
id: skill.id,
|
|
92
120
|
file: skill.file,
|
|
93
121
|
tokens: costOf(skill),
|
|
122
|
+
redundancy: redundancyOf(skill.id, tokenSets),
|
|
94
123
|
}))
|
|
95
|
-
const hideOnly =
|
|
124
|
+
const hideOnly = pickPreserving(hideCandidates, gap)
|
|
96
125
|
|
|
97
|
-
// 3)
|
|
126
|
+
// 3) 推荐方案:先精简,再在「精简后的成本」上按重复度隐藏
|
|
98
127
|
const afterTrim = advertised.map((skill) => ({
|
|
99
128
|
id: skill.id,
|
|
100
129
|
file: skill.file,
|
|
101
130
|
tokens: trimMap.get(skill.id)?.cost ?? costOf(skill),
|
|
102
131
|
originalTokens: costOf(skill),
|
|
132
|
+
redundancy: redundancyOf(skill.id, tokenSets),
|
|
103
133
|
}))
|
|
104
134
|
const postTrimTotal = afterTrim.reduce((sum, item) => sum + item.tokens, 0)
|
|
105
|
-
const recommendedHides =
|
|
135
|
+
const recommendedHides = pickPreserving(afterTrim, Math.max(0, postTrimTotal - target))
|
|
106
136
|
const hideSavings = recommendedHides.savings
|
|
107
137
|
const savings = trimSavings + hideSavings
|
|
108
138
|
const final = Math.max(0, current - savings)
|