dsh-plugin-tool-management 0.13.0 → 0.15.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 (62) hide show
  1. package/CHANGELOG.md +560 -474
  2. package/README.md +91 -123
  3. package/README_EN.md +99 -126
  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 +391 -337
  21. package/lib/audit-log.js +157 -0
  22. package/lib/client.js +4204 -2454
  23. package/lib/compat/patch-dialect.js +3 -1
  24. package/lib/compat/preset-reach.js +43 -9
  25. package/lib/compat/probe.js +149 -7
  26. package/lib/context-inject.js +91 -35
  27. package/lib/index.js +479 -114
  28. package/lib/mcp/loader-token.js +53 -28
  29. package/lib/mcp/manager.js +149 -21
  30. package/lib/mcp/secret-guard.js +21 -0
  31. package/lib/mcp/state-section.js +13 -11
  32. package/lib/memories/archive-engine.js +70 -2
  33. package/lib/memories/archive.js +8 -1
  34. package/lib/memories/constants.js +72 -8
  35. package/lib/memories/index-io.js +7 -1
  36. package/lib/memories/projection.js +67 -31
  37. package/lib/memories/service.js +17 -2
  38. package/lib/memories/snapshot.js +113 -22
  39. package/lib/op-registry.js +26 -6
  40. package/lib/ops/candidates.js +11 -0
  41. package/lib/ops/compat.js +113 -31
  42. package/lib/ops/scene-records.js +34 -0
  43. package/lib/ops/scene-sync.js +12 -1
  44. package/lib/ops/sessions.js +24 -9
  45. package/lib/ops/snapshot.js +596 -0
  46. package/lib/prompts/service.js +25 -2
  47. package/lib/scenes/candidates.js +66 -5
  48. package/lib/sessions/bridge.js +5 -0
  49. package/lib/sessions/workspace.js +17 -5
  50. package/lib/skills/core.js +105 -3
  51. package/lib/skills/service.js +34 -2
  52. package/lib/subagents/service.js +37 -4
  53. package/lib/subagents/tools.js +11 -4
  54. package/lib/tools/deps.js +16 -1
  55. package/lib/tools/mcp.js +291 -50
  56. package/lib/tools/memory.js +131 -80
  57. package/lib/tools/prompt.js +27 -13
  58. package/lib/tools/scene.js +351 -0
  59. package/lib/tools/skills.js +93 -17
  60. package/lib/tools/subagent.js +56 -45
  61. package/lib/tools/table.js +208 -15
  62. package/package.json +24 -20
