dsh-plugin-tool-management 0.16.5 → 0.17.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 (52) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +2 -1
  3. package/README_EN.md +2 -1
  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 +53 -0
  21. package/lib/audit-log.js +6 -2
  22. package/lib/client.js +44 -17
  23. package/lib/compat/probe.js +75 -24
  24. package/lib/context-inject.js +73 -7
  25. package/lib/host-names.js +150 -0
  26. package/lib/hub.js +82 -11
  27. package/lib/imports/parsers.js +40 -15
  28. package/lib/index.js +168 -74
  29. package/lib/mcp/loader-token.js +26 -0
  30. package/lib/mcp/manager.js +314 -87
  31. package/lib/mcp/patch-yaml.js +207 -40
  32. package/lib/mcp/secret-guard.js +122 -16
  33. package/lib/memories/archive-engine.js +21 -11
  34. package/lib/memories/index-io.js +125 -12
  35. package/lib/memories/service.js +163 -40
  36. package/lib/memories/snapshot.js +32 -9
  37. package/lib/op-registry.js +51 -8
  38. package/lib/ops/compat.js +6 -2
  39. package/lib/ops/prompts.js +18 -5
  40. package/lib/ops/scene-records.js +117 -10
  41. package/lib/ops/sessions.js +50 -4
  42. package/lib/ops/snapshot.js +105 -12
  43. package/lib/ops/trash.js +45 -9
  44. package/lib/prompts/service.js +7 -2
  45. package/lib/request-gate.js +115 -56
  46. package/lib/scene-prompt-sync.js +46 -7
  47. package/lib/scenes/candidates.js +21 -3
  48. package/lib/sessions/workspace.js +40 -5
  49. package/lib/skills/core.js +206 -43
  50. package/lib/skills/service.js +9 -4
  51. package/lib/subagents/service.js +6 -1
  52. package/package.json +1 -1
