dsh-plugin-tool-management 0.10.0 → 0.11.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 (74) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/README.md +64 -49
  3. package/README_EN.md +58 -37
  4. package/docs/images/1-EN.png +0 -0
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2-EN.png +0 -0
  7. package/docs/images/2.png +0 -0
  8. package/docs/images/3-EN.png +0 -0
  9. package/docs/images/3.png +0 -0
  10. package/docs/images/4-EN.png +0 -0
  11. package/docs/images/4.png +0 -0
  12. package/docs/images/5-EN.png +0 -0
  13. package/docs/images/5.png +0 -0
  14. package/docs/images/6-EN.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/images/7-EN.png +0 -0
  17. package/docs/images/7.png +0 -0
  18. package/docs/images/8-EN.png +0 -0
  19. package/docs/images/8.png +0 -0
  20. package/docs/update.md +105 -12
  21. package/lib/client.js +2763 -546
  22. package/lib/compat/preset-reach.js +1 -10
  23. package/lib/compat/probe.js +158 -20
  24. package/lib/context-inject.js +11 -0
  25. package/lib/host-names.js +12 -0
  26. package/lib/http-fence.js +35 -15
  27. package/lib/hub.js +28 -2
  28. package/lib/imports/parsers.js +15 -9
  29. package/lib/imports/upload.js +43 -4
  30. package/lib/index.js +804 -3887
  31. package/lib/mcp/loader-token.js +238 -0
  32. package/lib/mcp/manager.js +1681 -0
  33. package/lib/mcp/override-blocks.js +10 -3
  34. package/lib/mcp/patch-yaml.js +351 -0
  35. package/lib/mcp/secret-guard.js +145 -0
  36. package/lib/{rules → memories}/archive-engine.js +1 -1
  37. package/lib/{rules → memories}/archive.js +1 -1
  38. package/lib/memories/constants.js +128 -0
  39. package/lib/memories/index-io.js +330 -0
  40. package/lib/memories/projection.js +280 -0
  41. package/lib/memories/service.js +686 -0
  42. package/lib/memories/snapshot.js +672 -0
  43. package/lib/ops/candidates.js +64 -0
  44. package/lib/ops/compat.js +136 -0
  45. package/lib/ops/ctx.js +9 -0
  46. package/lib/ops/memory.js +678 -0
  47. package/lib/ops/prompts.js +107 -0
  48. package/lib/ops/scene-records.js +460 -0
  49. package/lib/ops/scene-sync.js +17 -0
  50. package/lib/ops/sessions.js +603 -0
  51. package/lib/ops/trash.js +140 -0
  52. package/lib/paths.js +103 -0
  53. package/lib/prompts/preset-id.js +49 -0
  54. package/lib/{agents-md → prompts}/service.js +1 -1
  55. package/lib/request-gate.js +320 -0
  56. package/lib/scene-prompt-sync.js +4 -4
  57. package/lib/scenes/candidates.js +344 -0
  58. package/lib/{history → sessions}/bridge.js +15 -5
  59. package/lib/sessions/history.js +323 -0
  60. package/lib/{history → sessions}/tombstone.js +1 -1
  61. package/lib/{history → sessions}/workspace.js +92 -24
  62. package/lib/skills/core.js +74 -39
  63. package/lib/skills/readonly-discovery.js +4 -1
  64. package/lib/skills/service.js +98 -13
  65. package/lib/subagents/service.js +199 -55
  66. package/lib/tools/deps.js +8 -0
  67. package/lib/tools/mcp.js +110 -0
  68. package/lib/tools/memory.js +87 -0
  69. package/lib/tools/prompt.js +70 -0
  70. package/lib/tools/skills.js +139 -0
  71. package/lib/tools/subagent.js +40 -0
  72. package/package.json +7 -7
  73. package/lib/agents-md/preset-id.js +0 -49
  74. package/lib/rules/service.js +0 -3078
