dsh-plugin-tool-management 0.8.0 → 0.9.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 (60) hide show
  1. package/CHANGELOG.md +67 -1
  2. package/README.md +53 -48
  3. package/README_EN.md +47 -44
  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 +39 -0
  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 +431 -135
  25. package/lib/compat/preset-reach.js +155 -93
  26. package/lib/compat/probe.js +6 -4
  27. package/lib/context-inject.js +304 -0
  28. package/lib/hub.js +155 -13
  29. package/lib/imports/upload.js +34 -14
  30. package/lib/index.js +870 -226
  31. package/lib/mcp/state-section.js +46 -15
  32. package/lib/rules/archive-engine.js +182 -57
  33. package/lib/rules/archive.js +57 -1
  34. package/lib/rules/service.js +386 -156
  35. package/lib/scene-prompt-sync.js +101 -12
  36. package/lib/skills/catalog.js +125 -0
  37. package/lib/skills/core.js +38 -4
  38. package/lib/skills/service.js +36 -10
  39. package/lib/subagents/catalog.js +15 -5
  40. package/lib/subagents/service.js +39 -28
  41. package/lib/subagents/tools.js +18 -13
  42. package/package.json +14 -9
  43. package/screenshots.json +8 -8
  44. package/docs/images/1/345/234/272/346/231/257.png +0 -0
  45. package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
  46. package/docs/images/2MCP.png +0 -0
  47. package/docs/images/2MCP_en.png +0 -0
  48. package/docs/images/3/346/212/200/350/203/275.png +0 -0
  49. package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
  50. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  51. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
  52. package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
  53. package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
  54. package/docs/images/6/350/256/260/345/277/206.png +0 -0
  55. package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
  56. package/docs/images/7/344/274/232/350/257/235.png +0 -0
  57. package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
  58. package/docs/images/8/345/205/274/345/256/271.png +0 -0
  59. package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
  60. 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 !== '') {
@@ -420,7 +448,7 @@ function parseArchives(raw) {
420
448
  }
421
449
  return out;
422
450
  }
