dsh-plugin-tool-management 0.7.0 → 0.8.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 (36) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +16 -5
  3. package/README_EN.md +16 -5
  4. package/docs/images/1/345/234/272/346/231/257.png +0 -0
  5. package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
  6. package/docs/images/2MCP.png +0 -0
  7. package/docs/images/2MCP_en.png +0 -0
  8. package/docs/images/3/346/212/200/350/203/275.png +0 -0
  9. package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
  10. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  11. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
  12. package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
  13. package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
  14. package/docs/images/6/350/256/260/345/277/206.png +0 -0
  15. package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
  16. package/docs/images/7/344/274/232/350/257/235.png +0 -0
  17. package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
  18. package/docs/images/8/345/205/274/345/256/271.png +0 -0
  19. package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
  20. package/docs/update.md +17 -0
  21. package/lib/agents-md/service.js +41 -3
  22. package/lib/client.js +820 -375
  23. package/lib/compat/preset-reach.js +1 -1
  24. package/lib/compat/probe.js +8 -0
  25. package/lib/index.js +433 -10
  26. package/lib/mcp/state-section.js +90 -0
  27. package/lib/prompt-sections.js +148 -0
  28. package/lib/rules/archive-engine.js +99 -29
  29. package/lib/rules/archive.js +56 -28
  30. package/lib/rules/service.js +259 -39
  31. package/lib/skills/core.js +4 -1
  32. package/lib/subagents/catalog.js +89 -0
  33. package/lib/subagents/service.js +189 -20
  34. package/lib/subagents/tools.js +10 -3
  35. package/package.json +97 -97
  36. package/lib/rules/provider.js +0 -121
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * WHY THIS EXISTS
7
7
  * ---------------
8
- * `rules/provider.ts` registers a per-agent `systemPrompt` section, and the
8
+ * `prompt-sections.ts` registers a per-agent `systemPrompt` section, and the
9
9
  * registration SUCCEEDS under every preset. But `@deepseek-ai/dsh-persona`
10
10
  * with `complete: true` makes the prompt registry restore its prefix as the
11
11
  * ONLY section at assembly time, so the section never reaches the model — and
