dsh-plugin-tool-management 0.20.3 → 0.21.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 (42) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +4 -1
  3. package/README_EN.md +4 -1
  4. package/docs/update.md +1 -0
  5. package/lib/client.js +117 -11
  6. package/lib/compat/capability-specs.js +60 -1
  7. package/lib/compat/host-contracts.js +72 -0
  8. package/lib/compat/host-probe.js +59 -0
  9. package/lib/compat/host-roots.js +1 -1
  10. package/lib/compat/probe.js +84 -13
  11. package/lib/context-inject-frame.js +134 -26
  12. package/lib/context-inject.js +12 -9
  13. package/lib/http-api.js +3 -2
  14. package/lib/http-fence.js +26 -1
  15. package/lib/index.js +5 -2
  16. package/lib/mcp/manager.js +6 -1
  17. package/lib/mcp/state-section.js +75 -34
  18. package/lib/memories/constants.js +39 -7
  19. package/lib/memories/projection.js +18 -18
  20. package/lib/memories/service.js +8 -7
  21. package/lib/memories/snapshot.js +27 -17
  22. package/lib/ops/compat.js +64 -2
  23. package/lib/ops/memory.js +2 -2
  24. package/lib/ops/prompts.js +30 -2
  25. package/lib/request-gate.js +5 -4
  26. package/lib/skills/catalog.js +2 -2
  27. package/lib/subagents/catalog.js +4 -4
  28. package/lib/subagents/child-preset.js +120 -0
  29. package/lib/subagents/persona-parse.js +9 -9
  30. package/lib/subagents/run-provider.js +218 -62
  31. package/lib/subagents/tool-filter.js +12 -12
  32. package/lib/subagents/tools.js +29 -22
  33. package/lib/tools/deps.js +4 -4
  34. package/lib/tools/mcp.js +34 -34
  35. package/lib/tools/memory.js +34 -34
  36. package/lib/tools/prompt.js +5 -5
  37. package/lib/tools/remove.js +17 -17
  38. package/lib/tools/scene.js +66 -66
  39. package/lib/tools/skills.js +21 -21
  40. package/lib/tools/subagent.js +13 -13
  41. package/lib/tools/table.js +2 -2
  42. package/package.json +25 -24
@@ -11,7 +11,7 @@ import { readdirSync, statSync } from 'node:fs';
11
11
  import { readFile } from 'node:fs/promises';
12
12
  import { dirname, join } from 'node:path';
13
13
  import { parseSkillDoc } from '../skills/core.js';
14
- import { MAX_SOURCE_DEPTH, MAX_DIRECTORIES, MAX_ENTRIES, DEFAULT_ORDER, DEFAULT_GROUP_ORDER, GLOBAL_SCENE, TRUNCATION_MARKER, DROPPED_HEADING, SCENE_MEMORY_NOTE, LEGACY_BUNDLE_DOC, bundleDocName, SHARED_GROUP, byteLen, message, isValidGroupSegment } from './constants.js';
14
+ import { MAX_SOURCE_DEPTH, MAX_DIRECTORIES, MAX_ENTRIES, DEFAULT_ORDER, DEFAULT_GROUP_ORDER, GLOBAL_SCENE, TRUNCATION_MARKER, DROPPED_HEADING, SCENE_MEMORY_NOTE, SCENE_MEMORY_NOTE_PARTIAL, LEGACY_BUNDLE_DOC, bundleDocName, SHARED_GROUP, byteLen, message, isValidGroupSegment } from './constants.js';
15
15
  import { ensureSceneRecords, resolveActiveScenes, signatureOfIndex, sceneLabel, sceneHeading, sceneLine, compareSceneBuckets, memoryBlock } from './projection.js';
16
16
  import { isIndexQuarantined, readIndex, writeIndex, readFileOrThrowSync, isAbsentError } from './index-io.js';
17
17
  // 来源发现与投影(扫目录读成条目 / frontmatter 投成规则 / 老布局搬家)2026-10-01 整段搬到
