dsh-plugin-tool-management 0.8.5 → 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 (58) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +50 -48
  3. package/README_EN.md +44 -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 +25 -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 +335 -92
  25. package/lib/compat/preset-reach.js +155 -93
  26. package/lib/context-inject.js +304 -0
  27. package/lib/hub.js +155 -13
  28. package/lib/imports/upload.js +34 -14
  29. package/lib/index.js +680 -233
  30. package/lib/mcp/state-section.js +46 -15
  31. package/lib/rules/archive-engine.js +137 -63
  32. package/lib/rules/archive.js +34 -0
  33. package/lib/rules/service.js +376 -158
  34. package/lib/scene-prompt-sync.js +101 -12
  35. package/lib/skills/catalog.js +125 -0
  36. package/lib/skills/core.js +6 -4
  37. package/lib/subagents/catalog.js +15 -6
  38. package/lib/subagents/service.js +25 -12
  39. package/lib/subagents/tools.js +18 -13
  40. package/package.json +11 -6
  41. package/screenshots.json +8 -8
  42. package/docs/images/1/345/234/272/346/231/257.png +0 -0
  43. package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
  44. package/docs/images/2MCP.png +0 -0
  45. package/docs/images/2MCP_en.png +0 -0
  46. package/docs/images/3/346/212/200/350/203/275.png +0 -0
  47. package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
  48. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  49. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
  50. package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
  51. package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
  52. package/docs/images/6/350/256/260/345/277/206.png +0 -0
  53. package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
  54. package/docs/images/7/344/274/232/350/257/235.png +0 -0
  55. package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
  56. package/docs/images/8/345/205/274/345/256/271.png +0 -0
  57. package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
  58. 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 !== '') {
@@ -470,6 +498,9 @@ 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);
473
504
  // v0.8.1 的场景备注恢复名单:**必须原样透传**——这里漏掉它,退出模式时备注永不回退
474
505
  //(与历史上 mcpServers / skillSources 被剥掉是同一类 bug)。
475
506
  const mcpNotes = (Array.isArray(snapshotRaw.mcpNotes) ? snapshotRaw.mcpNotes : [])
@@ -485,12 +516,13 @@ export function parseModeState(raw) {
485
516
  ...(mcpServers.length ? { mcpServers } : {}),
486
517
  ...(skillSources.length ? { skillSources } : {}),
487
518
  ...(subagents.length ? { subagents } : {}),
519
+ ...(subagentsOn.length ? { subagentsOn } : {}),
488
520
  ...(mcpNotes.length ? { mcpNotes } : {}),
489
521
  }
490
522
  : null,
491
523
  };
492
524
  }
