dsh-plugin-tool-management 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +100 -1
  2. package/README.md +67 -50
  3. package/README_EN.md +61 -38
  4. package/cordis.patch.yml +10 -1
  5. package/docs/images/1-EN.png +0 -0
  6. package/docs/images/1.png +0 -0
  7. package/docs/images/2-EN.png +0 -0
  8. package/docs/images/2.png +0 -0
  9. package/docs/images/3-EN.png +0 -0
  10. package/docs/images/3.png +0 -0
  11. package/docs/images/4-EN.png +0 -0
  12. package/docs/images/4.png +0 -0
  13. package/docs/images/5-EN.png +0 -0
  14. package/docs/images/5.png +0 -0
  15. package/docs/images/6-EN.png +0 -0
  16. package/docs/images/6.png +0 -0
  17. package/docs/images/7-EN.png +0 -0
  18. package/docs/images/7.png +0 -0
  19. package/docs/images/8-EN.png +0 -0
  20. package/docs/images/8.png +0 -0
  21. package/docs/update.md +132 -12
  22. package/lib/client.js +2826 -547
  23. package/lib/compat/patch-dialect.js +173 -0
  24. package/lib/compat/preset-reach.js +1 -10
  25. package/lib/compat/probe.js +205 -24
  26. package/lib/compat/runtime-notes.js +25 -0
  27. package/lib/context-inject.js +83 -9
  28. package/lib/host-names.js +12 -0
  29. package/lib/http-fence.js +35 -15
  30. package/lib/hub.js +28 -2
  31. package/lib/imports/parsers.js +15 -9
  32. package/lib/imports/upload.js +43 -4
  33. package/lib/index.js +927 -3897
  34. package/lib/mcp/loader-token.js +238 -0
  35. package/lib/mcp/manager.js +1769 -0
  36. package/lib/mcp/override-blocks.js +10 -3
  37. package/lib/mcp/patch-yaml.js +351 -0
  38. package/lib/mcp/secret-guard.js +145 -0
  39. package/lib/{rules → memories}/archive-engine.js +1 -1
  40. package/lib/{rules → memories}/archive.js +1 -1
  41. package/lib/memories/constants.js +128 -0
  42. package/lib/memories/index-io.js +330 -0
  43. package/lib/memories/projection.js +280 -0
  44. package/lib/memories/service.js +686 -0
  45. package/lib/memories/snapshot.js +672 -0
  46. package/lib/ops/candidates.js +64 -0
  47. package/lib/ops/compat.js +226 -0
  48. package/lib/ops/ctx.js +9 -0
  49. package/lib/ops/memory.js +678 -0
  50. package/lib/ops/prompts.js +107 -0
  51. package/lib/ops/scene-records.js +460 -0
  52. package/lib/ops/scene-sync.js +17 -0
  53. package/lib/ops/sessions.js +603 -0
  54. package/lib/ops/trash.js +140 -0
  55. package/lib/paths.js +103 -0
  56. package/lib/prompts/preset-id.js +49 -0
  57. package/lib/{agents-md → prompts}/service.js +1 -1
  58. package/lib/request-gate.js +320 -0
  59. package/lib/scene-prompt-sync.js +4 -4
  60. package/lib/scenes/candidates.js +344 -0
  61. package/lib/{history → sessions}/bridge.js +124 -36
  62. package/lib/sessions/history.js +323 -0
  63. package/lib/{history → sessions}/tombstone.js +1 -1
  64. package/lib/{history → sessions}/workspace.js +151 -50
  65. package/lib/skills/core.js +74 -39
  66. package/lib/skills/readonly-discovery.js +4 -1
  67. package/lib/skills/service.js +98 -13
  68. package/lib/subagents/service.js +199 -55
  69. package/lib/tools/deps.js +8 -0
  70. package/lib/tools/mcp.js +110 -0
  71. package/lib/tools/memory.js +87 -0
  72. package/lib/tools/prompt.js +70 -0
  73. package/lib/tools/skills.js +139 -0
  74. package/lib/tools/subagent.js +40 -0
  75. package/package.json +13 -10
  76. package/lib/agents-md/preset-id.js +0 -49
  77. package/lib/rules/service.js +0 -3078
