dsh-plugin-tool-management 0.2.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,10 @@
1
1
  // dsh-plugin-tool-management —— 规则/记忆(Rules v0.3,见 CHANGE-REQUEST-01)服务层。
2
2
  //
3
- // 记忆 = $DSH_HOME/scene-memory/<场景>/<name>.md(flat)或 <场景>/<name>/SKILL.md(bundle)。
4
- // **场景 = scene-memory/ 下的一级目录**,目录名即场景名(任意 Unicode,见 isValidGroupSegment)。
3
+ // 记忆 = $DSH_HOME/tool-management/memories/<场景>/<name>.md(flat)或 <场景>/<name>/SKILL.md(bundle)。
4
+ // **场景是显式记录**:$DSH_HOME/tool-management/scenes/<场景>.json(见 SceneRecord),
5
+ // 不再是「memories/ 下恰好有这个名字的目录」这种隐式约定——空场景因此可以存在,
6
+ // 且场景可以有描述。目录名即场景名(任意 Unicode,见 isValidGroupSegment)。
7
+ // 保留场景 `global`(界面显示「全局」):其记忆注入任何对话;它恒定存在、不可删除。
5
8
  // 勾选启用后,该目录树内所有 .md 的正文自动进入系统提示词(provider.ts 注册的
6
9
  // per-agent systemPrompt 段),模型无需做任何动作 —— 这就是"不用每次都要解释"。
7
10
  //
@@ -24,10 +27,12 @@
24
27
  // ops 成功返回扁平 { ok:true, ... },不套 { ok:true, data }。
25
28
  import { randomUUID } from 'node:crypto';
26
29
  import { readFileSync, readdirSync, statSync } from 'node:fs';
