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 CHANGED
@@ -5,8 +5,8 @@
5
5
  <h1 align="center">skills-doctor</h1>
6
6
 
7
7
  <p align="center">
8
- <b>Audit, fix and update the skills your coding agent loads.</b><br>
9
- Measure the per-step token tax, find shadowed duplicates and broken references — then fix them safely.
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 low-frequency skills, trim descriptions with a measured plan |
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.3
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`. Outputs:
169
- `exit-code`, `error-count`, `warning-count`, `advertised-tokens`. Anything else
170
- goes through `extra-args` (for example `--no-global`).
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` | exact token savings: what to trim, what to hide, projected over N steps |
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 # 58 tests (node test/doctor.test.mjs)
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
 
@@ -5,8 +5,8 @@
5
5
  <h1 align="center">skills-doctor</h1>
6
6
 
7
7
  <p align="center">
8
- <b>审计、修复、更新你的 coding agent 加载的 skill。</b><br>
9
- 量出每步 token 税,找出遮蔽重复和断链,再安全地修掉。
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
- | **能修** | 归档遮蔽定义(可逆)、隐藏低频 skill、catalog 同步并检测 drift |
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 | 两个 skill 的 description 相似度 ≥ 75%,模型容易选错 |
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 是启发式估算(CJK 约 1.7 字/token,其余约 4 字符/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** | 58 tests, CI on ubuntu/macos/windows × Node 20/22/24 |
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 | Descriptions are ≥ 75% similar; the model may pick the wrong one |
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 heuristic (CJK ≈ 1.7 chars/token, the rest ≈ 4 chars/token) — good for comparisons, not for billing.
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 # 58 tests (node test/doctor.test.mjs)
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",
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
- /** 在 frontmatter 里把 metadata.opencode/autoinvoke 设为 false。 */
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 lines = text.split(/\r?\n/)
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
- if (lines[0]?.trim() !== "---") return ["---", ...snippet, "---", ...lines].join("\n")
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].join("\n")
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)].join("\n")
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)].join("\n")
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
- fs.writeFileSync(skill.file, setAutoinvokeFalse(text), "utf8")
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}" descriptions are {score}% similar; the model may pick the wrong one',
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}" 的描述相似度 {score}%,模型容易选错',
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 { estimateTokens } from "./parse.mjs"
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 NEAR_DUPLICATE_THRESHOLD = 0.75
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
- function tokenSet(text) {
13
- const tokens = String(text ?? "")
14
- .toLowerCase()
15
- .split(/[^\p{L}\p{N}]+/u)
16
- .filter((t) => t.length >= 2)
17
- return new Set(tokens)
18
- }
19
-
20
- function jaccard(a, b) {
21
- if (!a.size || !b.size) return 0
22
- let inter = 0
23
- for (const t of a) if (b.has(t)) inter++
24
- return inter / (a.size + b.size - inter)
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 (!fs.existsSync(target)) missingRefs.push(ref)
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) 描述近似(token 集合只算一次;按大小升序后用 Jaccard 上界提前结束)
213
+ // 5) 描述近似:丢掉套话后看 Jaccard,以及短描述是否被长描述包含。
214
+ // 按特征数升序,|a|/|b| 低于 MIN_SIZE_RATIO 后不可能再达到任一门槛。
207
215
  const withDesc = described
208
- .map((skill) => ({ skill, tokens: tokenSet(skill.description) }))
209
- .filter((entry) => entry.tokens.size > 0)
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
- // 大小升序:a 为空集已过滤,|a|/|b| 随 j 单调下降,低于阈值后再无可能命中。
218
- if (a.tokens.size / b.tokens.size < NEAR_DUPLICATE_THRESHOLD) break
219
- const score = jaccard(a.tokens, b.tokens)
220
- if (score >= NEAR_DUPLICATE_THRESHOLD) pairs.push({ a: a.skill.id, b: b.skill.id, score })
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
- /** 粗略 token 估算:CJK 约 1.7 字/token,其余约 4 字符/token。 */
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
- const cjk = (str.match(CJK_ALL) ?? []).length
114
- return Math.ceil(cjk * 1.7 + (str.length - cjk) / 4)
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 { estimateTokens } from "./parse.mjs"
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 estimateTokens(`${skill.id} ${skill.displayName} ${description}`)
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
- * 把 description 截到目标长度,优先在句子边界断开(中英文标点都算)。
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 + 1).trim() : slice.trim()
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
- /** 在候选中选「数量最少」的一组,使节省量 ≥ gap;按单项节省降序取前缀即为最优。 */
33
- function pickMinimal(items, gap) {
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 sorted = [...items].sort((a, b) => b.tokens - a.tokens || a.id.localeCompare(b.id))
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
- // 2) 只隐藏(不精简)的方案
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 = pickMinimal(hideCandidates, gap)
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 = pickMinimal(afterTrim, Math.max(0, postTrimTotal - target))
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)