dsh-plugin-tool-management 0.9.1 → 0.11.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +119 -1
  2. package/README.md +227 -201
  3. package/README_EN.md +227 -199
  4. package/docs/images/1-EN.png +0 -0
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2-EN.png +0 -0
  7. package/docs/images/2.png +0 -0
  8. package/docs/images/3-EN.png +0 -0
  9. package/docs/images/3.png +0 -0
  10. package/docs/images/4-EN.png +0 -0
  11. package/docs/images/4.png +0 -0
  12. package/docs/images/5-EN.png +0 -0
  13. package/docs/images/5.png +0 -0
  14. package/docs/images/6-EN.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/images/7-EN.png +0 -0
  17. package/docs/images/7.png +0 -0
  18. package/docs/images/8-EN.png +0 -0
  19. package/docs/images/8.png +0 -0
  20. package/docs/update.md +144 -12
  21. package/lib/client.js +8437 -5929
  22. package/lib/compat/preset-reach.js +1 -10
  23. package/lib/compat/probe.js +158 -25
  24. package/lib/context-inject.js +396 -53
  25. package/lib/host-names.js +12 -0
  26. package/lib/http-fence.js +35 -15
  27. package/lib/hub.js +31 -3
  28. package/lib/imports/parsers.js +15 -9
  29. package/lib/imports/upload.js +43 -4
  30. package/lib/index.js +940 -3765
  31. package/lib/mcp/loader-token.js +238 -0
  32. package/lib/mcp/manager.js +1681 -0
  33. package/lib/mcp/override-blocks.js +20 -9
  34. package/lib/mcp/patch-yaml.js +351 -0
  35. package/lib/mcp/secret-guard.js +145 -0
  36. package/lib/mcp/state-section.js +64 -21
  37. package/lib/{rules → memories}/archive-engine.js +65 -10
  38. package/lib/{rules → memories}/archive.js +6 -7
  39. package/lib/memories/constants.js +128 -0
  40. package/lib/memories/index-io.js +330 -0
  41. package/lib/memories/projection.js +280 -0
  42. package/lib/memories/service.js +686 -0
  43. package/lib/memories/snapshot.js +672 -0
  44. package/lib/ops/candidates.js +64 -0
  45. package/lib/ops/compat.js +136 -0
  46. package/lib/ops/ctx.js +9 -0
  47. package/lib/ops/memory.js +678 -0
  48. package/lib/ops/prompts.js +107 -0
  49. package/lib/ops/scene-records.js +460 -0
  50. package/lib/ops/scene-sync.js +17 -0
  51. package/lib/ops/sessions.js +603 -0
  52. package/lib/ops/trash.js +140 -0
  53. package/lib/paths.js +103 -0
  54. package/lib/prompts/preset-id.js +49 -0
  55. package/lib/{agents-md → prompts}/service.js +1 -1
  56. package/lib/request-gate.js +320 -0
  57. package/lib/scene-prompt-sync.js +4 -4
  58. package/lib/scenes/candidates.js +344 -0
  59. package/lib/{history → sessions}/bridge.js +22 -5
  60. package/lib/sessions/history.js +323 -0
  61. package/lib/{history → sessions}/tombstone.js +1 -2
  62. package/lib/{history → sessions}/workspace.js +92 -24
  63. package/lib/skills/catalog.js +6 -9
  64. package/lib/skills/core.js +79 -43
  65. package/lib/skills/readonly-discovery.js +4 -1
  66. package/lib/skills/service.js +98 -13
  67. package/lib/subagents/catalog.js +31 -15
  68. package/lib/subagents/service.js +506 -89
  69. package/lib/subagents/tools.js +29 -4
  70. package/lib/tools/deps.js +8 -0
  71. package/lib/tools/mcp.js +110 -0
  72. package/lib/tools/memory.js +87 -0
  73. package/lib/tools/prompt.js +70 -0
  74. package/lib/tools/skills.js +139 -0
  75. package/lib/tools/subagent.js +40 -0
  76. package/package.json +105 -102
  77. package/lib/agents-md/preset-id.js +0 -49
  78. package/lib/history/projcache.js +0 -335
  79. package/lib/rules/service.js +0 -2971