@@ -0,0 +1,107 @@
1
+ // AGENTS.md / 提示词预设域的 HTTP ops(2026-09-19 从 index.ts 的 handlers 表抽出)。
2
+ //
3
+ // 与 ops/compat.ts 同一条纪律:只负责 op 实现,依赖显式传参;MCP 写后刷新、场景锁定守卫
4
+ // 仍作用在组装后的整张表上(见 index.ts)。
5
+ export function buildPromptOps(deps) {
6
+ return {
7
+ // 提示词预设库(由 ../prompts/service.ts 提供)。这里把两件事对齐成用户看到的一份状态:
8
+ // - `active`(界面「生效中」)= **当前真正在起作用的基线**:启用的场景绑了预设就是它,
9
+ // 否则才看文件比对(预设正文 == ~/.dsh/AGENTS.md)。用户裁定:「场景启动了,
10
+ // 提示词页生效中的应该是场景选择的那个」。
11
+ // - `fileApplied` = 文件比对结果(AGENTS.md 里确实是它)。两者同时为真说明
12
+ // 场景绑的就是已应用的那份,正文一致时宿主不会再注入第二遍(去重)。
13
+ 'agentsmd-list': async () => {
14
+ const base = await deps.promptsService.list();
15
+ if (!base || base.ok === false)
16
+ return base;
17
+ let scenePrompt;
18
+ try {
19
+ const r = await deps.rulesOps['rules-list']({});
20
+ if (r && r.ok && r.scenePrompt)
21
+ scenePrompt = r.scenePrompt;
22
+ }
23
+ catch { /* 场景服务不可用 → 只回预设库 */ }
24
+ const sceneId = scenePrompt && !scenePrompt.missing && scenePrompt.scene ? scenePrompt.presetId : null;
25
+ // 场景驱动时**只有场景绑的那份**算生效中(用户裁定);文件里那份降级为「文件里是它」,
26
+ // 因为场景的提示词已经接管基线。没有场景驱动时才按文件比对定「生效中」。
27
+ const sceneDrives = sceneId !== null;
28
+ // 引用清单与删除保护同源(scene-prompt-sync):界面据此禁用删除并说明「谁在用」,
29
+ // 不必自己再拼一遍判定(那正是这次四处状态各说各话的根源)。探测失败 = 空清单。
30
+ const refs = await deps.scenePromptSync.refs();
31
+ const presets = base.presets.map((p) => {
32
+ const fileApplied = p.active === true;
33
+ const byScene = sceneDrives && sceneId === p.id;
34
+ const active = sceneDrives ? byScene : fileApplied;
35
+ return {
36
+ ...p,
37
+ active,
38
+ fileApplied,
39
+ activeVia: byScene ? 'scene' : (active && fileApplied ? 'file' : null),
40
+ refs: refs ? refs.get(p.id) || [] : [],
41
+ };
42
+ });
43
+ return { ...base, presets, ...(scenePrompt ? { scenePrompt } : {}) };
44
+ },
45
+ 'agentsmd-read': (args) => deps.promptsService.read(String((args && args.id) || '')),
46
+ // content 与 from 都可选:新建弹窗里直接写正文(content),或从现有预设复制(from)。
47
+ 'agentsmd-create': (args) => deps.promptsService.create(String((args && args.id) || ''), {
48
+ ...(args && args.from ? { from: String(args.from) } : {}),
49
+ ...(args && typeof args.content === 'string' ? { content: String(args.content) } : {}),
50
+ ...(args && typeof args.description === 'string' ? { description: String(args.description) } : {}),
51
+ }),
52
+ // 保存:改正文 + 可选改名(nextId)。改名成功顺带把场景绑定一起改名 ——
53
+ // 绑定存在 memories-index.json 里,预设库自己看不到,不叫这一声就会留悬空绑定。
54
+ // 保存后同步一次全局基线:若改的正是「正在驱动基线的那份」,正文要跟着写进
55
+ // `~/.dsh/AGENTS.md`(否则页面标着「生效中」而文件里还是旧内容)。
56
+ 'agentsmd-update': async (args) => {
57
+ const res = await deps.promptsService.update(String((args && args.id) || ''), String((args && args.content) ?? ''), args && args.nextId !== undefined ? String(args.nextId) : undefined,
58
+ // 描述与正文同一次提交:省略(undefined)= 不动,空串 = 清空。
59
+ args && typeof args.description === 'string' ? String(args.description) : undefined);
60
+ if (!res || res.ok === false)
61
+ return res;
62
+ let renamed = {};
63
+ if (res.renamedFrom) {
64
+ try {
65
+ const r = await deps.rulesOps['rules-rebind-prompt']({ from: res.renamedFrom, to: res.id });
66
+ renamed = r && r.ok ? { reboundScenes: r.changed } : {};
67
+ }
68
+ catch { /* 改名同步失败不影响保存本身 */ }
69
+ }
70
+ return deps.withAgentsMdSync({ ...res, ...renamed });
71
+ },
72
+ // 应用 = 写全局基线文件;场景接管期间只放行「场景绑定的那一份」(见 applyPresetGuarded)。
73
+ 'agentsmd-apply': (args) => deps.applyPresetGuarded(String((args && args.id) || '')),
74
+ 'agentsmd-get-current': () => deps.promptsService.getCurrent(),
75
+ // 删除:**被引用的那份一律拒绝**(用户裁定,2026-09-16 扩到引用清单)。三类引用:
76
+ // 场景绑定(含恒常的 `_shared` / `global`)、`~/.dsh/AGENTS.md` 的当前内容、
77
+ // 进场景前保存的基线(退出场景后要恢复的那一份)。拒绝时逐条说明「谁在用」,
78
+ // 用户知道该先改哪里。引用清单由 scene-prompt-sync 与显示/注入同源算出。
79
+ // 探测失败(refs() 返回 null)时放行并记 warn:删预设不动 ~/.dsh/AGENTS.md,
80
+ // 最坏是少一份副本,可重建 —— 不因一次探测故障把删除堵死。
81
+ 'agentsmd-remove': async (args) => {
82
+ const id = String((args && args.id) || '');
83
+ const refs = await deps.scenePromptSync.refs();
84
+ if (refs === null)
85
+ deps.warn('prompts: reference probe failed; remove allowed');
86
+ const why = refs ? refs.get(id) || [] : [];
87
+ if (why.length) {
88
+ const refsText = why.map(deps.promptRefReason).join(';');
89
+ return {
90
+ ok: false,
91
+ code: 'error.agentsMd.referenced',
92
+ error: `「${id}」仍被引用,不能删除:${refsText}。先改掉引用(换绑提示词 / 退出场景 / 应用别的预设)再删除。`,
93
+ params: { id, refs: refsText },
94
+ };
95
+ }
96
+ return deps.promptsService.remove(id);
97
+ },
98
+ 'agentsmd-import': (args) => deps.promptsService.importPreset(String((args && args.id) || ''), String((args && args.content) ?? ''), args && typeof args.description === 'string' ? String(args.description) : undefined),
99
+ // 提示词预设的回收站:删预设 = 把整个预设目录移入 `hub/trash/prompts-trash/<id>/`,
100
+ // 误删可从「提示词」页的回收站里恢复(同 id 已存在时拒绝恢复,绝不覆盖)。
101
+ 'agentsmd-trash-list': () => deps.promptsService.trashList(),
102
+ // 恢复也要同步:把一个「场景绑定着、但曾被删掉」的预设恢复回来 → 绑定重新变活,
103
+ // 基线要跟着写回它(否则场景页说「生效中:它」而文件里并不是)。
104
+ 'agentsmd-trash-restore': async (args) => deps.withAgentsMdSync(await deps.promptsService.trashRestore(String((args && args.id) || ''))),
105
+ 'agentsmd-trash-delete': (args) => deps.promptsService.trashDelete(String((args && args.id) || '')),
106
+ };
107
+ }
@@ -0,0 +1,460 @@
1
+ // 规则/记忆域的**场景 ops**(2026-09-19 从 memories/service.ts 的 createMemoriesService 闭包抽出)。
2
+ //
3
+ // 启用场景切换、场景记录增删改、场景锁、场景回收站(管场景记录与档案,与记忆回收站分开)、
4
+ // 提示词改绑。动的是 index 的 scenes / active 切片与 AGENTS.md 投影,与记忆正文 ops 互不调用。
5
+ import { mkdir, readdir, rename, rm } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
8
+ import { MAX_DESCRIPTION_LENGTH, DEFAULT_GROUP_ORDER, GLOBAL_SCENE, GLOBAL_SCENE_LABEL, SHARED_GROUP, SEGMENT_RULE_HINT, isValidGroupSegment, isValidGroupPath, message, fail } from '../memories/constants.js';
9
+ import { normalizeActive, collapseActiveForNewScene } from '../memories/projection.js';
10
+ import { readIndex, writeIndex, sceneRecordOf, normalizeScenePromptId, pathExists } from '../memories/index-io.js';
11
+ /** 组装本域 op。rc 由 createMemoriesService 组装,契约见 ./ctx.ts。 */
12
+ export function buildSceneRecordOps(rc) {
13
+ const { ensureLayout, invalidateSnapshot, presetExistsSync, memoriesRoot, sceneRows, snapshot, stateDir } = rc;
14
+ /**
15
+ * 设置"启用场景"(全局持久化,切换后**下一个请求即生效**,§4 对照表)。
16
+ * - `args.scenes` 数组 → 启用集合;**至多一个非保留场景**(用户裁定:除「全局」外
17
+ * 同时只能启用一个),`[]` = 全部关闭(只留恒常的 `_shared` 与 `global`)
18
+ * - `args.all === true` → 旧的"全部启用"形态,与新模型冲突,明确拒绝(不猜)
19
+ * 场景名按放宽后的规则校验;不存在的场景名也允许保存(目录随后创建即可生效)。
20
+ */
21
+ async function rulesSetActive(args) {
22
+ // 参数必须显式二选一。旧实现把「没传参数」「传了未知参数名」都落进 else 分支、
23
+ // 用空数组覆盖 active,于是 `rules-set-active {}` 会静默把模式切成 custom 且零激活场景
24
+ // (用户以为自己只是"没改动",实际已经改了注入范围)。这里显式拒绝。
25
+ const hasAll = !!(args && args.all === true);
26
+ const hasScenes = Array.isArray(args && args.scenes);
27
+ if (!hasAll && !hasScenes) {
28
+ return fail('error.rules.invalidArgs', '缺少参数:需要 scenes:[...](启用集合,至多一个场景)');
29
+ }
30
+ if (hasAll) {
31
+ return fail('error.rules.singleSceneOnly', '除「全局」外同时只能启用一个场景:不支持"全部启用",请改用 scenes:[<场景名>] 或 scenes:[](全部关闭)', { detail: ':不支持"全部启用",请改用 scenes:[<场景名>] 或 scenes:[](全部关闭)' });
32
+ }
33
+ const index = await readIndex(stateDir);
34
+ const raw = Array.isArray(args && args.scenes) ? args.scenes : [];
35
+ const names = [];
36
+ const seen = new Set();
37
+ for (const item of raw) {
38
+ const name = String(item == null ? '' : item).trim();
39
+ // 恒常启用的保留场景不入显式集合:`_shared`(公共基线)与 `global`(「全局」)。
40
+ if (name === '' || name === SHARED_GROUP || name === GLOBAL_SCENE)
41
+ continue;
42
+ if (!isValidGroupPath(name)) {
43
+ return fail('error.rules.invalidGroup', `场景名非法:${name}(${SEGMENT_RULE_HINT})`);
44
+ }
45
+ if (seen.has(name))
46
+ continue;
47
+ seen.add(name);
48
+ names.push(name);
49
+ }
50
+ if (names.length > 1) {
51
+ return fail('error.rules.singleSceneOnly', `除「全局」外同时只能启用一个场景(收到 ${names.length} 个:${names.join('、')})`, { detail: `(收到 ${names.length} 个:${names.join('、')})` });
52
+ }
53
+ index.active = names;
54
+ await writeIndex(stateDir, index);
55
+ invalidateSnapshot();
56
+ const snap = await snapshot();
57
+ return {
58
+ ok: true,
59
+ activeMode: index.active === null ? 'all' : 'custom',
60
+ scenes: sceneRows(snap, index),
61
+ };
62
+ }
63
+ /**
64
+ * 新建场景:写一条场景记录(索引 scenes 切片)+ 建空目录 `memories/<场景>/`。
65
+ * 场景是**显式实体**——空场景(还没有记忆)也是合法场景,会出现在场景列表里。
66
+ * 场景名是**单个路径段**(不允许 `a/b`):界面把场景当一级列表展示,
67
+ * 允许多段只会让「记忆的场景」与「目录层级」两套语义互相打架。
68
+ * 幂等:已存在则更新描述/标签,不报错。`_shared` 与 `global` 是保留名。
69
+ */
70
+ async function rulesCreateScene(args) {
71
+ const name = String((args && args.name) || '').trim();
72
+ if (!isValidGroupSegment(name)) {
73
+ return fail('error.rules.invalidGroup', `场景名非法:${name || '(空)'}(${SEGMENT_RULE_HINT})`);
74
+ }
75
+ if (name === SHARED_GROUP)
76
+ return fail('error.rules.invalidGroup', `_shared 是保留场景名,无需创建`);
77
+ const label = args && args.label !== undefined ? String(args.label).trim() : '';
78
+ const description = args && args.description !== undefined ? String(args.description).trim() : '';
79
+ if (description.length > MAX_DESCRIPTION_LENGTH) {
80
+ return fail('error.rules.descriptionTooLong', `场景描述过长(≤${MAX_DESCRIPTION_LENGTH} 字符)`);
81
+ }
82
+ const prompt = args && args.prompt !== undefined ? normalizeScenePromptId(args.prompt) : undefined;
83
+ if (prompt === null) {
84
+ return fail('error.rules.invalidGroup', `提示词预设 id 非法:${String(args.prompt)}(仅小写字母/数字/连字符,且不能是备份槽)`);
85
+ }
86
+ await ensureLayout();
87
+ try {
88
+ await mkdir(join(memoriesRoot, name), { recursive: true });
89
+ }
90
+ catch (e) {
91
+ return fail('error.rules.ioFailed', `创建场景目录失败:${message(e)}`);
92
+ }
93
+ const index = await readIndex(stateDir);
94
+ // **新场景默认不启动**:先把历史默认(active=null=全部启用)收敛成显式集合,
95
+ // 新建的这个自然不在其中;收敛后仍是"至多一个场景启用"。
96
+ const collapsed = collapseActiveForNewScene(index);
97
+ if (!index.scenes)
98
+ index.scenes = {};
99
+ const prev = index.scenes[name] || {};
100
+ const next = {
101
+ ...prev,
102
+ ...(label !== '' && name !== GLOBAL_SCENE ? { label } : {}),
103
+ ...(description !== '' ? { description } : {}),
104
+ ...(prompt !== undefined && prompt !== '' ? { prompt } : {}),
105
+ order: prev.order ?? DEFAULT_GROUP_ORDER,
106
+ createdAt: prev.createdAt ?? new Date().toISOString(),
107
+ };
108
+ if (name === GLOBAL_SCENE)
109
+ next.label = GLOBAL_SCENE_LABEL;
110
+ index.scenes[name] = next;
111
+ await writeIndex(stateDir, index);
112
+ invalidateSnapshot();
113
+ return { ok: true, scene: sceneRecordOf(name, next), ...(collapsed ? { collapsedActive: true } : {}) };
114
+ }
115
+ /**
116
+ * 更新场景记录(描述 / 显示名 / 顺序 / 绑定的提示词预设),**可选改名**(nextName)。
117
+ *
118
+ * 场景名就是它的一级目录名(`memories/<场景>/…`),所以改名不是改一个字段:
119
+ * ① 目录 `memories/<旧>` → `memories/<新>`;
120
+ * ② 索引里的四处引用一起改:`scenes` 记录、`archives` 档案、`active` 启用集合、`mode.scene`。
121
+ * 记忆正文一个字节都不动(只是换了所在目录名)。
122
+ *
123
+ * 拒绝的三种情况:保留场景 `global` / `_shared`;目标名已被占用(目录或记录);
124
+ * 该场景正在当前模式里——模式快照是按场景名算的,改了名与快照就对不上(同删除的处理)。
125
+ */
126
+ async function rulesUpdateScene(args) {
127
+ const name = String((args && args.name) || '').trim();
128
+ if (!isValidGroupPath(name))
129
+ return fail('error.rules.invalidGroup', `场景名非法:${name || '(空)'}`);
130
+ const index = await readIndex(stateDir);
131
+ if (!index.scenes)
132
+ index.scenes = {};
133
+ const prev = index.scenes[name];
134
+ if (!prev && name !== GLOBAL_SCENE)
135
+ return fail('error.rules.notFound', `场景不存在:${name}`);
136
+ const next = { ...(prev || {}) };
137
+ if (args && args.description !== undefined) {
138
+ const description = String(args.description).trim();
139
+ if (description.length > MAX_DESCRIPTION_LENGTH) {
140
+ return fail('error.rules.descriptionTooLong', `场景描述过长(≤${MAX_DESCRIPTION_LENGTH} 字符)`);
141
+ }
142
+ if (description === '')
143
+ delete next.description;
144
+ else
145
+ next.description = description;
146
+ }
147
+ if (args && args.prompt !== undefined) {
148
+ const prompt = normalizeScenePromptId(args.prompt);
149
+ if (prompt === null) {
150
+ return fail('error.rules.invalidGroup', `提示词预设 id 非法:${String(args.prompt)}(仅小写字母/数字/连字符,且不能是备份槽)`);
151
+ }
152
+ // 绑定前校验预设确实存在:宁可现在拒绝,也不要留一个永远注入不出东西的悬空绑定。
153
+ if (prompt !== '' && !presetExistsSync(prompt)) {
154
+ return fail('error.rules.notFound', `提示词预设不存在:${prompt}`);
155
+ }
156
+ if (prompt === '')
157
+ delete next.prompt;
158
+ else
159
+ next.prompt = prompt;
160
+ }
161
+ if (args && args.label !== undefined && name !== GLOBAL_SCENE) {
162
+ const label = String(args.label).trim();
163
+ if (label === '')
164
+ delete next.label;
165
+ else
166
+ next.label = label;
167
+ }
168
+ if (args && args.order !== undefined) {
169
+ const order = Number(args.order);
170
+ if (!Number.isFinite(order) || order < 0)
171
+ return fail('error.rules.invalidGroup', `非法排序值:${args.order}`);
172
+ next.order = Math.floor(order);
173
+ }
174
+ // ── 改名(可选):目录 + 索引里的四处引用一起动 ──
175
+ let finalName = name;
176
+ const nextRaw = args && args.nextName !== undefined ? String(args.nextName).trim() : '';
177
+ if (nextRaw !== '' && nextRaw !== name) {
178
+ if (!isValidGroupSegment(nextRaw)) {
179
+ return fail('error.rules.invalidGroup', `新场景名非法:${nextRaw}(${SEGMENT_RULE_HINT})`);
180
+ }
181
+ if (name === GLOBAL_SCENE)
182
+ return fail('error.rules.reservedScene', '「全局」是保留场景,不可改名', { name: GLOBAL_SCENE, action: 'rename', reason: '' });
183
+ if (name === SHARED_GROUP || nextRaw === SHARED_GROUP)
184
+ return fail('error.rules.invalidGroup', '_shared 是保留场景名,不可改名');
185
+ if ((index.scenes && index.scenes[nextRaw]) || (await pathExists(join(memoriesRoot, nextRaw)))) {
186
+ return fail('error.rules.nameTaken', `目标场景名已被占用:${nextRaw}`);
187
+ }
188
+ if (index.mode && index.mode.scene === name) {
189
+ return fail('error.rules.sceneInMode', `场景「${name}」正处在当前模式,请先退出模式再改名`);
190
+ }
191
+ const fromDir = join(memoriesRoot, name);
192
+ const toDir = join(memoriesRoot, nextRaw);
193
+ let movedDir = false;
194
+ if (await pathExists(fromDir)) {
195
+ try {
196
+ await rename(fromDir, toDir);
197
+ movedDir = true;
198
+ }
199
+ catch (e) {
200
+ return fail('error.rules.ioFailed', `场景目录改名失败:${message(e)}`);
201
+ }
202
+ }
203
+ // 目录已经搬过去了:索引这一步失败就必须把目录搬回来,否则目录名与索引各说各话。
204
+ try {
205
+ const scenes = index.scenes || {};
206
+ if (scenes[name]) {
207
+ scenes[nextRaw] = scenes[name];
208
+ delete scenes[name];
209
+ }
210
+ else {
211
+ scenes[nextRaw] = { order: DEFAULT_GROUP_ORDER, createdAt: new Date().toISOString() };
212
+ }
213
+ index.scenes = scenes;
214
+ if (index.archives && index.archives[name]) {
215
+ index.archives[nextRaw] = index.archives[name];
216
+ delete index.archives[name];
217
+ }
218
+ const act = normalizeActive(index.active);
219
+ if (act !== null && act.indexOf(name) >= 0)
220
+ index.active = act.map((n) => (n === name ? nextRaw : n));
221
+ if (index.mode && index.mode.scene === name)
222
+ index.mode = { ...index.mode, scene: nextRaw };
223
+ }
224
+ catch (e) {
225
+ if (movedDir) {
226
+ try {
227
+ await rename(toDir, fromDir);
228
+ }
229
+ catch { /* 回滚失败:错误信息里如实带上原因 */ }
230
+ }
231
+ return fail('error.rules.ioFailed', `场景改名后索引更新失败:${message(e)}`);
232
+ }
233
+ finalName = nextRaw;
234
+ }
235
+ if (finalName === GLOBAL_SCENE)
236
+ next.label = GLOBAL_SCENE_LABEL;
237
+ index.scenes[finalName] = next;
238
+ await writeIndex(stateDir, index);
239
+ invalidateSnapshot();
240
+ return {
241
+ ok: true,
242
+ ...(finalName === name ? {} : { renamedFrom: name }),
243
+ scene: sceneRecordOf(finalName, next),
244
+ };
245
+ }
246
+ /**
247
+ * 场景锁定(v0.8):锁定后五个管理域(MCP/技能/子智能体/记忆/提示词)整体只读 ——
248
+ * 场景页的档案编辑与功能页的启停/编辑都被拒(index.ts 的 handlers 守卫 + 界面禁用),
249
+ * 场景自身的启停(进/退模式)不受影响。`locked` 必须显式给布尔值(与 rules-toggle 同一口径)。
250
+ * 保留场景 `global` 不在场景页出现,不可锁。
251
+ */
252
+ async function rulesSceneLock(args) {
253
+ const name = String((args && args.scene) || '').trim();
254
+ if (!isValidGroupPath(name))
255
+ return fail('error.rules.invalidGroup', `场景名非法:${name || '(空)'}`);
256
+ if (name === GLOBAL_SCENE || name === SHARED_GROUP)
257
+ return fail('error.rules.reservedScene', '「全局 / _shared」是保留场景,不可锁定', { name: '全局 / _shared', action: 'lock', reason: '' });
258
+ if (typeof (args && args.locked) !== 'boolean') {
259
+ return fail('error.rules.invalidArgs', '缺少参数:locked 必须是布尔值(只按传入值写入,不做"翻转"推断)');
260
+ }
261
+ const index = await readIndex(stateDir);
262
+ if (!index.scenes || !index.scenes[name])
263
+ return fail('error.rules.notFound', `场景不存在:${name}`);
264
+ // 未启动的场景不能上锁(用户裁定):锁定的意义是冻结**运行中**场景的配置。
265
+ // 解锁随时允许 —— 否则退出模式后,被锁的场景就没人能解了。
266
+ if (args.locked === true && (!index.mode || index.mode.scene !== name)) {
267
+ return fail('error.rules.sceneNotActive', `场景「${name}」未启动:先启动再锁定`);
268
+ }
269
+ index.scenes[name] = { ...index.scenes[name], locked: args.locked === true };
270
+ await writeIndex(stateDir, index);
271
+ invalidateSnapshot();
272
+ return { ok: true, scene: name, locked: args.locked === true };
273
+ }
274
+ /**
275
+ * 删除场景:**仅空目录可删**(避免一次操作带走整组记忆)。
276
+ * 同时删掉场景记录、把它从 `active` 集合里摘掉、清掉它的档案,避免悬空引用。
277
+ * 保留场景 `global` 不可删除;场景名口径与创建一致(单个路径段)。
278
+ */
279
+ async function rulesRemoveScene(args) {
280
+ const name = String((args && args.name) || '').trim();
281
+ if (!isValidGroupSegment(name))
282
+ return fail('error.rules.invalidGroup', `场景名非法:${name || '(空)'}`);
283
+ if (name === SHARED_GROUP)
284
+ return fail('error.rules.invalidGroup', `_shared 是保留场景名,不可删除`);
285
+ if (name === GLOBAL_SCENE)
286
+ return fail('error.rules.reservedScene', `「全局」是保留场景,不可删除(它的记忆对任何对话都生效)`, { name: GLOBAL_SCENE, action: 'delete', reason: '(它的记忆对任何对话都生效)' });
287
+ const index = await readIndex(stateDir);
288
+ // 场景不存在(既无记录也无目录)→ 明确报错,而不是假装删成功。
289
+ if (!index.scenes?.[name] && !(await pathExists(join(memoriesRoot, name)))) {
290
+ return fail('error.rules.notFound', `场景不存在:${name}`);
291
+ }
292
+ // 当前模式场景不可删:快照只在引擎里可退(rules service 反向注入会成环),
293
+ // 直接删除会让运行时启停永久停在档案态且无恢复路径 → 给出可逆出路(先退出模式)。
294
+ if (index.mode?.scene === name) {
295
+ return fail('error.rules.sceneInMode', `场景「${name}」正处在当前模式,请先退出模式再删除`);
296
+ }
297
+ // 删除场景 = **连它下面的全部记忆一起去掉**(用户裁定):不管有没有启用、是不是子目录、
298
+ // 是不是 bundle 附件,统统收走。
299
+ // 但一律**先移入回收站**(与场景记录、档案装在同一条条目里),所以「删错了」还能整条恢复:
300
+ // 记忆文件按原来的相对路径放回,场景记录与档案也一起回来。
301
+ const sceneDir = join(memoriesRoot, name);
302
+ const moves = [];
303
+ const collect = async (abs, rel) => {
304
+ let items = [];
305
+ try {
306
+ items = await readdir(abs, { withFileTypes: true });
307
+ }
308
+ catch {
309
+ return; // 目录不存在(只有记录的场景)或读不了 → 当作没有文件
310
+ }
311
+ for (const item of items) {
312
+ const childAbs = join(abs, item.name);
313
+ const childRel = rel === '' ? item.name : `${rel}/${item.name}`;
314
+ if (item.isDirectory())
315
+ await collect(childAbs, childRel);
316
+ else
317
+ moves.push({ from: childAbs, dest: childRel });
318
+ }
319
+ };
320
+ await collect(sceneDir, '');
321
+ const trashed = await moveToTrash('scenes', name, moves, {
322
+ record: (index.scenes && index.scenes[name]) || null,
323
+ archive: (index.archives && index.archives[name]) || null,
324
+ memories: moves.map((m) => m.dest),
325
+ });
326
+ if (trashed.ok === false) {
327
+ return fail('error.rules.ioFailed', `移入回收站失败:${trashed.error}`);
328
+ }
329
+ try {
330
+ // 文件已搬空,剩下的只是空子目录;recursive + force 一并清掉。
331
+ await rm(sceneDir, { recursive: true, force: true });
332
+ }
333
+ catch (e) {
334
+ // 目录没清掉就把记忆放回去:宁可整个操作失败,也不要「文件在回收站、场景还留在列表里」。
335
+ for (const move of moves) {
336
+ try {
337
+ await moveOutOfTrash('scenes', trashed.id, move.dest, move.from);
338
+ }
339
+ catch { /* 尽力而为 */ }
340
+ }
341
+ try {
342
+ await purgeTrashEntry('scenes', trashed.id);
343
+ }
344
+ catch { /* 同上 */ }
345
+ return fail('error.rules.ioFailed', `删除场景目录失败:${message(e)}`);
346
+ }
347
+ // 索引清理一次读-改-写:记录 + 启用集合悬空引用 + 该场景档案(否则 archives 留孤儿条目)。
348
+ let dirty = false;
349
+ if (index.scenes && index.scenes[name]) {
350
+ delete index.scenes[name];
351
+ dirty = true;
352
+ }
353
+ if (Array.isArray(index.active) && index.active.indexOf(name) >= 0) {
354
+ index.active = index.active.filter((s) => s !== name);
355
+ dirty = true;
356
+ }
357
+ if (index.archives && index.archives[name]) {
358
+ delete index.archives[name];
359
+ dirty = true;
360
+ }
361
+ if (dirty)
362
+ await writeIndex(stateDir, index);
363
+ invalidateSnapshot();
364
+ return { ok: true, name, trashId: trashed.id, movedFiles: moves.length };
365
+ }
366
+ /** 场景回收站列表(只读):`<hub>/trash/scenes-trash/<id>/manifest.json`。 */
367
+ async function sceneTrashList() {
368
+ return { ok: true, trash: await listTrashEntries('scenes') };
369
+ }
370
+ /**
371
+ * 从回收站恢复一个场景:重建记忆目录 + 写回记录与档案。
372
+ * 同名场景已存在时**拒绝**(绝不覆盖既有场景)。
373
+ */
374
+ async function sceneTrashRestore(args) {
375
+ const id = String((args && args.id) || '').trim();
376
+ const entry = await readTrashEntry('scenes', id);
377
+ if (!entry)
378
+ return fail('error.rules.notFound', `回收站条目不存在:${id}`);
379
+ const name = String(entry.name || '').trim();
380
+ if (!isValidGroupSegment(name))
381
+ return fail('error.rules.invalidGroup', `回收站里的场景名非法:${name || '(空)'}`);
382
+ const index = await readIndex(stateDir);
383
+ if (index.scenes?.[name])
384
+ return fail('error.rules.invalidGroup', `无法恢复,同名场景已存在:${name}`);
385
+ const dir = join(memoriesRoot, name);
386
+ let entries = [];
387
+ try {
388
+ entries = await readdir(dir);
389
+ }
390
+ catch {
391
+ entries = [];
392
+ }
393
+ if (entries.filter((n) => !n.startsWith('.')).length > 0) {
394
+ return fail('error.rules.sceneNotEmpty', `无法恢复,记忆目录「${name}」里已有内容,请先处理`);
395
+ }
396
+ const data = (entry.data || {});
397
+ try {
398
+ await mkdir(dir, { recursive: true });
399
+ }
400
+ catch (e) {
401
+ return fail('error.rules.ioFailed', `重建场景目录失败:${message(e)}`);
402
+ }
403
+ // 删除场景时一起收进回收站的记忆文件:按原来的相对路径放回(目录已确认是空的,不会覆盖)。
404
+ const files = Array.isArray(entry.files) ? entry.files : [];
405
+ const restored = [];
406
+ for (const rel of files) {
407
+ try {
408
+ await moveOutOfTrash('scenes', id, String(rel), join(dir, String(rel)));
409
+ restored.push(String(rel));
410
+ }
411
+ catch (e) {
412
+ return fail('error.rules.ioFailed', `恢复记忆文件失败(已放回 ${restored.length}/${files.length}):${message(e)}`);
413
+ }
414
+ }
415
+ if (data.record && typeof data.record === 'object')
416
+ index.scenes = { ...(index.scenes || {}), [name]: data.record };
417
+ if (data.archive && typeof data.archive === 'object')
418
+ index.archives = { ...(index.archives || {}), [name]: data.archive };
419
+ await writeIndex(stateDir, index);
420
+ await purgeTrashEntry('scenes', id);
421
+ invalidateSnapshot();
422
+ return { ok: true, name, restoredFiles: restored.length };
423
+ }
424
+ /** 永久删除一条场景回收站条目。 */
425
+ async function sceneTrashDelete(args) {
426
+ const id = String((args && args.id) || '').trim();
427
+ const gone = await purgeTrashEntry('scenes', id);
428
+ if (!gone)
429
+ return fail('error.rules.notFound', `回收站条目不存在:${id}`);
430
+ return { ok: true, id };
431
+ }
432
+ /**
433
+ * 提示词预设改名后**同步场景绑定**:把所有 `scenes[].prompt === from` 改成 `to`。
434
+ * 由 AGENTS.md 预设库的改名路径调用——绑定存在 `memories-index.json` 里,预设库
435
+ * 自己看不到它,不叫这一声改名就会留下悬空绑定(场景卡片显示「预设不存在」)。
436
+ * @returns 改了几个场景。
437
+ */
438
+ async function rulesRebindPrompt(args) {
439
+ const from = String((args && args.from) || '').trim();
440
+ const to = String((args && args.to) || '').trim();
441
+ if (!from || !to)
442
+ return fail('error.rules.invalidArgs', '需要 from 与 to 两个预设 id');
443
+ const index = await readIndex(stateDir);
444
+ const scenes = index.scenes || {};
445
+ let changed = 0;
446
+ for (const [name, entry] of Object.entries(scenes)) {
447
+ if (entry && entry.prompt === from) {
448
+ scenes[name] = { ...entry, prompt: to };
449
+ changed++;
450
+ }
451
+ }
452
+ if (changed) {
453
+ index.scenes = scenes;
454
+ await writeIndex(stateDir, index);
455
+ invalidateSnapshot();
456
+ }
457
+ return { ok: true, from, to, changed };
458
+ }
459
+ return { rulesSetActive, rulesCreateScene, rulesUpdateScene, rulesSceneLock, rulesRemoveScene, sceneTrashList, sceneTrashRestore, sceneTrashDelete, rulesRebindPrompt };
460
+ }
@@ -0,0 +1,17 @@
1
+ // rules 域里需要包装的那四个 op(2026-09-19 从 index.ts 的 handlers 表抽出)。
2
+ //
3
+ // rules 域的**其余 op 由 ./memories/service.ts 直接 spread 进表**,不经过本文件 —— 这里只放
4
+ // 四个要走「场景 ↔ 全局基线同步」包装的写 op。
5
+ export function buildSceneSyncOps(deps) {
6
+ return {
7
+ // 场景 ↔ 全局基线(AGENTS.md)同步:用户裁定「切换场景,对应的提示词直接把 AGENTS.md
8
+ // 直接修改」,所以这四个 rules 写 op 走包装 —— 先执行原逻辑,成功后把当前场景绑定的
9
+ // 提示词写进 `~/.dsh/AGENTS.md`(或关掉场景时恢复进场景前的基线),结果并进响应
10
+ // 的 `agentsMd` 字段(`applied` / `restored` / `unchanged` / `error`)。
11
+ // 组装时放在 memoriesService.ops 的 spread **之后**,显式覆盖同名 op。
12
+ 'rules-set-active': async (args) => deps.withAgentsMdSync(await deps.rulesOps['rules-set-active'](args)),
13
+ 'rules-update-scene': async (args) => deps.withAgentsMdSync(await deps.rulesOps['rules-update-scene'](args)),
14
+ 'rules-create-scene': async (args) => deps.withAgentsMdSync(await deps.rulesOps['rules-create-scene'](args)),
15
+ 'rules-remove-scene': async (args) => deps.withAgentsMdSync(await deps.rulesOps['rules-remove-scene'](args)),
16
+ };
17
+ }