dsh-plugin-tool-management 0.1.3 → 0.2.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.
@@ -0,0 +1,1657 @@
1
+ // dsh-plugin-tool-management —— 规则/记忆(Rules v0.3,见 CHANGE-REQUEST-01)服务层。
2
+ //
3
+ // 记忆 = $DSH_HOME/scene-memory/<场景>/<name>.md(flat)或 <场景>/<name>/SKILL.md(bundle)。
4
+ // **场景 = scene-memory/ 下的一级目录**,目录名即场景名(任意 Unicode,见 isValidGroupSegment)。
5
+ // 勾选启用后,该目录树内所有 .md 的正文自动进入系统提示词(provider.ts 注册的
6
+ // per-agent systemPrompt 段),模型无需做任何动作 —— 这就是"不用每次都要解释"。
7
+ //
8
+ // 单投影(原 ADR-4 的"双投影"已被本变更单修订):
9
+ // - 活动场景记忆 → systemPrompt 段(自动在场,会话级恒定 → 前缀稳定、缓存可命中)
10
+ // - `_shared/` 承担"恒常"语义(所有场景共用);原 per-rule `always` 标志已移除
11
+ // - 不再写 ~/.dsh/AGENTS.md(原始终层投影下线)
12
+ //
13
+ // 状态分层(两份文件各司其职,互不写回):
14
+ // - 规则文件:正文真源。frontmatter 可声明 name/description/whenToUse/globs/metadata。
15
+ // - rules-index.json:启停/排序/标签/启用场景集合等**索引为准**字段(不写回记忆文件)。
16
+ //
17
+ // 发现必须自实现(不复用 readonly-discovery):可写来源只扫一层会压扁子目录场景,
18
+ // 而规则的目录树天然是多层的;且 readonly-discovery 的 flat 只认顶层。
19
+ //
20
+ // 缓存红线(§5.2):段内容只由「启用场景 + 文件内容」决定,禁止时间戳/计数/相对时间;
21
+ // 场景组合或记忆文件不变 ⇒ 逐字节稳定 ⇒ 前缀缓存命中。切换场景/编辑记忆只变化一次。
22
+ //
23
+ // 错误约定:业务校验失败返回 { ok:false, error: 中文, code, params? }(与 skills core 一致);
24
+ // ops 成功返回扁平 { ok:true, ... },不套 { ok:true, data }。
25
+ import { randomUUID } from 'node:crypto';
26
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
27
+ import { copyFile, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
28
+ import { basename, dirname, join, resolve } from 'node:path';
29
+ import { parseSkillDoc, resolveDshHome, unquote } from '../skills/core.js';
30
+ import { createRuleProviderRegistrar } from './provider.js';
31
+ // ── 常量 ───────────────────────────────────────────────────────────────────
32
+ const MAX_SOURCE_DEPTH = 64; // 与 core.js 一致
33
+ const MAX_DIRECTORIES = 2000; // 目录预算
34
+ const MAX_ENTRIES = 20000; // 条目预算
35
+ const MAX_GROUP_SEGMENT_LENGTH = 64; // 场景/子目录段名长度上限
36
+ const MAX_DESCRIPTION_LENGTH = 500; // 派生/显式描述上限(派生超长截断,显式超长拒绝)
37
+ const MAX_RULE_BYTES = 1 << 18; // 正文上限 256 KiB
38
+ const DEFAULT_ORDER = 1000; // 默认投影 order(索引无记录时)
39
+ const DEFAULT_GROUP_ORDER = 1000; // 新场景默认 order
40
+ const SNAPSHOT_TTL_MS = 1000; // 读路径短 TTL 缓存,吸收 UI 密集轮询
41
+ const DEFAULT_MAX_BYTES = 65536; // 场景记忆段预算上限(字节)
42
+ const TRUNCATION_MARKER = '<!-- truncated -->';
43
+ const DROPPED_HEADING = '## 场景记忆:未注入(超出预算)'; // 段尾清单:让模型知道自己漏了什么
44
+ // bundle 附件限制(body 走 HTTP JSON + base64,故比技能上传收紧一档)。
45
+ const MAX_ATTACH_ENTRY_BYTES = 8 << 20; // 单个附件 8 MiB
46
+ const MAX_ATTACH_TOTAL_BYTES = 16 << 20; // 单次总大小 16 MiB
47
+ const MAX_ATTACH_ENTRIES = 32; // 单次最多 32 个
48
+ const BUNDLE_DOC = 'SKILL.md'; // bundle 的正文文件(附件列表里排除)
49
+ const SHARED_GROUP = '_shared'; // 保留场景名:公共基线,所有场景共用、始终生效
50
+ const INDEX_VERSION = 1;
51
+ // 场景/子目录段名约束(§4 对照表 + §5.3):任意 Unicode,但必须对文件系统安全。
52
+ // - 非空、长度 ≤64、不等于 `.` / `..`、不以 `.` 开头(隐藏目录语义冲突)
53
+ // - 不含路径分隔符 `/` `\`,不含 Windows 保留字符 `< > : " | ? *`
54
+ // - 首尾无空白、末尾不是 `.` 或空格(Windows 会静默裁剪,导致路径与显示名不一致)
55
+ const WINDOWS_RESERVED_RE = /[<>:"|?*\\/]/;
56
+ /** 单个路径段(场景名或子目录名)是否合法。放宽后 `办公` / `日常` 均通过。 */
57
+ export function isValidGroupSegment(segment) {
58
+ if (typeof segment !== 'string')
59
+ return false;
60
+ if (segment === '' || segment === '.' || segment === '..')
61
+ return false;
62
+ if (segment.length > MAX_GROUP_SEGMENT_LENGTH)
63
+ return false;
64
+ if (segment.startsWith('.'))
65
+ return false;
66
+ if (WINDOWS_RESERVED_RE.test(segment))
67
+ return false;
68
+ if (segment !== segment.trim())
69
+ return false;
70
+ if (/[.\s]$/.test(segment))
71
+ return false;
72
+ return true;
73
+ }
74
+ /** group/场景路径可多层(a/b/c),每段必须合法;_shared 作为保留场景名放行。 */
75
+ export function isValidGroupPath(group) {
76
+ if (typeof group !== 'string' || group === '' || group.startsWith('/') || group.endsWith('/'))
77
+ return false;
78
+ return group.split('/').every(isValidGroupSegment);
79
+ }
80
+ const message = (e) => String((e && e.message) || e);
81
+ const fail = (code, error, params) => (params ? { ok: false, error, code, params } : { ok: false, error, code });
82
+ const identity = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
83
+ /** 记忆根目录名($DSH_HOME 下)。v0.3 由 `rules` 更名为 `scene-memory`。 */
84
+ export const SCENE_MEMORY_DIR = 'scene-memory';
85
+ // ── 文件工具 ───────────────────────────────────────────────────────────────
86
+ /** 同目录临时文件 + rename 原子写(临时名 `.xxx.dsh-rules-<uuid>.tmp`)。 */
87
+ async function writeFileAtomically(path, content) {
88
+ const temp = join(dirname(path), `.${basename(path)}.dsh-rules-${randomUUID()}.tmp`);
89
+ try {
90
+ await writeFile(temp, content, 'utf8');
91
+ await rename(temp, path);
92
+ }
93
+ catch (error) {
94
+ await rm(temp, { force: true }).catch(() => undefined);
95
+ throw error;
96
+ }
97
+ }
98
+ /** 二进制版原子写(附件用):临时文件 + rename,失败清理临时文件。 */
99
+ async function writeFileAtomicBinary(path, data) {
100
+ const temp = join(dirname(path), `.${basename(path)}.dsh-rules-${randomUUID()}.tmp`);
101
+ try {
102
+ await writeFile(temp, data);
103
+ await rename(temp, path);
104
+ }
105
+ catch (error) {
106
+ await rm(temp, { force: true }).catch(() => undefined);
107
+ throw error;
108
+ }
109
+ }
110
+ // ── 递归发现 ───────────────────────────────────────────────────────────────
111
+ /**
112
+ * BFS 发现(realpath 防环、深度/目录/条目预算),与 readonly-discovery 同构,
113
+ * 但规则的场景是**多层相对路径**(readonly-discovery 的 group 恒为第一层)。
114
+ *
115
+ * 目录语义:目录含 SKILL.md → 它是 bundle 规则(叶子,不再深入),其「父路径」
116
+ * 是场景、目录名是规则名;否则它是场景/子分类,继续遍历其下 .md(flat,任意层级)
117
+ * 与子目录。同名 flat 与 bundle 冲突时 bundle 优先,flat 记入 shadowed。
118
+ */
119
+ async function discover(rulesRoot) {
120
+ const entries = new Map();
121
+ const shadowed = [];
122
+ const groups = new Set();
123
+ const warnings = [];
124
+ const rootPath = resolve(rulesRoot);
125
+ try {
126
+ const st = await lstat(rootPath);
127
+ if (!st.isDirectory() || st.isSymbolicLink())
128
+ return { entries, shadowed, groups, warnings, truncated: false };
129
+ }
130
+ catch {
131
+ return { entries, shadowed, groups, warnings, truncated: false };
132
+ }
133
+ const queue = [
134
+ { path: rootPath, group: '', depth: 0, ancestors: new Set() },
135
+ ];
136
+ let directories = 0;
137
+ let itemCount = 0;
138
+ let truncated = false;
139
+ const addEntry = (entry) => {
140
+ const existing = entries.get(entry.id);
141
+ if (!existing) {
142
+ entries.set(entry.id, entry);
143
+ return;
144
+ }
145
+ if (entry.kind === 'bundle' && existing.kind === 'flat') {
146
+ shadowed.push({ ...existing, shadowed: true });
147
+ entries.set(entry.id, entry);
148
+ return;
149
+ }
150
+ if (entry.kind === 'flat' && existing.kind === 'bundle') {
151
+ shadowed.push({ ...entry, shadowed: true });
152
+ }
153
+ };
154
+ for (let cursor = 0; cursor < queue.length; cursor++) {
155
+ if (directories >= MAX_DIRECTORIES || itemCount >= MAX_ENTRIES) {
156
+ truncated = true;
157
+ break;
158
+ }
159
+ const current = queue[cursor];
160
+ let realDirectory;
161
+ let dirents = [];
162
+ try {
163
+ realDirectory = await realpath(current.path);
164
+ if (current.ancestors.has(identity(realDirectory)))
165
+ continue;
166
+ const dir = await readdir(current.path, { withFileTypes: true });
167
+ directories++;
168
+ for (const item of dir) {
169
+ if (itemCount >= MAX_ENTRIES) {
170
+ truncated = true;
171
+ break;
172
+ }
173
+ itemCount++;
174
+ dirents.push(item);
175
+ }
176
+ }
177
+ catch {
178
+ continue; // 失效链接或不可读目录不阻断其他来源
179
+ }
180
+ // bundle 检查:当前目录(非根)含 SKILL.md → 它是规则叶子,父路径为场景。
181
+ if (current.group !== '') {
182
+ const docPath = join(current.path, 'SKILL.md');
183
+ try {
184
+ const st = await lstat(docPath);
185
+ if (st.isFile() && !st.isSymbolicLink()) {
186
+ const sepIdx = current.group.lastIndexOf('/');
187
+ const parentGroup = sepIdx >= 0 ? current.group.slice(0, sepIdx) : '';
188
+ const name = sepIdx >= 0 ? current.group.slice(sepIdx + 1) : current.group;
189
+ const id = parentGroup ? `${parentGroup}/${name}` : name;
190
+ addEntry({ id, group: parentGroup, name, kind: 'bundle', docPath, entryPath: current.path });
191
+ continue;
192
+ }
193
+ }
194
+ catch {
195
+ /* 无 SKILL.md → 作为场景/子分类继续 */
196
+ }
197
+ }
198
+ if (current.group !== '')
199
+ groups.add(current.group);
200
+ dirents.sort((a, b) => a.name.localeCompare(b.name));
201
+ const ancestors = new Set(current.ancestors).add(identity(realDirectory));
202
+ for (const item of dirents) {
203
+ if (item.name.startsWith('.'))
204
+ continue; // 隐藏目录/文件跳过
205
+ const path = join(current.path, item.name);
206
+ try {
207
+ const st = await lstat(path);
208
+ if (st.isDirectory() || st.isSymbolicLink()) {
209
+ if (st.isSymbolicLink() && !(await statDirIsDirectory(path)))
210
+ continue;
211
+ if (!isValidGroupSegment(item.name)) {
212
+ warnings.push(`跳过非法目录名(含路径分隔符 / Windows 保留字符 / 首尾空白或点,或超过 ${MAX_GROUP_SEGMENT_LENGTH} 字符):${current.group ? current.group + '/' : ''}${item.name}`);
213
+ continue;
214
+ }
215
+ if (current.depth >= MAX_SOURCE_DEPTH || queue.length >= MAX_DIRECTORIES) {
216
+ truncated = true;
217
+ continue;
218
+ }
219
+ queue.push({
220
+ path,
221
+ group: current.group ? `${current.group}/${item.name}` : item.name,
222
+ depth: current.depth + 1,
223
+ ancestors,
224
+ });
225
+ continue;
226
+ }
227
+ if (!st.isFile())
228
+ continue;
229
+ if (!item.name.toLowerCase().endsWith('.md'))
230
+ continue; // 只认 .md
231
+ const name = item.name.slice(0, -3);
232
+ const id = current.group ? `${current.group}/${name}` : name;
233
+ addEntry({ id, group: current.group, name, kind: 'flat', docPath: path, entryPath: path });
234
+ }
235
+ catch {
236
+ /* 来源在扫描期间失效时跳过 */
237
+ }
238
+ }
239
+ }
240
+ return { entries, shadowed, groups, warnings, truncated };
241
+ }
242
+ /** 符号链接目录:lstat 得链接后,stat 确认为目录才进入(防链接到文件)。 */
243
+ async function statDirIsDirectory(path) {
244
+ try {
245
+ return (await stat(path)).isDirectory();
246
+ }
247
+ catch {
248
+ return false;
249
+ }
250
+ }
251
+ // ── 规则解析 + 派生 ────────────────────────────────────────────────────────
252
+ /** 派生 description:首个 `#` 标题 → 首个非空行;截断 500 字符。 */
253
+ function deriveDescription(body) {
254
+ const text = String(body ?? '').trim();
255
+ if (!text)
256
+ return '';
257
+ const heading = /^#\s+(.+)$/m.exec(text);
258
+ const raw = heading && heading[1].trim() ? heading[1].trim() : text.split(/\r?\n/, 1)[0].trim();
259
+ return raw.slice(0, MAX_DESCRIPTION_LENGTH);
260
+ }
261
+ /** 从 frontmatter/正文派生字段(派生只存在于内存投影,不写回文件)。 */
262
+ function deriveFromDoc(entry, doc) {
263
+ const declaredName = doc.map.name != null && String(doc.map.name).trim() !== '' ? unquote(String(doc.map.name)).trim() : '';
264
+ const name = declaredName || entry.name;
265
+ let description = doc.map.description != null ? unquote(String(doc.map.description)).trim() : '';
266
+ let descriptionDerived = false;
267
+ if (description === '') {
268
+ description = deriveDescription(doc.body);
269
+ descriptionDerived = true;
270
+ }
271
+ const globs = doc.map.globs != null && String(doc.map.globs).trim() !== '' ? String(doc.map.globs).trim() : undefined;
272
+ let whenToUse;
273
+ if (doc.map.whenToUse != null && String(doc.map.whenToUse).trim() !== '') {
274
+ whenToUse = String(doc.map.whenToUse).trim();
275
+ }
276
+ else if (globs) {
277
+ whenToUse = `适用路径:${globs}`;
278
+ }
279
+ const metadata = doc.map.metadata !== undefined ? doc.map.metadata : undefined;
280
+ return { name, description, descriptionDerived, whenToUse, globs, metadata };
281
+ }
282
+ /** 合并索引字段(enabled/order 等以索引为准;无记录走默认投影)。 */
283
+ function projectRule(entry, derived, idxEntry) {
284
+ const rule = {
285
+ id: entry.id,
286
+ group: entry.group,
287
+ name: derived.name,
288
+ form: entry.kind,
289
+ path: entry.docPath,
290
+ description: derived.description,
291
+ ...(derived.descriptionDerived ? { descriptionDerived: true } : {}),
292
+ ...(derived.whenToUse !== undefined ? { whenToUse: derived.whenToUse } : {}),
293
+ ...(derived.globs !== undefined ? { globs: derived.globs } : {}),
294
+ ...(derived.metadata !== undefined ? { metadata: derived.metadata } : {}),
295
+ enabled: idxEntry?.enabled ?? true,
296
+ order: idxEntry?.order ?? DEFAULT_ORDER,
297
+ tags: Array.isArray(idxEntry?.tags) ? idxEntry.tags : [],
298
+ pinned: idxEntry?.pinned === true,
299
+ note: typeof idxEntry?.note === 'string' ? idxEntry.note : '',
300
+ ...(typeof idxEntry?.updatedAt === 'string' ? { updatedAt: idxEntry.updatedAt } : {}),
301
+ };
302
+ return rule;
303
+ }
304
+ // ── 侧车文件(索引 / 场景)─────────────────────────────────────────────────
305
+ const defaultIndex = () => ({ version: INDEX_VERSION, rules: {}, groups: {}, active: null });
306
+ /** 解析 rules-index.json 原文;任何异常/版本不符 → 默认索引(容忍缺失,§9.3)。 */
307
+ function parseIndex(raw) {
308
+ const parsed = JSON.parse(raw);
309
+ if (!parsed || parsed.version !== INDEX_VERSION || typeof parsed.rules !== 'object' || parsed.rules === null)
310
+ throw new Error('bad index');
311
+ return {
312
+ version: INDEX_VERSION,
313
+ rules: (parsed.rules || {}),
314
+ groups: (parsed.groups || {}),
315
+ active: normalizeActive(parsed.active),
316
+ };
317
+ }
318
+ async function readIndex(stateDir) {
319
+ try {
320
+ return parseIndex(await readFile(join(stateDir, 'rules-index.json'), 'utf8'));
321
+ }
322
+ catch {
323
+ return defaultIndex();
324
+ }
325
+ }
326
+ /** 同步读索引:systemPrompt 段的渲染路径不能 await(见文件内「两相扫描」注释)。 */
327
+ function readIndexSync(stateDir) {
328
+ const raw = readFileIfExistsSync(join(stateDir, 'rules-index.json'));
329
+ if (raw === null)
330
+ return defaultIndex();
331
+ try {
332
+ return parseIndex(raw);
333
+ }
334
+ catch {
335
+ return defaultIndex();
336
+ }
337
+ }
338
+ async function writeIndex(stateDir, index) {
339
+ await mkdir(stateDir, { recursive: true });
340
+ await writeFileAtomically(join(stateDir, 'rules-index.json'), JSON.stringify(index, null, 2));
341
+ }
342
+ /** 启用场景集合归一化:非数组 → null(= 全部启用);数组 → 去重后的字符串数组。 */
343
+ function normalizeActive(raw) {
344
+ if (!Array.isArray(raw))
345
+ return null;
346
+ const out = [];
347
+ const seen = new Set();
348
+ for (const item of raw) {
349
+ if (typeof item !== 'string')
350
+ continue;
351
+ const name = item.trim();
352
+ if (name === '' || seen.has(name))
353
+ continue;
354
+ seen.add(name);
355
+ out.push(name);
356
+ }
357
+ return out;
358
+ }
359
+ /**
360
+ * 活动场景解析(§4 对照表):
361
+ * - `index.active` 缺失 / null → **全部场景启用**(默认;保证"丢进去就有用")
362
+ * - `index.active` 为数组 → 只有列出的场景启用(显式收窄)
363
+ * - `_shared` 恒常启用(公共基线),不受开关影响
364
+ * 缺失 rules-index.json 一律按默认值运行("全部场景启用"),不抛错(§9.3)。
365
+ * 场景生效与否只由 index.active 决定,不读 scenes.json(preset 绑定已移除)。
366
+ */
367
+ function resolveActiveScenes(index, knownScenes) {
368
+ const stored = normalizeActive(index.active);
369
+ const mode = stored === null ? 'all' : 'custom';
370
+ const active = new Set(stored === null ? knownScenes : stored);
371
+ active.add(SHARED_GROUP);
372
+ return { active, mode };
373
+ }
374
+ /** 场景名 = 分组路径的第一段(`web/frontend` 属于场景 `web`)。 */
375
+ function sceneOf(group) {
376
+ const idx = String(group || '').indexOf('/');
377
+ return idx >= 0 ? group.slice(0, idx) : group;
378
+ }
379
+ // ── 快照(发现 + 索引合并 + 索引清理)──────────────────────────────────────
380
+ async function buildSnapshot(rulesRoot, stateDir) {
381
+ const discovery = await discover(rulesRoot);
382
+ let index = await readIndex(stateDir);
383
+ // 索引有记录但文件已删 → 清理记录(磁盘与索引保持一致)。
384
+ let dirty = false;
385
+ for (const id of Object.keys(index.rules)) {
386
+ if (!discovery.entries.has(id)) {
387
+ delete index.rules[id];
388
+ dirty = true;
389
+ }
390
+ }
391
+ for (const group of Object.keys(index.groups)) {
392
+ if (!discovery.groups.has(group)) {
393
+ delete index.groups[group];
394
+ dirty = true;
395
+ }
396
+ }
397
+ if (dirty)
398
+ await writeIndex(stateDir, index);
399
+ const rules = [];
400
+ const bodies = new Map();
401
+ const entries = new Map();
402
+ for (const entry of discovery.entries.values()) {
403
+ try {
404
+ const text = await readFile(entry.docPath, 'utf8');
405
+ const doc = parseSkillDoc(text);
406
+ const derived = deriveFromDoc(entry, doc);
407
+ rules.push(projectRule(entry, derived, index.rules[entry.id]));
408
+ bodies.set(entry.id, doc.body);
409
+ entries.set(entry.id, entry);
410
+ }
411
+ catch {
412
+ /* 文件在扫描与读取间被删/损坏:跳过该规则 */
413
+ }
414
+ }
415
+ for (const entry of discovery.shadowed) {
416
+ try {
417
+ const text = await readFile(entry.docPath, 'utf8');
418
+ const doc = parseSkillDoc(text);
419
+ const derived = deriveFromDoc(entry, doc);
420
+ rules.push({ ...projectRule(entry, derived, undefined), shadowed: true });
421
+ entries.set(entry.id, entry);
422
+ }
423
+ catch {
424
+ /* 同上 */
425
+ }
426
+ }
427
+ const groups = [...discovery.groups]
428
+ .map((name) => {
429
+ const gi = index.groups[name] || {};
430
+ return {
431
+ name,
432
+ label: typeof gi.label === 'string' && gi.label !== '' ? gi.label : name,
433
+ order: gi.order ?? DEFAULT_GROUP_ORDER,
434
+ count: rules.filter((r) => r.group === name && !r.shadowed).length,
435
+ };
436
+ })
437
+ .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
438
+ return { rules, groups, warnings: discovery.warnings, truncated: discovery.truncated, entries, bodies };
439
+ }
440
+ // ── 场景记忆段:两相扫描(廉价指纹 → 按需读正文)──────────────────────────
441
+ //
442
+ // 硬约束 1(§5.1):`systemPrompt.section({ text })` 的 provider 在**每次装配**时
443
+ // 同步求值(`text: string | ((context) => string)`),返回 Promise 会破坏 renderPrompt。
444
+ //
445
+ // 缓存策略(§5.4 S3):段 provider 必须同步返回 string,但不希望每个模型步骤都
446
+ // 读一遍所有记忆正文。因此拆成两相:
447
+ // ① `probeSceneFilesSync()`:只走目录树 + `statSync`(**不读正文**),产出候选文件
448
+ // 与**指纹**(`id|mtime|size` + 影响渲染的索引字段)。
449
+ // ② 指纹变化时才 `renderSceneMemory()`:读正文、派生、排序、拼接。
450
+ //
451
+ // 为什么不用「内存缓存 + 目录监听(方案 a)」:fs.watch 在 Windows 上不可靠(本仓库
452
+ // 技能侧不得不把 watcher 放进 worker 线程才不被卡死),而 watcher 静默失效的后果是
453
+ // **永久返回过期内容**——正是本次变更单要根治的"静默失效"形态。指纹探测每次装配只做
454
+ // 一次 stat 遍历(个人记忆树是亚毫秒级),换来"永远最新且永不静默失效",优先于"零 IO"。
455
+ //
456
+ // 与 discover() 的关系:discover 是异步全量快照(供 CRUD/列表/体检),这里是同步轻量
457
+ // 扫描(供提示词段),两者对"什么是规则"的定义保持一致:
458
+ // 目录含 SKILL.md → bundle 规则(叶子);否则继续下钻;只认 .md;跳过隐藏项;
459
+ // 同名 flat 与 bundle 冲突时 bundle 优先。
460
+ function readFileIfExistsSync(path) {
461
+ try {
462
+ return readFileSync(path, 'utf8');
463
+ }
464
+ catch {
465
+ return null;
466
+ }
467
+ }
468
+ function fileStampSync(path) {
469
+ try {
470
+ const st = statSync(path);
471
+ return `${st.mtimeMs}:${st.size}`;
472
+ }
473
+ catch {
474
+ return 'missing';
475
+ }
476
+ }
477
+ /**
478
+ * 第一相:走目录树 + stat,不读正文。
479
+ * - `''`(规则根目录下的 .md)→ 全局记忆,始终生效
480
+ * - `<scene>/...` → 场景记忆,是否生效由 index.active 决定
481
+ * `signature` 不变 ⇒ 上次渲染结果可原样复用(零正文 IO、零重排)。
482
+ */
483
+ function probeSceneFilesSync(rulesRoot, index) {
484
+ const byId = new Map();
485
+ const scenes = [];
486
+ let truncated = false;
487
+ const budget = { dirs: 0, items: 0 };
488
+ let rootEntries;
489
+ try {
490
+ rootEntries = readdirSync(rulesRoot, { withFileTypes: true });
491
+ }
492
+ catch {
493
+ // 目录不存在/不可读 → 空段(不报错)。签名含固定前缀,便于与"空树"区分。
494
+ return { refs: [], scenes: [], truncated: false, signature: `∅|${signatureOfIndex(index)}` };
495
+ }
496
+ const add = (ref) => {
497
+ const existing = byId.get(ref.id);
498
+ // bundle 优先于 flat(与 discover 的同名遮蔽规则一致)。
499
+ if (!existing || (ref.kind === 'bundle' && existing.kind === 'flat'))
500
+ byId.set(ref.id, ref);
501
+ };
502
+ /** 收集单个目录树内的记忆(scene 为空串表示全局基线)。 */
503
+ const walk = (scene, dir, rel, depth) => {
504
+ if (depth > MAX_SOURCE_DEPTH || budget.dirs >= MAX_DIRECTORIES || budget.items >= MAX_ENTRIES) {
505
+ truncated = true;
506
+ return;
507
+ }
508
+ let entries;
509
+ try {
510
+ entries = readdirSync(dir, { withFileTypes: true });
511
+ }
512
+ catch {
513
+ return;
514
+ }
515
+ budget.dirs++;
516
+ entries.sort((a, b) => a.name.localeCompare(b.name));
517
+ for (const item of entries) {
518
+ if (budget.items >= MAX_ENTRIES) {
519
+ truncated = true;
520
+ return;
521
+ }
522
+ if (item.name.startsWith('.'))
523
+ continue; // 隐藏项跳过
524
+ const child = join(dir, item.name);
525
+ const relChild = rel ? `${rel}/${item.name}` : item.name;
526
+ if (item.isSymbolicLink())
527
+ continue; // 同步扫描不跟随符号链接(防环)
528
+ if (item.isDirectory()) {
529
+ budget.items++;
530
+ const skillDoc = join(child, 'SKILL.md');
531
+ const stamp = fileStampSync(skillDoc);
532
+ if (stamp !== 'missing') {
533
+ // bundle 规则(叶子):父路径为场景,目录名为记忆名。
534
+ const id = scene ? `${scene}/${relChild}` : relChild;
535
+ if (index.rules[id]?.enabled === false)
536
+ continue; // 单条停用 → 不进入段
537
+ add({ id, scene, name: item.name, kind: 'bundle', path: skillDoc, order: index.rules[id]?.order ?? DEFAULT_ORDER, stamp });
538
+ continue;
539
+ }
540
+ walk(scene, child, relChild, depth + 1);
541
+ continue;
542
+ }
543
+ if (!item.isFile())
544
+ continue;
545
+ if (!item.name.toLowerCase().endsWith('.md'))
546
+ continue; // 只认 .md
547
+ budget.items++;
548
+ const id = scene ? `${scene}/${relChild.slice(0, -3)}` : relChild.slice(0, -3);
549
+ if (index.rules[id]?.enabled === false)
550
+ continue;
551
+ add({
552
+ id,
553
+ scene,
554
+ name: item.name.slice(0, -3),
555
+ kind: 'flat',
556
+ path: child,
557
+ order: index.rules[id]?.order ?? DEFAULT_ORDER,
558
+ stamp: fileStampSync(child),
559
+ });
560
+ }
561
+ };
562
+ // ① 根层:全局记忆(scene = '')+ 一级目录(场景)
563
+ for (const item of [...rootEntries].sort((a, b) => a.name.localeCompare(b.name))) {
564
+ if (item.name.startsWith('.'))
565
+ continue;
566
+ if (item.isSymbolicLink())
567
+ continue;
568
+ if (item.isDirectory()) {
569
+ if (!isValidGroupSegment(item.name))
570
+ continue; // 非法目录名 → 不作为场景
571
+ scenes.push(item.name);
572
+ continue;
573
+ }
574
+ if (!item.isFile())
575
+ continue;
576
+ if (!item.name.toLowerCase().endsWith('.md'))
577
+ continue;
578
+ const id = item.name.slice(0, -3);
579
+ if (index.rules[id]?.enabled === false)
580
+ continue; // 单条停用 → 不进入段
581
+ const path = join(rulesRoot, item.name);
582
+ add({ id, scene: '', name: id, kind: 'flat', path, order: index.rules[id]?.order ?? DEFAULT_ORDER, stamp: fileStampSync(path) });
583
+ }
584
+ // ② 场景目录树
585
+ for (const scene of scenes)
586
+ walk(scene, join(rulesRoot, scene), '', 1);
587
+ const refs = [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
588
+ const signature = [
589
+ signatureOfIndex(index),
590
+ truncated ? 'T' : '-',
591
+ scenes.join('\u0001'),
592
+ ...refs.map((r) => `${r.id}\u0000${r.kind}\u0000${r.order}\u0000${r.stamp}`),
593
+ ].join('\u0002');
594
+ return { refs, scenes, truncated, signature };
595
+ }
596
+ /** 指纹里必须包含一切影响渲染的索引字段(active / enabled / order / groups.order)。 */
597
+ function signatureOfIndex(index) {
598
+ const active = normalizeActive(index.active);
599
+ const rules = Object.keys(index.rules).sort().map((id) => {
600
+ const e = index.rules[id];
601
+ return `${id}\u0000${e.enabled === false ? '0' : '1'}\u0000${e.order ?? DEFAULT_ORDER}`;
602
+ });
603
+ const groups = Object.keys(index.groups).sort().map((g) => `${g}\u0000${index.groups[g]?.order ?? DEFAULT_GROUP_ORDER}`);
604
+ return `A:${active === null ? '*' : active.join(',')}|R:${rules.join(';')}|G:${groups.join(';')}`;
605
+ }
606
+ /**
607
+ * 第二相:读正文 + 派生 + 确定性拼接。仅在指纹变化时调用。
608
+ */
609
+ function renderSceneMemory(probe, index, maxBytes) {
610
+ const files = [];
611
+ for (const ref of probe.refs) {
612
+ const text = readFileIfExistsSync(ref.path);
613
+ if (text === null)
614
+ continue; // 探测与读取之间被删:跳过(下次指纹变化会再校正)
615
+ const doc = parseSkillDoc(text);
616
+ const derived = deriveFromDoc({ id: ref.id, group: ref.scene, name: ref.name, kind: ref.kind, docPath: ref.path, entryPath: ref.path }, doc);
617
+ files.push({
618
+ id: ref.id,
619
+ scene: ref.scene,
620
+ name: derived.name,
621
+ description: derived.description,
622
+ descriptionDerived: derived.descriptionDerived,
623
+ order: ref.order,
624
+ body: doc.body,
625
+ });
626
+ }
627
+ const { active } = resolveActiveScenes(index, probe.scenes);
628
+ const buckets = new Map();
629
+ for (const file of files) {
630
+ if (!active.has(file.scene))
631
+ continue;
632
+ const list = buckets.get(file.scene);
633
+ if (list)
634
+ list.push(file);
635
+ else
636
+ buckets.set(file.scene, [file]);
637
+ }
638
+ // ── 候选块(确定性顺序:场景 → 场景内 order/名称)────────────────────────
639
+ const candidates = [];
640
+ let seq = 0;
641
+ for (const scene of [...buckets.keys()].sort((a, b) => compareSceneBuckets(a, b, index))) {
642
+ const sceneFiles = (buckets.get(scene) || []).slice().sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
643
+ const header = `${sceneHeading(scene)}\n\n`;
644
+ for (const file of sceneFiles) {
645
+ const block = `### ${ruleHeading(file)}\n\n${String(file.body ?? '').trim()}\n`;
646
+ candidates.push({
647
+ seq: seq++,
648
+ scene,
649
+ header,
650
+ block,
651
+ item: { id: file.id, scene: file.scene, name: file.name, bytes: byteLen(block) },
652
+ });
653
+ }
654
+ }
655
+ if (candidates.length === 0) {
656
+ return { text: '', bytes: 0, truncated: probe.truncated, maxBytes, scenes: [], items: [], dropped: [] };
657
+ }
658
+ /** 选中块 → 段正文(同一场景的 `## 场景记忆:x` 只在首次出现时发出一次)。 */
659
+ const renderBody = (selected) => {
660
+ const chunks = [];
661
+ let current = null;
662
+ let buf = '';
663
+ for (const c of selected) {
664
+ if (c.scene !== current) {
665
+ if (buf !== '')
666
+ chunks.push(buf);
667
+ current = c.scene;
668
+ buf = c.header;
669
+ }
670
+ buf += c.block;
671
+ }
672
+ if (buf !== '')
673
+ chunks.push(buf);
674
+ return chunks.join('\n');
675
+ };
676
+ const sceneLabelOf = (scene) => (scene === '' ? '全局' : scene);
677
+ /** 未注入清单 + 截断标记,在 `space` 字节内尽量列全(放不下的折叠为一行计数)。 */
678
+ const renderTail = (missed, space, wasTruncated) => {
679
+ if (!wasTruncated)
680
+ return ''; // 没截断就不该出现标记
681
+ const marker = `\n${TRUNCATION_MARKER}\n`;
682
+ if (missed.length === 0)
683
+ return byteLen(marker) <= space ? marker : '';
684
+ const head = `\n${DROPPED_HEADING}\n\n`;
685
+ const lines = [];
686
+ let used = byteLen(head) + byteLen(marker);
687
+ for (const c of missed) {
688
+ const line = `- ${sceneLabelOf(c.scene)}/${c.item.name}(${c.item.bytes} B)\n`;
689
+ if (used + byteLen(line) > space)
690
+ break;
691
+ lines.push(line);
692
+ used += byteLen(line);
693
+ }
694
+ if (lines.length === 0)
695
+ return byteLen(marker) <= space ? marker : '';
696
+ const rest = missed.length - lines.length;
697
+ const fold = `- …(其余 ${rest} 条未列出)\n`;
698
+ const tail = `${head}${lines.join('')}${rest > 0 && used + byteLen(fold) <= space ? fold : ''}${marker}`;
699
+ return tail;
700
+ };
701
+ // ── ① 贪心:放得下就放,越界**跳过**(而不是整体停止)──────────────────
702
+ // 原来一旦某块越界就 stopped,于是一条超长记忆会把它后面的所有小记忆一起饿死。
703
+ const markerReserve = byteLen(`\n${TRUNCATION_MARKER}\n`);
704
+ const selected = [];
705
+ const missed = [];
706
+ const takenScenes = new Set();
707
+ let used = 0;
708
+ for (const c of candidates) {
709
+ const headerCost = takenScenes.has(c.scene) ? 0 : byteLen(c.header);
710
+ if (used + headerCost + c.item.bytes + markerReserve > maxBytes) {
711
+ missed.push(c);
712
+ continue;
713
+ }
714
+ selected.push(c);
715
+ takenScenes.add(c.scene);
716
+ used += headerCost + c.item.bytes;
717
+ }
718
+ missed.sort((a, b) => a.seq - b.seq);
719
+ // ── ② 尾注自身也占字节:放不下就把已入选的块从后往前退回,直到回到预算内 ──
720
+ let body = renderBody(selected);
721
+ let tail = renderTail(missed, maxBytes - byteLen(body), probe.truncated || missed.length > 0);
722
+ while (byteLen(body) + byteLen(tail) > maxBytes && selected.length > 0) {
723
+ missed.push(selected.pop());
724
+ missed.sort((a, b) => a.seq - b.seq);
725
+ body = renderBody(selected);
726
+ tail = renderTail(missed, maxBytes - byteLen(body), true);
727
+ }
728
+ let text = `${body}${tail}`.replace(/^\n+/, '');
729
+ // ③ 兜底:预算小到连标记都放不下时也宁可超出几个字节——"有记忆没注入"这件事
730
+ // 绝不能静默消失(原来单条超预算会让整段变成空串,模型端完全无痕)。
731
+ if (text === '' && (missed.length > 0 || probe.truncated))
732
+ text = `${TRUNCATION_MARKER}\n`;
733
+ const scenes = [];
734
+ for (const c of selected)
735
+ if (!scenes.includes(c.scene))
736
+ scenes.push(c.scene);
737
+ return {
738
+ text,
739
+ bytes: byteLen(text),
740
+ truncated: probe.truncated || missed.length > 0,
741
+ maxBytes,
742
+ scenes,
743
+ items: selected.map((c) => c.item),
744
+ dropped: missed.map((c) => c.item),
745
+ };
746
+ }
747
+ /** 场景渲染顺序:全局 `''` 最先,其次 `_shared`,其余按(索引 order, 目录名)。 */
748
+ function compareSceneBuckets(a, b, index) {
749
+ if (a === b)
750
+ return 0;
751
+ if (a === '')
752
+ return -1;
753
+ if (b === '')
754
+ return 1;
755
+ if (a === SHARED_GROUP)
756
+ return -1;
757
+ if (b === SHARED_GROUP)
758
+ return 1;
759
+ const ao = index.groups[a]?.order ?? DEFAULT_GROUP_ORDER;
760
+ const bo = index.groups[b]?.order ?? DEFAULT_GROUP_ORDER;
761
+ return ao - bo || a.localeCompare(b);
762
+ }
763
+ /** 场景标题:`''` → 全局;其余用目录名(可追溯)。 */
764
+ const sceneHeading = (scene) => `## 场景记忆:${scene === '' ? '全局' : scene}`;
765
+ /** 单条记忆的标题:显式 description 优先;派生描述与正文重复,改用文件名。 */
766
+ const ruleHeading = (f) => (f.descriptionDerived || !f.description ? f.name : f.description);
767
+ const byteLen = (s) => Buffer.byteLength(s, 'utf8');
768
+ // ── 创建服务 ───────────────────────────────────────────────────────────────
769
+ export function createRulesService(ctx, deps) {
770
+ // 仅测试注入绝对路径;生产按 $DSH_HOME 解析(与核心技能目录同源)。
771
+ // v0.3:默认目录由 `$DSH_HOME/rules` 更名为 `$DSH_HOME/scene-memory`。
772
+ // **不做自动迁移、也不回退读旧目录**——旧 `rules/` 里的内容需要用户自行移动
773
+ // (决策见交付说明;如需回退读旧目录,在此处补一个候选路径即可)。
774
+ const rulesRoot = deps.rulesRoot && deps.rulesRoot.trim() !== '' ? resolve(deps.rulesRoot) : join(resolveDshHome(), SCENE_MEMORY_DIR);
775
+ const stateDir = deps.stateDir && deps.stateDir.trim() !== '' ? resolve(deps.stateDir) : join(resolveDshHome(), 'tool-management');
776
+ const maxBytes = Number.isFinite(deps.maxBytes) && deps.maxBytes > 0 ? deps.maxBytes : DEFAULT_MAX_BYTES;
777
+ let snapCache = null;
778
+ const snapshot = async () => {
779
+ if (snapCache && Date.now() - snapCache.at < SNAPSHOT_TTL_MS)
780
+ return snapCache.value;
781
+ const value = await buildSnapshot(rulesRoot, stateDir);
782
+ snapCache = { at: Date.now(), value };
783
+ return value;
784
+ };
785
+ const invalidateSnapshot = () => {
786
+ snapCache = null;
787
+ };
788
+ // ── 场景记忆段(两相扫描 + 指纹缓存)──────────────────────────────────────
789
+ //
790
+ // 段文本 = 活动场景(`_shared` ∪ index.active,缺失时全部)下所有 .md 正文,
791
+ // 按「场景顺序 → 记忆名」确定性拼接;超预算按同一顺序确定性截断并追加固定标记。
792
+ // 禁止时间戳/计数:否则每请求都变,前缀缓存永远不命中(§5.2)。
793
+ //
794
+ // 每次装配:只做「读索引 + stat 遍历」的廉价探测;指纹不变直接返回上次结果,
795
+ // 指纹一变(切场景 / 改文件 / 外部编辑器改文件 / 改 enabled)才重读正文并重排。
796
+ // 见文件顶部「两相扫描」注释里为什么不选 fs.watch 方案。
797
+ let sceneCache = null;
798
+ function sceneMemory() {
799
+ const index = readIndexSync(stateDir);
800
+ const probe = probeSceneFilesSync(rulesRoot, index);
801
+ if (sceneCache && sceneCache.signature === probe.signature)
802
+ return sceneCache.value;
803
+ const value = renderSceneMemory(probe, index, maxBytes);
804
+ sceneCache = { signature: probe.signature, value };
805
+ return value;
806
+ }
807
+ const providerInvalidators = new Set();
808
+ /** 通知已注册的 provider 提示词已变化(best-effort)。 */
809
+ const invalidateProviders = () => {
810
+ for (const invalidate of providerInvalidators) {
811
+ try {
812
+ invalidate();
813
+ }
814
+ catch { /* 单个失效失败不影响其余 */ }
815
+ }
816
+ };
817
+ // ── 写操作串行队列(避免并发覆盖同一索引/文件)──
818
+ let mutationQueue = Promise.resolve();
819
+ const enqueueMutation = (task) => {
820
+ const queued = mutationQueue.then(task, task);
821
+ mutationQueue = queued.catch(() => undefined);
822
+ return queued;
823
+ };
824
+ // ── 定位 / 解析 id ──────────────────────────────────────────────────────
825
+ /** id = "<group>/<name>";group 可含 '/'(多层),根下规则允许 group 为空。 */
826
+ function parseId(id) {
827
+ if (typeof id !== 'string' || id === '' || id.startsWith('/') || id.endsWith('/'))
828
+ return null;
829
+ const idx = id.lastIndexOf('/');
830
+ const group = idx >= 0 ? id.slice(0, idx) : '';
831
+ const name = idx >= 0 ? id.slice(idx + 1) : id;
832
+ if (!name || !isValidGroupSegment(name))
833
+ return null;
834
+ if (group && !isValidGroupPath(group))
835
+ return null;
836
+ return { group, name };
837
+ }
838
+ /** 磁盘定位(bundle 优先)。返回规则文件与条目路径。 */
839
+ async function locateRule(group, name) {
840
+ const bundleDir = join(rulesRoot, group, name);
841
+ const bundleDoc = join(bundleDir, 'SKILL.md');
842
+ try {
843
+ const st = await lstat(bundleDoc);
844
+ if (st.isFile() && !st.isSymbolicLink()) {
845
+ return { id: group ? `${group}/${name}` : name, group, name, kind: 'bundle', docPath: bundleDoc, entryPath: bundleDir };
846
+ }
847
+ }
848
+ catch { /* 非 bundle */ }
849
+ const flatPath = join(rulesRoot, group, name + '.md');
850
+ try {
851
+ const st = await lstat(flatPath);
852
+ if (st.isFile() && !st.isSymbolicLink()) {
853
+ return { id: group ? `${group}/${name}` : name, group, name, kind: 'flat', docPath: flatPath, entryPath: flatPath };
854
+ }
855
+ }
856
+ catch { /* 非 flat */ }
857
+ return null;
858
+ }
859
+ /** 写操作后构建单条投影(不依赖快照,避免全量重扫)。 */
860
+ async function buildProjected(id) {
861
+ const parts = parseId(id);
862
+ if (!parts)
863
+ return null;
864
+ const entry = await locateRule(parts.group, parts.name);
865
+ if (!entry)
866
+ return null;
867
+ try {
868
+ const text = await readFile(entry.docPath, 'utf8');
869
+ const doc = parseSkillDoc(text);
870
+ const derived = deriveFromDoc(entry, doc);
871
+ const index = await readIndex(stateDir);
872
+ return projectRule(entry, derived, index.rules[id]);
873
+ }
874
+ catch {
875
+ return null;
876
+ }
877
+ }
878
+ // ── 规则文件序列化 ──────────────────────────────────────────────────────
879
+ /** 值含换行用 `|` 块(保留换行);否则 `key: value` 原样(parseSkillDoc 按行贪婪解析)。 */
880
+ function yamlField(key, value) {
881
+ if (typeof value === 'boolean')
882
+ return [`${key}: ${value}`];
883
+ const s = String(value);
884
+ if (s.includes('\n')) {
885
+ return [`${key}: |`, ...s.split('\n').map((line) => ` ${line}`)];
886
+ }
887
+ return [`${key}: ${s}`];
888
+ }
889
+ function serializeRuleFile(fields, body) {
890
+ const lines = ['---'];
891
+ for (const [key, value] of Object.entries(fields))
892
+ lines.push(...yamlField(key, value));
893
+ lines.push('---', '');
894
+ return lines.join('\n') + String(body ?? '');
895
+ }
896
+ /** update 时重建文件:保留 frontmatter 其余字段,仅更新 name/description。 */
897
+ function serializeUpdatedFile(doc, newName, description, body) {
898
+ const fields = {};
899
+ for (const [key, value] of Object.entries(doc.map)) {
900
+ if (key === 'name' || key === 'description')
901
+ continue;
902
+ fields[key] = String(value);
903
+ }
904
+ fields.name = newName;
905
+ if (description === undefined) {
906
+ if (doc.map.description != null && String(doc.map.description).trim() !== '')
907
+ fields.description = String(doc.map.description);
908
+ }
909
+ else if (description !== null) {
910
+ fields.description = description;
911
+ }
912
+ return serializeRuleFile(fields, body);
913
+ }
914
+ /**
915
+ * 写操作成功后:失效快照 + 丢弃场景记忆段缓存 + 通知 provider。
916
+ * 指纹本身已能发现变化,这里显式丢弃是为了让写操作返回后**必然**是新鲜结果。
917
+ * 不再写 ~/.dsh/AGENTS.md(原始终层投影已下线,见变更单 §4/§10):
918
+ * 公共基线改由 `_shared/` 场景承担,统一走 systemPrompt 段。
919
+ */
920
+ const refresh = async () => {
921
+ invalidateSnapshot();
922
+ sceneCache = null;
923
+ invalidateProviders();
924
+ };
925
+ // ── 场景行(UI 用:启用/停用开关)──────────────────────────────────────
926
+ /** 由快照的分组派生场景行(场景 = 一级目录);`active` 由索引的启用集合决定。 */
927
+ function sceneRows(snap, index) {
928
+ const byScene = new Map();
929
+ for (const group of snap.groups) {
930
+ const scene = sceneOf(group.name);
931
+ if (scene === '')
932
+ continue; // 根层规则属于"全局"桶,不是场景
933
+ const rec = byScene.get(scene);
934
+ if (!rec) {
935
+ byScene.set(scene, { order: group.order, count: group.count });
936
+ continue;
937
+ }
938
+ rec.count += group.count;
939
+ // 一级目录自身(group.name === scene)的 order 优先;子分类只贡献更小的 order。
940
+ if (group.name === scene)
941
+ rec.order = group.order;
942
+ else
943
+ rec.order = Math.min(rec.order, group.order);
944
+ }
945
+ const { active } = resolveActiveScenes(index, [...byScene.keys()]);
946
+ return [...byScene.entries()]
947
+ .map(([name, rec]) => ({
948
+ name,
949
+ label: name,
950
+ order: rec.order,
951
+ count: rec.count,
952
+ active: active.has(name),
953
+ shared: name === SHARED_GROUP,
954
+ }))
955
+ .sort((a, b) => (a.shared === b.shared ? 0 : a.shared ? -1 : 1) || a.order - b.order || a.name.localeCompare(b.name));
956
+ }
957
+ // ── ops:读 ─────────────────────────────────────────────────────────────
958
+ async function rulesList(args) {
959
+ const groupFilter = args && typeof args.group === 'string' && args.group !== '' ? args.group : undefined;
960
+ const snap = await snapshot();
961
+ const index = await readIndex(stateDir);
962
+ const rules = snap.rules
963
+ .filter((r) => !groupFilter || r.group === groupFilter)
964
+ .sort((a, b) => a.group.localeCompare(b.group) || a.order - b.order || a.name.localeCompare(b.name));
965
+ const groups = groupFilter ? snap.groups.filter((g) => g.name === groupFilter) : snap.groups;
966
+ const scenes = sceneRows(snap, index);
967
+ const projection = sceneMemory();
968
+ return {
969
+ ok: true,
970
+ rules,
971
+ // 对外契约(§7.1)用 key 标识分组;name 保留兼容内部引用。
972
+ groups: groups.map((g) => ({ ...g, key: g.name })),
973
+ // 场景 = rules/ 一级目录;active 为插件内"启用场景"开关的持久化状态。
974
+ scenes,
975
+ activeMode: normalizeActive(index.active) === null ? 'all' : 'custom',
976
+ sceneMemory: { usedBytes: projection.bytes, maxBytes: projection.maxBytes, truncated: projection.truncated, dropped: projection.dropped },
977
+ stats: {
978
+ total: rules.length,
979
+ enabled: rules.filter((r) => r.enabled).length,
980
+ scenes: scenes.filter((s) => s.active).length,
981
+ },
982
+ ...(snap.warnings.length ? { warnings: snap.warnings } : {}),
983
+ };
984
+ }
985
+ async function rulesRead(args) {
986
+ const id = String((args && args.id) || '');
987
+ const snap = await snapshot();
988
+ const rule = snap.rules.find((r) => r.id === id && !r.shadowed);
989
+ const entry = snap.entries.get(id);
990
+ if (!rule || !entry)
991
+ return fail('error.rules.notFound', `规则不存在:${id}`);
992
+ let text = '';
993
+ try {
994
+ text = await readFile(entry.docPath, 'utf8');
995
+ }
996
+ catch {
997
+ return fail('error.rules.notFound', `规则不存在:${id}`);
998
+ }
999
+ const doc = parseSkillDoc(text);
1000
+ return {
1001
+ ok: true,
1002
+ rule: {
1003
+ ...rule,
1004
+ body: doc.body,
1005
+ attachments: entry.kind === 'bundle' ? await listAttachments(entry.entryPath) : [],
1006
+ frontmatter: {
1007
+ name: doc.map.name != null ? String(doc.map.name) : undefined,
1008
+ description: doc.map.description != null ? String(doc.map.description) : undefined,
1009
+ whenToUse: doc.map.whenToUse != null ? String(doc.map.whenToUse) : undefined,
1010
+ globs: doc.map.globs != null ? String(doc.map.globs) : undefined,
1011
+ metadata: doc.map.metadata !== undefined ? doc.map.metadata : undefined,
1012
+ },
1013
+ },
1014
+ };
1015
+ }
1016
+ /**
1017
+ * 场景记忆段预算视图。段落由「活动场景 + 文件内容」决定,超限时**不拒绝写入**,
1018
+ * 而是按确定性顺序截断(§4 对照表)——这里只报告事实,供 UI 显著告警。
1019
+ */
1020
+ async function rulesBudget() {
1021
+ const projection = sceneMemory();
1022
+ return {
1023
+ ok: true,
1024
+ usedBytes: projection.bytes,
1025
+ maxBytes: projection.maxBytes,
1026
+ truncated: projection.truncated,
1027
+ scenes: projection.scenes,
1028
+ items: projection.items,
1029
+ };
1030
+ }
1031
+ /**
1032
+ * 规则体检 + 诊断汇总(Phase 3):逐条规则检查 6 类异常,附场景记忆段预算与场景统计。
1033
+ * 只读;结果只用于 UI 展示,不做任何写操作。
1034
+ */
1035
+ async function rulesDiagnose() {
1036
+ const snap = await snapshot();
1037
+ const index = await readIndex(stateDir);
1038
+ const scenes = sceneRows(snap, index);
1039
+ const issues = [];
1040
+ for (const rule of snap.rules) {
1041
+ if (rule.shadowed) {
1042
+ issues.push({ severity: 'warning', code: 'shadowed', ruleId: rule.id, message: `规则「${rule.id}」被同名 bundle 遮蔽,不会被加载。` });
1043
+ }
1044
+ if (String(rule.description || '').length > MAX_DESCRIPTION_LENGTH) {
1045
+ issues.push({ severity: 'error', code: 'descriptionTooLong', ruleId: rule.id, message: `规则「${rule.id}」描述超过 ${MAX_DESCRIPTION_LENGTH} 字符,会被列表与检索截断。` });
1046
+ }
1047
+ if (rule.form === 'flat') {
1048
+ const fileBase = basename(rule.path);
1049
+ const base = fileBase.toLowerCase().endsWith('.md') ? fileBase.slice(0, -3) : fileBase;
1050
+ if (base !== rule.name) {
1051
+ issues.push({ severity: 'error', code: 'nameMismatch', ruleId: rule.id, message: `规则「${rule.id}」文件名(${fileBase})与 name(${rule.name})不一致,无法按 name 定位。` });
1052
+ }
1053
+ }
1054
+ const body = snap.bodies.get(rule.id) || '';
1055
+ if (body.trim() === '') {
1056
+ issues.push({ severity: 'error', code: 'emptyBody', ruleId: rule.id, message: `规则「${rule.id}」正文为空。` });
1057
+ }
1058
+ if (rule.descriptionDerived && String(rule.description || '').length < 10) {
1059
+ issues.push({ severity: 'info', code: 'vagueDescription', ruleId: rule.id, message: `规则「${rule.id}」描述过于笼统(自动派生,不足 10 字符),建议补充。` });
1060
+ }
1061
+ // 记忆所在场景未启用 → 不会自动生效(不是错误,但值得提示,避免"改了没效果")。
1062
+ const scene = sceneOf(rule.group);
1063
+ if (scene !== '' && !rule.shadowed) {
1064
+ const row = scenes.find((s) => s.name === scene);
1065
+ if (row && !row.active) {
1066
+ issues.push({ severity: 'info', code: 'sceneDisabled', ruleId: rule.id, message: `规则「${rule.id}」所属场景「${scene}」未启用,当前不会进入系统提示词。` });
1067
+ }
1068
+ }
1069
+ }
1070
+ // frontmatter 非法:以 --- 开头但解析不出任何字段(残缺 frontmatter)。
1071
+ for (const entry of snap.entries.values()) {
1072
+ try {
1073
+ const text = await readFile(entry.docPath, 'utf8');
1074
+ const stripped = String(text || '').replace(/^\uFEFF/, '').trimStart();
1075
+ if (stripped.startsWith('---')) {
1076
+ const doc = parseSkillDoc(text);
1077
+ if (Object.keys(doc.map || {}).length === 0) {
1078
+ issues.push({ severity: 'warning', code: 'badFrontmatter', ruleId: entry.id, message: `规则「${entry.id}」frontmatter 无法解析(以 --- 开头但无有效字段)。` });
1079
+ }
1080
+ }
1081
+ }
1082
+ catch { /* 读取失败由上面的 emptyBody 兜底 */ }
1083
+ }
1084
+ const projection = sceneMemory();
1085
+ if (projection.truncated) {
1086
+ issues.push({
1087
+ severity: 'warning',
1088
+ code: 'sceneMemoryTruncated',
1089
+ message: `场景记忆段超出预算(${projection.bytes} 字节 > ${projection.maxBytes} 字节),已按确定性顺序截断并追加 ${TRUNCATION_MARKER};请精简记忆或只保留 _shared。`,
1090
+ });
1091
+ }
1092
+ return {
1093
+ ok: true,
1094
+ issues,
1095
+ budget: { usedBytes: projection.bytes, maxBytes: projection.maxBytes, over: projection.truncated },
1096
+ counts: { rules: snap.rules.length, groups: snap.groups.length, scenes: scenes.length },
1097
+ };
1098
+ }
1099
+ // ── ops:写(串行队列内)───────────────────────────────────────────────
1100
+ async function rulesCreate(args) {
1101
+ const group = String((args && args.group) || '').trim();
1102
+ const name = String((args && args.name) || '').trim();
1103
+ const form = args && args.form === 'bundle' ? 'bundle' : 'flat';
1104
+ if (!isValidGroupPath(group))
1105
+ return fail('error.rules.invalidGroup', `场景/分组名非法:${group || '(空)'}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头、首尾无空白)`);
1106
+ if (!isValidGroupSegment(name))
1107
+ return fail('error.rules.invalidName', `记忆名非法:${name}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含 / \\ < > : " | ? *、不以 . 开头)`);
1108
+ // 目标已存在(bundle 或 flat 皆算)→ 拒绝,避免静默覆盖。
1109
+ const existing = await locateRule(group, name);
1110
+ if (existing) {
1111
+ return fail('error.rules.shadowed', `同名规则已存在(${existing.kind === 'bundle' ? 'bundle' : 'flat'}):${group}/${name}`);
1112
+ }
1113
+ const body = String((args && args.body) ?? '');
1114
+ if (body.trim() === '')
1115
+ return fail('error.rules.bodyRequired', '规则正文不能为空');
1116
+ if (Buffer.byteLength(body, 'utf8') > MAX_RULE_BYTES)
1117
+ return fail('error.rules.tooLarge', `规则正文过大(超过 ${MAX_RULE_BYTES >> 10} KiB)`);
1118
+ let description;
1119
+ const rawDescription = args && args.description != null ? String(args.description).trim() : '';
1120
+ if (rawDescription !== '') {
1121
+ if (rawDescription.length > MAX_DESCRIPTION_LENGTH) {
1122
+ return fail('error.rules.descriptionTooLong', `描述超过 ${MAX_DESCRIPTION_LENGTH} 字符(当前 ${rawDescription.length})`);
1123
+ }
1124
+ description = rawDescription;
1125
+ }
1126
+ else {
1127
+ const derived = deriveDescription(body);
1128
+ if (!derived)
1129
+ return fail('error.rules.descriptionRequired', '请提供规则描述(正文中也无可派生的标题或首行)');
1130
+ description = derived; // 派生只进内存投影,不写回文件
1131
+ }
1132
+ // 写文件(frontmatter 仅写用户显式提供的字段)。是否生效由"场景是否启用"决定,
1133
+ // 超预算只在渲染时确定性截断 + 告警,不再拒绝写入(§4 对照表)。
1134
+ const fields = { name };
1135
+ if (rawDescription !== '')
1136
+ fields.description = description;
1137
+ const text = serializeRuleFile(fields, body);
1138
+ if (form === 'bundle') {
1139
+ await mkdir(join(rulesRoot, group, name), { recursive: true });
1140
+ await writeFileAtomically(join(rulesRoot, group, name, 'SKILL.md'), text);
1141
+ }
1142
+ else {
1143
+ await mkdir(join(rulesRoot, group), { recursive: true });
1144
+ await writeFileAtomically(join(rulesRoot, group, name + '.md'), text);
1145
+ }
1146
+ // 更新索引(force 重读后合并,避免覆盖用户手工编辑)
1147
+ const index = await readIndex(stateDir);
1148
+ index.rules[`${group}/${name}`] = { order: DEFAULT_ORDER, enabled: true, updatedAt: new Date().toISOString() };
1149
+ if (!index.groups[group])
1150
+ index.groups[group] = { order: DEFAULT_GROUP_ORDER, label: group };
1151
+ await writeIndex(stateDir, index);
1152
+ invalidateSnapshot();
1153
+ invalidateProviders();
1154
+ return { ok: true, rule: await buildProjected(`${group}/${name}`) };
1155
+ }
1156
+ async function rulesUpdate(args) {
1157
+ const id = String((args && args.id) || '');
1158
+ const parts = parseId(id);
1159
+ if (!parts)
1160
+ return fail('error.rules.notFound', `规则不存在:${id}`);
1161
+ const located = await locateRule(parts.group, parts.name);
1162
+ if (!located)
1163
+ return fail('error.rules.notFound', `规则不存在:${id}`);
1164
+ let text;
1165
+ try {
1166
+ text = await readFile(located.docPath, 'utf8');
1167
+ }
1168
+ catch {
1169
+ return fail('error.rules.notFound', `规则不存在:${id}`);
1170
+ }
1171
+ const doc = parseSkillDoc(text);
1172
+ // flat 形态:frontmatter 声明名必须等于文件名(发现时可列出,更新时校验)。
1173
+ const currentName = doc.map.name != null && String(doc.map.name).trim() !== '' ? unquote(String(doc.map.name)).trim() : parts.name;
1174
+ if (located.kind === 'flat' && currentName !== parts.name) {
1175
+ return fail('error.rules.invalidName', `flat 规则名与文件名不一致(frontmatter name: ${currentName},文件名: ${parts.name}),请先修正规则文件`);
1176
+ }
1177
+ const newName = args && args.name != null ? String(args.name).trim() : currentName;
1178
+ if (!isValidGroupSegment(newName))
1179
+ return fail('error.rules.invalidName', `记忆名非法:${newName}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含 / \\ < > : " | ? *、不以 . 开头)`);
1180
+ const newForm = args && args.form === 'bundle' ? 'bundle' : args && args.form === 'flat' ? 'flat' : located.kind;
1181
+ const newBody = args && args.body != null ? String(args.body) : doc.body;
1182
+ if (newBody.trim() === '')
1183
+ return fail('error.rules.bodyRequired', '规则正文不能为空');
1184
+ if (Buffer.byteLength(newBody, 'utf8') > MAX_RULE_BYTES)
1185
+ return fail('error.rules.tooLarge', `规则正文过大(超过 ${MAX_RULE_BYTES >> 10} KiB)`);
1186
+ // description:显式传 → 校验并写文件;传空串 → 删除字段并从正文派生;未传 → 保留现有(缺失则派生)。
1187
+ let description;
1188
+ if (args && args.description != null) {
1189
+ const ds = String(args.description).trim();
1190
+ if (ds !== '') {
1191
+ if (ds.length > MAX_DESCRIPTION_LENGTH) {
1192
+ return fail('error.rules.descriptionTooLong', `描述超过 ${MAX_DESCRIPTION_LENGTH} 字符(当前 ${ds.length})`);
1193
+ }
1194
+ description = ds;
1195
+ }
1196
+ else {
1197
+ const derived = deriveDescription(newBody);
1198
+ if (!derived)
1199
+ return fail('error.rules.descriptionRequired', '请提供规则描述(正文中也无可派生的标题或首行)');
1200
+ description = derived; // 派生不写回
1201
+ }
1202
+ }
1203
+ else {
1204
+ const existing = doc.map.description != null ? unquote(String(doc.map.description)).trim() : '';
1205
+ if (existing !== '')
1206
+ description = existing;
1207
+ else
1208
+ description = deriveDescription(newBody) || undefined;
1209
+ }
1210
+ // 形态转换 / 改名:先建新形态文件,再清理旧形态(确保任意失败点不产生半份规则)。
1211
+ // description 写回约定:显式传 → string/null(删除);未传 → undefined(保留原字段)。
1212
+ const serialize = () => {
1213
+ let descArg;
1214
+ if (args && args.description != null)
1215
+ descArg = String(args.description).trim() === '' ? null : description;
1216
+ else
1217
+ descArg = undefined;
1218
+ return serializeUpdatedFile(doc, newName, descArg, newBody);
1219
+ };
1220
+ if (located.kind === 'bundle' && newForm === 'flat') {
1221
+ await mkdir(join(rulesRoot, parts.group), { recursive: true });
1222
+ await writeFileAtomically(join(rulesRoot, parts.group, newName + '.md'), serialize());
1223
+ await rm(located.entryPath, { recursive: true, force: true });
1224
+ }
1225
+ else if (located.kind === 'flat' && newForm === 'bundle') {
1226
+ await mkdir(join(rulesRoot, parts.group, newName), { recursive: true });
1227
+ await writeFileAtomically(join(rulesRoot, parts.group, newName, 'SKILL.md'), serialize());
1228
+ await rm(join(rulesRoot, parts.group, parts.name + '.md'), { force: true });
1229
+ }
1230
+ else if (located.kind === 'flat' && newName !== parts.name) {
1231
+ // flat 改名 = 文件改名
1232
+ await writeFileAtomically(join(rulesRoot, parts.group, newName + '.md'), serialize());
1233
+ await rm(join(rulesRoot, parts.group, parts.name + '.md'), { force: true });
1234
+ }
1235
+ else {
1236
+ // bundle 不 rename 目录:只更新 frontmatter/正文
1237
+ await writeFileAtomically(located.docPath, serialize());
1238
+ }
1239
+ // 索引:改名则迁移记录(保留 order/tags/enabled 等),否则原地更新。
1240
+ const index = await readIndex(stateDir);
1241
+ const newId = `${parts.group}/${newName}`;
1242
+ if (newId !== id && index.rules[id]) {
1243
+ index.rules[newId] = { ...index.rules[id], updatedAt: new Date().toISOString() };
1244
+ delete index.rules[id];
1245
+ }
1246
+ else {
1247
+ index.rules[id] = { ...(index.rules[id] || {}), updatedAt: new Date().toISOString() };
1248
+ }
1249
+ await writeIndex(stateDir, index);
1250
+ invalidateSnapshot();
1251
+ invalidateProviders();
1252
+ return { ok: true, rule: await buildProjected(newId) };
1253
+ }
1254
+ async function rulesRemove(args) {
1255
+ const id = String((args && args.id) || '');
1256
+ const parts = parseId(id);
1257
+ if (!parts)
1258
+ return fail('error.rules.notFound', `规则不存在:${id}`);
1259
+ const located = await locateRule(parts.group, parts.name);
1260
+ if (!located)
1261
+ return fail('error.rules.notFound', `规则不存在:${id}`);
1262
+ const trashId = Date.now().toString(36) + '-' + randomUUID().slice(0, 8);
1263
+ const trashDir = join(stateDir, 'rules-trash', trashId);
1264
+ const manifest = { group: parts.group, name: parts.name, form: located.kind, deletedAt: new Date().toISOString() };
1265
+ await mkdir(trashDir, { recursive: true });
1266
+ if (located.kind === 'bundle') {
1267
+ const { cp } = await import('node:fs/promises');
1268
+ await cp(located.entryPath, join(trashDir, 'bundle'), { recursive: true });
1269
+ await rm(located.entryPath, { recursive: true, force: true });
1270
+ }
1271
+ else {
1272
+ await copyFile(located.docPath, join(trashDir, 'rule.md'));
1273
+ await rm(located.docPath, { force: true });
1274
+ }
1275
+ await writeFileAtomically(join(trashDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
1276
+ const index = await readIndex(stateDir);
1277
+ delete index.rules[id];
1278
+ await writeIndex(stateDir, index);
1279
+ invalidateSnapshot();
1280
+ invalidateProviders();
1281
+ return { ok: true, trashId };
1282
+ }
1283
+ async function rulesRestore(args) {
1284
+ const trashId = String((args && args.trashId) || '');
1285
+ if (!isValidTrashId(trashId))
1286
+ return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
1287
+ const trashDir = join(stateDir, 'rules-trash', trashId);
1288
+ let manifest;
1289
+ try {
1290
+ manifest = JSON.parse(await readFile(join(trashDir, 'manifest.json'), 'utf8'));
1291
+ }
1292
+ catch {
1293
+ return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
1294
+ }
1295
+ if (!manifest || typeof manifest.name !== 'string' || typeof manifest.group !== 'string' || !isValidGroupSegment(manifest.name)) {
1296
+ return fail('error.rules.notFound', `回收站条目损坏:${trashId}`);
1297
+ }
1298
+ // 恢复目标已存在(期间用户重建了同名规则)→ 拒绝,避免覆盖。
1299
+ const conflict = await locateRule(manifest.group, manifest.name);
1300
+ if (conflict) {
1301
+ return fail('error.rules.shadowed', `同名规则已存在:${manifest.group}/${manifest.name},请先移除后再恢复`);
1302
+ }
1303
+ const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
1304
+ if (form === 'bundle') {
1305
+ await mkdir(join(rulesRoot, manifest.group), { recursive: true });
1306
+ const { cp } = await import('node:fs/promises');
1307
+ await cp(join(trashDir, 'bundle'), join(rulesRoot, manifest.group, manifest.name), { recursive: true });
1308
+ }
1309
+ else {
1310
+ await mkdir(join(rulesRoot, manifest.group), { recursive: true });
1311
+ await copyFile(join(trashDir, 'rule.md'), join(rulesRoot, manifest.group, manifest.name + '.md'));
1312
+ }
1313
+ await rm(trashDir, { recursive: true, force: true });
1314
+ invalidateSnapshot();
1315
+ invalidateProviders();
1316
+ return { ok: true, rule: await buildProjected(manifest.group ? `${manifest.group}/${manifest.name}` : manifest.name) };
1317
+ }
1318
+ /** trashId 只由本插件生成(时间戳 base36 + uuid 前 8 位):严格白名单,杜绝路径穿越。 */
1319
+ function isValidTrashId(trashId) {
1320
+ return /^[a-z0-9]+-[a-z0-9]{1,32}$/i.test(trashId);
1321
+ }
1322
+ /**
1323
+ * 记忆回收站列表(只读):`<stateDir>/rules-trash/<trashId>/`(`rulesRemove` 移入,
1324
+ * `rulesRestore` 恢复)。损坏或内容缺失的条目跳过,不让一个坏条目挡住整份列表。
1325
+ */
1326
+ async function rulesTrashList() {
1327
+ const root = join(stateDir, 'rules-trash');
1328
+ let ids = [];
1329
+ try {
1330
+ ids = await readdir(root);
1331
+ }
1332
+ catch {
1333
+ return { ok: true, entries: [] }; // 目录不存在 = 回收站为空
1334
+ }
1335
+ const entries = [];
1336
+ for (const trashId of ids) {
1337
+ if (!isValidTrashId(trashId))
1338
+ continue;
1339
+ const dir = join(root, trashId);
1340
+ try {
1341
+ const st = await lstat(dir);
1342
+ if (!st.isDirectory() || st.isSymbolicLink())
1343
+ continue;
1344
+ const manifest = JSON.parse(await readFile(join(dir, 'manifest.json'), 'utf8'));
1345
+ if (!manifest || typeof manifest.name !== 'string' || !isValidGroupSegment(manifest.name))
1346
+ continue;
1347
+ const group = typeof manifest.group === 'string' ? manifest.group : '';
1348
+ if (group !== '' && !isValidGroupPath(group))
1349
+ continue;
1350
+ const form = manifest.form === 'bundle' ? 'bundle' : 'flat';
1351
+ let bytes = 0;
1352
+ try {
1353
+ bytes = (await stat(form === 'bundle' ? join(dir, 'bundle', 'SKILL.md') : join(dir, 'rule.md'))).size;
1354
+ }
1355
+ catch { /* 内容缺失:仍列出,恢复时由 rulesRestore 兜底报错 */ }
1356
+ entries.push({ trashId, group, name: manifest.name, form, deletedAt: String(manifest.deletedAt || ''), bytes });
1357
+ }
1358
+ catch { /* 损坏条目跳过 */ }
1359
+ }
1360
+ entries.sort((a, b) => (a.deletedAt === b.deletedAt ? b.trashId.localeCompare(a.trashId) : b.deletedAt.localeCompare(a.deletedAt)));
1361
+ return { ok: true, entries };
1362
+ }
1363
+ /** 永久删除回收站条目(不可恢复)。 */
1364
+ async function rulesTrashRemove(args) {
1365
+ const trashId = String((args && args.trashId) || '');
1366
+ if (!isValidTrashId(trashId))
1367
+ return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
1368
+ const dir = join(stateDir, 'rules-trash', trashId);
1369
+ try {
1370
+ const st = await lstat(dir);
1371
+ if (!st.isDirectory())
1372
+ return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
1373
+ }
1374
+ catch {
1375
+ return fail('error.rules.notFound', `回收站条目不存在:${trashId}`);
1376
+ }
1377
+ await rm(dir, { recursive: true, force: true });
1378
+ return { ok: true };
1379
+ }
1380
+ /** bundle 目录下的附件(顶层普通文件,排除正文 SKILL.md);不存在/不可读返回空数组。 */
1381
+ async function listAttachments(bundleDir) {
1382
+ let entries;
1383
+ try {
1384
+ entries = await readdir(bundleDir, { withFileTypes: true });
1385
+ }
1386
+ catch {
1387
+ return [];
1388
+ }
1389
+ const out = [];
1390
+ for (const entry of entries) {
1391
+ if (entry.name === BUNDLE_DOC)
1392
+ continue;
1393
+ if (!entry.isFile())
1394
+ continue; // 子目录 / 符号链接不列出(detach 也只动普通文件)
1395
+ try {
1396
+ out.push({ name: entry.name, size: (await stat(join(bundleDir, entry.name))).size });
1397
+ }
1398
+ catch { /* 读不到的条目跳过 */ }
1399
+ }
1400
+ out.sort((a, b) => a.name.localeCompare(b.name));
1401
+ return out;
1402
+ }
1403
+ /**
1404
+ * 往 bundle 记忆里写附件。flat 形态没有目录,直接拒绝。
1405
+ * 先整体校验、再逐个落盘:避免写到一半才因为第 N 个文件非法而留下半份附件。
1406
+ * 同名附件覆盖(这是用户对自己文件的显式上传动作)。
1407
+ */
1408
+ async function rulesAttach(args) {
1409
+ const id = String((args && args.id) || '');
1410
+ const parts = parseId(id);
1411
+ if (!parts)
1412
+ return fail('error.rules.notFound', `规则不存在:${id}`);
1413
+ const located = await locateRule(parts.group, parts.name);
1414
+ if (!located)
1415
+ return fail('error.rules.notFound', `规则不存在:${id}`);
1416
+ if (located.kind !== 'bundle')
1417
+ return fail('error.rules.notBundle', `「${parts.name}」是 flat(单文件),不能带附件`);
1418
+ const files = Array.isArray(args && args.files) ? args.files : [];
1419
+ if (files.length === 0)
1420
+ return fail('error.rules.noFiles', '没有选择附件');
1421
+ if (files.length > MAX_ATTACH_ENTRIES)
1422
+ return fail('error.rules.tooManyFiles', `一次最多 ${MAX_ATTACH_ENTRIES} 个附件`, { limit: MAX_ATTACH_ENTRIES });
1423
+ const pending = [];
1424
+ let total = 0;
1425
+ for (const file of files) {
1426
+ const name = String((file && (file.path || file.name)) || '');
1427
+ if (!isValidGroupSegment(name))
1428
+ return fail('error.rules.invalidName', `附件名非法:${name || '(空)'}`);
1429
+ const data = Buffer.from(String((file && file.data) || ''), 'base64');
1430
+ if (data.length === 0)
1431
+ return fail('error.rules.emptyFile', `附件内容为空:${name}`);
1432
+ if (data.length > MAX_ATTACH_ENTRY_BYTES)
1433
+ return fail('error.rules.fileTooLarge', `附件过大:${name}(单个上限 ${MAX_ATTACH_ENTRY_BYTES >> 20} MiB)`, { limit: MAX_ATTACH_ENTRY_BYTES >> 20 });
1434
+ total += data.length;
1435
+ if (total > MAX_ATTACH_TOTAL_BYTES)
1436
+ return fail('error.rules.tooLarge', `附件总大小超过 ${MAX_ATTACH_TOTAL_BYTES >> 20} MiB`, { limit: MAX_ATTACH_TOTAL_BYTES >> 20 });
1437
+ pending.push({ name, data });
1438
+ }
1439
+ for (const file of pending)
1440
+ await writeFileAtomicBinary(join(located.entryPath, file.name), file.data);
1441
+ return { ok: true, attachments: await listAttachments(located.entryPath) };
1442
+ }
1443
+ /** 删除 bundle 记忆的一个附件;正文 SKILL.md 不可删。 */
1444
+ async function rulesDetach(args) {
1445
+ const id = String((args && args.id) || '');
1446
+ const parts = parseId(id);
1447
+ if (!parts)
1448
+ return fail('error.rules.notFound', `规则不存在:${id}`);
1449
+ const located = await locateRule(parts.group, parts.name);
1450
+ if (!located)
1451
+ return fail('error.rules.notFound', `规则不存在:${id}`);
1452
+ if (located.kind !== 'bundle')
1453
+ return fail('error.rules.notBundle', `「${parts.name}」是 flat(单文件),没有附件`);
1454
+ const name = String((args && args.name) || '');
1455
+ if (!isValidGroupSegment(name) || name === BUNDLE_DOC)
1456
+ return fail('error.rules.invalidName', `附件名非法:${name || '(空)'}`);
1457
+ const target = join(located.entryPath, name);
1458
+ try {
1459
+ const st = await lstat(target);
1460
+ if (!st.isFile() || st.isSymbolicLink())
1461
+ return fail('error.rules.notFound', `附件不存在:${name}`);
1462
+ }
1463
+ catch {
1464
+ return fail('error.rules.notFound', `附件不存在:${name}`);
1465
+ }
1466
+ await rm(target, { force: true });
1467
+ return { ok: true, attachments: await listAttachments(located.entryPath) };
1468
+ }
1469
+ async function rulesToggle(args) {
1470
+ const id = String((args && args.id) || '');
1471
+ const parts = parseId(id);
1472
+ if (!parts)
1473
+ return fail('error.rules.notFound', `规则不存在:${id}`);
1474
+ const index = await readIndex(stateDir);
1475
+ const idxEntry = index.rules[id] || {};
1476
+ const enabled = args && args.enabled !== undefined ? args.enabled === true : (idxEntry.enabled ?? true);
1477
+ index.rules[id] = { ...idxEntry, enabled, updatedAt: new Date().toISOString() };
1478
+ await writeIndex(stateDir, index);
1479
+ invalidateSnapshot();
1480
+ invalidateProviders();
1481
+ return { ok: true, rule: await buildProjected(id) };
1482
+ }
1483
+ /**
1484
+ * 设置"启用场景"集合(全局持久化,切换后**下一个请求即生效**,§4 对照表)。
1485
+ * - `args.all === true` → 清空显式集合,回到"全部启用"默认(新建场景自动生效)
1486
+ * - `args.scenes` 数组 → 显式收窄(`_shared` 恒常,无需列出)
1487
+ * 场景名按放宽后的规则校验;不存在的场景名也允许保存(目录随后创建即可生效)。
1488
+ */
1489
+ async function rulesSetActive(args) {
1490
+ const index = await readIndex(stateDir);
1491
+ if (args && args.all === true) {
1492
+ index.active = null;
1493
+ }
1494
+ else {
1495
+ const raw = Array.isArray(args && args.scenes) ? args.scenes : [];
1496
+ const names = [];
1497
+ const seen = new Set();
1498
+ for (const item of raw) {
1499
+ const name = String(item == null ? '' : item).trim();
1500
+ if (name === '' || name === SHARED_GROUP)
1501
+ continue; // _shared 恒常,不入集合
1502
+ if (!isValidGroupPath(name)) {
1503
+ return fail('error.rules.invalidGroup', `场景名非法:${name}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头)`);
1504
+ }
1505
+ if (seen.has(name))
1506
+ continue;
1507
+ seen.add(name);
1508
+ names.push(name);
1509
+ }
1510
+ index.active = names;
1511
+ }
1512
+ await writeIndex(stateDir, index);
1513
+ invalidateSnapshot();
1514
+ invalidateProviders();
1515
+ const snap = await snapshot();
1516
+ return {
1517
+ ok: true,
1518
+ activeMode: index.active === null ? 'all' : 'custom',
1519
+ scenes: sceneRows(snap, index),
1520
+ };
1521
+ }
1522
+ /**
1523
+ * 新建场景 = 在 `scene-memory/` 下建一个一级目录(目录内暂时没有记忆也成立)。
1524
+ * 幂等:目录已存在直接成功。`_shared` 是保留名(无需创建)。
1525
+ */
1526
+ async function rulesCreateScene(args) {
1527
+ const name = String((args && args.name) || '').trim();
1528
+ if (!isValidGroupSegment(name)) {
1529
+ return fail('error.rules.invalidGroup', `场景名非法:${name || '(空)'}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头)`);
1530
+ }
1531
+ if (name === SHARED_GROUP)
1532
+ return fail('error.rules.invalidGroup', `_shared 是保留场景名,无需创建`);
1533
+ try {
1534
+ await mkdir(join(rulesRoot, name), { recursive: true });
1535
+ }
1536
+ catch (e) {
1537
+ return fail('error.rules.ioFailed', `创建场景目录失败:${message(e)}`);
1538
+ }
1539
+ invalidateSnapshot();
1540
+ invalidateProviders();
1541
+ return { ok: true, scene: { name, path: join(rulesRoot, name) } };
1542
+ }
1543
+ /**
1544
+ * 删除场景目录:**仅空目录可删**(避免一次操作带走整组记忆)。
1545
+ * 同时把该场景从 `active` 集合里摘掉,避免留下悬空引用。
1546
+ */
1547
+ async function rulesRemoveScene(args) {
1548
+ const name = String((args && args.name) || '').trim();
1549
+ if (!isValidGroupSegment(name))
1550
+ return fail('error.rules.invalidGroup', `场景名非法:${name || '(空)'}`);
1551
+ if (name === SHARED_GROUP)
1552
+ return fail('error.rules.invalidGroup', `_shared 是保留场景名,不可删除`);
1553
+ let entries = [];
1554
+ try {
1555
+ entries = await readdir(join(rulesRoot, name));
1556
+ }
1557
+ catch {
1558
+ return fail('error.rules.notFound', `场景不存在:${name}`);
1559
+ }
1560
+ if (entries.length > 0)
1561
+ return fail('error.rules.sceneNotEmpty', `场景「${name}」里还有 ${entries.length} 项,请先删除其中的记忆`);
1562
+ try {
1563
+ // fs.rm 删目录必须 recursive(即使已确认它是空的),否则报 EISDIR。
1564
+ await rm(join(rulesRoot, name), { recursive: true });
1565
+ }
1566
+ catch (e) {
1567
+ return fail('error.rules.ioFailed', `删除场景目录失败:${message(e)}`);
1568
+ }
1569
+ const index = await readIndex(stateDir);
1570
+ if (Array.isArray(index.active)) {
1571
+ index.active = index.active.filter((s) => s !== name);
1572
+ await writeIndex(stateDir, index);
1573
+ }
1574
+ invalidateSnapshot();
1575
+ invalidateProviders();
1576
+ return { ok: true, name };
1577
+ }
1578
+ async function rulesSetIndex(args) {
1579
+ const id = String((args && args.id) || '');
1580
+ const parts = parseId(id);
1581
+ if (!parts)
1582
+ return fail('error.rules.notFound', `规则不存在:${id}`);
1583
+ const index = await readIndex(stateDir);
1584
+ const idxEntry = index.rules[id] || {};
1585
+ const next = { ...idxEntry, updatedAt: new Date().toISOString() };
1586
+ if (args && args.order !== undefined) {
1587
+ const order = Number(args.order);
1588
+ if (!Number.isFinite(order) || order < 0)
1589
+ return fail('error.rules.invalidGroup', `非法排序值:${args.order}`);
1590
+ next.order = Math.floor(order);
1591
+ }
1592
+ if (args && args.tags !== undefined) {
1593
+ next.tags = Array.isArray(args.tags) ? args.tags.map((t) => String(t)) : [];
1594
+ }
1595
+ if (args && args.pinned !== undefined)
1596
+ next.pinned = args.pinned === true;
1597
+ if (args && args.note !== undefined)
1598
+ next.note = String(args.note);
1599
+ index.rules[id] = next;
1600
+ await writeIndex(stateDir, index);
1601
+ invalidateSnapshot();
1602
+ invalidateProviders();
1603
+ return { ok: true, rule: await buildProjected(id) };
1604
+ }
1605
+ // ── 组装 ─────────────────────────────────────────────────────────────────
1606
+ const registerProviders = () => {
1607
+ try {
1608
+ const registrar = createRuleProviderRegistrar(ctx, {
1609
+ renderActiveScenes: () => sceneMemory().text,
1610
+ });
1611
+ providerInvalidators.add(registrar.invalidate);
1612
+ return () => {
1613
+ providerInvalidators.delete(registrar.invalidate);
1614
+ registrar.dispose();
1615
+ };
1616
+ }
1617
+ catch (e) {
1618
+ console.error('[dsh-plugin-tool-management] rules provider setup failed:', message(e));
1619
+ return () => { };
1620
+ }
1621
+ };
1622
+ /** 写操作:串行队列内执行,成功后触发 refresh(失效缓存,下一请求即生效)。 */
1623
+ const runWrite = (task) => enqueueMutation(async () => {
1624
+ const result = await task();
1625
+ if (result && result.ok !== false)
1626
+ await refresh();
1627
+ return result;
1628
+ });
1629
+ const ops = {
1630
+ 'rules-list': (args) => rulesList(args || {}),
1631
+ 'rules-read': (args) => rulesRead(args || {}),
1632
+ 'rules-budget': () => rulesBudget(),
1633
+ 'rules-diagnose': () => rulesDiagnose(),
1634
+ 'rules-create': (args) => runWrite(() => rulesCreate(args || {})),
1635
+ 'rules-update': (args) => runWrite(() => rulesUpdate(args || {})),
1636
+ 'rules-remove': (args) => runWrite(() => rulesRemove(args || {})),
1637
+ 'rules-restore': (args) => runWrite(() => rulesRestore(args || {})),
1638
+ 'rules-trash-list': () => rulesTrashList(),
1639
+ 'rules-trash-remove': (args) => runWrite(() => rulesTrashRemove(args || {})),
1640
+ 'rules-attach': (args) => runWrite(() => rulesAttach(args || {})),
1641
+ 'rules-detach': (args) => runWrite(() => rulesDetach(args || {})),
1642
+ 'rules-toggle': (args) => runWrite(() => rulesToggle(args || {})),
1643
+ 'rules-set-index': (args) => runWrite(() => rulesSetIndex(args || {})),
1644
+ 'rules-set-active': (args) => runWrite(() => rulesSetActive(args || {})),
1645
+ 'rules-create-scene': (args) => runWrite(() => rulesCreateScene(args || {})),
1646
+ 'rules-remove-scene': (args) => runWrite(() => rulesRemoveScene(args || {})),
1647
+ };
1648
+ const service = {
1649
+ ops,
1650
+ registerProviders,
1651
+ refresh,
1652
+ _invalidate: invalidateProviders,
1653
+ _renderActiveScenes: () => sceneMemory().text,
1654
+ _sceneMemory: () => sceneMemory(),
1655
+ };
1656
+ return service;
1657
+ }