@@ -326,7 +326,7 @@ export function renderSceneMemory(probe, index, maxBytes) {
326
326
  // 场景块只留标题:**场景说明归场景段**(`renderSceneCatalog`),否则同一句话会在
327
327
  // 上下文里出现两遍。记忆段要的是"这些条目属于哪个场景"这个分组标签。
328
328
  // `sceneHeading` 只给标题、不自带结尾空行(场景段的 `sceneLine` 才自带),这里补上 ——
329
- // 否则标题会与紧跟的引导语/条目贴成一行(`## 场景:全局**以下是…**`)。
329
+ // 否则标题会与紧跟的引导语/条目贴成一行(`## Always-on memories**Memories for…**`)。
330
330
  const header = sceneHeading(scene) + '\n\n';
331
331
  for (const file of sceneFiles) {
332
332
  const { text: block, inline } = memoryBlock(file);
@@ -344,16 +344,16 @@ export function renderSceneMemory(probe, index, maxBytes) {
344
344
  return { text: '', bytes: 0, truncated: probe.truncated, maxBytes, scenes: [], items: [], dropped: [] };
345
345
  }
346
346
  /**
347
- * 选中块 → 段正文(同一场景的 `## 场景:x` 只在首次出现时发出)。
347
+ * 选中块 → 段正文(同一场景的 `## Scene: x` 只在首次出现时发出)。
348
348
  *
349
- * 授权语**只有一版**:2026-09-23 第四次裁定删掉了完整性声明(「以下就是全部信息」),
350
- * 于是"按截断状态选长短两版"的自适应失去意义(两版会完全相同),`dropped` 参数一并移除。
351
- * 详见 `SCENE_MEMORY_NOTE` 的注释 —— 代价是这一段不再有防探测手段。
349
+ * 授权语**两版**(2026-10-18 审查恢复分叉,理由见 `SCENE_MEMORY_NOTE_PARTIAL`):不截断时带
350
+ * "this is the complete set",截断时去掉 —— 否则段首的完整性与段尾的 `## Not injected` 直接
351
+ * 打架。预算仍按全量版预留(`sceneNoteBytes()`),截断态那句更短,不会多占。
352
352
  */
353
- const renderBody = (selected) => {
353
+ const renderBody = (selected, truncated) => {
354
354
  if (selected.length === 0)
355
355
  return '';
356
- const note = SCENE_MEMORY_NOTE;
356
+ const note = truncated ? SCENE_MEMORY_NOTE_PARTIAL : SCENE_MEMORY_NOTE;
357
357
  const chunks = [];
358
358
  let current = null;
359
359
  let buf = '';
@@ -380,7 +380,7 @@ export function renderSceneMemory(probe, index, maxBytes) {
380
380
  // 引导语只出现**一次**,放在板块层(第一个场景块之前)。
381
381
  //
382
382
  // 为什么挪上来(用户 2026-09-23 看到实际注入后要求):此前它跟着**每个场景块**各来一遍
383
- // —— 2 个场景就是 272 B ≈68 tok/轮,占记忆段整段的 22%;而且它夹在 `## 场景:X` 与条目
383
+ // —— 2 个场景就是 272 B ≈68 tok/轮,占记忆段整段的 22%;而且它夹在 `## Scene: X` 与条目
384
384
  // 之间,把"分组"和"内容"隔开了。它管的本来就是整段(授权语讲的是这一整段条目的效力,
385
385
  // 不是某个场景的),放在最前面才对得上。
386
386
  //
@@ -401,7 +401,8 @@ export function renderSceneMemory(probe, index, maxBytes) {
401
401
  const lines = [];
402
402
  let used = byteLen(head) + byteLen(marker);
403
403
  for (const c of missed) {
404
- const line = `- ${sceneLabelOf(c.scene)}/${c.item.name}(${c.item.bytes} B)\n`;
404
+ // 只给 `场景/名称`:字节数对人有用、对模型没有动作价值(2026-10-18 审查)。
405
+ const line = `- ${sceneLabelOf(c.scene)}/${c.item.name}\n`;
405
406
  if (used + byteLen(line) > space)
406
407
  break;
407
408
  lines.push(line);
@@ -410,8 +411,13 @@ export function renderSceneMemory(probe, index, maxBytes) {
410
411
  if (lines.length === 0)
411
412
  return byteLen(marker) <= space ? marker : '';
412
413
  const rest = missed.length - lines.length;
413
- const fold = `- …(其余 ${rest} 条未列出)\n`;
414
- const tail = `${head}${lines.join('')}${rest > 0 && used + byteLen(fold) <= space ? fold : ''}${marker}`;
414
+ const fold = `- ... (${rest} more not listed)\n`;
415
+ // 清单已经列全时**不再加标记**:`## Not injected` 这个标题本身就说清了"有东西没进来",
416
+ // 再补一句 `(Truncated: …)` 只是把同一件事说两遍。标记只在清单**自己也不全**(有没列出的
417
+ // 条目)时才有信息量 —— 注意判据是 `rest > 0`,不是"折叠行放得下":折叠行放不下时清单
418
+ // 同样是残缺的,标记不能跟着一起消失(那会让残缺的清单看起来是全部)。
419
+ const folded = rest > 0 && used + byteLen(fold) <= space;
420
+ const tail = `${head}${lines.join('')}${folded ? fold : ''}${rest > 0 ? marker : ''}`;
415
421
  return tail;
416
422
  };
417
423
  // ── ① 贪心:放得下就放,越界**跳过**(而不是整体停止)──────────────────
@@ -436,13 +442,17 @@ export function renderSceneMemory(probe, index, maxBytes) {
436
442
  }
437
443
  missed.sort((a, b) => a.seq - b.seq);
438
444
  // ── ② 尾注自身也占字节:放不下就把已入选的块从后往前退回,直到回到预算内 ──
439
- let body = renderBody(selected);
440
- let tail = renderTail(missed, maxBytes - byteLen(body), probe.truncated || missed.length > 0);
445
+ // 引导语要不要带完整性声明,取决于**最终**有没有截断 —— 所以每轮重算 body(贪心阶段不知道
446
+ // 结论)。两版字数差在 3 个词以内,预算按全量版预留,不会因此挤掉条目。
447
+ let truncatedNow = probe.truncated || missed.length > 0;
448
+ let body = renderBody(selected, truncatedNow);
449
+ let tail = renderTail(missed, maxBytes - byteLen(body), truncatedNow);
441
450
  while (byteLen(body) + byteLen(tail) > maxBytes && selected.length > 0) {
442
451
  missed.push(selected.pop());
443
452
  missed.sort((a, b) => a.seq - b.seq);
444
- body = renderBody(selected);
445
- tail = renderTail(missed, maxBytes - byteLen(body), true);
453
+ truncatedNow = true;
454
+ body = renderBody(selected, truncatedNow);
455
+ tail = renderTail(missed, maxBytes - byteLen(body), truncatedNow);
446
456
  }
447
457
  let text = `${body}${tail}`.replace(/^\n+/, '');
448
458
  // ③ 兜底:预算小到连标记都放不下时也宁可超出几个字节——"有记忆没注入"这件事
@@ -456,7 +466,7 @@ export function renderSceneMemory(probe, index, maxBytes) {
456
466
  return {
457
467
  text,
458
468
  bytes: byteLen(text),
459
- truncated: probe.truncated || missed.length > 0,
469
+ truncated: truncatedNow,
460
470
  maxBytes,
461
471
  scenes,
462
472
  items: selected.map((c) => c.item),
package/lib/ops/compat.js CHANGED
@@ -11,7 +11,22 @@ import { INJECT_DOMAIN_KEYS } from '../context-inject.js';
11
11
  import { MCP_TOOL_PREFIX } from '../host-names.js';
12
12
  import { assessPresetReach } from '../compat/preset-reach.js';
13
13
  import { runtimeNotes } from '../compat/runtime-notes.js';
14
+ import { AGENTS_MD_REFRESH_PACKAGE } from '../compat/host-contracts.js';
14
15
  import { pluginLog } from '../skills/service.js';
16
+ /**
17
+ * 功能总览里那几条「宿主契约」行对应的**能力 id**(capability-specs.ts 那张表)。
18
+ *
19
+ * 为什么要一张显式映射:`findings` 的 id 是宿主侧的稳定契约名(`agentPreset.register` /
20
+ * `tools.restrict-method` / `agent.created-event` / `connection.admit`),而行 key 是本插件自己
21
+ * 的界面标识(`subagent-persona` / ...)。两边命名空间不同,写成字面量表最直白,也最不容易
22
+ * 在改名时悄悄对错。
23
+ */
24
+ const CONTRACT_CAPABILITY_IDS = {
25
+ 'subagent-persona': 'agentPreset.register',
26
+ 'subagent-tool-restrict': 'tools.restrict-method',
27
+ 'subagent-created-event': 'agent.created-event',
28
+ 'web-admission': 'connection.admit',
29
+ };
15
30
  export function buildCompatOps(deps) {
16
31
  return {
17
32
  'compat-status': async (args) => {
@@ -72,6 +87,9 @@ export function buildCompatOps(deps) {
72
87
  // 修复指引按实际形态给:`scripts/` 不在 `files` 里,npm 安装形态下没有
73
88
  // `scripts/doctor.mjs` 与 `scripts/host-deps.mjs`,指了也跑不了。
74
89
  sourceInstall: isSourceInstall(),
90
+ // 宿主非 peer 契约的实况:插件按裸模块名 require 的那几个官方包。与 findings 不同源
91
+ // (模块层 vs 服务层),所以单独一个字段,界面据此列出"裸模块解析"那一块。
92
+ ...(assessment.contracts === undefined ? {} : { contracts: assessment.contracts }),
75
93
  findings,
76
94
  degraded,
77
95
  blockers: assessment.identity.blockers,
@@ -297,9 +315,53 @@ export function buildCompatOps(deps) {
297
315
  ...(asarHost === undefined ? {} : { asarHost: true, asarName: asarHost.asarName }),
298
316
  }),
299
317
  });
300
- // 无独立装配点的域:没有上报就是正常(如实说明依据是"没有降级上报")。
318
+ // 宿主契约层(0.2.1 新增的两处 + 一个连接面):这几条**不是**上面那些从 0.1.x 起
319
+ // 就在表里的能力,而是本版适配真实依赖的宿主成员。它们的共同点是缺了**不抛错**,
320
+ // 只是功能少一半 —— 所以必须在总览里占一行,让"升级后某处悄悄不工作"有地方看。
321
+ //
322
+ // 逐条的后果(与 capability-specs.ts 里的 fallback 一一对应):
323
+ // · agentPreset.register 没了 → 带人设的子代理委派失败(临时预设注册不上去);
324
+ // · tools.restrict-method 没了 → 人设的 tools/toolsDeny 不生效,子代理继承父级全部工具;
325
+ // · agent.created-event 串不起来 → 工具限制慢一步(首轮看到的还是继承来的工具集);
326
+ // · connection.admit 没了 → Web 鉴权退回 requestRejection,再缺则退回本地回环判定。
327
+ const findingOf = (id) => assessment === undefined ? undefined : assessment.findings.find((item) => item.id === id);
328
+ const contractRows = [
329
+ ['subagent-persona', '子智能体人设委派', 'subagents'],
330
+ ['subagent-tool-restrict', '子代理工具限制', 'subagents'],
331
+ ['subagent-created-event', '子代理创建时序', 'subagents'],
332
+ ['web-admission', 'Web 连接准入', 'compat'],
333
+ ];
334
+ for (const [key, label, tab] of contractRows) {
335
+ const finding = findingOf(CONTRACT_CAPABILITY_IDS[key] ?? '');
336
+ push({
337
+ key, label, tab,
338
+ state: finding === undefined ? 'unknown' : finding.state === 'ok' ? 'ok' : 'degraded',
339
+ detail: finding === undefined ? '能力探测不可用(归档服务未挂载)' : finding.detail,
340
+ ...(finding === undefined ? {} : { capabilityId: finding.id }),
341
+ });
342
+ }
343
+ // 无独立装配点的域:这一行的依据必须是**真的查过什么**,不能拿一句"无降级上报"糊过去 ——
344
+ // 那句话对没有上报通道的域恒真(它只说明"没坏到会上报",不说明"现在好不好")。
345
+ // 有宿主模块可核的域(提示词走 AGENTS.md 裸模块,子智能体还要加上人设包)按实况说;
346
+ // 记忆 / 场景档案确实没有可核的宿主模块,那才停留在"无降级上报"。
347
+ const contractFeature = (packageName) => assessment?.contracts?.packages.find((item) => item.package === packageName);
301
348
  for (const [key, label, tab] of [['memory', '记忆', 'memory'], ['prompts', '提示词', 'prompts'], ['subagents', '子智能体', 'subagents'], ['scenes', '场景档案', 'scenes']]) {
302
- push({ key, label, tab, state: 'ok', detail: '无降级上报(装配失败会出现在这里)' });
349
+ // 子智能体的依据是那三条宿主契约 + AGENTS.md 裸模块(人设正文要走它注入)。
350
+ const persona = contractFeature('@deepseek-ai/dsh-persona');
351
+ const instructions = contractFeature(AGENTS_MD_REFRESH_PACKAGE);
352
+ const deps = key === 'subagents'
353
+ ? [persona, instructions].filter((item) => item !== undefined)
354
+ : key === 'prompts' ? [instructions].filter((item) => item !== undefined) : [];
355
+ const broken = deps.filter((item) => item.resolved === null || item.missingMarkers.length > 0);
356
+ push({
357
+ key, label, tab,
358
+ state: broken.length > 0 ? 'degraded' : 'ok',
359
+ detail: broken.length > 0
360
+ ? '宿主契约缺失:' + broken.map((item) => item.feature).join('、')
361
+ : deps.length > 0
362
+ ? '依赖的宿主模块在位(' + deps.map((item) => item.feature).join('、') + ')'
363
+ : '无降级上报(装配失败会出现在这里)',
364
+ });
303
365
  }
304
366
  return { ok: true, rows, generatedAt: Date.now() };
305
367
  }
package/lib/ops/memory.js CHANGED
@@ -9,7 +9,7 @@ import { lstat, mkdir, readFile, rename, rm } from 'node:fs/promises';
9
9
  import { basename, dirname, join } from 'node:path';
10
10
  import { parseSkillDoc, unquote } from '../skills/core.js';
11
11
  import { expandUploads, planMemoryImport } from '../imports/upload.js';
12
- import { MAX_DESCRIPTION_LENGTH, MAX_RULE_BYTES, DEFAULT_ORDER, DEFAULT_GROUP_ORDER, GLOBAL_SCENE, GLOBAL_SCENE_LABEL, TRUNCATION_MARKER, MAX_ATTACH_ENTRY_BYTES, MAX_ATTACH_TOTAL_BYTES, MAX_ATTACH_ENTRIES, LEGACY_BUNDLE_DOC, bundleDocName, SEGMENT_RULE_HINT, isValidGroupSegment, isValidGroupPath, message, fail } from '../memories/constants.js';
12
+ import { MAX_DESCRIPTION_LENGTH, MAX_RULE_BYTES, DEFAULT_ORDER, DEFAULT_GROUP_ORDER, GLOBAL_SCENE, GLOBAL_SCENE_LABEL, MAX_ATTACH_ENTRY_BYTES, MAX_ATTACH_TOTAL_BYTES, MAX_ATTACH_ENTRIES, LEGACY_BUNDLE_DOC, bundleDocName, SEGMENT_RULE_HINT, isValidGroupSegment, isValidGroupPath, message, fail } from '../memories/constants.js';
13
13
  import { normalizeActive, enabledSceneOf, sceneOf, sceneLabel, attachmentSummarySync } from '../memories/projection.js';
14
14
  import { writeFileAtomically, writeFileAtomicBinary, readIndex, writeIndex, pathExists } from '../memories/index-io.js';
15
15
  import { deriveDescription } from '../memories/snapshot.js';
@@ -195,7 +195,7 @@ export function buildMemoryOps(rc) {
195
195
  issues.push({
196
196
  severity: 'warning',
197
197
  code: 'sceneMemoryTruncated',
198
- message: `场景记忆段超出预算(${projection.bytes} 字节 > ${projection.maxBytes} 字节),已按确定性顺序截断并追加 ${TRUNCATION_MARKER};请精简记忆或只保留 _shared。`,
198
+ message: `场景记忆段超出预算(${projection.bytes} 字节 > ${projection.maxBytes} 字节),已按确定性顺序截断,段尾附未注入清单与截断标记;请精简记忆或只保留 _shared。`,
199
199
  });
200
200
  }
201
201
  return {
@@ -2,7 +2,28 @@
2
2
  //
3
3
  // 与 ops/compat.ts 同一条纪律:只负责 op 实现,依赖显式传参;MCP 写后刷新、场景锁定守卫
4
4
  // 仍作用在组装后的整张表上(见 index.ts)。
5
+ import { resolveAgentsHome } from '../skills/homes.js';
6
+ import { existsSync } from 'node:fs';
7
+ import { join } from 'node:path';
5
8
  export function buildPromptOps(deps) {
9
+ /**
10
+ * 共享 agents 根的 `AGENTS.md`(`~/.agents/AGENTS.md`)。
11
+ *
12
+ * 0.2.1 起这条文件**也在被注入**:官方 `dsh-agent-instructions` 的用户全局链是
13
+ * `$DSH_HOME/AGENTS.md` → `<agentsHome>/AGENTS.md` 两份并列(内容相同只渲染一次),
14
+ * 而本插件的提示词页只管得到第一份。于是"我改了什么都没变化"有了第二个成因:
15
+ * 真正在说话的是第二份。这里只回答**它在不在、路径在哪**,不提供写入入口 ——
16
+ * 写入会与官方自己的去重/刷新规则互相覆盖,而这份文件的读者还包括别的 agent 工具。
17
+ */
18
+ const agentsHomeAgentsMd = () => {
19
+ const path = join(resolveAgentsHome(), 'AGENTS.md');
20
+ let exists = false;
21
+ try {
22
+ exists = existsSync(path);
23
+ }
24
+ catch { /* 读不到 = 当作不在,不阻塞列表 */ }
25
+ return { path, exists };
26
+ };
6
27
  return {
7
28
  // 提示词预设库(由 ../prompts/service.ts 提供)。这里把两件事对齐成用户看到的一份状态:
8
29
  // - `active`(界面「生效中」)= **当前真正在起作用的基线**:启用的场景绑了预设就是它,
@@ -40,7 +61,7 @@ export function buildPromptOps(deps) {
40
61
  refs: refs ? refs.get(p.id) || [] : [],
41
62
  };
42
63
  });
43
- return { ...base, presets, ...(scenePrompt ? { scenePrompt } : {}) };
64
+ return { ...base, presets, ...(scenePrompt ? { scenePrompt } : {}), agentsHomeAgentsMd: agentsHomeAgentsMd() };
44
65
  },
45
66
  'agentsmd-read': (args) => deps.promptsService.read(String((args && args.id) || '')),
46
67
  // content 与 from 都可选:新建弹窗里直接写正文(content),或从现有预设复制(from)。
@@ -107,7 +128,14 @@ export function buildPromptOps(deps) {
107
128
  }
108
129
  return res;
109
130
  },
110
- 'agentsmd-get-current': () => deps.promptsService.getCurrent(),
131
+ // 与 `agentsmd-list` 同一份事实:两处都要展示"共享 agents 根那一份在不在"(列表用来
132
+ // 提示,当前页用来对照),分成两个实现必然会出现"列表说在、当前页说不在"。
133
+ 'agentsmd-get-current': async () => {
134
+ const res = await deps.promptsService.getCurrent();
135
+ if (!res || res.ok === false)
136
+ return res;
137
+ return { ...res, agentsHomeAgentsMd: agentsHomeAgentsMd() };
138
+ },
111
139
  // 删除:**场景绑定**与**当前应用的那份**拦得住(判据见下方注释)。拒绝时逐条说明「谁在用」,
112
140
  // 用户知道该先改哪里。引用清单由 scene-prompt-sync 与显示/注入同源算出。
113
141
  //
@@ -31,9 +31,10 @@ export function createAccessToken(deps) {
31
31
  // 2. defense in depth — when set, every state-changing op additionally
32
32
  // requires `x-dsh-token: <token>`.
33
33
  // It is NOT the primary gate anymore: the route calls the host's
34
- // connection.requestRejection fence first (Host must be loopback/LAN
35
- // IP-literal → defeats DNS rebinding, plus browser-session cookie auth),
36
- // so an unset token no longer means "anyone may call writes".
34
+ // connection.admit fence first (Host must be loopback/LAN IP-literal →
35
+ // defeats DNS rebinding, plus browser-session cookie auth, plus the
36
+ // owner-Principal check an exact route owes), so an unset token no longer
37
+ // means "anyone may call writes".
37
38
  // NOTE: the entry config arrives as the SECOND apply argument (Cordis
38
39
  // calls `callback(ctx, config)`) — never read it off `ctx.config`, which
39
40
  // is not an injected service and throws "cannot get property without
@@ -151,7 +152,7 @@ export function createSceneLock(deps) {
151
152
  if (!scene)
152
153
  return null;
153
154
  const locked = await lockedSceneNames();
154
- return locked.includes(scene) ? `场景「${scene}」已锁定,本次写入被拒:锁定期间该域不可修改,需用户解锁后再试。` : null;
155
+ return locked.includes(scene) ? `scene "${scene}" is locked, so this write was refused: that domain cannot be modified while the scene is locked; the user has to unlock it first.` : null;
155
156
  }
156
157
  return { lockedSceneNames, activeSceneName, lockedSceneGuard };
157
158
  }
@@ -81,8 +81,8 @@ export function renderSkillCatalog(data, maxEntries = SKILL_CATALOG_MAX_ENTRIES,
81
81
  // 它只会去猜名字。数量照报 —— 那是实话,与有没有出口无关。
82
82
  if (hidden > 0) {
83
83
  out.push('', listToolVisible
84
- ? `(另有 ${hidden} 个未列出,用 \`skill_manager_list\` 查。)`
85
- : `(另有 ${hidden} 个未列出。)`);
84
+ ? `(${hidden} more not listed — see them with \`skill_manager_list\`.)`
85
+ : `(${hidden} more not listed.)`);
86
86
  }
87
87
  return out.join('\n');
88
88
  }
@@ -13,7 +13,7 @@ export const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500;
13
13
  /** 段里最多列几个人设;超出部分只报数量,让模型自己去调 subagent_manager_list。 */
14
14
  export const DEFAULT_CATALOG_MAX_ENTRIES = 50;
15
15
  /** 无描述时的占位,与 `subagent_manager_list` 工具的输出保持同一口径。 */
16
- const NO_DESCRIPTION = '(无描述)';
16
+ const NO_DESCRIPTION = '(no description)';
17
17
  /**
18
18
  * 描述归一化 + 截断 —— 逐字对齐官方 `dsh-tool-skill` 的实现:
19
19
  * 先把所有空白折叠成单空格,再按长度截断并补省略号。保证「一人设一行」。
@@ -57,12 +57,12 @@ export function renderSubagentCatalog(allowed, maxEntries = DEFAULT_CATALOG_MAX_
57
57
  const out = [...lines];
58
58
  // 查询工具**只在真被 40 条上限截掉时**才出现:常态下不提,省常驻字符,也免得模型为了
59
59
  // 「确认一遍」去调它(用户裁定:没列出来的就是当前不想要的)。与 MCP 状态段的
60
- // `(另有 N 台未列出。)` 同一句式,但这里多给一个出口——不给人设就真的找不回来了。
60
+ // `(${N} more not listed.)` 同一句式,但这里多给一个出口——不给人设就真的找不回来了。
61
61
  if (hidden > 0) {
62
62
  // 与技能目录同一条纪律:点名一个被关掉的工具只会让模型去猜名字。数量照报。
63
63
  out.push('', listToolVisible
64
- ? `(另有 ${hidden} 个未列出,用 \`subagent_manager_list\` 查。)`
65
- : `(另有 ${hidden} 个未列出。)`);
64
+ ? `(${hidden} more not listed — see them with \`subagent_manager_list\`.)`
65
+ : `(${hidden} more not listed.)`);
66
66
  }
67
67
  return out.join('\n');
68
68
  }
@@ -0,0 +1,120 @@
1
+ // Agent Preset helpers for persona-scoped child composition.
2
+ //
3
+ // DSH 0.2.1 removed persona/toolFilter from SubagentStartRequest. A spawn child
4
+ // can still select an independent preset, so this module clones the parent's
5
+ // declared preset, replaces its persona row, and registers a short-lived preset
6
+ // id for that child. The clone is mounted by the official registry; nothing in
7
+ // the official package is patched.
8
+ import { createHash } from 'node:crypto';
9
+ import { JSON_SCHEMA, Type, load } from 'js-yaml';
10
+ import { renderPersonaPrompt } from './persona-parse.js';
11
+ const CHILD_PRESET_PREFIX = 'tool-management-child-';
12
+ const JS_EXPR_TAG = 'tag:yaml.org,2002:js';
13
+ const jsExprType = new Type(JS_EXPR_TAG, {
14
+ kind: 'scalar',
15
+ resolve: (data) => typeof data === 'string',
16
+ construct: (data) => ({ __jsExpr: data }),
17
+ represent: (data) => data.__jsExpr,
18
+ });
19
+ const entryListSchema = JSON_SCHEMA.extend(jsExprType);
20
+ /** `!!js` 标量(官方 `entryListSchema` 的 `JsExpr`):克隆时只带 `__jsExpr` 一个键。 */
21
+ function isJsExpr(value) {
22
+ return typeof value === 'object' && value !== null && '__jsExpr' in value;
23
+ }
24
+ function presetRosterOf(ctx) {
25
+ try {
26
+ return ctx?.get?.('agentPresets');
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ function cloneEntry(value) {
33
+ if (isJsExpr(value))
34
+ return { __jsExpr: value.__jsExpr };
35
+ if (!value || typeof value !== 'object')
36
+ return value;
37
+ if (Array.isArray(value))
38
+ return value.map(cloneEntry);
39
+ const copy = {};
40
+ for (const [key, item] of Object.entries(value))
41
+ copy[key] = cloneEntry(item);
42
+ return copy;
43
+ }
44
+ function isPersonaOrPresetSwitch(row) {
45
+ if (row.name === '@deepseek-ai/dsh-persona')
46
+ return true;
47
+ // A custom preset row would replace the whole composition; cloning it would
48
+ // silently change the parent instead of merely overriding its persona.
49
+ return row.name === '@deepseek-ai/dsh-agent-preset';
50
+ }
51
+ function removePersonaRows(rows) {
52
+ const kept = [];
53
+ for (const value of rows) {
54
+ // 不是对象的行:官方 entry list 不接受它,说明这份组合已不是本插件读懂的形状。
55
+ // 静默原样保留会把"人设"语义悄悄换掉(克隆出来的预设带着看不懂的行),所以明确报错。
56
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
57
+ throw new Error('the parent agent preset composition contains a non-object plugin row; cannot clone it safely');
58
+ }
59
+ const row = value;
60
+ if (isPersonaOrPresetSwitch(row))
61
+ continue;
62
+ if (row.group === true) {
63
+ const config = Array.isArray(row.config) ? removePersonaRows(row.config) : row.config;
64
+ kept.push(cloneEntry({ ...row, config }));
65
+ }
66
+ else {
67
+ kept.push(cloneEntry(row));
68
+ }
69
+ }
70
+ return kept;
71
+ }
72
+ /** Read the parent preset's declared rows and return a child definition. */
73
+ export async function childPresetDefinition(parentAgent, persona) {
74
+ const parentCtx = parentAgent?.ctx;
75
+ const roster = presetRosterOf(parentCtx);
76
+ if (!roster || typeof roster.composedPreset !== 'function' || typeof roster.readDocument !== 'function' || typeof roster.register !== 'function') {
77
+ throw new Error('this host lacks the agent-preset read/register API, so no standalone persona can be created for the subagent; upgrade to DSH >= 0.2.1 and enable the agentPresets service');
78
+ }
79
+ const parentPresetId = String(roster.composedPreset(parentCtx) ?? '');
80
+ if (!parentPresetId)
81
+ throw new Error('this parent session has no agent preset bound; cannot safely create a subagent with a persona');
82
+ const document = await roster.readDocument(parentPresetId);
83
+ if (!document || typeof document.content !== 'string') {
84
+ throw new Error(`failed to read the parent agent preset "${parentPresetId}": readDocument returned no composition YAML`);
85
+ }
86
+ let parsed;
87
+ try {
88
+ parsed = load(document.content, { schema: entryListSchema });
89
+ }
90
+ catch (error) {
91
+ throw new Error(`failed to parse the parent agent preset "${parentPresetId}": ${String(error?.message ?? error)}`);
92
+ }
93
+ if (!Array.isArray(parsed)) {
94
+ throw new Error(`the composition of the parent agent preset "${parentPresetId}" is not a list of plugin rows; cannot clone it`);
95
+ }
96
+ const plugins = removePersonaRows(parsed);
97
+ plugins.unshift({
98
+ id: 'tool-management-child-persona',
99
+ name: '@deepseek-ai/dsh-persona',
100
+ config: { prefix: renderPersonaPrompt(persona) },
101
+ });
102
+ // hash 必须覆盖**父预设的组合正文**:同一个 id 的内容可以被 profile 补丁改写,只按
103
+ // id + 人设定哈希会让改过的内容继续复用旧定义(冷恢复/追问时加载的是上一版插件树)。
104
+ const hash = createHash('sha256')
105
+ .update(parentPresetId)
106
+ .update('\0')
107
+ .update(document.content)
108
+ .update('\0')
109
+ .update(persona.name)
110
+ .update('\0')
111
+ .update(renderPersonaPrompt(persona))
112
+ .digest('hex')
113
+ .slice(0, 16);
114
+ return {
115
+ id: CHILD_PRESET_PREFIX + hash,
116
+ name: `persona:${persona.name}`,
117
+ description: `Ephemeral child preset cloned from ${parentPresetId}`,
118
+ plugins: plugins,
119
+ };
120
+ }
@@ -238,12 +238,12 @@ export const DEFAULT_PERSONA_CATALOG_DEPTH = 1;
238
238
  * 放在本模块(叶子)而不是 `catalog.ts`:`tools.ts` 也要用它,而 `catalog.ts` 反向依赖
239
239
  * `tools.ts` 的 `filterBySceneBinding` —— 放那边会形成 tools ↔ catalog 的循环。
240
240
  */
241
- export const CONTINUABLE_MARK = '(允许追问)';
241
+ export const CONTINUABLE_MARK = ' (continuable)';
242
242
  /**
243
243
  * 「不限制嵌套」的目录注入深度(界面下拉的第四档,前端同值见 client.js 的
244
244
  * `CATALOG_DEPTH_UNLIMITED`)。
245
245
  *
246
- * 取值远大于宿主能嵌套到的深度(官方 `dsh-tool-subagent` 默认 `maxDepth: 3`),所以判据
246
+ * 取值远大于宿主能嵌套到的深度(0.2.1 的 `dsh-subagent` 默认 `maxDepth: 1`),所以判据
247
247
  * `深度 < catalogDepth` 在任何可达的会话里都成立 —— 判据本身不必为它加特例分支。
248
248
  */
249
249
  export const UNLIMITED_PERSONA_CATALOG_DEPTH = 99;
@@ -330,18 +330,18 @@ export function textOfBlocks(blocks, type) {
330
330
  export function emptyResultNote(output, stopReason) {
331
331
  // 连思考都没有 → 子代理确实什么都没产出,这时说「没干活」是对的。
332
332
  if (textOfBlocks(output, 'reasoning') === '')
333
- return '(子代理无输出)';
334
- const head = '子代理没有返回正文 —— 它产出了思考';
333
+ return '(the subagent produced no output)';
334
+ const head = 'the subagent returned no text — only reasoning';
335
335
  switch (stopReason) {
336
336
  case 'max-tokens':
337
- return `(${head},但写出结论前就用完了 token 上限;需要结果请把任务拆小些再委派。)`;
337
+ return `(${head}, but it hit the token limit before writing a conclusion; split the task smaller and delegate again if you need a result.)`;
338
338
  case 'error':
339
- return `(${head},但中途出错了;诊断信息见后。)`;
339
+ return `(${head}, but it errored; diagnostics follow.)`;
340
340
  case 'aborted':
341
- return `(${head},但写出结论前被中止了。)`;
341
+ return `(${head}, but it was aborted before writing a conclusion.)`;
342
342
  case 'refusal':
343
- return `(${head},但它拒绝了这项任务。)`;
343
+ return `(${head}, but it refused this task.)`;
344
344
  default:
345
- return `(${head},但没有写出结论;可以拆小任务重新委派,或你自己接着做。)`;
345
+ return `(${head}, and wrote no conclusion; split the task and delegate again, or carry on yourself.)`;
346
346
  }
347
347
  }