27
- import { copyFile, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
30
+ import { copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
28
31
  import { basename, dirname, join, resolve } from 'node:path';
29
32
  import { parseSkillDoc, resolveDshHome, unquote } from '../skills/core.js';
33
+ import { expandUploads, planMemoryImport } from '../imports/upload.js';
30
34
  import { createRuleProviderRegistrar } from './provider.js';
35
+ import { normalizeArchive, memoryAllowed } from './archive.js';
31
36
  // ── 常量 ───────────────────────────────────────────────────────────────────
32
37
  const MAX_SOURCE_DEPTH = 64; // 与 core.js 一致
33
38
  const MAX_DIRECTORIES = 2000; // 目录预算
@@ -46,7 +51,7 @@ const MAX_ATTACH_ENTRY_BYTES = 8 << 20; // 单个附件 8 MiB
46
51
  const MAX_ATTACH_TOTAL_BYTES = 16 << 20; // 单次总大小 16 MiB
47
52
  const MAX_ATTACH_ENTRIES = 32; // 单次最多 32 个
48
53
  const BUNDLE_DOC = 'SKILL.md'; // bundle 的正文文件(附件列表里排除)
49
- const SHARED_GROUP = '_shared'; // 保留场景名:公共基线,所有场景共用、始终生效
54
+ const SHARED_GROUP = '_shared'; // 保留场景名:公共基线(历史语义,仍可使用)
50
55
  const INDEX_VERSION = 1;
51
56
  // 场景/子目录段名约束(§4 对照表 + §5.3):任意 Unicode,但必须对文件系统安全。
52
57
  // - 非空、长度 ≤64、不等于 `.` / `..`、不以 `.` 开头(隐藏目录语义冲突)
@@ -80,8 +85,21 @@ export function isValidGroupPath(group) {
80
85
  const message = (e) => String((e && e.message) || e);
81
86
  const fail = (code, error, params) => (params ? { ok: false, error, code, params } : { ok: false, error, code });
82
87
  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';
88
+ /**
89
+ * 场景/记忆根目录名($DSH_HOME 下),集中在 `tool-management/` 一个目录内:
90
+ * - `scenes/<场景>.json` 场景记录(名称/描述/顺序)
91
+ * - `memories/<场景>/…` 记忆正文真源
92
+ * - `..`(即 tool-management 根)侧车:rules-index.json / config.json / trash/
93
+ *
94
+ * 历史位置 `$DSH_HOME/scene-memory/` 由 `relocateLegacyLayout()` 在首次读写前搬入。
95
+ */
96
+ export const HUB_DIR = 'tool-management';
97
+ export const SCENES_DIR = 'scenes';
98
+ export const MEMORIES_DIR = 'memories';
99
+ /** 保留场景名:界面显示「全局」,恒定存在、不可删除,其记忆注入任何对话。 */
100
+ export const GLOBAL_SCENE = 'global';
101
+ /** 保留场景在界面上的显示名(磁盘上仍用 ASCII 目录/文件名)。 */
102
+ export const GLOBAL_SCENE_LABEL = '全局';
85
103
  // ── 文件工具 ───────────────────────────────────────────────────────────────
86
104
  /** 同目录临时文件 + rename 原子写(临时名 `.xxx.dsh-rules-<uuid>.tmp`)。 */
87
105
  async function writeFileAtomically(path, content) {
@@ -120,15 +138,16 @@ async function discover(rulesRoot) {
120
138
  const entries = new Map();
121
139
  const shadowed = [];
122
140
  const groups = new Set();
141
+ const scenes = [];
123
142
  const warnings = [];
124
143
  const rootPath = resolve(rulesRoot);
125
144
  try {
126
145
  const st = await lstat(rootPath);
127
146
  if (!st.isDirectory() || st.isSymbolicLink())
128
- return { entries, shadowed, groups, warnings, truncated: false };
147
+ return { entries, shadowed, groups, scenes, warnings, truncated: false };
129
148
  }
130
149
  catch {
131
- return { entries, shadowed, groups, warnings, truncated: false };
150
+ return { entries, shadowed, groups, scenes, warnings, truncated: false };
132
151
  }
133
152
  const queue = [
134
153
  { path: rootPath, group: '', depth: 0, ancestors: new Set() },
@@ -195,8 +214,12 @@ async function discover(rulesRoot) {
195
214
  /* 无 SKILL.md → 作为场景/子分类继续 */
196
215
  }
197
216
  }
198
- if (current.group !== '')
217
+ if (current.group !== '') {
199
218
  groups.add(current.group);
219
+ // 一级目录 = 场景名(多段场景名用 '/' 连接,见 isValidGroupPath)。
220
+ if (current.depth === 1)
221
+ scenes.push(current.group);
222
+ }
200
223
  dirents.sort((a, b) => a.name.localeCompare(b.name));
201
224
  const ancestors = new Set(current.ancestors).add(identity(realDirectory));
202
225
  for (const item of dirents) {
@@ -237,7 +260,7 @@ async function discover(rulesRoot) {
237
260
  }
238
261
  }
239
262
  }
240
- return { entries, shadowed, groups, warnings, truncated };
263
+ return { entries, shadowed, groups, scenes, warnings, truncated };
241
264
  }
242
265
  /** 符号链接目录:lstat 得链接后,stat 确认为目录才进入(防链接到文件)。 */
243
266
  async function statDirIsDirectory(path) {
@@ -302,7 +325,91 @@ function projectRule(entry, derived, idxEntry) {
302
325
  return rule;
303
326
  }
304
327
  // ── 侧车文件(索引 / 场景)─────────────────────────────────────────────────
305
- const defaultIndex = () => ({ version: INDEX_VERSION, rules: {}, groups: {}, active: null });
328
+ const defaultIndex = () => ({ version: INDEX_VERSION, rules: {}, groups: {}, scenes: {}, active: null, archives: {}, mode: { scene: null, snapshot: null } });
329
+ /** 场景镜像条目归一化:只保留已知字段,非法值丢弃(容忍脏数据)。 */
330
+ function parseSceneEntry(raw) {
331
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
332
+ return null;
333
+ const obj = raw;
334
+ const out = {};
335
+ if (typeof obj.label === 'string' && obj.label.trim() !== '')
336
+ out.label = obj.label;
337
+ if (typeof obj.description === 'string' && obj.description !== '')
338
+ out.description = obj.description;
339
+ if (typeof obj.order === 'number' && Number.isFinite(obj.order))
340
+ out.order = obj.order;
341
+ if (typeof obj.createdAt === 'string' && obj.createdAt !== '')
342
+ out.createdAt = obj.createdAt;
343
+ return out;
344
+ }
345
+ function parseScenes(raw) {
346
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
347
+ return {};
348
+ const out = {};
349
+ for (const [name, value] of Object.entries(raw)) {
350
+ if (!isValidGroupPath(name))
351
+ continue;
352
+ const entry = parseSceneEntry(value);
353
+ if (entry)
354
+ out[name] = entry;
355
+ }
356
+ return out;
357
+ }
358
+ /** 档案切片归一化:未知形态 → 空对象(容忍脏数据,与 §9.3 同哲学)。 */
359
+ function parseArchives(raw) {
360
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
361
+ return {};
362
+ const out = {};
363
+ for (const [name, value] of Object.entries(raw)) {
364
+ const archive = normalizeArchive(value);
365
+ if (Object.keys(archive).length)
366
+ out[name] = archive;
367
+ }
368
+ return out;
369
+ }
370
+ function parseModeState(raw) {
371
+ const obj = (raw && typeof raw === 'object' ? raw : {});
372
+ const scene = typeof obj.scene === 'string' && obj.scene.trim() !== '' ? obj.scene : null;
373
+ const snapshotRaw = (obj.snapshot && typeof obj.snapshot === 'object' ? obj.snapshot : {});
374
+ const toFlagMap = (v) => {
375
+ const out = {};
376
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
377
+ for (const [k, b] of Object.entries(v))
378
+ if (typeof b === 'boolean')
379
+ out[k] = b;
380
+ }
381
+ return out;
382
+ };
383
+ // v2 快照:mcp = 停用表原文(serverName → ['*'] / 工具名);skills = 启停布尔表。
384
+ const mcp = {};
385
+ const mcpRaw = snapshotRaw.mcp;
386
+ if (mcpRaw && typeof mcpRaw === 'object' && !Array.isArray(mcpRaw)) {
387
+ for (const [server, list] of Object.entries(mcpRaw)) {
388
+ if (Array.isArray(list))
389
+ mcp[server] = list.map((x) => String(x));
390
+ }
391
+ }
392
+ else if (snapshotRaw.tools && typeof snapshotRaw.tools === 'object' && !Array.isArray(snapshotRaw.tools)) {
393
+ // v1 快照兼容(升级前数据):tools = 布尔启停表(key = `<server>/<tool>`,false = 停用)
394
+ // → 折算成 v2 的停用名单;否则退出模式会把「快照启停」错误还原成「全部启用」。
395
+ for (const [key, on] of Object.entries(snapshotRaw.tools)) {
396
+ if (on !== false)
397
+ continue;
398
+ const i = key.indexOf('/');
399
+ if (i <= 0 || i === key.length - 1)
400
+ continue;
401
+ const server = key.slice(0, i);
402
+ const tool = key.slice(i + 1);
403
+ const list = mcp[server] || (mcp[server] = []);
404
+ if (list.indexOf(tool) < 0)
405
+ list.push(tool);
406
+ }
407
+ }
408
+ return {
409
+ scene,
410
+ snapshot: scene ? { mcp, skills: toFlagMap(snapshotRaw.skills) } : null,
411
+ };
412
+ }
306
413
  /** 解析 rules-index.json 原文;任何异常/版本不符 → 默认索引(容忍缺失,§9.3)。 */
307
414
  function parseIndex(raw) {
308
415
  const parsed = JSON.parse(raw);
@@ -312,7 +419,10 @@ function parseIndex(raw) {
312
419
  version: INDEX_VERSION,
313
420
  rules: (parsed.rules || {}),
314
421
  groups: (parsed.groups || {}),
422
+ scenes: parseScenes(parsed.scenes),
315
423
  active: normalizeActive(parsed.active),
424
+ archives: parseArchives(parsed.archives),
425
+ mode: parseModeState(parsed.mode),
316
426
  };
317
427
  }
318
428
  async function readIndex(stateDir) {
@@ -339,6 +449,100 @@ async function writeIndex(stateDir, index) {
339
449
  await mkdir(stateDir, { recursive: true });
340
450
  await writeFileAtomically(join(stateDir, 'rules-index.json'), JSON.stringify(index, null, 2));
341
451
  }
452
+ // ── 场景记录(索引内,`rules-index.json` 的 scenes 切片)+ 旧布局迁移 ────────
453
+ /** 索引条目 → 场景记录(供 UI 直接渲染)。 */
454
+ function sceneRecordOf(name, entry) {
455
+ const e = entry || {};
456
+ return {
457
+ name,
458
+ ...(name === GLOBAL_SCENE ? { label: e.label || GLOBAL_SCENE_LABEL } : (e.label ? { label: e.label } : {})),
459
+ ...(e.description ? { description: e.description } : {}),
460
+ order: e.order ?? (name === GLOBAL_SCENE ? 0 : DEFAULT_GROUP_ORDER),
461
+ ...(e.createdAt ? { createdAt: e.createdAt } : {}),
462
+ };
463
+ }
464
+ /** 路径是否存在(不跟随符号链接;用于迁移前置判断)。 */
465
+ async function pathExists(path) {
466
+ try {
467
+ await lstat(path);
468
+ return true;
469
+ }
470
+ catch {
471
+ return false;
472
+ }
473
+ }
474
+ /** 把旧布局搬进 `tool-management/`(每个进程只尝试一次,幂等)。
475
+ *
476
+ * 旧:$DSH_HOME/scene-memory/<root>.md → memories/global/<root>.md
477
+ * $DSH_HOME/scene-memory/<scene>/… → memories/<scene>/…
478
+ * (场景记录由索引镜像补齐,见 ensureSceneRecords)
479
+ * 更旧:$DSH_HOME/rules/…(v0.3 之前)同样按上面两条处理。
480
+ *
481
+ * 搬移用 `rename`(同卷零拷贝);跨卷(EXDEV)退化为 `cp` + `rm`。
482
+ * 源目录**保留**(内容已搬走,留空壳不影响正确性,也方便用户核对)。
483
+ * 目标已存在同名项 → 保留目标、跳过该项(绝不覆盖新数据)。
484
+ */
485
+ async function relocateLegacyLayout(memoriesRoot) {
486
+ const home = resolveDshHome();
487
+ const legacyRoots = [join(home, 'scene-memory'), join(home, 'rules')];
488
+ for (const legacyRoot of legacyRoots) {
489
+ if (!(await pathExists(legacyRoot)))
490
+ continue;
491
+ if (identity(resolve(legacyRoot)) === identity(resolve(memoriesRoot)))
492
+ continue; // 自定义 roots 落在旧路径 → 不自我搬移
493
+ let items;
494
+ try {
495
+ items = await readdir(legacyRoot, { withFileTypes: true });
496
+ }
497
+ catch {
498
+ continue;
499
+ }
500
+ for (const item of items) {
501
+ if (item.name.startsWith('.'))
502
+ continue;
503
+ const src = join(legacyRoot, item.name);
504
+ // 根层 .md = 旧的「全局」桶 → 搬进 `global/`,文件名保留(否则多套一层目录)。
505
+ // 其余条目(场景目录)整体搬进 memories/,目录名保留。
506
+ const isRootMd = item.isFile() && item.name.toLowerCase().endsWith('.md');
507
+ const destDir = isRootMd ? join(memoriesRoot, GLOBAL_SCENE) : memoriesRoot;
508
+ const dest = join(destDir, item.name);
509
+ try {
510
+ if (await pathExists(dest))
511
+ continue;
512
+ await mkdir(destDir, { recursive: true });
513
+ try {
514
+ await rename(src, dest);
515
+ }
516
+ catch {
517
+ // 跨设备/被占用 → 复制后删源;复制失败则保留源文件(宁可重复,不可丢失)。
518
+ await cp(src, dest, { recursive: true });
519
+ await rm(src, { recursive: true, force: true });
520
+ }
521
+ }
522
+ catch {
523
+ /* 单项失败不阻断其余项(如文件被外部程序锁住,下次启动再试) */
524
+ }
525
+ }
526
+ }
527
+ }
528
+ /** 确保保留场景 `global` 的记录存在;旧数据里的场景名补一条记录。 */
529
+ function ensureSceneRecords(index, probeScenes) {
530
+ if (!index.scenes)
531
+ index.scenes = {};
532
+ let dirty = false;
533
+ if (!index.scenes[GLOBAL_SCENE]) {
534
+ index.scenes[GLOBAL_SCENE] = { label: GLOBAL_SCENE_LABEL, order: 0 };
535
+ dirty = true;
536
+ }
537
+ // 有目录但没记录(旧布局搬进来的、或用户手工建的目录)→ 补记录,label 用目录名。
538
+ for (const name of probeScenes) {
539
+ if (index.scenes[name])
540
+ continue;
541
+ index.scenes[name] = { order: DEFAULT_GROUP_ORDER };
542
+ dirty = true;
543
+ }
544
+ return dirty;
545
+ }
342
546
  /** 启用场景集合归一化:非数组 → null(= 全部启用);数组 → 去重后的字符串数组。 */
343
547
  function normalizeActive(raw) {
344
548
  if (!Array.isArray(raw))
@@ -361,14 +565,16 @@ function normalizeActive(raw) {
361
565
  * - `index.active` 缺失 / null → **全部场景启用**(默认;保证"丢进去就有用")
362
566
  * - `index.active` 为数组 → 只有列出的场景启用(显式收窄)
363
567
  * - `_shared` 恒常启用(公共基线),不受开关影响
568
+ * - 保留场景 `global` 恒常启用:它的记忆对任何对话都成立
364
569
  * 缺失 rules-index.json 一律按默认值运行("全部场景启用"),不抛错(§9.3)。
365
- * 场景生效与否只由 index.active 决定,不读 scenes.json(preset 绑定已移除)。
570
+ * 场景生效与否只由 index.active 决定(场景档案/模式也写这一份)。
366
571
  */
367
572
  function resolveActiveScenes(index, knownScenes) {
368
573
  const stored = normalizeActive(index.active);
369
574
  const mode = stored === null ? 'all' : 'custom';
370
575
  const active = new Set(stored === null ? knownScenes : stored);
371
576
  active.add(SHARED_GROUP);
577
+ active.add(GLOBAL_SCENE);
372
578
  return { active, mode };
373
579
  }
374
580
  /** 场景名 = 分组路径的第一段(`web/frontend` 属于场景 `web`)。 */
@@ -394,6 +600,11 @@ async function buildSnapshot(rulesRoot, stateDir) {
394
600
  dirty = true;
395
601
  }
396
602
  }
603
+ // 场景记录:保留场景 global 恒存在;有目录没记录的补一条。
604
+ // 注意**不反向清理**——删掉 memories/<场景>/ 目录不应删掉场景记录,
605
+ // 否则“先建场景、后加记忆”的用法会在加记忆前把场景弄丢。
606
+ if (ensureSceneRecords(index, discovery.scenes))
607
+ dirty = true;
397
608
  if (dirty)
398
609
  await writeIndex(stateDir, index);
399
610
  const rules = [];
@@ -435,7 +646,7 @@ async function buildSnapshot(rulesRoot, stateDir) {
435
646
  };
436
647
  })
437
648
  .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
438
- return { rules, groups, warnings: discovery.warnings, truncated: discovery.truncated, entries, bodies };
649
+ return { rules, groups, scenes: discovery.scenes, warnings: discovery.warnings, truncated: discovery.truncated, entries, bodies };
439
650
  }
440
651
  // ── 场景记忆段:两相扫描(廉价指纹 → 按需读正文)──────────────────────────
441
652
  //
@@ -476,8 +687,10 @@ function fileStampSync(path) {
476
687
  }
477
688
  /**
478
689
  * 第一相:走目录树 + stat,不读正文。
479
- * - `''`(规则根目录下的 .md)→ 全局记忆,始终生效
480
- * - `<scene>/...` → 场景记忆,是否生效由 index.active 决定
690
+ * `<场景>/...` → 场景记忆(一级目录名即场景名,含保留场景 `global`);
691
+ * 是否生效由 index.active 决定,`global` 恒定生效(见 renderSceneMemory)。
692
+ * 根层的裸 .md **不再是记忆**(旧的「全局」桶已迁入 `global/`,见 relocateLegacyLayout),
693
+ * 因此这里不再扫描根层文件——把文件丢在 memories/ 根下不会静默生效,也不会被投影。
481
694
  * `signature` 不变 ⇒ 上次渲染结果可原样复用(零正文 IO、零重排)。
482
695
  */
483
696
  function probeSceneFilesSync(rulesRoot, index) {
@@ -499,7 +712,7 @@ function probeSceneFilesSync(rulesRoot, index) {
499
712
  if (!existing || (ref.kind === 'bundle' && existing.kind === 'flat'))
500
713
  byId.set(ref.id, ref);
501
714
  };
502
- /** 收集单个目录树内的记忆(scene 为空串表示全局基线)。 */
715
+ /** 收集单个目录树内的记忆(scene = 场景名)。 */
503
716
  const walk = (scene, dir, rel, depth) => {
504
717
  if (depth > MAX_SOURCE_DEPTH || budget.dirs >= MAX_DIRECTORIES || budget.items >= MAX_ENTRIES) {
505
718
  truncated = true;
@@ -559,27 +772,17 @@ function probeSceneFilesSync(rulesRoot, index) {
559
772
  });
560
773
  }
561
774
  };
562
- // ① 根层:全局记忆(scene = '')+ 一级目录(场景)
775
+ // 一级目录 = 场景(根层的裸 .md 不再是记忆,故此处不处理文件)。
563
776
  for (const item of [...rootEntries].sort((a, b) => a.name.localeCompare(b.name))) {
564
777
  if (item.name.startsWith('.'))
565
778
  continue;
566
779
  if (item.isSymbolicLink())
567
780
  continue;
568
- if (item.isDirectory()) {
569
- if (!isValidGroupSegment(item.name))
570
- continue; // 非法目录名 → 不作为场景
571
- scenes.push(item.name);
781
+ if (!item.isDirectory())
572
782
  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) });
783
+ if (!isValidGroupSegment(item.name))
784
+ continue; // 非法目录名 → 不作为场景
785
+ scenes.push(item.name);
583
786
  }