@@ -0,0 +1,603 @@
1
+ // 归档会话(History)域的 HTTP ops(2026-09-19 从 index.ts 的 handlers 表抽出)。
2
+ //
3
+ // 同一条纪律:只负责 op 实现,依赖显式传参;MCP 写后刷新、场景锁定守卫仍作用在组装后的
4
+ // 整张表上(见 index.ts)。
5
+ //
6
+ // 本域的 op 名:history-list / history-sessions / history-export-defaults / dir-list /
7
+ // history-archive / history-archive-batch / history-unarchive / history-delete /
8
+ // history-unarchive-batch / history-delete-batch / history-workspace-register /
9
+ // history-import / bundle-export / history-export / history-retention-get /
10
+ // history-retention-set。
11
+ import { createHash, randomUUID } from 'node:crypto';
12
+ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
13
+ import { basename, dirname, join } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+ import { zipSync } from 'fflate';
16
+ import { detectFormat, parseGenericText, parseJsonlTranscript, parseMarkdownTranscript } from '../imports/parsers.js';
17
+ import { isValidPresetId } from '../prompts/preset-id.js';
18
+ import { defaultPersonasDir, validPersonaName } from '../subagents/service.js';
19
+ import { isInsideRoot } from '../paths.js';
20
+ import { planMemoryExport } from '../memories/service.js';
21
+ export function buildSessionOps(deps) {
22
+ return {
23
+ // History(归档会话管理)ops。只读:history-list / history-retention-get;
24
+ // 写:history-archive / history-unarchive / history-delete / history-retention-set。
25
+ // workspaceRegistry 缺失(补丁未生效)时返回明确错误,不崩页面。
26
+ 'history-list': async () => {
27
+ const registry = deps.getSessionsRegistry();
28
+ if (!registry)
29
+ return { ok: false, error: '归档服务未就绪:请检查宿主 workspaceRegistry 服务。' };
30
+ try {
31
+ const items = (typeof registry.archivedSessionDetails === 'function')
32
+ ? (await registry.archivedSessionDetails()).items
33
+ : (await registry.archivedSessionMetadata()).items.map((i) => ({ sessionId: i.sessionId, createdAt: i.createdAt, archivedAt: registry.archivedAt?.(i.sessionId) }));
34
+ const { retentionDays } = await deps.readHistoryRetention();
35
+ // 归属分组(best-effort):活登记 → 路径命中 → 按会话目录重建。
36
+ // 宿主删除工作区后登记消失,靠会话目录把分组补回来(详见 buildHistoryGroups)。
37
+ const { groups, workspaces } = await deps.buildHistoryGroups(registry, items);
38
+ return { ok: true, items, groups, workspaces, retentionDays };
39
+ }
40
+ catch (e) {
41
+ return { ok: false, error: deps.message(e) };
42
+ }
43
+ },
44
+ // 枚举全部持久化会话(含未归档/冷会话)供导出弹窗选择;只读,不门控。
45
+ // 标题经投影缓存 best-effort;任何一步失败只降级为缺字段。
46
+ 'history-sessions': async () => {
47
+ const registry = deps.getSessionsRegistry();
48
+ if (!registry || typeof registry.listStoredHeaders !== 'function') {
49
+ return { ok: false, error: '当前环境不支持枚举全部会话(registry 未实现 listStoredHeaders)' };
50
+ }
51
+ try {
52
+ const headers = await registry.listStoredHeaders();
53
+ const archivedItems = typeof registry.archivedSessionDetails === 'function'
54
+ ? (await registry.archivedSessionDetails()).items
55
+ : (await registry.archivedSessionMetadata()).items;
56
+ const archived = new Set(archivedItems.map((i) => i.sessionId));
57
+ const cache = deps.host('sessionProjectionCache');
58
+ const items = [];
59
+ for (const h of headers) {
60
+ const sessionId = h && typeof h.id === 'string' ? h.id : undefined;
61
+ if (!sessionId)
62
+ continue;
63
+ // 过滤子代理派生的会话:不占用用户会话列表,也不应出现在导出选择里。
64
+ if (h.origin === 'subagent')
65
+ continue;
66
+ const item = {
67
+ sessionId,
68
+ archived: archived.has(sessionId),
69
+ };
70
+ if (typeof h.cwd === 'string' && h.cwd)
71
+ item.cwd = h.cwd;
72
+ if (typeof h.createdAt === 'number' && Number.isFinite(h.createdAt))
73
+ item.createdAt = h.createdAt;
74
+ if (cache && typeof cache.cachedSnapshot === 'function') {
75
+ try {
76
+ const snap = cache.cachedSnapshot(h, 0, ['title']);
77
+ if (snap && snap.values && typeof snap.values.title === 'string')
78
+ item.title = snap.values.title;
79
+ }
80
+ catch { /* title best-effort */ }
81
+ }
82
+ // 工作区目录可能已被删除/移动(孤儿会话):best-effort 标记,供导出选择时识别。
83
+ if (item.cwd) {
84
+ try {
85
+ const st = await stat(item.cwd);
86
+ item.cwdMissing = !st.isDirectory();
87
+ }
88
+ catch {
89
+ item.cwdMissing = true;
90
+ }
91
+ }
92
+ items.push(item);
93
+ }
94
+ // 归属分组(与 history-list 同源):导出弹窗的工作区筛选按同一套组 id。
95
+ const { groups } = await deps.buildHistoryGroups(registry, items);
96
+ return { ok: true, items, groups };
97
+ }
98
+ catch (e) {
99
+ return { ok: false, error: deps.message(e) };
100
+ }
101
+ },
102
+ // 导出默认目录:桌面(存在时)否则用户主目录。只读,不门控。
103
+ 'history-export-defaults': async () => {
104
+ let dir = homedir();
105
+ try {
106
+ const desktop = join(homedir(), 'Desktop');
107
+ const st = await stat(desktop);
108
+ if (st.isDirectory())
109
+ dir = desktop;
110
+ }
111
+ catch { /* 桌面路径不可用 → 回退主目录 */ }
112
+ return { ok: true, defaultDir: dir };
113
+ },
114
+ // 列出目录的子目录,供客户端「选择文件夹」弹窗逐级浏览。dir 为空时返回根
115
+ // 视图(Windows 枚举盘符,其他平台返回 '/')。只读,不门控。
116
+ 'dir-list': async (args) => {
117
+ const raw = String((args && args.dir) || '').trim();
118
+ try {
119
+ if (!raw) {
120
+ if (process.platform !== 'win32')
121
+ return { ok: true, current: '/', parent: null, entries: [] };
122
+ const drives = [];
123
+ for (let c = 65; c <= 90; c++) {
124
+ const root = String.fromCharCode(c) + ':\\';
125
+ try {
126
+ await stat(root);
127
+ drives.push({ name: root, path: root });
128
+ }
129
+ catch { /* 跳过不存在的盘符 */ }
130
+ }
131
+ return { ok: true, current: '', parent: null, entries: drives };
132
+ }
133
+ const st = await stat(raw);
134
+ if (!st.isDirectory())
135
+ return { ok: false, error: '该路径不是目录' };
136
+ const parent = dirname(raw);
137
+ const names = await readdir(raw, { withFileTypes: true });
138
+ const entries = names
139
+ .filter((d) => d.isDirectory())
140
+ .map((d) => ({ name: d.name, path: join(raw, d.name) }))
141
+ .sort((a, b) => a.name.localeCompare(b.name));
142
+ return { ok: true, current: raw, parent: parent === raw ? null : parent, entries };
143
+ }
144
+ catch (e) {
145
+ return { ok: false, error: deps.message(e) };
146
+ }
147
+ },
148
+ 'history-archive': async (args) => {
149
+ const registry = deps.getSessionsRegistry();
150
+ if (!registry)
151
+ return { ok: false, error: '归档服务未挂载' };
152
+ const sessionId = String((args && args.sessionId) || '').trim();
153
+ if (!sessionId)
154
+ return { ok: false, error: '缺少 sessionId' };
155
+ try {
156
+ await registry.archiveSession(sessionId);
157
+ return { ok: true, sessionId };
158
+ }
159
+ catch (e) {
160
+ return { ok: false, error: deps.message(e) };
161
+ }
162
+ },
163
+ // 批量归档(导出弹窗「归档所选」):把选中的会话收进 History,纳入保留期管理。
164
+ 'history-archive-batch': async (args) => {
165
+ const registry = deps.getSessionsRegistry();
166
+ if (!registry)
167
+ return { ok: false, error: '归档服务未挂载' };
168
+ const rawIds = (args && args.sessionIds) || [];
169
+ const ids = Array.isArray(rawIds) ? rawIds.map((s) => String(s).trim()).filter(Boolean) : [];
170
+ if (!ids.length)
171
+ return { ok: false, error: '请至少选择一个会话' };
172
+ const archived = [];
173
+ const failed = [];
174
+ for (const sessionId of ids) {
175
+ try {
176
+ await registry.archiveSession(sessionId);
177
+ archived.push(sessionId);
178
+ }
179
+ catch (e) {
180
+ failed.push({ sessionId, error: deps.message(e) });
181
+ }
182
+ }
183
+ return { ok: true, archived, failed };
184
+ },
185
+ 'history-unarchive': async (args) => {
186
+ const registry = deps.getSessionsRegistry();
187
+ if (!registry)
188
+ return { ok: false, error: '归档服务未挂载' };
189
+ const sessionId = String((args && args.sessionId) || '').trim();
190
+ if (!sessionId)
191
+ return { ok: false, error: '缺少 sessionId' };
192
+ try {
193
+ const r = await registry.unarchiveSession(sessionId);
194
+ const workspace = await deps.restoreWorkspaceAccounting([sessionId]);
195
+ return { ok: true, archivedSessionIds: r.archivedSessionIds, ...(workspace ? { workspace } : {}) };
196
+ }
197
+ catch (e) {
198
+ return { ok: false, error: deps.message(e) };
199
+ }
200
+ },
201
+ 'history-delete': async (args) => {
202
+ const registry = deps.getSessionsRegistry();
203
+ if (!registry)
204
+ return { ok: false, error: '归档服务未挂载' };
205
+ const sessionId = String((args && args.sessionId) || '').trim();
206
+ if (!sessionId)
207
+ return { ok: false, error: '缺少 sessionId' };
208
+ try {
209
+ await registry.deleteSession(sessionId);
210
+ return { ok: true, sessionId, deleted: true };
211
+ }
212
+ catch (e) {
213
+ return { ok: false, error: deps.message(e) };
214
+ }
215
+ },
216
+ 'history-unarchive-batch': async (args) => {
217
+ const registry = deps.getSessionsRegistry();
218
+ if (!registry)
219
+ return { ok: false, error: '归档服务未挂载' };
220
+ if (typeof registry.unarchiveSessions !== 'function')
221
+ return { ok: false, error: '当前环境不支持批量恢复(registry 未实现 unarchiveSessions)' };
222
+ const parsed = deps.parseHistoryBatchTarget(args && args.target);
223
+ if (!parsed.ok)
224
+ return parsed;
225
+ try {
226
+ const r = await registry.unarchiveSessions(parsed.target);
227
+ const workspace = await deps.restoreWorkspaceAccounting(r.unarchivedSessionIds || []);
228
+ return { ok: true, unarchivedSessionIds: r.unarchivedSessionIds, archivedSessionIds: r.archivedSessionIds, ...(workspace ? { workspace } : {}) };
229
+ }
230
+ catch (e) {
231
+ return { ok: false, error: deps.message(e) };
232
+ }
233
+ },
234
+ 'history-delete-batch': async (args) => {
235
+ const registry = deps.getSessionsRegistry();
236
+ if (!registry)
237
+ return { ok: false, error: '归档服务未挂载' };
238
+ const parsed = deps.parseHistoryBatchTarget(args && args.target);
239
+ if (!parsed.ok)
240
+ return parsed;
241
+ try {
242
+ const r = await registry.deleteArchivedSessions(parsed.target);
243
+ return { ok: true, requestedSessionIds: r.requestedSessionIds, deletedSessionIds: r.deletedSessionIds, skippedSessionIds: r.skippedSessionIds, failures: r.failures };
244
+ }
245
+ catch (e) {
246
+ return { ok: false, error: deps.message(e) };
247
+ }
248
+ },
249
+ // 重新登记工作区(写宿主状态):为「工作区已被删除、目录仍在」的分组补回一条登记。
250
+ // 只在用户显式确认后调用;宿主 realpath 会拒绝不存在的目录,同路径已登记则幂等返回。
251
+ // 不移动、不删除任何文件或会话 —— 但**会改写 `sessionIds` 记账**:`registerWorkspace`
252
+ // 无条件调 `attachKnownSessions`,把该目录下已知的会话挂回这条登记(它自己的文档就
253
+ // 写着"登记被删除时它的 sessionIds 记账一起没了")。这是不可逆动作前唯一的确认依据
254
+ // 文本,别写成"不影响记账"(界面文案 `hist.register.body`「只写工作区记账」是对得上的)。
255
+ 'history-workspace-register': async (args) => {
256
+ const registry = deps.getSessionsRegistry();
257
+ if (!registry)
258
+ return { ok: false, error: '归档服务未挂载' };
259
+ const path = String((args && args.path) || '').trim();
260
+ if (!path)
261
+ return { ok: false, error: '缺少工作区目录 path' };
262
+ if (typeof registry.registerWorkspace !== 'function') {
263
+ return { ok: false, error: '当前环境不支持重新登记工作区(registry 未实现 registerWorkspace)' };
264
+ }
265
+ const title = args && args.title ? String(args.title) : undefined;
266
+ try {
267
+ const workspace = await registry.registerWorkspace(path, title);
268
+ return { ok: true, workspace };
269
+ }
270
+ catch (e) {
271
+ return { ok: false, error: deps.message(e) };
272
+ }
273
+ },
274
+ // 从其他 Agent(Claude Code / Cursor JSONL、Codex Markdown、通用文本)导入对话,
275
+ // 通过 sessions.create 的 seed 机制生成一个可继续对话的全新会话。
276
+ 'history-import': async (args) => {
277
+ const fileName = String((args && args.fileName) || '').trim();
278
+ const content = String((args && args.content) ?? '');
279
+ if (!fileName || !content.trim())
280
+ return { ok: false, error: '缺少文件内容' };
281
+ let turns;
282
+ try {
283
+ const format = detectFormat(fileName, content);
284
+ turns = format === 'jsonl' ? parseJsonlTranscript(content)
285
+ : format === 'markdown' ? parseMarkdownTranscript(content)
286
+ : parseGenericText(content);
287
+ }
288
+ catch (e) {
289
+ return { ok: false, error: '对话解析失败: ' + deps.message(e) };
290
+ }
291
+ if (!turns.length)
292
+ return { ok: false, error: '未能从该文件中识别出对话内容' };
293
+ if (typeof deps.createSession !== 'function')
294
+ return { ok: false, error: '当前环境不支持创建会话(agents.create 不可用)' };
295
+ // 仅接受绝对路径的 cwd;非法或缺失时会话不带目录(归入未分组)。
296
+ const cwdArg = String((args && args.cwd) || '').trim();
297
+ const cwd = /^([A-Za-z]:[\\/]|\\\\|\/)/.test(cwdArg) ? cwdArg : undefined;
298
+ // seed 事件信封:seq 从 0 连续、time 为安全整数、surface 事件必须带 surfaceOp:'append'。
299
+ const base = Date.now();
300
+ const seed = turns.map((turn, i) => ({
301
+ type: turn.role === 'user' ? 'user/message' : 'assistant/message',
302
+ seq: i,
303
+ time: base + i,
304
+ data: turn.role === 'user'
305
+ ? { id: 'msg-' + i, role: 'user', content: [{ type: 'text', text: turn.text }], source: { kind: 'typed' } }
306
+ : { message: { id: 'msg-' + i, role: 'assistant', content: [{ type: 'text', text: turn.text }], source: { kind: 'model', provider: 'imported', model: 'imported' } }, turn: i, step: 0, stream: [] },
307
+ surfaceOp: 'append',
308
+ }));
309
+ try {
310
+ // `sessions.create()` 只创建裸 Session;它不经过 DSH agent factory / workspace
311
+ // / session-log,所以返回的内存 id 不会进入 UI 的会话列表。必须走官方的
312
+ // `ctx.agents.create()`,它会在同一事务里创建 Agent、发布 Session,并触发宿主
313
+ // 的持久化与 projection 链路。这里使用 UUID,避免 SessionStore 的裸自增 id 与
314
+ // 宿主 API 会话 id 空间混用。
315
+ const id = 'session-' + randomUUID();
316
+ await deps.createSession({ sessionId: id, seed, ...(cwd ? { meta: { cwd } } : {}) });
317
+ const sessions = deps.host('sessions');
318
+ const live = sessions && typeof sessions.get === 'function' ? sessions.get(id) : undefined;
319
+ if (live === undefined || live === null) {
320
+ return { ok: false, error: `创建会话失败:宿主未登记该会话(${id}),未导入任何内容` };
321
+ }
322
+ return { ok: true, sessionId: id, count: turns.length };
323
+ }
324
+ catch (e) {
325
+ return { ok: false, error: '创建会话失败: ' + deps.message(e) };
326
+ }
327
+ },
328
+ // ---------- 通用导出(D14)----------
329
+ // 技能 / 子智能体 / 提示词 / 记忆四个域的差异只有「从哪取哪些文件」,其余
330
+ //(绝对路径校验、建目录、zip 打包、错误回报)完全相同 —— 所以只加**一个** op。
331
+ // 打包用已在依赖里的 `fflate.zipSync`(此前只用了 unzipSync)。
332
+ 'bundle-export': async (args) => {
333
+ const kind = String((args && args.kind) || '');
334
+ const KINDS = ['skills', 'subagents', 'presets', 'memories'];
335
+ if (KINDS.indexOf(kind) < 0)
336
+ return { ok: false, error: `kind 需为 ${KINDS.join(' / ')}` };
337
+ const rawNames = (args && args.names) || [];
338
+ const names = Array.isArray(rawNames) ? rawNames.map((s) => String(s).trim()).filter(Boolean) : [];
339
+ if (!names.length)
340
+ return { ok: false, error: '请至少选择一项' };
341
+ // 名字逐项校验:子智能体与提示词这两个域的名字会被拼成绝对路径直接进 readFile,
342
+ // `..` 或路径分隔符能读到根外(此前只做 trim().filter(Boolean))。
343
+ // 另外两个域**不套单段谓词**,原因不同:
344
+ // - 记忆:名字是 `<场景>/<名>` 的 id,只用来查索引拿路径,本身不参与拼接;
345
+ // - 技能:名字可以是多段相对路径(只读来源的 bundle 用 relative() 拼出名字),
346
+ // 而路径本身由 skill-detail 从磁盘发现结果里取,名字只用于精确匹配条目 ——
347
+ // 匹配不上就进 missing,套单段谓词反而会把合法的多段技能名误杀。
348
+ const rejected = [];
349
+ const accepted = names.filter((name) => {
350
+ const ok = kind === 'presets' ? isValidPresetId(name)
351
+ : kind === 'subagents' ? validPersonaName(name)
352
+ : true;
353
+ if (!ok)
354
+ rejected.push(name);
355
+ return ok;
356
+ });
357
+ if (rejected.length)
358
+ return { ok: false, error: `名称非法,已拒绝导出:${rejected.join('、')}` };
359
+ if (!accepted.length)
360
+ return { ok: false, error: '请至少选择一项' };
361
+ const outDir = String((args && args.outDir) || '').trim();
362
+ // 与 history-export 同一条校验:必须绝对路径(相对路径会在宿主进程的 cwd 下落盘,
363
+ // 用户根本找不到文件)。
364
+ if (!/^([A-Za-z]:[\\/]|\\\\|\/)/.test(outDir))
365
+ return { ok: false, error: '导出目录需为绝对路径' };
366
+ // ① 收集 (zip 内路径, 绝对路径)。
367
+ const entries = [];
368
+ const missing = [];
369
+ try {
370
+ if (kind === 'subagents') {
371
+ const dir = defaultPersonasDir();
372
+ for (const name of accepted) {
373
+ const abs = join(dir, `${name}.md`);
374
+ // 名字已是单段,`join` 拼不出根外;这里再比一次是**后置断言** —— 一旦触发说明
375
+ // 段名谓词有洞,宁可少导一项也不去读根外的文件。
376
+ if (!isInsideRoot(dir, abs)) {
377
+ missing.push(name);
378
+ continue;
379
+ }
380
+ entries.push({ zip: `${name}.md`, abs });
381
+ }
382
+ }
383
+ else if (kind === 'presets') {
384
+ for (const id of accepted) {
385
+ const docAbs = join(deps.promptsDir, id, 'AGENTS.md');
386
+ if (!isInsideRoot(deps.promptsDir, docAbs)) {
387
+ missing.push(id);
388
+ continue;
389
+ }
390
+ entries.push({ zip: `${id}/AGENTS.md`, abs: docAbs });
391
+ // 描述侧车(「只给使用者看」的那句)随预设一起走:导出再导入不该把它丢掉。
392
+ const metaAbs = join(deps.promptsDir, id, 'meta.json');
393
+ try {
394
+ if ((await stat(metaAbs)).isFile())
395
+ entries.push({ zip: `${id}/meta.json`, abs: metaAbs });
396
+ }
397
+ catch { /* 没写描述 */ }
398
+ }
399
+ }
400
+ else if (kind === 'memories') {
401
+ // 记忆有 flat(`<场景>/<name>.md`)与 bundle(`<场景>/<name>/<name>.md`)两种形态,
402
+ // 且场景名本身可含 '/' —— 按 id 拼路径会把 bundle 读成 `<场景>/<name>.md`,读不到
403
+ // 就静默丢项(详见 planMemoryExport)。所以走索引拿那条规则自己的真实路径。
404
+ const r = await deps.rulesList({});
405
+ const plan = planMemoryExport(accepted, (r && r.rules) || []);
406
+ missing.push(...plan.missing);
407
+ for (const item of plan.entries) {
408
+ if (item.kind === 'file') {
409
+ entries.push({ zip: item.zip, abs: item.abs });
410
+ continue;
411
+ }
412
+ // bundle:把目录内的文件全部打包(与技能分支同口径),单个目录读不了只丢它自己。
413
+ try {
414
+ for (const e of await readdir(item.abs, { withFileTypes: true })) {
415
+ if (e.isFile())
416
+ entries.push({ zip: `${item.zip}/${e.name}`, abs: join(item.abs, e.name) });
417
+ }
418
+ }
419
+ catch {
420
+ missing.push(item.id);
421
+ }
422
+ }
423
+ }
424
+ else {
425
+ // 技能:先经 skill-detail 拿源文件路径(bundle 型是目录,单文件型是 .md)。
426
+ for (const key of accepted) {
427
+ const i = key.indexOf('/');
428
+ if (i <= 0) {
429
+ missing.push(key);
430
+ continue;
431
+ }
432
+ const short = key.slice(i + 1);
433
+ const r = await deps.skillDetail({ root: key.slice(0, i), name: short });
434
+ const p = r && r.ok !== false && r.data ? String(r.data.path || '') : '';
435
+ if (!p) {
436
+ missing.push(key);
437
+ continue;
438
+ }
439
+ let isDir = false;
440
+ try {
441
+ isDir = (await stat(p)).isDirectory();
442
+ }
443
+ catch {
444
+ isDir = false;
445
+ }
446
+ if (isDir) {
447
+ for (const e of await readdir(p, { withFileTypes: true })) {
448
+ if (e.isFile())
449
+ entries.push({ zip: `${short}/${e.name}`, abs: join(p, e.name) });
450
+ }
451
+ }
452
+ else {
453
+ entries.push({ zip: `${short}/${basename(p)}`, abs: p });
454
+ }
455
+ }
456
+ }
457
+ }
458
+ catch (e) {
459
+ return { ok: false, error: '收集待导出文件失败: ' + deps.message(e) };
460
+ }
461
+ // ② 读盘 + 打包(读不到的项进 missing,不整体失败 —— 部分成功也有价值)。
462
+ // 上限是**病态保护**,不是策略:导入侧有 5 条限额(见 skills/core.js 的 MAX_UPLOAD_*),
463
+ // 导出侧此前一条都没有 —— 选中一个塞了几百 MB 附件的 bundle 时,「全部正文 + 一份 zip」
464
+ // 会同时驻留内存。超限**整体报错**而不是塞进 missing:静默少几个文件比报错更难查。
465
+ const maxFileBytes = 32 * 1024 * 1024;
466
+ const maxTotalBytes = 256 * 1024 * 1024;
467
+ const asMb = (n) => Math.round(n / 1024 / 1024) + ' MB';
468
+ const bundle = {};
469
+ let totalBytes = 0;
470
+ for (const item of entries) {
471
+ try {
472
+ const size = (await stat(item.abs)).size;
473
+ if (size > maxFileBytes) {
474
+ return { ok: false, error: `导出内容过大:${item.zip} 约 ${asMb(size)},单文件上限 ${asMb(maxFileBytes)}` };
475
+ }
476
+ if (totalBytes + size > maxTotalBytes) {
477
+ return { ok: false, error: `导出内容过大:合计上限 ${asMb(maxTotalBytes)},已累计 ${asMb(totalBytes)},加上 ${item.zip} 会超出` };
478
+ }
479
+ bundle[item.zip] = new Uint8Array(await readFile(item.abs));
480
+ totalBytes += size;
481
+ }
482
+ catch {
483
+ missing.push(item.zip);
484
+ }
485
+ }
486
+ if (!Object.keys(bundle).length)
487
+ return { ok: false, error: '没有可导出的文件(选中项都不存在?)' };
488
+ let zipPath = '';
489
+ try {
490
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
491
+ zipPath = join(outDir, `dsh-tool-management-${kind}-${stamp}.zip`);
492
+ await mkdir(outDir, { recursive: true });
493
+ await writeFile(zipPath, Buffer.from(zipSync(bundle)));
494
+ }
495
+ catch (e) {
496
+ return { ok: false, error: '写入导出文件失败: ' + deps.message(e) };
497
+ }
498
+ return { ok: true, written: Object.keys(bundle).length, zipPath, missing };
499
+ },
500
+ // 把选中的归档会话导出为可再导入的转录文件(Markdown / JSONL),写入指定目录。
501
+ // 每个会话一个文件;读不到正文的冷会话列入 skipped,不中断其余导出。
502
+ 'history-export': async (args) => {
503
+ const rawIds = (args && args.sessionIds) || [];
504
+ const ids = Array.isArray(rawIds) ? rawIds.map((s) => String(s).trim()).filter(Boolean) : [];
505
+ if (!ids.length)
506
+ return { ok: false, error: '请至少选择一个会话' };
507
+ const format = String((args && args.format) || 'markdown').toLowerCase();
508
+ if (format !== 'markdown' && format !== 'jsonl')
509
+ return { ok: false, error: 'format 需为 markdown 或 jsonl' };
510
+ const outDir = String((args && args.outDir) || '').trim();
511
+ if (!/^([A-Za-z]:[\\/]|\\\\|\/)/.test(outDir))
512
+ return { ok: false, error: '导出目录需为绝对路径' };
513
+ const sessions = deps.host('sessions');
514
+ if (!sessions || typeof sessions.get !== 'function')
515
+ return { ok: false, error: '当前环境不支持读取会话(sessions.get 不可用)' };
516
+ // 读会话事件:先取活动 store;冷会话(进程重启后)从持久化后端恢复只读句柄。
517
+ const readEvents = async (sessionId) => {
518
+ const live = sessions.get(sessionId);
519
+ if (live && typeof live.snapshotEvents === 'function') {
520
+ const ev = live.snapshotEvents();
521
+ return Array.isArray(ev) ? ev : undefined;
522
+ }
523
+ const persistence = deps.host('sessionPersistence');
524
+ if (persistence && typeof persistence.prepare === 'function') {
525
+ try {
526
+ const prep = await persistence.prepare(sessionId);
527
+ const ps = prep && prep.session;
528
+ try {
529
+ if (ps && typeof ps.snapshotEvents === 'function') {
530
+ const ev = ps.snapshotEvents();
531
+ return Array.isArray(ev) ? ev : undefined;
532
+ }
533
+ }
534
+ finally {
535
+ const dispose = prep[Symbol.dispose];
536
+ if (typeof dispose === 'function')
537
+ dispose();
538
+ }
539
+ }
540
+ catch {
541
+ return undefined;
542
+ }
543
+ }
544
+ return undefined;
545
+ };
546
+ try {
547
+ await mkdir(outDir, { recursive: true });
548
+ }
549
+ catch (e) {
550
+ return { ok: false, error: '创建导出目录失败: ' + deps.message(e) };
551
+ }
552
+ const ext = format === 'jsonl' ? '.jsonl' : '.md';
553
+ const exported = [];
554
+ const skipped = [];
555
+ // 文件名归一是**有损映射**(`a/b` 与 `a_b` 都成 `a_b`),所以后缀一段短哈希把
556
+ // 不同 sessionId 分开 —— 否则同一批导出里后者静默覆盖前者,用户只拿到一个文件
557
+ // 却以为导出了 N 个(审计 T-26)。哈希只取 8 位、只用来区分,不追求不可逆。
558
+ const digest = (text) => createHash('sha1').update(text).digest('hex').slice(0, 8);
559
+ for (const sessionId of ids) {
560
+ const safe = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80) || 'session';
561
+ const fileName = safe + '-' + digest(sessionId) + ext;
562
+ const path = join(outDir, fileName);
563
+ try {
564
+ const events = await readEvents(sessionId);
565
+ const turns = deps.extractTurnsFromEvents(events || []);
566
+ if (!turns.length) {
567
+ skipped.push({ sessionId, error: events ? '会话中没有可导出的消息' : '无法读取会话内容(不在活动存储且无持久化句柄)' });
568
+ continue;
569
+ }
570
+ // `'wx'`:绝不覆盖已存在的同名文件 —— 覆盖是静默的,用户会以为导出成功
571
+ // 而实际上把上一次的产物(或自己的文件)抹掉了。撞名就如实报 skipped。
572
+ await writeFile(path, deps.serializeTurns(turns, format), { encoding: 'utf8', flag: 'wx' });
573
+ exported.push({ sessionId, fileName, path, count: turns.length });
574
+ }
575
+ catch (e) {
576
+ skipped.push({ sessionId, error: '导出失败: ' + deps.message(e) });
577
+ }
578
+ }
579
+ return { ok: true, exported, skipped };
580
+ },
581
+ 'history-retention-get': async () => {
582
+ const { retentionDays } = await deps.readHistoryRetention();
583
+ return { ok: true, retentionDays };
584
+ },
585
+ 'history-retention-set': async (args) => {
586
+ const days = Number((args && args.retentionDays) ?? -1);
587
+ // 错误文案承诺的是「非负整数」,就按整数校验:1.5 这类小数以前会被静默取整成 1,
588
+ // 用户看到的回执与输入不符。
589
+ if (!Number.isSafeInteger(days) || days < 0) {
590
+ return { ok: false, error: 'retentionDays 需为非负整数(0=永久不删除),收到: ' + String((args && args.retentionDays)) };
591
+ }
592
+ try {
593
+ await deps.writeHistoryRetention(days);
594
+ }
595
+ catch (e) {
596
+ return { ok: false, error: '写入保留期失败: ' + deps.message(e) };
597
+ }
598
+ // 设置变更后立即扫一次,UI 反映新策略。
599
+ await deps.sweepHistory().catch(() => { });
600
+ return { ok: true, retentionDays: days };
601
+ },
602
+ };
603
+ }