opencode-skills-doctor 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,9 +20,10 @@ Local scanner, linter, fixer, updater and **OpenCode plugin** for agent skills
20
20
  | **What it finds** | shadowed IDs, duplicates, long descriptions, oversized bodies, per-step token budget, broken file references, invalid frontmatter, empty bodies, stale skills |
21
21
  | **What it fixes** | archives shadowed definitions (reversible), hides low-frequency skills, syncs catalogs with drift detection |
22
22
  | **Reports** | terminal, JSON, or a self-contained HTML report (`--html [file] --open`) |
23
+ | **Plan** | exact token savings: trim descriptions and/or hide skills, projected over N steps |
23
24
  | **Cost** | CLI: 0 tokens. Plugin: ~191 tokens/step for the tool definition, plus a one-time warning per session |
24
25
  | **Requirements** | Node ≥ 20, zero runtime dependencies |
25
- | **Quality** | 50 tests, CI on ubuntu/macos/windows × Node 20/22/24 |
26
+ | **Quality** | 53 tests, CI on ubuntu/macos/windows × Node 20/22/24 |
26
27
 
27
28
  ## Why
28
29
 
@@ -102,6 +103,23 @@ skills-doctor check --html report.html --open
102
103
 
103
104
  The plugin and MCP tool accept the same thing as an `html` argument, so the agent can ask you where to save it — for example: *"save the report to D:\reports\skills.html"*.
104
105
 
106
+ ### `plan` (how many tokens can I actually save?)
107
+
108
+ ```powershell
109
+ skills-doctor plan
110
+ skills-doctor plan --budget 800 --desc-target 160 --steps 200
111
+ skills-doctor plan --json
112
+ ```
113
+
114
+ It answers "how much can I cut, exactly?":
115
+
116
+ - only **advertised** definitions cost tokens — archiving shadowed duplicates saves 0, and the plan says so instead of pretending;
117
+ - **Option A** trims every description longer than `--desc-target` (default 160 chars, cut at sentence boundaries) and computes the exact saving;
118
+ - **Option B** hides the fewest skills needed to reach the budget (still loadable with `@id`; picking the largest savings first is optimal for "minimal hides");
119
+ - **Recommended** trims first, then hides, with no double counting, and projects the saving over `--steps` model steps (default 100).
120
+
121
+ `check` prints a one-line pointer when you are over budget, and the HTML report shows the same potential-savings card.
122
+
105
123
  ### `scan`
106
124
 
107
125
  Lists every skill with its source, precedence, whether the model can see it, and token cost.
@@ -468,13 +486,13 @@ Executing code is free, but the tool definition is part of the model's context.
468
486
 
469
487
  ## Roadmap
470
488
 
471
- - v0.5: CI governance (baseline that only reports new issues, official GitHub Action), content-aware suggestions (which skills are never used), catalog signature verification
489
+ - v0.6: CI governance (baseline that only reports new issues, official GitHub Action), content-aware suggestions (which skills are never used), catalog signature verification
472
490
  - Later: V1/Claude skill migration report
473
491
 
474
492
  ## Development and contributing
475
493
 
476
494
  ```powershell
477
- npm test # 50 tests (node test/doctor.test.mjs)
495
+ npm test # 53 tests (node test/doctor.test.mjs)
478
496
  npm run bench # lint timing for 100/500/1000 skills
479
497
  ```
480
498
 
@@ -20,9 +20,10 @@ OpenCode skill 的**本地检测器 + 更新器 + 修复器**,并附带 **Open
20
20
  | **能查** | 同 ID 遮蔽、重复、超长描述、超大正文、每步 token 预算、断链引用、非法 frontmatter、空正文、陈旧 skill |
21
21
  | **能修** | 归档遮蔽定义(可逆)、隐藏低频 skill、catalog 同步并检测 drift |
22
22
  | **报告** | 终端、JSON,或自包含 HTML(`--html [file] --open`) |
23
+ | **规划** | 精确 token 节省:精简描述 / 隐藏 skill,按 N 步折算 |
23
24
  | **成本** | CLI 0 token;插件工具定义约 191/步,另有每会话一次预警 |
