ygc-dsh-sync 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,409 @@
1
+ /**
2
+ * 本机特征文件夹(`<工作区>/.dsh-sync/sessions/<本机标识>/`)的采纳、撤销与「谁参与」判定。
3
+ *
4
+ * 用户 2026-11-14 定的规矩(这一层是这套规矩的唯一实现):
5
+ *
6
+ * 1. **检测到的匹配目录要人点**:巡检扫出「符合插件结构、但本机还没建特征文件夹」
7
+ * 的目录时,只列出来问人,绝不自己建。人点了同意 → 建格子 + 注册成工作区
8
+ * (进侧边栏,参与备份与拉取),一次做完。
9
+ * 2. **新建的保持原样**:本机新建的共享工作区默认就带特征文件夹
10
+ * (1.1.0 之前一直是这个行为,升级不能把它改成必须点一下)。
11
+ * 3. **外部删掉会自愈**:曾经建过、现在磁盘上没有 → 巡检自动补建,不进人工队列。
12
+ * 4. **删工作区就删格**:本机把某个工作区从侧边栏删掉后,它的特征文件夹跟着删。
13
+ * 只删本机那一格,别人的格子一个字节都不动。
14
+ * 5. **没格子的不干活**:备份、拉取、自动备份、自动拉取、补登记,
15
+ * 一律只对「特征文件夹存在」的工作区生效。
16
+ *
17
+ * 判据永远是磁盘:「特征文件夹存在」= 已参与。state 里只记三样辅助信息:
18
+ * initialized 1.2.0 首次运行是否已经把「存量工作区」归过档
19
+ * decisions 人对某个路径的表态(adopt / decline),防止反复骚扰
20
+ * known 已经采纳过的工作区(id → 路径),用来判「被删除」与「被外部删除」
21
+ */
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { TARGET_DIR_NAME, normSlash, ownRoot, pathKey, safeStat } from './core.js';
25
+
26
+ /* --------------------------------------------------------------- 基础判定 */
27
+
28
+ /** 本机特征文件夹的绝对路径(就是写边界里那个 ownRoot)。 */
29
+ export function featurePath(workspacePath, machineId) {
30
+ return ownRoot(workspacePath, machineId);
31
+ }
32
+
33
+ /** 特征文件夹是否已存在(必须真的是目录)。 */
34
+ export function hasFeature(workspacePath, machineId) {
35
+ const st = safeStat(featurePath(workspacePath, machineId));
36
+ return !!st && st.isDirectory();
37
+ }
38
+
39
+ /**
40
+ * 建本机特征文件夹。
41
+ *
42
+ * 只建 `<工作区>/.dsh-sync/sessions/<本机标识>/` 这一格,
43
+ * 不碰别人的目录,也不动任何会话文件。
44
+ */
45
+ export function adoptFeature(workspacePath, machineId) {
46
+ if (!machineId) throw new Error('缺少本机标识,拒绝建特征文件夹');
47
+ const target = featurePath(workspacePath, machineId);
48
+ // 防 machineId 里带 `..` 或分隔符把目标打穿到别处
49
+ if (path.basename(target) !== String(machineId)) {
50
+ throw new Error(`特征文件夹名与本机标识不一致:${target}`);
51
+ }
52
+ fs.mkdirSync(target, { recursive: true });
53
+ return target;
54
+ }
55
+
56
+ /**
57
+ * 删本机特征文件夹(删工作区时调用)。
58
+ *
59
+ * 三条约束:
60
+ * - 只删本机那一格;
61
+ * - `sessions/` 里还有别人的备份时,`sessions/` 与 `.dsh-sync/` 一概不动;
62
+ * - 目标不存在算成功(幂等)。
63
+ */
64
+ export function dropFeature(workspacePath, machineId, { log = () => {} } = {}) {
65
+ if (!machineId) return { removed: false, reason: '缺少本机标识' };
66
+ const target = featurePath(workspacePath, machineId);
67
+ if (path.basename(target) !== String(machineId)) {
68
+ return { removed: false, reason: '目标不是本机特征文件夹' };
69
+ }
70
+ const st = safeStat(target);
71
+ if (!st) return { removed: false, reason: '本来就没有' };
72
+ if (!st.isDirectory()) return { removed: false, reason: '不是目录' };
73
+ fs.rmSync(target, { recursive: true, force: true });
74
+ log(`删除本机特征文件夹 ${target}`);
75
+
76
+ // 收尾:空了才收,避免把别人的备份目录一起删掉
77
+ const sessionsDir = path.join(normSlash(workspacePath), TARGET_DIR_NAME);
78
+ const dshSyncDir = path.dirname(sessionsDir);
79
+ for (const dir of [sessionsDir, dshSyncDir]) {
80
+ try {
81
+ if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
82
+ } catch {
83
+ /* 收尾失败不影响主结果 */
84
+ }
85
+ }
86
+ return { removed: true, target };
87
+ }
88
+
89
+ /* ----------------------------------------------------------- 决策与基线 */
90
+
91
+ /** 决策键:路径的大小写不敏感写法(Windows 下同一个目录只有一份)。 */
92
+ const keyOf = (p) => {
93
+ try {
94
+ return pathKey(p);
95
+ } catch {
96
+ return String(p || '').toLowerCase();
97
+ }
98
+ };
99
+
100
+ /** 人为某个路径表过态吗?返回 'adopt' | 'decline' | 'ask' | null。 */
101
+ export function decisionOf(state, workspacePath) {
102
+ const bag = (state && state.features && state.features.decisions) || {};
103
+ const hit = bag[keyOf(workspacePath)];
104
+ if (!hit) return null;
105
+ if (hit.action === 'decline') return 'decline';
106
+ if (hit.action === 'ask') return 'ask';
107
+ return 'adopt';
108
+ }
109
+
110
+ /**
111
+ * 记一次表态。
112
+ *
113
+ * adopt 人点了「建格子」(之后缺了就自动补)
114
+ * decline 人点了「不要」(之后只列出来,不自动建)
115
+ * ask 已经在面板上问过人、还等回话 —— 不写这一条,下一轮巡检
116
+ * 会把「等待表态」误当成「本机新建」,直接替人建了格子。
117
+ */
118
+ export function rememberDecision(state, workspacePath, action) {
119
+ if (!state.features || typeof state.features !== 'object') {
120
+ state.features = { initialized: false, decisions: {}, known: {} };
121
+ }
122
+ if (!state.features.decisions || typeof state.features.decisions !== 'object') {
123
+ state.features.decisions = {};
124
+ }
125
+ const normalized = action === 'decline' ? 'decline' : action === 'ask' ? 'ask' : 'adopt';
126
+ state.features.decisions[keyOf(workspacePath)] = {
127
+ action: normalized,
128
+ path: normSlash(workspacePath),
129
+ at: Date.now(),
130
+ };
131
+ return state;
132
+ }
133
+
134
+ /** 清掉某路径的表态(人后来又把格子建回来时用)。 */
135
+ export function forgetDecision(state, workspacePath) {
136
+ const bag = (state.features && state.features.decisions) || null;
137
+ if (!bag) return state;
138
+ delete bag[keyOf(workspacePath)];
139
+ return state;
140
+ }
141
+
142
+ /* --------------------------------------------------------------- 巡检计划 */
143
+
144
+ /**
145
+ * 算本轮该对特征文件夹做什么。**纯函数:不落盘、不改任何文件。**
146
+ *
147
+ * 输入:
148
+ * folders 特定文件夹前缀
149
+ * workspaces 当前工作区清单(id/path/title)
150
+ * discover 巡检扫出、但还没注册成工作区的目录({path,title,folder})
151
+ * state 插件状态(读 features.initialized / known / decisions)
152
+ * machineId 本机标识
153
+ *
154
+ * 输出:
155
+ * active 已有格子、正常参与备份与拉取的工作区
156
+ * adopt 本机新建的工作区 → **默认建格子**(保持 1.1.0 原有设置)
157
+ * restore 曾经建过、现在磁盘上没有 → 自动补建(被外部删掉的自愈)
158
+ * drop 本机已删除的工作区 → 跟着删格子
159
+ * pending 必须人工表态的:未注册的匹配目录,以及升级时检出的存量工作区
160
+ * seed 首次运行需要把「已有格子」的存量工作区记进基线(不改任何文件)
161
+ */
162
+ export function planFeatures({
163
+ folders = [],
164
+ workspaces = [],
165
+ discover = [],
166
+ state = {},
167
+ machineId = '',
168
+ } = {}) {
169
+ const features = (state && state.features) || {};
170
+ const known = features.known || {};
171
+ const initialized = features.initialized === true;
172
+ const seenNow = {};
173
+ const adopt = [];
174
+ const restore = [];
175
+ const drop = [];
176
+ const pending = [];
177
+ const active = [];
178
+ const seed = [];
179
+
180
+ const underFolders = (p) => {
181
+ const key = keyOf(p);
182
+ return (folders || []).some((f) => {
183
+ const fk = keyOf(f);
184
+ return key === fk || key.startsWith(fk + path.sep) || key.startsWith(fk + '/');
185
+ });
186
+ };
187
+
188
+ for (const ws of workspaces || []) {
189
+ if (!ws || !ws.path) continue;
190
+ const id = ws.id === undefined || ws.id === null ? keyOf(ws.path) : String(ws.id);
191
+ seenNow[id] = normSlash(ws.path);
192
+ const entry = { id, path: ws.path, title: ws.title || path.basename(ws.path) };
193
+
194
+ if (hasFeature(ws.path, machineId)) {
195
+ active.push(entry);
196
+ // 已有格子的一律进基线:首次运行靠它建档,之后靠它判「被删除」
197
+ seed.push(entry);
198
+ continue;
199
+ }
200
+
201
+ // 缺格子:不在特定文件夹里的本来就不归本插件管,不建也不问
202
+ if (!underFolders(ws.path)) continue;
203
+
204
+ const wasKnown = Object.prototype.hasOwnProperty.call(known, id);
205
+ const said = decisionOf(state, ws.path);
206
+ if (said === 'decline') {
207
+ // 人说过不要 → 仍然列出来(可以改主意),但不自己建
208
+ pending.push({ ...entry, why: 'declined' });
209
+ } else if (said === 'ask') {
210
+ // 已经在面板上问过、还没回话 → 继续等着,别替人决定
211
+ pending.push({ ...entry, why: 'awaiting' });
212
+ } else if (said === 'adopt' || wasKnown) {
213
+ // 人点过同意 / 曾经建过又没了 → 自动补
214
+ restore.push({ ...entry, why: wasKnown ? 'vanished' : 'adopted' });
215
+ } else if (!initialized) {
216
+ // 1.2.0 首次运行:存量工作区,交给人决定
217
+ pending.push({ ...entry, why: 'existing' });
218
+ } else {
219
+ // 基线建档之后才出现 = 本机新建的,默认建格子
220
+ adopt.push({ ...entry, why: 'new' });
221
+ }
222
+ }
223
+
224
+ // 匹配目录里还没注册成工作区的:必须人工选择(同意后建格子 + 注册一起做)
225
+ for (const d of discover || []) {
226
+ if (!d || !d.path) continue;
227
+ if (decisionOf(state, d.path) === 'decline') continue;
228
+ if (hasFeature(d.path, machineId)) continue;
229
+ pending.push({
230
+ id: null,
231
+ path: d.path,
232
+ title: d.title || path.basename(d.path),
233
+ why: 'unregistered',
234
+ });
235
+ }
236
+
237
+ // 基线里、这一轮清单里没有的 = 本机删了工作区 → 跟着删格子
238
+ for (const [id, p] of Object.entries(known)) {
239
+ if (Object.prototype.hasOwnProperty.call(seenNow, id)) continue;
240
+ if (!p) continue;
241
+ if (!hasFeature(p, machineId)) continue;
242
+ drop.push({ id, path: p, title: path.basename(p) });
243
+ }
244
+
245
+ return { active, adopt, restore, drop, pending, seed, seenNow };
246
+ }
247
+
248
+ /**
249
+ * 把「本轮列进 pending 问人的」记成 ask,避免下一轮被当成「本机新建」。
250
+ *
251
+ * 只对「已注册工作区缺格子」这一类记(why = existing / awaiting),
252
+ * 未注册的匹配目录本来就不会自动处理,不必额外打标。
253
+ */
254
+ export function markAsked(state, plan) {
255
+ if (!state.features) state.features = { initialized: false, decisions: {}, known: {} };
256
+ let n = 0;
257
+ for (const item of (plan && plan.pending) || []) {
258
+ if (item.why !== 'existing' && item.why !== 'awaiting') continue;
259
+ if (decisionOf(state, item.path) === 'ask') continue;
260
+ rememberDecision(state, item.path, 'ask');
261
+ n++;
262
+ }
263
+ return n;
264
+ }
265
+
266
+ /**
267
+ * 执行计划里「不需要人点头」的部分(adopt / restore / drop + 基线),
268
+ * 并把首次运行标记打上。
269
+ *
270
+ * `pending` 不在这里处理:那一档必须由人显式调用 /features/adopt 才动。
271
+ */
272
+ export function applyFeaturePlan({ plan, state, machineId, log = () => {} } = {}) {
273
+ const adopted = [];
274
+ const restored = [];
275
+ const dropped = [];
276
+ const errors = [];
277
+
278
+ for (const item of plan.adopt || []) {
279
+ try {
280
+ adoptFeature(item.path, machineId);
281
+ adopted.push(item);
282
+ } catch (err) {
283
+ errors.push({ path: item.path, message: String((err && err.message) || err) });
284
+ }
285
+ }
286
+ for (const item of plan.restore || []) {
287
+ try {
288
+ adoptFeature(item.path, machineId);
289
+ restored.push(item);
290
+ forgetDecision(state, item.path);
291
+ log(`补建本机特征文件夹 ${item.path}`);
292
+ } catch (err) {
293
+ errors.push({ path: item.path, message: String((err && err.message) || err) });
294
+ }
295
+ }
296
+ for (const item of plan.drop || []) {
297
+ try {
298
+ const out = dropFeature(item.path, machineId, { log });
299
+ if (out.removed) dropped.push(item);
300
+ } catch (err) {
301
+ errors.push({ path: item.path, message: String((err && err.message) || err) });
302
+ }
303
+ }
304
+
305
+ // 基线:只记「这一轮真的在清单里」且「现在有格子」的工作区
306
+ if (!state.features || typeof state.features !== 'object') {
307
+ state.features = { initialized: false, decisions: {}, known: {} };
308
+ }
309
+ // 这一轮问过人的,打上 ask 标记——否则下一轮 initialized 已是 true,
310
+ // 会被当成「本机新建」直接建格子,等于替人做了决定。
311
+ markAsked(state, plan);
312
+ const keep = {};
313
+ const live = new Set((plan.seenNow ? Object.keys(plan.seenNow) : []).map(String));
314
+ for (const item of plan.seed || []) keep[item.id] = item.path;
315
+ for (const item of adopted) keep[item.id] = item.path;
316
+ for (const item of restored) keep[item.id] = item.path;
317
+ // 已有但本轮没扫到的(前缀被改掉等)保留,避免误判成「被删除」
318
+ for (const [id, p] of Object.entries(state.features.known || {})) {
319
+ if (keep[id]) continue;
320
+ if (live.has(String(id))) continue;
321
+ if (plan.drop && plan.drop.some((d) => String(d.id) === String(id))) continue;
322
+ keep[id] = p;
323
+ }
324
+ state.features.known = keep;
325
+ state.features.initialized = true;
326
+ state.features.lastPlanAt = Date.now();
327
+
328
+ return { adopted, restored, dropped, errors };
329
+ }
330
+
331
+ /**
332
+ * 只做「删掉的工作区 → 删格子」这一件事。
333
+ *
334
+ * 供 30 秒一次的轻量巡检用:不扫盘、不建目录,只按给定 id 清单清格子。
335
+ * `confirmedIds` 由 pickRemovedIds 选出来(已经过二次确认)。
336
+ */
337
+ export function reconcileRemoved(state, machineId, confirmedIds, { log = () => {} } = {}) {
338
+ const dropped = [];
339
+ const errors = [];
340
+ if (!state.features || typeof state.features !== 'object') return { dropped, errors };
341
+ const known = state.features.known || {};
342
+ for (const id of confirmedIds || []) {
343
+ const key = String(id);
344
+ const p = known[key];
345
+ if (!p) continue;
346
+ try {
347
+ const out = dropFeature(p, machineId, { log });
348
+ if (out.removed) dropped.push({ id: key, path: p });
349
+ delete known[key];
350
+ } catch (err) {
351
+ errors.push({ id: key, path: p, message: String((err && err.message) || err) });
352
+ }
353
+ }
354
+ return { dropped, errors };
355
+ }
356
+
357
+ /**
358
+ * 挑出「确实已经被删除」的工作区 id。**纯函数**,不碰磁盘。
359
+ *
360
+ * 为什么要二次确认:workspaceRegistry 在启动早期、注册表重建、或宿主正在
361
+ * 切换 profile 时可能短暂返回空清单/缺项。只凭一次快照就删特征文件夹,
362
+ * 等于把这个工作区的同步历史一把抹掉。规矩:
363
+ * - 清单为空 → 本轮什么都不确认(清除计时);
364
+ * - 同一个 id 连续两轮都不在,且间隔 ≥ minAgeMs,才确认删除。
365
+ *
366
+ * @param {{known:Object<string,string>, liveIds:string[], missingSince:Map<string,number>,
367
+ * now:number, minAgeMs:number}} input
368
+ * @returns {{confirmed:string[], missingSince:Map<string,number>}}
369
+ */
370
+ export function pickRemovedIds({ known = {}, liveIds = [], missingSince, now = Date.now(), minAgeMs = 30000 }) {
371
+ const next = missingSince instanceof Map ? missingSince : new Map();
372
+ const confirmed = [];
373
+ const live = new Set((liveIds || []).map(String));
374
+
375
+ if (!live.size) {
376
+ next.clear();
377
+ return { confirmed, missingSince: next };
378
+ }
379
+
380
+ // 又回来了 / 基线里已经没有它了 → 清掉计时
381
+ for (const id of [...next.keys()]) {
382
+ if (live.has(id) || !Object.prototype.hasOwnProperty.call(known, id)) next.delete(id);
383
+ }
384
+
385
+ for (const id of Object.keys(known)) {
386
+ if (live.has(id)) continue;
387
+ const since = next.get(id);
388
+ if (since === undefined) {
389
+ next.set(id, now);
390
+ continue;
391
+ }
392
+ if (now - since >= minAgeMs) confirmed.push(id);
393
+ }
394
+
395
+ for (const id of confirmed) next.delete(id);
396
+ return { confirmed, missingSince: next };
397
+ }
398
+
399
+ /** 过滤:只留下「特征文件夹存在」的工作区。备份/拉取/展示都用它。 */
400
+ export function onlyActive(list, machineId) {
401
+ return (list || []).filter((ws) => ws && ws.path && hasFeature(ws.path, machineId));
402
+ }
403
+
404
+ /** 某个工作区为什么没参与,给面板显示用。 */
405
+ export function inactiveReason(workspacePath, machineId, state) {
406
+ if (hasFeature(workspacePath, machineId)) return '';
407
+ if (decisionOf(state, workspacePath) === 'decline') return '你选了不在这个文件夹建本机特征文件夹';
408
+ return '还没有建本机特征文件夹(未参与备份与拉取)';
409
+ }
package/package.json CHANGED
@@ -1,61 +1,62 @@
1
- {
2
- "name": "ygc-dsh-sync",
3
- "version": "1.0.2",
4
- "type": "module",
5
- "description": "DSH 同步插件:会话按工作区落地、按机器隔离的跨机备份与拉取;技能本机↔母版双向同步(含记忆插件技能优化闭环);插件清单共享(只存清单与来源,不存源码,只能联网装)。",
6
- "icon": "icon.svg",
7
- "keywords": [
8
- "dsh",
9
- "deepseek-harness",
10
- "dsh-plugin",
11
- "session",
12
- "sync",
13
- "backup",
14
- "plugin-manifest"
15
- ],
16
- "license": "MIT",
17
- "exports": {
18
- ".": "./index.js",
19
- "./client": "./client.js",
20
- "./locale/*.json": "./locale/*.json",
21
- "./icon": "./icon.svg"
22
- },
23
- "files": [
24
- "index.js",
25
- "client.js",
26
- "lib/core.js",
27
- "lib/plugins.js",
28
- "lib/backup.js",
29
- "lib/attach.js",
30
- "lib/api.js",
31
- "lib/patrol.js",
32
- "lib/skills.js",
33
- "lib/mnemon.js",
34
- "lib/mnemon-optimize.js",
35
- "lib/changelog.js",
36
- "lib/zstd-frame.js",
37
- "cordis.patch.yml",
38
- "locale/*.json",
39
- "icon.svg",
40
- "README.md",
41
- "tools/*.mjs",
42
- "lib/mnemon-pipeline.js",
43
- "lib/mnemon-drafter.js"
44
- ],
45
- "publishConfig": {
46
- "registry": "https://registry.npmjs.org/",
47
- "access": "public"
48
- },
49
- "dsh": {
50
- "bundle": {
51
- "patch": "./cordis.patch.yml"
52
- },
53
- "client": {
54
- "platform": "web",
55
- "immediately": true,
56
- "inject": [
57
- "@deepseek-ai/dsh-client-ui-conversation"
58
- ]
59
- }
60
- }
61
- }
1
+ {
2
+ "name": "ygc-dsh-sync",
3
+ "version": "1.2.0",
4
+ "type": "module",
5
+ "description": "DSH 同步插件:会话按工作区落地、按机器隔离的跨机备份与拉取;技能本机↔母版双向同步(含记忆插件技能优化闭环);插件清单共享(只存清单与来源,不存源码,只能联网装)。",
6
+ "icon": "icon.svg",
7
+ "keywords": [
8
+ "dsh",
9
+ "deepseek-harness",
10
+ "dsh-plugin",
11
+ "session",
12
+ "sync",
13
+ "backup",
14
+ "plugin-manifest"
15
+ ],
16
+ "license": "MIT",
17
+ "exports": {
18
+ ".": "./index.js",
19
+ "./client": "./client.js",
20
+ "./locale/*.json": "./locale/*.json",
21
+ "./icon": "./icon.svg"
22
+ },
23
+ "files": [
24
+ "index.js",
25
+ "client.js",
26
+ "lib/core.js",
27
+ "lib/features.js",
28
+ "lib/plugins.js",
29
+ "lib/backup.js",
30
+ "lib/attach.js",
31
+ "lib/api.js",
32
+ "lib/patrol.js",
33
+ "lib/skills.js",
34
+ "lib/mnemon.js",
35
+ "lib/mnemon-optimize.js",
36
+ "lib/changelog.js",
37
+ "lib/zstd-frame.js",
38
+ "cordis.patch.yml",
39
+ "locale/*.json",
40
+ "icon.svg",
41
+ "README.md",
42
+ "tools/*.mjs",
43
+ "lib/mnemon-pipeline.js",
44
+ "lib/mnemon-drafter.js"
45
+ ],
46
+ "publishConfig": {
47
+ "registry": "https://registry.npmjs.org/",
48
+ "access": "public"
49
+ },
50
+ "dsh": {
51
+ "bundle": {
52
+ "patch": "./cordis.patch.yml"
53
+ },
54
+ "client": {
55
+ "platform": "web",
56
+ "immediately": true,
57
+ "inject": [
58
+ "@deepseek-ai/dsh-client-ui-conversation"
59
+ ]
60
+ }
61
+ }
62
+ }