@@ -0,0 +1,686 @@
1
+ // 域 op(2026-09-19 抽出):24 个 op 按记忆正文 / 回收站 / 场景分域,依赖经显式 ctx 传入。
2
+ import { buildMemoryOps } from '../ops/memory.js';
3
+ import { buildTrashOps } from '../ops/trash.js';
4
+ import { buildSceneRecordOps } from '../ops/scene-records.js';
5
+ // dsh-plugin-tool-management —— 规则/记忆(Rules v0.3,见 CHANGE-REQUEST-01)服务层。
6
+ //
7
+ // 记忆 = $DSH_HOME/tool-management/memories/<场景>/<name>.md(flat)或 <场景>/<name>/<name>.md(bundle)。
8
+ // 历史遗留的 bundle 正文文件名 `SKILL.md` 仍被识别(只读兼容),但新建/更新一律写 `<name>.md`。
9
+ // **场景是显式记录**,但记录的真源是 `memories-index.json` 的 `scenes` 切片(见 `SceneRecord`
10
+ // 与 `sceneRecordOf`),**不是** `scenes/<场景>.json` —— 那个目录是 v0.3 遗留的空壳:代码
11
+ // 只 `mkdir` 它、并把路径回显给前端,从不读写里面的文件(`SCENES_DIR` 全仓仅 3 处命中)。
12
+ // 场景因此不依赖"memories/ 下恰好有这个名字的目录"这种隐式约定:空场景可以存在,
13
+ // 且场景可以有描述。目录名即场景名(任意 Unicode,见 isValidGroupSegment)。
14
+ // (按 `scenes/*.json` 去找场景记录、或按它写备份/迁移脚本,都会扑空 —— 2026-09-19 订正。)
15
+ // 保留场景 `global`(界面显示「全局」):其记忆注入任何对话;它恒定存在、不可删除。
16
+ // 勾选启用后,该目录树内所有 .md 的正文自动进入模型的上下文(index.ts 注册的注入通道,
17
+ // 每步一条消息;见 src/context-inject.ts),模型无需做任何动作 —— 这就是"不用每次都要解释"。
18
+ //
19
+ // 单投影(原 ADR-4 的"双投影"已被本变更单修订):
20
+ // - 活动场景记忆 → 上下文注入(自动在场,会话级恒定 → 前缀稳定、缓存可命中;
21
+ // 文本没变时不重发 —— 2026-09-16 从 systemPrompt 段改道,理由见 context-inject.ts 文件头)
22
+ // - `_shared/` 承担"恒常"语义(所有场景共用);原 per-rule `always` 标志已移除
23
+ // - 不再写 ~/.dsh/AGENTS.md(原始终层投影下线)
24
+ //
25
+ // 状态分层(两份文件各司其职,互不写回):
26
+ // - 规则文件:正文真源。frontmatter 可声明 name/description/whenToUse/globs/metadata。
27
+ // - memories-index.json:启停/排序/标签/启用场景集合等**索引为准**字段(不写回记忆文件)。
28
+ //
29
+ // 发现必须自实现(不复用 readonly-discovery):可写来源只扫一层会压扁子目录场景,
30
+ // 而规则的目录树天然是多层的;且 readonly-discovery 的 flat 只认顶层。
31
+ //
32
+ // 缓存红线(§5.2):段内容只由「启用场景 + 文件内容」决定,禁止时间戳/计数/相对时间;
33
+ // 场景组合或记忆文件不变 ⇒ 逐字节稳定 ⇒ 前缀缓存命中。切换场景/编辑记忆只变化一次。
34
+ //
35
+ // 错误约定:业务校验失败返回 { ok:false, error: 中文, code, params? }(与 skills core 一致);
36
+ // ops 成功返回扁平 { ok:true, ... },不套 { ok:true, data }。
37
+ import { createHash } from 'node:crypto';
38
+ import { readFileSync, statSync } from 'node:fs';
39
+ import { copyFile, lstat, mkdir, readFile, readdir, stat } from 'node:fs/promises';
40
+ import { homedir } from 'node:os';
41
+ import { dirname, join, resolve } from 'node:path';
42
+ import { isInsideRootResolved } from '../paths.js';
43
+ import { parseSkillDoc, resolveDshHome } from '../skills/core.js';
44
+ import { newTrashId } from '../hub.js';
45
+ // 预算 / 保留场景名 / 段渲染常量(2026-09-19 抽到 ./constants.ts:投影函数与本服务共用,
46
+ // 留在这里会让 projection.ts 反向 import 本文件,形成运行时循环依赖)。
47
+ import { bundleDocName, DEFAULT_MAX_BYTES, GLOBAL_SCENE, LEGACY_BUNDLE_DOC, SHARED_GROUP, SNAPSHOT_TTL_MS, isValidGroupPath, isValidGroupSegment, MEMORIES_TRASH_DIR, fail, } from './constants.js';
48
+ // 对外契约保持在原路径:本文件此前直接 export 这两个,index.ts 等已按此 import。
49
+ export { GLOBAL_SCENE, GLOBAL_SCENE_LABEL } from './constants.js';
50
+ export { isValidGroupSegment, isValidGroupPath, MEMORIES_INDEX_FILE, MEMORIES_TRASH_DIR } from './constants.js';
51
+ // parseModeState 搬到了 index-io.ts,但 test/contracts.test.mjs 按原路径引它 —— 契约不能断。
52
+ export { parseModeState } from './index-io.js';
53
+ // 投影 / 渲染纯函数(2026-09-19 抽到 ./projection.ts):索引 → 界面与注入看到的那份文本。
54
+ import { enabledSceneOf, normalizeActive, resolveActiveScenes, sceneLabel, sceneOf, sceneOrderOf, } from './projection.js';
55
+ // 索引 IO 组与发现/快照组(2026-09-19 抽出)。
56
+ import { writeFileAtomically, isIndexQuarantined, readIndex, readIndexSync, writeIndex } from './index-io.js';
57
+ import { deriveFromDoc, projectRule, relocateLegacyLayout, buildSnapshot, probeSceneFilesSync, renderSceneMemory } from './snapshot.js';
58
+ // ── 目录常量与失败约定 ─────────────────────────────────────────────────────
59
+ // 其余跨文件共用的常量与无依赖小工具(message / isValidGroupSegment / isValidGroupPath /
60
+ // MEMORIES_INDEX_FILE)已下沉到 ./constants.ts,避免 index-io / snapshot 反向引本文件。
61
+ /**
62
+ * 场景/记忆根目录名($DSH_HOME 下),集中在 `tool-management/` 一个目录内:
63
+ * - `memories/<场景>/…` 记忆正文真源
64
+ * - `..`(即 tool-management 根)侧车:memories-index.json(内含 `scenes` 切片 = 场景记录
65
+ * 的**真源**)/ skills-state.json / trash/ / memories-trash/
66
+ *
67
+ * `scenes/`(`SCENES_DIR`)是 v0.3 遗留的**空壳**:只创建、只回显路径,没有任何读写。
68
+ * 别把场景记录的位置写成它。
69
+ *
70
+ * 历史位置 `$DSH_HOME/scene-memory/` 由 `relocateLegacyLayout()` 在首次读写前搬入。
71
+ */
72
+ export const HUB_DIR = 'tool-management';
73
+ export const SCENES_DIR = 'scenes';
74
+ export const MEMORIES_DIR = 'memories';
75
+ /** 提示词预设库目录名(hub 根下;域 = 提示词,工具 `prompt_manager_*`)。 */
76
+ export const PRESETS_DIR = 'prompts';
77
+ /**
78
+ * 记忆导出的落盘映射(`bundle-export` 的 `kind: 'memories'` 用,见 design-plan D14)。
79
+ *
80
+ * 记忆有两种形态(见文件头):flat = `<场景>/<name>.md`,bundle = `<场景>/<name>/<name>.md`;
81
+ * 而且**场景名本身可含 `/`**(多段场景名,见 `isValidGroupPath`)。所以「按 id 的最后一个
82
+ * `/` 切出场景与名字、再拼 `.md`」是错的:bundle 会被读成 `<场景>/<name>.md` → 读不到 →
83
+ * 该项目静默丢失(只在 `missing` 里留个名,界面按「导出成功」显示)。
84
+ *
85
+ * 这里一律按索引里那条规则自己的 `path` 与 `form` 决定;bundle 只交出目录,由调用方
86
+ * 把目录内的文件全部打包(与技能分支同口径)。索引里没有、或已被同名 bundle 遮蔽的 id
87
+ * 进 `missing`——遮蔽的 flat 不会被加载,导出去只会让人以为它能用。
88
+ *
89
+ * 纯函数(不碰磁盘),可直接断言。
90
+ */
91
+ export function planMemoryExport(names, rules) {
92
+ const list = Array.isArray(names) ? names.map((n) => String(n).trim()).filter(Boolean) : [];
93
+ const byId = new Map();
94
+ for (const row of (Array.isArray(rules) ? rules : [])) {
95
+ const id = row && typeof row.id === 'string' ? row.id : '';
96
+ if (id !== '')
97
+ byId.set(id, row);
98
+ }
99
+ const entries = [];
100
+ const missing = [];
101
+ for (const id of list) {
102
+ const row = byId.get(id);
103
+ const abs = row && typeof row.path === 'string' ? row.path : '';
104
+ if (!row || abs === '' || row.shadowed === true) {
105
+ missing.push(id);
106
+ continue;
107
+ }
108
+ entries.push(row.form === 'bundle'
109
+ ? { id, zip: id, abs: dirname(abs), kind: 'dir' }
110
+ : { id, zip: `${id}.md`, abs, kind: 'file' });
111
+ }
112
+ return { entries, missing };
113
+ }
114
+ export function createMemoriesService(ctx, deps) {
115
+ // 仅测试注入绝对路径;生产按 $DSH_HOME 解析(与核心技能目录同源)。
116
+ // v0.4:全部落到 $DSH_HOME/tool-management/ 一个目录内(memories/ + scenes/ + 侧车),
117
+ // 旧的 $DSH_HOME/scene-memory 与更旧的 $DSH_HOME/rules 由 relocateLegacyLayout() 搬入。
118
+ const stateDir = deps.stateDir && deps.stateDir.trim() !== '' ? resolve(deps.stateDir) : join(resolveDshHome(), HUB_DIR);
119
+ const memoriesRoot = deps.memoriesRoot && deps.memoriesRoot.trim() !== '' ? resolve(deps.memoriesRoot) : join(stateDir, MEMORIES_DIR);
120
+ const scenesRoot = deps.scenesDir && deps.scenesDir.trim() !== '' ? resolve(deps.scenesDir) : join(stateDir, SCENES_DIR);
121
+ const maxBytes = Number.isFinite(deps.maxBytes) && deps.maxBytes > 0 ? deps.maxBytes : DEFAULT_MAX_BYTES;
122
+ // 旧布局迁移:每个进程只跑一次。放在目录解析之后、任何首次读盘之前。
123
+ let legacyRelocated = false;
124
+ const ensureLayout = async () => {
125
+ if (legacyRelocated)
126
+ return;
127
+ legacyRelocated = true;
128
+ try {
129
+ await relocateLegacyLayout(memoriesRoot);
130
+ }
131
+ catch {
132
+ /* 迁移失败不阻断服务:旧目录原样留着,用户可手工搬 */
133
+ }
134
+ try {
135
+ await mkdir(join(memoriesRoot, GLOBAL_SCENE), { recursive: true });
136
+ await mkdir(scenesRoot, { recursive: true });
137
+ }
138
+ catch {
139
+ /* 目录建不出来时后面的 op 会各自报错,这里不提前抛 */
140
+ }
141
+ };
142
+ let snapCache = null;
143
+ const snapshot = async () => {
144
+ if (snapCache && Date.now() - snapCache.at < SNAPSHOT_TTL_MS)
145
+ return snapCache.value;
146
+ await ensureLayout();
147
+ const value = await buildSnapshot(memoriesRoot, stateDir);
148
+ snapCache = { at: Date.now(), value };
149
+ return value;
150
+ };
151
+ const invalidateSnapshot = () => {
152
+ snapCache = null;
153
+ };
154
+ // ── 场景记忆段(两相扫描 + 指纹缓存)──────────────────────────────────────
155
+ //
156
+ // 段文本 = 活动场景(`_shared` ∪ index.active,缺失时全部)下所有 .md 正文,
157
+ // 按「场景顺序 → 记忆名」确定性拼接;超预算按同一顺序确定性截断并追加固定标记。
158
+ // 禁止时间戳/计数:否则每请求都变,前缀缓存永远不命中(§5.2)。
159
+ //
160
+ // 每次装配:只做「读索引 + stat 遍历」的廉价探测;指纹不变直接返回上次结果,
161
+ // 指纹一变(切场景 / 改文件 / 外部编辑器改文件 / 改 enabled)才重读正文并重排。
162
+ // 见文件顶部「两相扫描」注释里为什么不选 fs.watch 方案。
163
+ let sceneCache = null;
164
+ function sceneMemory() {
165
+ const index = readIndexSync(stateDir);
166
+ const probe = probeSceneFilesSync(memoriesRoot, index, isIndexQuarantined(stateDir));
167
+ if (sceneCache && sceneCache.signature === probe.signature)
168
+ return sceneCache.value;
169
+ const value = renderSceneMemory(probe, index, maxBytes);
170
+ sceneCache = { signature: probe.signature, value };
171
+ return value;
172
+ }
173
+ // ── 场景提示词(绑定预设正文;读盘、只读;写文件由 scene-prompt-sync.ts 负责)──
174
+ //
175
+ // 语义(用户裁定 2026-09-15):
176
+ // - 场景可绑定**一个**提示词预设(`scenes[].prompt` → `prompts/<id>/AGENTS.md`);
177
+ // - 除保留场景 `global` 外**同时只能启用一个场景**,所以同一时刻至多一份提示词在场;
178
+ // - 没有启用其它场景时才轮到 `global` 自己的绑定(它恒常生效,覆盖"永远在场的提示词");
179
+ // - 预设文件不存在 / 未绑定 → 返回 `''`(renderPrompt 会删掉空段,不产生空标题)。
180
+ //
181
+ // 为什么不用 fs.watch 也不用每请求读盘:段契约要求**同步返回**且逐字节稳定,
182
+ // 因此按 `mtimeMs + size` 做一次 stat 缓存 —— 命中时零读盘,外部编辑器改了预设
183
+ // 也会在下一个请求自动刷新(与记忆段的"两相扫描"同一哲学)。
184
+ const promptFileCache = new Map();
185
+ /** 读一个预设正文(同步、带 stat 指纹缓存);不存在返回 `''`。 */
186
+ function readPresetTextSync(id) {
187
+ const file = join(stateDir, PRESETS_DIR, id, 'AGENTS.md');
188
+ let key;
189
+ try {
190
+ const st = statSync(file);
191
+ if (!st.isFile())
192
+ return '';
193
+ key = `${st.mtimeMs}:${st.size}`;
194
+ }
195
+ catch {
196
+ promptFileCache.delete(id);
197
+ return '';
198
+ }
199
+ const cached = promptFileCache.get(id);
200
+ if (cached && cached.key === key)
201
+ return cached.text;
202
+ let text = '';
203
+ try {
204
+ text = readFileSync(file, 'utf8');
205
+ }
206
+ catch {
207
+ return '';
208
+ }
209
+ promptFileCache.set(id, { key, text });
210
+ return text;
211
+ }
212
+ /** 预设文件是否存在(同步;绑定前校验用,避免悬空绑定)。 */
213
+ function presetExistsSync(id) {
214
+ try {
215
+ return statSync(join(stateDir, PRESETS_DIR, id, 'AGENTS.md')).isFile();
216
+ }
217
+ catch {
218
+ return false;
219
+ }
220
+ }
221
+ /**
222
+ * 读当前 `~/.dsh/AGENTS.md`(同步、带 stat 指纹缓存)。
223
+ * 用途只有一个:**去重**——绑定的预设与文件内容逐字节相同时不再注入第二遍正文。
224
+ */
225
+ let globalAgentsCache = null;
226
+ function readGlobalAgentsMdSync() {
227
+ const file = join(resolveDshHome(), 'AGENTS.md');
228
+ let key;
229
+ try {
230
+ const st = statSync(file);
231
+ if (!st.isFile())
232
+ return '';
233
+ key = `${st.mtimeMs}:${st.size}`;
234
+ }
235
+ catch {
236
+ globalAgentsCache = null;
237
+ return '';
238
+ }
239
+ if (globalAgentsCache && globalAgentsCache.key === key)
240
+ return globalAgentsCache.text;
241
+ let text = '';
242
+ try {
243
+ text = readFileSync(file, 'utf8');
244
+ }
245
+ catch {
246
+ return '';
247
+ }
248
+ globalAgentsCache = { key, text };
249
+ return text;
250
+ }
251
+ /**
252
+ * 当前生效的场景提示词:启用场景的绑定优先,其次 `global` 的绑定;都没有 → `null`。
253
+ * 「绑了但预设为空/已删」按没绑处理(不注入空段),继续找下一个。
254
+ */
255
+ function resolveScenePreset() {
256
+ const index = readIndexSync(stateDir);
257
+ const names = Object.keys(index.scenes || {});
258
+ const { active } = resolveActiveScenes(index, names);
259
+ // 单选:取唯一一个非保留启用场景(resolveActiveScenes 已保证 ≤1,这里仍做确定性排序兜底)。
260
+ const enabled = names
261
+ .filter((n) => n !== SHARED_GROUP && n !== GLOBAL_SCENE && active.has(n))
262
+ .sort((a, b) => sceneOrderOf(index, a) - sceneOrderOf(index, b) || a.localeCompare(b));
263
+ for (const scene of enabled) {
264
+ const id = index.scenes?.[scene]?.prompt;
265
+ if (!id)
266
+ continue;
267
+ const text = readPresetTextSync(id);
268
+ // 绑了但预设为空/已删 → 继续找下一个(不注入空段)。
269
+ if (text.trim() === '')
270
+ continue;
271
+ return { scene, presetId: id, text };
272
+ }
273
+ const globalId = index.scenes?.[GLOBAL_SCENE]?.prompt;
274
+ if (globalId) {
275
+ const text = readPresetTextSync(globalId);
276
+ if (text.trim() !== '')
277
+ return { scene: GLOBAL_SCENE, presetId: globalId, text };
278
+ }
279
+ return null;
280
+ }
281
+ /**
282
+ * 当前生效的场景提示词(只读投影,界面用):绑定内容与 `~/.dsh/AGENTS.md` 相同时标记
283
+ * `duplicate` 且返回空段 —— 那份正文已经在基线里了,界面据此显示「生效中」。
284
+ * (注入侧不看这个字段:预设挂不到 AGENTS.md 通道时照样要注入,见 `promptText`。)
285
+ */
286
+ function scenePrompt() {
287
+ const found = resolveScenePreset();
288
+ if (!found)
289
+ return { scene: null, presetId: null, text: '', missing: false };
290
+ const globalText = readGlobalAgentsMdSync().trim();
291
+ if (globalText !== '' && found.text.trim() === globalText) {
292
+ return { scene: found.scene, presetId: found.presetId, text: '', missing: false, duplicate: true };
293
+ }
294
+ return { scene: found.scene, presetId: found.presetId, text: found.text, missing: false };
295
+ }
296
+ // ── 写操作串行队列(避免并发覆盖同一索引/文件)──
297
+ let mutationQueue = Promise.resolve();
298
+ const enqueueMutation = (task) => {
299
+ const queued = mutationQueue.then(task, task);
300
+ mutationQueue = queued.catch(() => undefined);
301
+ return queued;
302
+ };
303
+ // ── 定位 / 解析 id ──────────────────────────────────────────────────────
304
+ /** id = "<group>/<name>";group 可含 '/'(多层),根下规则允许 group 为空。 */
305
+ function parseId(id) {
306
+ if (typeof id !== 'string' || id === '' || id.startsWith('/') || id.endsWith('/'))
307
+ return null;
308
+ const idx = id.lastIndexOf('/');
309
+ const group = idx >= 0 ? id.slice(0, idx) : '';
310
+ const name = idx >= 0 ? id.slice(idx + 1) : id;
311
+ if (!name || !isValidGroupSegment(name))
312
+ return null;
313
+ if (group && !isValidGroupPath(group))
314
+ return null;
315
+ return { group, name };
316
+ }
317
+ /**
318
+ * 磁盘定位(bundle 优先)。返回规则文件与条目路径。
319
+ * 根层(`group === ''`)**没有 bundle**:一级目录恒为场景(见 discover 的注释),
320
+ * 否则 `memories/1/1.md`(场景 1 的 flat 记忆 1)会被这里读成根层 bundle「1」,
321
+ * 与列表/注入两端不一致(编辑、删除都会落到错误的文件上)。
322
+ */
323
+ async function locateRule(group, name) {
324
+ if (group !== '') {
325
+ const bundleDir = join(memoriesRoot, group, name);
326
+ for (const candidate of [bundleDocName(name), LEGACY_BUNDLE_DOC]) {
327
+ const bundleDoc = join(bundleDir, candidate);
328
+ try {
329
+ const st = await lstat(bundleDoc);
330
+ if (st.isFile() && !st.isSymbolicLink()) {
331
+ return { id: group ? `${group}/${name}` : name, group, name, kind: 'bundle', docPath: bundleDoc, entryPath: bundleDir };
332
+ }
333
+ }
334
+ catch { /* 该候选名不存在 → 试下一个 */ }
335
+ }
336
+ }
337
+ const flatPath = join(memoriesRoot, group, name + '.md');
338
+ try {
339
+ const st = await lstat(flatPath);
340
+ if (st.isFile() && !st.isSymbolicLink()) {
341
+ return { id: group ? `${group}/${name}` : name, group, name, kind: 'flat', docPath: flatPath, entryPath: flatPath };
342
+ }
343
+ }
344
+ catch { /* 非 flat */ }
345
+ return null;
346
+ }
347
+ /**
348
+ * 写入 / 删除前的根内断言(realpath 口径)。
349
+ *
350
+ * 为什么 `parseId` 还不够:它挡住了 `..` 与分隔符,但**中间目录可以是符号链接** ——
351
+ * `<root>/办公` 若是指向别处的链接,`join(root, '办公', 'x.md')` 看着在根内,
352
+ * 实际会落到链接目标里(`lstat` 只看末级,发现不了)。发现侧已经不跟随链接,
353
+ * 这里是第二道:直接调 op 的调用方(模型工具、HTTP)绕过发现也拦得住。
354
+ * @returns 在根内返回 `null`,否则返回可直接交给调用方的失败对象。
355
+ */
356
+ async function refuseOutsideRoot(target) {
357
+ if (await isInsideRootResolved(memoriesRoot, target))
358
+ return null;
359
+ return fail('error.rules.outsideRoot', `拒绝操作:解析后的落点不在记忆根目录内(可能存在指向根外的目录链接):${target}`);
360
+ }
361
+ /** 写操作后构建单条投影(不依赖快照,避免全量重扫)。 */
362
+ async function buildProjected(id) {
363
+ const parts = parseId(id);
364
+ if (!parts)
365
+ return null;
366
+ const entry = await locateRule(parts.group, parts.name);
367
+ if (!entry)
368
+ return null;
369
+ try {
370
+ const text = await readFile(entry.docPath, 'utf8');
371
+ const doc = parseSkillDoc(text);
372
+ const derived = deriveFromDoc(entry, doc);
373
+ const index = await readIndex(stateDir);
374
+ return projectRule(entry, derived, index.rules[id]);
375
+ }
376
+ catch {
377
+ return null;
378
+ }
379
+ }
380
+ // ── 规则文件序列化 ──────────────────────────────────────────────────────
381
+ /**
382
+ * 值含换行用 `|` 块(保留换行);否则 `key: value` 原样(parseSkillDoc 按行贪婪解析)。
383
+ * 例外:多行值里有去空白后恰为 `---` 的行时改用 JSON 引号标量 —— 块标量把内容行缩进
384
+ * 两格也躲不开 parseSkillDoc 的 frontmatter 结束扫描(`trim() === '---'`),描述会被
385
+ * 截断、残片混进正文;引号形式是单物理行,扫描无从误判,decodeYamlScalar 无损还原
386
+ * (skills 写侧的引号标量正是靠这一点免疫同一断裂)。
387
+ */
388
+ function yamlField(key, value) {
389
+ if (typeof value === 'boolean')
390
+ return [`${key}: ${value}`];
391
+ const s = String(value);
392
+ if (s.includes('\n')) {
393
+ if (s.split('\n').some((line) => line.trim() === '---'))
394
+ return [`${key}: ${JSON.stringify(s)}`];
395
+ return [`${key}: |`, ...s.split('\n').map((line) => ` ${line}`)];
396
+ }
397
+ return [`${key}: ${s}`];
398
+ }
399
+ function serializeRuleFile(fields, body) {
400
+ const lines = ['---'];
401
+ for (const [key, value] of Object.entries(fields))
402
+ lines.push(...yamlField(key, value));
403
+ lines.push('---', '');
404
+ return lines.join('\n') + String(body ?? '');
405
+ }
406
+ /** update 时重建文件:保留 frontmatter 其余字段,仅更新 name/description。 */
407
+ function serializeUpdatedFile(doc, newName, description, body) {
408
+ const fields = {};
409
+ for (const [key, value] of Object.entries(doc.map)) {
410
+ if (key === 'name' || key === 'description')
411
+ continue;
412
+ fields[key] = String(value);
413
+ }
414
+ fields.name = newName;
415
+ if (description === undefined) {
416
+ if (doc.map.description != null && String(doc.map.description).trim() !== '')
417
+ fields.description = String(doc.map.description);
418
+ }
419
+ else if (description !== null) {
420
+ fields.description = description;
421
+ }
422
+ return serializeRuleFile(fields, body);
423
+ }
424
+ /**
425
+ * 写操作成功后:失效快照 + 丢弃场景记忆段缓存 + 通知 provider。
426
+ * 指纹本身已能发现变化,这里显式丢弃是为了让写操作返回后**必然**是新鲜结果。
427
+ * 不再写 ~/.dsh/AGENTS.md(原始终层投影已下线,见变更单 §4/§10):
428
+ * 公共基线改由 `_shared/` 场景承担,统一走 systemPrompt 段。
429
+ */
430
+ const refresh = async () => {
431
+ invalidateSnapshot();
432
+ sceneCache = null;
433
+ };
434
+ /** 场景档案引擎专用:读-改-写 mode/archives/active 切片(写队列内,保持与其余索引写串行)。 */
435
+ const patchIndex = (patch) => enqueueMutation(async () => {
436
+ const index = await readIndex(stateDir);
437
+ if (patch.mode !== undefined)
438
+ index.mode = patch.mode;
439
+ if (patch.archives !== undefined)
440
+ index.archives = patch.archives;
441
+ // active 复用「记忆启用集」语义(null = 全部启用);进入模式时引擎收窄为 [S]。
442
+ if (patch.active !== undefined)
443
+ index.active = patch.active;
444
+ await writeIndex(stateDir, index);
445
+ await refresh();
446
+ });
447
+ /** 场景档案引擎专用:读 mode/archives/active 切片(容忍缺失,缺省 = 无档案 + 自由模式 + 全部启用)。 */
448
+ const readArchiveSlice = async () => {
449
+ const index = await readIndex(stateDir);
450
+ return { mode: index.mode ?? { scene: null, snapshot: null }, archives: index.archives ?? {}, active: normalizeActive(index.active) };
451
+ };
452
+ // ── 场景行(UI 用:启用/停用开关)──────────────────────────────────────
453
+ /** 场景行:**以索引的场景记录为准**(空场景也在列表里),记忆条数由快照统计。 */
454
+ function sceneRows(snap, index) {
455
+ const names = new Set([GLOBAL_SCENE, ...Object.keys(index.scenes || {}), ...snap.scenes]);
456
+ const counts = new Map();
457
+ for (const rule of snap.rules) {
458
+ if (rule.shadowed)
459
+ continue;
460
+ const scene = sceneOf(rule.group);
461
+ if (scene === '')
462
+ continue; // 无场景归属的记忆(旧根层遗留)不归属任何场景
463
+ counts.set(scene, (counts.get(scene) || 0) + 1);
464
+ }
465
+ const known = [...names];
466
+ const { active } = resolveActiveScenes(index, known);
467
+ const enabledScene = enabledSceneOf(index);
468
+ return known
469
+ .map((name) => ({
470
+ name,
471
+ label: sceneLabel(name, index),
472
+ order: sceneOrderOf(index, name),
473
+ count: counts.get(name) || 0,
474
+ active: active.has(name),
475
+ shared: name === SHARED_GROUP,
476
+ global: name === GLOBAL_SCENE,
477
+ description: index.scenes?.[name]?.description || '',
478
+ prompt: index.scenes?.[name]?.prompt || '',
479
+ // 单选模型下"能不能点开":已被别的场景占用时,这个开关要置灰。
480
+ selectable: name !== SHARED_GROUP && name !== GLOBAL_SCENE && (enabledScene === null || enabledScene === name),
481
+ locked: index.scenes?.[name]?.locked === true,
482
+ }))
483
+ .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
484
+ }
485
+ // ── ops:读 ─────────────────────────────────────────────────────────────
486
+ /**
487
+ * 把条目原件复制进 memories 回收站并写 manifest,返回 trashId。只复制、不删除:
488
+ * 原件的 rm 由调用方在复制成功后执行 —— 先有副本才允许删,rm 永远不会销毁唯一数据。
489
+ * manifest 先于原件删除落盘:中途崩溃最坏留下「原件还在 + 一份完整回收站副本」,
490
+ * 不会出现「有文件没清单」的损坏条目。
491
+ */
492
+ async function copyIntoMemoriesTrash(located, group, name) {
493
+ const trashId = newTrashId();
494
+ const trashDir = join(stateDir, MEMORIES_TRASH_DIR, trashId);
495
+ const manifest = { group, name, form: located.kind, deletedAt: new Date().toISOString() };
496
+ await mkdir(trashDir, { recursive: true });
497
+ if (located.kind === 'bundle') {
498
+ const { cp } = await import('node:fs/promises');
499
+ await cp(located.entryPath, join(trashDir, 'bundle'), { recursive: true });
500
+ }
501
+ else {
502
+ await copyFile(located.docPath, join(trashDir, 'rule.md'));
503
+ }
504
+ await writeFileAtomically(join(trashDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
505
+ return trashId;
506
+ }
507
+ /** bundle 目录下的附件(顶层普通文件,排除正文 `<名>.md`,兼容旧数据 `SKILL.md`);不存在/不可读返回空数组。 */
508
+ async function listAttachments(bundleDir, name) {
509
+ let entries;
510
+ try {
511
+ entries = await readdir(bundleDir, { withFileTypes: true });
512
+ }
513
+ catch {
514
+ return [];
515
+ }
516
+ const docNames = new Set([bundleDocName(name), LEGACY_BUNDLE_DOC]);
517
+ const out = [];
518
+ for (const entry of entries) {
519
+ if (docNames.has(entry.name))
520
+ continue;
521
+ if (!entry.isFile())
522
+ continue; // 子目录 / 符号链接不列出(detach 也只动普通文件)
523
+ try {
524
+ out.push({ name: entry.name, size: (await stat(join(bundleDir, entry.name))).size });
525
+ }
526
+ catch { /* 读不到的条目跳过 */ }
527
+ }
528
+ out.sort((a, b) => a.name.localeCompare(b.name));
529
+ return out;
530
+ }
531
+ // 注入文本出口(2026-09-16 第四版)。
532
+ //
533
+ // 历史:本服务先后把场景记忆做成"系统提示词段"(会被 persona complete 压掉)与
534
+ // "写 AGENTS.md 文件"(预设不挂 dsh-agent-instructions 时到不了模型)。现在两条都交给
535
+ // 注入通道(src/context-inject.ts):每个 step 前把文本并进一条注入消息,任何预设都到得了。
536
+ // 这里只留**同步取文本**的两个出口,注册/生命周期由 index.ts 的注入器统一管。
537
+ //
538
+ // - `memoryText`:场景记忆段(记忆正文;`''` = 没有可注入的内容)。
539
+ // - `promptText`:全局提示词正文 = 场景期间用场景提示词、平时用 AGENTS.md 正文。
540
+ // **不判"与文件重复"** —— 预设挂了官方 agent-instructions 时那份正文已经由文件送达
541
+ // (注入侧会跳过整个域),没挂时(极简)才需要注入兜底;判定在注入器的 `selectInjections` 里。
542
+ const memoryText = () => sceneMemory().text;
543
+ /**
544
+ * `~/.dsh/AGENTS.md` 正文的上限,与官方那一行的 `maxBytes` 同量级(64 KiB):超大文件按字节
545
+ * 截断并留一行标记,免得一份手写的巨型基线把上下文撑爆。
546
+ */
547
+ const AGENTS_MD_MAX_BYTES = 65536;
548
+ /**
549
+ * DSH home 的**展示形态**,规则与官方 `dsh-home-paths` 的 `dshHomeDisplay()` 一致
550
+ * (`dsh-home-paths/lib/index.js:93`):默认位置显示 `~/.dsh`,被 `DSH_HOME` 指到别处时
551
+ * 才显示 `$DSH_HOME`。只在给**界面**看的字符串里用 —— 读盘一律用真实路径。
552
+ */
553
+ const dshHomeDisplay = () => {
554
+ const home = resolveDshHome();
555
+ return resolve(home) === resolve(join(homedir(), '.dsh')) ? '~/.dsh' : '$DSH_HOME';
556
+ };
557
+ /**
558
+ * 当前生效的提示词:**来源文件 + 正文**,一次解析、两处出口共用(模型侧 `promptText`、
559
+ * 界面清单 `promptFiles`)。`null` = 现在没有提示词。
560
+ *
561
+ * 为什么合并成一处:两条出口必须给出**同一个**结论(文件是哪个、正文取哪份),分开写两份
562
+ * 同样的分支判断迟早分叉 —— 分叉的后果就是界面标错文件、或模型看到与文件不符的正文。
563
+ * 合并前那里已经有一处小分叉:场景预设在场但正文为空时,文本退回 AGENTS.md、清单却仍报
564
+ * 预设路径。现在两边同源,这类角落不可能再各说各话。
565
+ *
566
+ * `digest` 照抄官方 `instructionContentSha1`(`dsh-agent-instructions/lib/index.js:90`,
567
+ * sha1 hex):算的是**文件内容**(AGENTS.md 取截断前的原文),是"文件这一版"的身份,
568
+ * 不是"这次注入了什么";界面只拿它当悬停提示,不参与任何判定。
569
+ *
570
+ * `text` 是**模型侧**正文,开头一行 `来源:<展示路径>` —— 照抄官方 agent-instructions 的
571
+ * 写法(`Instructions from: <path>`,`render.js` 的 `sectionText`)。模型据此知道这些规则
572
+ * 写在哪个文件里:用户说"把这条记下来"时它知道该改哪份文件,而不是只能凭印象回答。
573
+ */
574
+ const resolvePrompt = () => {
575
+ // 场景提示词与 AGENTS.md 正文在用户眼里就是同一件事("全局提示词"):场景期间前者取代后者,
576
+ // 与官方语义一致(进场景改写文件、退出恢复)。所以一个域、一份文本,取到非空的场景提示词就用它。
577
+ const scene = resolveScenePreset();
578
+ if (scene && scene.text.trim() !== '') {
579
+ const path = `${dshHomeDisplay()}/${HUB_DIR}/${PRESETS_DIR}/${scene.presetId}/AGENTS.md`;
580
+ return {
581
+ path,
582
+ digest: createHash('sha1').update(scene.text).digest('hex'),
583
+ text: `来源:${path}\n\n${scene.text}`,
584
+ };
585
+ }
586
+ const raw = readGlobalAgentsMdSync();
587
+ if (raw.trim() === '')
588
+ return null;
589
+ const path = `${dshHomeDisplay()}/AGENTS.md`;
590
+ const body = Buffer.byteLength(raw, 'utf8') <= AGENTS_MD_MAX_BYTES
591
+ ? raw
592
+ : Buffer.from(raw, 'utf8').subarray(0, AGENTS_MD_MAX_BYTES).toString('utf8') +
593
+ `\n\n(…文件超过 ${Math.floor(AGENTS_MD_MAX_BYTES / 1024)} KiB,已截断。)`;
594
+ return { path, digest: createHash('sha1').update(raw).digest('hex'), text: `来源:${path}\n\n${body}` };
595
+ };
596
+ /**
597
+ * 模型侧的提示词正文(`''` = 没有可注入的内容)。
598
+ *
599
+ * **不判"与文件重复"** —— 预设挂了官方 agent-instructions 时那份正文已经由文件送达
600
+ * (注入侧会跳过整个域),没挂时(极简)才需要注入兜底;判定在注入器的 `selectInjections` 里。
601
+ */
602
+ const promptText = () => resolvePrompt()?.text ?? '';
603
+ /**
604
+ * `promptText()` 的**来源文件**(同步):注入通道把它当作 instructions 形态的
605
+ * `changes` 交给界面(文件清单 + 已载入/已更新),见 context-inject.ts 的 `files`。
606
+ * 与文本同源(都走 `resolvePrompt`),所以两边不会分叉;`[]` = 无正文。
607
+ *
608
+ * 路径用**展示形态**(见 `dshHomeDisplay`):界面里官方 agent-instructions 那条行写的是
609
+ * `~/.dsh/AGENTS.md`,我们写绝对路径的话,同一个界面上会出现两种风格。
610
+ */
611
+ const promptFiles = () => {
612
+ const resolved = resolvePrompt();
613
+ return resolved === null ? [] : [{ path: resolved.path, digest: resolved.digest }];
614
+ };
615
+ /** 写操作:串行队列内执行,成功后触发 refresh(失效缓存,下一请求即生效)。 */
616
+ const runWrite = (task) => enqueueMutation(async () => {
617
+ const result = await task();
618
+ if (result && result.ok !== false)
619
+ await refresh();
620
+ return result;
621
+ });
622
+ // 写操作清单:与下方 ops 表同文件同源维护(含此前漂移漏掉的
623
+ // rules-attach / rules-detach / rules-trash-remove);HTTP 端门禁由
624
+ // index.ts 从本集合派生,勿在宿主端另抄一份。
625
+ // 域 op 的显式上下文:成员就是本闭包内的读模型 / 写管道 / 入口,契约见 ops/ctx.ts。
626
+ // 抽出去的 ops/*.ts 只通过它访问闭包,任何漏挂都会在 tsc 阶段报「Cannot find name」。
627
+ const rc = {
628
+ stateDir, memoriesRoot, scenesRoot, maxBytes,
629
+ snapshot, invalidateSnapshot, sceneMemory, scenePrompt,
630
+ parseId, locateRule, refuseOutsideRoot, buildProjected, sceneRows,
631
+ copyIntoMemoriesTrash, listAttachments, serializeRuleFile, serializeUpdatedFile,
632
+ ensureLayout, presetExistsSync,
633
+ };
634
+ const memoryOps = buildMemoryOps(rc);
635
+ const trashOps = buildTrashOps(rc);
636
+ const sceneOps = buildSceneRecordOps(rc);
637
+ const writeOps = new Set([
638
+ 'rules-create', 'rules-update', 'rules-remove', 'rules-restore', 'rules-toggle', 'rules-import',
639
+ 'rules-set-index', 'rules-set-active', 'rules-create-scene', 'rules-update-scene', 'rules-remove-scene', 'rules-rebind-prompt',
640
+ 'rules-scene-lock',
641
+ 'rules-attach', 'rules-detach', 'rules-trash-remove',
642
+ // 场景回收站(与记忆回收站 rules-trash-* 分开:那套管记忆正文,这套管场景记录与档案)
643
+ 'scene-trash-restore', 'scene-trash-delete',
644
+ ]);
645
+ const ops = {
646
+ // 这三个「只读」op 也要进写队列:它们经 snapshot() → buildSnapshot 做悬空记录清理,
647
+ // 而那次清理是**整份 index 覆盖写**。不入队时,一次并发的 rules-list 会把
648
+ // rules-toggle 刚写入的开关用旧快照盖回去(用户点了开关、状态却弹回)。
649
+ // 注意**不能**用 runWrite —— 那个还会 refresh(),会让只读 op 每次都作废快照缓存。
650
+ 'rules-list': (args) => enqueueMutation(() => memoryOps.rulesList(args || {})),
651
+ 'rules-read': (args) => enqueueMutation(() => memoryOps.rulesRead(args || {})),
652
+ 'rules-budget': () => memoryOps.rulesBudget(),
653
+ 'rules-diagnose': () => enqueueMutation(() => memoryOps.rulesDiagnose()),
654
+ 'rules-create': (args) => runWrite(() => memoryOps.rulesCreate(args || {})),
655
+ 'rules-import': (args) => runWrite(() => memoryOps.rulesImport(args || {})),
656
+ 'rules-update': (args) => runWrite(() => memoryOps.rulesUpdate(args || {})),
657
+ 'rules-remove': (args) => runWrite(() => trashOps.rulesRemove(args || {})),
658
+ 'rules-restore': (args) => runWrite(() => trashOps.rulesRestore(args || {})),
659
+ 'rules-trash-list': () => trashOps.rulesTrashList(),
660
+ 'rules-trash-remove': (args) => runWrite(() => trashOps.rulesTrashRemove(args || {})),
661
+ 'rules-attach': (args) => runWrite(() => memoryOps.rulesAttach(args || {})),
662
+ 'rules-detach': (args) => runWrite(() => memoryOps.rulesDetach(args || {})),
663
+ 'rules-toggle': (args) => runWrite(() => memoryOps.rulesToggle(args || {})),
664
+ 'rules-set-index': (args) => runWrite(() => memoryOps.rulesSetIndex(args || {})),
665
+ 'rules-set-active': (args) => runWrite(() => sceneOps.rulesSetActive(args || {})),
666
+ 'rules-create-scene': (args) => runWrite(() => sceneOps.rulesCreateScene(args || {})),
667
+ 'rules-update-scene': (args) => runWrite(() => sceneOps.rulesUpdateScene(args || {})),
668
+ 'rules-scene-lock': (args) => runWrite(() => sceneOps.rulesSceneLock(args || {})),
669
+ 'rules-remove-scene': (args) => runWrite(() => sceneOps.rulesRemoveScene(args || {})),
670
+ 'scene-trash-list': () => sceneOps.sceneTrashList(),
671
+ 'scene-trash-restore': (args) => runWrite(() => sceneOps.sceneTrashRestore(args || {})),
672
+ 'scene-trash-delete': (args) => runWrite(() => sceneOps.sceneTrashDelete(args || {})),
673
+ 'rules-rebind-prompt': (args) => runWrite(() => sceneOps.rulesRebindPrompt(args || {})),
674
+ };
675
+ const service = {
676
+ ops,
677
+ writeOps,
678
+ memoryText,
679
+ promptText,
680
+ promptFiles,
681
+ refresh,
682
+ patchIndex,
683
+ readArchiveSlice,
684
+ };
685
+ return service;
686
+ }