ygc-dsh-sync 1.2.1 → 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,288 @@
1
+ /**
2
+ * 技能归属判定:哪些技能归本插件管(可共享、可优化),哪些不归。
3
+ *
4
+ * 用户定的规矩(2026-11-15):
5
+ * 来自技能市场的第三方技能**不管理、不同步、不优化**(例如记忆插件
6
+ * dsh-mnemon 自带的技能市场);只对本机自己总结 / 编写的技能做优化与共享。
7
+ *
8
+ * 为什么单独一层:这条规矩要同时被三个入口遵守——共享备份、跨机拉取、
9
+ * 本机优化。写在三个地方必然走形,所以判定只在这里做一次,别处一律消费结论。
10
+ *
11
+ * 判据顺序(强证据优先,宁可少管也不要动第三方):
12
+ * 1. 人工覆盖(state.skills.share.scope[name]) → 说了算
13
+ * 2. 市场安装台账 `~/.mnemon/skills/ledger.json` 的 installs[]
14
+ * → 第三方(最硬的一条:
15
+ * 这是市场自己写的记录)
16
+ * 3. 市场官方目录 `market/official.json` 同名 + 目录里有 LICENSE*
17
+ * → 第三方(台账被清掉时兜底)
18
+ * 4. 母版 / 技能库同名 → 自己的
19
+ * 5. 其余 → 按「自己的」处理,
20
+ * 但证据记为 weak,
21
+ * 面板上能一眼看出来
22
+ *
23
+ * 母版要废弃这件事(用户 2026-11-15)在这里的处置:
24
+ * masterRoot 只作为**加分证据**,不是必要条件。只要判定过一次,结论就写进
25
+ * `state.skills.share.scope`(本机持久化),母版删掉之后既有判定不丢;
26
+ * 新出现的技能按第 5 条默认归自己——新建技能本来就写在 ~/.agents/skills 里。
27
+ */
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
30
+ import os from 'node:os';
31
+
32
+ /** 市场台账(记忆插件的技能市场用它记录「装了什么、从哪来」)。 */
33
+ export const MNEMON_LEDGER = process.env.DSH_SYNC_MNEMON_LEDGER
34
+ ? path.resolve(process.env.DSH_SYNC_MNEMON_LEDGER)
35
+ : path.join(os.homedir(), '.mnemon', 'skills', 'ledger.json');
36
+
37
+ /** 记忆插件自带的官方精选市场目录(39 条)。 */
38
+ export const MARKET_OFFICIAL = path.join(
39
+ os.homedir(),
40
+ '.dsh',
41
+ 'profiles',
42
+ 'desktop',
43
+ 'node_modules',
44
+ 'dsh-mnemon-source-skills',
45
+ 'market',
46
+ 'official.json',
47
+ );
48
+
49
+ /** 市场安装会附带这类许可证文件,作为「不是我写的」的弱证据。 */
50
+ const LICENSE_RE = /^(LICEN[CS]E|COPYING)(\.[A-Za-z0-9]+)?$/i;
51
+
52
+ export const SCOPE = Object.freeze({
53
+ OWN: 'own',
54
+ THIRD_PARTY: 'third-party',
55
+ });
56
+
57
+ /* -------------------------------------------------------------- 读证据 */
58
+
59
+ function readJson(file) {
60
+ try {
61
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * 读市场安装台账:技能名集合 + 细节。
69
+ *
70
+ * 台账里的 `installs[]` 每条长这样:
71
+ * { skillKey, registryId, name, source: { type:'github', repo, path, ref }, hash, files, installedAt }
72
+ * 只要 `name` 命中,就算市场装的。
73
+ */
74
+ export function readMarketInstalls(file = MNEMON_LEDGER) {
75
+ const doc = readJson(file);
76
+ const out = new Map();
77
+ if (!doc || !Array.isArray(doc.installs)) return { file, ok: false, installs: out };
78
+ for (const it of doc.installs) {
79
+ const name = typeof it?.name === 'string' ? it.name.trim() : '';
80
+ if (!name) continue;
81
+ const prev = out.get(name);
82
+ const at = typeof it.installedAt === 'string' ? it.installedAt : '';
83
+ // 同一个技能可能被装过多次:保留最新一次
84
+ if (!prev || at > prev.installedAt) {
85
+ out.set(name, {
86
+ name,
87
+ skillKey: typeof it.skillKey === 'string' ? it.skillKey : '',
88
+ registryId: typeof it.registryId === 'string' ? it.registryId : '',
89
+ repo: it?.source?.repo || '',
90
+ ref: it?.source?.ref || '',
91
+ srcPath: it?.source?.path || '',
92
+ hash: typeof it.hash === 'string' ? it.hash : '',
93
+ installedAt: at,
94
+ });
95
+ }
96
+ }
97
+ return { file, ok: true, installs: out };
98
+ }
99
+
100
+ /** 读官方精选目录里的技能名集合(离线文件,不是联网查)。 */
101
+ export function readMarketCatalog(file = MARKET_OFFICIAL) {
102
+ const doc = readJson(file);
103
+ const names = new Set();
104
+ if (!doc || !Array.isArray(doc.skills)) return { file, ok: false, names };
105
+ for (const s of doc.skills) {
106
+ const name = typeof s?.name === 'string' ? s.name.trim() : '';
107
+ if (name) names.add(name);
108
+ }
109
+ return { file, ok: true, names, id: typeof doc.id === 'string' ? doc.id : '' };
110
+ }
111
+
112
+ /** 目录里有没有许可证文件(市场技能的常见特征,只作弱证据)。 */
113
+ export function hasLicenseFile(dir) {
114
+ let entries = [];
115
+ try {
116
+ entries = fs.readdirSync(dir, { withFileTypes: true });
117
+ } catch {
118
+ return false;
119
+ }
120
+ return entries.some((e) => e.isFile() && LICENSE_RE.test(e.name));
121
+ }
122
+
123
+ /** 扫本机技能根:只认含 SKILL.md 的目录,跳过备份残留。 */
124
+ export function listLocalSkillDirs(root) {
125
+ const out = [];
126
+ let entries = [];
127
+ try {
128
+ entries = fs.readdirSync(root, { withFileTypes: true });
129
+ } catch {
130
+ return out;
131
+ }
132
+ for (const e of entries) {
133
+ if (!e.isDirectory()) continue;
134
+ if (e.name.startsWith('.') || e.name.startsWith('_')) continue;
135
+ const dir = path.join(root, e.name);
136
+ if (!fs.existsSync(path.join(dir, 'SKILL.md'))) continue;
137
+ out.push({ name: e.name, dir });
138
+ }
139
+ return out.sort((a, b) => a.name.localeCompare(b.name));
140
+ }
141
+
142
+ /* ------------------------------------------------------------ 判定 */
143
+
144
+ /**
145
+ * 判定全部本机技能的归属。
146
+ *
147
+ * @param {object} input
148
+ * @param {string} input.localRoot 本机技能根
149
+ * @param {string} [input.masterRoot] 母版 / 技能库(可空;只作加分证据)
150
+ * @param {Record<string,string>} [input.overrides] 人工覆盖:name → 'own' | 'third-party'
151
+ * @param {string} [input.ledgerFile] 市场安装台账
152
+ * @param {string} [input.catalogFile] 市场官方目录
153
+ * @returns {{items:Array, own:Array, thirdParty:Array, weak:Array, evidence:{}}}
154
+ */
155
+ export function classifySkills({
156
+ localRoot,
157
+ masterRoot = '',
158
+ overrides = {},
159
+ ledgerFile = MNEMON_LEDGER,
160
+ catalogFile = MARKET_OFFICIAL,
161
+ }) {
162
+ const installs = readMarketInstalls(ledgerFile);
163
+ const catalog = readMarketCatalog(catalogFile);
164
+ const dirs = listLocalSkillDirs(localRoot);
165
+ const masterSet = new Set();
166
+ if (masterRoot) {
167
+ let entries = [];
168
+ try {
169
+ entries = fs.readdirSync(masterRoot, { withFileTypes: true });
170
+ } catch {
171
+ entries = [];
172
+ }
173
+ for (const e of entries) {
174
+ if (!e.isDirectory() || e.name.startsWith('_') || e.name.startsWith('.')) continue;
175
+ if (fs.existsSync(path.join(masterRoot, e.name, 'SKILL.md'))) masterSet.add(e.name);
176
+ }
177
+ }
178
+
179
+ const items = [];
180
+ for (const { name, dir } of dirs) {
181
+ const evidence = [];
182
+ let scope = '';
183
+ let by = '';
184
+
185
+ const override = overrides && typeof overrides === 'object' ? overrides[name] : undefined;
186
+ if (override === SCOPE.OWN || override === SCOPE.THIRD_PARTY) {
187
+ scope = override;
188
+ by = 'manual';
189
+ evidence.push(`人工指定为 ${override === SCOPE.OWN ? '自己编写' : '第三方'}(面板上改的)`);
190
+ }
191
+
192
+ if (!scope && installs.installs.has(name)) {
193
+ const rec = installs.installs.get(name);
194
+ scope = SCOPE.THIRD_PARTY;
195
+ by = 'market-ledger';
196
+ evidence.push(
197
+ `市场安装台账有记录(${rec.registryId || 'market'} · ${rec.repo || '来源未记'}${rec.installedAt ? ` · ${rec.installedAt}` : ''})`,
198
+ );
199
+ }
200
+
201
+ if (!scope && catalog.names.has(name) && hasLicenseFile(dir)) {
202
+ scope = SCOPE.THIRD_PARTY;
203
+ by = 'market-catalog';
204
+ evidence.push('官方精选目录里有同名技能,且目录带许可证文件(台账可能已被清理)');
205
+ }
206
+
207
+ if (!scope && masterSet.has(name)) {
208
+ scope = SCOPE.OWN;
209
+ by = 'master';
210
+ evidence.push('母版 / 技能库里同名(自己维护的那一份)');
211
+ }
212
+
213
+ if (!scope) {
214
+ scope = SCOPE.OWN;
215
+ // 母版存在时,「默认归自己」只是没有反证;母版取消之后这就是正常判定,不算弱。
216
+ by = masterSet.size ? 'default' : 'own-by-default';
217
+ evidence.push(
218
+ masterSet.size
219
+ ? '没有市场安装记录,也不在技能库里,按「自己编写」处理(可在面板上改)'
220
+ : '没有市场安装记录,按「自己编写」处理(母版已取消,这就是正常路径)',
221
+ );
222
+ }
223
+
224
+ items.push({
225
+ name,
226
+ dir,
227
+ scope,
228
+ by,
229
+ evidence,
230
+ // weak 只标「本机确实有个技能库、但这个技能不在里面」这种需要人看一眼的情况;
231
+ // 没有母版时不产生 weak。
232
+ weak: by === 'default',
233
+ license: hasLicenseFile(dir),
234
+ });
235
+ }
236
+
237
+ const own = items.filter((x) => x.scope === SCOPE.OWN);
238
+ const thirdParty = items.filter((x) => x.scope === SCOPE.THIRD_PARTY);
239
+ return {
240
+ items,
241
+ own,
242
+ thirdParty,
243
+ weak: items.filter((x) => x.weak),
244
+ evidence: {
245
+ ledger: { file: installs.file, ok: installs.ok, count: installs.installs.size },
246
+ catalog: { file: catalog.file, ok: catalog.ok, count: catalog.names.size },
247
+ master: { root: masterRoot || '', ok: Boolean(masterRoot), count: masterSet.size },
248
+ },
249
+ };
250
+ }
251
+
252
+ /**
253
+ * 只取「归本插件管」的名字集合(共享 / 拉取 / 优化三个入口都用它)。
254
+ *
255
+ * 做成 Set 而不是函数式判断,是为了让调用处能一次算完、反复查,避免每处理
256
+ * 一个技能就重读一次台账(140 个技能 × 每轮多次 = 慢性卡顿)。
257
+ */
258
+ export function ownNames({ localRoot, masterRoot = '', overrides = {}, ledgerFile, catalogFile }) {
259
+ const r = classifySkills({ localRoot, masterRoot, overrides, ledgerFile, catalogFile });
260
+ const set = new Set(r.own.map((x) => x.name));
261
+ return {
262
+ names: set,
263
+ own: r.own,
264
+ thirdParty: r.thirdParty,
265
+ weak: r.weak,
266
+ evidence: r.evidence,
267
+ isOwn: (name) => set.has(name),
268
+ };
269
+ }
270
+
271
+ /** 面板摘要:只给计数与名字,别把 140 条明细塞进快照。 */
272
+ export function scopeSnapshot(input) {
273
+ const r = classifySkills(input);
274
+ return {
275
+ counts: {
276
+ total: r.items.length,
277
+ own: r.own.length,
278
+ thirdParty: r.thirdParty.length,
279
+ weak: r.weak.length,
280
+ },
281
+ thirdParty: r.thirdParty.map((x) => ({ name: x.name, by: x.by, evidence: x.evidence.slice(0, 1) })),
282
+ weak: r.weak.map((x) => x.name),
283
+ evidence: r.evidence,
284
+ note:
285
+ '第三方(技能市场装的)不同步、不优化;自己编写或总结的才归本插件管。' +
286
+ '判定不看母版是否存在:母版废弃后,已有判定与人工指定都不受影响。',
287
+ };
288
+ }
@@ -0,0 +1,388 @@
1
+ /**
2
+ * 技能共享一轮:备份本机 → 扫别人 → 拉缺失 → 评估差异 → 记台账。
3
+ *
4
+ * 这一层只做编排,判定规则全在 skillshare.js(谁有什么、差在哪)与
5
+ * skill-learn.js(差异怎么处理)。分开的理由:判定要能被单测直接打,
6
+ * 而编排要能整体跳步(比如关掉学习、或宿主没有模型)。
7
+ *
8
+ * **写边界**:本文件只调用两个写入口——
9
+ * syncMySkills / applySkillPull → 写本机那一格与本机技能根
10
+ * landLearningDraft / recordLearning → 写本机那一格的台账与草稿
11
+ * 别人的特征文件夹从头到尾只被读(readdirSync / readFileSync)。
12
+ */
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+
16
+ import {
17
+ DRAFTS_DIR,
18
+ LEARNINGS_INDEX,
19
+ applySkillPull,
20
+ collectSharedSkills,
21
+ makeStamp,
22
+ mySkillsRoot,
23
+ recordOrigin,
24
+ syncMySkills,
25
+ } from './skillshare.js';
26
+ import { ownNames } from './skill-scope.js';
27
+ import {
28
+ approveLearning,
29
+ findRedundantPairs,
30
+ learnOneDiff,
31
+ recordLearning,
32
+ writeLearningsMd,
33
+ } from './skill-learn.js';
34
+ import { scanSkills } from './skills.js';
35
+
36
+ /** 一轮里「差异评估」最多用掉的模型调用(含重试),防止一次同步拖太久。 */
37
+ const MAX_LEARN_MODEL_CALLS = 10;
38
+
39
+ /**
40
+ * @param {object} input
41
+ * @param {object} input.config { libraryRoot }
42
+ * @param {string} input.machineId 本机标识
43
+ * @param {string} [input.machineHash]
44
+ * @param {string} [input.machineName]
45
+ * @param {string} input.localRoot 本机技能根(~/.agents/skills)
46
+ * @param {string} [input.masterRoot] 母版根,仅用于名片上标「来源:母版 / 本机独属」
47
+ * @param {object} [input.modes] 单机属性表
48
+ * @param {boolean} [input.learn=true] 差异是否调模型评估
49
+ * @param {number} [input.learnPerRound=4]
50
+ * @param {boolean} [input.autoApply=false] 评估结论是否直接落到本机
51
+ * @param {Function|null} [input.generate] 宿主模型
52
+ * @param {boolean} [input.dryRun=false]
53
+ * @param {Function} [input.log]
54
+ * @param {string} [input.stamp]
55
+ * @param {string} [input.reason] 触发原因(开机 / 定时 / 手动 / 技能同步)
56
+ */
57
+ export async function runSkillShare({
58
+ config,
59
+ machineId,
60
+ machineHash = '',
61
+ machineName = '',
62
+ localRoot,
63
+ masterRoot = '',
64
+ modes = {},
65
+ learn = true,
66
+ learnPerRound = 4,
67
+ autoApply = false,
68
+ generate = null,
69
+ dryRun = false,
70
+ log,
71
+ stamp,
72
+ reason = '定时',
73
+ scopeOverrides = {},
74
+ }) {
75
+ const started = Date.now();
76
+ const feature = String(machineId || '').trim();
77
+ if (!feature) return { ok: false, error: '本机标识还没生成' };
78
+
79
+ // 归属判定先跑:市场装的第三方技能不备份、不拉取、不优化。
80
+ // 母版废弃后,这个集合只依赖「市场台账 + 人工指定 + 默认归自己」。
81
+ let scope = { names: null, thirdParty: [], weak: [], evidence: {} };
82
+ try {
83
+ scope = ownNames({ localRoot, masterRoot, overrides: scopeOverrides || {} });
84
+ } catch (err) {
85
+ log?.(`技能归属判定失败,本轮按「全部归自己」处理:${String((err && err.message) || err)}`);
86
+ }
87
+ const only = scope.names;
88
+
89
+ const report = {
90
+ ok: true,
91
+ feature,
92
+ reason,
93
+ scope: {
94
+ own: only ? only.size : null,
95
+ thirdParty: scope.thirdParty.map((x) => x.name),
96
+ weak: scope.weak.map((x) => x.name),
97
+ },
98
+ stamp: stamp || makeStamp(),
99
+ ms: 0,
100
+ backup: null,
101
+ peers: [],
102
+ missing: { found: 0, pulled: 0, errors: [] },
103
+ differ: { found: 0, evaluated: 0, byAction: {} },
104
+ learnings: null,
105
+ pending: 0,
106
+ applied: [],
107
+ autoApplyErrors: [],
108
+ errors: [],
109
+ skipped: '',
110
+ };
111
+
112
+ // ── 1. 先把本机技能备份出去(整棵目录 + 名片 + 索引)
113
+ if (!dryRun) {
114
+ try {
115
+ report.backup = syncMySkills({
116
+ config,
117
+ machineId: feature,
118
+ machineHash,
119
+ machineName,
120
+ localRoot,
121
+ masterRoot,
122
+ modes,
123
+ only,
124
+ log,
125
+ stamp: report.stamp,
126
+ });
127
+ } catch (err) {
128
+ report.ok = false;
129
+ report.errors.push(`备份本机技能失败:${String((err && err.message) || err)}`);
130
+ report.ms = Date.now() - started;
131
+ return report;
132
+ }
133
+ }
134
+
135
+ // ── 2. 扫别人的格子(只读)
136
+ // 市场上装过的技能名:别人的格子里就算有,本机也不拉(避免市场技能顺着共享盘扩散)。
137
+ // 直接用上面算好的 scope,不再重扫一遍——140 个技能重扫一次要几十毫秒,没必要。
138
+ const thirdPartyNames = new Set(scope.thirdParty.map((x) => x.name));
139
+ // 拉取之后集合会被刷新,所以提前声明成可变 Set 供下面复用。
140
+ const collected = collectSharedSkills({ config, machineId: feature, localRoot, masterRoot, only, thirdParty: thirdPartyNames });
141
+ report.peers = collected.sources;
142
+ report.missing.found = collected.missing.length;
143
+ report.differ.found = collected.differ.length;
144
+ report.thirdPartyFound = collected.thirdParty.length;
145
+ report.sameCount = collected.same.length;
146
+ // 面板要逐条列出来的三组预览(只给名字与来源,不塞整份索引)
147
+ report.missingPreview = collected.missing.map((x) => ({
148
+ name: x.name,
149
+ from: (x.peers[0] && x.peers[0].feature) || '',
150
+ version: x.version || '',
151
+ }));
152
+ report.differPreview = collected.differ.map((x) => ({
153
+ name: x.name,
154
+ peers: x.peers.map((p) => p.feature),
155
+ distinctHashes: x.counts.distinctHashes,
156
+ }));
157
+ report.thirdPartyPreview = collected.thirdParty.map((x) => ({
158
+ name: x.name,
159
+ peers: x.peers.map((p) => p.feature),
160
+ }));
161
+ for (const w of collected.warnings) report.errors.push(w);
162
+
163
+ // ── 3. 本机没有的:直接拉(不覆盖任何已有技能)
164
+ if (!dryRun && collected.missing.length) {
165
+ const pulled = applySkillPull({
166
+ items: collected.missing,
167
+ localRoot,
168
+ config,
169
+ machineId: feature,
170
+ stamp: report.stamp,
171
+ });
172
+ report.missing.pulled = pulled.pulled.length;
173
+ report.missing.errors = pulled.errors;
174
+ // 拉进来的技能归谁?新技能不在市场台账里,按「自己编写」处理。
175
+ // 所以这里必须重算一次归属,否则同一轮的差异评估与后续优化还会把它当外人。
176
+ if (pulled.pulled.length) {
177
+ try {
178
+ const after = ownNames({ localRoot, masterRoot, overrides: scopeOverrides || {} });
179
+ report.scope.own = after.names.size;
180
+ report.scope.thirdParty = after.thirdParty.map((x) => x.name);
181
+ report.scope.weak = after.weak.map((x) => x.name);
182
+ report.scope.pulledIn = pulled.pulled.map((x) => x.name);
183
+ thirdPartyNames.clear();
184
+ for (const x of after.thirdParty) thirdPartyNames.add(x.name);
185
+ } catch (err) {
186
+ report.errors.push(`拉取后重算归属失败:${String((err && err.message) || err)}`);
187
+ }
188
+ }
189
+ if (pulled.pulled.length) {
190
+ log?.(
191
+ `技能共享:从别的机器拉来 ${pulled.pulled.length} 个技能(` +
192
+ `${pulled.pulled.map((x) => `${x.name}←${x.from}`).join('、')})`,
193
+ );
194
+ }
195
+ for (const e of pulled.errors) report.errors.push(`${e.name}:拉取失败 ${e.message}`);
196
+ }
197
+
198
+ // ── 4. 同名不同内容:能机械定就机械定,判不了才叫模型
199
+ const pendingItems = collected.differ.slice(0, Math.max(0, Math.min(Number(learnPerRound) || 0, 24)));
200
+ if (collected.differ.length && !learn) {
201
+ report.skipped = '学习评估已关闭(skills.share.learn = false)';
202
+ }
203
+
204
+ // 冗余候选:不调模型,纯词面相似度,作为「合并精炼」的线索记进台账
205
+ let redundantPairs = [];
206
+ try {
207
+ const localSkills = scanSkills(localRoot);
208
+ redundantPairs = findRedundantPairs(localSkills);
209
+ } catch (err) {
210
+ report.errors.push(`冗余技能扫描失败:${String((err && err.message) || err)}`);
211
+ }
212
+
213
+ let modelCalls = 0;
214
+ for (const item of pendingItems) {
215
+ if (!learn) break;
216
+ if (typeof generate !== 'function') {
217
+ // 没有模型也要留下记录,不然面板上永远是「差异 N 个」而看不到为什么
218
+ const rec = {
219
+ skill: item.name,
220
+ at: new Date().toISOString(),
221
+ mineHash: item.mineHash || '',
222
+ peerHash: (item.peers[0] && item.peers[0].mdHash) || '',
223
+ peerFeature: (item.peers[0] && item.peers[0].feature) || '',
224
+ peerMachine: (item.peers[0] && item.peers[0].machine) || '',
225
+ action: 'manual',
226
+ label: '等人工判断',
227
+ basis: 'manual',
228
+ confidence: 0,
229
+ reason: '宿主没有可用的默认模型,无法评估两份同名技能怎么合并;差异已记录,可人工比对后处理。',
230
+ evidence: [],
231
+ dropped: [],
232
+ draft: null,
233
+ modelCalls: 0,
234
+ error: '',
235
+ };
236
+ if (!dryRun) {
237
+ try {
238
+ recordLearning({ config, machineId: feature, record: rec, machineName });
239
+ } catch (err) {
240
+ report.errors.push(`${item.name}:写台账失败 ${String((err && err.message) || err)}`);
241
+ }
242
+ }
243
+ report.differ.evaluated += 1;
244
+ report.differ.byAction.manual = (report.differ.byAction.manual || 0) + 1;
245
+ continue;
246
+ }
247
+ if (modelCalls >= MAX_LEARN_MODEL_CALLS) {
248
+ report.errors.push(`本轮模型调用已达上限 ${MAX_LEARN_MODEL_CALLS},剩下的差异下一轮再评估`);
249
+ break;
250
+ }
251
+
252
+ const peer = item.peers[0];
253
+ let mineText = '';
254
+ let peerText = '';
255
+ try {
256
+ mineText = fs.readFileSync(path.join(localRoot, item.name, 'SKILL.md'), 'utf8');
257
+ } catch {
258
+ mineText = '';
259
+ }
260
+ try {
261
+ peerText = fs.readFileSync(path.join(peer.dir, 'SKILL.md'), 'utf8');
262
+ } catch {
263
+ peerText = '';
264
+ }
265
+ if (!mineText || !peerText) {
266
+ report.errors.push(`${item.name}:读不到正文,跳过评估`);
267
+ continue;
268
+ }
269
+
270
+ let rec = null;
271
+ try {
272
+ rec = await learnOneDiff({
273
+ name: item.name,
274
+ mineText,
275
+ peerText,
276
+ peer,
277
+ mineAssets: {
278
+ scripts: fs.existsSync(path.join(localRoot, item.name, 'scripts')),
279
+ references: fs.existsSync(path.join(localRoot, item.name, 'references')),
280
+ evals: fs.existsSync(path.join(localRoot, item.name, 'evals')),
281
+ },
282
+ generate,
283
+ dryRun,
284
+ redundant: redundantPairs.filter((p) => p.a === item.name || p.b === item.name),
285
+ config,
286
+ machineId: feature,
287
+ });
288
+ modelCalls += rec.modelCalls || 0;
289
+ } catch (err) {
290
+ report.errors.push(`${item.name}:评估失败 ${String((err && err.message) || err)}`);
291
+ continue;
292
+ }
293
+
294
+ report.differ.evaluated += 1;
295
+ report.differ.byAction[rec.action] = (report.differ.byAction[rec.action] || 0) + 1;
296
+
297
+ if (!dryRun) {
298
+ try {
299
+ recordLearning({ config, machineId: feature, record: rec, machineName });
300
+ } catch (err) {
301
+ report.errors.push(`${item.name}:写台账失败 ${String((err && err.message) || err)}`);
302
+ }
303
+ if (rec.action === 'adopt' && rec.peerFeature) {
304
+ try {
305
+ recordOrigin(config, feature, item.name, { learnedFrom: [rec.peerFeature] });
306
+ } catch {
307
+ /* 来源写不进去不影响主流程 */
308
+ }
309
+ }
310
+ }
311
+ }
312
+
313
+ // ── 5. 冗余候选也进台账(同一对技能只记一次,替换旧记录)
314
+ if (!dryRun && learn && redundantPairs.length) {
315
+ for (const pair of redundantPairs.slice(0, 8)) {
316
+ const rec = {
317
+ skill: pair.a,
318
+ at: new Date().toISOString(),
319
+ mineHash: '',
320
+ peerHash: '',
321
+ peerFeature: '',
322
+ peerMachine: '',
323
+ action: 'merge',
324
+ label: '合并精炼(候选)',
325
+ basis: 'deterministic',
326
+ confidence: pair.similarity,
327
+ reason: `与本机 \`${pair.b}\` 描述重合理度 ${pair.similarity}:${pair.reason}。两侧都在本机,判据是「先人工比对再合并」,不做自动合并。`,
328
+ evidence: [`\`${pair.a}\` ${pair.lines[0]} 行 / \`${pair.b}\` ${pair.lines[1]} 行`],
329
+ dropped: [],
330
+ draft: null,
331
+ modelCalls: 0,
332
+ error: '',
333
+ pair: [pair.a, pair.b],
334
+ };
335
+ try {
336
+ recordLearning({ config, machineId: feature, record: rec, machineName });
337
+ } catch {
338
+ /* 台账写失败不影响本轮 */
339
+ }
340
+ }
341
+ }
342
+
343
+ // ── 6. autoApply:只对「本轮刚出的、过了审核的」草稿动手
344
+ if (!dryRun && autoApply) {
345
+ let pendingList = [];
346
+ try {
347
+ const file = path.join(mySkillsRoot(config, feature), LEARNINGS_INDEX);
348
+ pendingList = (JSON.parse(fs.readFileSync(file, 'utf8')).items || []).filter(
349
+ (x) => x.draft && !x.appliedAt && !x.rejectedAt && x.action !== 'manual' && (!x.audit || x.audit.ok),
350
+ );
351
+ } catch {
352
+ pendingList = [];
353
+ }
354
+ for (const x of pendingList.slice(0, 8)) {
355
+ const r = approveLearning({ config, machineId: feature, localRoot, name: x.skill, log });
356
+ if (r.ok) report.applied.push(x.skill);
357
+ else report.autoApplyErrors.push(`${x.skill}:${r.message}`);
358
+ }
359
+ }
360
+
361
+ // ── 7. 人读版台账(也是给别的机器参考的那一份)
362
+ if (!dryRun) {
363
+ try {
364
+ const md = writeLearningsMd({ config, machineId: feature, log });
365
+ report.learnings = { file: md.file, mdFile: md.mdFile };
366
+ const doc = md.doc || {};
367
+ report.pending = (doc.items || []).filter((x) => x.draft && !x.appliedAt && !x.rejectedAt).length;
368
+ } catch (err) {
369
+ report.errors.push(`写学习台账失败:${String((err && err.message) || err)}`);
370
+ }
371
+ }
372
+
373
+ report.learn = Boolean(learn);
374
+ report.redundant = redundantPairs.length;
375
+ report.ms = Date.now() - started;
376
+ log?.(
377
+ `技能共享完成(${reason}):备份 ${report.backup ? report.backup.counts.total : '-'} 个、` +
378
+ `他人 ${report.peers.length} 台、缺失 ${report.missing.found}(拉 ${report.missing.pulled})、` +
379
+ `差异 ${report.differ.found}(评估 ${report.differ.evaluated})、待批 ${report.pending}` +
380
+ (report.errors.length ? `、错误 ${report.errors.length}` : ''),
381
+ );
382
+ return report;
383
+ }
384
+
385
+ /** 草稿目录(面板要展示「合并稿就在这」)。 */
386
+ export function draftsDirOf(config, machineId) {
387
+ return path.join(mySkillsRoot(config, machineId), DRAFTS_DIR);
388
+ }