@@ -206,8 +206,43 @@ export function parseIndex(raw) {
206
206
  // - 读侧降级 —— 注入路径(readIndexSync)不能抛,返回默认索引,界面表现为「记忆都没了」,
207
207
  // 是可见症状而不是静默改写;
208
208
  // - 写侧 fail-closed —— 拒绝落盘并把原因回给界面,用户的数据一个字节不动。
209
+ //
210
+ // 2026-09-30(审查 P0-1)补的那一刀:上面第一段说的「三者合并」其实**只修掉了一条**——
211
+ // 隔离只由 parse 失败触发,读失败仍被并进「文件不存在」这条路。于是「文件在、这一次读不到」
212
+ // 依然能走完整条静默覆盖链。现在**读失败(非 ENOENT)也判不可信**:它和「解析不出来」是
213
+ // 同一件事的两面 —— 我们都不知道里面是什么,都不许拿一份空索引去覆盖它。
214
+ //
215
+ // 但两条路的**处置**必须分开(2026-09-30 自查,避免修 A 挖 B):
216
+ // - parse 失败 → `quarantineIndex`:改名留存 + 标记。同一份内容每次 parse 都失败,文件
217
+ // 确实坏了,改名是恰当的处置。
218
+ // - 读失败 → `markIndexUntrusted`:**只标记,不动文件**。读失败常常是瞬时的(见该函数注释)。
219
+ // Map 的值 = 改名后的留存路径;`''` = 文件未被改动(仍在原位),供 `writeIndex` 换措辞。
209
220
  export const corruptIndexes = new Map();
210
- /** 把损坏的索引改名留存并记录该 stateDir 已损坏(幂等,只记一次)。 */
221
+ /** 一个 fs 错误的 `code`(取不到就返回空串)。 */
222
+ function errnoCode(error) {
223
+ const code = error?.code;
224
+ return typeof code === 'string' ? code : '';
225
+ }
226
+ /** `code` 或消息,拼进日志用。 */
227
+ function errnoText(error) {
228
+ return errnoCode(error) || String(error?.message || error);
229
+ }
230
+ /**
231
+ * 该错误是不是「目标**不存在**」(`ENOENT`)。
232
+ *
233
+ * 这是本仓「全新用户」与「这一次读失败」的**唯一分界**:整个 0.16.x 的数据丢失事故
234
+ * (见 `readIndex` 的长注释)就源于把两者合并。凡是要把 IO 失败与"本来就没有"分开的读路径
235
+ * 都应当用这一条判据,而不要各写一套。
236
+ */
237
+ export function isAbsentError(error) {
238
+ return errnoCode(error) === 'ENOENT';
239
+ }
240
+ /**
241
+ * 把**解析失败**的索引改名留存并记录该 stateDir 已损坏(幂等,只记一次)。
242
+ *
243
+ * 只给 `parseOrQuarantine` 用(解析失败)。**读失败不要用它** —— 那条路要的是
244
+ * `markIndexUntrusted`:读失败可能是瞬时的,改名会把"重试即可恢复"升级成"记忆全没了"。
245
+ */
211
246
  export function quarantineIndex(stateDir) {
212
247
  const key = resolve(stateDir);
213
248
  if (corruptIndexes.has(key))
@@ -225,14 +260,41 @@ export function quarantineIndex(stateDir) {
225
260
  console.warn(`[dsh-plugin-tool-management] 记忆索引解析失败,已停止写入以免覆盖你的数据。`
226
261
  + `损坏的原文件已留存为 ${kept} —— 请检查它(或删除后重启 DSH)再继续操作。`);
227
262
  }
228
- /** 该 stateDir 的索引是否已判定损坏(写侧拒绝落盘,读侧按「全部未启用」处理)。 */
263
+ /**
264
+ * 标记该 stateDir 的索引**不可信** → 写侧拒写、读侧按「全部未启用」投影。**不动文件**。
265
+ *
266
+ * 为什么读失败不能走 `quarantineIndex`(2026-09-30 自查 P0-1 的次生风险):那条路会
267
+ * `renameSync` 把文件改名成 `.corrupt-*`。但「读失败」与「解析失败」的**确定性**不同 ——
268
+ * parse 失败对同一份内容可复现(文件确实坏了,改名留存是对的);而读失败常常是**瞬时**的
269
+ * (Windows 上杀软 / 索引器 / 备份软件占住会报 EACCES/EBUSY),内容很可能完好。对一次瞬时
270
+ * 故障执行改名,等于把「重试即可恢复」升级成「记忆全没了(数据在 `.corrupt-*` 里,需人工
271
+ * 恢复)」。所以这里只标记:数据留在原位,当前进程拒写(不会被空索引覆盖),故障过去后
272
+ * 自动恢复 —— `parseOrQuarantine` 成功时会解除标记。
273
+ */
274
+ export function markIndexUntrusted(stateDir, reason) {
275
+ const key = resolve(stateDir);
276
+ const first = !corruptIndexes.has(key);
277
+ // 值 `''` = 文件未被改动(仍在原位)—— `writeIndex` 的错误消息据此换措辞。
278
+ corruptIndexes.set(key, '');
279
+ if (!first)
280
+ return; // 幂等:重复的失败不再刷日志
281
+ console.warn(`[dsh-plugin-tool-management] ${reason},已停止写入以免覆盖你的数据。`
282
+ + `原文件未被改动(仍在 ${join(stateDir, MEMORIES_INDEX_FILE)})—— 若它其实完好,`
283
+ + `下次读取成功会自动恢复;也可检查后重启 DSH。`);
284
+ }
285
+ /** 该 stateDir 的索引是否已判定不可信(写侧拒绝落盘,读侧按「全部未启用」处理)。 */
229
286
  export function isIndexQuarantined(stateDir) {
230
287
  return corruptIndexes.has(resolve(stateDir));
231
288
  }
232
289
  /** 解析索引;失败即隔离该文件并返回默认索引(写侧由 writeIndex 拦截)。 */
233
290
  export function parseOrQuarantine(stateDir, raw) {
234
291
  try {
235
- return parseIndex(raw);
292
+ const index = parseIndex(raw);
293
+ // 读到了能解析的内容 → 之前那次「不可信」判定已过期,解除。
294
+ // 只对 `markIndexUntrusted` 那类(文件仍在原位)有意义:parse 失败时文件已被改名,
295
+ // 后续读到的是 ENOENT,走不到这里 —— 所以这一行不会削弱 parse 失败的 fail-closed。
296
+ corruptIndexes.delete(resolve(stateDir));
297
+ return index;
236
298
  }
237
299
  catch {
238
300
  quarantineIndex(stateDir);
@@ -244,8 +306,17 @@ export async function readIndex(stateDir) {
244
306
  try {
245
307
  raw = await readFile(join(stateDir, MEMORIES_INDEX_FILE), 'utf8');
246
308
  }
247
- catch {
248
- // 文件不存在 = 全新用户;其余 IO 错误也按「没有索引」处理(与既有语义一致)。
309
+ catch (e) {
310
+ // 只有 **ENOENT**(文件不存在)= 全新用户。其余 IO 错误必须与它分开 —— 这里曾经把两者
311
+ // 合并成「返回默认索引」,于是这一次读失败之后的任何一次写都会「读空 → 改一处 → 整份写」,
312
+ // 把用户全部配置(场景记录 / 档案 / 启用集合 / 每条记忆的 enabled/order/tags/pinned/note /
313
+ // 模式快照)换成空索引;更糟的是**纯读 op 也会落盘**(`rules-list` 是界面 5s 轮询的读 op,
314
+ // 而 buildSnapshot 补 `global` 记录后会 writeIndex),也就是打开面板就可能触发覆盖。
315
+ // 现在按 fail-closed 处理:标记该 stateDir 不可信(写侧随即拒写),读侧退默认索引(可见症状)。
316
+ // 用 `markIndexUntrusted` 而不是 `quarantineIndex`:读失败可能是瞬时的,**不改名**用户的文件。
317
+ if (errnoCode(e) === 'ENOENT')
318
+ return defaultIndex();
319
+ markIndexUntrusted(stateDir, `记忆索引读失败(${errnoText(e)})`);
249
320
  return defaultIndex();
250
321
  }
251
322
  return parseOrQuarantine(stateDir, raw);
@@ -262,6 +333,11 @@ export async function readIndex(stateDir) {
262
333
  * 拿到新对象)。当前两个调用方(`sceneMemory` / `resolveScenePreset`)及其下游
263
334
  * (`signatureOfIndex` / `resolveActiveScenes` / `sceneLine` / `compareSceneBuckets` /
264
335
  * `sceneOrderOf`)都已确认只读 —— 新增调用方请保持这条。
336
+ *
337
+ * 2026-09-30(审查 P0-1):**读失败**(非 ENOENT)同样走隔离分支,不再只是「退默认索引」。
338
+ * 退默认索引在注入侧等于 `enabled ?? true` → 用户显式停用的记忆会**重新注入**;隔离之后
339
+ * `isIndexQuarantined()` 为真,调用方按 `forceDisabled` 投影成「全部未启用」(宁可少注入,
340
+ * 也不注入用户关掉的东西)。写侧同时也被保护(见 writeIndex)。
265
341
  */
266
342
  export const indexSyncCache = new Map();
267
343
  export function readIndexSync(stateDir) {
@@ -271,25 +347,44 @@ export function readIndexSync(stateDir) {
271
347
  const st = statSync(file);
272
348
  key = `${st.mtimeMs}:${st.size}`;
273
349
  }
274
- catch {
275
- // 文件不存在 = 全新用户;其余 IO 错误也按「没有索引」处理(与 readIndex 一致)。
350
+ catch (e) {
276
351
  indexSyncCache.delete(stateDir);
352
+ // 文件不存在 = 全新用户;其余 IO 错误(被占住/权限)→ 标记不可信(写侧拒写 + 读侧 forceDisabled)。
353
+ // 同样**不改名**文件(瞬时故障要能自愈,见 `markIndexUntrusted`)。
354
+ if (errnoCode(e) !== 'ENOENT')
355
+ markIndexUntrusted(stateDir, `记忆索引读失败(${errnoText(e)})`);
277
356
  return defaultIndex();
278
357
  }
279
358
  const cached = indexSyncCache.get(stateDir);
280
359
  if (cached && cached.key === key)
281
360
  return cached.value;
282
- const raw = readFileIfExistsSync(file);
283
- const value = raw === null ? defaultIndex() : parseOrQuarantine(stateDir, raw);
361
+ let raw;
362
+ try {
363
+ raw = readFileSync(file, 'utf8');
364
+ }
365
+ catch (e) {
366
+ // stat 与 read 之间文件可能被换掉(写侧是 temp + rename),ENOENT 属正常竞态 → 只丢缓存;
367
+ // 其余错误与上面同口径:隔离,别把「读失败」当成「全部启用」。
368
+ indexSyncCache.delete(stateDir);
369
+ if (errnoCode(e) !== 'ENOENT')
370
+ markIndexUntrusted(stateDir, `记忆索引读失败(${errnoText(e)})`);
371
+ return defaultIndex();
372
+ }
373
+ const value = parseOrQuarantine(stateDir, raw);
284
374
  indexSyncCache.set(stateDir, { key, value });
285
375
  return value;
286
376
  }
287
377
  export async function writeIndex(stateDir, index) {
288
- // 索引损坏时拒绝写入:这一次写会把空索引落盘,等于用一次解析失败换掉用户全部配置。
378
+ // 索引不可信时拒绝写入:这一次写会把空索引落盘,等于用一次解析失败换掉用户全部配置。
379
+ // 值分两种:非空 = parse 失败后改名的留存路径;`''` = 读失败、文件未被改动(仍在原位)。
289
380
  const kept = corruptIndexes.get(resolve(stateDir));
290
381
  if (kept !== undefined) {
291
- throw new Error(`记忆索引文件损坏,已拒绝写入以免覆盖你的数据(原文件留存为 ${kept})。`
292
- + `请检查或删除该损坏文件后重启 DSH,再重试本次操作。`);
382
+ throw new Error(kept
383
+ ? `记忆索引文件损坏,已拒绝写入以免覆盖你的数据(原文件留存为 ${kept})。`
384
+ + `请检查或删除该损坏文件后重启 DSH,再重试本次操作。`
385
+ : `记忆索引当前读不到,已拒绝写入以免覆盖你的数据。原文件未被改动`
386
+ + `(仍在 ${join(stateDir, MEMORIES_INDEX_FILE)})—— 多半是被别的进程暂时占住,`
387
+ + `稍后重试即可;若持续如此,请检查该文件后重启 DSH。`);
293
388
  }
294
389
  await mkdir(stateDir, { recursive: true });
295
390
  await writeFileAtomically(join(stateDir, MEMORIES_INDEX_FILE), JSON.stringify(index, null, 2));
@@ -334,3 +429,21 @@ export function readFileIfExistsSync(path) {
334
429
  return null;
335
430
  }
336
431
  }
432
+ /**
433
+ * 同步读文件:**不存在**返回 `null`,其余 IO 失败**抛异常**。
434
+ *
435
+ * 与 `readFileIfExistsSync` 只差这一条,而它正是注入通道要的:那个函数把「不存在」与
436
+ * 「读失败」一起吞成 `null`,调用方据此把后者读成「这一轮没内容」,进而在该域此前发布过时
437
+ * 发出一条假的「已清空 —— 此前注入的同类内容不再有效」(2026-09-30 审查 F7)。
438
+ * 凡是"读失败必须出声"的读路径都用这个;界面投影那类"失败退化成空"的路径仍用上面那个。
439
+ */
440
+ export function readFileOrThrowSync(path) {
441
+ try {
442
+ return readFileSync(path, 'utf8');
443
+ }
444
+ catch (e) {
445
+ if (isAbsentError(e))
446
+ return null;
447
+ throw e;
448
+ }
449
+ }
@@ -53,7 +53,7 @@ export { parseModeState } from './index-io.js';
53
53
  // 投影 / 渲染纯函数(2026-09-19 抽到 ./projection.ts):索引 → 界面与注入看到的那份文本。
54
54
  import { enabledSceneOf, normalizeActive, resolveActiveScenes, sceneLabel, sceneOf, sceneOrderOf, } from './projection.js';
55
55
  // 索引 IO 组与发现/快照组(2026-09-19 抽出)。
56
- import { writeFileAtomically, isIndexQuarantined, readIndex, readIndexSync, writeIndex } from './index-io.js';
56
+ import { writeFileAtomically, isIndexQuarantined, readIndex, readIndexSync, writeIndex, isAbsentError } from './index-io.js';
57
57
  import { deriveFromDoc, projectRule, relocateLegacyLayout, buildSnapshot, probeSceneFilesSync, renderSceneCatalog, renderSceneMemory } from './snapshot.js';
58
58
  // ── 目录常量与失败约定 ─────────────────────────────────────────────────────
59
59
  // 其余跨文件共用的常量与无依赖小工具(message / isValidGroupSegment / isValidGroupPath /
@@ -162,49 +162,115 @@ export function createMemoriesService(ctx, deps) {
162
162
  // 见文件顶部「两相扫描」注释里为什么不选 fs.watch 方案。
163
163
  let sceneCache = null;
164
164
  let sceneCatalogCache = null;
165
- function sceneMemory() {
165
+ // ── 注入侧取数:读失败必须出声(2026-09-30 审查 F7)────────────────────────
166
+ //
167
+ // 注入通道把 `text()` 返回的空串读成"这一轮没有内容";若该域此前发布过,它会据此发一条
168
+ // 「已清空 —— 此前注入的同类内容不再有效」。但空串有两个来源:**真的没内容**与**读失败**。
169
+ // 后者发「已清空」就是对模型说谎(第一性目的是"让模型看见现在是什么"),而且这条谎在界面上
170
+ // 被报成正常的 `empty`(琥珀只留给"该投却没投"),唯一痕迹是模型行为异常。
171
+ //
172
+ // 所以下面 `require*` 那几个出口在 **非 ENOENT** 的 IO 失败上抛异常 —— 注入通道据此报
173
+ // `error`、跳过该域、**不发「已清空」**,上一轮内容原样留着,恢复可读后自动重发。
174
+ // `ENOENT`(文件 / 目录不存在 = 全新用户或刚删掉)仍按正常空处理,不误报。
175
+ //
176
+ // 判据只有一份(`isAbsentError` 与 `readIndexSync` 的不可信标记),差别只在**处置**:
177
+ // 界面投影(`rulesList` 的内存占用条、`scenePrompt`)走下面**不抛**的包装 —— 一次读失败
178
+ // 不该把整个记忆列表页弄崩,那种处置由注入实况的琥珀来承担。
179
+ //
180
+ // 边界(写清楚免得后来者以为漏改):技能 / MCP / 子智能体三个域**结构上不会**出现这种假
181
+ // 「已清空」—— 它们是 SWR 缓存,计算失败时保留上一次的值,因此只可能"没能更新",
182
+ // 不可能从"有内容"变成"空串";`''` 只在真的渲染不出东西时出现。
183
+ const emptySceneMemory = () => ({ text: '', bytes: 0, truncated: false, maxBytes, scenes: [], items: [], dropped: [] });
184
+ /** 索引读不到 / 解析不了 → 注入侧必须报「取数失败」,而不是把"全部未启用"当成"用户清空了记忆"。 */
185
+ const assertIndexReadable = () => {
186
+ if (isIndexQuarantined(stateDir))
187
+ throw new Error('记忆索引当前读不到,无法确定哪些记忆处于启用状态');
188
+ };
189
+ /** 记忆段:注入侧口径(索引不可信或目录读不动 → 抛)。 */
190
+ function requireSceneMemory() {
166
191
  const index = readIndexSync(stateDir);
167
- const probe = probeSceneFilesSync(memoriesRoot, index, isIndexQuarantined(stateDir));
192
+ assertIndexReadable(); // 抛在这里 ⇒ 下面必然处于「索引可信」状态,无需再传 forceDisabled
193
+ const probe = probeSceneFilesSync(memoriesRoot, index, false);
168
194
  if (sceneCache && sceneCache.signature === probe.signature)
169
195
  return sceneCache.value;
170
196
  const value = renderSceneMemory(probe, index, maxBytes);
171
197
  sceneCache = { signature: probe.signature, value };
172
198
  return value;
173
199
  }
200
+ /**
201
+ * 记忆段(界面 / CRUD 口径):读失败**不抛**,退回上一轮算出来的那份(没有才退空投影)。
202
+ *
203
+ * 为什么退上一轮而不是空投影:界面上的内存占用条突然归零会让用户以为记忆没了;上一轮那个数
204
+ * 是**已知的最后一份真相**,与注入侧"保留上一轮内容"同一取向。故障本身由注入实况报 `error`。
205
+ */
206
+ function sceneMemory() {
207
+ try {
208
+ return requireSceneMemory();
209
+ }
210
+ catch {
211
+ return sceneCache?.value ?? emptySceneMemory();
212
+ }
213
+ }
214
+ /** 场景段:只有注入侧一个消费者,直接按注入口径(读失败抛)。 */
215
+ function sceneCatalog() {
216
+ const index = readIndexSync(stateDir);
217
+ assertIndexReadable();
218
+ const probe = probeSceneFilesSync(memoriesRoot, index, false);
219
+ if (sceneCatalogCache && sceneCatalogCache.signature === probe.signature)
220
+ return sceneCatalogCache.value;
221
+ const value = renderSceneCatalog(probe, index, SCENE_CATALOG_MAX_BYTES);
222
+ sceneCatalogCache = { signature: probe.signature, value };
223
+ return value;
224
+ }
174
225
  // ── 场景提示词(绑定预设正文;读盘、只读;写文件由 scene-prompt-sync.ts 负责)──
175
226
  //
176
227
  // 语义(用户裁定 2026-09-15):
177
228
  // - 场景可绑定**一个**提示词预设(`scenes[].prompt` → `prompts/<id>/AGENTS.md`);
178
229
  // - 除保留场景 `global` 外**同时只能启用一个场景**,所以同一时刻至多一份提示词在场;
179
230
  // - 没有启用其它场景时才轮到 `global` 自己的绑定(它恒常生效,覆盖"永远在场的提示词");
180
- // - 预设文件不存在 / 未绑定 → 返回 `''`(renderPrompt 会删掉空段,不产生空标题)。
231
+ // - 预设文件不存在 / 未绑定 → 返回 `''`(renderPrompt 会删掉空段,不产生空标题);
232
+ // **文件在但读不动** → 抛异常(见上面「注入侧取数」那一节,F7)。
181
233
  //
182
234
  // 为什么不用 fs.watch 也不用每请求读盘:段契约要求**同步返回**且逐字节稳定,
183
235
  // 因此按 `mtimeMs + size` 做一次 stat 缓存 —— 命中时零读盘,外部编辑器改了预设
184
236
  // 也会在下一个请求自动刷新(与记忆段的"两相扫描"同一哲学)。
185
237
  const promptFileCache = new Map();
186
- /** 读一个预设正文(同步、带 stat 指纹缓存);不存在返回 `''`。 */
238
+ /**
239
+ * 读一个预设正文(同步、带 stat 指纹缓存);**不存在**返回 `''`,读失败**抛异常**。
240
+ *
241
+ * 抛的理由见上面「注入侧取数:读失败必须出声」那一节:调用方(`resolveScenePreset` →
242
+ * `resolvePrompt` → 提示词域)必须能把"预设读不动"与"预设为空/已删"分开,否则前者会
243
+ * 变成一条假的「已清空」。界面侧由 `scenePrompt()` 自己接住(它本来就把失败当"没绑")。
244
+ */
187
245
  function readPresetTextSync(id) {
188
246
  const file = join(stateDir, PRESETS_DIR, id, 'AGENTS.md');
189
247
  let key;
248
+ let isFile = false;
190
249
  try {
191
250
  const st = statSync(file);
192
- if (!st.isFile())
193
- return '';
251
+ isFile = st.isFile();
194
252
  key = `${st.mtimeMs}:${st.size}`;
195
253
  }
196
- catch {
254
+ catch (e) {
197
255
  promptFileCache.delete(id);
256
+ if (!isAbsentError(e))
257
+ throw e;
198
258
  return '';
199
259
  }
260
+ // 路径上**有东西但不是普通文件**(目录 / 设备)→ 也是故障,不能当成"预设不存在":
261
+ // 后者会让提示词域静默退回基线、或在该域此前发布过时发一条假的「已清空」(F7)。
262
+ if (!isFile)
263
+ throw new Error(`预设正文不是一个文件:${file}`);
200
264
  const cached = promptFileCache.get(id);
201
265
  if (cached && cached.key === key)
202
266
  return cached.text;
203
- let text = '';
267
+ let text;
204
268
  try {
205
269
  text = readFileSync(file, 'utf8');
206
270
  }
207
- catch {
271
+ catch (e) {
272
+ if (!isAbsentError(e))
273
+ throw e;
208
274
  return '';
209
275
  }
210
276
  promptFileCache.set(id, { key, text });
@@ -222,28 +288,42 @@ export function createMemoriesService(ctx, deps) {
222
288
  /**
223
289
  * 读当前 `~/.dsh/AGENTS.md`(同步、带 stat 指纹缓存)。
224
290
  * 用途只有一个:**去重**——绑定的预设与文件内容逐字节相同时不再注入第二遍正文。
291
+ *
292
+ * 读失败**抛异常**(同 `readPresetTextSync`):注入侧必须能把"基线文件读不动"与"基线为空"
293
+ * 分开 —— 后者是正常的(用户没写过 AGENTS.md),前者会让提示词域发出一句假的「已清空」。
225
294
  */
226
295
  let globalAgentsCache = null;
227
296
  function readGlobalAgentsMdSync() {
228
- const file = join(resolveDshHome(), 'AGENTS.md');
297
+ // 优先用宿主实际读的那一份(`deps.globalAgentsMdPath`,与写侧同源);拿不到时退回
298
+ // `resolveDshHome()`(`$DSH_HOME` ‖ `~/.dsh`)—— 那是**另一条独立来源**,两边不同时
299
+ // 会取到另一个文件(2026-09-30 审查 P2-19:去重读错文件 → 正文注入两遍)。
300
+ const file = deps.globalAgentsMdPath?.() || join(resolveDshHome(), 'AGENTS.md');
229
301
  let key;
302
+ let isFile = false;
230
303
  try {
231
304
  const st = statSync(file);
232
- if (!st.isFile())
233
- return '';
305
+ isFile = st.isFile();
234
306
  key = `${st.mtimeMs}:${st.size}`;
235
307
  }
236
- catch {
308
+ catch (e) {
237
309
  globalAgentsCache = null;
310
+ if (!isAbsentError(e))
311
+ throw e;
238
312
  return '';
239
313
  }
314
+ // 有东西但不是普通文件 → 故障(同 `readPresetTextSync`):当成"基线为空"会让提示词域
315
+ // 在该域此前发布过时发一条假的「已清空」。
316
+ if (!isFile)
317
+ throw new Error(`AGENTS.md 不是一个文件:${file}`);
240
318
  if (globalAgentsCache && globalAgentsCache.key === key)
241
319
  return globalAgentsCache.text;
242
- let text = '';
320
+ let text;
243
321
  try {
244
322
  text = readFileSync(file, 'utf8');
245
323
  }
246
- catch {
324
+ catch (e) {
325
+ if (!isAbsentError(e))
326
+ throw e;
247
327
  return '';
248
328
  }
249
329
  globalAgentsCache = { key, text };
@@ -251,10 +331,13 @@ export function createMemoriesService(ctx, deps) {
251
331
  }
252
332
  /**
253
333
  * 当前生效的场景提示词:启用场景的绑定优先,其次 `global` 的绑定;都没有 → `null`。
254
- * 「绑了但预设为空/已删」按没绑处理(不注入空段),继续找下一个。
334
+ * 「绑了但预设为空/已删」按没绑处理(不注入空段),继续找下一个;
335
+ * 「绑了但预设**读不动**」抛异常 —— 注入侧要的是"故障"而不是"没绑"(见上面 F7 那一节)。
255
336
  */
256
337
  function resolveScenePreset() {
257
338
  const index = readIndexSync(stateDir);
339
+ // 索引读不到 → 连"哪个场景绑了哪个预设"都无从得知,这必须报故障而不是报"没有提示词"(F7)。
340
+ assertIndexReadable();
258
341
  const names = Object.keys(index.scenes || {});
259
342
  const { active } = resolveActiveScenes(index, names);
260
343
  // 单选:取唯一一个非保留启用场景(resolveActiveScenes 已保证 ≤1,这里仍做确定性排序兜底)。
@@ -283,16 +366,26 @@ export function createMemoriesService(ctx, deps) {
283
366
  * 当前生效的场景提示词(只读投影,界面用):绑定内容与 `~/.dsh/AGENTS.md` 相同时标记
284
367
  * `duplicate` 且返回空段 —— 那份正文已经在基线里了,界面据此显示「生效中」。
285
368
  * (注入侧不看这个字段:预设挂不到 AGENTS.md 通道时照样要注入,见 `promptText`。)
369
+ *
370
+ * **界面口径:读失败不抛**(接住 `resolveScenePreset` / `readGlobalAgentsMdSync` 的异常)。
371
+ * 退回「没有生效的场景提示词」正是这次读失败下界面此前给出的答案(那两个读函数曾把失败吞成
372
+ * 空串),所以这里没有行为变化;**故障由注入侧如实报出**(提示词域报 `error` 并发琥珀)。
373
+ * 反过来,让这里抛会把 `rules-list` 整个读 op 带崩 —— 一次读不到预设不该让记忆页打不开。
286
374
  */
287
375
  function scenePrompt() {
288
- const found = resolveScenePreset();
289
- if (!found)
376
+ try {
377
+ const found = resolveScenePreset();
378
+ if (!found)
379
+ return { scene: null, presetId: null, text: '', missing: false };
380
+ const globalText = readGlobalAgentsMdSync().trim();
381
+ if (globalText !== '' && found.text.trim() === globalText) {
382
+ return { scene: found.scene, presetId: found.presetId, text: '', missing: false, duplicate: true };
383
+ }
384
+ return { scene: found.scene, presetId: found.presetId, text: found.text, missing: false };
385
+ }
386
+ catch {
290
387
  return { scene: null, presetId: null, text: '', missing: false };
291
- const globalText = readGlobalAgentsMdSync().trim();
292
- if (globalText !== '' && found.text.trim() === globalText) {
293
- return { scene: found.scene, presetId: found.presetId, text: '', missing: false, duplicate: true };
294
388
  }
295
- return { scene: found.scene, presetId: found.presetId, text: found.text, missing: false };
296
389
  }
297
390
  // ── 写操作串行队列(避免并发覆盖同一索引/文件)──
298
391
  let mutationQueue = Promise.resolve();
@@ -432,19 +525,46 @@ export function createMemoriesService(ctx, deps) {
432
525
  invalidateSnapshot();
433
526
  sceneCache = null;
434
527
  };
435
- /** 场景档案引擎专用:读-改-写 mode/archives/active 切片(写队列内,保持与其余索引写串行)。 */
436
- const patchIndex = (patch) => enqueueMutation(async () => {
528
+ /**
529
+ * 场景档案引擎专用:读-改-写 mode/archives/active 切片(写队列内,保持与其余索引写串行)。
530
+ *
531
+ * `fields` 是**意图声明**(2026-09-30 审查 P2-3):不传 = 老行为(按 `patch` 里出现的字段
532
+ * 全写)。传了就只写列出的那几项 —— 引擎的每条路径各自动的是哪一片是明确的("只落 mode"
533
+ * 与"落 mode + active"是两件事),而"我没打算改的字段"必须留给**队列内刚读出来**的值,
534
+ * 否则一次陈旧的整片回写会把窗口期里别人改的东西抹掉。
535
+ */
536
+ const patchIndex = (patch, fields) => enqueueMutation(async () => {
437
537
  const index = await readIndex(stateDir);
438
- if (patch.mode !== undefined)
538
+ const want = (f) => (fields ? fields.indexOf(f) >= 0 : patch[f] !== undefined);
539
+ if (want('mode') && patch.mode !== undefined)
439
540
  index.mode = patch.mode;
440
- if (patch.archives !== undefined)
541
+ if (want('archives') && patch.archives !== undefined)
441
542
  index.archives = patch.archives;
442
543
  // active 复用「记忆启用集」语义(null = 全部启用);进入模式时引擎收窄为 [S]。
443
- if (patch.active !== undefined)
544
+ if (want('active') && patch.active !== undefined)
444
545
  index.active = patch.active;
445
546
  await writeIndex(stateDir, index);
446
547
  await refresh();
447
548
  });
549
+ /**
550
+ * 场景档案引擎专用:只写**一个场景**的档案(队列内按**逐场景增量**落盘)。
551
+ *
552
+ * 为什么不能走 `patchIndex({ archives })`(2026-09-30 审查 P2-3):那个接口拿到的是
553
+ * 一份**整表**,而整表是在队外读出来的 —— 引擎保存场景 A 的档案期间,另一个写者(HTTP op /
554
+ * 模型工具 / 另一条引擎路径)保存了场景 B 的档案,前者落盘时会把 B 的整份覆盖掉。
555
+ * 逐场景增量只动自己那一条,其余键保留队列内读到的值。
556
+ */
557
+ const saveArchive = (scene, archive) => enqueueMutation(async () => {
558
+ const index = await readIndex(stateDir);
559
+ const archives = index.archives ?? {};
560
+ if (archive === null)
561
+ delete archives[scene];
562
+ else
563
+ archives[scene] = archive;
564
+ index.archives = archives;
565
+ await writeIndex(stateDir, index);
566
+ await refresh();
567
+ });
448
568
  /** 场景档案引擎专用:读 mode/archives/active 切片(容忍缺失,缺省 = 无档案 + 自由模式 + 全部启用)。 */
449
569
  const readArchiveSlice = async () => {
450
570
  const index = await readIndex(stateDir);
@@ -536,23 +656,17 @@ export function createMemoriesService(ctx, deps) {
536
656
  // 注入通道(src/context-inject.ts):每个 step 前把文本并进一条注入消息,任何预设都到得了。
537
657
  // 这里只留**同步取文本**的两个出口,注册/生命周期由 index.ts 的注入器统一管。
538
658
  //
539
- // - `memoryText`:场景记忆段(记忆正文;`''` = 没有可注入的内容)。
659
+ // - `memoryText`:场景记忆段(记忆正文;`''` = 没有可注入的内容)。走**注入侧口径**
660
+ // (`requireSceneMemory`):读失败抛异常,由注入通道报 `error` 并保留上一轮内容(F7)。
661
+ // 界面投影用的是 `sceneMemory()`(不抛、退回上一轮),两者共用同一个 `requireSceneMemory`。
540
662
  // - `promptText`:全局提示词正文 = 场景期间用场景提示词、平时用 AGENTS.md 正文。
541
663
  // **不判"与文件重复"** —— 预设挂了官方 agent-instructions 时那份正文已经由文件送达
542
664
  // (注入侧会跳过整个域),没挂时(极简)才需要注入兜底;判定在注入器的 `selectInjections` 里。
543
- const memoryText = () => sceneMemory().text;
665
+ const memoryText = () => requireSceneMemory().text;
544
666
  // 场景段。与记忆段是**两次探测**:`probeSceneFilesSync` 没有内部缓存(signature 本身就是
545
667
  // 扫描的结果,缓存不了),代价是有界的一次目录树 stat(**不读正文**),换来两段各自渲染、
546
668
  // 各自去重 —— 只改场景描述时不会连带重发整段记忆正文。
547
- const sceneCatalog = () => {
548
- const index = readIndexSync(stateDir);
549
- const probe = probeSceneFilesSync(memoriesRoot, index, isIndexQuarantined(stateDir));
550
- if (sceneCatalogCache && sceneCatalogCache.signature === probe.signature)
551
- return sceneCatalogCache.value;
552
- const value = renderSceneCatalog(probe, index, SCENE_CATALOG_MAX_BYTES);
553
- sceneCatalogCache = { signature: probe.signature, value };
554
- return value;
555
- };
669
+ // 渲染本身在 `sceneCatalog()`(见上面「注入侧取数」那一节,读失败抛)。
556
670
  const sceneCatalogText = () => sceneCatalog().text;
557
671
  /**
558
672
  * `~/.dsh/AGENTS.md` 正文的上限,与官方那一行的 `maxBytes` 同量级(64 KiB):超大文件按字节
@@ -591,10 +705,18 @@ export function createMemoriesService(ctx, deps) {
591
705
  const scene = resolveScenePreset();
592
706
  if (scene && scene.text.trim() !== '') {
593
707
  const path = `${dshHomeDisplay()}/${HUB_DIR}/${PRESETS_DIR}/${scene.presetId}/AGENTS.md`;
708
+ // 与下面的 AGENTS.md 分支**同一套截断**(2026-09-30 审查 P2-1):场景绑定的预设正文
709
+ // 此前**没有任何上限**,一份超大预设会把 system-reminder 挤爆(AGENTS.md 那条路一直有
710
+ // 64 KiB 的闸)。两条出口在用户眼里就是同一件事("全局提示词"),预算必须一致 ——
711
+ // 否则"把预设绑到场景上"就成了绕过预算的口子。`digest` 仍算**原文**(与另一分支同口径)。
712
+ const body = Buffer.byteLength(scene.text, 'utf8') <= AGENTS_MD_MAX_BYTES
713
+ ? scene.text
714
+ : Buffer.from(scene.text, 'utf8').subarray(0, AGENTS_MD_MAX_BYTES).toString('utf8') +
715
+ `\n\n(…预设正文超过 ${Math.floor(AGENTS_MD_MAX_BYTES / 1024)} KiB,已截断。)`;
594
716
  return {
595
717
  path,
596
718
  digest: createHash('sha1').update(scene.text).digest('hex'),
597
- text: `来源:${path}\n\n${scene.text}`,
719
+ text: `来源:${path}\n\n${body}`,
598
720
  };
599
721
  }
600
722
  const raw = readGlobalAgentsMdSync();
@@ -695,6 +817,7 @@ export function createMemoriesService(ctx, deps) {
695
817
  promptFiles,
696
818
  refresh,
697
819
  patchIndex,
820
+ saveArchive,
698
821
  readArchiveSlice,
699
822
  sceneLocks: async () => {
700
823
  const index = await readIndex(stateDir);
@@ -13,7 +13,7 @@ import { dirname, join, resolve } from 'node:path';
13
13
  import { parseSkillDoc, resolveDshHome, unquote } from '../skills/core.js';
14
14
  import { MAX_SOURCE_DEPTH, MAX_DIRECTORIES, MAX_ENTRIES, MAX_DESCRIPTION_LENGTH, DEFAULT_ORDER, DEFAULT_GROUP_ORDER, GLOBAL_SCENE, TRUNCATION_MARKER, DROPPED_HEADING, SCENE_MEMORY_NOTE, LEGACY_BUNDLE_DOC, bundleDocName, SEGMENT_RULE_HINT, SHARED_GROUP, byteLen, message, isValidGroupSegment } from './constants.js';
15
15
  import { ensureSceneRecords, resolveActiveScenes, signatureOfIndex, sceneLabel, sceneHeading, sceneLine, compareSceneBuckets, memoryBlock } from './projection.js';
16
- import { isIndexQuarantined, readIndex, writeIndex, pathExists, readFileIfExistsSync } from './index-io.js';
16
+ import { isIndexQuarantined, readIndex, writeIndex, pathExists, readFileOrThrowSync, isAbsentError } from './index-io.js';
17
17
  export const identity = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
18
18
  // ── 文件工具 ───────────────────────────────────────────────────────────────
19
19
  /**
@@ -365,13 +365,23 @@ export async function buildSnapshot(memoriesRoot, stateDir) {
365
365
  .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
366
366
  return { rules, groups, scenes: discovery.scenes, warnings, truncated: discovery.truncated, entries, bodies, index };
367
367
  }
368
+ /**
369
+ * `mtime:size` 指纹;**不存在**返回 `'missing'`,其余 IO 失败**抛异常**。
370
+ *
371
+ * 为什么抛(2026-09-30 审查 F7):探测阶段这个值同时用来判「bundle 的正文文件在不在」。
372
+ * 把 EACCES/EBUSY 一并吞成 `'missing'`,等于把"读不动"读成"没有这条记忆" —— 段里少一条,
373
+ * 而注入通道只看到"内容变了/变空了",最坏时发出一条假的「已清空」。抛出去之后,注入侧
374
+ * 报 `error` 并**保留上一轮内容**,界面侧退回上一轮投影(见 service.ts 的同名注释)。
375
+ */
368
376
  export function fileStampSync(path) {
369
377
  try {
370
378
  const st = statSync(path);
371
379
  return `${st.mtimeMs}:${st.size}`;
372
380
  }
373
- catch {
374
- return 'missing';
381
+ catch (e) {
382
+ if (isAbsentError(e))
383
+ return 'missing';
384
+ throw e;
375
385
  }
376
386
  }
377
387
  /**
@@ -381,6 +391,11 @@ export function fileStampSync(path) {
381
391
  * 根层的裸 .md **不再是记忆**(旧的「全局」桶已迁入 `global/`,见 relocateLegacyLayout),
382
392
  * 因此这里不再扫描根层文件——把文件丢在 memories/ 根下不会静默生效,也不会被投影。
383
393
  * `signature` 不变 ⇒ 上次渲染结果可原样复用(零正文 IO、零重排)。
394
+ *
395
+ * **读失败抛异常、不存在才算空**(2026-09-30 审查 F7):根目录与子目录的 `readdirSync` 只在
396
+ * `ENOENT`(目录不存在 = 全新用户 / 刚删掉)时退化成空段;其余 IO 错误(Windows 上杀软 /
397
+ * 索引器 / 备份软件占住会报 EACCES/EBUSY)一律抛给调用方 —— 把"读不动"当成"没有记忆"会让
398
+ * 注入通道发出假的「已清空」。
384
399
  */
385
400
  export function probeSceneFilesSync(memoriesRoot, index,
386
401
  // 索引已损坏时置真:索引是开关的唯一真相源,它没了就无法知道用户开过哪些记忆,
@@ -395,8 +410,11 @@ forceDisabled = false) {
395
410
  try {
396
411
  rootEntries = readdirSync(memoriesRoot, { withFileTypes: true });
397
412
  }
398
- catch {
399
- // 目录不存在/不可读 → 空段(不报错)。签名含固定前缀,便于与"空树"区分。
413
+ catch (e) {
414
+ // 目录不存在(全新用户 / 刚删掉)→ 空段。签名含固定前缀,便于与"空树"区分。
415
+ // 其余 IO 错误必须抛:见函数头注释(F7)。
416
+ if (!isAbsentError(e))
417
+ throw e;
400
418
  return { refs: [], scenes: [], truncated: false, signature: `∅|${signatureOfIndex(index)}` };
401
419
  }
402
420
  const add = (ref) => {
@@ -415,8 +433,11 @@ forceDisabled = false) {
415
433
  try {
416
434
  entries = readdirSync(dir, { withFileTypes: true });
417
435
  }
418
- catch {
419
- return;
436
+ catch (e) {
437
+ // 子目录在遍历中途被删 → 跳过(正常竞态);其余 IO 失败照函数头的口径抛出(F7)。
438
+ if (isAbsentError(e))
439
+ return;
440
+ throw e;
420
441
  }
421
442
  budget.dirs++;
422
443
  entries.sort((a, b) => a.name.localeCompare(b.name));
@@ -504,9 +525,11 @@ forceDisabled = false) {
504
525
  export function renderSceneMemory(probe, index, maxBytes) {
505
526
  const files = [];
506
527
  for (const ref of probe.refs) {
507
- const text = readFileIfExistsSync(ref.path);
528
+ const text = readFileOrThrowSync(ref.path);
529
+ // 探测与读取之间被删 → 跳过(下次指纹变化会再校正)。读不动**不**走这条:那会静默
530
+ // 少一条记忆,最坏时让注入通道发假的「已清空」(F7)—— 抛出去由调用方决定处置。
508
531
  if (text === null)
509
- continue; // 探测与读取之间被删:跳过(下次指纹变化会再校正)
532
+ continue;
510
533
  const doc = parseSkillDoc(text);
511
534
  const derived = deriveFromDoc({ id: ref.id, group: ref.scene, name: ref.name, kind: ref.kind, docPath: ref.path, entryPath: ref.path }, doc);
512
535
  files.push({