493
- /** 解析 rules-index.json 原文;任何异常/版本不符 → 默认索引(容忍缺失,§9.3)。 */
525
+ /** 解析 memories-index.json 原文;任何异常/版本不符 → 默认索引(容忍缺失,§9.3)。 */
494
526
  function parseIndex(raw) {
495
527
  const parsed = JSON.parse(raw);
496
528
  if (!parsed || parsed.version !== INDEX_VERSION || typeof parsed.rules !== 'object' || parsed.rules === null)
@@ -507,15 +539,15 @@ function parseIndex(raw) {
507
539
  }
508
540
  async function readIndex(stateDir) {
509
541
  try {
510
- return parseIndex(await readFile(join(stateDir, 'rules-index.json'), 'utf8'));
542
+ return parseIndex(await readFile(join(stateDir, MEMORIES_INDEX_FILE), 'utf8'));
511
543
  }
512
544
  catch {
513
545
  return defaultIndex();
514
546
  }
515
547
  }
516
- /** 同步读索引:systemPrompt 段的渲染路径不能 await(见文件内「两相扫描」注释)。 */
548
+ /** 同步读索引:注入文本的渲染路径不能 await(见文件内「两相扫描」注释)。 */
517
549
  function readIndexSync(stateDir) {
518
- const raw = readFileIfExistsSync(join(stateDir, 'rules-index.json'));
550
+ const raw = readFileIfExistsSync(join(stateDir, MEMORIES_INDEX_FILE));
519
551
  if (raw === null)
520
552
  return defaultIndex();
521
553
  try {
@@ -527,9 +559,9 @@ function readIndexSync(stateDir) {
527
559
  }
528
560
  async function writeIndex(stateDir, index) {
529
561
  await mkdir(stateDir, { recursive: true });
530
- 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));
531
563
  }
532
- // ── 场景记录(索引内,`rules-index.json` 的 scenes 切片)+ 旧布局迁移 ────────
564
+ // ── 场景记录(索引内,`memories-index.json` 的 scenes 切片)+ 旧布局迁移 ────────
533
565
  /** 索引条目 → 场景记录(供 UI 直接渲染)。 */
534
566
  function sceneRecordOf(name, entry) {
535
567
  const e = entry || {};
@@ -658,7 +690,7 @@ function normalizeActive(raw) {
658
690
  * 因此"新建即启用"不会发生;存量数据仍按老语义读,避免升级后注入范围突变。
659
691
  * - `_shared` 恒常启用(公共基线),不受开关影响
660
692
  * - 保留场景 `global` 恒常启用:它的记忆对任何对话都成立
661
- * 缺失 rules-index.json 一律按默认值运行,不抛错(§9.3)。
693
+ * 缺失 memories-index.json 一律按默认值运行,不抛错(§9.3)。
662
694
  * 场景生效与否只由 index.active 决定(场景档案/模式也写这一份)。
663
695
  */
664
696
  function resolveActiveScenes(index, knownScenes) {
@@ -766,10 +798,10 @@ async function buildSnapshot(rulesRoot, stateDir) {
766
798
  }
767
799
  // ── 场景记忆段:两相扫描(廉价指纹 → 按需读正文)──────────────────────────
768
800
  //
769
- // 硬约束 1(§5.1):`systemPrompt.section({ text })` 的 provider 在**每次装配**时
770
- // 同步求值(`text: string | ((context) => string)`),返回 Promise 会破坏 renderPrompt。
801
+ // 硬约束 1(§5.1):注入通道每个 step 都要求**同步**取一次文本(`text: () => string`,
802
+ // 见 src/context-inject.ts),返回 Promise 会让这一步的注入直接失败(异常被吞)。
771
803
  //
772
- // 缓存策略(§5.4 S3):段 provider 必须同步返回 string,但不希望每个模型步骤都
804
+ // 缓存策略(§5.4 S3):文本出口必须同步返回 string,但不希望每个模型步骤都
773
805
  // 读一遍所有记忆正文。因此拆成两相:
774
806
  // ① `probeSceneFilesSync()`:只走目录树 + `statSync`(**不读正文**),产出候选文件
775
807
  // 与**指纹**(`id|mtime|size` + 影响渲染的索引字段)。
@@ -781,8 +813,8 @@ async function buildSnapshot(rulesRoot, stateDir) {
781
813
  // 一次 stat 遍历(个人记忆树是亚毫秒级),换来"永远最新且永不静默失效",优先于"零 IO"。
782
814
  //
783
815
  // 与 discover() 的关系:discover 是异步全量快照(供 CRUD/列表/体检),这里是同步轻量
784
- // 扫描(供提示词段),两者对"什么是规则"的定义保持一致:
785
- // 目录含 SKILL.md → bundle 规则(叶子);否则继续下钻;只认 .md;跳过隐藏项;
816
+ // 扫描(供注入通道),两者对"什么是规则"的定义保持一致:
817
+ // 目录含 `<目录名>.md`(或旧版 `SKILL.md`)→ bundle 规则(叶子);否则继续下钻;只认 .md;跳过隐藏项;
786
818
  // 同名 flat 与 bundle 冲突时 bundle 优先。
787
819
  function readFileIfExistsSync(path) {
788
820
  try {
@@ -856,14 +888,23 @@ function probeSceneFilesSync(rulesRoot, index) {
856
888
  continue; // 同步扫描不跟随符号链接(防环)
857
889
  if (item.isDirectory()) {
858
890
  budget.items++;
859
- const skillDoc = join(child, 'SKILL.md');
860
- const stamp = fileStampSync(skillDoc);
861
- 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)}`;
862
903
  // bundle 规则(叶子):父路径为场景,目录名为记忆名。
863
904
  const id = scene ? `${scene}/${relChild}` : relChild;
864
905
  if (index.rules[id]?.enabled === false)
865
906
  continue; // 单条停用 → 不进入段
866
- 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 });
867
908
  continue;
868
909
  }
869
910
  walk(scene, child, relChild, depth + 1);
@@ -946,6 +987,9 @@ function renderSceneMemory(probe, index, maxBytes) {
946
987
  descriptionDerived: derived.descriptionDerived,
947
988
  order: ref.order,
948
989
  body: doc.body,
990
+ kind: ref.kind,
991
+ // bundle 的正文是 `<目录>/<名>.md`,附件是**同目录**的其余文件。
992
+ bundleDir: ref.kind === 'bundle' ? dirname(ref.path) : '',
949
993
  });
950
994
  }
951
995
  const { active } = resolveActiveScenes(index, probe.scenes);
@@ -968,14 +1012,15 @@ function renderSceneMemory(probe, index, maxBytes) {
968
1012
  let seq = 0;
969
1013
  for (const scene of [...buckets.keys()].sort((a, b) => compareSceneBuckets(a, b, index))) {
970
1014
  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`;
1015
+ const header = sceneHeader(scene, index);
972
1016
  for (const file of sceneFiles) {
973
- const block = `### ${ruleHeading(file)}\n\n${String(file.body ?? '').trim()}\n`;
1017
+ const { text: block, inline } = memoryBlock(file);
974
1018
  candidates.push({
975
1019
  seq: seq++,
976
1020
  scene,
977
1021
  header,
978
1022
  block,
1023
+ inline,
979
1024
  item: { id: file.id, scene: file.scene, name: file.name, bytes: byteLen(block) },
980
1025
  });
981
1026
  }
@@ -983,18 +1028,36 @@ function renderSceneMemory(probe, index, maxBytes) {
983
1028
  if (candidates.length === 0) {
984
1029
  return { text: '', bytes: 0, truncated: probe.truncated, maxBytes, scenes: [], items: [], dropped: [] };
985
1030
  }
986
- /** 选中块 → 段正文(同一场景的 `## 场景记忆:x` 只在首次出现时发出一次)。 */
987
- 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;
988
1039
  const chunks = [];
989
1040
  let current = null;
990
1041
  let buf = '';
1042
+ // 上一条是不是「单行条目」。连续两条单行条目紧挨着(列表的自然形态),
1043
+ // 其余情况都要空一行 —— 多行正文(含其缩进挂载的附件行)与下一条之间没有空行的话,
1044
+ // 读起来会连成一片、看不出条目边界。
1045
+ let prevInline = false;
991
1046
  for (const c of selected) {
992
1047
  if (c.scene !== current) {
993
1048
  if (buf !== '')
994
1049
  chunks.push(buf);
995
1050
  current = c.scene;
996
- buf = c.header;
1051
+ // 引导语跟着**场景块**走(场景说明之后、条目之前):它管的就是下面这些条目,
1052
+ // 放最顶层会飘在场景之外(用户实测反馈「怎么跑到最顶层了」)。场景数有上限
1053
+ // (`global`/`_shared` 恒常启用 + 至多一个启用场景),最多出现 3 次,代价可接受。
1054
+ buf = `${c.header}${note}\n\n`;
1055
+ prevInline = false;
997
1056
  }
1057
+ const inline = c.inline;
1058
+ if (!(inline && prevInline) && !buf.endsWith('\n\n'))
1059
+ buf += '\n';
1060
+ prevInline = inline;
998
1061
  buf += c.block;
999
1062
  }
1000
1063
  if (buf !== '')
@@ -1032,9 +1095,12 @@ function renderSceneMemory(probe, index, maxBytes) {
1032
1095
  const selected = [];
1033
1096
  const missed = [];
1034
1097
  const takenScenes = new Set();
1098
+ // 场景标题与引导语也是开销,按「每个首次出现的场景」计进预算 —— 否则它们会挤掉
1099
+ // 本该放得下的记忆(引导语的字节见 SCENE_NOTE_BYTES)。
1035
1100
  let used = 0;
1101
+ const noteBytes = sceneNoteBytes();
1036
1102
  for (const c of candidates) {
1037
- const headerCost = takenScenes.has(c.scene) ? 0 : byteLen(c.header);
1103
+ const headerCost = takenScenes.has(c.scene) ? 0 : byteLen(c.header) + noteBytes;
1038
1104
  if (used + headerCost + c.item.bytes + markerReserve > maxBytes) {
1039
1105
  missed.push(c);
1040
1106
  continue;
@@ -1045,12 +1111,12 @@ function renderSceneMemory(probe, index, maxBytes) {
1045
1111
  }
1046
1112
  missed.sort((a, b) => a.seq - b.seq);
1047
1113
  // ── ② 尾注自身也占字节:放不下就把已入选的块从后往前退回,直到回到预算内 ──
1048
- let body = renderBody(selected);
1114
+ let body = renderBody(selected, probe.truncated || missed.length > 0);
1049
1115
  let tail = renderTail(missed, maxBytes - byteLen(body), probe.truncated || missed.length > 0);
1050
1116
  while (byteLen(body) + byteLen(tail) > maxBytes && selected.length > 0) {
1051
1117
  missed.push(selected.pop());
1052
1118
  missed.sort((a, b) => a.seq - b.seq);
1053
- body = renderBody(selected);
1119
+ body = renderBody(selected, true);
1054
1120
  tail = renderTail(missed, maxBytes - byteLen(body), true);
1055
1121
  }
1056
1122
  let text = `${body}${tail}`.replace(/^\n+/, '');
@@ -1105,15 +1171,144 @@ function sceneLabel(scene, index) {
1105
1171
  const label = index?.scenes?.[scene]?.label;
1106
1172
  return label && label !== '' ? label : scene;
1107
1173
  }
1108
- /** 场景标题:`''` → 全局;其余用目录名(可追溯)。 */
1109
- const sceneHeading = (scene) => `## 场景记忆:${sceneLabel(scene)}`;
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
+ : '用户配置的上下文,当前启用');
1110
1186
  /**
1111
- * 场景标题后的固定引导语(常量,不破坏前缀缓存稳定):让模型知道这段是当前场景的
1112
- * 常驻参考知识,而不是对话历史或临时说明 —— 相关就用、无关可忽略。
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`:调用方据此决定下一条记忆前要不要空行(单行条目连续排列,其余空行分隔)。
1113
1287
  */
1114
- const SCENE_MEMORY_NOTE = '(本场景的常驻参考知识:与当前任务相关时直接采用,无关时忽略)';
1115
- /** 单条记忆的标题:显式 description 优先;派生描述与正文重复,改用文件名。 */
1116
- const ruleHeading = (f) => (f.descriptionDerived || !f.description ? f.name : f.description);
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());
1117
1312
  const byteLen = (s) => Buffer.byteLength(s, 'utf8');
1118
1313
  // ── 创建服务 ───────────────────────────────────────────────────────────────
1119
1314
  export function createRulesService(ctx, deps) {
@@ -1175,10 +1370,10 @@ export function createRulesService(ctx, deps) {
1175
1370
  sceneCache = { signature: probe.signature, value };
1176
1371
  return value;
1177
1372
  }
1178
- // ── 场景提示词段(绑定预设正文;只读,绝不写 ~/.dsh/AGENTS.md)──────────────
1373
+ // ── 场景提示词(绑定预设正文;读盘、只读;写文件由 scene-prompt-sync.ts 负责)──
1179
1374
  //
1180
1375
  // 语义(用户裁定 2026-09-15):
1181
- // - 场景可绑定**一个**提示词预设(`scenes[].prompt` → `agents-md/<id>/AGENTS.md`);
1376
+ // - 场景可绑定**一个**提示词预设(`scenes[].prompt` → `prompts/<id>/AGENTS.md`);
1182
1377
  // - 除保留场景 `global` 外**同时只能启用一个场景**,所以同一时刻至多一份提示词在场;
1183
1378
  // - 没有启用其它场景时才轮到 `global` 自己的绑定(它恒常生效,覆盖"永远在场的提示词");
1184
1379
  // - 预设文件不存在 / 未绑定 → 返回 `''`(renderPrompt 会删掉空段,不产生空标题)。
@@ -1189,7 +1384,7 @@ export function createRulesService(ctx, deps) {
1189
1384
  const promptFileCache = new Map();
1190
1385
  /** 读一个预设正文(同步、带 stat 指纹缓存);不存在返回 `''`。 */
1191
1386
  function readPresetTextSync(id) {
1192
- const file = join(stateDir, 'agents-md', id, 'AGENTS.md');
1387
+ const file = join(stateDir, PRESETS_DIR, id, 'AGENTS.md');
1193
1388
  let key;
1194
1389
  try {
1195
1390
  const st = statSync(file);
@@ -1217,7 +1412,7 @@ export function createRulesService(ctx, deps) {
1217
1412
  /** 预设文件是否存在(同步;绑定前校验用,避免悬空绑定)。 */
1218
1413
  function presetExistsSync(id) {
1219
1414
  try {
1220
- return statSync(join(stateDir, 'agents-md', id, 'AGENTS.md')).isFile();
1415
+ return statSync(join(stateDir, PRESETS_DIR, id, 'AGENTS.md')).isFile();
1221
1416
  }
1222
1417
  catch {
1223
1418
  return false;
@@ -1254,24 +1449,13 @@ export function createRulesService(ctx, deps) {
1254
1449
  return text;
1255
1450
  }
1256
1451
  /**
1257
- * 当前生效的场景提示词:启用场景的绑定优先,其次 `global` 的绑定;都没有则 `''`。
1258
- * 绑定内容与 `~/.dsh/AGENTS.md` 相同时标记 `duplicate` 且返回空段 —— 那份正文已经在
1259
- * 基线里了,再注入一遍只会把同样的内容塞进上下文两次。
1452
+ * 当前生效的场景提示词:启用场景的绑定优先,其次 `global` 的绑定;都没有 → `null`。
1453
+ * 「绑了但预设为空/已删」按没绑处理(不注入空段),继续找下一个。
1260
1454
  */
1261
- function scenePrompt() {
1455
+ function resolveScenePreset() {
1262
1456
  const index = readIndexSync(stateDir);
1263
1457
  const names = Object.keys(index.scenes || {});
1264
1458
  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
1459
  // 单选:取唯一一个非保留启用场景(resolveActiveScenes 已保证 ≤1,这里仍做确定性排序兜底)。
1276
1460
  const enabled = names
1277
1461
  .filter((n) => n !== SHARED_GROUP && n !== GLOBAL_SCENE && active.has(n))
@@ -1280,27 +1464,35 @@ export function createRulesService(ctx, deps) {
1280
1464
  const id = index.scenes?.[scene]?.prompt;
1281
1465
  if (!id)
1282
1466
  continue;
1283
- const projected = project(scene, id);
1467
+ const text = readPresetTextSync(id);
1284
1468
  // 绑了但预设为空/已删 → 继续找下一个(不注入空段)。
1285
- if (projected.missing)
1469
+ if (text.trim() === '')
1286
1470
  continue;
1287
- return projected;
1471
+ return { scene, presetId: id, text };
1288
1472
  }
1289
1473
  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 { /* 单个失效失败不影响其余 */ }
1474
+ if (globalId) {
1475
+ const text = readPresetTextSync(globalId);
1476
+ if (text.trim() !== '')
1477
+ return { scene: GLOBAL_SCENE, presetId: globalId, text };
1302
1478
  }
1303
- };
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
+ }
1304
1496
  // ── 写操作串行队列(避免并发覆盖同一索引/文件)──
1305
1497
  let mutationQueue = Promise.resolve();
1306
1498
  const enqueueMutation = (task) => {
@@ -1322,17 +1514,26 @@ export function createRulesService(ctx, deps) {
1322
1514
  return null;
1323
1515
  return { group, name };
1324
1516
  }
1325
- /** 磁盘定位(bundle 优先)。返回规则文件与条目路径。 */
1517
+ /**
1518
+ * 磁盘定位(bundle 优先)。返回规则文件与条目路径。
1519
+ * 根层(`group === ''`)**没有 bundle**:一级目录恒为场景(见 discover 的注释),
1520
+ * 否则 `memories/1/1.md`(场景 1 的 flat 记忆 1)会被这里读成根层 bundle「1」,
1521
+ * 与列表/注入两端不一致(编辑、删除都会落到错误的文件上)。
1522
+ */
1326
1523
  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 };
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 { /* 该候选名不存在 → 试下一个 */ }
1333
1535
  }
1334
1536
  }
1335
- catch { /* 非 bundle */ }
1336
1537
  const flatPath = join(rulesRoot, group, name + '.md');
1337
1538
  try {
1338
1539
  const st = await lstat(flatPath);
@@ -1407,7 +1608,6 @@ export function createRulesService(ctx, deps) {
1407
1608
  const refresh = async () => {
1408
1609
  invalidateSnapshot();
1409
1610
  sceneCache = null;
1410
- invalidateProviders();
1411
1611
  };
1412
1612
  /** 场景档案引擎专用:读-改-写 mode/archives/active 切片(写队列内,保持与其余索引写串行)。 */
1413
1613
  const patchIndex = (patch) => enqueueMutation(async () => {
@@ -1488,8 +1688,11 @@ export function createRulesService(ctx, deps) {
1488
1688
  activeScene: enabledSceneOf(index),
1489
1689
  sceneMemory: { usedBytes: projection.bytes, maxBytes: projection.maxBytes, truncated: projection.truncated, dropped: projection.dropped },
1490
1690
  // 当前生效的场景提示词(绑定的预设正文;与 ~/.dsh/AGENTS.md 一致时不重复注入)。
1691
+ // `label` 是给用户指路用的显示名(「去场景设置里改『全局』的绑定」)——`global`
1692
+ // 这类磁盘名直接甩给用户看没有意义。
1491
1693
  scenePrompt: {
1492
1694
  scene: promptProjection.scene,
1695
+ label: promptProjection.scene ? sceneLabel(promptProjection.scene, index) : null,
1493
1696
  presetId: promptProjection.presetId,
1494
1697
  missing: promptProjection.missing,
1495
1698
  duplicate: promptProjection.duplicate === true,
@@ -1525,7 +1728,7 @@ export function createRulesService(ctx, deps) {
1525
1728
  rule: {
1526
1729
  ...rule,
1527
1730
  body: doc.body,
1528
- attachments: entry.kind === 'bundle' ? await listAttachments(entry.entryPath) : [],
1731
+ attachments: entry.kind === 'bundle' ? await listAttachments(entry.entryPath, entry.name) : [],
1529
1732
  frontmatter: {
1530
1733
  name: doc.map.name != null ? String(doc.map.name) : undefined,
1531
1734
  description: doc.map.description != null ? String(doc.map.description) : undefined,
@@ -1648,9 +1851,11 @@ export function createRulesService(ctx, deps) {
1648
1851
  if (!isValidGroupSegment(name))
1649
1852
  return fail('error.rules.invalidName', `记忆名非法:${name}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含 / \\ < > : " | ? *、不以 . 开头)`);
1650
1853
  // 目标已存在(bundle 或 flat 皆算)→ 拒绝,避免静默覆盖。
1854
+ // 用 exists 而不是 shadowed:后者说的是「同名 bundle 把 flat 遮蔽了」,用户看到
1855
+ // 「被同名 bundle 遮蔽」会去找一个并不存在的 bundle(2026-09-16 实测)。
1651
1856
  const existing = await locateRule(group, name);
1652
1857
  if (existing) {
1653
- 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}`);
1654
1859
  }
1655
1860
  const body = String((args && args.body) ?? '');
1656
1861
  if (body.trim() === '')
@@ -1679,7 +1884,7 @@ export function createRulesService(ctx, deps) {
1679
1884
  const text = serializeRuleFile(fields, body);
1680
1885
  if (form === 'bundle') {
1681
1886
  await mkdir(join(rulesRoot, targetGroup, name), { recursive: true });
1682
- await writeFileAtomically(join(rulesRoot, targetGroup, name, 'SKILL.md'), text);
1887
+ await writeFileAtomically(join(rulesRoot, targetGroup, name, bundleDocName(name)), text);
1683
1888
  }
1684
1889
  else {
1685
1890
  await mkdir(join(rulesRoot, targetGroup), { recursive: true });
@@ -1696,7 +1901,6 @@ export function createRulesService(ctx, deps) {
1696
1901
  index.groups[targetGroup] = { order: DEFAULT_GROUP_ORDER, label: targetGroup };
1697
1902
  await writeIndex(stateDir, index);
1698
1903
  invalidateSnapshot();
1699
- invalidateProviders();
1700
1904
  return { ok: true, rule: await buildProjected(createdId) };
1701
1905
  }
1702
1906
  /**
@@ -1781,10 +1985,10 @@ export function createRulesService(ctx, deps) {
1781
1985
  const id = `${item.group}/${item.name}`;
1782
1986
  try {
1783
1987
  if (item.kind === 'bundle') {
1784
- // 与 rules-create 同落点:bundle = <场景>/<名>/ 目录,正文 SKILL.md,附件平铺同层。
1988
+ // 与 rules-create 同落点:bundle = <场景>/<名>/ 目录,正文 <名>.md,附件平铺同层。
1785
1989
  const bundleDir = join(rulesRoot, item.group, item.name);
1786
1990
  await mkdir(bundleDir, { recursive: true });
1787
- await writeFileAtomically(join(bundleDir, 'SKILL.md'), item.text);
1991
+ await writeFileAtomically(join(bundleDir, bundleDocName(item.name)), item.text);
1788
1992
  for (const att of item.attachments)
1789
1993
  await writeFileAtomicBinary(join(bundleDir, att.name), att.data);
1790
1994
  }
@@ -1887,7 +2091,7 @@ export function createRulesService(ctx, deps) {
1887
2091
  }
1888
2092
  else if (located.kind === 'flat' && newForm === 'bundle') {
1889
2093
  await mkdir(join(rulesRoot, parts.group, newName), { recursive: true });
1890
- await writeFileAtomically(join(rulesRoot, parts.group, newName, 'SKILL.md'), serialize());
2094
+ await writeFileAtomically(join(rulesRoot, parts.group, newName, bundleDocName(newName)), serialize());
1891
2095
  await rm(join(rulesRoot, parts.group, parts.name + '.md'), { force: true });
1892
2096
  }
1893
2097
  else if (located.kind === 'flat' && newName !== parts.name) {
@@ -1911,7 +2115,6 @@ export function createRulesService(ctx, deps) {
1911
2115
  }
1912
2116
  await writeIndex(stateDir, index);
1913
2117
  invalidateSnapshot();
1914
- invalidateProviders();
1915
2118
  return { ok: true, rule: await buildProjected(newId) };
1916
2119
  }
1917
2120
  async function rulesRemove(args) {
@@ -1923,7 +2126,7 @@ export function createRulesService(ctx, deps) {
1923
2126
  if (!located)
1924
2127
  return fail('error.rules.notFound', `规则不存在:${id}`);
1925
2128
  const trashId = Date.now().toString(36) + '-' + randomUUID().slice(0, 8);
1926
- const trashDir = join(stateDir, 'rules-trash', trashId);
2129
+ const trashDir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
1927
2130
  const manifest = { group: parts.group, name: parts.name, form: located.kind, deletedAt: new Date().toISOString() };
1928
2131
  await mkdir(trashDir, { recursive: true });
1929
2132
  if (located.kind === 'bundle') {
@@ -1940,14 +2143,13 @@ export function createRulesService(ctx, deps) {
1940
2143
  delete index.rules[id];
1941
2144
  await writeIndex(stateDir, index);
1942
2145
  invalidateSnapshot();
1943
- invalidateProviders();
1944
2146
  return { ok: true, trashId };
1945
2147
  }
1946
2148
  async function rulesRestore(args) {
1947
2149
  const trashId = String((args && args.trashId) || '');
1948
2150
  if (!isValidTrashId(trashId))
1949
2151
  return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
1950
- const trashDir = join(stateDir, 'rules-trash', trashId);
2152
+ const trashDir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
1951
2153
  let manifest;
1952
2154
  try {
1953
2155
  manifest = JSON.parse(await readFile(join(trashDir, 'manifest.json'), 'utf8'));
@@ -1961,7 +2163,7 @@ export function createRulesService(ctx, deps) {
1961
2163
  // 恢复目标已存在(期间用户重建了同名规则)→ 拒绝,避免覆盖。
1962
2164
  const conflict = await locateRule(manifest.group, manifest.name);
1963
2165
  if (conflict) {
1964
- return fail('error.rules.shadowed', `同名规则已存在:${manifest.group}/${manifest.name},请先移除后再恢复`);
2166
+ return fail('error.rules.exists', `同名记忆已存在:${conflict.id},请先移除后再恢复`);
1965
2167
  }
1966
2168
  const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
1967
2169
  if (form === 'bundle') {
@@ -1975,7 +2177,6 @@ export function createRulesService(ctx, deps) {
1975
2177
  }
1976
2178
  await rm(trashDir, { recursive: true, force: true });
1977
2179
  invalidateSnapshot();
1978
- invalidateProviders();
1979
2180
  return { ok: true, rule: await buildProjected(manifest.group ? `${manifest.group}/${manifest.name}` : manifest.name) };
1980
2181
  }
1981
2182
  /** trashId 只由本插件生成(时间戳 base36 + uuid 前 8 位):严格白名单,杜绝路径穿越。 */
@@ -1983,11 +2184,11 @@ export function createRulesService(ctx, deps) {
1983
2184
  return /^[a-z0-9]+-[a-z0-9]{1,32}$/i.test(trashId);
1984
2185
  }
1985
2186
  /**
1986
- * 记忆回收站列表(只读):`<stateDir>/rules-trash/<trashId>/`(`rulesRemove` 移入,
2187
+ * 记忆回收站列表(只读):`<stateDir>/memories-trash/<trashId>/`(`rulesRemove` 移入,
1987
2188
  * `rulesRestore` 恢复)。损坏或内容缺失的条目跳过,不让一个坏条目挡住整份列表。
1988
2189
  */
1989
2190
  async function rulesTrashList() {
1990
- const root = join(stateDir, 'rules-trash');
2191
+ const root = join(stateDir, MEMORIES_TRASH_DIR);
1991
2192
  let ids = [];
1992
2193
  try {
1993
2194
  ids = await readdir(root);
@@ -2013,9 +2214,20 @@ export function createRulesService(ctx, deps) {
2013
2214
  const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
2014
2215
  let bytes = 0;
2015
2216
  try {
2016
- 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
+ }
2017
2230
  }
2018
- catch { /* 内容缺失:仍列出,恢复时由 rulesRestore 兜底报错 */ }
2019
2231
  entries.push({ trashId, group, name: manifest.name, form, deletedAt: String(manifest.deletedAt || ''), bytes });
2020
2232
  }
2021
2233
  catch { /* 损坏条目跳过 */ }
@@ -2028,7 +2240,7 @@ export function createRulesService(ctx, deps) {
2028
2240
  const trashId = String((args && args.trashId) || '');
2029
2241
  if (!isValidTrashId(trashId))
2030
2242
  return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
2031
- const dir = join(stateDir, 'rules-trash', trashId);
2243
+ const dir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
2032
2244
  try {
2033
2245
  const st = await lstat(dir);
2034
2246
  if (!st.isDirectory())
@@ -2040,8 +2252,8 @@ export function createRulesService(ctx, deps) {
2040
2252
  await rm(dir, { recursive: true, force: true });
2041
2253
  return { ok: true };
2042
2254
  }
2043
- /** bundle 目录下的附件(顶层普通文件,排除正文 SKILL.md);不存在/不可读返回空数组。 */
2044
- async function listAttachments(bundleDir) {
2255
+ /** bundle 目录下的附件(顶层普通文件,排除正文 `<名>.md`,兼容旧数据 `SKILL.md`);不存在/不可读返回空数组。 */
2256
+ async function listAttachments(bundleDir, name) {
2045
2257
  let entries;
2046
2258
  try {
2047
2259
  entries = await readdir(bundleDir, { withFileTypes: true });
@@ -2049,9 +2261,10 @@ export function createRulesService(ctx, deps) {
2049
2261
  catch {
2050
2262
  return [];
2051
2263
  }
2264
+ const docNames = new Set([bundleDocName(name), LEGACY_BUNDLE_DOC]);
2052
2265
  const out = [];
2053
2266
  for (const entry of entries) {
2054
- if (entry.name === BUNDLE_DOC)
2267
+ if (docNames.has(entry.name))
2055
2268
  continue;
2056
2269
  if (!entry.isFile())
2057
2270
  continue; // 子目录 / 符号链接不列出(detach 也只动普通文件)
@@ -2085,10 +2298,13 @@ export function createRulesService(ctx, deps) {
2085
2298
  return fail('error.rules.tooManyFiles', `一次最多 ${MAX_ATTACH_ENTRIES} 个附件`, { limit: MAX_ATTACH_ENTRIES });
2086
2299
  const pending = [];
2087
2300
  let total = 0;
2301
+ const docNames = new Set([bundleDocName(located.name), LEGACY_BUNDLE_DOC]);
2088
2302
  for (const file of files) {
2089
2303
  const name = String((file && (file.path || file.name)) || '');
2090
2304
  if (!isValidGroupSegment(name))
2091
2305
  return fail('error.rules.invalidName', `附件名非法:${name || '(空)'}`);
2306
+ if (docNames.has(name))
2307
+ return fail('error.rules.invalidName', `附件名不能与正文文件同名:${name}`);
2092
2308
  const data = Buffer.from(String((file && file.data) || ''), 'base64');
2093
2309
  if (data.length === 0)
2094
2310
  return fail('error.rules.emptyFile', `附件内容为空:${name}`);
@@ -2101,9 +2317,9 @@ export function createRulesService(ctx, deps) {
2101
2317
  }
2102
2318
  for (const file of pending)
2103
2319
  await writeFileAtomicBinary(join(located.entryPath, file.name), file.data);
2104
- return { ok: true, attachments: await listAttachments(located.entryPath) };
2320
+ return { ok: true, attachments: await listAttachments(located.entryPath, located.name) };
2105
2321
  }
2106
- /** 删除 bundle 记忆的一个附件;正文 SKILL.md 不可删。 */
2322
+ /** 删除 bundle 记忆的一个附件;正文 `<名>.md`(或旧数据 `SKILL.md`)不可删。 */
2107
2323
  async function rulesDetach(args) {
2108
2324
  const id = String((args && args.id) || '');
2109
2325
  const parts = parseId(id);
@@ -2115,8 +2331,9 @@ export function createRulesService(ctx, deps) {
2115
2331
  if (located.kind !== 'bundle')
2116
2332
  return fail('error.rules.notBundle', `「${parts.name}」是 flat(单文件),没有附件`);
2117
2333
  const name = String((args && args.name) || '');
2118
- if (!isValidGroupSegment(name) || name === BUNDLE_DOC)
2334
+ if (!isValidGroupSegment(name) || name === bundleDocName(located.name) || name === LEGACY_BUNDLE_DOC) {
2119
2335
  return fail('error.rules.invalidName', `附件名非法:${name || '(空)'}`);
2336
+ }
2120
2337
  const target = join(located.entryPath, name);
2121
2338
  try {
2122
2339
  const st = await lstat(target);
@@ -2127,7 +2344,7 @@ export function createRulesService(ctx, deps) {
2127
2344
  return fail('error.rules.notFound', `附件不存在:${name}`);
2128
2345
  }
2129
2346
  await rm(target, { force: true });
2130
- return { ok: true, attachments: await listAttachments(located.entryPath) };
2347
+ return { ok: true, attachments: await listAttachments(located.entryPath, located.name) };
2131
2348
  }
2132
2349
  async function rulesToggle(args) {
2133
2350
  const id = String((args && args.id) || '');
@@ -2147,7 +2364,6 @@ export function createRulesService(ctx, deps) {
2147
2364
  index.rules[id] = { ...idxEntry, enabled, updatedAt: new Date().toISOString() };
2148
2365
  await writeIndex(stateDir, index);
2149
2366
  invalidateSnapshot();
2150
- invalidateProviders();
2151
2367
  return { ok: true, rule: await buildProjected(id) };
2152
2368
  }
2153
2369
  /**
@@ -2192,7 +2408,6 @@ export function createRulesService(ctx, deps) {
2192
2408
  index.active = names;
2193
2409
  await writeIndex(stateDir, index);
2194
2410
  invalidateSnapshot();
2195
- invalidateProviders();
2196
2411
  const snap = await snapshot();
2197
2412
  return {
2198
2413
  ok: true,
@@ -2250,7 +2465,6 @@ export function createRulesService(ctx, deps) {
2250
2465
  index.scenes[name] = next;
2251
2466
  await writeIndex(stateDir, index);
2252
2467
  invalidateSnapshot();
2253
- invalidateProviders();
2254
2468
  return { ok: true, scene: sceneRecordOf(name, next), ...(collapsed ? { collapsedActive: true } : {}) };
2255
2469
  }
2256
2470
  /**
@@ -2378,7 +2592,6 @@ export function createRulesService(ctx, deps) {
2378
2592
  index.scenes[finalName] = next;
2379
2593
  await writeIndex(stateDir, index);
2380
2594
  invalidateSnapshot();
2381
- invalidateProviders();
2382
2595
  return {
2383
2596
  ok: true,
2384
2597
  ...(finalName === name ? {} : { renamedFrom: name }),
@@ -2503,7 +2716,6 @@ export function createRulesService(ctx, deps) {
2503
2716
  if (dirty)
2504
2717
  await writeIndex(stateDir, index);
2505
2718
  invalidateSnapshot();
2506
- invalidateProviders();
2507
2719
  return { ok: true, name, trashId: trashed.id, movedFiles: moves.length };
2508
2720
  }
2509
2721
  /** 场景回收站列表(只读):`<hub>/trash/scenes-trash/<id>/manifest.json`。 */
@@ -2562,7 +2774,6 @@ export function createRulesService(ctx, deps) {
2562
2774
  await writeIndex(stateDir, index);
2563
2775
  await purgeTrashEntry('scenes', id);
2564
2776
  invalidateSnapshot();
2565
- invalidateProviders();
2566
2777
  return { ok: true, name, restoredFiles: restored.length };
2567
2778
  }
2568
2779
  /** 永久删除一条场景回收站条目。 */
@@ -2575,7 +2786,7 @@ export function createRulesService(ctx, deps) {
2575
2786
  }
2576
2787
  /**
2577
2788
  * 提示词预设改名后**同步场景绑定**:把所有 `scenes[].prompt === from` 改成 `to`。
2578
- * 由 AGENTS.md 预设库的改名路径调用——绑定存在 `rules-index.json` 里,预设库
2789
+ * 由 AGENTS.md 预设库的改名路径调用——绑定存在 `memories-index.json` 里,预设库
2579
2790
  * 自己看不到它,不叫这一声改名就会留下悬空绑定(场景卡片显示「预设不存在」)。
2580
2791
  * @returns 改了几个场景。
2581
2792
  */
@@ -2597,7 +2808,6 @@ export function createRulesService(ctx, deps) {
2597
2808
  index.scenes = scenes;
2598
2809
  await writeIndex(stateDir, index);
2599
2810
  invalidateSnapshot();
2600
- invalidateProviders();
2601
2811
  }
2602
2812
  return { ok: true, from, to, changed };
2603
2813
  }
@@ -2625,33 +2835,40 @@ export function createRulesService(ctx, deps) {
2625
2835
  index.rules[id] = next;
2626
2836
  await writeIndex(stateDir, index);
2627
2837
  invalidateSnapshot();
2628
- invalidateProviders();
2629
2838
  return { ok: true, rule: await buildProjected(id) };
2630
2839
  }
2631
2840
  // ── 组装 ─────────────────────────────────────────────────────────────────
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
- }
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();
2655
2872
  };
2656
2873
  /** 写操作:串行队列内执行,成功后触发 refresh(失效缓存,下一请求即生效)。 */
2657
2874
  const runWrite = (task) => enqueueMutation(async () => {
@@ -2700,7 +2917,8 @@ export function createRulesService(ctx, deps) {
2700
2917
  const service = {
2701
2918
  ops,
2702
2919
  writeOps,
2703
- registerProviders,
2920
+ memoryText,
2921
+ promptText,
2704
2922
  refresh,
2705
2923
  patchIndex,
2706
2924
  readArchiveSlice,