423
- function parseModeState(raw) {
451
+ export function parseModeState(raw) {
424
452
  const obj = (raw && typeof raw === 'object' ? raw : {});
425
453
  const scene = typeof obj.scene === 'string' && obj.scene.trim() !== '' ? obj.scene : null;
426
454
  const snapshotRaw = (obj.snapshot && typeof obj.snapshot === 'object' ? obj.snapshot : {});
@@ -470,6 +498,15 @@ 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.8.1 的场景备注恢复名单:**必须原样透传**——这里漏掉它,退出模式时备注永不回退
505
+ //(与历史上 mcpServers / skillSources 被剥掉是同一类 bug)。
506
+ const mcpNotes = (Array.isArray(snapshotRaw.mcpNotes) ? snapshotRaw.mcpNotes : [])
507
+ .filter((x) => !!x && typeof x === 'object')
508
+ .map((x) => ({ id: String(x.id || ''), note: typeof x.note === 'string' ? x.note : null }))
509
+ .filter((x) => x.id !== '');
473
510
  return {
474
511
  scene,
475
512
  snapshot: scene
@@ -479,11 +516,13 @@ function parseModeState(raw) {
479
516
  ...(mcpServers.length ? { mcpServers } : {}),
480
517
  ...(skillSources.length ? { skillSources } : {}),
481
518
  ...(subagents.length ? { subagents } : {}),
519
+ ...(subagentsOn.length ? { subagentsOn } : {}),
520
+ ...(mcpNotes.length ? { mcpNotes } : {}),
482
521
  }
483
522
  : null,
484
523
  };
485
524
  }
486
- /** 解析 rules-index.json 原文;任何异常/版本不符 → 默认索引(容忍缺失,§9.3)。 */
525
+ /** 解析 memories-index.json 原文;任何异常/版本不符 → 默认索引(容忍缺失,§9.3)。 */
487
526
  function parseIndex(raw) {
488
527
  const parsed = JSON.parse(raw);
489
528
  if (!parsed || parsed.version !== INDEX_VERSION || typeof parsed.rules !== 'object' || parsed.rules === null)
@@ -500,15 +539,15 @@ function parseIndex(raw) {
500
539
  }
501
540
  async function readIndex(stateDir) {
502
541
  try {
503
- return parseIndex(await readFile(join(stateDir, 'rules-index.json'), 'utf8'));
542
+ return parseIndex(await readFile(join(stateDir, MEMORIES_INDEX_FILE), 'utf8'));
504
543
  }
505
544
  catch {
506
545
  return defaultIndex();
507
546
  }
508
547
  }
509
- /** 同步读索引:systemPrompt 段的渲染路径不能 await(见文件内「两相扫描」注释)。 */
548
+ /** 同步读索引:注入文本的渲染路径不能 await(见文件内「两相扫描」注释)。 */
510
549
  function readIndexSync(stateDir) {
511
- const raw = readFileIfExistsSync(join(stateDir, 'rules-index.json'));
550
+ const raw = readFileIfExistsSync(join(stateDir, MEMORIES_INDEX_FILE));
512
551
  if (raw === null)
513
552
  return defaultIndex();
514
553
  try {
@@ -520,9 +559,9 @@ function readIndexSync(stateDir) {
520
559
  }
521
560
  async function writeIndex(stateDir, index) {
522
561
  await mkdir(stateDir, { recursive: true });
523
- await writeFileAtomically(join(stateDir, 'rules-index.json'), JSON.stringify(index, null, 2));
562
+ await writeFileAtomically(join(stateDir, MEMORIES_INDEX_FILE), JSON.stringify(index, null, 2));
524
563
  }
525
- // ── 场景记录(索引内,`rules-index.json` 的 scenes 切片)+ 旧布局迁移 ────────
564
+ // ── 场景记录(索引内,`memories-index.json` 的 scenes 切片)+ 旧布局迁移 ────────
526
565
  /** 索引条目 → 场景记录(供 UI 直接渲染)。 */
527
566
  function sceneRecordOf(name, entry) {
528
567
  const e = entry || {};
@@ -651,7 +690,7 @@ function normalizeActive(raw) {
651
690
  * 因此"新建即启用"不会发生;存量数据仍按老语义读,避免升级后注入范围突变。
652
691
  * - `_shared` 恒常启用(公共基线),不受开关影响
653
692
  * - 保留场景 `global` 恒常启用:它的记忆对任何对话都成立
654
- * 缺失 rules-index.json 一律按默认值运行,不抛错(§9.3)。
693
+ * 缺失 memories-index.json 一律按默认值运行,不抛错(§9.3)。
655
694
  * 场景生效与否只由 index.active 决定(场景档案/模式也写这一份)。
656
695
  */
657
696
  function resolveActiveScenes(index, knownScenes) {
@@ -759,10 +798,10 @@ async function buildSnapshot(rulesRoot, stateDir) {
759
798
  }
760
799
  // ── 场景记忆段:两相扫描(廉价指纹 → 按需读正文)──────────────────────────
761
800
  //
762
- // 硬约束 1(§5.1):`systemPrompt.section({ text })` 的 provider 在**每次装配**时
763
- // 同步求值(`text: string | ((context) => string)`),返回 Promise 会破坏 renderPrompt。
801
+ // 硬约束 1(§5.1):注入通道每个 step 都要求**同步**取一次文本(`text: () => string`,
802
+ // 见 src/context-inject.ts),返回 Promise 会让这一步的注入直接失败(异常被吞)。
764
803
  //
765
- // 缓存策略(§5.4 S3):段 provider 必须同步返回 string,但不希望每个模型步骤都
804
+ // 缓存策略(§5.4 S3):文本出口必须同步返回 string,但不希望每个模型步骤都
766
805
  // 读一遍所有记忆正文。因此拆成两相:
767
806
  // ① `probeSceneFilesSync()`:只走目录树 + `statSync`(**不读正文**),产出候选文件
768
807
  // 与**指纹**(`id|mtime|size` + 影响渲染的索引字段)。
@@ -774,8 +813,8 @@ async function buildSnapshot(rulesRoot, stateDir) {
774
813
  // 一次 stat 遍历(个人记忆树是亚毫秒级),换来"永远最新且永不静默失效",优先于"零 IO"。
775
814
  //
776
815
  // 与 discover() 的关系:discover 是异步全量快照(供 CRUD/列表/体检),这里是同步轻量
777
- // 扫描(供提示词段),两者对"什么是规则"的定义保持一致:
778
- // 目录含 SKILL.md → bundle 规则(叶子);否则继续下钻;只认 .md;跳过隐藏项;
816
+ // 扫描(供注入通道),两者对"什么是规则"的定义保持一致:
817
+ // 目录含 `<目录名>.md`(或旧版 `SKILL.md`)→ bundle 规则(叶子);否则继续下钻;只认 .md;跳过隐藏项;
779
818
  // 同名 flat 与 bundle 冲突时 bundle 优先。
780
819
  function readFileIfExistsSync(path) {
781
820
  try {
@@ -849,14 +888,23 @@ function probeSceneFilesSync(rulesRoot, index) {
849
888
  continue; // 同步扫描不跟随符号链接(防环)
850
889
  if (item.isDirectory()) {
851
890
  budget.items++;
852
- const skillDoc = join(child, 'SKILL.md');
853
- const stamp = fileStampSync(skillDoc);
854
- if (stamp !== 'missing') {
891
+ let docPath = '';
892
+ for (const candidate of [bundleDocName(item.name), LEGACY_BUNDLE_DOC]) {
893
+ const p = join(child, candidate);
894
+ if (fileStampSync(p) !== 'missing') {
895
+ docPath = p;
896
+ break;
897
+ }
898
+ }
899
+ // 指纹带上**目录本身**的 mtime:附件只在段里以「路径 + 文件名」出现,
900
+ // 增删附件不改正文文件,只靠它的话指纹不变、段不会重算,列表就会停在旧值。
901
+ if (docPath !== '') {
902
+ const stamp = `${fileStampSync(docPath)}:${fileStampSync(child)}`;
855
903
  // bundle 规则(叶子):父路径为场景,目录名为记忆名。
856
904
  const id = scene ? `${scene}/${relChild}` : relChild;
857
905
  if (index.rules[id]?.enabled === false)
858
906
  continue; // 单条停用 → 不进入段
859
- add({ id, scene, name: item.name, kind: 'bundle', path: skillDoc, order: index.rules[id]?.order ?? DEFAULT_ORDER, stamp });
907
+ add({ id, scene, name: item.name, kind: 'bundle', path: docPath, order: index.rules[id]?.order ?? DEFAULT_ORDER, stamp });
860
908
  continue;
861
909
  }
862
910
  walk(scene, child, relChild, depth + 1);
@@ -939,6 +987,9 @@ function renderSceneMemory(probe, index, maxBytes) {
939
987
  descriptionDerived: derived.descriptionDerived,
940
988
  order: ref.order,
941
989
  body: doc.body,
990
+ kind: ref.kind,
991
+ // bundle 的正文是 `<目录>/<名>.md`,附件是**同目录**的其余文件。
992
+ bundleDir: ref.kind === 'bundle' ? dirname(ref.path) : '',
942
993
  });
943
994
  }
944
995
  const { active } = resolveActiveScenes(index, probe.scenes);
@@ -961,14 +1012,15 @@ function renderSceneMemory(probe, index, maxBytes) {
961
1012
  let seq = 0;
962
1013
  for (const scene of [...buckets.keys()].sort((a, b) => compareSceneBuckets(a, b, index))) {
963
1014
  const sceneFiles = (buckets.get(scene) || []).slice().sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
964
- const header = `${sceneHeading(scene)}\n\n`;
1015
+ const header = sceneHeader(scene, index);
965
1016
  for (const file of sceneFiles) {
966
- const block = `### ${ruleHeading(file)}\n\n${String(file.body ?? '').trim()}\n`;
1017
+ const { text: block, inline } = memoryBlock(file);
967
1018
  candidates.push({
968
1019
  seq: seq++,
969
1020
  scene,
970
1021
  header,
971
1022
  block,
1023
+ inline,
972
1024
  item: { id: file.id, scene: file.scene, name: file.name, bytes: byteLen(block) },
973
1025
  });
974
1026
  }
@@ -976,18 +1028,36 @@ function renderSceneMemory(probe, index, maxBytes) {
976
1028
  if (candidates.length === 0) {
977
1029
  return { text: '', bytes: 0, truncated: probe.truncated, maxBytes, scenes: [], items: [], dropped: [] };
978
1030
  }
979
- /** 选中块 → 段正文(同一场景的 `## 场景记忆:x` 只在首次出现时发出一次)。 */
980
- const renderBody = (selected) => {
1031
+ /**
1032
+ * 选中块 → 段正文(同一场景的 `## 场景:x` 只在首次出现时发出)。
1033
+ * `dropped` 决定引导语用哪一版:真有条目没注入时不再声称「以下就是全部信息」。
1034
+ */
1035
+ const renderBody = (selected, dropped) => {
1036
+ if (selected.length === 0)
1037
+ return '';
1038
+ const note = dropped ? SCENE_MEMORY_NOTE_PARTIAL : SCENE_MEMORY_NOTE;
981
1039
  const chunks = [];
982
1040
  let current = null;
983
1041
  let buf = '';
1042
+ // 上一条是不是「单行条目」。连续两条单行条目紧挨着(列表的自然形态),
1043
+ // 其余情况都要空一行 —— 多行正文(含其缩进挂载的附件行)与下一条之间没有空行的话,
1044
+ // 读起来会连成一片、看不出条目边界。
1045
+ let prevInline = false;
984
1046
  for (const c of selected) {
985
1047
  if (c.scene !== current) {
986
1048
  if (buf !== '')
987
1049
  chunks.push(buf);
988
1050
  current = c.scene;
989
- buf = c.header;
1051
+ // 引导语跟着**场景块**走(场景说明之后、条目之前):它管的就是下面这些条目,
1052
+ // 放最顶层会飘在场景之外(用户实测反馈「怎么跑到最顶层了」)。场景数有上限
1053
+ // (`global`/`_shared` 恒常启用 + 至多一个启用场景),最多出现 3 次,代价可接受。
1054
+ buf = `${c.header}${note}\n\n`;
1055
+ prevInline = false;
990
1056
  }
1057
+ const inline = c.inline;
1058
+ if (!(inline && prevInline) && !buf.endsWith('\n\n'))
1059
+ buf += '\n';
1060
+ prevInline = inline;
991
1061
  buf += c.block;
992
1062
  }
993
1063
  if (buf !== '')
@@ -1025,9 +1095,12 @@ function renderSceneMemory(probe, index, maxBytes) {
1025
1095
  const selected = [];
1026
1096
  const missed = [];
1027
1097
  const takenScenes = new Set();
1098
+ // 场景标题与引导语也是开销,按「每个首次出现的场景」计进预算 —— 否则它们会挤掉
1099
+ // 本该放得下的记忆(引导语的字节见 SCENE_NOTE_BYTES)。
1028
1100
  let used = 0;
1101
+ const noteBytes = sceneNoteBytes();
1029
1102
  for (const c of candidates) {
1030
- const headerCost = takenScenes.has(c.scene) ? 0 : byteLen(c.header);
1103
+ const headerCost = takenScenes.has(c.scene) ? 0 : byteLen(c.header) + noteBytes;
1031
1104
  if (used + headerCost + c.item.bytes + markerReserve > maxBytes) {
1032
1105
  missed.push(c);
1033
1106
  continue;
@@ -1038,12 +1111,12 @@ function renderSceneMemory(probe, index, maxBytes) {
1038
1111
  }
1039
1112
  missed.sort((a, b) => a.seq - b.seq);
1040
1113
  // ── ② 尾注自身也占字节:放不下就把已入选的块从后往前退回,直到回到预算内 ──
1041
- let body = renderBody(selected);
1114
+ let body = renderBody(selected, probe.truncated || missed.length > 0);
1042
1115
  let tail = renderTail(missed, maxBytes - byteLen(body), probe.truncated || missed.length > 0);
1043
1116
  while (byteLen(body) + byteLen(tail) > maxBytes && selected.length > 0) {
1044
1117
  missed.push(selected.pop());
1045
1118
  missed.sort((a, b) => a.seq - b.seq);
1046
- body = renderBody(selected);
1119
+ body = renderBody(selected, true);
1047
1120
  tail = renderTail(missed, maxBytes - byteLen(body), true);
1048
1121
  }
1049
1122
  let text = `${body}${tail}`.replace(/^\n+/, '');
@@ -1098,10 +1171,144 @@ function sceneLabel(scene, index) {
1098
1171
  const label = index?.scenes?.[scene]?.label;
1099
1172
  return label && label !== '' ? label : scene;
1100
1173
  }
1101
- /** 场景标题:`''` → 全局;其余用目录名(可追溯)。 */
1102
- const sceneHeading = (scene) => `## 场景记忆:${sceneLabel(scene)}`;
1103
- /** 单条记忆的标题:显式 description 优先;派生描述与正文重复,改用文件名。 */
1104
- const ruleHeading = (f) => (f.descriptionDerived || !f.description ? f.name : f.description);
1174
+ /** 单个场景的标题:**场景在最顶层**(`##`,与「子智能体」「MCP 服务器」等段同级)。 */
1175
+ const sceneHeading = (scene) => `## 场景:${sceneLabel(scene)}`;
1176
+ /**
1177
+ * 没填描述时的默认「场景说明」(用户裁定 2026-09-16:全局桶一直没有描述,读起来像缺了一块,
1178
+ * 统一成"每个场景块都有场景说明")。
1179
+ *
1180
+ * 默认句同时承担"这个场景是什么"的答疑(此前只有光秃秃的 `## 场景:X`,模型读不懂 —— 用户实测):
1181
+ * 两个恒常桶说明生效范围,用户场景说明它是当前启用的那份配置。
1182
+ */
1183
+ const defaultSceneDescription = (scene) => (scene === GLOBAL_SCENE ? '全局记忆,任何对话都生效'
1184
+ : scene === SHARED_GROUP ? '共享记忆,任何对话都生效'
1185
+ : '用户配置的上下文,当前启用');
1186
+ /**
1187
+ * 单个场景的段头:场景标题 + **恒有**的 `场景说明:<描述>`。
1188
+ *
1189
+ * 场景描述(界面「描述(可选)」,≤60 字符)**此前从未注入过** —— 它正是「这个场景是
1190
+ * 干什么的」的答案,属于模型做判断需要的上下文,而不是只给人看的元数据;界面上的文案
1191
+ * 也从没把它标成「只给使用者看」(对比 AGENTS.md 预设的描述,那里是明确标注的)。
1192
+ * 描述为空时给 `defaultSceneDescription` 的默认句(用户裁定:全局桶没描述时读起来像
1193
+ * 缺了一块,统一成每个场景块都有说明)。
1194
+ */
1195
+ function sceneHeader(scene, index) {
1196
+ const head = sceneHeading(scene);
1197
+ const described = String(index?.scenes?.[scene]?.description ?? '').replaceAll(/\s+/g, ' ').trim();
1198
+ const description = described === '' ? defaultSceneDescription(scene) : described;
1199
+ // 加粗(用户裁定 2026-09-16):与引导语同款强调,别让"场景说明"读起来像可忽略的普通正文。
1200
+ return `${head}\n\n**场景说明:${description}**\n\n`;
1201
+ }
1202
+ /**
1203
+ * 段首的引导语:让模型知道下面是**用户提供的参考信息**,而不是对话历史或临时说明
1204
+ * —— 相关就用、无关可忽略。
1205
+ *
1206
+ * 写法(2026-09-16 用户裁定,基于真实注入结果的三次修正):
1207
+ * - **单行、加粗**,不再用括号分两行 —— 括号跨行在真实提示词里读起来像被截断,
1208
+ * 而加粗是 Markdown 里最省字符的强调手段(用户要求「加强模型对此的重视程度」)。
1209
+ * - **提到段首、整段只出现一次**:原来它挂在每个场景的段头里,多场景时会重复注入。
1210
+ * - **不点名任何工具**:模型从工具 schema 就知道 `memory_manager_list` 存在,点名反而
1211
+ * 像在提示它去调;用户裁定「没启用的信息就是不想在当前用」,所以工具指引整句删除。
1212
+ * 真正防探测的是**完整性声明**(「以下就是全部信息」),那半句必须留。
1213
+ * - **完整性声明按截断状态自适应**:真有条目因预算没注入时,段尾会有未注入清单,
1214
+ * 此时不能再声称「全部」,否则和清单自相矛盾 —— 也正因为那时确实有东西没给到,
1215
+ * 模型去查工具是**合理**的,不该再拦。
1216
+ *
1217
+ * 用词:不用「常驻」「注入」这类内部行话(模型没有先验);用户裁定用「信息」而不是
1218
+ * 「记忆」——「记忆」在系统提示词里指代不明,而这段的实质就是用户写的参考信息。
1219
+ */
1220
+ const SCENE_MEMORY_NOTE = '**用户提供的参考信息:与当前任务相关时直接采用,无关时忽略。以下就是全部信息。**';
1221
+ /** 有未注入条目时的版本:去掉完整性声明(见上)。 */
1222
+ const SCENE_MEMORY_NOTE_PARTIAL = '**用户提供的参考信息:与当前任务相关时直接采用,无关时忽略。**';
1223
+ /**
1224
+ * 段首固定块。以 `\n` 结尾,与场景块 join 后自然空一行。
1225
+ * 预算按**较长**的那版算(见 renderSceneMemory),保守一点只会浪费几个字节。
1226
+ */
1227
+ /**
1228
+ * 引导语所占的字节(含它后面的一个空行)。跟着场景块走,所以按**每个场景**计入段头开销;
1229
+ * 用较长的那版(带完整性声明)算,保守一点只会浪费几个字节。
1230
+ *
1231
+ * 注意:模块级不能直接算 —— `byteLen` 是后面才声明的 const,模块初始化期取它会 TDZ 报错。
1232
+ */
1233
+ const sceneNoteBytes = () => byteLen(`${SCENE_MEMORY_NOTE}\n\n`);
1234
+ /** 单行正文的最大长度:超过就退回「标题 + 正文块」,避免出现一条几千字符的列表行。 */
1235
+ const INLINE_BODY_MAX = 120;
1236
+ /** 附件行里最多列几个文件名;多的只报总数(路径已经给了,缺的名字模型自己列目录即可)。 */
1237
+ const ATTACHMENT_LIST_MAX = 10;
1238
+ /**
1239
+ * bundle 记忆的附件名(**同步**版 —— 段渲染必须同步返回,`listAttachments` 是异步的)。
1240
+ * 口径与异步版一致:跳过正文本体(`<名>.md`,旧数据可能是 `SKILL.md`),只认普通文件。
1241
+ */
1242
+ function attachmentNamesSync(bundleDir, name) {
1243
+ try {
1244
+ const docNames = new Set([bundleDocName(name), LEGACY_BUNDLE_DOC]);
1245
+ return readdirSync(bundleDir, { withFileTypes: true })
1246
+ .filter((e) => e.isFile() && !docNames.has(e.name))
1247
+ .map((e) => e.name)
1248
+ .sort((a, b) => a.localeCompare(b));
1249
+ }
1250
+ catch {
1251
+ return [];
1252
+ }
1253
+ }
1254
+ /**
1255
+ * 附件行(只有 bundle 记忆才有):**给目录与文件名,不给内容**。
1256
+ * 附件可能是图片、二进制、大 md —— 全文注入又贵又会把段预算吃光;给路径,模型需要时自己读。
1257
+ */
1258
+ function attachmentLine(file) {
1259
+ if (file.kind !== 'bundle')
1260
+ return '';
1261
+ const names = attachmentNamesSync(file.bundleDir, file.name);
1262
+ if (names.length === 0)
1263
+ return '';
1264
+ const shown = names.slice(0, ATTACHMENT_LIST_MAX);
1265
+ const more = names.length - shown.length;
1266
+ return `附件目录:${file.bundleDir}(未注入正文,共 ${names.length} 个:${shown.join('、')}${more > 0 ? `,另 ${more} 个` : ''})`;
1267
+ }
1268
+ /**
1269
+ * 单条信息的渲染形态 —— **能一行就一行,但恒为列表项**。
1270
+ *
1271
+ * 单行且不长的正文 → `- **名称**(描述) — 正文`(与 MCP / 子智能体两个段的列表同形;无显式描述时括号不出现)
1272
+ * 多行或过长的正文 → `- **名称**(描述)` + 空行 + 缩进 2 格的正文(挂在条目下)
1273
+ *
1274
+ * 名称恒为标题:它就是这条记忆的身份(工具 id `<场景>/<名称>`、bundle 目录/文件名都以它为准),
1275
+ * 用户说「记忆里的 X」、模型再调 `memory_manager_*` 时都对得上号;显式描述是括号注解,不抢标题。
1276
+ *
1277
+ * 为什么全都做成列表项:早先多行正文走 `### 名称` 标题块,附件行只能退化成与记忆**同级**的
1278
+ * `- 附件目录:…`(没有父列表项可挂)—— 既可能被读成一条独立记忆,某些渲染器里还会把下一个
1279
+ * `###` 标题吞进列表(与上一条粘连)。统一成「一条记忆 = 一个列表项、正文与附件都缩进挂在
1280
+ * 条目下」后,两种记忆外观完全一致,归属也不再靠位置猜测。
1281
+ *
1282
+ * 为什么要分两种:用户常有十几条「一句话事实」(「提交格式:PDF」),每条都占标题 + 空行 +
1283
+ * 正文三行,整段会散成一长串标题;压成一行后十条信息就是十行。多行正文是**用户写的完整
1284
+ * Markdown**(可能自带标题、代码块、嵌套列表),整体缩进 2 格挂到条目下,结构原样保留。
1285
+ *
1286
+ * 返回值带 `inline`:调用方据此决定下一条记忆前要不要空行(单行条目连续排列,其余空行分隔)。
1287
+ */
1288
+ function memoryBlock(file) {
1289
+ const desc = explicitDescriptionOf(file);
1290
+ // 加粗的只有名称:`- **名称**(描述) — 正文`,与 MCP 段 `- **server**(N 个工具) — …` 同形。
1291
+ const title = `**${file.name}**` + (desc === '' ? '' : `(${desc})`);
1292
+ const text = String(file.body ?? '').trim();
1293
+ const inline = text === '' || (!text.includes('\n') && text.length <= INLINE_BODY_MAX);
1294
+ const head = text === ''
1295
+ ? `- ${title}`
1296
+ : (inline ? `- ${title} — ${text}` : `- ${title}\n\n${indentBody(text)}`);
1297
+ const attach = attachmentLine(file);
1298
+ if (attach === '')
1299
+ return { text: `${head}\n`, inline };
1300
+ // 附件行恒为缩进子项:只用 `- ` 会被解析成与记忆**同级**的列表项(`- A` / `- 附件目录:A的` /
1301
+ // `- B` … 四条平级,归属读不出来)。单行条目紧跟其后保持列表连续;多行条目前面空一行,
1302
+ // 免得被读成用户正文自己的列表项。
1303
+ return { text: inline ? `${head}\n - ${attach}\n` : `${head}\n\n - ${attach}\n`, inline };
1304
+ }
1305
+ /** 多行正文整体缩进 2 格(列在条目内容列上),空行保持空行、不加尾随空白。 */
1306
+ const indentBody = (text) => text
1307
+ .split('\n')
1308
+ .map((line) => (line === '' ? line : ' ' + line))
1309
+ .join('\n');
1310
+ /** 括号注解用的显式描述:派生描述与正文重复、不进段(只存在于界面投影);换行压成单行,避免把「一行一条」的列表项撑断。 */
1311
+ const explicitDescriptionOf = (f) => (f.descriptionDerived || !f.description ? '' : String(f.description).replaceAll(/\s+/g, ' ').trim());
1105
1312
  const byteLen = (s) => Buffer.byteLength(s, 'utf8');
1106
1313
  // ── 创建服务 ───────────────────────────────────────────────────────────────
1107
1314
  export function createRulesService(ctx, deps) {
@@ -1163,10 +1370,10 @@ export function createRulesService(ctx, deps) {
1163
1370
  sceneCache = { signature: probe.signature, value };
1164
1371
  return value;
1165
1372
  }
1166
- // ── 场景提示词段(绑定预设正文;只读,绝不写 ~/.dsh/AGENTS.md)──────────────
1373
+ // ── 场景提示词(绑定预设正文;读盘、只读;写文件由 scene-prompt-sync.ts 负责)──
1167
1374
  //
1168
1375
  // 语义(用户裁定 2026-09-15):
1169
- // - 场景可绑定**一个**提示词预设(`scenes[].prompt` → `agents-md/<id>/AGENTS.md`);
1376
+ // - 场景可绑定**一个**提示词预设(`scenes[].prompt` → `prompts/<id>/AGENTS.md`);
1170
1377
  // - 除保留场景 `global` 外**同时只能启用一个场景**,所以同一时刻至多一份提示词在场;
1171
1378
  // - 没有启用其它场景时才轮到 `global` 自己的绑定(它恒常生效,覆盖"永远在场的提示词");
1172
1379
  // - 预设文件不存在 / 未绑定 → 返回 `''`(renderPrompt 会删掉空段,不产生空标题)。
@@ -1177,7 +1384,7 @@ export function createRulesService(ctx, deps) {
1177
1384
  const promptFileCache = new Map();
1178
1385
  /** 读一个预设正文(同步、带 stat 指纹缓存);不存在返回 `''`。 */
1179
1386
  function readPresetTextSync(id) {
1180
- const file = join(stateDir, 'agents-md', id, 'AGENTS.md');
1387
+ const file = join(stateDir, PRESETS_DIR, id, 'AGENTS.md');
1181
1388
  let key;
1182
1389
  try {
1183
1390
  const st = statSync(file);
@@ -1205,7 +1412,7 @@ export function createRulesService(ctx, deps) {
1205
1412
  /** 预设文件是否存在(同步;绑定前校验用,避免悬空绑定)。 */
1206
1413
  function presetExistsSync(id) {
1207
1414
  try {
1208
- return statSync(join(stateDir, 'agents-md', id, 'AGENTS.md')).isFile();
1415
+ return statSync(join(stateDir, PRESETS_DIR, id, 'AGENTS.md')).isFile();
1209
1416
  }
1210
1417
  catch {
1211
1418
  return false;
@@ -1242,24 +1449,13 @@ export function createRulesService(ctx, deps) {
1242
1449
  return text;
1243
1450
  }
1244
1451
  /**
1245
- * 当前生效的场景提示词:启用场景的绑定优先,其次 `global` 的绑定;都没有则 `''`。
1246
- * 绑定内容与 `~/.dsh/AGENTS.md` 相同时标记 `duplicate` 且返回空段 —— 那份正文已经在
1247
- * 基线里了,再注入一遍只会把同样的内容塞进上下文两次。
1452
+ * 当前生效的场景提示词:启用场景的绑定优先,其次 `global` 的绑定;都没有 → `null`。
1453
+ * 「绑了但预设为空/已删」按没绑处理(不注入空段),继续找下一个。
1248
1454
  */
1249
- function scenePrompt() {
1455
+ function resolveScenePreset() {
1250
1456
  const index = readIndexSync(stateDir);
1251
1457
  const names = Object.keys(index.scenes || {});
1252
1458
  const { active } = resolveActiveScenes(index, names);
1253
- const globalText = readGlobalAgentsMdSync().trim();
1254
- /** 绑定 → 投影;与文件一致时是 duplicate(不注入),否则原样注入。 */
1255
- const project = (scene, id) => {
1256
- const text = readPresetTextSync(id);
1257
- if (text.trim() === '')
1258
- return { scene, presetId: id, text: '', missing: true };
1259
- if (globalText !== '' && text.trim() === globalText)
1260
- return { scene, presetId: id, text: '', missing: false, duplicate: true };
1261
- return { scene, presetId: id, text, missing: false };
1262
- };
1263
1459
  // 单选:取唯一一个非保留启用场景(resolveActiveScenes 已保证 ≤1,这里仍做确定性排序兜底)。
1264
1460
  const enabled = names
1265
1461
  .filter((n) => n !== SHARED_GROUP && n !== GLOBAL_SCENE && active.has(n))
@@ -1268,27 +1464,35 @@ export function createRulesService(ctx, deps) {
1268
1464
  const id = index.scenes?.[scene]?.prompt;
1269
1465
  if (!id)
1270
1466
  continue;
1271
- const projected = project(scene, id);
1467
+ const text = readPresetTextSync(id);
1272
1468
  // 绑了但预设为空/已删 → 继续找下一个(不注入空段)。
1273
- if (projected.missing)
1469
+ if (text.trim() === '')
1274
1470
  continue;
1275
- return projected;
1471
+ return { scene, presetId: id, text };
1276
1472
  }
1277
1473
  const globalId = index.scenes?.[GLOBAL_SCENE]?.prompt;
1278
- if (globalId)
1279
- return project(GLOBAL_SCENE, globalId);
1280
- return { scene: null, presetId: null, text: '', missing: false };
1281
- }
1282
- const providerInvalidators = new Set();
1283
- /** 通知已注册的 provider 提示词已变化(best-effort)。 */
1284
- const invalidateProviders = () => {
1285
- for (const invalidate of providerInvalidators) {
1286
- try {
1287
- invalidate();
1288
- }
1289
- catch { /* 单个失效失败不影响其余 */ }
1474
+ if (globalId) {
1475
+ const text = readPresetTextSync(globalId);
1476
+ if (text.trim() !== '')
1477
+ return { scene: GLOBAL_SCENE, presetId: globalId, text };
1290
1478
  }
1291
- };
1479
+ return null;
1480
+ }
1481
+ /**
1482
+ * 当前生效的场景提示词(只读投影,界面用):绑定内容与 `~/.dsh/AGENTS.md` 相同时标记
1483
+ * `duplicate` 且返回空段 —— 那份正文已经在基线里了,界面据此显示「生效中」。
1484
+ * (注入侧不看这个字段:预设挂不到 AGENTS.md 通道时照样要注入,见 `promptText`。)
1485
+ */
1486
+ function scenePrompt() {
1487
+ const found = resolveScenePreset();
1488
+ if (!found)
1489
+ return { scene: null, presetId: null, text: '', missing: false };
1490
+ const globalText = readGlobalAgentsMdSync().trim();
1491
+ if (globalText !== '' && found.text.trim() === globalText) {
1492
+ return { scene: found.scene, presetId: found.presetId, text: '', missing: false, duplicate: true };
1493
+ }
1494
+ return { scene: found.scene, presetId: found.presetId, text: found.text, missing: false };
1495
+ }
1292
1496
  // ── 写操作串行队列(避免并发覆盖同一索引/文件)──
1293
1497
  let mutationQueue = Promise.resolve();
1294
1498
  const enqueueMutation = (task) => {
@@ -1310,17 +1514,26 @@ export function createRulesService(ctx, deps) {
1310
1514
  return null;
1311
1515
  return { group, name };
1312
1516
  }
1313
- /** 磁盘定位(bundle 优先)。返回规则文件与条目路径。 */
1517
+ /**
1518
+ * 磁盘定位(bundle 优先)。返回规则文件与条目路径。
1519
+ * 根层(`group === ''`)**没有 bundle**:一级目录恒为场景(见 discover 的注释),
1520
+ * 否则 `memories/1/1.md`(场景 1 的 flat 记忆 1)会被这里读成根层 bundle「1」,
1521
+ * 与列表/注入两端不一致(编辑、删除都会落到错误的文件上)。
1522
+ */
1314
1523
  async function locateRule(group, name) {
1315
- const bundleDir = join(rulesRoot, group, name);
1316
- const bundleDoc = join(bundleDir, 'SKILL.md');
1317
- try {
1318
- const st = await lstat(bundleDoc);
1319
- if (st.isFile() && !st.isSymbolicLink()) {
1320
- return { id: group ? `${group}/${name}` : name, group, name, kind: 'bundle', docPath: bundleDoc, entryPath: bundleDir };
1524
+ if (group !== '') {
1525
+ const bundleDir = join(rulesRoot, group, name);
1526
+ for (const candidate of [bundleDocName(name), LEGACY_BUNDLE_DOC]) {
1527
+ const bundleDoc = join(bundleDir, candidate);
1528
+ try {
1529
+ const st = await lstat(bundleDoc);
1530
+ if (st.isFile() && !st.isSymbolicLink()) {
1531
+ return { id: group ? `${group}/${name}` : name, group, name, kind: 'bundle', docPath: bundleDoc, entryPath: bundleDir };
1532
+ }
1533
+ }
1534
+ catch { /* 该候选名不存在 → 试下一个 */ }
1321
1535
  }
1322
1536
  }
1323
- catch { /* 非 bundle */ }
1324
1537
  const flatPath = join(rulesRoot, group, name + '.md');
1325
1538
  try {
1326
1539
  const st = await lstat(flatPath);
@@ -1395,7 +1608,6 @@ export function createRulesService(ctx, deps) {
1395
1608
  const refresh = async () => {
1396
1609
  invalidateSnapshot();
1397
1610
  sceneCache = null;
1398
- invalidateProviders();
1399
1611
  };
1400
1612
  /** 场景档案引擎专用:读-改-写 mode/archives/active 切片(写队列内,保持与其余索引写串行)。 */
1401
1613
  const patchIndex = (patch) => enqueueMutation(async () => {
@@ -1476,8 +1688,11 @@ export function createRulesService(ctx, deps) {
1476
1688
  activeScene: enabledSceneOf(index),
1477
1689
  sceneMemory: { usedBytes: projection.bytes, maxBytes: projection.maxBytes, truncated: projection.truncated, dropped: projection.dropped },
1478
1690
  // 当前生效的场景提示词(绑定的预设正文;与 ~/.dsh/AGENTS.md 一致时不重复注入)。
1691
+ // `label` 是给用户指路用的显示名(「去场景设置里改『全局』的绑定」)——`global`
1692
+ // 这类磁盘名直接甩给用户看没有意义。
1479
1693
  scenePrompt: {
1480
1694
  scene: promptProjection.scene,
1695
+ label: promptProjection.scene ? sceneLabel(promptProjection.scene, index) : null,
1481
1696
  presetId: promptProjection.presetId,
1482
1697
  missing: promptProjection.missing,
1483
1698
  duplicate: promptProjection.duplicate === true,
@@ -1513,7 +1728,7 @@ export function createRulesService(ctx, deps) {
1513
1728
  rule: {
1514
1729
  ...rule,
1515
1730
  body: doc.body,
1516
- attachments: entry.kind === 'bundle' ? await listAttachments(entry.entryPath) : [],
1731
+ attachments: entry.kind === 'bundle' ? await listAttachments(entry.entryPath, entry.name) : [],
1517
1732
  frontmatter: {
1518
1733
  name: doc.map.name != null ? String(doc.map.name) : undefined,
1519
1734
  description: doc.map.description != null ? String(doc.map.description) : undefined,
@@ -1636,9 +1851,11 @@ export function createRulesService(ctx, deps) {
1636
1851
  if (!isValidGroupSegment(name))
1637
1852
  return fail('error.rules.invalidName', `记忆名非法:${name}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含 / \\ < > : " | ? *、不以 . 开头)`);
1638
1853
  // 目标已存在(bundle 或 flat 皆算)→ 拒绝,避免静默覆盖。
1854
+ // 用 exists 而不是 shadowed:后者说的是「同名 bundle 把 flat 遮蔽了」,用户看到
1855
+ // 「被同名 bundle 遮蔽」会去找一个并不存在的 bundle(2026-09-16 实测)。
1639
1856
  const existing = await locateRule(group, name);
1640
1857
  if (existing) {
1641
- return fail('error.rules.shadowed', `同名规则已存在(${existing.kind === 'bundle' ? 'bundle' : 'flat'}):${group}/${name}`);
1858
+ return fail('error.rules.exists', `同名记忆已存在(${existing.kind === 'bundle' ? 'bundle' : 'flat'}):${existing.id}`);
1642
1859
  }
1643
1860
  const body = String((args && args.body) ?? '');
1644
1861
  if (body.trim() === '')
@@ -1667,7 +1884,7 @@ export function createRulesService(ctx, deps) {
1667
1884
  const text = serializeRuleFile(fields, body);
1668
1885
  if (form === 'bundle') {
1669
1886
  await mkdir(join(rulesRoot, targetGroup, name), { recursive: true });
1670
- await writeFileAtomically(join(rulesRoot, targetGroup, name, 'SKILL.md'), text);
1887
+ await writeFileAtomically(join(rulesRoot, targetGroup, name, bundleDocName(name)), text);
1671
1888
  }
1672
1889
  else {
1673
1890
  await mkdir(join(rulesRoot, targetGroup), { recursive: true });
@@ -1684,7 +1901,6 @@ export function createRulesService(ctx, deps) {
1684
1901
  index.groups[targetGroup] = { order: DEFAULT_GROUP_ORDER, label: targetGroup };
1685
1902
  await writeIndex(stateDir, index);
1686
1903
  invalidateSnapshot();
1687
- invalidateProviders();
1688
1904
  return { ok: true, rule: await buildProjected(createdId) };
1689
1905
  }
1690
1906
  /**
@@ -1769,10 +1985,10 @@ export function createRulesService(ctx, deps) {
1769
1985
  const id = `${item.group}/${item.name}`;
1770
1986
  try {
1771
1987
  if (item.kind === 'bundle') {
1772
- // 与 rules-create 同落点:bundle = <场景>/<名>/ 目录,正文 SKILL.md,附件平铺同层。
1988
+ // 与 rules-create 同落点:bundle = <场景>/<名>/ 目录,正文 <名>.md,附件平铺同层。
1773
1989
  const bundleDir = join(rulesRoot, item.group, item.name);
1774
1990
  await mkdir(bundleDir, { recursive: true });
1775
- await writeFileAtomically(join(bundleDir, 'SKILL.md'), item.text);
1991
+ await writeFileAtomically(join(bundleDir, bundleDocName(item.name)), item.text);
1776
1992
  for (const att of item.attachments)
1777
1993
  await writeFileAtomicBinary(join(bundleDir, att.name), att.data);
1778
1994
  }
@@ -1875,7 +2091,7 @@ export function createRulesService(ctx, deps) {
1875
2091
  }
1876
2092
  else if (located.kind === 'flat' && newForm === 'bundle') {
1877
2093
  await mkdir(join(rulesRoot, parts.group, newName), { recursive: true });
1878
- await writeFileAtomically(join(rulesRoot, parts.group, newName, 'SKILL.md'), serialize());
2094
+ await writeFileAtomically(join(rulesRoot, parts.group, newName, bundleDocName(newName)), serialize());
1879
2095
  await rm(join(rulesRoot, parts.group, parts.name + '.md'), { force: true });
1880
2096
  }
1881
2097
  else if (located.kind === 'flat' && newName !== parts.name) {
@@ -1899,7 +2115,6 @@ export function createRulesService(ctx, deps) {
1899
2115
  }
1900
2116
  await writeIndex(stateDir, index);
1901
2117
  invalidateSnapshot();
1902
- invalidateProviders();
1903
2118
  return { ok: true, rule: await buildProjected(newId) };
1904
2119
  }
1905
2120
  async function rulesRemove(args) {
@@ -1911,7 +2126,7 @@ export function createRulesService(ctx, deps) {
1911
2126
  if (!located)
1912
2127
  return fail('error.rules.notFound', `规则不存在:${id}`);
1913
2128
  const trashId = Date.now().toString(36) + '-' + randomUUID().slice(0, 8);
1914
- const trashDir = join(stateDir, 'rules-trash', trashId);
2129
+ const trashDir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
1915
2130
  const manifest = { group: parts.group, name: parts.name, form: located.kind, deletedAt: new Date().toISOString() };
1916
2131
  await mkdir(trashDir, { recursive: true });
1917
2132
  if (located.kind === 'bundle') {
@@ -1928,14 +2143,13 @@ export function createRulesService(ctx, deps) {
1928
2143
  delete index.rules[id];
1929
2144
  await writeIndex(stateDir, index);
1930
2145
  invalidateSnapshot();
1931
- invalidateProviders();
1932
2146
  return { ok: true, trashId };
1933
2147
  }
1934
2148
  async function rulesRestore(args) {
1935
2149
  const trashId = String((args && args.trashId) || '');
1936
2150
  if (!isValidTrashId(trashId))
1937
2151
  return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
1938
- const trashDir = join(stateDir, 'rules-trash', trashId);
2152
+ const trashDir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
1939
2153
  let manifest;
1940
2154
  try {
1941
2155
  manifest = JSON.parse(await readFile(join(trashDir, 'manifest.json'), 'utf8'));
@@ -1949,7 +2163,7 @@ export function createRulesService(ctx, deps) {
1949
2163
  // 恢复目标已存在(期间用户重建了同名规则)→ 拒绝,避免覆盖。
1950
2164
  const conflict = await locateRule(manifest.group, manifest.name);
1951
2165
  if (conflict) {
1952
- return fail('error.rules.shadowed', `同名规则已存在:${manifest.group}/${manifest.name},请先移除后再恢复`);
2166
+ return fail('error.rules.exists', `同名记忆已存在:${conflict.id},请先移除后再恢复`);
1953
2167
  }
1954
2168
  const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
1955
2169
  if (form === 'bundle') {
@@ -1963,7 +2177,6 @@ export function createRulesService(ctx, deps) {
1963
2177
  }
1964
2178
  await rm(trashDir, { recursive: true, force: true });
1965
2179
  invalidateSnapshot();
1966
- invalidateProviders();
1967
2180
  return { ok: true, rule: await buildProjected(manifest.group ? `${manifest.group}/${manifest.name}` : manifest.name) };
1968
2181
  }
1969
2182
  /** trashId 只由本插件生成(时间戳 base36 + uuid 前 8 位):严格白名单,杜绝路径穿越。 */
@@ -1971,11 +2184,11 @@ export function createRulesService(ctx, deps) {
1971
2184
  return /^[a-z0-9]+-[a-z0-9]{1,32}$/i.test(trashId);
1972
2185
  }
1973
2186
  /**
1974
- * 记忆回收站列表(只读):`<stateDir>/rules-trash/<trashId>/`(`rulesRemove` 移入,
2187
+ * 记忆回收站列表(只读):`<stateDir>/memories-trash/<trashId>/`(`rulesRemove` 移入,
1975
2188
  * `rulesRestore` 恢复)。损坏或内容缺失的条目跳过,不让一个坏条目挡住整份列表。
1976
2189
  */
1977
2190
  async function rulesTrashList() {
1978
- const root = join(stateDir, 'rules-trash');
2191
+ const root = join(stateDir, MEMORIES_TRASH_DIR);
1979
2192
  let ids = [];
1980
2193
  try {
1981
2194
  ids = await readdir(root);
@@ -2001,9 +2214,20 @@ export function createRulesService(ctx, deps) {
2001
2214
  const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
2002
2215
  let bytes = 0;
2003
2216
  try {
2004
- bytes = (await stat(form === 'bundle' ? join(dir, 'bundle', 'SKILL.md') : join(dir, 'rule.md'))).size;
2217
+ const bundleDoc = form === 'bundle'
2218
+ ? join(dir, 'bundle', bundleDocName(manifest.name))
2219
+ : join(dir, 'rule.md');
2220
+ bytes = (await stat(bundleDoc)).size;
2221
+ }
2222
+ catch {
2223
+ // 兼容旧回收站条目(正文仍叫 SKILL.md);仍读不到就按 0 计,恢复时由 rulesRestore 兜底报错。
2224
+ if (form === 'bundle') {
2225
+ try {
2226
+ bytes = (await stat(join(dir, 'bundle', LEGACY_BUNDLE_DOC))).size;
2227
+ }
2228
+ catch { /* 内容缺失 */ }
2229
+ }
2005
2230
  }
2006
- catch { /* 内容缺失:仍列出,恢复时由 rulesRestore 兜底报错 */ }
2007
2231
  entries.push({ trashId, group, name: manifest.name, form, deletedAt: String(manifest.deletedAt || ''), bytes });
2008
2232
  }
2009
2233
  catch { /* 损坏条目跳过 */ }
@@ -2016,7 +2240,7 @@ export function createRulesService(ctx, deps) {
2016
2240
  const trashId = String((args && args.trashId) || '');
2017
2241
  if (!isValidTrashId(trashId))
2018
2242
  return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
2019
- const dir = join(stateDir, 'rules-trash', trashId);
2243
+ const dir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
2020
2244
  try {
2021
2245
  const st = await lstat(dir);
2022
2246
  if (!st.isDirectory())
@@ -2028,8 +2252,8 @@ export function createRulesService(ctx, deps) {
2028
2252
  await rm(dir, { recursive: true, force: true });
2029
2253
  return { ok: true };
2030
2254
  }
2031
- /** bundle 目录下的附件(顶层普通文件,排除正文 SKILL.md);不存在/不可读返回空数组。 */
2032
- async function listAttachments(bundleDir) {
2255
+ /** bundle 目录下的附件(顶层普通文件,排除正文 `<名>.md`,兼容旧数据 `SKILL.md`);不存在/不可读返回空数组。 */
2256
+ async function listAttachments(bundleDir, name) {
2033
2257
  let entries;
2034
2258
  try {
2035
2259
  entries = await readdir(bundleDir, { withFileTypes: true });
@@ -2037,9 +2261,10 @@ export function createRulesService(ctx, deps) {
2037
2261
  catch {
2038
2262
  return [];
2039
2263
  }
2264
+ const docNames = new Set([bundleDocName(name), LEGACY_BUNDLE_DOC]);
2040
2265
  const out = [];
2041
2266
  for (const entry of entries) {
2042
- if (entry.name === BUNDLE_DOC)
2267
+ if (docNames.has(entry.name))
2043
2268
  continue;
2044
2269
  if (!entry.isFile())
2045
2270
  continue; // 子目录 / 符号链接不列出(detach 也只动普通文件)
@@ -2073,10 +2298,13 @@ export function createRulesService(ctx, deps) {
2073
2298
  return fail('error.rules.tooManyFiles', `一次最多 ${MAX_ATTACH_ENTRIES} 个附件`, { limit: MAX_ATTACH_ENTRIES });
2074
2299
  const pending = [];
2075
2300
  let total = 0;
2301
+ const docNames = new Set([bundleDocName(located.name), LEGACY_BUNDLE_DOC]);
2076
2302
  for (const file of files) {
2077
2303
  const name = String((file && (file.path || file.name)) || '');
2078
2304
  if (!isValidGroupSegment(name))
2079
2305
  return fail('error.rules.invalidName', `附件名非法:${name || '(空)'}`);
2306
+ if (docNames.has(name))
2307
+ return fail('error.rules.invalidName', `附件名不能与正文文件同名:${name}`);
2080
2308
  const data = Buffer.from(String((file && file.data) || ''), 'base64');
2081
2309
  if (data.length === 0)
2082
2310
  return fail('error.rules.emptyFile', `附件内容为空:${name}`);
@@ -2089,9 +2317,9 @@ export function createRulesService(ctx, deps) {
2089
2317
  }
2090
2318
  for (const file of pending)
2091
2319
  await writeFileAtomicBinary(join(located.entryPath, file.name), file.data);
2092
- return { ok: true, attachments: await listAttachments(located.entryPath) };
2320
+ return { ok: true, attachments: await listAttachments(located.entryPath, located.name) };
2093
2321
  }
2094
- /** 删除 bundle 记忆的一个附件;正文 SKILL.md 不可删。 */
2322
+ /** 删除 bundle 记忆的一个附件;正文 `<名>.md`(或旧数据 `SKILL.md`)不可删。 */
2095
2323
  async function rulesDetach(args) {
2096
2324
  const id = String((args && args.id) || '');
2097
2325
  const parts = parseId(id);
@@ -2103,8 +2331,9 @@ export function createRulesService(ctx, deps) {
2103
2331
  if (located.kind !== 'bundle')
2104
2332
  return fail('error.rules.notBundle', `「${parts.name}」是 flat(单文件),没有附件`);
2105
2333
  const name = String((args && args.name) || '');
2106
- if (!isValidGroupSegment(name) || name === BUNDLE_DOC)
2334
+ if (!isValidGroupSegment(name) || name === bundleDocName(located.name) || name === LEGACY_BUNDLE_DOC) {
2107
2335
  return fail('error.rules.invalidName', `附件名非法:${name || '(空)'}`);
2336
+ }
2108
2337
  const target = join(located.entryPath, name);
2109
2338
  try {
2110
2339
  const st = await lstat(target);
@@ -2115,7 +2344,7 @@ export function createRulesService(ctx, deps) {
2115
2344
  return fail('error.rules.notFound', `附件不存在:${name}`);
2116
2345
  }
2117
2346
  await rm(target, { force: true });
2118
- return { ok: true, attachments: await listAttachments(located.entryPath) };
2347
+ return { ok: true, attachments: await listAttachments(located.entryPath, located.name) };
2119
2348
  }
2120
2349
  async function rulesToggle(args) {
2121
2350
  const id = String((args && args.id) || '');
@@ -2135,7 +2364,6 @@ export function createRulesService(ctx, deps) {
2135
2364
  index.rules[id] = { ...idxEntry, enabled, updatedAt: new Date().toISOString() };
2136
2365
  await writeIndex(stateDir, index);
2137
2366
  invalidateSnapshot();
2138
- invalidateProviders();
2139
2367
  return { ok: true, rule: await buildProjected(id) };
2140
2368
  }
2141
2369
  /**
@@ -2180,7 +2408,6 @@ export function createRulesService(ctx, deps) {
2180
2408
  index.active = names;
2181
2409
  await writeIndex(stateDir, index);
2182
2410
  invalidateSnapshot();
2183
- invalidateProviders();
2184
2411
  const snap = await snapshot();
2185
2412
  return {
2186
2413
  ok: true,
@@ -2238,7 +2465,6 @@ export function createRulesService(ctx, deps) {
2238
2465
  index.scenes[name] = next;
2239
2466
  await writeIndex(stateDir, index);
2240
2467
  invalidateSnapshot();
2241
- invalidateProviders();
2242
2468
  return { ok: true, scene: sceneRecordOf(name, next), ...(collapsed ? { collapsedActive: true } : {}) };
2243
2469
  }
2244
2470
  /**
@@ -2366,7 +2592,6 @@ export function createRulesService(ctx, deps) {
2366
2592
  index.scenes[finalName] = next;
2367
2593
  await writeIndex(stateDir, index);
2368
2594
  invalidateSnapshot();
2369
- invalidateProviders();
2370
2595
  return {
2371
2596
  ok: true,
2372
2597
  ...(finalName === name ? {} : { renamedFrom: name }),
@@ -2491,7 +2716,6 @@ export function createRulesService(ctx, deps) {
2491
2716
  if (dirty)
2492
2717
  await writeIndex(stateDir, index);
2493
2718
  invalidateSnapshot();
2494
- invalidateProviders();
2495
2719
  return { ok: true, name, trashId: trashed.id, movedFiles: moves.length };
2496
2720
  }
2497
2721
  /** 场景回收站列表(只读):`<hub>/trash/scenes-trash/<id>/manifest.json`。 */
@@ -2550,7 +2774,6 @@ export function createRulesService(ctx, deps) {
2550
2774
  await writeIndex(stateDir, index);
2551
2775
  await purgeTrashEntry('scenes', id);
2552
2776
  invalidateSnapshot();
2553
- invalidateProviders();
2554
2777
  return { ok: true, name, restoredFiles: restored.length };
2555
2778
  }
2556
2779
  /** 永久删除一条场景回收站条目。 */
@@ -2563,7 +2786,7 @@ export function createRulesService(ctx, deps) {
2563
2786
  }
2564
2787
  /**
2565
2788
  * 提示词预设改名后**同步场景绑定**:把所有 `scenes[].prompt === from` 改成 `to`。
2566
- * 由 AGENTS.md 预设库的改名路径调用——绑定存在 `rules-index.json` 里,预设库
2789
+ * 由 AGENTS.md 预设库的改名路径调用——绑定存在 `memories-index.json` 里,预设库
2567
2790
  * 自己看不到它,不叫这一声改名就会留下悬空绑定(场景卡片显示「预设不存在」)。
2568
2791
  * @returns 改了几个场景。
2569
2792
  */
@@ -2585,7 +2808,6 @@ export function createRulesService(ctx, deps) {
2585
2808
  index.scenes = scenes;
2586
2809
  await writeIndex(stateDir, index);
2587
2810
  invalidateSnapshot();
2588
- invalidateProviders();
2589
2811
  }
2590
2812
  return { ok: true, from, to, changed };
2591
2813
  }
@@ -2613,33 +2835,40 @@ export function createRulesService(ctx, deps) {
2613
2835
  index.rules[id] = next;
2614
2836
  await writeIndex(stateDir, index);
2615
2837
  invalidateSnapshot();
2616
- invalidateProviders();
2617
2838
  return { ok: true, rule: await buildProjected(id) };
2618
2839
  }
2619
2840
  // ── 组装 ─────────────────────────────────────────────────────────────────
2620
- const registerProviders = () => {
2621
- try {
2622
- // 场景绑定的提示词**不再注入**(2026-09-15 用户裁定:「切换场景,对应的提示词直接把
2623
- // AGENTS.md 修改」):启用/切换场景、改绑、编辑绑定的预设时,宿主把那份正文写进
2624
- // `~/.dsh/AGENTS.md`(与「提示词」页的「应用」同一条路,覆盖前多代备份),关掉场景时
2625
- // 恢复进场景之前的基线。真改文件之后再注入一遍,同一份正文会进上下文两次,而且用户手改
2626
- // 基线之后还会被重新注入 —— 所以这里只保留场景记忆段。绑定关系的**只读投影**仍由 rules
2627
- // 服务提供(页面「生效中」标记,以及"文件是否已经同步成它"的判断)。
2628
- const registrar = createPromptSectionRegistrar(ctx, [{
2629
- name: 'tool-management:scene-memory',
2630
- order: SECTION_ORDER.sceneMemory,
2631
- text: () => sceneMemory().text,
2632
- }]);
2633
- providerInvalidators.add(registrar.invalidate);
2634
- return () => {
2635
- providerInvalidators.delete(registrar.invalidate);
2636
- registrar.dispose();
2637
- };
2638
- }
2639
- catch (e) {
2640
- console.error('[dsh-plugin-tool-management] rules provider setup failed:', message(e));
2641
- return () => { };
2642
- }
2841
+ // 注入文本出口(2026-09-16 第四版)。
2842
+ //
2843
+ // 历史:本服务先后把场景记忆做成"系统提示词段"(会被 persona complete 压掉)与
2844
+ // "写 AGENTS.md 文件"(预设不挂 dsh-agent-instructions 时到不了模型)。现在两条都交给
2845
+ // 注入通道(src/context-inject.ts):每个 step 前把文本并进一条注入消息,任何预设都到得了。
2846
+ // 这里只留**同步取文本**的两个出口,注册/生命周期由 index.ts 的注入器统一管。
2847
+ //
2848
+ // - `memoryText`:场景记忆段(记忆正文;`''` = 没有可注入的内容)。
2849
+ // - `promptText`:全局提示词正文 = 场景期间用场景提示词、平时用 AGENTS.md 正文。
2850
+ // **不判"与文件重复"** —— 预设挂了官方 agent-instructions 时那份正文已经由文件送达
2851
+ // (注入侧会跳过整个域),没挂时(极简)才需要注入兜底;判定在注入器的 `selectInjections` 里。
2852
+ const memoryText = () => sceneMemory().text;
2853
+ /**
2854
+ * `~/.dsh/AGENTS.md` 正文。上限与官方那一行的 `maxBytes` 同量级(64 KiB):超大文件按字节
2855
+ * 截断并留一行标记,免得一份手写的巨型基线把上下文撑爆。
2856
+ */
2857
+ const AGENTS_MD_MAX_BYTES = 65536;
2858
+ const agentsMdText = () => {
2859
+ const text = readGlobalAgentsMdSync();
2860
+ if (text.trim() === '')
2861
+ return '';
2862
+ if (Buffer.byteLength(text, 'utf8') <= AGENTS_MD_MAX_BYTES)
2863
+ return text;
2864
+ const cut = Buffer.from(text, 'utf8').subarray(0, AGENTS_MD_MAX_BYTES).toString('utf8');
2865
+ return cut + `\n\n(…文件超过 ${Math.floor(AGENTS_MD_MAX_BYTES / 1024)} KiB,已截断。)`;
2866
+ };
2867
+ // 场景提示词与 AGENTS.md 正文在用户眼里就是同一件事("全局提示词"):场景期间前者取代后者,
2868
+ // 与官方语义一致(进场景改写文件、退出恢复)。所以一个域、一份文本,取到非空的场景提示词就用它。
2869
+ const promptText = () => {
2870
+ const scene = resolveScenePreset()?.text ?? '';
2871
+ return scene.trim() !== '' ? scene : agentsMdText();
2643
2872
  };
2644
2873
  /** 写操作:串行队列内执行,成功后触发 refresh(失效缓存,下一请求即生效)。 */
2645
2874
  const runWrite = (task) => enqueueMutation(async () => {
@@ -2688,7 +2917,8 @@ export function createRulesService(ctx, deps) {
2688
2917
  const service = {
2689
2918
  ops,
2690
2919
  writeOps,
2691
- registerProviders,
2920
+ memoryText,
2921
+ promptText,
2692
2922
  refresh,
2693
2923
  patchIndex,
2694
2924
  readArchiveSlice,