ygc-dsh-sync 1.2.0 → 1.2.2

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.
Files changed (6) hide show
  1. package/client.js +69 -17
  2. package/index.js +43 -16
  3. package/lib/api.js +1818 -1781
  4. package/lib/core.js +484 -484
  5. package/lib/features.js +169 -154
  6. package/package.json +62 -62
package/lib/core.js CHANGED
@@ -1,484 +1,484 @@
1
- /**
2
- * 基础层:机器标识、projectKey 编码、路径前缀匹配、写权限边界、状态持久化。
3
- */
4
- import fs from 'node:fs';
5
- import path from 'node:path';
6
- import os from 'node:os';
7
- import crypto from 'node:crypto';
8
-
9
- export const STATE_DIR = path.join(os.homedir(), '.dsh', 'storages');
10
-
11
- /**
12
- * 插件状态文件。
13
- *
14
- * 默认 ~/.dsh/storages/sync-backup.json。允许用 DSH_SYNC_STATE_FILE 覆盖——
15
- * 端到端测试必须写进临时目录:测试中途抛异常时恢复逻辑跑不到,
16
- * 会把假的母版路径留在真实 state 里,下次开机同步就对着空目录拉。
17
- */
18
- export const STATE_FILE = process.env.DSH_SYNC_STATE_FILE
19
- ? path.resolve(process.env.DSH_SYNC_STATE_FILE)
20
- : path.join(STATE_DIR, 'sync-backup.json');
21
-
22
- /**
23
- * 本机会话库根目录,惰性求值。
24
- *
25
- * 默认 ~/.dsh/sessions。允许用环境变量 DSH_SYNC_SESSIONS_ROOT 覆盖——
26
- * 这只为测试服务:端到端测试必须写进临时目录,否则会把伪造的会话
27
- * (同一个 header.id 出现在多个 projectKey 下)留在真实会话库里,
28
- * 让 sessionPersistence 抛 `duplicate JSONL session id`,整块工作区停摆。
29
- */
30
- export function sessionsRoot() {
31
- const override = process.env.DSH_SYNC_SESSIONS_ROOT;
32
- return override ? path.resolve(override) : path.join(os.homedir(), '.dsh', 'sessions');
33
- }
34
-
35
- /** 兼容写法:默认会话库根。读写会话请用 sessionsRoot(),别在模块顶层固化。 */
36
- export const SESSIONS_ROOT = path.join(os.homedir(), '.dsh', 'sessions');
37
-
38
- /** 工作区内的相对目录:会话文件夹 / 本机标识。本机只写自己那一格。 */
39
- export const TARGET_DIR_NAME = path.join('.dsh-sync', 'sessions');
40
-
41
- /* ---------------------------------------------------------------- 机器标识 */
42
-
43
- /**
44
- * 生成一次性特征值。
45
- *
46
- * 语义:插件第一次安装时生成一个,之后永久固定,永不重算。
47
- * 用随机字节而不是主机名 / MachineGuid 复算,这样换主机名、
48
- * 注册表读不到、甚至重装系统都不会让标识漂移。
49
- */
50
- export function generateHash() {
51
- return crypto.randomBytes(6).toString('hex').slice(0, 12);
52
- }
53
-
54
- /** 当前主机名,只作为标识里的「名称」默认值,不参与哈希。 */
55
- export function hostName() {
56
- return (os.hostname() || 'DSH').replace(/[^A-Za-z0-9_-]/g, '') || 'DSH';
57
- }
58
-
59
- /** 默认标识:<电脑名>-<特征值>。 */
60
- export function computeMachineId() {
61
- return `${hostName()}-${generateHash()}`;
62
- }
63
-
64
- /* ------------------------------------------------------------- projectKey */
65
-
66
- /**
67
- * 由 header.cwd 推出 dsh 的会话目录名。
68
- * 规则:去掉前导分隔符 → `\` `/` 记作 `-`、`:` 丢弃、非 ASCII 记作 `~XXXX`
69
- * → 前后各加 `--`。
70
- */
71
- export function projectKeyOf(cwd) {
72
- if (typeof cwd !== 'string' || cwd.length === 0) return null;
73
- let s = cwd.replace(/^[\\/]+/, '');
74
- let out = '';
75
- for (let i = 0; i < s.length; i++) {
76
- const code = s.charCodeAt(i);
77
- const ch = s[i];
78
- if (ch === '\\' || ch === '/') out += '-';
79
- else if (ch === ':') continue;
80
- else if (code < 0x80) out += ch;
81
- else out += '~' + code.toString(16).toUpperCase().padStart(4, '0');
82
- }
83
- return `--${out}--`;
84
- }
85
-
86
- /* ----------------------------------------------------------------- 路径 */
87
-
88
- export function normSlash(p) {
89
- return String(p).replace(/[\\/]+/g, '\\').replace(/\\+$/, '');
90
- }
91
-
92
- /** Windows 下大小写不敏感。 */
93
- export function pathKey(p) {
94
- const n = normSlash(path.resolve(String(p)));
95
- return process.platform === 'win32' ? n.toLowerCase() : n;
96
- }
97
-
98
- /** target 是否位于 prefix 之内(含相等)。 */
99
- export function isInside(prefix, target) {
100
- const a = pathKey(prefix);
101
- const b = pathKey(target);
102
- if (a === b) return true;
103
- return b.startsWith(a + '\\');
104
- }
105
-
106
- export function matchAnyPrefix(prefixes, target) {
107
- for (const p of prefixes || []) {
108
- if (!p) continue;
109
- try {
110
- if (isInside(p, target)) return p;
111
- } catch {
112
- /* 无效前缀跳过 */
113
- }
114
- }
115
- return null;
116
- }
117
-
118
- /* ------------------------------------------------------------- 状态 */
119
-
120
- const DEFAULT_STATE = {
121
- version: 2,
122
- machineId: '',
123
- machineHash: '',
124
- machineName: '',
125
- folders: [],
126
- history: [],
127
- lastBackupAt: 0,
128
- lastPullAt: 0,
129
- patrol: {
130
- enabled: false,
131
- /** 固定间隔:每 N 分钟巡检一次。 */
132
- intervalMinutes: 30,
133
- lastRunAt: 0,
134
- lastResult: null,
135
- },
136
- skills: {
137
- /** 母版根目录;空表示未配置。 */
138
- masterRoot: '',
139
- /** 自动拉取开关。 */
140
- enabled: false,
141
- /** 拉取间隔,默认 120 分钟。 */
142
- intervalMinutes: 120,
143
- /**
144
- * 记忆插件(dsh-mnemon)技能优化自动落地;默认开。
145
- * 关掉后草稿只在账本里堆着,等人到面板上逐个采纳。
146
- */
147
- mnemonAuto: true,
148
- /**
149
- * 记忆插件技能优化闭环(发现 → 判断 → 入队 → 审核 → 通过);默认开。
150
- * 关掉后只做草稿落地,不主动发现和处理新建议。
151
- */
152
- mnemonOptimize: true,
153
- /** 分诊时的置信度门槛,低于它的建议不处理。 */
154
- mnemonMinConfidence: 0.7,
155
- /** 一轮优化闭环最多起草几条:模型调用要花时间,默认 6 条一批。 */
156
- mnemonMaxDrafts: 6,
157
- lastRunAt: 0,
158
- lastResult: null,
159
- /**
160
- * 技能的单机属性(只存本机,不随母版同步):技能名 → 'sync' | 'local' | 'master'
161
- * sync 同步(双向)
162
- * local 本机独属(母版不该有它)
163
- * master 母版有·本机不拉取
164
- * 未列出的按自动:本机有母版没有 → local,其余 → sync。
165
- */
166
- modes: {},
167
- },
168
- /**
169
- * 插件清单共享(特征文件夹)。1.0.0 从 ygc-dsh-plugin-autoupdate 迁移过来:
170
- * 自动更新器只剩「更新已装插件」,清单共享归到同步插件这一侧。
171
- *
172
- * 规矩没变:只写 `插件库\<本机标识>\` 那一格;只存清单不存插件文件;
173
- * 安装只能按 installSpec 联网拉。
174
- */
175
- plugins: {
176
- /** 每轮巡检顺带同步一次我自己的特征文件夹。 */
177
- enabled: true,
178
- /** 插件库根目录;空 = 用默认 `D:\Ai工作空间\插件库`。 */
179
- libraryRoot: '',
180
- /** 上次同步时间与结果摘要。 */
181
- lastSyncAt: 0,
182
- lastResult: null,
183
- lastError: '',
184
- },
185
- /**
186
- * 本机特征文件夹的采纳状态(1.2.0 新增)。
187
- *
188
- * 判据永远是磁盘:`<工作区>/.dsh-sync/sessions/<本机标识>/` 存在 = 已采纳。
189
- * 这里只记两样辅助信息:
190
- * decisions 人对某个路径的表态(adopt / decline),防止每轮重复问;
191
- * known 上一轮看到的工作区清单,用来判「本机新建」与「本机已删除」。
192
- */
193
- features: {
194
- /** 1.2.0 首次运行是否已把「存量工作区」归过档:没有就一律问人,不自己建。 */
195
- initialized: false,
196
- /** 路径(小写归一)→ { action, path, at } */
197
- decisions: {},
198
- /** 工作区 id → 路径;只记已采纳过的 */
199
- known: {},
200
- lastPlanAt: 0,
201
- },
202
- };
203
-
204
- export function loadState() {
205
- let state = { ...DEFAULT_STATE };
206
- /** 磁盘原始结构的标记。只记布尔量,不持有对象引用(避免与 state 共享后被就地改写)。 */
207
- let diskHas = {
208
- machineHash: false,
209
- machineHashValue: '',
210
- skills: false,
211
- modes: false,
212
- mnemonAuto: false,
213
- patrol: false,
214
- patrolInterval: false,
215
- plugins: false,
216
- features: false,
217
- };
218
- try {
219
- const raw = fs.readFileSync(STATE_FILE, 'utf8');
220
- const parsed = JSON.parse(raw);
221
- diskHas = {
222
- machineHash: typeof parsed.machineHash === 'string' && !!parsed.machineHash,
223
- machineHashValue: parsed.machineHash || '',
224
- skills: parsed.skills !== undefined,
225
- modes: !!(parsed.skills && parsed.skills.modes !== undefined),
226
- mnemonAuto: !!(parsed.skills && typeof parsed.skills.mnemonAuto === 'boolean'),
227
- patrol: parsed.patrol !== undefined,
228
- patrolInterval: !!(parsed.patrol && parsed.patrol.intervalMinutes !== undefined),
229
- plugins: parsed.plugins !== undefined,
230
- features: parsed.features !== undefined,
231
- };
232
- state = { ...state, ...parsed };
233
- // skills 与 patrol 需要深拷贝,否则后续就地修改会改到 parsed
234
- if (parsed.skills && typeof parsed.skills === 'object') {
235
- state.skills = { ...parsed.skills };
236
- if (parsed.skills.modes && typeof parsed.skills.modes === 'object') {
237
- state.skills.modes = { ...parsed.skills.modes };
238
- }
239
- }
240
- if (parsed.patrol && typeof parsed.patrol === 'object') state.patrol = { ...parsed.patrol };
241
- if (parsed.plugins && typeof parsed.plugins === 'object') state.plugins = { ...parsed.plugins };
242
- if (parsed.features && typeof parsed.features === 'object') {
243
- state.features = {
244
- initialized: parsed.features.initialized === true,
245
- decisions: { ...(parsed.features.decisions || {}) },
246
- known: { ...(parsed.features.known || {}) },
247
- lastPlanAt: Number(parsed.features.lastPlanAt) || 0,
248
- };
249
- }
250
- if (!Array.isArray(state.folders)) state.folders = [];
251
- if (!Array.isArray(state.history)) state.history = [];
252
- } catch {
253
- /* 首次运行 */
254
- }
255
- // 老版本的 state 没有这些字段,补齐
256
- if (!state.patrol || typeof state.patrol !== 'object') {
257
- state.patrol = { ...DEFAULT_STATE.patrol };
258
- }
259
- // 旧版按「每日 HH:MM」调度;迁移成固定间隔,并避免留下无用字段
260
- if (state.patrol.intervalMinutes === undefined || state.patrol.intervalMinutes === null) {
261
- state.patrol.intervalMinutes = DEFAULT_STATE.patrol.intervalMinutes;
262
- }
263
- if (state.patrol.time !== undefined) delete state.patrol.time;
264
- const iv = Number(state.patrol.intervalMinutes);
265
- state.patrol.intervalMinutes =
266
- Number.isFinite(iv) && iv >= 1 ? Math.min(Math.floor(iv), 1440) : DEFAULT_STATE.patrol.intervalMinutes;
267
-
268
- // 技能同步配置
269
- if (!state.skills || typeof state.skills !== 'object') {
270
- state.skills = { ...DEFAULT_STATE.skills };
271
- }
272
- // 旧版字段清理
273
- if (state.skills.syncedSkills !== undefined) delete state.skills.syncedSkills;
274
- // 单机属性表:只接受三种合法值
275
- if (!state.skills.modes || typeof state.skills.modes !== 'object' || Array.isArray(state.skills.modes)) {
276
- state.skills.modes = {};
277
- } else {
278
- const clean = {};
279
- for (const [k, v] of Object.entries(state.skills.modes)) {
280
- if (typeof k !== 'string' || !k) continue;
281
- if (v === 'sync' || v === 'local' || v === 'master') clean[k] = v;
282
- }
283
- state.skills.modes = clean;
284
- }
285
- const siv = Number(state.skills.intervalMinutes);
286
- state.skills.intervalMinutes =
287
- Number.isFinite(siv) && siv >= 1 ? Math.min(Math.floor(siv), 10080) : DEFAULT_STATE.skills.intervalMinutes;
288
- // 老版本没有这个字段:补成默认值(开)
289
- if (typeof state.skills.mnemonAuto !== 'boolean') {
290
- state.skills.mnemonAuto = DEFAULT_STATE.skills.mnemonAuto;
291
- }
292
- if (typeof state.skills.mnemonOptimize !== 'boolean') {
293
- state.skills.mnemonOptimize = DEFAULT_STATE.skills.mnemonOptimize;
294
- }
295
- if (!Number.isFinite(Number(state.skills.mnemonMinConfidence))) {
296
- state.skills.mnemonMinConfidence = DEFAULT_STATE.skills.mnemonMinConfidence;
297
- }
298
- if (!Number.isFinite(Number(state.skills.mnemonMaxDrafts)) || Number(state.skills.mnemonMaxDrafts) < 0) {
299
- state.skills.mnemonMaxDrafts = DEFAULT_STATE.skills.mnemonMaxDrafts;
300
- }
301
-
302
- // 插件清单同步配置(1.0.0 新增;老 state 没有这一段)
303
- if (!state.plugins || typeof state.plugins !== 'object' || Array.isArray(state.plugins)) {
304
- state.plugins = { ...DEFAULT_STATE.plugins };
305
- }
306
- // 默认开:迁移过来的老用户本来就在同步清单,不该因为升级而静默停止
307
- state.plugins.enabled = state.plugins.enabled !== false;
308
- state.plugins.libraryRoot =
309
- typeof state.plugins.libraryRoot === 'string' ? state.plugins.libraryRoot.trim().slice(0, 400) : '';
310
- state.plugins.lastSyncAt = Number(state.plugins.lastSyncAt) || 0;
311
- state.plugins.lastResult =
312
- state.plugins.lastResult && typeof state.plugins.lastResult === 'object' ? state.plugins.lastResult : null;
313
- state.plugins.lastError = typeof state.plugins.lastError === 'string' ? state.plugins.lastError.slice(0, 800) : '';
314
-
315
- // 本机特征文件夹的采纳状态(1.2.0 新增;老 state 没有这一段)
316
- if (!state.features || typeof state.features !== 'object' || Array.isArray(state.features)) {
317
- state.features = { initialized: false, decisions: {}, known: {}, lastPlanAt: 0 };
318
- }
319
- state.features.initialized = state.features.initialized === true;
320
- if (!state.features.decisions || typeof state.features.decisions !== 'object' || Array.isArray(state.features.decisions)) {
321
- state.features.decisions = {};
322
- }
323
- if (!state.features.known || typeof state.features.known !== 'object' || Array.isArray(state.features.known)) {
324
- state.features.known = {};
325
- }
326
- state.features.lastPlanAt = Number(state.features.lastPlanAt) || 0;
327
-
328
- if (!state.machineHashGeneratedAt) state.machineHashGeneratedAt = 0;
329
-
330
- /**
331
- * 特征值策略(按用户要求):
332
- * 插件第一次安装时生成一次,此后永久固定,绝不重算。
333
- * 唯一的迁移来源是旧版本 state 里已经存在的哈希——那是既有目录名,
334
- * 必须原样继承,否则已备份的会话就找不回来了。
335
- */
336
- const hashFromId = String(state.machineId || '').match(/-([0-9a-f]{6,})$/i);
337
- const legacyHash = state.machineHash || (hashFromId ? hashFromId[1].toLowerCase() : '');
338
- const nameFromId = String(state.machineId || '').match(/^(.*)-[0-9a-f]{6,}$/i);
339
-
340
- if (!legacyHash) {
341
- // 真正的首次安装:生成并固定
342
- state.machineHash = generateHash();
343
- state.machineHashGeneratedAt = Date.now();
344
- } else if (state.machineHash !== legacyHash) {
345
- // 继承旧值,不改写
346
- state.machineHash = legacyHash;
347
- }
348
-
349
- // 名称:machineId 里的名称部分是既有目录名的真相,优先于独立字段
350
- if (nameFromId) state.machineName = nameFromId[1];
351
- else if (!state.machineName) state.machineName = hostName();
352
- state.machineId = joinId(state.machineName, state.machineHash);
353
-
354
- // 落盘条件(用磁盘原始标记判断,不能看 state——它已被就地改写):
355
- // 1) 首次安装 / 特征值继承
356
- // 2) 文件不存在
357
- // 3) 结构缺字段(老版本升级)——保证磁盘与内存一致
358
- const needsWrite =
359
- !diskHas.machineHash ||
360
- diskHas.machineHashValue !== state.machineHash ||
361
- !fs.existsSync(STATE_FILE) ||
362
- !diskHas.skills ||
363
- !diskHas.modes ||
364
- !diskHas.mnemonAuto ||
365
- !diskHas.patrol ||
366
- !diskHas.patrolInterval ||
367
- !diskHas.features;
368
-
369
- if (needsWrite) saveState(state);
370
- return state;
371
- }
372
-
373
- /** 拼标识:空名用哈希本身。与 patrol.js 的规则保持一致。 */
374
- export function joinId(name, hash) {
375
- const n = String(name || '')
376
- .trim()
377
- .replace(/[\\/:*?"<>|\s]+/g, '_')
378
- .replace(/^-+|-+$/g, '');
379
- const h = String(hash || '').trim();
380
- if (!h) return n;
381
- return n ? `${n}-${h}` : h;
382
- }
383
-
384
- export function saveState(state) {
385
- fs.mkdirSync(STATE_DIR, { recursive: true });
386
- const tmp = STATE_FILE + '.tmp';
387
- fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8');
388
- fs.renameSync(tmp, STATE_FILE);
389
- return state;
390
- }
391
-
392
- export function pushHistory(state, kind, detail, limit = 60) {
393
- state.history.unshift({ at: Date.now(), kind, detail });
394
- if (state.history.length > limit) state.history.length = limit;
395
- }
396
-
397
- /* ------------------------------------------------------- 写权限边界 */
398
-
399
- export class WriteDeniedError extends Error {
400
- constructor(target, reason) {
401
- super(`拒绝写入:${target}(${reason})`);
402
- this.name = 'WriteDeniedError';
403
- this.code = 'WRITE_DENIED';
404
- this.target = target;
405
- }
406
- }
407
-
408
- /** 本机允许写入的根:<工作区>/.dsh-sync/machine-sessions/<机器标识>/ */
409
- export function ownRoot(workspacePath, machineId) {
410
- return path.join(normSlash(workspacePath), TARGET_DIR_NAME, machineId);
411
- }
412
-
413
- /**
414
- * 唯一写入口的守卫。所有写操作必须先过这里,
415
- * 目标不在 <工作区>/.dsh-sync/machine-sessions/<本机标识>/ 下就抛错。
416
- */
417
- export function assertWritable(target, { workspacePath, machineId }) {
418
- if (!workspacePath) throw new WriteDeniedError(target, '缺少工作区路径');
419
- if (!machineId) throw new WriteDeniedError(target, '缺少本机标识');
420
- const allowed = ownRoot(workspacePath, machineId);
421
- const key = pathKey(target);
422
- const rootKey = pathKey(allowed);
423
- if (!(key === rootKey || key.startsWith(rootKey + '\\'))) {
424
- throw new WriteDeniedError(target, `只允许写 ${allowed}`);
425
- }
426
- return target;
427
- }
428
-
429
- /** 安全写文件:先写临时文件再 rename,且强制走写边界。 */
430
- export function writeGuarded(target, data, ctx) {
431
- assertWritable(target, ctx);
432
- fs.mkdirSync(path.dirname(target), { recursive: true });
433
- const tmp = `${target}.tmp-${process.pid}`;
434
- fs.writeFileSync(tmp, data);
435
- fs.renameSync(tmp, target);
436
- return target;
437
- }
438
-
439
- /* ------------------------------------------------------------ 小工具 */
440
-
441
- export function humanBytes(n) {
442
- if (!Number.isFinite(n) || n < 0) return '0 B';
443
- const units = ['B', 'KB', 'MB', 'GB'];
444
- let v = n;
445
- let i = 0;
446
- while (v >= 1024 && i < units.length - 1) {
447
- v /= 1024;
448
- i++;
449
- }
450
- return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
451
- }
452
-
453
- export function safeStat(p) {
454
- try {
455
- return fs.statSync(p);
456
- } catch {
457
- return null;
458
- }
459
- }
460
-
461
- export function listDirs(p) {
462
- try {
463
- return fs
464
- .readdirSync(p, { withFileTypes: true })
465
- .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
466
- .map((d) => d.name);
467
- } catch {
468
- return [];
469
- }
470
- }
471
-
472
- /** 找出某会话目录下的会话日志文件。 */
473
- export function findSessionLog(dir) {
474
- let names;
475
- try {
476
- names = fs.readdirSync(dir);
477
- } catch {
478
- return null;
479
- }
480
- const exact = names.find((n) => /^session\..*\.zstd$/.test(n));
481
- if (exact) return path.join(dir, exact);
482
- const anyZstd = names.find((n) => n.endsWith('.zstd'));
483
- return anyZstd ? path.join(dir, anyZstd) : null;
484
- }
1
+ /**
2
+ * 基础层:机器标识、projectKey 编码、路径前缀匹配、写权限边界、状态持久化。
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import crypto from 'node:crypto';
8
+
9
+ export const STATE_DIR = path.join(os.homedir(), '.dsh', 'storages');
10
+
11
+ /**
12
+ * 插件状态文件。
13
+ *
14
+ * 默认 ~/.dsh/storages/sync-backup.json。允许用 DSH_SYNC_STATE_FILE 覆盖——
15
+ * 端到端测试必须写进临时目录:测试中途抛异常时恢复逻辑跑不到,
16
+ * 会把假的母版路径留在真实 state 里,下次开机同步就对着空目录拉。
17
+ */
18
+ export const STATE_FILE = process.env.DSH_SYNC_STATE_FILE
19
+ ? path.resolve(process.env.DSH_SYNC_STATE_FILE)
20
+ : path.join(STATE_DIR, 'sync-backup.json');
21
+
22
+ /**
23
+ * 本机会话库根目录,惰性求值。
24
+ *
25
+ * 默认 ~/.dsh/sessions。允许用环境变量 DSH_SYNC_SESSIONS_ROOT 覆盖——
26
+ * 这只为测试服务:端到端测试必须写进临时目录,否则会把伪造的会话
27
+ * (同一个 header.id 出现在多个 projectKey 下)留在真实会话库里,
28
+ * 让 sessionPersistence 抛 `duplicate JSONL session id`,整块工作区停摆。
29
+ */
30
+ export function sessionsRoot() {
31
+ const override = process.env.DSH_SYNC_SESSIONS_ROOT;
32
+ return override ? path.resolve(override) : path.join(os.homedir(), '.dsh', 'sessions');
33
+ }
34
+
35
+ /** 兼容写法:默认会话库根。读写会话请用 sessionsRoot(),别在模块顶层固化。 */
36
+ export const SESSIONS_ROOT = path.join(os.homedir(), '.dsh', 'sessions');
37
+
38
+ /** 工作区内的相对目录:会话文件夹 / 本机标识。本机只写自己那一格。 */
39
+ export const TARGET_DIR_NAME = path.join('.dsh-sync', 'sessions');
40
+
41
+ /* ---------------------------------------------------------------- 机器标识 */
42
+
43
+ /**
44
+ * 生成一次性特征值。
45
+ *
46
+ * 语义:插件第一次安装时生成一个,之后永久固定,永不重算。
47
+ * 用随机字节而不是主机名 / MachineGuid 复算,这样换主机名、
48
+ * 注册表读不到、甚至重装系统都不会让标识漂移。
49
+ */
50
+ export function generateHash() {
51
+ return crypto.randomBytes(6).toString('hex').slice(0, 12);
52
+ }
53
+
54
+ /** 当前主机名,只作为标识里的「名称」默认值,不参与哈希。 */
55
+ export function hostName() {
56
+ return (os.hostname() || 'DSH').replace(/[^A-Za-z0-9_-]/g, '') || 'DSH';
57
+ }
58
+
59
+ /** 默认标识:<电脑名>-<特征值>。 */
60
+ export function computeMachineId() {
61
+ return `${hostName()}-${generateHash()}`;
62
+ }
63
+
64
+ /* ------------------------------------------------------------- projectKey */
65
+
66
+ /**
67
+ * 由 header.cwd 推出 dsh 的会话目录名。
68
+ * 规则:去掉前导分隔符 → `\` `/` 记作 `-`、`:` 丢弃、非 ASCII 记作 `~XXXX`
69
+ * → 前后各加 `--`。
70
+ */
71
+ export function projectKeyOf(cwd) {
72
+ if (typeof cwd !== 'string' || cwd.length === 0) return null;
73
+ let s = cwd.replace(/^[\\/]+/, '');
74
+ let out = '';
75
+ for (let i = 0; i < s.length; i++) {
76
+ const code = s.charCodeAt(i);
77
+ const ch = s[i];
78
+ if (ch === '\\' || ch === '/') out += '-';
79
+ else if (ch === ':') continue;
80
+ else if (code < 0x80) out += ch;
81
+ else out += '~' + code.toString(16).toUpperCase().padStart(4, '0');
82
+ }
83
+ return `--${out}--`;
84
+ }
85
+
86
+ /* ----------------------------------------------------------------- 路径 */
87
+
88
+ export function normSlash(p) {
89
+ return String(p).replace(/[\\/]+/g, '\\').replace(/\\+$/, '');
90
+ }
91
+
92
+ /** Windows 下大小写不敏感。 */
93
+ export function pathKey(p) {
94
+ const n = normSlash(path.resolve(String(p)));
95
+ return process.platform === 'win32' ? n.toLowerCase() : n;
96
+ }
97
+
98
+ /** target 是否位于 prefix 之内(含相等)。 */
99
+ export function isInside(prefix, target) {
100
+ const a = pathKey(prefix);
101
+ const b = pathKey(target);
102
+ if (a === b) return true;
103
+ return b.startsWith(a + '\\');
104
+ }
105
+
106
+ export function matchAnyPrefix(prefixes, target) {
107
+ for (const p of prefixes || []) {
108
+ if (!p) continue;
109
+ try {
110
+ if (isInside(p, target)) return p;
111
+ } catch {
112
+ /* 无效前缀跳过 */
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+
118
+ /* ------------------------------------------------------------- 状态 */
119
+
120
+ const DEFAULT_STATE = {
121
+ version: 2,
122
+ machineId: '',
123
+ machineHash: '',
124
+ machineName: '',
125
+ folders: [],
126
+ history: [],
127
+ lastBackupAt: 0,
128
+ lastPullAt: 0,
129
+ patrol: {
130
+ enabled: false,
131
+ /** 固定间隔:每 N 分钟巡检一次。 */
132
+ intervalMinutes: 30,
133
+ lastRunAt: 0,
134
+ lastResult: null,
135
+ },
136
+ skills: {
137
+ /** 母版根目录;空表示未配置。 */
138
+ masterRoot: '',
139
+ /** 自动拉取开关。 */
140
+ enabled: false,
141
+ /** 拉取间隔,默认 120 分钟。 */
142
+ intervalMinutes: 120,
143
+ /**
144
+ * 记忆插件(dsh-mnemon)技能优化自动落地;默认开。
145
+ * 关掉后草稿只在账本里堆着,等人到面板上逐个采纳。
146
+ */
147
+ mnemonAuto: true,
148
+ /**
149
+ * 记忆插件技能优化闭环(发现 → 判断 → 入队 → 审核 → 通过);默认开。
150
+ * 关掉后只做草稿落地,不主动发现和处理新建议。
151
+ */
152
+ mnemonOptimize: true,
153
+ /** 分诊时的置信度门槛,低于它的建议不处理。 */
154
+ mnemonMinConfidence: 0.7,
155
+ /** 一轮优化闭环最多起草几条:模型调用要花时间,默认 6 条一批。 */
156
+ mnemonMaxDrafts: 6,
157
+ lastRunAt: 0,
158
+ lastResult: null,
159
+ /**
160
+ * 技能的单机属性(只存本机,不随母版同步):技能名 → 'sync' | 'local' | 'master'
161
+ * sync 同步(双向)
162
+ * local 本机独属(母版不该有它)
163
+ * master 母版有·本机不拉取
164
+ * 未列出的按自动:本机有母版没有 → local,其余 → sync。
165
+ */
166
+ modes: {},
167
+ },
168
+ /**
169
+ * 插件清单共享(特征文件夹)。1.0.0 从 ygc-dsh-plugin-autoupdate 迁移过来:
170
+ * 自动更新器只剩「更新已装插件」,清单共享归到同步插件这一侧。
171
+ *
172
+ * 规矩没变:只写 `插件库\<本机标识>\` 那一格;只存清单不存插件文件;
173
+ * 安装只能按 installSpec 联网拉。
174
+ */
175
+ plugins: {
176
+ /** 每轮巡检顺带同步一次我自己的特征文件夹。 */
177
+ enabled: true,
178
+ /** 插件库根目录;空 = 用默认 `D:\Ai工作空间\插件库`。 */
179
+ libraryRoot: '',
180
+ /** 上次同步时间与结果摘要。 */
181
+ lastSyncAt: 0,
182
+ lastResult: null,
183
+ lastError: '',
184
+ },
185
+ /**
186
+ * 本机特征文件夹的采纳状态(1.2.0 新增)。
187
+ *
188
+ * 判据永远是磁盘:`<工作区>/.dsh-sync/sessions/<本机标识>/` 存在 = 已采纳。
189
+ * 这里只记两样辅助信息:
190
+ * decisions 人对某个路径的表态(adopt / decline),防止每轮重复问;
191
+ * known 上一轮看到的工作区清单,用来判「本机新建」与「本机已删除」。
192
+ */
193
+ features: {
194
+ /** 1.2.0 首次运行是否已把「存量工作区」归过档:没有就一律问人,不自己建。 */
195
+ initialized: false,
196
+ /** 路径(小写归一)→ { action, path, at } */
197
+ decisions: {},
198
+ /** 工作区 id → 路径;只记已采纳过的 */
199
+ known: {},
200
+ lastPlanAt: 0,
201
+ },
202
+ };
203
+
204
+ export function loadState() {
205
+ let state = { ...DEFAULT_STATE };
206
+ /** 磁盘原始结构的标记。只记布尔量,不持有对象引用(避免与 state 共享后被就地改写)。 */
207
+ let diskHas = {
208
+ machineHash: false,
209
+ machineHashValue: '',
210
+ skills: false,
211
+ modes: false,
212
+ mnemonAuto: false,
213
+ patrol: false,
214
+ patrolInterval: false,
215
+ plugins: false,
216
+ features: false,
217
+ };
218
+ try {
219
+ const raw = fs.readFileSync(STATE_FILE, 'utf8');
220
+ const parsed = JSON.parse(raw);
221
+ diskHas = {
222
+ machineHash: typeof parsed.machineHash === 'string' && !!parsed.machineHash,
223
+ machineHashValue: parsed.machineHash || '',
224
+ skills: parsed.skills !== undefined,
225
+ modes: !!(parsed.skills && parsed.skills.modes !== undefined),
226
+ mnemonAuto: !!(parsed.skills && typeof parsed.skills.mnemonAuto === 'boolean'),
227
+ patrol: parsed.patrol !== undefined,
228
+ patrolInterval: !!(parsed.patrol && parsed.patrol.intervalMinutes !== undefined),
229
+ plugins: parsed.plugins !== undefined,
230
+ features: parsed.features !== undefined,
231
+ };
232
+ state = { ...state, ...parsed };
233
+ // skills 与 patrol 需要深拷贝,否则后续就地修改会改到 parsed
234
+ if (parsed.skills && typeof parsed.skills === 'object') {
235
+ state.skills = { ...parsed.skills };
236
+ if (parsed.skills.modes && typeof parsed.skills.modes === 'object') {
237
+ state.skills.modes = { ...parsed.skills.modes };
238
+ }
239
+ }
240
+ if (parsed.patrol && typeof parsed.patrol === 'object') state.patrol = { ...parsed.patrol };
241
+ if (parsed.plugins && typeof parsed.plugins === 'object') state.plugins = { ...parsed.plugins };
242
+ if (parsed.features && typeof parsed.features === 'object') {
243
+ state.features = {
244
+ initialized: parsed.features.initialized === true,
245
+ decisions: { ...(parsed.features.decisions || {}) },
246
+ known: { ...(parsed.features.known || {}) },
247
+ lastPlanAt: Number(parsed.features.lastPlanAt) || 0,
248
+ };
249
+ }
250
+ if (!Array.isArray(state.folders)) state.folders = [];
251
+ if (!Array.isArray(state.history)) state.history = [];
252
+ } catch {
253
+ /* 首次运行 */
254
+ }
255
+ // 老版本的 state 没有这些字段,补齐
256
+ if (!state.patrol || typeof state.patrol !== 'object') {
257
+ state.patrol = { ...DEFAULT_STATE.patrol };
258
+ }
259
+ // 旧版按「每日 HH:MM」调度;迁移成固定间隔,并避免留下无用字段
260
+ if (state.patrol.intervalMinutes === undefined || state.patrol.intervalMinutes === null) {
261
+ state.patrol.intervalMinutes = DEFAULT_STATE.patrol.intervalMinutes;
262
+ }
263
+ if (state.patrol.time !== undefined) delete state.patrol.time;
264
+ const iv = Number(state.patrol.intervalMinutes);
265
+ state.patrol.intervalMinutes =
266
+ Number.isFinite(iv) && iv >= 1 ? Math.min(Math.floor(iv), 1440) : DEFAULT_STATE.patrol.intervalMinutes;
267
+
268
+ // 技能同步配置
269
+ if (!state.skills || typeof state.skills !== 'object') {
270
+ state.skills = { ...DEFAULT_STATE.skills };
271
+ }
272
+ // 旧版字段清理
273
+ if (state.skills.syncedSkills !== undefined) delete state.skills.syncedSkills;
274
+ // 单机属性表:只接受三种合法值
275
+ if (!state.skills.modes || typeof state.skills.modes !== 'object' || Array.isArray(state.skills.modes)) {
276
+ state.skills.modes = {};
277
+ } else {
278
+ const clean = {};
279
+ for (const [k, v] of Object.entries(state.skills.modes)) {
280
+ if (typeof k !== 'string' || !k) continue;
281
+ if (v === 'sync' || v === 'local' || v === 'master') clean[k] = v;
282
+ }
283
+ state.skills.modes = clean;
284
+ }
285
+ const siv = Number(state.skills.intervalMinutes);
286
+ state.skills.intervalMinutes =
287
+ Number.isFinite(siv) && siv >= 1 ? Math.min(Math.floor(siv), 10080) : DEFAULT_STATE.skills.intervalMinutes;
288
+ // 老版本没有这个字段:补成默认值(开)
289
+ if (typeof state.skills.mnemonAuto !== 'boolean') {
290
+ state.skills.mnemonAuto = DEFAULT_STATE.skills.mnemonAuto;
291
+ }
292
+ if (typeof state.skills.mnemonOptimize !== 'boolean') {
293
+ state.skills.mnemonOptimize = DEFAULT_STATE.skills.mnemonOptimize;
294
+ }
295
+ if (!Number.isFinite(Number(state.skills.mnemonMinConfidence))) {
296
+ state.skills.mnemonMinConfidence = DEFAULT_STATE.skills.mnemonMinConfidence;
297
+ }
298
+ if (!Number.isFinite(Number(state.skills.mnemonMaxDrafts)) || Number(state.skills.mnemonMaxDrafts) < 0) {
299
+ state.skills.mnemonMaxDrafts = DEFAULT_STATE.skills.mnemonMaxDrafts;
300
+ }
301
+
302
+ // 插件清单同步配置(1.0.0 新增;老 state 没有这一段)
303
+ if (!state.plugins || typeof state.plugins !== 'object' || Array.isArray(state.plugins)) {
304
+ state.plugins = { ...DEFAULT_STATE.plugins };
305
+ }
306
+ // 默认开:迁移过来的老用户本来就在同步清单,不该因为升级而静默停止
307
+ state.plugins.enabled = state.plugins.enabled !== false;
308
+ state.plugins.libraryRoot =
309
+ typeof state.plugins.libraryRoot === 'string' ? state.plugins.libraryRoot.trim().slice(0, 400) : '';
310
+ state.plugins.lastSyncAt = Number(state.plugins.lastSyncAt) || 0;
311
+ state.plugins.lastResult =
312
+ state.plugins.lastResult && typeof state.plugins.lastResult === 'object' ? state.plugins.lastResult : null;
313
+ state.plugins.lastError = typeof state.plugins.lastError === 'string' ? state.plugins.lastError.slice(0, 800) : '';
314
+
315
+ // 本机特征文件夹的采纳状态(1.2.0 新增;老 state 没有这一段)
316
+ if (!state.features || typeof state.features !== 'object' || Array.isArray(state.features)) {
317
+ state.features = { initialized: false, decisions: {}, known: {}, lastPlanAt: 0 };
318
+ }
319
+ state.features.initialized = state.features.initialized === true;
320
+ if (!state.features.decisions || typeof state.features.decisions !== 'object' || Array.isArray(state.features.decisions)) {
321
+ state.features.decisions = {};
322
+ }
323
+ if (!state.features.known || typeof state.features.known !== 'object' || Array.isArray(state.features.known)) {
324
+ state.features.known = {};
325
+ }
326
+ state.features.lastPlanAt = Number(state.features.lastPlanAt) || 0;
327
+
328
+ if (!state.machineHashGeneratedAt) state.machineHashGeneratedAt = 0;
329
+
330
+ /**
331
+ * 特征值策略(按用户要求):
332
+ * 插件第一次安装时生成一次,此后永久固定,绝不重算。
333
+ * 唯一的迁移来源是旧版本 state 里已经存在的哈希——那是既有目录名,
334
+ * 必须原样继承,否则已备份的会话就找不回来了。
335
+ */
336
+ const hashFromId = String(state.machineId || '').match(/-([0-9a-f]{6,})$/i);
337
+ const legacyHash = state.machineHash || (hashFromId ? hashFromId[1].toLowerCase() : '');
338
+ const nameFromId = String(state.machineId || '').match(/^(.*)-[0-9a-f]{6,}$/i);
339
+
340
+ if (!legacyHash) {
341
+ // 真正的首次安装:生成并固定
342
+ state.machineHash = generateHash();
343
+ state.machineHashGeneratedAt = Date.now();
344
+ } else if (state.machineHash !== legacyHash) {
345
+ // 继承旧值,不改写
346
+ state.machineHash = legacyHash;
347
+ }
348
+
349
+ // 名称:machineId 里的名称部分是既有目录名的真相,优先于独立字段
350
+ if (nameFromId) state.machineName = nameFromId[1];
351
+ else if (!state.machineName) state.machineName = hostName();
352
+ state.machineId = joinId(state.machineName, state.machineHash);
353
+
354
+ // 落盘条件(用磁盘原始标记判断,不能看 state——它已被就地改写):
355
+ // 1) 首次安装 / 特征值继承
356
+ // 2) 文件不存在
357
+ // 3) 结构缺字段(老版本升级)——保证磁盘与内存一致
358
+ const needsWrite =
359
+ !diskHas.machineHash ||
360
+ diskHas.machineHashValue !== state.machineHash ||
361
+ !fs.existsSync(STATE_FILE) ||
362
+ !diskHas.skills ||
363
+ !diskHas.modes ||
364
+ !diskHas.mnemonAuto ||
365
+ !diskHas.patrol ||
366
+ !diskHas.patrolInterval ||
367
+ !diskHas.features;
368
+
369
+ if (needsWrite) saveState(state);
370
+ return state;
371
+ }
372
+
373
+ /** 拼标识:空名用哈希本身。与 patrol.js 的规则保持一致。 */
374
+ export function joinId(name, hash) {
375
+ const n = String(name || '')
376
+ .trim()
377
+ .replace(/[\\/:*?"<>|\s]+/g, '_')
378
+ .replace(/^-+|-+$/g, '');
379
+ const h = String(hash || '').trim();
380
+ if (!h) return n;
381
+ return n ? `${n}-${h}` : h;
382
+ }
383
+
384
+ export function saveState(state) {
385
+ fs.mkdirSync(STATE_DIR, { recursive: true });
386
+ const tmp = STATE_FILE + '.tmp';
387
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8');
388
+ fs.renameSync(tmp, STATE_FILE);
389
+ return state;
390
+ }
391
+
392
+ export function pushHistory(state, kind, detail, limit = 60) {
393
+ state.history.unshift({ at: Date.now(), kind, detail });
394
+ if (state.history.length > limit) state.history.length = limit;
395
+ }
396
+
397
+ /* ------------------------------------------------------- 写权限边界 */
398
+
399
+ export class WriteDeniedError extends Error {
400
+ constructor(target, reason) {
401
+ super(`拒绝写入:${target}(${reason})`);
402
+ this.name = 'WriteDeniedError';
403
+ this.code = 'WRITE_DENIED';
404
+ this.target = target;
405
+ }
406
+ }
407
+
408
+ /** 本机允许写入的根:<工作区>/.dsh-sync/machine-sessions/<机器标识>/ */
409
+ export function ownRoot(workspacePath, machineId) {
410
+ return path.join(normSlash(workspacePath), TARGET_DIR_NAME, machineId);
411
+ }
412
+
413
+ /**
414
+ * 唯一写入口的守卫。所有写操作必须先过这里,
415
+ * 目标不在 <工作区>/.dsh-sync/machine-sessions/<本机标识>/ 下就抛错。
416
+ */
417
+ export function assertWritable(target, { workspacePath, machineId }) {
418
+ if (!workspacePath) throw new WriteDeniedError(target, '缺少工作区路径');
419
+ if (!machineId) throw new WriteDeniedError(target, '缺少本机标识');
420
+ const allowed = ownRoot(workspacePath, machineId);
421
+ const key = pathKey(target);
422
+ const rootKey = pathKey(allowed);
423
+ if (!(key === rootKey || key.startsWith(rootKey + '\\'))) {
424
+ throw new WriteDeniedError(target, `只允许写 ${allowed}`);
425
+ }
426
+ return target;
427
+ }
428
+
429
+ /** 安全写文件:先写临时文件再 rename,且强制走写边界。 */
430
+ export function writeGuarded(target, data, ctx) {
431
+ assertWritable(target, ctx);
432
+ fs.mkdirSync(path.dirname(target), { recursive: true });
433
+ const tmp = `${target}.tmp-${process.pid}`;
434
+ fs.writeFileSync(tmp, data);
435
+ fs.renameSync(tmp, target);
436
+ return target;
437
+ }
438
+
439
+ /* ------------------------------------------------------------ 小工具 */
440
+
441
+ export function humanBytes(n) {
442
+ if (!Number.isFinite(n) || n < 0) return '0 B';
443
+ const units = ['B', 'KB', 'MB', 'GB'];
444
+ let v = n;
445
+ let i = 0;
446
+ while (v >= 1024 && i < units.length - 1) {
447
+ v /= 1024;
448
+ i++;
449
+ }
450
+ return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
451
+ }
452
+
453
+ export function safeStat(p) {
454
+ try {
455
+ return fs.statSync(p);
456
+ } catch {
457
+ return null;
458
+ }
459
+ }
460
+
461
+ export function listDirs(p) {
462
+ try {
463
+ return fs
464
+ .readdirSync(p, { withFileTypes: true })
465
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
466
+ .map((d) => d.name);
467
+ } catch {
468
+ return [];
469
+ }
470
+ }
471
+
472
+ /** 找出某会话目录下的会话日志文件。 */
473
+ export function findSessionLog(dir) {
474
+ let names;
475
+ try {
476
+ names = fs.readdirSync(dir);
477
+ } catch {
478
+ return null;
479
+ }
480
+ const exact = names.find((n) => /^session\..*\.zstd$/.test(n));
481
+ if (exact) return path.join(dir, exact);
482
+ const anyZstd = names.find((n) => n.endsWith('.zstd'));
483
+ return anyZstd ? path.join(dir, anyZstd) : null;
484
+ }