584
787
  // ② 场景目录树
585
788
  for (const scene of scenes)
@@ -593,7 +796,7 @@ function probeSceneFilesSync(rulesRoot, index) {
593
796
  ].join('\u0002');
594
797
  return { refs, scenes, truncated, signature };
595
798
  }
596
- /** 指纹里必须包含一切影响渲染的索引字段(active / enabled / order / groups.order)。 */
799
+ /** 指纹里必须包含一切影响渲染的索引字段(active / enabled / order / groups.order / scenes.order)。 */
597
800
  function signatureOfIndex(index) {
598
801
  const active = normalizeActive(index.active);
599
802
  const rules = Object.keys(index.rules).sort().map((id) => {
@@ -601,7 +804,12 @@ function signatureOfIndex(index) {
601
804
  return `${id}\u0000${e.enabled === false ? '0' : '1'}\u0000${e.order ?? DEFAULT_ORDER}`;
602
805
  });
603
806
  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(';')}`;
807
+ // 场景顺序决定段内场景的先后 → 必须进指纹,否则改顺序后段文本不会重算。
808
+ const scenes = Object.keys(index.scenes || {}).sort().map((s) => {
809
+ const e = index.scenes[s];
810
+ return `${s}\u0000${e.order ?? (s === GLOBAL_SCENE ? 0 : DEFAULT_GROUP_ORDER)}\u0000${e.label ?? ''}`;
811
+ });
812
+ return `A:${active === null ? '*' : active.join(',')}|R:${rules.join(';')}|G:${groups.join(';')}|S:${scenes.join(';')}`;
605
813
  }
606
814
  /**
607
815
  * 第二相:读正文 + 派生 + 确定性拼接。仅在指纹变化时调用。
@@ -627,7 +835,11 @@ function renderSceneMemory(probe, index, maxBytes) {
627
835
  const { active } = resolveActiveScenes(index, probe.scenes);
628
836
  const buckets = new Map();
629
837
  for (const file of files) {
630
- if (!active.has(file.scene))
838
+ // 保留场景 global 恒定生效(「全局」= 任何对话都注入);其余由 index.active 决定。
839
+ if (file.scene !== GLOBAL_SCENE && !active.has(file.scene))
840
+ continue;
841
+ // 场景档案的记忆段(v3):该场景定过 memories 段 → 只有勾选的记忆进段(纯投影,不改文件)。
842
+ if (!memoryAllowed(index.archives, file.scene, file.id))
631
843
  continue;
632
844
  const list = buckets.get(file.scene);
633
845
  if (list)
@@ -673,7 +885,7 @@ function renderSceneMemory(probe, index, maxBytes) {
673
885
  chunks.push(buf);
674
886
  return chunks.join('\n');
675
887
  };
676
- const sceneLabelOf = (scene) => (scene === '' ? '全局' : scene);
888
+ const sceneLabelOf = (scene) => sceneLabel(scene);
677
889
  /** 未注入清单 + 截断标记,在 `space` 字节内尽量列全(放不下的折叠为一行计数)。 */
678
890
  const renderTail = (missed, space, wasTruncated) => {
679
891
  if (!wasTruncated)
@@ -744,40 +956,78 @@ function renderSceneMemory(probe, index, maxBytes) {
744
956
  dropped: missed.map((c) => c.item),
745
957
  };
746
958
  }
747
- /** 场景渲染顺序:全局 `''` 最先,其次 `_shared`,其余按(索引 order, 目录名)。 */
959
+ /** 场景渲染顺序:全局 `global` 最先(它的记忆对任何对话都成立,先讲总则),
960
+ * 其次 `_shared`(历史保留名),其余按(索引 scenes.order, 场景名)。 */
748
961
  function compareSceneBuckets(a, b, index) {
749
962
  if (a === b)
750
963
  return 0;
751
- if (a === '')
964
+ if (a === GLOBAL_SCENE)
752
965
  return -1;
753
- if (b === '')
966
+ if (b === GLOBAL_SCENE)
754
967
  return 1;
755
968
  if (a === SHARED_GROUP)
756
969
  return -1;
757
970
  if (b === SHARED_GROUP)
758
971
  return 1;
759
- const ao = index.groups[a]?.order ?? DEFAULT_GROUP_ORDER;
760
- const bo = index.groups[b]?.order ?? DEFAULT_GROUP_ORDER;
972
+ const ao = sceneOrderOf(index, a);
973
+ const bo = sceneOrderOf(index, b);
761
974
  return ao - bo || a.localeCompare(b);
762
975
  }
976
+ /** 场景排序键:索引 scenes.order 优先,回退到旧 groups.order,再回退默认值。 */
977
+ function sceneOrderOf(index, scene) {
978
+ if (scene === GLOBAL_SCENE)
979
+ return 0;
980
+ const s = index.scenes?.[scene]?.order;
981
+ if (typeof s === 'number' && Number.isFinite(s))
982
+ return s;
983
+ return index.groups[scene]?.order ?? DEFAULT_GROUP_ORDER;
984
+ }
985
+ /** 场景显示名:`global` → 「全局」(磁盘名保持 ASCII),其余用索引 label 或场景名。 */
986
+ function sceneLabel(scene, index) {
987
+ if (scene === GLOBAL_SCENE)
988
+ return index?.scenes?.[GLOBAL_SCENE]?.label || GLOBAL_SCENE_LABEL;
989
+ const label = index?.scenes?.[scene]?.label;
990
+ return label && label !== '' ? label : scene;
991
+ }
763
992
  /** 场景标题:`''` → 全局;其余用目录名(可追溯)。 */
764
- const sceneHeading = (scene) => `## 场景记忆:${scene === '' ? '全局' : scene}`;
993
+ const sceneHeading = (scene) => `## 场景记忆:${sceneLabel(scene)}`;
765
994
  /** 单条记忆的标题:显式 description 优先;派生描述与正文重复,改用文件名。 */
766
995
  const ruleHeading = (f) => (f.descriptionDerived || !f.description ? f.name : f.description);
767
996
  const byteLen = (s) => Buffer.byteLength(s, 'utf8');
768
997
  // ── 创建服务 ───────────────────────────────────────────────────────────────
769
998
  export function createRulesService(ctx, deps) {
770
999
  // 仅测试注入绝对路径;生产按 $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');
1000
+ // v0.4:全部落到 $DSH_HOME/tool-management/ 一个目录内(memories/ + scenes/ + 侧车),
1001
+ // 旧的 $DSH_HOME/scene-memory 与更旧的 $DSH_HOME/rules 由 relocateLegacyLayout() 搬入。
1002
+ const stateDir = deps.stateDir && deps.stateDir.trim() !== '' ? resolve(deps.stateDir) : join(resolveDshHome(), HUB_DIR);
1003
+ const rulesRoot = deps.rulesRoot && deps.rulesRoot.trim() !== '' ? resolve(deps.rulesRoot) : join(stateDir, MEMORIES_DIR);
1004
+ const scenesRoot = deps.scenesDir && deps.scenesDir.trim() !== '' ? resolve(deps.scenesDir) : join(stateDir, SCENES_DIR);
776
1005
  const maxBytes = Number.isFinite(deps.maxBytes) && deps.maxBytes > 0 ? deps.maxBytes : DEFAULT_MAX_BYTES;
1006
+ // 旧布局迁移:每个进程只跑一次。放在目录解析之后、任何首次读盘之前。
1007
+ let legacyRelocated = false;
1008
+ const ensureLayout = async () => {
1009
+ if (legacyRelocated)
1010
+ return;
1011
+ legacyRelocated = true;
1012
+ try {
1013
+ await relocateLegacyLayout(rulesRoot);
1014
+ }
1015
+ catch {
1016
+ /* 迁移失败不阻断服务:旧目录原样留着,用户可手工搬 */
1017
+ }
1018
+ try {
1019
+ await mkdir(join(rulesRoot, GLOBAL_SCENE), { recursive: true });
1020
+ await mkdir(scenesRoot, { recursive: true });
1021
+ }
1022
+ catch {
1023
+ /* 目录建不出来时后面的 op 会各自报错,这里不提前抛 */
1024
+ }
1025
+ };
777
1026
  let snapCache = null;
778
1027
  const snapshot = async () => {
779
1028
  if (snapCache && Date.now() - snapCache.at < SNAPSHOT_TTL_MS)
780
1029
  return snapCache.value;
1030
+ await ensureLayout();
781
1031
  const value = await buildSnapshot(rulesRoot, stateDir);
782
1032
  snapCache = { at: Date.now(), value };
783
1033
  return value;
@@ -922,37 +1172,51 @@ export function createRulesService(ctx, deps) {
922
1172
  sceneCache = null;
923
1173
  invalidateProviders();
924
1174
  };
1175
+ /** 场景档案引擎专用:读-改-写 mode/archives/active 切片(写队列内,保持与其余索引写串行)。 */
1176
+ const patchIndex = (patch) => enqueueMutation(async () => {
1177
+ const index = await readIndex(stateDir);
1178
+ if (patch.mode !== undefined)
1179
+ index.mode = patch.mode;
1180
+ if (patch.archives !== undefined)
1181
+ index.archives = patch.archives;
1182
+ // active 复用「记忆启用集」语义(null = 全部启用);进入模式时引擎收窄为 [S]。
1183
+ if (patch.active !== undefined)
1184
+ index.active = patch.active;
1185
+ await writeIndex(stateDir, index);
1186
+ await refresh();
1187
+ });
1188
+ /** 场景档案引擎专用:读 mode/archives/active 切片(容忍缺失,缺省 = 无档案 + 自由模式 + 全部启用)。 */
1189
+ const readArchiveSlice = async () => {
1190
+ const index = await readIndex(stateDir);
1191
+ return { mode: index.mode ?? { scene: null, snapshot: null }, archives: index.archives ?? {}, active: normalizeActive(index.active) };
1192
+ };
925
1193
  // ── 场景行(UI 用:启用/停用开关)──────────────────────────────────────
926
- /** 由快照的分组派生场景行(场景 = 一级目录);`active` 由索引的启用集合决定。 */
1194
+ /** 场景行:**以索引的场景记录为准**(空场景也在列表里),记忆条数由快照统计。 */
927
1195
  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 });
1196
+ const names = new Set([GLOBAL_SCENE, ...Object.keys(index.scenes || {}), ...snap.scenes]);
1197
+ const counts = new Map();
1198
+ for (const rule of snap.rules) {
1199
+ if (rule.shadowed)
936
1200
  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);
1201
+ const scene = sceneOf(rule.group);
1202
+ if (scene === '')
1203
+ continue; // 无场景归属的记忆(旧根层遗留)不归属任何场景
1204
+ counts.set(scene, (counts.get(scene) || 0) + 1);
944
1205
  }
