ygc-dsh-sync 1.1.0 → 1.2.1

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/lib/api.js CHANGED
@@ -1,1510 +1,1815 @@
1
- /**
2
- * Host 侧的 HTTP API。
3
- * 路由:/api/local/dsh-sync-backup/*
4
- */
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
- import {
8
- loadState,
9
- saveState,
10
- pushHistory,
11
- computeMachineId,
12
- normSlash,
13
- humanBytes,
14
- pathKey,
15
- } from './core.js';
16
- import {
17
- backupWorkspace,
18
- listMachines,
19
- pickWorkspaces,
20
- previewPull,
21
- projectKeysFor,
22
- pullWorkspace,
23
- scanLocalSessions,
24
- } from './backup.js';
25
- import { attachMany, attachedIds, findPulledUnattached, findUnattached } from './attach.js';
26
- import { ensureOwnFolder, joinMachineId, repairAll, scanFolders, splitMachineId } from './patrol.js';
27
- import {
28
- LINEAGE_FILE,
29
- LOCAL_SKILLS_ROOT,
30
- applyModeChange,
31
- applyPull,
32
- applyPush,
33
- buildHistoryIndex,
34
- diffSummary,
35
- judgeUpdate,
36
- loadLineage,
37
- makeStamp,
38
- planSync,
39
- readMnemonLedger,
40
- recordLineage,
41
- resolveMode,
42
- saveLineage,
43
- scanSkills,
44
- } from './skills.js';
45
- import {
46
- appendMasterChanges,
47
- changelogPath,
48
- ensureChangelog,
49
- readChangelog,
50
- readChangelogTimes,
51
- } from './changelog.js';
52
- import { runMnemonPrepass } from './mnemon.js';
53
- import {
54
- runOptimizationCycle,
55
- readQueue,
56
- triage,
57
- discoverOptimizations,
58
- auditDraft,
59
- MNEMON_LEDGER,
60
- } from './mnemon-optimize.js';
61
- import { createHostDrafter } from './mnemon-drafter.js';
62
- import {
63
- SHARE_POLICY,
64
- installSharedPlugin,
65
- libraryRoot as pluginsLibraryRoot,
66
- shareSnapshot,
67
- } from './plugins.js';
68
-
69
- export const API_PREFIX = '/api/local/dsh-sync-backup';
70
-
71
- /** 三种单机属性的中文名。 */
72
- export const MODE_LABEL = {
73
- sync: '同步',
74
- local: '本机独属',
75
- master: '母版有·本机不拉取',
76
- };
77
-
78
- const json = (res, code, body) => {
79
- const text = JSON.stringify(body);
80
- res.writeHead(code, {
81
- 'content-type': 'application/json; charset=utf-8',
82
- 'cache-control': 'no-store',
83
- });
84
- res.end(text);
85
- };
86
-
87
- async function readJson(req) {
88
- const chunks = [];
89
- for await (const chunk of req) chunks.push(chunk);
90
- if (!chunks.length) return {};
91
- try {
92
- return JSON.parse(Buffer.concat(chunks).toString('utf8'));
93
- } catch {
94
- return {};
95
- }
96
- }
97
-
98
- /**
99
- * @param {{ workspaces: () => Array<{id:string,path:string,title:string}>,
100
- * machineId: () => string,
101
- * registerWorkspace?: (path:string, title:string) => Promise<{id:string,created:boolean}>,
102
- * wsEntity?: (id:string) => ({attachSession:(sid:string)=>Promise<void>, sessionIds:string[]}|undefined),
103
- * entities?: () => Array<{id:string,sessionIds?:string[]}> }} deps
104
- */
105
- /** 手动触发的那一轮的运行态:路由异步化之后,进度只能从这里读。 */
106
- const optimizeRun = { running: false, startedAt: null, finishedAt: null, lastReport: null, lastError: null };
107
-
108
- export function createApi(deps) {
109
- /**
110
- * 起草器:宿主的 LLM。没有就返回 null,流水线会跳过起草阶段(只审核已有草稿)。
111
- * 每轮现取现造,不缓存 —— 宿主换模型后下一轮就跟着换。
112
- */
113
- const drafter = () => (typeof deps.drafter === 'function' ? deps.drafter() : null);
114
-
115
- /**
116
- * 日志:api.js 是模块层,拿不到 index.js 里的 log。必须由 deps 注入。
117
- * 不注入就静默 —— 静默比 ReferenceError 好:后者会让整条路由 500,
118
- * 前端只看到「点了没反应」(实测就是这么被投诉的)。
119
- */
120
- const log = (m) => {
121
- try {
122
- if (typeof deps.log === 'function') deps.log(m);
123
- } catch {
124
- /* 日志失败不能影响主流程 */
125
- }
126
- };
127
- const listWorkspaces = () =>
128
- typeof deps.workspaces === 'function' ? deps.workspaces() : [];
129
-
130
- /** 取某个工作区实体(能 attachSession 的那个),拿不到就返回 undefined。 */
131
- const wsEntity = (id) =>
132
- typeof deps.wsEntity === 'function' ? deps.wsEntity(id) : undefined;
133
-
134
- /** 全部工作区实体,用于判断某个 sessionId 是否已有归属。 */
135
- const entities = () =>
136
- typeof deps.entities === 'function' ? deps.entities() || [] : [];
137
-
138
- /** 本机某工作区路径对应的 projectKey 集合。 */
139
- const projectKeysOf = (p) => projectKeysFor(p);
140
-
141
- /**
142
- * 判新旧要的三份只读证据。
143
- *
144
- * lineage :母版根目录的 `.skill-lineage.json`,同步插件自己的观测记录——
145
- * 每侧内容何时变成现在这样、上次一致时的内容树是哪份。
146
- * mnemonLedger:`~/.mnemon/skills/ledger.json`,记忆插件的技能账本,
147
- * 里面每条 hash 就是 SKILL.md 的 sha256,reason='evolution'
148
- * 就是它改的。只读,不写。
149
- * history :母版 `_backup/`、本机 `.pre-pull-*`、记忆插件 `versions/`
150
- * 里的历史全文,按内容哈希索引出「这份内容何时出现过」。
151
- */
152
- const judgeEvidence = (masterRoot) => ({
153
- lineage: masterRoot ? loadLineage(path.join(masterRoot, LINEAGE_FILE)) : {},
154
- mnemonLedger: readMnemonLedger(),
155
- history: buildHistoryIndex({
156
- masterRoot,
157
- localRoot: LOCAL_SKILLS_ROOT,
158
- changelogTimes: masterRoot ? readChangelogTimes(masterRoot) : null,
159
- }),
160
- });
161
-
162
- /**
163
- * 记录一次比对观测:两侧各自当前是什么内容、各自何时变成这样。
164
- *
165
- * 这是「内容修改时间」的唯一来源——不是 mtime(同步/NAS 搬运都会刷),
166
- * 而是同步插件真正看到内容变化的那一刻。内容没变则时间保持不动。
167
- *
168
- * 两侧内容一致时顺手把 baseline 更新成这一份:基线就是「上次两侧一致的
169
- * 内容树」,只有一致的时候才有意义。
170
- */
171
- const observeLineage = (masterRoot, plan, by) => {
172
- if (!masterRoot) return 0;
173
- const file = path.join(masterRoot, LINEAGE_FILE);
174
- let map = loadLineage(file);
175
- const now = Date.now();
176
- let touched = 0;
177
- for (const [name, m] of plan.master) {
178
- const l = plan.local.get(name);
179
- if (!l) continue; // 只有一侧有,没有可比性
180
- const same = m.treeHash === l.treeHash;
181
- map = recordLineage(map, {
182
- name,
183
- ...(same ? { baseline: m.treeHash } : {}),
184
- master: m,
185
- local: l,
186
- by: by || '',
187
- at: now,
188
- });
189
- touched++;
190
- }
191
- return saveLineage(file, map) ? touched : 0;
192
- };
193
-
194
- /** 同步真正落地之后,把两侧一致的内容登记成新基线。 */
195
- const stampBaseline = (masterRoot, entries, by) => {
196
- if (!masterRoot || !entries.length) return 0;
197
- const file = path.join(masterRoot, LINEAGE_FILE);
198
- let map = loadLineage(file);
199
- for (const e of entries) {
200
- if (!e) continue;
201
- map = recordLineage(map, {
202
- name: e.name,
203
- baseline: e.treeHash,
204
- master: e,
205
- local: e,
206
- by: by || '',
207
- });
208
- }
209
- return saveLineage(file, map) ? entries.length : 0;
210
- };
211
-
212
- const routes = {
213
- /** 全量状态:配置、匹配工作区、备份统计、其他机器。 */
214
- 'GET /state': async (req, res) => {
215
- const state = loadState();
216
- const machineId = deps.machineId() || state.machineId;
217
- const all = listWorkspaces();
218
- const matched = pickWorkspaces(all, state.folders);
219
- const localIndex = scanLocalSessions();
220
-
221
- const workspaces = matched.map((ws) => {
222
- const machines = listMachines(ws.path);
223
- const own = machines.find((m) => m.machineId === machineId);
224
- const others = machines.filter((m) => m.machineId !== machineId);
225
- return {
226
- id: ws.id,
227
- path: ws.path,
228
- title: ws.title || ws.path,
229
- matchedPrefix: ws.matchedPrefix,
230
- own: own ? { sessions: own.sessions, bytes: own.bytes, newest: own.newest } : { sessions: 0, bytes: 0, newest: 0 },
231
- others: others.map((o) => ({
232
- machineId: o.machineId,
233
- sessions: o.sessions,
234
- bytes: o.bytes,
235
- newest: o.newest,
236
- })),
237
- };
238
- });
239
-
240
- json(res, 200, {
241
- ok: true,
242
- machineId,
243
- machineName: state.machineName,
244
- machineHash: state.machineHash,
245
- machineIdSuggested: computeMachineId(),
246
- folders: state.folders,
247
- lastBackupAt: state.lastBackupAt,
248
- lastPullAt: state.lastPullAt,
249
- patrol: state.patrol,
250
- skills: { ...state.skills, localRoot: LOCAL_SKILLS_ROOT },
251
- // 插件清单共享(1.0.0 从自动更新器迁来):配置 + 运行态 + 口径声明
252
- plugins: {
253
- ...state.plugins,
254
- // 面板没填时告诉用户实际会落到哪,避免「留空 = 不知道去哪」
255
- effectiveRoot: pluginsLibraryRoot({ libraryRoot: state.plugins.libraryRoot }),
256
- policy: SHARE_POLICY,
257
- },
258
- history: state.history.slice(0, 20),
259
- workspaceCount: all.length,
260
- matchedCount: workspaces.length,
261
- workspaces,
262
- totals: {
263
- ownSessions: workspaces.reduce((n, w) => n + w.own.sessions, 0),
264
- ownBytes: workspaces.reduce((n, w) => n + w.own.bytes, 0),
265
- otherSessions: workspaces.reduce(
266
- (n, w) => n + w.others.reduce((m, o) => m + o.sessions, 0),
267
- 0,
268
- ),
269
- },
270
- });
271
- },
272
-
273
- /** 保存配置:文件夹前缀 / 机器标识。 */
274
- 'POST /config': async (req, res) => {
275
- const body = await readJson(req);
276
- const state = loadState();
277
- if (Array.isArray(body.folders)) {
278
- const seen = new Set();
279
- state.folders = [];
280
- for (const raw of body.folders) {
281
- if (typeof raw !== 'string') continue;
282
- const p = normSlash(raw.trim());
283
- if (!p) continue;
284
- const k = pathKey(p);
285
- if (seen.has(k)) continue;
286
- seen.add(k);
287
- state.folders.push(p);
288
- }
289
- }
290
- if (typeof body.machineName === 'string') {
291
- // 只改名称部分,哈希固定不变
292
- state.machineName = body.machineName.replace(/[\\/:*?"<>|\s]+/g, '_').trim();
293
- state.machineHash = deps.machineHash ? deps.machineHash() || state.machineHash : state.machineHash;
294
- state.machineId = joinMachineId(state.machineName, state.machineHash);
295
- }
296
- if (typeof body.machineId === 'string' && body.machineId.trim() && body.machineName === undefined) {
297
- // 兼容:直接给完整标识时,拆出名称,哈希仍以本地为准
298
- const parsed = splitMachineId(body.machineId.trim(), state.machineHash);
299
- state.machineName = parsed.name.replace(/[\\/:*?"<>|\s]+/g, '_');
300
- state.machineId = joinMachineId(state.machineName, state.machineHash);
301
- }
302
- saveState(state);
303
- json(res, 200, {
304
- ok: true,
305
- folders: state.folders,
306
- machineId: state.machineId,
307
- machineName: state.machineName,
308
- machineHash: state.machineHash,
309
- });
310
- },
311
-
312
- /** 立即备份:全部匹配工作区,或指定 id 列表。 */
313
- 'POST /backup': async (req, res) => {
314
- const body = await readJson(req);
315
- const state = loadState();
316
- const machineId = deps.machineId() || state.machineId;
317
- const all = listWorkspaces();
318
- const matched = pickWorkspaces(all, state.folders);
319
- const chosen =
320
- Array.isArray(body.workspaceIds) && body.workspaceIds.length
321
- ? matched.filter((w) => body.workspaceIds.includes(w.id))
322
- : matched;
323
-
324
- const localIndex = scanLocalSessions();
325
- const results = [];
326
- for (const ws of chosen) {
327
- results.push(backupWorkspace({ workspace: ws, machineId, localIndex }));
328
- }
329
-
330
- const created = results.reduce((n, r) => n + r.created, 0);
331
- const updated = results.reduce((n, r) => n + r.updated, 0);
332
- const bytes = results.reduce((n, r) => n + r.bytes, 0);
333
-
334
- const next = loadState();
335
- next.lastBackupAt = Date.now();
336
- pushHistory(
337
- next,
338
- 'backup',
339
- `备份 ${chosen.length} 个工作区:新增 ${created}、更新 ${updated}、${humanBytes(bytes)}`,
340
- );
341
- saveState(next);
342
-
343
- json(res, 200, {
344
- ok: true,
345
- results,
346
- summary: { workspaces: chosen.length, created, updated, bytes },
347
- });
348
- },
349
-
350
- /** 一键拉取:多选工作区。 */
351
- 'POST /pull': async (req, res) => {
352
- const body = await readJson(req);
353
- const state = loadState();
354
- const machineId = deps.machineId() || state.machineId;
355
- const all = listWorkspaces();
356
- const matched = pickWorkspaces(all, state.folders);
357
- const chosen =
358
- Array.isArray(body.workspaceIds) && body.workspaceIds.length
359
- ? matched.filter((w) => body.workspaceIds.includes(w.id))
360
- : matched;
361
-
362
- /**
363
- * 有备份、但没注册成工作区的目录。
364
- *
365
- * 这是「拉取后显示在未分组」最常见的原因之一:目录里有别的机器的
366
- * 会话备份,可它压根不在 workspaceRegistry 里,/pull 的匹配范围就没有它。
367
- * 用户点「一键拉取」本身就是明确意图,这里先注册再拉。
368
- */
369
- const autoRegister = body.autoRegister !== false;
370
- const registered = [];
371
- const registerErrors = [];
372
- if (autoRegister && typeof deps.registerWorkspace === 'function') {
373
- const scan = scanFolders({
374
- folders: state.folders,
375
- workspaces: all,
376
- machineId,
377
- });
378
- for (const item of scan.discover) {
379
- // 只自动处理「确实有别的机器的备份」那些,别把空壳目录也注册进来
380
- const hasRemote = listMachines(item.path).some((m) => m.machineId !== machineId);
381
- if (!hasRemote) continue;
382
- try {
383
- const out = await deps.registerWorkspace(item.path, item.title);
384
- registered.push({ path: item.path, id: out?.id });
385
- all.push({ id: out?.id, path: item.path, title: item.title });
386
- } catch (err) {
387
- registerErrors.push({ path: item.path, message: String((err && err.message) || err) });
388
- }
389
- }
390
- }
391
-
392
- // 注册完重新匹配一次,把新注册的目录纳入拉取范围
393
- const matched2 = pickWorkspaces(all, state.folders);
394
- const chosen2 =
395
- Array.isArray(body.workspaceIds) && body.workspaceIds.length
396
- ? matched2.filter((w) =>
397
- body.workspaceIds.includes(w.id) ||
398
- registered.some((r) => r.path === w.path),
399
- )
400
- : matched2;
401
-
402
- const localIndex = scanLocalSessions();
403
- const results = [];
404
- for (const ws of chosen2) {
405
- const r = pullWorkspace({
406
- workspace: ws,
407
- machineId,
408
- localIndex,
409
- machines: Array.isArray(body.machines) && body.machines.length ? body.machines : null,
410
- });
411
-
412
- // 关键一步:把刚落盘的会话登记进工作区归属账。
413
- // 只写文件不登记,DSH 侧栏只会把它显示成「未分组」。
414
- const entity = wsEntity(ws.id);
415
- if (r.newSessionIds.length) {
416
- const att = await attachMany(entity, r.newSessionIds);
417
- r.attached = att.attached;
418
- r.attachFailed = att.failed;
419
- } else {
420
- r.attached = [];
421
- r.attachFailed = [];
422
- }
423
- results.push(r);
424
- }
425
-
426
- const imported = results.reduce((n, r) => n + r.imported, 0);
427
- const updated = results.reduce((n, r) => n + r.updated, 0);
428
- const forked = results.reduce((n, r) => n + r.forked, 0);
429
- const attached = results.reduce((n, r) => n + (r.attached || []).length, 0);
430
- const attachFailed = results.reduce((n, r) => n + (r.attachFailed || []).length, 0);
431
-
432
- const next = loadState();
433
- next.lastPullAt = Date.now();
434
- pushHistory(
435
- next,
436
- 'pull',
437
- `拉取 ${chosen2.length} 个工作区:新增 ${imported}、更新 ${updated}、分叉 ${forked}、登记 ${attached}${
438
- attachFailed ? `、登记失败 ${attachFailed}` : ''
439
- }${registered.length ? `、自动注册 ${registered.length}` : ''}`,
440
- );
441
- saveState(next);
442
-
443
- json(res, 200, {
444
- ok: true,
445
- results,
446
- registered,
447
- registerErrors,
448
- summary: {
449
- workspaces: chosen2.length,
450
- imported,
451
- updated,
452
- forked,
453
- attached,
454
- attachFailed,
455
- registered: registered.length,
456
- },
457
- // 全部登记成功就不需要重开会话索引:会话列表是实时读盘 + 归属表的
458
- needsRestart: imported + forked > attached,
459
- });
460
- },
461
-
462
- /**
463
- * 补登记:把「本插件拉进来、但没进归属账」的会话补进 sessionIds。
464
- *
465
- * 这是历史遗留数据的清理入口——旧版拉取只写文件不登记,
466
- * 那些会话至今仍散在「未分组」里。
467
- *
468
- * 默认只补拉取产物(别的机器备份目录里有它),不会把自动化插件和
469
- * 子代理留下的会话一起刷进来。要全量补必须显式传 all:true。
470
- */
471
- 'POST /attach/repair': async (req, res) => {
472
- const body = await readJson(req);
473
- const state = loadState();
474
- const machineId = deps.machineId() || state.machineId;
475
- const all = listWorkspaces();
476
- const matched = pickWorkspaces(all, state.folders);
477
- const chosen =
478
- Array.isArray(body.workspaceIds) && body.workspaceIds.length
479
- ? matched.filter((w) => body.workspaceIds.includes(w.id))
480
- : matched;
481
-
482
- const localIndex = scanLocalSessions();
483
- const ents = entities();
484
- const scopeAll = body.all === true;
485
- const results = [];
486
-
487
- for (const ws of chosen) {
488
- const entity = wsEntity(ws.id);
489
- if (!entity) {
490
- results.push({ id: ws.id, title: ws.title || ws.path, attached: [], skipped: '当前环境不支持登记会话归属' });
491
- continue;
492
- }
493
- const missing = scopeAll
494
- ? findUnattached({ workspace: ws, entities: ents, localIndex, projectKeysFor })
495
- : findPulledUnattached({
496
- workspace: ws,
497
- machineId,
498
- entities: ents,
499
- localIndex,
500
- projectKeysFor,
501
- });
502
- const att = await attachMany(entity, missing);
503
- results.push({
504
- id: ws.id,
505
- title: ws.title || ws.path,
506
- scope: scopeAll ? 'all' : 'pulled',
507
- candidates: missing.length,
508
- attached: att.attached,
509
- failed: att.failed,
510
- });
511
- }
512
-
513
- const total = results.reduce((n, r) => n + (r.attached || []).length, 0);
514
- const failed = results.reduce((n, r) => n + ((r.failed && r.failed.length) || 0), 0);
515
-
516
- const next = loadState();
517
- if (total || failed) {
518
- pushHistory(next, 'attach', `补登记归属:成功 ${total}、失败 ${failed}`);
519
- saveState(next);
520
- }
521
-
522
- json(res, 200, {
523
- ok: true,
524
- scope: scopeAll ? 'all' : 'pulled',
525
- results,
526
- summary: { attached: total, failed },
527
- });
528
- },
529
-
530
- /** 巡检:发现待注册工作区 + 缺本机特征文件夹的。只扫描,不改任何东西。 */
531
- 'POST /patrol/scan': async (req, res) => {
532
- const state = loadState();
533
- const machineId = deps.machineId() || state.machineId;
534
- const result = scanFolders({
535
- folders: state.folders,
536
- workspaces: listWorkspaces(),
537
- machineId,
538
- });
539
-
540
- const next = loadState();
541
- next.patrol = { ...next.patrol, lastRunAt: Date.now(), lastResult: {
542
- discover: result.discover.length,
543
- repair: result.repair.length,
544
- } };
545
- saveState(next);
546
-
547
- json(res, 200, { ok: true, ...result, machineId });
548
- },
549
-
550
- /** 注册:把选中的目录注册为工作区(用户确认后才调用)。 */
551
- 'POST /patrol/register': async (req, res) => {
552
- const body = await readJson(req);
553
- const state = loadState();
554
- const machineId = deps.machineId() || state.machineId;
555
- const paths = Array.isArray(body.paths) ? body.paths.filter((p) => typeof p === 'string') : [];
556
- if (!paths.length) {
557
- json(res, 400, { ok: false, error: '未选择任何目录' });
558
- return;
559
- }
560
- if (typeof deps.registerWorkspace !== 'function') {
561
- json(res, 503, { ok: false, error: '当前环境不支持注册工作区' });
562
- return;
563
- }
564
-
565
- const registered = [];
566
- const errors = [];
567
- const existing = new Set(listWorkspaces().map((w) => pathKey(w.path)));
568
-
569
- for (const p of paths) {
570
- try {
571
- if (existing.has(pathKey(p))) {
572
- registered.push({ path: p, skipped: '已注册' });
573
- continue;
574
- }
575
- const out = await deps.registerWorkspace(p, path.basename(p));
576
- // 注册完顺手补齐本机特征文件夹
577
- const target = ensureOwnFolder(p, machineId);
578
- registered.push({ path: p, id: out?.id, created: !!out?.created, own: target });
579
- } catch (err) {
580
- errors.push({ path: p, message: String((err && err.message) || err) });
581
- }
582
- }
583
-
584
- const next = loadState();
585
- pushHistory(next, 'register', `注册 ${registered.length} 个工作区`);
586
- saveState(next);
587
-
588
- json(res, 200, { ok: true, registered, errors });
589
- },
590
-
591
- /** 补全:给缺本机特征文件夹的工作区补上。 */
592
- 'POST /patrol/repair': async (req, res) => {
593
- const body = await readJson(req);
594
- const state = loadState();
595
- const machineId = deps.machineId() || state.machineId;
596
- const all = listWorkspaces();
597
- const targets = Array.isArray(body.paths) && body.paths.length
598
- ? all.filter((w) => body.paths.some((p) => pathKey(p) === pathKey(w.path)))
599
- : pickWorkspaces(all, state.folders);
600
-
601
- const out = repairAll(targets, machineId);
602
- const next = loadState();
603
- pushHistory(next, 'repair', `补全 ${out.created.length} 个特征文件夹`);
604
- saveState(next);
605
-
606
- json(res, 200, { ok: true, ...out });
607
- },
608
-
609
- /** 定时巡检设置。 */
610
- 'POST /patrol/config': async (req, res) => {
611
- const body = await readJson(req);
612
- const state = loadState();
613
- const patrol = { ...state.patrol };
614
-
615
- if (typeof body.enabled === 'boolean') patrol.enabled = body.enabled;
616
- if (body.intervalMinutes !== undefined) {
617
- const n = Number(body.intervalMinutes);
618
- if (!Number.isFinite(n) || n < 1 || n > 1440) {
619
- json(res, 400, { ok: false, error: '间隔应为 1–1440 之间的整数(分钟)' });
620
- return;
621
- }
622
- patrol.intervalMinutes = Math.floor(n);
623
- }
624
- state.patrol = patrol;
625
- saveState(state);
626
- json(res, 200, { ok: true, patrol: state.patrol });
627
- },
628
-
629
- /* ---------------- 技能同步 ---------------- */
630
-
631
- /**
632
- * 比对 + 自动同步。
633
- *
634
- * 语义(用户 2026-11-01 定):同步属性的技能,比对时就把能定下来的更新做掉——
635
- * 母版更新就拉下来,本机更新就推上去。只有**判不出谁新**的才留给用户裁决。
636
- *
637
- * body.auto === false 时退化为纯比对(只报告,不动任何文件)。
638
- */
639
- 'POST /skills/scan': async (req, res) => {
640
- const body = await readJson(req).catch(() => ({}));
641
- const auto = body && body.auto === false ? false : true;
642
- const dryRunFlag = body?.dryRun === true;
643
- const state = loadState();
644
- const master = state.skills.masterRoot;
645
- if (!master) {
646
- json(res, 400, { ok: false, error: '尚未设置技能母版文件夹' });
647
- return;
648
- }
649
- if (!fs.existsSync(master)) {
650
- json(res, 400, { ok: false, error: `母版文件夹不存在或不可访问:${master}` });
651
- return;
652
- }
653
-
654
- // ── 阶段 0:记忆插件技能优化闭环(发现 → 起草 → 审核 → 落地 → 结案)
655
- // 0.8.0 起这一步会写账本(结案)并调模型起草;dryRun 时只报告。
656
- const optimize = auto
657
- ? await runOptimizationCycle({
658
- localRoot: LOCAL_SKILLS_ROOT,
659
- masterRoot: master,
660
- stageDir: process.env.DSH_SYNC_STAGE_DIR || 'C:/DSH-Work/mnemon-drafts/stage',
661
- minConfidence: Number(state.skills.mnemonMinConfidence) || 0.7,
662
- enabled: state.skills.mnemonOptimize !== false,
663
- // 比对/巡检必须自己把「生成草案」这一步走完。
664
- //
665
- // 历史:这里曾经写死 `draft: null`,理由是「比对是同步语义,起草要调模型会卡住」。
666
- // 结果是任何走比对路径的巡检(面板点「比对」、开机首拉、每 120 分钟的定时轮)
667
- // 都只发现建议、从不起草,建议永远停在 pending;只有单独点「立即优化」
668
- // 或走定时器那条后台链路才会起草。用户要的是「巡检发现待处理就自己处理完」,
669
- // 所以把起草器接回来,由 body 控制:
670
- // body.draft === false → 只比对(显式要求纯比对时才这样)
671
- // body.dryRun === true → 不调模型(预演)
672
- // 其余情况一律带起草器,让「发现 → 起草 → 审核 → 落地 → 结案」在一条路径里跑完。
673
- draft: dryRunFlag || body?.draft === false ? null : drafter(),
674
- maxDrafts: Number(state.skills.mnemonMaxDrafts) || 6,
675
- onEvent: (kind, payload) => log(`优化闭环 ${kind}: ${JSON.stringify(payload)}`),
676
- })
677
- : { enabled: false, skipped: 'auto=false', discovered: { suggestions: 0, drafts: 0 }, todo: [], dropped: [], enqueued: [], audited: [], accepted: [], errors: [] };
678
-
679
- // 记忆插件草稿预处理:auto 时先落地再比对,比对看到的就是落地后的内容
680
- const prepass = auto
681
- ? runMnemonPrepass({
682
- masterRoot: master,
683
- localRoot: LOCAL_SKILLS_ROOT,
684
- modes: state.skills.modes || {},
685
- machineId: state.machineId,
686
- machineName: state.machineName,
687
- enabled: state.skills.mnemonAuto !== false,
688
- })
689
- : { enabled: false, drafts: 0, apply: [], skipped: [], applied: [], pushed: [], errors: [] };
690
-
691
- const plan = planSync({
692
- masterRoot: master,
693
- localRoot: LOCAL_SKILLS_ROOT,
694
- modes: state.skills.modes || {},
695
- ...judgeEvidence(master),
696
- });
697
-
698
- // 比对本身就是一次观测:记下两侧各自现在是什么内容、何时变成这样
699
- const next = loadState();
700
- const observed = observeLineage(master, plan, next.machineId);
701
-
702
- const stamp = makeStamp();
703
- let pulled = { pulled: [], errors: [] };
704
- let pushed = { pushed: [], errors: [] };
705
-
706
- if (auto && (plan.pull.length || plan.autoPush.length)) {
707
- // 母版更新 → 拉到本机
708
- if (plan.pull.length) {
709
- pulled = applyPull({ items: plan.pull, localRoot: LOCAL_SKILLS_ROOT, stamp });
710
- }
711
- // 本机更新(同步属性)→ 推回母版,旧版先存档
712
- if (plan.autoPush.length) {
713
- const backRoot = path.join(master, '_backup');
714
- pushed = applyPush({ items: plan.autoPush, masterRoot: master, backRoot, stamp });
715
- const auditItems = pushed.pushed.map((p) => ({
716
- skill: p.name,
717
- operation: p.operation,
718
- detail: `${p.operation === '覆盖' ? `旧版备份于 _backup/${p.name}.${stamp}` : '母版此前无此技能'}(自动:内容更新)`,
719
- }));
720
- if (auditItems.length) {
721
- try {
722
- appendMasterChanges(auditItems, {
723
- masterRoot: master,
724
- machineId: next.machineId,
725
- machineName: next.machineName,
726
- trigger: '自动同步',
727
- });
728
- } catch {
729
- /* 记录写不进去不影响同步本身 */
730
- }
731
- }
732
- }
733
- // 同步过的内容现在两侧一致,登记成新基线
734
- const aligned = [
735
- ...plan.pull.map((x) => x.master),
736
- ...plan.autoPush.map((x) => x.local),
737
- ...plan.same.map((x) => x.master),
738
- ].filter(Boolean);
739
- if (aligned.length) {
740
- let map = loadLineage(path.join(master, LINEAGE_FILE));
741
- for (const e of aligned) {
742
- map = recordLineage(map, { name: e.name, baseline: e.treeHash, master: e, local: e, by: next.machineId });
743
- }
744
- saveLineage(path.join(master, LINEAGE_FILE), map);
745
- }
746
- }
747
-
748
- next.skills.lastRunAt = Date.now();
749
- next.skills.lastResult = {
750
- pull: plan.pull.length,
751
- autoPush: plan.autoPush.length,
752
- push: plan.push.length,
753
- same: plan.same.length,
754
- localOnly: plan.localOnly.length,
755
- pulled: pulled.pulled.length,
756
- pushed: pushed.pushed.length,
757
- observed,
758
- auto,
759
- scannedAt: plan.scannedAt,
760
- mnemon: {
761
- drafts: prepass.drafts,
762
- applied: prepass.applied.length,
763
- pushed: prepass.pushed.length,
764
- skipped: prepass.skipped.length,
765
- },
766
- };
767
- if (auto && (pulled.pulled.length || pushed.pushed.length || prepass.applied.length)) {
768
- pushHistory(
769
- next,
770
- 'skills',
771
- `技能自动同步:拉取 ${pulled.pulled.length}、推送 ${pushed.pushed.length}、待裁决 ${plan.push.length}${
772
- prepass.applied.length ? `、记忆插件草稿落地 ${prepass.applied.length}` : ''
773
- }`,
774
- );
775
- }
776
- saveState(next);
777
-
778
- const brief = (x) => ({
779
- name: x.name,
780
- action: x.action,
781
- reason: x.reason || (x.action === 'new' ? '母版新增' : ''),
782
- basis: x.basis || null,
783
- });
784
-
785
- const pulledNames = new Set(pulled.pulled.map((p) => p.name));
786
- const pushedNames = new Set(pushed.pushed.map((p) => p.name));
787
-
788
- json(res, 200, {
789
- ok: true,
790
- masterRoot: master,
791
- localRoot: LOCAL_SKILLS_ROOT,
792
- auto,
793
- stamp,
794
- // 母版更新:自动拉取
795
- pull: plan.pull.map((x) => ({ ...brief(x), version: x.master.version, done: pulledNames.has(x.name) })),
796
- // 本机更新(同步属性):自动推送
797
- autoPush: plan.autoPush.map((x) => ({
798
- ...brief(x),
799
- masterVersion: x.master.version,
800
- localVersion: x.local.version,
801
- done: pushedNames.has(x.name),
802
- })),
803
- // 判不出来的:只报告,等用户裁决(附差异摘要,方便一眼决定)
804
- push: plan.push.map((x) => ({
805
- ...brief(x),
806
- masterVersion: x.master.version,
807
- localVersion: x.local.version,
808
- diff: x.diff || null,
809
- })),
810
- same: plan.same.map((x) => x.name),
811
- localOnly: plan.localOnly.map((x) => x.name),
812
- // 优化闭环:发现了什么、判断结果、入队、审核、通过
813
- optimize,
814
- // 记忆插件草稿:这一轮落地了哪些、跳过了哪些(跳过都带原因)
815
- mnemon: {
816
- enabled: prepass.enabled,
817
- drafts: prepass.drafts,
818
- apply: prepass.apply,
819
- applied: prepass.applied.map((a) => ({
820
- name: a.name,
821
- action: a.action,
822
- draftId: a.draftId,
823
- targetRoot: a.targetRoot,
824
- backup: a.backup,
825
- })),
826
- pushed: prepass.pushed.map((p) => ({ name: p.name, operation: p.operation })),
827
- skipped: prepass.skipped,
828
- },
829
- errors: [...pulled.errors, ...pushed.errors, ...prepass.errors],
830
- observed,
831
- });
832
- },
833
-
834
- /**
835
- * 优化闭环的只读快照:队列里在等什么、账本里还有多少建议没做。
836
- *
837
- * 不改任何文件。面板用它显示「现在积压了多少」。
838
- */
839
- /**
840
- * 手动跑一轮完整流程(发现 → 起草 → 审核 → 落地 → 结案)。
841
- *
842
- * body: { dryRun?: boolean, maxDrafts?: number, wait?: boolean }
843
- *
844
- * **默认立刻返回 202,起草在后台跑**。为什么:一轮 6 条草稿要两分钟以上,
845
- * 串在 HTTP 响应里会让前端「点了没反应」(实测就是这么被投诉的),
846
- * 浏览器/代理还可能先超时。要同步结果就传 `wait:true`(只建议 dryRun 时用)。
847
- * 进度用 `POST /skills/optimize/state` 的 running/nextRun 两个字段轮询。
848
- */
849
- 'POST /skills/optimize/run': async (req, res) => {
850
- const body = (await readJson(req).catch(() => ({}))) || {};
851
- const state = loadState();
852
- // 母版不是必需品:没有母版照样能落地并结案(结案不依赖母版是既有约定),
853
- // 只是跳过「推母版」这一步。所以这里不拦,只在报告里说明。
854
- const master = state.skills.masterRoot || undefined;
855
- const enabled = state.skills.mnemonOptimize !== false;
856
- const maxDrafts = Number(body.maxDrafts) || Number(state.skills.mnemonMaxDrafts) || 6;
857
- const dryRun = body.dryRun === true;
858
-
859
- const runOnce = () =>
860
- runOptimizationCycle({
861
- localRoot: LOCAL_SKILLS_ROOT,
862
- masterRoot: master,
863
- stageDir: process.env.DSH_SYNC_STAGE_DIR || 'C:/DSH-Work/mnemon-drafts/stage',
864
- minConfidence: Number(state.skills.mnemonMinConfidence) || 0.7,
865
- maxDrafts,
866
- dryRun,
867
- // dryRun 不调模型:那样「预演」也要两分钟,且没有意义(没落地可预演)
868
- draft: dryRun ? null : drafter(),
869
- enabled,
870
- onEvent: (kind, payload) => log(`优化闭环 ${kind}: ${JSON.stringify(payload)}`),
871
- });
872
-
873
- // 同步模式:只给 dryRun / 明确要 wait 的调用方
874
- if (body.wait === true || dryRun) {
875
- try {
876
- const report = await runOnce();
877
- json(res, 200, { ok: true, report });
878
- } catch (err) {
879
- json(res, 500, { ok: false, error: String((err && err.message) || err) });
880
- }
881
- return;
882
- }
883
-
884
- // 异步模式(默认):立刻回 202,后台跑
885
- if (optimizeRun.running) {
886
- json(res, 202, {
887
- ok: true,
888
- started: false,
889
- running: true,
890
- startedAt: optimizeRun.startedAt,
891
- note: '上一轮还在跑,本次不重复启动',
892
- });
893
- return;
894
- }
895
- optimizeRun.running = true;
896
- optimizeRun.startedAt = new Date().toISOString();
897
- optimizeRun.lastReport = null;
898
- optimizeRun.lastError = null;
899
- runOnce()
900
- .then((report) => {
901
- optimizeRun.lastReport = {
902
- at: new Date().toISOString(),
903
- remaining: report.remaining,
904
- closed: report.closed,
905
- landed: (report.pipeline && report.pipeline.stage && report.pipeline.stage.landed) || [],
906
- drafted: (report.pipeline && report.pipeline.stage && report.pipeline.stage.drafted) || [],
907
- failed: (report.pipeline && report.pipeline.stage && report.pipeline.stage.failed) || [],
908
- errors: report.errors,
909
- };
910
- log(
911
- `优化闭环(手动):起草 ${optimizeRun.lastReport.drafted.length}、` +
912
- `落地 ${optimizeRun.lastReport.landed.length}、失败 ${optimizeRun.lastReport.failed.length}、` +
913
- `剩余 ${report.remaining}`,
914
- );
915
- })
916
- .catch((err) => {
917
- optimizeRun.lastError = String((err && err.message) || err);
918
- log(`优化闭环(手动)失败:${optimizeRun.lastError}`);
919
- })
920
- .finally(() => {
921
- optimizeRun.running = false;
922
- optimizeRun.finishedAt = new Date().toISOString();
923
- });
924
- json(res, 202, {
925
- ok: true,
926
- started: true,
927
- running: true,
928
- startedAt: optimizeRun.startedAt,
929
- maxDrafts,
930
- note: '起草已转后台,用 POST /skills/optimize/state 轮询进度',
931
- });
932
- },
933
-
934
- 'POST /skills/optimize/state': async (req, res) => {
935
- const state = loadState();
936
- const queue = readQueue();
937
- const found = discoverOptimizations({ localRoot: LOCAL_SKILLS_ROOT });
938
- const { todo, dropped } = triage({
939
- suggestions: found.suggestions,
940
- minConfidence: Number(state.skills.mnemonMinConfidence) || 0.7,
941
- seen: queue.seen,
942
- });
943
- // 账本视角的三段计数:没有草稿的 / 有 ready 草稿的 / 已经在磁盘上的
944
- let ledgerView = { pending: 0, withDraft: 0, readyDrafts: 0, adopted: 0, generation: null };
945
- try {
946
- const raw = JSON.parse(fs.readFileSync(MNEMON_LEDGER, 'utf8'));
947
- const sug = Array.isArray(raw.suggestions) ? raw.suggestions : [];
948
- const drf = Array.isArray(raw.drafts) ? raw.drafts : [];
949
- const readyBySuggestion = new Set(drf.filter((d) => d && d.status === 'ready' && d.suggestionId).map((d) => d.suggestionId));
950
- const pending = sug.filter((s) => s && s.status === 'pending');
951
- ledgerView = {
952
- pending: pending.length,
953
- withDraft: pending.filter((s) => readyBySuggestion.has(s.id)).length,
954
- readyDrafts: drf.filter((d) => d && d.status === 'ready').length,
955
- adopted: sug.filter((s) => s && s.status === 'adopted').length,
956
- generation: typeof raw.generation === 'number' ? raw.generation : null,
957
- };
958
- } catch {
959
- /* 账本读不到就返回零值,面板不报错 */
960
- }
961
- json(res, 200, {
962
- ok: true,
963
- ledgerView,
964
- // 手动触发那一轮的运行态:面板靠这两个字段显示「在跑 / 上次结果」
965
- running: optimizeRun.running,
966
- startedAt: optimizeRun.startedAt,
967
- finishedAt: optimizeRun.finishedAt,
968
- lastReport: optimizeRun.lastReport,
969
- lastError: optimizeRun.lastError,
970
- enabled: state.skills.mnemonOptimize !== false,
971
- minConfidence: Number(state.skills.mnemonMinConfidence) || 0.7,
972
- queue: {
973
- file: '~/.mnemon/skills/external-drafts.json',
974
- generation: queue.generation,
975
- waiting: queue.items.filter((x) => x.status === 'waiting-draft').length,
976
- done: queue.items.filter((x) => x.status === 'done').length,
977
- seen: queue.seen.length,
978
- items: queue.items.slice(-40),
979
- },
980
- ledger: { suggestions: found.suggestions.length, readyDrafts: found.drafts.length },
981
- todo: todo.map((x) => ({ skillName: x.skillName, action: x.action, priority: x.priority, reason: x.reason })),
982
- dropped,
983
- });
984
- },
985
-
986
- /**
987
- * 只审核不落地:把暂存区里的草稿过一遍机械审核,看哪些能通过。
988
- * 用来在真正落地前先看一眼。
989
- */
990
- 'POST /skills/optimize/audit': async (req, res) => {
991
- const body = await readJson(req).catch(() => ({}));
992
- const stageDir = body.stageDir || process.env.DSH_SYNC_STAGE_DIR || 'C:/DSH-Work/mnemon-drafts/stage';
993
- const state = loadState();
994
- const queue = readQueue();
995
- const found = discoverOptimizations({ localRoot: LOCAL_SKILLS_ROOT });
996
- const { todo } = triage({
997
- suggestions: found.suggestions,
998
- minConfidence: Number(state.skills.mnemonMinConfidence) || 0.7,
999
- seen: queue.seen,
1000
- });
1001
-
1002
- const rows = [];
1003
- for (const item of todo) {
1004
- const file = `${stageDir}/${item.skillName}/SKILL.md`;
1005
- if (!fs.existsSync(file)) {
1006
- rows.push({ skillName: item.skillName, action: item.action, staged: false, ok: null, problems: [] });
1007
- continue;
1008
- }
1009
- const text = fs.readFileSync(file, 'utf8');
1010
- const audit = auditDraft({ text, name: item.skillName, isUpdate: item.action === 'update', localRoot: LOCAL_SKILLS_ROOT });
1011
- rows.push({ skillName: item.skillName, action: item.action, staged: true, ok: audit.ok, problems: audit.problems, info: audit.info });
1012
- }
1013
- json(res, 200, { ok: true, stageDir, rows });
1014
- },
1015
-
1016
- /** 执行拉取(母版 → 本地)+ 移除母版已删的。 */
1017
- 'POST /skills/pull': async (req, res) => {
1018
- const state = loadState();
1019
- const master = state.skills.masterRoot;
1020
- if (!master) {
1021
- json(res, 400, { ok: false, error: '尚未设置技能母版文件夹' });
1022
- return;
1023
- }
1024
- if (!fs.existsSync(master)) {
1025
- json(res, 400, { ok: false, error: `母版文件夹不存在或不可访问:${master}` });
1026
- return;
1027
- }
1028
-
1029
- const plan = planSync({
1030
- masterRoot: master,
1031
- localRoot: LOCAL_SKILLS_ROOT,
1032
- modes: state.skills.modes || {},
1033
- ...judgeEvidence(master),
1034
- });
1035
- const stamp = makeStamp();
1036
-
1037
- const pulled = applyPull({ items: plan.pull, localRoot: LOCAL_SKILLS_ROOT, stamp });
1038
-
1039
- // 拉取落地后,被拉的和本来就一致的两侧都对齐了,一起登记成基线
1040
- const next = loadState();
1041
- const stamped = stampBaseline(
1042
- master,
1043
- [...plan.pull.map((x) => x.master), ...plan.same.map((x) => x.master)].filter(Boolean),
1044
- next.machineId,
1045
- );
1046
-
1047
- next.skills.lastRunAt = Date.now();
1048
- next.skills.lastResult = {
1049
- pulled: pulled.pulled.length,
1050
- pendingPush: plan.push.length,
1051
- errors: pulled.errors.length,
1052
- lineage: stamped,
1053
- };
1054
- pushHistory(
1055
- next,
1056
- 'skills',
1057
- `技能拉取:更新 ${pulled.pulled.length}、待推 ${plan.push.length}`,
1058
- );
1059
- saveState(next);
1060
-
1061
- json(res, 200, {
1062
- ok: true,
1063
- pulled: pulled.pulled,
1064
- errors: pulled.errors,
1065
- pendingPush: plan.push.map((x) => ({ name: x.name, reason: x.reason, basis: x.basis || null })),
1066
- lineage: stamped,
1067
- stamp,
1068
- });
1069
- },
1070
-
1071
- /** 把本地技能推回母版(用户确认后才调用)。支持覆盖,也支持新增本地独有技能。 */
1072
- 'POST /skills/push': async (req, res) => {
1073
- const body = await readJson(req);
1074
- const state = loadState();
1075
- const master = state.skills.masterRoot;
1076
- if (!master) {
1077
- json(res, 400, { ok: false, error: '尚未设置技能母版文件夹' });
1078
- return;
1079
- }
1080
- const names = Array.isArray(body.names) ? body.names.filter((n) => typeof n === 'string') : [];
1081
- if (!names.length) {
1082
- json(res, 400, { ok: false, error: '未选择任何技能' });
1083
- return;
1084
- }
1085
-
1086
- const plan = planSync({
1087
- masterRoot: master,
1088
- localRoot: LOCAL_SKILLS_ROOT,
1089
- modes: state.skills.modes || {},
1090
- ...judgeEvidence(master),
1091
- });
1092
- const stamp = makeStamp();
1093
-
1094
- // 覆盖项:本机更新的(同步属性,在 autoPush 里)+ 待裁决里被指名的
1095
- // 新增项:localOnly(用户显式指名才推)
1096
- // 注意 autoPush 也要算进来——否则「本机更新」这条在面板上点「推送」
1097
- // 会报「没有可推送的变更」。
1098
- const overwrites = [...plan.autoPush, ...plan.push].filter((x) => names.includes(x.name));
1099
- const creates = plan.localOnly
1100
- .filter((x) => names.includes(x.name))
1101
- .map((x) => ({ name: x.name, action: 'create', local: x.local, master: null }));
1102
- const items = [...overwrites, ...creates];
1103
-
1104
- if (!items.length) {
1105
- json(res, 400, { ok: false, error: '选中的技能当前没有可推送的变更' });
1106
- return;
1107
- }
1108
-
1109
- const backRoot = path.join(master, '_backup');
1110
- const out = applyPush({ items, masterRoot: master, backRoot, stamp });
1111
-
1112
- // 每一次实际写入都留记录
1113
- const audit = appendMasterChanges(
1114
- out.pushed.map((p) => ({
1115
- skill: p.name,
1116
- operation: p.operation,
1117
- detail: p.operation === '覆盖' ? `旧版备份于 _backup/${p.name}.${stamp}` : '母版此前无此技能',
1118
- })),
1119
- {
1120
- masterRoot: master,
1121
- machineId: state.machineId,
1122
- machineName: state.machineName,
1123
- trigger: '用户授权',
1124
- },
1125
- );
1126
-
1127
- const next = loadState();
1128
- // 推送落地后母版就是本机这一份,登记成新基线
1129
- const stamped = stampBaseline(
1130
- master,
1131
- out.pushed.map((p) => items.find((i) => i.name === p.name)).filter(Boolean).map((i) => i.local).filter(Boolean),
1132
- next.machineId,
1133
- );
1134
- pushHistory(
1135
- next,
1136
- 'skills',
1137
- `技能推送:覆盖 ${out.pushed.filter((p) => p.operation === '覆盖').length}、新增 ${
1138
- out.pushed.filter((p) => p.operation === '新增').length
1139
- }`,
1140
- );
1141
- saveState(next);
1142
-
1143
- json(res, 200, {
1144
- ok: true,
1145
- pushed: out.pushed,
1146
- errors: out.errors,
1147
- backupRoot: backRoot,
1148
- stamp,
1149
- lineage: stamped,
1150
- changelog: {
1151
- path: audit.written[0]?.path || changelogPath(master),
1152
- written: audit.written.length,
1153
- failed: audit.failed,
1154
- },
1155
- });
1156
- },
1157
-
1158
- /** 读取母版修改记录。 */
1159
- 'POST /skills/changelog': async (req, res) => {
1160
- const body = await readJson(req);
1161
- const state = loadState();
1162
- const master = state.skills.masterRoot;
1163
- if (!master) {
1164
- json(res, 400, { ok: false, error: '尚未设置技能母版文件夹' });
1165
- return;
1166
- }
1167
- const limit = Number(body.limit) > 0 ? Math.min(Math.floor(Number(body.limit)), 200) : 30;
1168
- const log = readChangelog(master, limit);
1169
- json(res, 200, { ok: true, ...log });
1170
- },
1171
-
1172
- /** 技能列表:合并母版与本机,带单机属性。 */
1173
- 'POST /skills/list': async (req, res) => {
1174
- const state = loadState();
1175
- const master = state.skills.masterRoot;
1176
- if (!master) {
1177
- json(res, 400, { ok: false, error: '尚未设置技能母版文件夹' });
1178
- return;
1179
- }
1180
- if (!fs.existsSync(master)) {
1181
- json(res, 400, { ok: false, error: `母版文件夹不存在或不可访问:${master}` });
1182
- return;
1183
- }
1184
-
1185
- const modes = state.skills.modes || {};
1186
- const m = scanSkills(master);
1187
- const l = scanSkills(LOCAL_SKILLS_ROOT);
1188
- const names = new Set([...m.keys(), ...l.keys()]);
1189
- const { lineage, mnemonLedger, history } = judgeEvidence(master);
1190
-
1191
- const items = [...names].sort().map((name) => {
1192
- const mm = m.get(name);
1193
- const ll = l.get(name);
1194
- const mode = resolveMode(name, modes, m, l);
1195
- let status;
1196
- if (mm && !ll) status = mode === 'master' ? 'master-only' : 'missing-local';
1197
- else if (!mm && ll) status = 'local-only';
1198
- else status = mm.treeHash === ll.treeHash ? 'same' : 'diff';
1199
-
1200
- // 内容不同时顺便给出「谁更新」的结论,前端不用再自己算
1201
- let verdict = null;
1202
- let basis = null;
1203
- let hint = '';
1204
- let diff = null;
1205
- if (status === 'diff') {
1206
- const j = judgeUpdate({ name, master: mm, local: ll, record: lineage[name], ledger: mnemonLedger, history });
1207
- verdict = j.verdict;
1208
- basis = j.basis;
1209
- hint = j.detail;
1210
- diff = diffSummary(mm.text, ll.text);
1211
- }
1212
-
1213
- return {
1214
- name,
1215
- mode,
1216
- explicit: Object.prototype.hasOwnProperty.call(modes, name),
1217
- status,
1218
- verdict,
1219
- basis,
1220
- hint,
1221
- diff,
1222
- masterVersion: (mm && mm.version) || '',
1223
- localVersion: (ll && ll.version) || '',
1224
- inMaster: !!mm,
1225
- inLocal: !!ll,
1226
- };
1227
- });
1228
-
1229
- json(res, 200, { ok: true, masterRoot: master, localRoot: LOCAL_SKILLS_ROOT, items });
1230
- },
1231
-
1232
- /** 切换单机属性,并执行对应的实质动作(删母版 / 删本机 / 推送)。 */
1233
- 'POST /skills/mode': async (req, res) => {
1234
- const body = await readJson(req);
1235
- const state = loadState();
1236
- const master = state.skills.masterRoot;
1237
- if (!master) {
1238
- json(res, 400, { ok: false, error: '尚未设置技能母版文件夹' });
1239
- return;
1240
- }
1241
- const mode = body.mode;
1242
- if (mode !== 'sync' && mode !== 'local' && mode !== 'master') {
1243
- json(res, 400, { ok: false, error: '属性只能是 sync / local / master' });
1244
- return;
1245
- }
1246
- const names = Array.isArray(body.names) ? body.names.filter((n) => typeof n === 'string' && n) : [];
1247
- if (!names.length) {
1248
- json(res, 400, { ok: false, error: '未选择任何技能' });
1249
- return;
1250
- }
1251
-
1252
- const stamp = makeStamp();
1253
- const backRoot = path.join(master, '_backup');
1254
- const modes = state.skills.modes || {};
1255
- const m0 = scanSkills(master);
1256
- const l0 = scanSkills(LOCAL_SKILLS_ROOT);
1257
-
1258
- const results = [];
1259
- const applied = [];
1260
-
1261
- for (const name of names) {
1262
- const from = resolveMode(name, modes, m0, l0);
1263
- const out = applyModeChange({
1264
- name,
1265
- from,
1266
- to: mode,
1267
- masterRoot: master,
1268
- localRoot: LOCAL_SKILLS_ROOT,
1269
- backRoot,
1270
- stamp,
1271
- });
1272
- results.push(out);
1273
- if (out.ok) applied.push({ name, mode });
1274
- }
1275
-
1276
- // 只有成功的才落属性
1277
- const next = loadState();
1278
- for (const a of applied) next.skills.modes[a.name] = a.mode;
1279
- const auditItems = results.flatMap((r) => (r.ok && r.audit ? r.audit : []));
1280
- if (applied.length) {
1281
- pushHistory(next, 'skills', `技能属性:${applied.length} 个改为「${MODE_LABEL[mode]}」`);
1282
- }
1283
- saveState(next);
1284
-
1285
- // 对母版的实际改动写审计
1286
- let audit = { written: 0, failed: [] };
1287
- if (auditItems.length) {
1288
- const w = appendMasterChanges(auditItems, {
1289
- masterRoot: master,
1290
- machineId: next.machineId,
1291
- machineName: next.machineName,
1292
- trigger: '用户授权',
1293
- });
1294
- audit = { written: w.written.length, failed: w.failed, path: w.written[0] && w.written[0].path };
1295
- }
1296
-
1297
- json(res, 200, {
1298
- ok: true,
1299
- mode,
1300
- results,
1301
- applied: applied.length,
1302
- failed: results.filter((r) => !r.ok).length,
1303
- audit,
1304
- });
1305
- },
1306
-
1307
- /** 技能同步配置。 */
1308
- 'POST /skills/config': async (req, res) => {
1309
- const body = await readJson(req);
1310
- const state = loadState();
1311
- const skills = { ...state.skills };
1312
-
1313
- if (typeof body.masterRoot === 'string') {
1314
- const p = body.masterRoot.trim();
1315
- skills.masterRoot = p ? normSlash(p) : '';
1316
- // 设置路径时就把记录文件准备好,用户能立刻看到
1317
- if (skills.masterRoot && fs.existsSync(skills.masterRoot)) {
1318
- try {
1319
- ensureChangelog(skills.masterRoot);
1320
- } catch {
1321
- /* 母版只读时建不了,不影响后续 */
1322
- }
1323
- }
1324
- }
1325
- if (typeof body.enabled === 'boolean') skills.enabled = body.enabled;
1326
- // 记忆插件技能优化自动落地:默认开,显式传 false 才关
1327
- if (typeof body.mnemonAuto === 'boolean') skills.mnemonAuto = body.mnemonAuto;
1328
- // 优化闭环(发现 → 判断 → 入队 → 审核 → 通过):默认开
1329
- if (typeof body.mnemonOptimize === 'boolean') skills.mnemonOptimize = body.mnemonOptimize;
1330
- if (body.mnemonMinConfidence !== undefined) {
1331
- const n = Number(body.mnemonMinConfidence);
1332
- if (!Number.isFinite(n) || n < 0 || n > 1) {
1333
- json(res, 400, { ok: false, error: '置信度门槛应在 0 到 1 之间' });
1334
- return;
1335
- }
1336
- skills.mnemonMinConfidence = n;
1337
- }
1338
- if (body.intervalMinutes !== undefined) {
1339
- const n = Number(body.intervalMinutes);
1340
- if (!Number.isFinite(n) || n < 1 || n > 10080) {
1341
- json(res, 400, { ok: false, error: '间隔应为 1–10080 之间的整数(分钟)' });
1342
- return;
1343
- }
1344
- skills.intervalMinutes = Math.floor(n);
1345
- }
1346
- state.skills = skills;
1347
- saveState(state);
1348
- json(res, 200, { ok: true, skills: state.skills, localRoot: LOCAL_SKILLS_ROOT });
1349
- },
1350
- /** 插件清单同步的配置:开关与插件库根目录。 */
1351
- 'POST /plugins/config': async (req, res) => {
1352
- const body = await readJson(req);
1353
- const state = loadState();
1354
- const next = { ...state.plugins };
1355
- if (typeof body.enabled === 'boolean') next.enabled = body.enabled;
1356
- if (typeof body.libraryRoot === 'string') {
1357
- const p = body.libraryRoot.trim();
1358
- next.libraryRoot = p ? normSlash(p) : '';
1359
- // 填了就必须是已存在的目录:不存在的路径建出来也是空的,同步一定失败
1360
- if (next.libraryRoot && !fs.existsSync(next.libraryRoot)) {
1361
- json(res, 400, { ok: false, error: `插件库目录不存在:${next.libraryRoot}` });
1362
- return;
1363
- }
1364
- }
1365
- state.plugins = next;
1366
- saveState(state);
1367
- json(res, 200, { ok: true, plugins: state.plugins });
1368
- },
1369
-
1370
- /** 插件清单快照:我的特征文件夹 + 他人清单(只读,扫盘)。 */
1371
- 'GET /plugins': async (req, res) => {
1372
- const state = loadState();
1373
- const machineId = deps.machineId() || state.machineId;
1374
- const snap = shareSnapshot({
1375
- config: { libraryRoot: state.plugins.libraryRoot },
1376
- machineId,
1377
- machineHash: state.machineHash,
1378
- machineName: state.machineName,
1379
- publishedNames: Array.isArray(state.plugins.publishedNames) ? state.plugins.publishedNames : [],
1380
- });
1381
- json(res, 200, {
1382
- ok: true,
1383
- plugins: { ...state.plugins, effectiveRoot: pluginsLibraryRoot({ libraryRoot: state.plugins.libraryRoot }) },
1384
- share: snap,
1385
- });
1386
- },
1387
-
1388
- /** 立即同步一次我的特征文件夹,并重新汇集他人清单。 */
1389
- 'POST /plugins/sync': async (req, res) => {
1390
- if (typeof deps.syncPlugins !== 'function') {
1391
- json(res, 400, { ok: false, error: '宿主未提供清单同步能力' });
1392
- return;
1393
- }
1394
- const result = await deps.syncPlugins({ reason: '手动' });
1395
- const state = loadState();
1396
- const machineId = deps.machineId() || state.machineId;
1397
- json(res, 200, {
1398
- ok: result.ok !== false,
1399
- result,
1400
- plugins: { ...state.plugins, effectiveRoot: pluginsLibraryRoot({ libraryRoot: state.plugins.libraryRoot }) },
1401
- share: shareSnapshot({
1402
- config: { libraryRoot: state.plugins.libraryRoot },
1403
- machineId,
1404
- machineHash: state.machineHash,
1405
- machineName: state.machineName,
1406
- publishedNames: [],
1407
- }),
1408
- });
1409
- },
1410
-
1411
- /** 装他人清单里的一个插件(人工点选;只走联网拉取)。 */
1412
- 'POST /plugins/install': async (req, res) => {
1413
- const body = await readJson(req);
1414
- const name = typeof body.name === 'string' ? body.name.trim() : '';
1415
- if (!name) {
1416
- json(res, 400, { ok: false, error: '缺少 name' });
1417
- return;
1418
- }
1419
- if (typeof deps.pluginInstall !== 'function') {
1420
- json(res, 400, { ok: false, error: '宿主未提供插件安装通道' });
1421
- return;
1422
- }
1423
- const state = loadState();
1424
- const machineId = deps.machineId() || state.machineId;
1425
- const result = await installSharedPlugin({
1426
- config: { libraryRoot: state.plugins.libraryRoot },
1427
- machineId,
1428
- publishedNames: [],
1429
- name,
1430
- installSpec: deps.pluginInstall,
1431
- addToBundles: deps.addToBundles,
1432
- log,
1433
- });
1434
- pushHistory(state, {
1435
- kind: result.ok ? 'plugins-install' : 'plugins-install-failed',
1436
- detail: result.ok
1437
- ? `从共享装 ${name}:${result.message}`
1438
- : `从共享装 ${name} 失败:${result.message}`,
1439
- });
1440
- saveState(state);
1441
- json(res, result.ok ? 200 : 400, {
1442
- ok: result.ok,
1443
- result,
1444
- share: shareSnapshot({
1445
- config: { libraryRoot: state.plugins.libraryRoot },
1446
- machineId,
1447
- machineHash: state.machineHash,
1448
- machineName: state.machineName,
1449
- publishedNames: [],
1450
- }),
1451
- });
1452
- },
1453
-
1454
- 'POST /preview': async (req, res) => {
1455
- const body = await readJson(req);
1456
- const state = loadState();
1457
- const machineId = deps.machineId() || state.machineId;
1458
- const all = listWorkspaces();
1459
- const matched = pickWorkspaces(all, state.folders);
1460
- const chosen =
1461
- Array.isArray(body.workspaceIds) && body.workspaceIds.length
1462
- ? matched.filter((w) => body.workspaceIds.includes(w.id))
1463
- : matched;
1464
-
1465
- const localIndex = scanLocalSessions();
1466
- const out = chosen.map((ws) => {
1467
- const plan = previewPull({ workspace: ws, machineId, localIndex });
1468
- return {
1469
- id: ws.id,
1470
- title: ws.title || ws.path,
1471
- path: ws.path,
1472
- plan,
1473
- counts: plan.reduce((acc, p) => {
1474
- acc[p.action] = (acc[p.action] || 0) + 1;
1475
- return acc;
1476
- }, {}),
1477
- };
1478
- });
1479
- json(res, 200, { ok: true, workspaces: out });
1480
- },
1481
- };
1482
-
1483
- return {
1484
- prefix: API_PREFIX,
1485
- routes,
1486
- /** 供 webServer 注册的统一 handler。 */
1487
- handle: async (req, res) => {
1488
- const url = new URL(req.url || '/', 'http://localhost');
1489
- if (!url.pathname.startsWith(API_PREFIX)) {
1490
- json(res, 404, { ok: false, error: 'not found' });
1491
- return;
1492
- }
1493
- const sub = url.pathname.slice(API_PREFIX.length) || '/';
1494
- const key = `${req.method} ${sub}`;
1495
- const handler = routes[key];
1496
- if (!handler) {
1497
- json(res, 404, { ok: false, error: `no route ${key}` });
1498
- return;
1499
- }
1500
- try {
1501
- await handler(req, res);
1502
- } catch (err) {
1503
- json(res, 500, {
1504
- ok: false,
1505
- error: String(err && err.message ? err.message : err),
1506
- });
1507
- }
1508
- },
1509
- };
1510
- }
1
+ /**
2
+ * Host 侧的 HTTP API。
3
+ * 路由:/api/local/dsh-sync-backup/*
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import {
8
+ loadState,
9
+ saveState,
10
+ pushHistory,
11
+ computeMachineId,
12
+ normSlash,
13
+ humanBytes,
14
+ pathKey,
15
+ } from './core.js';
16
+ import {
17
+ backupWorkspace,
18
+ listMachines,
19
+ pickWorkspaces,
20
+ previewPull,
21
+ projectKeysFor,
22
+ pullWorkspace,
23
+ scanLocalSessions,
24
+ } from './backup.js';
25
+ import { attachMany, attachedIds, findPulledUnattached, findUnattached } from './attach.js';
26
+ import { joinMachineId, repairAll, scanFolders, splitMachineId } from './patrol.js';
27
+ import {
28
+ adoptFeature,
29
+ decisionOf,
30
+ dropFeature,
31
+ featurePath,
32
+ forgetDecision,
33
+ hasFeature,
34
+ inactiveReason,
35
+ planFeatures,
36
+ rememberDecision,
37
+ } from './features.js';
38
+ import {
39
+ LINEAGE_FILE,
40
+ LOCAL_SKILLS_ROOT,
41
+ applyModeChange,
42
+ applyPull,
43
+ applyPush,
44
+ buildHistoryIndex,
45
+ diffSummary,
46
+ judgeUpdate,
47
+ loadLineage,
48
+ makeStamp,
49
+ planSync,
50
+ readMnemonLedger,
51
+ recordLineage,
52
+ resolveMode,
53
+ saveLineage,
54
+ scanSkills,
55
+ } from './skills.js';
56
+ import {
57
+ appendMasterChanges,
58
+ changelogPath,
59
+ ensureChangelog,
60
+ readChangelog,
61
+ readChangelogTimes,
62
+ } from './changelog.js';
63
+ import { runMnemonPrepass } from './mnemon.js';
64
+ import {
65
+ runOptimizationCycle,
66
+ readQueue,
67
+ triage,
68
+ discoverOptimizations,
69
+ auditDraft,
70
+ MNEMON_LEDGER,
71
+ } from './mnemon-optimize.js';
72
+ import { createHostDrafter } from './mnemon-drafter.js';
73
+ import {
74
+ SHARE_POLICY,
75
+ installSharedPlugin,
76
+ libraryRoot as pluginsLibraryRoot,
77
+ shareSnapshot,
78
+ } from './plugins.js';
79
+
80
+ export const API_PREFIX = '/api/local/dsh-sync-backup';
81
+
82
+ /** 三种单机属性的中文名。 */
83
+ export const MODE_LABEL = {
84
+ sync: '同步',
85
+ local: '本机独属',
86
+ master: '母版有·本机不拉取',
87
+ };
88
+
89
+ const json = (res, code, body) => {
90
+ const text = JSON.stringify(body);
91
+ res.writeHead(code, {
92
+ 'content-type': 'application/json; charset=utf-8',
93
+ 'cache-control': 'no-store',
94
+ });
95
+ res.end(text);
96
+ };
97
+
98
+ async function readJson(req) {
99
+ const chunks = [];
100
+ for await (const chunk of req) chunks.push(chunk);
101
+ if (!chunks.length) return {};
102
+ try {
103
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
104
+ } catch {
105
+ return {};
106
+ }
107
+ }
108
+
109
+ /**
110
+ * @param {{ workspaces: () => Array<{id:string,path:string,title:string}>,
111
+ * machineId: () => string,
112
+ * registerWorkspace?: (path:string, title:string) => Promise<{id:string,created:boolean}>,
113
+ * wsEntity?: (id:string) => ({attachSession:(sid:string)=>Promise<void>, sessionIds:string[]}|undefined),
114
+ * entities?: () => Array<{id:string,sessionIds?:string[]}> }} deps
115
+ */
116
+ /** 手动触发的那一轮的运行态:路由异步化之后,进度只能从这里读。 */
117
+ const optimizeRun = { running: false, startedAt: null, finishedAt: null, lastReport: null, lastError: null };
118
+
119
+ export function createApi(deps) {
120
+ /**
121
+ * 起草器:宿主的 LLM。没有就返回 null,流水线会跳过起草阶段(只审核已有草稿)。
122
+ * 每轮现取现造,不缓存 —— 宿主换模型后下一轮就跟着换。
123
+ */
124
+ const drafter = () => (typeof deps.drafter === 'function' ? deps.drafter() : null);
125
+
126
+ /**
127
+ * 日志:api.js 是模块层,拿不到 index.js 里的 log。必须由 deps 注入。
128
+ * 不注入就静默 —— 静默比 ReferenceError 好:后者会让整条路由 500,
129
+ * 前端只看到「点了没反应」(实测就是这么被投诉的)。
130
+ */
131
+ const log = (m) => {
132
+ try {
133
+ if (typeof deps.log === 'function') deps.log(m);
134
+ } catch {
135
+ /* 日志失败不能影响主流程 */
136
+ }
137
+ };
138
+ const listWorkspaces = () =>
139
+ typeof deps.workspaces === 'function' ? deps.workspaces() : [];
140
+
141
+ /** 取某个工作区实体(能 attachSession 的那个),拿不到就返回 undefined。 */
142
+ const wsEntity = (id) =>
143
+ typeof deps.wsEntity === 'function' ? deps.wsEntity(id) : undefined;
144
+
145
+ /** 全部工作区实体,用于判断某个 sessionId 是否已有归属。 */
146
+ const entities = () =>
147
+ typeof deps.entities === 'function' ? deps.entities() || [] : [];
148
+
149
+ /** 本机某工作区路径对应的 projectKey 集合。 */
150
+ const projectKeysOf = (p) => projectKeysFor(p);
151
+
152
+ /**
153
+ * 判新旧要的三份只读证据。
154
+ *
155
+ * lineage :母版根目录的 `.skill-lineage.json`,同步插件自己的观测记录——
156
+ * 每侧内容何时变成现在这样、上次一致时的内容树是哪份。
157
+ * mnemonLedger:`~/.mnemon/skills/ledger.json`,记忆插件的技能账本,
158
+ * 里面每条 hash 就是 SKILL.md 的 sha256,reason='evolution'
159
+ * 就是它改的。只读,不写。
160
+ * history :母版 `_backup/`、本机 `.pre-pull-*`、记忆插件 `versions/`
161
+ * 里的历史全文,按内容哈希索引出「这份内容何时出现过」。
162
+ */
163
+ const judgeEvidence = (masterRoot) => ({
164
+ lineage: masterRoot ? loadLineage(path.join(masterRoot, LINEAGE_FILE)) : {},
165
+ mnemonLedger: readMnemonLedger(),
166
+ history: buildHistoryIndex({
167
+ masterRoot,
168
+ localRoot: LOCAL_SKILLS_ROOT,
169
+ changelogTimes: masterRoot ? readChangelogTimes(masterRoot) : null,
170
+ }),
171
+ });
172
+
173
+ /**
174
+ * 记录一次比对观测:两侧各自当前是什么内容、各自何时变成这样。
175
+ *
176
+ * 这是「内容修改时间」的唯一来源——不是 mtime(同步/NAS 搬运都会刷),
177
+ * 而是同步插件真正看到内容变化的那一刻。内容没变则时间保持不动。
178
+ *
179
+ * 两侧内容一致时顺手把 baseline 更新成这一份:基线就是「上次两侧一致的
180
+ * 内容树」,只有一致的时候才有意义。
181
+ */
182
+ const observeLineage = (masterRoot, plan, by) => {
183
+ if (!masterRoot) return 0;
184
+ const file = path.join(masterRoot, LINEAGE_FILE);
185
+ let map = loadLineage(file);
186
+ const now = Date.now();
187
+ let touched = 0;
188
+ for (const [name, m] of plan.master) {
189
+ const l = plan.local.get(name);
190
+ if (!l) continue; // 只有一侧有,没有可比性
191
+ const same = m.treeHash === l.treeHash;
192
+ map = recordLineage(map, {
193
+ name,
194
+ ...(same ? { baseline: m.treeHash } : {}),
195
+ master: m,
196
+ local: l,
197
+ by: by || '',
198
+ at: now,
199
+ });
200
+ touched++;
201
+ }
202
+ return saveLineage(file, map) ? touched : 0;
203
+ };
204
+
205
+ /** 同步真正落地之后,把两侧一致的内容登记成新基线。 */
206
+ const stampBaseline = (masterRoot, entries, by) => {
207
+ if (!masterRoot || !entries.length) return 0;
208
+ const file = path.join(masterRoot, LINEAGE_FILE);
209
+ let map = loadLineage(file);
210
+ for (const e of entries) {
211
+ if (!e) continue;
212
+ map = recordLineage(map, {
213
+ name: e.name,
214
+ baseline: e.treeHash,
215
+ master: e,
216
+ local: e,
217
+ by: by || '',
218
+ });
219
+ }
220
+ return saveLineage(file, map) ? entries.length : 0;
221
+ };
222
+
223
+ const routes = {
224
+ /** 全量状态:配置、匹配工作区、备份统计、其他机器。 */
225
+ 'GET /state': async (req, res) => {
226
+ const state = loadState();
227
+ const machineId = deps.machineId() || state.machineId;
228
+ const all = listWorkspaces();
229
+ const matched = pickWorkspaces(all, state.folders);
230
+ const localIndex = scanLocalSessions();
231
+
232
+ // 1.2.0:匹配的工作区里只把「有本机特征文件夹」的算作参与同步的;
233
+ // 其余的单独列一份「未参与」清单,面板上给原因和采纳按钮。
234
+ const active = matched.filter((ws) => hasFeature(ws.path, machineId));
235
+ const inactive = matched
236
+ .filter((ws) => !hasFeature(ws.path, machineId))
237
+ .map((ws) => ({
238
+ id: ws.id,
239
+ path: ws.path,
240
+ title: ws.title || ws.path,
241
+ featurePath: featurePath(ws.path, machineId),
242
+ declined: decisionOf(state, ws.path) === 'decline',
243
+ reason: inactiveReason(ws.path, machineId, state),
244
+ }));
245
+
246
+ const workspaces = active.map((ws) => {
247
+ const machines = listMachines(ws.path);
248
+ const own = machines.find((m) => m.machineId === machineId);
249
+ const others = machines.filter((m) => m.machineId !== machineId);
250
+ return {
251
+ id: ws.id,
252
+ path: ws.path,
253
+ title: ws.title || ws.path,
254
+ matchedPrefix: ws.matchedPrefix,
255
+ featurePath: featurePath(ws.path, machineId),
256
+ own: own ? { sessions: own.sessions, bytes: own.bytes, newest: own.newest } : { sessions: 0, bytes: 0, newest: 0 },
257
+ others: others.map((o) => ({
258
+ machineId: o.machineId,
259
+ sessions: o.sessions,
260
+ bytes: o.bytes,
261
+ newest: o.newest,
262
+ })),
263
+ };
264
+ });
265
+
266
+ // 待人工确认的清单(按矩阵,只有「没我的格子 + 没工作区」才问人)
267
+ const scan = scanFolders({
268
+ folders: state.folders,
269
+ workspaces: all,
270
+ machineId,
271
+ });
272
+ const plan = planFeatures({
273
+ folders: state.folders,
274
+ workspaces: all,
275
+ discover: scan.discover,
276
+ state,
277
+ machineId,
278
+ });
279
+
280
+ json(res, 200, {
281
+ ok: true,
282
+ machineId,
283
+ machineName: state.machineName,
284
+ machineHash: state.machineHash,
285
+ machineIdSuggested: computeMachineId(),
286
+ folders: state.folders,
287
+ lastBackupAt: state.lastBackupAt,
288
+ lastPullAt: state.lastPullAt,
289
+ patrol: state.patrol,
290
+ // 特征文件夹采纳:待人工确认清单 + 已有格子的工作区数
291
+ features: {
292
+ initialized: state.features.initialized === true,
293
+ pending: plan.ask,
294
+ pendingCount: plan.ask.length,
295
+ activeCount: active.length,
296
+ inactiveCount: inactive.length,
297
+ // 巡检会自动处理的(面板上只报个数)
298
+ autoCreateFolder: plan.createFolder.length,
299
+ autoRestoreWorkspace: plan.restoreWorkspace.length,
300
+ },
301
+ skills: { ...state.skills, localRoot: LOCAL_SKILLS_ROOT },
302
+ // 插件清单共享(1.0.0 从自动更新器迁来):配置 + 运行态 + 口径声明
303
+ plugins: {
304
+ ...state.plugins,
305
+ // 面板没填时告诉用户实际会落到哪,避免「留空 = 不知道去哪」
306
+ effectiveRoot: pluginsLibraryRoot({ libraryRoot: state.plugins.libraryRoot }),
307
+ policy: SHARE_POLICY,
308
+ },
309
+ history: state.history.slice(0, 20),
310
+ workspaceCount: all.length,
311
+ matchedCount: workspaces.length,
312
+ matchedTotal: matched.length,
313
+ inactive,
314
+ workspaces,
315
+ totals: {
316
+ ownSessions: workspaces.reduce((n, w) => n + w.own.sessions, 0),
317
+ ownBytes: workspaces.reduce((n, w) => n + w.own.bytes, 0),
318
+ otherSessions: workspaces.reduce(
319
+ (n, w) => n + w.others.reduce((m, o) => m + o.sessions, 0),
320
+ 0,
321
+ ),
322
+ },
323
+ });
324
+ },
325
+
326
+ /** 保存配置:文件夹前缀 / 机器标识。 */
327
+ 'POST /config': async (req, res) => {
328
+ const body = await readJson(req);
329
+ const state = loadState();
330
+ if (Array.isArray(body.folders)) {
331
+ const seen = new Set();
332
+ state.folders = [];
333
+ for (const raw of body.folders) {
334
+ if (typeof raw !== 'string') continue;
335
+ const p = normSlash(raw.trim());
336
+ if (!p) continue;
337
+ const k = pathKey(p);
338
+ if (seen.has(k)) continue;
339
+ seen.add(k);
340
+ state.folders.push(p);
341
+ }
342
+ }
343
+ if (typeof body.machineName === 'string') {
344
+ // 只改名称部分,哈希固定不变
345
+ state.machineName = body.machineName.replace(/[\\/:*?"<>|\s]+/g, '_').trim();
346
+ state.machineHash = deps.machineHash ? deps.machineHash() || state.machineHash : state.machineHash;
347
+ state.machineId = joinMachineId(state.machineName, state.machineHash);
348
+ }
349
+ if (typeof body.machineId === 'string' && body.machineId.trim() && body.machineName === undefined) {
350
+ // 兼容:直接给完整标识时,拆出名称,哈希仍以本地为准
351
+ const parsed = splitMachineId(body.machineId.trim(), state.machineHash);
352
+ state.machineName = parsed.name.replace(/[\\/:*?"<>|\s]+/g, '_');
353
+ state.machineId = joinMachineId(state.machineName, state.machineHash);
354
+ }
355
+ saveState(state);
356
+ json(res, 200, {
357
+ ok: true,
358
+ folders: state.folders,
359
+ machineId: state.machineId,
360
+ machineName: state.machineName,
361
+ machineHash: state.machineHash,
362
+ });
363
+ },
364
+
365
+ /** 立即备份:只备份「有本机特征文件夹」的工作区。 */
366
+ 'POST /backup': async (req, res) => {
367
+ const body = await readJson(req);
368
+ const state = loadState();
369
+ const machineId = deps.machineId() || state.machineId;
370
+ const all = listWorkspaces();
371
+ // 1.2.0:只对「特征文件夹存在」的工作区干活;没建格子的跳过并报原因
372
+ const matchedAll = pickWorkspaces(all, state.folders);
373
+ const matched = matchedAll.filter((w) => hasFeature(w.path, machineId));
374
+ const skipped = matchedAll
375
+ .filter((w) => !hasFeature(w.path, machineId))
376
+ .map((w) => ({
377
+ id: w.id,
378
+ path: w.path,
379
+ title: w.title || w.path,
380
+ reason: '没有本机特征文件夹(未参与备份)',
381
+ }));
382
+ const chosen =
383
+ Array.isArray(body.workspaceIds) && body.workspaceIds.length
384
+ ? matched.filter((w) => body.workspaceIds.includes(w.id))
385
+ : matched;
386
+
387
+ const localIndex = scanLocalSessions();
388
+ const results = [];
389
+ for (const ws of chosen) {
390
+ results.push(backupWorkspace({ workspace: ws, machineId, localIndex }));
391
+ }
392
+
393
+ const created = results.reduce((n, r) => n + r.created, 0);
394
+ const updated = results.reduce((n, r) => n + r.updated, 0);
395
+ const bytes = results.reduce((n, r) => n + r.bytes, 0);
396
+
397
+ const next = loadState();
398
+ next.lastBackupAt = Date.now();
399
+ pushHistory(
400
+ next,
401
+ 'backup',
402
+ `备份 ${chosen.length} 个工作区:新增 ${created}、更新 ${updated}、${humanBytes(bytes)}` +
403
+ `${skipped.length ? `;跳过 ${skipped.length} 个没有本机特征文件夹的` : ''}`,
404
+ );
405
+ saveState(next);
406
+
407
+ json(res, 200, {
408
+ ok: true,
409
+ results,
410
+ skipped,
411
+ summary: { workspaces: chosen.length, created, updated, bytes, skipped: skipped.length },
412
+ });
413
+ },
414
+
415
+ /**
416
+ * 一键拉取:多选工作区。
417
+ *
418
+ * 1.2.0 起只拉「有本机特征文件夹」的工作区——没建格子的本机不参与同步,
419
+ * 往里拉会话等于替人做决定。拉完的会话归属登记是拉取的一部分,不再单列按钮:
420
+ * 只写文件不登记,DSH 侧栏只会把它显示成「未分组」。
421
+ */
422
+ 'POST /pull': async (req, res) => {
423
+ const body = await readJson(req);
424
+ const state = loadState();
425
+ const machineId = deps.machineId() || state.machineId;
426
+ const all = listWorkspaces();
427
+ const matchedAll = pickWorkspaces(all, state.folders);
428
+ const matched = matchedAll.filter((w) => hasFeature(w.path, machineId));
429
+ const skipped = matchedAll
430
+ .filter((w) => !hasFeature(w.path, machineId))
431
+ .map((w) => ({
432
+ id: w.id,
433
+ path: w.path,
434
+ title: w.title || w.path,
435
+ reason: '没有本机特征文件夹(未参与拉取)',
436
+ }));
437
+ const chosen =
438
+ Array.isArray(body.workspaceIds) && body.workspaceIds.length
439
+ ? matched.filter((w) => body.workspaceIds.includes(w.id))
440
+ : matched;
441
+
442
+ /**
443
+ * 有备份、但没注册成工作区的目录。
444
+ *
445
+ * 这是「拉取后显示在未分组」最常见的原因之一:目录里有别的机器的
446
+ * 会话备份,可它压根不在 workspaceRegistry 里,/pull 的匹配范围就没有它。
447
+ *
448
+ * 但它同时是「本机还没建特征文件夹」的目录 —— 1.2.0 起**不再自动注册**:
449
+ * 注册 + 建格子必须由人在面板上确认(POST /features/adopt)。
450
+ * 这里只把它们列在 unregistered 里交回去。
451
+ */
452
+ const autoRegister = body.autoRegister === true;
453
+ const registered = [];
454
+ const registerErrors = [];
455
+ const unregistered = [];
456
+ if (typeof deps.registerWorkspace === 'function') {
457
+ const scan = scanFolders({
458
+ folders: state.folders,
459
+ workspaces: all,
460
+ machineId,
461
+ });
462
+ for (const item of scan.discover) {
463
+ // 只关心「确实有别的机器的备份」那些,空壳目录不提
464
+ const hasRemote = listMachines(item.path).some((m) => m.machineId !== machineId);
465
+ if (!hasRemote) continue;
466
+ if (hasFeature(item.path, machineId)) continue;
467
+ if (!autoRegister) {
468
+ unregistered.push({ path: item.path, title: item.title, why: '需要先建本机特征文件夹' });
469
+ continue;
470
+ }
471
+ // 显式 autoRegister:true(供脚本/测试用),仍然要先建格子再注册
472
+ try {
473
+ adoptFeature(item.path, machineId);
474
+ const out = await deps.registerWorkspace(item.path, item.title);
475
+ registered.push({ path: item.path, id: out?.id });
476
+ all.push({ id: out?.id, path: item.path, title: item.title });
477
+ } catch (err) {
478
+ registerErrors.push({ path: item.path, message: String((err && err.message) || err) });
479
+ }
480
+ }
481
+ }
482
+
483
+ // 注册完重新匹配一次,把新注册的目录纳入拉取范围(仍只含有格子的)
484
+ const matched2 = pickWorkspaces(all, state.folders).filter((w) => hasFeature(w.path, machineId));
485
+ const chosen2 =
486
+ Array.isArray(body.workspaceIds) && body.workspaceIds.length
487
+ ? matched2.filter((w) =>
488
+ body.workspaceIds.includes(w.id) ||
489
+ registered.some((r) => r.path === w.path),
490
+ )
491
+ : matched2;
492
+
493
+ const localIndex = scanLocalSessions();
494
+ const results = [];
495
+ for (const ws of chosen2) {
496
+ const r = pullWorkspace({
497
+ workspace: ws,
498
+ machineId,
499
+ localIndex,
500
+ machines: Array.isArray(body.machines) && body.machines.length ? body.machines : null,
501
+ });
502
+
503
+ // 关键一步:把刚落盘的会话登记进工作区归属账。
504
+ // 只写文件不登记,DSH 侧栏只会把它显示成「未分组」。
505
+ const entity = wsEntity(ws.id);
506
+ if (r.newSessionIds.length) {
507
+ const att = await attachMany(entity, r.newSessionIds);
508
+ r.attached = att.attached;
509
+ r.attachFailed = att.failed;
510
+ } else {
511
+ r.attached = [];
512
+ r.attachFailed = [];
513
+ }
514
+ results.push(r);
515
+ }
516
+
517
+ const imported = results.reduce((n, r) => n + r.imported, 0);
518
+ const updated = results.reduce((n, r) => n + r.updated, 0);
519
+ const forked = results.reduce((n, r) => n + r.forked, 0);
520
+ const attached = results.reduce((n, r) => n + (r.attached || []).length, 0);
521
+ const attachFailed = results.reduce((n, r) => n + (r.attachFailed || []).length, 0);
522
+
523
+ const next = loadState();
524
+ next.lastPullAt = Date.now();
525
+ pushHistory(
526
+ next,
527
+ 'pull',
528
+ `拉取 ${chosen2.length} 个工作区:新增 ${imported}、更新 ${updated}、分叉 ${forked}、登记 ${attached}${
529
+ attachFailed ? `、登记失败 ${attachFailed}` : ''
530
+ }${registered.length ? `、自动注册 ${registered.length}` : ''}`,
531
+ );
532
+ saveState(next);
533
+
534
+ json(res, 200, {
535
+ ok: true,
536
+ results,
537
+ skipped,
538
+ registered,
539
+ registerErrors,
540
+ unregistered,
541
+ summary: {
542
+ workspaces: chosen2.length,
543
+ imported,
544
+ updated,
545
+ forked,
546
+ attached,
547
+ attachFailed,
548
+ registered: registered.length,
549
+ skipped: skipped.length,
550
+ },
551
+ // 全部登记成功就不需要重开会话索引:会话列表是实时读盘 + 归属表的
552
+ needsRestart: imported + forked > attached,
553
+ });
554
+ },
555
+
556
+ /**
557
+ * 采纳:给选中的目录建本机特征文件夹;未注册的顺带注册成工作区。
558
+ *
559
+ * 这是「检测到匹配的工作区文件夹 → 人工选择要不要在里面建本机特征文件夹」
560
+ * 的那个人工入口。同意之后立刻生效:进侧边栏、参与备份与拉取,
561
+ * 不需要再点一次注册。
562
+ */
563
+ 'POST /features/adopt': async (req, res) => {
564
+ const body = await readJson(req);
565
+ const state = loadState();
566
+ const machineId = deps.machineId() || state.machineId;
567
+ const wanted = Array.isArray(body.paths)
568
+ ? body.paths.filter((p) => typeof p === 'string' && p.trim())
569
+ : [];
570
+ if (!wanted.length) {
571
+ json(res, 400, { ok: false, error: '未选择任何目录' });
572
+ return;
573
+ }
574
+
575
+ const existing = new Set(listWorkspaces().map((w) => pathKey(w.path)));
576
+ const adopted = [];
577
+ const registeredList = [];
578
+ const errors = [];
579
+
580
+ for (const raw of wanted) {
581
+ const p = normSlash(raw);
582
+ try {
583
+ const target = adoptFeature(p, machineId);
584
+ rememberDecision(state, p, 'adopt');
585
+ let registeredId;
586
+ if (!existing.has(pathKey(p))) {
587
+ if (typeof deps.registerWorkspace === 'function') {
588
+ const out = await deps.registerWorkspace(p, path.basename(p));
589
+ registeredId = out?.id;
590
+ existing.add(pathKey(p));
591
+ registeredList.push({ path: p, id: registeredId });
592
+ }
593
+ } else {
594
+ registeredId = listWorkspaces().find((w) => pathKey(w.path) === pathKey(p))?.id;
595
+ }
596
+ adopted.push({ path: p, id: registeredId, feature: target });
597
+ } catch (err) {
598
+ errors.push({ path: p, message: String((err && err.message) || err) });
599
+ }
600
+ }
601
+
602
+ // 基线补上:刚采纳的立刻算「已知」,否则下一轮会被当成新建/缺失再处理一遍
603
+ if (!state.features) state.features = { initialized: true, decisions: {}, known: {} };
604
+ for (const item of adopted) {
605
+ if (item.id !== undefined && item.id !== null) state.features.known[String(item.id)] = item.path;
606
+ }
607
+ pushHistory(
608
+ state,
609
+ 'features',
610
+ `按人工选择建本机特征文件夹 ${adopted.length} 个${
611
+ registeredList.length ? `(其中注册工作区 ${registeredList.length} 个)` : ''
612
+ }`,
613
+ );
614
+ saveState(state);
615
+
616
+ json(res, 200, { ok: true, adopted, registered: registeredList, errors });
617
+ },
618
+
619
+ /** 拒绝:记录「这个目录本机不要建格子」,之后巡检不再反复建。 */
620
+ 'POST /features/decline': async (req, res) => {
621
+ const body = await readJson(req);
622
+ const state = loadState();
623
+ const wanted = Array.isArray(body.paths)
624
+ ? body.paths.filter((p) => typeof p === 'string' && p.trim())
625
+ : [];
626
+ if (!wanted.length) {
627
+ json(res, 400, { ok: false, error: '未选择任何目录' });
628
+ return;
629
+ }
630
+ for (const raw of wanted) rememberDecision(state, normSlash(raw), 'decline');
631
+ pushHistory(state, 'features', `跳过 ${wanted.length} 个目录(不建本机特征文件夹)`);
632
+ saveState(state);
633
+ json(res, 200, { ok: true, declined: wanted.map((p) => normSlash(p)) });
634
+ },
635
+
636
+ /**
637
+ * 删除:删本机特征文件夹(等于本机不再参与这个工作区的同步)。
638
+ *
639
+ * body.removeWorkspace=true 时连工作区注册一起去掉(侧边栏不再显示);
640
+ * 默认只删格子,保留工作区,因为「不想同步」和「不想在这里干活」是两件事。
641
+ */
642
+ 'POST /features/drop': async (req, res) => {
643
+ const body = await readJson(req);
644
+ const machineId = deps.machineId() || loadState().machineId;
645
+ const wanted = Array.isArray(body.paths)
646
+ ? body.paths.filter((p) => typeof p === 'string' && p.trim())
647
+ : [];
648
+ if (!wanted.length) {
649
+ json(res, 400, { ok: false, error: '未选择任何目录' });
650
+ return;
651
+ }
652
+ if (typeof deps.deleteWorkspace !== 'function') {
653
+ json(res, 503, { ok: false, error: '当前环境不支持删除工作区' });
654
+ return;
655
+ }
656
+
657
+ /**
658
+ * 删工作区(连带删本机特征文件夹)。语义按「有没有别人的格子」分两种:
659
+ *
660
+ * - **只有本机这一格**:连 `.dsh-sync/` 一起收掉。巡检就再也扫不到它,
661
+ * 不会追问、也不会被当成本机没建的共享工作区(用户明确要求)。
662
+ * - **还有别人的格子**:只删我这一格(连同格里备份的对话),`.dsh-sync/` 留着。
663
+ * 巡检据此认为「本机没有这个共享工作区」,进人工队列问要不要重建。
664
+ *
665
+ * 必须删工作区注册本身:只删格子会留下「已注册但没格子」的工作区,
666
+ * 下一轮巡检按矩阵会把它判成「有工作区没格子 → 补文件夹」,看起来就是删不掉。
667
+ */
668
+ const all = listWorkspaces();
669
+ const dropped = [];
670
+ const errors = [];
671
+
672
+ for (const raw of wanted) {
673
+ const p = normSlash(raw);
674
+ try {
675
+ const hit = all.find((w) => pathKey(w.path) === pathKey(p));
676
+ if (!hit) {
677
+ errors.push({ path: p, message: '这个目录没有注册成工作区,无需删除' });
678
+ continue;
679
+ }
680
+ await deps.deleteWorkspace(hit.id);
681
+ const out = dropFeature(p, machineId, { log });
682
+ const state = loadState();
683
+ for (const [id, knownPath] of Object.entries(state.features.known || {})) {
684
+ if (pathKey(knownPath) === pathKey(p)) delete state.features.known[id];
685
+ }
686
+ // 删掉 ≠「本机不要这个目录」:它是删工作区。不清这条记录,巡检会把它过滤掉。
687
+ forgetDecision(state, p);
688
+ pushHistory(
689
+ state,
690
+ 'features',
691
+ `删除工作区并清掉本机特征文件夹:${path.basename(p)}${
692
+ out.keptStructure && (out.others || []).length
693
+ ? `(保留 ${out.others.length} 个其他机器的格子,巡检会问要不要重建)`
694
+ : '(目录结构一并收掉,巡检不再列出)'
695
+ }`,
696
+ );
697
+ saveState(state);
698
+ dropped.push({
699
+ path: p,
700
+ title: hit.title,
701
+ id: hit.id,
702
+ removed: out.removed,
703
+ removedWorkspace: true,
704
+ others: out.others || [],
705
+ keptStructure: !!out.keptStructure,
706
+ });
707
+ } catch (err) {
708
+ errors.push({ path: p, message: String((err && err.message) || err) });
709
+ }
710
+ }
711
+
712
+ json(res, 200, { ok: true, dropped, errors });
713
+ },
714
+
715
+ /**
716
+ * 补登记:把「本插件拉进来、但没进归属账」的会话补进 sessionIds。
717
+ *
718
+ * 这是历史遗留数据的清理入口——旧版拉取只写文件不登记,
719
+ * 那些会话至今仍散在「未分组」里。**日常不需要点**:/pull 拉完自己会登记。
720
+ *
721
+ * 默认只补拉取产物(别的机器备份目录里有它),不会把自动化插件和
722
+ * 子代理留下的会话一起刷进来。要全量补必须显式传 all:true。
723
+ * 1.2.0 起只处理有本机特征文件夹的工作区。
724
+ */
725
+ 'POST /attach/repair': async (req, res) => {
726
+ const body = await readJson(req);
727
+ const state = loadState();
728
+ const machineId = deps.machineId() || state.machineId;
729
+ const all = listWorkspaces();
730
+ const matched = pickWorkspaces(all, state.folders).filter((w) => hasFeature(w.path, machineId));
731
+ const chosen =
732
+ Array.isArray(body.workspaceIds) && body.workspaceIds.length
733
+ ? matched.filter((w) => body.workspaceIds.includes(w.id))
734
+ : matched;
735
+
736
+ const localIndex = scanLocalSessions();
737
+ const ents = entities();
738
+ const scopeAll = body.all === true;
739
+ const results = [];
740
+
741
+ for (const ws of chosen) {
742
+ const entity = wsEntity(ws.id);
743
+ if (!entity) {
744
+ results.push({ id: ws.id, title: ws.title || ws.path, attached: [], skipped: '当前环境不支持登记会话归属' });
745
+ continue;
746
+ }
747
+ const missing = scopeAll
748
+ ? findUnattached({ workspace: ws, entities: ents, localIndex, projectKeysFor })
749
+ : findPulledUnattached({
750
+ workspace: ws,
751
+ machineId,
752
+ entities: ents,
753
+ localIndex,
754
+ projectKeysFor,
755
+ });
756
+ const att = await attachMany(entity, missing);
757
+ results.push({
758
+ id: ws.id,
759
+ title: ws.title || ws.path,
760
+ scope: scopeAll ? 'all' : 'pulled',
761
+ candidates: missing.length,
762
+ attached: att.attached,
763
+ failed: att.failed,
764
+ });
765
+ }
766
+
767
+ const total = results.reduce((n, r) => n + (r.attached || []).length, 0);
768
+ const failed = results.reduce((n, r) => n + ((r.failed && r.failed.length) || 0), 0);
769
+
770
+ const next = loadState();
771
+ if (total || failed) {
772
+ pushHistory(next, 'attach', `补登记归属:成功 ${total}、失败 ${failed}`);
773
+ saveState(next);
774
+ }
775
+
776
+ json(res, 200, {
777
+ ok: true,
778
+ scope: scopeAll ? 'all' : 'pulled',
779
+ results,
780
+ summary: { attached: total, failed },
781
+ });
782
+ },
783
+
784
+ /**
785
+ * 巡检:只扫描,不动任何文件。
786
+ *
787
+ * 返回四类:
788
+ * discover 符合插件结构、但本机还没注册的目录(要人点)
789
+ * pending 需要人工决定「要不要在本机建特征文件夹」的(含 discover 与存量工作区)
790
+ * restore/drop 下一轮巡检会自动做的(外部删除自愈 / 随工作区删除)
791
+ * repair 已注册但缺格子——1.2.0 起不再自动补,归到 pending 让人点头
792
+ */
793
+ 'POST /patrol/scan': async (req, res) => {
794
+ const state = loadState();
795
+ const machineId = deps.machineId() || state.machineId;
796
+ const all = listWorkspaces();
797
+ const result = scanFolders({
798
+ folders: state.folders,
799
+ workspaces: all,
800
+ machineId,
801
+ });
802
+ const plan = planFeatures({
803
+ folders: state.folders,
804
+ workspaces: all,
805
+ discover: result.discover,
806
+ state,
807
+ machineId,
808
+ });
809
+
810
+ const next = loadState();
811
+ next.patrol = {
812
+ ...next.patrol,
813
+ lastRunAt: Date.now(),
814
+ lastResult: {
815
+ discover: result.discover.length,
816
+ repair: result.repair.length,
817
+ ask: plan.ask.length,
818
+ createFolder: plan.createFolder.length,
819
+ restoreWorkspace: plan.restoreWorkspace.length,
820
+ },
821
+ };
822
+ saveState(next);
823
+
824
+ json(res, 200, {
825
+ ok: true,
826
+ ...result,
827
+ // 人要看的那一档
828
+ pending: plan.ask,
829
+ // 下面三类巡检会自己做,这里只报个数方便核对
830
+ autoCreateFolder: plan.createFolder,
831
+ autoRestoreWorkspace: plan.restoreWorkspace,
832
+ autoDrop: plan.drop,
833
+ machineId,
834
+ });
835
+ },
836
+
837
+ /**
838
+ * 注册:把选中的目录注册为工作区,并建本机特征文件夹。
839
+ *
840
+ * 与 /features/adopt 等价(保留旧路由给已有面板/脚本用)。
841
+ */
842
+ 'POST /patrol/register': async (req, res) => {
843
+ const body = await readJson(req);
844
+ const state = loadState();
845
+ const machineId = deps.machineId() || state.machineId;
846
+ const paths = Array.isArray(body.paths) ? body.paths.filter((p) => typeof p === 'string') : [];
847
+ if (!paths.length) {
848
+ json(res, 400, { ok: false, error: '未选择任何目录' });
849
+ return;
850
+ }
851
+ if (typeof deps.registerWorkspace !== 'function') {
852
+ json(res, 503, { ok: false, error: '当前环境不支持注册工作区' });
853
+ return;
854
+ }
855
+
856
+ const registered = [];
857
+ const errors = [];
858
+ const existing = new Set(listWorkspaces().map((w) => pathKey(w.path)));
859
+
860
+ for (const p of paths) {
861
+ try {
862
+ if (existing.has(pathKey(p))) {
863
+ // 已注册但可能缺格子:这次点了同意就等于确认建格子
864
+ const target = adoptFeature(p, machineId);
865
+ rememberDecision(state, p, 'adopt');
866
+ registered.push({ path: p, skipped: '已注册', own: target });
867
+ continue;
868
+ }
869
+ const out = await deps.registerWorkspace(p, path.basename(p));
870
+ // 注册 + 建格子一起做:人点了同意,就该立刻参与
871
+ const target = adoptFeature(p, machineId);
872
+ rememberDecision(state, p, 'adopt');
873
+ if (out?.id !== undefined) state.features.known[String(out.id)] = normSlash(p);
874
+ registered.push({ path: p, id: out?.id, created: !!out?.created, own: target });
875
+ } catch (err) {
876
+ errors.push({ path: p, message: String((err && err.message) || err) });
877
+ }
878
+ }
879
+
880
+ pushHistory(state, 'register', `注册 ${registered.length} 个工作区(含建本机特征文件夹)`);
881
+ saveState(state);
882
+
883
+ json(res, 200, { ok: true, registered, errors });
884
+ },
885
+
886
+ /**
887
+ * 补建:给选中的工作区建本机特征文件夹。
888
+ *
889
+ * 1.2.0 起**不再**「发现问题就自动补」:只补传进来的 paths,
890
+ * 没传就什么都不做(历史上这里会对全部匹配工作区动手)。
891
+ */
892
+ 'POST /patrol/repair': async (req, res) => {
893
+ const body = await readJson(req);
894
+ const state = loadState();
895
+ const machineId = deps.machineId() || state.machineId;
896
+ const all = listWorkspaces();
897
+ const targets = Array.isArray(body.paths) && body.paths.length
898
+ ? all.filter((w) => body.paths.some((p) => pathKey(p) === pathKey(w.path)))
899
+ : [];
900
+
901
+ const out = repairAll(targets, machineId);
902
+ for (const p of body.paths || []) rememberDecision(state, p, 'adopt');
903
+ if (!state.features) state.features = { initialized: true, decisions: {}, known: {} };
904
+ for (const ws of targets) {
905
+ if (!hasFeature(ws.path, machineId)) continue;
906
+ state.features.known[String(ws.id)] = normSlash(ws.path);
907
+ }
908
+ pushHistory(state, 'repair', `补建 ${out.created.length} 个本机特征文件夹(人工指定)`);
909
+ saveState(state);
910
+
911
+ json(res, 200, { ok: true, ...out });
912
+ },
913
+
914
+ /** 定时巡检设置。 */
915
+ 'POST /patrol/config': async (req, res) => {
916
+ const body = await readJson(req);
917
+ const state = loadState();
918
+ const patrol = { ...state.patrol };
919
+
920
+ if (typeof body.enabled === 'boolean') patrol.enabled = body.enabled;
921
+ if (body.intervalMinutes !== undefined) {
922
+ const n = Number(body.intervalMinutes);
923
+ if (!Number.isFinite(n) || n < 1 || n > 1440) {
924
+ json(res, 400, { ok: false, error: '间隔应为 1–1440 之间的整数(分钟)' });
925
+ return;
926
+ }
927
+ patrol.intervalMinutes = Math.floor(n);
928
+ }
929
+ state.patrol = patrol;
930
+ saveState(state);
931
+ json(res, 200, { ok: true, patrol: state.patrol });
932
+ },
933
+
934
+ /* ---------------- 技能同步 ---------------- */
935
+
936
+ /**
937
+ * 比对 + 自动同步。
938
+ *
939
+ * 语义(用户 2026-11-01 定):同步属性的技能,比对时就把能定下来的更新做掉——
940
+ * 母版更新就拉下来,本机更新就推上去。只有**判不出谁新**的才留给用户裁决。
941
+ *
942
+ * body.auto === false 时退化为纯比对(只报告,不动任何文件)。
943
+ */
944
+ 'POST /skills/scan': async (req, res) => {
945
+ const body = await readJson(req).catch(() => ({}));
946
+ const auto = body && body.auto === false ? false : true;
947
+ const dryRunFlag = body?.dryRun === true;
948
+ const state = loadState();
949
+ const master = state.skills.masterRoot;
950
+ if (!master) {
951
+ json(res, 400, { ok: false, error: '尚未设置技能母版文件夹' });
952
+ return;
953
+ }
954
+ if (!fs.existsSync(master)) {
955
+ json(res, 400, { ok: false, error: `母版文件夹不存在或不可访问:${master}` });
956
+ return;
957
+ }
958
+
959
+ // ── 阶段 0:记忆插件技能优化闭环(发现 → 起草 → 审核 → 落地 → 结案)
960
+ // 0.8.0 起这一步会写账本(结案)并调模型起草;dryRun 时只报告。
961
+ const optimize = auto
962
+ ? await runOptimizationCycle({
963
+ localRoot: LOCAL_SKILLS_ROOT,
964
+ masterRoot: master,
965
+ stageDir: process.env.DSH_SYNC_STAGE_DIR || 'C:/DSH-Work/mnemon-drafts/stage',
966
+ minConfidence: Number(state.skills.mnemonMinConfidence) || 0.7,
967
+ enabled: state.skills.mnemonOptimize !== false,
968
+ // 比对/巡检必须自己把「生成草案」这一步走完。
969
+ //
970
+ // 历史:这里曾经写死 `draft: null`,理由是「比对是同步语义,起草要调模型会卡住」。
971
+ // 结果是任何走比对路径的巡检(面板点「比对」、开机首拉、每 120 分钟的定时轮)
972
+ // 都只发现建议、从不起草,建议永远停在 pending;只有单独点「立即优化」
973
+ // 或走定时器那条后台链路才会起草。用户要的是「巡检发现待处理就自己处理完」,
974
+ // 所以把起草器接回来,由 body 控制:
975
+ // body.draft === false → 只比对(显式要求纯比对时才这样)
976
+ // body.dryRun === true → 不调模型(预演)
977
+ // 其余情况一律带起草器,让「发现 → 起草 → 审核 → 落地 → 结案」在一条路径里跑完。
978
+ draft: dryRunFlag || body?.draft === false ? null : drafter(),
979
+ maxDrafts: Number(state.skills.mnemonMaxDrafts) || 6,
980
+ onEvent: (kind, payload) => log(`优化闭环 ${kind}: ${JSON.stringify(payload)}`),
981
+ })
982
+ : { enabled: false, skipped: 'auto=false', discovered: { suggestions: 0, drafts: 0 }, todo: [], dropped: [], enqueued: [], audited: [], accepted: [], errors: [] };
983
+
984
+ // 记忆插件草稿预处理:auto 时先落地再比对,比对看到的就是落地后的内容
985
+ const prepass = auto
986
+ ? runMnemonPrepass({
987
+ masterRoot: master,
988
+ localRoot: LOCAL_SKILLS_ROOT,
989
+ modes: state.skills.modes || {},
990
+ machineId: state.machineId,
991
+ machineName: state.machineName,
992
+ enabled: state.skills.mnemonAuto !== false,
993
+ })
994
+ : { enabled: false, drafts: 0, apply: [], skipped: [], applied: [], pushed: [], errors: [] };
995
+
996
+ const plan = planSync({
997
+ masterRoot: master,
998
+ localRoot: LOCAL_SKILLS_ROOT,
999
+ modes: state.skills.modes || {},
1000
+ ...judgeEvidence(master),
1001
+ });
1002
+
1003
+ // 比对本身就是一次观测:记下两侧各自现在是什么内容、何时变成这样
1004
+ const next = loadState();
1005
+ const observed = observeLineage(master, plan, next.machineId);
1006
+
1007
+ const stamp = makeStamp();
1008
+ let pulled = { pulled: [], errors: [] };
1009
+ let pushed = { pushed: [], errors: [] };
1010
+
1011
+ if (auto && (plan.pull.length || plan.autoPush.length)) {
1012
+ // 母版更新 → 拉到本机
1013
+ if (plan.pull.length) {
1014
+ pulled = applyPull({ items: plan.pull, localRoot: LOCAL_SKILLS_ROOT, stamp });
1015
+ }
1016
+ // 本机更新(同步属性)→ 推回母版,旧版先存档
1017
+ if (plan.autoPush.length) {
1018
+ const backRoot = path.join(master, '_backup');
1019
+ pushed = applyPush({ items: plan.autoPush, masterRoot: master, backRoot, stamp });
1020
+ const auditItems = pushed.pushed.map((p) => ({
1021
+ skill: p.name,
1022
+ operation: p.operation,
1023
+ detail: `${p.operation === '覆盖' ? `旧版备份于 _backup/${p.name}.${stamp}` : '母版此前无此技能'}(自动:内容更新)`,
1024
+ }));
1025
+ if (auditItems.length) {
1026
+ try {
1027
+ appendMasterChanges(auditItems, {
1028
+ masterRoot: master,
1029
+ machineId: next.machineId,
1030
+ machineName: next.machineName,
1031
+ trigger: '自动同步',
1032
+ });
1033
+ } catch {
1034
+ /* 记录写不进去不影响同步本身 */
1035
+ }
1036
+ }
1037
+ }
1038
+ // 同步过的内容现在两侧一致,登记成新基线
1039
+ const aligned = [
1040
+ ...plan.pull.map((x) => x.master),
1041
+ ...plan.autoPush.map((x) => x.local),
1042
+ ...plan.same.map((x) => x.master),
1043
+ ].filter(Boolean);
1044
+ if (aligned.length) {
1045
+ let map = loadLineage(path.join(master, LINEAGE_FILE));
1046
+ for (const e of aligned) {
1047
+ map = recordLineage(map, { name: e.name, baseline: e.treeHash, master: e, local: e, by: next.machineId });
1048
+ }
1049
+ saveLineage(path.join(master, LINEAGE_FILE), map);
1050
+ }
1051
+ }
1052
+
1053
+ next.skills.lastRunAt = Date.now();
1054
+ next.skills.lastResult = {
1055
+ pull: plan.pull.length,
1056
+ autoPush: plan.autoPush.length,
1057
+ push: plan.push.length,
1058
+ same: plan.same.length,
1059
+ localOnly: plan.localOnly.length,
1060
+ pulled: pulled.pulled.length,
1061
+ pushed: pushed.pushed.length,
1062
+ observed,
1063
+ auto,
1064
+ scannedAt: plan.scannedAt,
1065
+ mnemon: {
1066
+ drafts: prepass.drafts,
1067
+ applied: prepass.applied.length,
1068
+ pushed: prepass.pushed.length,
1069
+ skipped: prepass.skipped.length,
1070
+ },
1071
+ };
1072
+ if (auto && (pulled.pulled.length || pushed.pushed.length || prepass.applied.length)) {
1073
+ pushHistory(
1074
+ next,
1075
+ 'skills',
1076
+ `技能自动同步:拉取 ${pulled.pulled.length}、推送 ${pushed.pushed.length}、待裁决 ${plan.push.length}${
1077
+ prepass.applied.length ? `、记忆插件草稿落地 ${prepass.applied.length}` : ''
1078
+ }`,
1079
+ );
1080
+ }
1081
+ saveState(next);
1082
+
1083
+ const brief = (x) => ({
1084
+ name: x.name,
1085
+ action: x.action,
1086
+ reason: x.reason || (x.action === 'new' ? '母版新增' : ''),
1087
+ basis: x.basis || null,
1088
+ });
1089
+
1090
+ const pulledNames = new Set(pulled.pulled.map((p) => p.name));
1091
+ const pushedNames = new Set(pushed.pushed.map((p) => p.name));
1092
+
1093
+ json(res, 200, {
1094
+ ok: true,
1095
+ masterRoot: master,
1096
+ localRoot: LOCAL_SKILLS_ROOT,
1097
+ auto,
1098
+ stamp,
1099
+ // 母版更新:自动拉取
1100
+ pull: plan.pull.map((x) => ({ ...brief(x), version: x.master.version, done: pulledNames.has(x.name) })),
1101
+ // 本机更新(同步属性):自动推送
1102
+ autoPush: plan.autoPush.map((x) => ({
1103
+ ...brief(x),
1104
+ masterVersion: x.master.version,
1105
+ localVersion: x.local.version,
1106
+ done: pushedNames.has(x.name),
1107
+ })),
1108
+ // 判不出来的:只报告,等用户裁决(附差异摘要,方便一眼决定)
1109
+ push: plan.push.map((x) => ({
1110
+ ...brief(x),
1111
+ masterVersion: x.master.version,
1112
+ localVersion: x.local.version,
1113
+ diff: x.diff || null,
1114
+ })),
1115
+ same: plan.same.map((x) => x.name),
1116
+ localOnly: plan.localOnly.map((x) => x.name),
1117
+ // 优化闭环:发现了什么、判断结果、入队、审核、通过
1118
+ optimize,
1119
+ // 记忆插件草稿:这一轮落地了哪些、跳过了哪些(跳过都带原因)
1120
+ mnemon: {
1121
+ enabled: prepass.enabled,
1122
+ drafts: prepass.drafts,
1123
+ apply: prepass.apply,
1124
+ applied: prepass.applied.map((a) => ({
1125
+ name: a.name,
1126
+ action: a.action,
1127
+ draftId: a.draftId,
1128
+ targetRoot: a.targetRoot,
1129
+ backup: a.backup,
1130
+ })),
1131
+ pushed: prepass.pushed.map((p) => ({ name: p.name, operation: p.operation })),
1132
+ skipped: prepass.skipped,
1133
+ },
1134
+ errors: [...pulled.errors, ...pushed.errors, ...prepass.errors],
1135
+ observed,
1136
+ });
1137
+ },
1138
+
1139
+ /**
1140
+ * 优化闭环的只读快照:队列里在等什么、账本里还有多少建议没做。
1141
+ *
1142
+ * 不改任何文件。面板用它显示「现在积压了多少」。
1143
+ */
1144
+ /**
1145
+ * 手动跑一轮完整流程(发现 → 起草 → 审核 → 落地 → 结案)。
1146
+ *
1147
+ * body: { dryRun?: boolean, maxDrafts?: number, wait?: boolean }
1148
+ *
1149
+ * **默认立刻返回 202,起草在后台跑**。为什么:一轮 6 条草稿要两分钟以上,
1150
+ * 串在 HTTP 响应里会让前端「点了没反应」(实测就是这么被投诉的),
1151
+ * 浏览器/代理还可能先超时。要同步结果就传 `wait:true`(只建议 dryRun 时用)。
1152
+ * 进度用 `POST /skills/optimize/state` 的 running/nextRun 两个字段轮询。
1153
+ */
1154
+ 'POST /skills/optimize/run': async (req, res) => {
1155
+ const body = (await readJson(req).catch(() => ({}))) || {};
1156
+ const state = loadState();
1157
+ // 母版不是必需品:没有母版照样能落地并结案(结案不依赖母版是既有约定),
1158
+ // 只是跳过「推母版」这一步。所以这里不拦,只在报告里说明。
1159
+ const master = state.skills.masterRoot || undefined;
1160
+ const enabled = state.skills.mnemonOptimize !== false;
1161
+ const maxDrafts = Number(body.maxDrafts) || Number(state.skills.mnemonMaxDrafts) || 6;
1162
+ const dryRun = body.dryRun === true;
1163
+
1164
+ const runOnce = () =>
1165
+ runOptimizationCycle({
1166
+ localRoot: LOCAL_SKILLS_ROOT,
1167
+ masterRoot: master,
1168
+ stageDir: process.env.DSH_SYNC_STAGE_DIR || 'C:/DSH-Work/mnemon-drafts/stage',
1169
+ minConfidence: Number(state.skills.mnemonMinConfidence) || 0.7,
1170
+ maxDrafts,
1171
+ dryRun,
1172
+ // dryRun 不调模型:那样「预演」也要两分钟,且没有意义(没落地可预演)
1173
+ draft: dryRun ? null : drafter(),
1174
+ enabled,
1175
+ onEvent: (kind, payload) => log(`优化闭环 ${kind}: ${JSON.stringify(payload)}`),
1176
+ });
1177
+
1178
+ // 同步模式:只给 dryRun / 明确要 wait 的调用方
1179
+ if (body.wait === true || dryRun) {
1180
+ try {
1181
+ const report = await runOnce();
1182
+ json(res, 200, { ok: true, report });
1183
+ } catch (err) {
1184
+ json(res, 500, { ok: false, error: String((err && err.message) || err) });
1185
+ }
1186
+ return;
1187
+ }
1188
+
1189
+ // 异步模式(默认):立刻回 202,后台跑
1190
+ if (optimizeRun.running) {
1191
+ json(res, 202, {
1192
+ ok: true,
1193
+ started: false,
1194
+ running: true,
1195
+ startedAt: optimizeRun.startedAt,
1196
+ note: '上一轮还在跑,本次不重复启动',
1197
+ });
1198
+ return;
1199
+ }
1200
+ optimizeRun.running = true;
1201
+ optimizeRun.startedAt = new Date().toISOString();
1202
+ optimizeRun.lastReport = null;
1203
+ optimizeRun.lastError = null;
1204
+ runOnce()
1205
+ .then((report) => {
1206
+ optimizeRun.lastReport = {
1207
+ at: new Date().toISOString(),
1208
+ remaining: report.remaining,
1209
+ closed: report.closed,
1210
+ landed: (report.pipeline && report.pipeline.stage && report.pipeline.stage.landed) || [],
1211
+ drafted: (report.pipeline && report.pipeline.stage && report.pipeline.stage.drafted) || [],
1212
+ failed: (report.pipeline && report.pipeline.stage && report.pipeline.stage.failed) || [],
1213
+ errors: report.errors,
1214
+ };
1215
+ log(
1216
+ `优化闭环(手动):起草 ${optimizeRun.lastReport.drafted.length}、` +
1217
+ `落地 ${optimizeRun.lastReport.landed.length}、失败 ${optimizeRun.lastReport.failed.length}、` +
1218
+ `剩余 ${report.remaining}`,
1219
+ );
1220
+ })
1221
+ .catch((err) => {
1222
+ optimizeRun.lastError = String((err && err.message) || err);
1223
+ log(`优化闭环(手动)失败:${optimizeRun.lastError}`);
1224
+ })
1225
+ .finally(() => {
1226
+ optimizeRun.running = false;
1227
+ optimizeRun.finishedAt = new Date().toISOString();
1228
+ });
1229
+ json(res, 202, {
1230
+ ok: true,
1231
+ started: true,
1232
+ running: true,
1233
+ startedAt: optimizeRun.startedAt,
1234
+ maxDrafts,
1235
+ note: '起草已转后台,用 POST /skills/optimize/state 轮询进度',
1236
+ });
1237
+ },
1238
+
1239
+ 'POST /skills/optimize/state': async (req, res) => {
1240
+ const state = loadState();
1241
+ const queue = readQueue();
1242
+ const found = discoverOptimizations({ localRoot: LOCAL_SKILLS_ROOT });
1243
+ const { todo, dropped } = triage({
1244
+ suggestions: found.suggestions,
1245
+ minConfidence: Number(state.skills.mnemonMinConfidence) || 0.7,
1246
+ seen: queue.seen,
1247
+ });
1248
+ // 账本视角的三段计数:没有草稿的 / 有 ready 草稿的 / 已经在磁盘上的
1249
+ let ledgerView = { pending: 0, withDraft: 0, readyDrafts: 0, adopted: 0, generation: null };
1250
+ try {
1251
+ const raw = JSON.parse(fs.readFileSync(MNEMON_LEDGER, 'utf8'));
1252
+ const sug = Array.isArray(raw.suggestions) ? raw.suggestions : [];
1253
+ const drf = Array.isArray(raw.drafts) ? raw.drafts : [];
1254
+ const readyBySuggestion = new Set(drf.filter((d) => d && d.status === 'ready' && d.suggestionId).map((d) => d.suggestionId));
1255
+ const pending = sug.filter((s) => s && s.status === 'pending');
1256
+ ledgerView = {
1257
+ pending: pending.length,
1258
+ withDraft: pending.filter((s) => readyBySuggestion.has(s.id)).length,
1259
+ readyDrafts: drf.filter((d) => d && d.status === 'ready').length,
1260
+ adopted: sug.filter((s) => s && s.status === 'adopted').length,
1261
+ generation: typeof raw.generation === 'number' ? raw.generation : null,
1262
+ };
1263
+ } catch {
1264
+ /* 账本读不到就返回零值,面板不报错 */
1265
+ }
1266
+ json(res, 200, {
1267
+ ok: true,
1268
+ ledgerView,
1269
+ // 手动触发那一轮的运行态:面板靠这两个字段显示「在跑 / 上次结果」
1270
+ running: optimizeRun.running,
1271
+ startedAt: optimizeRun.startedAt,
1272
+ finishedAt: optimizeRun.finishedAt,
1273
+ lastReport: optimizeRun.lastReport,
1274
+ lastError: optimizeRun.lastError,
1275
+ enabled: state.skills.mnemonOptimize !== false,
1276
+ minConfidence: Number(state.skills.mnemonMinConfidence) || 0.7,
1277
+ queue: {
1278
+ file: '~/.mnemon/skills/external-drafts.json',
1279
+ generation: queue.generation,
1280
+ waiting: queue.items.filter((x) => x.status === 'waiting-draft').length,
1281
+ done: queue.items.filter((x) => x.status === 'done').length,
1282
+ seen: queue.seen.length,
1283
+ items: queue.items.slice(-40),
1284
+ },
1285
+ ledger: { suggestions: found.suggestions.length, readyDrafts: found.drafts.length },
1286
+ todo: todo.map((x) => ({ skillName: x.skillName, action: x.action, priority: x.priority, reason: x.reason })),
1287
+ dropped,
1288
+ });
1289
+ },
1290
+
1291
+ /**
1292
+ * 只审核不落地:把暂存区里的草稿过一遍机械审核,看哪些能通过。
1293
+ * 用来在真正落地前先看一眼。
1294
+ */
1295
+ 'POST /skills/optimize/audit': async (req, res) => {
1296
+ const body = await readJson(req).catch(() => ({}));
1297
+ const stageDir = body.stageDir || process.env.DSH_SYNC_STAGE_DIR || 'C:/DSH-Work/mnemon-drafts/stage';
1298
+ const state = loadState();
1299
+ const queue = readQueue();
1300
+ const found = discoverOptimizations({ localRoot: LOCAL_SKILLS_ROOT });
1301
+ const { todo } = triage({
1302
+ suggestions: found.suggestions,
1303
+ minConfidence: Number(state.skills.mnemonMinConfidence) || 0.7,
1304
+ seen: queue.seen,
1305
+ });
1306
+
1307
+ const rows = [];
1308
+ for (const item of todo) {
1309
+ const file = `${stageDir}/${item.skillName}/SKILL.md`;
1310
+ if (!fs.existsSync(file)) {
1311
+ rows.push({ skillName: item.skillName, action: item.action, staged: false, ok: null, problems: [] });
1312
+ continue;
1313
+ }
1314
+ const text = fs.readFileSync(file, 'utf8');
1315
+ const audit = auditDraft({ text, name: item.skillName, isUpdate: item.action === 'update', localRoot: LOCAL_SKILLS_ROOT });
1316
+ rows.push({ skillName: item.skillName, action: item.action, staged: true, ok: audit.ok, problems: audit.problems, info: audit.info });
1317
+ }
1318
+ json(res, 200, { ok: true, stageDir, rows });
1319
+ },
1320
+
1321
+ /** 执行拉取(母版 → 本地)+ 移除母版已删的。 */
1322
+ 'POST /skills/pull': async (req, res) => {
1323
+ const state = loadState();
1324
+ const master = state.skills.masterRoot;
1325
+ if (!master) {
1326
+ json(res, 400, { ok: false, error: '尚未设置技能母版文件夹' });
1327
+ return;
1328
+ }
1329
+ if (!fs.existsSync(master)) {
1330
+ json(res, 400, { ok: false, error: `母版文件夹不存在或不可访问:${master}` });
1331
+ return;
1332
+ }
1333
+
1334
+ const plan = planSync({
1335
+ masterRoot: master,
1336
+ localRoot: LOCAL_SKILLS_ROOT,
1337
+ modes: state.skills.modes || {},
1338
+ ...judgeEvidence(master),
1339
+ });
1340
+ const stamp = makeStamp();
1341
+
1342
+ const pulled = applyPull({ items: plan.pull, localRoot: LOCAL_SKILLS_ROOT, stamp });
1343
+
1344
+ // 拉取落地后,被拉的和本来就一致的两侧都对齐了,一起登记成基线
1345
+ const next = loadState();
1346
+ const stamped = stampBaseline(
1347
+ master,
1348
+ [...plan.pull.map((x) => x.master), ...plan.same.map((x) => x.master)].filter(Boolean),
1349
+ next.machineId,
1350
+ );
1351
+
1352
+ next.skills.lastRunAt = Date.now();
1353
+ next.skills.lastResult = {
1354
+ pulled: pulled.pulled.length,
1355
+ pendingPush: plan.push.length,
1356
+ errors: pulled.errors.length,
1357
+ lineage: stamped,
1358
+ };
1359
+ pushHistory(
1360
+ next,
1361
+ 'skills',
1362
+ `技能拉取:更新 ${pulled.pulled.length}、待推 ${plan.push.length}`,
1363
+ );
1364
+ saveState(next);
1365
+
1366
+ json(res, 200, {
1367
+ ok: true,
1368
+ pulled: pulled.pulled,
1369
+ errors: pulled.errors,
1370
+ pendingPush: plan.push.map((x) => ({ name: x.name, reason: x.reason, basis: x.basis || null })),
1371
+ lineage: stamped,
1372
+ stamp,
1373
+ });
1374
+ },
1375
+
1376
+ /** 把本地技能推回母版(用户确认后才调用)。支持覆盖,也支持新增本地独有技能。 */
1377
+ 'POST /skills/push': async (req, res) => {
1378
+ const body = await readJson(req);
1379
+ const state = loadState();
1380
+ const master = state.skills.masterRoot;
1381
+ if (!master) {
1382
+ json(res, 400, { ok: false, error: '尚未设置技能母版文件夹' });
1383
+ return;
1384
+ }
1385
+ const names = Array.isArray(body.names) ? body.names.filter((n) => typeof n === 'string') : [];
1386
+ if (!names.length) {
1387
+ json(res, 400, { ok: false, error: '未选择任何技能' });
1388
+ return;
1389
+ }
1390
+
1391
+ const plan = planSync({
1392
+ masterRoot: master,
1393
+ localRoot: LOCAL_SKILLS_ROOT,
1394
+ modes: state.skills.modes || {},
1395
+ ...judgeEvidence(master),
1396
+ });
1397
+ const stamp = makeStamp();
1398
+
1399
+ // 覆盖项:本机更新的(同步属性,在 autoPush 里)+ 待裁决里被指名的
1400
+ // 新增项:localOnly(用户显式指名才推)
1401
+ // 注意 autoPush 也要算进来——否则「本机更新」这条在面板上点「推送」
1402
+ // 会报「没有可推送的变更」。
1403
+ const overwrites = [...plan.autoPush, ...plan.push].filter((x) => names.includes(x.name));
1404
+ const creates = plan.localOnly
1405
+ .filter((x) => names.includes(x.name))
1406
+ .map((x) => ({ name: x.name, action: 'create', local: x.local, master: null }));
1407
+ const items = [...overwrites, ...creates];
1408
+
1409
+ if (!items.length) {
1410
+ json(res, 400, { ok: false, error: '选中的技能当前没有可推送的变更' });
1411
+ return;
1412
+ }
1413
+
1414
+ const backRoot = path.join(master, '_backup');
1415
+ const out = applyPush({ items, masterRoot: master, backRoot, stamp });
1416
+
1417
+ // 每一次实际写入都留记录
1418
+ const audit = appendMasterChanges(
1419
+ out.pushed.map((p) => ({
1420
+ skill: p.name,
1421
+ operation: p.operation,
1422
+ detail: p.operation === '覆盖' ? `旧版备份于 _backup/${p.name}.${stamp}` : '母版此前无此技能',
1423
+ })),
1424
+ {
1425
+ masterRoot: master,
1426
+ machineId: state.machineId,
1427
+ machineName: state.machineName,
1428
+ trigger: '用户授权',
1429
+ },
1430
+ );
1431
+
1432
+ const next = loadState();
1433
+ // 推送落地后母版就是本机这一份,登记成新基线
1434
+ const stamped = stampBaseline(
1435
+ master,
1436
+ out.pushed.map((p) => items.find((i) => i.name === p.name)).filter(Boolean).map((i) => i.local).filter(Boolean),
1437
+ next.machineId,
1438
+ );
1439
+ pushHistory(
1440
+ next,
1441
+ 'skills',
1442
+ `技能推送:覆盖 ${out.pushed.filter((p) => p.operation === '覆盖').length}、新增 ${
1443
+ out.pushed.filter((p) => p.operation === '新增').length
1444
+ }`,
1445
+ );
1446
+ saveState(next);
1447
+
1448
+ json(res, 200, {
1449
+ ok: true,
1450
+ pushed: out.pushed,
1451
+ errors: out.errors,
1452
+ backupRoot: backRoot,
1453
+ stamp,
1454
+ lineage: stamped,
1455
+ changelog: {
1456
+ path: audit.written[0]?.path || changelogPath(master),
1457
+ written: audit.written.length,
1458
+ failed: audit.failed,
1459
+ },
1460
+ });
1461
+ },
1462
+
1463
+ /** 读取母版修改记录。 */
1464
+ 'POST /skills/changelog': async (req, res) => {
1465
+ const body = await readJson(req);
1466
+ const state = loadState();
1467
+ const master = state.skills.masterRoot;
1468
+ if (!master) {
1469
+ json(res, 400, { ok: false, error: '尚未设置技能母版文件夹' });
1470
+ return;
1471
+ }
1472
+ const limit = Number(body.limit) > 0 ? Math.min(Math.floor(Number(body.limit)), 200) : 30;
1473
+ const log = readChangelog(master, limit);
1474
+ json(res, 200, { ok: true, ...log });
1475
+ },
1476
+
1477
+ /** 技能列表:合并母版与本机,带单机属性。 */
1478
+ 'POST /skills/list': async (req, res) => {
1479
+ const state = loadState();
1480
+ const master = state.skills.masterRoot;
1481
+ if (!master) {
1482
+ json(res, 400, { ok: false, error: '尚未设置技能母版文件夹' });
1483
+ return;
1484
+ }
1485
+ if (!fs.existsSync(master)) {
1486
+ json(res, 400, { ok: false, error: `母版文件夹不存在或不可访问:${master}` });
1487
+ return;
1488
+ }
1489
+
1490
+ const modes = state.skills.modes || {};
1491
+ const m = scanSkills(master);
1492
+ const l = scanSkills(LOCAL_SKILLS_ROOT);
1493
+ const names = new Set([...m.keys(), ...l.keys()]);
1494
+ const { lineage, mnemonLedger, history } = judgeEvidence(master);
1495
+
1496
+ const items = [...names].sort().map((name) => {
1497
+ const mm = m.get(name);
1498
+ const ll = l.get(name);
1499
+ const mode = resolveMode(name, modes, m, l);
1500
+ let status;
1501
+ if (mm && !ll) status = mode === 'master' ? 'master-only' : 'missing-local';
1502
+ else if (!mm && ll) status = 'local-only';
1503
+ else status = mm.treeHash === ll.treeHash ? 'same' : 'diff';
1504
+
1505
+ // 内容不同时顺便给出「谁更新」的结论,前端不用再自己算
1506
+ let verdict = null;
1507
+ let basis = null;
1508
+ let hint = '';
1509
+ let diff = null;
1510
+ if (status === 'diff') {
1511
+ const j = judgeUpdate({ name, master: mm, local: ll, record: lineage[name], ledger: mnemonLedger, history });
1512
+ verdict = j.verdict;
1513
+ basis = j.basis;
1514
+ hint = j.detail;
1515
+ diff = diffSummary(mm.text, ll.text);
1516
+ }
1517
+
1518
+ return {
1519
+ name,
1520
+ mode,
1521
+ explicit: Object.prototype.hasOwnProperty.call(modes, name),
1522
+ status,
1523
+ verdict,
1524
+ basis,
1525
+ hint,
1526
+ diff,
1527
+ masterVersion: (mm && mm.version) || '',
1528
+ localVersion: (ll && ll.version) || '',
1529
+ inMaster: !!mm,
1530
+ inLocal: !!ll,
1531
+ };
1532
+ });
1533
+
1534
+ json(res, 200, { ok: true, masterRoot: master, localRoot: LOCAL_SKILLS_ROOT, items });
1535
+ },
1536
+
1537
+ /** 切换单机属性,并执行对应的实质动作(删母版 / 删本机 / 推送)。 */
1538
+ 'POST /skills/mode': async (req, res) => {
1539
+ const body = await readJson(req);
1540
+ const state = loadState();
1541
+ const master = state.skills.masterRoot;
1542
+ if (!master) {
1543
+ json(res, 400, { ok: false, error: '尚未设置技能母版文件夹' });
1544
+ return;
1545
+ }
1546
+ const mode = body.mode;
1547
+ if (mode !== 'sync' && mode !== 'local' && mode !== 'master') {
1548
+ json(res, 400, { ok: false, error: '属性只能是 sync / local / master' });
1549
+ return;
1550
+ }
1551
+ const names = Array.isArray(body.names) ? body.names.filter((n) => typeof n === 'string' && n) : [];
1552
+ if (!names.length) {
1553
+ json(res, 400, { ok: false, error: '未选择任何技能' });
1554
+ return;
1555
+ }
1556
+
1557
+ const stamp = makeStamp();
1558
+ const backRoot = path.join(master, '_backup');
1559
+ const modes = state.skills.modes || {};
1560
+ const m0 = scanSkills(master);
1561
+ const l0 = scanSkills(LOCAL_SKILLS_ROOT);
1562
+
1563
+ const results = [];
1564
+ const applied = [];
1565
+
1566
+ for (const name of names) {
1567
+ const from = resolveMode(name, modes, m0, l0);
1568
+ const out = applyModeChange({
1569
+ name,
1570
+ from,
1571
+ to: mode,
1572
+ masterRoot: master,
1573
+ localRoot: LOCAL_SKILLS_ROOT,
1574
+ backRoot,
1575
+ stamp,
1576
+ });
1577
+ results.push(out);
1578
+ if (out.ok) applied.push({ name, mode });
1579
+ }
1580
+
1581
+ // 只有成功的才落属性
1582
+ const next = loadState();
1583
+ for (const a of applied) next.skills.modes[a.name] = a.mode;
1584
+ const auditItems = results.flatMap((r) => (r.ok && r.audit ? r.audit : []));
1585
+ if (applied.length) {
1586
+ pushHistory(next, 'skills', `技能属性:${applied.length} 个改为「${MODE_LABEL[mode]}」`);
1587
+ }
1588
+ saveState(next);
1589
+
1590
+ // 对母版的实际改动写审计
1591
+ let audit = { written: 0, failed: [] };
1592
+ if (auditItems.length) {
1593
+ const w = appendMasterChanges(auditItems, {
1594
+ masterRoot: master,
1595
+ machineId: next.machineId,
1596
+ machineName: next.machineName,
1597
+ trigger: '用户授权',
1598
+ });
1599
+ audit = { written: w.written.length, failed: w.failed, path: w.written[0] && w.written[0].path };
1600
+ }
1601
+
1602
+ json(res, 200, {
1603
+ ok: true,
1604
+ mode,
1605
+ results,
1606
+ applied: applied.length,
1607
+ failed: results.filter((r) => !r.ok).length,
1608
+ audit,
1609
+ });
1610
+ },
1611
+
1612
+ /** 技能同步配置。 */
1613
+ 'POST /skills/config': async (req, res) => {
1614
+ const body = await readJson(req);
1615
+ const state = loadState();
1616
+ const skills = { ...state.skills };
1617
+
1618
+ if (typeof body.masterRoot === 'string') {
1619
+ const p = body.masterRoot.trim();
1620
+ skills.masterRoot = p ? normSlash(p) : '';
1621
+ // 设置路径时就把记录文件准备好,用户能立刻看到
1622
+ if (skills.masterRoot && fs.existsSync(skills.masterRoot)) {
1623
+ try {
1624
+ ensureChangelog(skills.masterRoot);
1625
+ } catch {
1626
+ /* 母版只读时建不了,不影响后续 */
1627
+ }
1628
+ }
1629
+ }
1630
+ if (typeof body.enabled === 'boolean') skills.enabled = body.enabled;
1631
+ // 记忆插件技能优化自动落地:默认开,显式传 false 才关
1632
+ if (typeof body.mnemonAuto === 'boolean') skills.mnemonAuto = body.mnemonAuto;
1633
+ // 优化闭环(发现 → 判断 → 入队 → 审核 → 通过):默认开
1634
+ if (typeof body.mnemonOptimize === 'boolean') skills.mnemonOptimize = body.mnemonOptimize;
1635
+ if (body.mnemonMinConfidence !== undefined) {
1636
+ const n = Number(body.mnemonMinConfidence);
1637
+ if (!Number.isFinite(n) || n < 0 || n > 1) {
1638
+ json(res, 400, { ok: false, error: '置信度门槛应在 0 到 1 之间' });
1639
+ return;
1640
+ }
1641
+ skills.mnemonMinConfidence = n;
1642
+ }
1643
+ if (body.intervalMinutes !== undefined) {
1644
+ const n = Number(body.intervalMinutes);
1645
+ if (!Number.isFinite(n) || n < 1 || n > 10080) {
1646
+ json(res, 400, { ok: false, error: '间隔应为 1–10080 之间的整数(分钟)' });
1647
+ return;
1648
+ }
1649
+ skills.intervalMinutes = Math.floor(n);
1650
+ }
1651
+ state.skills = skills;
1652
+ saveState(state);
1653
+ json(res, 200, { ok: true, skills: state.skills, localRoot: LOCAL_SKILLS_ROOT });
1654
+ },
1655
+ /** 插件清单同步的配置:开关与插件库根目录。 */
1656
+ 'POST /plugins/config': async (req, res) => {
1657
+ const body = await readJson(req);
1658
+ const state = loadState();
1659
+ const next = { ...state.plugins };
1660
+ if (typeof body.enabled === 'boolean') next.enabled = body.enabled;
1661
+ if (typeof body.libraryRoot === 'string') {
1662
+ const p = body.libraryRoot.trim();
1663
+ next.libraryRoot = p ? normSlash(p) : '';
1664
+ // 填了就必须是已存在的目录:不存在的路径建出来也是空的,同步一定失败
1665
+ if (next.libraryRoot && !fs.existsSync(next.libraryRoot)) {
1666
+ json(res, 400, { ok: false, error: `插件库目录不存在:${next.libraryRoot}` });
1667
+ return;
1668
+ }
1669
+ }
1670
+ state.plugins = next;
1671
+ saveState(state);
1672
+ json(res, 200, { ok: true, plugins: state.plugins });
1673
+ },
1674
+
1675
+ /** 插件清单快照:我的特征文件夹 + 他人清单(只读,扫盘)。 */
1676
+ 'GET /plugins': async (req, res) => {
1677
+ const state = loadState();
1678
+ const machineId = deps.machineId() || state.machineId;
1679
+ const snap = shareSnapshot({
1680
+ config: { libraryRoot: state.plugins.libraryRoot },
1681
+ machineId,
1682
+ machineHash: state.machineHash,
1683
+ machineName: state.machineName,
1684
+ publishedNames: Array.isArray(state.plugins.publishedNames) ? state.plugins.publishedNames : [],
1685
+ });
1686
+ json(res, 200, {
1687
+ ok: true,
1688
+ plugins: { ...state.plugins, effectiveRoot: pluginsLibraryRoot({ libraryRoot: state.plugins.libraryRoot }) },
1689
+ share: snap,
1690
+ });
1691
+ },
1692
+
1693
+ /** 立即同步一次我的特征文件夹,并重新汇集他人清单。 */
1694
+ 'POST /plugins/sync': async (req, res) => {
1695
+ if (typeof deps.syncPlugins !== 'function') {
1696
+ json(res, 400, { ok: false, error: '宿主未提供清单同步能力' });
1697
+ return;
1698
+ }
1699
+ const result = await deps.syncPlugins({ reason: '手动' });
1700
+ const state = loadState();
1701
+ const machineId = deps.machineId() || state.machineId;
1702
+ json(res, 200, {
1703
+ ok: result.ok !== false,
1704
+ result,
1705
+ plugins: { ...state.plugins, effectiveRoot: pluginsLibraryRoot({ libraryRoot: state.plugins.libraryRoot }) },
1706
+ share: shareSnapshot({
1707
+ config: { libraryRoot: state.plugins.libraryRoot },
1708
+ machineId,
1709
+ machineHash: state.machineHash,
1710
+ machineName: state.machineName,
1711
+ publishedNames: [],
1712
+ }),
1713
+ });
1714
+ },
1715
+
1716
+ /** 装他人清单里的一个插件(人工点选;只走联网拉取)。 */
1717
+ 'POST /plugins/install': async (req, res) => {
1718
+ const body = await readJson(req);
1719
+ const name = typeof body.name === 'string' ? body.name.trim() : '';
1720
+ if (!name) {
1721
+ json(res, 400, { ok: false, error: '缺少 name' });
1722
+ return;
1723
+ }
1724
+ if (typeof deps.pluginInstall !== 'function') {
1725
+ json(res, 400, { ok: false, error: '宿主未提供插件安装通道' });
1726
+ return;
1727
+ }
1728
+ const state = loadState();
1729
+ const machineId = deps.machineId() || state.machineId;
1730
+ const result = await installSharedPlugin({
1731
+ config: { libraryRoot: state.plugins.libraryRoot },
1732
+ machineId,
1733
+ publishedNames: [],
1734
+ name,
1735
+ installSpec: deps.pluginInstall,
1736
+ addToBundles: deps.addToBundles,
1737
+ log,
1738
+ });
1739
+ pushHistory(state, {
1740
+ kind: result.ok ? 'plugins-install' : 'plugins-install-failed',
1741
+ detail: result.ok
1742
+ ? `从共享装 ${name}:${result.message}`
1743
+ : `从共享装 ${name} 失败:${result.message}`,
1744
+ });
1745
+ saveState(state);
1746
+ json(res, result.ok ? 200 : 400, {
1747
+ ok: result.ok,
1748
+ result,
1749
+ share: shareSnapshot({
1750
+ config: { libraryRoot: state.plugins.libraryRoot },
1751
+ machineId,
1752
+ machineHash: state.machineHash,
1753
+ machineName: state.machineName,
1754
+ publishedNames: [],
1755
+ }),
1756
+ });
1757
+ },
1758
+
1759
+ 'POST /preview': async (req, res) => {
1760
+ const body = await readJson(req);
1761
+ const state = loadState();
1762
+ const machineId = deps.machineId() || state.machineId;
1763
+ const all = listWorkspaces();
1764
+ const matched = pickWorkspaces(all, state.folders);
1765
+ const chosen =
1766
+ Array.isArray(body.workspaceIds) && body.workspaceIds.length
1767
+ ? matched.filter((w) => body.workspaceIds.includes(w.id))
1768
+ : matched;
1769
+
1770
+ const localIndex = scanLocalSessions();
1771
+ const out = chosen.map((ws) => {
1772
+ const plan = previewPull({ workspace: ws, machineId, localIndex });
1773
+ return {
1774
+ id: ws.id,
1775
+ title: ws.title || ws.path,
1776
+ path: ws.path,
1777
+ plan,
1778
+ counts: plan.reduce((acc, p) => {
1779
+ acc[p.action] = (acc[p.action] || 0) + 1;
1780
+ return acc;
1781
+ }, {}),
1782
+ };
1783
+ });
1784
+ json(res, 200, { ok: true, workspaces: out });
1785
+ },
1786
+ };
1787
+
1788
+ return {
1789
+ prefix: API_PREFIX,
1790
+ routes,
1791
+ /** 供 webServer 注册的统一 handler。 */
1792
+ handle: async (req, res) => {
1793
+ const url = new URL(req.url || '/', 'http://localhost');
1794
+ if (!url.pathname.startsWith(API_PREFIX)) {
1795
+ json(res, 404, { ok: false, error: 'not found' });
1796
+ return;
1797
+ }
1798
+ const sub = url.pathname.slice(API_PREFIX.length) || '/';
1799
+ const key = `${req.method} ${sub}`;
1800
+ const handler = routes[key];
1801
+ if (!handler) {
1802
+ json(res, 404, { ok: false, error: `no route ${key}` });
1803
+ return;
1804
+ }
1805
+ try {
1806
+ await handler(req, res);
1807
+ } catch (err) {
1808
+ json(res, 500, {
1809
+ ok: false,
1810
+ error: String(err && err.message ? err.message : err),
1811
+ });
1812
+ }
1813
+ },
1814
+ };
1815
+ }