opencode-skills-doctor 0.9.4 → 0.9.7

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/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
@@ -33,8 +33,6 @@ Options:
33
33
  --into <dir> update: output directory (default: .opencode/skills)
34
34
  --check-only update: only report available updates
35
35
  --prune update: delete skills that disappeared from the catalog
36
- --html [file] check: write a self-contained HTML report (default: skills-report.html)
37
- --open check: open the HTML report after writing it
38
36
  --config-dir <dir> install/uninstall: OpenCode config directory (default: ~/.config/opencode)
39
37
  --client <name> install/uninstall: auto | opencode | desktop | dsh | kimi (default auto)
40
38
  --profile <name> dsh: profile to install into (default: desktop, or the only one)
@@ -60,7 +58,6 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
60
58
  "check.configError": " ! cannot parse config: {path} ({error})",
61
59
  "check.missingPath": " ! path in config does not exist: {path}",
62
60
  "check.none": "No problems found.",
63
- "check.htmlSaved": " HTML report: {path}",
64
61
  "check.summary": "{errors} error, {warnings} warn, {infos} info",
65
62
  "check.strict": " (--strict: warnings also fail)",
66
63
  "check.hints": "Hints:",
@@ -193,7 +190,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
193
190
  'Both {flat} and {dir} exist in the same source (ID "{id}"), semantics are ambiguous',
194
191
  "lint.duplicateContent": "These skills have identical bodies: {ids}",
195
192
  "lint.nearDuplicate":
196
- '"{a}" and "{b}" descriptions are {score}% similar; the model may pick the wrong one',
193
+ '"{a}" and "{b}" overlap {score}% on {terms}; the model may pick the wrong one',
197
194
  "lint.missingDescription":
198
195
  '"{id}" has no description, so the model will not discover it (only explicit @{id})',
199
196
  "lint.longDescription": '"{id}" description is {chars} chars; it is paid on every model step',
@@ -265,8 +262,6 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
265
262
  --into <dir> update 的落盘目录(默认 .opencode/skills)
266
263
  --check-only update 只报告可用更新,不下载
267
264
  --prune update: 删除 catalog 中已移除的 skill
268
- --html [file] check: 生成自包含 HTML 报告(默认 skills-report.html)
269
- --open check: 写完报告后用浏览器打开
270
265
  --config-dir <dir> install/uninstall: OpenCode 配置目录(默认 ~/.config/opencode)
271
266
  --client <name> install/uninstall: auto | opencode | desktop | dsh | kimi(默认 auto)
272
267
  --profile <name> dsh: 安装到哪个 profile(默认 desktop,或唯一的一个)
@@ -292,7 +287,6 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
292
287
  "check.configError": " ! 配置文件无法解析: {path}({error})",
293
288
  "check.missingPath": " ! 配置里引用的路径不存在: {path}",
294
289
  "check.none": "没有发现问题。",
295
- "check.htmlSaved": " HTML 报告: {path}",
296
290
  "check.summary": "{errors} error, {warnings} warn, {infos} info",
297
291
  "check.strict": "(--strict:warning 也会非 0 退出)",
298
292
  "check.hints": "建议:",
@@ -420,7 +414,7 @@ Exit codes: 0 ok; 1 errors (and warnings with --strict); 2 bad arguments or runt
420
414
  'ID "{id}" 有 {count} 处定义,实际生效的是 {winner};其余 {losers} 处被遮蔽(白占体积、容易改错文件)',
421
415
  "lint.flatDirCollision": '同一来源同时存在 {flat} 与 {dir}(ID 都是 "{id}"),语义不明确',
422
416
  "lint.duplicateContent": "这些 skill 的正文完全相同:{ids}",
423
- "lint.nearDuplicate": '"{a}" 与 "{b}" 的描述相似度 {score}%,模型容易选错',
417
+ "lint.nearDuplicate": '"{a}" 与 "{b}" 在 {terms} 上重叠 {score}%,模型容易选错',
424
418
  "lint.missingDescription": '"{id}" 没有 description,模型不会发现它(只能 @{id} 手动加载)',