945
- const { active } = resolveActiveScenes(index, [...byScene.keys()]);
946
- return [...byScene.entries()]
947
- .map(([name, rec]) => ({
1206
+ const known = [...names];
1207
+ const { active } = resolveActiveScenes(index, known);
1208
+ return known
1209
+ .map((name) => ({
948
1210
  name,
949
- label: name,
950
- order: rec.order,
951
- count: rec.count,
1211
+ label: sceneLabel(name, index),
1212
+ order: sceneOrderOf(index, name),
1213
+ count: counts.get(name) || 0,
952
1214
  active: active.has(name),
953
1215
  shared: name === SHARED_GROUP,
1216
+ global: name === GLOBAL_SCENE,
1217
+ description: index.scenes?.[name]?.description || '',
954
1218
  }))
955
- .sort((a, b) => (a.shared === b.shared ? 0 : a.shared ? -1 : 1) || a.order - b.order || a.name.localeCompare(b.name));
1219
+ .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
956
1220
  }
957
1221
  // ── ops:读 ─────────────────────────────────────────────────────────────
958
1222
  async function rulesList(args) {
@@ -970,10 +1234,12 @@ export function createRulesService(ctx, deps) {
970
1234
  rules,
971
1235
  // 对外契约(§7.1)用 key 标识分组;name 保留兼容内部引用。
972
1236
  groups: groups.map((g) => ({ ...g, key: g.name })),
973
- // 场景 = rules/ 一级目录;active 为插件内"启用场景"开关的持久化状态。
1237
+ // 场景 = 显式记录(含保留场景 global 与尚无记忆的空场景);active = 是否参与注入。
974
1238
  scenes,
975
1239
  activeMode: normalizeActive(index.active) === null ? 'all' : 'custom',
976
1240
  sceneMemory: { usedBytes: projection.bytes, maxBytes: projection.maxBytes, truncated: projection.truncated, dropped: projection.dropped },
1241
+ // 路径供 UI 显示「文件在哪」;不再让界面硬编码 ~/.dsh/scene-memory。
1242
+ paths: { memories: rulesRoot, scenes: scenesRoot, hub: stateDir },
977
1243
  stats: {
978
1244
  total: rules.length,
979
1245
  enabled: rules.filter((r) => r.enabled).length,
@@ -1060,11 +1326,21 @@ export function createRulesService(ctx, deps) {
1060
1326
  }
1061
1327
  // 记忆所在场景未启用 → 不会自动生效(不是错误,但值得提示,避免"改了没效果")。
1062
1328
  const scene = sceneOf(rule.group);
1063
- if (scene !== '' && !rule.shadowed) {
1329
+ if (scene === '') {
1330
+ // v0.4:记忆必须归属某个场景(留空 = 保留场景 `global`)。归不到场景的记忆
1331
+ // 不会被投影,也不会出现在场景卡片里 —— 必须显式报出来,不能静默。
1332
+ if (!rule.shadowed) {
1333
+ issues.push({ severity: 'warning', code: 'noScene', ruleId: rule.id, message: `规则「${rule.id}」没有归属场景(文件直接放在 memories/ 根层),不会被注入。请移入某个场景目录,或放到 memories/${GLOBAL_SCENE}/ 作为「全局」记忆。` });
1334
+ }
1335
+ }
1336
+ else if (!rule.shadowed) {
1064
1337
  const row = scenes.find((s) => s.name === scene);
1065
1338
  if (row && !row.active) {
1066
1339
  issues.push({ severity: 'info', code: 'sceneDisabled', ruleId: rule.id, message: `规则「${rule.id}」所属场景「${scene}」未启用,当前不会进入系统提示词。` });
1067
1340
  }
1341
+ else if (!row) {
1342
+ issues.push({ severity: 'warning', code: 'sceneUnknown', ruleId: rule.id, message: `规则「${rule.id}」的场景「${scene}」没有对应记录(可能被手工创建)——请到「场景」页补一条场景描述。` });
1343
+ }
1068
1344
  }
1069
1345
  }
1070
1346
  // frontmatter 非法:以 --- 开头但解析不出任何字段(残缺 frontmatter)。
@@ -1101,8 +1377,17 @@ export function createRulesService(ctx, deps) {
1101
1377
  const group = String((args && args.group) || '').trim();
1102
1378
  const name = String((args && args.name) || '').trim();
1103
1379
  const form = args && args.form === 'bundle' ? 'bundle' : 'flat';
1104
- if (!isValidGroupPath(group))
1105
- return fail('error.rules.invalidGroup', `场景/分组名非法:${group || '(空)'}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头、首尾无空白)`);
1380
+ // 场景必填:留空落到保留场景 `global`(界面「全局」,任何对话都注入)。
1381
+ // 场景必须是**已存在**的记录——写成不存在的名字会静默造出一个没有描述的场景,
1382
+ // 所以这里显式引导用户先去「场景」页创建(错误码可被 UI 翻译)。
1383
+ if (group !== '' && !isValidGroupPath(group))
1384
+ return fail('error.rules.invalidGroup', `场景名非法:${group}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头、首尾无空白)`);
1385
+ const targetGroup = group === '' ? GLOBAL_SCENE : group;
1386
+ const indexForScene = await readIndex(stateDir);
1387
+ // 保留场景 global 恒存在(不用先建);其余场景必须已存在——见上方注释。
1388
+ if (targetGroup !== GLOBAL_SCENE && !indexForScene.scenes?.[targetGroup] && !(await pathExists(join(rulesRoot, targetGroup)))) {
1389
+ return fail('error.rules.sceneNotFound', `场景不存在:${targetGroup}(请先在「场景」页创建该场景)`);
1390
+ }
1106
1391
  if (!isValidGroupSegment(name))
1107
1392
  return fail('error.rules.invalidName', `记忆名非法:${name}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含 / \\ < > : " | ? *、不以 . 开头)`);
1108
1393
  // 目标已存在(bundle 或 flat 皆算)→ 拒绝,避免静默覆盖。
@@ -1136,22 +1421,140 @@ export function createRulesService(ctx, deps) {
1136
1421
  fields.description = description;
1137
1422
  const text = serializeRuleFile(fields, body);
1138
1423
  if (form === 'bundle') {
1139
- await mkdir(join(rulesRoot, group, name), { recursive: true });
1140
- await writeFileAtomically(join(rulesRoot, group, name, 'SKILL.md'), text);
1424
+ await mkdir(join(rulesRoot, targetGroup, name), { recursive: true });
1425
+ await writeFileAtomically(join(rulesRoot, targetGroup, name, 'SKILL.md'), text);
1141
1426
  }
1142
1427
  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 };
1428
+ await mkdir(join(rulesRoot, targetGroup), { recursive: true });
1429
+ await writeFileAtomically(join(rulesRoot, targetGroup, name + '.md'), text);
1430
+ }
1431
+ // 更新索引(force 重读后合并,避免覆盖用户手工编辑)。
1432
+ // id 恒为 `<场景>/<名>`(与 parseId 同构);场景记录此时必然已存在。
1433
+ const createdId = `${targetGroup}/${name}`;
1434
+ const index = indexForScene;
1435
+ index.rules[createdId] = { order: DEFAULT_ORDER, enabled: true, updatedAt: new Date().toISOString() };
1436
+ if (!index.groups[targetGroup])
1437
+ index.groups[targetGroup] = { order: DEFAULT_GROUP_ORDER, label: targetGroup };
1151
1438
  await writeIndex(stateDir, index);
1152
1439
  invalidateSnapshot();
1153
1440
  invalidateProviders();
1154
- return { ok: true, rule: await buildProjected(`${group}/${name}`) };
1441
+ return { ok: true, rule: await buildProjected(createdId) };
1442
+ }
1443
+ /**
1444
+ * 导入记忆(.md / .zip):文件即真源——把 .md 原文落进 `<场景>/<名>.md`,场景为空 = 全局根层
1445
+ * zip 内带目录 → 目录路径当场景;裸 .md → 落到 `args.scene`(留空 = 保留场景 `global`)。
1446
+ * 引用到的场景不存在时**自动补一条场景记录**(导入是批量动作,要求用户先逐个建场景不现实);
1447
+ * 这不算静默造场景——被补的场景会在 `scenes` 结果里回传,UI 会提示。
1448
+ * 重名一律**跳过并报告**(与技能导入同策略),绝不覆盖用户既有文件。
1449
+ * 部分成功:单条失败只记 skipped,不影响同批其余条目。
1450
+ */
1451
+ async function rulesImport(args) {
1452
+ const scene = String((args && args.scene) || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '').trim();
1453
+ if (scene !== '' && !isValidGroupPath(scene)) {
1454
+ return fail('error.rules.invalidGroup', `场景名非法:${scene}(留空 = 全局;否则非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头、首尾无空白)`);
1455
+ }
1456
+ const defaultScene = scene === '' ? GLOBAL_SCENE : scene;
1457
+ const files = args && args.files;
1458
+ if (!Array.isArray(files) || !files.length)
1459
+ return fail('error.import.noFiles', '没有选择要导入的文件');
1460
+ const { entries, problems } = expandUploads(files);
1461
+ const planned = planMemoryImport(entries, defaultScene);
1462
+ const skipped = [...problems, ...planned.problems];
1463
+ const imported = [];
1464
+ const accepted = [];
1465
+ for (const target of planned.targets) {
1466
+ if (target.group !== '' && !isValidGroupPath(target.group)) {
1467
+ skipped.push({ name: target.name, reason: '场景名非法,已跳过' });
1468
+ continue;
1469
+ }
1470
+ if (!isValidGroupSegment(target.name)) {
1471
+ skipped.push({ name: target.name, reason: '记忆名不合法,已跳过' });
1472
+ continue;
1473
+ }
1474
+ const text = Buffer.from(target.bytes).toString('utf8').replace(/^\uFEFF/, '');
1475
+ if (text.trim() === '') {
1476
+ skipped.push({ name: target.name, reason: '内容为空,已跳过' });
1477
+ continue;
1478
+ }
1479
+ if (Buffer.byteLength(text, 'utf8') > MAX_RULE_BYTES) {
1480
+ skipped.push({ name: target.name, reason: `正文超过 ${MAX_RULE_BYTES >> 10} KiB,已跳过` });
1481
+ continue;
1482
+ }
1483
+ // bundle 附件复检(规划层已过滤,这里按 rules-attach 同口径再拦一次,防绕过规划层的调用方)。
1484
+ const rawAttachments = target.kind === 'bundle' ? target.attachments || [] : [];
1485
+ const checkedAttachments = [];
1486
+ let attachTotal = 0;
1487
+ for (const att of rawAttachments) {
1488
+ if (!isValidGroupSegment(att.name)) {
1489
+ skipped.push({ name: `${target.name}/${att.name}`, reason: '附件名不合法,已跳过' });
1490
+ continue;
1491
+ }
1492
+ const data = Buffer.from(att.bytes);
1493
+ if (!data.length) {
1494
+ skipped.push({ name: `${target.name}/${att.name}`, reason: '附件内容为空,已跳过' });
1495
+ continue;
1496
+ }
1497
+ if (data.length > MAX_ATTACH_ENTRY_BYTES) {
1498
+ skipped.push({ name: `${target.name}/${att.name}`, reason: `附件过大(单个上限 ${MAX_ATTACH_ENTRY_BYTES >> 20} MiB),已跳过` });
1499
+ continue;
1500
+ }
1501
+ attachTotal += data.length;
1502
+ if (attachTotal > MAX_ATTACH_TOTAL_BYTES) {
1503
+ skipped.push({ name: `${target.name}/${att.name}`, reason: `附件合计超过 ${MAX_ATTACH_TOTAL_BYTES >> 20} MiB,已跳过` });
1504
+ continue;
1505
+ }
1506
+ checkedAttachments.push({ name: att.name, data });
1507
+ }
1508
+ const existing = await locateRule(target.group, target.name);
1509
+ if (existing) {
1510
+ skipped.push({ name: target.group ? `${target.group}/${target.name}` : target.name, reason: '同名已存在(已跳过)' });
1511
+ continue;
1512
+ }
1513
+ accepted.push({ group: target.group, name: target.name, text, kind: target.kind, attachments: checkedAttachments });
1514
+ }
1515
+ if (!accepted.length)
1516
+ return { ok: true, imported, skipped, scenes: [] };
1517
+ const index = await readIndex(stateDir);
1518
+ if (!index.scenes)
1519
+ index.scenes = {};
1520
+ const createdScenes = [];
1521
+ for (const item of accepted) {
1522
+ const id = `${item.group}/${item.name}`;
1523
+ try {
1524
+ if (item.kind === 'bundle') {
1525
+ // 与 rules-create 同落点:bundle = <场景>/<名>/ 目录,正文 SKILL.md,附件平铺同层。
1526
+ const bundleDir = join(rulesRoot, item.group, item.name);
1527
+ await mkdir(bundleDir, { recursive: true });
1528
+ await writeFileAtomically(join(bundleDir, 'SKILL.md'), item.text);
1529
+ for (const att of item.attachments)
1530
+ await writeFileAtomicBinary(join(bundleDir, att.name), att.data);
1531
+ }
1532
+ else {
1533
+ await mkdir(join(rulesRoot, item.group), { recursive: true });
1534
+ await writeFileAtomically(join(rulesRoot, item.group, item.name + '.md'), item.text);
1535
+ }
1536
+ }
1537
+ catch (e) {
1538
+ skipped.push({ name: id, reason: '写入失败:' + message(e) });
1539
+ continue;
1540
+ }
1541
+ // 索引记录:与 rules-create 同口径(order/enabled/updatedAt + 场景分组),用户既有设置不覆盖。
1542
+ const entry = index.rules[id];
1543
+ index.rules[id] = { ...(entry || {}), order: entry?.order ?? DEFAULT_ORDER, enabled: entry?.enabled ?? true, updatedAt: new Date().toISOString() };
1544
+ if (!index.groups[item.group])
1545
+ index.groups[item.group] = { order: DEFAULT_GROUP_ORDER, label: item.group };
1546
+ // 场景记录补齐(导入进来的目录名此前可能没有记录)。
1547
+ if (!index.scenes[item.group]) {
1548
+ index.scenes[item.group] = item.group === GLOBAL_SCENE
1549
+ ? { label: GLOBAL_SCENE_LABEL, order: 0 }
1550
+ : { order: DEFAULT_GROUP_ORDER, createdAt: new Date().toISOString() };
1551
+ createdScenes.push(item.group);
1552
+ }
1553
+ imported.push(id);
1554
+ }
1555
+ if (imported.length)
1556
+ await writeIndex(stateDir, index);
1557
+ return { ok: true, imported, skipped, scenes: createdScenes };
1155
1558
  }
1156
1559
  async function rulesUpdate(args) {
1157
1560
  const id = String((args && args.id) || '');
@@ -1497,8 +1900,9 @@ export function createRulesService(ctx, deps) {
1497
1900
  const seen = new Set();
1498
1901
  for (const item of raw) {
1499
1902
  const name = String(item == null ? '' : item).trim();
1500
- if (name === '' || name === SHARED_GROUP)
1501
- continue; // _shared 恒常,不入集合
1903
+ // 恒常启用的保留场景不入显式集合:`_shared`(公共基线)与 `global`(「全局」)。
1904
+ if (name === '' || name === SHARED_GROUP || name === GLOBAL_SCENE)
1905
+ continue;
1502
1906
  if (!isValidGroupPath(name)) {
1503
1907
  return fail('error.rules.invalidGroup', `场景名非法:${name}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头)`);
1504
1908
  }
@@ -1520,8 +1924,11 @@ export function createRulesService(ctx, deps) {
1520
1924
  };
1521
1925
  }
1522
1926
  /**
1523
- * 新建场景 = 在 `scene-memory/` 下建一个一级目录(目录内暂时没有记忆也成立)。
1524
- * 幂等:目录已存在直接成功。`_shared` 是保留名(无需创建)。
1927
+ * 新建场景:写一条场景记录(索引 scenes 切片)+ 建空目录 `memories/<场景>/`。
1928
+ * 场景是**显式实体**——空场景(还没有记忆)也是合法场景,会出现在场景列表里。
1929
+ * 场景名是**单个路径段**(不允许 `a/b`):界面把场景当一级列表展示,
1930
+ * 允许多段只会让「记忆的场景」与「目录层级」两套语义互相打架。
1931
+ * 幂等:已存在则更新描述/标签,不报错。`_shared` 与 `global` 是保留名。
1525
1932
  */
1526
1933
  async function rulesCreateScene(args) {
1527
1934
  const name = String((args && args.name) || '').trim();
@@ -1530,19 +1937,87 @@ export function createRulesService(ctx, deps) {
1530
1937
  }
1531
1938
  if (name === SHARED_GROUP)
1532
1939
  return fail('error.rules.invalidGroup', `_shared 是保留场景名,无需创建`);
1940
+ const label = args && args.label !== undefined ? String(args.label).trim() : '';
1941
+ const description = args && args.description !== undefined ? String(args.description).trim() : '';
1942
+ if (description.length > MAX_DESCRIPTION_LENGTH) {
1943
+ return fail('error.rules.descriptionTooLong', `场景描述过长(≤${MAX_DESCRIPTION_LENGTH} 字符)`);
1944
+ }
1945
+ await ensureLayout();
1533
1946
  try {
1534
1947
  await mkdir(join(rulesRoot, name), { recursive: true });
1535
1948
  }
1536
1949
  catch (e) {
1537
1950
  return fail('error.rules.ioFailed', `创建场景目录失败:${message(e)}`);
1538
1951
  }
1952
+ const index = await readIndex(stateDir);
1953
+ if (!index.scenes)
1954
+ index.scenes = {};
1955
+ const prev = index.scenes[name] || {};
1956
+ const next = {
1957
+ ...prev,
1958
+ ...(label !== '' && name !== GLOBAL_SCENE ? { label } : {}),
1959
+ ...(description !== '' ? { description } : {}),
1960
+ order: prev.order ?? DEFAULT_GROUP_ORDER,
1961
+ createdAt: prev.createdAt ?? new Date().toISOString(),
1962
+ };
1963
+ if (name === GLOBAL_SCENE)
1964
+ next.label = GLOBAL_SCENE_LABEL;
1965
+ index.scenes[name] = next;
1966
+ await writeIndex(stateDir, index);
1539
1967
  invalidateSnapshot();
1540
1968
  invalidateProviders();
1541
- return { ok: true, scene: { name, path: join(rulesRoot, name) } };
1969
+ return { ok: true, scene: sceneRecordOf(name, next) };
1542
1970
  }
1543
1971
  /**
1544
- * 删除场景目录:**仅空目录可删**(避免一次操作带走整组记忆)。
1545
- * 同时把该场景从 `active` 集合里摘掉,避免留下悬空引用。
1972
+ * 更新场景记录(描述 / 显示名 / 顺序)。记忆文件不动。
1973
+ * 这是「场景有描述」的写入口——旧版场景只有目录名,没有可编辑元数据。
1974
+ */
1975
+ async function rulesUpdateScene(args) {
1976
+ const name = String((args && args.name) || '').trim();
1977
+ if (!isValidGroupPath(name))
1978
+ return fail('error.rules.invalidGroup', `场景名非法:${name || '(空)'}`);
1979
+ const index = await readIndex(stateDir);
1980
+ if (!index.scenes)
1981
+ index.scenes = {};
1982
+ const prev = index.scenes[name];
1983
+ if (!prev && name !== GLOBAL_SCENE)
1984
+ return fail('error.rules.notFound', `场景不存在:${name}`);
1985
+ const next = { ...(prev || {}) };
1986
+ if (args && args.description !== undefined) {
1987
+ const description = String(args.description).trim();
1988
+ if (description.length > MAX_DESCRIPTION_LENGTH) {
1989
+ return fail('error.rules.descriptionTooLong', `场景描述过长(≤${MAX_DESCRIPTION_LENGTH} 字符)`);
1990
+ }
1991
+ if (description === '')
1992
+ delete next.description;
1993
+ else
1994
+ next.description = description;
1995
+ }
1996
+ if (args && args.label !== undefined && name !== GLOBAL_SCENE) {
1997
+ const label = String(args.label).trim();
1998
+ if (label === '')
1999
+ delete next.label;
2000
+ else
2001
+ next.label = label;
2002
+ }
2003
+ if (args && args.order !== undefined) {
2004
+ const order = Number(args.order);
2005
+ if (!Number.isFinite(order) || order < 0)
2006
+ return fail('error.rules.invalidGroup', `非法排序值:${args.order}`);
2007
+ next.order = Math.floor(order);
2008
+ }
2009
+ if (name === GLOBAL_SCENE)
2010
+ next.label = GLOBAL_SCENE_LABEL;
2011
+ index.scenes[name] = next;
2012
+ await writeIndex(stateDir, index);
2013
+ invalidateSnapshot();
2014
+ invalidateProviders();
2015
+ return { ok: true, scene: sceneRecordOf(name, next) };
2016
+ }
2017
+ /**
2018
+ * 删除场景:**仅空目录可删**(避免一次操作带走整组记忆)。
2019
+ * 同时删掉场景记录、把它从 `active` 集合里摘掉、清掉它的档案,避免悬空引用。
2020
+ * 保留场景 `global` 不可删除;场景名口径与创建一致(单个路径段)。
1546
2021
  */
1547
2022
  async function rulesRemoveScene(args) {
1548
2023
  const name = String((args && args.name) || '').trim();
@@ -1550,27 +2025,51 @@ export function createRulesService(ctx, deps) {
1550
2025
  return fail('error.rules.invalidGroup', `场景名非法:${name || '(空)'}`);
1551
2026
  if (name === SHARED_GROUP)
1552
2027
  return fail('error.rules.invalidGroup', `_shared 是保留场景名,不可删除`);
2028
+ if (name === GLOBAL_SCENE)
2029
+ return fail('error.rules.reservedScene', `「全局」是保留场景,不可删除(它的记忆对任何对话都生效)`);
2030
+ const index = await readIndex(stateDir);
2031
+ // 场景不存在(既无记录也无目录)→ 明确报错,而不是假装删成功。
2032
+ if (!index.scenes?.[name] && !(await pathExists(join(rulesRoot, name)))) {
2033
+ return fail('error.rules.notFound', `场景不存在:${name}`);
2034
+ }
1553
2035
  let entries = [];
1554
2036
  try {
1555
2037
  entries = await readdir(join(rulesRoot, name));
1556
2038
  }
1557
2039
  catch {
1558
- return fail('error.rules.notFound', `场景不存在:${name}`);
2040
+ entries = []; // 目录本来就不存在(只有记录)→ 仍允许删记录
1559
2041
  }
1560
- if (entries.length > 0)
2042
+ if (entries.filter((n) => !n.startsWith('.')).length > 0) {
1561
2043
  return fail('error.rules.sceneNotEmpty', `场景「${name}」里还有 ${entries.length} 项,请先删除其中的记忆`);
2044
+ }
2045
+ // 当前模式场景不可删:快照只在引擎里可退(rules service 反向注入会成环),
2046
+ // 直接删除会让运行时启停永久停在档案态且无恢复路径 → 给出可逆出路(先退出模式)。
2047
+ if (index.mode?.scene === name) {
2048
+ return fail('error.rules.sceneInMode', `场景「${name}」正处在当前模式,请先退出模式再删除`);
2049
+ }
1562
2050
  try {
1563
2051
  // fs.rm 删目录必须 recursive(即使已确认它是空的),否则报 EISDIR。
1564
- await rm(join(rulesRoot, name), { recursive: true });
2052
+ await rm(join(rulesRoot, name), { recursive: true, force: true });
1565
2053
  }
1566
2054
  catch (e) {
1567
2055
  return fail('error.rules.ioFailed', `删除场景目录失败:${message(e)}`);
1568
2056
  }
1569
- const index = await readIndex(stateDir);
1570
- if (Array.isArray(index.active)) {
2057
+ // 索引清理一次读-改-写:记录 + 启用集合悬空引用 + 该场景档案(否则 archives 留孤儿条目)。
2058
+ let dirty = false;
2059
+ if (index.scenes && index.scenes[name]) {
2060
+ delete index.scenes[name];
2061
+ dirty = true;
2062
+ }
2063
+ if (Array.isArray(index.active) && index.active.indexOf(name) >= 0) {
1571
2064
  index.active = index.active.filter((s) => s !== name);
1572
- await writeIndex(stateDir, index);
2065
+ dirty = true;
1573
2066
  }
2067
+ if (index.archives && index.archives[name]) {
2068
+ delete index.archives[name];
2069
+ dirty = true;
2070
+ }
2071
+ if (dirty)
2072
+ await writeIndex(stateDir, index);
1574
2073
  invalidateSnapshot();
1575
2074
  invalidateProviders();
1576
2075
  return { ok: true, name };
@@ -1626,12 +2125,21 @@ export function createRulesService(ctx, deps) {
1626
2125
  await refresh();
1627
2126
  return result;
1628
2127
  });
2128
+ // 写操作清单:与下方 ops 表同文件同源维护(含此前漂移漏掉的
2129
+ // rules-attach / rules-detach / rules-trash-remove);HTTP 端门禁由
2130
+ // index.ts 从本集合派生,勿在宿主端另抄一份。
2131
+ const writeOps = new Set([
2132
+ 'rules-create', 'rules-update', 'rules-remove', 'rules-restore', 'rules-toggle', 'rules-import',
2133
+ 'rules-set-index', 'rules-set-active', 'rules-create-scene', 'rules-update-scene', 'rules-remove-scene',
2134
+ 'rules-attach', 'rules-detach', 'rules-trash-remove',
2135
+ ]);
1629
2136
  const ops = {
1630
2137
  'rules-list': (args) => rulesList(args || {}),
1631
2138
  'rules-read': (args) => rulesRead(args || {}),
1632
2139
  'rules-budget': () => rulesBudget(),
1633
2140
  'rules-diagnose': () => rulesDiagnose(),
1634
2141
  'rules-create': (args) => runWrite(() => rulesCreate(args || {})),
2142
+ 'rules-import': (args) => runWrite(() => rulesImport(args || {})),
1635
2143
  'rules-update': (args) => runWrite(() => rulesUpdate(args || {})),
1636
2144
  'rules-remove': (args) => runWrite(() => rulesRemove(args || {})),
1637
2145
  'rules-restore': (args) => runWrite(() => rulesRestore(args || {})),
@@ -1643,15 +2151,16 @@ export function createRulesService(ctx, deps) {
1643
2151
  'rules-set-index': (args) => runWrite(() => rulesSetIndex(args || {})),
1644
2152
  'rules-set-active': (args) => runWrite(() => rulesSetActive(args || {})),
1645
2153
  'rules-create-scene': (args) => runWrite(() => rulesCreateScene(args || {})),
2154
+ 'rules-update-scene': (args) => runWrite(() => rulesUpdateScene(args || {})),
1646
2155
  'rules-remove-scene': (args) => runWrite(() => rulesRemoveScene(args || {})),
1647
2156
  };
1648
2157
  const service = {
1649
2158
  ops,
2159
+ writeOps,
1650
2160
  registerProviders,
1651
2161
  refresh,
1652
- _invalidate: invalidateProviders,
1653
- _renderActiveScenes: () => sceneMemory().text,
1654
- _sceneMemory: () => sceneMemory(),
2162
+ patchIndex,
2163
+ readArchiveSlice,
1655
2164
  };
1656
2165
  return service;
1657
2166
  }