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/README.md +28 -44
- package/SECURITY.md +1 -1
- package/SECURITY.zh-CN.md +1 -1
- package/docs/README.zh-CN.md +18 -26
- package/docs/REFERENCE.md +18 -27
- package/docs/assets/banner.svg +1 -1
- package/package.json +2 -2
- package/skills/dsh-skills-doctor/SKILL.md +2 -7
- package/src/audit.mjs +29 -0
- package/src/cli.mjs +3 -23
- package/src/discover.mjs +2 -0
- package/src/fix.mjs +35 -7
- package/src/i18n.mjs +2 -8
- package/src/install.mjs +8 -11
- package/src/lint.mjs +53 -32
- package/src/mcp.mjs +3 -13
- package/src/notify.mjs +62 -0
- package/src/parse.mjs +91 -4
- package/src/plan.mjs +49 -19
- package/src/plugin-v1.mjs +5 -12
- package/src/plugin.mjs +38 -35
- package/src/warning.mjs +3 -5
- package/src/html.mjs +0 -171
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
|
@@ -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}"
|
|
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}"
|
|
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:
|
|
14
|
+
description: Tell the user about skill problems in this chat
|
|
15
15
|
---
|
|
16
|
-
|
|
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
|
-
|
|
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 {
|
|
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/mcp.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { auditSkills,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|