dsh-plugin-tool-management 0.8.5 → 0.9.1

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 (59) hide show
  1. package/CHANGELOG.md +275 -206
  2. package/README.md +71 -69
  3. package/README_EN.md +65 -65
  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 +132 -86
  21. package/lib/agents-md/preset-id.js +1 -1
  22. package/lib/agents-md/service.js +46 -9
  23. package/lib/approval-policy.js +1 -1
  24. package/lib/client.js +867 -146
  25. package/lib/compat/preset-reach.js +155 -93
  26. package/lib/context-inject.js +401 -0
  27. package/lib/hub.js +155 -13
  28. package/lib/imports/upload.js +34 -14
  29. package/lib/index.js +1072 -243
  30. package/lib/mcp/state-section.js +46 -15
  31. package/lib/rules/archive-engine.js +158 -64
  32. package/lib/rules/archive.js +41 -1
  33. package/lib/rules/service.js +424 -162
  34. package/lib/scene-prompt-sync.js +93 -12
  35. package/lib/skills/catalog.js +125 -0
  36. package/lib/skills/core.js +6 -4
  37. package/lib/skills/service.js +56 -2
  38. package/lib/subagents/catalog.js +15 -6
  39. package/lib/subagents/service.js +25 -12
  40. package/lib/subagents/tools.js +18 -13
  41. package/package.json +11 -6
  42. package/screenshots.json +8 -8
  43. package/docs/images/1/345/234/272/346/231/257.png +0 -0
  44. package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
  45. package/docs/images/2MCP.png +0 -0
  46. package/docs/images/2MCP_en.png +0 -0
  47. package/docs/images/3/346/212/200/350/203/275.png +0 -0
  48. package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
  49. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  50. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
  51. package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
  52. package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
  53. package/docs/images/6/350/256/260/345/277/206.png +0 -0
  54. package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
  55. package/docs/images/7/344/274/232/350/257/235.png +0 -0
  56. package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
  57. package/docs/images/8/345/205/274/345/256/271.png +0 -0
  58. package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
  59. package/lib/prompt-sections.js +0 -148
@@ -1,21 +1,23 @@
1
1
  // dsh-plugin-tool-management —— 规则/记忆(Rules v0.3,见 CHANGE-REQUEST-01)服务层。
2
2
  //
3
- // 记忆 = $DSH_HOME/tool-management/memories/<场景>/<name>.md(flat)或 <场景>/<name>/SKILL.md(bundle)。
3
+ // 记忆 = $DSH_HOME/tool-management/memories/<场景>/<name>.md(flat)或 <场景>/<name>/<name>.md(bundle)。
4
+ // 历史遗留的 bundle 正文文件名 `SKILL.md` 仍被识别(只读兼容),但新建/更新一律写 `<name>.md`。
4
5
  // **场景是显式记录**:$DSH_HOME/tool-management/scenes/<场景>.json(见 SceneRecord),
5
6
  // 不再是「memories/ 下恰好有这个名字的目录」这种隐式约定——空场景因此可以存在,
6
7
  // 且场景可以有描述。目录名即场景名(任意 Unicode,见 isValidGroupSegment)。
7
8
  // 保留场景 `global`(界面显示「全局」):其记忆注入任何对话;它恒定存在、不可删除。
8
- // 勾选启用后,该目录树内所有 .md 的正文自动进入系统提示词(provider.ts 注册的
9
- // per-agent systemPrompt 段),模型无需做任何动作 —— 这就是"不用每次都要解释"。
9
+ // 勾选启用后,该目录树内所有 .md 的正文自动进入模型的上下文(index.ts 注册的注入通道,
10
+ // 每步一条消息;见 src/context-inject.ts),模型无需做任何动作 —— 这就是"不用每次都要解释"。
10
11
  //
11
12
  // 单投影(原 ADR-4 的"双投影"已被本变更单修订):
12
- // - 活动场景记忆 → systemPrompt 段(自动在场,会话级恒定 → 前缀稳定、缓存可命中)
13
+ // - 活动场景记忆 → 上下文注入(自动在场,会话级恒定 → 前缀稳定、缓存可命中;
14
+ // 文本没变时不重发 —— 2026-09-16 从 systemPrompt 段改道,理由见 context-inject.ts 文件头)
13
15
  // - `_shared/` 承担"恒常"语义(所有场景共用);原 per-rule `always` 标志已移除
14
16
  // - 不再写 ~/.dsh/AGENTS.md(原始终层投影下线)
15
17
  //
16
18
  // 状态分层(两份文件各司其职,互不写回):
17
19
  // - 规则文件:正文真源。frontmatter 可声明 name/description/whenToUse/globs/metadata。
18
- // - rules-index.json:启停/排序/标签/启用场景集合等**索引为准**字段(不写回记忆文件)。
20
+ // - memories-index.json:启停/排序/标签/启用场景集合等**索引为准**字段(不写回记忆文件)。
19
21
  //
20
22
  // 发现必须自实现(不复用 readonly-discovery):可写来源只扫一层会压扁子目录场景,
21
23
  // 而规则的目录树天然是多层的;且 readonly-discovery 的 flat 只认顶层。