425
419
  "lint.longDescription": '"{id}" 的 description 有 {chars} 字符,每个模型步骤都会占上下文',
426
420
  "lint.hiddenSkill": '"{id}" 被隐藏(autoinvoke/disable-model-invocation),不会出现在模型列表里',
package/src/install.mjs CHANGED
@@ -11,15 +11,9 @@ const PLUGIN_FILE = "skills-doctor.js"
11
11
  const V1_PLUGIN_SOURCE = `// Generated by skills-doctor install\n// OpenCode 1.x plugin entry\nexport { SkillsDoctorPlugin } from "opencode-skills-doctor/v1"\n`
12
12
 
13
13
  const V1_COMMAND = `---
14
- description: Audit local skills with skills-doctor
14
+ description: Tell the user about skill problems in this chat
15
15
  ---
16
- Use the skills_doctor tool to audit the skills in this project. Summarize the top findings and propose concrete fixes (archive shadowed duplicates, hide low-frequency skills). $ARGUMENTS
17
- `
18
-
19
- const V1_REPORT_COMMAND = `---
20
- description: Write an HTML skills report to a path you choose
21
- ---
22
- Ask the user where to save the HTML report (default ./skills-report.html), then call the skills_doctor tool with the html argument set to that path and tell them where it was written. $ARGUMENTS
16
+ Call the skills_doctor tool, then tell the user its briefing in this chat. Do not write a report file. Do not change skill files unless the user confirms. $ARGUMENTS
23
17
  `
24
18
 
25
19
  const V1_UPGRADE_COMMAND = `---
@@ -30,9 +24,9 @@ Run \`npx -y opencode-skills-doctor upgrade\` in the terminal (add --apply only
30
24
 
31
25
  const V1_COMMANDS = [
32
26
  { file: "skills-doctor.md", body: V1_COMMAND },
33
- { file: "skills-report.md", body: V1_REPORT_COMMAND },
34
27
  { file: "skills-upgrade.md", body: V1_UPGRADE_COMMAND },
35
28
  ]
29
+ const V1_LEGACY_COMMANDS = ["skills-report.md"]
36
30
 
37
31
  export function defaultConfigDir() {
38
32
  const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config")
@@ -353,11 +347,14 @@ export function uninstallOpenCode({
353
347
  }
354
348
  }
355
349
 
356
- for (const command of V1_COMMANDS) {
350
+ for (const command of [...V1_COMMANDS, ...V1_LEGACY_COMMANDS.map((file) => ({ file, legacy: true }))]) {
357
351
  const commandFile = path.join(dir, "command", command.file)
358
352
  if (!fs.existsSync(commandFile)) continue
359
353
  const content = readText(commandFile) ?? ""
360
- if (content.includes("skills_doctor tool")) {
354
+ const owned = command.legacy
355
+ ? content.includes("skills_doctor") || content.includes("skills-doctor")
356
+ : content.includes("skills_doctor tool")
357
+ if (owned) {
361
358
  if (!dryRun) {
362
359
  fs.rmSync(commandFile, { force: true })
363
360
  act(`deleted ${commandFile}`)
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/mcp.mjs CHANGED
@@ -1,5 +1,4 @@
1
- import { auditSkills, formatMarkdown } from "./audit.mjs"
2
- import { writeHtmlReport } from "./html.mjs"
1
+ import { auditSkills, formatChat } from "./audit.mjs"
3
2
  import { normalizeLang, detectLang } from "./i18n.mjs"
4
3
 
5
4
  export const PROTOCOL_VERSION = "2025-06-18"
@@ -10,7 +9,7 @@ export function toolDefinition() {
10
9
  return {
11
10
  name: TOOL_NAME,
12
11
  description:
13
- "Audit installed agent skills (OpenCode, Claude Code, Grok, Cursor, Hermes, Kimi, DSH): shadowed duplicates, per-step description token budget, oversized bodies and fix hints. Returns a markdown report.",
12
+ "Audit installed agent skills and return a short briefing to say to the user in the chat. Does not write a file.",
14
13
  inputSchema: {
15
14
  type: "object",
16
15
  properties: {
@@ -31,10 +30,6 @@ export function toolDefinition() {
31
30
  type: "boolean",
32
31
  description: "Include global skill directories such as ~/.config/opencode and ~/.claude (default true)",
33
32
  },
34
- html: {
35
- type: "string",
36
- description: "Optional path; when set, a self-contained HTML report is written there and the path is returned",
37
- },
38
33
  },
39
34
  additionalProperties: false,
40
35
  },
@@ -91,12 +86,7 @@ export async function handleMessage(message, { version = "0.0.0" } = {}) {
91
86
  const lang = normalizeLang(args.lang) ?? detectLang()
92
87
 
93
88
  const audit = auditSkills({ cwd: directory, budget, lang, includeGlobal: args.global !== false })
94
- let text = formatMarkdown(audit)
95
- if (typeof args.html === "string" && args.html.trim()) {
96
- const file = writeHtmlReport(audit, args.html.trim(), { title: "skills-doctor report" })
97
- text += `\n\nHTML report written to \`${file}\``
98
- }
99
- return ok(id, { content: [{ type: "text", text }] })
89
+ return ok(id, { content: [{ type: "text", text: formatChat(audit) }] })
100
90
  }
