dsh-skill-folder 0.3.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/lib/bm25.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * bm25.js — zero-dependency BM25 lexical search for tool retrieval.
3
+ *
4
+ * Design (ported from the xiaowan agent's ToolBM25Index idea; the algorithm is
5
+ * the reference, this code is a clean-room JS implementation):
6
+ * - BM25 with standard k1=1.2, b=0.75 (RAG-MCP / BoR validated the
7
+ * retrieval-first approach; BM25 is the cheap lexical leg).
8
+ * - Tokenizer handles mixed CN/EN: ASCII words + CJK bigrams. No jieba, no
9
+ * network, no dependencies — must run inside the DSH plugin runtime.
10
+ * - Synchronous and fast: indexing ~50 tools is <1ms, a query <1ms.
11
+ *
12
+ * Cordis sandbox note: this module only does string/number math. It never
13
+ * touches fs/net/process, so it is safe in any host or sandbox context.
14
+ *
15
+ * Vendored verbatim from dsh-tool-folder/lib/bm25.js (same file, same exports)
16
+ * so the skill-folder retrieval leg behaves byte-identically to tool-folder.
17
+ */
18
+
19
+ const K1 = 1.2;
20
+ const B = 0.75;
21
+
22
+ const EN_STOP = new Set([
23
+ "the", "a", "an", "and", "or", "of", "to", "in", "on", "for", "with",
24
+ "at", "by", "from", "as", "is", "are", "was", "were", "be", "been",
25
+ "it", "this", "that", "these", "those", "i", "you", "he", "she", "we",
26
+ "they", "do", "does", "did", "have", "has", "had", "not", "no", "yes",
27
+ "will", "would", "can", "could", "should", "may", "might", "must",
28
+ "your", "my", "our", "their", "its", "all", "any", "some", "each",
29
+ ]);
30
+
31
+ /** Tokenize mixed CN/EN text into lowercase tokens. */
32
+ function tokenize(text) {
33
+ const out = [];
34
+ const s = String(text || "").toLowerCase();
35
+ for (const m of s.matchAll(/[a-z0-9][a-z0-9_+-]{1,}/g)) {
36
+ const t = m[0];
37
+ if (!EN_STOP.has(t)) out.push(t);
38
+ }
39
+ // CJK bigrams: "开源框架" -> "开源","源框","框架". Bigrams beat single
40
+ // chars for short tool descriptions and are dependency-free.
41
+ const cjk = s.replace(/[^\u4e00-\u9fff]/g, "");
42
+ if (cjk.length >= 2) {
43
+ for (let i = 0; i + 2 <= cjk.length; i++) out.push(cjk.slice(i, i + 2));
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /** Build a BM25 index over an array of docs. Each doc: {id, text, ...}. */
49
+ function buildIndex(docs) {
50
+ const n = docs.length;
51
+ const docTokens = [];
52
+ const df = new Map(); // term -> #docs containing it
53
+ let totalLen = 0;
54
+
55
+ for (const doc of docs) {
56
+ const tokens = tokenize(doc.text);
57
+ docTokens.push(tokens);
58
+ totalLen += tokens.length;
59
+ const seen = new Set();
60
+ for (const t of tokens) {
61
+ if (!seen.has(t)) {
62
+ seen.add(t);
63
+ df.set(t, (df.get(t) || 0) + 1);
64
+ }
65
+ }
66
+ }
67
+ const avgdl = n > 0 ? totalLen / n : 1;
68
+
69
+ return { n, docTokens, df, avgdl };
70
+ }
71
+
72
+ /** Score one doc's token list against the query's IDF map. */
73
+ function score(tokens, queryIdf, n, avgdl) {
74
+ const tf = new Map();
75
+ for (const t of tokens) tf.set(t, (tf.get(t) || 0) + 1);
76
+ let acc = 0;
77
+ const dl = tokens.length;
78
+ for (const [term, idf] of queryIdf) {
79
+ const f = tf.get(term);
80
+ if (!f) continue;
81
+ acc += idf * ((f * (K1 + 1)) / (f + K1 * (1 - B + (B * dl) / avgdl)));
82
+ }
83
+ return acc;
84
+ }
85
+
86
+ /**
87
+ * Search an index. Returns up to topK doc ids sorted by BM25 score desc.
88
+ * @param index result of buildIndex
89
+ * @param query raw query string (mixed CN/EN ok)
90
+ * @param topK max results (default 10)
91
+ * @param opts {filter: (docId) => boolean}
92
+ */
93
+ function search(index, query, topK = 10, opts = {}) {
94
+ const { n, docTokens, df, avgdl } = index;
95
+ if (!n || !query) return [];
96
+ const qTerms = tokenize(query);
97
+ if (qTerms.length === 0) return [];
98
+
99
+ const queryIdf = new Map();
100
+ for (const t of qTerms) {
101
+ const d = df.get(t) || 0;
102
+ // BM25+ style: add 1 inside the log to keep IDF positive for unseen terms.
103
+ queryIdf.set(t, Math.log((n - d + 0.5) / (d + 0.5) + 1));
104
+ }
105
+
106
+ const scored = [];
107
+ for (let i = 0; i < n; i++) {
108
+ const s = score(docTokens[i], queryIdf, n, avgdl);
109
+ if (s > 0 && (!opts.filter || opts.filter(i))) scored.push([i, s]);
110
+ }
111
+ scored.sort((a, b) => b[1] - a[1]);
112
+ return scored.slice(0, topK).map(([i]) => i);
113
+ }
114
+
115
+ export { tokenize, buildIndex, search, score };
package/lib/catalog.js ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * catalog.js — CatalogStabilizer (the core fold, KV-cache-stable).
3
+ *
4
+ * v0.2.0: the rendered catalog is a STATIC function of the skill set only.
5
+ * It never varies with the per-step query, so the message stays byte-identical
6
+ * across turns and the prompt-cache prefix never invalidates.
7
+ *
8
+ * Red line (spec B3 / Shared Knowledge #1): the catalog message's
9
+ * `source.entries` is the FULL snapshot and is NEVER trimmed — only
10
+ * `content[0].text` (the rendered model-facing text) is replaced. The host's
11
+ * digest (dsh-tool-skill :279-282) and catalogHistory (:309-326) are computed
12
+ * from source.entries, so keeping entries untouched keeps the digest feedback
13
+ * loop stable and prevents the "republish full catalog every turn" death loop.
14
+ *
15
+ * Fail-safe: any abnormal shape (kind !== "enter", no catalog, unreadable
16
+ * entries, no text block, identical text) returns the SAME decision reference
17
+ * unchanged. All rewrites build NEW objects (spread) — frozen host messages
18
+ * are never mutated.
19
+ */
20
+ import { selectEntries } from "./select.js";
21
+ import { renderCatalogText } from "./render.js";
22
+
23
+ /**
24
+ * Validate a catalog source exactly like the host's readCatalogEntries
25
+ * (dsh-tool-skill :294-308): entries must be an array of {name, description}
26
+ * with non-empty string name and string description; otherwise undefined.
27
+ * @param {object|undefined} source
28
+ * @returns {Array<{name: string, description: string}>|undefined}
29
+ */
30
+ export function readCatalogEntries(source) {
31
+ const entries = source && source.entries;
32
+ if (!Array.isArray(entries)) return undefined;
33
+ const readable = [];
34
+ for (const entry of entries) {
35
+ if (typeof entry !== "object" || entry === null) return undefined;
36
+ const { name, description } = entry;
37
+ if (typeof name !== "string" || name === "" || typeof description !== "string") return undefined;
38
+ readable.push({ name, description });
39
+ }
40
+ return readable;
41
+ }
42
+
43
+ /** Whether a message is one of this plugin's durable catalogs. */
44
+ function isCatalogMessage(message) {
45
+ return Boolean(
46
+ message &&
47
+ message.source &&
48
+ message.source.kind === "skill-catalog" &&
49
+ readCatalogEntries(message.source) !== undefined,
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Find the first readable skill-catalog message in a message batch.
55
+ * @param {Array} messages
56
+ * @returns {{message: object, entries: Array}|undefined}
57
+ */
58
+ export function findCatalogMessage(messages) {
59
+ if (!Array.isArray(messages)) return undefined;
60
+ for (const message of messages) {
61
+ if (!isCatalogMessage(message)) continue;
62
+ return { message, entries: readCatalogEntries(message.source) };
63
+ }
64
+ return undefined;
65
+ }
66
+
67
+ /**
68
+ * Build a NEW catalog message with the same identity (id/role/source, entries
69
+ * untouched) but a replaced content[0].text block. Never mutates the input.
70
+ * @param {object} cat - the original catalog message (frozen or not).
71
+ * @param {string} text - the trimmed rendered text.
72
+ * @returns {object} a new frozen message.
73
+ */
74
+ export function rewriteContent(cat, text) {
75
+ const content = Object.freeze([Object.freeze({ type: "text", text })]);
76
+ return Object.freeze({ ...cat, content });
77
+ }
78
+
79
+ /**
80
+ * Stabilize every readable catalog message in the decision's message batch.
81
+ * Rendering is a pure function of the entries + config — no query input.
82
+ * @param {object} decision - pre-step decision ({kind, messages}).
83
+ * @param {object} cfg - merged config (core/deny/maxDescLength).
84
+ * @param {object} [logger] - optional cordis logger (used for diagnostics only).
85
+ * @returns {object} same reference when nothing changed; otherwise a new decision.
86
+ */
87
+ export function trimDecision(decision, cfg, logger) {
88
+ if (!decision || decision.kind !== "enter") return decision;
89
+ if (!Array.isArray(decision.messages)) return decision;
90
+ if (!decision.messages.some(isCatalogMessage)) return decision;
91
+
92
+ const maxDescLength = Number.isFinite(Number(cfg.maxDescLength))
93
+ ? Math.max(0, Math.floor(Number(cfg.maxDescLength)))
94
+ : 100;
95
+ const coreList = Array.isArray(cfg.core) ? cfg.core : [];
96
+
97
+ let changed = false;
98
+ const messages = decision.messages.map((message) => {
99
+ if (!isCatalogMessage(message)) return message;
100
+ const entries = readCatalogEntries(message.source);
101
+ // Empty catalog: nothing to fold — keep the host's exact "no skills"
102
+ // wording untouched (fail-safe).
103
+ if (!entries || entries.length === 0) return message;
104
+
105
+ const blocks = Array.isArray(message.content) ? message.content : [];
106
+ const first = blocks[0];
107
+ if (!first || first.type !== "text" || typeof first.text !== "string") return message;
108
+ const originalText = first.text;
109
+
110
+ const selected = selectEntries(entries, cfg);
111
+ // All entries denied (e.g. deny:["*"] + empty core) → render nothing.
112
+ // Keep the host's exact wording untouched (fail-safe): an empty
113
+ // <available_skills> would make the model believe no skills exist.
114
+ if (!selected.length) return message;
115
+ const text = renderCatalogText(selected, [], {
116
+ update: message.source.update === true,
117
+ maxDescLength,
118
+ core: coreList,
119
+ });
120
+
121
+ if (text === originalText) return message;
122
+ changed = true;
123
+ return rewriteContent(message, text);
124
+ });
125
+
126
+ if (!changed) return decision;
127
+ logger?.info?.(
128
+ "skill-folder: stabilized %d catalog message(s) in %d-entry decision",
129
+ decision.messages.filter(isCatalogMessage).length,
130
+ decision.messages.length,
131
+ );
132
+ return { ...decision, messages };
133
+ }
package/lib/index.js ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * dsh-skill-folder — fold the DSH skill catalog prompt surface.
3
+ *
4
+ * v0.2.0 — KV-cache-stable rewrite (fixes the net-negative dynamic trim):
5
+ * - The catalog message is rendered STATICALLY (core full + all others
6
+ * truncated). It depends only on the skill set, never on the per-step
7
+ * query → the prompt-cache prefix stays byte-identical across turns.
8
+ * - A `skill_search(intent)` tool provides on-demand retrieval (BM25 over
9
+ * name + description + aliases). Tool definitions live in the static
10
+ * prefix; search RESULTS append to the message tail → nothing invalidates.
11
+ * - Selection quality: model searches by intent, gets top-K relevant
12
+ * skills, loads the winner via the native `skill` tool. (Deferred+search
13
+ * pattern; Anthropic measured 74% vs 49% accuracy over all-loaded.)
14
+ *
15
+ * Mechanism (verified against DSH host sources, see docs/system_design.md):
16
+ * - @deepseek-ai/dsh-tool-skill owns the `skill` tool and the catalog
17
+ * L2 publish/update/digest/history machinery. We register on
18
+ * "agent/pre-step" with prepend:true → OUTERMOST in the waterfall; we
19
+ * await next() → L1/L2 complete → we stabilize content[0].text while
20
+ * source.entries (digest input) stays untouched.
21
+ * - We never veto, never throw: any error/timeout falls back to the
22
+ * untouched decision (fail-safe).
23
+ */
24
+ import z from "@deepseek-ai/schemastery";
25
+ import { trimDecision, readCatalogEntries, findCatalogMessage } from "./catalog.js";
26
+ import { buildSkillSearchTool } from "./tool-skill-search.js";
27
+ import { configureSemantic, buildSemanticIndex } from "./semantic.js";
28
+ import { filterPool, routeHint } from "./skill-search.js";
29
+
30
+ export const name = "skill-folder";
31
+ export const inject = ["agents", "tools", "skills"]; // skills for skill_search snapshot
32
+
33
+ /**
34
+ * Schemastery configuration schema — renders the plugin's settings form in the
35
+ * DSH settings UI. Mirrors DEFAULTS below (same pattern as dsh-tool-folder).
36
+ */
37
+ export const Config = z.object({
38
+ enabled: z.boolean().default(true).description("总开关:启用技能折叠"),
39
+ core: z.array(z.string()).default(["dsh-injection-guard", "dsh-verifier"])
40
+ .description("P0 常驻:每轮全量描述可见(安全底线)"),
41
+ deny: z.array(z.string()).default(["autotelic-evolution", "dsh-team-orchestra"])
42
+ .description("P3 彻底封杀:精确名或 prefix*;目录不渲染 + skill_search 不可搜到"),
43
+ aliases: z.dict(z.array(z.string())).default({
44
+ "viking-memory-guide": ["记忆", "回忆", "记住", "memory", "remember"],
45
+ "dsh-grilling": ["访谈", "对齐", "先问我", "grilling", "问清楚", "开工前"],
46
+ "dsh-delegation-checklist": ["委派", "子智能体", "subagent", "delegate", "openhands"],
47
+ "dsh-context-language": ["术语", "词汇表", "领域语言", "context", "语言"],
48
+ "dsh-injection-guard": ["注入", "安全", "不可信", "injection", "外部内容"],
49
+ "dsh-verifier": ["验证", "检查完成", "防假完成", "verify", "验证器"],
50
+ "dsh-bug-diagnosis": ["排查", "诊断", "bug", "崩溃", "报错", "异常", "debug", "变慢"],
51
+ "dsh-two-axis-review": ["审查", "评审", "code review", "规范", "spec"],
52
+ "cordis-plugin-development": ["插件", "cordis", "plugin", "动态插件", "开发插件", "扩展插件"],
53
+ "editing-cordis-compositions": ["编排", "composition", "compose", "cordis.yml", "插件配置", "组合"],
54
+ }).description("意图词→技能:用于 skill_search 检索索引(BM25 加分项),中文意图可命中英文技能;别名关键词≥2 字(CJK bigram)"),
55
+ toolSearchEnabled: z.boolean().default(true).description("注册 skill_search 检索工具(静态前缀,结果追加消息尾部,不破坏缓存)"),
56
+ maxDescLength: z.number().min(0).max(500).default(100)
57
+ .description("动态条目描述最大长度;core 不受限"),
58
+ maxFoldMs: z.number().min(0).max(1000).default(5)
59
+ .description("裁剪耗时上限(ms),超时仅告警,结果仍放行"),
60
+ semanticEnabled: z.boolean().default(true).description("语义检索腿(本地 Ollama bge-m3):skill_search 用 BM25+语义 RRF 混合,中文意图可命中英文技能"),
61
+ ollamaBase: z.string().default("http://127.0.0.1:11434").description("Ollama 地址(semanticEnabled 时使用)"),
62
+ embedModel: z.string().default("bge-m3").description("embedding 模型(semanticEnabled 时使用)"),
63
+ autoRoute: z.boolean().default(true).description("自动路由提示:用户消息明显指向某技能时,在消息尾部追加一行提示(不碰 catalog 前缀,不破坏 KV 缓存)"),
64
+ });
65
+
66
+ export const DEFAULTS = {
67
+ enabled: true,
68
+ core: ["dsh-injection-guard", "dsh-verifier"],
69
+ deny: ["autotelic-evolution", "dsh-team-orchestra"],
70
+ aliases: {
71
+ "viking-memory-guide": ["记忆", "回忆", "记住", "memory", "remember"],
72
+ "dsh-grilling": ["访谈", "对齐", "先问我", "grilling", "问清楚", "开工前"],
73
+ "dsh-delegation-checklist": ["委派", "子智能体", "subagent", "delegate", "openhands"],
74
+ "dsh-context-language": ["术语", "词汇表", "领域语言", "context", "语言"],
75
+ "dsh-injection-guard": ["注入", "安全", "不可信", "injection", "外部内容"],
76
+ "dsh-verifier": ["验证", "检查完成", "防假完成", "verify", "验证器"],
77
+ "dsh-bug-diagnosis": ["排查", "诊断", "bug", "崩溃", "报错", "异常", "debug", "变慢"],
78
+ "dsh-two-axis-review": ["审查", "评审", "code review", "规范", "spec"],
79
+ "cordis-plugin-development": ["插件", "cordis", "plugin", "动态插件", "开发插件", "扩展插件"],
80
+ "editing-cordis-compositions": ["编排", "composition", "compose", "cordis.yml", "插件配置", "组合"],
81
+ },
82
+ maxDescLength: 100,
83
+ maxFoldMs: 5,
84
+ toolSearchEnabled: true,
85
+ semanticEnabled: true, // P0-1: bge-m3 semantic leg (RRF hybrid)
86
+ ollamaBase: "http://127.0.0.1:11434",
87
+ embedModel: "bge-m3",
88
+ autoRoute: true, // P1: tail-appended skill routing hint (KV-safe)
89
+ };
90
+
91
+ /**
92
+ * @param {object} ctx - cordis context.
93
+ * @param {object} [config] - user config (merged over DEFAULTS).
94
+ * @returns {(() => void)|void} disposer (no-op when disabled).
95
+ */
96
+ export function apply(ctx, config = {}) {
97
+ const cfg = { ...DEFAULTS, ...(config || {}) };
98
+ const logger = ctx.logger("skill-folder");
99
+ if (!cfg.enabled) {
100
+ logger.info("disabled by config");
101
+ return;
102
+ }
103
+
104
+ // Register the `skill_search` retrieval tool (static prefix — never
105
+ // invalidates the cache; results append to the message tail).
106
+ configureSemantic({ ollamaBase: cfg.ollamaBase, embedModel: cfg.embedModel });
107
+ const semIndexCache = new Map(); // pool fingerprint → Promise<semantic index>
108
+ const getSemIndex = (docs) => {
109
+ const fp = docs.map((d) => `${d.id}\u0000${d.text}`).join("\u0001");
110
+ if (!semIndexCache.has(fp)) {
111
+ semIndexCache.set(fp, buildSemanticIndex(docs));
112
+ }
113
+ return semIndexCache.get(fp);
114
+ };
115
+ const searchTool = cfg.toolSearchEnabled
116
+ ? buildSkillSearchTool(ctx, cfg, logger, cfg.semanticEnabled !== false ? getSemIndex : null)
117
+ : null;
118
+ if (searchTool) {
119
+ try {
120
+ ctx.tools.register(searchTool);
121
+ logger.info("registered skill_search tool");
122
+ } catch (e) {
123
+ logger.warn("skill_search register failed (%s) — catalog still stabilized", e?.message);
124
+ }
125
+ }
126
+
127
+ // prepend:true → outermost in the pre-step waterfall (B2). We run AFTER
128
+ // dsh-tool-skill L1/L2, stabilize the final decision, and pass it back.
129
+ const disposer = ctx.on(
130
+ "agent/pre-step",
131
+ async ({ messages, signal }, next) => {
132
+ // Run the rest of the waterfall (L1 /name, L2 catalog, inner).
133
+ const decision = await next();
134
+ const t0 = Date.now();
135
+ try {
136
+ let out = trimDecision(decision, cfg, logger);
137
+ // P1 auto-route: a user message that clearly points at one skill gets
138
+ // a one-line hint appended to the USER message tail (dynamic region —
139
+ // the catalog prefix stays byte-identical, KV cache untouched).
140
+ if (cfg.autoRoute !== false && out && out.kind === "enter" && Array.isArray(out.messages)) {
141
+ const routed = appendRouteHint(out, cfg);
142
+ if (routed !== out) out = routed;
143
+ }
144
+ const dt = Date.now() - t0;
145
+ if (dt > cfg.maxFoldMs) {
146
+ logger.warn("fold took %dms (> %dms) — result still applied", dt, cfg.maxFoldMs);
147
+ }
148
+ return out;
149
+ } catch (e) {
150
+ // Never throw out of a pre-step listener: fail-safe passthrough.
151
+ logger.warn("trim failed (%s) — passthrough", e?.message);
152
+ return decision;
153
+ }
154
+ },
155
+ true,
156
+ );
157
+
158
+ return () => {
159
+ try {
160
+ disposer();
161
+ } catch {
162
+ /* already disposed */
163
+ }
164
+ };
165
+ }
166
+
167
+ /**
168
+ * P1 auto-route (2026-08-30): append a one-line skill hint to the LAST user
169
+ * message when the message clearly points at one skill (alias hit or name
170
+ * token hit — see routeHint). Appending to the USER message tail is KV-safe:
171
+ * the user region is already dynamic per turn; the catalog message (prefix)
172
+ * is never touched. Fail-safe: any shape oddity returns the same reference.
173
+ * @param {object} decision - pre-step decision ({kind, messages}).
174
+ * @param {object} cfg - merged config (aliases/deny/autoRoute).
175
+ * @returns {object} same reference when nothing to add; otherwise new decision.
176
+ */
177
+ export function appendRouteHint(decision, cfg) {
178
+ try {
179
+ if (!decision || decision.kind !== "enter" || !Array.isArray(decision.messages)) return decision;
180
+ const cat = findCatalogMessage(decision.messages);
181
+ if (!cat) return decision;
182
+ const entries = cat.entries;
183
+
184
+ let lastUser = -1;
185
+ for (let i = decision.messages.length - 1; i >= 0; i--) {
186
+ const m = decision.messages[i];
187
+ if (m && m.role === "user") {
188
+ lastUser = i;
189
+ break;
190
+ }
191
+ }
192
+ if (lastUser < 0) return decision;
193
+ const msg = decision.messages[lastUser];
194
+ const blocks = Array.isArray(msg.content) ? msg.content : [];
195
+ const first = blocks[0];
196
+ if (!first || first.type !== "text" || typeof first.text !== "string") return decision;
197
+ if (first.text.includes("<skill-route>")) return decision; // idempotent
198
+
199
+ const pool = filterPool(entries, cfg);
200
+ const hit = routeHint(pool, first.text, cfg);
201
+ if (!hit) return decision;
202
+
203
+ const desc = (entries.find((e) => e.name === hit) || {}).description || "";
204
+ const hint =
205
+ `\n\n<skill-route>📌 任务明显指向技能「${hit}」——可直接用 skill 工具加载:` +
206
+ `${desc.slice(0, 80)}${desc.length > 80 ? "…" : ""}</skill-route>`;
207
+ const newBlocks = Object.freeze([Object.freeze({ ...first, text: first.text + hint }), ...blocks.slice(1)]);
208
+ const newMsg = Object.freeze({ ...msg, content: newBlocks });
209
+ const messages = [...decision.messages];
210
+ messages[lastUser] = newMsg;
211
+ return { ...decision, messages };
212
+ } catch {
213
+ return decision; // never throw out of pre-step
214
+ }
215
+ }
package/lib/pattern.js ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * pattern.js — shared pattern matching (exact name or "prefix*").
3
+ * Used by deny / core / aliases across the plugin. Zero dependencies.
4
+ */
5
+
6
+ /**
7
+ * @param {string} name
8
+ * @param {Array<string>} patterns
9
+ * @returns {boolean}
10
+ */
11
+ export function matchesAnyPattern(name, patterns) {
12
+ if (!name || !Array.isArray(patterns) || patterns.length === 0) return false;
13
+ const n = String(name);
14
+ for (const p of patterns) {
15
+ const pat = String(p);
16
+ if (pat.endsWith("*")) {
17
+ if (n.startsWith(pat.slice(0, -1))) return true;
18
+ } else if (n === pat) {
19
+ return true;
20
+ }
21
+ }
22
+ return false;
23
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * quality-scorer.js — 技能质量评分器(方案 3)
3
+ *
4
+ * 评分维度(基于实测校准):
5
+ * - 结构完整性 (0-10): When to Use(3) + Core Pattern/Workflow(3) + Examples(2) + Overview(2)
6
+ * - 示例质量 (0-5): 代码块数量
7
+ * - 更新频率 (0-5): 天数
8
+ * - 描述本地化 (0-5): 含中文=5, 纯英文=0
9
+ *
10
+ * 用法:
11
+ * node test/quality-scorer.js # 评分全部 118 技能并输出报告
12
+ * node test/quality-scorer.js --summary # 仅输出分布汇总
13
+ * node test/quality-scorer.js --top 20 # 输出 Top 20
14
+ *
15
+ * 作为库使用:
16
+ * import { scoreSkill, scoreAll } from '../lib/quality-scorer.js';
17
+ */
18
+
19
+ import { readdir, readFile, stat } from 'node:fs/promises';
20
+ import { join } from 'node:path';
21
+ import os from 'node:os';
22
+
23
+ // Resolve the skills dir from the environment (no hard-coded local paths —
24
+ // this module ships in the npm package).
25
+ const SKILLS_DIR = join(process.env.DSH_HOME || join(os.homedir(), '.dsh'), 'skills');
26
+
27
+ /**
28
+ * 对单个技能评分
29
+ * @param {string} dir - 技能目录路径
30
+ * @returns {object|null} 评分结果
31
+ */
32
+ export async function scoreSkill(dir) {
33
+ const skillFile = join(dir, 'SKILL.md');
34
+ try {
35
+ const content = await readFile(skillFile, 'utf-8');
36
+ const stats = await stat(skillFile);
37
+ const name = content.match(/^name:\s*(.+)$/m)?.[1]?.trim() || dir.split(/[/\\]/).pop();
38
+
39
+ // === 结构完整性 (0-10) ===
40
+ let structureScore = 0;
41
+ // When to Use 相关 (3 分)
42
+ if (/^## When to Use/m.test(content) || /^## 何时使用/m.test(content) || /^## When to use this skill/m.test(content)) {
43
+ structureScore += 3;
44
+ }
45
+ // Core Pattern / Workflow / Process 相关 (3 分)
46
+ if (/^## Core Pattern/m.test(content) || /^## 核心模式/m.test(content) || /^## Workflow/m.test(content) || /^## 工作流/m.test(content) || /^## Process/m.test(content) || /^## Key Patterns/m.test(content)) {
47
+ structureScore += 3;
48
+ }
49
+ // Examples / 示例 (2 分)
50
+ if (/^## Examples/m.test(content) || /^## 示例/m.test(content)) {
51
+ structureScore += 2;
52
+ }
53
+ // Overview / 概述 (2 分)
54
+ if (/^## Overview/m.test(content) || /^## 概述/m.test(content)) {
55
+ structureScore += 2;
56
+ }
57
+
58
+ // === 示例质量 (0-5) ===
59
+ const codeBlocks = (content.match(/```[\s\S]*?```/g) || []).length;
60
+ const exampleScore = codeBlocks === 0 ? 0 : codeBlocks <= 2 ? 3 : codeBlocks <= 5 ? 4 : 5;
61
+
62
+ // === 更新频率 (0-5) ===
63
+ const daysSinceUpdate = Math.floor((Date.now() - stats.mtimeMs) / (1000 * 60 * 60 * 24));
64
+ const updateScore = daysSinceUpdate < 30 ? 5 : daysSinceUpdate < 90 ? 3 : daysSinceUpdate < 180 ? 2 : daysSinceUpdate < 365 ? 1 : 0;
65
+
66
+ // === 描述本地化 (0-5) ===
67
+ const descMatch = content.match(/^description:\s*(.+)$/m);
68
+ let descScore = 0;
69
+ if (descMatch) {
70
+ const desc = descMatch[1].trim();
71
+ // 含中文 = 5, 纯英文 = 0
72
+ descScore = /[一-鿿]/.test(desc) ? 5 : 0;
73
+ }
74
+
75
+ return {
76
+ name,
77
+ structureScore,
78
+ exampleScore,
79
+ updateScore,
80
+ descScore,
81
+ total: structureScore + exampleScore + updateScore + descScore,
82
+ codeBlocks,
83
+ daysSinceUpdate,
84
+ maxScore: 25,
85
+ };
86
+ } catch (e) {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * 评分全部技能
93
+ * @returns {object[]} 评分结果数组
94
+ */
95
+ export async function scoreAll() {
96
+ const entries = await readdir(SKILLS_DIR, { withFileTypes: true });
97
+ const skills = [];
98
+ for (const e of entries) {
99
+ if (!e.isDirectory()) continue;
100
+ const skill = await scoreSkill(join(SKILLS_DIR, e.name));
101
+ if (skill) skills.push(skill);
102
+ }
103
+ return skills;
104
+ }
105
+
106
+ // CLI 入口
107
+ const isCLI = process.argv[1] && (
108
+ import.meta.url === `file://${process.argv[1]}` ||
109
+ import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}` ||
110
+ import.meta.url === `file:///${process.argv[1].replace(/\\/g, '/')}`
111
+ );
112
+ if (isCLI) {
113
+ const args = process.argv.slice(2);
114
+ const isSummary = args.includes('--summary');
115
+ const topN = args.includes('--top') ? parseInt(args[args.indexOf('--top') + 1]) : null;
116
+
117
+ const skills = await scoreAll();
118
+
119
+ if (isSummary) {
120
+ // 仅输出分布
121
+ const buckets = { '0-5': 0, '6-10': 0, '11-15': 0, '16-20': 0, '21-25': 0 };
122
+ for (const s of skills) {
123
+ if (s.total <= 5) buckets['0-5']++;
124
+ else if (s.total <= 10) buckets['6-10']++;
125
+ else if (s.total <= 15) buckets['11-15']++;
126
+ else if (s.total <= 20) buckets['16-20']++;
127
+ else buckets['21-25']++;
128
+ }
129
+ const avg = skills.reduce((a, s) => a + s.total, 0) / skills.length;
130
+ console.log(JSON.stringify({ average: avg.toFixed(1), distribution: buckets, total: skills.length }));
131
+ } else if (topN) {
132
+ // 输出 Top N
133
+ const sorted = [...skills].sort((a, b) => b.total - a.total).slice(0, topN);
134
+ console.log(`\n## Top ${topN} 技能质量评分\n`);
135
+ console.log('| 排名 | 技能名 | 结构 | 示例 | 更新 | 描述 | 总分 |');
136
+ console.log('|------|--------|------|------|------|------|------|');
137
+ sorted.forEach((s, i) => {
138
+ console.log(`| ${i + 1} | ${s.name.slice(0, 30)} | ${s.structureScore} | ${s.exampleScore} | ${s.updateScore} | ${s.descScore} | ${s.total}/25 |`);
139
+ });
140
+ } else {
141
+ // 完整报告
142
+ const sorted = [...skills].sort((a, b) => a.total - b.total);
143
+ const avg = skills.reduce((a, s) => a + s.total, 0) / skills.length;
144
+
145
+ console.log('\n## 技能质量评分报告(全部 118 技能)\n');
146
+ console.log(`**平均分**: ${avg.toFixed(1)}/25`);
147
+ console.log(`**中位数**: ${sorted[Math.floor(sorted.length / 2)].total}/25`);
148
+
149
+ // 分数分布
150
+ const buckets = { '0-5': 0, '6-10': 0, '11-15': 0, '16-20': 0, '21-25': 0 };
151
+ for (const s of skills) {
152
+ if (s.total <= 5) buckets['0-5']++;
153
+ else if (s.total <= 10) buckets['6-10']++;
154
+ else if (s.total <= 15) buckets['11-15']++;
155
+ else if (s.total <= 20) buckets['16-20']++;
156
+ else buckets['21-25']++;
157
+ }
158
+ console.log('\n**分数分布**:');
159
+ for (const [bucket, count] of Object.entries(buckets)) {
160
+ const bar = '█'.repeat(Math.round(count / 2));
161
+ console.log(` ${bucket}: ${count} ${bar}`);
162
+ }
163
+
164
+ // 各维度平均
165
+ const avgStructure = skills.reduce((a, s) => a + s.structureScore, 0) / skills.length;
166
+ const avgExample = skills.reduce((a, s) => a + s.exampleScore, 0) / skills.length;
167
+ const avgUpdate = skills.reduce((a, s) => a + s.updateScore, 0) / skills.length;
168
+ const avgDesc = skills.reduce((a, s) => a + s.descScore, 0) / skills.length;
169
+ console.log('\n**各维度平均**:');
170
+ console.log(` 结构完整性: ${avgStructure.toFixed(1)}/10`);
171
+ console.log(` 示例质量: ${avgExample.toFixed(1)}/5`);
172
+ console.log(` 更新频率: ${avgUpdate.toFixed(1)}/5`);
173
+ console.log(` 描述本地化: ${avgDesc.toFixed(1)}/5`);
174
+
175
+ // 最低分技能
176
+ console.log('\n## 最低分技能(Bottom 10)\n');
177
+ console.log('| 技能名 | 结构 | 示例 | 更新 | 描述 | 总分 |');
178
+ console.log('|--------|------|------|------|------|------|');
179
+ sorted.slice(0, 10).forEach(s => {
180
+ console.log(`| ${s.name.slice(0, 30)} | ${s.structureScore} | ${s.exampleScore} | ${s.updateScore} | ${s.descScore} | ${s.total}/25 |`);
181
+ });
182
+
183
+ // 最高分技能
184
+ console.log('\n## 最高分技能(Top 10)\n');
185
+ console.log('| 技能名 | 结构 | 示例 | 更新 | 描述 | 总分 |');
186
+ console.log('|--------|------|------|------|------|------|');
187
+ sorted.slice(-10).reverse().forEach(s => {
188
+ console.log(`| ${s.name.slice(0, 30)} | ${s.structureScore} | ${s.exampleScore} | ${s.updateScore} | ${s.descScore} | ${s.total}/25 |`);
189
+ });
190
+ }
191
+ }