dsh-plugin-tool-management 0.10.0 → 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 (74) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/README.md +64 -49
  3. package/README_EN.md +58 -37
  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 +105 -12
  21. package/lib/client.js +2763 -546
  22. package/lib/compat/preset-reach.js +1 -10
  23. package/lib/compat/probe.js +158 -20
  24. package/lib/context-inject.js +11 -0
  25. package/lib/host-names.js +12 -0
  26. package/lib/http-fence.js +35 -15
  27. package/lib/hub.js +28 -2
  28. package/lib/imports/parsers.js +15 -9
  29. package/lib/imports/upload.js +43 -4
  30. package/lib/index.js +804 -3887
  31. package/lib/mcp/loader-token.js +238 -0
  32. package/lib/mcp/manager.js +1681 -0
  33. package/lib/mcp/override-blocks.js +10 -3
  34. package/lib/mcp/patch-yaml.js +351 -0
  35. package/lib/mcp/secret-guard.js +145 -0
  36. package/lib/{rules → memories}/archive-engine.js +1 -1
  37. package/lib/{rules → memories}/archive.js +1 -1
  38. package/lib/memories/constants.js +128 -0
  39. package/lib/memories/index-io.js +330 -0
  40. package/lib/memories/projection.js +280 -0
  41. package/lib/memories/service.js +686 -0
  42. package/lib/memories/snapshot.js +672 -0
  43. package/lib/ops/candidates.js +64 -0
  44. package/lib/ops/compat.js +136 -0
  45. package/lib/ops/ctx.js +9 -0
  46. package/lib/ops/memory.js +678 -0
  47. package/lib/ops/prompts.js +107 -0
  48. package/lib/ops/scene-records.js +460 -0
  49. package/lib/ops/scene-sync.js +17 -0
  50. package/lib/ops/sessions.js +603 -0
  51. package/lib/ops/trash.js +140 -0
  52. package/lib/paths.js +103 -0
  53. package/lib/prompts/preset-id.js +49 -0
  54. package/lib/{agents-md → prompts}/service.js +1 -1
  55. package/lib/request-gate.js +320 -0
  56. package/lib/scene-prompt-sync.js +4 -4
  57. package/lib/scenes/candidates.js +344 -0
  58. package/lib/{history → sessions}/bridge.js +15 -5
  59. package/lib/sessions/history.js +323 -0
  60. package/lib/{history → sessions}/tombstone.js +1 -1
  61. package/lib/{history → sessions}/workspace.js +92 -24
  62. package/lib/skills/core.js +74 -39
  63. package/lib/skills/readonly-discovery.js +4 -1
  64. package/lib/skills/service.js +98 -13
  65. package/lib/subagents/service.js +199 -55
  66. package/lib/tools/deps.js +8 -0
  67. package/lib/tools/mcp.js +110 -0
  68. package/lib/tools/memory.js +87 -0
  69. package/lib/tools/prompt.js +70 -0
  70. package/lib/tools/skills.js +139 -0
  71. package/lib/tools/subagent.js +40 -0
  72. package/package.json +7 -7
  73. package/lib/agents-md/preset-id.js +0 -49
  74. package/lib/rules/service.js +0 -3078
@@ -5,8 +5,9 @@
5
5
  // v0.4 目录变更:人设由 `$DSH_HOME/subagents/` 搬到 `$DSH_HOME/tool-management/agents/`
6
6
  // (插件产生的文件统一收在 tool-management/ 下)。旧目录在首次扫描时搬入,见 relocateLegacyPersonas。
7
7
  import { createRequire } from 'node:module';