101
91
 
102
92
  default:
package/src/notify.mjs ADDED
@@ -0,0 +1,62 @@
1
+ import { auditSkills, formatChat } from "./audit.mjs"
2
+
3
+ const settled = new Set()
4
+ const visible = new Set()
5
+
6
+ function keyOf(cwd, sessionID) {
7
+ return `${cwd}::${sessionID}`
8
+ }
9
+
10
+ /** 这条会话里,用户已经能在时间线上看到通知。 */
11
+ export function chatNoticeVisible(cwd, sessionID) {
12
+ return visible.has(keyOf(cwd, sessionID))
13
+ }
14
+
15
+ /**
16
+ * 往 OpenCode 会话里发一条用户能看见的通知。
17
+ * 带 description 的 synthetic 会单独出现在对话里;resume 为 false 时不另开一轮模型。
18
+ * 每个会话最多自动发一次。force 用于用户明确执行了 /skills-doctor。
19
+ */
20
+ export async function postChatNotice({
21
+ sessionID,
22
+ cwd = process.cwd(),
23
+ includeGlobal = true,
24
+ synthetic,
25
+ resume = false,
26
+ force = false,
27
+ when = "problems",
28
+ } = {}) {
29
+ if (!sessionID || typeof synthetic !== "function") return null
30
+ const key = keyOf(cwd, sessionID)
31
+ if (!force && settled.has(key)) return null
32
+
33
+ const audit = auditSkills({ cwd, includeGlobal })
34
+ const notable = audit.findings.some((finding) => finding.severity === "error" || finding.severity === "warn")
35
+ if (when === "problems" && !notable) {
36
+ settled.add(key)
37
+ return null
38
+ }
39
+
40
+ const text = formatChat(audit)
41
+ try {
42
+ await synthetic({
43
+ sessionID,
44
+ text,
45
+ description: "skills-doctor",
46
+ resume,
47
+ })
48
+ } catch {
49
+ if (!force) settled.add(key)
50
+ return null
51
+ }
52
+
53
+ settled.add(key)
54
+ visible.add(key)
55
+ return text
56
+ }
57
+
58
+ /** 测试用。 */
59
+ export function resetChatNotices() {
60
+ settled.clear()
61
+ visible.clear()
62
+ }
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)