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.
- package/README.md +107 -283
- package/client.js +425 -545
- package/index.js +311 -223
- package/lib/api.js +161 -747
- package/lib/core.js +73 -43
- package/lib/skill-learn.js +1115 -0
- package/lib/skill-optimize.js +888 -0
- package/lib/skill-scope.js +288 -0
- package/lib/skill-share-run.js +388 -0
- package/lib/skillshare.js +781 -0
- package/package.json +14 -3
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 技能共享(特征文件夹)——把「技能同步」做成和「插件清单同步」同一套模型。
|
|
3
|
+
*
|
|
4
|
+
* 插件那一侧只存清单(清单里只有名字、版本、来源坐标,安装一律联网拉)。
|
|
5
|
+
* 技能这一侧没有 registry,技能本身就是文件,所以规则相应地改成:
|
|
6
|
+
*
|
|
7
|
+
* 插件库\
|
|
8
|
+
* └─ <我的特征名>\
|
|
9
|
+
* ├─ FEATURE.json 本机特征说明(插件侧写的,本来就有的)
|
|
10
|
+
* ├─ PLUGINS.json 本机插件清单(插件侧)
|
|
11
|
+
* ├─ skills\ ← 本文件管这一层
|
|
12
|
+
* │ ├─ SKILLS.json 本机技能索引(我自己算的)
|
|
13
|
+
* │ ├─ SKILLS.md 同一份的人读版本
|
|
14
|
+
* │ ├─ SKILL-LEARNINGS.json 我的学习台账(别人差异 → 我怎么处理)
|
|
15
|
+
* │ ├─ SKILL-LEARNINGS.md 同一份的人读版本(**给别的机器看的**)
|
|
16
|
+
* │ └─ <技能名>\
|
|
17
|
+
* │ ├─ SKILL.md 技能正文(整棵目录一起备份)
|
|
18
|
+
* │ ├─ scripts\ ... 技能自带的脚本与资料
|
|
19
|
+
* │ └─ SKILL-CARD.json 这一个技能的名片:说明 / 来源 / 更新时间 / 内容指纹
|
|
20
|
+
*
|
|
21
|
+
* 三条硬规则:
|
|
22
|
+
* 1. **只写自己那一格**,别人的格只读。所有写入前过 assertOwnFeaturePath。
|
|
23
|
+
* 2. **备份是整棵目录**,不是只拷 SKILL.md:技能的价值常常在 scripts/ 与
|
|
24
|
+
* references/ 里,只拷正文等于把可执行层扔掉。
|
|
25
|
+
* 3. **同名不同内容不等于覆盖**。谁新谁旧判不出来时不做破坏性动作,
|
|
26
|
+
* 交给学习台账(skill-learn.js)评估要不要合并,人再决定。
|
|
27
|
+
*
|
|
28
|
+
* 为什么不用 mtime 判新旧:NAS 同步会改写时间戳,这一点在会话同步一侧已经踩过。
|
|
29
|
+
* 这里把 mtime 只当「技能更新时间」展示,判同一律看内容指纹。
|
|
30
|
+
*/
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import os from 'node:os';
|
|
34
|
+
import crypto from 'node:crypto';
|
|
35
|
+
|
|
36
|
+
import { assertOwnFeaturePath, featuresDir } from './plugins.js';
|
|
37
|
+
import { parseFrontMatter, scanSkills, walkFiles } from './skills.js';
|
|
38
|
+
|
|
39
|
+
/** 特征文件夹里放技能的那一层目录名。 */
|
|
40
|
+
export const SKILLS_DIR = 'skills';
|
|
41
|
+
/** 本机技能索引(机器读)。 */
|
|
42
|
+
export const SKILLS_INDEX = 'SKILLS.json';
|
|
43
|
+
/** 本机技能索引(人读)。 */
|
|
44
|
+
export const SKILLS_INDEX_MD = 'SKILLS.md';
|
|
45
|
+
/** 单个技能的名片。 */
|
|
46
|
+
export const SKILL_CARD = 'SKILL-CARD.json';
|
|
47
|
+
/** 学习台账(机器读)。 */
|
|
48
|
+
export const LEARNINGS_INDEX = 'SKILL-LEARNINGS.json';
|
|
49
|
+
/** 学习台账(人读,也是给别的机器参考的那一份)。 */
|
|
50
|
+
export const LEARNINGS_INDEX_MD = 'SKILL-LEARNINGS.md';
|
|
51
|
+
/** 来源台账:这个技能最初是从哪台机器 / 哪次学习来的。 */
|
|
52
|
+
export const ORIGIN_INDEX = 'SKILL-ORIGIN.json';
|
|
53
|
+
/** 等人工批准的学习草稿(落在本机自己的格子里,不直接覆盖本机技能)。 */
|
|
54
|
+
export const DRAFTS_DIR = 'learn-drafts';
|
|
55
|
+
|
|
56
|
+
/** 一键开关的默认值:默认开(与插件清单同步一致)。 */
|
|
57
|
+
export const SHARE_DEFAULTS = Object.freeze({
|
|
58
|
+
enabled: true,
|
|
59
|
+
/** 同机不同内容时是否调用模型评估「怎么优化」。默认开。 */
|
|
60
|
+
learn: true,
|
|
61
|
+
/** 一轮最多评估几个冲突,避免一次同步跑太久。 */
|
|
62
|
+
learnPerRound: 4,
|
|
63
|
+
/** 评估通过后是否直接落到本机技能根。默认关:先出草稿,人批了再落。 */
|
|
64
|
+
autoApply: false,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/* ---------------------------------------------------------------- 路径 */
|
|
68
|
+
|
|
69
|
+
/** 插件库根(与插件清单同步共用同一个根,一人一格)。 */
|
|
70
|
+
export function libraryRootOf(config) {
|
|
71
|
+
return featuresDir(config);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 我的技能共享根:`插件库/<本机标识>/skills`。 */
|
|
75
|
+
export function mySkillsRoot(config, machineId) {
|
|
76
|
+
const feature = String(machineId || '').trim();
|
|
77
|
+
if (!feature) throw new Error('缺少本机标识,拒绝定位技能共享目录');
|
|
78
|
+
return path.join(libraryRootOf(config), feature, SKILLS_DIR);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 别人的技能共享根(只读;名字先安全化,防 `../..`)。 */
|
|
82
|
+
export function peerSkillsRoot(config, featureName) {
|
|
83
|
+
return path.join(libraryRootOf(config), featureName, SKILLS_DIR);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* -------------------------------------------------------------- 小工具 */
|
|
87
|
+
|
|
88
|
+
function readJson(file) {
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readText(file) {
|
|
97
|
+
try {
|
|
98
|
+
return fs.readFileSync(file, 'utf8');
|
|
99
|
+
} catch {
|
|
100
|
+
return '';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 内容没变就不写(忽略易变字段),返回是否真的写了。 */
|
|
105
|
+
function writeJsonIfChanged(file, value, volatile = ['generatedAt', 'lastSharedAt', 'ms']) {
|
|
106
|
+
const strip = (v) => {
|
|
107
|
+
if (Array.isArray(v)) return v.map(strip);
|
|
108
|
+
if (!v || typeof v !== 'object') return v;
|
|
109
|
+
const out = {};
|
|
110
|
+
for (const k of Object.keys(v).sort()) {
|
|
111
|
+
if (volatile.includes(k)) continue;
|
|
112
|
+
out[k] = strip(v[k]);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
};
|
|
116
|
+
const old = readJson(file);
|
|
117
|
+
if (old !== undefined) {
|
|
118
|
+
try {
|
|
119
|
+
if (JSON.stringify(strip(old)) === JSON.stringify(strip(value))) return false;
|
|
120
|
+
} catch {
|
|
121
|
+
/* 比不了就写 */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
125
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
126
|
+
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
127
|
+
fs.renameSync(tmp, file);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 文本版幂等写:比较时剔掉「生成时间」。 */
|
|
132
|
+
function writeTextIfChanged(file, text) {
|
|
133
|
+
const strip = (s) => String(s).replace(/^> 生成时间:.*$/m, '');
|
|
134
|
+
try {
|
|
135
|
+
if (strip(fs.readFileSync(file, 'utf8')) === strip(text)) return false;
|
|
136
|
+
} catch {
|
|
137
|
+
/* 不存在就写 */
|
|
138
|
+
}
|
|
139
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
140
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
141
|
+
fs.writeFileSync(tmp, text, 'utf8');
|
|
142
|
+
fs.renameSync(tmp, file);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** SKILL.md 的 sha256。与 dsh-mnemon 账本同一算法(逐字节)。 */
|
|
147
|
+
export function mdHash(dir) {
|
|
148
|
+
try {
|
|
149
|
+
return crypto.createHash('sha256').update(fs.readFileSync(path.join(dir, 'SKILL.md'))).digest('hex');
|
|
150
|
+
} catch {
|
|
151
|
+
return '';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 整棵技能目录的指纹(文件名 + 大小 + 内容),与时间戳无关。 */
|
|
156
|
+
export function dirHash(dir) {
|
|
157
|
+
let files = [];
|
|
158
|
+
try {
|
|
159
|
+
files = walkFiles(dir).sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
160
|
+
} catch {
|
|
161
|
+
return '';
|
|
162
|
+
}
|
|
163
|
+
const sha = crypto.createHash('sha256');
|
|
164
|
+
for (const f of files) {
|
|
165
|
+
sha.update(f.rel);
|
|
166
|
+
sha.update('|');
|
|
167
|
+
sha.update(String(f.size));
|
|
168
|
+
sha.update('|');
|
|
169
|
+
try {
|
|
170
|
+
sha.update(fs.readFileSync(f.abs));
|
|
171
|
+
} catch {
|
|
172
|
+
sha.update('<unreadable>');
|
|
173
|
+
}
|
|
174
|
+
sha.update('\n');
|
|
175
|
+
}
|
|
176
|
+
return sha.digest('hex');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 复制整棵目录,跳过备份残留与缓存。 */
|
|
180
|
+
const COPY_IGNORED = /^(__pycache__|node_modules|\.git|\.trash-.*|_trash-.*)$|\.(bak|orig|rej)$|~$/i;
|
|
181
|
+
|
|
182
|
+
function copyDir(from, to) {
|
|
183
|
+
fs.mkdirSync(to, { recursive: true });
|
|
184
|
+
let entries = [];
|
|
185
|
+
try {
|
|
186
|
+
entries = fs.readdirSync(from, { withFileTypes: true });
|
|
187
|
+
} catch {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
for (const e of entries) {
|
|
191
|
+
if (COPY_IGNORED.test(e.name)) continue;
|
|
192
|
+
const src = path.join(from, e.name);
|
|
193
|
+
const dst = path.join(to, e.name);
|
|
194
|
+
if (e.isDirectory()) copyDir(src, dst);
|
|
195
|
+
else if (e.isFile()) {
|
|
196
|
+
try {
|
|
197
|
+
fs.copyFileSync(src, dst);
|
|
198
|
+
} catch {
|
|
199
|
+
/* 单个文件拷不动不影响整棵树 */
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function rmDir(dir) {
|
|
206
|
+
try {
|
|
207
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
208
|
+
} catch {
|
|
209
|
+
/* 尽力而为 */
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function bytesOfDir(dir) {
|
|
214
|
+
let n = 0;
|
|
215
|
+
for (const f of walkFiles(dir)) n += f.size;
|
|
216
|
+
return n;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 人类可读字节数。 */
|
|
220
|
+
export function humanBytes(n) {
|
|
221
|
+
const v = Number(n) || 0;
|
|
222
|
+
if (v < 1024) return `${v} B`;
|
|
223
|
+
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KB`;
|
|
224
|
+
return `${(v / 1024 / 1024).toFixed(1)} MB`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** ISO 时间,取不到返回空串。 */
|
|
228
|
+
function isoOf(ts) {
|
|
229
|
+
const n = Number(ts);
|
|
230
|
+
if (!Number.isFinite(n) || n <= 0) return '';
|
|
231
|
+
try {
|
|
232
|
+
return new Date(n).toISOString();
|
|
233
|
+
} catch {
|
|
234
|
+
return '';
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* ------------------------------------------------------------ 技能元信息 */
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 读一个技能目录的元信息(比 skills.js 的 readSkill 多出「更新时间」与「来源」)。
|
|
242
|
+
*
|
|
243
|
+
* @param {string} dir 技能绝对目录
|
|
244
|
+
* @param {string} name 目录名
|
|
245
|
+
* @param {{masterRoot?:string, origin?:object}} extra
|
|
246
|
+
*/
|
|
247
|
+
export function readSkillMeta(dir, name, extra = {}) {
|
|
248
|
+
const skillMd = path.join(dir, 'SKILL.md');
|
|
249
|
+
const fm = parseFrontMatter(skillMd);
|
|
250
|
+
let mtime = 0;
|
|
251
|
+
try {
|
|
252
|
+
mtime = fs.statSync(skillMd).mtimeMs;
|
|
253
|
+
} catch {
|
|
254
|
+
mtime = 0;
|
|
255
|
+
}
|
|
256
|
+
const files = walkFiles(dir);
|
|
257
|
+
const origin = extra.origin && typeof extra.origin === 'object' ? extra.origin : {};
|
|
258
|
+
const masterRoot = typeof extra.masterRoot === 'string' ? extra.masterRoot : '';
|
|
259
|
+
const inMaster = masterRoot ? fs.existsSync(path.join(masterRoot, name, 'SKILL.md')) : false;
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
name: fm.name || name,
|
|
263
|
+
skill: name,
|
|
264
|
+
dir,
|
|
265
|
+
version: fm.version || '',
|
|
266
|
+
description: fm.description || '',
|
|
267
|
+
whenToUse: fm.whenToUse || '',
|
|
268
|
+
hasSkillMd: fs.existsSync(skillMd),
|
|
269
|
+
mdHash: mdHash(dir),
|
|
270
|
+
treeHash: dirHash(dir),
|
|
271
|
+
files: files.length,
|
|
272
|
+
bytes: files.reduce((n, f) => n + f.size, 0),
|
|
273
|
+
hasScripts: fs.existsSync(path.join(dir, 'scripts')),
|
|
274
|
+
hasReferences: fs.existsSync(path.join(dir, 'references')),
|
|
275
|
+
hasEvals: fs.existsSync(path.join(dir, 'evals')) || fs.existsSync(path.join(dir, 'evals.json')),
|
|
276
|
+
updatedAt: isoOf(mtime),
|
|
277
|
+
updatedAtMs: mtime,
|
|
278
|
+
/** 来源:母版有没有、是不是从别的机器学来的。 */
|
|
279
|
+
source: {
|
|
280
|
+
inMaster: inMaster,
|
|
281
|
+
masterRoot: inMaster ? masterRoot : '',
|
|
282
|
+
/** 最初从哪台机器的特征本拿到的;本机自产为空。 */
|
|
283
|
+
firstSeenFrom: typeof origin.firstSeenFrom === 'string' ? origin.firstSeenFrom : '',
|
|
284
|
+
/** 学习记录:从哪些机器学到过东西。 */
|
|
285
|
+
learnedFrom: Array.isArray(origin.learnedFrom) ? origin.learnedFrom.slice(0, 12) : [],
|
|
286
|
+
/** 母版是「本机独属」属性的,就等于只在本机存在。 */
|
|
287
|
+
mode: typeof origin.mode === 'string' ? origin.mode : '',
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** 来源台账的路径(写在自己那一格里,别人也能读到是谁学的)。 */
|
|
293
|
+
export function originFile(config, machineId) {
|
|
294
|
+
return path.join(mySkillsRoot(config, machineId), ORIGIN_INDEX);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** 读来源台账:技能名 → { firstSeenFrom, learnedFrom, mode }。 */
|
|
298
|
+
export function readOrigins(config, machineId) {
|
|
299
|
+
const doc = readJson(originFile(config, machineId));
|
|
300
|
+
return doc && typeof doc.skills === 'object' && doc.skills ? doc.skills : {};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** 记一条来源(幂等:重复写同样的值不产生改动)。 */
|
|
304
|
+
export function recordOrigin(config, machineId, name, patch = {}) {
|
|
305
|
+
const file = originFile(config, machineId);
|
|
306
|
+
const doc = readJson(file) || { schemaVersion: 1, skills: {} };
|
|
307
|
+
if (!doc.skills || typeof doc.skills !== 'object') doc.skills = {};
|
|
308
|
+
const cur = doc.skills[name] && typeof doc.skills[name] === 'object' ? doc.skills[name] : {};
|
|
309
|
+
const next = { ...cur, ...patch };
|
|
310
|
+
if (Array.isArray(patch.learnedFrom)) {
|
|
311
|
+
const set = new Set([...(cur.learnedFrom || []), ...patch.learnedFrom]);
|
|
312
|
+
next.learnedFrom = [...set].slice(0, 12);
|
|
313
|
+
}
|
|
314
|
+
doc.schemaVersion = 1;
|
|
315
|
+
doc.skills[name] = next;
|
|
316
|
+
doc.updatedAt = new Date().toISOString();
|
|
317
|
+
return writeJsonIfChanged(file, doc, ['updatedAt']);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* ------------------------------------------------------ 把我的技能备份出去 */
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 扫本机技能根,写成 `插件库/<本机>/skills/` 一整套。
|
|
324
|
+
*
|
|
325
|
+
* 幂等:内容没变的技能不重拷(比对目录指纹),索引没变不重写。
|
|
326
|
+
* 清理:特征目录里有、本机技能根里已经没有的技能目录会被移进 `_trash-<stamp>`
|
|
327
|
+
* (不直接删,留着给别的机器最后一次机会读)。
|
|
328
|
+
*
|
|
329
|
+
* @param {{config:object, machineId:string, machineHash?:string, machineName?:string,
|
|
330
|
+
* localRoot:string, masterRoot?:string, modes?:object, log?:Function,
|
|
331
|
+
* stamp?:string}} input
|
|
332
|
+
*/
|
|
333
|
+
export function syncMySkills({
|
|
334
|
+
config,
|
|
335
|
+
machineId,
|
|
336
|
+
machineHash = '',
|
|
337
|
+
machineName = '',
|
|
338
|
+
localRoot,
|
|
339
|
+
masterRoot = '',
|
|
340
|
+
modes = {},
|
|
341
|
+
only = null,
|
|
342
|
+
log,
|
|
343
|
+
stamp,
|
|
344
|
+
}) {
|
|
345
|
+
const started = Date.now();
|
|
346
|
+
const feature = String(machineId || '').trim();
|
|
347
|
+
const root = mySkillsRoot(config, feature);
|
|
348
|
+
// 写入前过守门:只允许写自己那一格
|
|
349
|
+
assertOwnFeaturePath(root, { featuresRoot: libraryRootOf(config), machineId: feature });
|
|
350
|
+
|
|
351
|
+
const origins = readOrigins(config, feature);
|
|
352
|
+
const scanned = scanSkills(localRoot);
|
|
353
|
+
// `only` 是 skill-scope 判定的「归本插件管」集合:
|
|
354
|
+
// 只备份归本插件管的技能,市场装的第三方技能不进特征文件夹(既不共享也不优化)。
|
|
355
|
+
// only 为 null 时退化成「全都备份」(测试与首次迁移用)。
|
|
356
|
+
const local = new Map();
|
|
357
|
+
for (const [name, skill] of scanned) {
|
|
358
|
+
if (only && !only.has(name)) continue;
|
|
359
|
+
local.set(name, skill);
|
|
360
|
+
}
|
|
361
|
+
const excluded = only ? scanned.size - local.size : 0;
|
|
362
|
+
const items = [];
|
|
363
|
+
const copied = [];
|
|
364
|
+
const skipped = [];
|
|
365
|
+
const errors = [];
|
|
366
|
+
|
|
367
|
+
fs.mkdirSync(root, { recursive: true });
|
|
368
|
+
|
|
369
|
+
for (const [name, skill] of local) {
|
|
370
|
+
const srcDir = skill.dir;
|
|
371
|
+
const dstDir = path.join(root, name);
|
|
372
|
+
const meta = readSkillMeta(srcDir, name, {
|
|
373
|
+
masterRoot,
|
|
374
|
+
origin: { ...(origins[name] || {}), mode: modes[name] || '' },
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// 已经备份过、且整棵目录指纹一致 → 不重拷,只更新名片里的易变字段
|
|
378
|
+
const oldCard = readJson(path.join(dstDir, SKILL_CARD));
|
|
379
|
+
const upToDate = oldCard && oldCard.treeHash === meta.treeHash && fs.existsSync(path.join(dstDir, 'SKILL.md'));
|
|
380
|
+
if (upToDate) {
|
|
381
|
+
skipped.push(name);
|
|
382
|
+
} else {
|
|
383
|
+
try {
|
|
384
|
+
rmDir(dstDir);
|
|
385
|
+
copyDir(srcDir, dstDir);
|
|
386
|
+
copied.push(name);
|
|
387
|
+
} catch (err) {
|
|
388
|
+
errors.push({ name, message: String((err && err.message) || err) });
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const card = {
|
|
394
|
+
schemaVersion: 1,
|
|
395
|
+
skill: name,
|
|
396
|
+
name: meta.name,
|
|
397
|
+
feature,
|
|
398
|
+
machine: machineName || '',
|
|
399
|
+
machineId: feature,
|
|
400
|
+
machineHash: machineHash || '',
|
|
401
|
+
description: meta.description,
|
|
402
|
+
whenToUse: meta.whenToUse,
|
|
403
|
+
version: meta.version,
|
|
404
|
+
/** 技能更新时间:取 SKILL.md 的落盘时间,只作展示,判同一律看指纹。 */
|
|
405
|
+
updatedAt: meta.updatedAt,
|
|
406
|
+
contentHash: meta.mdHash,
|
|
407
|
+
treeHash: meta.treeHash,
|
|
408
|
+
files: meta.files,
|
|
409
|
+
bytes: meta.bytes,
|
|
410
|
+
assets: {
|
|
411
|
+
scripts: meta.hasScripts,
|
|
412
|
+
references: meta.hasReferences,
|
|
413
|
+
evals: meta.hasEvals,
|
|
414
|
+
},
|
|
415
|
+
source: {
|
|
416
|
+
type: meta.source.inMaster ? 'master' : 'local',
|
|
417
|
+
inMaster: meta.source.inMaster,
|
|
418
|
+
masterRoot: meta.source.masterRoot,
|
|
419
|
+
mode: meta.source.mode,
|
|
420
|
+
firstSeenFrom: meta.source.firstSeenFrom,
|
|
421
|
+
learnedFrom: meta.source.learnedFrom,
|
|
422
|
+
},
|
|
423
|
+
note:
|
|
424
|
+
'本目录由 ygc-dsh-sync 的「技能共享」自动生成:整棵技能目录的备份 + 这张名片。' +
|
|
425
|
+
'目录名即技能名;contentHash 是 SKILL.md 的 sha256,treeHash 是整棵目录的指纹;' +
|
|
426
|
+
'updatedAt 只是落盘时间,判同一律看指纹。',
|
|
427
|
+
};
|
|
428
|
+
writeJsonIfChanged(path.join(dstDir, SKILL_CARD), card);
|
|
429
|
+
items.push(card);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// 本机已经没有的技能(含被 skill-scope 划出去的第三方):移进回收目录,不直删。
|
|
433
|
+
// 移出的是**自己格子里的旧副本**,不碰任何别人的特征本。
|
|
434
|
+
const trashStamp = stamp || makeStamp();
|
|
435
|
+
const trashed = [];
|
|
436
|
+
let entries = [];
|
|
437
|
+
try {
|
|
438
|
+
entries = fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
439
|
+
} catch {
|
|
440
|
+
entries = [];
|
|
441
|
+
}
|
|
442
|
+
for (const e of entries) {
|
|
443
|
+
if (!local.has(e.name)) {
|
|
444
|
+
const from = path.join(root, e.name);
|
|
445
|
+
const to = path.join(root, `.trash-${trashStamp}`, e.name);
|
|
446
|
+
try {
|
|
447
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
448
|
+
fs.renameSync(from, to);
|
|
449
|
+
trashed.push(e.name);
|
|
450
|
+
} catch {
|
|
451
|
+
/* 挪不动就留着,不影响索引 */
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
items.sort((a, b) => a.skill.localeCompare(b.skill));
|
|
457
|
+
const index = {
|
|
458
|
+
schemaVersion: 1,
|
|
459
|
+
feature,
|
|
460
|
+
machine: machineName || '',
|
|
461
|
+
machineId: feature,
|
|
462
|
+
machineHash: machineHash || '',
|
|
463
|
+
platform: `${process.platform}-${process.arch}`,
|
|
464
|
+
localRoot,
|
|
465
|
+
generatedAt: new Date().toISOString(),
|
|
466
|
+
counts: {
|
|
467
|
+
total: items.length,
|
|
468
|
+
withScripts: items.filter((x) => x.assets.scripts).length,
|
|
469
|
+
withReferences: items.filter((x) => x.assets.references).length,
|
|
470
|
+
withEvals: items.filter((x) => x.assets.evals).length,
|
|
471
|
+
bytes: items.reduce((n, x) => n + x.bytes, 0),
|
|
472
|
+
},
|
|
473
|
+
policy: {
|
|
474
|
+
/** 技能整棵备份(含 scripts/references),与插件侧的「只存清单」不同:技能没有 registry。 */
|
|
475
|
+
storesSkillTree: true,
|
|
476
|
+
storesSourceCode: true,
|
|
477
|
+
/** 同名不同内容不自动覆盖,交给学习台账。 */
|
|
478
|
+
sameNameConflict: 'learn',
|
|
479
|
+
/** 索引里带的字段:说明、来源、更新时间、内容指纹。 */
|
|
480
|
+
fields: ['description', 'source', 'updatedAt', 'contentHash', 'treeHash'],
|
|
481
|
+
note: '技能共享是整棵目录备份;判同一律看 contentHash/treeHash,不看时间戳。',
|
|
482
|
+
},
|
|
483
|
+
skills: items,
|
|
484
|
+
};
|
|
485
|
+
const indexChanged = writeJsonIfChanged(path.join(root, SKILLS_INDEX), index);
|
|
486
|
+
|
|
487
|
+
const lines = [
|
|
488
|
+
'# 本机技能索引(自动生成)',
|
|
489
|
+
'',
|
|
490
|
+
'> 由 ygc-dsh-sync 的「技能共享」生成。目录名即本机标识,与插件清单、会话同步共用。',
|
|
491
|
+
'> 每个技能目录里除了技能本体,还有一张 `SKILL-CARD.json`:说明、来源、更新时间、内容指纹。',
|
|
492
|
+
'> 判同一律看 `contentHash`(SKILL.md 的 sha256)与 `treeHash`(整棵目录),**不看时间戳**。',
|
|
493
|
+
'> 同名不同内容**不会被覆盖**:差异会进 `SKILL-LEARNINGS.md`,由本机评估后写学习建议,',
|
|
494
|
+
'> 别的机器读那份文件也能参考(学到什么、为什么、草稿落在哪)。',
|
|
495
|
+
`> 生成时间:${index.generatedAt}`,
|
|
496
|
+
`> 技能 ${index.counts.total} 个 · ${humanBytes(index.counts.bytes)} · 带脚本 ${index.counts.withScripts} 个 · 带评测 ${index.counts.withEvals} 个`,
|
|
497
|
+
'',
|
|
498
|
+
'| 技能 | 版本 | 更新时间 | 来源 | 资产 | 说明 |',
|
|
499
|
+
'|---|---|---|---|---|---|',
|
|
500
|
+
...items.map((x) => {
|
|
501
|
+
const assets = [x.assets.scripts ? 'scripts' : '', x.assets.references ? 'references' : '', x.assets.evals ? 'evals' : '']
|
|
502
|
+
.filter(Boolean)
|
|
503
|
+
.join(' / ');
|
|
504
|
+
const src = x.source.type === 'master' ? '母版' : '本机独属';
|
|
505
|
+
const from = x.source.firstSeenFrom ? ` · 学自 ${x.source.firstSeenFrom}` : '';
|
|
506
|
+
const desc = String(x.description || '').replace(/\|/g, '/').slice(0, 90);
|
|
507
|
+
return `| \`${x.skill}\` | ${x.version || '-'} | ${x.updatedAt || '-'} | ${src}${from} | ${assets || '-'} | ${desc || '-'} |`;
|
|
508
|
+
}),
|
|
509
|
+
'',
|
|
510
|
+
];
|
|
511
|
+
if (trashed.length) {
|
|
512
|
+
lines.push('## 本轮移出的技能', '', ...trashed.map((n) => `- \`${n}\`(本机已没有,移进 \`.trash-${trashStamp}/\`)`), '');
|
|
513
|
+
}
|
|
514
|
+
if (errors.length) {
|
|
515
|
+
lines.push('## 备份失败', '', ...errors.map((e) => `- \`${e.name}\`:${e.message}`), '');
|
|
516
|
+
}
|
|
517
|
+
const mdChanged = writeTextIfChanged(path.join(root, SKILLS_INDEX_MD), lines.join('\n'));
|
|
518
|
+
|
|
519
|
+
log?.(
|
|
520
|
+
`技能共享:${feature} → ${items.length} 个技能(新备份 ${copied.length}、未变 ${skipped.length}` +
|
|
521
|
+
`${trashed.length ? `、移出 ${trashed.length}` : ''}${errors.length ? `、失败 ${errors.length}` : ''})`,
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
ok: true,
|
|
526
|
+
feature,
|
|
527
|
+
root,
|
|
528
|
+
ms: Date.now() - started,
|
|
529
|
+
indexChanged,
|
|
530
|
+
mdChanged,
|
|
531
|
+
copied,
|
|
532
|
+
skipped,
|
|
533
|
+
trashed,
|
|
534
|
+
excluded,
|
|
535
|
+
errors,
|
|
536
|
+
counts: index.counts,
|
|
537
|
+
index,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** 生成时间戳串(与 skills.js 的 makeStamp 同格式)。 */
|
|
542
|
+
export function makeStamp(date = new Date()) {
|
|
543
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
544
|
+
return `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}-${p(date.getHours())}${p(
|
|
545
|
+
date.getMinutes(),
|
|
546
|
+
)}${p(date.getSeconds())}`;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/* ------------------------------------------------------ 扫别人的技能格 */
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* 扫全部特征文件夹的技能层,合并成「别人有什么」。
|
|
553
|
+
*
|
|
554
|
+
* 返回里每个技能带 `peer` 数组(哪几台机器有、指纹分别是什么),
|
|
555
|
+
* 本机技能根里的同名技能用 `mine` 标出状态:
|
|
556
|
+
* missing 本机没有 → 可直接拉取
|
|
557
|
+
* same 内容一致 → 什么都不用做
|
|
558
|
+
* differ 内容不同 → 交给学习评估
|
|
559
|
+
*
|
|
560
|
+
* @param {{config:object, machineId:string, localRoot:string, masterRoot?:string}} input
|
|
561
|
+
*/
|
|
562
|
+
export function collectSharedSkills({ config, machineId, localRoot, masterRoot = '', only = null, thirdParty = null }) {
|
|
563
|
+
const mine = String(machineId || '').trim();
|
|
564
|
+
const base = libraryRootOf(config);
|
|
565
|
+
const scannedLocal = scanSkills(localRoot);
|
|
566
|
+
// 只把「归本插件管」的技能算进本机现状:第三方(市场装的)不参与比对,
|
|
567
|
+
// 否则会出现「别人也装了同一个市场技能、内容不同 → 触发学习」这种无意义动作。
|
|
568
|
+
const local = new Map();
|
|
569
|
+
for (const [name, skill] of scannedLocal) {
|
|
570
|
+
if (only && !only.has(name)) continue;
|
|
571
|
+
local.set(name, skill);
|
|
572
|
+
}
|
|
573
|
+
const sources = [];
|
|
574
|
+
const warnings = [];
|
|
575
|
+
const merged = new Map();
|
|
576
|
+
|
|
577
|
+
let entries = [];
|
|
578
|
+
try {
|
|
579
|
+
entries = fs.readdirSync(base, { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
580
|
+
} catch {
|
|
581
|
+
entries = [];
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
for (const entry of entries) {
|
|
585
|
+
const feature = entry.name;
|
|
586
|
+
const dir = path.join(base, feature, SKILLS_DIR);
|
|
587
|
+
const index = readJson(path.join(dir, SKILLS_INDEX));
|
|
588
|
+
let skillDirs = [];
|
|
589
|
+
try {
|
|
590
|
+
skillDirs = fs
|
|
591
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
592
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
593
|
+
.map((e) => e.name);
|
|
594
|
+
} catch {
|
|
595
|
+
skillDirs = [];
|
|
596
|
+
}
|
|
597
|
+
if (!index && !skillDirs.length) {
|
|
598
|
+
// 这个特征本没有技能层(对方还是旧版插件):不算错,只提示一句
|
|
599
|
+
if (feature !== mine) warnings.push(`${feature}:还没有技能共享目录,跳过`);
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const isMine = feature === mine;
|
|
604
|
+
sources.push({
|
|
605
|
+
feature,
|
|
606
|
+
mine: isMine,
|
|
607
|
+
machine: (index && index.machine) || '',
|
|
608
|
+
generatedAt: (index && index.generatedAt) || '',
|
|
609
|
+
total: Array.isArray(index && index.skills) ? index.skills.length : skillDirs.length,
|
|
610
|
+
bytes: (index && index.counts && index.counts.bytes) || 0,
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
for (const skillName of skillDirs) {
|
|
614
|
+
// **不要用 only 过滤别人的格子**。only 只回答「本机该不该管这个技能」,
|
|
615
|
+
// 拿它过滤对方的目录会让本机永远看不见新技能(对方独有的一律被滤掉,拉取永远为 0)。
|
|
616
|
+
// 第三方过滤放在拉取那一步:市场台账里有的技能,不往本机拉(见 applySkillPull)。
|
|
617
|
+
const skillDir = path.join(dir, skillName);
|
|
618
|
+
if (!fs.existsSync(path.join(skillDir, 'SKILL.md'))) continue;
|
|
619
|
+
const card = readJson(path.join(skillDir, SKILL_CARD)) || {};
|
|
620
|
+
// 名片可能过期(对方手改过文件)。指纹一律现算,名片只用来取说明与来源。
|
|
621
|
+
const md = mdHash(skillDir);
|
|
622
|
+
const tree = dirHash(skillDir);
|
|
623
|
+
const rec =
|
|
624
|
+
merged.get(skillName) ||
|
|
625
|
+
{
|
|
626
|
+
name: skillName,
|
|
627
|
+
description: typeof card.description === 'string' ? card.description : '',
|
|
628
|
+
version: typeof card.version === 'string' ? card.version : '',
|
|
629
|
+
updatedAt: typeof card.updatedAt === 'string' ? card.updatedAt : '',
|
|
630
|
+
source: card.source && typeof card.source === 'object' ? card.source : {},
|
|
631
|
+
peers: [],
|
|
632
|
+
counts: { peers: 0, distinctHashes: 0 },
|
|
633
|
+
};
|
|
634
|
+
rec.peers.push({
|
|
635
|
+
feature,
|
|
636
|
+
machine: typeof card.machine === 'string' ? card.machine : '',
|
|
637
|
+
dir: skillDir,
|
|
638
|
+
mdHash: md,
|
|
639
|
+
treeHash: tree,
|
|
640
|
+
descLength: String(card.description || '').length,
|
|
641
|
+
});
|
|
642
|
+
merged.set(skillName, rec);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
for (const rec of merged.values()) {
|
|
647
|
+
// 本机没有的话,还要判断「是不是市场第三方」:第三方的技能不拉、不优化。
|
|
648
|
+
// 判据在这里注入(thirdParty 集合由编排层传进来),不在这里重读台账。
|
|
649
|
+
const mineRec = local.get(rec.name);
|
|
650
|
+
rec.mineHash = mineRec ? mineRec.contentHash : '';
|
|
651
|
+
rec.mineTree = mineRec ? mineRec.treeHash : '';
|
|
652
|
+
rec.mineDir = mineRec ? mineRec.dir : '';
|
|
653
|
+
// 市场第三方:本机没有也不要,别把市场的技能顺着共享盘扩散过来
|
|
654
|
+
rec.thirdParty = Boolean(thirdParty && thirdParty.has(rec.name));
|
|
655
|
+
rec.status = rec.thirdParty ? 'third-party' : !mineRec ? 'missing' : mineRec.treeHash === rec.peers[0].treeHash ? 'same' : 'differ';
|
|
656
|
+
// 只要有一台机器的指纹与本机不同,就算 differ(别的机器可能是同一版)
|
|
657
|
+
if (mineRec && rec.peers.some((p) => p.treeHash !== mineRec.treeHash)) rec.status = 'differ';
|
|
658
|
+
const hashes = new Set(rec.peers.map((p) => p.treeHash));
|
|
659
|
+
rec.counts.peers = rec.peers.length;
|
|
660
|
+
rec.counts.distinctHashes = hashes.size;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const all = [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
664
|
+
return {
|
|
665
|
+
mine,
|
|
666
|
+
sources: sources.sort((a, b) => a.feature.localeCompare(b.feature)),
|
|
667
|
+
warnings,
|
|
668
|
+
skills: all,
|
|
669
|
+
missing: all.filter((x) => x.status === 'missing'),
|
|
670
|
+
same: all.filter((x) => x.status === 'same'),
|
|
671
|
+
differ: all.filter((x) => x.status === 'differ'),
|
|
672
|
+
thirdParty: all.filter((x) => x.thirdParty),
|
|
673
|
+
localCount: local.size,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/* --------------------------------------------------------- 拉取缺失技能 */
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* 把本机没有的技能从别人的格子里拉到本机技能根。
|
|
681
|
+
*
|
|
682
|
+
* 只拉 missing。differ 一律不碰——那是学习评估的事,覆盖就等于用别人的版本
|
|
683
|
+
* 冲掉本机可能更好的版本,而这里没有可信的新旧判据。
|
|
684
|
+
*
|
|
685
|
+
* 拉取前记来源(firstSeenFrom),这样名片和台账里都能看出「这个技能是从哪学的」。
|
|
686
|
+
*
|
|
687
|
+
* @param {{items:Array, localRoot:string, config:object, machineId:string, stamp?:string}} input
|
|
688
|
+
*/
|
|
689
|
+
export function applySkillPull({ items = [], localRoot, config, machineId, stamp = makeStamp() }) {
|
|
690
|
+
const pulled = [];
|
|
691
|
+
const errors = [];
|
|
692
|
+
for (const item of items) {
|
|
693
|
+
const peer = (item.peers || [])[0];
|
|
694
|
+
if (!peer) {
|
|
695
|
+
errors.push({ name: item.name, message: '没有可用的来源特征本' });
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
const dst = path.join(localRoot, item.name);
|
|
699
|
+
try {
|
|
700
|
+
if (fs.existsSync(dst)) {
|
|
701
|
+
// 说好只拉 missing 的,走到了这里说明磁盘变了,保守跳过
|
|
702
|
+
errors.push({ name: item.name, message: '本机已有同名技能,跳过(不覆盖)' });
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
copyDir(peer.dir, dst);
|
|
706
|
+
// 名片是共享层的东西,不该污染本机技能目录
|
|
707
|
+
try {
|
|
708
|
+
fs.rmSync(path.join(dst, SKILL_CARD), { force: true });
|
|
709
|
+
} catch {
|
|
710
|
+
/* 删不掉也不影响用 */
|
|
711
|
+
}
|
|
712
|
+
if (config && machineId) {
|
|
713
|
+
recordOrigin(config, machineId, item.name, { firstSeenFrom: peer.feature });
|
|
714
|
+
}
|
|
715
|
+
pulled.push({ name: item.name, from: peer.feature, machine: peer.machine || '', mdHash: peer.mdHash });
|
|
716
|
+
} catch (err) {
|
|
717
|
+
rmDir(dst);
|
|
718
|
+
errors.push({ name: item.name, message: String((err && err.message) || err) });
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return { pulled, errors, stamp };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/* ------------------------------------------------------------ 面板快照 */
|
|
725
|
+
|
|
726
|
+
/** 面板用:我的技能层现状 + 来源列表 + 缺失/差异摘要(只读,扫盘)。 */
|
|
727
|
+
export function skillShareSnapshot({ config, machineId, localRoot, masterRoot = '', only = null }) {
|
|
728
|
+
let myRoot = '';
|
|
729
|
+
let myIndex = null;
|
|
730
|
+
let myError = '';
|
|
731
|
+
try {
|
|
732
|
+
myRoot = mySkillsRoot(config, machineId);
|
|
733
|
+
myIndex = readJson(path.join(myRoot, SKILLS_INDEX)) || null;
|
|
734
|
+
} catch (err) {
|
|
735
|
+
myError = String((err && err.message) || err);
|
|
736
|
+
}
|
|
737
|
+
const collected = collectSharedSkills({ config, machineId, localRoot, masterRoot, only });
|
|
738
|
+
const learnings = readJson(path.join(myRoot, LEARNINGS_INDEX)) || null;
|
|
739
|
+
return {
|
|
740
|
+
root: myRoot,
|
|
741
|
+
libraryRoot: libraryRootOf(config),
|
|
742
|
+
myFeature: String(machineId || ''),
|
|
743
|
+
index: myIndex
|
|
744
|
+
? {
|
|
745
|
+
generatedAt: myIndex.generatedAt,
|
|
746
|
+
counts: myIndex.counts,
|
|
747
|
+
// 面板只列名字,不塞整份索引
|
|
748
|
+
skills: (myIndex.skills || []).map((x) => ({
|
|
749
|
+
skill: x.skill,
|
|
750
|
+
version: x.version,
|
|
751
|
+
updatedAt: x.updatedAt,
|
|
752
|
+
sourceType: x.source && x.source.type,
|
|
753
|
+
firstSeenFrom: x.source && x.source.firstSeenFrom,
|
|
754
|
+
})),
|
|
755
|
+
}
|
|
756
|
+
: null,
|
|
757
|
+
error: myError,
|
|
758
|
+
sources: collected.sources,
|
|
759
|
+
warnings: collected.warnings,
|
|
760
|
+
counts: {
|
|
761
|
+
local: collected.localCount,
|
|
762
|
+
peers: collected.sources.length,
|
|
763
|
+
missing: collected.missing.length,
|
|
764
|
+
same: collected.same.length,
|
|
765
|
+
differ: collected.differ.length,
|
|
766
|
+
},
|
|
767
|
+
missing: collected.missing.map((x) => ({ name: x.name, from: x.peers[0] && x.peers[0].feature, version: x.version })),
|
|
768
|
+
differ: collected.differ.map((x) => ({
|
|
769
|
+
name: x.name,
|
|
770
|
+
peers: x.peers.map((p) => p.feature),
|
|
771
|
+
distinctHashes: x.counts.distinctHashes,
|
|
772
|
+
})),
|
|
773
|
+
learnings: learnings
|
|
774
|
+
? {
|
|
775
|
+
generatedAt: learnings.generatedAt,
|
|
776
|
+
counts: learnings.counts,
|
|
777
|
+
items: (learnings.items || []).slice(0, 30),
|
|
778
|
+
}
|
|
779
|
+
: null,
|
|
780
|
+
};
|
|
781
|
+
}
|