8
- import { mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
8
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
9
9
  import { join } from 'node:path';
10
+ import { isValidSegment } from '../paths.js';
10
11
  import { resolveDshHome } from '../skills/core.js';
11
12
  import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
12
13
  import { expandUploads, planPersonaImport } from '../imports/upload.js';
@@ -357,32 +358,85 @@ export function createSubagentService(ctx, opts) {
357
358
  const stateFile = join(stateDir, 'subagents-index.json');
358
359
  const req = createRequire(import.meta.url);
359
360
  let cache = null;
361
+ // ── 写操作串行队列 ──────────────────────────────────────────────────────
362
+ // 状态文件的「读-改-写」必须整体串行:两个并发开关各自读到同一份快照、各自写回,
363
+ // 后写的会把先写的改动抹掉(用户关掉的人设会自己回来)。人设文件的建/改/删也走这里,
364
+ // 否则「查重 → 写入」之间的空窗会让两次同名导入互相覆盖。
365
+ // 与 rules / skills 两域同一实现(`then(task, task)` 让前一个失败后队列照样继续)。
366
+ let mutationQueue = Promise.resolve();
367
+ const enqueueMutation = (task) => {
368
+ const queued = mutationQueue.then(task, task);
369
+ mutationQueue = queued.catch(() => undefined);
370
+ return queued;
371
+ };
360
372
  // ── 人设启用集合(子智能体开关)────────────────────────────────────────
361
373
  // subagents-index.json:{ version: 1, enabled: string[] }(与 memories-index.json 同目录约定)。
362
- // - 文件缺失/损坏 = **全部启用**(老用户升级零感知,行为与开关上线前一致);
374
+ // - 文件**缺失** = **全部启用**(老用户升级零感知,行为与开关上线前一致);
375
+ // - 文件**存在但解析失败** = **全部停用** + 告警一次(fail-closed:损坏不该把用户停用的
376
+ // 人设一次性重新暴露给模型,且用户看不到任何提示;与 rules 域索引隔离同口径);
363
377
  // - 文件一旦写出即为权威:之后新建/导入/回收站恢复的人设**自动启用**(刚建就想用是常理);
364
378
  // - 停用只影响注入与 subagent_* 工具的可见性,人设文件一个字节不动。
365
379
  // 缓存约定:undefined = 还没读过盘;null = 文件缺失(全部启用);数组 = 权威集合。
366
380
  let enabledCache = undefined;
381
+ // 与缓存配对的文件指纹(mtimeMs;文件不存在时 null)。**必须比对指纹**:这个文件可能被
382
+ // 另一个 DSH 实例(多 profile 共享 state 目录)或手工编辑改动,只在自身写入时失效缓存
383
+ // 会让插件一直返回旧集合 —— 用户停用的人设悄悄回来。一次 stat 比重新读 + 解析便宜,
384
+ // 而且立刻跟上外部改动,不引入新的 TTL 窗口。
385
+ let enabledStamp = null;
386
+ // 解析失败只告警一次:readEnabled 每次读都会走到那段,不设标志会刷屏。
387
+ let warnedBrokenState = false;
388
+ /** 文件 mtimeMs;不存在或读不到时 null(与「文件缺失」同一判定)。 */
389
+ const fileStamp = async (path) => {
390
+ try {
391
+ return (await stat(path)).mtimeMs;
392
+ }
393
+ catch {
394
+ return null;
395
+ }
396
+ };
367
397
  async function readEnabled() {
368
- if (enabledCache !== undefined)
398
+ const stamp = await fileStamp(stateFile);
399
+ if (enabledCache !== undefined && stamp === enabledStamp)
369
400
  return enabledCache;
370
401
  // 用局部变量过渡:await 之后 TS 对闭包级缓存变量的收窄会失效,直接返回会报 undefined。
371
402
  let next;
403
+ let text;
372
404
  try {
373
- const raw = JSON.parse(await readFile(stateFile, 'utf8'));
374
- next = Array.isArray(raw && raw.enabled) ? raw.enabled.map((x) => String(x)) : [];
405
+ text = await readFile(stateFile, 'utf8');
375
406
  }
376
407
  catch {
408
+ text = null;
409
+ }
410
+ if (text === null) {
411
+ // 文件不存在 → 全部启用(升级零感知,见上方约定)。
377
412
  next = null;
378
413
  }
414
+ else {
415
+ try {
416
+ const raw = JSON.parse(text);
417
+ next = Array.isArray(raw && raw.enabled) ? raw.enabled.map((x) => String(x)) : [];
418
+ }
419
+ catch (e) {
420
+ // 文件**存在**却解析失败 → fail-closed 成「谁都不启用」。沿用「全部启用」会把用户
421
+ // 停用的人设一次性全部重新暴露给模型,而用户看不到任何提示;空集合至少可见、可恢复
422
+ // (重新开启即可)。文件本身一个字节不动,修好后重启即恢复。
423
+ if (!warnedBrokenState) {
424
+ warnedBrokenState = true;
425
+ ctx.logger?.warn?.(`[dsh-plugin-tool-management] 人设索引解析失败(${message(e)});已按「全部停用」处理,请检查 ${stateFile}`);
426
+ }
427
+ next = [];
428
+ }
429
+ }
379
430
  enabledCache = next;
431
+ enabledStamp = stamp;
380
432
  return next;
381
433
  }
382
434
  async function writeEnabled(list) {
383
435
  await mkdir(stateDir, { recursive: true });
384
436
  await writeFile(stateFile, JSON.stringify({ version: 1, enabled: list }, null, 2), 'utf8');
385
437
  enabledCache = list;
438
+ // 写入后重取指纹,让下一次 readEnabled 直接命中缓存(否则会白读一次)。
439
+ enabledStamp = await fileStamp(stateFile);
386
440
  }
387
441
  /** 新建/导入/恢复的人设默认停用(v0.8.5 用户裁定,与技能 / MCP 同口径):
388
442
  * 文件缺失(含旧数据)时先把「全部启用」物化成显式全集**并排除新名**——
@@ -414,6 +468,25 @@ export function createSubagentService(ctx, opts) {
414
468
  return;
415
469
  await writeEnabled(set.filter((n) => n !== name));
416
470
  }
471
+ /**
472
+ * 启用集合的「跟随写」失败**不能吞**。原先这五处一律 `.catch(() => undefined)`,
473
+ * 而写盘失败时后果是静默改变开关状态:新建/导入/恢复的人设会停在**启用**态(下一轮就进
474
+ * 模型上下文),改名的人设会停在**停用**态 —— 用户看到的却都是成功。
475
+ *
476
+ * 沿用 `sceneSyncError` 的形状:只给**原因原文**,句子由界面按当前语言拼。
477
+ */
478
+ async function withEnabledNote(res, task) {
479
+ try {
480
+ await task();
481
+ return res;
482
+ }
483
+ catch (e) {
484
+ const reason = message(e);
485
+ return res && res.ok === false
486
+ ? { ...res, error: `${res.error};另外,启停状态没能同步(${reason})` }
487
+ : { ...res, stateSyncError: reason };
488
+ }
489
+ }
417
490
  /** list() 输出统一附上 enabled:文件缺失 → 全 true;否则按集合。 */
418
491
  function withEnabled(docs, set) {
419
492
  if (set === null)
@@ -576,6 +649,11 @@ export function createSubagentService(ctx, opts) {
576
649
  chain = queued.catch(() => undefined);
577
650
  return queued;
578
651
  };
652
+ /** 写操作 op 名集合(HTTP 端 WRITE_OPS 由它派生)。 */
653
+ const writeOps = new Set([
654
+ 'subagent-create', 'subagent-update', 'subagent-delete', 'subagent-import',
655
+ 'subagent-toggle', 'subagent-trash-restore', 'subagent-trash-delete',
656
+ ]);
579
657
  const ops = {
580
658
  'subagent-list': async () => {
581
659
  const docs = await list();
@@ -611,9 +689,8 @@ export function createSubagentService(ctx, opts) {
611
689
  }
612
690
  await writeFile(target, serializePersona(args), 'utf8');
613
691
  // v0.8.5:新建默认不启动——显式集合下新名天然停用;文件缺失时先物化全集并排除新名。
614
- await materializeEnabledExcluding([name]).catch(() => undefined);
615
692
  cache = null;
616
- return { ok: true, name };
693
+ return withEnabledNote({ ok: true, name }, () => materializeEnabledExcluding([name]));
617
694
  },
618
695
  /**
619
696
  * 保存人设,**可选改名**(nextName)。
@@ -640,20 +717,28 @@ export function createSubagentService(ctx, opts) {
640
717
  const taken = await readFile(nextTarget, 'utf8').then(() => true).catch(() => false);
641
718
  if (taken)
642
719
  return { ok: false, error: `人设已存在: ${nextRaw}` };
643
- try {
644
- await rename(target, nextTarget);
645
- }
646
- catch (e) {
647
- return { ok: false, error: `人设改名失败: ${message(e)}` };
648
- }
649
720
  finalName = nextRaw;
650
721
  renamedFrom = name;
651
722
  }
652
- await writeFile(join(dir, finalName + '.md'), serializePersona({ ...args, name: finalName }), 'utf8');
723
+ // 先把新内容写进临时文件、再一次性改名到位。原先的顺序是「先 rename 旧文件 → 再
724
+ // writeFile 新文件」,writeFile 一旦失败就留下「新名字 + 旧内容」的半态 —— 用户看到
725
+ // 改名成功、内容却还是旧的。临时名以 `.` 开头且不以 `.md` 结尾,扫描时天然被忽略。
726
+ const finalTarget = join(dir, finalName + '.md');
727
+ const tmp = join(dir, `.${finalName}.tmp-${process.pid}-${Date.now()}`);
728
+ try {
729
+ await writeFile(tmp, serializePersona({ ...args, name: finalName }), 'utf8');
730
+ await rename(tmp, finalTarget);
731
+ }
732
+ catch (e) {
733
+ await rm(tmp, { force: true }).catch(() => undefined);
734
+ return { ok: false, error: `保存人设失败: ${message(e)}` };
735
+ }
736
+ // 改名时旧文件等新文件就位后再清:清失败只多一份副本,不丢数据。
653
737
  if (renamedFrom)
654
- await renamePersonaInEnabled(renamedFrom, finalName).catch(() => undefined);
738
+ await rm(join(dir, renamedFrom + '.md'), { force: true }).catch(() => undefined);
655
739
  cache = null;
656
- return { ok: true, name: finalName, ...(renamedFrom ? { renamedFrom } : {}) };
740
+ return withEnabledNote({ ok: true, name: finalName, ...(renamedFrom ? { renamedFrom } : {}) }, async () => { if (renamedFrom)
741
+ await renamePersonaInEnabled(renamedFrom, finalName); });
657
742
  },
658
743
  /**
659
744
  * 删除人设 = **移入回收站**(`hub/trash/agents-trash/<id>/persona.md`)。
@@ -671,9 +756,8 @@ export function createSubagentService(ctx, opts) {
671
756
  const moved = await moveToTrash('subagents', name, [{ from: target, dest: 'persona.md' }]);
672
757
  if (moved.ok === false)
673
758
  return { ok: false, error: `移入回收站失败: ${moved.error}` };
674
- await removePersonaFromEnabled(name).catch(() => undefined);
675
759
  cache = null;
676
- return { ok: true, name, trashId: moved.id };
760
+ return withEnabledNote({ ok: true, name, trashId: moved.id }, () => removePersonaFromEnabled(name));
677
761
  },
678
762
  'subagent-trash-list': async () => ({ ok: true, trash: await listTrashEntries('subagents') }),
679
763
  'subagent-trash-restore': async (args) => {
@@ -697,9 +781,8 @@ export function createSubagentService(ctx, opts) {
697
781
  }
698
782
  await purgeTrashEntry('subagents', id);
699
783
  // v0.8.5:回收站恢复默认不启动(与新建/导入同口径)。
700
- await materializeEnabledExcluding([entry.name]).catch(() => undefined);
701
784
  cache = null;
702
- return { ok: true, name: entry.name };
785
+ return withEnabledNote({ ok: true, name: entry.name }, () => materializeEnabledExcluding([entry.name]));
703
786
  },
704
787
  'subagent-trash-delete': async (args) => {
705
788
  const id = String((args && args.id) || '').trim();
@@ -747,11 +830,10 @@ export function createSubagentService(ctx, opts) {
747
830
  }
748
831
  imported.push(target.name);
749
832
  }
750
- if (imported.length) {
751
- await materializeEnabledExcluding(imported).catch(() => undefined);
833
+ if (imported.length)
752
834
  cache = null;
753
- }
754
- return { ok: true, imported, skipped };
835
+ return withEnabledNote({ ok: true, imported, skipped }, async () => { if (imported.length)
836
+ await materializeEnabledExcluding(imported); });
755
837
  },
756
838
  /**
757
839
  * 子智能体开关(v0.8):停用 = 不注入目录段、subagent_manager_list/run 不可见;文件本体不动。
@@ -784,6 +866,13 @@ export function createSubagentService(ctx, opts) {
784
866
  return { ok: true, name, enabled: args.enabled === true };
785
867
  },
786
868
  };
869
+ // 写 op 统一进串行队列(理由见上方 mutationQueue 的说明)。放在这里统一包、而不是逐个手写:
870
+ // 以后新增写 op 只改 writeOps 一处,不会漏掉。
871
+ for (const name of writeOps) {
872
+ const fn = ops[name];
873
+ if (typeof fn === 'function')
874
+ ops[name] = (args) => enqueueMutation(() => fn(args));
875
+ }
787
876
  const enabledStore = {
788
877
  /** 指定名单里当前被停用的(进入模式拍快照用:只记将被启用的行,退出时精确停回)。 */
789
878
  async disabledAmong(names) {
@@ -803,35 +892,64 @@ export function createSubagentService(ctx, opts) {
803
892
  const state = new Map(docs.map((d) => [d.name, d.enabled !== false]));
804
893
  return docs.map((d) => d.name).filter((n) => state.get(n) === true);
805
894
  },
806
- /** 批量启停;只碰给出的名字,人设已不存在的跳过(别把悬空名写进集合)。 */
895
+ /** 批量启停;只碰给出的名字,人设已不存在的跳过(别把悬空名写进集合)。
896
+ * 同样走写队列:它由场景档案引擎直接调用(不经 op 表),不排队就会与开关 op 互相覆盖。 */
807
897
  async setEnabled(names, enabled) {
808
- const docs = await list();
809
- const set = await readEnabled();
810
- const base = (set === null ? docs.map((d) => d.name) : set.slice()).filter((n) => docs.some((d) => d.name === n));
811
- let dirty = false;
812
- for (const n of names) {
813
- if (!docs.some((d) => d.name === n))
814
- continue;
815
- const i = base.indexOf(n);
816
- if (enabled && i < 0) {
817
- base.push(n);
818
- dirty = true;
898
+ await enqueueMutation(async () => {
899
+ const docs = await list();
900
+ const set = await readEnabled();
901
+ const base = (set === null ? docs.map((d) => d.name) : set.slice()).filter((n) => docs.some((d) => d.name === n));
902
+ let dirty = false;
903
+ for (const n of names) {
904
+ if (!docs.some((d) => d.name === n))
905
+ continue;
906
+ const i = base.indexOf(n);
907
+ if (enabled && i < 0) {
908
+ base.push(n);
909
+ dirty = true;
910
+ }
911
+ if (!enabled && i >= 0) {
912
+ base.splice(i, 1);
913
+ dirty = true;
914
+ }
819
915
  }
820
- if (!enabled && i >= 0) {
821
- base.splice(i, 1);
822
- dirty = true;
916
+ if (dirty) {
917
+ await writeEnabled(base);
918
+ cache = null;
823
919
  }
824
- }
825
- if (dirty) {
826
- await writeEnabled(base);
827
- cache = null;
828
- }
920
+ });
829
921
  },
830
922
  };
831
- return { list, runSerial, ops, enabledStore, writeOps: new Set(['subagent-create', 'subagent-update', 'subagent-delete', 'subagent-import', 'subagent-toggle', 'subagent-trash-restore', 'subagent-trash-delete']) };
923
+ return { list, runSerial, ops, enabledStore, writeOps };
832
924
  }
833
- function validPersonaName(name) {
834
- return name.length > 0 && name.length <= 64 && !name.startsWith('.') && !/[\\/<>:"|?*]/.test(name);
925
+ /** 人设名长度上限(字符)。 */
926
+ const PERSONA_NAME_MAX = 64;
927
+ /**
928
+ * 官方 `tools.restrict()` 的**保留名**:名单里出现它时,官方不是"当它不存在",而是**直接抛错**
929
+ * (`dsh-tools/lib/index.js:2800`:cannot name reserved PTC mode presentation transport),
930
+ * 子代理当场起不来。
931
+ *
932
+ * 为什么必须在这里显式剔除:`run_code` 在宿主面上是**合法可见**的工具名(非 native 模式下
933
+ * 由官方补进可见集合),所以"按当前存在的工具名过滤未知项"剔不掉它 —— 过滤留下的正好是
934
+ * 会让官方抛错的那个。用户的直观预期是"写了就生效(或至少被忽略)",实际却是整个委派失败。
935
+ */
936
+ const RESERVED_TOOL_NAMES = new Set(['run_code']);
937
+ /** 把保留名从名单里剔掉,并说明剔了什么(不静默)。 */
938
+ function stripReserved(names) {
939
+ const kept = [];
940
+ const dropped = [];
941
+ for (const name of names)
942
+ (RESERVED_TOOL_NAMES.has(name) ? dropped : kept).push(name);
943
+ return { kept, dropped };
944
+ }
945
+ /**
946
+ * 人设名合法性:谓词收敛到 `../paths.ts`(此前这里与 rules / imports / skills 各写一套,
947
+ * 缺了 Windows 保留设备名与控制字符检查 —— 设备名在 Windows 上创建即失败)。
948
+ * 注意:**不挡花括号**,手写 frontmatter 仍造得出 `{{…}}` 名字,渲染侧照旧做中和
949
+ * (见 renderPersonaPrompt)。
950
+ */
951
+ export function validPersonaName(name) {
952
+ return isValidSegment(name, PERSONA_NAME_MAX);
835
953
  }
836
954
  /** 字符串数组规范化(非数组/空项都丢掉)。 */
837
955
  function toStringList(value) {
@@ -908,34 +1026,57 @@ export function serializePersona(args) {
908
1026
  * 按**当前会话的 Agent 预设**决定这次委派下发什么工具限制。
909
1027
  *
910
1028
  * 为什么必须按预设分:子代理跑在父会话的预设里(官方 `composeFrom(childCtx, parent.ctx)`),
911
- * 而各预设的工具集合差别极大(极简模式只有持久 shell),官方 `tools.restrict()` 遇到
912
- * 名单里不存在的工具名会直接抛错、子代理根本起不来。所以:
1029
+ * 而各预设的工具集合差别极大(极简模式只有持久 shell)。官方 `tools.restrict()` 实有
1030
+ * **四个**抛错点(`dsh-tools/lib/index.js:2790-2803`),抛了子代理就起不来:
1031
+ *
1032
+ * ① 非 scoped context 调用(宿主 `childCtx` 已满足,无风险);
1033
+ * ② `allow` 与 `deny` 同时缺省 ⇒ `restrict({})` 抛 —— 本函数用 `filter: null`(不下发)
1034
+ * 表达"不加限制",**从不**调用 `restrict({})`;
1035
+ * ③ **名单含保留名 `run_code` 即抛** —— 而它在宿主面上是合法可见名,"按现有工具名过滤"
1036
+ * 剔不掉它,所以这里**显式剔除**并写进 note(见 `stripReserved`);
1037
+ * ④ 未知名 ⇒ 抛(唯一此前被记录的那条)。
913
1038
  *
1039
+ * 所以:
914
1040
  * - 当前预设配了名单(且名单非空)→ 用它;白名单额外并入**当时真实在跑的 MCP 工具**
915
1041
  * (官方 allow 是"清单之外全砍",不并进来会把 MCP 一起砍掉;用户裁定:子代理要能
916
1042
  * 用当前启动的 MCP);
917
1043
  * - 当前预设没配 → 回落旧的全局 `tools` / `toolsDeny`(老文件行为不变);
918
- * - 名单里有已经消失的工具名 → **丢掉并在 note 里如实说明**(不接受静默失效);
1044
+ * - 名单里有已经消失的工具名、或写了保留名 → **丢掉并在 note 里如实说明**(不接受静默失效);
919
1045
  * - 判断不了当前预设(老宿主 / 异常)→ 不按模式施加,并在 note 里说明。
920
1046
  *
1047
+ * ⚠ 两种失败方向都要记账(它们互斥,且都不是"没生效"这么简单):名单全落空时本函数返回
1048
+ * `filter: null` = **放宽到不限制**(fail-open);保留名没剔干净时官方抛错 = **收紧到起不来**。
1049
+ *
921
1050
  * 三态返回:`filter: null` = 明确不加限制;`filter: {...}` = 下发该限制。
922
1051
  */
923
1052
  export function decideToolFilter(persona, presetId, known) {
924
1053
  const rule = presetId === null ? undefined : persona.toolsByPreset?.[presetId];
925
1054
  if (rule && rule.names.length) {
926
- const usable = rule.names.filter((name) => known.names.has(name));
1055
+ const knownNames = rule.names.filter((name) => known.names.has(name));
1056
+ const reserved = stripReserved(knownNames);
1057
+ const usable = reserved.kept;
927
1058
  const dropped = rule.names.filter((name) => !known.names.has(name));
928
- const droppedNote = dropped.length ? `名单里这些工具当前不存在,已忽略:${dropped.join('、')}` : undefined;
1059
+ const notes = [];
1060
+ if (dropped.length)
1061
+ notes.push(`名单里这些工具当前不存在,已忽略:${dropped.join('、')}`);
1062
+ if (reserved.dropped.length) {
1063
+ notes.push(`名单里的 ${reserved.dropped.join('、')} 是官方保留名(写进工具限制会让子代理直接起不来),已忽略`);
1064
+ }
929
1065
  if (!usable.length) {
930
- return { filter: null, note: `「${presetId}」的${rule.mode === 'allow' ? '白' : '黑'}名单里没有当前存在的工具,本次不施加工具限制` };
1066
+ const note = notes.length ? notes.join(';') : undefined;
1067
+ return { filter: null, note: note ?? `「${presetId}」的${rule.mode === 'allow' ? '白' : '黑'}名单里没有当前存在的工具,本次不施加工具限制` };
931
1068
  }
1069
+ const droppedNote = notes.length ? notes.join(';') : undefined;
932
1070
  if (rule.mode === 'allow')
933
1071
  return { filter: { allow: [...new Set([...usable, ...known.mcp])] }, ...(droppedNote === undefined ? {} : { note: droppedNote }) };
934
1072
  return { filter: { deny: usable }, ...(droppedNote === undefined ? {} : { note: droppedNote }) };
935
1073
  }
936
1074
  // 旧格式(全局名单):保持老行为,白名单同样并入 MCP。
937
- const legacyAllow = (persona.tools || []).filter((name) => known.names.has(name));
938
- const legacyDeny = (persona.toolsDeny || []).filter((name) => known.names.has(name));
1075
+ const legacyKnown = [...(persona.tools || []), ...(persona.toolsDeny || [])].filter((name) => known.names.has(name));
1076
+ const legacyReserved = stripReserved(legacyKnown);
1077
+ const legacyKept = new Set(legacyReserved.kept);
1078
+ const legacyAllow = (persona.tools || []).filter((name) => legacyKept.has(name));
1079
+ const legacyDeny = (persona.toolsDeny || []).filter((name) => legacyKept.has(name));
939
1080
  const legacyDropped = [...(persona.tools || []), ...(persona.toolsDeny || [])].filter((name) => !known.names.has(name));
940
1081
  const filter = {
941
1082
  ...(legacyAllow.length ? { allow: [...new Set([...legacyAllow, ...known.mcp])] } : {}),
@@ -951,6 +1092,9 @@ export function decideToolFilter(persona, presetId, known) {
951
1092
  }
952
1093
  if (legacyDropped.length)
953
1094
  notes.push(`名单里这些工具当前不存在,已忽略:${legacyDropped.join('、')}`);
1095
+ if (legacyReserved.dropped.length) {
1096
+ notes.push(`名单里的 ${legacyReserved.dropped.join('、')} 是官方保留名(写进工具限制会让子代理直接起不来),已忽略`);
1097
+ }
954
1098
  if (!Object.keys(filter).length) {
955
1099
  if (presetId === null && !notes.length)
956
1100
  notes.push('没能判断当前会话的 Agent 预设,本次不施加工具限制');
@@ -0,0 +1,8 @@
1
+ // model 工具(ctx.tools.register + defineTool)各域共用的依赖与 helper。
2
+ //
3
+ // 为什么单独一个文件:五个域的工具都要同一批依赖(defineTool 包装、注册出口、场景锁定
4
+ // 守卫、注入边界提示),各写一份会漂移。
5
+ /** 每个工具 output.render 的统一出口:把字符串包成宿主要的 content 数组。 */
6
+ export function text(value) {
7
+ return [{ type: 'text', text: value }];
8
+ }
@@ -0,0 +1,110 @@
1
+ // MCP 域的 model 工具(2026-09-19 从 index.ts 的注册区抽出):
2
+ // mcp_manager_list / mcp_manager_set_enabled / mcp_manager_restart / mcp_manager_add。
3
+ import { DEFAULT_MCP_NOTE_MAX_LENGTH, normalizeMcpNote } from '../mcp/state-section.js';
4
+ import { text } from './deps.js';
5
+ export function buildMcpTools(deps) {
6
+ const { defineTool, register } = deps;
7
+ register(defineTool({
8
+ name: 'mcp_manager_list',
9
+ description: 'List configured MCP servers (level, enabled state, live loader status, tool count excluding switched-off tools, note). Read a server\'s note before choosing it. The same list is injected into your context each turn (the「本机 MCP 服务器的当前状态」system-reminder); this tool is the raw view — all=true includes disabled servers. Defaults to enabled servers; pass all=true for every configured server.',
10
+ parameters: {
11
+ all: { type: 'boolean', description: 'Include disabled servers (default false).' },
12
+ },
13
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
14
+ async execute(args, exec) {
15
+ const showAll = Boolean(args && args.all === true);
16
+ // 必须走 mcpmListView()(= mcpmRowsWithNotes(false)):备注在这里合入,且未打码的
17
+ // url/headers/env 已被打码。备注是本插件 MCP 这一项的真正价值(用户写给未来模型的
18
+ // 决策提示,如「A 挂了改用 B」),而它只走注入通道 —— 压制型预设下不注入时,
19
+ // 这里就是唯一的读取路径。
20
+ const r = await deps.mcpmListView();
21
+ if (!r.ok)
22
+ throw new Error(r.error);
23
+ const all = r.rows || [];
24
+ // 默认只列「已启用」的(用户裁定 2026-09-16,与技能 / 记忆列表同口径):停用的服务器
25
+ // 模型调不到,列出来只是噪音。表头给出全量口径,所以不会变成盲区。
26
+ const shown = showAll ? all : all.filter((x) => !x.disabled);
27
+ const summary = shown.map((x) => {
28
+ const note = normalizeMcpNote(x.notes, DEFAULT_MCP_NOTE_MAX_LENGTH);
29
+ // 工具数 = 可用数(停用表扣减后):段里给模型的是同一个口径,模型据此与
30
+ // 自己 schema 里的 `mcp__*` 工具对得上;被场景收窄 / 手动关掉的工具不算。
31
+ const usableTools = typeof x.enabledToolCount === 'number' ? x.enabledToolCount : x.toolCount;
32
+ return x.id + ' | ' + x.serverName + ' | ' + x.level + ' | ' + (x.disabled ? 'disabled' : 'enabled') +
33
+ (x.live ? ' | loader:' + (x.live.enabled ? 'on' : 'off') + (x.live.phase ? ':' + x.live.phase : '') : '') +
34
+ (typeof usableTools === 'number' ? ' | tools:' + usableTools : '') +
35
+ (note ? ' | user-hint:' + note : '');
36
+ });
37
+ const header = 'MCP servers: ' + (showAll
38
+ ? all.length + ' configured'
39
+ : shown.length + ' enabled of ' + all.length + ' configured' +
40
+ (shown.length === all.length ? '' : ' (pass all=true for every configured server)')) + '\n';
41
+ // 注入边界:压制型预设(persona complete / 关闭运行时上下文,如极简)下本插件
42
+ // 默认不注入 —— 模型只有 mcp__* 的工具名与参数,没有服务级信息。不说明的话,
43
+ // 模型会把「能调用这些工具」当成「已经知道有哪些 server、用户给它们写了什么备注」。
44
+ // 提示本身不点名任何工具,所以挂在哪个发现型工具上都不会出现循环指引。
45
+ const notice = await deps.reachNoticeForAgent(deps.presetRoster(), exec && exec.agent && exec.agent.ctx, deps.injectNoticeOptions());
46
+ return header + (summary.join('\n') || '(none)') + notice;
47
+ },
48
+ }));
49
+ register(defineTool({
50
+ name: 'mcp_manager_set_enabled',
51
+ description: 'Enable or disable one configured MCP server (writes the patch file; takes effect via HMR).',
52
+ parameters: {
53
+ id: { type: 'string', required: true, description: 'Entry id of the MCP server, e.g. mcp-stepfun-web-search.' },
54
+ level: { type: 'string', required: true, description: 'project or global.' },
55
+ enabled: { type: 'boolean', required: true, description: 'true to enable, false to disable.' },
56
+ },
57
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
58
+ async execute(args) {
59
+ const blocked = await deps.lockedSceneGuard();
60
+ if (blocked)
61
+ throw new Error(blocked);
62
+ const r = await deps.mcpmSetEnabled({ id: args.id, level: args.level, enabled: args.enabled });
63
+ if (!r.ok)
64
+ throw new Error(r.error);
65
+ // 场景未锁定时,页面 / 模型改的开关都要落进当前场景的档案(与 handlers 层同一套同步)。
66
+ const syncErr = await deps.syncSwitchToScene('mcpm-set-enabled', args);
67
+ return 'OK: ' + args.id + ' now ' + (args.enabled ? 'enabled' : 'disabled') +
68
+ (syncErr ? '\nWARN: 当前场景档案未同步(' + syncErr + ')' : '');
69
+ },
70
+ }));
71
+ register(defineTool({
72
+ name: 'mcp_manager_restart',
73
+ description: 'Restart one configured MCP server (disable + re-enable; reconnect and re-sync tools).',
74
+ parameters: {
75
+ id: { type: 'string', required: true, description: 'Entry id of the MCP server, e.g. mcp-stepfun-web-search.' },
76
+ level: { type: 'string', required: true, description: 'project or global.' },
77
+ },
78
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
79
+ async execute(args) {
80
+ const r = await deps.mcpmRestart({ id: args.id, level: args.level });
81
+ if (!r.ok)
82
+ throw new Error(r.error);
83
+ return 'OK: ' + args.id + ' restarted';
84
+ },
85
+ }));
86
+ register(defineTool({
87
+ name: 'mcp_manager_add',
88
+ description: 'Add a new MCP server (streamable-http or stdio) at project or global level.',
89
+ parameters: {
90
+ serverName: { type: 'string', required: true, description: 'Unique server name (1-32 chars, [A-Za-z0-9_-]).' },
91
+ transport: { type: 'string', required: true, description: 'streamable-http or stdio.' },
92
+ url: { type: 'string', description: 'Server URL (required for streamable-http).' },
93
+ command: { type: 'string', description: 'Executable (required for stdio).' },
94
+ args: { type: 'string', description: 'Arguments, space separated (stdio).' },
95
+ headers: { type: 'string', description: 'Extra headers as key=value lines (streamable-http).' },
96
+ env: { type: 'string', description: 'Extra env vars as key=value lines (stdio).' },
97
+ level: { type: 'string', description: 'project or global (default project).' },
98
+ },
99
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
100
+ async execute(args) {
101
+ const blocked = await deps.lockedSceneGuard();
102
+ if (blocked)
103
+ throw new Error(blocked);
104
+ const r = await deps.mcpmAdd(args);
105
+ if (!r.ok)
106
+ throw new Error(r.error);
107
+ return 'OK: added ' + r.row.id + ' at ' + r.row.level;
108
+ },
109
+ }));
110
+ }
@@ -0,0 +1,87 @@
1
+ // 记忆(rules)域的 model 工具(2026-09-19 从 index.ts 的注册区抽出):
2
+ // memory_manager_list / memory_manager_read / memory_manager_write。
3
+ //
4
+ // 活动场景的记忆正文会自动注入上下文(无需调用工具读取);这里的工具用于查询/编辑规则
5
+ // 本身。memory_manager_write 受 tools/pre-execute 审批门禁(D2)。
6
+ // 路径锚点:$DSH_HOME/tool-management/memories/<场景>/…(场景 `global` = 界面「全局」)。
7
+ import { text } from './deps.js';
8
+ export function buildMemoryTools(deps) {
9
+ const { defineTool, register } = deps;
10
+ register(defineTool({
11
+ name: 'memory_manager_list',
12
+ description: 'List memories under ~/.dsh/tool-management/memories (id, scene, enabled, description). The ones actually injected are carried in your context each turn (the「本机当前的场景和记忆」system-reminder); use this tool to find ids/paths or to see entries that are off. Defaults to the memories that will actually be injected; pass all=true for every entry.',
13
+ parameters: {
14
+ group: { type: 'string', description: 'Optional scene filter.' },
15
+ all: { type: 'boolean', description: 'Include memories that are off, in an inactive scene, or shadowed (default false).' },
16
+ },
17
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
18
+ async execute(args, exec) {
19
+ const showAll = Boolean(args && args.all === true);
20
+ const r = await deps.rulesOps['rules-list'](args);
21
+ if (!r || r.ok === false)
22
+ throw new Error((r && r.error) || '读取规则失败');
23
+ // 「会注入」的判定与记忆段同源(renderSceneMemory):global 场景恒常注入,其余场景要
24
+ // 在 index.active 里,再叠加单条 enabled 与同名 bundle 的 shadowed。默认只列这些,
25
+ // 全列一次会把没启用的条目也算进上下文(用户裁定 2026-09-16,与技能列表同口径)。
26
+ const sceneOn = (group) => group === 'global' ||
27
+ (group !== '' && (r.activeMode === 'all' || String(r.activeScene || '') === group));
28
+ const stateOf = (x) => {
29
+ if (x.shadowed === true)
30
+ return { injects: false, label: '被同名覆盖' };
31
+ if (x.enabled === false)
32
+ return { injects: false, label: '已停用' };
33
+ if (!sceneOn(String(x.group || '')))
34
+ return { injects: false, label: '场景未启用' };
35
+ return { injects: true, label: '已启用' };
36
+ };
37
+ const rows = (r.rules || []).map((x) => ({ x, ...stateOf(x) }));
38
+ const shown = showAll ? rows : rows.filter((row) => row.injects);
39
+ const lines = shown.map(({ x, label }) => ('- ' + x.id + ' [' + (x.group || '未归属场景') + '] ' + label +
40
+ (x.description ? ' — ' + x.description : '')));
41
+ const scenes = (r.scenes || []).map((s) => (s.label || s.name) + (s.active ? '(启用)' : '(未启用)')).join('、');
42
+ const header = '记忆:' + (showAll
43
+ ? rows.length + ' 条'
44
+ : shown.length + ' 条会注入 / 共 ' + rows.length + ' 条' +
45
+ (shown.length === rows.length ? '' : '(传 all=true 看全部)')) + '\n';
46
+ // 注入边界:压制型预设(persona complete / 关闭运行时上下文)下本插件默认不注入,
47
+ // 此时列出的记忆**不在**模型上下文里。必须说出来,否则模型会假设自己已经看到正文。
48
+ const notice = await deps.reachNoticeForAgent(deps.presetRoster(), exec && exec.agent && exec.agent.ctx, deps.injectNoticeOptions());
49
+ return header + (lines.join('\n') || '(无记忆)') +
50
+ '\n场景:' + (scenes || '(无)') + (r.activeMode === 'all' ? '(默认全部启用)' : '(已收窄)') + notice;
51
+ },
52
+ }));
53
+ register(defineTool({
54
+ name: 'memory_manager_read',
55
+ description: 'Read the full body of one memory under ~/.dsh/tool-management/memories. Call it only for memories that are not already in your context (disabled, unassigned to a scene, or dropped by the injection budget).',
56
+ parameters: {
57
+ id: { type: 'string', required: true, description: 'Memory id like <scene>/<name>.' },
58
+ },
59
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
60
+ async execute(args) {
61
+ const r = await deps.rulesOps['rules-read'](args);
62
+ if (!r || r.ok === false)
63
+ throw new Error((r && r.error) || '读取规则失败');
64
+ return '# ' + r.rule.id + '\n\n' + (r.rule.body || '');
65
+ },
66
+ }));
67
+ register(defineTool({
68
+ name: 'memory_manager_write',
69
+ description: 'Create a new memory as ~/.dsh/tool-management/memories/<scene>/<name>.md. The scene must already exist (use global for the always-on scene).',
70
+ parameters: {
71
+ group: { type: 'string', required: true, description: 'Scene name (no path separators or < > : " | ? *); `global` = the always-on scene.' },
72
+ name: { type: 'string', required: true, description: 'Memory name = .md file name without extension; <=64 chars, no path separators or < > : " | ? *, must not start with a dot.' },
73
+ description: { type: 'string', required: true, description: 'One-sentence description (<=500 chars).' },
74
+ body: { type: 'string', required: true, description: 'Markdown body (<=256 KiB).' },
75
+ },
76
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
77
+ async execute(args) {
78
+ const blocked = await deps.lockedSceneGuard();
79
+ if (blocked)
80
+ throw new Error(blocked);
81
+ const r = await deps.rulesOps['rules-create'](args);
82
+ if (!r || r.ok === false)
83
+ throw new Error((r && r.error) || '创建规则失败');
84
+ return 'OK: memory ' + r.rule.id + '(场景「' + (r.rule.group || '未归属') + '」启用后自动生效)';
85
+ },
86
+ }));
87
+ }