dsh-plugin-tool-management 0.13.0 → 0.14.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 (51) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +91 -123
  3. package/README_EN.md +99 -126
  4. package/docs/images/1-EN.png +0 -0
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2-EN.png +0 -0
  7. package/docs/images/2.png +0 -0
  8. package/docs/images/3-EN.png +0 -0
  9. package/docs/images/3.png +0 -0
  10. package/docs/images/4-EN.png +0 -0
  11. package/docs/images/4.png +0 -0
  12. package/docs/images/5-EN.png +0 -0
  13. package/docs/images/5.png +0 -0
  14. package/docs/images/6-EN.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/images/7-EN.png +0 -0
  17. package/docs/images/7.png +0 -0
  18. package/docs/images/8-EN.png +0 -0
  19. package/docs/images/8.png +0 -0
  20. package/docs/update.md +28 -0
  21. package/lib/client.js +578 -124
  22. package/lib/compat/preset-reach.js +6 -3
  23. package/lib/context-inject.js +86 -32
  24. package/lib/index.js +251 -92
  25. package/lib/mcp/manager.js +3 -14
  26. package/lib/mcp/secret-guard.js +21 -0
  27. package/lib/mcp/state-section.js +13 -11
  28. package/lib/memories/constants.js +72 -8
  29. package/lib/memories/index-io.js +1 -1
  30. package/lib/memories/projection.js +67 -31
  31. package/lib/memories/service.js +17 -2
  32. package/lib/memories/snapshot.js +113 -22
  33. package/lib/op-registry.js +8 -2
  34. package/lib/ops/candidates.js +11 -0
  35. package/lib/ops/compat.js +10 -2
  36. package/lib/ops/sessions.js +1 -1
  37. package/lib/prompts/service.js +25 -2
  38. package/lib/scenes/candidates.js +56 -0
  39. package/lib/skills/core.js +93 -0
  40. package/lib/skills/service.js +34 -2
  41. package/lib/subagents/service.js +37 -4
  42. package/lib/subagents/tools.js +11 -4
  43. package/lib/tools/deps.js +15 -0
  44. package/lib/tools/mcp.js +291 -50
  45. package/lib/tools/memory.js +131 -80
  46. package/lib/tools/prompt.js +27 -13
  47. package/lib/tools/scene.js +351 -0
  48. package/lib/tools/skills.js +93 -17
  49. package/lib/tools/subagent.js +56 -45
  50. package/lib/tools/table.js +204 -15
  51. package/package.json +108 -108
@@ -16,6 +16,17 @@ export function buildCandidateOps(deps) {
16
16
  }),
17
17
  // model-candidates:宿主 LLM 目录里的 (provider, model) 对(不发网络请求)。
18
18
  'model-candidates': async () => ({ ok: true, ...(await deps.modelCandidates()) }),
19
+ // model-reasoning:某个 (provider, model) 支持的思考强度档位(人设表单的「思考强度」下拉)。
20
+ // 为什么单独一个 op:档位清单要问 adapter(`llm.resolveModelInfo`,异步、可能联网),而
21
+ // model-candidates 是"不发网络请求"的本地目录 —— 捆在一次调用里会让"打开人设表单"这件事
22
+ // 忽然变成可能联网的操作。这里只做参数归一与转发,判定留给调用方(界面按 ok 分两态显示)。
23
+ 'model-reasoning': async (args) => {
24
+ const provider = String((args && args.provider) || '').trim();
25
+ const model = String((args && args.model) || '').trim();
26
+ if (!provider || !model)
27
+ return { ok: false, error: 'provider 与 model 都要给(档位跟模型走)' };
28
+ return deps.modelReasoning(provider, model);
29
+ },
19
30
  // 场景档案勾选器数据源 v2:全部 MCP 服务器(含未运行)+ 技能全集 + 人设清单。