24
25
  | **要求** | Node ≥ 20,零运行时依赖 |
25
- | **质量** | 50 个测试,ubuntu/macos/windows × Node 20/22/24 CI |
26
+ | **质量** | 53 个测试,ubuntu/macos/windows × Node 20/22/24 CI |
26
27
 
27
28
  ## 为什么需要它
28
29
 
@@ -102,6 +103,23 @@ skills-doctor check --html report.html --open
102
103
 
103
104
  插件和 MCP 工具同样接受 `html` 参数,所以可以让 agent 先问你要存哪里——例如:*"把报告存到 D:\reports\skills.html"*。
104
105
 
106
+ ### `plan`(到底能省多少 token?)
107
+
108
+ ```powershell
109
+ skills-doctor plan
110
+ skills-doctor plan --budget 800 --desc-target 160 --steps 200
111
+ skills-doctor plan --json
112
+ ```
113
+
114
+ 回答「能省多少、怎么省」:
115
+
116
+ - 只有**会展示给模型**的定义才花 token —— 归档遮蔽重复定义省 0,方案里会明说,不糊弄;
117
+ - **方案 A**:把所有超过 `--desc-target`(默认 160 字符,按句子边界截断)的 description 精简,算出精确节省;
118
+ - **方案 B**:用最少的隐藏数量把总量压进预算(仍可 `@id` 加载;按单项节省从大到小取,对「最少隐藏数」是最优的);
119
+ - **推荐方案**:先精简再隐藏,不重复计算,并按 `--steps`(默认 100)折算总节省。
120
+
121
+ 超预算时 `check` 会打印一行提示,HTML 报告里有同样的「potential savings」卡片。
122
+
105
123
  ### `scan`
106
124
 
107
125
  列出所有 skill 及其来源、优先级、是否会被模型看到、token 占用。
@@ -468,13 +486,13 @@ token 是启发式估算,1200 是建议预算。可以用 `--budget` 或 `.ski
468
486
 
469
487
  ## Roadmap
470
488
 
471
- - v0.5:CI 治理(baseline 只报新增问题、官方 GitHub Action)、内容层建议(哪些 skill 从未被使用)、catalog 签名校验
489
+ - v0.6:CI 治理(baseline 只报新增问题、官方 GitHub Action)、内容层建议(哪些 skill 从未被使用)、catalog 签名校验
472
490
  - 后续:V1/Claude skill 兼容迁移报告
473
491
 
474
492
  ## 开发与贡献
475
493
 
476
494
  ```powershell
477
- npm test # 50 个测试(node test/doctor.test.mjs)
495
+ npm test # 53 个测试(node test/doctor.test.mjs)
478
496
  npm run bench # 100/500/1000 个 skill 的 lint 耗时