@@ -31,7 +33,6 @@ import { copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, st
31
33
  import { basename, dirname, join, resolve } from 'node:path';
32
34
  import { parseSkillDoc, renameWithRetry, resolveDshHome, unquote } from '../skills/core.js';
33
35
  import { expandUploads, planMemoryImport } from '../imports/upload.js';
34
- import { SECTION_ORDER, createPromptSectionRegistrar } from '../prompt-sections.js';
35
36
  import { normalizePresetId } from '../agents-md/preset-id.js';
36
37
  import { normalizeArchive } from './archive.js';
37
38
  import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
@@ -47,12 +48,16 @@ const DEFAULT_GROUP_ORDER = 1000; // 新场景默认 order
47
48
  const SNAPSHOT_TTL_MS = 1000; // 读路径短 TTL 缓存,吸收 UI 密集轮询
48
49
  const DEFAULT_MAX_BYTES = 65536; // 场景记忆段预算上限(字节)
49
50
  const TRUNCATION_MARKER = '<!-- truncated -->';
50
- const DROPPED_HEADING = '## 场景记忆:未注入(超出预算)'; // 段尾清单:让模型知道自己漏了什么
51
+ // 段尾清单:让模型知道自己漏了什么。去掉伞标题后这里也不再挂「场景记忆」前缀 ——
52
+ // 它紧跟在场景块之后,`参考信息` 与段首引导语同一说法。
53
+ const DROPPED_HEADING = '## 未注入的参考信息(超出预算)';
51
54
  // bundle 附件限制(body 走 HTTP JSON + base64,故比技能上传收紧一档)。
52
55
  const MAX_ATTACH_ENTRY_BYTES = 8 << 20; // 单个附件 8 MiB
53
56
  const MAX_ATTACH_TOTAL_BYTES = 16 << 20; // 单次总大小 16 MiB
54
57
  const MAX_ATTACH_ENTRIES = 32; // 单次最多 32 个
55
- const BUNDLE_DOC = 'SKILL.md'; // bundle 的正文文件(附件列表里排除)
58
+ const LEGACY_BUNDLE_DOC = 'SKILL.md'; // 旧版 bundle 的正文文件名;仅在发现/附件排除时作只读兼容,新建一律用 bundleDocName()
59
+ /** bundle 的正文文件名 = `<记忆名>.md`(与目录名一致,不再是固定的 SKILL.md)。 */
60
+ const bundleDocName = (name) => `${name}.md`;
56
61
  const SHARED_GROUP = '_shared'; // 保留场景名:公共基线(历史语义,仍可使用)
57
62
  const INDEX_VERSION = 1;
58
63
  // 场景/子目录段名约束(§4 对照表 + §5.3):任意 Unicode,但必须对文件系统安全。
@@ -91,13 +96,22 @@ const identity = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
91
96
  * 场景/记忆根目录名($DSH_HOME 下),集中在 `tool-management/` 一个目录内:
92
97
  * - `scenes/<场景>.json` 场景记录(名称/描述/顺序)
93
98
  * - `memories/<场景>/…` 记忆正文真源
94
- * - `..`(即 tool-management 根)侧车:rules-index.json / config.json / trash/
99
+ * - `..`(即 tool-management 根)侧车:memories-index.json / skills-state.json / trash/
95
100
  *
96
101
  * 历史位置 `$DSH_HOME/scene-memory/` 由 `relocateLegacyLayout()` 在首次读写前搬入。
97
102
  */
98
103
  export const HUB_DIR = 'tool-management';
99
104
  export const SCENES_DIR = 'scenes';
100
105
  export const MEMORIES_DIR = 'memories';
106
+ /**
107
+ * 记忆索引文件名 / 记忆回收站目录名(hub 根下)。
108
+ * 域叫「记忆」(工具 `memory_manager_*`、界面「记忆」页),所以按域命名 ——
109
+ * 旧名 `rules-index.json` / `rules-trash/` 由 hub 的启动迁移搬过来(见 hub.ts)。
110
+ */
111
+ export const MEMORIES_INDEX_FILE = 'memories-index.json';
112
+ export const MEMORIES_TRASH_DIR = 'memories-trash';
113
+ /** 提示词预设库目录名(hub 根下;域 = 提示词,工具 `prompt_manager_*`)。 */
114
+ export const PRESETS_DIR = 'prompts';
101
115
  /** 保留场景名:界面显示「全局」,恒定存在、不可删除,其记忆注入任何对话。 */
102
116
  export const GLOBAL_SCENE = 'global';
103
117
  /** 保留场景在界面上的显示名(磁盘上仍用 ASCII 目录/文件名)。 */
@@ -105,7 +119,7 @@ export const GLOBAL_SCENE_LABEL = '全局';
105
119
  /**
106
120
  * 记忆导出的落盘映射(`bundle-export` 的 `kind: 'memories'` 用,见 design-plan D14)。
107
121
  *
108
- * 记忆有两种形态(见文件头):flat = `<场景>/<name>.md`,bundle = `<场景>/<name>/SKILL.md`;
122
+ * 记忆有两种形态(见文件头):flat = `<场景>/<name>.md`,bundle = `<场景>/<name>/<name>.md`;
109
123
  * 而且**场景名本身可含 `/`**(多段场景名,见 `isValidGroupPath`)。所以「按 id 的最后一个
110
124
  * `/` 切出场景与名字、再拼 `.md`」是错的:bundle 会被读成 `<场景>/<name>.md` → 读不到 →
111
125
  * 该项目静默丢失(只在 `missing` 里留个名,界面按「导出成功」显示)。
@@ -143,7 +157,7 @@ export function planMemoryExport(names, rules) {
143
157
  /**
144
158
  * 同目录临时文件 + rename 原子写(临时名 `.xxx.dsh-rules-<uuid>.tmp`)。
145
159
  * rename 走 `renameWithRetry`:Windows 上杀软/索引器会短暂占住目标文件报
146
- * EPERM/EACCES/EBUSY(用户实测:进入/退出模式时 rules-index.json 被 rename 撞上,
160
+ * EPERM/EACCES/EBUSY(用户实测:进入/退出模式时 memories-index.json 被 rename 撞上,
147
161
  * 运行时已切换但状态落盘失败,界面开关停在旧状态、还得再点一次)。这种占用是
148
162
  * 瞬时的,重试几轮就能过去;全失败才清理临时文件并把错误抛出。
149
163
  */
@@ -175,8 +189,9 @@ async function writeFileAtomicBinary(path, data) {
175
189
  * BFS 发现(realpath 防环、深度/目录/条目预算),与 readonly-discovery 同构,
176
190
  * 但规则的场景是**多层相对路径**(readonly-discovery 的 group 恒为第一层)。
177
191
  *
178
- * 目录语义:目录含 SKILL.md → 它是 bundle 规则(叶子,不再深入),其「父路径」
179
- * 是场景、目录名是规则名;否则它是场景/子分类,继续遍历其下 .md(flat,任意层级)
192
+ * 目录语义:目录含 `<目录名>.md` → 它是 bundle 规则(叶子,不再深入),其「父路径」
193
+ * 是场景、目录名是规则名;找不到时再退回认旧文件名 `SKILL.md`(只读兼容,不再新建);
194
+ * 都没有则它是场景/子分类,继续遍历其下 .md(flat,任意层级)
180
195
  * 与子目录。同名 flat 与 bundle 冲突时 bundle 优先,flat 记入 shadowed。
181
196
  */
182
197
  async function discover(rulesRoot) {
@@ -241,22 +256,35 @@ async function discover(rulesRoot) {
241
256
  catch {
242
257
  continue; // 失效链接或不可读目录不阻断其他来源
243
258
  }
244
- // bundle 检查:当前目录(非根)含 SKILL.md → 它是规则叶子,父路径为场景。
259
+ // bundle 检查:当前目录含 `<目录名>.md`(或旧版 `SKILL.md`)→ 它是规则叶子,父路径为场景。
260
+ //
261
+ // **根下的一级目录恒为场景**(`parentGroup === ''` 时不做 bundle 判断)——与
262
+ // `probeSceneFilesSync`(注入段)同口径。场景 `1` 里一条叫 `1` 的记忆(`memories/1/1.md`)
263
+ // 与「根层的 bundle `1`」在磁盘上同形,认成后者会把整个场景目录吃成叶子:同场景其余
264
+ // 记忆全从列表消失、多出一条「未归属场景」的同名记忆,新建记忆还会被误报「同名 bundle
265
+ // 遮蔽」(2026-09-16 用户实测)。根层因此没有 bundle,只有 legacy 裸 .md(见 rulesCheck 的 noScene)。
245
266
  if (current.group !== '') {
246
- const docPath = join(current.path, 'SKILL.md');
247
- try {
248
- const st = await lstat(docPath);
249
- if (st.isFile() && !st.isSymbolicLink()) {
250
- const sepIdx = current.group.lastIndexOf('/');
251
- const parentGroup = sepIdx >= 0 ? current.group.slice(0, sepIdx) : '';
252
- const name = sepIdx >= 0 ? current.group.slice(sepIdx + 1) : current.group;
253
- const id = parentGroup ? `${parentGroup}/${name}` : name;
254
- addEntry({ id, group: parentGroup, name, kind: 'bundle', docPath, entryPath: current.path });
255
- continue;
267
+ const sepIdx = current.group.lastIndexOf('/');
268
+ const parentGroup = sepIdx >= 0 ? current.group.slice(0, sepIdx) : '';
269
+ const name = sepIdx >= 0 ? current.group.slice(sepIdx + 1) : current.group;
270
+ let docPath = '';
271
+ if (parentGroup !== '') {
272
+ for (const candidate of [bundleDocName(name), LEGACY_BUNDLE_DOC]) {
273
+ const p = join(current.path, candidate);
274
+ try {
275
+ const st = await lstat(p);
276
+ if (st.isFile() && !st.isSymbolicLink()) {
277
+ docPath = p;
278
+ break;
279
+ }
280
+ }
281
+ catch { /* 该候选名不存在 → 试下一个 */ }
256
282
  }
257
283
  }
258
- catch {
259
- /* 无 SKILL.md → 作为场景/子分类继续 */
284
+ if (docPath !== '') {
285
+ const id = parentGroup ? `${parentGroup}/${name}` : name;
286
+ addEntry({ id, group: parentGroup, name, kind: 'bundle', docPath, entryPath: current.path });
287
+ continue;
260
288
  }
261
289
  }
262
290
  if (current.group !== '') {
@@ -458,9 +486,9 @@ export function parseModeState(raw) {
458
486
  list.push(tool);
459
487
  }
460
488
  }
461
- // v2 快照的**服务器级 / 来源级**名单与 v0.8 的子智能体名单必须原样透传 ——
462
- // 这里曾把它们剥掉,结果退出模式时两张恢复名单全是空的:服务器级 MCP 与技能来源
463
- // 永远不回滚(用户报的「MCP 不复原」「技能目录不回退」),只有工具级 / 技能级能还原。
489
+ // v2 快照的**服务器级 / 来源级**名单、v0.8 的子智能体名单、v0.9.1 的人设全量映射
490
+ // 必须原样透传 —— 这里曾把它们剥掉,结果退出模式时两张恢复名单全是空的:服务器级 MCP 与
491
+ // 技能来源永远不回滚(用户报的「MCP 不复原」「技能目录不回退」),只有工具级 / 技能级能还原。
464
492
  const mcpServers = (Array.isArray(snapshotRaw.mcpServers) ? snapshotRaw.mcpServers : [])
465
493
  .filter((x) => !!x && typeof x === 'object')
466
494
  .map((x) => ({ id: String(x.id || ''), level: String(x.level || ''), disabled: x.disabled === true }))
@@ -470,6 +498,12 @@ export function parseModeState(raw) {
470
498
  .map((x) => ({ root: String(x.root || ''), enabled: x.enabled === true }))
471
499
  .filter((x) => x.root !== '');
472
500
  const subagents = (Array.isArray(snapshotRaw.subagents) ? snapshotRaw.subagents : []).map((x) => String(x)).filter(Boolean);
501
+ // 同 subagents 一组:进入时被档案**关掉**的人设(退出要重新打开)。漏掉它 = 用户实测的
502
+ // 「进场景关掉了,退出却没开回来」——快照落盘后读回来就只剩「被启用」那一半名单。
503
+ const subagentsOn = (Array.isArray(snapshotRaw.subagentsOn) ? snapshotRaw.subagentsOn : []).map((x) => String(x)).filter(Boolean);
504
+ // v0.9.1 的人设**全量**开关映射(名字 → 进场景时是否开着):同样必须原样透传 ——
505
+ // 剥掉它,退出模式就只能退回上面两个部分名单,「场景里手动开过的人设」不再被还原。
506
+ const subagentsAll = toFlagMap(snapshotRaw.subagentsAll);
473
507
  // v0.8.1 的场景备注恢复名单:**必须原样透传**——这里漏掉它,退出模式时备注永不回退
474
508
  //(与历史上 mcpServers / skillSources 被剥掉是同一类 bug)。
475
509
  const mcpNotes = (Array.isArray(snapshotRaw.mcpNotes) ? snapshotRaw.mcpNotes : [])
@@ -485,12 +519,14 @@ export function parseModeState(raw) {
485
519
  ...(mcpServers.length ? { mcpServers } : {}),
486
520
  ...(skillSources.length ? { skillSources } : {}),
487
521
  ...(subagents.length ? { subagents } : {}),
522
+ ...(subagentsOn.length ? { subagentsOn } : {}),
523
+ ...(Object.keys(subagentsAll).length ? { subagentsAll } : {}),
488
524
  ...(mcpNotes.length ? { mcpNotes } : {}),
489
525
  }
490
526
  : null,
491
527
  };
492
528
  }
493
- /** 解析 rules-index.json 原文;任何异常/版本不符 → 默认索引(容忍缺失,§9.3)。 */
529
+ /** 解析 memories-index.json 原文;任何异常/版本不符 → 默认索引(容忍缺失,§9.3)。 */
494
530
  function parseIndex(raw) {
495
531
  const parsed = JSON.parse(raw);
496
532
  if (!parsed || parsed.version !== INDEX_VERSION || typeof parsed.rules !== 'object' || parsed.rules === null)
@@ -507,15 +543,15 @@ function parseIndex(raw) {
507
543
  }
508
544
  async function readIndex(stateDir) {
509
545
  try {
510
- return parseIndex(await readFile(join(stateDir, 'rules-index.json'), 'utf8'));
546
+ return parseIndex(await readFile(join(stateDir, MEMORIES_INDEX_FILE), 'utf8'));
511
547
  }
512
548
  catch {
513
549
  return defaultIndex();
514
550
  }
515
551
  }
516
- /** 同步读索引:systemPrompt 段的渲染路径不能 await(见文件内「两相扫描」注释)。 */
552
+ /** 同步读索引:注入文本的渲染路径不能 await(见文件内「两相扫描」注释)。 */
517
553
  function readIndexSync(stateDir) {
518
- const raw = readFileIfExistsSync(join(stateDir, 'rules-index.json'));
554
+ const raw = readFileIfExistsSync(join(stateDir, MEMORIES_INDEX_FILE));
519
555
  if (raw === null)
520
556
  return defaultIndex();
521
557
  try {
@@ -527,9 +563,9 @@ function readIndexSync(stateDir) {
527
563
  }
528
564
  async function writeIndex(stateDir, index) {
529
565
  await mkdir(stateDir, { recursive: true });
530
- await writeFileAtomically(join(stateDir, 'rules-index.json'), JSON.stringify(index, null, 2));
566
+ await writeFileAtomically(join(stateDir, MEMORIES_INDEX_FILE), JSON.stringify(index, null, 2));
531
567
  }
532
- // ── 场景记录(索引内,`rules-index.json` 的 scenes 切片)+ 旧布局迁移 ────────
568
+ // ── 场景记录(索引内,`memories-index.json` 的 scenes 切片)+ 旧布局迁移 ────────
533
569
  /** 索引条目 → 场景记录(供 UI 直接渲染)。 */
534
570
  function sceneRecordOf(name, entry) {
535
571
  const e = entry || {};
@@ -658,7 +694,7 @@ function normalizeActive(raw) {
658
694
  * 因此"新建即启用"不会发生;存量数据仍按老语义读,避免升级后注入范围突变。
659
695
  * - `_shared` 恒常启用(公共基线),不受开关影响
660
696
  * - 保留场景 `global` 恒常启用:它的记忆对任何对话都成立
661
- * 缺失 rules-index.json 一律按默认值运行,不抛错(§9.3)。
697
+ * 缺失 memories-index.json 一律按默认值运行,不抛错(§9.3)。
662
698
  * 场景生效与否只由 index.active 决定(场景档案/模式也写这一份)。
663
699
  */
664
700
  function resolveActiveScenes(index, knownScenes) {
@@ -766,10 +802,10 @@ async function buildSnapshot(rulesRoot, stateDir) {
766
802
  }
767
803
  // ── 场景记忆段:两相扫描(廉价指纹 → 按需读正文)──────────────────────────
768
804
  //
769
- // 硬约束 1(§5.1):`systemPrompt.section({ text })` 的 provider 在**每次装配**时
770
- // 同步求值(`text: string | ((context) => string)`),返回 Promise 会破坏 renderPrompt。
805
+ // 硬约束 1(§5.1):注入通道每个 step 都要求**同步**取一次文本(`text: () => string`,
806
+ // 见 src/context-inject.ts),返回 Promise 会让这一步的注入直接失败(异常被吞)。
771
807
  //
772
- // 缓存策略(§5.4 S3):段 provider 必须同步返回 string,但不希望每个模型步骤都
808
+ // 缓存策略(§5.4 S3):文本出口必须同步返回 string,但不希望每个模型步骤都
773
809
  // 读一遍所有记忆正文。因此拆成两相:
774
810
  // ① `probeSceneFilesSync()`:只走目录树 + `statSync`(**不读正文**),产出候选文件
775
811
  // 与**指纹**(`id|mtime|size` + 影响渲染的索引字段)。
@@ -781,8 +817,8 @@ async function buildSnapshot(rulesRoot, stateDir) {
781
817
  // 一次 stat 遍历(个人记忆树是亚毫秒级),换来"永远最新且永不静默失效",优先于"零 IO"。
782
818
  //
783
819
  // 与 discover() 的关系:discover 是异步全量快照(供 CRUD/列表/体检),这里是同步轻量
784
- // 扫描(供提示词段),两者对"什么是规则"的定义保持一致:
785
- // 目录含 SKILL.md → bundle 规则(叶子);否则继续下钻;只认 .md;跳过隐藏项;
820
+ // 扫描(供注入通道),两者对"什么是规则"的定义保持一致:
821
+ // 目录含 `<目录名>.md`(或旧版 `SKILL.md`)→ bundle 规则(叶子);否则继续下钻;只认 .md;跳过隐藏项;
786
822
  // 同名 flat 与 bundle 冲突时 bundle 优先。
787
823
  function readFileIfExistsSync(path) {
788
824
  try {
@@ -856,14 +892,23 @@ function probeSceneFilesSync(rulesRoot, index) {
856
892
  continue; // 同步扫描不跟随符号链接(防环)
857
893
  if (item.isDirectory()) {
858
894
  budget.items++;
859
- const skillDoc = join(child, 'SKILL.md');
860
- const stamp = fileStampSync(skillDoc);
861
- if (stamp !== 'missing') {
895
+ let docPath = '';
896
+ for (const candidate of [bundleDocName(item.name), LEGACY_BUNDLE_DOC]) {
897
+ const p = join(child, candidate);
898
+ if (fileStampSync(p) !== 'missing') {
899
+ docPath = p;
900
+ break;
901
+ }
902
+ }
903
+ // 指纹带上**目录本身**的 mtime:附件只在段里以「路径 + 文件名」出现,
904
+ // 增删附件不改正文文件,只靠它的话指纹不变、段不会重算,列表就会停在旧值。
905
+ if (docPath !== '') {
906
+ const stamp = `${fileStampSync(docPath)}:${fileStampSync(child)}`;
862
907
  // bundle 规则(叶子):父路径为场景,目录名为记忆名。
863
908
  const id = scene ? `${scene}/${relChild}` : relChild;
864
909
  if (index.rules[id]?.enabled === false)
865
910
  continue; // 单条停用 → 不进入段
866
- add({ id, scene, name: item.name, kind: 'bundle', path: skillDoc, order: index.rules[id]?.order ?? DEFAULT_ORDER, stamp });
911
+ add({ id, scene, name: item.name, kind: 'bundle', path: docPath, order: index.rules[id]?.order ?? DEFAULT_ORDER, stamp });
867
912
  continue;
868
913
  }
869
914
  walk(scene, child, relChild, depth + 1);
@@ -946,6 +991,9 @@ function renderSceneMemory(probe, index, maxBytes) {
946
991
  descriptionDerived: derived.descriptionDerived,
947
992
  order: ref.order,
948
993
  body: doc.body,
994
+ kind: ref.kind,
995
+ // bundle 的正文是 `<目录>/<名>.md`,附件是**同目录**的其余文件。
996
+ bundleDir: ref.kind === 'bundle' ? dirname(ref.path) : '',
949
997
  });
950
998
  }
951
999
  const { active } = resolveActiveScenes(index, probe.scenes);
@@ -968,14 +1016,15 @@ function renderSceneMemory(probe, index, maxBytes) {
968
1016
  let seq = 0;
969
1017
  for (const scene of [...buckets.keys()].sort((a, b) => compareSceneBuckets(a, b, index))) {
970
1018
  const sceneFiles = (buckets.get(scene) || []).slice().sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
971
- const header = `${sceneHeading(scene)}\n\n${SCENE_MEMORY_NOTE}\n\n`;
1019
+ const header = sceneHeader(scene, index);
972
1020
  for (const file of sceneFiles) {
973
- const block = `### ${ruleHeading(file)}\n\n${String(file.body ?? '').trim()}\n`;
1021
+ const { text: block, inline } = memoryBlock(file);
974
1022
  candidates.push({
975
1023
  seq: seq++,
976
1024
  scene,
977
1025
  header,
978
1026
  block,
1027
+ inline,
979
1028
  item: { id: file.id, scene: file.scene, name: file.name, bytes: byteLen(block) },
980
1029
  });
981
1030
  }
@@ -983,18 +1032,36 @@ function renderSceneMemory(probe, index, maxBytes) {
983
1032
  if (candidates.length === 0) {
984
1033
  return { text: '', bytes: 0, truncated: probe.truncated, maxBytes, scenes: [], items: [], dropped: [] };
985
1034
  }
986
- /** 选中块 → 段正文(同一场景的 `## 场景记忆:x` 只在首次出现时发出一次)。 */
987
- const renderBody = (selected) => {
1035
+ /**
1036
+ * 选中块 → 段正文(同一场景的 `## 场景:x` 只在首次出现时发出)。
1037
+ * `dropped` 决定引导语用哪一版:真有条目没注入时不再声称「以下就是全部信息」。
1038
+ */
1039
+ const renderBody = (selected, dropped) => {
1040
+ if (selected.length === 0)
1041
+ return '';
1042
+ const note = dropped ? SCENE_MEMORY_NOTE_PARTIAL : SCENE_MEMORY_NOTE;
988
1043
  const chunks = [];
989
1044
  let current = null;
990
1045
  let buf = '';
1046
+ // 上一条是不是「单行条目」。连续两条单行条目紧挨着(列表的自然形态),
1047
+ // 其余情况都要空一行 —— 多行正文(含其缩进挂载的附件行)与下一条之间没有空行的话,
1048
+ // 读起来会连成一片、看不出条目边界。
1049
+ let prevInline = false;
991
1050
  for (const c of selected) {
992
1051
  if (c.scene !== current) {
993
1052
  if (buf !== '')
994
1053
  chunks.push(buf);
995
1054
  current = c.scene;
996
- buf = c.header;
1055
+ // 引导语跟着**场景块**走(场景说明之后、条目之前):它管的就是下面这些条目,
1056
+ // 放最顶层会飘在场景之外(用户实测反馈「怎么跑到最顶层了」)。场景数有上限
1057
+ // (`global`/`_shared` 恒常启用 + 至多一个启用场景),最多出现 3 次,代价可接受。
1058
+ buf = `${c.header}${note}\n\n`;
1059
+ prevInline = false;
997
1060
  }
1061
+ const inline = c.inline;
1062
+ if (!(inline && prevInline) && !buf.endsWith('\n\n'))
1063
+ buf += '\n';
1064
+ prevInline = inline;
998
1065
  buf += c.block;
999
1066
  }
1000
1067
  if (buf !== '')
@@ -1032,9 +1099,12 @@ function renderSceneMemory(probe, index, maxBytes) {
1032
1099
  const selected = [];
1033
1100
  const missed = [];
1034
1101
  const takenScenes = new Set();
1102
+ // 场景标题与引导语也是开销,按「每个首次出现的场景」计进预算 —— 否则它们会挤掉
1103
+ // 本该放得下的记忆(引导语的字节见 SCENE_NOTE_BYTES)。
1035
1104
  let used = 0;
1105
+ const noteBytes = sceneNoteBytes();
1036
1106
  for (const c of candidates) {
1037
- const headerCost = takenScenes.has(c.scene) ? 0 : byteLen(c.header);
1107
+ const headerCost = takenScenes.has(c.scene) ? 0 : byteLen(c.header) + noteBytes;
1038
1108
  if (used + headerCost + c.item.bytes + markerReserve > maxBytes) {
1039
1109
  missed.push(c);
1040
1110
  continue;
@@ -1045,12 +1115,12 @@ function renderSceneMemory(probe, index, maxBytes) {
1045
1115
  }
1046
1116
  missed.sort((a, b) => a.seq - b.seq);
1047
1117
  // ── ② 尾注自身也占字节:放不下就把已入选的块从后往前退回,直到回到预算内 ──
1048
- let body = renderBody(selected);
1118
+ let body = renderBody(selected, probe.truncated || missed.length > 0);
1049
1119
  let tail = renderTail(missed, maxBytes - byteLen(body), probe.truncated || missed.length > 0);
1050
1120
  while (byteLen(body) + byteLen(tail) > maxBytes && selected.length > 0) {
1051
1121
  missed.push(selected.pop());
1052
1122
  missed.sort((a, b) => a.seq - b.seq);
1053
- body = renderBody(selected);
1123
+ body = renderBody(selected, true);
1054
1124
  tail = renderTail(missed, maxBytes - byteLen(body), true);
1055
1125
  }
1056
1126
  let text = `${body}${tail}`.replace(/^\n+/, '');
@@ -1105,15 +1175,175 @@ function sceneLabel(scene, index) {
1105
1175
  const label = index?.scenes?.[scene]?.label;
1106
1176
  return label && label !== '' ? label : scene;
1107
1177
  }
1108
- /** 场景标题:`''` → 全局;其余用目录名(可追溯)。 */
1109
- const sceneHeading = (scene) => `## 场景记忆:${sceneLabel(scene)}`;
1178
+ /** 单个场景的标题:**场景在最顶层**(`##`,与「子智能体」「MCP 服务器」等段同级)。 */
1179
+ const sceneHeading = (scene) => `## 场景:${sceneLabel(scene)}`;
1180
+ /**
1181
+ * 没填描述时的默认「场景说明」(用户裁定 2026-09-16:全局桶一直没有描述,读起来像缺了一块,
1182
+ * 统一成"每个场景块都有场景说明")。
1183
+ *
1184
+ * 默认句同时承担"这个场景是什么"的答疑(此前只有光秃秃的 `## 场景:X`,模型读不懂 —— 用户实测):
1185
+ * 两个恒常桶说明生效范围,用户场景说明它是当前启用的那份配置。
1186
+ */
1187
+ const defaultSceneDescription = (scene) => (scene === GLOBAL_SCENE ? '全局记忆,任何对话都生效'
1188
+ : scene === SHARED_GROUP ? '共享记忆,任何对话都生效'
1189
+ : '用户配置的上下文,当前启用');
1190
+ /**
1191
+ * 单个场景的段头:场景标题 + **恒有**的 `场景说明:<描述>`。
1192
+ *
1193
+ * 场景描述(界面「描述(可选)」,≤60 字符)**此前从未注入过** —— 它正是「这个场景是
1194
+ * 干什么的」的答案,属于模型做判断需要的上下文,而不是只给人看的元数据;界面上的文案
1195
+ * 也从没把它标成「只给使用者看」(对比 AGENTS.md 预设的描述,那里是明确标注的)。
1196
+ * 描述为空时给 `defaultSceneDescription` 的默认句(用户裁定:全局桶没描述时读起来像
1197
+ * 缺了一块,统一成每个场景块都有说明)。
1198
+ */
1199
+ function sceneHeader(scene, index) {
1200
+ const head = sceneHeading(scene);
1201
+ const described = String(index?.scenes?.[scene]?.description ?? '').replaceAll(/\s+/g, ' ').trim();
1202
+ const description = described === '' ? defaultSceneDescription(scene) : described;
1203
+ // 加粗(用户裁定 2026-09-16):与引导语同款强调,别让"场景说明"读起来像可忽略的普通正文。
1204
+ return `${head}\n\n**场景说明:${description}**\n\n`;
1205
+ }
1206
+ /**
1207
+ * 段首的引导语:让模型知道下面是**用户提供的参考信息**,而不是对话历史或临时说明
1208
+ * —— 相关就用、无关可忽略。
1209
+ *
1210
+ * 写法(2026-09-16 用户裁定,基于真实注入结果的三次修正):
1211
+ * - **单行、加粗**,不再用括号分两行 —— 括号跨行在真实提示词里读起来像被截断,
1212
+ * 而加粗是 Markdown 里最省字符的强调手段(用户要求「加强模型对此的重视程度」)。
1213
+ * - **提到段首、整段只出现一次**:原来它挂在每个场景的段头里,多场景时会重复注入。
1214
+ * - **不点名任何工具**:模型从工具 schema 就知道 `memory_manager_list` 存在,点名反而
1215
+ * 像在提示它去调;用户裁定「没启用的信息就是不想在当前用」,所以工具指引整句删除。
1216
+ * 真正防探测的是**完整性声明**(「以下就是全部信息」),那半句必须留。
1217
+ * - **完整性声明按截断状态自适应**:真有条目因预算没注入时,段尾会有未注入清单,
1218
+ * 此时不能再声称「全部」,否则和清单自相矛盾 —— 也正因为那时确实有东西没给到,
1219
+ * 模型去查工具是**合理**的,不该再拦。
1220
+ *
1221
+ * 用词:不用「常驻」「注入」这类内部行话(模型没有先验);用户裁定用「信息」而不是
1222
+ * 「记忆」——「记忆」在系统提示词里指代不明,而这段的实质就是用户写的参考信息。
1223
+ */
1224
+ const SCENE_MEMORY_NOTE = '**用户提供的参考信息:与当前任务相关时直接采用,无关时忽略。以下就是全部信息。**';
1225
+ /** 有未注入条目时的版本:去掉完整性声明(见上)。 */
1226
+ const SCENE_MEMORY_NOTE_PARTIAL = '**用户提供的参考信息:与当前任务相关时直接采用,无关时忽略。**';
1227
+ /**
1228
+ * 段首固定块。以 `\n` 结尾,与场景块 join 后自然空一行。
1229
+ * 预算按**较长**的那版算(见 renderSceneMemory),保守一点只会浪费几个字节。
1230
+ */
1231
+ /**
1232
+ * 引导语所占的字节(含它后面的一个空行)。跟着场景块走,所以按**每个场景**计入段头开销;
1233
+ * 用较长的那版(带完整性声明)算,保守一点只会浪费几个字节。
1234
+ *
1235
+ * 注意:模块级不能直接算 —— `byteLen` 是后面才声明的 const,模块初始化期取它会 TDZ 报错。
1236
+ */
1237
+ const sceneNoteBytes = () => byteLen(`${SCENE_MEMORY_NOTE}\n\n`);
1238
+ /** 单行正文的最大长度:超过就退回「标题 + 正文块」,避免出现一条几千字符的列表行。 */
1239
+ const INLINE_BODY_MAX = 120;
1240
+ /** 附件行里最多列几个文件名;多的只报总数(路径已经给了,缺的名字模型自己列目录即可)。 */
1241
+ const ATTACHMENT_LIST_MAX = 10;
1242
+ /**
1243
+ * bundle 记忆的附件名(**同步**版 —— 段渲染必须同步返回,`listAttachments` 是异步的)。
1244
+ * 口径与异步版一致:跳过正文本体(`<名>.md`,旧数据可能是 `SKILL.md`),只认普通文件。
1245
+ */
1246
+ function attachmentNamesSync(bundleDir, name) {
1247
+ try {
1248
+ const docNames = new Set([bundleDocName(name), LEGACY_BUNDLE_DOC]);
1249
+ return readdirSync(bundleDir, { withFileTypes: true })
1250
+ .filter((e) => e.isFile() && !docNames.has(e.name))
1251
+ .map((e) => e.name)
1252
+ .sort((a, b) => a.localeCompare(b));
1253
+ }
1254
+ catch {
1255
+ return [];
1256
+ }
1257
+ }
1258
+ /**
1259
+ * bundle 记忆的附件**摘要**(列表页用):数量 / 总体积 / 前几个文件名。
1260
+ *
1261
+ * 口径与 `attachmentNamesSync`(也就是注入给模型的那份清单)逐字一致:跳过正文本体,
1262
+ * 只认普通文件 —— 所以页面上的数字与模型实际看到的一致,不会出现「界面说 3 个、模型只见 2 个」。
1263
+ * `names` 截到 ATTACHMENT_LIST_MAX:tooltip 列不下更多,要全看到编辑弹窗里去看。
1264
+ *
1265
+ * 目录读不到(索引残留了已消失的条目)→ 返回 null,让界面**什么都不显示**,
1266
+ * 而不是谎报「0 个附件」。
1267
+ */
1268
+ function attachmentSummarySync(bundleDir, name) {
1269
+ let names;
1270
+ try {
1271
+ const docNames = new Set([bundleDocName(name), LEGACY_BUNDLE_DOC]);
1272
+ names = readdirSync(bundleDir, { withFileTypes: true })
1273
+ .filter((e) => e.isFile() && !docNames.has(e.name))
1274
+ .map((e) => e.name)
1275
+ .sort((a, b) => a.localeCompare(b));
1276
+ }
1277
+ catch {
1278
+ return null;
1279
+ }
1280
+ let bytes = 0;
1281
+ for (const entry of names) {
1282
+ try {
1283
+ bytes += statSync(join(bundleDir, entry)).size;
1284
+ }
1285
+ catch { /* 读不到的条目不计体积 */ }
1286
+ }
1287
+ return { count: names.length, bytes, names: names.slice(0, ATTACHMENT_LIST_MAX) };
1288
+ }
1289
+ /**
1290
+ * 附件行(只有 bundle 记忆才有):**给目录与文件名,不给内容**。
1291
+ * 附件可能是图片、二进制、大 md —— 全文注入又贵又会把段预算吃光;给路径,模型需要时自己读。
1292
+ */
1293
+ function attachmentLine(file) {
1294
+ if (file.kind !== 'bundle')
1295
+ return '';
1296
+ const names = attachmentNamesSync(file.bundleDir, file.name);
1297
+ if (names.length === 0)
1298
+ return '';
1299
+ const shown = names.slice(0, ATTACHMENT_LIST_MAX);
1300
+ const more = names.length - shown.length;
1301
+ return `附件目录:${file.bundleDir}(未注入正文,共 ${names.length} 个:${shown.join('、')}${more > 0 ? `,另 ${more} 个` : ''})`;
1302
+ }
1110
1303
  /**
1111
- * 场景标题后的固定引导语(常量,不破坏前缀缓存稳定):让模型知道这段是当前场景的
1112
- * 常驻参考知识,而不是对话历史或临时说明 —— 相关就用、无关可忽略。
1304
+ * 单条信息的渲染形态 —— **能一行就一行,但恒为列表项**。
1305
+ *
1306
+ * 单行且不长的正文 → `- **名称**(描述) — 正文`(与 MCP / 子智能体两个段的列表同形;无显式描述时括号不出现)
1307
+ * 多行或过长的正文 → `- **名称**(描述)` + 空行 + 缩进 2 格的正文(挂在条目下)
1308
+ *
1309
+ * 名称恒为标题:它就是这条记忆的身份(工具 id `<场景>/<名称>`、bundle 目录/文件名都以它为准),
1310
+ * 用户说「记忆里的 X」、模型再调 `memory_manager_*` 时都对得上号;显式描述是括号注解,不抢标题。
1311
+ *
1312
+ * 为什么全都做成列表项:早先多行正文走 `### 名称` 标题块,附件行只能退化成与记忆**同级**的
1313
+ * `- 附件目录:…`(没有父列表项可挂)—— 既可能被读成一条独立记忆,某些渲染器里还会把下一个
1314
+ * `###` 标题吞进列表(与上一条粘连)。统一成「一条记忆 = 一个列表项、正文与附件都缩进挂在
1315
+ * 条目下」后,两种记忆外观完全一致,归属也不再靠位置猜测。
1316
+ *
1317
+ * 为什么要分两种:用户常有十几条「一句话事实」(「提交格式:PDF」),每条都占标题 + 空行 +
1318
+ * 正文三行,整段会散成一长串标题;压成一行后十条信息就是十行。多行正文是**用户写的完整
1319
+ * Markdown**(可能自带标题、代码块、嵌套列表),整体缩进 2 格挂到条目下,结构原样保留。
1320
+ *
1321
+ * 返回值带 `inline`:调用方据此决定下一条记忆前要不要空行(单行条目连续排列,其余空行分隔)。
1113
1322
  */
1114
- const SCENE_MEMORY_NOTE = '(本场景的常驻参考知识:与当前任务相关时直接采用,无关时忽略)';
1115
- /** 单条记忆的标题:显式 description 优先;派生描述与正文重复,改用文件名。 */
1116
- const ruleHeading = (f) => (f.descriptionDerived || !f.description ? f.name : f.description);
1323
+ function memoryBlock(file) {
1324
+ const desc = explicitDescriptionOf(file);
1325
+ // 加粗的只有名称:`- **名称**(描述) — 正文`,与 MCP 段 `- **server**(N 个工具) — …` 同形。
1326
+ const title = `**${file.name}**` + (desc === '' ? '' : `(${desc})`);
1327
+ const text = String(file.body ?? '').trim();
1328
+ const inline = text === '' || (!text.includes('\n') && text.length <= INLINE_BODY_MAX);
1329
+ const head = text === ''
1330
+ ? `- ${title}`
1331
+ : (inline ? `- ${title} — ${text}` : `- ${title}\n\n${indentBody(text)}`);
1332
+ const attach = attachmentLine(file);
1333
+ if (attach === '')
1334
+ return { text: `${head}\n`, inline };
1335
+ // 附件行恒为缩进子项:只用 `- ` 会被解析成与记忆**同级**的列表项(`- A` / `- 附件目录:A的` /
1336
+ // `- B` … 四条平级,归属读不出来)。单行条目紧跟其后保持列表连续;多行条目前面空一行,
1337
+ // 免得被读成用户正文自己的列表项。
1338
+ return { text: inline ? `${head}\n - ${attach}\n` : `${head}\n\n - ${attach}\n`, inline };
1339
+ }
1340
+ /** 多行正文整体缩进 2 格(列在条目内容列上),空行保持空行、不加尾随空白。 */
1341
+ const indentBody = (text) => text
1342
+ .split('\n')
1343
+ .map((line) => (line === '' ? line : ' ' + line))
1344
+ .join('\n');
1345
+ /** 括号注解用的显式描述:派生描述与正文重复、不进段(只存在于界面投影);换行压成单行,避免把「一行一条」的列表项撑断。 */
1346
+ const explicitDescriptionOf = (f) => (f.descriptionDerived || !f.description ? '' : String(f.description).replaceAll(/\s+/g, ' ').trim());
1117
1347
  const byteLen = (s) => Buffer.byteLength(s, 'utf8');
1118
1348
  // ── 创建服务 ───────────────────────────────────────────────────────────────
1119
1349
  export function createRulesService(ctx, deps) {
@@ -1175,10 +1405,10 @@ export function createRulesService(ctx, deps) {
1175
1405
  sceneCache = { signature: probe.signature, value };
1176
1406
  return value;
1177
1407
  }
1178
- // ── 场景提示词段(绑定预设正文;只读,绝不写 ~/.dsh/AGENTS.md)──────────────
1408
+ // ── 场景提示词(绑定预设正文;读盘、只读;写文件由 scene-prompt-sync.ts 负责)──
1179
1409
  //
1180
1410
  // 语义(用户裁定 2026-09-15):
1181
- // - 场景可绑定**一个**提示词预设(`scenes[].prompt` → `agents-md/<id>/AGENTS.md`);
1411
+ // - 场景可绑定**一个**提示词预设(`scenes[].prompt` → `prompts/<id>/AGENTS.md`);
1182
1412
  // - 除保留场景 `global` 外**同时只能启用一个场景**,所以同一时刻至多一份提示词在场;
1183
1413
  // - 没有启用其它场景时才轮到 `global` 自己的绑定(它恒常生效,覆盖"永远在场的提示词");
1184
1414
  // - 预设文件不存在 / 未绑定 → 返回 `''`(renderPrompt 会删掉空段,不产生空标题)。
@@ -1189,7 +1419,7 @@ export function createRulesService(ctx, deps) {
1189
1419
  const promptFileCache = new Map();
1190
1420
  /** 读一个预设正文(同步、带 stat 指纹缓存);不存在返回 `''`。 */
1191
1421
  function readPresetTextSync(id) {
1192
- const file = join(stateDir, 'agents-md', id, 'AGENTS.md');
1422
+ const file = join(stateDir, PRESETS_DIR, id, 'AGENTS.md');
1193
1423
  let key;
1194
1424
  try {
1195
1425
  const st = statSync(file);
@@ -1217,7 +1447,7 @@ export function createRulesService(ctx, deps) {
1217
1447
  /** 预设文件是否存在(同步;绑定前校验用,避免悬空绑定)。 */
1218
1448
  function presetExistsSync(id) {
1219
1449
  try {
1220
- return statSync(join(stateDir, 'agents-md', id, 'AGENTS.md')).isFile();
1450
+ return statSync(join(stateDir, PRESETS_DIR, id, 'AGENTS.md')).isFile();
1221
1451
  }
1222
1452
  catch {
1223
1453
  return false;
@@ -1254,24 +1484,13 @@ export function createRulesService(ctx, deps) {
1254
1484
  return text;
1255
1485
  }
1256
1486
  /**
1257
- * 当前生效的场景提示词:启用场景的绑定优先,其次 `global` 的绑定;都没有则 `''`。
1258
- * 绑定内容与 `~/.dsh/AGENTS.md` 相同时标记 `duplicate` 且返回空段 —— 那份正文已经在
1259
- * 基线里了,再注入一遍只会把同样的内容塞进上下文两次。
1487
+ * 当前生效的场景提示词:启用场景的绑定优先,其次 `global` 的绑定;都没有 → `null`。
1488
+ * 「绑了但预设为空/已删」按没绑处理(不注入空段),继续找下一个。
1260
1489
  */
1261
- function scenePrompt() {
1490
+ function resolveScenePreset() {
1262
1491
  const index = readIndexSync(stateDir);
1263
1492
  const names = Object.keys(index.scenes || {});
1264
1493
  const { active } = resolveActiveScenes(index, names);
1265
- const globalText = readGlobalAgentsMdSync().trim();
1266
- /** 绑定 → 投影;与文件一致时是 duplicate(不注入),否则原样注入。 */
1267
- const project = (scene, id) => {
1268
- const text = readPresetTextSync(id);
1269
- if (text.trim() === '')
1270
- return { scene, presetId: id, text: '', missing: true };
1271
- if (globalText !== '' && text.trim() === globalText)
1272
- return { scene, presetId: id, text: '', missing: false, duplicate: true };
1273
- return { scene, presetId: id, text, missing: false };
1274
- };
1275
1494
  // 单选:取唯一一个非保留启用场景(resolveActiveScenes 已保证 ≤1,这里仍做确定性排序兜底)。
1276
1495
  const enabled = names
1277
1496
  .filter((n) => n !== SHARED_GROUP && n !== GLOBAL_SCENE && active.has(n))
@@ -1280,27 +1499,35 @@ export function createRulesService(ctx, deps) {
1280
1499
  const id = index.scenes?.[scene]?.prompt;
1281
1500
  if (!id)
1282
1501
  continue;
1283
- const projected = project(scene, id);
1502
+ const text = readPresetTextSync(id);
1284
1503
  // 绑了但预设为空/已删 → 继续找下一个(不注入空段)。
1285
- if (projected.missing)
1504
+ if (text.trim() === '')
1286
1505
  continue;
1287
- return projected;
1506
+ return { scene, presetId: id, text };
1288
1507
  }
1289
1508
  const globalId = index.scenes?.[GLOBAL_SCENE]?.prompt;
1290
- if (globalId)
1291
- return project(GLOBAL_SCENE, globalId);
1292
- return { scene: null, presetId: null, text: '', missing: false };
1293
- }
1294
- const providerInvalidators = new Set();
1295
- /** 通知已注册的 provider 提示词已变化(best-effort)。 */
1296
- const invalidateProviders = () => {
1297
- for (const invalidate of providerInvalidators) {
1298
- try {
1299
- invalidate();
1300
- }
1301
- catch { /* 单个失效失败不影响其余 */ }
1509
+ if (globalId) {
1510
+ const text = readPresetTextSync(globalId);
1511
+ if (text.trim() !== '')
1512
+ return { scene: GLOBAL_SCENE, presetId: globalId, text };
1302
1513
  }
1303
- };
1514
+ return null;
1515
+ }
1516
+ /**
1517
+ * 当前生效的场景提示词(只读投影,界面用):绑定内容与 `~/.dsh/AGENTS.md` 相同时标记
1518
+ * `duplicate` 且返回空段 —— 那份正文已经在基线里了,界面据此显示「生效中」。
1519
+ * (注入侧不看这个字段:预设挂不到 AGENTS.md 通道时照样要注入,见 `promptText`。)
1520
+ */
1521
+ function scenePrompt() {
1522
+ const found = resolveScenePreset();
1523
+ if (!found)
1524
+ return { scene: null, presetId: null, text: '', missing: false };
1525
+ const globalText = readGlobalAgentsMdSync().trim();
1526
+ if (globalText !== '' && found.text.trim() === globalText) {
1527
+ return { scene: found.scene, presetId: found.presetId, text: '', missing: false, duplicate: true };
1528
+ }
1529
+ return { scene: found.scene, presetId: found.presetId, text: found.text, missing: false };
1530
+ }
1304
1531
  // ── 写操作串行队列(避免并发覆盖同一索引/文件)──
1305
1532
  let mutationQueue = Promise.resolve();
1306
1533
  const enqueueMutation = (task) => {
@@ -1322,17 +1549,26 @@ export function createRulesService(ctx, deps) {
1322
1549
  return null;
1323
1550
  return { group, name };
1324
1551
  }
1325
- /** 磁盘定位(bundle 优先)。返回规则文件与条目路径。 */
1552
+ /**
1553
+ * 磁盘定位(bundle 优先)。返回规则文件与条目路径。
1554
+ * 根层(`group === ''`)**没有 bundle**:一级目录恒为场景(见 discover 的注释),
1555
+ * 否则 `memories/1/1.md`(场景 1 的 flat 记忆 1)会被这里读成根层 bundle「1」,
1556
+ * 与列表/注入两端不一致(编辑、删除都会落到错误的文件上)。
1557
+ */
1326
1558
  async function locateRule(group, name) {
1327
- const bundleDir = join(rulesRoot, group, name);
1328
- const bundleDoc = join(bundleDir, 'SKILL.md');
1329
- try {
1330
- const st = await lstat(bundleDoc);
1331
- if (st.isFile() && !st.isSymbolicLink()) {
1332
- return { id: group ? `${group}/${name}` : name, group, name, kind: 'bundle', docPath: bundleDoc, entryPath: bundleDir };
1559
+ if (group !== '') {
1560
+ const bundleDir = join(rulesRoot, group, name);
1561
+ for (const candidate of [bundleDocName(name), LEGACY_BUNDLE_DOC]) {
1562
+ const bundleDoc = join(bundleDir, candidate);
1563
+ try {
1564
+ const st = await lstat(bundleDoc);
1565
+ if (st.isFile() && !st.isSymbolicLink()) {
1566
+ return { id: group ? `${group}/${name}` : name, group, name, kind: 'bundle', docPath: bundleDoc, entryPath: bundleDir };
1567
+ }
1568
+ }
1569
+ catch { /* 该候选名不存在 → 试下一个 */ }
1333
1570
  }
1334
1571
  }
1335
- catch { /* 非 bundle */ }
1336
1572
  const flatPath = join(rulesRoot, group, name + '.md');
1337
1573
  try {
1338
1574
  const st = await lstat(flatPath);
@@ -1407,7 +1643,6 @@ export function createRulesService(ctx, deps) {
1407
1643
  const refresh = async () => {
1408
1644
  invalidateSnapshot();
1409
1645
  sceneCache = null;
1410
- invalidateProviders();
1411
1646
  };
1412
1647
  /** 场景档案引擎专用:读-改-写 mode/archives/active 切片(写队列内,保持与其余索引写串行)。 */
1413
1648
  const patchIndex = (patch) => enqueueMutation(async () => {
@@ -1467,7 +1702,16 @@ export function createRulesService(ctx, deps) {
1467
1702
  const index = await readIndex(stateDir);
1468
1703
  const rules = snap.rules
1469
1704
  .filter((r) => !groupFilter || r.group === groupFilter)
1470
- .sort((a, b) => a.group.localeCompare(b.group) || a.order - b.order || a.name.localeCompare(b.name));
1705
+ .sort((a, b) => a.group.localeCompare(b.group) || a.order - b.order || a.name.localeCompare(b.name))
1706
+ // bundle 记忆附带附件摘要(用户裁定 2026-09-17):界面要显示「几个附件」。
1707
+ // 每条 bundle 一次 readdirSync + 每个附件一次 statSync —— bundle 记忆通常只有几条,
1708
+ // 这个开销可以忽略;flat 没有目录,直接原样返回(不加字段 = 界面不显示附件标签)。
1709
+ .map((r) => {
1710
+ if (r.form !== 'bundle')
1711
+ return r;
1712
+ const attach = attachmentSummarySync(dirname(r.path), r.name);
1713
+ return attach ? { ...r, attach } : r;
1714
+ });
1471
1715
  const groups = groupFilter ? snap.groups.filter((g) => g.name === groupFilter) : snap.groups;
1472
1716
  const scenes = sceneRows(snap, index);
1473
1717
  // 场景锁定状态(v0.8):任意一个场景锁定 = 五个管理域整体冻结。界面据此禁用各页的写控件。
@@ -1488,8 +1732,11 @@ export function createRulesService(ctx, deps) {
1488
1732
  activeScene: enabledSceneOf(index),
1489
1733
  sceneMemory: { usedBytes: projection.bytes, maxBytes: projection.maxBytes, truncated: projection.truncated, dropped: projection.dropped },
1490
1734
  // 当前生效的场景提示词(绑定的预设正文;与 ~/.dsh/AGENTS.md 一致时不重复注入)。
1735
+ // `label` 是给用户指路用的显示名(「去场景设置里改『全局』的绑定」)——`global`
1736
+ // 这类磁盘名直接甩给用户看没有意义。
1491
1737
  scenePrompt: {
1492
1738
  scene: promptProjection.scene,
1739
+ label: promptProjection.scene ? sceneLabel(promptProjection.scene, index) : null,
1493
1740
  presetId: promptProjection.presetId,
1494
1741
  missing: promptProjection.missing,
1495
1742
  duplicate: promptProjection.duplicate === true,
@@ -1525,7 +1772,7 @@ export function createRulesService(ctx, deps) {
1525
1772
  rule: {
1526
1773
  ...rule,
1527
1774
  body: doc.body,
1528
- attachments: entry.kind === 'bundle' ? await listAttachments(entry.entryPath) : [],
1775
+ attachments: entry.kind === 'bundle' ? await listAttachments(entry.entryPath, entry.name) : [],
1529
1776
  frontmatter: {
1530
1777
  name: doc.map.name != null ? String(doc.map.name) : undefined,
1531
1778
  description: doc.map.description != null ? String(doc.map.description) : undefined,
@@ -1648,9 +1895,11 @@ export function createRulesService(ctx, deps) {
1648
1895
  if (!isValidGroupSegment(name))
1649
1896
  return fail('error.rules.invalidName', `记忆名非法:${name}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含 / \\ < > : " | ? *、不以 . 开头)`);
1650
1897
  // 目标已存在(bundle 或 flat 皆算)→ 拒绝,避免静默覆盖。
1898
+ // 用 exists 而不是 shadowed:后者说的是「同名 bundle 把 flat 遮蔽了」,用户看到
1899
+ // 「被同名 bundle 遮蔽」会去找一个并不存在的 bundle(2026-09-16 实测)。
1651
1900
  const existing = await locateRule(group, name);
1652
1901
  if (existing) {
1653
- return fail('error.rules.shadowed', `同名规则已存在(${existing.kind === 'bundle' ? 'bundle' : 'flat'}):${group}/${name}`);
1902
+ return fail('error.rules.exists', `同名记忆已存在(${existing.kind === 'bundle' ? 'bundle' : 'flat'}):${existing.id}`);
1654
1903
  }
1655
1904
  const body = String((args && args.body) ?? '');
1656
1905
  if (body.trim() === '')
@@ -1679,7 +1928,7 @@ export function createRulesService(ctx, deps) {
1679
1928
  const text = serializeRuleFile(fields, body);
1680
1929
  if (form === 'bundle') {
1681
1930
  await mkdir(join(rulesRoot, targetGroup, name), { recursive: true });
1682
- await writeFileAtomically(join(rulesRoot, targetGroup, name, 'SKILL.md'), text);
1931
+ await writeFileAtomically(join(rulesRoot, targetGroup, name, bundleDocName(name)), text);
1683
1932
  }
1684
1933
  else {
1685
1934
  await mkdir(join(rulesRoot, targetGroup), { recursive: true });
@@ -1696,7 +1945,6 @@ export function createRulesService(ctx, deps) {
1696
1945
  index.groups[targetGroup] = { order: DEFAULT_GROUP_ORDER, label: targetGroup };
1697
1946
  await writeIndex(stateDir, index);
1698
1947
  invalidateSnapshot();
1699
- invalidateProviders();
1700
1948
  return { ok: true, rule: await buildProjected(createdId) };
1701
1949
  }
1702
1950
  /**
@@ -1781,10 +2029,10 @@ export function createRulesService(ctx, deps) {
1781
2029
  const id = `${item.group}/${item.name}`;
1782
2030
  try {
1783
2031
  if (item.kind === 'bundle') {
1784
- // 与 rules-create 同落点:bundle = <场景>/<名>/ 目录,正文 SKILL.md,附件平铺同层。
2032
+ // 与 rules-create 同落点:bundle = <场景>/<名>/ 目录,正文 <名>.md,附件平铺同层。
1785
2033
  const bundleDir = join(rulesRoot, item.group, item.name);
1786
2034
  await mkdir(bundleDir, { recursive: true });
1787
- await writeFileAtomically(join(bundleDir, 'SKILL.md'), item.text);
2035
+ await writeFileAtomically(join(bundleDir, bundleDocName(item.name)), item.text);
1788
2036
  for (const att of item.attachments)
1789
2037
  await writeFileAtomicBinary(join(bundleDir, att.name), att.data);
1790
2038
  }
@@ -1887,7 +2135,7 @@ export function createRulesService(ctx, deps) {
1887
2135
  }
1888
2136
  else if (located.kind === 'flat' && newForm === 'bundle') {
1889
2137
  await mkdir(join(rulesRoot, parts.group, newName), { recursive: true });
1890
- await writeFileAtomically(join(rulesRoot, parts.group, newName, 'SKILL.md'), serialize());
2138
+ await writeFileAtomically(join(rulesRoot, parts.group, newName, bundleDocName(newName)), serialize());
1891
2139
  await rm(join(rulesRoot, parts.group, parts.name + '.md'), { force: true });
1892
2140
  }
1893
2141
  else if (located.kind === 'flat' && newName !== parts.name) {
@@ -1911,7 +2159,6 @@ export function createRulesService(ctx, deps) {
1911
2159
  }
1912
2160
  await writeIndex(stateDir, index);
1913
2161
  invalidateSnapshot();
1914
- invalidateProviders();
1915
2162
  return { ok: true, rule: await buildProjected(newId) };
1916
2163
  }
1917
2164
  async function rulesRemove(args) {
@@ -1923,7 +2170,7 @@ export function createRulesService(ctx, deps) {
1923
2170
  if (!located)
1924
2171
  return fail('error.rules.notFound', `规则不存在:${id}`);
1925
2172
  const trashId = Date.now().toString(36) + '-' + randomUUID().slice(0, 8);
1926
- const trashDir = join(stateDir, 'rules-trash', trashId);
2173
+ const trashDir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
1927
2174
  const manifest = { group: parts.group, name: parts.name, form: located.kind, deletedAt: new Date().toISOString() };
1928
2175
  await mkdir(trashDir, { recursive: true });
1929
2176
  if (located.kind === 'bundle') {
@@ -1940,14 +2187,13 @@ export function createRulesService(ctx, deps) {
1940
2187
  delete index.rules[id];
1941
2188
  await writeIndex(stateDir, index);
1942
2189
  invalidateSnapshot();
1943
- invalidateProviders();
1944
2190
  return { ok: true, trashId };
1945
2191
  }
1946
2192
  async function rulesRestore(args) {
1947
2193
  const trashId = String((args && args.trashId) || '');
1948
2194
  if (!isValidTrashId(trashId))
1949
2195
  return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
1950
- const trashDir = join(stateDir, 'rules-trash', trashId);
2196
+ const trashDir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
1951
2197
  let manifest;
1952
2198
  try {
1953
2199
  manifest = JSON.parse(await readFile(join(trashDir, 'manifest.json'), 'utf8'));
@@ -1961,7 +2207,7 @@ export function createRulesService(ctx, deps) {
1961
2207
  // 恢复目标已存在(期间用户重建了同名规则)→ 拒绝,避免覆盖。
1962
2208
  const conflict = await locateRule(manifest.group, manifest.name);
1963
2209
  if (conflict) {
1964
- return fail('error.rules.shadowed', `同名规则已存在:${manifest.group}/${manifest.name},请先移除后再恢复`);
2210
+ return fail('error.rules.exists', `同名记忆已存在:${conflict.id},请先移除后再恢复`);
1965
2211
  }
1966
2212
  const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
1967
2213
  if (form === 'bundle') {
@@ -1975,7 +2221,6 @@ export function createRulesService(ctx, deps) {
1975
2221
  }
1976
2222
  await rm(trashDir, { recursive: true, force: true });
1977
2223
  invalidateSnapshot();
1978
- invalidateProviders();
1979
2224
  return { ok: true, rule: await buildProjected(manifest.group ? `${manifest.group}/${manifest.name}` : manifest.name) };
1980
2225
  }
1981
2226
  /** trashId 只由本插件生成(时间戳 base36 + uuid 前 8 位):严格白名单,杜绝路径穿越。 */
@@ -1983,11 +2228,11 @@ export function createRulesService(ctx, deps) {
1983
2228
  return /^[a-z0-9]+-[a-z0-9]{1,32}$/i.test(trashId);
1984
2229
  }
1985
2230
  /**
1986
- * 记忆回收站列表(只读):`<stateDir>/rules-trash/<trashId>/`(`rulesRemove` 移入,
2231
+ * 记忆回收站列表(只读):`<stateDir>/memories-trash/<trashId>/`(`rulesRemove` 移入,
1987
2232
  * `rulesRestore` 恢复)。损坏或内容缺失的条目跳过,不让一个坏条目挡住整份列表。
1988
2233
  */
1989
2234
  async function rulesTrashList() {
1990
- const root = join(stateDir, 'rules-trash');
2235
+ const root = join(stateDir, MEMORIES_TRASH_DIR);
1991
2236
  let ids = [];
1992
2237
  try {
1993
2238
  ids = await readdir(root);
@@ -2013,9 +2258,20 @@ export function createRulesService(ctx, deps) {
2013
2258
  const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
2014
2259
  let bytes = 0;
2015
2260
  try {
2016
- bytes = (await stat(form === 'bundle' ? join(dir, 'bundle', 'SKILL.md') : join(dir, 'rule.md'))).size;
2261
+ const bundleDoc = form === 'bundle'
2262
+ ? join(dir, 'bundle', bundleDocName(manifest.name))
2263
+ : join(dir, 'rule.md');
2264
+ bytes = (await stat(bundleDoc)).size;
2265
+ }
2266
+ catch {
2267
+ // 兼容旧回收站条目(正文仍叫 SKILL.md);仍读不到就按 0 计,恢复时由 rulesRestore 兜底报错。
2268
+ if (form === 'bundle') {
2269
+ try {
2270
+ bytes = (await stat(join(dir, 'bundle', LEGACY_BUNDLE_DOC))).size;
2271
+ }
2272
+ catch { /* 内容缺失 */ }
2273
+ }
2017
2274
  }
2018
- catch { /* 内容缺失:仍列出,恢复时由 rulesRestore 兜底报错 */ }
2019
2275
  entries.push({ trashId, group, name: manifest.name, form, deletedAt: String(manifest.deletedAt || ''), bytes });
2020
2276
  }
2021
2277
  catch { /* 损坏条目跳过 */ }
@@ -2028,7 +2284,7 @@ export function createRulesService(ctx, deps) {
2028
2284
  const trashId = String((args && args.trashId) || '');
2029
2285
  if (!isValidTrashId(trashId))
2030
2286
  return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
2031
- const dir = join(stateDir, 'rules-trash', trashId);
2287
+ const dir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
2032
2288
  try {
2033
2289
  const st = await lstat(dir);
2034
2290
  if (!st.isDirectory())
@@ -2040,8 +2296,8 @@ export function createRulesService(ctx, deps) {
2040
2296
  await rm(dir, { recursive: true, force: true });
2041
2297
  return { ok: true };
2042
2298
  }
2043
- /** bundle 目录下的附件(顶层普通文件,排除正文 SKILL.md);不存在/不可读返回空数组。 */
2044
- async function listAttachments(bundleDir) {
2299
+ /** bundle 目录下的附件(顶层普通文件,排除正文 `<名>.md`,兼容旧数据 `SKILL.md`);不存在/不可读返回空数组。 */
2300
+ async function listAttachments(bundleDir, name) {
2045
2301
  let entries;
2046
2302
  try {
2047
2303
  entries = await readdir(bundleDir, { withFileTypes: true });
@@ -2049,9 +2305,10 @@ export function createRulesService(ctx, deps) {
2049
2305
  catch {
2050
2306
  return [];
2051
2307
  }
2308
+ const docNames = new Set([bundleDocName(name), LEGACY_BUNDLE_DOC]);
2052
2309
  const out = [];
2053
2310
  for (const entry of entries) {
2054
- if (entry.name === BUNDLE_DOC)
2311
+ if (docNames.has(entry.name))
2055
2312
  continue;
2056
2313
  if (!entry.isFile())
2057
2314
  continue; // 子目录 / 符号链接不列出(detach 也只动普通文件)
@@ -2085,10 +2342,13 @@ export function createRulesService(ctx, deps) {
2085
2342
  return fail('error.rules.tooManyFiles', `一次最多 ${MAX_ATTACH_ENTRIES} 个附件`, { limit: MAX_ATTACH_ENTRIES });
2086
2343
  const pending = [];
2087
2344
  let total = 0;
2345
+ const docNames = new Set([bundleDocName(located.name), LEGACY_BUNDLE_DOC]);
2088
2346
  for (const file of files) {
2089
2347
  const name = String((file && (file.path || file.name)) || '');
2090
2348
  if (!isValidGroupSegment(name))
2091
2349
  return fail('error.rules.invalidName', `附件名非法:${name || '(空)'}`);
2350
+ if (docNames.has(name))
2351
+ return fail('error.rules.invalidName', `附件名不能与正文文件同名:${name}`);
2092
2352
  const data = Buffer.from(String((file && file.data) || ''), 'base64');
2093
2353
  if (data.length === 0)
2094
2354
  return fail('error.rules.emptyFile', `附件内容为空:${name}`);
@@ -2101,9 +2361,9 @@ export function createRulesService(ctx, deps) {
2101
2361
  }
2102
2362
  for (const file of pending)
2103
2363
  await writeFileAtomicBinary(join(located.entryPath, file.name), file.data);
2104
- return { ok: true, attachments: await listAttachments(located.entryPath) };
2364
+ return { ok: true, attachments: await listAttachments(located.entryPath, located.name) };
2105
2365
  }
2106
- /** 删除 bundle 记忆的一个附件;正文 SKILL.md 不可删。 */
2366
+ /** 删除 bundle 记忆的一个附件;正文 `<名>.md`(或旧数据 `SKILL.md`)不可删。 */
2107
2367
  async function rulesDetach(args) {
2108
2368
  const id = String((args && args.id) || '');
2109
2369
  const parts = parseId(id);
@@ -2115,8 +2375,9 @@ export function createRulesService(ctx, deps) {
2115
2375
  if (located.kind !== 'bundle')
2116
2376
  return fail('error.rules.notBundle', `「${parts.name}」是 flat(单文件),没有附件`);
2117
2377
  const name = String((args && args.name) || '');
2118
- if (!isValidGroupSegment(name) || name === BUNDLE_DOC)
2378
+ if (!isValidGroupSegment(name) || name === bundleDocName(located.name) || name === LEGACY_BUNDLE_DOC) {
2119
2379
  return fail('error.rules.invalidName', `附件名非法:${name || '(空)'}`);
2380
+ }
2120
2381
  const target = join(located.entryPath, name);
2121
2382
  try {
2122
2383
  const st = await lstat(target);
@@ -2127,7 +2388,7 @@ export function createRulesService(ctx, deps) {
2127
2388
  return fail('error.rules.notFound', `附件不存在:${name}`);
2128
2389
  }
2129
2390
  await rm(target, { force: true });
2130
- return { ok: true, attachments: await listAttachments(located.entryPath) };
2391
+ return { ok: true, attachments: await listAttachments(located.entryPath, located.name) };
2131
2392
  }
2132
2393
  async function rulesToggle(args) {
2133
2394
  const id = String((args && args.id) || '');
@@ -2147,7 +2408,6 @@ export function createRulesService(ctx, deps) {
2147
2408
  index.rules[id] = { ...idxEntry, enabled, updatedAt: new Date().toISOString() };
2148
2409
  await writeIndex(stateDir, index);
2149
2410
  invalidateSnapshot();
2150
- invalidateProviders();
2151
2411
  return { ok: true, rule: await buildProjected(id) };
2152
2412
  }
2153
2413
  /**
@@ -2192,7 +2452,6 @@ export function createRulesService(ctx, deps) {
2192
2452
  index.active = names;
2193
2453
  await writeIndex(stateDir, index);
2194
2454
  invalidateSnapshot();
2195
- invalidateProviders();
2196
2455
  const snap = await snapshot();
2197
2456
  return {
2198
2457
  ok: true,
@@ -2250,7 +2509,6 @@ export function createRulesService(ctx, deps) {
2250
2509
  index.scenes[name] = next;
2251
2510
  await writeIndex(stateDir, index);
2252
2511
  invalidateSnapshot();
2253
- invalidateProviders();
2254
2512
  return { ok: true, scene: sceneRecordOf(name, next), ...(collapsed ? { collapsedActive: true } : {}) };
2255
2513
  }
2256
2514
  /**
@@ -2378,7 +2636,6 @@ export function createRulesService(ctx, deps) {
2378
2636
  index.scenes[finalName] = next;
2379
2637
  await writeIndex(stateDir, index);
2380
2638
  invalidateSnapshot();
2381
- invalidateProviders();
2382
2639
  return {
2383
2640
  ok: true,
2384
2641
  ...(finalName === name ? {} : { renamedFrom: name }),
@@ -2503,7 +2760,6 @@ export function createRulesService(ctx, deps) {
2503
2760
  if (dirty)
2504
2761
  await writeIndex(stateDir, index);
2505
2762
  invalidateSnapshot();
2506
- invalidateProviders();
2507
2763
  return { ok: true, name, trashId: trashed.id, movedFiles: moves.length };
2508
2764
  }
2509
2765
  /** 场景回收站列表(只读):`<hub>/trash/scenes-trash/<id>/manifest.json`。 */
@@ -2562,7 +2818,6 @@ export function createRulesService(ctx, deps) {
2562
2818
  await writeIndex(stateDir, index);
2563
2819
  await purgeTrashEntry('scenes', id);
2564
2820
  invalidateSnapshot();
2565
- invalidateProviders();
2566
2821
  return { ok: true, name, restoredFiles: restored.length };
2567
2822
  }
2568
2823
  /** 永久删除一条场景回收站条目。 */
@@ -2575,7 +2830,7 @@ export function createRulesService(ctx, deps) {
2575
2830
  }
2576
2831
  /**
2577
2832
  * 提示词预设改名后**同步场景绑定**:把所有 `scenes[].prompt === from` 改成 `to`。
2578
- * 由 AGENTS.md 预设库的改名路径调用——绑定存在 `rules-index.json` 里,预设库
2833
+ * 由 AGENTS.md 预设库的改名路径调用——绑定存在 `memories-index.json` 里,预设库
2579
2834
  * 自己看不到它,不叫这一声改名就会留下悬空绑定(场景卡片显示「预设不存在」)。
2580
2835
  * @returns 改了几个场景。
2581
2836
  */
@@ -2597,7 +2852,6 @@ export function createRulesService(ctx, deps) {
2597
2852
  index.scenes = scenes;
2598
2853
  await writeIndex(stateDir, index);
2599
2854
  invalidateSnapshot();
2600
- invalidateProviders();
2601
2855
  }
2602
2856
  return { ok: true, from, to, changed };
2603
2857
  }
@@ -2625,33 +2879,40 @@ export function createRulesService(ctx, deps) {
2625
2879
  index.rules[id] = next;
2626
2880
  await writeIndex(stateDir, index);
2627
2881
  invalidateSnapshot();
2628
- invalidateProviders();
2629
2882
  return { ok: true, rule: await buildProjected(id) };
2630
2883
  }
2631
2884
  // ── 组装 ─────────────────────────────────────────────────────────────────
2632
- const registerProviders = () => {
2633
- try {
2634
- // 场景绑定的提示词**不再注入**(2026-09-15 用户裁定:「切换场景,对应的提示词直接把
2635
- // AGENTS.md 修改」):启用/切换场景、改绑、编辑绑定的预设时,宿主把那份正文写进
2636
- // `~/.dsh/AGENTS.md`(与「提示词」页的「应用」同一条路,覆盖前多代备份),关掉场景时
2637
- // 恢复进场景之前的基线。真改文件之后再注入一遍,同一份正文会进上下文两次,而且用户手改
2638
- // 基线之后还会被重新注入 —— 所以这里只保留场景记忆段。绑定关系的**只读投影**仍由 rules
2639
- // 服务提供(页面「生效中」标记,以及"文件是否已经同步成它"的判断)。
2640
- const registrar = createPromptSectionRegistrar(ctx, [{
2641
- name: 'tool-management:scene-memory',
2642
- order: SECTION_ORDER.sceneMemory,
2643
- text: () => sceneMemory().text,
2644
- }]);
2645
- providerInvalidators.add(registrar.invalidate);
2646
- return () => {
2647
- providerInvalidators.delete(registrar.invalidate);
2648
- registrar.dispose();
2649
- };
2650
- }
2651
- catch (e) {
2652
- console.error('[dsh-plugin-tool-management] rules provider setup failed:', message(e));
2653
- return () => { };
2654
- }
2885
+ // 注入文本出口(2026-09-16 第四版)。
2886
+ //
2887
+ // 历史:本服务先后把场景记忆做成"系统提示词段"(会被 persona complete 压掉)与
2888
+ // "写 AGENTS.md 文件"(预设不挂 dsh-agent-instructions 时到不了模型)。现在两条都交给
2889
+ // 注入通道(src/context-inject.ts):每个 step 前把文本并进一条注入消息,任何预设都到得了。
2890
+ // 这里只留**同步取文本**的两个出口,注册/生命周期由 index.ts 的注入器统一管。
2891
+ //
2892
+ // - `memoryText`:场景记忆段(记忆正文;`''` = 没有可注入的内容)。
2893
+ // - `promptText`:全局提示词正文 = 场景期间用场景提示词、平时用 AGENTS.md 正文。
2894
+ // **不判"与文件重复"** —— 预设挂了官方 agent-instructions 时那份正文已经由文件送达
2895
+ // (注入侧会跳过整个域),没挂时(极简)才需要注入兜底;判定在注入器的 `selectInjections` 里。
2896
+ const memoryText = () => sceneMemory().text;
2897
+ /**
2898
+ * `~/.dsh/AGENTS.md` 正文。上限与官方那一行的 `maxBytes` 同量级(64 KiB):超大文件按字节
2899
+ * 截断并留一行标记,免得一份手写的巨型基线把上下文撑爆。
2900
+ */
2901
+ 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,已截断。)`;
2910
+ };
2911
+ // 场景提示词与 AGENTS.md 正文在用户眼里就是同一件事("全局提示词"):场景期间前者取代后者,
2912
+ // 与官方语义一致(进场景改写文件、退出恢复)。所以一个域、一份文本,取到非空的场景提示词就用它。
2913
+ const promptText = () => {
2914
+ const scene = resolveScenePreset()?.text ?? '';
2915
+ return scene.trim() !== '' ? scene : agentsMdText();
2655
2916
  };
2656
2917
  /** 写操作:串行队列内执行,成功后触发 refresh(失效缓存,下一请求即生效)。 */
2657
2918
  const runWrite = (task) => enqueueMutation(async () => {
@@ -2700,7 +2961,8 @@ export function createRulesService(ctx, deps) {
2700
2961
  const service = {
2701
2962
  ops,
2702
2963
  writeOps,
2703
- registerProviders,
2964
+ memoryText,
2965
+ promptText,
2704
2966
  refresh,
2705
2967
  patchIndex,
2706
2968
  readArchiveSlice,