20
31
  'scene-inventory': async () => {
21
32
  const [rowsR, tools, skills, subs] = await Promise.all([deps.mcpmListView(), deps.toolStates(), deps.skillRows(), deps.subagentList()]);
package/lib/ops/compat.js CHANGED
@@ -170,13 +170,21 @@ export function buildCompatOps(deps) {
170
170
  push('mcp-tools', 'MCP 工具停用', 'mcp', disabledTools === 0 ? 'ok' : 'partial', disabledTools === 0 ? '没有停用的工具' : `${disabledTools} 个工具处于停用态(执行拦截 + 可见性摘除)`);
171
171
  // 工具表按每个请求付钱:这一行回答"这一轮实际发出去多少"。关掉的工具整份不进请求,
172
172
  // 但代价是模型调不到它们(本插件的面板不受影响)——所以是 partial,不是 ok。
173
+ // **出厂默认关掉的那几条不算**:那是插件替用户做的一个可逆选择,不是用户关出了
174
+ // 一个缺口。把默认态报成 partial 违背本页口径(琥珀只留给"该做却没做"),也永远
175
+ // 无法消掉 —— 用户打开它们反而会被罚一个 ok。
173
176
  // 末尾那句是**逐会话**的差额:官方 `skill` 工具在场的会话里我们那份加载器会再让位
174
177
  // 一个(见 index.ts 的 CARRIER_DUPLICATES),本表的数字是全局口径、不含它。
175
178
  const table = deps.toolTableReport();
176
179
  const carrierNote = ';官方 `skill` 工具在场的会话,`skill_manager_read` 还会自动让位一份';
177
- push('tool-table', '模型工具表', 'compat', table.hiddenCount === 0 ? 'ok' : 'partial', (table.hiddenCount === 0
180
+ const userOff = table.hiddenCount - table.defaultHiddenCount;
181
+ const offPart = table.hiddenCount === 0
178
182
  ? `${table.totalCount} 个工具全部下发(≈${table.totalTok} tok/轮)`
179
- : `关掉 ${table.hiddenCount}/${table.totalCount} 个:一轮少发 ≈${table.hiddenTok} tok(现在 ≈${table.visibleTok} tok/轮,面板不受影响)`) + carrierNote);
183
+ : (userOff === 0
184
+ ? `出厂默认关掉 ${table.defaultHiddenCount}/${table.totalCount} 个`
185
+ : `关掉 ${table.hiddenCount}/${table.totalCount} 个(含出厂默认 ${table.defaultHiddenCount} 个)`)
186
+ + `:一轮少发 ≈${table.hiddenTok} tok(现在 ≈${table.visibleTok} tok/轮,到「兼容」页的「模型工具表」可逐条打开;面板不受影响)`;
187
+ push('tool-table', '模型工具表', 'compat', userOff === 0 ? 'ok' : 'partial', offPart + carrierNote);
180
188
  push('native-delete', '宿主原生删除入口', 'compat', notes.has('workspace.delete-native') ? 'partial' : 'ok', notes.get('workspace.delete-native')?.detail ?? '宿主未提供原生删除入口(本插件自有完整序列)');
181
189
  // 身份 / 版本:与 compat-status 同一份数据。
182
190
  const identity = assessment?.identity;
@@ -388,7 +388,7 @@ export function buildSessionOps(deps) {
388
388
  continue;
389
389
  }
390
390
  entries.push({ zip: `${id}/AGENTS.md`, abs: docAbs });
391
- // 描述侧车(「只给使用者看」的那句)随预设一起走:导出再导入不该把它丢掉。
391
+ // 描述侧车(预设那句说明)随预设一起走:导出再导入不该把它丢掉。
392
392
  const metaAbs = join(deps.promptsDir, id, 'meta.json');
393
393
  try {
394
394
  if ((await stat(metaAbs)).isFile())
@@ -18,8 +18,29 @@ import { createHash } from 'node:crypto';
18
18
  import { isValidPresetId, LAST_APPLIED_PRESET_ID, normalizePresetId } from './preset-id.js';
19
19
  import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
20
20
  const FILENAME = 'AGENTS.md';
21
- /** 描述侧车:与 AGENTS.md 同目录,避免把「只给使用者看」的文字注入提示词。 */
21
+ /** 描述侧车:与 AGENTS.md 同目录,避免描述的文字被当成提示词正文注入。 */
22
22
  const META_FILE = 'meta.json';
23
+ /**
24
+ * 预设描述的字数上限。界面上那两枚输入框(提示词页的新建 / 编辑弹窗)用同一个数当
25
+ * `maxLength` 与字数计数的分母,客户端镜像在 `42-shared-ui.js` 的 `PRESET_DESC_MAX`
26
+ * —— 改这里要一起改它。
27
+ *
28
+ * 为什么需要它(0.14.0):描述此前是"只给使用者看"的,一个字都不进模型上下文,所以没有
29
+ * 上限也说得过去。现在 `prompt_manager_list` 会把它打给模型(否则模型无法在预设之间做
30
+ * 选择),它就成了**常驻成本**,必须有个预算。300 与 MCP 备注同数(那一条也是"给模型看的
31
+ * 一句用户提示"),但那是两个各自独立的预算,别当成同一个常量共用。
32
+ */
33
+ export const DEFAULT_PRESET_DESC_MAX_LENGTH = 300;
34
+ /**
35
+ * 压成一行并按上限截断。与 `normalizeMcpNote`(`../mcp/state-section.ts`)同形:两处都是
36
+ * "给模型看的一句用户文字",口径必须一致,否则同一句在界面、段、工具里会是三个样子。
37
+ */
38
+ export function normalizePresetDescription(value, maxLength = DEFAULT_PRESET_DESC_MAX_LENGTH) {
39
+ const flat = String(value ?? '').replaceAll(/\s+/g, ' ').trim();
40
+ if (!flat)
41
+ return '';
42
+ return flat.length <= maxLength ? flat : `${flat.slice(0, maxLength - 3)}...`;
43
+ }
23
44
  const LAST_APPLIED_ID = LAST_APPLIED_PRESET_ID;
24
45
  /**
25
46
  * 「最近一次应用」的记录侧车。是**文件**不是目录,所以 `list()` / `ensureInit()` 的
@@ -46,9 +67,11 @@ export function createPromptsService(_ctx, deps) {
46
67
  return null;
47
68
  }
48
69
  }
49
- // ── 描述(「只给使用者看」)────────────────────────────────────────────────
70
+ // ── 描述(给人看,也进模型清单)────────────────────────────────────────────
50
71
  // 存在预设目录的 `meta.json` 里,**不写进 AGENTS.md**:那个文件的正文会被原样注入
51
72
  // 系统提示词,把「这份预设是干什么的」写进去等于凭空给模型加了一段说明。
73
+ // 0.14.0 起另有一条通道会把它带给模型 —— `prompt_manager_list` 的清单行(模型要在预设
74
+ // 之间做选择,而 id 本身不说明用途),所以它有了 `DEFAULT_PRESET_DESC_MAX_LENGTH` 这条预算。
52
75
  // 读写都 best-effort:描述坏掉/写不进去不该让「保存预设」失败(正文才是本体)。
53
76
  async function readDescription(id) {
54
77
  try {
@@ -15,6 +15,15 @@
15
15
  // 放进本文件会让两个域互相引用。
16
16
  import { presetRosterOf } from '../compat/preset-reach.js';
17
17
  import { injectionFactsOf, readCompositionFacts } from '../compat/preset-reach.js';
18
+ /**
19
+ * 问 adapter 的等待上限。官方签名 `resolveModelInfo(provider, model, signal?)` 的 signal 是
20
+ * "optional cancellation for adapter-owned asynchronous lookup",所以给一个上限 —— 界面那一格
21
+ * 是懒加载 + loading 态,挂住不返回会让它一直转。
22
+ *
23
+ * ⚠️ 10s 是**防御性**取值,没有实测依据:本机拿不到活着的 adapter,量不出真实耗时。
24
+ * 真机上若发现常见 provider 只要几十毫秒,可以调小;若某个 provider 稳定超过它,就该调大。
25
+ */
26
+ const MODEL_REASONING_TIMEOUT_MS = 10_000;
18
27
  export function createCandidates(deps) {
19
28
  // 外部能力一次解构成局部名:块内代码逐字搬来,保持原样最不容易出错。
20
29
  const { get, tools, mcp, skillsService, memoriesService, toolKeyParts } = deps;
@@ -329,6 +338,52 @@ export function createCandidates(deps) {
329
338
  }
330
339
  return { models };
331
340
  }
341
+ /**
342
+ * 某个 (provider, model) 的思考强度档位。见 `Candidates.modelReasoning` 的注释。
343
+ *
344
+ * `llm` 与 `modelCandidates` 同一路径取(**不在 inject 声明里**,所以必须 `ctx.get('llm')`
345
+ * 而不是 `ctx.llm` —— 后者在未声明该服务时 cordis 代理会抛
346
+ * "cannot get property without inject")。
347
+ */
348
+ async function modelReasoning(provider, model) {
349
+ const llm = (typeof ctx.get === 'function' ? ctx.get('llm') : undefined);
350
+ if (!llm || typeof llm.resolveModelInfo !== 'function') {
351
+ return { ok: false, error: '宿主没有提供 llm.resolveModelInfo(拿不到档位清单)' };
352
+ }
353
+ const ac = typeof AbortController === 'function' ? new AbortController() : null;
354
+ const timer = ac ? setTimeout(() => ac.abort(), MODEL_REASONING_TIMEOUT_MS) : null;
355
+ try {
356
+ const info = await llm.resolveModelInfo(provider, model, ac ? ac.signal : undefined);
357
+ const reasoning = info && info.reasoning;
358
+ const raw = reasoning && Array.isArray(reasoning.efforts) ? reasoning.efforts : [];
359
+ const efforts = [];
360
+ for (const e of raw) {
361
+ const id = String((e && e.id) || '');
362
+ if (!id)
363
+ continue;
364
+ // 官方结构:`{ id, name, description? }`(`dsh-llm/lib/types/types.d.ts:295-302`)。
365
+ // name 是给人看的档位名,id 才是要传回去的值 —— 两者都要留着,界面按 name 显示、按 id 提交。
366
+ efforts.push({
367
+ id,
368
+ name: String((e && e.name) || id),
369
+ ...(e && typeof e.description === 'string' && e.description ? { description: e.description } : {}),
370
+ });
371
+ }
372
+ return {
373
+ ok: true,
374
+ efforts,
375
+ // 缺省(null)= 由 provider 自己的默认决定(官方注释:Absence preserves the provider's own default)。
376
+ defaultEffort: reasoning && reasoning.defaultEffort ? String(reasoning.defaultEffort) : null,
377
+ };
378
+ }
379
+ catch (e) {
380
+ return { ok: false, error: (e && e.message) ? String(e.message) : String(e) };
381
+ }
382
+ finally {
383
+ if (timer)
384
+ clearTimeout(timer);
385
+ }
386
+ }
332
387
  return {
333
388
  presetRoster,
334
389
  presetFactsForAgent,
@@ -338,6 +393,7 @@ export function createCandidates(deps) {
338
393
  presetToolNames,
339
394
  presetNames,
340
395
  modelCandidates,
396
+ modelReasoning,
341
397
  memoryCandidates,
342
398
  memorySceneCandidates,
343
399
  };
@@ -2696,6 +2696,99 @@ export async function createSkill(input, log, options = {}) {
2696
2696
  log("create", `创建 ${join(target, "SKILL.md")}`);
2697
2697
  return { name, path: join(target, "SKILL.md"), root: definition.key };
2698
2698
  }
2699
+ /**
2700
+ * 改写一份**已存在**的技能(bundle 的 `SKILL.md` 或 flat 的 `<name>.md`)。
2701
+ *
2702
+ * 为什么是独立函数、而不是给 `createSkill` 加一个 `allowOverwriteInOwnRoot` 开关:
2703
+ * 「静默覆盖」这件事不该出现在同一个入口的签名里 —— 调用方读到的函数名必须已经说明了
2704
+ * 它是破坏性的,否则一次参数写错就从"新建"变成"覆盖别人的技能"。
2705
+ *
2706
+ * 写入复用同一套原子写(stage 文件 + `renameWithRetry`)。bundle 形态**只换 SKILL.md**,
2707
+ * 目录里其余文件(附件、脚本、参考文档)原样留着 —— 整目录替换会把它们全删掉。
2708
+ *
2709
+ * 边界:只在 `options.root`(默认 hub = `$DSH_HOME/tool-management/skills`)**里面已经
2710
+ * 存在**的那一份上写。文件不在那里就是"不存在",**绝不新建**(新建是 `createSkill` 的事,
2711
+ * 它的冲突检查会拒掉同名)。
2712
+ *
2713
+ * 「胜出者必须是 hub 这一份」这层判定不在这里:它要读来源排序与首选设置,属于状态层
2714
+ * (见 service.ts 的 `skill-update` op)。本函数只保证"不越出给定根"。
2715
+ */
2716
+ export async function updateSkill(input, log, options = {}) {
2717
+ const requestedRoot = Object.prototype.hasOwnProperty.call(options, "root")
2718
+ ? options.root
2719
+ : rootByKey("hub") || rootByKey("dsh");
2720
+ const definition = await checkedWritableRootDefinition(requestedRoot);
2721
+ if (definition && definition.ok === false)
2722
+ return definition;
2723
+ if (!definition) {
2724
+ if (!rootDefinition(requestedRoot)) {
2725
+ return {
2726
+ ok: false,
2727
+ code: "error.root.unknown",
2728
+ params: { root: String(requestedRoot == null ? "" : requestedRoot) },
2729
+ error: `技能来源不存在:${requestedRoot == null || requestedRoot === "" ? "(空)" : requestedRoot}`,
2730
+ };
2731
+ }
2732
+ return readonlyError("update");
2733
+ }
2734
+ const root = definition.path;
2735
+ const requestedName = String((input && input.name) || "").trim();
2736
+ const name = toKebab(requestedName);
2737
+ if (!name || !KEBAB_RE.test(name) || entryPath(root, name) === null)
2738
+ return {
2739
+ ok: false,
2740
+ error: `无法生成合法 kebab-case 名称(原始名: ${requestedName})`,
2741
+ code: "error.import.invalidName",
2742
+ params: { name: requestedName },
2743
+ };
2744
+ // 形态判定与 `resolveEntry` 同口径:bundle 目录优先,其次 flat 单文件。
2745
+ const bundleDir = entryPath(root, name);
2746
+ const bundleDoc = join(bundleDir, "SKILL.md");
2747
+ const flatDoc = resolve(root, `${name}.md`);
2748
+ const exists = async (p) => await fs.stat(p).then(() => true).catch(() => false);
2749
+ const existingDoc = (await exists(bundleDoc)) ? bundleDoc : (await exists(flatDoc)) ? flatDoc : null;
2750
+ if (!existingDoc)
2751
+ return {
2752
+ ok: false,
2753
+ code: "error.update.notFound",
2754
+ error: `技能不存在(本插件落点里没有 ${name}):本工具只改本插件自己写的技能,不新建、也不动官方根里的`,
2755
+ params: { name },
2756
+ };
2757
+ const current = parseSkillDoc(await fs.readFile(existingDoc, "utf8"));
2758
+ const curDescription = unquote(String(current.map.description ?? "")).trim();
2759
+ const curBody = String(current.body ?? "").trim();
2760
+ // 省略 = 保持。注意区分"未给"与"给了空串":空串是显式意图,会被下面的必填校验挡下并
2761
+ // 说明缺哪个 —— 静默保留会让模型以为清掉了。
2762
+ const description = input && input.description !== undefined
2763
+ ? String(input.description).trim()
2764
+ : curDescription;
2765
+ const body = input && input.body !== undefined ? String(input.body).trim() : curBody;
2766
+ if (!description)
2767
+ return { ok: false, error: "技能简介不能为空", code: "error.create.descriptionRequired" };
2768
+ if (!body)
2769
+ return { ok: false, error: "技能正文不能为空", code: "error.create.bodyRequired" };
2770
+ if (description.length > 500 || body.length > 1 << 18)
2771
+ return { ok: false, error: "技能内容过长", code: "error.create.tooLarge" };
2772
+ const content = `---\nname: ${name}\ndescription: ${yamlString(description)}\n---\n\n${body}\n`;
2773
+ if (existingDoc === flatDoc) {
2774
+ await writeFileAtomically(flatDoc, content);
2775
+ if (log)
2776
+ log("update", `更新 ${flatDoc}`);
2777
+ return { name, path: flatDoc, root: definition.key };
2778
+ }
2779
+ const stage = temporaryPath(existingDoc, "update");
2780
+ try {
2781
+ await fs.writeFile(stage, content, "utf8");
2782
+ await renameWithRetry(stage, existingDoc);
2783
+ }
2784
+ catch (error) {
2785
+ await fs.rm(stage, { force: true }).catch(() => undefined);
2786
+ throw error;
2787
+ }
2788
+ if (log)
2789
+ log("update", `更新 ${existingDoc}`);
2790
+ return { name, path: existingDoc, root: definition.key };
2791
+ }
2699
2792
  export async function skillDetail(keyOrRoot, name, options = {}) {
2700
2793
  const scopedRoots = await projectRoots(options.projectCwds);
2701
2794
  // 支持传 key 或 definition(自定义来源由 service 层解析后传入)。
@@ -10,7 +10,7 @@ import { spawn } from 'node:child_process';
10
10
  import { homedir } from 'node:os';
11
11
  import { basename, join } from 'node:path';
12
12
  import { isInsideRootResolved } from '../paths.js';
13
- import { state, setSkillEnabled, setPreferredSkill, markSkillsDisabled, setSourceEnabled, setSourceRemoved, deleteSkill, restoreTrash, permanentlyDeleteTrash, importSkill, importUploadedSkill, browseDirectories, createSkill, skillDetail, listProviderCandidates, getProviderSkill, userRoots, projectRoots, readManagerState, customRootsFromState, addCustomRoot, removeCustomRoot, logPath, trashRootPath, } from './core.js';
13
+ import { state, setSkillEnabled, setPreferredSkill, markSkillsDisabled, setSourceEnabled, setSourceRemoved, deleteSkill, restoreTrash, permanentlyDeleteTrash, importSkill, importUploadedSkill, browseDirectories, createSkill, updateSkill, skillDetail, listProviderCandidates, getProviderSkill, userRoots, projectRoots, readManagerState, customRootsFromState, addCustomRoot, removeCustomRoot, logPath, trashRootPath, } from './core.js';
14
14
  const MAX_LOG_BYTES = 1 << 20;
15
15
  const PROVIDER_NAME = 'dsh-plugin-tool-management-external';
16
16
  const message = (e) => String((e && e.message) || e);
@@ -455,7 +455,7 @@ export function createSkillsService(ctx) {
455
455
  // 也变),是写操作;漏在这份清单里 = 配了访问令牌也不会被要求带令牌。
456
456
  'skill-source-remove', 'skill-source-restore',
457
457
  'skill-prefer', 'skill-unprefer',
458
- 'skill-create', 'skill-import', 'skill-upload', 'skill-delete',
458
+ 'skill-create', 'skill-update', 'skill-import', 'skill-upload', 'skill-delete',
459
459
  // 批量启停与单条启停同权:配了访问令牌的宿主必须同样要求带令牌。
460
460
  'skill-set-all',
461
461
  'skill-trash-restore', 'skill-trash-delete', 'skill-custom-add', 'skill-custom-remove',
@@ -549,6 +549,38 @@ export function createSkillsService(ctx) {
549
549
  }
550
550
  return res;
551
551
  }), afterWrite),
552
+ // 改写**本插件自己写的那一份**技能。只认 hub 里的胜出者:同名技能在 dsh / agents /
553
+ // claude / 自定义根各有一份时只有一份生效,改错那一份会返回 OK 而技能毫无变化
554
+ // (用户实测踩过:同一技能四份并存,启错根等于没启)。胜出者判定要读来源排序与首选
555
+ // 设置,属于状态层,所以在这里做;core 的 `updateSkill` 只保证不越出给定根。
556
+ 'skill-update': wrap((args) => write(async () => {
557
+ const wanted = String(args.name || '').trim();
558
+ const s = await readState();
559
+ const hits = [];
560
+ for (const root of (s && s.roots) || []) {
561
+ for (const skill of root.skills || []) {
562
+ if (String(skill.declaredName || skill.name || '') !== wanted)
563
+ continue;
564
+ hits.push({
565
+ rootKey: String(root.key || ''),
566
+ shadowedBy: skill.shadowedBy && skill.shadowedBy.root ? String(skill.shadowedBy.root) : '',
567
+ });
568
+ }
569
+ }
570
+ if (!hits.length) {
571
+ return { ok: false, code: 'error.update.notFound', params: { name: wanted }, error: `技能不存在:${wanted}` };
572
+ }
573
+ const winner = hits.find((h) => !h.shadowedBy) || hits[0];
574
+ if (winner.rootKey !== 'hub') {
575
+ return {
576
+ ok: false,
577
+ code: 'error.update.notOwnRoot',
578
+ params: { name: wanted, root: winner.rootKey },
579
+ error: `技能 ${wanted} 的生效副本在来源「${winner.rootKey}」,不在本插件落点(hub = $DSH_HOME/tool-management/skills):本插件不修改官方根里的技能`,
580
+ };
581
+ }
582
+ return updateSkill({ name: args.name, description: args.description, body: args.body }, log, { root: (await requestRoot('hub')) || 'hub' });
583
+ }), afterWrite),
552
584
  'skill-import': wrap((args) => write(async () => {
553
585
  const res = await importSkill(String(args.source || ''), log, {
554
586
  conflict: args.conflict === 'overwrite' ? 'overwrite' : 'skip',
@@ -99,6 +99,17 @@ export function parsePresetToolRules(frontmatter) {
99
99
  return Object.keys(rules).length ? rules : undefined;
100
100
  }
101
101
  /** 行式 frontmatter 解析:只认 description / provider / model / tools / toolsDeny / toolsByPreset / catalogDepth / output。 */
102
+ /**
103
+ * 思考强度档位 id 的归一:单行、去空白、限长。**不校验它是不是合法档位** —— 清单要问 adapter
104
+ * 才拿得到(可能拿不到),而官方本来就会在 provider I/O 之前拒掉不支持的显式值。
105
+ *
106
+ * 为什么必须去换行:frontmatter 是**逐行**解析的(`parsePersona`),值里带换行会把后面半截
107
+ * 变成一行无主文本。限长是防脏值(正常档位 id 都很短)。
108
+ */
109
+ export const REASONING_EFFORT_MAX_LENGTH = 64;
110
+ export function normalizeReasoningEffort(value) {
111
+ return String(value ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, REASONING_EFFORT_MAX_LENGTH);
112
+ }
102
113
  export function parsePersona(raw, fallbackName) {
103
114
  const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw);
104
115
  const data = {};
@@ -133,6 +144,8 @@ export function parsePersona(raw, fallbackName) {
133
144
  description: data.description || firstLine,
134
145
  provider: data.provider || undefined,
135
146
  model: data.model || undefined,
147
+ // frontmatter 键统一小写后是 `reasoningeffort`(写出去的是 camelCase)。
148
+ reasoningEffort: normalizeReasoningEffort(data.reasoningeffort) || undefined,
136
149
  tools,
137
150
  toolsDeny,
138
151
  ...(toolsByPreset === undefined ? {} : { toolsByPreset }),
@@ -618,8 +631,22 @@ export function createSubagentService(ctx, opts) {
618
631
  // provider 与 model 是模型路由的两半:DSH 的 resolveModel(provider, model) 不做
619
632
  // `provider/model` 字符串拆分,只改 model 会落在**主会话的 provider** 上——跨来源
620
633
  // 指定模型(如 sensenova 的 sensenova-6.8-flash-lite)必须两个键一起给。
621
- ...(p.provider || p.model
622
- ? { agentOptions: { ...(p.provider ? { provider: p.provider } : {}), ...(p.model ? { model: p.model } : {}) } }
634
+ // 2026-09-23 加 `reasoningEffort`:条件是"三个键任一有值"—— 只设强度(不改模型)也得
635
+ // 把这个对象发出去,否则那一格永远不生效。官方合并语义(`dsh-subagent/lib/index.js:471-483`)
636
+ // 是"先继承主会话的三项、再用这里的覆盖",所以只给 reasoningEffort 时 provider/model
637
+ // 照旧继承;反过来,改了 provider/model 而没给 reasoningEffort 时,**继承来的那一档会被
638
+ // 删掉**(回落到该模型自己的默认)—— 那不是 bug,是官方的显式语义。
639
+ ...(p.provider || p.model || p.reasoningEffort
640
+ ? {
641
+ agentOptions: {
642
+ ...(p.provider ? { provider: p.provider } : {}),
643
+ ...(p.model ? { model: p.model } : {}),
644
+ // 类型是官方的品牌串 `ReasoningEffortId`;我们的值本来就是 adapter 自己给的
645
+ // opaque id(界面从 `resolveModelInfo` 的清单里选出来的),所以这里只做归一、
646
+ // 不重新校验 —— 不支持的档位由官方在 provider I/O 之前拒。
647
+ ...(p.reasoningEffort ? { reasoningEffort: p.reasoningEffort } : {}),
648
+ },
649
+ }
623
650
  : {}),
624
651
  });
625
652
  try {
@@ -660,7 +687,8 @@ export function createSubagentService(ctx, opts) {
660
687
  return {
661
688
  ok: true,
662
689
  // catalogDepth 报**生效值**(没写就是默认 1),界面与模型都不必各自知道默认是多少。
663
- subagents: docs.map((p) => ({ name: p.name, enabled: p.enabled !== false, description: p.description, provider: p.provider ?? null, model: p.model ?? null, tools: p.tools ?? null, toolsDeny: p.toolsDeny ?? null, toolsByPreset: p.toolsByPreset ?? null, catalogDepth: catalogDepthOf(p), output: p.output ?? null })),
690
+ // 手写 map:新增字段必须**两处都加**(list 与 get),少一处就是界面读不到。
691
+ subagents: docs.map((p) => ({ name: p.name, enabled: p.enabled !== false, description: p.description, provider: p.provider ?? null, model: p.model ?? null, reasoningEffort: p.reasoningEffort ?? null, tools: p.tools ?? null, toolsDeny: p.toolsDeny ?? null, toolsByPreset: p.toolsByPreset ?? null, catalogDepth: catalogDepthOf(p), output: p.output ?? null })),
664
692
  };
665
693
  },
666
694
  'subagent-get': async (args) => {
@@ -669,7 +697,7 @@ export function createSubagentService(ctx, opts) {
669
697
  const p = docs.find((d) => d.name === name);
670
698
  if (!p)
671
699
  return { ok: false, error: `人设不存在: ${name}` };
672
- return { ok: true, persona: { name: p.name, description: p.description, provider: p.provider ?? '', model: p.model ?? '', tools: p.tools ?? [], toolsDeny: p.toolsDeny ?? [], toolsByPreset: p.toolsByPreset ?? {}, catalogDepth: catalogDepthOf(p), output: p.output ?? '', body: p.body } };
700
+ return { ok: true, persona: { name: p.name, description: p.description, provider: p.provider ?? '', model: p.model ?? '', reasoningEffort: p.reasoningEffort ?? '', tools: p.tools ?? [], toolsDeny: p.toolsDeny ?? [], toolsByPreset: p.toolsByPreset ?? {}, catalogDepth: catalogDepthOf(p), output: p.output ?? '', body: p.body } };
673
701
  },
674
702
  'subagent-create': async (args) => {
675
703
  const name = String((args && args.name) || '').trim();
@@ -982,6 +1010,9 @@ export function serializePersona(args) {
982
1010
  const description = String((args && args.description) || '').replace(/\r?\n/g, ' ').trim();
983
1011
  const provider = String((args && args.provider) || '').trim();
984
1012
  const model = String((args && args.model) || '').trim();
1013
+ // 思考强度:空值**不落盘**(与 `catalogDepth` 默认值同规矩)—— 免得每个新建的人设都多一行
1014
+ // 说明"它和默认一样",也保证老的人设文件回写后逐字节不变。
1015
+ const reasoningEffort = normalizeReasoningEffort(args?.reasoningEffort);
985
1016
  const tools = toStringList(args?.tools);
986
1017
  // 黑名单字段兼容两种入参名:toolsDeny(UI/camel)与 tools_deny(snake)。
987
1018
  const toolsDeny = toStringList(args?.toolsDeny ?? args?.tools_deny);
@@ -1006,6 +1037,8 @@ export function serializePersona(args) {
1006
1037
  lines.push('provider: ' + provider);
1007
1038
  if (model)
1008
1039
  lines.push('model: ' + model);
1040
+ if (reasoningEffort)
1041
+ lines.push('reasoningEffort: ' + reasoningEffort);
1009
1042
  if (hasCatalogDepth)
1010
1043
  lines.push('catalogDepth: ' + String(catalogDepth));
1011
1044
  for (const line of outputLines)
@@ -22,7 +22,7 @@ export async function filterBySceneBinding(docs, enabledSceneLists) {
22
22
  export function defineSubagentManagerListTool(subagents) {
23
23
  return {
24
24
  name: 'subagent_manager_list',
25
- description: 'List personas (pre-configured subagent profiles) with their descriptions. The「可委派的子智能体」reminder carries the same catalog; call this for the always-current full list before subagent_manager_run.',
25
+ description: 'List personas (pre-configured subagent profiles) with their descriptions. The「可委派的子智能体」reminder carries the same catalog; call this for the always-current full list.',
26
26
  parameters: {},
27
27
  output: {
28
28
  schema: { type: 'string' },
@@ -66,11 +66,18 @@ export function defineSubagentManagerRunTool(subagents) {
66
66
  // 代价如实记下:子智能体域被关掉、或走压制型预设时上下文里没有那条 how 行,模型只剩本
67
67
  // 描述与 `agent` 参数说明("Persona name from subagent_manager_list")—— 够它认出这条是
68
68
  // 带人设的委派通道,但"没有人设贴合时才用官方那两个"这层分界就没人说了。
69
+ //
70
+ // 2026-09-23(用户裁定):本工具的 `task` / `inherit` 两个参数说明与上面的 Modes 段
71
+ // **逐字重复**,而工具表每轮都发 —— 同一件事付两次 token。实测 `subagent_manager_run`
72
+ // 整份 407 tok(参数段:`inherit` 86 / `task` 57 / `agent` 19),其中 `inherit` 是全表
73
+ // 最贵的单个参数。收完两处重复后目标 ≈325 tok。判据是"删掉的那句在描述里已经有了吗":
74
+ // Modes 段留着(它是模型的入口),参数说明只留"这一段独有的信息"。
75
+ // `inherit` 那句"轮中委派拿不到当前轮"必须留 —— 它是这条参数唯一会让人写错 task 的地方。
69
76
  description: 'Run a named persona as a subagent: it gets the persona as its own system prompt, works on `task`, and returns only its final output.\n\nModes: by default a fresh child that cannot see this conversation, so `task` must be self-contained. With `inherit: true` it also gets this conversation\'s **finished** turns (like the host\'s `subagent_fork`) — the current turn is never included, so a mid-turn hand-off still needs a self-contained `task`.\n\n`task` = the goal plus the context it needs; leave method and output format to the persona.\n\nUse it when the work matches a persona in the「可委派的子智能体」reminder (a review, an investigation, a piece of writing) and the detail should not sit in your own context. Not for reading a file (Read), finding a definition (Grep/Glob), or touching two or three files.',
70
77
  parameters: {
71
- agent: { type: 'string', required: true, description: 'Persona name from subagent_manager_list.' },
72
- task: { type: 'string', required: true, description: 'The task for the subagent: the goal plus the context it needs. Self-contained by default; with inherit: true it only needs to state what is new. Leave method and output format to the persona.' },
73
- inherit: { type: 'boolean', description: 'Let the subagent inherit this conversation\'s finished turns, like the host\'s subagent_fork (default false = a fresh child that cannot see this conversation). Only finished turns are inherited — a delegation made mid-turn cannot pass the current turn\'s content, so write `task` as if it were self-contained.' },
78
+ agent: { type: 'string', required: true, description: 'Persona name.' },
79
+ task: { type: 'string', required: true, description: 'The task for the subagent. Self-contained by default; with inherit: true only state what is new.' },
80
+ inherit: { type: 'boolean', description: 'Let the subagent see this conversation\'s finished turns (the current turn is never included). Default false = a fresh child.' },
74
81
  },
75
82
  output: {
76
83
  schema: { type: 'string' },
package/lib/tools/deps.js CHANGED
@@ -6,3 +6,18 @@
6
6
  export function text(value) {
7
7
  return [{ type: 'text', text: value }];
8
8
  }
9
+ /**
10
+ * 名单渲染:超过 `TOOL_NAME_LIST_CAP` 项就截断,并把总数写在后面("等 N 项")。
11
+ *
12
+ * 为什么两个地方都要它:`scene_manager_list` 用它列档案,`scene_manager_save` 用它
13
+ * 在回执里回显"这一次替换挤掉了什么"。两处都必须**点名、不能只给数量** —— 只给数量
14
+ * 答不了"我这次给出去的段会不会盖掉原来勾着的那些",而那正是会静默出错的地方。
15
+ * 截断则是因为这些文字都要进上下文,不能无上限。
16
+ */
17
+ export const TOOL_NAME_LIST_CAP = 6;
18
+ export function toolNameList(list) {
19
+ if (!list.length)
20
+ return '空';
21
+ const head = list.slice(0, TOOL_NAME_LIST_CAP).join('、');
22
+ return list.length > TOOL_NAME_LIST_CAP ? head + ' 等 ' + list.length + ' 项' : head;
23
+ }