@@ -45,6 +45,14 @@ export const IDENTITY_PACKAGES = [
45
45
  export const EXPECTED_PEER_RANGE = '>=0.1.5-rc.2 <0.2.0-0';
46
46
  /** The release this plugin's adapters were last verified against. */
47
47
  export const VERIFIED_HOST_VERSION = '0.1.5-rc.2';
48
+ /**
49
+ * peer range 的下界 —— 界面展示用。
50
+ *
51
+ * 上界 `<0.2.0-0` 是 semver「不跨下一个 minor」的**约定写法**,不是被支持的版本;
52
+ * 把它当作「要求范围」展示会让人以为插件支持一批尚不存在的版本。
53
+ * 从 EXPECTED_PEER_RANGE 派生,避免两处手写漂移。
54
+ */
55
+ export const EXPECTED_MIN_HOST_VERSION = EXPECTED_PEER_RANGE.match(/^>=\s*([^\s]+)/)?.[1] ?? VERIFIED_HOST_VERSION;
48
56
  const isFn = (value) => typeof value === 'function';
49
57
  /** Resolve a package the way this plugin resolves it, without throwing. */
50
58
  function safeResolve(specifier) {
package/lib/index.js CHANGED
@@ -16,14 +16,17 @@ import { randomUUID } from 'node:crypto';
16
16
  import { ArchiveWorkspaceRegistry as HistoryService, workspaceBaseName, workspacePathKey } from './history/workspace.js';
17
17
  import { createSkillsService, pluginLog } from './skills/service.js';
18
18
  import { createAgentsMdService } from './agents-md/service.js';
19
- import { createRulesService } from './rules/service.js';
19
+ import { createRulesService, planMemoryExport } from './rules/service.js';
20
20
  import { createArchiveEngine } from './rules/archive-engine.js';
21
- import { createSubagentService, decideToolFilter } from './subagents/service.js';
21
+ import { createSubagentService, decideToolFilter, defaultPersonasDir } from './subagents/service.js';
22
+ import { createSubagentCatalog } from './subagents/catalog.js';
23
+ import { SECTION_ORDER, createPromptSectionRegistrar } from './prompt-sections.js';
22
24
  import { isApprovalNever } from './approval-policy.js';
23
- import { EXPECTED_PEER_RANGE, VERIFIED_HOST_VERSION, summarize } from './compat/probe.js';
25
+ import { EXPECTED_MIN_HOST_VERSION, EXPECTED_PEER_RANGE, VERIFIED_HOST_VERSION, summarize } from './compat/probe.js';
24
26
  import { assessPresetReach, presetRosterOf, reachNoticeForAgent } from './compat/preset-reach.js';
25
27
  import { fenceRejection, secretOpRejection } from './http-fence.js';
26
28
  import { planOverrideCompaction } from './mcp/override-blocks.js';
29
+ import { createMcpStateCatalog, mcpStateSectionOf } from './mcp/state-section.js';
27
30
  import { hubPath, hubRoot, relocateEntries } from './hub.js';
28
31
  import { createScenePromptSync } from './scene-prompt-sync.js';
29
32
  import { defineSubagentListTool, defineSubagentRunTool } from './subagents/tools.js';
@@ -31,7 +34,8 @@ import { detectFormat, extractText, parseGenericText, parseJsonlTranscript, pars
31
34
  import { homedir } from 'node:os';
32
35
  import { spawn } from 'node:child_process';
33
36
  import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
34
- import { dirname, join } from 'node:path';
37
+ import { basename, dirname, join } from 'node:path';
38
+ import { zipSync } from 'fflate';
35
39
  // ---------------------------------------------------------------------------
36
40
  // Transcript export helpers — mirror the import parsers (src/imports/parsers.js)
37
41
  // so an exported file can be imported back losslessly. Pure Node, no ctx.
@@ -124,7 +128,7 @@ export default {
124
128
  // ---------- agents-md 预设库 + 切换 ----------
125
129
  // DSH 全局指令基线只有 ~/.dsh/AGENTS.md 一个文件,无内置多预设切换;
126
130
  // 本服务在 hub 内 agents-md/ 维护预设库,「应用」= 写入 ~/.dsh/AGENTS.md,
127
- // 新会话生效(当前会话不变,DSH 本身如此)。
131
+ // 下一轮对话生效:宿主 dsh-agent-instructions 每个 agent/pre-step 都会 stat 并重读该文件。
128
132
  // presetsDir 可由 config 注入(测试用),否则落到 $DSH_HOME/tool-management/agents-md。
129
133
  // v0.4:旧位置(插件目录 data/agents-md-presets/)在启动时搬入 hub——
130
134
  // 插件目录在 npm 安装下会被覆盖,放用户数据在那里本身就丢数据风险。
@@ -365,12 +369,76 @@ export default {
365
369
  throw new Error(`技能 ${key} 应用失败: ${r.error}`);
366
370
  }
367
371
  },
372
+ // ── 服务器级 / 来源级启停(P5)────────────────────────────────────────────
373
+ // 为什么档案必须能改**上层**:工具级停用只在「服务器已经加载」时才有意义 ——
374
+ // 服务器没起来,它的工具根本不存在,勾选与否毫无区别。技能同理:来源关闭时,
375
+ // 技能级的 enable 会被 core 的 sourceEnabled 判定直接吞掉。
376
+ // 只报**补丁文件里真实存在**的行(level = project/global);loader 级条目改不了补丁。
377
+ mcpServerStates: async () => {
378
+ const r = await mcpmListView();
379
+ const out = [];
380
+ for (const row of ((r && r.rows) || [])) {
381
+ const level = String((row && row.level) || '');
382
+ if (level !== 'global' && level !== 'project')
383
+ continue;
384
+ out.push({
385
+ id: String(row.id),
386
+ serverName: String(row.serverName || row.id),
387
+ level,
388
+ disabled: !!row.disabled,
389
+ });
390
+ }
391
+ return out;
392
+ },
393
+ applyMcpServerSwitches: async (switches) => {
394
+ for (const s of switches) {
395
+ const r = await mcpmSetEnabled({ id: s.id, level: s.level, enabled: s.enabled });
396
+ if (r && r.ok === false)
397
+ throw new Error(`MCP 服务器 ${s.id} ${s.enabled ? '启用' : '停用'}失败:${r.error}`);
398
+ }
399
+ // 补丁改动由宿主热重载(实测 ≤5s)。这里**不等**,避免进入模式被拖住十几秒;
400
+ // 界面在应用完成后即可用,工具集合会在数秒内补齐。
401
+ },
402
+ skillSourceStates: async () => {
403
+ const r = await skillsService.ops['skill-state']({});
404
+ const roots = (r && r.data && r.data.roots) || [];
405
+ const out = [];
406
+ for (const root of roots) {
407
+ const key = String((root && root.key) || '');
408
+ if (!key)
409
+ continue;
410
+ // 默认来源(dsh / hub)不能停用(core 会拒绝)→ 不进切换集,避免无谓报错。
411
+ if (root.defaultSource === true)
412
+ continue;
413
+ out.push({ root: key, enabled: root.enabled === true });
414
+ }
415
+ return out;
416
+ },
417
+ applySkillSourceSwitches: async (switches) => {
418
+ for (const s of switches) {
419
+ const op = s.enabled ? 'skill-source-enable' : 'skill-source-disable';
420
+ const r = await skillsService.ops[op]({ root: s.root });
421
+ if (r && r.ok === false)
422
+ throw new Error(`技能来源 ${s.root} ${s.enabled ? '启用' : '停用'}失败:${r.error}`);
423
+ }
424
+ },
368
425
  sceneExists: async (name) => {
369
426
  const r = await rulesService.ops['rules-list']({});
370
427
  return !!(r && r.ok !== false && (r.scenes || []).some((s) => s.name === name));
371
428
  },
372
429
  // 人设名全集(保存 subagents 段时校验并报 stale,与 mcp/skills 两段同口径)。
373
430
  knownPersonas: async () => new Set((await subagentService.list()).map((p) => p.name)),
431
+ // 人设开关读/写(进入模式启用档案勾选的人设;退出按快照停回)。
432
+ disabledPersonas: async (names) => subagentService.enabledStore.disabledAmong(names),
433
+ applySubagentSwitches: async (switches) => {
434
+ const off = switches.filter((s) => !s.enabled).map((s) => s.name);
435
+ const on = switches.filter((s) => s.enabled).map((s) => s.name);
436
+ if (off.length)
437
+ await subagentService.enabledStore.setEnabled(off, false);
438
+ if (on.length)
439
+ await subagentService.enabledStore.setEnabled(on, true);
440
+ void subagentCatalog.refresh();
441
+ },
374
442
  // 记忆 id 全集(保存 memories 段时校验并报 stale)。
375
443
  knownMemoryIds: async () => {
376
444
  const r = await rulesService.ops['rules-list']({});
@@ -380,7 +448,8 @@ export default {
380
448
  // ---------- 轻量子智能体(设计 §3)----------
381
449
  // 人设 = $DSH_HOME/tool-management/agents/<name>.md;运行走官方 ctx.subagents.start(spawn provider)。
382
450
  // sceneLists 供场景绑定校验:启用场景(rules-list 的 active 行)档案里的 subagents 并集。
383
- const subagentService = createSubagentService(ctx, {});
451
+ // stateDir 与 rules 共用同一个覆盖键(测试注入一致);子智能体开关存 <stateDir>/agents-index.json。
452
+ const subagentService = createSubagentService(ctx, { stateDir: String(config?.rulesStateDir || '') });
384
453
  const subagentSceneLists = async () => {
385
454
  const slice = await rulesService.readArchiveSlice();
386
455
  const r = await rulesService.ops['rules-list']({});
@@ -390,6 +459,149 @@ export default {
390
459
  .filter((s) => s.active)
391
460
  .map((s) => slice.archives[s.name]?.subagents ?? []);
392
461
  };
462
+ // ---------- 注入类段(P1 人设目录 + P2 MCP 状态)----------
463
+ // 两者是同一类缺陷的两端:**某个域的配置状态没有在场陈述**,模型只能靠用户口头提醒。
464
+ // - 人设:本插件自造的概念,宿主没有对应物 → 模型不知道有哪些人设,subagent_run 不会被触发。
465
+ // - MCP:启停只改工具 schema,没有任何显式状态陈述 → 模型不知道有哪些 server,
466
+ // 更拿不到用户写的**备注**(那是写给「未来的模型」的决策提示,工具 schema 永远传达不到)。
467
+ // 两个段都只列「名字 + 摘要」,正文/工具清单绝不进上下文。
468
+ // 口径与理由分别见 src/subagents/catalog.ts、src/mcp/state-section.ts 的文件头。
469
+ const subagentCatalog = createSubagentCatalog({
470
+ list: () => subagentService.list(),
471
+ sceneLists: subagentSceneLists,
472
+ });
473
+ // ⚠️ 取数必须用 mcpmRowsWithNotes(false):true 会带上**未打码**的 url/headers/env(含明文密钥)。
474
+ const mcpStateCatalog = createMcpStateCatalog({
475
+ rows: () => mcpmRowsWithNotes(false),
476
+ });
477
+ try {
478
+ ctx.effect(() => {
479
+ const registrar = createPromptSectionRegistrar(ctx, [
480
+ { name: 'tool-management:subagents', order: SECTION_ORDER.subagents, text: () => subagentCatalog.text() },
481
+ mcpStateSectionOf(mcpStateCatalog, SECTION_ORDER.mcpState),
482
+ ]);
483
+ return () => registrar.dispose();
484
+ }, 'dsh-plugin-tool-management: prompt sections');
485
+ }
486
+ catch (e) {
487
+ console.error('[dsh-plugin-tool-management] prompt section setup failed:', message(e));
488
+ }
489
+ // 预热放到下一轮事件循环:此时 apply 的同步初始化(补丁路径、tools 服务…)已全部完成,
490
+ // 避免在初始化中途就去读 MCP 补丁与工具 schema。
491
+ setTimeout(() => {
492
+ void subagentCatalog.warm();
493
+ void mcpStateCatalog.warm();
494
+ }, 0);
495
+ // 人设写操作成功后立即重算目录(SWR:text() 同步返回缓存值,写后主动 refresh)。
496
+ // 包装在 service 的 ops 上:写入口只有这一处,不需要在界面层逐个补。
497
+ for (const opName of subagentService.writeOps) {
498
+ const original = subagentService.ops[opName];
499
+ if (typeof original !== 'function')
500
+ continue;
501
+ subagentService.ops[opName] = async (args) => {
502
+ const result = await original(args);
503
+ if (result && result.ok !== false)
504
+ void subagentCatalog.refresh();
505
+ return result;
506
+ };
507
+ }
508
+ /**
509
+ * 人设改名后同步场景绑定:场景档案的 `subagents` 名单存的是**人设名**,
510
+ * 不跟着改就会留一个悬空引用(界面把它报成 stale,用户看到「人设不存在」却找不到地方改)。
511
+ * 绑定存在 rules-index.json 里,人设服务看不到它,所以在这一层补一次(同提示词改名的做法)。
512
+ */
513
+ async function rebindSubagentInArchives(from, to) {
514
+ try {
515
+ const slice = await rulesService.readArchiveSlice();
516
+ const archives = { ...(slice.archives || {}) };
517
+ let changed = 0;
518
+ for (const [scene, archive] of Object.entries(archives)) {
519
+ const list = archive.subagents;
520
+ if (!Array.isArray(list) || !list.includes(from))
521
+ continue;
522
+ archives[scene] = { ...archive, subagents: list.map((n) => (n === from ? to : n)) };
523
+ changed++;
524
+ }
525
+ if (changed)
526
+ await rulesService.patchIndex({ archives });
527
+ return changed;
528
+ }
529
+ catch {
530
+ return 0;
531
+ }
532
+ }
533
+ const baseSubagentUpdate = subagentService.ops['subagent-update'];
534
+ if (typeof baseSubagentUpdate === 'function') {
535
+ subagentService.ops['subagent-update'] = async (args) => {
536
+ const res = await baseSubagentUpdate(args);
537
+ if (res && res.ok !== false && res.renamedFrom)
538
+ void rebindSubagentInArchives(String(res.renamedFrom), String(res.name));
539
+ return res;
540
+ };
541
+ }
542
+ // 模式切换会改人设开关(进入=启用勾选的,退出=按快照停回)——目录段立即重算,
543
+ // 别等 1s TTL:切完场景紧接着的下一轮请求就该看到新名单。
544
+ const baseSceneModeSet = archiveService.ops['scene-mode-set'];
545
+ if (typeof baseSceneModeSet === 'function') {
546
+ archiveService.ops['scene-mode-set'] = async (args) => {
547
+ // 锁定的场景不能关闭(用户裁定):退出模式(scene=null)和切换到别的场景
548
+ // 都意味着先退出当前模式 —— 当前模式场景处于锁定状态时一律拒绝。
549
+ // 目标就是当前场景 = 无操作,放行(引擎自己会 early-return)。
550
+ try {
551
+ const slice = await rulesService.readArchiveSlice();
552
+ const current = slice.mode && slice.mode.scene;
553
+ if (current && args && 'scene' in (args || {})) {
554
+ const target = args.scene == null ? null : String(args.scene).trim() || null;
555
+ if (target !== current) {
556
+ const locked = await lockedSceneNames();
557
+ if (locked.includes(current)) {
558
+ return { ok: false, error: `场景「${current}」已锁定:先解锁再关闭` };
559
+ }
560
+ }
561
+ }
562
+ }
563
+ catch { /* 守卫读状态失败不拦正常流程(引擎自身校验兜底) */ }
564
+ const res = await baseSceneModeSet(args);
565
+ if (res && res.ok !== false)
566
+ void subagentCatalog.refresh();
567
+ return res;
568
+ };
569
+ }
570
+ /**
571
+ * 模式进行中保存当前场景的档案时,联动人设开关:subagents 段新勾进来的名字**立即启用**,
572
+ * 并追加进快照的「模式期间自动启用」名单(退出时停回)。只启用、不停用 —— 用户手动
573
+ * 关掉的开关不该被一次档案保存悄悄改回去。
574
+ */
575
+ const baseSceneArchiveSave = archiveService.ops['scene-archive-save'];
576
+ if (typeof baseSceneArchiveSave === 'function') {
577
+ archiveService.ops['scene-archive-save'] = async (args) => {
578
+ const res = await baseSceneArchiveSave(args);
579
+ try {
580
+ const bound = res && res.ok !== false && res.archive && Array.isArray(res.archive.subagents) ? res.archive.subagents : [];
581
+ if (bound.length) {
582
+ const slice = await rulesService.readArchiveSlice();
583
+ const mode = slice.mode;
584
+ if (mode && mode.scene === res.scene) {
585
+ const disabled = await subagentService.enabledStore.disabledAmong(bound);
586
+ if (disabled.length) {
587
+ await subagentService.enabledStore.setEnabled(disabled, true);
588
+ void subagentCatalog.refresh();
589
+ const snapshot = mode.snapshot;
590
+ if (snapshot) {
591
+ const merged = Array.isArray(snapshot.subagents) ? snapshot.subagents.slice() : [];
592
+ for (const n of disabled)
593
+ if (merged.indexOf(n) < 0)
594
+ merged.push(n);
595
+ await rulesService.patchIndex({ mode: { ...mode, snapshot: { ...snapshot, subagents: merged } } });
596
+ }
597
+ }
598
+ }
599
+ }
600
+ }
601
+ catch { /* 联动失败不阻断档案保存本身;开关会在下次进/退模式时对齐 */ }
602
+ return res;
603
+ };
604
+ }
393
605
  // HTTP 写操作门禁清单。skills/rules/档案引擎域由各自 service 导出的 writeOps 派生
394
606
  // (与其 ops 表同文件维护,新增写 op 改对应 service 即可);本文件内联域
395
607
  // (mcpm-* / skill-open / agentsmd-* / history-*)在此列举。
@@ -415,6 +627,8 @@ export default {
415
627
  // workspace-register 会新增一条宿主工作区登记,同样是写)
416
628
  'history-archive', 'history-unarchive', 'history-delete', 'history-retention-set',
417
629
  'history-unarchive-batch', 'history-delete-batch', 'history-import', 'history-export',
630
+ // 通用导出:往用户指定的目录写文件,按写操作门禁(token)。
631
+ 'bundle-export',
418
632
  'history-archive-batch', 'history-workspace-register',
419
633
  ]);
420
634
  // 会泄露明文凭据 / 完整配置的 op:**必须**带对的访问令牌,没配令牌就一律拒绝
@@ -2684,13 +2898,16 @@ export default {
2684
2898
  'agentsmd-create': (args) => agentsMdService.create(String((args && args.id) || ''), {
2685
2899
  ...(args && args.from ? { from: String(args.from) } : {}),
2686
2900
  ...(args && typeof args.content === 'string' ? { content: String(args.content) } : {}),
2901
+ ...(args && typeof args.description === 'string' ? { description: String(args.description) } : {}),
2687
2902
  }),
2688
2903
  // 保存:改正文 + 可选改名(nextId)。改名成功顺带把场景绑定一起改名 ——
2689
2904
  // 绑定存在 rules-index.json 里,预设库自己看不到,不叫这一声就会留悬空绑定。
2690
2905
  // 保存后同步一次全局基线:若改的正是「正在驱动基线的那份」,正文要跟着写进
2691
2906
  // `~/.dsh/AGENTS.md`(否则页面标着「生效中」而文件里还是旧内容)。
2692
2907
  'agentsmd-update': async (args) => {
2693
- const res = await agentsMdService.update(String((args && args.id) || ''), String((args && args.content) ?? ''), args && args.nextId !== undefined ? String(args.nextId) : undefined);
2908
+ const res = await agentsMdService.update(String((args && args.id) || ''), String((args && args.content) ?? ''), args && args.nextId !== undefined ? String(args.nextId) : undefined,
2909
+ // 描述与正文同一次提交:省略(undefined)= 不动,空串 = 清空。
2910
+ args && typeof args.description === 'string' ? String(args.description) : undefined);
2694
2911
  if (!res || res.ok === false)
2695
2912
  return res;
2696
2913
  let renamed = {};
@@ -2716,7 +2933,7 @@ export default {
2716
2933
  }
2717
2934
  return agentsMdService.remove(id);
2718
2935
  },
2719
- 'agentsmd-import': (args) => agentsMdService.importPreset(String((args && args.id) || ''), String((args && args.content) ?? '')),
2936
+ 'agentsmd-import': (args) => agentsMdService.importPreset(String((args && args.id) || ''), String((args && args.content) ?? ''), args && typeof args.description === 'string' ? String(args.description) : undefined),
2720
2937
  // 提示词预设的回收站:删预设 = 把整个预设目录移入 `hub/trash/agents-md-trash/<id>/`,
2721
2938
  // 误删可从「提示词」页的回收站里恢复(同 id 已存在时拒绝恢复,绝不覆盖)。
2722
2939
  'agentsmd-trash-list': () => agentsMdService.trashList(),
@@ -2924,6 +3141,7 @@ export default {
2924
3141
  mayDelete: assessment.mayDelete,
2925
3142
  verifiedVersion: VERIFIED_HOST_VERSION,
2926
3143
  expectedPeerRange: EXPECTED_PEER_RANGE,
3144
+ minHostVersion: EXPECTED_MIN_HOST_VERSION,
2927
3145
  generatedAt: assessment.generatedAt,
2928
3146
  summary,
2929
3147
  };
@@ -3098,6 +3316,130 @@ export default {
3098
3316
  },
3099
3317
  // 把选中的归档会话导出为可再导入的转录文件(Markdown / JSONL),写入指定目录。
3100
3318
  // 每个会话一个文件;读不到正文的冷会话列入 skipped,不中断其余导出。
3319
+ // ---------- 通用导出(D14)----------
3320
+ // 技能 / 子智能体 / 提示词 / 记忆四个域的差异只有「从哪取哪些文件」,其余
3321
+ //(绝对路径校验、建目录、zip 打包、错误回报)完全相同 —— 所以只加**一个** op。
3322
+ // 打包用已在依赖里的 `fflate.zipSync`(此前只用了 unzipSync)。
3323
+ 'bundle-export': async (args) => {
3324
+ const kind = String((args && args.kind) || '');
3325
+ const KINDS = ['skills', 'subagents', 'presets', 'memories'];
3326
+ if (KINDS.indexOf(kind) < 0)
3327
+ return { ok: false, error: `kind 需为 ${KINDS.join(' / ')}` };
3328
+ const rawNames = (args && args.names) || [];
3329
+ const names = Array.isArray(rawNames) ? rawNames.map((s) => String(s).trim()).filter(Boolean) : [];
3330
+ if (!names.length)
3331
+ return { ok: false, error: '请至少选择一项' };
3332
+ const outDir = String((args && args.outDir) || '').trim();
3333
+ // 与 history-export 同一条校验:必须绝对路径(相对路径会在宿主进程的 cwd 下落盘,
3334
+ // 用户根本找不到文件)。
3335
+ if (!/^([A-Za-z]:[\\/]|\\\\|\/)/.test(outDir))
3336
+ return { ok: false, error: '导出目录需为绝对路径' };
3337
+ // ① 收集 (zip 内路径, 绝对路径)。
3338
+ const entries = [];
3339
+ const missing = [];
3340
+ try {
3341
+ if (kind === 'subagents') {
3342
+ const dir = defaultPersonasDir();
3343
+ for (const name of names)
3344
+ entries.push({ zip: `${name}.md`, abs: join(dir, `${name}.md`) });
3345
+ }
3346
+ else if (kind === 'presets') {
3347
+ for (const id of names) {
3348
+ entries.push({ zip: `${id}/AGENTS.md`, abs: join(agentsMdPresetsDir, id, 'AGENTS.md') });
3349
+ // 描述侧车(「只给使用者看」的那句)随预设一起走:导出再导入不该把它丢掉。
3350
+ const metaAbs = join(agentsMdPresetsDir, id, 'meta.json');
3351
+ try {
3352
+ if ((await stat(metaAbs)).isFile())
3353
+ entries.push({ zip: `${id}/meta.json`, abs: metaAbs });
3354
+ }
3355
+ catch { /* 没写描述 */ }
3356
+ }
3357
+ }
3358
+ else if (kind === 'memories') {
3359
+ // 记忆有 flat(`<场景>/<name>.md`)与 bundle(`<场景>/<name>/SKILL.md`)两种形态,
3360
+ // 且场景名本身可含 '/' —— 按 id 拼路径会把 bundle 读成 `<场景>/<name>.md`,读不到
3361
+ // 就静默丢项(详见 planMemoryExport)。所以走索引拿那条规则自己的真实路径。
3362
+ const r = await rulesService.ops['rules-list']({});
3363
+ const plan = planMemoryExport(names, (r && r.rules) || []);
3364
+ missing.push(...plan.missing);
3365
+ for (const item of plan.entries) {
3366
+ if (item.kind === 'file') {
3367
+ entries.push({ zip: item.zip, abs: item.abs });
3368
+ continue;
3369
+ }
3370
+ // bundle:把目录内的文件全部打包(与技能分支同口径),单个目录读不了只丢它自己。
3371
+ try {
3372
+ for (const e of await readdir(item.abs, { withFileTypes: true })) {
3373
+ if (e.isFile())
3374
+ entries.push({ zip: `${item.zip}/${e.name}`, abs: join(item.abs, e.name) });
3375
+ }
3376
+ }
3377
+ catch {
3378
+ missing.push(item.id);
3379
+ }
3380
+ }
3381
+ }
3382
+ else {
3383
+ // 技能:先经 skill-detail 拿源文件路径(bundle 型是目录,单文件型是 .md)。
3384
+ for (const key of names) {
3385
+ const i = key.indexOf('/');
3386
+ if (i <= 0) {
3387
+ missing.push(key);
3388
+ continue;
3389
+ }
3390
+ const short = key.slice(i + 1);
3391
+ const r = await skillsService.ops['skill-detail']({ root: key.slice(0, i), name: short });
3392
+ const p = r && r.ok !== false && r.data ? String(r.data.path || '') : '';
3393
+ if (!p) {
3394
+ missing.push(key);
3395
+ continue;
3396
+ }
3397
+ let isDir = false;
3398
+ try {
3399
+ isDir = (await stat(p)).isDirectory();
3400
+ }
3401
+ catch {
3402
+ isDir = false;
3403
+ }
3404
+ if (isDir) {
3405
+ for (const e of await readdir(p, { withFileTypes: true })) {
3406
+ if (e.isFile())
3407
+ entries.push({ zip: `${short}/${e.name}`, abs: join(p, e.name) });
3408
+ }
3409
+ }
3410
+ else {
3411
+ entries.push({ zip: `${short}/${basename(p)}`, abs: p });
3412
+ }
3413
+ }
3414
+ }
3415
+ }
3416
+ catch (e) {
3417
+ return { ok: false, error: '收集待导出文件失败: ' + message(e) };
3418
+ }
3419
+ // ② 读盘 + 打包(读不到的项进 missing,不整体失败 —— 部分成功也有价值)。
3420
+ const bundle = {};
3421
+ for (const item of entries) {
3422
+ try {
3423
+ bundle[item.zip] = new Uint8Array(await readFile(item.abs));
3424
+ }
3425
+ catch {
3426
+ missing.push(item.zip);
3427
+ }
3428
+ }
3429
+ if (!Object.keys(bundle).length)
3430
+ return { ok: false, error: '没有可导出的文件(选中项都不存在?)' };
3431
+ let zipPath = '';
3432
+ try {
3433
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
3434
+ zipPath = join(outDir, `dsh-tool-management-${kind}-${stamp}.zip`);
3435
+ await mkdir(outDir, { recursive: true });
3436
+ await writeFile(zipPath, Buffer.from(zipSync(bundle)));
3437
+ }
3438
+ catch (e) {
3439
+ return { ok: false, error: '写入导出文件失败: ' + message(e) };
3440
+ }
3441
+ return { ok: true, written: Object.keys(bundle).length, zipPath, missing };
3442
+ },
3101
3443
  'history-export': async (args) => {
3102
3444
  const rawIds = (args && args.sessionIds) || [];
3103
3445
  const ids = Array.isArray(rawIds) ? rawIds.map((s) => String(s).trim()).filter(Boolean) : [];
@@ -3193,6 +3535,87 @@ export default {
3193
3535
  return { ok: true, retentionDays: days };
3194
3536
  },
3195
3537
  };
3538
+ // MCP 写操作成功后立即重算状态段(SWR:text() 同步返回缓存值,写后主动 refresh)。
3539
+ // 覆盖所有会改变「哪些 server 可用 / 有哪些备注」的写入口 —— 集中在这一处包,
3540
+ // 不需要在界面层逐个补(漏一个就会出现「改了但模型看不到」的静默不一致)。
3541
+ for (const opName of ['mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all',
3542
+ 'mcpm-restart', 'mcpm-note', 'mcpm-tool-enabled', 'mcpm-import', 'mcpm-compact']) {
3543
+ const original = handlers[opName];
3544
+ if (typeof original !== 'function')
3545
+ continue;
3546
+ handlers[opName] = async (args) => {
3547
+ const result = await original(args);
3548
+ if (result && result.ok !== false)
3549
+ void mcpStateCatalog.refresh();
3550
+ return result;
3551
+ };
3552
+ }
3553
+ // ---------- 场景锁定守卫(v0.8)----------
3554
+ // 任一场景 locked=true = 五个管理域(MCP/技能/子智能体/记忆/提示词)整体冻结:
3555
+ // 下面列出的写 op 一律拒绝;界面按钮同步禁用,这里是兜底(防止绕过界面直接打 op)。
3556
+ // 只包 **handlers** 这一层是有意的:进/退模式的运行时应用走的是内部函数与
3557
+ // service.ops(applyMcpServerSwitches / applySkills / applySubagentSwitches / patchIndex),
3558
+ // 不经过 handlers —— 锁定就是为了让场景能按原样启动,运行时应用不能被自己挡住。
3559
+ // 例外里的例外:scene-archive-save / rules-remove-scene 只对**被锁的那个场景**拒绝。
3560
+ async function lockedSceneNames() {
3561
+ const r = await rulesService.ops['rules-list']({});
3562
+ return ((r && r.scenes) || []).filter((s) => s.locked === true).map((s) => String(s.name));
3563
+ }
3564
+ function guardLockedOps(opNames, what) {
3565
+ for (const opName of opNames) {
3566
+ const original = handlers[opName];
3567
+ if (typeof original !== 'function')
3568
+ continue;
3569
+ handlers[opName] = async (args) => {
3570
+ const locked = await lockedSceneNames();
3571
+ if (locked.length)
3572
+ return { ok: false, error: `场景已锁定(${locked.join('、')}):先到场景页解锁再${what}` };
3573
+ return original(args);
3574
+ };
3575
+ }
3576
+ }
3577
+ guardLockedOps([
3578
+ // MCP:改配置 / 服务器启停 / 工具启停 / 导入导出配置 / 备注 / 设置。restart 只重连不改配置,放行。
3579
+ 'mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all', 'mcpm-tool-enabled', 'mcpm-import', 'mcpm-compact', 'mcpm-note', 'mcpm-settings',
3580
+ // 技能:启停 / 来源启停与移除恢复 / 首选 / 删除 / 创建导入 / 自定义目录 / 回收站。
3581
+ 'skill-enable', 'skill-disable', 'skill-source-enable', 'skill-source-disable', 'skill-source-remove', 'skill-source-restore',
3582
+ 'skill-prefer', 'skill-unprefer', 'skill-delete', 'skill-create', 'skill-import', 'skill-upload',
3583
+ 'skill-custom-add', 'skill-custom-remove', 'skill-trash-restore', 'skill-trash-delete',
3584
+ // 子智能体:开关 / 改名保存 / 删除 / 导入 / 回收站。
3585
+ 'subagent-create', 'subagent-update', 'subagent-delete', 'subagent-toggle', 'subagent-import', 'subagent-trash-restore', 'subagent-trash-delete',
3586
+ // 记忆:增删改 / 开关 / 导入 / 回收站 / 绑定。set-active 是场景启停,不在冻结范围。
3587
+ 'rules-create', 'rules-update', 'rules-remove', 'rules-toggle', 'rules-import', 'rules-restore', 'rules-trash-remove', 'rules-attach', 'rules-detach', 'rules-set-index',
3588
+ // 提示词:建改删 / 应用(切换生效基线)/ 导入 / 回收站。
3589
+ 'agentsmd-create', 'agentsmd-update', 'agentsmd-remove', 'agentsmd-apply', 'agentsmd-import', 'agentsmd-trash-restore', 'agentsmd-trash-delete',
3590
+ ], '修改');
3591
+ // 被锁场景自身的档案与删除:只挡它自己,别的场景照常。
3592
+ for (const [opName, pickScene, what] of [
3593
+ ['scene-archive-save', (args) => (args && args.scene) || '', '改档案'],
3594
+ ['rules-remove-scene', (args) => (args && args.name) || '', '删除'],
3595
+ ]) {
3596
+ const original = handlers[opName];
3597
+ if (typeof original !== 'function')
3598
+ continue;
3599
+ handlers[opName] = async (args) => {
3600
+ const scene = String(pickScene(args) || '').trim();
3601
+ const locked = await lockedSceneNames();
3602
+ if (scene && locked.includes(scene))
3603
+ return { ok: false, error: `场景「${scene}」已锁定:先解锁再${what}` };
3604
+ return original(args);
3605
+ };
3606
+ }
3607
+ // 各页列表响应带上 anyLocked:界面据此禁用写控件(读 op,附加字段不影响既有消费方)。
3608
+ async function annotateLocked(res) {
3609
+ if (res && res.ok !== false)
3610
+ res.anyLocked = (await lockedSceneNames()).length > 0;
3611
+ return res;
3612
+ }
3613
+ for (const opName of ['mcpm-list', 'mcpm-reveal', 'skill-state', 'subagent-list', 'agentsmd-list']) {
3614
+ const original = handlers[opName];
3615
+ if (typeof original !== 'function')
3616
+ continue;
3617
+ handlers[opName] = async (args) => annotateLocked(await original(args));
3618
+ }
3196
3619
  // ---------- agent-facing tools (standard ctx.tools.register + defineTool) ----------
3197
3620
  const text = (value) => [{ type: 'text', text: value }];
3198
3621
  tools.register(defineTool({
@@ -3334,12 +3757,12 @@ export default {
3334
3757
  // 同上:AGENTS.md 由 dsh-agent-instructions 行承载,预设没挂这一行(或 persona
3335
3758
  // 是 complete)时文件内容不会进提示词。
3336
3759
  const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx);
3337
- return 'AGENTS.md presets:\n' + (summary.join('\n') || '(none)') + '\n(Applying takes effect on the next session created; the current session is unchanged.)' + notice;
3760
+ return 'AGENTS.md presets:\n' + (summary.join('\n') || '(none)') + '\n(DSH re-reads ~/.dsh/AGENTS.md every turn, so applying takes effect on the next turn.)' + notice;
3338
3761
  },
3339
3762
  }));
3340
3763
  tools.register(defineTool({
3341
3764
  name: 'agentsmd_apply',
3342
- description: 'Apply one AGENTS.md preset by writing it to ~/.dsh/AGENTS.md. Takes effect on the next session created; the current session is unchanged.',
3765
+ description: 'Apply one AGENTS.md preset by writing it to ~/.dsh/AGENTS.md. DSH re-reads that file every turn, so it takes effect on the next turn.',
3343
3766
  parameters: {
3344
3767
  id: { type: 'string', required: true, description: 'Preset id (lowercase letters, digits, hyphens).' },
3345
3768
  },