dsh-plugin-tool-management 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/README.md +117 -221
  3. package/README_EN.md +116 -220
  4. package/docs/images/1/345/234/272/346/231/257.png +0 -0
  5. package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
  6. package/docs/images/2MCP.png +0 -0
  7. package/docs/images/2MCP_en.png +0 -0
  8. package/docs/images/3/346/212/200/350/203/275.png +0 -0
  9. package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
  10. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  11. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
  12. package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
  13. package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
  14. package/docs/images/6/350/256/260/345/277/206.png +0 -0
  15. package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
  16. package/docs/images/7/344/274/232/350/257/235.png +0 -0
  17. package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
  18. package/docs/images/8/345/205/274/345/256/271.png +0 -0
  19. package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
  20. package/docs/update.md +56 -38
  21. package/lib/agents-md/service.js +84 -5
  22. package/lib/client.js +1488 -475
  23. package/lib/compat/preset-reach.js +425 -0
  24. package/lib/compat/probe.js +8 -0
  25. package/lib/http-fence.js +21 -0
  26. package/lib/hub.js +160 -2
  27. package/lib/index.js +687 -47
  28. package/lib/mcp/override-blocks.js +195 -0
  29. package/lib/mcp/state-section.js +90 -0
  30. package/lib/prompt-sections.js +148 -0
  31. package/lib/rules/archive-engine.js +99 -29
  32. package/lib/rules/archive.js +56 -28
  33. package/lib/rules/service.js +332 -35
  34. package/lib/skills/core.js +15 -1
  35. package/lib/skills/service.js +3 -0
  36. package/lib/subagents/catalog.js +89 -0
  37. package/lib/subagents/service.js +384 -41
  38. package/lib/subagents/tools.js +18 -5
  39. package/package.json +97 -96
  40. package/screenshots.json +10 -10
  41. package/docs/Changelog.md +0 -144
  42. package/lib/rules/provider.js +0 -121
@@ -94,20 +94,63 @@ export function computeMcpPlan(mcp, input) {
94
94
  if (known.indexOf(t) < 0)
95
95
  stale.push('mcp/' + server + '/' + t);
96
96
  }
97
- return { entries, wildcards, stale };
97
+ // 服务器级双向切换:勾选集 = 该场景下的**服务器启停配置**。
98
+ // 勾了 → 启用;没勾 → 停用。只推入状态需要变化的行。
99
+ const serverSwitches = [];
100
+ for (const state of input.serverStates || []) {
101
+ if (!configured.has(state.serverName))
102
+ continue;
103
+ const enabled = mcp[state.serverName] !== undefined;
104
+ if (state.disabled === !enabled)
105
+ continue; // 已经是目标状态
106
+ serverSwitches.push({ id: state.id, serverName: state.serverName, level: state.level, enabled, disabledBefore: state.disabled });
107
+ }
108
+ return { entries, wildcards, stale, serverSwitches };
98
109
  }
