dsh-plugin-tool-management 0.10.0 → 0.12.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 (77) hide show
  1. package/CHANGELOG.md +100 -1
  2. package/README.md +67 -50
  3. package/README_EN.md +61 -38
  4. package/cordis.patch.yml +10 -1
  5. package/docs/images/1-EN.png +0 -0
  6. package/docs/images/1.png +0 -0
  7. package/docs/images/2-EN.png +0 -0
  8. package/docs/images/2.png +0 -0
  9. package/docs/images/3-EN.png +0 -0
  10. package/docs/images/3.png +0 -0
  11. package/docs/images/4-EN.png +0 -0
  12. package/docs/images/4.png +0 -0
  13. package/docs/images/5-EN.png +0 -0
  14. package/docs/images/5.png +0 -0
  15. package/docs/images/6-EN.png +0 -0
  16. package/docs/images/6.png +0 -0
  17. package/docs/images/7-EN.png +0 -0
  18. package/docs/images/7.png +0 -0
  19. package/docs/images/8-EN.png +0 -0
  20. package/docs/images/8.png +0 -0
  21. package/docs/update.md +132 -12
  22. package/lib/client.js +2826 -547
  23. package/lib/compat/patch-dialect.js +173 -0
  24. package/lib/compat/preset-reach.js +1 -10
  25. package/lib/compat/probe.js +205 -24
  26. package/lib/compat/runtime-notes.js +25 -0
  27. package/lib/context-inject.js +83 -9
  28. package/lib/host-names.js +12 -0
  29. package/lib/http-fence.js +35 -15
  30. package/lib/hub.js +28 -2
  31. package/lib/imports/parsers.js +15 -9
  32. package/lib/imports/upload.js +43 -4
  33. package/lib/index.js +927 -3897
  34. package/lib/mcp/loader-token.js +238 -0
  35. package/lib/mcp/manager.js +1769 -0
  36. package/lib/mcp/override-blocks.js +10 -3
  37. package/lib/mcp/patch-yaml.js +351 -0
  38. package/lib/mcp/secret-guard.js +145 -0
  39. package/lib/{rules → memories}/archive-engine.js +1 -1
  40. package/lib/{rules → memories}/archive.js +1 -1
  41. package/lib/memories/constants.js +128 -0
  42. package/lib/memories/index-io.js +330 -0
  43. package/lib/memories/projection.js +280 -0
  44. package/lib/memories/service.js +686 -0
  45. package/lib/memories/snapshot.js +672 -0
  46. package/lib/ops/candidates.js +64 -0
  47. package/lib/ops/compat.js +226 -0
  48. package/lib/ops/ctx.js +9 -0
  49. package/lib/ops/memory.js +678 -0
  50. package/lib/ops/prompts.js +107 -0
  51. package/lib/ops/scene-records.js +460 -0
  52. package/lib/ops/scene-sync.js +17 -0
  53. package/lib/ops/sessions.js +603 -0
  54. package/lib/ops/trash.js +140 -0
  55. package/lib/paths.js +103 -0
  56. package/lib/prompts/preset-id.js +49 -0
  57. package/lib/{agents-md → prompts}/service.js +1 -1
  58. package/lib/request-gate.js +320 -0
  59. package/lib/scene-prompt-sync.js +4 -4
  60. package/lib/scenes/candidates.js +344 -0
  61. package/lib/{history → sessions}/bridge.js +124 -36
  62. package/lib/sessions/history.js +323 -0
  63. package/lib/{history → sessions}/tombstone.js +1 -1
  64. package/lib/{history → sessions}/workspace.js +151 -50
  65. package/lib/skills/core.js +74 -39
  66. package/lib/skills/readonly-discovery.js +4 -1
  67. package/lib/skills/service.js +98 -13
  68. package/lib/subagents/service.js +199 -55
  69. package/lib/tools/deps.js +8 -0
  70. package/lib/tools/mcp.js +110 -0
  71. package/lib/tools/memory.js +87 -0
  72. package/lib/tools/prompt.js +70 -0
  73. package/lib/tools/skills.js +139 -0
  74. package/lib/tools/subagent.js +40 -0
  75. package/package.json +13 -10
  76. package/lib/agents-md/preset-id.js +0 -49
  77. package/lib/rules/service.js +0 -3078
