ygc-dsh-sync 1.2.2 → 1.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.
@@ -0,0 +1,1115 @@
1
+ /**
2
+ * 技能学习:把「别的机器有同名的技能,但内容与本机不一样」这件事,
3
+ * 从「覆盖 / 不覆盖」的二选一,升级成一次**有人负责的评估**。
4
+ *
5
+ * 为什么要有这一层:
6
+ * - 技能的价值不在正文长度,而在它有没有把某类工况、某个坑写进去。
7
+ * 两台机器各自踩过不同的坑,简单覆盖一定丢掉一边。
8
+ * - 但也不能「差异一律等人工」:那样台账会积一堆待办,最后没人看。
9
+ * 所以分两段:机械判定能定的直接给出结论,只有真正需要判断的才叫模型。
10
+ *
11
+ * 判定顺序(便宜的先跑):
12
+ * 1. 指纹相同 → same,什么都不做
13
+ * 2. 一侧是另一侧的严格超集(章节 + 行数都在包含关系里)
14
+ * → 直接取超集那一侧,不叫模型
15
+ * 3. 双方各有对方没有的章节 → 进模型评估(可能是合并 / 补全 / 删除)
16
+ * 4. 模型不可用 → 只出确定性结论 + 差异清单,标 manual
17
+ *
18
+ * 优化类型(用户定的,不限于此):
19
+ * merge 合并精炼:两个版本各有一半,合成一份更准的(允许比任一侧短)
20
+ * condense 精炼冗余:内容重复、篇幅虚胖,压短同时保住可执行层
21
+ * enrich 补全工况:把对方踩过的工况 / 触发条件补进来
22
+ * pitfalls 增加踩坑经验:把对方的 Pitfalls 并进本机
23
+ * revise 修订错误技能:**改正**会误导模型的错误做法(错命令 / 过时 API /
24
+ * 引用不存在的资源),产出一份修正后的正文;整条作废时标
25
+ * `deprecate`,正文换成停用声明,不误删别人的历史记录
26
+ * adopt 采用对方版本:本机版本明显更差
27
+ * keep 保留本机:对方版本更差,只记录理由
28
+ *
29
+ * 落点:所有评估结果写进 `插件库/<本机>/skills/SKILL-LEARNINGS.{json,md}`,
30
+ * 草稿落在 `插件库/<本机>/skills/learn-drafts/<技能名>/SKILL.md`。
31
+ * 别的机器读那份说明文件就能看到「谁、因为什么、怎么改的」,不必重跑一次评估。
32
+ *
33
+ * **写边界(硬)**:本模块只写本机那一格(`插件库/<本机标识>/skills/**`)
34
+ * 与本机技能根(`~/.agents/skills/**`)。别人的特征文件夹**全程只读**:
35
+ * 不写、不改、不删、不移动。任何「要不要覆盖对方」的念头都在这里被排除,
36
+ * 对方的版本永远是输入,不是输出。
37
+ */
38
+ import fs from 'node:fs';
39
+ import path from 'node:path';
40
+ import crypto from 'node:crypto';
41
+
42
+ import {
43
+ DRAFTS_DIR,
44
+ LEARNINGS_INDEX,
45
+ LEARNINGS_INDEX_MD,
46
+ SKILLS_DIR,
47
+ mySkillsRoot,
48
+ readSkillMeta,
49
+ recordOrigin,
50
+ } from './skillshare.js';
51
+ import { assertOwnFeaturePath, featuresDir } from './plugins.js';
52
+ import { diffSummary, parseFrontMatter, walkFiles } from './skills.js';
53
+
54
+ /** 一条学习单最多允许的模型调用次数(含一次格式纠正重试)。 */
55
+ const MAX_MODEL_CALLS = 2;
56
+ /** 送进模型的单侧正文上限:超了截断并明确标注,不假装看全了。 */
57
+ const MAX_SIDE_CHARS = 24000;
58
+ /** 合并结果的硬上限行数与相对上限。 */
59
+ const MAX_MERGED_LINES = 900;
60
+ /** 认定「冗余技能」的描述相似度门槛。 */
61
+ const REDUNDANT_SIMILARITY = 0.42;
62
+
63
+ export const LEARN_LABEL = {
64
+ merge: '合并精炼',
65
+ condense: '精炼冗余',
66
+ enrich: '补全工况',
67
+ pitfalls: '增加踩坑经验',
68
+ revise: '修订错误技能',
69
+ deprecate: '停用(作废)',
70
+ adopt: '采用对方版本',
71
+ keep: '保留本机版本',
72
+ manual: '等人工判断',
73
+ };
74
+
75
+ /* -------------------------------------------------------------- 小工具 */
76
+
77
+ function readText(file) {
78
+ try {
79
+ return fs.readFileSync(file, 'utf8');
80
+ } catch {
81
+ return '';
82
+ }
83
+ }
84
+
85
+ function readJson(file) {
86
+ try {
87
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }
92
+
93
+ function writeJson(file, value) {
94
+ fs.mkdirSync(path.dirname(file), { recursive: true });
95
+ const tmp = `${file}.${process.pid}.tmp`;
96
+ fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
97
+ fs.renameSync(tmp, file);
98
+ }
99
+
100
+ function sha256(text) {
101
+ return crypto.createHash('sha256').update(String(text), 'utf8').digest('hex');
102
+ }
103
+
104
+ /** 取 H2/H3 标题集合,用于判断章节包含关系。 */
105
+ export function headingsOf(text) {
106
+ const out = new Set();
107
+ for (const m of String(text || '').matchAll(/^#{2,3}\s+(.+)$/gm)) {
108
+ out.add(m[1].replace(/\s+/g, ' ').trim());
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** 机械特征:给判定与模型都用同一份事实。 */
114
+ export function profileOf(text, dir) {
115
+ const t = String(text || '');
116
+ const lines = t ? t.split(/\r?\n/).length : 0;
117
+ const heads = headingsOf(t);
118
+ const fm = dir ? parseFrontMatter(path.join(dir, 'SKILL.md')) : {};
119
+ const fences = (t.match(/^```/gm) || []).length;
120
+ const codeLines = (t.match(/^\s*(\$\s|\w+\s+--|node |python |pnpm |npm |git )/gm) || []).length;
121
+ const numbers = (t.match(/\b\d+(\.\d+)?\s*(ms|s|秒|分钟|行|字|%|MB|GB|pt|px)\b/g) || []).length;
122
+ return {
123
+ lines,
124
+ chars: t.length,
125
+ bytes: Buffer.byteLength(t, 'utf8'),
126
+ headings: [...heads].sort(),
127
+ fences,
128
+ codeLines,
129
+ numbers,
130
+ description: fm.description || '',
131
+ version: fm.version || '',
132
+ hasPitfalls: /^##+\s*(Pitfalls|常见错误)/m.test(t),
133
+ hasVerification: /^##+\s*(Verification|验收)/m.test(t),
134
+ files: dir ? walkFiles(dir).length : 0,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * 机械判「一侧是不是另一侧的超集」。
140
+ *
141
+ * 只看两件事:章节集合的包含关系,与行数不短于对方。两条都成立才算,
142
+ * 因为「章节全有但删掉了一大段」不是超集,那是精炼过。
143
+ *
144
+ * @returns {'master'|'peer'|'neither'}
145
+ */
146
+ export function supersetOf(aText, bText) {
147
+ const a = headingsOf(aText);
148
+ const b = headingsOf(bText);
149
+ const aLines = aText ? aText.split(/\r?\n/).length : 0;
150
+ const bLines = bText ? bText.split(/\r?\n/).length : 0;
151
+ const aHasAllB = [...b].every((h) => a.has(h));
152
+ const bHasAllA = [...a].every((h) => b.has(h));
153
+ if (aHasAllB && !bHasAllA && aLines >= bLines) return 'master';
154
+ if (bHasAllA && !aHasAllB && bLines >= aLines) return 'peer';
155
+ return 'neither';
156
+ }
157
+
158
+ /* ------------------------------------------------------- 冗余技能发现 */
159
+
160
+ const STOP_WORDS = new Set([
161
+ 'the','and','for','with','that','this','from','when','use','used','user','users','skill','skills',
162
+ 'dsh','本机','用户','技能','使用','以及','可以','需要','这个','那个','时候','问题','进行','一个',
163
+ ]);
164
+
165
+ function tokensOf(text) {
166
+ const raw = String(text || '')
167
+ .toLowerCase()
168
+ .split(/[^a-z0-9\u4e00-\u9fa5-]+/)
169
+ .filter(Boolean);
170
+ const out = new Set();
171
+ for (const t of raw) {
172
+ if (t.length < 2 || STOP_WORDS.has(t)) continue;
173
+ out.add(t);
174
+ }
175
+ return out;
176
+ }
177
+
178
+ function jaccard(a, b) {
179
+ if (!a.size || !b.size) return 0;
180
+ let inter = 0;
181
+ for (const x of a) if (b.has(x)) inter += 1;
182
+ return inter / (a.size + b.size - inter);
183
+ }
184
+
185
+ /**
186
+ * 找出「像是同一件事」的多个技能(合并精炼的候选)。
187
+ *
188
+ * 判据:描述词的 Jaccard 相似度 >= 门槛,或去掉前缀后名字相同
189
+ * (`comfyui-a-b` 与 `comfyui-a-c` 这种不算,必须去掉 `-` 后前两段一致)。
190
+ *
191
+ * 只产出候选,不做任何合并:合并是不可逆的,必须有人点头。
192
+ */
193
+ export function findRedundantPairs(localSkills, { threshold = REDUNDANT_SIMILARITY } = {}) {
194
+ const list = [...localSkills.values()].map((s) => ({
195
+ name: s.name,
196
+ dirName: s.dirName,
197
+ tokens: tokensOf(`${s.description} ${s.name.replace(/-/g, ' ')}`),
198
+ desc: s.description || '',
199
+ lines: s.text ? s.text.split(/\r?\n/).length : 0,
200
+ }));
201
+ const pairs = [];
202
+ for (let i = 0; i < list.length; i += 1) {
203
+ for (let j = i + 1; j < list.length; j += 1) {
204
+ const sim = jaccard(list[i].tokens, list[j].tokens);
205
+ if (sim < threshold) continue;
206
+ pairs.push({
207
+ a: list[i].name,
208
+ b: list[j].name,
209
+ similarity: Number(sim.toFixed(3)),
210
+ lines: [list[i].lines, list[j].lines],
211
+ action: 'merge',
212
+ reason: `描述词重合理度 ${sim.toFixed(2)}(门槛 ${threshold}),像同一件事的两个版本`,
213
+ });
214
+ }
215
+ }
216
+ return pairs.sort((x, y) => y.similarity - x.similarity).slice(0, 20);
217
+ }
218
+
219
+ /* ------------------------------------------------------------ 提示词 */
220
+
221
+ /**
222
+ * 评估用的 system。
223
+ *
224
+ * 与 mnemon 起草段同一条教训:**不要把正文塞进 JSON 字符串**。正文里有代码块、
225
+ * 引号、反斜杠,转义必坏。所以拆成两块——短结论用 json 围栏,长正文用 markdown 围栏。
226
+ */
227
+ export const LEARN_SYSTEM = [
228
+ '你是技能(SKILL.md)评审。你的任务不是夸,而是判断两份同名技能该不该合。',
229
+ '',
230
+ '输出必须分成两块,顺序固定:',
231
+ '第一块:一个 ```json 围栏,只含这些键:',
232
+ '{',
233
+ ' "action": "merge|condense|enrich|pitfalls|revise|deprecate|adopt|keep",',
234
+ ' "confidence": 0.0 到 1.0,',
235
+ ' "base": "mine|peer",',
236
+ ' "reason": "不超过 200 字,说清判据:哪一侧多了什么、为什么这样处理",',
237
+ ' "evidence": ["对方版本里本机没有的具体一条", "..."],',
238
+ ' "dropped": ["合并后会删掉的章节或做法", "..."]',
239
+ '}',
240
+ '第二块:当 action 是 merge / condense / enrich / pitfalls / revise / adopt 时,',
241
+ '再给一个 ```markdown 围栏,里面是可直接落盘的 SKILL.md 全文;',
242
+ 'action 是 keep 或 deprecate 时不输出第二块。',
243
+ '',
244
+ '判定规则(按顺序套,不许跳):',
245
+ '1. 一侧做的事会**误导**模型(错误命令、过时 API、把不存在的文件当资源)→ 错的必须被改正,不能原样保留:',
246
+ ' 本机一侧有错 → action=revise,正文写成改正后的版本(其它正确内容保留,只替换掉错的那部分);',
247
+ ' 错只在对方一侧 → action=keep,并在 reason 与 evidence 写清哪条错、应该怎么改,供对方修订自己的副本。',
248
+ '2. 双方各有对方没有的**工况/前提条件**(什么情况下适用、什么情况下不要用)→ enrich 或 merge。',
249
+ '3. 双方各有对方没有的**踩坑经验**(Pitfalls)→ pitfalls 或 merge,把两边都保住。',
250
+ '4. 内容大量重复、正文虚胖、可执行层(命令/数值/模板)没增加 → condense。',
251
+ '5. 只有「改也没意义、整条技能本就不该存在」才 action=deprecate,reason 写清为什么,不要输出正文;',
252
+ ' 能改就先改:能定位到错在哪就一律 revise,不要用 deprecate 回避修订。',
253
+ '6. 只多出无关紧要的措辞 → keep。',
254
+ '',
255
+ '合并正文的硬要求:',
256
+ '- 保留 frontmatter,`name:` 与给定技能名逐字相同;description 60 到 400 字,要写进两边新增的触发场景。',
257
+ '- 必须有 `## Pitfalls`(或 `## 常见错误`)与 `## Verification`(或 `## 验收`)两节,两边原有的条目一条都不许丢。',
258
+ '- 不许出现长破折号「—」「–」,用逗号或冒号。',
259
+ '- 代码围栏成对闭合。',
260
+ '- 行数不得超过两版之和;合并后不许比更长的那一版多出 20%。',
261
+ '- 只写这一个文件,不要引用不存在的 references/ 或 scripts/ 文件;两边原本就有的资源目录可以继续引用。',
262
+ ].join('\n');
263
+
264
+ /**
265
+ * 组评估提示词。
266
+ *
267
+ * @param {{name:string, mineText:string, peerText:string, mineProfile:object,
268
+ * peerProfile:object, peerFeature:string, peerMachine:string,
269
+ * peerAssets:object, mineAssets:object, redundant?:Array}} input
270
+ */
271
+ export function buildLearnPrompt(input) {
272
+ const tr = (s, cap = MAX_SIDE_CHARS) => {
273
+ const t = String(s || '');
274
+ if (t.length <= cap) return { text: t, truncated: false };
275
+ return { text: `${t.slice(0, cap)}\n\n[以上被截断:原文 ${t.length} 字,只给了前 ${cap} 字]`, truncated: true };
276
+ };
277
+ const mine = tr(input.mineText);
278
+ const peer = tr(input.peerText);
279
+ const line = (label, v) => `${label}:${v}`;
280
+ const L = [];
281
+ L.push(`技能名(合并正文的 frontmatter name 必须逐字写成这个):${input.name}`);
282
+ L.push('');
283
+ L.push('## 机械比对(这两行是真实现算,不要推翻,只能解释)');
284
+ L.push(line('左(本机 mine)', ``));
285
+ L.push(` 行 ${input.mineProfile.lines} · 字 ${input.mineProfile.chars} · 命令行 ${input.mineProfile.codeLines} · 数值 ${input.mineProfile.numbers} · Pitfalls ${input.mineProfile.hasPitfalls ? '有' : '无'} · Verification ${input.mineProfile.hasVerification ? '有' : '无'}`);
286
+ L.push(` 章节:${input.mineProfile.headings.join(' / ') || '(无)'}`);
287
+ L.push(` 资产:${input.mineAssets.scripts ? 'scripts ' : ''}${input.mineAssets.references ? 'references ' : ''}${input.mineAssets.evals ? 'evals' : ''}${input.mineAssets.scripts || input.mineAssets.references || input.mineAssets.evals ? '' : '(无)'}`);
288
+ L.push(line('右(对方 peer)', `${input.peerFeature}${input.peerMachine ? ` · ${input.peerMachine}` : ''}`));
289
+ L.push(` 行 ${input.peerProfile.lines} · 字 ${input.peerProfile.chars} · 命令行 ${input.peerProfile.codeLines} · 数值 ${input.peerProfile.numbers} · Pitfalls ${input.peerProfile.hasPitfalls ? '有' : '无'} · Verification ${input.peerProfile.hasVerification ? '有' : '无'}`);
290
+ L.push(` 章节:${input.peerProfile.headings.join(' / ') || '(无)'}`);
291
+ L.push(` 资产:${input.peerAssets.scripts ? 'scripts ' : ''}${input.peerAssets.references ? 'references ' : ''}${input.peerAssets.evals ? 'evals' : ''}${input.peerAssets.scripts || input.peerAssets.references || input.peerAssets.evals ? '' : '(无)'}`);
292
+ const onlyMine = input.mineProfile.headings.filter((h) => !input.peerProfile.headings.includes(h));
293
+ const onlyPeer = input.peerProfile.headings.filter((h) => !input.mineProfile.headings.includes(h));
294
+ L.push(` 只有本机有的章节:${onlyMine.join(' / ') || '(无)'}`);
295
+ L.push(` 只有对方有的章节:${onlyPeer.join(' / ') || '(无)'}`);
296
+ L.push(` 文本差异摘要:${input.diff || '(无)'}`);
297
+ if (Array.isArray(input.redundant) && input.redundant.length) {
298
+ L.push('');
299
+ L.push('## 与本机其他技能的重合(合并精炼的候选,酌情在 reason 里提一句)');
300
+ for (const r of input.redundant.slice(0, 5)) {
301
+ L.push(`- \`${r.a}\` 与 \`${r.b}\` 描述重合理度 ${r.similarity}:${r.reason}`);
302
+ }
303
+ }
304
+ L.push('');
305
+ L.push('## 左:本机(mine)SKILL.md 原文');
306
+ L.push('```markdown');
307
+ L.push(mine.text || '(空)');
308
+ L.push('```');
309
+ L.push('');
310
+ L.push('## 右:对方(peer)SKILL.md 原文');
311
+ L.push('```markdown');
312
+ L.push(peer.text || '(空)');
313
+ L.push('```');
314
+ L.push('');
315
+ L.push('现在给出结论。先 json 围栏,需要给正文时再跟一个 markdown 围栏。');
316
+ return L.join('\n');
317
+ }
318
+
319
+ /* ------------------------------------------------------------ 解析 */
320
+
321
+ /**
322
+ * 从模型回复里切出「可落盘的 SKILL.md 正文」。
323
+ *
324
+ * **不能用「第一个 ``` 到下一个 ```」那种非贪婪匹配**:SKILL.md 正文里本来就有
325
+ * 代码块(```bash / ```json),非贪婪一碰到内层围栏就截断,正文只剩半个文件,
326
+ * 后面的机械审核会以「缺 Pitfalls / 缺 Verification」拒掉——本机实测踩过这个坑。
327
+ *
328
+ * 正确做法:找到起始围栏,取到**最后一个**闭合围栏(或整段结尾),把中间的
329
+ * 内层围栏原样保留。
330
+ *
331
+ * 兼容三种形态:
332
+ * 1. ```markdown 围栏,内容以 `---` 起手
333
+ * 2. 任意语言标记的围栏,内容含 `name:`
334
+ * 3. 整段回复本身就是 SKILL.md(没有围栏)
335
+ *
336
+ * @param {string} raw 模型原始回复
337
+ * @returns {string} 正文;切不出来返回空串
338
+ */
339
+ export function extractSkillMarkdown(raw) {
340
+ const text = String(raw || '');
341
+ if (!text.trim()) return '';
342
+
343
+ // 1. 所有可能的起始围栏位置(带语言标记或不带)
344
+ const openRe = /^[ \t]*```[A-Za-z0-9_-]*[ \t]*\r?\n/gm;
345
+ const starts = [];
346
+ for (const m of text.matchAll(openRe)) starts.push(m.index + m[0].length);
347
+
348
+ for (const from of starts) {
349
+ const rest = text.slice(from);
350
+ if (!/^\s*---\s*\r?\n/.test(rest)) continue;
351
+ // 取到最后一个闭合围栏(行首的 ```,其后到行尾没有别的内容)
352
+ const closeRe = /^[ \t]*```[ \t]*$/gm;
353
+ let last = -1;
354
+ for (const c of rest.matchAll(closeRe)) last = c.index;
355
+ const body = (last >= 0 ? rest.slice(0, last) : rest).replace(/\s+$/, '');
356
+ if (/^name\s*:/m.test(body)) return body;
357
+ }
358
+
359
+ // 2. 整段就是正文
360
+ if (/^\s*---\s*\r?\n/.test(text) && /^name\s*:/m.test(text)) return text.trim();
361
+ return '';
362
+ }
363
+
364
+ const ACTIONS = new Set(['merge', 'condense', 'enrich', 'pitfalls', 'revise', 'deprecate', 'adopt', 'keep']);
365
+
366
+ /**
367
+ * 从模型回复里取出结论 + 正文。
368
+ *
369
+ * 兼容:
370
+ * - 有 json 围栏 → 取结论;没 json 围栏但整段是 JSON → 也算
371
+ * - markdown 围栏里以 `---` 开头的 → 正文
372
+ * - 正文里带了 json(模型把结论塞进正文前后的 prose)→ 用 `name:` + `## Pitfalls` 兜底判
373
+ */
374
+ export function parseLearnReply(text) {
375
+ const raw = String(text || '');
376
+ const out = { action: '', confidence: 0, base: '', reason: '', evidence: [], dropped: [], content: '', form: '' };
377
+
378
+ const jsonBlocks = [...raw.matchAll(/```(?:json|JSON)\s*\r?\n([\s\S]*?)```/g)].map((m) => m[1].trim());
379
+ const candidates = jsonBlocks.length ? jsonBlocks : [];
380
+ // 没有围栏但整段以 { 开头也试一次
381
+ const whole = raw.trim();
382
+ if (!candidates.length && whole.startsWith('{')) candidates.push(whole);
383
+
384
+ for (const c of candidates) {
385
+ try {
386
+ const v = JSON.parse(c);
387
+ if (!v || typeof v !== 'object') continue;
388
+ if (typeof v.action === 'string' && ACTIONS.has(v.action.trim())) out.action = v.action.trim();
389
+ const conf = Number(v.confidence);
390
+ if (Number.isFinite(conf)) out.confidence = Math.max(0, Math.min(1, conf));
391
+ if (v.base === 'mine' || v.base === 'peer') out.base = v.base;
392
+ if (typeof v.reason === 'string') out.reason = v.reason.trim().slice(0, 1200);
393
+ if (Array.isArray(v.evidence)) out.evidence = v.evidence.filter((x) => typeof x === 'string').slice(0, 20);
394
+ if (Array.isArray(v.dropped)) out.dropped = v.dropped.filter((x) => typeof x === 'string').slice(0, 20);
395
+ out.form = 'json';
396
+ break;
397
+ } catch {
398
+ /* 坏 JSON 继续找下一个 */
399
+ }
400
+ }
401
+
402
+ // 正文:走共用的切分器(内层代码块不会被当成结束围栏)
403
+ out.content = extractSkillMarkdown(raw);
404
+ if (out.content) out.form = out.form ? `${out.form}+markdown` : 'markdown';
405
+ return out;
406
+ }
407
+
408
+ /* ------------------------------------------------------- 合并结果审核 */
409
+
410
+ /**
411
+ * 审核一份合并后的 SKILL.md。
412
+ *
413
+ * 与 mnemon 的 auditDraftFor 不同:这里**允许比原件短**(精炼是合法动作),
414
+ * 但不允许丢可执行层、不允许丢两侧都有的 Pitfalls 条目、不允许超出硬上限。
415
+ *
416
+ * @param {{text:string, name:string, mineText:string, peerText:string, action:string}} input
417
+ */
418
+ export function auditLearning({ text, name, mineText, peerText, action, allowShorter = false }) {
419
+ const problems = [];
420
+ const info = {};
421
+ if (typeof text !== 'string' || !text.trim()) {
422
+ return { ok: false, problems: ['合并正文为空'], info };
423
+ }
424
+ info.lines = text.split(/\r?\n/).length;
425
+ info.chars = text.length;
426
+
427
+ let body = text;
428
+ if (body.charCodeAt(0) === 0xfeff) body = body.slice(1);
429
+ const fm = body.match(/^---\r?\n([\s\S]*?)\r?\n---/);
430
+ if (!fm) problems.push('没有 frontmatter');
431
+ else {
432
+ const kv = {};
433
+ for (const l of fm[1].split(/\r?\n/)) {
434
+ const m = l.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
435
+ if (!m) continue;
436
+ let v = m[2].trim();
437
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
438
+ kv[m[1]] = v;
439
+ }
440
+ if (kv.name !== name) problems.push(`frontmatter name=${kv.name || '(空)'} 与技能名 ${name} 不一致`);
441
+ const desc = kv.description || '';
442
+ info.description = desc.length;
443
+ if (!desc) problems.push('缺 description');
444
+ else if (desc.length < 60) problems.push(`description 只有 ${desc.length} 字,触发不了`);
445
+ else if (desc.length > 400) problems.push(`description ${desc.length} 字,超过 400`);
446
+ }
447
+
448
+ if (!/^##+\s*(Pitfalls|常见错误)/m.test(text)) problems.push('缺 Pitfalls / 常见错误 一节');
449
+ if (!/^##+\s*(Verification|验收)/m.test(text)) problems.push('缺 Verification / 验收 一节');
450
+
451
+ const fences = (text.match(/^```/gm) || []).length;
452
+ if (fences % 2 !== 0) problems.push(`代码围栏 ${fences} 个,不成对`);
453
+
454
+ const dashes = (text.match(/[—–]/g) || []).length;
455
+ const srcDashes = ((mineText || '').match(/[—–]/g) || []).length + ((peerText || '').match(/[—–]/g) || []).length;
456
+ if (dashes > srcDashes) problems.push(`新增长破折号 ${dashes} 处(两侧合计 ${srcDashes} 处)`);
457
+
458
+ const capLines = Math.max(
459
+ 120,
460
+ Math.min(
461
+ MAX_MERGED_LINES,
462
+ Math.ceil(Math.max(info.lines, ((mineText || '').split(/\r?\n/).length + (peerText || '').split(/\r?\n/).length) * 1.2)),
463
+ ),
464
+ );
465
+ info.capLines = capLines;
466
+ if (info.lines > capLines) problems.push(`${info.lines} 行,超过上限 ${capLines}`);
467
+
468
+ // 可执行层:命令行 + 数值,不许比两侧任何一侧都少。
469
+ //
470
+ // revise 与 condense 是「改正 / 精简」,删掉的恰恰是错的那部分,所以这两种动作
471
+ // 只要求「要么保住可执行层,要么确实变短了」——两条都不满足才判失败。
472
+ const prof = profileOf(text, null);
473
+ info.codeLines = prof.codeLines;
474
+ info.numbers = prof.numbers;
475
+ const mineP = profileOf(mineText || '', null);
476
+ const peerP = profileOf(peerText || '', null);
477
+ const bestCode = Math.max(mineP.codeLines, peerP.codeLines);
478
+ const bestNum = Math.max(mineP.numbers, peerP.numbers);
479
+ const mineLines = (mineText || '').split(/\r?\n/).length;
480
+ const shrank = info.lines < mineLines;
481
+ const lenient = Boolean(allowShorter) && shrank;
482
+ if (!lenient && bestCode >= 3 && prof.codeLines < bestCode) {
483
+ problems.push(`命令行从 ${bestCode} 掉到 ${prof.codeLines},可执行层被削`);
484
+ }
485
+ if (!lenient && bestNum >= 5 && prof.numbers < Math.floor(bestNum * 0.6)) {
486
+ problems.push(`具体数值从 ${bestNum} 掉到 ${prof.numbers},判据被稀释`);
487
+ }
488
+
489
+ // 两侧都有的 Pitfalls 条目:合并后一条都不许少
490
+ const pitItems = (t) => {
491
+ const lines = String(t || '').split(/\r?\n/);
492
+ const out = [];
493
+ let inPit = false;
494
+ for (const l of lines) {
495
+ const h = l.match(/^##+\s+(.+)$/);
496
+ if (h) {
497
+ inPit = /^(Pitfalls|常见错误)/i.test(h[1].trim());
498
+ continue;
499
+ }
500
+ if (!inPit) continue;
501
+ const item = l.match(/^\s*(?:[-*+]|\d+\.)\s+(.{6,})$/);
502
+ if (item) out.push(item[1].trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 60));
503
+ }
504
+ return out;
505
+ };
506
+ const mergedPit = pitItems(text);
507
+ const lost = [];
508
+ for (const src of [mineText, peerText]) {
509
+ for (const item of pitItems(src)) {
510
+ // 合并正文里应能找到该条目的前 20 个字符(允许改写措辞,但要留下痕迹)
511
+ const key = item.slice(0, 20);
512
+ if (key && !mergedPit.some((x) => x.includes(key) || key.includes(x.slice(0, 20)))) lost.push(item.slice(0, 40));
513
+ }
514
+ }
515
+ info.lostPitfalls = lost.slice(0, 8);
516
+ if (lost.length > Math.max(2, mergedPit.length)) {
517
+ problems.push(`丢了 ${lost.length} 条原有踩坑条目(合并后只剩 ${mergedPit.length} 条)`);
518
+ }
519
+
520
+ if (action && !ACTIONS.has(action)) problems.push(`未知动作 ${action}`);
521
+ return { ok: problems.length === 0, problems, info };
522
+ }
523
+
524
+ /**
525
+ * 生成「停用声明」正文。
526
+ *
527
+ * 为什么不是直接删目录:删掉不可逆,而且别的机器还想知道「这个技能曾经存在、
528
+ * 为什么作废」。停用声明留在原地(名字不变,正文换成声明),技能加载器读到
529
+ * `deprecated: true` 就不该再把它当能力用,人也能一眼看到原因与来源。
530
+ */
531
+ export function deprecationStub({ name, reason, source = '', at = new Date().toISOString() }) {
532
+ const why = String(reason || '评估判定该技能会误导模型').replace(/\s+/g, ' ').trim();
533
+ return [
534
+ '---',
535
+ `name: ${name}`,
536
+ 'description: "已停用:这条技能被判定会误导模型,不要按它执行。保留本文件只为记录原因与来源。"',
537
+ 'deprecated: true',
538
+ `deprecatedAt: "${at}"`,
539
+ `deprecatedFrom: "${String(source || '')}"`,
540
+ 'license: MIT',
541
+ '---',
542
+ '',
543
+ `# ${name}(已停用)`,
544
+ '',
545
+ '这条技能不再生效。下面是停用原因,供日后复查:',
546
+ '',
547
+ why,
548
+ '',
549
+ '## Pitfalls',
550
+ '',
551
+ `- 停用前的问题:${why}`,
552
+ '- 不要照着旧版本的文件继续执行:旧命令/旧 API 会得到错误结果。',
553
+ '',
554
+ '## Verification',
555
+ '',
556
+ '- 确认技能目录里 `SKILL.md` 的 frontmatter 含 `deprecated: true`;',
557
+ '- 需要恢复时,改回原正文并把 `deprecated` 删掉,不要新建同名目录。',
558
+ '',
559
+ ].join('\n');
560
+ }
561
+
562
+ /* ------------------------------------------------------------ 落草稿 */
563
+
564
+ /**
565
+ * 把合并结果落成待批草稿(写在自己那一格里,不动本机技能根)。
566
+ *
567
+ * 为什么先落草稿:合并是不可逆动作,而模型评审会出错。草稿 + 原文差异 +
568
+ * 结论理由三者一起放在共享盘上,人(或者另一个模型)能在不重跑评估的前提下复核。
569
+ */
570
+ export function landLearningDraft({ config, machineId, name, content, meta = {} }) {
571
+ const root = mySkillsRoot(config, machineId);
572
+ const dir = path.join(root, DRAFTS_DIR, name);
573
+ assertOwnFeaturePath(dir, { featuresRoot: featuresDir(config), machineId });
574
+ fs.mkdirSync(dir, { recursive: true });
575
+ const file = path.join(dir, 'SKILL.md');
576
+ const tmp = `${file}.${process.pid}.tmp`;
577
+ fs.writeFileSync(tmp, content, 'utf8');
578
+ fs.renameSync(tmp, file);
579
+ writeJson(path.join(dir, 'DRAFT-META.json'), {
580
+ schemaVersion: 1,
581
+ skill: name,
582
+ draftHash: sha256(content),
583
+ lines: content.split(/\r?\n/).length,
584
+ createdAt: new Date().toISOString(),
585
+ ...meta,
586
+ });
587
+ return { dir, file, hash: sha256(content) };
588
+ }
589
+
590
+ /* ------------------------------------------------------------ 主流程 */
591
+
592
+ /**
593
+ * 评估一条「同名不同内容」的差异。
594
+ *
595
+ * @param {object} input
596
+ * @param {string} input.name 技能名
597
+ * @param {string} input.mineText 本机 SKILL.md
598
+ * @param {string} input.peerText 对方 SKILL.md
599
+ * @param {object} input.peer 对方 peers[0](含 feature / machine / dir)
600
+ * @param {object} input.mineAssets 本机资产(scripts/references/evals)
601
+ * @param {Function|null} input.generate 宿主模型(createHostDrafter 产物),可为 null
602
+ * @param {boolean} input.dryRun 只算不写
603
+ * @param {object} [input.redundant] 该技能在本机的冗余候选
604
+ * @param {number} [input.now]
605
+ */
606
+ export async function learnOneDiff({
607
+ name,
608
+ mineText,
609
+ peerText,
610
+ peer,
611
+ mineAssets = {},
612
+ generate = null,
613
+ dryRun = false,
614
+ redundant = [],
615
+ now = Date.now(),
616
+ /** 落草稿的目标(本机标识与插件库配置)。缺了就只算不写。 */
617
+ config = null,
618
+ machineId = '',
619
+ }) {
620
+ const record = {
621
+ skill: name,
622
+ at: new Date(now).toISOString(),
623
+ mineHash: sha256(mineText),
624
+ peerHash: sha256(peerText),
625
+ peerFeature: peer?.feature || '',
626
+ peerMachine: peer?.machine || '',
627
+ action: '',
628
+ label: '',
629
+ confidence: 0,
630
+ reason: '',
631
+ evidence: [],
632
+ dropped: [],
633
+ /** 判定依据:deterministic(机械)/ model(模型)/ manual(等人工) */
634
+ basis: '',
635
+ draft: null,
636
+ audit: null,
637
+ modelCalls: 0,
638
+ error: '',
639
+ };
640
+
641
+ const mineProfile = profileOf(mineText, null);
642
+ const peerProfile = profileOf(peerText, null);
643
+
644
+ // ── 1. 严格超集:不叫模型,直接有结论
645
+ const sup = supersetOf(mineText, peerText);
646
+ if (sup === 'master') {
647
+ record.action = 'keep';
648
+ record.basis = 'deterministic';
649
+ record.confidence = 0.86;
650
+ record.reason =
651
+ `本机版本的章节是对方版本的超集(本机 ${mineProfile.lines} 行 / 对方 ${peerProfile.lines} 行,` +
652
+ `对方多出的章节:无)。采用本机版本,把对方的差异只记录到台账。`;
653
+ record.evidence = [
654
+ `本机章节 ${mineProfile.headings.length} 个,覆盖对方全部 ${peerProfile.headings.length} 个`,
655
+ `命令行 本机 ${mineProfile.codeLines} / 对方 ${peerProfile.codeLines}`,
656
+ ];
657
+ record.label = LEARN_LABEL[record.action];
658
+ return record;
659
+ }
660
+ if (sup === 'peer') {
661
+ record.action = 'adopt';
662
+ record.basis = 'deterministic';
663
+ record.confidence = 0.82;
664
+ record.reason =
665
+ `对方版本的章节是本机版本的超集(对方 ${peerProfile.lines} 行 / 本机 ${mineProfile.lines} 行)。` +
666
+ '建议采用对方版本(来自 ' + (peer?.feature || '未知特征本') + ')。';
667
+ record.evidence = [
668
+ `对方章节 ${peerProfile.headings.length} 个,覆盖本机全部 ${mineProfile.headings.length} 个`,
669
+ `命令行 对方 ${peerProfile.codeLines} / 本机 ${mineProfile.codeLines}`,
670
+ ];
671
+ record.label = LEARN_LABEL[record.action];
672
+ // adopt 要落草稿,这样复核与落地走同一条路
673
+ if (!dryRun) {
674
+ try {
675
+ const d = landLearningDraft({
676
+ config,
677
+ machineId,
678
+ name,
679
+ content: peerText,
680
+ meta: { action: 'adopt', from: peer?.feature || '', basis: 'deterministic' },
681
+ });
682
+ record.draft = d.file;
683
+ } catch (err) {
684
+ record.error = String((err && err.message) || err);
685
+ }
686
+ }
687
+ return record;
688
+ }
689
+
690
+ // ── 2. 需要判断:叫模型
691
+ if (typeof generate !== 'function') {
692
+ record.action = 'manual';
693
+ record.label = LEARN_LABEL.manual;
694
+ record.basis = 'manual';
695
+ record.confidence = 0;
696
+ record.reason =
697
+ '双方各有对方没有的章节,且宿主没有可用默认模型,无法评估怎么合并。' +
698
+ `本机独有:${mineProfile.headings.filter((h) => !peerProfile.headings.includes(h)).join(' / ') || '(无章节差异,差异在正文)'};` +
699
+ `对方独有:${peerProfile.headings.filter((h) => !mineProfile.headings.includes(h)).join(' / ') || '(无章节差异,差异在正文)'}`;
700
+ record.evidence = [diffSummary(mineText, peerText)].filter(Boolean);
701
+ return record;
702
+ }
703
+
704
+ const prompt = buildLearnPrompt({
705
+ name,
706
+ mineText,
707
+ peerText,
708
+ mineProfile,
709
+ peerProfile,
710
+ peerFeature: peer?.feature || '',
711
+ peerMachine: peer?.machine || '',
712
+ mineAssets,
713
+ peerAssets: { scripts: false, references: false, evals: false },
714
+ diff: diffSummary(mineText, peerText),
715
+ redundant,
716
+ });
717
+
718
+ let parsed = null;
719
+ let lastError = '';
720
+ for (let attempt = 0; attempt < MAX_MODEL_CALLS; attempt += 1) {
721
+ try {
722
+ record.modelCalls += 1;
723
+ const reply = await generate(prompt);
724
+ const p = parseLearnReply(reply);
725
+ if (!p.action) {
726
+ lastError = '模型回复里没有可解析的 action(json 围栏缺失或坏掉)';
727
+ continue;
728
+ }
729
+ parsed = p;
730
+ break;
731
+ } catch (err) {
732
+ lastError = String((err && err.message) || err);
733
+ break;
734
+ }
735
+ }
736
+
737
+ if (!parsed) {
738
+ record.action = 'manual';
739
+ record.label = LEARN_LABEL.manual;
740
+ record.basis = 'manual';
741
+ record.reason = `模型评估没跑通:${lastError}`;
742
+ return record;
743
+ }
744
+
745
+ record.action = parsed.action;
746
+ record.label = LEARN_LABEL[parsed.action] || parsed.action;
747
+ record.basis = 'model';
748
+ record.confidence = parsed.confidence;
749
+ record.reason = parsed.reason;
750
+ record.evidence = parsed.evidence;
751
+ record.dropped = parsed.dropped;
752
+
753
+ // ── 3. keep / deprecate:不需要正文
754
+ //
755
+ // 注意:这里没有「擅自删掉对方技能」这条路。deprecate 出的是**停用声明**,
756
+ // 落成本机草稿(人批了才替换本机副本),对方那一格从不被触碰。
757
+ // 能定位到错在哪就一律改(revise),deprecate 只留给「改也没意义」。
758
+ if (parsed.action === 'keep' || parsed.action === 'deprecate') {
759
+ if (parsed.action === 'deprecate' && parsed.confidence < 0.6) {
760
+ // 低置信度的停用建议不该出现在面板上等人批准,降级成人工
761
+ record.action = 'manual';
762
+ record.label = LEARN_LABEL.manual;
763
+ record.reason = `模型建议停用但置信度只有 ${parsed.confidence}:${parsed.reason}`;
764
+ return record;
765
+ }
766
+ if (parsed.action === 'deprecate') {
767
+ record.deprecation = deprecationStub({
768
+ name,
769
+ reason: parsed.reason,
770
+ source: peer?.feature || '',
771
+ at: record.at,
772
+ });
773
+ if (!dryRun) {
774
+ try {
775
+ const d = landLearningDraft({
776
+ config,
777
+ machineId,
778
+ name,
779
+ content: record.deprecation,
780
+ meta: { action: 'deprecate', from: peer?.feature || '', basis: 'model', reason: parsed.reason },
781
+ });
782
+ record.draft = d.file;
783
+ } catch (err) {
784
+ record.error = String((err && err.message) || err);
785
+ }
786
+ }
787
+ }
788
+ return record;
789
+ }
790
+
791
+ // ── 4. 需要正文:审核
792
+ if (!parsed.content) {
793
+ record.action = 'manual';
794
+ record.label = LEARN_LABEL.manual;
795
+ record.reason = `模型选了 ${parsed.action} 但没有给出可落盘的 SKILL.md`;
796
+ return record;
797
+ }
798
+ // revise 的重心是「改正」,允许比本机短(删掉错的那段就该变短);
799
+ // 但两侧原有的 Pitfalls 条目仍然一条都不许丢,所以审核参数只放宽行动作。
800
+ const audit = auditLearning({
801
+ text: parsed.content,
802
+ name,
803
+ mineText,
804
+ peerText,
805
+ action: parsed.action,
806
+ allowShorter: parsed.action === 'revise' || parsed.action === 'condense',
807
+ });
808
+ record.audit = { ok: audit.ok, problems: audit.problems, info: audit.info };
809
+ if (!audit.ok) {
810
+ record.action = 'manual';
811
+ record.label = LEARN_LABEL.manual;
812
+ record.reason =
813
+ `模型给的合并稿没过机械审核(${audit.problems.slice(0, 3).join(';')})。原始理由:${parsed.reason}`;
814
+ record.rejectedDraft = parsed.content.slice(0, 400);
815
+ return record;
816
+ }
817
+
818
+ if (!dryRun) {
819
+ try {
820
+ const d = landLearningDraft({
821
+ config,
822
+ machineId,
823
+ name,
824
+ content: parsed.content,
825
+ meta: {
826
+ action: parsed.action,
827
+ from: peer?.feature || '',
828
+ base: parsed.base || '',
829
+ confidence: parsed.confidence,
830
+ reason: parsed.reason,
831
+ evidence: parsed.evidence,
832
+ dropped: parsed.dropped,
833
+ audit: audit.info,
834
+ },
835
+ });
836
+ record.draft = d.file;
837
+ } catch (err) {
838
+ record.error = String((err && err.message) || err);
839
+ }
840
+ }
841
+ return record;
842
+ }
843
+
844
+ /* ------------------------------------------------------------ 台账读写 */
845
+
846
+ /**
847
+ * 学一条记录进台账(同名同对方指纹的旧记录会被替换,不做无意义的堆叠)。
848
+ *
849
+ * @param {object} input
850
+ */
851
+ export function recordLearning({ config, machineId, record, machineName = '' }) {
852
+ const root = mySkillsRoot(config, machineId);
853
+ const file = path.join(root, LEARNINGS_INDEX);
854
+ assertOwnFeaturePath(file, { featuresRoot: featuresDir(config), machineId });
855
+ const doc = readJson(file) || { schemaVersion: 1, items: [] };
856
+ const items = Array.isArray(doc.items) ? doc.items : [];
857
+ const key = (x) => `${x.skill}\u0000${x.peerFeature}\u0000${x.peerHash}`;
858
+ const next = items.filter((x) => key(x) !== key(record));
859
+ next.push({ ...record, machine: record.machine || machineName });
860
+ // 只留最近 400 条,避免共享盘上无限膨胀
861
+ const trimmed = next.slice(-400);
862
+ const counts = {};
863
+ for (const x of trimmed) counts[x.action] = (counts[x.action] || 0) + 1;
864
+ const out = {
865
+ schemaVersion: 1,
866
+ feature: String(machineId || ''),
867
+ machine: machineName,
868
+ generatedAt: new Date().toISOString(),
869
+ counts: { total: trimmed.length, pending: trimmed.filter((x) => x.draft && !x.appliedAt).length, byAction: counts },
870
+ items: trimmed,
871
+ };
872
+ writeJson(file, out);
873
+ return out;
874
+ }
875
+
876
+ /** 生成人读版(也是给别的机器参考的那一份)。 */
877
+ export function writeLearningsMd({ config, machineId, log }) {
878
+ const root = mySkillsRoot(config, machineId);
879
+ const doc = readJson(path.join(root, LEARNINGS_INDEX)) || { items: [], counts: {} };
880
+ const items = [...(doc.items || [])].reverse();
881
+ const pending = items.filter((x) => x.draft && !x.appliedAt);
882
+
883
+ const lines = [
884
+ '# 技能学习台账(本机产出,共享给所有机器参考)',
885
+ '',
886
+ '> 由 ygc-dsh-sync 的「技能共享」在每次同步后自动生成。',
887
+ '> 它回答一个问题:**别的机器有同名技能、内容不一样时,本机评估后决定怎么处理**。',
888
+ '> 任何一台机器读到这份文件,都能直接参考结论,不必重跑一次评估;',
889
+ '> 想复核就按 `draft` 路径去读合并稿,按 `peerFeature` 去找原版。',
890
+ '> 判据一律是内容指纹(`mineHash` / `peerHash`),不是时间戳。',
891
+ `> 生成时间:${doc.generatedAt || new Date().toISOString()}`,
892
+ `> 记录 ${items.length} 条 · 待批草稿 ${pending.length} 条`,
893
+ '',
894
+ '## 待批合并稿',
895
+ '',
896
+ pending.length
897
+ ? '| 技能 | 动作 | 来源 | 置信度 | 草稿 | 理由 |'
898
+ : '(当前没有待批草稿)',
899
+ ];
900
+ if (pending.length) {
901
+ lines.push('|---|---|---|---|---|---|');
902
+ for (const x of pending.slice(0, 60)) {
903
+ lines.push(
904
+ `| \`${x.skill}\` | ${x.label || x.action} | ${x.peerFeature || '-'} | ${fmtConf(x.confidence)} | \`${relOf(root, x.draft)}\` | ${clip(x.reason, 110)} |`,
905
+ );
906
+ }
907
+ }
908
+ lines.push('');
909
+
910
+ lines.push('## 评估记录(最近 80 条)', '');
911
+ lines.push('| 技能 | 动作 | 依据 | 来源 | 置信度 | 理由 |');
912
+ lines.push('|---|---|---|---|---|---|');
913
+ for (const x of items.slice(0, 80)) {
914
+ lines.push(
915
+ `| \`${x.skill}\` | ${x.label || x.action} | ${x.basis || '-'} | ${x.peerFeature || '-'} | ${fmtConf(x.confidence)} | ${clip(x.reason, 120)} |`,
916
+ );
917
+ }
918
+ lines.push('');
919
+
920
+ // 优化类型说明:别的机器看这一节就知道本机都会评估什么
921
+ lines.push('## 本机会评估的优化类型', '');
922
+ lines.push('| 动作 | 含义 | 判据 |');
923
+ lines.push('|---|---|---|');
924
+ lines.push('| 合并精炼 merge | 两侧各有一半,合成一份更准的 | 双方各有对方没有的章节,且章节都能保住 |');
925
+ lines.push('| 精炼冗余 condense | 内容重复、篇幅虚胖 | 命令行与数值没有增加,正文显著冗余 |');
926
+ lines.push('| 补全工况 enrich | 补上「什么情况下用 / 不用」 | 一侧有适用条件,一侧没有 |');
927
+ lines.push('| 增加踩坑经验 pitfalls | 把对方的 Pitfalls 并进来 | 两侧踩坑条目不同,且都能保住 |');
928
+ lines.push('| 修订错误技能 revise | 改正会误导模型的错做法 | 含错误命令 / 过时 API / 引用不存在的资源,且能定位到错在哪 |');
929
+ lines.push('| 停用(作废)deprecate | 整条技能不该存在 | 改也没意义,正文换成停用声明;本机副本要人批了才替换 |');
930
+ lines.push('| 采用对方版本 adopt | 对方明显更完整 | 对方章节是本机超集 |');
931
+ lines.push('| 保留本机 keep | 本机更完整或对方有错 | 本机章节是对方超集,或对方是错误做法 |');
932
+ lines.push('| 等人工 manual | 判不出来 | 模型不可用 / 回复不可解析 / 合并稿没过审核 |');
933
+ lines.push('');
934
+
935
+ const mdChanged = (() => {
936
+ const file = path.join(root, LEARNINGS_INDEX_MD);
937
+ const strip = (s) => String(s).replace(/^> 生成时间:.*$/m, '');
938
+ try {
939
+ if (strip(fs.readFileSync(file, 'utf8')) === strip(lines.join('\n'))) return false;
940
+ } catch {
941
+ /* 不存在就写 */
942
+ }
943
+ fs.mkdirSync(path.dirname(file), { recursive: true });
944
+ const tmp = `${file}.${process.pid}.tmp`;
945
+ fs.writeFileSync(tmp, lines.join('\n'), 'utf8');
946
+ fs.renameSync(tmp, file);
947
+ return true;
948
+ })();
949
+
950
+ log?.(`学习台账已更新:${items.length} 条(待批 ${pending.length})`);
951
+ return { file: path.join(root, LEARNINGS_INDEX), mdFile: path.join(root, LEARNINGS_INDEX_MD), mdChanged, doc };
952
+ }
953
+
954
+ function fmtConf(c) {
955
+ const n = Number(c);
956
+ return Number.isFinite(n) && n > 0 ? n.toFixed(2) : '-';
957
+ }
958
+
959
+ function clip(s, n) {
960
+ return String(s || '').replace(/\s+/g, ' ').replace(/\|/g, '/').slice(0, n) || '-';
961
+ }
962
+
963
+ function relOf(root, file) {
964
+ try {
965
+ return path.relative(root, file).replace(/\\/g, '/');
966
+ } catch {
967
+ return String(file || '');
968
+ }
969
+ }
970
+
971
+ /* ------------------------------------------------------------ 批准 / 驳回 */
972
+
973
+ /**
974
+ * 批准一条学习草稿:写进本机技能根。
975
+ *
976
+ * 落地前再核一次指纹:从出草稿到现在,本机技能可能已经被人改过,
977
+ * 这时候覆盖就等于把别人的改动冲掉——判据是「当前 SKILL.md 的 hash 是否等于
978
+ * 出草稿时的 mineHash」,不等就拒绝并要求重新评估。
979
+ *
980
+ * @param {{config:object, machineId:string, localRoot:string, name:string,
981
+ * force?:boolean, stamp?:string, log?:Function}} input
982
+ */
983
+ export function approveLearning({ config, machineId, localRoot, name, force = false, stamp, log }) {
984
+ const root = mySkillsRoot(config, machineId);
985
+ const draftFile = path.join(root, DRAFTS_DIR, name, 'SKILL.md');
986
+ let content = '';
987
+ try {
988
+ content = fs.readFileSync(draftFile, 'utf8');
989
+ } catch {
990
+ return { ok: false, message: `没有待批草稿:${draftFile}` };
991
+ }
992
+ const meta = readJson(path.join(root, DRAFTS_DIR, name, 'DRAFT-META.json')) || {};
993
+
994
+ const target = path.join(localRoot, name, 'SKILL.md');
995
+ let current = '';
996
+ try {
997
+ current = fs.readFileSync(target, 'utf8');
998
+ } catch {
999
+ current = '';
1000
+ }
1001
+ const doc = readJson(path.join(root, LEARNINGS_INDEX)) || { items: [] };
1002
+ const rec = (doc.items || []).find((x) => x.skill === name && x.draft && !x.appliedAt);
1003
+ if (!force && rec && rec.mineHash && current && sha256(current) !== rec.mineHash) {
1004
+ return {
1005
+ ok: false,
1006
+ message:
1007
+ `${name} 的本机 SKILL.md 在出草稿之后被改过(当前 ${sha256(current).slice(0, 8)},` +
1008
+ `出草稿时 ${String(rec.mineHash).slice(0, 8)})。强制覆盖请带 force。`,
1009
+ };
1010
+ }
1011
+
1012
+ const st = stamp || new Date().toISOString().replace(/[:.]/g, '-');
1013
+ const backupDir = path.join(localRoot, `.pre-learn-${st}`, name);
1014
+ try {
1015
+ if (current) {
1016
+ fs.mkdirSync(backupDir, { recursive: true });
1017
+ fs.writeFileSync(path.join(backupDir, 'SKILL.md'), current, 'utf8');
1018
+ }
1019
+ fs.mkdirSync(path.dirname(target), { recursive: true });
1020
+ const tmp = `${target}.${process.pid}.tmp`;
1021
+ fs.writeFileSync(tmp, content, 'utf8');
1022
+ fs.renameSync(tmp, target);
1023
+ } catch (err) {
1024
+ return { ok: false, message: String((err && err.message) || err) };
1025
+ }
1026
+
1027
+ // 台账标记已落地,并记一条来源(学自哪台机器)
1028
+ const items = doc.items || [];
1029
+ const idx = items.findIndex((x) => x.skill === name && x.draft && !x.appliedAt);
1030
+ if (idx >= 0) {
1031
+ items[idx] = {
1032
+ ...items[idx],
1033
+ appliedAt: new Date().toISOString(),
1034
+ appliedHash: sha256(content),
1035
+ appliedBy: String(machineId || ''),
1036
+ };
1037
+ }
1038
+ writeJson(path.join(root, LEARNINGS_INDEX), {
1039
+ ...doc,
1040
+ generatedAt: new Date().toISOString(),
1041
+ items,
1042
+ counts: {
1043
+ total: items.length,
1044
+ pending: items.filter((x) => x.draft && !x.appliedAt).length,
1045
+ },
1046
+ });
1047
+ if (idx >= 0 && items[idx].peerFeature) {
1048
+ try {
1049
+ recordOrigin(config, machineId, name, { learnedFrom: [items[idx].peerFeature] });
1050
+ } catch {
1051
+ /* 来源写不进去不影响落地 */
1052
+ }
1053
+ }
1054
+ log?.(`学习草稿已落地:${name}(旧版备份在 .pre-learn-${st}/)`);
1055
+ return { ok: true, name, file: target, backup: current ? backupDir : '', hash: sha256(content) };
1056
+ }
1057
+
1058
+ /** 驳回一条学习草稿:只标状态 + 记理由,草稿文件留着供复核。 */
1059
+ export function rejectLearning({ config, machineId, name, reason = '', machineIdOf = '' }) {
1060
+ const root = mySkillsRoot(config, machineId);
1061
+ const file = path.join(root, LEARNINGS_INDEX);
1062
+ assertOwnFeaturePath(file, { featuresRoot: featuresDir(config), machineId });
1063
+ const doc = readJson(file) || { items: [] };
1064
+ const items = Array.isArray(doc.items) ? doc.items : [];
1065
+ const idx = items.findIndex((x) => x.skill === name && x.draft && !x.appliedAt);
1066
+ if (idx < 0) return { ok: false, message: `没有待批记录:${name}` };
1067
+ items[idx] = {
1068
+ ...items[idx],
1069
+ rejectedAt: new Date().toISOString(),
1070
+ rejectReason: String(reason || '人工驳回').slice(0, 400),
1071
+ rejectedBy: String(machineIdOf || machineId || ''),
1072
+ };
1073
+ writeJson(file, {
1074
+ ...doc,
1075
+ generatedAt: new Date().toISOString(),
1076
+ items,
1077
+ counts: { total: items.length, pending: items.filter((x) => x.draft && !x.appliedAt).length },
1078
+ });
1079
+ return { ok: true, name };
1080
+ }
1081
+
1082
+ /** 列出待批草稿(面板用)。 */
1083
+ export function listPendingLearnings({ config, machineId }) {
1084
+ const root = mySkillsRoot(config, machineId);
1085
+ const doc = readJson(path.join(root, LEARNINGS_INDEX)) || { items: [] };
1086
+ return (doc.items || [])
1087
+ .filter((x) => x.draft && !x.appliedAt && !x.rejectedAt)
1088
+ .map((x) => ({
1089
+ skill: x.skill,
1090
+ action: x.action,
1091
+ label: x.label,
1092
+ basis: x.basis,
1093
+ confidence: x.confidence,
1094
+ peerFeature: x.peerFeature,
1095
+ reason: x.reason,
1096
+ evidence: (x.evidence || []).slice(0, 6),
1097
+ dropped: (x.dropped || []).slice(0, 6),
1098
+ audit: x.audit,
1099
+ at: x.at,
1100
+ draft: path.relative(root, x.draft).replace(/\\/g, '/'),
1101
+ }));
1102
+ }
1103
+
1104
+ /** 读一份草稿正文(面板预览用)。 */
1105
+ export function readDraft({ config, machineId, name }) {
1106
+ const file = path.join(mySkillsRoot(config, machineId), DRAFTS_DIR, name, 'SKILL.md');
1107
+ try {
1108
+ return { ok: true, file, text: fs.readFileSync(file, 'utf8') };
1109
+ } catch {
1110
+ return { ok: false, file, text: '' };
1111
+ }
1112
+ }
1113
+
1114
+ /** 导出常量给上层用(面板要显示目录名)。 */
1115
+ export const PATHS = Object.freeze({ SKILLS_DIR, DRAFTS_DIR, LEARNINGS_INDEX, LEARNINGS_INDEX_MD });