479
497
  ```
480
498
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-skills-doctor",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
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 and Cursor skill layouts.",
5
5
  "type": "module",
6
6
  "exports": {
package/src/cli.mjs CHANGED
@@ -10,6 +10,7 @@ import { loadConfig } from "./config.mjs"
10
10
  import { startMcpServer } from "./mcp.mjs"
11
11
  import { installOpenCode, uninstallOpenCode } from "./install.mjs"
12
12
  import { writeHtmlReport, openReport } from "./html.mjs"
13
+ import { planBudget } from "./plan.mjs"
13
14
  import {
14
15
  checkForUpdate,
15
16
  resolveMode,
@@ -76,12 +77,14 @@ function parseArgs(argv) {
76
77
  open: false,
77
78
  apply: false,
78
79
  updateCheck: true,
80
+ descTarget: 160,
81
+ steps: 100,
79
82
  help: false,
80
83
  version: false,
81
84
  }
82
85
 
83
86
  const args = [...argv]
84
- const commands = new Set(["check", "scan", "update", "fix", "install", "uninstall", "upgrade", "mcp", "help"])
87
+ const commands = new Set(["check", "scan", "plan", "update", "fix", "install", "uninstall", "upgrade", "mcp", "help"])
85
88
  if (args[0] && commands.has(args[0])) {
86
89
  options.command = args.shift()
87
90
  }
@@ -101,6 +104,12 @@ function parseArgs(argv) {
101
104
  case "--stale-days":
102
105
  options.staleDays = Number(args[++i])
103
106
  break
107
+ case "--desc-target":
108
+ options.descTarget = Number(args[++i])
109
+ break
110
+ case "--steps":
111
+ options.steps = Number(args[++i])
112
+ break
104
113
  case "--strict":
105
114
  options.strict = true
106
115
  break
@@ -279,6 +288,92 @@ function printCheck(audit, result, options, t) {
279
288
  console.log(bold(t("check.hints")))
280
289
  for (const rule of usedRules) console.log(dim(` - ${t(HINT_KEYS[rule])}`))
281
290
  }
291
+
292
+ if (audit.summary.advertisedTokens > audit.budget) {
293
+ const plan = planBudget(audit)
294
+ if (plan.recommended.savings > 0) {
295
+ console.log("")
296
+ console.log(yellow(t("check.planHint", { tokens: plan.recommended.savings })))
297
+ }
298
+ }
299
+ }
300
+
301
+ function printPlan(plan, t) {
302
+ console.log(bold(t("plan.title")))
303
+ console.log(dim(t("plan.current", { tokens: plan.current, count: plan.advertised })))
304
+ console.log(dim(t("plan.budget", { budget: plan.budget, gap: plan.gap })))
305
+ console.log(dim(t("plan.steps", { steps: plan.steps })))
306
+ console.log("")
307
+
308
+ const sessionLine = (savings) => {
309
+ if (plan.steps > 0 && savings > 0) console.log(dim(t("plan.session", { tokens: savings * plan.steps, steps: plan.steps })))
310
+ }
311
+
312
+ if (plan.current <= plan.budget) {
313
+ console.log(green(t("plan.under")))
314
+ if (plan.trim.count) {
315
+ console.log("")
316
+ console.log(bold(t("plan.trimTitle", { chars: plan.descTarget })))
317
+ console.log(
318
+ t("plan.trimLine", {
319
+ count: plan.trim.count,
320
+ savings: plan.trim.savings,
321
+ final: Math.max(0, plan.current - plan.trim.savings),
322
+ }),
323
+ )
324
+ sessionLine(plan.trim.savings)
325
+ }
326
+ return
327
+ }
328
+
329
+ console.log(bold(t("plan.trimTitle", { chars: plan.descTarget })))
330
+ if (plan.trim.count) {
331
+ console.log(
332
+ t("plan.trimLine", {
333
+ count: plan.trim.count,
334
+ savings: plan.trim.savings,
335
+ final: Math.max(0, plan.current - plan.trim.savings),
336
+ }),
337
+ )
338
+ for (const item of plan.trim.items.slice(0, 10)) {
339
+ console.log(dim(t("plan.trimItem", { id: item.id, saving: item.saving })))
340
+ }
341
+ if (plan.trim.items.length > 10) console.log(dim(t("plan.more", { count: plan.trim.items.length - 10 })))
342
+ } else {
343
+ console.log(dim(t("plan.trimNone")))
344
+ }
345
+ console.log("")
346
+
347
+ console.log(bold(t("plan.hideTitle")))
348
+ if (plan.hideOnly.count) {
349
+ console.log(
350
+ t("plan.hideLine", {
351
+ count: plan.hideOnly.count,
352
+ savings: plan.hideOnly.savings,
353
+ final: plan.hideOnly.final,
354
+ }),
355
+ )
356
+ } else {
357
+ console.log(dim(t("plan.hideNone")))
358
+ }
359
+ console.log("")
360
+
361
+ console.log(bold(t("plan.recTitle")))
362
+ console.log(
363
+ t("plan.recLine", {
364
+ trims: plan.trim.count,
365
+ hides: plan.recommended.hides.length,
366
+ final: plan.recommended.final,
367
+ savings: plan.recommended.savings,
368
+ }),
369
+ )
370
+ for (const item of plan.recommended.hides) {
371
+ console.log(dim(t("plan.hideItem", { id: item.id, tokens: item.tokens })))
372
+ }
373
+ if (plan.recommended.hidesEverything) console.log(yellow(t("plan.hidesAll")))
374
+ sessionLine(plan.recommended.savings)
375
+ console.log("")
376
+ console.log(dim(t("plan.next")))
282
377
  }
283
378
 
284
379
  function printScan(discovered, options, managed, t) {
@@ -592,6 +687,21 @@ export async function main(argv) {
592
687
  return
593
688
  }
594
689
 
690
+ if (options.command === "plan") {
691
+ const audit = auditSkills({
692
+ cwd,
693
+ includeGlobal: options.global,
694
+ lang,
695
+ configPath: options.config,
696
+ budget: options.budget,
697
+ staleDays: options.staleDays,
698
+ })
699
+ const plan = planBudget(audit, { descTarget: options.descTarget, steps: options.steps })
700
+ if (options.json) console.log(JSON.stringify(plan, null, 2))
701
+ else printPlan(plan, t)
702
+ return
703
+ }
704
+
595
705
  if (options.command === "check") {
596
706
  const audit = auditSkills({
597
707
  cwd,
package/src/html.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs"
2
2
  import path from "node:path"
3
3
  import { spawn } from "node:child_process"
4
+ import { planBudget } from "./plan.mjs"
4
5
 
5
6
  function esc(value) {
6
7
  return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char])
@@ -13,6 +14,7 @@ export function renderHtml(audit, { title = "skills-doctor report", now = new Da
13
14
  const summary = audit.summary
14
15
  const counts = audit.counts
15
16
  const budget = audit.budget
17
+ const plan = planBudget(audit)
16
18
  const ratio = budget > 0 ? Math.min(1.5, summary.advertisedTokens / (budget * 2)) : 0
17
19
  const barWidth = Math.min(100, Math.round(ratio * 100))
18
20
  const barClass = summary.advertisedTokens > budget * 2 ? "over" : summary.advertisedTokens > budget ? "warn" : "ok"
@@ -89,6 +91,7 @@ export function renderHtml(audit, { title = "skills-doctor report", now = new Da
89
91
  <div class="card"><div class="k">unique IDs</div><div class="v">${summary.unique}</div></div>
90
92
  <div class="card"><div class="k">advertised</div><div class="v">${summary.advertised}</div></div>
91
93
  <div class="card"><div class="k">description tokens / step</div><div class="v">${summary.advertisedTokens}</div></div>
94
+ <div class="card"><div class="k">potential savings / step</div><div class="v">${plan.recommended.savings}</div></div>
92
95
  <div class="card error"><div class="k">errors</div><div class="v">${counts.errors}</div></div>
93
96
  <div class="card warn"><div class="k">warnings</div><div class="v">${counts.warnings}</div></div>
94
97
  <div class="card info"><div class="k">info</div><div class="v">${counts.infos}</div></div>
@@ -97,6 +100,7 @@ export function renderHtml(audit, { title = "skills-doctor report", now = new Da
97
100
  <h2>Context budget</h2>
98
101
  <div class="bar"><span class="${barClass}"></span></div>
99
102
  <div class="sub">${summary.advertisedTokens} tokens vs budget ${budget}${summary.advertisedTokens > budget * 2 ? " (over 2x)" : summary.advertisedTokens > budget ? " (over)" : " (ok)"}</div>
103
+ <div class="sub">plan: trim ${plan.trim.count} + hide ${plan.recommended.hides.length} → ~${plan.recommended.final} tokens/step (saves ~${plan.recommended.savings}; ~${plan.recommended.stepsSavings} over ${plan.steps} steps)</div>
100
104
 
101
105
  <h2>Findings (${audit.findings.length})</h2>
102
106
  <div class="toolbar">
package/src/i18n.mjs CHANGED
@@ -7,6 +7,7 @@ const dict = {
7
7
 
8
8
  Usage:
9
9
  skills-doctor check [options] check sources, conflicts and context budget (default)
10
+ skills-doctor plan [options] estimate how many tokens you can save, and how
10
11
  skills-doctor scan [options] list every skill with its source and precedence
11
12
  skills-doctor fix [options] archive shadowed definitions (safe structural fixes)
12
13
  skills-doctor update [options] compare and download skills from HTTP catalogs
@@ -19,6 +20,8 @@ Options:
19
20
  --root <dir> working directory (default: current directory)
20
21
  --no-global skip ~/.claude, ~/.agents and ~/.config/opencode
21
22
  --budget <n> tokens allowed for advertised descriptions (default 1200)
23
+ --desc-target <n> plan: target description length in chars (default 160)
24
+ --steps <n> plan: project savings over n model steps (default 100)
22
25
  --stale-days <n> report skills not updated in n days (default: off)
23
26
  --strict exit non-zero on warnings too
24
27
  --json print JSON
@@ -56,6 +59,27 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
56
59
  "check.summary": "{errors} error, {warnings} warn, {infos} info",
57
60
  "check.strict": " (--strict: warnings also fail)",
58
61
  "check.hints": "Hints:",
62
+ "check.planHint": " ! potential: about {tokens} tokens/step can be saved; run `skills-doctor plan` for the breakdown",
63
+
64
+ "plan.title": "skills-doctor plan",
65
+ "plan.current": " current: ~{tokens} tokens/step ({count} advertised skills)",
66
+ "plan.budget": " budget: {budget} gap: {gap}",
67
+ "plan.steps": " projection: {steps} model steps",
68
+ "plan.under": "Under budget — no changes needed.",
69
+ "plan.trimTitle": "Option A — trim descriptions to {chars} chars (stays discoverable)",
70
+ "plan.trimLine": " {count} skills, saves ~{savings} tokens/step → ~{final} tokens",
71
+ "plan.trimNone": " nothing to trim.",
72
+ "plan.trimItem": " - {id} (−{saving})",
73
+ "plan.hideTitle": "Option B — hide skills (autoinvoke: false, still loadable with @id)",
74
+ "plan.hideLine": " hide {count} skills, saves ~{savings} tokens/step → ~{final} tokens",
75
+ "plan.hideNone": " no hide needed.",
76
+ "plan.recTitle": "Recommended",
77
+ "plan.recLine": " trim {trims} + hide {hides} → ~{final} tokens/step (saves ~{savings})",
78
+ "plan.hideItem": " - hide {id} (−{tokens})",
79
+ "plan.hidesAll": " ! this would hide every skill; consider raising the budget instead.",
80
+ "plan.session": " over {steps} steps that saves ~{tokens} tokens",
81
+ "plan.more": " …and {count} more",
82
+ "plan.next": "Next: hide with `skills-doctor fix --hide <ids>`, or trim descriptions and keep discoverability.",
59
83
 
60
84
  "scan.title": "skills-doctor {version} — scan",
61
85
  "scan.workdir": " working dir: {dir}",
@@ -192,6 +216,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
192
216
 
193
217
  用法:
194
218
  skills-doctor check [选项] 检查来源、冲突与上下文预算(默认命令)
219
+ skills-doctor plan [选项] 估算能省多少 token、怎么省
195
220
  skills-doctor scan [选项] 列出所有 skill 及其来源/优先级
196
221
  skills-doctor fix [选项] 归档被遮蔽的定义(安全的结构性修复)
197
222
  skills-doctor update [选项] 对比并拉取 HTTP catalog 里的 skill
@@ -204,6 +229,8 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
204
229
  --root <dir> 以该目录为工作目录(默认当前目录)
205
230
  --no-global 不扫描 ~/.claude、~/.agents、~/.config/opencode
206
231
  --budget <n> 模型每步可看的 description token 预算(默认 1200)
232
+ --desc-target <n> plan: description 目标字符数(默认 160)
233
+ --steps <n> plan: 按 n 个模型步骤折算节省量(默认 100)
207
234
  --stale-days <n> 超过 n 天未更新的 skill 给出提示(默认关闭)
208
235
  --strict 有 warning 也返回非 0
209
236
  --json 输出 JSON
@@ -241,6 +268,27 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
241
268
  "check.summary": "{errors} error, {warnings} warn, {infos} info",
242
269
  "check.strict": "(--strict:warning 也会非 0 退出)",
243
270
  "check.hints": "建议:",
271
+ "check.planHint": " ! 预计每步还能省约 {tokens} tokens;运行 `skills-doctor plan` 看具体方案",
272
+
273
+ "plan.title": "skills-doctor 省 token 方案",
274
+ "plan.current": " 当前: 约 {tokens} tokens/步({count} 个会展示的 skill)",
275
+ "plan.budget": " 预算: {budget} 差距: {gap}",
276
+ "plan.steps": " 折算: {steps} 个模型步骤",
277
+ "plan.under": "已在预算内,无需改动。",
278
+ "plan.trimTitle": "方案 A —— 精简 description 到 {chars} 字符(仍可被发现)",
279
+ "plan.trimLine": " {count} 个 skill,每步省约 {savings} tokens → 约 {final} tokens",
280
+ "plan.trimNone": " 没有需要精简的。",
281
+ "plan.trimItem": " - {id}(−{saving})",
282
+ "plan.hideTitle": "方案 B —— 隐藏 skill(autoinvoke: false,仍可 @id 加载)",
283
+ "plan.hideLine": " 隐藏 {count} 个,每步省约 {savings} tokens → 约 {final} tokens",
284
+ "plan.hideNone": " 无需隐藏。",
285
+ "plan.recTitle": "推荐方案",
286
+ "plan.recLine": " 精简 {trims} 个 + 隐藏 {hides} 个 → 约 {final} tokens/步(省约 {savings})",
287
+ "plan.hideItem": " - 隐藏 {id}(−{tokens})",
288
+ "plan.hidesAll": " ! 这样会把所有 skill 都隐藏;建议改为提高预算。",
289
+ "plan.session": " 按 {steps} 步计算,共省约 {tokens} tokens",
290
+ "plan.more": " …还有 {count} 个",
291
+ "plan.next": "下一步:用 `skills-doctor fix --hide <ids>` 隐藏,或精简 description 保留可发现性。",
244
292
 
245
293
  "scan.title": "skills-doctor {version} — scan",
246
294
  "scan.workdir": " 工作目录: {dir}",
package/src/plan.mjs ADDED
@@ -0,0 +1,142 @@
1
+ import { estimateTokens } from "./parse.mjs"
2
+ import { resolveWinners } from "./lint.mjs"
3
+
4
+ export const DEFAULT_DESC_TARGET = 160
5
+ export const DEFAULT_STEPS = 100
6
+
7
+ function costOf(skill, description = skill.description) {
8
+ return estimateTokens(`${skill.id} ${skill.displayName} ${description}`)
9
+ }
10
+
11
+ /**
12
+ * 把 description 截到目标长度,优先在句子边界断开(中英文标点都算)。
13
+ * 纯函数:相同输入永远得到相同输出,便于在报告里预览「如果这么改会省多少」。
14
+ */
15
+ export function trimDescription(text, limit = DEFAULT_DESC_TARGET) {
16
+ const value = String(text ?? "")
17
+ if (value.length <= limit) return value
18
+ const slice = value.slice(0, limit)
19
+ const cut = Math.max(
20
+ slice.lastIndexOf("。"),
21
+ slice.lastIndexOf("."),
22
+ slice.lastIndexOf("!"),
23
+ slice.lastIndexOf("?"),
24
+ slice.lastIndexOf(";"),
25
+ slice.lastIndexOf(";"),
26
+ slice.lastIndexOf("\n"),
27
+ )
28
+ const trimmed = cut >= limit * 0.5 ? value.slice(0, cut + 1).trim() : slice.trim()
29
+ return trimmed || slice.trim()
30
+ }
31
+
32
+ /** 在候选中选「数量最少」的一组,使节省量 ≥ gap;按单项节省降序取前缀即为最优。 */
33
+ function pickMinimal(items, gap) {
34
+ 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))
36
+ const picked = []
37
+ let savings = 0
38
+ for (const item of sorted) {
39
+ if (savings >= gap) break
40
+ picked.push(item)
41
+ savings += item.tokens
42
+ }
43
+ return { count: picked.length, savings, items: picked, enough: savings >= gap }
44
+ }
45
+
46
+ /**
47
+ * 预算规划:当前每步消耗多少、精简到多少、隐藏多少、最终能省多少。
48
+ *
49
+ * 规则:
50
+ * - 只有会被「展示给模型」的定义才有成本;被遮蔽的重复定义省不出 token(诚实很重要)。
51
+ * - 精简 description 不改变可发现性;隐藏(autoinvoke: false)省掉整条成本,但仍可用 @id 加载。
52
+ * - 推荐方案先精简所有超长描述,再按「单项节省最大」贪心隐藏,直到进入预算。
53
+ * 被隐藏的技能若也被精简过,其节省按原始成本计,不会重复计算。
54
+ */
55
+ export function planBudget(
56
+ audit,
57
+ { budget = audit.budget, descTarget = DEFAULT_DESC_TARGET, steps = DEFAULT_STEPS } = {},
58
+ ) {
59
+ const target = Number.isFinite(budget) && budget > 0 ? budget : audit.budget
60
+ const winners = [...resolveWinners(audit.skills).values()]
61
+ const advertised = winners.filter((skill) => skill.advertised)
62
+
63
+ const current = advertised.reduce((sum, skill) => sum + costOf(skill), 0)
64
+ const gap = Math.max(0, current - target)
65
+
66
+ // 1) 精简:所有 description 超过目标的,按句子边界截断
67
+ const trimMap = new Map()
68
+ const trims = []
69
+ for (const skill of advertised) {
70
+ if (skill.description.length <= descTarget) continue
71
+ const preview = trimDescription(skill.description, descTarget)
72
+ const before = costOf(skill)
73
+ const after = costOf(skill, preview)
74
+ const saving = Math.max(0, before - after)
75
+ if (!saving) continue
76
+ trimMap.set(skill.id, { preview, cost: after })
77
+ trims.push({
78
+ id: skill.id,
79
+ file: skill.file,
80
+ before,
81
+ after,
82
+ saving,
83
+ preview,
84
+ })
85
+ }
86
+ trims.sort((a, b) => b.saving - a.saving || a.id.localeCompare(b.id))
87
+ const trimSavings = trims.reduce((sum, item) => sum + item.saving, 0)
88
+
89
+ // 2) 只隐藏(不精简)的方案
90
+ const hideCandidates = advertised.map((skill) => ({
91
+ id: skill.id,
92
+ file: skill.file,
93
+ tokens: costOf(skill),
94
+ }))
95
+ const hideOnly = pickMinimal(hideCandidates, gap)
96
+
97
+ // 3) 推荐方案:先精简,再在「精简后的成本」上隐藏
98
+ const afterTrim = advertised.map((skill) => ({
99
+ id: skill.id,
100
+ file: skill.file,
101
+ tokens: trimMap.get(skill.id)?.cost ?? costOf(skill),
102
+ originalTokens: costOf(skill),
103
+ }))
104
+ const postTrimTotal = afterTrim.reduce((sum, item) => sum + item.tokens, 0)
105
+ const recommendedHides = pickMinimal(afterTrim, Math.max(0, postTrimTotal - target))
106
+ const hideSavings = recommendedHides.savings
107
+ const savings = trimSavings + hideSavings
108
+ const final = Math.max(0, current - savings)
109
+
110
+ return {
111
+ cwd: audit.cwd,
112
+ budget: target,
113
+ descTarget,
114
+ steps,
115
+ current,
116
+ gap,
117
+ advertised: advertised.length,
118
+ trim: {
119
+ count: trims.length,
120
+ savings: trimSavings,
121
+ items: trims,
122
+ },
123
+ hideOnly: {
124
+ count: hideOnly.count,
125
+ savings: hideOnly.savings,
126
+ final: Math.max(0, current - hideOnly.savings),
127
+ enough: hideOnly.enough,
128
+ items: hideOnly.items,
129
+ },
130
+ recommended: {
131
+ trims: trims.map((item) => item.id),
132
+ hides: recommendedHides.items,
133
+ trimSavings,
134
+ hideSavings,
135
+ savings,
136
+ final,
137
+ reachable: final <= target,
138
+ stepsSavings: savings * steps,
139
+ hidesEverything: advertised.length > 0 && recommendedHides.count === advertised.length,
140
+ },
141
+ }
142
+ }