@@ -0,0 +1,596 @@
1
+ // 整机迁移快照(0.15.0 C2)—— 一键带走全部配置,换机器 / 重装后一键放回来。
2
+ //
3
+ // 与另外两条出口的定位区分(用户裁定):
4
+ // · `mcpm-export` 只带 MCP 一个域;
5
+ // · 「兼容」页的 patch 备份是**本机故障恢复**(同机、含明文、按层级轮转);
6
+ // · 这里是**跨机迁移**:一个目录,人能在上面看、能改、能版本管理。
7
+ //
8
+ // 两条设计约束:
9
+ // ① 不新写任何语义。导入侧全部**经现有 op**(`mcpm-import` / `rules-import` /
10
+ // `subagent-import` / `agentsmd-import` / `tool-table` / `inject-settings` /
11
+ // `scene-archive-save` …),于是校验、写锁、门禁、缓存与目录重算都自动跟着原域走。
12
+ // `invokeOp` 传进来的就是 handlers 表那一份 —— 场景锁定期间各域自己拒绝,这里不再判一套。
13
+ // ② 默认**打码**。MCP 的 env / headers 里是明文凭据,一份要被拷去 U 盘 / 网盘 / 另一台机器的
14
+ // 导出物不该顺手把它们带走;要完整导出必须显式传 `includeSecrets:true`(界面二次确认)。
15
+ // 打码值导回来由 `secret-guard` 拿目标机的旧真值顶替(`mcpm-import` 既有的行为)。
16
+ import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
17
+ import { join, resolve } from 'node:path';
18
+ import { unzipSync, zipSync } from 'fflate';
19
+ const SNAP_PREFIX = 'dsh-tool-management-snapshot-';
20
+ /** 快照目录里各部分的名字(导入侧按这些名字找东西,别改)。 */
21
+ const F_MANIFEST = 'manifest.json';
22
+ const F_MCP = 'mcp.json';
23
+ const F_SETTINGS = 'settings.json';
24
+ const F_SCENES = 'scenes.json';
25
+ const D_MEMORIES = 'memories';
26
+ const D_SUBAGENTS = 'subagents';
27
+ const D_PROMPTS = 'prompts';
28
+ const D_SKILLS = 'skills';
29
+ /** 三个"文件域":整个目录打进一份 zip,键是相对路径。 */
30
+ const FILE_DOMAINS = [D_MEMORIES, D_SUBAGENTS, D_PROMPTS];
31
+ const ALL_DOMAINS = ['mcp', D_MEMORIES, D_SUBAGENTS, D_PROMPTS, D_SKILLS, 'settings', 'scenes'];
32
+ const MASKED = '••••••';
33
+ function stamp() {
34
+ return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
35
+ }
36
+ /** 与 `bundle-export` 同一条判据:目标目录必须是绝对路径(相对路径会落到进程 CWD,那是猜)。 */
37
+ function absoluteDir(raw) {
38
+ const s = String(raw == null ? '' : raw).trim();
39
+ if (!s)
40
+ return null;
41
+ return /^([A-Za-z]:[\\/]|\\\\|\/)/.test(s) ? resolve(s) : null;
42
+ }
43
+ /**
44
+ * 递归收一个目录里的所有文件(**相对该目录根**的路径 → 绝对路径)。
45
+ * 目录不存在 = 空(这个域可能压根没用过)。
46
+ * 只收普通文件、不跟随符号链接(`isFile()` 对 link 为 false)—— 迁移物里不该混进指向别处的链接。
47
+ */
48
+ async function walk(root) {
49
+ const out = [];
50
+ async function step(dir, prefix, depth) {
51
+ if (depth > 12)
52
+ return;
53
+ let entries;
54
+ try {
55
+ entries = await readdir(dir, { withFileTypes: true });
56
+ }
57
+ catch {
58
+ return;
59
+ }
60
+ for (const e of (entries || [])) {
61
+ const abs = join(dir, e.name);
62
+ const rel = prefix ? prefix + '/' + e.name : e.name;
63
+ if (e.isDirectory()) {
64
+ await step(abs, rel, depth + 1);
65
+ continue;
66
+ }
67
+ if (e.isFile())
68
+ out.push({ rel, abs });
69
+ }
70
+ }
71
+ try {
72
+ if ((await stat(root)).isDirectory())
73
+ await step(root, '', 0);
74
+ }
75
+ catch {
76
+ return out;
77
+ }
78
+ return out;
79
+ }
80
+ /**
81
+ * 各 op 的返回形态不一(rows / personas / presets / scenes…),这里只为「条数与重名统计」取一份名字清单。
82
+ * 取不到就是 0 —— 预览数字偏保守(少报重名)不影响导入本身,导入一律交给各域自己的 op。
83
+ */
84
+ function namesOf(res, prefer) {
85
+ if (!res || res.ok === false)
86
+ return [];
87
+ const bags = [res.data, res].filter(Boolean);
88
+ for (const bag of bags) {
89
+ for (const key of ['rows', 'personas', 'presets', 'scenes', 'skills', 'list', 'entries', 'items']) {
90
+ const arr = bag[key];
91
+ if (!Array.isArray(arr))
92
+ continue;
93
+ const names = [];
94
+ for (const item of arr) {
95
+ if (typeof item === 'string') {
96
+ names.push(item);
97
+ continue;
98
+ }
99
+ for (const field of prefer) {
100
+ const v = item && item[field];
101
+ if (typeof v === 'string' && v) {
102
+ names.push(v);
103
+ break;
104
+ }
105
+ }
106
+ }
107
+ if (names.length)
108
+ return names;
109
+ }
110
+ }
111
+ return [];
112
+ }
113
+ /** 场景清单(带 label / description / 提示词绑定):`rules-list` 的 scenes 那一栏,读不到就是空。 */
114
+ function sceneRecordsOf(res) {
115
+ const bags = [res && res.data, res].filter(Boolean);
116
+ for (const bag of bags) {
117
+ const arr = bag?.scenes;
118
+ if (Array.isArray(arr)) {
119
+ return arr
120
+ .map((x) => (typeof x === 'string' ? { name: x } : {
121
+ name: String((x && (x.name || x.scene)) || ''),
122
+ label: x && typeof x.label === 'string' ? x.label : undefined,
123
+ description: x && typeof x.description === 'string' ? x.description : undefined,
124
+ prompt: x && typeof x.prompt === 'string' && x.prompt ? x.prompt : undefined,
125
+ }))
126
+ .filter((x) => x.name && x.name !== '_shared');
127
+ }
128
+ }
129
+ return [];
130
+ }
131
+ function maskKv(map) {
132
+ const out = {};
133
+ if (map && typeof map === 'object' && !Array.isArray(map)) {
134
+ for (const k of Object.keys(map))
135
+ out[k] = MASKED;
136
+ }
137
+ return out;
138
+ }
139
+ export function createSnapshotOps(deps) {
140
+ /** 导出:产出一个**目录**(不是 zip)—— 用户要能直接看见里面带了什么、也方便手工挑着带。 */
141
+ async function snapshotExport(args) {
142
+ const outDir = absoluteDir(args && args.outDir);
143
+ if (!outDir)
144
+ return { ok: false, error: '导出目录必须是绝对路径' };
145
+ const includeSecrets = args && args.includeSecrets === true;
146
+ await deps.ensurePaths();
147
+ const dir = join(outDir, SNAP_PREFIX + stamp());
148
+ try {
149
+ await mkdir(dir, { recursive: true });
150
+ }
151
+ catch (e) {
152
+ return { ok: false, error: '创建快照目录失败: ' + deps.message(e) };
153
+ }
154
+ const domains = {};
155
+ const notes = [];
156
+ // ── MCP:现有 op 的 JSON,默认打码 ─────────────────────────────────────────
157
+ const mcpRes = await deps.invokeOp('mcpm-export', {});
158
+ let mcpRows = [];
159
+ if (mcpRes && mcpRes.ok) {
160
+ try {
161
+ const parsed = JSON.parse(String(mcpRes.json || '{}'));
162
+ mcpRows = Array.isArray(parsed.rows) ? parsed.rows : [];
163
+ }
164
+ catch (e) {
165
+ notes.push('MCP 导出解析失败: ' + deps.message(e));
166
+ }
167
+ }
168
+ else
169
+ notes.push('MCP 没读到:' + String((mcpRes && mcpRes.error) || 'op 未返回内容'));
170
+ const mcpOut = mcpRows.map((row) => {
171
+ if (includeSecrets)
172
+ return row;
173
+ const copy = Object.assign({}, row);
174
+ if (copy.env)
175
+ copy.env = maskKv(copy.env);
176
+ if (copy.headers)
177
+ copy.headers = maskKv(copy.headers);
178
+ if (typeof copy.url === 'string')
179
+ copy.url = copy.url.replace(/([?&](?:key|token|secret|password)=)[^&]*/gi, '$1' + MASKED);
180
+ return copy;
181
+ });
182
+ try {
183
+ await writeFile(join(dir, F_MCP), JSON.stringify({ exportedAt: new Date().toISOString(), masked: !includeSecrets, rows: mcpOut }, null, 2), 'utf8');
184
+ }
185
+ catch (e) {
186
+ return { ok: false, error: '写 mcp.json 失败: ' + deps.message(e) };
187
+ }
188
+ domains.mcp = mcpOut.length;
189
+ // ── 文件域:记忆 / 子智能体 / 提示词,各打进一份 zip(键 = 相对路径)──────────
190
+ const roots = {
191
+ [D_MEMORIES]: deps.hubPath(D_MEMORIES),
192
+ [D_SUBAGENTS]: deps.hubPath(D_SUBAGENTS),
193
+ [D_PROMPTS]: deps.promptsDir(),
194
+ };
195
+ for (const kind of FILE_DOMAINS) {
196
+ const files = await walk(roots[kind]);
197
+ if (files.length) {
198
+ const bundle = {};
199
+ for (const f of files) {
200
+ try {
201
+ bundle[f.rel] = new Uint8Array(await readFile(f.abs));
202
+ }
203
+ catch (e) {
204
+ notes.push(kind + ' 跳过 ' + f.rel + ':' + deps.message(e));
205
+ }
206
+ }
207
+ try {
208
+ await writeFile(join(dir, kind + '.zip'), Buffer.from(zipSync(bundle)));
209
+ }
210
+ catch (e) {
211
+ notes.push(kind + ' 打包失败:' + deps.message(e));
212
+ }
213
+ }
214
+ domains[kind] = files.length;
215
+ }
216
+ // ── 技能:源目录在外部(不在 hub),交给既有的 `bundle-export` 走它自己的收集路径 ──
217
+ const skillState = await deps.invokeOp('skill-state', {});
218
+ const skillKeys = namesOf(skillState, ['key']).filter((k) => k.indexOf('/') > 0);
219
+ if (!skillKeys.length)
220
+ notes.push('技能这次没导出:没从 skill-state 读到「<来源>/<名字>」形态的清单');
221
+ if (skillKeys.length) {
222
+ const bundled = await deps.invokeOp('bundle-export', { kind: 'skills', names: skillKeys, outDir: join(dir, D_SKILLS) });
223
+ if (bundled && bundled.ok)
224
+ domains.skills = Number(bundled.written || 0);
225
+ else
226
+ notes.push('技能打包失败:' + String((bundled && bundled.error) || 'bundle-export 未返回内容'));
227
+ }
228
+ else
229
+ domains.skills = 0;
230
+ // ── 侧车配置:三份设置都从各自的读 op 拿,不抄结构 ────────────────────────────
231
+ const [toolTable, inject, sceneSettings] = await Promise.all([
232
+ deps.invokeOp('tool-table', {}),
233
+ deps.invokeOp('inject-settings', {}),
234
+ deps.invokeOp('scene-settings', {}),
235
+ ]);
236
+ await writeFile(join(dir, F_SETTINGS), JSON.stringify({
237
+ exportedAt: new Date().toISOString(),
238
+ toolTable: toolTable && toolTable.ok ? { hidden: toolTable.hidden || [], presets: toolTable.presets || [] } : null,
239
+ inject: inject && inject.ok ? (inject.settings || null) : null,
240
+ sceneSettings: sceneSettings && sceneSettings.ok ? (sceneSettings.settings || null) : null,
241
+ }, null, 2), 'utf8');
242
+ domains.settings = 1;
243
+ // ── 场景:档案(含 0.15.0 C1 的工具表绑定)+ 场景记录(label / 描述 / 提示词绑定)──
244
+ const modeRes = await deps.invokeOp('scene-mode-get', {});
245
+ const rulesRes = await deps.invokeOp('rules-list', {});
246
+ const archives = (modeRes && modeRes.ok && modeRes.archives) || {};
247
+ await writeFile(join(dir, F_SCENES), JSON.stringify({
248
+ exportedAt: new Date().toISOString(),
249
+ archives,
250
+ records: sceneRecordsOf(rulesRes),
251
+ }, null, 2), 'utf8');
252
+ domains.scenes = Object.keys(archives).length;
253
+ const manifest = {
254
+ pluginVersion: deps.pluginVersion(),
255
+ exportedAt: new Date().toISOString(),
256
+ masked: !includeSecrets,
257
+ domains,
258
+ notes,
259
+ };
260
+ await writeFile(join(dir, F_MANIFEST), JSON.stringify(manifest, null, 2), 'utf8');
261
+ return { ok: true, dir, manifest };
262
+ }
263
+ /** 读快照目录:清单 + 实际存在的部件路径。缺哪个域就是没导出过,不当错误。 */
264
+ async function snapshotRead(dir) {
265
+ let manifestRaw = '';
266
+ try {
267
+ manifestRaw = await readFile(join(dir, F_MANIFEST), 'utf8');
268
+ }
269
+ catch {
270
+ return { error: '这个目录里没有 manifest.json —— 不是本插件导出的快照目录' };
271
+ }
272
+ let manifest;
273
+ try {
274
+ manifest = JSON.parse(manifestRaw);
275
+ }
276
+ catch (e) {
277
+ return { error: 'manifest.json 读不动:' + deps.message(e) };
278
+ }
279
+ if (!manifest || typeof manifest !== 'object')
280
+ return { error: 'manifest.json 形状不对' };
281
+ const files = {};
282
+ const parts = [F_MCP, F_SETTINGS, F_SCENES].concat(FILE_DOMAINS.map((d) => d + '.zip'));
283
+ for (const p of parts) {
284
+ try {
285
+ if ((await stat(join(dir, p))).isFile())
286
+ files[p] = join(dir, p);
287
+ }
288
+ catch { /* 这一件没有 */ }
289
+ }
290
+ return { manifest, files };
291
+ }
292
+ async function snapshotPreview(args) {
293
+ const dir = absoluteDir(args && args.dir);
294
+ if (!dir)
295
+ return { ok: false, error: '快照目录必须是绝对路径' };
296
+ const read = await snapshotRead(dir);
297
+ if ('error' in read)
298
+ return { ok: false, error: read.error };
299
+ const { manifest, files } = read;
300
+ const zipKey = (d) => d + '.zip';
301
+ const current = {
302
+ mcp: namesOf(await deps.invokeOp('mcpm-list', {}), ['serverName']),
303
+ [D_SUBAGENTS]: namesOf(await deps.invokeOp('subagent-list', {}), ['name']),
304
+ [D_PROMPTS]: namesOf(await deps.invokeOp('agentsmd-list', {}), ['id']),
305
+ [D_MEMORIES]: namesOf(await deps.invokeOp('rules-list', {}), ['id']),
306
+ scenes: sceneRecordsOf(await deps.invokeOp('rules-list', {})).map((r) => r.name),
307
+ };
308
+ const notes = [];
309
+ const domains = [];
310
+ if (files[F_MCP]) {
311
+ let rows = [];
312
+ try {
313
+ rows = (JSON.parse(await readFile(files[F_MCP], 'utf8')).rows) || [];
314
+ }
315
+ catch (e) {
316
+ notes.push('mcp.json 读不动:' + deps.message(e));
317
+ }
318
+ domains.push({ domain: 'mcp', count: rows.length, clashes: rows.filter((r) => current.mcp.indexOf(String(r.serverName || '')) >= 0).length });
319
+ }
320
+ for (const kind of FILE_DOMAINS) {
321
+ if (files[zipKey(kind)]) {
322
+ try {
323
+ const entries = Object.keys(unzipSync(new Uint8Array(await readFile(files[zipKey(kind)]))));
324
+ // 重名按"顶层条目名"比:人设是扁平文件(`<名>.md`),扩展名不算名字的一部分。
325
+ const stripExt = kind === D_SUBAGENTS ? (n) => n.replace(/\.[^.]+$/, '') : (n) => n;
326
+ const top = [...new Set(entries.map((p) => stripExt(p.split('/')[0])))];
327
+ domains.push({
328
+ domain: kind,
329
+ count: entries.length,
330
+ clashes: top.filter((n) => current[kind].indexOf(n) >= 0).length,
331
+ });
332
+ }
333
+ catch (e) {
334
+ notes.push(kind + ' 的 zip 读不动:' + deps.message(e));
335
+ }
336
+ }
337
+ else
338
+ domains.push({ domain: kind, count: 0, clashes: 0 });
339
+ }
340
+ if (files[F_SCENES]) {
341
+ try {
342
+ const parsed = JSON.parse(await readFile(files[F_SCENES], 'utf8'));
343
+ const names = Object.keys(parsed.archives || {});
344
+ domains.push({ domain: 'scenes', count: names.length, clashes: names.filter((n) => current.scenes.indexOf(n) >= 0).length });
345
+ }
346
+ catch (e) {
347
+ notes.push('scenes.json 读不动:' + deps.message(e));
348
+ }
349
+ }
350
+ if (files[F_SETTINGS])
351
+ domains.push({ domain: 'settings', count: 1, clashes: 1 });
352
+ if (manifest.domains && Number(manifest.domains.skills) > 0) {
353
+ let zips = [];
354
+ try {
355
+ zips = (await readdir(join(dir, D_SKILLS))).filter((n) => /\.zip$/i.test(n));
356
+ }
357
+ catch { /* 没带技能 */ }
358
+ domains.push({ domain: D_SKILLS, count: zips.length, clashes: 0 });
359
+ }
360
+ return {
361
+ ok: true,
362
+ dir,
363
+ pluginVersion: String(manifest.pluginVersion || ''),
364
+ exportedAt: String(manifest.exportedAt || ''),
365
+ masked: manifest.masked !== false,
366
+ domains,
367
+ notes: (manifest.notes || []).concat(notes),
368
+ missingDomains: ALL_DOMAINS.filter((d) => !domains.some((x) => x.domain === d && x.count > 0)),
369
+ };
370
+ }
371
+ /**
372
+ * 导入:逐域调**该域自己的 import op**,逐域回报成功 / 跳过。
373
+ * 绝不一步写:界面必须先 `snapshot-preview`,用户勾过域、选过冲突策略才走到这里。
374
+ */
375
+ async function snapshotImport(args) {
376
+ const dir = absoluteDir(args && args.dir);
377
+ if (!dir)
378
+ return { ok: false, error: '快照目录必须是绝对路径' };
379
+ const domains = Array.isArray(args && args.domains) ? args.domains.map((x) => String(x)) : [];
380
+ if (!domains.length)
381
+ return { ok: false, error: '没有勾选任何域' };
382
+ const overwrite = (args && args.conflict) === 'overwrite';
383
+ const read = await snapshotRead(dir);
384
+ if ('error' in read)
385
+ return { ok: false, error: read.error };
386
+ const { files } = read;
387
+ const report = [];
388
+ /** 一个域失败只记在这一行上:迁移的意义正是「哪域没过来」看得清,不是整批回滚。 */
389
+ async function run(domain, task) {
390
+ if (domains.indexOf(domain) < 0)
391
+ return;
392
+ try {
393
+ const r = await task();
394
+ report.push({ domain, imported: r.imported, skipped: r.skipped });
395
+ }
396
+ catch (e) {
397
+ report.push({ domain, imported: 0, skipped: 0, error: deps.message(e) });
398
+ }
399
+ }
400
+ await run('mcp', async () => {
401
+ if (!files[F_MCP])
402
+ return { imported: 0, skipped: 0 };
403
+ const raw = await readFile(files[F_MCP], 'utf8');
404
+ const res = await deps.invokeOp('mcpm-import', overwrite ? { json: raw, conflict: 'overwrite' } : { json: raw });
405
+ if (!res || res.ok === false)
406
+ throw new Error(String((res && res.error) || 'mcpm-import 失败'));
407
+ return { imported: (res.added || []).length + (res.overwritten || []).length, skipped: (res.skipped || []).length };
408
+ });
409
+ await run(D_SUBAGENTS, async () => {
410
+ const zip = files[D_SUBAGENTS + '.zip'];
411
+ if (!zip)
412
+ return { imported: 0, skipped: 0 };
413
+ const entries = unzipSync(new Uint8Array(await readFile(zip)));
414
+ const filesArg = [];
415
+ let skipped = 0;
416
+ for (const [rel, bytes] of Object.entries(entries)) {
417
+ // 人设是扁平的 `<名>.md`:嵌套路径不是 subagent-import 认的形状,如实跳过。
418
+ if (rel.indexOf('/') >= 0 || !/\.md$/i.test(rel)) {
419
+ skipped += 1;
420
+ continue;
421
+ }
422
+ filesArg.push({ name: rel, data: Buffer.from(bytes).toString('base64') });
423
+ }
424
+ if (!filesArg.length)
425
+ return { imported: 0, skipped };
426
+ const res = await deps.invokeOp('subagent-import', { files: filesArg });
427
+ if (!res || res.ok === false)
428
+ throw new Error(String((res && res.error) || 'subagent-import 失败'));
429
+ return { imported: (res.imported || []).length, skipped: skipped + (res.skipped || []).length };
430
+ });
431
+ await run(D_PROMPTS, async () => {
432
+ const zip = files[D_PROMPTS + '.zip'];
433
+ if (!zip)
434
+ return { imported: 0, skipped: 0 };
435
+ const entries = unzipSync(new Uint8Array(await readFile(zip)));
436
+ // 提示词的落点是 `<id>/AGENTS.md`(+ 可选 `<id>/meta.json`),而 `agentsmd-import` 一次一份。
437
+ const bodies = {};
438
+ const metas = {};
439
+ let skipped = 0;
440
+ for (const [rel, bytes] of Object.entries(entries)) {
441
+ const parts = rel.split('/');
442
+ if (parts.length !== 2) {
443
+ skipped += 1;
444
+ continue;
445
+ }
446
+ const text = Buffer.from(bytes).toString('utf8');
447
+ if (parts[1] === 'AGENTS.md')
448
+ bodies[parts[0]] = text;
449
+ else if (parts[1] === 'meta.json') {
450
+ try {
451
+ metas[parts[0]] = JSON.parse(text);
452
+ }
453
+ catch { /* 描述丢了不影响正文导入 */ }
454
+ }
455
+ }
456
+ let imported = 0;
457
+ for (const id of Object.keys(bodies)) {
458
+ const desc = String((metas[id] && metas[id].description) || '');
459
+ const res = await deps.invokeOp('agentsmd-import', { id, content: bodies[id], description: desc });
460
+ if (res && res.ok) {
461
+ imported += 1;
462
+ continue;
463
+ }
464
+ // 同名预设 `agentsmd-import` 一律拒(它不覆盖)。覆盖模式下改走 upsert 那条 update 路径。
465
+ if (!overwrite) {
466
+ skipped += 1;
467
+ continue;
468
+ }
469
+ const upd = await deps.invokeOp('agentsmd-update', { id, nextId: id, content: bodies[id], description: desc });
470
+ if (upd && upd.ok)
471
+ imported += 1;
472
+ else
473
+ skipped += 1;
474
+ }
475
+ return { imported, skipped };
476
+ });
477
+ await run(D_MEMORIES, async () => {
478
+ const zip = files[D_MEMORIES + '.zip'];
479
+ if (!zip)
480
+ return { imported: 0, skipped: 0 };
481
+ const entries = unzipSync(new Uint8Array(await readFile(zip)));
482
+ // zip 的相对路径就是 `<场景>/<文件>`:按场景分组,每组各调一次 rules-import。
483
+ const byScene = {};
484
+ let skipped = 0;
485
+ for (const [rel, bytes] of Object.entries(entries)) {
486
+ const i = rel.indexOf('/');
487
+ if (i <= 0) {
488
+ skipped += 1;
489
+ continue;
490
+ }
491
+ const scene = rel.slice(0, i);
492
+ const name = rel.slice(i + 1);
493
+ // 附件目录(`<场景>/<名>/附件…`)不是 rules-import 的形状:如实跳过,不假装带过来了。
494
+ if (name.indexOf('/') >= 0 || !name) {
495
+ skipped += 1;
496
+ continue;
497
+ }
498
+ ;
499
+ (byScene[scene] || (byScene[scene] = [])).push({ name, data: Buffer.from(bytes).toString('base64') });
500
+ }
501
+ let imported = 0;
502
+ for (const scene of Object.keys(byScene)) {
503
+ const res = await deps.invokeOp('rules-import', { scene, files: byScene[scene] });
504
+ if (!res || res.ok === false) {
505
+ skipped += byScene[scene].length;
506
+ continue;
507
+ }
508
+ const data = res.data || res;
509
+ imported += (data.imported || []).length;
510
+ skipped += (data.skipped || []).length;
511
+ }
512
+ return { imported, skipped };
513
+ });
514
+ await run('scenes', async () => {
515
+ if (!files[F_SCENES])
516
+ return { imported: 0, skipped: 0 };
517
+ const parsed = JSON.parse(await readFile(files[F_SCENES], 'utf8'));
518
+ const archives = parsed.archives || {};
519
+ const records = {};
520
+ for (const r of (parsed.records || []))
521
+ if (r && r.name)
522
+ records[String(r.name)] = r;
523
+ let imported = 0;
524
+ let skipped = 0;
525
+ for (const scene of Object.keys(archives)) {
526
+ const rec = records[scene] || {};
527
+ const withPrompt = { name: scene, label: rec.label, description: rec.description, ...(rec.prompt ? { prompt: rec.prompt } : {}) };
528
+ let created = await deps.invokeOp('rules-create-scene', withPrompt);
529
+ if (!created || created.ok === false) {
530
+ // 提示词预设可能还没过来(或名字变了):那就先不带绑定建场景,档案照存。
531
+ if (rec.prompt)
532
+ created = await deps.invokeOp('rules-create-scene', { name: scene, label: rec.label, description: rec.description });
533
+ }
534
+ if ((!created || created.ok === false) && !/已存在|exist/i.test(String((created && created.error) || ''))) {
535
+ skipped += 1;
536
+ continue;
537
+ }
538
+ const saved = await deps.invokeOp('scene-archive-save', { scene, archive: archives[scene] });
539
+ if (saved && saved.ok)
540
+ imported += 1;
541
+ else
542
+ skipped += 1;
543
+ }
544
+ return { imported, skipped };
545
+ });
546
+ await run('settings', async () => {
547
+ if (!files[F_SETTINGS])
548
+ return { imported: 0, skipped: 0 };
549
+ const s = JSON.parse(await readFile(files[F_SETTINGS], 'utf8'));
550
+ let done = 0;
551
+ let skipped = 0;
552
+ const write = async (op, args, has) => {
553
+ if (!has) {
554
+ skipped += 1;
555
+ return;
556
+ }
557
+ const r = await deps.invokeOp(op, args);
558
+ if (r && r.ok !== false)
559
+ done += 1;
560
+ else
561
+ skipped += 1;
562
+ };
563
+ await write('tool-table', { set: true, hidden: (s.toolTable && s.toolTable.hidden) || [] }, !!s.toolTable);
564
+ await write('inject-settings', { set: true, ...(s.inject || {}) }, !!s.inject);
565
+ await write('scene-settings', { set: true, ...(s.sceneSettings || {}) }, !!s.sceneSettings);
566
+ return { imported: done, skipped };
567
+ });
568
+ await run(D_SKILLS, async () => {
569
+ const skillDir = join(dir, D_SKILLS);
570
+ let zips = [];
571
+ try {
572
+ zips = (await readdir(skillDir)).filter((n) => /\.zip$/i.test(n)).map((n) => join(skillDir, n));
573
+ }
574
+ catch {
575
+ return { imported: 0, skipped: 0 };
576
+ }
577
+ let imported = 0;
578
+ let skipped = 0;
579
+ for (const zip of zips) {
580
+ const res = await deps.invokeOp('skill-import', overwrite ? { source: zip, conflict: 'overwrite' } : { source: zip });
581
+ if (!res || res.ok === false)
582
+ throw new Error(String((res && res.error) || 'skill-import 失败'));
583
+ const data = res.data || {};
584
+ imported += (data.imported || []).length;
585
+ skipped += (data.skipped || []).length + (data.failed || []).length;
586
+ }
587
+ return { imported, skipped };
588
+ });
589
+ return { ok: true, dir, conflict: overwrite ? 'overwrite' : 'skip', report };
590
+ }
591
+ return {
592
+ 'snapshot-export': snapshotExport,
593
+ 'snapshot-preview': snapshotPreview,
594
+ 'snapshot-import': snapshotImport,
595
+ };
596
+ }
@@ -18,8 +18,29 @@ import { createHash } from 'node:crypto';
18
18
  import { isValidPresetId, LAST_APPLIED_PRESET_ID, normalizePresetId } from './preset-id.js';
