dsh-plugin-tool-management 0.9.1 → 0.10.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.
@@ -27,9 +27,10 @@
27
27
  //
28
28
  // 错误约定:业务校验失败返回 { ok:false, error: 中文, code, params? }(与 skills core 一致);
29
29
  // ops 成功返回扁平 { ok:true, ... },不套 { ok:true, data }。
30
- import { randomUUID } from 'node:crypto';
30
+ import { createHash, randomUUID } from 'node:crypto';
31
31
  import { readFileSync, readdirSync, statSync } from 'node:fs';
32
32
  import { copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
33
+ import { homedir } from 'node:os';
33
34
  import { basename, dirname, join, resolve } from 'node:path';
34
35
  import { parseSkillDoc, renameWithRetry, resolveDshHome, unquote } from '../skills/core.js';
35
36
  import { expandUploads, planMemoryImport } from '../imports/upload.js';
@@ -957,7 +958,7 @@ function probeSceneFilesSync(rulesRoot, index) {
957
958
  ].join('\u0002');
958
959
  return { refs, scenes, truncated, signature };
959
960
  }
960
- /** 指纹里必须包含一切影响渲染的索引字段(active / enabled / order / groups.order / scenes.order)。 */
961
+ /** 指纹里必须包含一切影响渲染的索引字段(active / enabled / order / groups.order / scenes.order / label / description)。 */
961
962
  function signatureOfIndex(index) {
962
963
  const active = normalizeActive(index.active);
963
964
  const rules = Object.keys(index.rules).sort().map((id) => {
@@ -966,9 +967,11 @@ function signatureOfIndex(index) {
966
967
  });
967
968
  const groups = Object.keys(index.groups).sort().map((g) => `${g}\u0000${index.groups[g]?.order ?? DEFAULT_GROUP_ORDER}`);
968
969
  // 场景顺序决定段内场景的先后 → 必须进指纹,否则改顺序后段文本不会重算。
970
+ // label 与 description 同理(sceneHeader 的「场景说明」一行直接渲染 description)——
971
+ // 手改索引文件(带外变更)时只有指纹变化才会触发重算。
969
972
  const scenes = Object.keys(index.scenes || {}).sort().map((s) => {
970
973
  const e = index.scenes[s];
971
- return `${s}\u0000${e.order ?? (s === GLOBAL_SCENE ? 0 : DEFAULT_GROUP_ORDER)}\u0000${e.label ?? ''}`;
974
+ return `${s}\u0000${e.order ?? (s === GLOBAL_SCENE ? 0 : DEFAULT_GROUP_ORDER)}\u0000${e.label ?? ''}\u0000${e.description ?? ''}`;
972
975
  });
973
976
  return `A:${active === null ? '*' : active.join(',')}|R:${rules.join(';')}|G:${groups.join(';')}|S:${scenes.join(';')}`;
974
977
  }
@@ -1204,8 +1207,8 @@ function sceneHeader(scene, index) {
1204
1207
  return `${head}\n\n**场景说明:${description}**\n\n`;
1205
1208
  }
1206
1209
  /**
1207
- * 段首的引导语:让模型知道下面是**用户提供的参考信息**,而不是对话历史或临时说明
1208
- * —— 相关就用、无关可忽略。
1210
+ * 段首的引导语:让模型知道下面是**用户为本机写的参考信息**,并且**以它为准**
1211
+ * —— 涉及本机的事一律照它办,确实无关时才放下。
1209
1212
  *
1210
1213
  * 写法(2026-09-16 用户裁定,基于真实注入结果的三次修正):
1211
1214
  * - **单行、加粗**,不再用括号分两行 —— 括号跨行在真实提示词里读起来像被截断,
@@ -1220,10 +1223,37 @@ function sceneHeader(scene, index) {
1220
1223
  *
1221
1224
  * 用词:不用「常驻」「注入」这类内部行话(模型没有先验);用户裁定用「信息」而不是
1222
1225
  * 「记忆」——「记忆」在系统提示词里指代不明,而这段的实质就是用户写的参考信息。
1226
+ *
1227
+ * 2026-09-17 重写(用户:「当前模式会不重视这些提示词」)。上一版的三个毛病都在**授权**
1228
+ * 上,而不在措辞好不好看上:
1229
+ * 1. 「与当前任务相关时直接采用」——**没有给"相关"的判据**。最省力的解读永远是"无关",
1230
+ * 因为判成无关不需要任何工作;
1231
+ * 2. 通篇没有优先级规则。与本机实际情况冲突时,模型会默默按自己的默认假设走,
1232
+ * 而用户完全不知道发生了什么;
1233
+ * 3. 「无关时忽略」——「忽略」是这句话里**最后一个动词**,也是记得最牢的那个。它把
1234
+ * 一个免打扰出口写成了对内容的态度许可。
1235
+ * 现在:给出**判据**(凡涉及本机路径 / 配置 / 工具 / 习惯)、给出**裁决规则**、把出口降级为
1236
+ * 「不必提及」(关于**要不要声明**,不是关于**要不要采用**)。出口保留是必要的 —— 去掉它
1237
+ * 会让模型对无关条目强行攀附,那是另一种失真。
1238
+ *
1239
+ * 2026-09-17 第二版:**按条目类型分级授权**(用户采纳的四条里的第 1、4 条)。Claude Code
1240
+ * 把两类内容分进两个系统、用**相反**的授权:用户指令(`claudemd.ts:89`)是
1241
+ * "These instructions OVERRIDE any default behavior and you MUST follow them exactly as
1242
+ * written",而记忆(`memdir/memoryTypes.ts:202`)是
1243
+ * "If a recalled memory conflicts with current information, trust what you observe now"
1244
+ * —— 书里(ch11:25)说记忆是 "working notes, not gospel"。上一版把两类塞进一句授权,
1245
+ * 对**场景说明**(用户写的约定)是对的,对**记忆条目**(可能是几个月前记下的事实)是错的。
1246
+ * 好在这两类在渲染时就分处不同位置:场景说明在 `sceneHeader` 的 `**场景说明:…**` 里,
1247
+ * 记忆条目在 `memoryBlock` 里 —— 所以一句话就能分级,不必改数据结构。
1248
+ *
1249
+ * 冲突阶梯(第 4 条)来自 Codex `base_instructions/default.md:22-27` 与 Claude Code 的
1250
+ * `caller override > agent definition > parent model > default`:把"谁高于谁"写明,
1251
+ * 模型才不会在「用户当场说的 ≠ 本机记录」时悬空。写明它还有一个反直觉的好处 ——
1252
+ * 它让授权更可信:这说明本条不是要让记忆压过用户,只是要压过模型的默认假设。
1223
1253
  */
1224
- const SCENE_MEMORY_NOTE = '**用户提供的参考信息:与当前任务相关时直接采用,无关时忽略。以下就是全部信息。**';
1254
+ const SCENE_MEMORY_NOTE = '**用户为本机写的参考信息:「场景说明」是用户的约定,一律照办,覆盖你的默认做法;其余条目是记录,可能已过期 —— 与当前实际情况冲突时以你看到的为准,与用户当场说的冲突时以用户为准。无关时不必提及。以下就是全部信息。**';
1225
1255
  /** 有未注入条目时的版本:去掉完整性声明(见上)。 */
1226
- const SCENE_MEMORY_NOTE_PARTIAL = '**用户提供的参考信息:与当前任务相关时直接采用,无关时忽略。**';
1256
+ const SCENE_MEMORY_NOTE_PARTIAL = '**用户为本机写的参考信息:「场景说明」是用户的约定,一律照办,覆盖你的默认做法;其余条目是记录,可能已过期 —— 与当前实际情况冲突时以你看到的为准,与用户当场说的冲突时以用户为准。无关时不必提及。**';
1227
1257
  /**
1228
1258
  * 段首固定块。以 `\n` 结尾,与场景块 join 后自然空一行。
1229
1259
  * 预算按**较长**的那版算(见 renderSceneMemory),保守一点只会浪费几个字节。
@@ -1599,12 +1629,20 @@ export function createRulesService(ctx, deps) {
1599
1629
  }
1600
1630
  }
1601
1631
  // ── 规则文件序列化 ──────────────────────────────────────────────────────
1602
- /** 值含换行用 `|` 块(保留换行);否则 `key: value` 原样(parseSkillDoc 按行贪婪解析)。 */
1632
+ /**
1633
+ * 值含换行用 `|` 块(保留换行);否则 `key: value` 原样(parseSkillDoc 按行贪婪解析)。
1634
+ * 例外:多行值里有去空白后恰为 `---` 的行时改用 JSON 引号标量 —— 块标量把内容行缩进
1635
+ * 两格也躲不开 parseSkillDoc 的 frontmatter 结束扫描(`trim() === '---'`),描述会被
1636
+ * 截断、残片混进正文;引号形式是单物理行,扫描无从误判,decodeYamlScalar 无损还原
1637
+ * (skills 写侧的引号标量正是靠这一点免疫同一断裂)。
1638
+ */
1603
1639
  function yamlField(key, value) {
1604
1640
  if (typeof value === 'boolean')
1605
1641
  return [`${key}: ${value}`];
1606
1642
  const s = String(value);
1607
1643
  if (s.includes('\n')) {
1644
+ if (s.split('\n').some((line) => line.trim() === '---'))
1645
+ return [`${key}: ${JSON.stringify(s)}`];
1608
1646
  return [`${key}: |`, ...s.split('\n').map((line) => ` ${line}`)];
1609
1647
  }
1610
1648
  return [`${key}: ${s}`];
@@ -1890,7 +1928,7 @@ export function createRulesService(ctx, deps) {
1890
1928
  const indexForScene = await readIndex(stateDir);
1891
1929
  // 保留场景 global 恒存在(不用先建);其余场景必须已存在——见上方注释。
1892
1930
  if (targetGroup !== GLOBAL_SCENE && !indexForScene.scenes?.[targetGroup] && !(await pathExists(join(rulesRoot, targetGroup)))) {
1893
- return fail('error.rules.sceneNotFound', `场景不存在:${targetGroup}(请先在「场景」页创建该场景)`);
1931
+ return fail('error.rules.sceneNotFound', `场景不存在:${targetGroup}(请先在「场景」页创建该场景)`, { group: targetGroup });
1894
1932
  }
1895
1933
  if (!isValidGroupSegment(name))
1896
1934
  return fail('error.rules.invalidName', `记忆名非法:${name}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含 / \\ < > : " | ? *、不以 . 开头)`);
@@ -2118,7 +2156,8 @@ export function createRulesService(ctx, deps) {
2118
2156
  else
2119
2157
  description = deriveDescription(newBody) || undefined;
2120
2158
  }
2121
- // 形态转换 / 改名:先建新形态文件,再清理旧形态(确保任意失败点不产生半份规则)。
2159
+ // 形态转换 / 改名:先建新形态文件,再把旧形态复制进回收站、最后删原件
2160
+ // (确保任意失败点不产生半份规则;旧形态随时可恢复,README「删除都进回收站」无例外)。
2122
2161
  // description 写回约定:显式传 → string/null(删除);未传 → undefined(保留原字段)。
2123
2162
  const serialize = () => {
2124
2163
  let descArg;
@@ -2131,16 +2170,19 @@ export function createRulesService(ctx, deps) {
2131
2170
  if (located.kind === 'bundle' && newForm === 'flat') {
2132
2171
  await mkdir(join(rulesRoot, parts.group), { recursive: true });
2133
2172
  await writeFileAtomically(join(rulesRoot, parts.group, newName + '.md'), serialize());
2173
+ await copyIntoMemoriesTrash(located, parts.group, parts.name);
2134
2174
  await rm(located.entryPath, { recursive: true, force: true });
2135
2175
  }
2136
2176
  else if (located.kind === 'flat' && newForm === 'bundle') {
2137
2177
  await mkdir(join(rulesRoot, parts.group, newName), { recursive: true });
2138
2178
  await writeFileAtomically(join(rulesRoot, parts.group, newName, bundleDocName(newName)), serialize());
2179
+ await copyIntoMemoriesTrash(located, parts.group, parts.name);
2139
2180
  await rm(join(rulesRoot, parts.group, parts.name + '.md'), { force: true });
2140
2181
  }
2141
2182
  else if (located.kind === 'flat' && newName !== parts.name) {
2142
2183
  // flat 改名 = 文件改名
2143
2184
  await writeFileAtomically(join(rulesRoot, parts.group, newName + '.md'), serialize());
2185
+ await copyIntoMemoriesTrash(located, parts.group, parts.name);
2144
2186
  await rm(join(rulesRoot, parts.group, parts.name + '.md'), { force: true });
2145
2187
  }
2146
2188
  else {
@@ -2161,28 +2203,40 @@ export function createRulesService(ctx, deps) {
2161
2203
  invalidateSnapshot();
2162
2204
  return { ok: true, rule: await buildProjected(newId) };
2163
2205
  }
2164
- async function rulesRemove(args) {
2165
- const id = String((args && args.id) || '');
2166
- const parts = parseId(id);
2167
- if (!parts)
2168
- return fail('error.rules.notFound', `规则不存在:${id}`);
2169
- const located = await locateRule(parts.group, parts.name);
2170
- if (!located)
2171
- return fail('error.rules.notFound', `规则不存在:${id}`);
2206
+ /**
2207
+ * 把条目原件复制进 memories 回收站并写 manifest,返回 trashId。只复制、不删除:
2208
+ * 原件的 rm 由调用方在复制成功后执行 —— 先有副本才允许删,rm 永远不会销毁唯一数据。
2209
+ * manifest 先于原件删除落盘:中途崩溃最坏留下「原件还在 + 一份完整回收站副本」,
2210
+ * 不会出现「有文件没清单」的损坏条目。
2211
+ */
2212
+ async function copyIntoMemoriesTrash(located, group, name) {
2172
2213
  const trashId = Date.now().toString(36) + '-' + randomUUID().slice(0, 8);
2173
2214
  const trashDir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
2174
- const manifest = { group: parts.group, name: parts.name, form: located.kind, deletedAt: new Date().toISOString() };
2215
+ const manifest = { group, name, form: located.kind, deletedAt: new Date().toISOString() };
2175
2216
  await mkdir(trashDir, { recursive: true });
2176
2217
  if (located.kind === 'bundle') {
2177
2218
  const { cp } = await import('node:fs/promises');
2178
2219
  await cp(located.entryPath, join(trashDir, 'bundle'), { recursive: true });
2179
- await rm(located.entryPath, { recursive: true, force: true });
2180
2220
  }
2181
2221
  else {
2182
2222
  await copyFile(located.docPath, join(trashDir, 'rule.md'));
2183
- await rm(located.docPath, { force: true });
2184
2223
  }
2185
2224
  await writeFileAtomically(join(trashDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
2225
+ return trashId;
2226
+ }
2227
+ async function rulesRemove(args) {
2228
+ const id = String((args && args.id) || '');
2229
+ const parts = parseId(id);
2230
+ if (!parts)
2231
+ return fail('error.rules.notFound', `规则不存在:${id}`);
2232
+ const located = await locateRule(parts.group, parts.name);
2233
+ if (!located)
2234
+ return fail('error.rules.notFound', `规则不存在:${id}`);
2235
+ const trashId = await copyIntoMemoriesTrash(located, parts.group, parts.name);
2236
+ if (located.kind === 'bundle')
2237
+ await rm(located.entryPath, { recursive: true, force: true });
2238
+ else
2239
+ await rm(located.docPath, { force: true });
2186
2240
  const index = await readIndex(stateDir);
2187
2241
  delete index.rules[id];
2188
2242
  await writeIndex(stateDir, index);
@@ -2427,7 +2481,7 @@ export function createRulesService(ctx, deps) {
2427
2481
  return fail('error.rules.invalidArgs', '缺少参数:需要 scenes:[...](启用集合,至多一个场景)');
2428
2482
  }
2429
2483
  if (hasAll) {
2430
- return fail('error.rules.singleSceneOnly', '除「全局」外同时只能启用一个场景:不支持"全部启用",请改用 scenes:[<场景名>] 或 scenes:[](全部关闭)');
2484
+ return fail('error.rules.singleSceneOnly', '除「全局」外同时只能启用一个场景:不支持"全部启用",请改用 scenes:[<场景名>] 或 scenes:[](全部关闭)', { detail: ':不支持"全部启用",请改用 scenes:[<场景名>] 或 scenes:[](全部关闭)' });
2431
2485
  }
2432
2486
  const index = await readIndex(stateDir);
2433
2487
  const raw = Array.isArray(args && args.scenes) ? args.scenes : [];
@@ -2447,7 +2501,7 @@ export function createRulesService(ctx, deps) {
2447
2501
  names.push(name);
2448
2502
  }
2449
2503
  if (names.length > 1) {
2450
- return fail('error.rules.singleSceneOnly', `除「全局」外同时只能启用一个场景(收到 ${names.length} 个:${names.join('、')})`);
2504
+ return fail('error.rules.singleSceneOnly', `除「全局」外同时只能启用一个场景(收到 ${names.length} 个:${names.join('、')})`, { detail: `(收到 ${names.length} 个:${names.join('、')})` });
2451
2505
  }
2452
2506
  index.active = names;
2453
2507
  await writeIndex(stateDir, index);
@@ -2578,7 +2632,7 @@ export function createRulesService(ctx, deps) {
2578
2632
  return fail('error.rules.invalidGroup', `新场景名非法:${nextRaw}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头)`);
2579
2633
  }
2580
2634
  if (name === GLOBAL_SCENE)
2581
- return fail('error.rules.reservedScene', '「全局」是保留场景,不可改名');
2635
+ return fail('error.rules.reservedScene', '「全局」是保留场景,不可改名', { name: GLOBAL_SCENE, action: 'rename', reason: '' });
2582
2636
  if (name === SHARED_GROUP || nextRaw === SHARED_GROUP)
2583
2637
  return fail('error.rules.invalidGroup', '_shared 是保留场景名,不可改名');
2584
2638
  if ((index.scenes && index.scenes[nextRaw]) || (await pathExists(join(rulesRoot, nextRaw)))) {
@@ -2653,7 +2707,7 @@ export function createRulesService(ctx, deps) {
2653
2707
  if (!isValidGroupPath(name))
2654
2708
  return fail('error.rules.invalidGroup', `场景名非法:${name || '(空)'}`);
2655
2709
  if (name === GLOBAL_SCENE || name === SHARED_GROUP)
2656
- return fail('error.rules.reservedScene', '「全局 / _shared」是保留场景,不可锁定');
2710
+ return fail('error.rules.reservedScene', '「全局 / _shared」是保留场景,不可锁定', { name: '全局 / _shared', action: 'lock', reason: '' });
2657
2711
  if (typeof (args && args.locked) !== 'boolean') {
2658
2712
  return fail('error.rules.invalidArgs', '缺少参数:locked 必须是布尔值(只按传入值写入,不做"翻转"推断)');
2659
2713
  }
@@ -2682,7 +2736,7 @@ export function createRulesService(ctx, deps) {
2682
2736
  if (name === SHARED_GROUP)
2683
2737
  return fail('error.rules.invalidGroup', `_shared 是保留场景名,不可删除`);
2684
2738
  if (name === GLOBAL_SCENE)
2685
- return fail('error.rules.reservedScene', `「全局」是保留场景,不可删除(它的记忆对任何对话都生效)`);
2739
+ return fail('error.rules.reservedScene', `「全局」是保留场景,不可删除(它的记忆对任何对话都生效)`, { name: GLOBAL_SCENE, action: 'delete', reason: '(它的记忆对任何对话都生效)' });
2686
2740
  const index = await readIndex(stateDir);
2687
2741
  // 场景不存在(既无记录也无目录)→ 明确报错,而不是假装删成功。
2688
2742
  if (!index.scenes?.[name] && !(await pathExists(join(rulesRoot, name)))) {
@@ -2895,24 +2949,76 @@ export function createRulesService(ctx, deps) {
2895
2949
  // (注入侧会跳过整个域),没挂时(极简)才需要注入兜底;判定在注入器的 `selectInjections` 里。
2896
2950
  const memoryText = () => sceneMemory().text;
2897
2951
  /**
2898
- * `~/.dsh/AGENTS.md` 正文。上限与官方那一行的 `maxBytes` 同量级(64 KiB):超大文件按字节
2952
+ * `~/.dsh/AGENTS.md` 正文的上限,与官方那一行的 `maxBytes` 同量级(64 KiB):超大文件按字节
2899
2953
  * 截断并留一行标记,免得一份手写的巨型基线把上下文撑爆。
2900
2954
  */
2901
2955
  const AGENTS_MD_MAX_BYTES = 65536;
2902
- const agentsMdText = () => {
2903
- const text = readGlobalAgentsMdSync();
2904
- if (text.trim() === '')
2905
- return '';
2906
- if (Buffer.byteLength(text, 'utf8') <= AGENTS_MD_MAX_BYTES)
2907
- return text;
2908
- const cut = Buffer.from(text, 'utf8').subarray(0, AGENTS_MD_MAX_BYTES).toString('utf8');
2909
- return cut + `\n\n(…文件超过 ${Math.floor(AGENTS_MD_MAX_BYTES / 1024)} KiB,已截断。)`;
2956
+ /**
2957
+ * DSH home 的**展示形态**,规则与官方 `dsh-home-paths` 的 `dshHomeDisplay()` 一致
2958
+ * (`dsh-home-paths/lib/index.js:93`):默认位置显示 `~/.dsh`,被 `DSH_HOME` 指到别处时
2959
+ * 才显示 `$DSH_HOME`。只在给**界面**看的字符串里用 —— 读盘一律用真实路径。
2960
+ */
2961
+ const dshHomeDisplay = () => {
2962
+ const home = resolveDshHome();
2963
+ return resolve(home) === resolve(join(homedir(), '.dsh')) ? '~/.dsh' : '$DSH_HOME';
2964
+ };
2965
+ /**
2966
+ * 当前生效的提示词:**来源文件 + 正文**,一次解析、两处出口共用(模型侧 `promptText`、
2967
+ * 界面清单 `promptFiles`)。`null` = 现在没有提示词。
2968
+ *
2969
+ * 为什么合并成一处:两条出口必须给出**同一个**结论(文件是哪个、正文取哪份),分开写两份
2970
+ * 同样的分支判断迟早分叉 —— 分叉的后果就是界面标错文件、或模型看到与文件不符的正文。
2971
+ * 合并前那里已经有一处小分叉:场景预设在场但正文为空时,文本退回 AGENTS.md、清单却仍报
2972
+ * 预设路径。现在两边同源,这类角落不可能再各说各话。
2973
+ *
2974
+ * `digest` 照抄官方 `instructionContentSha1`(`dsh-agent-instructions/lib/index.js:90`,
2975
+ * sha1 hex):算的是**文件内容**(AGENTS.md 取截断前的原文),是"文件这一版"的身份,
2976
+ * 不是"这次注入了什么";界面只拿它当悬停提示,不参与任何判定。
2977
+ *
2978
+ * `text` 是**模型侧**正文,开头一行 `来源:<展示路径>` —— 照抄官方 agent-instructions 的
2979
+ * 写法(`Instructions from: <path>`,`render.js` 的 `sectionText`)。模型据此知道这些规则
2980
+ * 写在哪个文件里:用户说"把这条记下来"时它知道该改哪份文件,而不是只能凭印象回答。
2981
+ */
2982
+ const resolvePrompt = () => {
2983
+ // 场景提示词与 AGENTS.md 正文在用户眼里就是同一件事("全局提示词"):场景期间前者取代后者,
2984
+ // 与官方语义一致(进场景改写文件、退出恢复)。所以一个域、一份文本,取到非空的场景提示词就用它。
2985
+ const scene = resolveScenePreset();
2986
+ if (scene && scene.text.trim() !== '') {
2987
+ const path = `${dshHomeDisplay()}/${HUB_DIR}/${PRESETS_DIR}/${scene.presetId}/AGENTS.md`;
2988
+ return {
2989
+ path,
2990
+ digest: createHash('sha1').update(scene.text).digest('hex'),
2991
+ text: `来源:${path}\n\n${scene.text}`,
2992
+ };
2993
+ }
2994
+ const raw = readGlobalAgentsMdSync();
2995
+ if (raw.trim() === '')
2996
+ return null;
2997
+ const path = `${dshHomeDisplay()}/AGENTS.md`;
2998
+ const body = Buffer.byteLength(raw, 'utf8') <= AGENTS_MD_MAX_BYTES
2999
+ ? raw
3000
+ : Buffer.from(raw, 'utf8').subarray(0, AGENTS_MD_MAX_BYTES).toString('utf8') +
3001
+ `\n\n(…文件超过 ${Math.floor(AGENTS_MD_MAX_BYTES / 1024)} KiB,已截断。)`;
3002
+ return { path, digest: createHash('sha1').update(raw).digest('hex'), text: `来源:${path}\n\n${body}` };
2910
3003
  };
2911
- // 场景提示词与 AGENTS.md 正文在用户眼里就是同一件事("全局提示词"):场景期间前者取代后者,
2912
- // 与官方语义一致(进场景改写文件、退出恢复)。所以一个域、一份文本,取到非空的场景提示词就用它。
2913
- const promptText = () => {
2914
- const scene = resolveScenePreset()?.text ?? '';
2915
- return scene.trim() !== '' ? scene : agentsMdText();
3004
+ /**
3005
+ * 模型侧的提示词正文(`''` = 没有可注入的内容)。
3006
+ *
3007
+ * **不判"与文件重复"** —— 预设挂了官方 agent-instructions 时那份正文已经由文件送达
3008
+ * (注入侧会跳过整个域),没挂时(极简)才需要注入兜底;判定在注入器的 `selectInjections` 里。
3009
+ */
3010
+ const promptText = () => resolvePrompt()?.text ?? '';
3011
+ /**
3012
+ * `promptText()` 的**来源文件**(同步):注入通道把它当作 instructions 形态的
3013
+ * `changes` 交给界面(文件清单 + 已载入/已更新),见 context-inject.ts 的 `files`。
3014
+ * 与文本同源(都走 `resolvePrompt`),所以两边不会分叉;`[]` = 无正文。
3015
+ *
3016
+ * 路径用**展示形态**(见 `dshHomeDisplay`):界面里官方 agent-instructions 那条行写的是
3017
+ * `~/.dsh/AGENTS.md`,我们写绝对路径的话,同一个界面上会出现两种风格。
3018
+ */
3019
+ const promptFiles = () => {
3020
+ const resolved = resolvePrompt();
3021
+ return resolved === null ? [] : [{ path: resolved.path, digest: resolved.digest }];
2916
3022
  };
2917
3023
  /** 写操作:串行队列内执行,成功后触发 refresh(失效缓存,下一请求即生效)。 */
2918
3024
  const runWrite = (task) => enqueueMutation(async () => {
@@ -2963,6 +3069,7 @@ export function createRulesService(ctx, deps) {
2963
3069
  writeOps,
2964
3070
  memoryText,
2965
3071
  promptText,
3072
+ promptFiles,
2966
3073
  refresh,
2967
3074
  patchIndex,
2968
3075
  readArchiveSlice,
@@ -11,8 +11,9 @@
11
11
  //
12
12
  // 与官方目录的两处有意差别:
13
13
  // - 只列**当前启用且可被模型调用**的(官方只过滤可调用性,不管用户在插件页关掉了谁)。
14
- // - 结尾多一行:本预设没有官方 `skill` 加载工具(目录与工具是一起挂的),需要正文时得先
15
- // `skill_manager_list` 取源文件路径再读那个文件 —— 不写这句,模型会以为有 `skill` 工具可调。
14
+ // - 「本预设没有官方 `skill` 加载工具,要正文先用 `skill_manager_list` 取源文件路径」那句
15
+ // 2026-09-18 起挪进注入框架的 `how` 行(context-inject.ts 的 DOMAIN_FRAME.skills)——
16
+ // 本段只排版清单,标题与"怎么用"一处一个出处。
16
17
  //
17
18
  // 同步性:`text()` 必须同步返回(注入通道每个 step 同步取文本),而技能清单是异步读盘 →
18
19
  // 与子智能体目录同构的 stale-while-revalidate:`text()` 返回缓存值并在超龄时后台重算,
@@ -72,13 +73,9 @@ export function renderSkillCatalog(data, maxEntries = SKILL_CATALOG_MAX_ENTRIES,
72
73
  const sorted = [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
73
74
  const shown = sorted.slice(0, Math.max(0, maxEntries));
74
75
  const lines = shown.map((row) => (row.description ? '- `' + row.name + '`: ' + row.description : '- `' + row.name + '`'));
75
- const out = [
76
- '## 技能',
77
- '',
78
- '**本预设没有官方 `skill` 加载工具;要技能正文时用 `skill_manager_list` 取源文件路径再读。**',
79
- '',
80
- ...lines,
81
- ];
76
+ // 只有清单:标题、工具名与"只给摘要、读完再用"的纪律由注入通道的框架承担
77
+ // (2026-09-18 起,见 context-inject.ts 的 DOMAIN_FRAME 的 `how` 行)—— 一处内容一个出处。
78
+ const out = [...lines];
82
79
  const hidden = sorted.length - shown.length;
83
80
  if (hidden > 0)
84
81
  out.push('', `(另有 ${hidden} 个未列出,用 \`skill_manager_list\` 查。)`);
@@ -564,13 +564,14 @@ function notDeletableError(definition) {
564
564
  */
565
565
  function reservedSourceError(root) {
566
566
  const key = root && root.key ? root.key : "";
567
+ const origin = key === "dsh"
568
+ ? "DSH 技能目录是默认来源,必须读取"
569
+ : "导入技能目录是默认来源、插件新建/导入的落点,必须读取";
567
570
  return {
568
571
  ok: false,
569
572
  code: "error.source.reserved",
570
- params: { root: key },
571
- error: key === "dsh"
572
- ? "DSH 技能目录是默认来源,必须读取:不能停用或移除(里面的技能可以删除)"
573
- : "导入技能目录是默认来源、插件新建/导入的落点,必须读取:不能停用或移除(里面的技能可以删除)",
573
+ params: { root: key, origin },
574
+ error: `${origin}:不能停用或移除(里面的技能可以删除)`,
574
575
  };
575
576
  }
576
577
  function rootDefinition(root) {
@@ -1,3 +1,5 @@
1
+ import { catalogInjectedAt } from './service.js';
2
+ import { subagentDepthOf } from '../context-inject.js';
1
3
  import { filterBySceneBinding } from './tools.js';
2
4
  /**
3
5
  * 描述截断长度 —— 照抄官方技能目录的默认值
@@ -25,6 +27,10 @@ export function catalogDescription(value, maxLength) {
25
27
  /**
26
28
  * 渲染人设目录段(纯函数,便于单独推理)。
27
29
  *
30
+ * ⚠️ 传进来的 `allowed` 必须**已经按当前会话的目录注入深度过滤过**(调用方
31
+ * `createSubagentCatalog.text` 负责,用 `catalogInjectedAt`)。这里刻意不再过滤一遍:
32
+ * 判据只该有一个来源,本函数只管排版。
33
+ *
28
34
  * 返回 `''` 表示不注入 —— `renderPrompt` 会删除空段,所以不用人设的用户零 token 成本。
29
35
  * 名字按字典序排序:即使底层目录枚举顺序变化,段文本也保持逐字节稳定(前缀缓存契约)。
30
36
  *
@@ -32,8 +38,10 @@ export function catalogDescription(value, maxLength) {
32
38
  * 「该人设的完整提示词会成为子代理的系统提示词」「子代理在独立上下文中执行」都是宿主内部
33
39
  * 机制,模型无法据此行动;而委派的调用语义与成本(自包含任务、只回最终结果、会开新会话)
34
40
  * 已经写在 `subagent_manager_run` 的描述里 —— 常驻层再重复一遍等于同一件事付两次 token。
35
- * 第二版(用户指出"太冗余"):引导语压成**半行** —— 只留「怎么用」,"下面是名字与摘要"这类
36
- * 自明的话删掉;域是什么由消息引导语与轨迹行名交代,细节由 `subagent_manager_list` 承担。
41
+ *
42
+ * 第三版(2026-09-17 用户指出"还是那句套话"):**职责彻底切开** —— 本函数只排版清单
43
+ * (`- **名字** — 描述`),"这是什么 + 该拿它做什么"整句交给注入通道的引导语。于是这里
44
+ * 既没有标题也没有"可委派给下列子智能体"那句:一处内容只有一个出处。
37
45
  */
38
46
  export function renderSubagentCatalog(allowed, maxEntries = DEFAULT_CATALOG_MAX_ENTRIES, maxDescription = DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH) {
39
47
  if (!allowed.length)
@@ -42,13 +50,11 @@ export function renderSubagentCatalog(allowed, maxEntries = DEFAULT_CATALOG_MAX_
42
50
  const shown = sorted.slice(0, Math.max(0, maxEntries));
43
51
  const lines = shown.map((p) => '- **' + p.name + '** — ' + (catalogDescription(p.description, maxDescription) || NO_DESCRIPTION));
44
52
  const hidden = sorted.length - shown.length;
45
- const out = [
46
- '## 子智能体',
47
- '',
48
- '**可委派给下列子智能体(调 `subagent_manager_run`)。**',
49
- '',
50
- ...lines,
51
- ];
53
+ // 只给清单。"这是什么"与"该拿它做什么"由注入通道的框架交代 —— 2026-09-18 起是
54
+ // context-inject.ts 的 `DOMAIN_FRAME.subagents`(标题 + 加粗的动作句 + 工具名行)。
55
+ // 这里原本还有 `## 子智能体` 标题与一句加粗的「可委派给下列子智能体(调 `subagent_manager_run`)。」
56
+ // —— 加上引导语,同一件事说了三遍(用户 2026-09-17 指出)。正文从此只管排版。
57
+ const out = [...lines];
52
58
  // 查询工具**只在真被 40 条上限截掉时**才出现:常态下不提,省常驻字符,也免得模型为了
53
59
  // 「确认一遍」去调它(用户裁定:没列出来的就是当前不想要的)。与 MCP 状态段的
54
60
  // `(另有 N 台未列出。)` 同一句式,但这里多给一个出口——不给人设就真的找不回来了。
@@ -67,15 +73,18 @@ export function createSubagentCatalog(deps, opts = {}) {
67
73
  const maxDescription = opts.maxDescription ?? DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH;
68
74
  const ttlMs = opts.ttlMs ?? 1000;
69
75
  const now = opts.now ?? (() => Date.now());
70
- let value = '';
76
+ let allowed = [];
71
77
  let loadedAt = Number.NEGATIVE_INFINITY;
72
78
  let inflight = null;
79
+ // 渲染结果按**深度**缓存:过滤只依赖深度这一个整数,而实际出现的深度就 0/1/2 三档,
80
+ // 于是"每个 step 同步渲染"退化成一次查表。重算成功时清空。
81
+ const rendered = new Map();
73
82
  const recompute = async () => {
74
83
  try {
75
- const { allowed } = await filterBySceneBinding(await deps.list(), await deps.sceneLists());
76
- value = renderSubagentCatalog(allowed, maxEntries, maxDescription);
84
+ allowed = (await filterBySceneBinding(await deps.list(), await deps.sceneLists())).allowed;
85
+ rendered.clear();
77
86
  }
78
- catch { /* 保留上一次的值;首次失败则维持 '' */ }
87
+ catch { /* 保留上一次的值;首次失败则维持 [] */ }
79
88
  loadedAt = now();
80
89
  };
81
90
  const revalidate = () => {
@@ -85,11 +94,18 @@ export function createSubagentCatalog(deps, opts = {}) {
85
94
  return inflight;
86
95
  };
87
96
  return {
88
- text: () => {
97
+ text: (agent) => {
89
98
  if (now() - loadedAt > ttlMs)
90
99
  void revalidate();
91
- return value;
100
+ const depth = subagentDepthOf(agent);
101
+ const cached = rendered.get(depth);
102
+ if (cached !== undefined)
103
+ return cached;
104
+ const out = renderSubagentCatalog(allowed.filter((p) => catalogInjectedAt(p, depth)), maxEntries, maxDescription);
105
+ rendered.set(depth, out);
106
+ return out;
92
107
  },
108
+ catalogVisibleAt: (depth) => allowed.some((p) => catalogInjectedAt(p, depth)),
93
109
  refresh: () => {
94
110
  loadedAt = Number.NEGATIVE_INFINITY;
95
111
  return revalidate();