dsh-plugin-tool-management 0.16.6 → 0.18.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 (96) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +20 -4
  3. package/README_EN.md +20 -4
  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 +59 -0
  21. package/lib/audit-log.js +6 -2
  22. package/lib/client.js +1615 -148
  23. package/lib/compat/capability-specs.js +321 -0
  24. package/lib/compat/host-probe.js +261 -0
  25. package/lib/compat/host-roots.js +168 -0
  26. package/lib/compat/probe-types.js +6 -0
  27. package/lib/compat/probe.js +54 -714
  28. package/lib/context-inject-contract.js +114 -0
  29. package/lib/context-inject-frame.js +147 -0
  30. package/lib/context-inject-select.js +127 -0
  31. package/lib/context-inject-surface.js +97 -0
  32. package/lib/context-inject.js +57 -459
  33. package/lib/host-names.js +150 -0
  34. package/lib/http-api.js +229 -0
  35. package/lib/hub.js +84 -11
  36. package/lib/imports/parsers.js +40 -15
  37. package/lib/index.js +235 -843
  38. package/lib/mcp/inspect-probe.js +199 -0
  39. package/lib/mcp/loader-token.js +26 -0
  40. package/lib/mcp/manager.js +267 -558
  41. package/lib/mcp/patch-yaml.js +207 -40
  42. package/lib/mcp/secret-guard.js +122 -16
  43. package/lib/mcp/tool-visibility.js +388 -0
  44. package/lib/memories/archive-engine.js +163 -16
  45. package/lib/memories/archive.js +10 -1
  46. package/lib/memories/discovery.js +261 -0
  47. package/lib/memories/export-plan.js +42 -0
  48. package/lib/memories/index-io.js +125 -12
  49. package/lib/memories/memories-types.js +1 -0
  50. package/lib/memories/service.js +167 -78
  51. package/lib/memories/snapshot.js +41 -260
  52. package/lib/op-registry.js +72 -8
  53. package/lib/ops/compat.js +17 -2
  54. package/lib/ops/prompts.js +18 -5
  55. package/lib/ops/quick-prompts.js +26 -0
  56. package/lib/ops/scene-records.js +157 -10
  57. package/lib/ops/scene-sync.js +71 -1
  58. package/lib/ops/sessions.js +63 -13
  59. package/lib/ops/snapshot.js +123 -24
  60. package/lib/ops/trash.js +45 -9
  61. package/lib/prompts/quick-service.js +211 -0
  62. package/lib/prompts/service.js +7 -2
  63. package/lib/request-gate.js +115 -56
  64. package/lib/scene-linkage.js +184 -0
  65. package/lib/scene-prompt-sync.js +46 -7
  66. package/lib/scenes/candidates.js +21 -3
  67. package/lib/sessions/archive-schemas.js +121 -0
  68. package/lib/sessions/path-key.js +46 -0
  69. package/lib/sessions/workspace.js +45 -168
  70. package/lib/settings-sidecars.js +143 -0
  71. package/lib/skills/core-types.js +8 -0
  72. package/lib/skills/core-util.js +81 -0
  73. package/lib/skills/core.js +97 -2297
  74. package/lib/skills/dir-watch.js +71 -0
  75. package/lib/skills/frontmatter.js +96 -0
  76. package/lib/skills/homes.js +14 -0
  77. package/lib/skills/import-pipeline.js +231 -0
  78. package/lib/skills/manager-state.js +800 -0
  79. package/lib/skills/official.js +269 -0
  80. package/lib/skills/provider-registration.js +89 -0
  81. package/lib/skills/service.js +4 -234
  82. package/lib/skills/skill-roots.js +287 -0
  83. package/lib/skills/skill-trash.js +265 -0
  84. package/lib/skills/system-trash.js +97 -0
  85. package/lib/skills/upload-pipeline.js +374 -0
  86. package/lib/slash-settings.js +25 -0
  87. package/lib/subagents/persona-parse.js +312 -0
  88. package/lib/subagents/persona-types.js +5 -0
  89. package/lib/subagents/persona-write.js +93 -0
  90. package/lib/subagents/run-provider.js +159 -0
  91. package/lib/subagents/service.js +20 -639
  92. package/lib/subagents/tool-filter.js +98 -0
  93. package/lib/token-config.js +240 -0
  94. package/lib/tool-table-switch.js +163 -0
  95. package/lib/tools/scene.js +40 -9
  96. package/package.json +3 -2
@@ -4,13 +4,28 @@
4
4
  // 提示词改绑。动的是 index 的 scenes / active 切片与 AGENTS.md 投影,与记忆正文 ops 互不调用。
5
5
  import { mkdir, readdir, rename, rm } from 'node:fs/promises';
6
6
  import { join } from 'node:path';
