dsh-skill-browser 1.5.4
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/LICENSE +21 -0
- package/README.md +63 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +941 -0
- package/lib/index.js +436 -0
- package/lib/ledger-writer.js +416 -0
- package/lib/pick-dir.ps1 +14 -0
- package/lib/scan.js +220 -0
- package/lib/zh.js +84 -0
- package/package.json +53 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// dsh-skill-browser - 技能生效/失效台账自动登记引擎(host 端)。
|
|
2
|
+
//
|
|
3
|
+
// 数据源与策略:
|
|
4
|
+
// 1. 自动登记(确定性):监听官方 `tools/result` 事件,过滤 skill 工具调用;
|
|
5
|
+
// result.isError=true → 未生效❌;成功 → 调用次数+1(生效✅)。
|
|
6
|
+
// 插件绝不解析对话内容猜关键词,只信官方工具结果事件。
|
|
7
|
+
// 2. 手动上报(语义层):对话 AI 发现用户纠错/技能未达预期时,
|
|
8
|
+
// POST /ledger/report { skill, note, kind } 上报登记。
|
|
9
|
+
// 3. 台账文件支持动态路径:state.ledgerPath 可指向库内任意被搜索命中的
|
|
10
|
+
// 台账文件;未设置时用约定路径 <root>/skill-master/references/技能生效-失效台账.md。
|
|
11
|
+
// 有则更新(人工内容不动,插件行用 HTML 注释标记识别);无则创建默认模板
|
|
12
|
+
// (对齐现行台账格式、零隐私内容、内置登记口令说明)。
|
|
13
|
+
// 4. 插件首次接管现有台账时先备份原文件(.bak-dsh-skill-browser-<ts>,一次性)。
|
|
14
|
+
// 5. 全量机器可读记录:<root>/skill-master/references/技能失效-自动登记.jsonl。
|
|
15
|
+
//
|
|
16
|
+
// 台账 md 内插件维护区块用标记包围,幂等追加:
|
|
17
|
+
// <!-- dsh-skill-browser:auto-ledger:v1 BEGIN --> ... <!-- dsh-skill-browser:auto-ledger:v1 END -->
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, appendFileSync, readdirSync } from "node:fs";
|
|
20
|
+
import { join, dirname } from "node:path";
|
|
21
|
+
|
|
22
|
+
var LEDGER_REL = ["skill-master", "references", "技能生效-失效台账.md"];
|
|
23
|
+
var LOG_REL = ["skill-master", "references", "技能失效-自动登记.jsonl"];
|
|
24
|
+
var MARK_BEGIN = "<!-- dsh-skill-browser:auto-ledger:v1 BEGIN -->";
|
|
25
|
+
var MARK_END = "<!-- dsh-skill-browser:auto-ledger:v1 END -->";
|
|
26
|
+
var BAK_PREFIX = ".bak-dsh-skill-browser-";
|
|
27
|
+
var FLUSH_BATCH = 5; // 队列达到 5 条立即写盘
|
|
28
|
+
var FLUSH_DELAY_MS = 60 * 1000; // 否则 60s 批量写盘
|
|
29
|
+
var QUEUE_HARD_CAP = 1000; // 防写盘长期失败导致队列无限膨胀
|
|
30
|
+
|
|
31
|
+
export function ledgerFilePath(root) {
|
|
32
|
+
if (root && root.__ledgerPath) return root.__ledgerPath; // 动态路径(不推荐用法,保留兼容)
|
|
33
|
+
return LEDGER_REL.reduce(function (acc, seg) { return join(acc, seg); }, root);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 台账实际路径:优先 state.ledgerPath(搜索命中/已创建),否则约定路径
|
|
37
|
+
export function ledgerEffectivePath(state) {
|
|
38
|
+
if (state && state.ledgerPath) return state.ledgerPath;
|
|
39
|
+
var base = (state && state.root) || "";
|
|
40
|
+
return LEDGER_REL.reduce(function (acc, seg) { return join(acc, seg); }, base);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function autoLogFilePath(root) {
|
|
44
|
+
return LOG_REL.reduce(function (acc, seg) { return join(acc, seg); }, root);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function pad2(n) { return n < 10 ? "0" + n : "" + n; }
|
|
48
|
+
|
|
49
|
+
function todayStr(d) {
|
|
50
|
+
var dt = d || new Date();
|
|
51
|
+
return dt.getFullYear() + "-" + pad2(dt.getMonth() + 1) + "-" + pad2(dt.getDate());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function nowStamp() {
|
|
55
|
+
var dt = new Date();
|
|
56
|
+
return todayStr(dt) + " " + pad2(dt.getHours()) + ":" + pad2(dt.getMinutes()) + ":" + pad2(dt.getSeconds());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ensureDir(p) {
|
|
60
|
+
try { mkdirSync(dirname(p), { recursive: true }); } catch (e) {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── 状态 ────────────────────────────────────────────────
|
|
64
|
+
export function createLedgerState(opts) {
|
|
65
|
+
return {
|
|
66
|
+
root: (opts && opts.root) || "",
|
|
67
|
+
ledgerPath: (opts && opts.ledgerPath) || "", // 动态台账路径(搜索命中后设置)
|
|
68
|
+
logger: (opts && opts.logger) || { info: function () {}, warn: function () {} },
|
|
69
|
+
queue: [],
|
|
70
|
+
flushTimer: null,
|
|
71
|
+
backupDone: false
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function setLedgerRoot(state, root) {
|
|
76
|
+
var changed = state.root !== (root || "");
|
|
77
|
+
state.root = root || "";
|
|
78
|
+
// 换根后路径与备份状态作废:新根需重新搜索台账、重新判断首次接管
|
|
79
|
+
if (changed) { state.ledgerPath = ""; state.backupDone = false; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 设置台账实际路径(搜索命中已有台账时调用)
|
|
83
|
+
export function setLedgerPath(state, p) {
|
|
84
|
+
if (state.ledgerPath !== (p || "")) {
|
|
85
|
+
state.ledgerPath = p || "";
|
|
86
|
+
state.backupDone = false; // 新文件需重新判断是否首接管备份
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── 初始化检查(用户设计:设置好技能库路径后第一件事) ──
|
|
91
|
+
// 幂等:有台账/有 skill-master 技能 → 不动;没有 → 各自创建。
|
|
92
|
+
// 新技能库由此获得完整的治理体系:skill-master 技能(面板可见,技能数+1)
|
|
93
|
+
// + 失效登记台账 + 空的自动流水。
|
|
94
|
+
export function initializeLedger(state) {
|
|
95
|
+
if (!state || !state.root) return;
|
|
96
|
+
ensureLedgerReady(state);
|
|
97
|
+
ensureSkillMasterSkill(state);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function ensureSkillMasterSkill(state) {
|
|
101
|
+
var dir = join(state.root, "skill-master");
|
|
102
|
+
var f = join(dir, "SKILL.md");
|
|
103
|
+
if (existsSync(f)) return; // 已有(如完整版技能治理大师)绝不覆盖
|
|
104
|
+
try {
|
|
105
|
+
ensureDir(f);
|
|
106
|
+
writeFileSync(f, skillMasterMd(), "utf8");
|
|
107
|
+
state.logger.info("dsh-skill-browser: skill-master scaffold created at " + f);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
safeWarn(state, "skill-master scaffold failed: " + (e && e.message));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function skillMasterMd() {
|
|
114
|
+
return [
|
|
115
|
+
"---",
|
|
116
|
+
"name: skill-master",
|
|
117
|
+
"description: 技能库治理与失效台账总控(dsh-skill-browser 插件自动初始化)。技能生效统计、失败自动登记、强化优先级。触发词:技能治理、失效台账、技能统计、技能失败登记。",
|
|
118
|
+
"---",
|
|
119
|
+
"",
|
|
120
|
+
"# skill-master — 技能库治理(dsh-skill-browser 自动化版)",
|
|
121
|
+
"",
|
|
122
|
+
"> 本技能由 dsh-skill-browser 插件在检查技能库时自动创建。",
|
|
123
|
+
"> 插件承担了本技能的自动化部分:登记、统计、台账维护;人工可继续补充。",
|
|
124
|
+
"",
|
|
125
|
+
"## 一、台账位置",
|
|
126
|
+
"",
|
|
127
|
+
"`skill-master/references/技能生效-失效台账.md`(本技能 references 目录下)",
|
|
128
|
+
"",
|
|
129
|
+
"## 二、登记机制(自动)",
|
|
130
|
+
"",
|
|
131
|
+
"- 每次 AI 通过 skill 工具调用技能:成功 → 生效✅;加载失败(技能名不存在/读取报错)→ 未生效❌。",
|
|
132
|
+
" 实现方式:dsh-skill-browser 插件监听 DSH 官方 `tools/result` 事件(不解析对话、不猜关键词)。",
|
|
133
|
+
"- 对话中发现用户纠错/技能未达预期 → AI 调用 `POST /dsh-skill-browser/ledger/report {skill, note, kind}` 手动登记。",
|
|
134
|
+
"- 全量机器可读流水:`skill-master/references/技能失效-自动登记.jsonl`。",
|
|
135
|
+
"",
|
|
136
|
+
"## 三、人工维护(可选)",
|
|
137
|
+
"",
|
|
138
|
+
"- 台账的统计表/失败明细区可人工补充;插件增量只写在 `<!-- dsh-skill-browser:auto-ledger:v1 -->` 标记区块内,**人工内容永不改动**。",
|
|
139
|
+
"- 失败次数最多的技能优先强化:改该技能 SKILL.md 补铁律/触发词,然后在台账标「已强化」。",
|
|
140
|
+
"",
|
|
141
|
+
"## 四、查看入口",
|
|
142
|
+
"",
|
|
143
|
+
"- DSH 界面 🧩 悬浮球 → 「📋 失效台账」Tab:等级徽章 + 强化状态 + 自动登记流水 + 失败明细。",
|
|
144
|
+
""
|
|
145
|
+
].join("\n");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── 事件入口 1:tools/result(自动登记) ─────────────────
|
|
149
|
+
// exec: { name:'skill', arguments:{ name:<skillName> }, callId }
|
|
150
|
+
// result: { isError, error?, content?: [{type:'text',text}] }
|
|
151
|
+
export function recordSkillResult(state, exec, result) {
|
|
152
|
+
try {
|
|
153
|
+
if (!state) return null;
|
|
154
|
+
if (!exec || exec.name !== "skill") return null;
|
|
155
|
+
var skillName = exec.arguments && typeof exec.arguments.name === "string" ? exec.arguments.name : "";
|
|
156
|
+
if (!skillName) return null;
|
|
157
|
+
var isError = !!(result && result.isError);
|
|
158
|
+
var entry = {
|
|
159
|
+
ts: new Date().toISOString(),
|
|
160
|
+
skill: skillName,
|
|
161
|
+
ok: !isError,
|
|
162
|
+
kind: "auto-tool-result",
|
|
163
|
+
callId: exec.callId || "",
|
|
164
|
+
error: isError ? extractErrorText(result) : ""
|
|
165
|
+
};
|
|
166
|
+
// root 未就绪时只入队不写盘(flushNow 会保留队列,root 就绪后落盘)
|
|
167
|
+
if (state.root) appendJsonl(state, entry);
|
|
168
|
+
enqueue(state, entry);
|
|
169
|
+
return entry;
|
|
170
|
+
} catch (e) {
|
|
171
|
+
safeWarn(state, "recordSkillResult failed: " + (e && e.message));
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── 事件入口 2:语义层手动上报 ──────────────────────────
|
|
177
|
+
export function reportFailure(state, payload) {
|
|
178
|
+
try {
|
|
179
|
+
if (!state) return { ok: false, error: "ledger not ready" };
|
|
180
|
+
var skill = String((payload && payload.skill) || "").trim();
|
|
181
|
+
if (!skill) return { ok: false, error: "missing skill" };
|
|
182
|
+
if (!state.root) {
|
|
183
|
+
// root 未就绪:读一遍运行时配置还没用的场景不做复杂兜底,直接提示
|
|
184
|
+
return { ok: false, error: "ledger not ready (no skill root detected yet)" };
|
|
185
|
+
}
|
|
186
|
+
var kind = String((payload && payload.kind) || "manual-report");
|
|
187
|
+
var note = String((payload && (payload.note || payload.reason)) || "").trim();
|
|
188
|
+
var entry = {
|
|
189
|
+
ts: new Date().toISOString(),
|
|
190
|
+
skill: skill,
|
|
191
|
+
ok: false,
|
|
192
|
+
kind: kind,
|
|
193
|
+
note: note.slice(0, 500)
|
|
194
|
+
};
|
|
195
|
+
appendJsonl(state, entry);
|
|
196
|
+
enqueue(state, entry);
|
|
197
|
+
flushNow(state); // 手动上报低频,立即落盘
|
|
198
|
+
return { ok: true, entry: entry };
|
|
199
|
+
} catch (e) {
|
|
200
|
+
safeWarn(state, "reportFailure failed: " + (e && e.message));
|
|
201
|
+
return { ok: false, error: String(e && e.message || e) };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 从 JSONL 读取自动记录(可选按技能过滤,最新在尾部)
|
|
206
|
+
export function readAutoLog(root, skillFilter) {
|
|
207
|
+
var p = autoLogFilePath(root);
|
|
208
|
+
if (!existsSync(p)) return [];
|
|
209
|
+
var out = [];
|
|
210
|
+
try {
|
|
211
|
+
var text = readFileSync(p, "utf8");
|
|
212
|
+
var lines = text.split("\n");
|
|
213
|
+
for (var i = 0; i < lines.length; i++) {
|
|
214
|
+
var line = lines[i].trim();
|
|
215
|
+
if (!line) continue;
|
|
216
|
+
try {
|
|
217
|
+
var obj = JSON.parse(line);
|
|
218
|
+
if (skillFilter && obj.skill !== skillFilter) continue;
|
|
219
|
+
out.push(obj);
|
|
220
|
+
} catch (e) { /* 跳过坏行 */ }
|
|
221
|
+
}
|
|
222
|
+
} catch (e) {}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── 内部:队列与写盘 ────────────────────────────────────
|
|
227
|
+
function enqueue(state, entry) {
|
|
228
|
+
state.queue.push(entry);
|
|
229
|
+
if (state.queue.length > QUEUE_HARD_CAP) {
|
|
230
|
+
var drop = state.queue.length - QUEUE_HARD_CAP;
|
|
231
|
+
state.queue.splice(0, drop);
|
|
232
|
+
safeWarn(state, "queue overflow, dropped " + drop + " oldest entries");
|
|
233
|
+
}
|
|
234
|
+
if (state.queue.length >= FLUSH_BATCH) { flushNow(state); return; }
|
|
235
|
+
if (!state.flushTimer) {
|
|
236
|
+
state.flushTimer = setTimeout(function () {
|
|
237
|
+
state.flushTimer = null;
|
|
238
|
+
flushNow(state);
|
|
239
|
+
}, FLUSH_DELAY_MS);
|
|
240
|
+
try { if (state.flushTimer && typeof state.flushTimer.unref === "function") state.flushTimer.unref(); } catch (e) {}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function flushNow(state) {
|
|
245
|
+
try {
|
|
246
|
+
if (state.flushTimer) { clearTimeout(state.flushTimer); state.flushTimer = null; }
|
|
247
|
+
if (!state.queue.length || !state.root) return;
|
|
248
|
+
var batch = state.queue.splice(0, state.queue.length);
|
|
249
|
+
try {
|
|
250
|
+
ensureLedgerReady(state);
|
|
251
|
+
var file = ledgerEffectivePath(state);
|
|
252
|
+
var text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
253
|
+
var delta = buildAutoSection(batch);
|
|
254
|
+
var merged = mergeAutoSection(text, delta);
|
|
255
|
+
ensureDir(file);
|
|
256
|
+
writeFileSync(file, merged, "utf8");
|
|
257
|
+
state.logger.info("dsh-skill-browser: ledger updated (+" + batch.length + " records)");
|
|
258
|
+
} catch (e) {
|
|
259
|
+
// 写盘失败:条目放回队头,下次事件再试
|
|
260
|
+
state.queue = batch.concat(state.queue);
|
|
261
|
+
safeWarn(state, "ledger write failed, " + batch.length + " entries requeued: " + (e && e.message));
|
|
262
|
+
}
|
|
263
|
+
} catch (e) {
|
|
264
|
+
safeWarn(state, "flushNow failed: " + (e && e.message));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 首次接管备份 + 默认台账创建(路径 = state.ledgerPath 或约定路径)
|
|
269
|
+
function ensureLedgerReady(state) {
|
|
270
|
+
var file = ledgerEffectivePath(state);
|
|
271
|
+
ensureDir(file);
|
|
272
|
+
if (!existsSync(file)) {
|
|
273
|
+
writeFileSync(file, defaultLedgerMd(), "utf8");
|
|
274
|
+
state.logger.info("dsh-skill-browser: default ledger created at " + file);
|
|
275
|
+
state.backupDone = true;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (state.backupDone) return;
|
|
279
|
+
// 一次性备份已存在的台账(查同目录是否已有本插件备份)
|
|
280
|
+
try {
|
|
281
|
+
var dir = dirname(file);
|
|
282
|
+
var existing = readdirSync(dir);
|
|
283
|
+
for (var i = 0; i < existing.length; i++) {
|
|
284
|
+
if (existing[i].indexOf(BAK_PREFIX) >= 0 && existing[i].indexOf("台账") >= 0) {
|
|
285
|
+
state.backupDone = true;
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
var bak = join(dir, BAK_PREFIX + timestampForFile() + "-" + (file.split(/[\\/]/).pop() || "ledger.md"));
|
|
290
|
+
copyFileSync(file, bak);
|
|
291
|
+
state.backupDone = true;
|
|
292
|
+
state.logger.info("dsh-skill-browser: ledger backed up to " + bak);
|
|
293
|
+
} catch (e) {
|
|
294
|
+
safeWarn(state, "ledger backup failed: " + (e && e.message));
|
|
295
|
+
state.backupDone = true; // 备份失败不阻塞登记
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function timestampForFile() {
|
|
300
|
+
var dt = new Date();
|
|
301
|
+
return dt.getFullYear() + pad2(dt.getMonth() + 1) + pad2(dt.getDate()) + "-" + pad2(dt.getHours()) + pad2(dt.getMinutes()) + pad2(dt.getSeconds());
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// 默认台账模板:对齐现行「技能生效-失效台账」格式(〇统计口径/一登记时机/
|
|
305
|
+
// 二统计表/三失败明细),零隐私内容,内置登记口令与自动登记说明。
|
|
306
|
+
// 表头列与解析器(parseLedgerTable 8 列)严格兼容。
|
|
307
|
+
function defaultLedgerMd() {
|
|
308
|
+
return [
|
|
309
|
+
"# 技能生效-失效台账",
|
|
310
|
+
"",
|
|
311
|
+
"> 本台账是「技能生效/未生效(失败执行)」的**唯一登记处**。目的:技能若未生效/执行失败/被用户纠错,必须**立即、主动**登记,失败次数最多的技能优先着重强化,最终降低失误率。",
|
|
312
|
+
">",
|
|
313
|
+
"> **引用前铁律**:任何任务要调用某个技能前,先在本台账查该技能名——若有 ❌未生效记录(失败次数 > 0),**必须先细读本台账该技能的「失败明细」,再执行该技能**,不得带旧坑上手。",
|
|
314
|
+
">",
|
|
315
|
+
"> 本台账由 dsh-skill-browser 插件参与维护:🤖 自动登记区由插件写入(人工内容永不改动);人工登记区按以下习惯维护。",
|
|
316
|
+
"",
|
|
317
|
+
"## 一、登记时机(遇下述任一情况,立即登记)",
|
|
318
|
+
"",
|
|
319
|
+
"1. **用户对话纠错**:用户说“不对 / 错了 / 你没按技能来 / 这技能根本没生效 / 你漏了某步”——按用户原话记为一次未生效。",
|
|
320
|
+
"2. **AI 主动发现**:技能被调用但未达到预期结果(报错、半途而废、产物缺失、与成功经验不符)——记为一次未生效。",
|
|
321
|
+
"3. **技能触发成功但执行出错**——记为一次未生效(note 区别:触发失败 vs 执行失败)。",
|
|
322
|
+
"4. **生效**:技能触发且达到预期、用户认可——记为一次生效(用于命中率统计)。",
|
|
323
|
+
"",
|
|
324
|
+
"## 二、登记口令(对话快捷方式)",
|
|
325
|
+
"",
|
|
326
|
+
"- 技能触发失败时,对 AI 说:**「触发失败,登记」**或**「登记失败:<技能名> <原因>」**。",
|
|
327
|
+
"- AI 收到口令后调用 `POST /dsh-skill-browser/ledger/report { skill, note, kind: \"manual-report\" }`,即写入本台账。",
|
|
328
|
+
"- 插件同时自动记录每次技能工具调用的结果(成功✅/加载失败❌),见下方 🤖 自动登记区。",
|
|
329
|
+
"",
|
|
330
|
+
"## 三、统计表(按失败次数降序,定期重排)",
|
|
331
|
+
"",
|
|
332
|
+
"> 失败次数最多者置顶,进入「着重强化队列」。强化动作 = 改进该技能 SKILL.md;完成后标记「已强化」并保留历史计数。",
|
|
333
|
+
"",
|
|
334
|
+
"| 排名 | 技能名 | 调用次数(估) | 生效✅ | 未生效❌ | 失败率(估) | 最近未生效日期 | 强化状态 |",
|
|
335
|
+
"|---|---|---|---|---|---|---|---|",
|
|
336
|
+
"",
|
|
337
|
+
"## 四、失败明细",
|
|
338
|
+
"",
|
|
339
|
+
"### 格式约定(人工登记请遵循)",
|
|
340
|
+
"",
|
|
341
|
+
"- 每个技能一段:`### 技能名:xxx(未生效 N 次)`,段内时间倒序条目。",
|
|
342
|
+
"",
|
|
343
|
+
MARK_BEGIN,
|
|
344
|
+
"",
|
|
345
|
+
"### 自动登记(dsh-skill-browser)",
|
|
346
|
+
"",
|
|
347
|
+
MARK_END,
|
|
348
|
+
""
|
|
349
|
+
].join("\n");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// 一批条目 → markdown 增量行
|
|
353
|
+
function buildAutoSection(batch) {
|
|
354
|
+
var lines = [];
|
|
355
|
+
for (var i = 0; i < batch.length; i++) {
|
|
356
|
+
var e = batch[i];
|
|
357
|
+
var mark = e.ok ? "✅ 生效" : "❌ 未生效";
|
|
358
|
+
var note = e.note || e.error || "";
|
|
359
|
+
var line = "- " + nowStampOf(e.ts) + " " + mark + " `" + e.skill + "`(" + e.kind + ")";
|
|
360
|
+
if (note) line += " — " + String(note).replace(/\r?\n/g, " ");
|
|
361
|
+
lines.push(line);
|
|
362
|
+
}
|
|
363
|
+
return lines.join("\n");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function nowStampOf(iso) {
|
|
367
|
+
try {
|
|
368
|
+
var d = new Date(iso);
|
|
369
|
+
if (isNaN(d.getTime())) return iso;
|
|
370
|
+
return todayStr(d) + " " + pad2(d.getHours()) + ":" + pad2(d.getMinutes());
|
|
371
|
+
} catch (e) { return iso; }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// 把增量合并进 md 的插件区块(幂等)
|
|
375
|
+
function mergeAutoSection(text, deltaText) {
|
|
376
|
+
if (text.indexOf(MARK_BEGIN) >= 0 && text.indexOf(MARK_END) >= 0) {
|
|
377
|
+
var pre = text.slice(0, text.indexOf(MARK_BEGIN) + MARK_BEGIN.length);
|
|
378
|
+
var mid = text.slice(text.indexOf(MARK_BEGIN) + MARK_BEGIN.length, text.indexOf(MARK_END));
|
|
379
|
+
var post = text.slice(text.indexOf(MARK_END));
|
|
380
|
+
return pre + mid.replace(/\s*$/, "\n\n") + deltaText + "\n\n" + post;
|
|
381
|
+
}
|
|
382
|
+
// 无区块(人工台账无标记):追加新区块到末尾
|
|
383
|
+
var sep = text.endsWith("\n") ? "\n" : "\n\n";
|
|
384
|
+
return text + sep + MARK_BEGIN + "\n\n### 自动登记(dsh-skill-browser)\n\n" + deltaText + "\n\n" + MARK_END + "\n";
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function extractErrorText(result) {
|
|
388
|
+
try {
|
|
389
|
+
if (!result) return "";
|
|
390
|
+
if (result.error && typeof result.error === "string") return result.error.slice(0, 300);
|
|
391
|
+
if (Array.isArray(result.content)) {
|
|
392
|
+
var texts = [];
|
|
393
|
+
for (var i = 0; i < result.content.length; i++) {
|
|
394
|
+
var b = result.content[i];
|
|
395
|
+
if (b && b.type === "text" && b.text) texts.push(String(b.text));
|
|
396
|
+
}
|
|
397
|
+
return texts.join(" | ").slice(0, 300);
|
|
398
|
+
}
|
|
399
|
+
} catch (e) {}
|
|
400
|
+
return "";
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function appendJsonl(state, entry) {
|
|
404
|
+
try {
|
|
405
|
+
if (!state.root) return;
|
|
406
|
+
var p = autoLogFilePath(state.root);
|
|
407
|
+
ensureDir(p);
|
|
408
|
+
appendFileSync(p, JSON.stringify(entry) + "\n", "utf8");
|
|
409
|
+
} catch (e) {
|
|
410
|
+
safeWarn(state, "appendJsonl failed: " + (e && e.message));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function safeWarn(state, msg) {
|
|
415
|
+
try { state.logger.warn("dsh-skill-browser: " + msg); } catch (e) {}
|
|
416
|
+
}
|
package/lib/pick-dir.ps1
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# dsh-skill-browser - folder picker (Windows).
|
|
2
|
+
# Spawned by lib/index.js with: powershell.exe -NoProfile -STA -WindowStyle Hidden -File pick-dir.ps1
|
|
3
|
+
# Writes the selected directory path to stdout (empty output = cancelled).
|
|
4
|
+
# Must stay ASCII-only to avoid any encoding pitfalls when spawned headless.
|
|
5
|
+
# NOTE: ShowDialog() intentionally takes NO owner argument - an invisible
|
|
6
|
+
# owner form anchors the modal dialog off-screen/behind and nothing appears.
|
|
7
|
+
Add-Type -AssemblyName System.Windows.Forms | Out-Null
|
|
8
|
+
$dialog = New-Object System.Windows.Forms.FolderBrowserDialog
|
|
9
|
+
$dialog.Description = 'Select the skill library root folder (skill library root)'
|
|
10
|
+
$dialog.ShowNewFolderButton = $false
|
|
11
|
+
$result = $dialog.ShowDialog()
|
|
12
|
+
if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
|
|
13
|
+
[Console]::Out.Write($dialog.SelectedPath)
|
|
14
|
+
}
|
package/lib/scan.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// dsh-skill-browser - skill directory scanning & categorization (host side).
|
|
2
|
+
// Pure functions, no DSH service dependencies, so it can be unit-tested
|
|
3
|
+
// standalone with `node test/run.js`.
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { CAT_RULES, SUB_RULES, CATS } from "./zh.js";
|
|
7
|
+
|
|
8
|
+
// Parse a flat YAML frontmatter (name/description/...) into a plain object.
|
|
9
|
+
// Handles folded scalars (`description: >` followed by indented lines) and
|
|
10
|
+
// strips surrounding quotes. Not a full YAML parser - frontmatter here is flat.
|
|
11
|
+
// Tolerates a UTF-8 BOM before the opening `---` (some editors write one).
|
|
12
|
+
export function parseFrontmatter(text) {
|
|
13
|
+
var out = {};
|
|
14
|
+
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
|
|
15
|
+
var m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
16
|
+
if (!m) return out;
|
|
17
|
+
var lastKey = null;
|
|
18
|
+
var lines = m[1].split(/\r?\n/);
|
|
19
|
+
for (var i = 0; i < lines.length; i++) {
|
|
20
|
+
var line = lines[i];
|
|
21
|
+
if (/^\s*#/.test(line)) continue;
|
|
22
|
+
var kv = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
|
|
23
|
+
if (kv) {
|
|
24
|
+
lastKey = kv[1];
|
|
25
|
+
var v = kv[2].trim();
|
|
26
|
+
if (/^[>|][+-]?$/.test(v)) v = "";
|
|
27
|
+
v = v.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1").trim();
|
|
28
|
+
out[lastKey] = v;
|
|
29
|
+
} else if (lastKey && /^\s+\S/.test(line)) {
|
|
30
|
+
out[lastKey] = (out[lastKey] ? out[lastKey] + " " : "") + line.trim();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Categorize a skill directory name: { id, sub }.
|
|
37
|
+
export function categorize(dirName) {
|
|
38
|
+
var cat = "other";
|
|
39
|
+
for (var i = 0; i < CAT_RULES.length; i++) {
|
|
40
|
+
if (CAT_RULES[i].re.test(dirName)) { cat = CAT_RULES[i].id; break; }
|
|
41
|
+
}
|
|
42
|
+
var sub = "";
|
|
43
|
+
var rules = SUB_RULES[cat];
|
|
44
|
+
if (rules) {
|
|
45
|
+
for (var j = 0; j < rules.length; j++) {
|
|
46
|
+
if (rules[j].re.test(dirName)) { sub = rules[j].id; break; }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { id: cat, sub: sub };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Scan one skill root directory. Returns an array of skill summaries,
|
|
53
|
+
// sorted by directory name. Skips non-directories, `_`-prefixed and
|
|
54
|
+
// `.`-prefixed entries, and directories without a SKILL.md.
|
|
55
|
+
export function scanSkills(root, zhDict) {
|
|
56
|
+
var out = [];
|
|
57
|
+
if (!root || !existsSync(root)) return out;
|
|
58
|
+
var entries = readdirSync(root, { withFileTypes: true });
|
|
59
|
+
for (var i = 0; i < entries.length; i++) {
|
|
60
|
+
var e = entries[i];
|
|
61
|
+
if (!e.isDirectory()) continue;
|
|
62
|
+
if (e.name.charAt(0) === "_" || e.name.charAt(0) === ".") continue;
|
|
63
|
+
var skillFile = join(root, e.name, "SKILL.md");
|
|
64
|
+
if (!existsSync(skillFile)) continue;
|
|
65
|
+
var fm = {};
|
|
66
|
+
try { fm = parseFrontmatter(readFileSync(skillFile, "utf8")); } catch (err) { fm = {}; }
|
|
67
|
+
var cat = categorize(e.name);
|
|
68
|
+
out.push({
|
|
69
|
+
dir: e.name,
|
|
70
|
+
name: fm.name || e.name,
|
|
71
|
+
desc: String(fm.description || "").trim(),
|
|
72
|
+
zh: (zhDict && zhDict[e.name]) || "",
|
|
73
|
+
cat: cat.id,
|
|
74
|
+
sub: cat.sub,
|
|
75
|
+
mtime: safeMtime(skillFile)
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
out.sort(function (a, b) { return a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0; });
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function safeMtime(p) {
|
|
83
|
+
try { return statSync(p).mtimeMs; } catch (e) { return 0; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Category metadata shipped to the client (ids/labels/icons/sub-chips).
|
|
87
|
+
export function categoriesMeta() {
|
|
88
|
+
return CATS;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Read the full SKILL.md body for the detail view. `dir` is validated
|
|
92
|
+
// against the safe-name charset to prevent path traversal.
|
|
93
|
+
export function readSkillBody(root, dir) {
|
|
94
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(dir) || dir.indexOf("..") >= 0) return null;
|
|
95
|
+
var p = join(root, dir, "SKILL.md");
|
|
96
|
+
if (!existsSync(p)) return null;
|
|
97
|
+
return readFileSync(p, "utf8");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── 技能生效/失效台账 ──────────────────────────────
|
|
101
|
+
// 台账发现(共享版,不限定死路径):
|
|
102
|
+
// 1) 约定路径优先:<root>/skill-master/references/技能生效-失效台账.md
|
|
103
|
+
// 2) 全库按文件名关键词搜索(失效台账/失败台账/生效-失效…,深度≤3)
|
|
104
|
+
// 统计表结构:## 二、… ## 三、之间的 markdown 表格(排名|技能名|调用次数|生效|未生效|失败率|最近日期|强化状态)
|
|
105
|
+
// 失败明细:### 技能名:xxx(未生效 N 次) + 时间倒序条目
|
|
106
|
+
var LEDGER_REL = ["skill-master", "references", "技能生效-失效台账.md"];
|
|
107
|
+
var LEDGER_KEYWORDS = ["失效台账", "失败台账", "生效-失效台账", "生效失效台账", "生效-失效"];
|
|
108
|
+
|
|
109
|
+
export function ledgerDefaultPath(root) {
|
|
110
|
+
return LEDGER_REL.reduce(function (acc, seg) { return join(acc, seg); }, root);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function walkForLedger(dir, depth, out) {
|
|
114
|
+
if (depth > 3 || out.length > 50) return;
|
|
115
|
+
var entries;
|
|
116
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
|
|
117
|
+
for (var i = 0; i < entries.length; i++) {
|
|
118
|
+
var e = entries[i];
|
|
119
|
+
if (e.name.charAt(0) === "_" || e.name.charAt(0) === ".") continue;
|
|
120
|
+
var full = join(dir, e.name);
|
|
121
|
+
if (e.isDirectory()) {
|
|
122
|
+
walkForLedger(full, depth + 1, out);
|
|
123
|
+
} else if (/\.md$/i.test(e.name)) {
|
|
124
|
+
for (var k = 0; k < LEDGER_KEYWORDS.length; k++) {
|
|
125
|
+
if (e.name.indexOf(LEDGER_KEYWORDS[k]) >= 0) { out.push(full); break; }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 搜索技能库中的台账文件:约定路径优先;否则按文件名关键词全库搜索,
|
|
132
|
+
// 多命中取目录最浅、字典序第一(确定性)。找不到返回 null。
|
|
133
|
+
export function findLedgerFile(root) {
|
|
134
|
+
if (!root || !existsSync(root)) return null;
|
|
135
|
+
var def = ledgerDefaultPath(root);
|
|
136
|
+
if (existsSync(def)) return def;
|
|
137
|
+
var hits = [];
|
|
138
|
+
walkForLedger(root, 0, hits);
|
|
139
|
+
if (!hits.length) return null;
|
|
140
|
+
hits.sort(function (a, b) {
|
|
141
|
+
var da = a.slice(root.length).split(/[\\/]/).length;
|
|
142
|
+
var db = b.slice(root.length).split(/[\\/]/).length;
|
|
143
|
+
if (da !== db) return da - db;
|
|
144
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
145
|
+
});
|
|
146
|
+
return hits[0];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function readLedgerAtPath(p) {
|
|
150
|
+
if (!p || !existsSync(p)) return null;
|
|
151
|
+
try { return readFileSync(p, "utf8"); } catch (e) { return null; }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function splitMdRow(line) {
|
|
155
|
+
var parts = line.split("|");
|
|
156
|
+
// 去掉首尾空段并 trim
|
|
157
|
+
var out = [];
|
|
158
|
+
for (var i = 1; i < parts.length - 1; i++) out.push(parts[i].trim());
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseFailCount(s) {
|
|
163
|
+
var m = /\*\*(\d+)\*\*|^(\d+)$/.exec(String(s || "").trim());
|
|
164
|
+
return m ? parseInt(m[1] || m[2], 10) : 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 解析统计表 → [{ rank, skill, calls, fails, rate, lastDate, status }]
|
|
168
|
+
export function parseLedgerTable(text) {
|
|
169
|
+
var rows = [];
|
|
170
|
+
var lines = String(text || "").split(/\r?\n/);
|
|
171
|
+
var inTable = false;
|
|
172
|
+
for (var i = 0; i < lines.length; i++) {
|
|
173
|
+
var line = lines[i];
|
|
174
|
+
if (/^##\s*二、/.test(line)) { inTable = true; continue; }
|
|
175
|
+
if (/^##\s*三、/.test(line)) { inTable = false; break; }
|
|
176
|
+
if (!inTable || !/^\|/.test(line)) continue;
|
|
177
|
+
var c = splitMdRow(line);
|
|
178
|
+
if (c.length < 8) continue;
|
|
179
|
+
if (/技能名|排名/.test(c[1]) || /^[: -]+$/.test(c[1])) continue; // 表头/分隔行
|
|
180
|
+
rows.push({
|
|
181
|
+
rank: c[0],
|
|
182
|
+
skill: c[1],
|
|
183
|
+
calls: c[2],
|
|
184
|
+
ok: c[3],
|
|
185
|
+
fails: parseFailCount(c[4]),
|
|
186
|
+
rate: c[5],
|
|
187
|
+
lastDate: c[6],
|
|
188
|
+
status: c[7]
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return rows;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 提取某技能的失败明细段落(### 技能名:xxx ... 到下一个 ### / ## 为止)
|
|
195
|
+
export function parseLedgerDetail(text, skill) {
|
|
196
|
+
var lines = String(text || "").split(/\r?\n/);
|
|
197
|
+
var out = [];
|
|
198
|
+
var capture = false;
|
|
199
|
+
for (var i = 0; i < lines.length; i++) {
|
|
200
|
+
var line = lines[i];
|
|
201
|
+
if (/^###\s*技能名:/.test(line)) {
|
|
202
|
+
if (capture) break; // 已捕获目标段落,遇到下一条目即止
|
|
203
|
+
capture = line.indexOf(skill) >= 0;
|
|
204
|
+
if (capture) out.push(line);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (capture) {
|
|
208
|
+
if (/^##\s/.test(line)) break;
|
|
209
|
+
out.push(line);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return capture ? out.join("\n") : "";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 读取台账文件;不存在返回 null
|
|
216
|
+
export function readLedgerFile(root) {
|
|
217
|
+
var p = LEDGER_REL.reduce(function (acc, seg) { return join(acc, seg); }, root);
|
|
218
|
+
if (!existsSync(p)) return null;
|
|
219
|
+
try { return readFileSync(p, "utf8"); } catch (e) { return null; }
|
|
220
|
+
}
|