19
19
  import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
20
20
  const FILENAME = 'AGENTS.md';
21
- /** 描述侧车:与 AGENTS.md 同目录,避免把「只给使用者看」的文字注入提示词。 */
21
+ /** 描述侧车:与 AGENTS.md 同目录,避免描述的文字被当成提示词正文注入。 */
22
22
  const META_FILE = 'meta.json';
23
+ /**
24
+ * 预设描述的字数上限。界面上那两枚输入框(提示词页的新建 / 编辑弹窗)用同一个数当
25
+ * `maxLength` 与字数计数的分母,客户端镜像在 `42-shared-ui.js` 的 `PRESET_DESC_MAX`
26
+ * —— 改这里要一起改它。
27
+ *
28
+ * 为什么需要它(0.14.0):描述此前是"只给使用者看"的,一个字都不进模型上下文,所以没有
29
+ * 上限也说得过去。现在 `prompt_manager_list` 会把它打给模型(否则模型无法在预设之间做
30
+ * 选择),它就成了**常驻成本**,必须有个预算。300 与 MCP 备注同数(那一条也是"给模型看的
31
+ * 一句用户提示"),但那是两个各自独立的预算,别当成同一个常量共用。
32
+ */
33
+ export const DEFAULT_PRESET_DESC_MAX_LENGTH = 300;
34
+ /**
35
+ * 压成一行并按上限截断。与 `normalizeMcpNote`(`../mcp/state-section.ts`)同形:两处都是
36
+ * "给模型看的一句用户文字",口径必须一致,否则同一句在界面、段、工具里会是三个样子。
37
+ */
38
+ export function normalizePresetDescription(value, maxLength = DEFAULT_PRESET_DESC_MAX_LENGTH) {
39
+ const flat = String(value ?? '').replaceAll(/\s+/g, ' ').trim();
40
+ if (!flat)
41
+ return '';
42
+ return flat.length <= maxLength ? flat : `${flat.slice(0, maxLength - 3)}...`;
43
+ }
23
44
  const LAST_APPLIED_ID = LAST_APPLIED_PRESET_ID;
24
45
  /**
25
46
  * 「最近一次应用」的记录侧车。是**文件**不是目录,所以 `list()` / `ensureInit()` 的
@@ -46,9 +67,11 @@ export function createPromptsService(_ctx, deps) {
46
67
  return null;
47
68
  }
48
69
  }
49
- // ── 描述(「只给使用者看」)────────────────────────────────────────────────
70
+ // ── 描述(给人看,也进模型清单)────────────────────────────────────────────
50
71
  // 存在预设目录的 `meta.json` 里,**不写进 AGENTS.md**:那个文件的正文会被原样注入
51
72
  // 系统提示词,把「这份预设是干什么的」写进去等于凭空给模型加了一段说明。
73
+ // 0.14.0 起另有一条通道会把它带给模型 —— `prompt_manager_list` 的清单行(模型要在预设
74
+ // 之间做选择,而 id 本身不说明用途),所以它有了 `DEFAULT_PRESET_DESC_MAX_LENGTH` 这条预算。
52
75
  // 读写都 best-effort:描述坏掉/写不进去不该让「保存预设」失败(正文才是本体)。
53
76
  async function readDescription(id) {
54
77
  try {