7
- import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
7
+ import { listTrashEntries, moveIntoTrash, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
8
8
  import { MAX_DESCRIPTION_LENGTH, DEFAULT_GROUP_ORDER, GLOBAL_SCENE, GLOBAL_SCENE_LABEL, SHARED_GROUP, SEGMENT_RULE_HINT, isValidGroupSegment, isValidGroupPath, message, fail } from '../memories/constants.js';
9
9
  import { normalizeActive, collapseActiveForNewScene } from '../memories/projection.js';
10
10
  import { readIndex, writeIndex, sceneRecordOf, normalizeScenePromptId, pathExists } from '../memories/index-io.js';
11
+ /** `quickPrompts` 参数的三种形态:不传 = 完全不碰这一域;null / 非数组 = 解绑(删字段);
12
+ * 数组 = 勾选集(去重去空)。**空数组是合法值**(= 进入后全部停用),不能当成"没绑"。 */
13
+ function quickPromptsArg(args) {
14
+ if (!args || args.quickPrompts === undefined)
15
+ return undefined;
16
+ if (!Array.isArray(args.quickPrompts))
17
+ return null;
18
+ const out = [];
19
+ for (const item of args.quickPrompts) {
20
+ const id = String(item == null ? '' : item).trim();
21
+ if (id && out.indexOf(id) < 0)
22
+ out.push(id);
23
+ }
24
+ return out;
25
+ }
11
26
  /** 组装本域 op。rc 由 createMemoriesService 组装,契约见 ./ctx.ts。 */
12
27
  export function buildSceneRecordOps(rc) {
13
- const { ensureLayout, invalidateSnapshot, presetExistsSync, memoriesRoot, sceneRows, snapshot, stateDir } = rc;
28
+ const { ensureLayout, invalidateSnapshot, presetExistsSync, memoriesRoot, refuseOutsideRoot, sceneRows, snapshot, stateDir } = rc;
14
29
  /**
15
30
  * 设置"启用场景"(全局持久化,切换后**下一个请求即生效**,§4 对照表)。
16
31
  * - `args.scenes` 数组 → 启用集合;**至多一个非保留场景**(用户裁定:除「全局」外
@@ -39,7 +54,13 @@ export function buildSceneRecordOps(rc) {
39
54
  // 恒常启用的保留场景不入显式集合:`_shared`(公共基线)与 `global`(「全局」)。
40
55
  if (name === '' || name === SHARED_GROUP || name === GLOBAL_SCENE)
41
56
  continue;
42
- if (!isValidGroupPath(name)) {
57
+ // 启用集合里的场景名必须是**单个路径段**(2026-09-30 审查 P2-4)。此前这里走
58
+ // `isValidGroupPath`,于是 `a/b` 这种多段名**通过校验并被写进 `active`**,但它压根
59
+ // 不是一个场景:场景恒等于一级目录(见 `snapshot.ts` 的 `depth === 1` 与
60
+ // `projection.ts` 的 `sceneOf`),注入时桶名是 `a`、`active` 里写的是 `a/b` ——
61
+ // 两边对不上,用户看到的是"开关点了、场景没生效",且**没有任何报错**。
62
+ // 改成单段后这种名字会当场被拒(响亮地失败),与创建/改名的口径一致。
63
+ if (!isValidGroupSegment(name)) {
43
64
  return fail('error.rules.invalidGroup', `场景名非法:${name}(${SEGMENT_RULE_HINT})`);
44
65
  }
45
66
  if (seen.has(name))
@@ -63,8 +84,14 @@ export function buildSceneRecordOps(rc) {
63
84
  /**
64
85
  * 新建场景:写一条场景记录(索引 scenes 切片)+ 建空目录 `memories/<场景>/`。
65
86
  * 场景是**显式实体**——空场景(还没有记忆)也是合法场景,会出现在场景列表里。
66
- * 场景名是**单个路径段**(不允许 `a/b`):界面把场景当一级列表展示,
67
- * 允许多段只会让「记忆的场景」与「目录层级」两套语义互相打架。
87
+ *
88
+ * 场景名的统一口径(2026-09-30 审查 P2-4 收敛,此前全仓分成两派):
89
+ * - **命名路径**(新建 / 改名目标 / 启用集合)一律是**单个路径段**。场景恒等于一级目录
90
+ * (见 `snapshot.ts` 的 `depth === 1` 与 `projection.ts` 的 `sceneOf`),允许 `a/b`
91
+ * 只会让「场景」与「场景内的分组」两套语义互相打架,而且启用时桶名对不上(静默失效)。
92
+ * - **定位路径**(改名的旧名 / 锁定 / 删除 / 回收站恢复)按**完整路径**寻址,`isValidGroupPath`。
93
+ * 历史与导入留下的多段记录因此仍能被改名或删除 —— 否则它们成了"看得见、动不了"的死条目。
94
+ *
68
95
  * 幂等:已存在则更新描述/标签,不报错。`_shared` 与 `global` 是保留名。
69
96
  */
70
97
  async function rulesCreateScene(args) {
@@ -120,6 +147,14 @@ export function buildSceneRecordOps(rc) {
120
147
  index.archives = {};
121
148
  index.archives[name] = { ...(index.archives[name] || {}), toolTablePreset };
122
149
  }
150
+ // 快捷提示词勾选集(0.18.0):同样存进**档案**。空数组也要落 —— 它说的是"进入后全部停用",
151
+ // 与"没绑"(不碰这一域)是相反的意思。
152
+ const quickCreate = quickPromptsArg(args);
153
+ if (quickCreate) {
154
+ if (!index.archives)
155
+ index.archives = {};
156
+ index.archives[name] = { ...(index.archives[name] || {}), quickPrompts: quickCreate };
157
+ }
123
158
  await writeIndex(stateDir, index);
124
159
  invalidateSnapshot();
125
160
  return { ok: true, scene: sceneRecordOf(name, next), ...(collapsed ? { collapsedActive: true } : {}) };
@@ -192,6 +227,23 @@ export function buildSceneRecordOps(rc) {
192
227
  else
193
228
  delete index.archives[name];
194
229
  }
230
+ if (args && args.quickPrompts !== undefined) {
231
+ // 快捷提示词勾选集(0.18.0,用户裁定入口在「修改场景」表单):与工具表方案同一族 ——
232
+ // 绑定存**档案**,这一次读-改-写顺带落掉。`null` = 解绑(这一域回到"保持现状");
233
+ // 空数组 = 绑定了但一条都没勾(进入后全部停用),**不是**解绑。不传这个参数 = 完全不碰。
234
+ const quick = quickPromptsArg(args);
235
+ if (!index.archives)
236
+ index.archives = {};
237
+ const archive = { ...((index.archives && index.archives[name]) || {}) };
238
+ if (quick === null)
239
+ delete archive.quickPrompts;
240
+ else if (quick !== undefined)
241
+ archive.quickPrompts = quick;
242
+ if (Object.keys(archive).length > 0)
243
+ index.archives[name] = archive;
244
+ else
245
+ delete index.archives[name];
246
+ }
195
247
  if (args && args.label !== undefined && name !== GLOBAL_SCENE) {
196
248
  const label = String(args.label).trim();
197
249
  if (label === '')
@@ -249,6 +301,18 @@ export function buildSceneRecordOps(rc) {
249
301
  index.archives[nextRaw] = index.archives[name];
250
302
  delete index.archives[name];
251
303
  }
304
+ // 该场景下的**记忆条目**也要跟着换前缀(2026-09-30 审查 P1-1)。不迁的话:改名后
305
+ // 旧键 `<旧场景>/<名>` 在磁盘上找不到对应文件,`buildSnapshot` 的清理会把它删掉
306
+ // (见 `memories/snapshot.ts` 的"索引有记录但文件已删"分支)→ 那条记忆失去索引条目
307
+ // → `projectRule` 的 `enabled ?? true` 把它**重新启用** —— 用户显式停用的记忆复活,
308
+ // 且 order / tags / pinned / note 一并丢失。记忆自己的改名路径(`ops/memory.ts`)
309
+ // 一直是迁索引的,场景这条漏了同一件事。
310
+ // 只换前缀、原值不动;`nextRaw` 的场景名已被上面的占用检查保证为空,不会覆盖。
311
+ const sceneRules = index.rules || {};
312
+ for (const id of Object.keys(sceneRules).filter((k) => k.startsWith(name + '/'))) {
313
+ sceneRules[nextRaw + id.slice(name.length)] = sceneRules[id];
314
+ delete sceneRules[id];
315
+ }
252
316
  const act = normalizeActive(index.active);
253
317
  if (act !== null && act.indexOf(name) >= 0)
254
318
  index.active = act.map((n) => (n === name ? nextRaw : n));
@@ -308,11 +372,15 @@ export function buildSceneRecordOps(rc) {
308
372
  /**
309
373
  * 删除场景:**仅空目录可删**(避免一次操作带走整组记忆)。
310
374
  * 同时删掉场景记录、把它从 `active` 集合里摘掉、清掉它的档案,避免悬空引用。
311
- * 保留场景 `global` 不可删除;场景名口径与创建一致(单个路径段)。
375
+ * 保留场景 `global` 不可删除。
376
+ *
377
+ * 场景名在这里按**完整路径**寻址(`isValidGroupPath`,2026-09-30 审查 P2-4):命名路径
378
+ * (创建 / 改名目标 / 启用集合)一律要求单段,而历史与导入留下的多段记录得能**被清掉** ——
379
+ * 否则它就成了"看得见、动不了"的死条目。删除只是定位 + 移入回收站,多段名不会造成歧义。
312
380
  */
313
381
  async function rulesRemoveScene(args) {
314
382
  const name = String((args && args.name) || '').trim();
315
- if (!isValidGroupSegment(name))
383
+ if (!isValidGroupPath(name))
316
384
  return fail('error.rules.invalidGroup', `场景名非法:${name || '(空)'}`);
317
385
  if (name === SHARED_GROUP)
318
386
  return fail('error.rules.invalidGroup', `_shared 是保留场景名,不可删除`);
@@ -333,6 +401,15 @@ export function buildSceneRecordOps(rc) {
333
401
  // 但一律**先移入回收站**(与场景记录、档案装在同一条条目里),所以「删错了」还能整条恢复:
334
402
  // 记忆文件按原来的相对路径放回,场景记录与档案也一起回来。
335
403
  const sceneDir = join(memoriesRoot, name);
404
+ // 删除前先确认落点仍在根内(realpath 口径)—— 与 `rulesRemove` / `rulesRestore` 同一道闸。
405
+ // 为什么删除侧最需要它(2026-09-30 审查 P1-2,已实测复现):`memories/<场景>` 若是指向根外的
406
+ // 目录链接(用户手工建、或旧布局迁移带进来的),下面 `collect` 的 `readdir` 会**列出链接目标
407
+ // 的内容**,于是 `from` 拼成 `<memoriesRoot>/<场景>/x.md` 看着在根内、`rename` 实际作用于
408
+ // **根外的真实文件** —— 删除场景会把 `D:\重要数据` 里的文件搬进回收站。`isValidGroupSegment`
409
+ // 挡不住(名字本身合法),`lstat` 也挡不住(只看末级)。
410
+ const deniedDir = await refuseOutsideRoot(sceneDir);
411
+ if (deniedDir)
412
+ return deniedDir;
336
413
  const moves = [];
337
414
  const collect = async (abs, rel) => {
338
415
  let items = [];
@@ -343,6 +420,12 @@ export function buildSceneRecordOps(rc) {
343
420
  return; // 目录不存在(只有记录的场景)或读不了 → 当作没有文件
344
421
  }
345
422
  for (const item of items) {
423
+ // 跳过符号链接(含 Windows junction),与 `snapshot.ts` 的 `discover` 同口径。
424
+ // 上面那道 realpath 断言挡的是「场景目录本身是链接」;这一条挡的是「场景目录内的某一项是
425
+ // 链接」—— 两者是不同的路径,缺一条就漏一条。链接本身也不搬走(它是环境相关的,
426
+ // 不该被回收站带到另一台机器上),留给后面的 `rm` 清掉。
427
+ if (item.isSymbolicLink())
428
+ continue;
346
429
  const childAbs = join(abs, item.name);
347
430
  const childRel = rel === '' ? item.name : `${rel}/${item.name}`;
348
431
  if (item.isDirectory())
@@ -352,10 +435,19 @@ export function buildSceneRecordOps(rc) {
352
435
  }
353
436
  };
354
437
  await collect(sceneDir, '');
438
+ // 该场景下每条记忆的索引条目(enabled / order / tags / pinned / note)一并收进回收站条目
439
+ // 的 `data`(2026-09-30 审查 P1-1)。不带的话:恢复出来的记忆全部按 `enabled ?? true`
440
+ // 重置 —— 用户停用的会复活,其余元数据归零。文件在回收站、元数据也得跟着在。
441
+ const sceneRuleSubset = {};
442
+ for (const id of Object.keys(index.rules || {})) {
443
+ if (id.startsWith(name + '/'))
444
+ sceneRuleSubset[id] = index.rules[id];
445
+ }
355
446
  const trashed = await moveToTrash('scenes', name, moves, {
356
447
  record: (index.scenes && index.scenes[name]) || null,
357
448
  archive: (index.archives && index.archives[name]) || null,
358
449
  memories: moves.map((m) => m.dest),
450
+ rules: sceneRuleSubset,
359
451
  });
360
452
  if (trashed.ok === false) {
361
453
  return fail('error.rules.ioFailed', `移入回收站失败:${trashed.error}`);
@@ -366,11 +458,21 @@ export function buildSceneRecordOps(rc) {
366
458
  }
367
459
  catch (e) {
368
460
  // 目录没清掉就把记忆放回去:宁可整个操作失败,也不要「文件在回收站、场景还留在列表里」。
461
+ let rollbackFailed = 0;
369
462
  for (const move of moves) {
370
463
  try {
371
464
  await moveOutOfTrash('scenes', trashed.id, move.dest, move.from);
372
465
  }
373
- catch { /* 尽力而为 */ }
466
+ catch {
467
+ rollbackFailed += 1;
468
+ }
469
+ }
470
+ // **回滚没做完就绝不 purge**:purge 会把条目整目录删掉,而没能放回的那些文件还在里面
471
+ // —— 那是一次静默的**永久删除**(用户记忆),且场景目录还留在原地(一个被掏空的场景)。
472
+ // 宁可留一条脏条目(界面看得见、能再点一次恢复),也不要"删干净"(审查 P0-2)。
473
+ if (rollbackFailed > 0) {
474
+ return fail('error.rules.ioFailed', `删除场景目录失败:${message(e)};另有 ${rollbackFailed} 个记忆文件没能放回原位,`
475
+ + `它们仍在回收站条目 ${trashed.id} 里(未删除),可从回收站恢复`);
374
476
  }
375
477
  try {
376
478
  await purgeTrashEntry('scenes', trashed.id);
@@ -392,6 +494,17 @@ export function buildSceneRecordOps(rc) {
392
494
  delete index.archives[name];
393
495
  dirty = true;
394
496
  }
497
+ // 记忆条目也跟着进了回收站(见上面的 `sceneRuleSubset`)→ 索引里同步删掉。不删的话会留下
498
+ // "索引有记录、磁盘没文件"的悬空键:`buildSnapshot` 下一轮也会清掉,但中间那一轮界面会
499
+ // 把它显示成"这条记忆不存在",且恢复时 `data.rules` 合并回来会与残留键打架。显式删干净。
500
+ if (index.rules) {
501
+ for (const id of Object.keys(index.rules)) {
502
+ if (id.startsWith(name + '/')) {
503
+ delete index.rules[id];
504
+ dirty = true;
505
+ }
506
+ }
507
+ }
395
508
  if (dirty)
396
509
  await writeIndex(stateDir, index);
397
510
  invalidateSnapshot();
@@ -404,6 +517,9 @@ export function buildSceneRecordOps(rc) {
404
517
  /**
405
518
  * 从回收站恢复一个场景:重建记忆目录 + 写回记录与档案。
406
519
  * 同名场景已存在时**拒绝**(绝不覆盖既有场景)。
520
+ *
521
+ * 场景名按**完整路径**寻址(2026-09-30 审查 P2-4,与删除侧同口径):回收站里的名字来自
522
+ * 当初删掉的那条记录,多段名同样得能恢复回来 —— 否则删得掉、恢复不了,数据就卡在回收站里。
407
523
  */
408
524
  async function sceneTrashRestore(args) {
409
525
  const id = String((args && args.id) || '').trim();
@@ -411,12 +527,18 @@ export function buildSceneRecordOps(rc) {
411
527
  if (!entry)
412
528
  return fail('error.rules.notFound', `回收站条目不存在:${id}`);
413
529
  const name = String(entry.name || '').trim();
414
- if (!isValidGroupSegment(name))
530
+ if (!isValidGroupPath(name))
415
531
  return fail('error.rules.invalidGroup', `回收站里的场景名非法:${name || '(空)'}`);
416
532
  const index = await readIndex(stateDir);
417
533
  if (index.scenes?.[name])
418
534
  return fail('error.rules.invalidGroup', `无法恢复,同名场景已存在:${name}`);
419
535
  const dir = join(memoriesRoot, name);
536
+ // 恢复是**写入**方向:落点若是指向根外的目录链接,`mkdir` 会顺着链接成功、随后
537
+ // `moveOutOfTrash` 把记忆文件写到**根外**去。与删除侧同一个根因(缺 realpath 根内断言),
538
+ // 2026-09-30 审查 P1-2 的相邻面 —— 修一处必须一起修,否则只是把同一个洞挪了个方向。
539
+ const deniedDir = await refuseOutsideRoot(dir);
540
+ if (deniedDir)
541
+ return deniedDir;
420
542
  let entries = [];
421
543
  try {
422
544
  entries = await readdir(dir);
@@ -443,13 +565,38 @@ export function buildSceneRecordOps(rc) {
443
565
  restored.push(String(rel));
444
566
  }
445
567
  catch (e) {
446
- return fail('error.rules.ioFailed', `恢复记忆文件失败(已放回 ${restored.length}/${files.length}):${message(e)}`);
568
+ // 已放回的文件**搬回回收站**再报错:原来这里是直接 return,已放回的那几个留在记忆目录里
569
+ // —— 下一次点恢复会撞上面那条「目录里已有内容」的检查,于是这个场景**永远恢复不了**
570
+ // (审查 P0-2)。回滚失败也如实说清楚,别让用户以为还能原样重试。
571
+ let backFailed = 0;
572
+ for (const doneRel of [...restored].reverse()) {
573
+ try {
574
+ await moveIntoTrash('scenes', id, join(dir, doneRel), doneRel);
575
+ }
576
+ catch {
577
+ backFailed += 1;
578
+ }
579
+ }
580
+ const tail = backFailed === 0
581
+ ? '(已放回的记忆文件已退回回收站,可重试)'
582
+ : `(另有 ${backFailed} 个文件没能退回回收站,它们现在在记忆目录「${name}」里,请先手工处理再重试)`;
583
+ return fail('error.rules.ioFailed', `恢复记忆文件失败:${message(e)}${tail}`);
447
584
  }
448
585
  }
449
586
  if (data.record && typeof data.record === 'object')
450
587
  index.scenes = { ...(index.scenes || {}), [name]: data.record };
451
588
  if (data.archive && typeof data.archive === 'object')
452
589
  index.archives = { ...(index.archives || {}), [name]: data.archive };
590
+ // 记忆条目随文件一起回来(删除时存进 `data.rules`,见 `rulesRemoveScene`)——
591
+ // 否则恢复出来的记忆全部按 `enabled ?? true` 重置,用户停用的会复活(审查 P1-1)。
592
+ // **只补不覆盖**:同名键若已存在以现有为准(上面已拒绝同名场景,正常情况下不会撞)。
593
+ if (data.rules && typeof data.rules === 'object' && !Array.isArray(data.rules)) {
594
+ const rules = index.rules || (index.rules = {});
595
+ for (const [ruleId, ruleEntry] of Object.entries(data.rules)) {
596
+ if (rules[ruleId] === undefined)
597
+ rules[ruleId] = ruleEntry;
598
+ }
599
+ }
453
600
  await writeIndex(stateDir, index);
454
601
  await purgeTrashEntry('scenes', id);
455
602
  invalidateSnapshot();
@@ -2,6 +2,56 @@
2
2
  //
3
3
  // rules 域的**其余 op 由 ./memories/service.ts 直接 spread 进表**,不经过本文件 —— 这里只放
4
4
  // 四个要走「场景 ↔ 全局基线同步」包装的写 op。
5
+ import { GLOBAL_SCENE, SHARED_GROUP } from '../memories/constants.js';
6
+ /** 参数形态不归本层管(多场景 / 非法名 / `all`):交给原 op 校验并报错,运行时一律不动。 */
7
+ const SKIP = Symbol('skip');
8
+ /**
9
+ * 「启用集合」与「当前模式」是同一个意图的两片状态:`active` 决定记忆与绑定的提示词按哪个
10
+ * 场景注入,`mode.scene` 决定运行时(MCP / 技能 / 人设 / 工具表 / 快捷词)停在哪份档案上。
11
+ * 两者必须恒等 —— 历史上每次只有一个动,就出现过一个方向各坏一次:拨开关不应用档案(面板
12
+ * 0.9.x 的旧账,见 45-scenes.js 的 ⚠️ 注释),以及 `/` 菜单进 / 出场景只写 `active`
13
+ * (用户 2026-10-01 报,图见 CHANGELOG)。
14
+ *
15
+ * 所以这里**在服务端兜住这条不变量**,而不是要求每个调用方各开两枪:`rules-set-active` 是
16
+ * "我要让哪个场景生效"的那个 op,四个调用方(面板开关、`/` 菜单、模型工具、以后新增的入口)
17
+ * 只要调它,运行时就会跟着对齐。
18
+ *
19
+ * 顺序是**先切运行时、后写启用集合**:引擎会校验场景存在、没被锁、绑的工具表方案还在,
20
+ * 它拒绝时启用集合必须一个字都不动。反过来(先写 active)留下的正是"开关过去了、运行时没切"
21
+ * 那个半截状态 —— 也就是这一版要消灭的东西。
22
+ */
23
+ async function syncModeToActive(args, deps) {
24
+ const want = targetSceneOf(args);
25
+ if (want === SKIP)
26
+ return null;
27
+ if (want === (await deps.currentModeScene()))
28
+ return null;
29
+ return deps.sceneModeSet({ scene: want });
30
+ }
31
+ /**
32
+ * 从 `scenes:[...]` 里取出"这一次要让哪个场景生效"(`null` = 一个都不让 = 退出模式)。
33
+ *
34
+ * 保留名的过滤与去重和 `rulesSetActive` **同源**(同一份常量,不是抄一份):`_shared` 与
35
+ * `global` 恒常生效,不进显式集合 —— 所以 `['global','_shared']` 与原 op 一样读成"清空"。
36
+ * 收敛后仍多于一个 = 单选模型下的非法请求,原 op 会拒,这里回 SKIP 让运行时一动不动
37
+ * (宁可少切一次,也不要按一个原 op 马上要拒绝的参数去改运行时)。
38
+ */
39
+ function targetSceneOf(args) {
40
+ if (args && args.all === true)
41
+ return SKIP;
42
+ const raw = args && args.scenes;
43
+ if (!Array.isArray(raw))
44
+ return SKIP;
45
+ const names = [];
46
+ for (const item of raw) {
47
+ const name = String(item == null ? '' : item).trim();
48
+ if (!name || name === SHARED_GROUP || name === GLOBAL_SCENE)
49
+ continue;
50
+ if (names.indexOf(name) < 0)
51
+ names.push(name);
52
+ }
53
+ return names.length > 1 ? SKIP : (names[0] ?? null);
54
+ }
5
55
  export function buildSceneSyncOps(deps) {
6
56
  return {
7
57
  // 场景 ↔ 全局基线(AGENTS.md)同步:用户裁定「切换场景,对应的提示词直接把 AGENTS.md
@@ -9,7 +59,20 @@ export function buildSceneSyncOps(deps) {
9
59
  // 提示词写进 `~/.dsh/AGENTS.md`(或关掉场景时恢复进场景前的基线),结果并进响应
10
60
  // 的 `agentsMd` 字段(`applied` / `restored` / `unchanged` / `error`)。
11
61
  // 组装时放在 memoriesService.ops 的 spread **之后**,显式覆盖同名 op。
12
- 'rules-set-active': async (args) => deps.withAgentsMdSync(await deps.rulesOps['rules-set-active'](args)),
62
+ //
63
+ // `rules-set-active` 额外承担「两轴恒等」(见文件头):先把运行时切到位,再写启用集合。
64
+ 'rules-set-active': async (args) => {
65
+ const mode = await syncModeToActive(args, deps);
66
+ if (mode && mode.ok === false)
67
+ return mode;
68
+ const res = await deps.rulesOps['rules-set-active'](args);
69
+ if (!res || res.ok === false)
70
+ return res;
71
+ // 引擎跳过的档案键(本机已不存在的服务器 / 技能键)并进来给调用方 —— 面板与 `/` 都要
72
+ // 那句"进了,但没全进"。
73
+ const stale = mode && Array.isArray(mode.stale) && mode.stale.length ? mode.stale : null;
74
+ return deps.withAgentsMdSync(stale ? Object.assign({}, res, { modeStale: stale }) : res);
75
+ },
13
76
  'rules-update-scene': async (args) => {
14
77
  const res = await deps.rulesOps['rules-update-scene'](args);
15
78
  // 工具表方案绑定改动(0.15.0):改的是**当前启用场景**且这次真的带了这一参数时即时生效;
@@ -20,6 +83,13 @@ export function buildSceneSyncOps(deps) {
20
83
  }
21
84
  catch { /* 即时生效失败不拦保存;下次进/退场景会对齐 */ }
22
85
  }
86
+ // 快捷提示词勾选集同理:只有这次真的带了这一参数才动(不传 = 不碰这一域)。
87
+ if (res && res.ok !== false && deps.onQuickPromptsChanged && args && args.quickPrompts !== undefined) {
88
+ try {
89
+ await deps.onQuickPromptsChanged(String(args.name || ''), Array.isArray(args.quickPrompts) ? args.quickPrompts.map((x) => String(x)) : null);
90
+ }
91
+ catch { /* 同上 */ }
92
+ }
23
93
  return deps.withAgentsMdSync(res);
24
94
  },
25
95
  'rules-create-scene': async (args) => deps.withAgentsMdSync(await deps.rulesOps['rules-create-scene'](args)),
@@ -220,9 +220,47 @@ export function buildSessionOps(deps) {
220
220
  const sessionId = String((args && args.sessionId) || '').trim();
221
221
  if (!sessionId)
222
222
  return { ok: false, error: '缺少 sessionId' };
223
+ // 范围收敛(2026-09-30 审查 P1-7):单删也只允许删**已归档**的会话 —— 与批量路径
224
+ // (`deleteArchivedSessions` → `archivedSessionIdsForTarget` 的四种 scope 全部与
225
+ // `archivedSessionIds` 求交)同一口径,也与界面对齐(`history-list` 只回已归档会话,
226
+ // 删除按钮只出现在那份列表里)。原先这里直接 `registry.deleteSession(sessionId)`,而
227
+ // `deleteLocalSession` 只问「这个 id 在持久化里存在吗」—— 于是一个客户端 bug 或一次
228
+ // 手工请求就能**永久删除任意已持久化会话**,包括未归档的和当前正开着的。
229
+ // 判据必须是**权威归档集合**,不是摘要投影(2026-09-30 审查 F9)。
230
+ // `archivedSessionMetadata()` 是**有损**的:逐条 try,读不到 header 或 `createdAt`
231
+ // 非有限就 `continue` 且不抛(`workspace.ts` 的 `archivedSessionMetadata`)。拿它的
232
+ // `items` 拼门禁集合,就会把「确实已归档、只是头部暂时读不到」的会话判成未归档 →
233
+ // 回一句「只能删除已归档的会话:请先归档,再删除」——**诊断是错的**(真因是读失败)。
234
+ // 而批量路径(`deleteArchivedSessions` → `archivedSessionIdsForTarget`)用的是
235
+ // `requireState().archivedSessionIds`,同一份数据两条路给出相反结论。
236
+ // 这里改成与批量路径同源;`requireState` 缺失(旧宿主)才退回投影 —— 投影只会**更小**,
237
+ // 方向仍是 fail-closed(宁可这次删不掉,也不要凭未知状态做不可逆操作)。
238
+ let archived;
223
239
  try {
224
- await registry.deleteSession(sessionId);
225
- return { ok: true, sessionId, deleted: true };
240
+ const authoritative = typeof registry.requireState === 'function' ? registry.requireState() : undefined;
241
+ if (authoritative && Array.isArray(authoritative.archivedSessionIds)) {
242
+ archived = new Set(authoritative.archivedSessionIds.map((id) => String(id)));
243
+ }
244
+ else {
245
+ const meta = await registry.archivedSessionMetadata();
246
+ const items = meta && Array.isArray(meta.items) ? meta.items : [];
247
+ archived = new Set(items.map((i) => String(i && i.sessionId)));
248
+ }
249
+ }
250
+ catch (e) {
251
+ // 查不到归档状态 → **拒绝删除**(fail-closed):宁可这次删不掉,也不要凭一个未知状态
252
+ // 去执行不可逆操作。
253
+ return { ok: false, error: '无法确认会话的归档状态,已拒绝删除:' + deps.message(e) };
254
+ }
255
+ if (!archived.has(sessionId)) {
256
+ return { ok: false, error: '只能删除已归档的会话:请先归档,再删除' };
257
+ }
258
+ try {
259
+ const r = await registry.deleteSession(sessionId);
260
+ // 级联只吃已归档的子会话(见 `deleteDescendants`):未归档的会被**保留**,
261
+ // 这里如实带出去,界面/模型才不会以为「这一支已经全没了」。
262
+ const kept = (r && Array.isArray(r.keptUnarchivedDescendants)) ? r.keptUnarchivedDescendants : [];
263
+ return { ok: true, sessionId, deleted: true, ...(kept.length ? { keptUnarchivedDescendants: kept } : {}) };
226
264
  }
227
265
  catch (e) {
228
266
  return { ok: false, error: deps.message(e) };
@@ -294,8 +332,9 @@ export function buildSessionOps(deps) {
294
332
  if (!fileName || !content.trim())
295
333
  return { ok: false, error: '缺少文件内容' };
296
334
  let turns;
335
+ let format;
297
336
  try {
298
- const format = detectFormat(fileName, content);
337
+ format = detectFormat(fileName, content);
299
338
  turns = format === 'jsonl' ? parseJsonlTranscript(content)
300
339
  : format === 'markdown' ? parseMarkdownTranscript(content)
301
340
  : parseGenericText(content);
@@ -305,6 +344,13 @@ export function buildSessionOps(deps) {
305
344
  }
306
345
  if (!turns.length)
307
346
  return { ok: false, error: '未能从该文件中识别出对话内容' };
347
+ // 「折成一条 user 消息」这件事本身要出声(2026-09-30 审查 F12)。
348
+ // 嗅探(`detectFormat`)已经能救回「JSONL / Markdown 被改名成 .txt」那一种,但**真的**
349
+ // 没有说话人标记的文本仍会走 generic:多行内容全塞进 1 条 user turn、结构全失,
350
+ // 而 op 此前回的是 `{ok:true,count:1}` —— 零警告。这里把它标出来(`lossy` 给界面出
351
+ // 本地化文案,`warning` 给模型看原因原文)。
352
+ const lossy = format === 'generic' && turns.length === 1
353
+ && content.split(/\r?\n/).filter((line) => line.trim()).length >= 5;
308
354
  if (typeof deps.createSession !== 'function')
309
355
  return { ok: false, error: '当前环境不支持创建会话(agents.create 不可用)' };
310
356
  // 仅接受绝对路径的 cwd;非法或缺失时会话不带目录(归入未分组)。
@@ -334,7 +380,7 @@ export function buildSessionOps(deps) {
334
380
  if (live === undefined || live === null) {
335
381
  return { ok: false, error: `创建会话失败:宿主未登记该会话(${id}),未导入任何内容` };
336
382
  }
337
- return { ok: true, sessionId: id, count: turns.length };
383
+ return { ok: true, sessionId: id, count: turns.length, ...(lossy ? { format, lossy: true, warning: '整份文本没有识别出任何说话人标记(如 User: / Assistant:),已折成一条用户消息:原对话的分轮结构可能已丢失。若源文件是 JSONL 或 Markdown 转录稿,请按原扩展名(.jsonl / .md)重新导入。' } : {}) };
338
384
  }
339
385
  catch (e) {
340
386
  return { ok: false, error: '创建会话失败: ' + deps.message(e) };
@@ -346,15 +392,15 @@ export function buildSessionOps(deps) {
346
392
  // 打包用已在依赖里的 `fflate.zipSync`(此前只用了 unzipSync)。
347
393
  'bundle-export': async (args) => {
348
394
  const kind = String((args && args.kind) || '');
349
- const KINDS = ['skills', 'subagents', 'presets', 'memories'];
395
+ const KINDS = ['skills', 'subagents', 'presets', 'quick-prompts', 'memories'];
350
396
  if (KINDS.indexOf(kind) < 0)
351
397
  return { ok: false, error: `kind 需为 ${KINDS.join(' / ')}` };
352
398
  const rawNames = (args && args.names) || [];
353
399
  const names = Array.isArray(rawNames) ? rawNames.map((s) => String(s).trim()).filter(Boolean) : [];
354
400
  if (!names.length)
355
401
  return { ok: false, error: '请至少选择一项' };
356
- // 名字逐项校验:子智能体与提示词这两个域的名字会被拼成绝对路径直接进 readFile,
357
- // `..` 或路径分隔符能读到根外(此前只做 trim().filter(Boolean))。
402
+ // 名字逐项校验:子智能体、提示词预设与快捷提示词这三个域的名字会被拼成绝对路径直接进
403
+ // readFile,`..` 或路径分隔符能读到根外(此前只做 trim().filter(Boolean))。
358
404
  // 另外两个域**不套单段谓词**,原因不同:
359
405
  // - 记忆:名字是 `<场景>/<名>` 的 id,只用来查索引拿路径,本身不参与拼接;
360
406
  // - 技能:名字可以是多段相对路径(只读来源的 bundle 用 relative() 拼出名字),
@@ -362,7 +408,7 @@ export function buildSessionOps(deps) {
362
408
  // 匹配不上就进 missing,套单段谓词反而会把合法的多段技能名误杀。
363
409
  const rejected = [];
364
410
  const accepted = names.filter((name) => {
365
- const ok = kind === 'presets' ? isValidPresetId(name)
411
+ const ok = kind === 'presets' || kind === 'quick-prompts' ? isValidPresetId(name)
366
412
  : kind === 'subagents' ? validPersonaName(name)
367
413
  : true;
368
414
  if (!ok)
@@ -395,16 +441,20 @@ export function buildSessionOps(deps) {
395
441
  entries.push({ zip: `${name}.md`, abs });
396
442
  }
397
443
  }
398
- else if (kind === 'presets') {
444
+ else if (kind === 'presets' || kind === 'quick-prompts') {
445
+ // 两个提示词域同形(一 id 一目录、正文 + `meta.json` 描述侧车),差别只在根目录与
446
+ // 正文文件名 —— 走同一段代码,别让"导出快捷词"变成第二份会漏改的拷贝。
447
+ const root = kind === 'quick-prompts' ? deps.quickPromptsDir : deps.promptsDir;
448
+ const doc = kind === 'quick-prompts' ? 'PROMPT.md' : 'AGENTS.md';
399
449
  for (const id of accepted) {
400
- const docAbs = join(deps.promptsDir, id, 'AGENTS.md');
401
- if (!isInsideRoot(deps.promptsDir, docAbs)) {
450
+ const docAbs = join(root, id, doc);
451
+ if (!isInsideRoot(root, docAbs)) {
402
452
  missing.push(id);
403
453
  continue;
404
454
  }
405
- entries.push({ zip: `${id}/AGENTS.md`, abs: docAbs });
455
+ entries.push({ zip: `${id}/${doc}`, abs: docAbs });
406
456
  // 描述侧车(预设那句说明)随预设一起走:导出再导入不该把它丢掉。
407
- const metaAbs = join(deps.promptsDir, id, 'meta.json');
457
+ const metaAbs = join(root, id, 'meta.json');
408
458
  try {
409
459
  if ((await stat(metaAbs)).isFile())
410
460
  entries.push({ zip: `${id}/meta.json`, abs: metaAbs });