@@ -1,3078 +0,0 @@
1
- // dsh-plugin-tool-management —— 规则/记忆(Rules v0.3,见 CHANGE-REQUEST-01)服务层。
2
- //
3
- // 记忆 = $DSH_HOME/tool-management/memories/<场景>/<name>.md(flat)或 <场景>/<name>/<name>.md(bundle)。
4
- // 历史遗留的 bundle 正文文件名 `SKILL.md` 仍被识别(只读兼容),但新建/更新一律写 `<name>.md`。
5
- // **场景是显式记录**:$DSH_HOME/tool-management/scenes/<场景>.json(见 SceneRecord),
6
- // 不再是「memories/ 下恰好有这个名字的目录」这种隐式约定——空场景因此可以存在,
7
- // 且场景可以有描述。目录名即场景名(任意 Unicode,见 isValidGroupSegment)。
8
- // 保留场景 `global`(界面显示「全局」):其记忆注入任何对话;它恒定存在、不可删除。
9
- // 勾选启用后,该目录树内所有 .md 的正文自动进入模型的上下文(index.ts 注册的注入通道,
10
- // 每步一条消息;见 src/context-inject.ts),模型无需做任何动作 —— 这就是"不用每次都要解释"。
11
- //
12
- // 单投影(原 ADR-4 的"双投影"已被本变更单修订):
13
- // - 活动场景记忆 → 上下文注入(自动在场,会话级恒定 → 前缀稳定、缓存可命中;
14
- // 文本没变时不重发 —— 2026-09-16 从 systemPrompt 段改道,理由见 context-inject.ts 文件头)
15
- // - `_shared/` 承担"恒常"语义(所有场景共用);原 per-rule `always` 标志已移除
16
- // - 不再写 ~/.dsh/AGENTS.md(原始终层投影下线)
17
- //
18
- // 状态分层(两份文件各司其职,互不写回):
19
- // - 规则文件:正文真源。frontmatter 可声明 name/description/whenToUse/globs/metadata。
20
- // - memories-index.json:启停/排序/标签/启用场景集合等**索引为准**字段(不写回记忆文件)。
21
- //
22
- // 发现必须自实现(不复用 readonly-discovery):可写来源只扫一层会压扁子目录场景,
23
- // 而规则的目录树天然是多层的;且 readonly-discovery 的 flat 只认顶层。
24
- //
25
- // 缓存红线(§5.2):段内容只由「启用场景 + 文件内容」决定,禁止时间戳/计数/相对时间;
26
- // 场景组合或记忆文件不变 ⇒ 逐字节稳定 ⇒ 前缀缓存命中。切换场景/编辑记忆只变化一次。
27
- //
28
- // 错误约定:业务校验失败返回 { ok:false, error: 中文, code, params? }(与 skills core 一致);
29
- // ops 成功返回扁平 { ok:true, ... },不套 { ok:true, data }。
30
- import { createHash, randomUUID } from 'node:crypto';
31
- import { readFileSync, readdirSync, statSync } from 'node:fs';
32
- import { copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
33
- import { homedir } from 'node:os';
34
- import { basename, dirname, join, resolve } from 'node:path';
35
- import { parseSkillDoc, renameWithRetry, resolveDshHome, unquote } from '../skills/core.js';
36
- import { expandUploads, planMemoryImport } from '../imports/upload.js';
37
- import { normalizePresetId } from '../agents-md/preset-id.js';
38
- import { normalizeArchive } from './archive.js';
39
- import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
40
- // ── 常量 ───────────────────────────────────────────────────────────────────
41
- const MAX_SOURCE_DEPTH = 64; // 与 core.js 一致
42
- const MAX_DIRECTORIES = 2000; // 目录预算
43
- const MAX_ENTRIES = 20000; // 条目预算
44
- const MAX_GROUP_SEGMENT_LENGTH = 64; // 场景/子目录段名长度上限
45
- const MAX_DESCRIPTION_LENGTH = 500; // 派生/显式描述上限(派生超长截断,显式超长拒绝)
46
- const MAX_RULE_BYTES = 1 << 18; // 正文上限 256 KiB
47
- const DEFAULT_ORDER = 1000; // 默认投影 order(索引无记录时)
48
- const DEFAULT_GROUP_ORDER = 1000; // 新场景默认 order
49
- const SNAPSHOT_TTL_MS = 1000; // 读路径短 TTL 缓存,吸收 UI 密集轮询
50
- const DEFAULT_MAX_BYTES = 65536; // 场景记忆段预算上限(字节)
51
- const TRUNCATION_MARKER = '<!-- truncated -->';
52
- // 段尾清单:让模型知道自己漏了什么。去掉伞标题后这里也不再挂「场景记忆」前缀 ——
53
- // 它紧跟在场景块之后,`参考信息` 与段首引导语同一说法。
54
- const DROPPED_HEADING = '## 未注入的参考信息(超出预算)';
55
- // bundle 附件限制(body 走 HTTP JSON + base64,故比技能上传收紧一档)。
56
- const MAX_ATTACH_ENTRY_BYTES = 8 << 20; // 单个附件 8 MiB
57
- const MAX_ATTACH_TOTAL_BYTES = 16 << 20; // 单次总大小 16 MiB
58
- const MAX_ATTACH_ENTRIES = 32; // 单次最多 32 个
59
- const LEGACY_BUNDLE_DOC = 'SKILL.md'; // 旧版 bundle 的正文文件名;仅在发现/附件排除时作只读兼容,新建一律用 bundleDocName()
60
- /** bundle 的正文文件名 = `<记忆名>.md`(与目录名一致,不再是固定的 SKILL.md)。 */
61
- const bundleDocName = (name) => `${name}.md`;
62
- const SHARED_GROUP = '_shared'; // 保留场景名:公共基线(历史语义,仍可使用)
63
- const INDEX_VERSION = 1;
64
- // 场景/子目录段名约束(§4 对照表 + §5.3):任意 Unicode,但必须对文件系统安全。
65
- // - 非空、长度 ≤64、不等于 `.` / `..`、不以 `.` 开头(隐藏目录语义冲突)
66
- // - 不含路径分隔符 `/` `\`,不含 Windows 保留字符 `< > : " | ? *`
67
- // - 首尾无空白、末尾不是 `.` 或空格(Windows 会静默裁剪,导致路径与显示名不一致)
68
- const WINDOWS_RESERVED_RE = /[<>:"|?*\\/]/;
69
- /** 单个路径段(场景名或子目录名)是否合法。放宽后 `办公` / `日常` 均通过。 */
70
- export function isValidGroupSegment(segment) {
71
- if (typeof segment !== 'string')
72
- return false;
73
- if (segment === '' || segment === '.' || segment === '..')
74
- return false;
75
- if (segment.length > MAX_GROUP_SEGMENT_LENGTH)
76
- return false;
77
- if (segment.startsWith('.'))
78
- return false;
79
- if (WINDOWS_RESERVED_RE.test(segment))
80
- return false;
81
- if (segment !== segment.trim())
82
- return false;
83
- if (/[.\s]$/.test(segment))
84
- return false;
85
- return true;
86
- }
87
- /** group/场景路径可多层(a/b/c),每段必须合法;_shared 作为保留场景名放行。 */
88
- export function isValidGroupPath(group) {
89
- if (typeof group !== 'string' || group === '' || group.startsWith('/') || group.endsWith('/'))
90
- return false;
91
- return group.split('/').every(isValidGroupSegment);
92
- }
93
- const message = (e) => String((e && e.message) || e);
94
- const fail = (code, error, params) => (params ? { ok: false, error, code, params } : { ok: false, error, code });
95
- const identity = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
96
- /**
97
- * 场景/记忆根目录名($DSH_HOME 下),集中在 `tool-management/` 一个目录内:
98
- * - `scenes/<场景>.json` 场景记录(名称/描述/顺序)
99
- * - `memories/<场景>/…` 记忆正文真源
100
- * - `..`(即 tool-management 根)侧车:memories-index.json / skills-state.json / trash/
101
- *
102
- * 历史位置 `$DSH_HOME/scene-memory/` 由 `relocateLegacyLayout()` 在首次读写前搬入。
103
- */
104
- export const HUB_DIR = 'tool-management';
105
- export const SCENES_DIR = 'scenes';
106
- export const MEMORIES_DIR = 'memories';
107
- /**
108
- * 记忆索引文件名 / 记忆回收站目录名(hub 根下)。
109
- * 域叫「记忆」(工具 `memory_manager_*`、界面「记忆」页),所以按域命名 ——
110
- * 旧名 `rules-index.json` / `rules-trash/` 由 hub 的启动迁移搬过来(见 hub.ts)。
111
- */
112
- export const MEMORIES_INDEX_FILE = 'memories-index.json';
113
- export const MEMORIES_TRASH_DIR = 'memories-trash';
114
- /** 提示词预设库目录名(hub 根下;域 = 提示词,工具 `prompt_manager_*`)。 */
115
- export const PRESETS_DIR = 'prompts';
116
- /** 保留场景名:界面显示「全局」,恒定存在、不可删除,其记忆注入任何对话。 */
117
- export const GLOBAL_SCENE = 'global';
118
- /** 保留场景在界面上的显示名(磁盘上仍用 ASCII 目录/文件名)。 */
119
- export const GLOBAL_SCENE_LABEL = '全局';
120
- /**
121
- * 记忆导出的落盘映射(`bundle-export` 的 `kind: 'memories'` 用,见 design-plan D14)。
122
- *
123
- * 记忆有两种形态(见文件头):flat = `<场景>/<name>.md`,bundle = `<场景>/<name>/<name>.md`;
124
- * 而且**场景名本身可含 `/`**(多段场景名,见 `isValidGroupPath`)。所以「按 id 的最后一个
125
- * `/` 切出场景与名字、再拼 `.md`」是错的:bundle 会被读成 `<场景>/<name>.md` → 读不到 →
126
- * 该项目静默丢失(只在 `missing` 里留个名,界面按「导出成功」显示)。
127
- *
128
- * 这里一律按索引里那条规则自己的 `path` 与 `form` 决定;bundle 只交出目录,由调用方
129
- * 把目录内的文件全部打包(与技能分支同口径)。索引里没有、或已被同名 bundle 遮蔽的 id
130
- * 进 `missing`——遮蔽的 flat 不会被加载,导出去只会让人以为它能用。
131
- *
132
- * 纯函数(不碰磁盘),可直接断言。
133
- */
134
- export function planMemoryExport(names, rules) {
135
- const list = Array.isArray(names) ? names.map((n) => String(n).trim()).filter(Boolean) : [];
136
- const byId = new Map();
137
- for (const row of (Array.isArray(rules) ? rules : [])) {
138
- const id = row && typeof row.id === 'string' ? row.id : '';
139
- if (id !== '')
140
- byId.set(id, row);
141
- }
142
- const entries = [];
143
- const missing = [];
144
- for (const id of list) {
145
- const row = byId.get(id);
146
- const abs = row && typeof row.path === 'string' ? row.path : '';
147
- if (!row || abs === '' || row.shadowed === true) {
148
- missing.push(id);
149
- continue;
150
- }
151
- entries.push(row.form === 'bundle'
152
- ? { id, zip: id, abs: dirname(abs), kind: 'dir' }
153
- : { id, zip: `${id}.md`, abs, kind: 'file' });
154
- }
155
- return { entries, missing };
156
- }
157
- // ── 文件工具 ───────────────────────────────────────────────────────────────
158
- /**
159
- * 同目录临时文件 + rename 原子写(临时名 `.xxx.dsh-rules-<uuid>.tmp`)。
160
- * rename 走 `renameWithRetry`:Windows 上杀软/索引器会短暂占住目标文件报
161
- * EPERM/EACCES/EBUSY(用户实测:进入/退出模式时 memories-index.json 被 rename 撞上,
162
- * 运行时已切换但状态落盘失败,界面开关停在旧状态、还得再点一次)。这种占用是
163
- * 瞬时的,重试几轮就能过去;全失败才清理临时文件并把错误抛出。
164
- */
165
- async function writeFileAtomically(path, content) {
166
- const temp = join(dirname(path), `.${basename(path)}.dsh-rules-${randomUUID()}.tmp`);
167
- try {
168
- await writeFile(temp, content, 'utf8');
169
- await renameWithRetry(temp, path);
170
- }
171
- catch (error) {
172
- await rm(temp, { force: true }).catch(() => undefined);
173
- throw error;
174
- }
175
- }
176
- /** 二进制版原子写(附件用):临时文件 + rename(同样带瞬时占用重试),失败清理临时文件。 */
177
- async function writeFileAtomicBinary(path, data) {
178
- const temp = join(dirname(path), `.${basename(path)}.dsh-rules-${randomUUID()}.tmp`);
179
- try {
180
- await writeFile(temp, data);
181
- await renameWithRetry(temp, path);
182
- }
183
- catch (error) {
184
- await rm(temp, { force: true }).catch(() => undefined);
185
- throw error;
186
- }
187
- }
188
- // ── 递归发现 ───────────────────────────────────────────────────────────────
189
- /**
190
- * BFS 发现(realpath 防环、深度/目录/条目预算),与 readonly-discovery 同构,
191
- * 但规则的场景是**多层相对路径**(readonly-discovery 的 group 恒为第一层)。
192
- *
193
- * 目录语义:目录含 `<目录名>.md` → 它是 bundle 规则(叶子,不再深入),其「父路径」
194
- * 是场景、目录名是规则名;找不到时再退回认旧文件名 `SKILL.md`(只读兼容,不再新建);
195
- * 都没有则它是场景/子分类,继续遍历其下 .md(flat,任意层级)
196
- * 与子目录。同名 flat 与 bundle 冲突时 bundle 优先,flat 记入 shadowed。
197
- */
198
- async function discover(rulesRoot) {
199
- const entries = new Map();
200
- const shadowed = [];
201
- const groups = new Set();
202
- const scenes = [];
203
- const warnings = [];
204
- const rootPath = resolve(rulesRoot);
205
- try {
206
- const st = await lstat(rootPath);
207
- if (!st.isDirectory() || st.isSymbolicLink())
208
- return { entries, shadowed, groups, scenes, warnings, truncated: false };
209
- }
210
- catch {
211
- return { entries, shadowed, groups, scenes, warnings, truncated: false };
212
- }
213
- const queue = [
214
- { path: rootPath, group: '', depth: 0, ancestors: new Set() },
215
- ];
216
- let directories = 0;
217
- let itemCount = 0;
218
- let truncated = false;
219
- const addEntry = (entry) => {
220
- const existing = entries.get(entry.id);
221
- if (!existing) {
222
- entries.set(entry.id, entry);
223
- return;
224
- }
225
- if (entry.kind === 'bundle' && existing.kind === 'flat') {
226
- shadowed.push({ ...existing, shadowed: true });
227
- entries.set(entry.id, entry);
228
- return;
229
- }
230
- if (entry.kind === 'flat' && existing.kind === 'bundle') {
231
- shadowed.push({ ...entry, shadowed: true });
232
- }
233
- };
234
- for (let cursor = 0; cursor < queue.length; cursor++) {
235
- if (directories >= MAX_DIRECTORIES || itemCount >= MAX_ENTRIES) {
236
- truncated = true;
237
- break;
238
- }
239
- const current = queue[cursor];
240
- let realDirectory;
241
- let dirents = [];
242
- try {
243
- realDirectory = await realpath(current.path);
244
- if (current.ancestors.has(identity(realDirectory)))
245
- continue;
246
- const dir = await readdir(current.path, { withFileTypes: true });
247
- directories++;
248
- for (const item of dir) {
249
- if (itemCount >= MAX_ENTRIES) {
250
- truncated = true;
251
- break;
252
- }
253
- itemCount++;
254
- dirents.push(item);
255
- }
256
- }
257
- catch {
258
- continue; // 失效链接或不可读目录不阻断其他来源
259
- }
260
- // bundle 检查:当前目录含 `<目录名>.md`(或旧版 `SKILL.md`)→ 它是规则叶子,父路径为场景。
261
- //
262
- // **根下的一级目录恒为场景**(`parentGroup === ''` 时不做 bundle 判断)——与
263
- // `probeSceneFilesSync`(注入段)同口径。场景 `1` 里一条叫 `1` 的记忆(`memories/1/1.md`)
264
- // 与「根层的 bundle `1`」在磁盘上同形,认成后者会把整个场景目录吃成叶子:同场景其余
265
- // 记忆全从列表消失、多出一条「未归属场景」的同名记忆,新建记忆还会被误报「同名 bundle
266
- // 遮蔽」(2026-09-16 用户实测)。根层因此没有 bundle,只有 legacy 裸 .md(见 rulesCheck 的 noScene)。
267
- if (current.group !== '') {
268
- const sepIdx = current.group.lastIndexOf('/');
269
- const parentGroup = sepIdx >= 0 ? current.group.slice(0, sepIdx) : '';
270
- const name = sepIdx >= 0 ? current.group.slice(sepIdx + 1) : current.group;
271
- let docPath = '';
272
- if (parentGroup !== '') {
273
- for (const candidate of [bundleDocName(name), LEGACY_BUNDLE_DOC]) {
274
- const p = join(current.path, candidate);
275
- try {
276
- const st = await lstat(p);
277
- if (st.isFile() && !st.isSymbolicLink()) {
278
- docPath = p;
279
- break;
280
- }
281
- }
282
- catch { /* 该候选名不存在 → 试下一个 */ }
283
- }
284
- }
285
- if (docPath !== '') {
286
- const id = parentGroup ? `${parentGroup}/${name}` : name;
287
- addEntry({ id, group: parentGroup, name, kind: 'bundle', docPath, entryPath: current.path });
288
- continue;
289
- }
290
- }
291
- if (current.group !== '') {
292
- groups.add(current.group);
293
- // 一级目录 = 场景名(多段场景名用 '/' 连接,见 isValidGroupPath)。
294
- if (current.depth === 1)
295
- scenes.push(current.group);
296
- }
297
- dirents.sort((a, b) => a.name.localeCompare(b.name));
298
- const ancestors = new Set(current.ancestors).add(identity(realDirectory));
299
- for (const item of dirents) {
300
- if (item.name.startsWith('.'))
301
- continue; // 隐藏目录/文件跳过
302
- const path = join(current.path, item.name);
303
- try {
304
- const st = await lstat(path);
305
- if (st.isDirectory() || st.isSymbolicLink()) {
306
- if (st.isSymbolicLink() && !(await statDirIsDirectory(path)))
307
- continue;
308
- if (!isValidGroupSegment(item.name)) {
309
- warnings.push(`跳过非法目录名(含路径分隔符 / Windows 保留字符 / 首尾空白或点,或超过 ${MAX_GROUP_SEGMENT_LENGTH} 字符):${current.group ? current.group + '/' : ''}${item.name}`);
310
- continue;
311
- }
312
- if (current.depth >= MAX_SOURCE_DEPTH || queue.length >= MAX_DIRECTORIES) {
313
- truncated = true;
314
- continue;
315
- }
316
- queue.push({
317
- path,
318
- group: current.group ? `${current.group}/${item.name}` : item.name,
319
- depth: current.depth + 1,
320
- ancestors,
321
- });
322
- continue;
323
- }
324
- if (!st.isFile())
325
- continue;
326
- if (!item.name.toLowerCase().endsWith('.md'))
327
- continue; // 只认 .md
328
- const name = item.name.slice(0, -3);
329
- const id = current.group ? `${current.group}/${name}` : name;
330
- addEntry({ id, group: current.group, name, kind: 'flat', docPath: path, entryPath: path });
331
- }
332
- catch {
333
- /* 来源在扫描期间失效时跳过 */
334
- }
335
- }
336
- }
337
- return { entries, shadowed, groups, scenes, warnings, truncated };
338
- }
339
- /** 符号链接目录:lstat 得链接后,stat 确认为目录才进入(防链接到文件)。 */
340
- async function statDirIsDirectory(path) {
341
- try {
342
- return (await stat(path)).isDirectory();
343
- }
344
- catch {
345
- return false;
346
- }
347
- }
348
- // ── 规则解析 + 派生 ────────────────────────────────────────────────────────
349
- /** 派生 description:首个 `#` 标题 → 首个非空行;截断 500 字符。 */
350
- function deriveDescription(body) {
351
- const text = String(body ?? '').trim();
352
- if (!text)
353
- return '';
354
- const heading = /^#\s+(.+)$/m.exec(text);
355
- const raw = heading && heading[1].trim() ? heading[1].trim() : text.split(/\r?\n/, 1)[0].trim();
356
- return raw.slice(0, MAX_DESCRIPTION_LENGTH);
357
- }
358
- /** 从 frontmatter/正文派生字段(派生只存在于内存投影,不写回文件)。 */
359
- function deriveFromDoc(entry, doc) {
360
- const declaredName = doc.map.name != null && String(doc.map.name).trim() !== '' ? unquote(String(doc.map.name)).trim() : '';
361
- const name = declaredName || entry.name;
362
- let description = doc.map.description != null ? unquote(String(doc.map.description)).trim() : '';
363
- let descriptionDerived = false;
364
- if (description === '') {
365
- description = deriveDescription(doc.body);
366
- descriptionDerived = true;
367
- }
368
- const globs = doc.map.globs != null && String(doc.map.globs).trim() !== '' ? String(doc.map.globs).trim() : undefined;
369
- let whenToUse;
370
- if (doc.map.whenToUse != null && String(doc.map.whenToUse).trim() !== '') {
371
- whenToUse = String(doc.map.whenToUse).trim();
372
- }
373
- else if (globs) {
374
- whenToUse = `适用路径:${globs}`;
375
- }
376
- const metadata = doc.map.metadata !== undefined ? doc.map.metadata : undefined;
377
- return { name, description, descriptionDerived, whenToUse, globs, metadata };
378
- }
379
- /** 合并索引字段(enabled/order 等以索引为准;无记录走默认投影)。 */
380
- function projectRule(entry, derived, idxEntry) {
381
- const rule = {
382
- id: entry.id,
383
- group: entry.group,
384
- name: derived.name,
385
- form: entry.kind,
386
- path: entry.docPath,
387
- description: derived.description,
388
- ...(derived.descriptionDerived ? { descriptionDerived: true } : {}),
389
- ...(derived.whenToUse !== undefined ? { whenToUse: derived.whenToUse } : {}),
390
- ...(derived.globs !== undefined ? { globs: derived.globs } : {}),
391
- ...(derived.metadata !== undefined ? { metadata: derived.metadata } : {}),
392
- enabled: idxEntry?.enabled ?? true,
393
- order: idxEntry?.order ?? DEFAULT_ORDER,
394
- tags: Array.isArray(idxEntry?.tags) ? idxEntry.tags : [],
395
- pinned: idxEntry?.pinned === true,
396
- note: typeof idxEntry?.note === 'string' ? idxEntry.note : '',
397
- ...(typeof idxEntry?.updatedAt === 'string' ? { updatedAt: idxEntry.updatedAt } : {}),
398
- };
399
- return rule;
400
- }
401
- // ── 侧车文件(索引 / 场景)─────────────────────────────────────────────────
402
- const defaultIndex = () => ({ version: INDEX_VERSION, rules: {}, groups: {}, scenes: {}, active: null, archives: {}, mode: { scene: null, snapshot: null } });
403
- /** 场景镜像条目归一化:只保留已知字段,非法值丢弃(容忍脏数据)。 */
404
- function parseSceneEntry(raw) {
405
- if (!raw || typeof raw !== 'object' || Array.isArray(raw))
406
- return null;
407
- const obj = raw;
408
- const out = {};
409
- if (typeof obj.label === 'string' && obj.label.trim() !== '')
410
- out.label = obj.label;
411
- if (typeof obj.description === 'string' && obj.description !== '')
412
- out.description = obj.description;
413
- if (typeof obj.prompt === 'string') {
414
- const prompt = normalizeScenePromptId(obj.prompt);
415
- if (prompt)
416
- out.prompt = prompt;
417
- }
418
- if (typeof obj.order === 'number' && Number.isFinite(obj.order))
419
- out.order = obj.order;
420
- if (typeof obj.createdAt === 'string' && obj.createdAt !== '')
421
- out.createdAt = obj.createdAt;
422
- // 场景锁定(v0.8):读盘必须原样保留 —— 这里曾只重建已知字段,锁了也会被剥成未锁。
423
- if (obj.locked === true)
424
- out.locked = true;
425
- return out;
426
- }
427
- function parseScenes(raw) {
428
- if (!raw || typeof raw !== 'object' || Array.isArray(raw))
429
- return {};
430
- const out = {};
431
- for (const [name, value] of Object.entries(raw)) {
432
- if (!isValidGroupPath(name))
433
- continue;
434
- const entry = parseSceneEntry(value);
435
- if (entry)
436
- out[name] = entry;
437
- }
438
- return out;
439
- }
440
- /** 档案切片归一化:未知形态 → 空对象(容忍脏数据,与 §9.3 同哲学)。 */
441
- function parseArchives(raw) {
442
- if (!raw || typeof raw !== 'object' || Array.isArray(raw))
443
- return {};
444
- const out = {};
445
- for (const [name, value] of Object.entries(raw)) {
446
- const archive = normalizeArchive(value);
447
- if (Object.keys(archive).length)
448
- out[name] = archive;
449
- }
450
- return out;
451
- }
452
- export function parseModeState(raw) {
453
- const obj = (raw && typeof raw === 'object' ? raw : {});
454
- const scene = typeof obj.scene === 'string' && obj.scene.trim() !== '' ? obj.scene : null;
455
- const snapshotRaw = (obj.snapshot && typeof obj.snapshot === 'object' ? obj.snapshot : {});
456
- const toFlagMap = (v) => {
457
- const out = {};
458
- if (v && typeof v === 'object' && !Array.isArray(v)) {
459
- for (const [k, b] of Object.entries(v))
460
- if (typeof b === 'boolean')
461
- out[k] = b;
462
- }
463
- return out;
464
- };
465
- // v2 快照:mcp = 停用表原文(serverName → ['*'] / 工具名);skills = 启停布尔表。
466
- const mcp = {};
467
- const mcpRaw = snapshotRaw.mcp;
468
- if (mcpRaw && typeof mcpRaw === 'object' && !Array.isArray(mcpRaw)) {
469
- for (const [server, list] of Object.entries(mcpRaw)) {
470
- if (Array.isArray(list))
471
- mcp[server] = list.map((x) => String(x));
472
- }
473
- }
474
- else if (snapshotRaw.tools && typeof snapshotRaw.tools === 'object' && !Array.isArray(snapshotRaw.tools)) {
475
- // v1 快照兼容(升级前数据):tools = 布尔启停表(key = `<server>/<tool>`,false = 停用)
476
- // → 折算成 v2 的停用名单;否则退出模式会把「快照启停」错误还原成「全部启用」。
477
- for (const [key, on] of Object.entries(snapshotRaw.tools)) {
478
- if (on !== false)
479
- continue;
480
- const i = key.indexOf('/');
481
- if (i <= 0 || i === key.length - 1)
482
- continue;
483
- const server = key.slice(0, i);
484
- const tool = key.slice(i + 1);
485
- const list = mcp[server] || (mcp[server] = []);
486
- if (list.indexOf(tool) < 0)
487
- list.push(tool);
488
- }
489
- }
490
- // v2 快照的**服务器级 / 来源级**名单、v0.8 的子智能体名单、v0.9.1 的人设全量映射
491
- // 必须原样透传 —— 这里曾把它们剥掉,结果退出模式时两张恢复名单全是空的:服务器级 MCP 与
492
- // 技能来源永远不回滚(用户报的「MCP 不复原」「技能目录不回退」),只有工具级 / 技能级能还原。
493
- const mcpServers = (Array.isArray(snapshotRaw.mcpServers) ? snapshotRaw.mcpServers : [])
494
- .filter((x) => !!x && typeof x === 'object')
495
- .map((x) => ({ id: String(x.id || ''), level: String(x.level || ''), disabled: x.disabled === true }))
496
- .filter((x) => x.id !== '' && (x.level === 'global' || x.level === 'project'));
497
- const skillSources = (Array.isArray(snapshotRaw.skillSources) ? snapshotRaw.skillSources : [])
498
- .filter((x) => !!x && typeof x === 'object')
499
- .map((x) => ({ root: String(x.root || ''), enabled: x.enabled === true }))
500
- .filter((x) => x.root !== '');
501
- const subagents = (Array.isArray(snapshotRaw.subagents) ? snapshotRaw.subagents : []).map((x) => String(x)).filter(Boolean);
502
- // 同 subagents 一组:进入时被档案**关掉**的人设(退出要重新打开)。漏掉它 = 用户实测的
503
- // 「进场景关掉了,退出却没开回来」——快照落盘后读回来就只剩「被启用」那一半名单。
504
- const subagentsOn = (Array.isArray(snapshotRaw.subagentsOn) ? snapshotRaw.subagentsOn : []).map((x) => String(x)).filter(Boolean);
505
- // v0.9.1 的人设**全量**开关映射(名字 → 进场景时是否开着):同样必须原样透传 ——
506
- // 剥掉它,退出模式就只能退回上面两个部分名单,「场景里手动开过的人设」不再被还原。
507
- const subagentsAll = toFlagMap(snapshotRaw.subagentsAll);
508
- // v0.8.1 的场景备注恢复名单:**必须原样透传**——这里漏掉它,退出模式时备注永不回退
509
- //(与历史上 mcpServers / skillSources 被剥掉是同一类 bug)。
510
- const mcpNotes = (Array.isArray(snapshotRaw.mcpNotes) ? snapshotRaw.mcpNotes : [])
511
- .filter((x) => !!x && typeof x === 'object')
512
- .map((x) => ({ id: String(x.id || ''), note: typeof x.note === 'string' ? x.note : null }))
513
- .filter((x) => x.id !== '');
514
- return {
515
- scene,
516
- snapshot: scene
517
- ? {
518
- mcp,
519
- skills: toFlagMap(snapshotRaw.skills),
520
- ...(mcpServers.length ? { mcpServers } : {}),
521
- ...(skillSources.length ? { skillSources } : {}),
522
- ...(subagents.length ? { subagents } : {}),
523
- ...(subagentsOn.length ? { subagentsOn } : {}),
524
- ...(Object.keys(subagentsAll).length ? { subagentsAll } : {}),
525
- ...(mcpNotes.length ? { mcpNotes } : {}),
526
- }
527
- : null,
528
- };
529
- }
530
- /** 解析 memories-index.json 原文;任何异常/版本不符 → 默认索引(容忍缺失,§9.3)。 */
531
- function parseIndex(raw) {
532
- const parsed = JSON.parse(raw);
533
- if (!parsed || parsed.version !== INDEX_VERSION || typeof parsed.rules !== 'object' || parsed.rules === null)
534
- throw new Error('bad index');
535
- return {
536
- version: INDEX_VERSION,
537
- rules: (parsed.rules || {}),
538
- groups: (parsed.groups || {}),
539
- scenes: parseScenes(parsed.scenes),
540
- active: normalizeActive(parsed.active),
541
- archives: parseArchives(parsed.archives),
542
- mode: parseModeState(parsed.mode),
543
- };
544
- }
545
- async function readIndex(stateDir) {
546
- try {
547
- return parseIndex(await readFile(join(stateDir, MEMORIES_INDEX_FILE), 'utf8'));
548
- }
549
- catch {
550
- return defaultIndex();
551
- }
552
- }
553
- /** 同步读索引:注入文本的渲染路径不能 await(见文件内「两相扫描」注释)。 */
554
- function readIndexSync(stateDir) {
555
- const raw = readFileIfExistsSync(join(stateDir, MEMORIES_INDEX_FILE));
556
- if (raw === null)
557
- return defaultIndex();
558
- try {
559
- return parseIndex(raw);
560
- }
561
- catch {
562
- return defaultIndex();
563
- }
564
- }
565
- async function writeIndex(stateDir, index) {
566
- await mkdir(stateDir, { recursive: true });
567
- await writeFileAtomically(join(stateDir, MEMORIES_INDEX_FILE), JSON.stringify(index, null, 2));
568
- }
569
- // ── 场景记录(索引内,`memories-index.json` 的 scenes 切片)+ 旧布局迁移 ────────
570
- /** 索引条目 → 场景记录(供 UI 直接渲染)。 */
571
- function sceneRecordOf(name, entry) {
572
- const e = entry || {};
573
- return {
574
- name,
575
- ...(name === GLOBAL_SCENE ? { label: e.label || GLOBAL_SCENE_LABEL } : (e.label ? { label: e.label } : {})),
576
- ...(e.description ? { description: e.description } : {}),
577
- ...(e.prompt ? { prompt: e.prompt } : {}),
578
- order: e.order ?? (name === GLOBAL_SCENE ? 0 : DEFAULT_GROUP_ORDER),
579
- ...(e.createdAt ? { createdAt: e.createdAt } : {}),
580
- };
581
- }
582
- /** 提示词预设 id 的口径与 agents-md 服务**同源**(用户裁定:id 什么都能写)。 */
583
- /** 校验场景要绑定的提示词预设 id;返回 `''` 表示解绑,`null` 表示非法。 */
584
- function normalizeScenePromptId(value) {
585
- const id = String(value ?? '').trim();
586
- if (id === '')
587
- return '';
588
- const result = normalizePresetId(id);
589
- return result.ok ? result.id : null;
590
- }
591
- /** 路径是否存在(不跟随符号链接;用于迁移前置判断)。 */
592
- async function pathExists(path) {
593
- try {
594
- await lstat(path);
595
- return true;
596
- }
597
- catch {
598
- return false;
599
- }
600
- }
601
- /** 把旧布局搬进 `tool-management/`(每个进程只尝试一次,幂等)。
602
- *
603
- * 旧:$DSH_HOME/scene-memory/<root>.md → memories/global/<root>.md
604
- * $DSH_HOME/scene-memory/<scene>/… → memories/<scene>/…
605
- * (场景记录由索引镜像补齐,见 ensureSceneRecords)
606
- * 更旧:$DSH_HOME/rules/…(v0.3 之前)同样按上面两条处理。
607
- *
608
- * 搬移用 `rename`(同卷零拷贝);跨卷(EXDEV)退化为 `cp` + `rm`。
609
- * 源目录**保留**(内容已搬走,留空壳不影响正确性,也方便用户核对)。
610
- * 目标已存在同名项 → 保留目标、跳过该项(绝不覆盖新数据)。
611
- */
612
- async function relocateLegacyLayout(memoriesRoot) {
613
- const home = resolveDshHome();
614
- const legacyRoots = [join(home, 'scene-memory'), join(home, 'rules')];
615
- for (const legacyRoot of legacyRoots) {
616
- if (!(await pathExists(legacyRoot)))
617
- continue;
618
- if (identity(resolve(legacyRoot)) === identity(resolve(memoriesRoot)))
619
- continue; // 自定义 roots 落在旧路径 → 不自我搬移
620
- let items;
621
- try {
622
- items = await readdir(legacyRoot, { withFileTypes: true });
623
- }
624
- catch {
625
- continue;
626
- }
627
- for (const item of items) {
628
- if (item.name.startsWith('.'))
629
- continue;
630
- const src = join(legacyRoot, item.name);
631
- // 根层 .md = 旧的「全局」桶 → 搬进 `global/`,文件名保留(否则多套一层目录)。
632
- // 其余条目(场景目录)整体搬进 memories/,目录名保留。
633
- const isRootMd = item.isFile() && item.name.toLowerCase().endsWith('.md');
634
- const destDir = isRootMd ? join(memoriesRoot, GLOBAL_SCENE) : memoriesRoot;
635
- const dest = join(destDir, item.name);
636
- try {
637
- if (await pathExists(dest))
638
- continue;
639
- await mkdir(destDir, { recursive: true });
640
- try {
641
- await rename(src, dest);
642
- }
643
- catch {
644
- // 跨设备/被占用 → 复制后删源;复制失败则保留源文件(宁可重复,不可丢失)。
645
- await cp(src, dest, { recursive: true });
646
- await rm(src, { recursive: true, force: true });
647
- }
648
- }
649
- catch {
650
- /* 单项失败不阻断其余项(如文件被外部程序锁住,下次启动再试) */
651
- }
652
- }
653
- }
654
- }
655
- /** 确保保留场景 `global` 的记录存在;旧数据里的场景名补一条记录。 */
656
- function ensureSceneRecords(index, probeScenes) {
657
- if (!index.scenes)
658
- index.scenes = {};
659
- let dirty = false;
660
- if (!index.scenes[GLOBAL_SCENE]) {
661
- index.scenes[GLOBAL_SCENE] = { label: GLOBAL_SCENE_LABEL, order: 0 };
662
- dirty = true;
663
- }
664
- // 有目录但没记录(旧布局搬进来的、或用户手工建的目录)→ 补记录,label 用目录名。
665
- for (const name of probeScenes) {
666
- if (index.scenes[name])
667
- continue;
668
- index.scenes[name] = { order: DEFAULT_GROUP_ORDER };
669
- dirty = true;
670
- }
671
- return dirty;
672
- }
673
- /** 启用场景集合归一化:非数组 → null(= 全部启用);数组 → 去重后的字符串数组。 */
674
- function normalizeActive(raw) {
675
- if (!Array.isArray(raw))
676
- return null;
677
- const out = [];
678
- const seen = new Set();
679
- for (const item of raw) {
680
- if (typeof item !== 'string')
681
- continue;
682
- const name = item.trim();
683
- if (name === '' || seen.has(name))
684
- continue;
685
- seen.add(name);
686
- out.push(name);
687
- }
688
- return out;
689
- }
690
- /**
691
- * 活动场景解析(用户裁定 2026-09-15:**除「全局」外同时只能启用一个场景**):
692
- * - `index.active` 为数组(新写入的唯一形态)→ 至多一个非保留场景启用;
693
- * - `index.active` 缺失 / null → 历史默认("全部场景启用");**不再产生**新值,
694
- * 新建场景时会被收敛成显式数组(见 `collapseActiveForNewScene`),
695
- * 因此"新建即启用"不会发生;存量数据仍按老语义读,避免升级后注入范围突变。
696
- * - `_shared` 恒常启用(公共基线),不受开关影响
697
- * - 保留场景 `global` 恒常启用:它的记忆对任何对话都成立
698
- * 缺失 memories-index.json 一律按默认值运行,不抛错(§9.3)。
699
- * 场景生效与否只由 index.active 决定(场景档案/模式也写这一份)。
700
- */
701
- function resolveActiveScenes(index, knownScenes) {
702
- const stored = normalizeActive(index.active);
703
- const mode = stored === null ? 'all' : 'custom';
704
- const active = new Set(stored === null ? knownScenes : stored);
705
- active.add(SHARED_GROUP);
706
- active.add(GLOBAL_SCENE);
707
- return { active, mode };
708
- }
709
- /** 当前启用的**非保留**场景(单选模型下至多一个;多个时按场景顺序取第一个)。 */
710
- function enabledSceneOf(index) {
711
- const names = Object.keys(index.scenes || {});
712
- const { active } = resolveActiveScenes(index, names);
713
- const enabled = names
714
- .filter((n) => n !== SHARED_GROUP && n !== GLOBAL_SCENE && active.has(n))
715
- .sort((a, b) => sceneOrderOf(index, a) - sceneOrderOf(index, b) || a.localeCompare(b));
716
- return enabled[0] ?? null;
717
- }
718
- /**
719
- * 新建场景前把历史默认(`active = null` = 全部启用)收敛成显式数组,保证
720
- * **新场景默认不启动**、且收敛后仍满足"至多一个非保留场景启用":
721
- * - 已存在其它非保留场景 → 取顺序第一个作为启用场景(其余收敛掉,返回 `collapsed: true`)
722
- * - 不存在 → 空数组(什么都不启用)
723
- * @returns 是否发生了收敛(供 UI 如实提示)。
724
- */
725
- function collapseActiveForNewScene(index) {
726
- if (normalizeActive(index.active) !== null)
727
- return false;
728
- const names = Object.keys(index.scenes || {}).filter((n) => n !== SHARED_GROUP && n !== GLOBAL_SCENE);
729
- const first = names.sort((a, b) => sceneOrderOf(index, a) - sceneOrderOf(index, b) || a.localeCompare(b))[0];
730
- index.active = first === undefined ? [] : [first];
731
- return true;
732
- }
733
- /** 场景名 = 分组路径的第一段(`web/frontend` 属于场景 `web`)。 */
734
- function sceneOf(group) {
735
- const idx = String(group || '').indexOf('/');
736
- return idx >= 0 ? group.slice(0, idx) : group;
737
- }
738
- // ── 快照(发现 + 索引合并 + 索引清理)──────────────────────────────────────
739
- async function buildSnapshot(rulesRoot, stateDir) {
740
- const discovery = await discover(rulesRoot);
741
- let index = await readIndex(stateDir);
742
- // 索引有记录但文件已删 → 清理记录(磁盘与索引保持一致)。
743
- let dirty = false;
744
- for (const id of Object.keys(index.rules)) {
745
- if (!discovery.entries.has(id)) {
746
- delete index.rules[id];
747
- dirty = true;
748
- }
749
- }
750
- for (const group of Object.keys(index.groups)) {
751
- if (!discovery.groups.has(group)) {
752
- delete index.groups[group];
753
- dirty = true;
754
- }
755
- }
756
- // 场景记录:保留场景 global 恒存在;有目录没记录的补一条。
757
- // 注意**不反向清理**——删掉 memories/<场景>/ 目录不应删掉场景记录,
758
- // 否则“先建场景、后加记忆”的用法会在加记忆前把场景弄丢。
759
- if (ensureSceneRecords(index, discovery.scenes))
760
- dirty = true;
761
- if (dirty)
762
- await writeIndex(stateDir, index);
763
- const rules = [];
764
- const bodies = new Map();
765
- const entries = new Map();
766
- for (const entry of discovery.entries.values()) {
767
- try {
768
- const text = await readFile(entry.docPath, 'utf8');
769
- const doc = parseSkillDoc(text);
770
- const derived = deriveFromDoc(entry, doc);
771
- rules.push(projectRule(entry, derived, index.rules[entry.id]));
772
- bodies.set(entry.id, doc.body);
773
- entries.set(entry.id, entry);
774
- }
775
- catch {
776
- /* 文件在扫描与读取间被删/损坏:跳过该规则 */
777
- }
778
- }
779
- for (const entry of discovery.shadowed) {
780
- try {
781
- const text = await readFile(entry.docPath, 'utf8');
782
- const doc = parseSkillDoc(text);
783
- const derived = deriveFromDoc(entry, doc);
784
- rules.push({ ...projectRule(entry, derived, undefined), shadowed: true });
785
- entries.set(entry.id, entry);
786
- }
787
- catch {
788
- /* 同上 */
789
- }
790
- }
791
- const groups = [...discovery.groups]
792
- .map((name) => {
793
- const gi = index.groups[name] || {};
794
- return {
795
- name,
796
- label: typeof gi.label === 'string' && gi.label !== '' ? gi.label : name,
797
- order: gi.order ?? DEFAULT_GROUP_ORDER,
798
- count: rules.filter((r) => r.group === name && !r.shadowed).length,
799
- };
800
- })
801
- .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
802
- return { rules, groups, scenes: discovery.scenes, warnings: discovery.warnings, truncated: discovery.truncated, entries, bodies };
803
- }
804
- // ── 场景记忆段:两相扫描(廉价指纹 → 按需读正文)──────────────────────────
805
- //
806
- // 硬约束 1(§5.1):注入通道每个 step 都要求**同步**取一次文本(`text: () => string`,
807
- // 见 src/context-inject.ts),返回 Promise 会让这一步的注入直接失败(异常被吞)。
808
- //
809
- // 缓存策略(§5.4 S3):文本出口必须同步返回 string,但不希望每个模型步骤都
810
- // 读一遍所有记忆正文。因此拆成两相:
811
- // ① `probeSceneFilesSync()`:只走目录树 + `statSync`(**不读正文**),产出候选文件
812
- // 与**指纹**(`id|mtime|size` + 影响渲染的索引字段)。
813
- // ② 指纹变化时才 `renderSceneMemory()`:读正文、派生、排序、拼接。
814
- //
815
- // 为什么不用「内存缓存 + 目录监听(方案 a)」:fs.watch 在 Windows 上不可靠(本仓库
816
- // 技能侧不得不把 watcher 放进 worker 线程才不被卡死),而 watcher 静默失效的后果是
817
- // **永久返回过期内容**——正是本次变更单要根治的"静默失效"形态。指纹探测每次装配只做
818
- // 一次 stat 遍历(个人记忆树是亚毫秒级),换来"永远最新且永不静默失效",优先于"零 IO"。
819
- //
820
- // 与 discover() 的关系:discover 是异步全量快照(供 CRUD/列表/体检),这里是同步轻量
821
- // 扫描(供注入通道),两者对"什么是规则"的定义保持一致:
822
- // 目录含 `<目录名>.md`(或旧版 `SKILL.md`)→ bundle 规则(叶子);否则继续下钻;只认 .md;跳过隐藏项;
823
- // 同名 flat 与 bundle 冲突时 bundle 优先。
824
- function readFileIfExistsSync(path) {
825
- try {
826
- return readFileSync(path, 'utf8');
827
- }
828
- catch {
829
- return null;
830
- }
831
- }
832
- function fileStampSync(path) {
833
- try {
834
- const st = statSync(path);
835
- return `${st.mtimeMs}:${st.size}`;
836
- }
837
- catch {
838
- return 'missing';
839
- }
840
- }
841
- /**
842
- * 第一相:走目录树 + stat,不读正文。
843
- * `<场景>/...` → 场景记忆(一级目录名即场景名,含保留场景 `global`);
844
- * 是否生效由 index.active 决定,`global` 恒定生效(见 renderSceneMemory)。
845
- * 根层的裸 .md **不再是记忆**(旧的「全局」桶已迁入 `global/`,见 relocateLegacyLayout),
846
- * 因此这里不再扫描根层文件——把文件丢在 memories/ 根下不会静默生效,也不会被投影。
847
- * `signature` 不变 ⇒ 上次渲染结果可原样复用(零正文 IO、零重排)。
848
- */
849
- function probeSceneFilesSync(rulesRoot, index) {
850
- const byId = new Map();
851
- const scenes = [];
852
- let truncated = false;
853
- const budget = { dirs: 0, items: 0 };
854
- let rootEntries;
855
- try {
856
- rootEntries = readdirSync(rulesRoot, { withFileTypes: true });
857
- }
858
- catch {
859
- // 目录不存在/不可读 → 空段(不报错)。签名含固定前缀,便于与"空树"区分。
860
- return { refs: [], scenes: [], truncated: false, signature: `∅|${signatureOfIndex(index)}` };
861
- }
862
- const add = (ref) => {
863
- const existing = byId.get(ref.id);
864
- // bundle 优先于 flat(与 discover 的同名遮蔽规则一致)。
865
- if (!existing || (ref.kind === 'bundle' && existing.kind === 'flat'))
866
- byId.set(ref.id, ref);
867
- };
868
- /** 收集单个目录树内的记忆(scene = 场景名)。 */
869
- const walk = (scene, dir, rel, depth) => {
870
- if (depth > MAX_SOURCE_DEPTH || budget.dirs >= MAX_DIRECTORIES || budget.items >= MAX_ENTRIES) {
871
- truncated = true;
872
- return;
873
- }
874
- let entries;
875
- try {
876
- entries = readdirSync(dir, { withFileTypes: true });
877
- }
878
- catch {
879
- return;
880
- }
881
- budget.dirs++;
882
- entries.sort((a, b) => a.name.localeCompare(b.name));
883
- for (const item of entries) {
884
- if (budget.items >= MAX_ENTRIES) {
885
- truncated = true;
886
- return;
887
- }
888
- if (item.name.startsWith('.'))
889
- continue; // 隐藏项跳过
890
- const child = join(dir, item.name);
891
- const relChild = rel ? `${rel}/${item.name}` : item.name;
892
- if (item.isSymbolicLink())
893
- continue; // 同步扫描不跟随符号链接(防环)
894
- if (item.isDirectory()) {
895
- budget.items++;
896
- let docPath = '';
897
- for (const candidate of [bundleDocName(item.name), LEGACY_BUNDLE_DOC]) {
898
- const p = join(child, candidate);
899
- if (fileStampSync(p) !== 'missing') {
900
- docPath = p;
901
- break;
902
- }
903
- }
904
- // 指纹带上**目录本身**的 mtime:附件只在段里以「路径 + 文件名」出现,
905
- // 增删附件不改正文文件,只靠它的话指纹不变、段不会重算,列表就会停在旧值。
906
- if (docPath !== '') {
907
- const stamp = `${fileStampSync(docPath)}:${fileStampSync(child)}`;
908
- // bundle 规则(叶子):父路径为场景,目录名为记忆名。
909
- const id = scene ? `${scene}/${relChild}` : relChild;
910
- if (index.rules[id]?.enabled === false)
911
- continue; // 单条停用 → 不进入段
912
- add({ id, scene, name: item.name, kind: 'bundle', path: docPath, order: index.rules[id]?.order ?? DEFAULT_ORDER, stamp });
913
- continue;
914
- }
915
- walk(scene, child, relChild, depth + 1);
916
- continue;
917
- }
918
- if (!item.isFile())
919
- continue;
920
- if (!item.name.toLowerCase().endsWith('.md'))
921
- continue; // 只认 .md
922
- budget.items++;
923
- const id = scene ? `${scene}/${relChild.slice(0, -3)}` : relChild.slice(0, -3);
924
- if (index.rules[id]?.enabled === false)
925
- continue;
926
- add({
927
- id,
928
- scene,
929
- name: item.name.slice(0, -3),
930
- kind: 'flat',
931
- path: child,
932
- order: index.rules[id]?.order ?? DEFAULT_ORDER,
933
- stamp: fileStampSync(child),
934
- });
935
- }
936
- };
937
- // 一级目录 = 场景(根层的裸 .md 不再是记忆,故此处不处理文件)。
938
- for (const item of [...rootEntries].sort((a, b) => a.name.localeCompare(b.name))) {
939
- if (item.name.startsWith('.'))
940
- continue;
941
- if (item.isSymbolicLink())
942
- continue;
943
- if (!item.isDirectory())
944
- continue;
945
- if (!isValidGroupSegment(item.name))
946
- continue; // 非法目录名 → 不作为场景
947
- scenes.push(item.name);
948
- }
949
- // ② 场景目录树
950
- for (const scene of scenes)
951
- walk(scene, join(rulesRoot, scene), '', 1);
952
- const refs = [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
953
- const signature = [
954
- signatureOfIndex(index),
955
- truncated ? 'T' : '-',
956
- scenes.join('\u0001'),
957
- ...refs.map((r) => `${r.id}\u0000${r.kind}\u0000${r.order}\u0000${r.stamp}`),
958
- ].join('\u0002');
959
- return { refs, scenes, truncated, signature };
960
- }
961
- /** 指纹里必须包含一切影响渲染的索引字段(active / enabled / order / groups.order / scenes.order / label / description)。 */
962
- function signatureOfIndex(index) {
963
- const active = normalizeActive(index.active);
964
- const rules = Object.keys(index.rules).sort().map((id) => {
965
- const e = index.rules[id];
966
- return `${id}\u0000${e.enabled === false ? '0' : '1'}\u0000${e.order ?? DEFAULT_ORDER}`;
967
- });
968
- const groups = Object.keys(index.groups).sort().map((g) => `${g}\u0000${index.groups[g]?.order ?? DEFAULT_GROUP_ORDER}`);
969
- // 场景顺序决定段内场景的先后 → 必须进指纹,否则改顺序后段文本不会重算。
970
- // label 与 description 同理(sceneHeader 的「场景说明」一行直接渲染 description)——
971
- // 手改索引文件(带外变更)时只有指纹变化才会触发重算。
972
- const scenes = Object.keys(index.scenes || {}).sort().map((s) => {
973
- const e = index.scenes[s];
974
- return `${s}\u0000${e.order ?? (s === GLOBAL_SCENE ? 0 : DEFAULT_GROUP_ORDER)}\u0000${e.label ?? ''}\u0000${e.description ?? ''}`;
975
- });
976
- return `A:${active === null ? '*' : active.join(',')}|R:${rules.join(';')}|G:${groups.join(';')}|S:${scenes.join(';')}`;
977
- }
978
- /**
979
- * 第二相:读正文 + 派生 + 确定性拼接。仅在指纹变化时调用。
980
- */
981
- function renderSceneMemory(probe, index, maxBytes) {
982
- const files = [];
983
- for (const ref of probe.refs) {
984
- const text = readFileIfExistsSync(ref.path);
985
- if (text === null)
986
- continue; // 探测与读取之间被删:跳过(下次指纹变化会再校正)
987
- const doc = parseSkillDoc(text);
988
- const derived = deriveFromDoc({ id: ref.id, group: ref.scene, name: ref.name, kind: ref.kind, docPath: ref.path, entryPath: ref.path }, doc);
989
- files.push({
990
- id: ref.id,
991
- scene: ref.scene,
992
- name: derived.name,
993
- description: derived.description,
994
- descriptionDerived: derived.descriptionDerived,
995
- order: ref.order,
996
- body: doc.body,
997
- kind: ref.kind,
998
- // bundle 的正文是 `<目录>/<名>.md`,附件是**同目录**的其余文件。
999
- bundleDir: ref.kind === 'bundle' ? dirname(ref.path) : '',
1000
- });
1001
- }
1002
- const { active } = resolveActiveScenes(index, probe.scenes);
1003
- const buckets = new Map();
1004
- for (const file of files) {
1005
- // 保留场景 global 恒定生效(「全局」= 任何对话都注入);其余由 index.active 决定。
1006
- if (file.scene !== GLOBAL_SCENE && !active.has(file.scene))
1007
- continue;
1008
- // ⚠️ 这里**不再**看场景档案的 memories 段:记忆的开关是**单一真相源** `rules[*].enabled`
1009
- // (见下方 enabled 判定)。档案弹窗里的记忆勾选就是同一个值,所以两边天然一致,
1010
- // 不存在「记忆页开了、档案页还显示未开」的两套状态。
1011
- const list = buckets.get(file.scene);
1012
- if (list)
1013
- list.push(file);
1014
- else
1015
- buckets.set(file.scene, [file]);
1016
- }
1017
- // ── 候选块(确定性顺序:场景 → 场景内 order/名称)────────────────────────
1018
- const candidates = [];
1019
- let seq = 0;
1020
- for (const scene of [...buckets.keys()].sort((a, b) => compareSceneBuckets(a, b, index))) {
1021
- const sceneFiles = (buckets.get(scene) || []).slice().sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
1022
- const header = sceneHeader(scene, index);
1023
- for (const file of sceneFiles) {
1024
- const { text: block, inline } = memoryBlock(file);
1025
- candidates.push({
1026
- seq: seq++,
1027
- scene,
1028
- header,
1029
- block,
1030
- inline,
1031
- item: { id: file.id, scene: file.scene, name: file.name, bytes: byteLen(block) },
1032
- });
1033
- }
1034
- }
1035
- if (candidates.length === 0) {
1036
- return { text: '', bytes: 0, truncated: probe.truncated, maxBytes, scenes: [], items: [], dropped: [] };
1037
- }
1038
- /**
1039
- * 选中块 → 段正文(同一场景的 `## 场景:x` 只在首次出现时发出)。
1040
- * `dropped` 决定引导语用哪一版:真有条目没注入时不再声称「以下就是全部信息」。
1041
- */
1042
- const renderBody = (selected, dropped) => {
1043
- if (selected.length === 0)
1044
- return '';
1045
- const note = dropped ? SCENE_MEMORY_NOTE_PARTIAL : SCENE_MEMORY_NOTE;
1046
- const chunks = [];
1047
- let current = null;
1048
- let buf = '';
1049
- // 上一条是不是「单行条目」。连续两条单行条目紧挨着(列表的自然形态),
1050
- // 其余情况都要空一行 —— 多行正文(含其缩进挂载的附件行)与下一条之间没有空行的话,
1051
- // 读起来会连成一片、看不出条目边界。
1052
- let prevInline = false;
1053
- for (const c of selected) {
1054
- if (c.scene !== current) {
1055
- if (buf !== '')
1056
- chunks.push(buf);
1057
- current = c.scene;
1058
- // 引导语跟着**场景块**走(场景说明之后、条目之前):它管的就是下面这些条目,
1059
- // 放最顶层会飘在场景之外(用户实测反馈「怎么跑到最顶层了」)。场景数有上限
1060
- // (`global`/`_shared` 恒常启用 + 至多一个启用场景),最多出现 3 次,代价可接受。
1061
- buf = `${c.header}${note}\n\n`;
1062
- prevInline = false;
1063
- }
1064
- const inline = c.inline;
1065
- if (!(inline && prevInline) && !buf.endsWith('\n\n'))
1066
- buf += '\n';
1067
- prevInline = inline;
1068
- buf += c.block;
1069
- }
1070
- if (buf !== '')
1071
- chunks.push(buf);
1072
- return chunks.join('\n');
1073
- };
1074
- const sceneLabelOf = (scene) => sceneLabel(scene);
1075
- /** 未注入清单 + 截断标记,在 `space` 字节内尽量列全(放不下的折叠为一行计数)。 */
1076
- const renderTail = (missed, space, wasTruncated) => {
1077
- if (!wasTruncated)
1078
- return ''; // 没截断就不该出现标记
1079
- const marker = `\n${TRUNCATION_MARKER}\n`;
1080
- if (missed.length === 0)
1081
- return byteLen(marker) <= space ? marker : '';
1082
- const head = `\n${DROPPED_HEADING}\n\n`;
1083
- const lines = [];
1084
- let used = byteLen(head) + byteLen(marker);
1085
- for (const c of missed) {
1086
- const line = `- ${sceneLabelOf(c.scene)}/${c.item.name}(${c.item.bytes} B)\n`;
1087
- if (used + byteLen(line) > space)
1088
- break;
1089
- lines.push(line);
1090
- used += byteLen(line);
1091
- }
1092
- if (lines.length === 0)
1093
- return byteLen(marker) <= space ? marker : '';
1094
- const rest = missed.length - lines.length;
1095
- const fold = `- …(其余 ${rest} 条未列出)\n`;
1096
- const tail = `${head}${lines.join('')}${rest > 0 && used + byteLen(fold) <= space ? fold : ''}${marker}`;
1097
- return tail;
1098
- };
1099
- // ── ① 贪心:放得下就放,越界**跳过**(而不是整体停止)──────────────────
1100
- // 原来一旦某块越界就 stopped,于是一条超长记忆会把它后面的所有小记忆一起饿死。
1101
- const markerReserve = byteLen(`\n${TRUNCATION_MARKER}\n`);
1102
- const selected = [];
1103
- const missed = [];
1104
- const takenScenes = new Set();
1105
- // 场景标题与引导语也是开销,按「每个首次出现的场景」计进预算 —— 否则它们会挤掉
1106
- // 本该放得下的记忆(引导语的字节见 SCENE_NOTE_BYTES)。
1107
- let used = 0;
1108
- const noteBytes = sceneNoteBytes();
1109
- for (const c of candidates) {
1110
- const headerCost = takenScenes.has(c.scene) ? 0 : byteLen(c.header) + noteBytes;
1111
- if (used + headerCost + c.item.bytes + markerReserve > maxBytes) {
1112
- missed.push(c);
1113
- continue;
1114
- }
1115
- selected.push(c);
1116
- takenScenes.add(c.scene);
1117
- used += headerCost + c.item.bytes;
1118
- }
1119
- missed.sort((a, b) => a.seq - b.seq);
1120
- // ── ② 尾注自身也占字节:放不下就把已入选的块从后往前退回,直到回到预算内 ──
1121
- let body = renderBody(selected, probe.truncated || missed.length > 0);
1122
- let tail = renderTail(missed, maxBytes - byteLen(body), probe.truncated || missed.length > 0);
1123
- while (byteLen(body) + byteLen(tail) > maxBytes && selected.length > 0) {
1124
- missed.push(selected.pop());
1125
- missed.sort((a, b) => a.seq - b.seq);
1126
- body = renderBody(selected, true);
1127
- tail = renderTail(missed, maxBytes - byteLen(body), true);
1128
- }
1129
- let text = `${body}${tail}`.replace(/^\n+/, '');
1130
- // ③ 兜底:预算小到连标记都放不下时也宁可超出几个字节——"有记忆没注入"这件事
1131
- // 绝不能静默消失(原来单条超预算会让整段变成空串,模型端完全无痕)。
1132
- if (text === '' && (missed.length > 0 || probe.truncated))
1133
- text = `${TRUNCATION_MARKER}\n`;
1134
- const scenes = [];
1135
- for (const c of selected)
1136
- if (!scenes.includes(c.scene))
1137
- scenes.push(c.scene);
1138
- return {
1139
- text,
1140
- bytes: byteLen(text),
1141
- truncated: probe.truncated || missed.length > 0,
1142
- maxBytes,
1143
- scenes,
1144
- items: selected.map((c) => c.item),
1145
- dropped: missed.map((c) => c.item),
1146
- };
1147
- }
1148
- /** 场景渲染顺序:全局 `global` 最先(它的记忆对任何对话都成立,先讲总则),
1149
- * 其次 `_shared`(历史保留名),其余按(索引 scenes.order, 场景名)。 */
1150
- function compareSceneBuckets(a, b, index) {
1151
- if (a === b)
1152
- return 0;
1153
- if (a === GLOBAL_SCENE)
1154
- return -1;
1155
- if (b === GLOBAL_SCENE)
1156
- return 1;
1157
- if (a === SHARED_GROUP)
1158
- return -1;
1159
- if (b === SHARED_GROUP)
1160
- return 1;
1161
- const ao = sceneOrderOf(index, a);
1162
- const bo = sceneOrderOf(index, b);
1163
- return ao - bo || a.localeCompare(b);
1164
- }
1165
- /** 场景排序键:索引 scenes.order 优先,回退到旧 groups.order,再回退默认值。 */
1166
- function sceneOrderOf(index, scene) {
1167
- if (scene === GLOBAL_SCENE)
1168
- return 0;
1169
- const s = index.scenes?.[scene]?.order;
1170
- if (typeof s === 'number' && Number.isFinite(s))
1171
- return s;
1172
- return index.groups[scene]?.order ?? DEFAULT_GROUP_ORDER;
1173
- }
1174
- /** 场景显示名:`global` → 「全局」(磁盘名保持 ASCII),其余用索引 label 或场景名。 */
1175
- function sceneLabel(scene, index) {
1176
- if (scene === GLOBAL_SCENE)
1177
- return index?.scenes?.[GLOBAL_SCENE]?.label || GLOBAL_SCENE_LABEL;
1178
- const label = index?.scenes?.[scene]?.label;
1179
- return label && label !== '' ? label : scene;
1180
- }
1181
- /** 单个场景的标题:**场景在最顶层**(`##`,与「子智能体」「MCP 服务器」等段同级)。 */
1182
- const sceneHeading = (scene) => `## 场景:${sceneLabel(scene)}`;
1183
- /**
1184
- * 没填描述时的默认「场景说明」(用户裁定 2026-09-16:全局桶一直没有描述,读起来像缺了一块,
1185
- * 统一成"每个场景块都有场景说明")。
1186
- *
1187
- * 默认句同时承担"这个场景是什么"的答疑(此前只有光秃秃的 `## 场景:X`,模型读不懂 —— 用户实测):
1188
- * 两个恒常桶说明生效范围,用户场景说明它是当前启用的那份配置。
1189
- */
1190
- const defaultSceneDescription = (scene) => (scene === GLOBAL_SCENE ? '全局记忆,任何对话都生效'
1191
- : scene === SHARED_GROUP ? '共享记忆,任何对话都生效'
1192
- : '用户配置的上下文,当前启用');
1193
- /**
1194
- * 单个场景的段头:场景标题 + **恒有**的 `场景说明:<描述>`。
1195
- *
1196
- * 场景描述(界面「描述(可选)」,≤60 字符)**此前从未注入过** —— 它正是「这个场景是
1197
- * 干什么的」的答案,属于模型做判断需要的上下文,而不是只给人看的元数据;界面上的文案
1198
- * 也从没把它标成「只给使用者看」(对比 AGENTS.md 预设的描述,那里是明确标注的)。
1199
- * 描述为空时给 `defaultSceneDescription` 的默认句(用户裁定:全局桶没描述时读起来像
1200
- * 缺了一块,统一成每个场景块都有说明)。
1201
- */
1202
- function sceneHeader(scene, index) {
1203
- const head = sceneHeading(scene);
1204
- const described = String(index?.scenes?.[scene]?.description ?? '').replaceAll(/\s+/g, ' ').trim();
1205
- const description = described === '' ? defaultSceneDescription(scene) : described;
1206
- // 加粗(用户裁定 2026-09-16):与引导语同款强调,别让"场景说明"读起来像可忽略的普通正文。
1207
- return `${head}\n\n**场景说明:${description}**\n\n`;
1208
- }
1209
- /**
1210
- * 段首的引导语:让模型知道下面是**用户为本机写的参考信息**,并且**以它为准**
1211
- * —— 涉及本机的事一律照它办,确实无关时才放下。
1212
- *
1213
- * 写法(2026-09-16 用户裁定,基于真实注入结果的三次修正):
1214
- * - **单行、加粗**,不再用括号分两行 —— 括号跨行在真实提示词里读起来像被截断,
1215
- * 而加粗是 Markdown 里最省字符的强调手段(用户要求「加强模型对此的重视程度」)。
1216
- * - **提到段首、整段只出现一次**:原来它挂在每个场景的段头里,多场景时会重复注入。
1217
- * - **不点名任何工具**:模型从工具 schema 就知道 `memory_manager_list` 存在,点名反而
1218
- * 像在提示它去调;用户裁定「没启用的信息就是不想在当前用」,所以工具指引整句删除。
1219
- * 真正防探测的是**完整性声明**(「以下就是全部信息」),那半句必须留。
1220
- * - **完整性声明按截断状态自适应**:真有条目因预算没注入时,段尾会有未注入清单,
1221
- * 此时不能再声称「全部」,否则和清单自相矛盾 —— 也正因为那时确实有东西没给到,
1222
- * 模型去查工具是**合理**的,不该再拦。
1223
- *
1224
- * 用词:不用「常驻」「注入」这类内部行话(模型没有先验);用户裁定用「信息」而不是
1225
- * 「记忆」——「记忆」在系统提示词里指代不明,而这段的实质就是用户写的参考信息。
1226
- *
1227
- * 2026-09-17 重写(用户:「当前模式会不重视这些提示词」)。上一版的三个毛病都在**授权**
1228
- * 上,而不在措辞好不好看上:
1229
- * 1. 「与当前任务相关时直接采用」——**没有给"相关"的判据**。最省力的解读永远是"无关",
1230
- * 因为判成无关不需要任何工作;
1231
- * 2. 通篇没有优先级规则。与本机实际情况冲突时,模型会默默按自己的默认假设走,
1232
- * 而用户完全不知道发生了什么;
1233
- * 3. 「无关时忽略」——「忽略」是这句话里**最后一个动词**,也是记得最牢的那个。它把
1234
- * 一个免打扰出口写成了对内容的态度许可。
1235
- * 现在:给出**判据**(凡涉及本机路径 / 配置 / 工具 / 习惯)、给出**裁决规则**、把出口降级为
1236
- * 「不必提及」(关于**要不要声明**,不是关于**要不要采用**)。出口保留是必要的 —— 去掉它
1237
- * 会让模型对无关条目强行攀附,那是另一种失真。
1238
- *
1239
- * 2026-09-17 第二版:**按条目类型分级授权**(用户采纳的四条里的第 1、4 条)。Claude Code
1240
- * 把两类内容分进两个系统、用**相反**的授权:用户指令(`claudemd.ts:89`)是
1241
- * "These instructions OVERRIDE any default behavior and you MUST follow them exactly as
1242
- * written",而记忆(`memdir/memoryTypes.ts:202`)是
1243
- * "If a recalled memory conflicts with current information, trust what you observe now"
1244
- * —— 书里(ch11:25)说记忆是 "working notes, not gospel"。上一版把两类塞进一句授权,
1245
- * 对**场景说明**(用户写的约定)是对的,对**记忆条目**(可能是几个月前记下的事实)是错的。
1246
- * 好在这两类在渲染时就分处不同位置:场景说明在 `sceneHeader` 的 `**场景说明:…**` 里,
1247
- * 记忆条目在 `memoryBlock` 里 —— 所以一句话就能分级,不必改数据结构。
1248
- *
1249
- * 冲突阶梯(第 4 条)来自 Codex `base_instructions/default.md:22-27` 与 Claude Code 的
1250
- * `caller override > agent definition > parent model > default`:把"谁高于谁"写明,
1251
- * 模型才不会在「用户当场说的 ≠ 本机记录」时悬空。写明它还有一个反直觉的好处 ——
1252
- * 它让授权更可信:这说明本条不是要让记忆压过用户,只是要压过模型的默认假设。
1253
- */
1254
- const SCENE_MEMORY_NOTE = '**用户为本机写的参考信息:「场景说明」是用户的约定,一律照办,覆盖你的默认做法;其余条目是记录,可能已过期 —— 与当前实际情况冲突时以你看到的为准,与用户当场说的冲突时以用户为准。无关时不必提及。以下就是全部信息。**';
1255
- /** 有未注入条目时的版本:去掉完整性声明(见上)。 */
1256
- const SCENE_MEMORY_NOTE_PARTIAL = '**用户为本机写的参考信息:「场景说明」是用户的约定,一律照办,覆盖你的默认做法;其余条目是记录,可能已过期 —— 与当前实际情况冲突时以你看到的为准,与用户当场说的冲突时以用户为准。无关时不必提及。**';
1257
- /**
1258
- * 段首固定块。以 `\n` 结尾,与场景块 join 后自然空一行。
1259
- * 预算按**较长**的那版算(见 renderSceneMemory),保守一点只会浪费几个字节。
1260
- */
1261
- /**
1262
- * 引导语所占的字节(含它后面的一个空行)。跟着场景块走,所以按**每个场景**计入段头开销;
1263
- * 用较长的那版(带完整性声明)算,保守一点只会浪费几个字节。
1264
- *
1265
- * 注意:模块级不能直接算 —— `byteLen` 是后面才声明的 const,模块初始化期取它会 TDZ 报错。
1266
- */
1267
- const sceneNoteBytes = () => byteLen(`${SCENE_MEMORY_NOTE}\n\n`);
1268
- /** 单行正文的最大长度:超过就退回「标题 + 正文块」,避免出现一条几千字符的列表行。 */
1269
- const INLINE_BODY_MAX = 120;
1270
- /** 附件行里最多列几个文件名;多的只报总数(路径已经给了,缺的名字模型自己列目录即可)。 */
1271
- const ATTACHMENT_LIST_MAX = 10;
1272
- /**
1273
- * bundle 记忆的附件名(**同步**版 —— 段渲染必须同步返回,`listAttachments` 是异步的)。
1274
- * 口径与异步版一致:跳过正文本体(`<名>.md`,旧数据可能是 `SKILL.md`),只认普通文件。
1275
- */
1276
- function attachmentNamesSync(bundleDir, name) {
1277
- try {
1278
- const docNames = new Set([bundleDocName(name), LEGACY_BUNDLE_DOC]);
1279
- return readdirSync(bundleDir, { withFileTypes: true })
1280
- .filter((e) => e.isFile() && !docNames.has(e.name))
1281
- .map((e) => e.name)
1282
- .sort((a, b) => a.localeCompare(b));
1283
- }
1284
- catch {
1285
- return [];
1286
- }
1287
- }
1288
- /**
1289
- * bundle 记忆的附件**摘要**(列表页用):数量 / 总体积 / 前几个文件名。
1290
- *
1291
- * 口径与 `attachmentNamesSync`(也就是注入给模型的那份清单)逐字一致:跳过正文本体,
1292
- * 只认普通文件 —— 所以页面上的数字与模型实际看到的一致,不会出现「界面说 3 个、模型只见 2 个」。
1293
- * `names` 截到 ATTACHMENT_LIST_MAX:tooltip 列不下更多,要全看到编辑弹窗里去看。
1294
- *
1295
- * 目录读不到(索引残留了已消失的条目)→ 返回 null,让界面**什么都不显示**,
1296
- * 而不是谎报「0 个附件」。
1297
- */
1298
- function attachmentSummarySync(bundleDir, name) {
1299
- let names;
1300
- try {
1301
- const docNames = new Set([bundleDocName(name), LEGACY_BUNDLE_DOC]);
1302
- names = readdirSync(bundleDir, { withFileTypes: true })
1303
- .filter((e) => e.isFile() && !docNames.has(e.name))
1304
- .map((e) => e.name)
1305
- .sort((a, b) => a.localeCompare(b));
1306
- }
1307
- catch {
1308
- return null;
1309
- }
1310
- let bytes = 0;
1311
- for (const entry of names) {
1312
- try {
1313
- bytes += statSync(join(bundleDir, entry)).size;
1314
- }
1315
- catch { /* 读不到的条目不计体积 */ }
1316
- }
1317
- return { count: names.length, bytes, names: names.slice(0, ATTACHMENT_LIST_MAX) };
1318
- }
1319
- /**
1320
- * 附件行(只有 bundle 记忆才有):**给目录与文件名,不给内容**。
1321
- * 附件可能是图片、二进制、大 md —— 全文注入又贵又会把段预算吃光;给路径,模型需要时自己读。
1322
- */
1323
- function attachmentLine(file) {
1324
- if (file.kind !== 'bundle')
1325
- return '';
1326
- const names = attachmentNamesSync(file.bundleDir, file.name);
1327
- if (names.length === 0)
1328
- return '';
1329
- const shown = names.slice(0, ATTACHMENT_LIST_MAX);
1330
- const more = names.length - shown.length;
1331
- return `附件目录:${file.bundleDir}(未注入正文,共 ${names.length} 个:${shown.join('、')}${more > 0 ? `,另 ${more} 个` : ''})`;
1332
- }
1333
- /**
1334
- * 单条信息的渲染形态 —— **能一行就一行,但恒为列表项**。
1335
- *
1336
- * 单行且不长的正文 → `- **名称**(描述) — 正文`(与 MCP / 子智能体两个段的列表同形;无显式描述时括号不出现)
1337
- * 多行或过长的正文 → `- **名称**(描述)` + 空行 + 缩进 2 格的正文(挂在条目下)
1338
- *
1339
- * 名称恒为标题:它就是这条记忆的身份(工具 id `<场景>/<名称>`、bundle 目录/文件名都以它为准),
1340
- * 用户说「记忆里的 X」、模型再调 `memory_manager_*` 时都对得上号;显式描述是括号注解,不抢标题。
1341
- *
1342
- * 为什么全都做成列表项:早先多行正文走 `### 名称` 标题块,附件行只能退化成与记忆**同级**的
1343
- * `- 附件目录:…`(没有父列表项可挂)—— 既可能被读成一条独立记忆,某些渲染器里还会把下一个
1344
- * `###` 标题吞进列表(与上一条粘连)。统一成「一条记忆 = 一个列表项、正文与附件都缩进挂在
1345
- * 条目下」后,两种记忆外观完全一致,归属也不再靠位置猜测。
1346
- *
1347
- * 为什么要分两种:用户常有十几条「一句话事实」(「提交格式:PDF」),每条都占标题 + 空行 +
1348
- * 正文三行,整段会散成一长串标题;压成一行后十条信息就是十行。多行正文是**用户写的完整
1349
- * Markdown**(可能自带标题、代码块、嵌套列表),整体缩进 2 格挂到条目下,结构原样保留。
1350
- *
1351
- * 返回值带 `inline`:调用方据此决定下一条记忆前要不要空行(单行条目连续排列,其余空行分隔)。
1352
- */
1353
- function memoryBlock(file) {
1354
- const desc = explicitDescriptionOf(file);
1355
- // 加粗的只有名称:`- **名称**(描述) — 正文`,与 MCP 段 `- **server**(N 个工具) — …` 同形。
1356
- const title = `**${file.name}**` + (desc === '' ? '' : `(${desc})`);
1357
- const text = String(file.body ?? '').trim();
1358
- const inline = text === '' || (!text.includes('\n') && text.length <= INLINE_BODY_MAX);
1359
- const head = text === ''
1360
- ? `- ${title}`
1361
- : (inline ? `- ${title} — ${text}` : `- ${title}\n\n${indentBody(text)}`);
1362
- const attach = attachmentLine(file);
1363
- if (attach === '')
1364
- return { text: `${head}\n`, inline };
1365
- // 附件行恒为缩进子项:只用 `- ` 会被解析成与记忆**同级**的列表项(`- A` / `- 附件目录:A的` /
1366
- // `- B` … 四条平级,归属读不出来)。单行条目紧跟其后保持列表连续;多行条目前面空一行,
1367
- // 免得被读成用户正文自己的列表项。
1368
- return { text: inline ? `${head}\n - ${attach}\n` : `${head}\n\n - ${attach}\n`, inline };
1369
- }
1370
- /** 多行正文整体缩进 2 格(列在条目内容列上),空行保持空行、不加尾随空白。 */
1371
- const indentBody = (text) => text
1372
- .split('\n')
1373
- .map((line) => (line === '' ? line : ' ' + line))
1374
- .join('\n');
1375
- /** 括号注解用的显式描述:派生描述与正文重复、不进段(只存在于界面投影);换行压成单行,避免把「一行一条」的列表项撑断。 */
1376
- const explicitDescriptionOf = (f) => (f.descriptionDerived || !f.description ? '' : String(f.description).replaceAll(/\s+/g, ' ').trim());
1377
- const byteLen = (s) => Buffer.byteLength(s, 'utf8');
1378
- // ── 创建服务 ───────────────────────────────────────────────────────────────
1379
- export function createRulesService(ctx, deps) {
1380
- // 仅测试注入绝对路径;生产按 $DSH_HOME 解析(与核心技能目录同源)。
1381
- // v0.4:全部落到 $DSH_HOME/tool-management/ 一个目录内(memories/ + scenes/ + 侧车),
1382
- // 旧的 $DSH_HOME/scene-memory 与更旧的 $DSH_HOME/rules 由 relocateLegacyLayout() 搬入。
1383
- const stateDir = deps.stateDir && deps.stateDir.trim() !== '' ? resolve(deps.stateDir) : join(resolveDshHome(), HUB_DIR);
1384
- const rulesRoot = deps.rulesRoot && deps.rulesRoot.trim() !== '' ? resolve(deps.rulesRoot) : join(stateDir, MEMORIES_DIR);
1385
- const scenesRoot = deps.scenesDir && deps.scenesDir.trim() !== '' ? resolve(deps.scenesDir) : join(stateDir, SCENES_DIR);
1386
- const maxBytes = Number.isFinite(deps.maxBytes) && deps.maxBytes > 0 ? deps.maxBytes : DEFAULT_MAX_BYTES;
1387
- // 旧布局迁移:每个进程只跑一次。放在目录解析之后、任何首次读盘之前。
1388
- let legacyRelocated = false;
1389
- const ensureLayout = async () => {
1390
- if (legacyRelocated)
1391
- return;
1392
- legacyRelocated = true;
1393
- try {
1394
- await relocateLegacyLayout(rulesRoot);
1395
- }
1396
- catch {
1397
- /* 迁移失败不阻断服务:旧目录原样留着,用户可手工搬 */
1398
- }
1399
- try {
1400
- await mkdir(join(rulesRoot, GLOBAL_SCENE), { recursive: true });
1401
- await mkdir(scenesRoot, { recursive: true });
1402
- }
1403
- catch {
1404
- /* 目录建不出来时后面的 op 会各自报错,这里不提前抛 */
1405
- }
1406
- };
1407
- let snapCache = null;
1408
- const snapshot = async () => {
1409
- if (snapCache && Date.now() - snapCache.at < SNAPSHOT_TTL_MS)
1410
- return snapCache.value;
1411
- await ensureLayout();
1412
- const value = await buildSnapshot(rulesRoot, stateDir);
1413
- snapCache = { at: Date.now(), value };
1414
- return value;
1415
- };
1416
- const invalidateSnapshot = () => {
1417
- snapCache = null;
1418
- };
1419
- // ── 场景记忆段(两相扫描 + 指纹缓存)──────────────────────────────────────
1420
- //
1421
- // 段文本 = 活动场景(`_shared` ∪ index.active,缺失时全部)下所有 .md 正文,
1422
- // 按「场景顺序 → 记忆名」确定性拼接;超预算按同一顺序确定性截断并追加固定标记。
1423
- // 禁止时间戳/计数:否则每请求都变,前缀缓存永远不命中(§5.2)。
1424
- //
1425
- // 每次装配:只做「读索引 + stat 遍历」的廉价探测;指纹不变直接返回上次结果,
1426
- // 指纹一变(切场景 / 改文件 / 外部编辑器改文件 / 改 enabled)才重读正文并重排。
1427
- // 见文件顶部「两相扫描」注释里为什么不选 fs.watch 方案。
1428
- let sceneCache = null;
1429
- function sceneMemory() {
1430
- const index = readIndexSync(stateDir);
1431
- const probe = probeSceneFilesSync(rulesRoot, index);
1432
- if (sceneCache && sceneCache.signature === probe.signature)
1433
- return sceneCache.value;
1434
- const value = renderSceneMemory(probe, index, maxBytes);
1435
- sceneCache = { signature: probe.signature, value };
1436
- return value;
1437
- }
1438
- // ── 场景提示词(绑定预设正文;读盘、只读;写文件由 scene-prompt-sync.ts 负责)──
1439
- //
1440
- // 语义(用户裁定 2026-09-15):
1441
- // - 场景可绑定**一个**提示词预设(`scenes[].prompt` → `prompts/<id>/AGENTS.md`);
1442
- // - 除保留场景 `global` 外**同时只能启用一个场景**,所以同一时刻至多一份提示词在场;
1443
- // - 没有启用其它场景时才轮到 `global` 自己的绑定(它恒常生效,覆盖"永远在场的提示词");
1444
- // - 预设文件不存在 / 未绑定 → 返回 `''`(renderPrompt 会删掉空段,不产生空标题)。
1445
- //
1446
- // 为什么不用 fs.watch 也不用每请求读盘:段契约要求**同步返回**且逐字节稳定,
1447
- // 因此按 `mtimeMs + size` 做一次 stat 缓存 —— 命中时零读盘,外部编辑器改了预设
1448
- // 也会在下一个请求自动刷新(与记忆段的"两相扫描"同一哲学)。
1449
- const promptFileCache = new Map();
1450
- /** 读一个预设正文(同步、带 stat 指纹缓存);不存在返回 `''`。 */
1451
- function readPresetTextSync(id) {
1452
- const file = join(stateDir, PRESETS_DIR, id, 'AGENTS.md');
1453
- let key;
1454
- try {
1455
- const st = statSync(file);
1456
- if (!st.isFile())
1457
- return '';
1458
- key = `${st.mtimeMs}:${st.size}`;
1459
- }
1460
- catch {
1461
- promptFileCache.delete(id);
1462
- return '';
1463
- }
1464
- const cached = promptFileCache.get(id);
1465
- if (cached && cached.key === key)
1466
- return cached.text;
1467
- let text = '';
1468
- try {
1469
- text = readFileSync(file, 'utf8');
1470
- }
1471
- catch {
1472
- return '';
1473
- }
1474
- promptFileCache.set(id, { key, text });
1475
- return text;
1476
- }
1477
- /** 预设文件是否存在(同步;绑定前校验用,避免悬空绑定)。 */
1478
- function presetExistsSync(id) {
1479
- try {
1480
- return statSync(join(stateDir, PRESETS_DIR, id, 'AGENTS.md')).isFile();
1481
- }
1482
- catch {
1483
- return false;
1484
- }
1485
- }
1486
- /**
1487
- * 读当前 `~/.dsh/AGENTS.md`(同步、带 stat 指纹缓存)。
1488
- * 用途只有一个:**去重**——绑定的预设与文件内容逐字节相同时不再注入第二遍正文。
1489
- */
1490
- let globalAgentsCache = null;
1491
- function readGlobalAgentsMdSync() {
1492
- const file = join(resolveDshHome(), 'AGENTS.md');
1493
- let key;
1494
- try {
1495
- const st = statSync(file);
1496
- if (!st.isFile())
1497
- return '';
1498
- key = `${st.mtimeMs}:${st.size}`;
1499
- }
1500
- catch {
1501
- globalAgentsCache = null;
1502
- return '';
1503
- }
1504
- if (globalAgentsCache && globalAgentsCache.key === key)
1505
- return globalAgentsCache.text;
1506
- let text = '';
1507
- try {
1508
- text = readFileSync(file, 'utf8');
1509
- }
1510
- catch {
1511
- return '';
1512
- }
1513
- globalAgentsCache = { key, text };
1514
- return text;
1515
- }
1516
- /**
1517
- * 当前生效的场景提示词:启用场景的绑定优先,其次 `global` 的绑定;都没有 → `null`。
1518
- * 「绑了但预设为空/已删」按没绑处理(不注入空段),继续找下一个。
1519
- */
1520
- function resolveScenePreset() {
1521
- const index = readIndexSync(stateDir);
1522
- const names = Object.keys(index.scenes || {});
1523
- const { active } = resolveActiveScenes(index, names);
1524
- // 单选:取唯一一个非保留启用场景(resolveActiveScenes 已保证 ≤1,这里仍做确定性排序兜底)。
1525
- const enabled = names
1526
- .filter((n) => n !== SHARED_GROUP && n !== GLOBAL_SCENE && active.has(n))
1527
- .sort((a, b) => sceneOrderOf(index, a) - sceneOrderOf(index, b) || a.localeCompare(b));
1528
- for (const scene of enabled) {
1529
- const id = index.scenes?.[scene]?.prompt;
1530
- if (!id)
1531
- continue;
1532
- const text = readPresetTextSync(id);
1533
- // 绑了但预设为空/已删 → 继续找下一个(不注入空段)。
1534
- if (text.trim() === '')
1535
- continue;
1536
- return { scene, presetId: id, text };
1537
- }
1538
- const globalId = index.scenes?.[GLOBAL_SCENE]?.prompt;
1539
- if (globalId) {
1540
- const text = readPresetTextSync(globalId);
1541
- if (text.trim() !== '')
1542
- return { scene: GLOBAL_SCENE, presetId: globalId, text };
1543
- }
1544
- return null;
1545
- }
1546
- /**
1547
- * 当前生效的场景提示词(只读投影,界面用):绑定内容与 `~/.dsh/AGENTS.md` 相同时标记
1548
- * `duplicate` 且返回空段 —— 那份正文已经在基线里了,界面据此显示「生效中」。
1549
- * (注入侧不看这个字段:预设挂不到 AGENTS.md 通道时照样要注入,见 `promptText`。)
1550
- */
1551
- function scenePrompt() {
1552
- const found = resolveScenePreset();
1553
- if (!found)
1554
- return { scene: null, presetId: null, text: '', missing: false };
1555
- const globalText = readGlobalAgentsMdSync().trim();
1556
- if (globalText !== '' && found.text.trim() === globalText) {
1557
- return { scene: found.scene, presetId: found.presetId, text: '', missing: false, duplicate: true };
1558
- }
1559
- return { scene: found.scene, presetId: found.presetId, text: found.text, missing: false };
1560
- }
1561
- // ── 写操作串行队列(避免并发覆盖同一索引/文件)──
1562
- let mutationQueue = Promise.resolve();
1563
- const enqueueMutation = (task) => {
1564
- const queued = mutationQueue.then(task, task);
1565
- mutationQueue = queued.catch(() => undefined);
1566
- return queued;
1567
- };
1568
- // ── 定位 / 解析 id ──────────────────────────────────────────────────────
1569
- /** id = "<group>/<name>";group 可含 '/'(多层),根下规则允许 group 为空。 */
1570
- function parseId(id) {
1571
- if (typeof id !== 'string' || id === '' || id.startsWith('/') || id.endsWith('/'))
1572
- return null;
1573
- const idx = id.lastIndexOf('/');
1574
- const group = idx >= 0 ? id.slice(0, idx) : '';
1575
- const name = idx >= 0 ? id.slice(idx + 1) : id;
1576
- if (!name || !isValidGroupSegment(name))
1577
- return null;
1578
- if (group && !isValidGroupPath(group))
1579
- return null;
1580
- return { group, name };
1581
- }
1582
- /**
1583
- * 磁盘定位(bundle 优先)。返回规则文件与条目路径。
1584
- * 根层(`group === ''`)**没有 bundle**:一级目录恒为场景(见 discover 的注释),
1585
- * 否则 `memories/1/1.md`(场景 1 的 flat 记忆 1)会被这里读成根层 bundle「1」,
1586
- * 与列表/注入两端不一致(编辑、删除都会落到错误的文件上)。
1587
- */
1588
- async function locateRule(group, name) {
1589
- if (group !== '') {
1590
- const bundleDir = join(rulesRoot, group, name);
1591
- for (const candidate of [bundleDocName(name), LEGACY_BUNDLE_DOC]) {
1592
- const bundleDoc = join(bundleDir, candidate);
1593
- try {
1594
- const st = await lstat(bundleDoc);
1595
- if (st.isFile() && !st.isSymbolicLink()) {
1596
- return { id: group ? `${group}/${name}` : name, group, name, kind: 'bundle', docPath: bundleDoc, entryPath: bundleDir };
1597
- }
1598
- }
1599
- catch { /* 该候选名不存在 → 试下一个 */ }
1600
- }
1601
- }
1602
- const flatPath = join(rulesRoot, group, name + '.md');
1603
- try {
1604
- const st = await lstat(flatPath);
1605
- if (st.isFile() && !st.isSymbolicLink()) {
1606
- return { id: group ? `${group}/${name}` : name, group, name, kind: 'flat', docPath: flatPath, entryPath: flatPath };
1607
- }
1608
- }
1609
- catch { /* 非 flat */ }
1610
- return null;
1611
- }
1612
- /** 写操作后构建单条投影(不依赖快照,避免全量重扫)。 */
1613
- async function buildProjected(id) {
1614
- const parts = parseId(id);
1615
- if (!parts)
1616
- return null;
1617
- const entry = await locateRule(parts.group, parts.name);
1618
- if (!entry)
1619
- return null;
1620
- try {
1621
- const text = await readFile(entry.docPath, 'utf8');
1622
- const doc = parseSkillDoc(text);
1623
- const derived = deriveFromDoc(entry, doc);
1624
- const index = await readIndex(stateDir);
1625
- return projectRule(entry, derived, index.rules[id]);
1626
- }
1627
- catch {
1628
- return null;
1629
- }
1630
- }
1631
- // ── 规则文件序列化 ──────────────────────────────────────────────────────
1632
- /**
1633
- * 值含换行用 `|` 块(保留换行);否则 `key: value` 原样(parseSkillDoc 按行贪婪解析)。
1634
- * 例外:多行值里有去空白后恰为 `---` 的行时改用 JSON 引号标量 —— 块标量把内容行缩进
1635
- * 两格也躲不开 parseSkillDoc 的 frontmatter 结束扫描(`trim() === '---'`),描述会被
1636
- * 截断、残片混进正文;引号形式是单物理行,扫描无从误判,decodeYamlScalar 无损还原
1637
- * (skills 写侧的引号标量正是靠这一点免疫同一断裂)。
1638
- */
1639
- function yamlField(key, value) {
1640
- if (typeof value === 'boolean')
1641
- return [`${key}: ${value}`];
1642
- const s = String(value);
1643
- if (s.includes('\n')) {
1644
- if (s.split('\n').some((line) => line.trim() === '---'))
1645
- return [`${key}: ${JSON.stringify(s)}`];
1646
- return [`${key}: |`, ...s.split('\n').map((line) => ` ${line}`)];
1647
- }
1648
- return [`${key}: ${s}`];
1649
- }
1650
- function serializeRuleFile(fields, body) {
1651
- const lines = ['---'];
1652
- for (const [key, value] of Object.entries(fields))
1653
- lines.push(...yamlField(key, value));
1654
- lines.push('---', '');
1655
- return lines.join('\n') + String(body ?? '');
1656
- }
1657
- /** update 时重建文件:保留 frontmatter 其余字段,仅更新 name/description。 */
1658
- function serializeUpdatedFile(doc, newName, description, body) {
1659
- const fields = {};
1660
- for (const [key, value] of Object.entries(doc.map)) {
1661
- if (key === 'name' || key === 'description')
1662
- continue;
1663
- fields[key] = String(value);
1664
- }
1665
- fields.name = newName;
1666
- if (description === undefined) {
1667
- if (doc.map.description != null && String(doc.map.description).trim() !== '')
1668
- fields.description = String(doc.map.description);
1669
- }
1670
- else if (description !== null) {
1671
- fields.description = description;
1672
- }
1673
- return serializeRuleFile(fields, body);
1674
- }
1675
- /**
1676
- * 写操作成功后:失效快照 + 丢弃场景记忆段缓存 + 通知 provider。
1677
- * 指纹本身已能发现变化,这里显式丢弃是为了让写操作返回后**必然**是新鲜结果。
1678
- * 不再写 ~/.dsh/AGENTS.md(原始终层投影已下线,见变更单 §4/§10):
1679
- * 公共基线改由 `_shared/` 场景承担,统一走 systemPrompt 段。
1680
- */
1681
- const refresh = async () => {
1682
- invalidateSnapshot();
1683
- sceneCache = null;
1684
- };
1685
- /** 场景档案引擎专用:读-改-写 mode/archives/active 切片(写队列内,保持与其余索引写串行)。 */
1686
- const patchIndex = (patch) => enqueueMutation(async () => {
1687
- const index = await readIndex(stateDir);
1688
- if (patch.mode !== undefined)
1689
- index.mode = patch.mode;
1690
- if (patch.archives !== undefined)
1691
- index.archives = patch.archives;
1692
- // active 复用「记忆启用集」语义(null = 全部启用);进入模式时引擎收窄为 [S]。
1693
- if (patch.active !== undefined)
1694
- index.active = patch.active;
1695
- await writeIndex(stateDir, index);
1696
- await refresh();
1697
- });
1698
- /** 场景档案引擎专用:读 mode/archives/active 切片(容忍缺失,缺省 = 无档案 + 自由模式 + 全部启用)。 */
1699
- const readArchiveSlice = async () => {
1700
- const index = await readIndex(stateDir);
1701
- return { mode: index.mode ?? { scene: null, snapshot: null }, archives: index.archives ?? {}, active: normalizeActive(index.active) };
1702
- };
1703
- // ── 场景行(UI 用:启用/停用开关)──────────────────────────────────────
1704
- /** 场景行:**以索引的场景记录为准**(空场景也在列表里),记忆条数由快照统计。 */
1705
- function sceneRows(snap, index) {
1706
- const names = new Set([GLOBAL_SCENE, ...Object.keys(index.scenes || {}), ...snap.scenes]);
1707
- const counts = new Map();
1708
- for (const rule of snap.rules) {
1709
- if (rule.shadowed)
1710
- continue;
1711
- const scene = sceneOf(rule.group);
1712
- if (scene === '')
1713
- continue; // 无场景归属的记忆(旧根层遗留)不归属任何场景
1714
- counts.set(scene, (counts.get(scene) || 0) + 1);
1715
- }
1716
- const known = [...names];
1717
- const { active } = resolveActiveScenes(index, known);
1718
- const enabledScene = enabledSceneOf(index);
1719
- return known
1720
- .map((name) => ({
1721
- name,
1722
- label: sceneLabel(name, index),
1723
- order: sceneOrderOf(index, name),
1724
- count: counts.get(name) || 0,
1725
- active: active.has(name),
1726
- shared: name === SHARED_GROUP,
1727
- global: name === GLOBAL_SCENE,
1728
- description: index.scenes?.[name]?.description || '',
1729
- prompt: index.scenes?.[name]?.prompt || '',
1730
- // 单选模型下"能不能点开":已被别的场景占用时,这个开关要置灰。
1731
- selectable: name !== SHARED_GROUP && name !== GLOBAL_SCENE && (enabledScene === null || enabledScene === name),
1732
- locked: index.scenes?.[name]?.locked === true,
1733
- }))
1734
- .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
1735
- }
1736
- // ── ops:读 ─────────────────────────────────────────────────────────────
1737
- async function rulesList(args) {
1738
- const groupFilter = args && typeof args.group === 'string' && args.group !== '' ? args.group : undefined;
1739
- const snap = await snapshot();
1740
- const index = await readIndex(stateDir);
1741
- const rules = snap.rules
1742
- .filter((r) => !groupFilter || r.group === groupFilter)
1743
- .sort((a, b) => a.group.localeCompare(b.group) || a.order - b.order || a.name.localeCompare(b.name))
1744
- // bundle 记忆附带附件摘要(用户裁定 2026-09-17):界面要显示「几个附件」。
1745
- // 每条 bundle 一次 readdirSync + 每个附件一次 statSync —— bundle 记忆通常只有几条,
1746
- // 这个开销可以忽略;flat 没有目录,直接原样返回(不加字段 = 界面不显示附件标签)。
1747
- .map((r) => {
1748
- if (r.form !== 'bundle')
1749
- return r;
1750
- const attach = attachmentSummarySync(dirname(r.path), r.name);
1751
- return attach ? { ...r, attach } : r;
1752
- });
1753
- const groups = groupFilter ? snap.groups.filter((g) => g.name === groupFilter) : snap.groups;
1754
- const scenes = sceneRows(snap, index);
1755
- // 场景锁定状态(v0.8):任意一个场景锁定 = 五个管理域整体冻结。界面据此禁用各页的写控件。
1756
- const anyLocked = Object.values(index.scenes || {}).some((s) => s.locked === true);
1757
- const projection = sceneMemory();
1758
- const promptProjection = scenePrompt();
1759
- return {
1760
- ok: true,
1761
- rules,
1762
- // 对外契约(§7.1)用 key 标识分组;name 保留兼容内部引用。
1763
- groups: groups.map((g) => ({ ...g, key: g.name })),
1764
- // 场景 = 显式记录(含保留场景 global 与尚无记忆的空场景);active = 是否参与注入。
1765
- scenes,
1766
- // 任一场景被锁定 = 五个管理域整体冻结(客户端据此禁用写控件;服务端 handlers 另有守卫)。
1767
- anyLocked,
1768
- activeMode: normalizeActive(index.active) === null ? 'all' : 'custom',
1769
- // 单选模型:当前启用的那个场景(null = 只留 `_shared` 与 `global`)。
1770
- activeScene: enabledSceneOf(index),
1771
- sceneMemory: { usedBytes: projection.bytes, maxBytes: projection.maxBytes, truncated: projection.truncated, dropped: projection.dropped },
1772
- // 当前生效的场景提示词(绑定的预设正文;与 ~/.dsh/AGENTS.md 一致时不重复注入)。
1773
- // `label` 是给用户指路用的显示名(「去场景设置里改『全局』的绑定」)——`global`
1774
- // 这类磁盘名直接甩给用户看没有意义。
1775
- scenePrompt: {
1776
- scene: promptProjection.scene,
1777
- label: promptProjection.scene ? sceneLabel(promptProjection.scene, index) : null,
1778
- presetId: promptProjection.presetId,
1779
- missing: promptProjection.missing,
1780
- duplicate: promptProjection.duplicate === true,
1781
- bytes: Buffer.byteLength(promptProjection.text, 'utf8'),
1782
- },
1783
- // 路径供 UI 显示「文件在哪」;不再让界面硬编码 ~/.dsh/scene-memory。
1784
- paths: { memories: rulesRoot, scenes: scenesRoot, hub: stateDir },
1785
- stats: {
1786
- total: rules.length,
1787
- enabled: rules.filter((r) => r.enabled).length,
1788
- scenes: scenes.filter((s) => s.active).length,
1789
- },
1790
- ...(snap.warnings.length ? { warnings: snap.warnings } : {}),
1791
- };
1792
- }
1793
- async function rulesRead(args) {
1794
- const id = String((args && args.id) || '');
1795
- const snap = await snapshot();
1796
- const rule = snap.rules.find((r) => r.id === id && !r.shadowed);
1797
- const entry = snap.entries.get(id);
1798
- if (!rule || !entry)
1799
- return fail('error.rules.notFound', `规则不存在:${id}`);
1800
- let text = '';
1801
- try {
1802
- text = await readFile(entry.docPath, 'utf8');
1803
- }
1804
- catch {
1805
- return fail('error.rules.notFound', `规则不存在:${id}`);
1806
- }
1807
- const doc = parseSkillDoc(text);
1808
- return {
1809
- ok: true,
1810
- rule: {
1811
- ...rule,
1812
- body: doc.body,
1813
- attachments: entry.kind === 'bundle' ? await listAttachments(entry.entryPath, entry.name) : [],
1814
- frontmatter: {
1815
- name: doc.map.name != null ? String(doc.map.name) : undefined,
1816
- description: doc.map.description != null ? String(doc.map.description) : undefined,
1817
- whenToUse: doc.map.whenToUse != null ? String(doc.map.whenToUse) : undefined,
1818
- globs: doc.map.globs != null ? String(doc.map.globs) : undefined,
1819
- metadata: doc.map.metadata !== undefined ? doc.map.metadata : undefined,
1820
- },
1821
- },
1822
- };
1823
- }
1824
- /**
1825
- * 场景记忆段预算视图。段落由「活动场景 + 文件内容」决定,超限时**不拒绝写入**,
1826
- * 而是按确定性顺序截断(§4 对照表)——这里只报告事实,供 UI 显著告警。
1827
- */
1828
- async function rulesBudget() {
1829
- const projection = sceneMemory();
1830
- return {
1831
- ok: true,
1832
- usedBytes: projection.bytes,
1833
- maxBytes: projection.maxBytes,
1834
- truncated: projection.truncated,
1835
- scenes: projection.scenes,
1836
- items: projection.items,
1837
- };
1838
- }
1839
- /**
1840
- * 规则体检 + 诊断汇总(Phase 3):逐条规则检查 6 类异常,附场景记忆段预算与场景统计。
1841
- * 只读;结果只用于 UI 展示,不做任何写操作。
1842
- */
1843
- async function rulesDiagnose() {
1844
- const snap = await snapshot();
1845
- const index = await readIndex(stateDir);
1846
- const scenes = sceneRows(snap, index);
1847
- const issues = [];
1848
- for (const rule of snap.rules) {
1849
- if (rule.shadowed) {
1850
- issues.push({ severity: 'warning', code: 'shadowed', ruleId: rule.id, message: `规则「${rule.id}」被同名 bundle 遮蔽,不会被加载。` });
1851
- }
1852
- if (String(rule.description || '').length > MAX_DESCRIPTION_LENGTH) {
1853
- issues.push({ severity: 'error', code: 'descriptionTooLong', ruleId: rule.id, message: `规则「${rule.id}」描述超过 ${MAX_DESCRIPTION_LENGTH} 字符,会被列表与检索截断。` });
1854
- }
1855
- if (rule.form === 'flat') {
1856
- const fileBase = basename(rule.path);
1857
- const base = fileBase.toLowerCase().endsWith('.md') ? fileBase.slice(0, -3) : fileBase;
1858
- if (base !== rule.name) {
1859
- issues.push({ severity: 'error', code: 'nameMismatch', ruleId: rule.id, message: `规则「${rule.id}」文件名(${fileBase})与 name(${rule.name})不一致,无法按 name 定位。` });
1860
- }
1861
- }
1862
- const body = snap.bodies.get(rule.id) || '';
1863
- if (body.trim() === '') {
1864
- issues.push({ severity: 'error', code: 'emptyBody', ruleId: rule.id, message: `规则「${rule.id}」正文为空。` });
1865
- }
1866
- if (rule.descriptionDerived && String(rule.description || '').length < 10) {
1867
- issues.push({ severity: 'info', code: 'vagueDescription', ruleId: rule.id, message: `规则「${rule.id}」描述过于笼统(自动派生,不足 10 字符),建议补充。` });
1868
- }
1869
- // 记忆所在场景未启用 → 不会自动生效(不是错误,但值得提示,避免"改了没效果")。
1870
- const scene = sceneOf(rule.group);
1871
- if (scene === '') {
1872
- // v0.4:记忆必须归属某个场景(留空 = 保留场景 `global`)。归不到场景的记忆
1873
- // 不会被投影,也不会出现在场景卡片里 —— 必须显式报出来,不能静默。
1874
- if (!rule.shadowed) {
1875
- issues.push({ severity: 'warning', code: 'noScene', ruleId: rule.id, message: `规则「${rule.id}」没有归属场景(文件直接放在 memories/ 根层),不会被注入。请移入某个场景目录,或放到 memories/${GLOBAL_SCENE}/ 作为「全局」记忆。` });
1876
- }
1877
- }
1878
- else if (!rule.shadowed) {
1879
- const row = scenes.find((s) => s.name === scene);
1880
- if (row && !row.active) {
1881
- issues.push({ severity: 'info', code: 'sceneDisabled', ruleId: rule.id, message: `规则「${rule.id}」所属场景「${scene}」未启用,当前不会进入系统提示词。` });
1882
- }
1883
- else if (!row) {
1884
- issues.push({ severity: 'warning', code: 'sceneUnknown', ruleId: rule.id, message: `规则「${rule.id}」的场景「${scene}」没有对应记录(可能被手工创建)——请到「场景」页补一条场景描述。` });
1885
- }
1886
- }
1887
- }
1888
- // frontmatter 非法:以 --- 开头但解析不出任何字段(残缺 frontmatter)。
1889
- for (const entry of snap.entries.values()) {
1890
- try {
1891
- const text = await readFile(entry.docPath, 'utf8');
1892
- const stripped = String(text || '').replace(/^\uFEFF/, '').trimStart();
1893
- if (stripped.startsWith('---')) {
1894
- const doc = parseSkillDoc(text);
1895
- if (Object.keys(doc.map || {}).length === 0) {
1896
- issues.push({ severity: 'warning', code: 'badFrontmatter', ruleId: entry.id, message: `规则「${entry.id}」frontmatter 无法解析(以 --- 开头但无有效字段)。` });
1897
- }
1898
- }
1899
- }
1900
- catch { /* 读取失败由上面的 emptyBody 兜底 */ }
1901
- }
1902
- const projection = sceneMemory();
1903
- if (projection.truncated) {
1904
- issues.push({
1905
- severity: 'warning',
1906
- code: 'sceneMemoryTruncated',
1907
- message: `场景记忆段超出预算(${projection.bytes} 字节 > ${projection.maxBytes} 字节),已按确定性顺序截断并追加 ${TRUNCATION_MARKER};请精简记忆或只保留 _shared。`,
1908
- });
1909
- }
1910
- return {
1911
- ok: true,
1912
- issues,
1913
- budget: { usedBytes: projection.bytes, maxBytes: projection.maxBytes, over: projection.truncated },
1914
- counts: { rules: snap.rules.length, groups: snap.groups.length, scenes: scenes.length },
1915
- };
1916
- }
1917
- // ── ops:写(串行队列内)───────────────────────────────────────────────
1918
- async function rulesCreate(args) {
1919
- const group = String((args && args.group) || '').trim();
1920
- const name = String((args && args.name) || '').trim();
1921
- const form = args && args.form === 'bundle' ? 'bundle' : 'flat';
1922
- // 场景必填:留空落到保留场景 `global`(界面「全局」,任何对话都注入)。
1923
- // 场景必须是**已存在**的记录——写成不存在的名字会静默造出一个没有描述的场景,
1924
- // 所以这里显式引导用户先去「场景」页创建(错误码可被 UI 翻译)。
1925
- if (group !== '' && !isValidGroupPath(group))
1926
- return fail('error.rules.invalidGroup', `场景名非法:${group}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头、首尾无空白)`);
1927
- const targetGroup = group === '' ? GLOBAL_SCENE : group;
1928
- const indexForScene = await readIndex(stateDir);
1929
- // 保留场景 global 恒存在(不用先建);其余场景必须已存在——见上方注释。
1930
- if (targetGroup !== GLOBAL_SCENE && !indexForScene.scenes?.[targetGroup] && !(await pathExists(join(rulesRoot, targetGroup)))) {
1931
- return fail('error.rules.sceneNotFound', `场景不存在:${targetGroup}(请先在「场景」页创建该场景)`, { group: targetGroup });
1932
- }
1933
- if (!isValidGroupSegment(name))
1934
- return fail('error.rules.invalidName', `记忆名非法:${name}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含 / \\ < > : " | ? *、不以 . 开头)`);
1935
- // 目标已存在(bundle 或 flat 皆算)→ 拒绝,避免静默覆盖。
1936
- // 用 exists 而不是 shadowed:后者说的是「同名 bundle 把 flat 遮蔽了」,用户看到
1937
- // 「被同名 bundle 遮蔽」会去找一个并不存在的 bundle(2026-09-16 实测)。
1938
- const existing = await locateRule(group, name);
1939
- if (existing) {
1940
- return fail('error.rules.exists', `同名记忆已存在(${existing.kind === 'bundle' ? 'bundle' : 'flat'}):${existing.id}`);
1941
- }
1942
- const body = String((args && args.body) ?? '');
1943
- if (body.trim() === '')
1944
- return fail('error.rules.bodyRequired', '规则正文不能为空');
1945
- if (Buffer.byteLength(body, 'utf8') > MAX_RULE_BYTES)
1946
- return fail('error.rules.tooLarge', `规则正文过大(超过 ${MAX_RULE_BYTES >> 10} KiB)`);
1947
- let description;
1948
- const rawDescription = args && args.description != null ? String(args.description).trim() : '';
1949
- if (rawDescription !== '') {
1950
- if (rawDescription.length > MAX_DESCRIPTION_LENGTH) {
1951
- return fail('error.rules.descriptionTooLong', `描述超过 ${MAX_DESCRIPTION_LENGTH} 字符(当前 ${rawDescription.length})`);
1952
- }
1953
- description = rawDescription;
1954
- }
1955
- else {
1956
- const derived = deriveDescription(body);
1957
- if (!derived)
1958
- return fail('error.rules.descriptionRequired', '请提供规则描述(正文中也无可派生的标题或首行)');
1959
- description = derived; // 派生只进内存投影,不写回文件
1960
- }
1961
- // 写文件(frontmatter 仅写用户显式提供的字段)。是否生效由"场景是否启用"决定,
1962
- // 超预算只在渲染时确定性截断 + 告警,不再拒绝写入(§4 对照表)。
1963
- const fields = { name };
1964
- if (rawDescription !== '')
1965
- fields.description = description;
1966
- const text = serializeRuleFile(fields, body);
1967
- if (form === 'bundle') {
1968
- await mkdir(join(rulesRoot, targetGroup, name), { recursive: true });
1969
- await writeFileAtomically(join(rulesRoot, targetGroup, name, bundleDocName(name)), text);
1970
- }
1971
- else {
1972
- await mkdir(join(rulesRoot, targetGroup), { recursive: true });
1973
- await writeFileAtomically(join(rulesRoot, targetGroup, name + '.md'), text);
1974
- }
1975
- // 更新索引(force 重读后合并,避免覆盖用户手工编辑)。
1976
- // id 恒为 `<场景>/<名>`(与 parseId 同构);场景记录此时必然已存在。
1977
- const createdId = `${targetGroup}/${name}`;
1978
- const index = indexForScene;
1979
- // P5:新建记忆默认**不开启** —— 记忆的开关是单一真相源(`enabled`),
1980
- // 用户裁定「创建的记忆默认不开启,在记忆页开启后对应的场景档案也要显示开启」。
1981
- index.rules[createdId] = { order: DEFAULT_ORDER, enabled: false, updatedAt: new Date().toISOString() };
1982
- if (!index.groups[targetGroup])
1983
- index.groups[targetGroup] = { order: DEFAULT_GROUP_ORDER, label: targetGroup };
1984
- await writeIndex(stateDir, index);
1985
- invalidateSnapshot();
1986
- return { ok: true, rule: await buildProjected(createdId) };
1987
- }
1988
- /**
1989
- * 导入记忆(.md / .zip):文件即真源——把 .md 原文落进 `<场景>/<名>.md`,场景为空 = 全局根层
1990
- * zip 内带目录 → 目录路径当场景;裸 .md → 落到 `args.scene`(留空 = 保留场景 `global`)。
1991
- * 引用到的场景不存在时**自动补一条场景记录**(导入是批量动作,要求用户先逐个建场景不现实);
1992
- * 这不算静默造场景——被补的场景会在 `scenes` 结果里回传,UI 会提示。
1993
- * 重名一律**跳过并报告**(与技能导入同策略),绝不覆盖用户既有文件。
1994
- * 部分成功:单条失败只记 skipped,不影响同批其余条目。
1995
- */
1996
- async function rulesImport(args) {
1997
- const scene = String((args && args.scene) || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '').trim();
1998
- if (scene !== '' && !isValidGroupPath(scene)) {
1999
- return fail('error.rules.invalidGroup', `场景名非法:${scene}(留空 = 全局;否则非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头、首尾无空白)`);
2000
- }
2001
- const defaultScene = scene === '' ? GLOBAL_SCENE : scene;
2002
- const files = args && args.files;
2003
- if (!Array.isArray(files) || !files.length)
2004
- return fail('error.import.noFiles', '没有选择要导入的文件');
2005
- const { entries, problems } = expandUploads(files);
2006
- const planned = planMemoryImport(entries, defaultScene);
2007
- const skipped = [...problems, ...planned.problems];
2008
- const imported = [];
2009
- const accepted = [];
2010
- for (const target of planned.targets) {
2011
- if (target.group !== '' && !isValidGroupPath(target.group)) {
2012
- skipped.push({ name: target.name, reason: '场景名非法,已跳过' });
2013
- continue;
2014
- }
2015
- if (!isValidGroupSegment(target.name)) {
2016
- skipped.push({ name: target.name, reason: '记忆名不合法,已跳过' });
2017
- continue;
2018
- }
2019
- const text = Buffer.from(target.bytes).toString('utf8').replace(/^\uFEFF/, '');
2020
- if (text.trim() === '') {
2021
- skipped.push({ name: target.name, reason: '内容为空,已跳过' });
2022
- continue;
2023
- }
2024
- if (Buffer.byteLength(text, 'utf8') > MAX_RULE_BYTES) {
2025
- skipped.push({ name: target.name, reason: `正文超过 ${MAX_RULE_BYTES >> 10} KiB,已跳过` });
2026
- continue;
2027
- }
2028
- // bundle 附件复检(规划层已过滤,这里按 rules-attach 同口径再拦一次,防绕过规划层的调用方)。
2029
- const rawAttachments = target.kind === 'bundle' ? target.attachments || [] : [];
2030
- const checkedAttachments = [];
2031
- let attachTotal = 0;
2032
- for (const att of rawAttachments) {
2033
- if (!isValidGroupSegment(att.name)) {
2034
- skipped.push({ name: `${target.name}/${att.name}`, reason: '附件名不合法,已跳过' });
2035
- continue;
2036
- }
2037
- const data = Buffer.from(att.bytes);
2038
- if (!data.length) {
2039
- skipped.push({ name: `${target.name}/${att.name}`, reason: '附件内容为空,已跳过' });
2040
- continue;
2041
- }
2042
- if (data.length > MAX_ATTACH_ENTRY_BYTES) {
2043
- skipped.push({ name: `${target.name}/${att.name}`, reason: `附件过大(单个上限 ${MAX_ATTACH_ENTRY_BYTES >> 20} MiB),已跳过` });
2044
- continue;
2045
- }
2046
- attachTotal += data.length;
2047
- if (attachTotal > MAX_ATTACH_TOTAL_BYTES) {
2048
- skipped.push({ name: `${target.name}/${att.name}`, reason: `附件合计超过 ${MAX_ATTACH_TOTAL_BYTES >> 20} MiB,已跳过` });
2049
- continue;
2050
- }
2051
- checkedAttachments.push({ name: att.name, data });
2052
- }
2053
- const existing = await locateRule(target.group, target.name);
2054
- if (existing) {
2055
- skipped.push({ name: target.group ? `${target.group}/${target.name}` : target.name, reason: '同名已存在(已跳过)' });
2056
- continue;
2057
- }
2058
- accepted.push({ group: target.group, name: target.name, text, kind: target.kind, attachments: checkedAttachments });
2059
- }
2060
- if (!accepted.length)
2061
- return { ok: true, imported, skipped, scenes: [] };
2062
- const index = await readIndex(stateDir);
2063
- if (!index.scenes)
2064
- index.scenes = {};
2065
- const createdScenes = [];
2066
- for (const item of accepted) {
2067
- const id = `${item.group}/${item.name}`;
2068
- try {
2069
- if (item.kind === 'bundle') {
2070
- // 与 rules-create 同落点:bundle = <场景>/<名>/ 目录,正文 <名>.md,附件平铺同层。
2071
- const bundleDir = join(rulesRoot, item.group, item.name);
2072
- await mkdir(bundleDir, { recursive: true });
2073
- await writeFileAtomically(join(bundleDir, bundleDocName(item.name)), item.text);
2074
- for (const att of item.attachments)
2075
- await writeFileAtomicBinary(join(bundleDir, att.name), att.data);
2076
- }
2077
- else {
2078
- await mkdir(join(rulesRoot, item.group), { recursive: true });
2079
- await writeFileAtomically(join(rulesRoot, item.group, item.name + '.md'), item.text);
2080
- }
2081
- }
2082
- catch (e) {
2083
- skipped.push({ name: id, reason: '写入失败:' + message(e) });
2084
- continue;
2085
- }
2086
- // 索引记录:与 rules-create 同口径(order/enabled/updatedAt + 场景分组),用户既有设置不覆盖。
2087
- const entry = index.rules[id];
2088
- // P5:导入的记忆同样默认**不开启**(与 rules-create 同一口径);已存在的条目保留原开关。
2089
- index.rules[id] = { ...(entry || {}), order: entry?.order ?? DEFAULT_ORDER, enabled: entry?.enabled ?? false, updatedAt: new Date().toISOString() };
2090
- if (!index.groups[item.group])
2091
- index.groups[item.group] = { order: DEFAULT_GROUP_ORDER, label: item.group };
2092
- // 场景记录补齐(导入进来的目录名此前可能没有记录)。
2093
- if (!index.scenes[item.group]) {
2094
- index.scenes[item.group] = item.group === GLOBAL_SCENE
2095
- ? { label: GLOBAL_SCENE_LABEL, order: 0 }
2096
- : { order: DEFAULT_GROUP_ORDER, createdAt: new Date().toISOString() };
2097
- createdScenes.push(item.group);
2098
- }
2099
- imported.push(id);
2100
- }
2101
- if (imported.length)
2102
- await writeIndex(stateDir, index);
2103
- return { ok: true, imported, skipped, scenes: createdScenes };
2104
- }
2105
- async function rulesUpdate(args) {
2106
- const id = String((args && args.id) || '');
2107
- const parts = parseId(id);
2108
- if (!parts)
2109
- return fail('error.rules.notFound', `规则不存在:${id}`);
2110
- const located = await locateRule(parts.group, parts.name);
2111
- if (!located)
2112
- return fail('error.rules.notFound', `规则不存在:${id}`);
2113
- let text;
2114
- try {
2115
- text = await readFile(located.docPath, 'utf8');
2116
- }
2117
- catch {
2118
- return fail('error.rules.notFound', `规则不存在:${id}`);
2119
- }
2120
- const doc = parseSkillDoc(text);
2121
- // flat 形态:frontmatter 声明名必须等于文件名(发现时可列出,更新时校验)。
2122
- const currentName = doc.map.name != null && String(doc.map.name).trim() !== '' ? unquote(String(doc.map.name)).trim() : parts.name;
2123
- if (located.kind === 'flat' && currentName !== parts.name) {
2124
- return fail('error.rules.invalidName', `flat 规则名与文件名不一致(frontmatter name: ${currentName},文件名: ${parts.name}),请先修正规则文件`);
2125
- }
2126
- const newName = args && args.name != null ? String(args.name).trim() : currentName;
2127
- if (!isValidGroupSegment(newName))
2128
- return fail('error.rules.invalidName', `记忆名非法:${newName}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含 / \\ < > : " | ? *、不以 . 开头)`);
2129
- const newForm = args && args.form === 'bundle' ? 'bundle' : args && args.form === 'flat' ? 'flat' : located.kind;
2130
- const newBody = args && args.body != null ? String(args.body) : doc.body;
2131
- if (newBody.trim() === '')
2132
- return fail('error.rules.bodyRequired', '规则正文不能为空');
2133
- if (Buffer.byteLength(newBody, 'utf8') > MAX_RULE_BYTES)
2134
- return fail('error.rules.tooLarge', `规则正文过大(超过 ${MAX_RULE_BYTES >> 10} KiB)`);
2135
- // description:显式传 → 校验并写文件;传空串 → 删除字段并从正文派生;未传 → 保留现有(缺失则派生)。
2136
- let description;
2137
- if (args && args.description != null) {
2138
- const ds = String(args.description).trim();
2139
- if (ds !== '') {
2140
- if (ds.length > MAX_DESCRIPTION_LENGTH) {
2141
- return fail('error.rules.descriptionTooLong', `描述超过 ${MAX_DESCRIPTION_LENGTH} 字符(当前 ${ds.length})`);
2142
- }
2143
- description = ds;
2144
- }
2145
- else {
2146
- const derived = deriveDescription(newBody);
2147
- if (!derived)
2148
- return fail('error.rules.descriptionRequired', '请提供规则描述(正文中也无可派生的标题或首行)');
2149
- description = derived; // 派生不写回
2150
- }
2151
- }
2152
- else {
2153
- const existing = doc.map.description != null ? unquote(String(doc.map.description)).trim() : '';
2154
- if (existing !== '')
2155
- description = existing;
2156
- else
2157
- description = deriveDescription(newBody) || undefined;
2158
- }
2159
- // 形态转换 / 改名:先建新形态文件,再把旧形态复制进回收站、最后删原件
2160
- // (确保任意失败点不产生半份规则;旧形态随时可恢复,README「删除都进回收站」无例外)。
2161
- // description 写回约定:显式传 → string/null(删除);未传 → undefined(保留原字段)。
2162
- const serialize = () => {
2163
- let descArg;
2164
- if (args && args.description != null)
2165
- descArg = String(args.description).trim() === '' ? null : description;
2166
- else
2167
- descArg = undefined;
2168
- return serializeUpdatedFile(doc, newName, descArg, newBody);
2169
- };
2170
- if (located.kind === 'bundle' && newForm === 'flat') {
2171
- await mkdir(join(rulesRoot, parts.group), { recursive: true });
2172
- await writeFileAtomically(join(rulesRoot, parts.group, newName + '.md'), serialize());
2173
- await copyIntoMemoriesTrash(located, parts.group, parts.name);
2174
- await rm(located.entryPath, { recursive: true, force: true });
2175
- }
2176
- else if (located.kind === 'flat' && newForm === 'bundle') {
2177
- await mkdir(join(rulesRoot, parts.group, newName), { recursive: true });
2178
- await writeFileAtomically(join(rulesRoot, parts.group, newName, bundleDocName(newName)), serialize());
2179
- await copyIntoMemoriesTrash(located, parts.group, parts.name);
2180
- await rm(join(rulesRoot, parts.group, parts.name + '.md'), { force: true });
2181
- }
2182
- else if (located.kind === 'flat' && newName !== parts.name) {
2183
- // flat 改名 = 文件改名
2184
- await writeFileAtomically(join(rulesRoot, parts.group, newName + '.md'), serialize());
2185
- await copyIntoMemoriesTrash(located, parts.group, parts.name);
2186
- await rm(join(rulesRoot, parts.group, parts.name + '.md'), { force: true });
2187
- }
2188
- else {
2189
- // bundle 不 rename 目录:只更新 frontmatter/正文
2190
- await writeFileAtomically(located.docPath, serialize());
2191
- }
2192
- // 索引:改名则迁移记录(保留 order/tags/enabled 等),否则原地更新。
2193
- const index = await readIndex(stateDir);
2194
- const newId = `${parts.group}/${newName}`;
2195
- if (newId !== id && index.rules[id]) {
2196
- index.rules[newId] = { ...index.rules[id], updatedAt: new Date().toISOString() };
2197
- delete index.rules[id];
2198
- }
2199
- else {
2200
- index.rules[id] = { ...(index.rules[id] || {}), updatedAt: new Date().toISOString() };
2201
- }
2202
- await writeIndex(stateDir, index);
2203
- invalidateSnapshot();
2204
- return { ok: true, rule: await buildProjected(newId) };
2205
- }
2206
- /**
2207
- * 把条目原件复制进 memories 回收站并写 manifest,返回 trashId。只复制、不删除:
2208
- * 原件的 rm 由调用方在复制成功后执行 —— 先有副本才允许删,rm 永远不会销毁唯一数据。
2209
- * manifest 先于原件删除落盘:中途崩溃最坏留下「原件还在 + 一份完整回收站副本」,
2210
- * 不会出现「有文件没清单」的损坏条目。
2211
- */
2212
- async function copyIntoMemoriesTrash(located, group, name) {
2213
- const trashId = Date.now().toString(36) + '-' + randomUUID().slice(0, 8);
2214
- const trashDir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
2215
- const manifest = { group, name, form: located.kind, deletedAt: new Date().toISOString() };
2216
- await mkdir(trashDir, { recursive: true });
2217
- if (located.kind === 'bundle') {
2218
- const { cp } = await import('node:fs/promises');
2219
- await cp(located.entryPath, join(trashDir, 'bundle'), { recursive: true });
2220
- }
2221
- else {
2222
- await copyFile(located.docPath, join(trashDir, 'rule.md'));
2223
- }
2224
- await writeFileAtomically(join(trashDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
2225
- return trashId;
2226
- }
2227
- async function rulesRemove(args) {
2228
- const id = String((args && args.id) || '');
2229
- const parts = parseId(id);
2230
- if (!parts)
2231
- return fail('error.rules.notFound', `规则不存在:${id}`);
2232
- const located = await locateRule(parts.group, parts.name);
2233
- if (!located)
2234
- return fail('error.rules.notFound', `规则不存在:${id}`);
2235
- const trashId = await copyIntoMemoriesTrash(located, parts.group, parts.name);
2236
- if (located.kind === 'bundle')
2237
- await rm(located.entryPath, { recursive: true, force: true });
2238
- else
2239
- await rm(located.docPath, { force: true });
2240
- const index = await readIndex(stateDir);
2241
- delete index.rules[id];
2242
- await writeIndex(stateDir, index);
2243
- invalidateSnapshot();
2244
- return { ok: true, trashId };
2245
- }
2246
- async function rulesRestore(args) {
2247
- const trashId = String((args && args.trashId) || '');
2248
- if (!isValidTrashId(trashId))
2249
- return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
2250
- const trashDir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
2251
- let manifest;
2252
- try {
2253
- manifest = JSON.parse(await readFile(join(trashDir, 'manifest.json'), 'utf8'));
2254
- }
2255
- catch {
2256
- return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
2257
- }
2258
- if (!manifest || typeof manifest.name !== 'string' || typeof manifest.group !== 'string' || !isValidGroupSegment(manifest.name)) {
2259
- return fail('error.rules.notFound', `回收站条目损坏:${trashId}`);
2260
- }
2261
- // 恢复目标已存在(期间用户重建了同名规则)→ 拒绝,避免覆盖。
2262
- const conflict = await locateRule(manifest.group, manifest.name);
2263
- if (conflict) {
2264
- return fail('error.rules.exists', `同名记忆已存在:${conflict.id},请先移除后再恢复`);
2265
- }
2266
- const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
2267
- if (form === 'bundle') {
2268
- await mkdir(join(rulesRoot, manifest.group), { recursive: true });
2269
- const { cp } = await import('node:fs/promises');
2270
- await cp(join(trashDir, 'bundle'), join(rulesRoot, manifest.group, manifest.name), { recursive: true });
2271
- }
2272
- else {
2273
- await mkdir(join(rulesRoot, manifest.group), { recursive: true });
2274
- await copyFile(join(trashDir, 'rule.md'), join(rulesRoot, manifest.group, manifest.name + '.md'));
2275
- }
2276
- await rm(trashDir, { recursive: true, force: true });
2277
- invalidateSnapshot();
2278
- return { ok: true, rule: await buildProjected(manifest.group ? `${manifest.group}/${manifest.name}` : manifest.name) };
2279
- }
2280
- /** trashId 只由本插件生成(时间戳 base36 + uuid 前 8 位):严格白名单,杜绝路径穿越。 */
2281
- function isValidTrashId(trashId) {
2282
- return /^[a-z0-9]+-[a-z0-9]{1,32}$/i.test(trashId);
2283
- }
2284
- /**
2285
- * 记忆回收站列表(只读):`<stateDir>/memories-trash/<trashId>/`(`rulesRemove` 移入,
2286
- * `rulesRestore` 恢复)。损坏或内容缺失的条目跳过,不让一个坏条目挡住整份列表。
2287
- */
2288
- async function rulesTrashList() {
2289
- const root = join(stateDir, MEMORIES_TRASH_DIR);
2290
- let ids = [];
2291
- try {
2292
- ids = await readdir(root);
2293
- }
2294
- catch {
2295
- return { ok: true, entries: [] }; // 目录不存在 = 回收站为空
2296
- }
2297
- const entries = [];
2298
- for (const trashId of ids) {
2299
- if (!isValidTrashId(trashId))
2300
- continue;
2301
- const dir = join(root, trashId);
2302
- try {
2303
- const st = await lstat(dir);
2304
- if (!st.isDirectory() || st.isSymbolicLink())
2305
- continue;
2306
- const manifest = JSON.parse(await readFile(join(dir, 'manifest.json'), 'utf8'));
2307
- if (!manifest || typeof manifest.name !== 'string' || !isValidGroupSegment(manifest.name))
2308
- continue;
2309
- const group = typeof manifest.group === 'string' ? manifest.group : '';
2310
- if (group !== '' && !isValidGroupPath(group))
2311
- continue;
2312
- const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
2313
- let bytes = 0;
2314
- try {
2315
- const bundleDoc = form === 'bundle'
2316
- ? join(dir, 'bundle', bundleDocName(manifest.name))
2317
- : join(dir, 'rule.md');
2318
- bytes = (await stat(bundleDoc)).size;
2319
- }
2320
- catch {
2321
- // 兼容旧回收站条目(正文仍叫 SKILL.md);仍读不到就按 0 计,恢复时由 rulesRestore 兜底报错。
2322
- if (form === 'bundle') {
2323
- try {
2324
- bytes = (await stat(join(dir, 'bundle', LEGACY_BUNDLE_DOC))).size;
2325
- }
2326
- catch { /* 内容缺失 */ }
2327
- }
2328
- }
2329
- entries.push({ trashId, group, name: manifest.name, form, deletedAt: String(manifest.deletedAt || ''), bytes });
2330
- }
2331
- catch { /* 损坏条目跳过 */ }
2332
- }
2333
- entries.sort((a, b) => (a.deletedAt === b.deletedAt ? b.trashId.localeCompare(a.trashId) : b.deletedAt.localeCompare(a.deletedAt)));
2334
- return { ok: true, entries };
2335
- }
2336
- /** 永久删除回收站条目(不可恢复)。 */
2337
- async function rulesTrashRemove(args) {
2338
- const trashId = String((args && args.trashId) || '');
2339
- if (!isValidTrashId(trashId))
2340
- return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
2341
- const dir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
2342
- try {
2343
- const st = await lstat(dir);
2344
- if (!st.isDirectory())
2345
- return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
2346
- }
2347
- catch {
2348
- return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
2349
- }
2350
- await rm(dir, { recursive: true, force: true });
2351
- return { ok: true };
2352
- }
2353
- /** bundle 目录下的附件(顶层普通文件,排除正文 `<名>.md`,兼容旧数据 `SKILL.md`);不存在/不可读返回空数组。 */
2354
- async function listAttachments(bundleDir, name) {
2355
- let entries;
2356
- try {
2357
- entries = await readdir(bundleDir, { withFileTypes: true });
2358
- }
2359
- catch {
2360
- return [];
2361
- }
2362
- const docNames = new Set([bundleDocName(name), LEGACY_BUNDLE_DOC]);
2363
- const out = [];
2364
- for (const entry of entries) {
2365
- if (docNames.has(entry.name))
2366
- continue;
2367
- if (!entry.isFile())
2368
- continue; // 子目录 / 符号链接不列出(detach 也只动普通文件)
2369
- try {
2370
- out.push({ name: entry.name, size: (await stat(join(bundleDir, entry.name))).size });
2371
- }
2372
- catch { /* 读不到的条目跳过 */ }
2373
- }
2374
- out.sort((a, b) => a.name.localeCompare(b.name));
2375
- return out;
2376
- }
2377
- /**
2378
- * 往 bundle 记忆里写附件。flat 形态没有目录,直接拒绝。
2379
- * 先整体校验、再逐个落盘:避免写到一半才因为第 N 个文件非法而留下半份附件。
2380
- * 同名附件覆盖(这是用户对自己文件的显式上传动作)。
2381
- */
2382
- async function rulesAttach(args) {
2383
- const id = String((args && args.id) || '');
2384
- const parts = parseId(id);
2385
- if (!parts)
2386
- return fail('error.rules.notFound', `规则不存在:${id}`);
2387
- const located = await locateRule(parts.group, parts.name);
2388
- if (!located)
2389
- return fail('error.rules.notFound', `规则不存在:${id}`);
2390
- if (located.kind !== 'bundle')
2391
- return fail('error.rules.notBundle', `「${parts.name}」是 flat(单文件),不能带附件`);
2392
- const files = Array.isArray(args && args.files) ? args.files : [];
2393
- if (files.length === 0)
2394
- return fail('error.rules.noFiles', '没有选择附件');
2395
- if (files.length > MAX_ATTACH_ENTRIES)
2396
- return fail('error.rules.tooManyFiles', `一次最多 ${MAX_ATTACH_ENTRIES} 个附件`, { limit: MAX_ATTACH_ENTRIES });
2397
- const pending = [];
2398
- let total = 0;
2399
- const docNames = new Set([bundleDocName(located.name), LEGACY_BUNDLE_DOC]);
2400
- for (const file of files) {
2401
- const name = String((file && (file.path || file.name)) || '');
2402
- if (!isValidGroupSegment(name))
2403
- return fail('error.rules.invalidName', `附件名非法:${name || '(空)'}`);
2404
- if (docNames.has(name))
2405
- return fail('error.rules.invalidName', `附件名不能与正文文件同名:${name}`);
2406
- const data = Buffer.from(String((file && file.data) || ''), 'base64');
2407
- if (data.length === 0)
2408
- return fail('error.rules.emptyFile', `附件内容为空:${name}`);
2409
- if (data.length > MAX_ATTACH_ENTRY_BYTES)
2410
- return fail('error.rules.fileTooLarge', `附件过大:${name}(单个上限 ${MAX_ATTACH_ENTRY_BYTES >> 20} MiB)`, { limit: MAX_ATTACH_ENTRY_BYTES >> 20 });
2411
- total += data.length;
2412
- if (total > MAX_ATTACH_TOTAL_BYTES)
2413
- return fail('error.rules.tooLarge', `附件总大小超过 ${MAX_ATTACH_TOTAL_BYTES >> 20} MiB`, { limit: MAX_ATTACH_TOTAL_BYTES >> 20 });
2414
- pending.push({ name, data });
2415
- }
2416
- for (const file of pending)
2417
- await writeFileAtomicBinary(join(located.entryPath, file.name), file.data);
2418
- return { ok: true, attachments: await listAttachments(located.entryPath, located.name) };
2419
- }
2420
- /** 删除 bundle 记忆的一个附件;正文 `<名>.md`(或旧数据 `SKILL.md`)不可删。 */
2421
- async function rulesDetach(args) {
2422
- const id = String((args && args.id) || '');
2423
- const parts = parseId(id);
2424
- if (!parts)
2425
- return fail('error.rules.notFound', `规则不存在:${id}`);
2426
- const located = await locateRule(parts.group, parts.name);
2427
- if (!located)
2428
- return fail('error.rules.notFound', `规则不存在:${id}`);
2429
- if (located.kind !== 'bundle')
2430
- return fail('error.rules.notBundle', `「${parts.name}」是 flat(单文件),没有附件`);
2431
- const name = String((args && args.name) || '');
2432
- if (!isValidGroupSegment(name) || name === bundleDocName(located.name) || name === LEGACY_BUNDLE_DOC) {
2433
- return fail('error.rules.invalidName', `附件名非法:${name || '(空)'}`);
2434
- }
2435
- const target = join(located.entryPath, name);
2436
- try {
2437
- const st = await lstat(target);
2438
- if (!st.isFile() || st.isSymbolicLink())
2439
- return fail('error.rules.notFound', `附件不存在:${name}`);
2440
- }
2441
- catch {
2442
- return fail('error.rules.notFound', `附件不存在:${name}`);
2443
- }
2444
- await rm(target, { force: true });
2445
- return { ok: true, attachments: await listAttachments(located.entryPath, located.name) };
2446
- }
2447
- async function rulesToggle(args) {
2448
- const id = String((args && args.id) || '');
2449
- const parts = parseId(id);
2450
- if (!parts)
2451
- return fail('error.rules.notFound', `规则不存在:${id}`);
2452
- // `enabled` 必须显式给。缺省时旧实现回落到"沿用当前值",却照样刷新
2453
- // updatedAt 并返回 ok:true —— 调用方按 toggle(翻转)理解时会以为自己改了
2454
- // 状态,实际什么都没改(界面不受影响:它一直显式传值)。与 rulesSetActive
2455
- // 同一条口径:参数缺失就明确拒绝,不猜。
2456
- if (typeof (args && args.enabled) !== 'boolean') {
2457
- return fail('error.rules.invalidArgs', '缺少参数:enabled 必须是布尔值(toggle 只按传入值写入,不做"翻转"推断)');
2458
- }
2459
- const index = await readIndex(stateDir);
2460
- const idxEntry = index.rules[id] || {};
2461
- const enabled = args.enabled === true;
2462
- index.rules[id] = { ...idxEntry, enabled, updatedAt: new Date().toISOString() };
2463
- await writeIndex(stateDir, index);
2464
- invalidateSnapshot();
2465
- return { ok: true, rule: await buildProjected(id) };
2466
- }
2467
- /**
2468
- * 设置"启用场景"(全局持久化,切换后**下一个请求即生效**,§4 对照表)。
2469
- * - `args.scenes` 数组 → 启用集合;**至多一个非保留场景**(用户裁定:除「全局」外
2470
- * 同时只能启用一个),`[]` = 全部关闭(只留恒常的 `_shared` 与 `global`)
2471
- * - `args.all === true` → 旧的"全部启用"形态,与新模型冲突,明确拒绝(不猜)
2472
- * 场景名按放宽后的规则校验;不存在的场景名也允许保存(目录随后创建即可生效)。
2473
- */
2474
- async function rulesSetActive(args) {
2475
- // 参数必须显式二选一。旧实现把「没传参数」「传了未知参数名」都落进 else 分支、
2476
- // 用空数组覆盖 active,于是 `rules-set-active {}` 会静默把模式切成 custom 且零激活场景
2477
- // (用户以为自己只是"没改动",实际已经改了注入范围)。这里显式拒绝。
2478
- const hasAll = !!(args && args.all === true);
2479
- const hasScenes = Array.isArray(args && args.scenes);
2480
- if (!hasAll && !hasScenes) {
2481
- return fail('error.rules.invalidArgs', '缺少参数:需要 scenes:[...](启用集合,至多一个场景)');
2482
- }
2483
- if (hasAll) {
2484
- return fail('error.rules.singleSceneOnly', '除「全局」外同时只能启用一个场景:不支持"全部启用",请改用 scenes:[<场景名>] 或 scenes:[](全部关闭)', { detail: ':不支持"全部启用",请改用 scenes:[<场景名>] 或 scenes:[](全部关闭)' });
2485
- }
2486
- const index = await readIndex(stateDir);
2487
- const raw = Array.isArray(args && args.scenes) ? args.scenes : [];
2488
- const names = [];
2489
- const seen = new Set();
2490
- for (const item of raw) {
2491
- const name = String(item == null ? '' : item).trim();
2492
- // 恒常启用的保留场景不入显式集合:`_shared`(公共基线)与 `global`(「全局」)。
2493
- if (name === '' || name === SHARED_GROUP || name === GLOBAL_SCENE)
2494
- continue;
2495
- if (!isValidGroupPath(name)) {
2496
- return fail('error.rules.invalidGroup', `场景名非法:${name}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头)`);
2497
- }
2498
- if (seen.has(name))
2499
- continue;
2500
- seen.add(name);
2501
- names.push(name);
2502
- }
2503
- if (names.length > 1) {
2504
- return fail('error.rules.singleSceneOnly', `除「全局」外同时只能启用一个场景(收到 ${names.length} 个:${names.join('、')})`, { detail: `(收到 ${names.length} 个:${names.join('、')})` });
2505
- }
2506
- index.active = names;
2507
- await writeIndex(stateDir, index);
2508
- invalidateSnapshot();
2509
- const snap = await snapshot();
2510
- return {
2511
- ok: true,
2512
- activeMode: index.active === null ? 'all' : 'custom',
2513
- scenes: sceneRows(snap, index),
2514
- };
2515
- }
2516
- /**
2517
- * 新建场景:写一条场景记录(索引 scenes 切片)+ 建空目录 `memories/<场景>/`。
2518
- * 场景是**显式实体**——空场景(还没有记忆)也是合法场景,会出现在场景列表里。
2519
- * 场景名是**单个路径段**(不允许 `a/b`):界面把场景当一级列表展示,
2520
- * 允许多段只会让「记忆的场景」与「目录层级」两套语义互相打架。
2521
- * 幂等:已存在则更新描述/标签,不报错。`_shared` 与 `global` 是保留名。
2522
- */
2523
- async function rulesCreateScene(args) {
2524
- const name = String((args && args.name) || '').trim();
2525
- if (!isValidGroupSegment(name)) {
2526
- return fail('error.rules.invalidGroup', `场景名非法:${name || '(空)'}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头)`);
2527
- }
2528
- if (name === SHARED_GROUP)
2529
- return fail('error.rules.invalidGroup', `_shared 是保留场景名,无需创建`);
2530
- const label = args && args.label !== undefined ? String(args.label).trim() : '';
2531
- const description = args && args.description !== undefined ? String(args.description).trim() : '';
2532
- if (description.length > MAX_DESCRIPTION_LENGTH) {
2533
- return fail('error.rules.descriptionTooLong', `场景描述过长(≤${MAX_DESCRIPTION_LENGTH} 字符)`);
2534
- }
2535
- const prompt = args && args.prompt !== undefined ? normalizeScenePromptId(args.prompt) : undefined;
2536
- if (prompt === null) {
2537
- return fail('error.rules.invalidGroup', `提示词预设 id 非法:${String(args.prompt)}(仅小写字母/数字/连字符,且不能是备份槽)`);
2538
- }
2539
- await ensureLayout();
2540
- try {
2541
- await mkdir(join(rulesRoot, name), { recursive: true });
2542
- }
2543
- catch (e) {
2544
- return fail('error.rules.ioFailed', `创建场景目录失败:${message(e)}`);
2545
- }
2546
- const index = await readIndex(stateDir);
2547
- // **新场景默认不启动**:先把历史默认(active=null=全部启用)收敛成显式集合,
2548
- // 新建的这个自然不在其中;收敛后仍是"至多一个场景启用"。
2549
- const collapsed = collapseActiveForNewScene(index);
2550
- if (!index.scenes)
2551
- index.scenes = {};
2552
- const prev = index.scenes[name] || {};
2553
- const next = {
2554
- ...prev,
2555
- ...(label !== '' && name !== GLOBAL_SCENE ? { label } : {}),
2556
- ...(description !== '' ? { description } : {}),
2557
- ...(prompt !== undefined && prompt !== '' ? { prompt } : {}),
2558
- order: prev.order ?? DEFAULT_GROUP_ORDER,
2559
- createdAt: prev.createdAt ?? new Date().toISOString(),
2560
- };
2561
- if (name === GLOBAL_SCENE)
2562
- next.label = GLOBAL_SCENE_LABEL;
2563
- index.scenes[name] = next;
2564
- await writeIndex(stateDir, index);
2565
- invalidateSnapshot();
2566
- return { ok: true, scene: sceneRecordOf(name, next), ...(collapsed ? { collapsedActive: true } : {}) };
2567
- }
2568
- /**
2569
- * 更新场景记录(描述 / 显示名 / 顺序 / 绑定的提示词预设),**可选改名**(nextName)。
2570
- *
2571
- * 场景名就是它的一级目录名(`memories/<场景>/…`),所以改名不是改一个字段:
2572
- * ① 目录 `memories/<旧>` → `memories/<新>`;
2573
- * ② 索引里的四处引用一起改:`scenes` 记录、`archives` 档案、`active` 启用集合、`mode.scene`。
2574
- * 记忆正文一个字节都不动(只是换了所在目录名)。
2575
- *
2576
- * 拒绝的三种情况:保留场景 `global` / `_shared`;目标名已被占用(目录或记录);
2577
- * 该场景正在当前模式里——模式快照是按场景名算的,改了名与快照就对不上(同删除的处理)。
2578
- */
2579
- async function rulesUpdateScene(args) {
2580
- const name = String((args && args.name) || '').trim();
2581
- if (!isValidGroupPath(name))
2582
- return fail('error.rules.invalidGroup', `场景名非法:${name || '(空)'}`);
2583
- const index = await readIndex(stateDir);
2584
- if (!index.scenes)
2585
- index.scenes = {};
2586
- const prev = index.scenes[name];
2587
- if (!prev && name !== GLOBAL_SCENE)
2588
- return fail('error.rules.notFound', `场景不存在:${name}`);
2589
- const next = { ...(prev || {}) };
2590
- if (args && args.description !== undefined) {
2591
- const description = String(args.description).trim();
2592
- if (description.length > MAX_DESCRIPTION_LENGTH) {
2593
- return fail('error.rules.descriptionTooLong', `场景描述过长(≤${MAX_DESCRIPTION_LENGTH} 字符)`);
2594
- }
2595
- if (description === '')
2596
- delete next.description;
2597
- else
2598
- next.description = description;
2599
- }
2600
- if (args && args.prompt !== undefined) {
2601
- const prompt = normalizeScenePromptId(args.prompt);
2602
- if (prompt === null) {
2603
- return fail('error.rules.invalidGroup', `提示词预设 id 非法:${String(args.prompt)}(仅小写字母/数字/连字符,且不能是备份槽)`);
2604
- }
2605
- // 绑定前校验预设确实存在:宁可现在拒绝,也不要留一个永远注入不出东西的悬空绑定。
2606
- if (prompt !== '' && !presetExistsSync(prompt)) {
2607
- return fail('error.rules.notFound', `提示词预设不存在:${prompt}`);
2608
- }
2609
- if (prompt === '')
2610
- delete next.prompt;
2611
- else
2612
- next.prompt = prompt;
2613
- }
2614
- if (args && args.label !== undefined && name !== GLOBAL_SCENE) {
2615
- const label = String(args.label).trim();
2616
- if (label === '')
2617
- delete next.label;
2618
- else
2619
- next.label = label;
2620
- }
2621
- if (args && args.order !== undefined) {
2622
- const order = Number(args.order);
2623
- if (!Number.isFinite(order) || order < 0)
2624
- return fail('error.rules.invalidGroup', `非法排序值:${args.order}`);
2625
- next.order = Math.floor(order);
2626
- }
2627
- // ── 改名(可选):目录 + 索引里的四处引用一起动 ──
2628
- let finalName = name;
2629
- const nextRaw = args && args.nextName !== undefined ? String(args.nextName).trim() : '';
2630
- if (nextRaw !== '' && nextRaw !== name) {
2631
- if (!isValidGroupSegment(nextRaw)) {
2632
- return fail('error.rules.invalidGroup', `新场景名非法:${nextRaw}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头)`);
2633
- }
2634
- if (name === GLOBAL_SCENE)
2635
- return fail('error.rules.reservedScene', '「全局」是保留场景,不可改名', { name: GLOBAL_SCENE, action: 'rename', reason: '' });
2636
- if (name === SHARED_GROUP || nextRaw === SHARED_GROUP)
2637
- return fail('error.rules.invalidGroup', '_shared 是保留场景名,不可改名');
2638
- if ((index.scenes && index.scenes[nextRaw]) || (await pathExists(join(rulesRoot, nextRaw)))) {
2639
- return fail('error.rules.nameTaken', `目标场景名已被占用:${nextRaw}`);
2640
- }
2641
- if (index.mode && index.mode.scene === name) {
2642
- return fail('error.rules.sceneInMode', `场景「${name}」正处在当前模式,请先退出模式再改名`);
2643
- }
2644
- const fromDir = join(rulesRoot, name);
2645
- const toDir = join(rulesRoot, nextRaw);
2646
- let movedDir = false;
2647
- if (await pathExists(fromDir)) {
2648
- try {
2649
- await rename(fromDir, toDir);
2650
- movedDir = true;
2651
- }
2652
- catch (e) {
2653
- return fail('error.rules.ioFailed', `场景目录改名失败:${message(e)}`);
2654
- }
2655
- }
2656
- // 目录已经搬过去了:索引这一步失败就必须把目录搬回来,否则目录名与索引各说各话。
2657
- try {
2658
- const scenes = index.scenes || {};
2659
- if (scenes[name]) {
2660
- scenes[nextRaw] = scenes[name];
2661
- delete scenes[name];
2662
- }
2663
- else {
2664
- scenes[nextRaw] = { order: DEFAULT_GROUP_ORDER, createdAt: new Date().toISOString() };
2665
- }
2666
- index.scenes = scenes;
2667
- if (index.archives && index.archives[name]) {
2668
- index.archives[nextRaw] = index.archives[name];
2669
- delete index.archives[name];
2670
- }
2671
- const act = normalizeActive(index.active);
2672
- if (act !== null && act.indexOf(name) >= 0)
2673
- index.active = act.map((n) => (n === name ? nextRaw : n));
2674
- if (index.mode && index.mode.scene === name)
2675
- index.mode = { ...index.mode, scene: nextRaw };
2676
- }
2677
- catch (e) {
2678
- if (movedDir) {
2679
- try {
2680
- await rename(toDir, fromDir);
2681
- }
2682
- catch { /* 回滚失败:错误信息里如实带上原因 */ }
2683
- }
2684
- return fail('error.rules.ioFailed', `场景改名后索引更新失败:${message(e)}`);
2685
- }
2686
- finalName = nextRaw;
2687
- }
2688
- if (finalName === GLOBAL_SCENE)
2689
- next.label = GLOBAL_SCENE_LABEL;
2690
- index.scenes[finalName] = next;
2691
- await writeIndex(stateDir, index);
2692
- invalidateSnapshot();
2693
- return {
2694
- ok: true,
2695
- ...(finalName === name ? {} : { renamedFrom: name }),
2696
- scene: sceneRecordOf(finalName, next),
2697
- };
2698
- }
2699
- /**
2700
- * 场景锁定(v0.8):锁定后五个管理域(MCP/技能/子智能体/记忆/提示词)整体只读 ——
2701
- * 场景页的档案编辑与功能页的启停/编辑都被拒(index.ts 的 handlers 守卫 + 界面禁用),
2702
- * 场景自身的启停(进/退模式)不受影响。`locked` 必须显式给布尔值(与 rules-toggle 同一口径)。
2703
- * 保留场景 `global` 不在场景页出现,不可锁。
2704
- */
2705
- async function rulesSceneLock(args) {
2706
- const name = String((args && args.scene) || '').trim();
2707
- if (!isValidGroupPath(name))
2708
- return fail('error.rules.invalidGroup', `场景名非法:${name || '(空)'}`);
2709
- if (name === GLOBAL_SCENE || name === SHARED_GROUP)
2710
- return fail('error.rules.reservedScene', '「全局 / _shared」是保留场景,不可锁定', { name: '全局 / _shared', action: 'lock', reason: '' });
2711
- if (typeof (args && args.locked) !== 'boolean') {
2712
- return fail('error.rules.invalidArgs', '缺少参数:locked 必须是布尔值(只按传入值写入,不做"翻转"推断)');
2713
- }
2714
- const index = await readIndex(stateDir);
2715
- if (!index.scenes || !index.scenes[name])
2716
- return fail('error.rules.notFound', `场景不存在:${name}`);
2717
- // 未启动的场景不能上锁(用户裁定):锁定的意义是冻结**运行中**场景的配置。
2718
- // 解锁随时允许 —— 否则退出模式后,被锁的场景就没人能解了。
2719
- if (args.locked === true && (!index.mode || index.mode.scene !== name)) {
2720
- return fail('error.rules.sceneNotActive', `场景「${name}」未启动:先启动再锁定`);
2721
- }
2722
- index.scenes[name] = { ...index.scenes[name], locked: args.locked === true };
2723
- await writeIndex(stateDir, index);
2724
- invalidateSnapshot();
2725
- return { ok: true, scene: name, locked: args.locked === true };
2726
- }
2727
- /**
2728
- * 删除场景:**仅空目录可删**(避免一次操作带走整组记忆)。
2729
- * 同时删掉场景记录、把它从 `active` 集合里摘掉、清掉它的档案,避免悬空引用。
2730
- * 保留场景 `global` 不可删除;场景名口径与创建一致(单个路径段)。
2731
- */
2732
- async function rulesRemoveScene(args) {
2733
- const name = String((args && args.name) || '').trim();
2734
- if (!isValidGroupSegment(name))
2735
- return fail('error.rules.invalidGroup', `场景名非法:${name || '(空)'}`);
2736
- if (name === SHARED_GROUP)
2737
- return fail('error.rules.invalidGroup', `_shared 是保留场景名,不可删除`);
2738
- if (name === GLOBAL_SCENE)
2739
- return fail('error.rules.reservedScene', `「全局」是保留场景,不可删除(它的记忆对任何对话都生效)`, { name: GLOBAL_SCENE, action: 'delete', reason: '(它的记忆对任何对话都生效)' });
2740
- const index = await readIndex(stateDir);
2741
- // 场景不存在(既无记录也无目录)→ 明确报错,而不是假装删成功。
2742
- if (!index.scenes?.[name] && !(await pathExists(join(rulesRoot, name)))) {
2743
- return fail('error.rules.notFound', `场景不存在:${name}`);
2744
- }
2745
- // 当前模式场景不可删:快照只在引擎里可退(rules service 反向注入会成环),
2746
- // 直接删除会让运行时启停永久停在档案态且无恢复路径 → 给出可逆出路(先退出模式)。
2747
- if (index.mode?.scene === name) {
2748
- return fail('error.rules.sceneInMode', `场景「${name}」正处在当前模式,请先退出模式再删除`);
2749
- }
2750
- // 删除场景 = **连它下面的全部记忆一起去掉**(用户裁定):不管有没有启用、是不是子目录、
2751
- // 是不是 bundle 附件,统统收走。
2752
- // 但一律**先移入回收站**(与场景记录、档案装在同一条条目里),所以「删错了」还能整条恢复:
2753
- // 记忆文件按原来的相对路径放回,场景记录与档案也一起回来。
2754
- const sceneDir = join(rulesRoot, name);
2755
- const moves = [];
2756
- const collect = async (abs, rel) => {
2757
- let items = [];
2758
- try {
2759
- items = await readdir(abs, { withFileTypes: true });
2760
- }
2761
- catch {
2762
- return; // 目录不存在(只有记录的场景)或读不了 → 当作没有文件
2763
- }
2764
- for (const item of items) {
2765
- const childAbs = join(abs, item.name);
2766
- const childRel = rel === '' ? item.name : `${rel}/${item.name}`;
2767
- if (item.isDirectory())
2768
- await collect(childAbs, childRel);
2769
- else
2770
- moves.push({ from: childAbs, dest: childRel });
2771
- }
2772
- };
2773
- await collect(sceneDir, '');
2774
- const trashed = await moveToTrash('scenes', name, moves, {
2775
- record: (index.scenes && index.scenes[name]) || null,
2776
- archive: (index.archives && index.archives[name]) || null,
2777
- memories: moves.map((m) => m.dest),
2778
- });
2779
- if (trashed.ok === false) {
2780
- return fail('error.rules.ioFailed', `移入回收站失败:${trashed.error}`);
2781
- }
2782
- try {
2783
- // 文件已搬空,剩下的只是空子目录;recursive + force 一并清掉。
2784
- await rm(sceneDir, { recursive: true, force: true });
2785
- }
2786
- catch (e) {
2787
- // 目录没清掉就把记忆放回去:宁可整个操作失败,也不要「文件在回收站、场景还留在列表里」。
2788
- for (const move of moves) {
2789
- try {
2790
- await moveOutOfTrash('scenes', trashed.id, move.dest, move.from);
2791
- }
2792
- catch { /* 尽力而为 */ }
2793
- }
2794
- try {
2795
- await purgeTrashEntry('scenes', trashed.id);
2796
- }
2797
- catch { /* 同上 */ }
2798
- return fail('error.rules.ioFailed', `删除场景目录失败:${message(e)}`);
2799
- }
2800
- // 索引清理一次读-改-写:记录 + 启用集合悬空引用 + 该场景档案(否则 archives 留孤儿条目)。
2801
- let dirty = false;
2802
- if (index.scenes && index.scenes[name]) {
2803
- delete index.scenes[name];
2804
- dirty = true;
2805
- }
2806
- if (Array.isArray(index.active) && index.active.indexOf(name) >= 0) {
2807
- index.active = index.active.filter((s) => s !== name);
2808
- dirty = true;
2809
- }
2810
- if (index.archives && index.archives[name]) {
2811
- delete index.archives[name];
2812
- dirty = true;
2813
- }
2814
- if (dirty)
2815
- await writeIndex(stateDir, index);
2816
- invalidateSnapshot();
2817
- return { ok: true, name, trashId: trashed.id, movedFiles: moves.length };
2818
- }
2819
- /** 场景回收站列表(只读):`<hub>/trash/scenes-trash/<id>/manifest.json`。 */
2820
- async function sceneTrashList() {
2821
- return { ok: true, trash: await listTrashEntries('scenes') };
2822
- }
2823
- /**
2824
- * 从回收站恢复一个场景:重建记忆目录 + 写回记录与档案。
2825
- * 同名场景已存在时**拒绝**(绝不覆盖既有场景)。
2826
- */
2827
- async function sceneTrashRestore(args) {
2828
- const id = String((args && args.id) || '').trim();
2829
- const entry = await readTrashEntry('scenes', id);
2830
- if (!entry)
2831
- return fail('error.rules.notFound', `回收站条目不存在:${id}`);
2832
- const name = String(entry.name || '').trim();
2833
- if (!isValidGroupSegment(name))
2834
- return fail('error.rules.invalidGroup', `回收站里的场景名非法:${name || '(空)'}`);
2835
- const index = await readIndex(stateDir);
2836
- if (index.scenes?.[name])
2837
- return fail('error.rules.invalidGroup', `无法恢复,同名场景已存在:${name}`);
2838
- const dir = join(rulesRoot, name);
2839
- let entries = [];
2840
- try {
2841
- entries = await readdir(dir);
2842
- }
2843
- catch {
2844
- entries = [];
2845
- }
2846
- if (entries.filter((n) => !n.startsWith('.')).length > 0) {
2847
- return fail('error.rules.sceneNotEmpty', `无法恢复,记忆目录「${name}」里已有内容,请先处理`);
2848
- }
2849
- const data = (entry.data || {});
2850
- try {
2851
- await mkdir(dir, { recursive: true });
2852
- }
2853
- catch (e) {
2854
- return fail('error.rules.ioFailed', `重建场景目录失败:${message(e)}`);
2855
- }
2856
- // 删除场景时一起收进回收站的记忆文件:按原来的相对路径放回(目录已确认是空的,不会覆盖)。
2857
- const files = Array.isArray(entry.files) ? entry.files : [];
2858
- const restored = [];
2859
- for (const rel of files) {
2860
- try {
2861
- await moveOutOfTrash('scenes', id, String(rel), join(dir, String(rel)));
2862
- restored.push(String(rel));
2863
- }
2864
- catch (e) {
2865
- return fail('error.rules.ioFailed', `恢复记忆文件失败(已放回 ${restored.length}/${files.length}):${message(e)}`);
2866
- }
2867
- }
2868
- if (data.record && typeof data.record === 'object')
2869
- index.scenes = { ...(index.scenes || {}), [name]: data.record };
2870
- if (data.archive && typeof data.archive === 'object')
2871
- index.archives = { ...(index.archives || {}), [name]: data.archive };
2872
- await writeIndex(stateDir, index);
2873
- await purgeTrashEntry('scenes', id);
2874
- invalidateSnapshot();
2875
- return { ok: true, name, restoredFiles: restored.length };
2876
- }
2877
- /** 永久删除一条场景回收站条目。 */
2878
- async function sceneTrashDelete(args) {
2879
- const id = String((args && args.id) || '').trim();
2880
- const gone = await purgeTrashEntry('scenes', id);
2881
- if (!gone)
2882
- return fail('error.rules.notFound', `回收站条目不存在:${id}`);
2883
- return { ok: true, id };
2884
- }
2885
- /**
2886
- * 提示词预设改名后**同步场景绑定**:把所有 `scenes[].prompt === from` 改成 `to`。
2887
- * 由 AGENTS.md 预设库的改名路径调用——绑定存在 `memories-index.json` 里,预设库
2888
- * 自己看不到它,不叫这一声改名就会留下悬空绑定(场景卡片显示「预设不存在」)。
2889
- * @returns 改了几个场景。
2890
- */
2891
- async function rulesRebindPrompt(args) {
2892
- const from = String((args && args.from) || '').trim();
2893
- const to = String((args && args.to) || '').trim();
2894
- if (!from || !to)
2895
- return fail('error.rules.invalidArgs', '需要 from 与 to 两个预设 id');
2896
- const index = await readIndex(stateDir);
2897
- const scenes = index.scenes || {};
2898
- let changed = 0;
2899
- for (const [name, entry] of Object.entries(scenes)) {
2900
- if (entry && entry.prompt === from) {
2901
- scenes[name] = { ...entry, prompt: to };
2902
- changed++;
2903
- }
2904
- }
2905
- if (changed) {
2906
- index.scenes = scenes;
2907
- await writeIndex(stateDir, index);
2908
- invalidateSnapshot();
2909
- }
2910
- return { ok: true, from, to, changed };
2911
- }
2912
- async function rulesSetIndex(args) {
2913
- const id = String((args && args.id) || '');
2914
- const parts = parseId(id);
2915
- if (!parts)
2916
- return fail('error.rules.notFound', `规则不存在:${id}`);
2917
- const index = await readIndex(stateDir);
2918
- const idxEntry = index.rules[id] || {};
2919
- const next = { ...idxEntry, updatedAt: new Date().toISOString() };
2920
- if (args && args.order !== undefined) {
2921
- const order = Number(args.order);
2922
- if (!Number.isFinite(order) || order < 0)
2923
- return fail('error.rules.invalidGroup', `非法排序值:${args.order}`);
2924
- next.order = Math.floor(order);
2925
- }
2926
- if (args && args.tags !== undefined) {
2927
- next.tags = Array.isArray(args.tags) ? args.tags.map((t) => String(t)) : [];
2928
- }
2929
- if (args && args.pinned !== undefined)
2930
- next.pinned = args.pinned === true;
2931
- if (args && args.note !== undefined)
2932
- next.note = String(args.note);
2933
- index.rules[id] = next;
2934
- await writeIndex(stateDir, index);
2935
- invalidateSnapshot();
2936
- return { ok: true, rule: await buildProjected(id) };
2937
- }
2938
- // ── 组装 ─────────────────────────────────────────────────────────────────
2939
- // 注入文本出口(2026-09-16 第四版)。
2940
- //
2941
- // 历史:本服务先后把场景记忆做成"系统提示词段"(会被 persona complete 压掉)与
2942
- // "写 AGENTS.md 文件"(预设不挂 dsh-agent-instructions 时到不了模型)。现在两条都交给
2943
- // 注入通道(src/context-inject.ts):每个 step 前把文本并进一条注入消息,任何预设都到得了。
2944
- // 这里只留**同步取文本**的两个出口,注册/生命周期由 index.ts 的注入器统一管。
2945
- //
2946
- // - `memoryText`:场景记忆段(记忆正文;`''` = 没有可注入的内容)。
2947
- // - `promptText`:全局提示词正文 = 场景期间用场景提示词、平时用 AGENTS.md 正文。
2948
- // **不判"与文件重复"** —— 预设挂了官方 agent-instructions 时那份正文已经由文件送达
2949
- // (注入侧会跳过整个域),没挂时(极简)才需要注入兜底;判定在注入器的 `selectInjections` 里。
2950
- const memoryText = () => sceneMemory().text;
2951
- /**
2952
- * `~/.dsh/AGENTS.md` 正文的上限,与官方那一行的 `maxBytes` 同量级(64 KiB):超大文件按字节
2953
- * 截断并留一行标记,免得一份手写的巨型基线把上下文撑爆。
2954
- */
2955
- const AGENTS_MD_MAX_BYTES = 65536;
2956
- /**
2957
- * DSH home 的**展示形态**,规则与官方 `dsh-home-paths` 的 `dshHomeDisplay()` 一致
2958
- * (`dsh-home-paths/lib/index.js:93`):默认位置显示 `~/.dsh`,被 `DSH_HOME` 指到别处时
2959
- * 才显示 `$DSH_HOME`。只在给**界面**看的字符串里用 —— 读盘一律用真实路径。
2960
- */
2961
- const dshHomeDisplay = () => {
2962
- const home = resolveDshHome();
2963
- return resolve(home) === resolve(join(homedir(), '.dsh')) ? '~/.dsh' : '$DSH_HOME';
2964
- };
2965
- /**
2966
- * 当前生效的提示词:**来源文件 + 正文**,一次解析、两处出口共用(模型侧 `promptText`、
2967
- * 界面清单 `promptFiles`)。`null` = 现在没有提示词。
2968
- *
2969
- * 为什么合并成一处:两条出口必须给出**同一个**结论(文件是哪个、正文取哪份),分开写两份
2970
- * 同样的分支判断迟早分叉 —— 分叉的后果就是界面标错文件、或模型看到与文件不符的正文。
2971
- * 合并前那里已经有一处小分叉:场景预设在场但正文为空时,文本退回 AGENTS.md、清单却仍报
2972
- * 预设路径。现在两边同源,这类角落不可能再各说各话。
2973
- *
2974
- * `digest` 照抄官方 `instructionContentSha1`(`dsh-agent-instructions/lib/index.js:90`,
2975
- * sha1 hex):算的是**文件内容**(AGENTS.md 取截断前的原文),是"文件这一版"的身份,
2976
- * 不是"这次注入了什么";界面只拿它当悬停提示,不参与任何判定。
2977
- *
2978
- * `text` 是**模型侧**正文,开头一行 `来源:<展示路径>` —— 照抄官方 agent-instructions 的
2979
- * 写法(`Instructions from: <path>`,`render.js` 的 `sectionText`)。模型据此知道这些规则
2980
- * 写在哪个文件里:用户说"把这条记下来"时它知道该改哪份文件,而不是只能凭印象回答。
2981
- */
2982
- const resolvePrompt = () => {
2983
- // 场景提示词与 AGENTS.md 正文在用户眼里就是同一件事("全局提示词"):场景期间前者取代后者,
2984
- // 与官方语义一致(进场景改写文件、退出恢复)。所以一个域、一份文本,取到非空的场景提示词就用它。
2985
- const scene = resolveScenePreset();
2986
- if (scene && scene.text.trim() !== '') {
2987
- const path = `${dshHomeDisplay()}/${HUB_DIR}/${PRESETS_DIR}/${scene.presetId}/AGENTS.md`;
2988
- return {
2989
- path,
2990
- digest: createHash('sha1').update(scene.text).digest('hex'),
2991
- text: `来源:${path}\n\n${scene.text}`,
2992
- };
2993
- }
2994
- const raw = readGlobalAgentsMdSync();
2995
- if (raw.trim() === '')
2996
- return null;
2997
- const path = `${dshHomeDisplay()}/AGENTS.md`;
2998
- const body = Buffer.byteLength(raw, 'utf8') <= AGENTS_MD_MAX_BYTES
2999
- ? raw
3000
- : Buffer.from(raw, 'utf8').subarray(0, AGENTS_MD_MAX_BYTES).toString('utf8') +
3001
- `\n\n(…文件超过 ${Math.floor(AGENTS_MD_MAX_BYTES / 1024)} KiB,已截断。)`;
3002
- return { path, digest: createHash('sha1').update(raw).digest('hex'), text: `来源:${path}\n\n${body}` };
3003
- };
3004
- /**
3005
- * 模型侧的提示词正文(`''` = 没有可注入的内容)。
3006
- *
3007
- * **不判"与文件重复"** —— 预设挂了官方 agent-instructions 时那份正文已经由文件送达
3008
- * (注入侧会跳过整个域),没挂时(极简)才需要注入兜底;判定在注入器的 `selectInjections` 里。
3009
- */
3010
- const promptText = () => resolvePrompt()?.text ?? '';
3011
- /**
3012
- * `promptText()` 的**来源文件**(同步):注入通道把它当作 instructions 形态的
3013
- * `changes` 交给界面(文件清单 + 已载入/已更新),见 context-inject.ts 的 `files`。
3014
- * 与文本同源(都走 `resolvePrompt`),所以两边不会分叉;`[]` = 无正文。
3015
- *
3016
- * 路径用**展示形态**(见 `dshHomeDisplay`):界面里官方 agent-instructions 那条行写的是
3017
- * `~/.dsh/AGENTS.md`,我们写绝对路径的话,同一个界面上会出现两种风格。
3018
- */
3019
- const promptFiles = () => {
3020
- const resolved = resolvePrompt();
3021
- return resolved === null ? [] : [{ path: resolved.path, digest: resolved.digest }];
3022
- };
3023
- /** 写操作:串行队列内执行,成功后触发 refresh(失效缓存,下一请求即生效)。 */
3024
- const runWrite = (task) => enqueueMutation(async () => {
3025
- const result = await task();
3026
- if (result && result.ok !== false)
3027
- await refresh();
3028
- return result;
3029
- });
3030
- // 写操作清单:与下方 ops 表同文件同源维护(含此前漂移漏掉的
3031
- // rules-attach / rules-detach / rules-trash-remove);HTTP 端门禁由
3032
- // index.ts 从本集合派生,勿在宿主端另抄一份。
3033
- const writeOps = new Set([
3034
- 'rules-create', 'rules-update', 'rules-remove', 'rules-restore', 'rules-toggle', 'rules-import',
3035
- 'rules-set-index', 'rules-set-active', 'rules-create-scene', 'rules-update-scene', 'rules-remove-scene', 'rules-rebind-prompt',
3036
- 'rules-scene-lock',
3037
- 'rules-attach', 'rules-detach', 'rules-trash-remove',
3038
- // 场景回收站(与记忆回收站 rules-trash-* 分开:那套管记忆正文,这套管场景记录与档案)
3039
- 'scene-trash-restore', 'scene-trash-delete',
3040
- ]);
3041
- const ops = {
3042
- 'rules-list': (args) => rulesList(args || {}),
3043
- 'rules-read': (args) => rulesRead(args || {}),
3044
- 'rules-budget': () => rulesBudget(),
3045
- 'rules-diagnose': () => rulesDiagnose(),
3046
- 'rules-create': (args) => runWrite(() => rulesCreate(args || {})),
3047
- 'rules-import': (args) => runWrite(() => rulesImport(args || {})),
3048
- 'rules-update': (args) => runWrite(() => rulesUpdate(args || {})),
3049
- 'rules-remove': (args) => runWrite(() => rulesRemove(args || {})),
3050
- 'rules-restore': (args) => runWrite(() => rulesRestore(args || {})),
3051
- 'rules-trash-list': () => rulesTrashList(),
3052
- 'rules-trash-remove': (args) => runWrite(() => rulesTrashRemove(args || {})),
3053
- 'rules-attach': (args) => runWrite(() => rulesAttach(args || {})),
3054
- 'rules-detach': (args) => runWrite(() => rulesDetach(args || {})),
3055
- 'rules-toggle': (args) => runWrite(() => rulesToggle(args || {})),
3056
- 'rules-set-index': (args) => runWrite(() => rulesSetIndex(args || {})),
3057
- 'rules-set-active': (args) => runWrite(() => rulesSetActive(args || {})),
3058
- 'rules-create-scene': (args) => runWrite(() => rulesCreateScene(args || {})),
3059
- 'rules-update-scene': (args) => runWrite(() => rulesUpdateScene(args || {})),
3060
- 'rules-scene-lock': (args) => runWrite(() => rulesSceneLock(args || {})),
3061
- 'rules-remove-scene': (args) => runWrite(() => rulesRemoveScene(args || {})),
3062
- 'scene-trash-list': () => sceneTrashList(),
3063
- 'scene-trash-restore': (args) => runWrite(() => sceneTrashRestore(args || {})),
3064
- 'scene-trash-delete': (args) => runWrite(() => sceneTrashDelete(args || {})),
3065
- 'rules-rebind-prompt': (args) => runWrite(() => rulesRebindPrompt(args || {})),
3066
- };
3067
- const service = {
3068
- ops,
3069
- writeOps,
3070
- memoryText,
3071
- promptText,
3072
- promptFiles,
3073
- refresh,
3074
- patchIndex,
3075
- readArchiveSlice,
3076
- };
3077
- return service;
3078
- }