99
- /** 技能段应用计划:已知技能全集 → 勾选集决定启停。 */
100
- export function computeSkillsPlan(skills, knownSkillKeys) {
110
+ /**
111
+ * 技能段应用计划:已知技能全集 → 勾选集决定启停。
112
+ *
113
+ * `sourceStates` 传了才会产出 `sourceSwitches`(来源级双向切换);不传 = 只做技能级。
114
+ */
115
+ export function computeSkillsPlan(skills, knownSkillKeys, sourceStates) {
101
116
  const target = {};
102
117
  for (const key of knownSkillKeys)
103
118
  target[key] = skills.indexOf(key) >= 0;
104
119
  const stale = skills.filter((k) => !knownSkillKeys.has(k)).map((k) => 'skills/' + k);
105
- return { target, stale };
120
+ const sourceSwitches = [];
121
+ if (sourceStates) {
122
+ // 该来源下有没有被勾选的技能(只看已知技能,避免 stale 项把来源"点亮")。
123
+ const checked = new Set(skills);
124
+ const onRoots = new Set();
125
+ for (const key of knownSkillKeys) {
126
+ if (!checked.has(key))
127
+ continue;
128
+ const i = key.indexOf('/');
129
+ if (i > 0)
130
+ onRoots.add(key.slice(0, i));
131
+ }
132
+ for (const state of sourceStates) {
133
+ const enabled = onRoots.has(state.root);
134
+ if (state.enabled === enabled)
135
+ continue;
136
+ sourceSwitches.push({ root: state.root, enabled, enabledBefore: state.enabled });
137
+ }
138
+ }
139
+ return { target, stale, sourceSwitches };
106
140
  }
107
- export function snapshotRuntime(mcpRaw, skills) {
141
+ export function snapshotRuntime(mcpRaw, skills,
142
+ /** **将被档案改动**的服务器行,带改动前的 `disabled`。 */
143
+ mcpServers = [],
144
+ /** **将被档案改动**的来源,带改动前的 `enabled`。 */
145
+ skillSources = [],
146
+ /** **将被档案启用**的人设名(改动前停用的子集;退出时按此停回)。 */
147
+ subagents = []) {
108
148
  return {
109
149
  mcp: Object.fromEntries(Object.entries(mcpRaw).map(([k, v]) => [k, v.slice()])),
110
150
  skills: { ...skills },
151
+ mcpServers: mcpServers.map((x) => ({ id: x.id, level: x.level, disabled: x.disabled })),
152
+ skillSources: skillSources.map((x) => ({ root: x.root, enabled: x.enabled })),
153
+ ...(subagents.length ? { subagents: subagents.slice() } : {}),
111
154
  };
112
155
  }
113
156
  // ── 记忆勾选(v3)──────────────────────────────────────────────────────────
@@ -115,26 +158,11 @@ export function snapshotRuntime(mcpRaw, skills) {
115
158
  // 记忆段与其余三段有一处关键差别:MCP / 技能是**运行时启停**(改了要落表、进/退模式要回滚),
116
159
  // 记忆只是**投影**(正文进 system prompt)。所以记忆段不产生"应用计划"、不进模式快照,
117
160
  // 而是在渲染时被查询——勾选后下一个请求即生效,退出模式无需回滚任何东西。
118
- /**
119
- * 记忆是否应当注入(纯函数,供渲染路径同步调用)。
120
- *
121
- * 口径与其余段同构:
122
- * - 该记忆所属场景**没有** memories 段 → 全部记忆照常注入(老档案 = 未定义 = 不碰);
123
- * - 有段 → 只有列在段里的记忆注入(段已定义但空 = 该场景记忆全部不注入)。
124
- *
125
- * 入参 `sceneEnabled` 是索引里的单条启停(`enabled === false` 的条目在上游已被过滤,
126
- * 这里只为把两种"不注入"的原因分开,便于体检报告区分)。
127
- */
128
- export function memoryAllowed(archives, scene, id) {
129
- const archive = archives ? archives[scene] : undefined;
130
- if (!archive || archive.memories === undefined)
131
- return true;
132
- return archive.memories.indexOf(id) >= 0;
133
- }
134
- /**
135
- * 记忆段的 stale 上报(档案保存时调用):段里引用了已经不存在的记忆 id。
136
- * 记忆段不写任何运行时状态,因此除 stale 外没有别的计划产物。
137
- */
138
- export function computeMemoriesPlan(memories, knownMemoryIds) {
139
- return { stale: memories.filter((id) => !knownMemoryIds.has(id)).map((id) => 'memories/' + id) };
140
- }
161
+ // P5(2026-09-15)已删除 `memoryAllowed` 与 `computeMemoriesPlan`:
162
+ // 记忆的注入条件改为**单一真相源** `rules[*].enabled`,场景档案不再持有 `memories` 段。
163
+ // 原因是「两个开关串联 + 两步操作」——用户必须先建段、再逐条勾,而且记忆页与档案页
164
+ // 各显示一半状态,看起来永远对不上。现在档案弹窗里的记忆勾选直接读写 `enabled`,
165
+ // 与记忆页同一个写入口,天然一致。
166
+ //
167
+ // 老档案兼容:`archives[*].memories` 字段被**忽略**,不做自动迁移。影响可控 ——
168
+ // 段内记忆的 `enabled` 通常本就是 true,忽略后行为不变。
@@ -29,11 +29,12 @@ import { randomUUID } from 'node:crypto';
29
29
  import { readFileSync, readdirSync, statSync } from 'node:fs';
30
30
  import { copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
31
31
  import { basename, dirname, join, resolve } from 'node:path';
32
- import { parseSkillDoc, resolveDshHome, unquote } from '../skills/core.js';
32
+ import { parseSkillDoc, renameWithRetry, resolveDshHome, unquote } from '../skills/core.js';
33
33
  import { expandUploads, planMemoryImport } from '../imports/upload.js';
34
- import { createRuleProviderRegistrar } from './provider.js';
34
+ import { SECTION_ORDER, createPromptSectionRegistrar } from '../prompt-sections.js';
35
35
  import { normalizePresetId } from '../agents-md/preset-id.js';
36
- import { normalizeArchive, memoryAllowed } from './archive.js';
36
+ import { normalizeArchive } from './archive.js';
37
+ import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
37
38
  // ── 常量 ───────────────────────────────────────────────────────────────────
38
39
  const MAX_SOURCE_DEPTH = 64; // 与 core.js 一致
39
40
  const MAX_DIRECTORIES = 2000; // 目录预算
@@ -101,25 +102,68 @@ export const MEMORIES_DIR = 'memories';
101
102
  export const GLOBAL_SCENE = 'global';
102
103
  /** 保留场景在界面上的显示名(磁盘上仍用 ASCII 目录/文件名)。 */
103
104
  export const GLOBAL_SCENE_LABEL = '全局';
105
+ /**
106
+ * 记忆导出的落盘映射(`bundle-export` 的 `kind: 'memories'` 用,见 design-plan D14)。
107
+ *
108
+ * 记忆有两种形态(见文件头):flat = `<场景>/<name>.md`,bundle = `<场景>/<name>/SKILL.md`;
109
+ * 而且**场景名本身可含 `/`**(多段场景名,见 `isValidGroupPath`)。所以「按 id 的最后一个
110
+ * `/` 切出场景与名字、再拼 `.md`」是错的:bundle 会被读成 `<场景>/<name>.md` → 读不到 →
111
+ * 该项目静默丢失(只在 `missing` 里留个名,界面按「导出成功」显示)。
112
+ *
113
+ * 这里一律按索引里那条规则自己的 `path` 与 `form` 决定;bundle 只交出目录,由调用方
114
+ * 把目录内的文件全部打包(与技能分支同口径)。索引里没有、或已被同名 bundle 遮蔽的 id
115
+ * 进 `missing`——遮蔽的 flat 不会被加载,导出去只会让人以为它能用。
116
+ *
117
+ * 纯函数(不碰磁盘),可直接断言。
118
+ */
119
+ export function planMemoryExport(names, rules) {
120
+ const list = Array.isArray(names) ? names.map((n) => String(n).trim()).filter(Boolean) : [];
121
+ const byId = new Map();
122
+ for (const row of (Array.isArray(rules) ? rules : [])) {
123
+ const id = row && typeof row.id === 'string' ? row.id : '';
124
+ if (id !== '')
125
+ byId.set(id, row);
126
+ }
127
+ const entries = [];
128
+ const missing = [];
129
+ for (const id of list) {
130
+ const row = byId.get(id);
131
+ const abs = row && typeof row.path === 'string' ? row.path : '';
132
+ if (!row || abs === '' || row.shadowed === true) {
133
+ missing.push(id);
134
+ continue;
135
+ }
136
+ entries.push(row.form === 'bundle'
137
+ ? { id, zip: id, abs: dirname(abs), kind: 'dir' }
138
+ : { id, zip: `${id}.md`, abs, kind: 'file' });
139
+ }
140
+ return { entries, missing };
141
+ }
104
142
  // ── 文件工具 ───────────────────────────────────────────────────────────────
105
- /** 同目录临时文件 + rename 原子写(临时名 `.xxx.dsh-rules-<uuid>.tmp`)。 */
143
+ /**
144
+ * 同目录临时文件 + rename 原子写(临时名 `.xxx.dsh-rules-<uuid>.tmp`)。
145
+ * rename 走 `renameWithRetry`:Windows 上杀软/索引器会短暂占住目标文件报
146
+ * EPERM/EACCES/EBUSY(用户实测:进入/退出模式时 rules-index.json 被 rename 撞上,
147
+ * 运行时已切换但状态落盘失败,界面开关停在旧状态、还得再点一次)。这种占用是
148
+ * 瞬时的,重试几轮就能过去;全失败才清理临时文件并把错误抛出。
149
+ */
106
150
  async function writeFileAtomically(path, content) {
107
151
  const temp = join(dirname(path), `.${basename(path)}.dsh-rules-${randomUUID()}.tmp`);
108
152
  try {
109
153
  await writeFile(temp, content, 'utf8');
110
- await rename(temp, path);
154
+ await renameWithRetry(temp, path);
111
155
  }
112
156
  catch (error) {
113
157
  await rm(temp, { force: true }).catch(() => undefined);
114
158
  throw error;
115
159
  }
116
160
  }
117
- /** 二进制版原子写(附件用):临时文件 + rename,失败清理临时文件。 */
161
+ /** 二进制版原子写(附件用):临时文件 + rename(同样带瞬时占用重试),失败清理临时文件。 */
118
162
  async function writeFileAtomicBinary(path, data) {
119
163
  const temp = join(dirname(path), `.${basename(path)}.dsh-rules-${randomUUID()}.tmp`);
120
164
  try {
121
165
  await writeFile(temp, data);
122
- await rename(temp, path);
166
+ await renameWithRetry(temp, path);
123
167
  }
124
168
  catch (error) {
125
169
  await rm(temp, { force: true }).catch(() => undefined);
@@ -346,6 +390,9 @@ function parseSceneEntry(raw) {
346
390
  out.order = obj.order;
347
391
  if (typeof obj.createdAt === 'string' && obj.createdAt !== '')
348
392
  out.createdAt = obj.createdAt;
393
+ // 场景锁定(v0.8):读盘必须原样保留 —— 这里曾只重建已知字段,锁了也会被剥成未锁。
394
+ if (obj.locked === true)
395
+ out.locked = true;
349
396
  return out;
350
397
  }
351
398
  function parseScenes(raw) {
@@ -411,9 +458,29 @@ function parseModeState(raw) {
411
458
  list.push(tool);
412
459
  }
413
460
  }
461
+ // v2 快照的**服务器级 / 来源级**名单与 v0.8 的子智能体名单必须原样透传 ——
462
+ // 这里曾把它们剥掉,结果退出模式时两张恢复名单全是空的:服务器级 MCP 与技能来源
463
+ // 永远不回滚(用户报的「MCP 不复原」「技能目录不回退」),只有工具级 / 技能级能还原。
464
+ const mcpServers = (Array.isArray(snapshotRaw.mcpServers) ? snapshotRaw.mcpServers : [])
465
+ .filter((x) => !!x && typeof x === 'object')
466
+ .map((x) => ({ id: String(x.id || ''), level: String(x.level || ''), disabled: x.disabled === true }))
467
+ .filter((x) => x.id !== '' && (x.level === 'global' || x.level === 'project'));
468
+ const skillSources = (Array.isArray(snapshotRaw.skillSources) ? snapshotRaw.skillSources : [])
469
+ .filter((x) => !!x && typeof x === 'object')
470
+ .map((x) => ({ root: String(x.root || ''), enabled: x.enabled === true }))
471
+ .filter((x) => x.root !== '');
472
+ const subagents = (Array.isArray(snapshotRaw.subagents) ? snapshotRaw.subagents : []).map((x) => String(x)).filter(Boolean);
414
473
  return {
415
474
  scene,
416
- snapshot: scene ? { mcp, skills: toFlagMap(snapshotRaw.skills) } : null,
475
+ snapshot: scene
476
+ ? {
477
+ mcp,
478
+ skills: toFlagMap(snapshotRaw.skills),
479
+ ...(mcpServers.length ? { mcpServers } : {}),
480
+ ...(skillSources.length ? { skillSources } : {}),
481
+ ...(subagents.length ? { subagents } : {}),
482
+ }
483
+ : null,
417
484
  };
418
485
  }
419
486
  /** 解析 rules-index.json 原文;任何异常/版本不符 → 默认索引(容忍缺失,§9.3)。 */
@@ -880,9 +947,9 @@ function renderSceneMemory(probe, index, maxBytes) {
880
947
  // 保留场景 global 恒定生效(「全局」= 任何对话都注入);其余由 index.active 决定。
881
948
  if (file.scene !== GLOBAL_SCENE && !active.has(file.scene))
882
949
  continue;
883
- // 场景档案的记忆段(v3):该场景定过 memories 段 → 只有勾选的记忆进段(纯投影,不改文件)。
884
- if (!memoryAllowed(index.archives, file.scene, file.id))
885
- continue;
950
+ // ⚠️ 这里**不再**看场景档案的 memories 段:记忆的开关是**单一真相源** `rules[*].enabled`
951
+ // (见下方 enabled 判定)。档案弹窗里的记忆勾选就是同一个值,所以两边天然一致,
952
+ // 不存在「记忆页开了、档案页还显示未开」的两套状态。
886
953
  const list = buckets.get(file.scene);
887
954
  if (list)
888
955
  list.push(file);
@@ -1377,6 +1444,7 @@ export function createRulesService(ctx, deps) {
1377
1444
  prompt: index.scenes?.[name]?.prompt || '',
1378
1445
  // 单选模型下"能不能点开":已被别的场景占用时,这个开关要置灰。
1379
1446
  selectable: name !== SHARED_GROUP && name !== GLOBAL_SCENE && (enabledScene === null || enabledScene === name),
1447
+ locked: index.scenes?.[name]?.locked === true,
1380
1448
  }))
1381
1449
  .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
1382
1450
  }
@@ -1390,6 +1458,8 @@ export function createRulesService(ctx, deps) {
1390
1458
  .sort((a, b) => a.group.localeCompare(b.group) || a.order - b.order || a.name.localeCompare(b.name));
1391
1459
  const groups = groupFilter ? snap.groups.filter((g) => g.name === groupFilter) : snap.groups;
1392
1460
  const scenes = sceneRows(snap, index);
1461
+ // 场景锁定状态(v0.8):任意一个场景锁定 = 五个管理域整体冻结。界面据此禁用各页的写控件。
1462
+ const anyLocked = Object.values(index.scenes || {}).some((s) => s.locked === true);
1393
1463
  const projection = sceneMemory();
1394
1464
  const promptProjection = scenePrompt();
1395
1465
  return {
@@ -1399,6 +1469,8 @@ export function createRulesService(ctx, deps) {
1399
1469
  groups: groups.map((g) => ({ ...g, key: g.name })),
1400
1470
  // 场景 = 显式记录(含保留场景 global 与尚无记忆的空场景);active = 是否参与注入。
1401
1471
  scenes,
1472
+ // 任一场景被锁定 = 五个管理域整体冻结(客户端据此禁用写控件;服务端 handlers 另有守卫)。
1473
+ anyLocked,
1402
1474
  activeMode: normalizeActive(index.active) === null ? 'all' : 'custom',
1403
1475
  // 单选模型:当前启用的那个场景(null = 只留 `_shared` 与 `global`)。
1404
1476
  activeScene: enabledSceneOf(index),
@@ -1605,7 +1677,9 @@ export function createRulesService(ctx, deps) {
1605
1677
  // id 恒为 `<场景>/<名>`(与 parseId 同构);场景记录此时必然已存在。
1606
1678
  const createdId = `${targetGroup}/${name}`;
1607
1679
  const index = indexForScene;
1608
- index.rules[createdId] = { order: DEFAULT_ORDER, enabled: true, updatedAt: new Date().toISOString() };
1680
+ // P5:新建记忆默认**不开启** —— 记忆的开关是单一真相源(`enabled`),
1681
+ // 用户裁定「创建的记忆默认不开启,在记忆页开启后对应的场景档案也要显示开启」。
1682
+ index.rules[createdId] = { order: DEFAULT_ORDER, enabled: false, updatedAt: new Date().toISOString() };
1609
1683
  if (!index.groups[targetGroup])
1610
1684
  index.groups[targetGroup] = { order: DEFAULT_GROUP_ORDER, label: targetGroup };
1611
1685
  await writeIndex(stateDir, index);
@@ -1713,7 +1787,8 @@ export function createRulesService(ctx, deps) {
1713
1787
  }
1714
1788
  // 索引记录:与 rules-create 同口径(order/enabled/updatedAt + 场景分组),用户既有设置不覆盖。
1715
1789
  const entry = index.rules[id];
1716
- index.rules[id] = { ...(entry || {}), order: entry?.order ?? DEFAULT_ORDER, enabled: entry?.enabled ?? true, updatedAt: new Date().toISOString() };
1790
+ // P5:导入的记忆同样默认**不开启**(与 rules-create 同一口径);已存在的条目保留原开关。
1791
+ index.rules[id] = { ...(entry || {}), order: entry?.order ?? DEFAULT_ORDER, enabled: entry?.enabled ?? false, updatedAt: new Date().toISOString() };
1717
1792
  if (!index.groups[item.group])
1718
1793
  index.groups[item.group] = { order: DEFAULT_GROUP_ORDER, label: item.group };
1719
1794
  // 场景记录补齐(导入进来的目录名此前可能没有记录)。
@@ -2047,9 +2122,16 @@ export function createRulesService(ctx, deps) {
2047
2122
  const parts = parseId(id);
2048
2123
  if (!parts)
2049
2124
  return fail('error.rules.notFound', `规则不存在:${id}`);
2125
+ // `enabled` 必须显式给。缺省时旧实现回落到"沿用当前值",却照样刷新
2126
+ // updatedAt 并返回 ok:true —— 调用方按 toggle(翻转)理解时会以为自己改了
2127
+ // 状态,实际什么都没改(界面不受影响:它一直显式传值)。与 rulesSetActive
2128
+ // 同一条口径:参数缺失就明确拒绝,不猜。
2129
+ if (typeof (args && args.enabled) !== 'boolean') {
2130
+ return fail('error.rules.invalidArgs', '缺少参数:enabled 必须是布尔值(toggle 只按传入值写入,不做"翻转"推断)');
2131
+ }
2050
2132
  const index = await readIndex(stateDir);
2051
2133
  const idxEntry = index.rules[id] || {};
2052
- const enabled = args && args.enabled !== undefined ? args.enabled === true : (idxEntry.enabled ?? true);
2134
+ const enabled = args.enabled === true;
2053
2135
  index.rules[id] = { ...idxEntry, enabled, updatedAt: new Date().toISOString() };
2054
2136
  await writeIndex(stateDir, index);
2055
2137
  invalidateSnapshot();
@@ -2160,8 +2242,15 @@ export function createRulesService(ctx, deps) {
2160
2242
  return { ok: true, scene: sceneRecordOf(name, next), ...(collapsed ? { collapsedActive: true } : {}) };
2161
2243
  }
2162
2244
  /**
2163
- * 更新场景记录(描述 / 显示名 / 顺序 / 绑定的提示词预设)。记忆文件不动。
2164
- * 这是「场景有描述」的写入口——旧版场景只有目录名,没有可编辑元数据。
2245
+ * 更新场景记录(描述 / 显示名 / 顺序 / 绑定的提示词预设),**可选改名**(nextName)。
2246
+ *
2247
+ * 场景名就是它的一级目录名(`memories/<场景>/…`),所以改名不是改一个字段:
2248
+ * ① 目录 `memories/<旧>` → `memories/<新>`;
2249
+ * ② 索引里的四处引用一起改:`scenes` 记录、`archives` 档案、`active` 启用集合、`mode.scene`。
2250
+ * 记忆正文一个字节都不动(只是换了所在目录名)。
2251
+ *
2252
+ * 拒绝的三种情况:保留场景 `global` / `_shared`;目标名已被占用(目录或记录);
2253
+ * 该场景正在当前模式里——模式快照是按场景名算的,改了名与快照就对不上(同删除的处理)。
2165
2254
  */
2166
2255
  async function rulesUpdateScene(args) {
2167
2256
  const name = String((args && args.name) || '').trim();
@@ -2211,13 +2300,106 @@ export function createRulesService(ctx, deps) {
2211
2300
  return fail('error.rules.invalidGroup', `非法排序值:${args.order}`);
2212
2301
  next.order = Math.floor(order);
2213
2302
  }
2214
- if (name === GLOBAL_SCENE)
2303
+ // ── 改名(可选):目录 + 索引里的四处引用一起动 ──
2304
+ let finalName = name;
2305
+ const nextRaw = args && args.nextName !== undefined ? String(args.nextName).trim() : '';
2306
+ if (nextRaw !== '' && nextRaw !== name) {
2307
+ if (!isValidGroupSegment(nextRaw)) {
2308
+ return fail('error.rules.invalidGroup', `新场景名非法:${nextRaw}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头)`);
2309
+ }
2310
+ if (name === GLOBAL_SCENE)
2311
+ return fail('error.rules.reservedScene', '「全局」是保留场景,不可改名');
2312
+ if (name === SHARED_GROUP || nextRaw === SHARED_GROUP)
2313
+ return fail('error.rules.invalidGroup', '_shared 是保留场景名,不可改名');
2314
+ if ((index.scenes && index.scenes[nextRaw]) || (await pathExists(join(rulesRoot, nextRaw)))) {
2315
+ return fail('error.rules.nameTaken', `目标场景名已被占用:${nextRaw}`);
2316
+ }
2317
+ if (index.mode && index.mode.scene === name) {
2318
+ return fail('error.rules.sceneInMode', `场景「${name}」正处在当前模式,请先退出模式再改名`);
2319
+ }
2320
+ const fromDir = join(rulesRoot, name);
2321
+ const toDir = join(rulesRoot, nextRaw);
2322
+ let movedDir = false;
2323
+ if (await pathExists(fromDir)) {
2324
+ try {
2325
+ await rename(fromDir, toDir);
2326
+ movedDir = true;
2327
+ }
2328
+ catch (e) {
2329
+ return fail('error.rules.ioFailed', `场景目录改名失败:${message(e)}`);
2330
+ }
2331
+ }
2332
+ // 目录已经搬过去了:索引这一步失败就必须把目录搬回来,否则目录名与索引各说各话。
2333
+ try {
2334
+ const scenes = index.scenes || {};
2335
+ if (scenes[name]) {
2336
+ scenes[nextRaw] = scenes[name];
2337
+ delete scenes[name];
2338
+ }
2339
+ else {
2340
+ scenes[nextRaw] = { order: DEFAULT_GROUP_ORDER, createdAt: new Date().toISOString() };
2341
+ }
2342
+ index.scenes = scenes;
2343
+ if (index.archives && index.archives[name]) {
2344
+ index.archives[nextRaw] = index.archives[name];
2345
+ delete index.archives[name];
2346
+ }
2347
+ const act = normalizeActive(index.active);
2348
+ if (act !== null && act.indexOf(name) >= 0)
2349
+ index.active = act.map((n) => (n === name ? nextRaw : n));
2350
+ if (index.mode && index.mode.scene === name)
2351
+ index.mode = { ...index.mode, scene: nextRaw };
2352
+ }
2353
+ catch (e) {
2354
+ if (movedDir) {
2355
+ try {
2356
+ await rename(toDir, fromDir);
2357
+ }
2358
+ catch { /* 回滚失败:错误信息里如实带上原因 */ }
2359
+ }
2360
+ return fail('error.rules.ioFailed', `场景改名后索引更新失败:${message(e)}`);
2361
+ }
2362
+ finalName = nextRaw;
2363
+ }
2364
+ if (finalName === GLOBAL_SCENE)
2215
2365
  next.label = GLOBAL_SCENE_LABEL;
2216
- index.scenes[name] = next;
2366
+ index.scenes[finalName] = next;
2217
2367
  await writeIndex(stateDir, index);
2218
2368
  invalidateSnapshot();
2219
2369
  invalidateProviders();
2220
- return { ok: true, scene: sceneRecordOf(name, next) };
2370
+ return {
2371
+ ok: true,
2372
+ ...(finalName === name ? {} : { renamedFrom: name }),
2373
+ scene: sceneRecordOf(finalName, next),
2374
+ };
2375
+ }
2376
+ /**
2377
+ * 场景锁定(v0.8):锁定后五个管理域(MCP/技能/子智能体/记忆/提示词)整体只读 ——
2378
+ * 场景页的档案编辑与功能页的启停/编辑都被拒(index.ts 的 handlers 守卫 + 界面禁用),
2379
+ * 场景自身的启停(进/退模式)不受影响。`locked` 必须显式给布尔值(与 rules-toggle 同一口径)。
2380
+ * 保留场景 `global` 不在场景页出现,不可锁。
2381
+ */
2382
+ async function rulesSceneLock(args) {
2383
+ const name = String((args && args.scene) || '').trim();
2384
+ if (!isValidGroupPath(name))
2385
+ return fail('error.rules.invalidGroup', `场景名非法:${name || '(空)'}`);
2386
+ if (name === GLOBAL_SCENE || name === SHARED_GROUP)
2387
+ return fail('error.rules.reservedScene', '「全局 / _shared」是保留场景,不可锁定');
2388
+ if (typeof (args && args.locked) !== 'boolean') {
2389
+ return fail('error.rules.invalidArgs', '缺少参数:locked 必须是布尔值(只按传入值写入,不做"翻转"推断)');
2390
+ }
2391
+ const index = await readIndex(stateDir);
2392
+ if (!index.scenes || !index.scenes[name])
2393
+ return fail('error.rules.notFound', `场景不存在:${name}`);
2394
+ // 未启动的场景不能上锁(用户裁定):锁定的意义是冻结**运行中**场景的配置。
2395
+ // 解锁随时允许 —— 否则退出模式后,被锁的场景就没人能解了。
2396
+ if (args.locked === true && (!index.mode || index.mode.scene !== name)) {
2397
+ return fail('error.rules.sceneNotActive', `场景「${name}」未启动:先启动再锁定`);
2398
+ }
2399
+ index.scenes[name] = { ...index.scenes[name], locked: args.locked === true };
2400
+ await writeIndex(stateDir, index);
2401
+ invalidateSnapshot();
2402
+ return { ok: true, scene: name, locked: args.locked === true };
2221
2403
  }
2222
2404
  /**
2223
2405
  * 删除场景:**仅空目录可删**(避免一次操作带走整组记忆)。
@@ -2237,26 +2419,59 @@ export function createRulesService(ctx, deps) {
2237
2419
  if (!index.scenes?.[name] && !(await pathExists(join(rulesRoot, name)))) {
2238
2420
  return fail('error.rules.notFound', `场景不存在:${name}`);
2239
2421
  }
2240
- let entries = [];
2241
- try {
2242
- entries = await readdir(join(rulesRoot, name));
2243
- }
2244
- catch {
2245
- entries = []; // 目录本来就不存在(只有记录)→ 仍允许删记录
2246
- }
2247
- if (entries.filter((n) => !n.startsWith('.')).length > 0) {
2248
- return fail('error.rules.sceneNotEmpty', `场景「${name}」里还有 ${entries.length} 项,请先删除其中的记忆`);
2249
- }
2250
2422
  // 当前模式场景不可删:快照只在引擎里可退(rules service 反向注入会成环),
2251
2423
  // 直接删除会让运行时启停永久停在档案态且无恢复路径 → 给出可逆出路(先退出模式)。
2252
2424
  if (index.mode?.scene === name) {
2253
2425
  return fail('error.rules.sceneInMode', `场景「${name}」正处在当前模式,请先退出模式再删除`);
2254
2426
  }
2427
+ // 删除场景 = **连它下面的全部记忆一起去掉**(用户裁定):不管有没有启用、是不是子目录、
2428
+ // 是不是 bundle 附件,统统收走。
2429
+ // 但一律**先移入回收站**(与场景记录、档案装在同一条条目里),所以「删错了」还能整条恢复:
2430
+ // 记忆文件按原来的相对路径放回,场景记录与档案也一起回来。
2431
+ const sceneDir = join(rulesRoot, name);
2432
+ const moves = [];
2433
+ const collect = async (abs, rel) => {
2434
+ let items = [];
2435
+ try {
2436
+ items = await readdir(abs, { withFileTypes: true });
2437
+ }
2438
+ catch {
2439
+ return; // 目录不存在(只有记录的场景)或读不了 → 当作没有文件
2440
+ }
2441
+ for (const item of items) {
2442
+ const childAbs = join(abs, item.name);
2443
+ const childRel = rel === '' ? item.name : `${rel}/${item.name}`;
2444
+ if (item.isDirectory())
2445
+ await collect(childAbs, childRel);
2446
+ else
2447
+ moves.push({ from: childAbs, dest: childRel });
2448
+ }
2449
+ };
2450
+ await collect(sceneDir, '');
2451
+ const trashed = await moveToTrash('scenes', name, moves, {
2452
+ record: (index.scenes && index.scenes[name]) || null,
2453
+ archive: (index.archives && index.archives[name]) || null,
2454
+ memories: moves.map((m) => m.dest),
2455
+ });
2456
+ if (trashed.ok === false) {
2457
+ return fail('error.rules.ioFailed', `移入回收站失败:${trashed.error}`);
2458
+ }
2255
2459
  try {
2256
- // fs.rm 删目录必须 recursive(即使已确认它是空的),否则报 EISDIR。
2257
- await rm(join(rulesRoot, name), { recursive: true, force: true });
2460
+ // 文件已搬空,剩下的只是空子目录;recursive + force 一并清掉。
2461
+ await rm(sceneDir, { recursive: true, force: true });
2258
2462
  }
2259
2463
  catch (e) {
2464
+ // 目录没清掉就把记忆放回去:宁可整个操作失败,也不要「文件在回收站、场景还留在列表里」。
2465
+ for (const move of moves) {
2466
+ try {
2467
+ await moveOutOfTrash('scenes', trashed.id, move.dest, move.from);
2468
+ }
2469
+ catch { /* 尽力而为 */ }
2470
+ }
2471
+ try {
2472
+ await purgeTrashEntry('scenes', trashed.id);
2473
+ }
2474
+ catch { /* 同上 */ }
2260
2475
  return fail('error.rules.ioFailed', `删除场景目录失败:${message(e)}`);
2261
2476
  }
2262
2477
  // 索引清理一次读-改-写:记录 + 启用集合悬空引用 + 该场景档案(否则 archives 留孤儿条目)。
@@ -2277,7 +2492,74 @@ export function createRulesService(ctx, deps) {
2277
2492
  await writeIndex(stateDir, index);
2278
2493
  invalidateSnapshot();
2279
2494
  invalidateProviders();
2280
- return { ok: true, name };
2495
+ return { ok: true, name, trashId: trashed.id, movedFiles: moves.length };
2496
+ }
2497
+ /** 场景回收站列表(只读):`<hub>/trash/scenes-trash/<id>/manifest.json`。 */
2498
+ async function sceneTrashList() {
2499
+ return { ok: true, trash: await listTrashEntries('scenes') };
2500
+ }
2501
+ /**
2502
+ * 从回收站恢复一个场景:重建记忆目录 + 写回记录与档案。
2503
+ * 同名场景已存在时**拒绝**(绝不覆盖既有场景)。
2504
+ */
2505
+ async function sceneTrashRestore(args) {
2506
+ const id = String((args && args.id) || '').trim();
2507
+ const entry = await readTrashEntry('scenes', id);
2508
+ if (!entry)
2509
+ return fail('error.rules.notFound', `回收站条目不存在:${id}`);
2510
+ const name = String(entry.name || '').trim();
2511
+ if (!isValidGroupSegment(name))
2512
+ return fail('error.rules.invalidGroup', `回收站里的场景名非法:${name || '(空)'}`);
2513
+ const index = await readIndex(stateDir);
2514
+ if (index.scenes?.[name])
2515
+ return fail('error.rules.invalidGroup', `无法恢复,同名场景已存在:${name}`);
2516
+ const dir = join(rulesRoot, name);
2517
+ let entries = [];
2518
+ try {
2519
+ entries = await readdir(dir);
2520
+ }
2521
+ catch {
2522
+ entries = [];
2523
+ }
2524
+ if (entries.filter((n) => !n.startsWith('.')).length > 0) {
2525
+ return fail('error.rules.sceneNotEmpty', `无法恢复,记忆目录「${name}」里已有内容,请先处理`);
2526
+ }
2527
+ const data = (entry.data || {});
2528
+ try {
2529
+ await mkdir(dir, { recursive: true });
2530
+ }
2531
+ catch (e) {
2532
+ return fail('error.rules.ioFailed', `重建场景目录失败:${message(e)}`);
2533
+ }
2534
+ // 删除场景时一起收进回收站的记忆文件:按原来的相对路径放回(目录已确认是空的,不会覆盖)。
2535
+ const files = Array.isArray(entry.files) ? entry.files : [];
2536
+ const restored = [];
2537
+ for (const rel of files) {
2538
+ try {
2539
+ await moveOutOfTrash('scenes', id, String(rel), join(dir, String(rel)));
2540
+ restored.push(String(rel));
2541
+ }
2542
+ catch (e) {
2543
+ return fail('error.rules.ioFailed', `恢复记忆文件失败(已放回 ${restored.length}/${files.length}):${message(e)}`);
2544
+ }
2545
+ }
2546
+ if (data.record && typeof data.record === 'object')
2547
+ index.scenes = { ...(index.scenes || {}), [name]: data.record };
2548
+ if (data.archive && typeof data.archive === 'object')
2549
+ index.archives = { ...(index.archives || {}), [name]: data.archive };
2550
+ await writeIndex(stateDir, index);
2551
+ await purgeTrashEntry('scenes', id);
2552
+ invalidateSnapshot();
2553
+ invalidateProviders();
2554
+ return { ok: true, name, restoredFiles: restored.length };
2555
+ }
2556
+ /** 永久删除一条场景回收站条目。 */
2557
+ async function sceneTrashDelete(args) {
2558
+ const id = String((args && args.id) || '').trim();
2559
+ const gone = await purgeTrashEntry('scenes', id);
2560
+ if (!gone)
2561
+ return fail('error.rules.notFound', `回收站条目不存在:${id}`);
2562
+ return { ok: true, id };
2281
2563
  }
2282
2564
  /**
2283
2565
  * 提示词预设改名后**同步场景绑定**:把所有 `scenes[].prompt === from` 改成 `to`。
@@ -2337,9 +2619,17 @@ export function createRulesService(ctx, deps) {
2337
2619
  // ── 组装 ─────────────────────────────────────────────────────────────────
2338
2620
  const registerProviders = () => {
2339
2621
  try {
2340
- const registrar = createRuleProviderRegistrar(ctx, {
2341
- renderActiveScenes: () => sceneMemory().text,
2342
- });
2622
+ // 场景绑定的提示词**不再注入**(2026-09-15 用户裁定:「切换场景,对应的提示词直接把
2623
+ // AGENTS.md 修改」):启用/切换场景、改绑、编辑绑定的预设时,宿主把那份正文写进
2624
+ // `~/.dsh/AGENTS.md`(与「提示词」页的「应用」同一条路,覆盖前多代备份),关掉场景时
2625
+ // 恢复进场景之前的基线。真改文件之后再注入一遍,同一份正文会进上下文两次,而且用户手改
2626
+ // 基线之后还会被重新注入 —— 所以这里只保留场景记忆段。绑定关系的**只读投影**仍由 rules
2627
+ // 服务提供(页面「生效中」标记,以及"文件是否已经同步成它"的判断)。
2628
+ const registrar = createPromptSectionRegistrar(ctx, [{
2629
+ name: 'tool-management:scene-memory',
2630
+ order: SECTION_ORDER.sceneMemory,
2631
+ text: () => sceneMemory().text,
2632
+ }]);
2343
2633
  providerInvalidators.add(registrar.invalidate);
2344
2634
  return () => {
2345
2635
  providerInvalidators.delete(registrar.invalidate);
@@ -2364,7 +2654,10 @@ export function createRulesService(ctx, deps) {
2364
2654
  const writeOps = new Set([
2365
2655
  'rules-create', 'rules-update', 'rules-remove', 'rules-restore', 'rules-toggle', 'rules-import',
2366
2656
  'rules-set-index', 'rules-set-active', 'rules-create-scene', 'rules-update-scene', 'rules-remove-scene', 'rules-rebind-prompt',
2657
+ 'rules-scene-lock',
2367
2658
  'rules-attach', 'rules-detach', 'rules-trash-remove',
2659
+ // 场景回收站(与记忆回收站 rules-trash-* 分开:那套管记忆正文,这套管场景记录与档案)
2660
+ 'scene-trash-restore', 'scene-trash-delete',
2368
2661
  ]);
2369
2662
  const ops = {
2370
2663
  'rules-list': (args) => rulesList(args || {}),
@@ -2385,7 +2678,11 @@ export function createRulesService(ctx, deps) {
2385
2678
  'rules-set-active': (args) => runWrite(() => rulesSetActive(args || {})),
2386
2679
  'rules-create-scene': (args) => runWrite(() => rulesCreateScene(args || {})),
2387
2680
  'rules-update-scene': (args) => runWrite(() => rulesUpdateScene(args || {})),
2681
+ 'rules-scene-lock': (args) => runWrite(() => rulesSceneLock(args || {})),
2388
2682
  'rules-remove-scene': (args) => runWrite(() => rulesRemoveScene(args || {})),
2683
+ 'scene-trash-list': () => sceneTrashList(),
2684
+ 'scene-trash-restore': (args) => runWrite(() => sceneTrashRestore(args || {})),
2685
+ 'scene-trash-delete': (args) => runWrite(() => sceneTrashDelete(args || {})),
2389
2686
  'rules-rebind-prompt': (args) => runWrite(() => rulesRebindPrompt(args || {})),
2390
2687
  };
2391
2688
  const service = {