dsh-plugin-tool-management 0.13.0 → 0.15.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 (62) hide show
  1. package/CHANGELOG.md +560 -474
  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 +391 -337
  21. package/lib/audit-log.js +157 -0
  22. package/lib/client.js +4204 -2454
  23. package/lib/compat/patch-dialect.js +3 -1
  24. package/lib/compat/preset-reach.js +43 -9
  25. package/lib/compat/probe.js +149 -7
  26. package/lib/context-inject.js +91 -35
  27. package/lib/index.js +479 -114
  28. package/lib/mcp/loader-token.js +53 -28
  29. package/lib/mcp/manager.js +149 -21
  30. package/lib/mcp/secret-guard.js +21 -0
  31. package/lib/mcp/state-section.js +13 -11
  32. package/lib/memories/archive-engine.js +70 -2
  33. package/lib/memories/archive.js +8 -1
  34. package/lib/memories/constants.js +72 -8
  35. package/lib/memories/index-io.js +7 -1
  36. package/lib/memories/projection.js +67 -31
  37. package/lib/memories/service.js +17 -2
  38. package/lib/memories/snapshot.js +113 -22
  39. package/lib/op-registry.js +26 -6
  40. package/lib/ops/candidates.js +11 -0
  41. package/lib/ops/compat.js +113 -31
  42. package/lib/ops/scene-records.js +34 -0
  43. package/lib/ops/scene-sync.js +12 -1
  44. package/lib/ops/sessions.js +24 -9
  45. package/lib/ops/snapshot.js +596 -0
  46. package/lib/prompts/service.js +25 -2
  47. package/lib/scenes/candidates.js +66 -5
  48. package/lib/sessions/bridge.js +5 -0
  49. package/lib/sessions/workspace.js +17 -5
  50. package/lib/skills/core.js +105 -3
  51. package/lib/skills/service.js +34 -2
  52. package/lib/subagents/service.js +37 -4
  53. package/lib/subagents/tools.js +11 -4
  54. package/lib/tools/deps.js +16 -1
  55. package/lib/tools/mcp.js +291 -50
  56. package/lib/tools/memory.js +131 -80
  57. package/lib/tools/prompt.js +27 -13
  58. package/lib/tools/scene.js +351 -0
  59. package/lib/tools/skills.js +93 -17
  60. package/lib/tools/subagent.js +56 -45
  61. package/lib/tools/table.js +208 -15
  62. package/package.json +24 -20
@@ -14,7 +14,16 @@
14
14
  // 依赖全部经 deps 显式传入。注意 \`toolKeyParts\` 留在 index.ts:场景档案引擎也在用它,
15
15
  // 放进本文件会让两个域互相引用。
16
16
  import { presetRosterOf } from '../compat/preset-reach.js';
17
- import { injectionFactsOf, readCompositionFacts } from '../compat/preset-reach.js';
17
+ import { injectionFactsOf, readCompositionFacts, readCompositionText } 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;
@@ -101,7 +110,9 @@ export function createCandidates(deps) {
101
110
  const presetFactsCache = new Map();
102
111
  async function presetFactsForAgent(agent) {
103
112
  const roster = presetRoster();
104
- if (!roster || typeof roster.composedPreset !== 'function' || typeof roster.read !== 'function')
113
+ if (!roster || typeof roster.composedPreset !== 'function')
114
+ return undefined;
115
+ if (typeof roster.read !== 'function' && typeof roster.readDocument !== 'function')
105
116
  return undefined;
106
117
  let presetId = '';
107
118
  try {
@@ -116,7 +127,7 @@ export function createCandidates(deps) {
116
127
  if (hit && Date.now() - hit.at < PRESET_FACTS_TTL_MS)
117
128
  return hit.value;
118
129
  try {
119
- const text = String((await roster.read(presetId)) ?? '');
130
+ const text = await readCompositionText(roster, presetId);
120
131
  const value = injectionFactsOf(readCompositionFacts(text));
121
132
  presetFactsCache.set(presetId, { at: Date.now(), value });
122
133
  return value;
@@ -207,7 +218,10 @@ export function createCandidates(deps) {
207
218
  presets.push({
208
219
  id,
209
220
  name: String((p && (p.name || p.id)) || id),
210
- trust: String((p && p.trust) || 'user'),
221
+ // 官方 registry 的预设**没有** trust 字段 —— 缺省必须是 ''(未知)而不是 'user':
222
+ // 客户端只对显式 user 隐藏内置模式的词典名,缺省 'user' 会让四个内置模式永远
223
+ // 显示原始 id(2026-09-28 用户截图)。
224
+ trust: String((p && p.trust) || ''),
211
225
  broken: typeof (p && p.broken) === 'string',
212
226
  tools: names,
213
227
  });
@@ -278,7 +292,7 @@ export function createCandidates(deps) {
278
292
  try {
279
293
  const roster = await agentPresets.list();
280
294
  return (roster || [])
281
- .map((p) => ({ id: String((p && p.id) || ''), name: String((p && (p.name || p.id)) || ''), trust: String((p && p.trust) || 'user') }))
295
+ .map((p) => ({ id: String((p && p.id) || ''), name: String((p && (p.name || p.id)) || ''), trust: String((p && p.trust) || '') }))
282
296
  .filter((p) => p.id !== '');
283
297
  }
284
298
  catch {
@@ -329,6 +343,52 @@ export function createCandidates(deps) {
329
343
  }
330
344
  return { models };
331
345
  }
346
+ /**
347
+ * 某个 (provider, model) 的思考强度档位。见 `Candidates.modelReasoning` 的注释。
348
+ *
349
+ * `llm` 与 `modelCandidates` 同一路径取(**不在 inject 声明里**,所以必须 `ctx.get('llm')`
350
+ * 而不是 `ctx.llm` —— 后者在未声明该服务时 cordis 代理会抛
351
+ * "cannot get property without inject")。
352
+ */
353
+ async function modelReasoning(provider, model) {
354
+ const llm = (typeof ctx.get === 'function' ? ctx.get('llm') : undefined);
355
+ if (!llm || typeof llm.resolveModelInfo !== 'function') {
356
+ return { ok: false, error: '宿主没有提供 llm.resolveModelInfo(拿不到档位清单)' };
357
+ }
358
+ const ac = typeof AbortController === 'function' ? new AbortController() : null;
359
+ const timer = ac ? setTimeout(() => ac.abort(), MODEL_REASONING_TIMEOUT_MS) : null;
360
+ try {
361
+ const info = await llm.resolveModelInfo(provider, model, ac ? ac.signal : undefined);
362
+ const reasoning = info && info.reasoning;
363
+ const raw = reasoning && Array.isArray(reasoning.efforts) ? reasoning.efforts : [];
364
+ const efforts = [];
365
+ for (const e of raw) {
366
+ const id = String((e && e.id) || '');
367
+ if (!id)
368
+ continue;
369
+ // 官方结构:`{ id, name, description? }`(`dsh-llm/lib/types/types.d.ts:295-302`)。
370
+ // name 是给人看的档位名,id 才是要传回去的值 —— 两者都要留着,界面按 name 显示、按 id 提交。
371
+ efforts.push({
372
+ id,
373
+ name: String((e && e.name) || id),
374
+ ...(e && typeof e.description === 'string' && e.description ? { description: e.description } : {}),
375
+ });
376
+ }
377
+ return {
378
+ ok: true,
379
+ efforts,
380
+ // 缺省(null)= 由 provider 自己的默认决定(官方注释:Absence preserves the provider's own default)。
381
+ defaultEffort: reasoning && reasoning.defaultEffort ? String(reasoning.defaultEffort) : null,
382
+ };
383
+ }
384
+ catch (e) {
385
+ return { ok: false, error: (e && e.message) ? String(e.message) : String(e) };
386
+ }
387
+ finally {
388
+ if (timer)
389
+ clearTimeout(timer);
390
+ }
391
+ }
332
392
  return {
333
393
  presetRoster,
334
394
  presetFactsForAgent,
@@ -338,6 +398,7 @@ export function createCandidates(deps) {
338
398
  presetToolNames,
339
399
  presetNames,
340
400
  modelCandidates,
401
+ modelReasoning,
341
402
  memoryCandidates,
342
403
  memorySceneCandidates,
343
404
  };
@@ -62,6 +62,8 @@ function acquireCacheGuard(cache) {
62
62
  kind: "delete",
63
63
  fallback: "inform-only",
64
64
  detail: `投影缓存适配未完成(requireTable 抛错):${String(error?.message ?? error)};宿主对象未被改动。`,
65
+ detailKey: "projection-cache-adapter.setup",
66
+ params: { reason: String(error?.message ?? error) },
65
67
  });
66
68
  throw new CapabilityRefusalError("delete", [{
67
69
  id: "projection.table-delete",
@@ -218,6 +220,9 @@ function acquireCacheGuard(cache) {
218
220
  detail: reason === "taken-over"
219
221
  ? "宿主缓存的 put/write 已被第三方包装接管:本插件的删除屏障这一轮已退役(缓存行删除少了墓碑拦截),下一次删除会重新安装。"
220
222
  : "投影缓存适配在首次使用时抛错(宿主形状可能变了):已恢复宿主原方法,下一次删除会重新安装并再探一次。",
223
+ detailKey: reason === "taken-over"
224
+ ? "projection-cache-adapter.taken-over"
225
+ : "projection-cache-adapter.first-use",
221
226
  });
222
227
  }
223
228
  cacheGuards.set(cache, self);
@@ -20,6 +20,8 @@ function reportNativeDeleteAvailable(entry) {
20
20
  kind: "delete",
21
21
  fallback: "inform-only",
22
22
  detail: `宿主已提供原生 ${entry}:本插件仍走自有完整序列(级联子会话 + spill + 记账 + 缓存行),尚未切换到原生入口。`,
23
+ detailKey: "workspace.delete-native",
24
+ params: { entry },
23
25
  });
24
26
  }
25
27
  //#region lib/types/index.js
@@ -697,8 +699,8 @@ var ArchiveWorkspaceRegistry = class {
697
699
  }
698
700
  /**
699
701
  * 列出已归档会话的展示元数据:sessionId / createdAt / cwd / title / archivedAt。
700
- * title 经投影缓存 best-effort 读取(未播种会话用 inheritedEventCount=0);
701
- * 任何一步失败只降级为缺字段,不阻断列表。
702
+ * title 经投影缓存 best-effort 读取(cachedSnapshot 的标题行,旧格式记录退
703
+ * cachedPredecessorTitle);任何一步失败只降级为缺字段,不阻断列表。
702
704
  */
703
705
  async archivedSessionDetails() {
704
706
  await this.reconcileArchiveLedger();
@@ -717,10 +719,19 @@ var ArchiveWorkspaceRegistry = class {
717
719
  entry.cwd = header.cwd;
718
720
  const cache = this.ctx.get("sessionProjectionCache");
719
721
  if (cache !== undefined && typeof cache.cachedSnapshot === "function") {
720
- const snap = cache.cachedSnapshot(header, 0, ["title"]);
722
+ // 官方契约是 cachedSnapshot(meta, keys?) 两参;keys 传数字会在
723
+ // viewCheckpoint 的 new Set 里抛错,被外层 best-effort 吞成"永远没标题"。
724
+ const snap = cache.cachedSnapshot(header, ["title"]);
721
725
  const title = snap?.values?.title;
722
726
  if (typeof title === "string")
723
727
  entry.title = title;
728
+ else if (typeof cache.cachedPredecessorTitle === "function") {
729
+ // 旧格式代际的缓存记录过不了上面的身份校验,官方另有只认标题的提示面。
730
+ const legacy = cache.cachedPredecessorTitle(header);
731
+ const legacyTitle = legacy?.values?.title;
732
+ if (typeof legacyTitle === "string")
733
+ entry.title = legacyTitle;
734
+ }
724
735
  }
725
736
  }
726
737
  catch (error) {
@@ -775,7 +786,8 @@ var ArchiveWorkspaceRegistry = class {
775
786
  return false;
776
787
  try {
777
788
  // 未播种会话的继承事件数恒为零,先查缓存可避免读取完整会话原文。
778
- if (!header.isSeeded && cache.cachedSnapshot(header, 0) !== void 0)
789
+ // keys 缺省 = 任一 wire 行可用即算已有缓存(官方两参契约,勿再传数字)。
790
+ if (!header.isSeeded && cache.cachedSnapshot(header) !== void 0)
779
791
  return false;
780
792
  const stored = await this.readStoredProjectionSource(persistence, header.id);
781
793
  const meta = stored.meta ?? header;
@@ -788,7 +800,7 @@ var ArchiveWorkspaceRegistry = class {
788
800
  this.ctx.logger.warn(`archive-manager: projection repair for unseeded archived session "${header.id}" skipped because its inherited event count is not zero`);
789
801
  return false;
790
802
  }
791
- if (cache.cachedSnapshot(meta, inheritedEventCount) !== void 0)
803
+ if (cache.cachedSnapshot(meta) !== void 0)
792
804
  return false;
793
805
  const restored = projections.restore({}, stored.events, 0, meta, inheritedEventCount);
794
806
  if (restored === void 0 ||
@@ -305,8 +305,13 @@ async function nearestProjectRoot(cwd) {
305
305
  if (!(await fs.stat(start)).isDirectory())
306
306
  return null;
307
307
  }
308
- catch {
309
- return { unavailable: true, cwd };
308
+ catch (error) {
309
+ // 目录不存在(会话还开着、项目已被删)是最常见的一类:带上系统错误码,
310
+ // 调用方对 ENOENT 静默跳过 —— 报警留给真正读不了的目录。
311
+ const code = error && typeof error === "object" && "code" in error
312
+ ? String(error.code)
313
+ : undefined;
314
+ return { unavailable: true, cwd, ...(code ? { code } : {}) };
310
315
  }
311
316
  let current = start;
312
317
  for (;;) {
@@ -344,6 +349,10 @@ export async function projectRoots(projectCwds = [], diagnostics) {
344
349
  for (const cwd of Array.isArray(projectCwds) ? projectCwds : []) {
345
350
  const found = await nearestProjectRoot(cwd);
346
351
  if (!found || !found.root) {
352
+ // 目录不存在(ENOENT)= 会话开着、项目已删:这是常态而不是故障,静默跳过。
353
+ // 报警只留给真实读不了的目录(权限、损坏的链接等)。
354
+ if (found && found.unavailable && found.code === "ENOENT")
355
+ continue;
347
356
  if (found &&
348
357
  found.unavailable &&
349
358
  Array.isArray(diagnostics) &&
@@ -352,7 +361,7 @@ export async function projectRoots(projectCwds = [], diagnostics) {
352
361
  diagnostics.push({
353
362
  code: "warning.project.unavailable",
354
363
  params: { path: cwd },
355
- error: `无法从宿主读取活动工作区,项目技能未显示: ${cwd}`,
364
+ error: `活动会话的项目目录不可访问,项目技能未显示: ${cwd}`,
356
365
  });
357
366
  }
358
367
  continue;
@@ -2696,6 +2705,99 @@ export async function createSkill(input, log, options = {}) {
2696
2705
  log("create", `创建 ${join(target, "SKILL.md")}`);
2697
2706
  return { name, path: join(target, "SKILL.md"), root: definition.key };
2698
2707
  }
2708
+ /**
2709
+ * 改写一份**已存在**的技能(bundle 的 `SKILL.md` 或 flat 的 `<name>.md`)。
2710
+ *
2711
+ * 为什么是独立函数、而不是给 `createSkill` 加一个 `allowOverwriteInOwnRoot` 开关:
2712
+ * 「静默覆盖」这件事不该出现在同一个入口的签名里 —— 调用方读到的函数名必须已经说明了
2713
+ * 它是破坏性的,否则一次参数写错就从"新建"变成"覆盖别人的技能"。
2714
+ *
2715
+ * 写入复用同一套原子写(stage 文件 + `renameWithRetry`)。bundle 形态**只换 SKILL.md**,
2716
+ * 目录里其余文件(附件、脚本、参考文档)原样留着 —— 整目录替换会把它们全删掉。
2717
+ *
2718
+ * 边界:只在 `options.root`(默认 hub = `$DSH_HOME/tool-management/skills`)**里面已经
2719
+ * 存在**的那一份上写。文件不在那里就是"不存在",**绝不新建**(新建是 `createSkill` 的事,
2720
+ * 它的冲突检查会拒掉同名)。
2721
+ *
2722
+ * 「胜出者必须是 hub 这一份」这层判定不在这里:它要读来源排序与首选设置,属于状态层
2723
+ * (见 service.ts 的 `skill-update` op)。本函数只保证"不越出给定根"。
2724
+ */
2725
+ export async function updateSkill(input, log, options = {}) {
2726
+ const requestedRoot = Object.prototype.hasOwnProperty.call(options, "root")
2727
+ ? options.root
2728
+ : rootByKey("hub") || rootByKey("dsh");
2729
+ const definition = await checkedWritableRootDefinition(requestedRoot);
2730
+ if (definition && definition.ok === false)
2731
+ return definition;
2732
+ if (!definition) {
2733
+ if (!rootDefinition(requestedRoot)) {
2734
+ return {
2735
+ ok: false,
2736
+ code: "error.root.unknown",
2737
+ params: { root: String(requestedRoot == null ? "" : requestedRoot) },
2738
+ error: `技能来源不存在:${requestedRoot == null || requestedRoot === "" ? "(空)" : requestedRoot}`,
2739
+ };
2740
+ }
2741
+ return readonlyError("update");
2742
+ }
2743
+ const root = definition.path;
2744
+ const requestedName = String((input && input.name) || "").trim();
2745
+ const name = toKebab(requestedName);
2746
+ if (!name || !KEBAB_RE.test(name) || entryPath(root, name) === null)
2747
+ return {
2748
+ ok: false,
2749
+ error: `无法生成合法 kebab-case 名称(原始名: ${requestedName})`,
2750
+ code: "error.import.invalidName",
2751
+ params: { name: requestedName },
2752
+ };
2753
+ // 形态判定与 `resolveEntry` 同口径:bundle 目录优先,其次 flat 单文件。
2754
+ const bundleDir = entryPath(root, name);
2755
+ const bundleDoc = join(bundleDir, "SKILL.md");
2756
+ const flatDoc = resolve(root, `${name}.md`);
2757
+ const exists = async (p) => await fs.stat(p).then(() => true).catch(() => false);
2758
+ const existingDoc = (await exists(bundleDoc)) ? bundleDoc : (await exists(flatDoc)) ? flatDoc : null;
2759
+ if (!existingDoc)
2760
+ return {
2761
+ ok: false,
2762
+ code: "error.update.notFound",
2763
+ error: `技能不存在(本插件落点里没有 ${name}):本工具只改本插件自己写的技能,不新建、也不动官方根里的`,
2764
+ params: { name },
2765
+ };
2766
+ const current = parseSkillDoc(await fs.readFile(existingDoc, "utf8"));
2767
+ const curDescription = unquote(String(current.map.description ?? "")).trim();
2768
+ const curBody = String(current.body ?? "").trim();
2769
+ // 省略 = 保持。注意区分"未给"与"给了空串":空串是显式意图,会被下面的必填校验挡下并
2770
+ // 说明缺哪个 —— 静默保留会让模型以为清掉了。
2771
+ const description = input && input.description !== undefined
2772
+ ? String(input.description).trim()
2773
+ : curDescription;
2774
+ const body = input && input.body !== undefined ? String(input.body).trim() : curBody;
2775
+ if (!description)
2776
+ return { ok: false, error: "技能简介不能为空", code: "error.create.descriptionRequired" };
2777
+ if (!body)
2778
+ return { ok: false, error: "技能正文不能为空", code: "error.create.bodyRequired" };
2779
+ if (description.length > 500 || body.length > 1 << 18)
2780
+ return { ok: false, error: "技能内容过长", code: "error.create.tooLarge" };
2781
+ const content = `---\nname: ${name}\ndescription: ${yamlString(description)}\n---\n\n${body}\n`;
2782
+ if (existingDoc === flatDoc) {
2783
+ await writeFileAtomically(flatDoc, content);
2784
+ if (log)
2785
+ log("update", `更新 ${flatDoc}`);
2786
+ return { name, path: flatDoc, root: definition.key };
2787
+ }
2788
+ const stage = temporaryPath(existingDoc, "update");
2789
+ try {
2790
+ await fs.writeFile(stage, content, "utf8");
2791
+ await renameWithRetry(stage, existingDoc);
2792
+ }
2793
+ catch (error) {
2794
+ await fs.rm(stage, { force: true }).catch(() => undefined);
2795
+ throw error;
2796
+ }
2797
+ if (log)
2798
+ log("update", `更新 ${existingDoc}`);
2799
+ return { name, path: existingDoc, root: definition.key };
2800
+ }
2699
2801
  export async function skillDetail(keyOrRoot, name, options = {}) {
2700
2802
  const scopedRoots = await projectRoots(options.projectCwds);
2701
2803
  // 支持传 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
@@ -1,8 +1,23 @@
1
1
  // model 工具(ctx.tools.register + defineTool)各域共用的依赖与 helper。
2
2
  //
3
- // 为什么单独一个文件:五个域的工具都要同一批依赖(defineTool 包装、注册出口、场景锁定
3
+ // 为什么单独一个文件:各注入域的工具都要同一批依赖(defineTool 包装、注册出口、场景锁定
4
4
  // 守卫、注入边界提示),各写一份会漂移。
5
5
  /** 每个工具 output.render 的统一出口:把字符串包成宿主要的 content 数组。 */
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
+ }