dsh-plugin-tool-management 0.16.6 → 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 +76 -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 +43 -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 +74 -4
  50. package/lib/skills/service.js +9 -4
  51. package/lib/subagents/service.js +6 -1
  52. package/package.json +1 -1
@@ -12,6 +12,8 @@
12
12
  // 而 service 不再自报 → 也红。两边谁改了都必须动另一边。
13
13
  // write 需要访问令牌。判据是"会不会改**宿主或外部系统**的状态" —— 注意这**不是**
14
14
  // "会不会写盘":见下方 readonly 那一段。
15
+ // writeWhen 仅对"两态 op"(一个名字兼读与写)生效:**这次调用**算不算写。判据不成立
16
+ // = 纯读,免令牌也不记流水。没带这一项的 op,标了 write 就一律按写处理。
15
17
  // sensitive 会泄露明文凭据 / 完整配置。**必须**带对的令牌,没配令牌就一律拒绝。
16
18
  // frozen 任一场景 locked=true 时整体拒绝(五个管理域冻结)。
17
19
  // frozenScope frozen 的三种口径,默认 'all':
@@ -43,7 +45,7 @@ export const OP_REGISTRY = Object.freeze({
43
45
  'mcpm-import': { write: true, frozen: true },
44
46
  'mcpm-compact': { write: true, frozen: true },
45
47
  'mcpm-note': { write: true, frozen: true },
46
- 'mcpm-settings': { write: true, frozen: true },
48
+ 'mcpm-settings': { write: true, frozen: true, writeWhen: (a) => a.set === true },
47
49
  'mcpm-set-enabled': { write: true, frozen: true, syncsArchive: true },
48
50
  'mcpm-set-all': { write: true, frozen: true, syncsArchive: true },
49
51
  'mcpm-tool-enabled': { write: true, frozen: true, syncsArchive: true },
@@ -56,7 +58,10 @@ export const OP_REGISTRY = Object.freeze({
56
58
  'mcpm-tools-refresh': { write: true },
57
59
  'mcpm-tools': { readonly: true },
58
60
  // 配置体检:只读补丁文件 + 一次 PATH 查询(`where`/`which`,不执行配置里的命令)。
59
- // 返回的是检查项 id,不回任何字段值 —— 与 `mcpm-list` 同档,不带令牌。
61
+ // 回的是检查项 id + 少量**打码后**的字段值(URL 查询串走 `maskUrlQuery`,与 `mcpm-list`
62
+ // 同口径;`command` 明文,理由同 `mcpm-list`:命令名不是凭据,`args` 两边都不回)。
63
+ // 与 `mcpm-list` 同档,不带令牌 —— 0.16.6 之前这里回的是 URL 原文,等于让免令牌的请求
64
+ // 拿到 `?api_key=…`,绕过了 `mcpm-reveal` 那道防线(审查 P0-5,0.17.0 修)。
60
65
  'mcpm-inspect': { readonly: true },
61
66
  // ── 技能域(22,含内联的 skill-open)───────────────────────────────────────
62
67
  'skill-state': { annotatesLock: true },
@@ -187,14 +192,19 @@ export const OP_REGISTRY = Object.freeze({
187
192
  'injection-live': { readonly: true },
188
193
  'backups-list': { readonly: true },
189
194
  // 注入设置(各域开关 / 压制型预设口径)写侧车。它是"配置"不是"宿主状态",但改的是
190
- // 投递语义,按写操作门禁。
191
- 'inject-settings': { write: true },
195
+ // 投递语义,按写操作门禁。读侧(不带 `set:true`)只回当前设置,是纯读。
196
+ 'inject-settings': { write: true, writeWhen: (a) => a.set === true },
192
197
  // 模型工具表(哪些工具根本不发给模型)也写侧车,改的是每轮请求的内容 —— 同上按写门禁。
193
- 'tool-table': { write: true },
198
+ // 三条写触发:`set` 换名单;`presetSave` / `presetDelete` 只动 `presets`、**不动 `hidden`**
199
+ // —— 后两条不经过 `set`,只认 `set` 的判据会把它们当成读放行(审查 F2)。
200
+ 'tool-table': {
201
+ write: true,
202
+ writeWhen: (a) => a.set === true || a.presetSave !== undefined || a.presetDelete !== undefined,
203
+ },
194
204
  // 场景页的界面设置(进入场景前要不要弹「会改什么」的预览卡):写侧车,按写门禁。
195
205
  // **刻意不冻结**:它只是界面提示,锁着场景的人在场景页照样该能关掉提醒 —— 冻结的是五个
196
206
  // 管理域的改动,不是这个页面的显示偏好(判据见文件头 write/frozen 两段的边界)。
197
- 'scene-settings': { write: true },
207
+ 'scene-settings': { write: true, writeWhen: (a) => a.set === true },
198
208
  // 清理 patch 备份:删磁盘文件(备份里含明文凭据副本),按写操作门禁。
199
209
  'backups-clean': { write: true },
200
210
  // ── 场景候选源(4)与内联(1)──────────────────────────────────────────────
@@ -230,6 +240,27 @@ export function frozenOps(scope) {
230
240
  }
231
241
  return out;
232
242
  }
243
+ /**
244
+ * 这次调用是不是「读侧」(免令牌、不记流水)。
245
+ *
246
+ * 判据只有一个来源:登记表的 `writeWhen`。**没带 `writeWhen` 的 op 一律返回 false**
247
+ * —— 即"标了 write 就是写",不知道就别放行。
248
+ *
249
+ * 判据抛错也返回 false(按写处理):一个会抛的谓词说明这次调用的形状没人设计过,
250
+ * 那种时候**要求令牌**才是安全的方向 —— 反过来会让"某个畸形 args 恰好免门禁"成为漏洞。
251
+ */
252
+ export function isReadCall(op, args) {
253
+ const cls = OP_REGISTRY[op];
254
+ if (!cls || cls.writeWhen === undefined)
255
+ return false;
256
+ const bag = (args && typeof args === 'object' ? args : {});
257
+ try {
258
+ return cls.writeWhen(bag) !== true;
259
+ }
260
+ catch {
261
+ return false;
262
+ }
263
+ }
233
264
  /**
234
265
  * 对账:真实 op 表里**没在登记表出现**的键。
235
266
  *
@@ -256,10 +287,20 @@ export function serviceWriteMismatch(reported) {
256
287
  extra: [...declared].filter((op) => !actual.has(op)).sort(),
257
288
  };
258
289
  }
259
- /** 自相矛盾的条目:既声明只读又声明要令牌 / 泄露明文。(登记时手滑的兜底。) */
290
+ /** 自相矛盾的条目:既声明只读又声明要令牌 / 泄露明文 / 带写判据。(登记时手滑的兜底。) */
260
291
  export function contradictoryEntries() {
261
292
  return Object.entries(OP_REGISTRY)
262
- .filter(([, cls]) => cls.readonly === true && (cls.write === true || cls.sensitive === true))
293
+ .filter(([, cls]) => cls.readonly === true && (cls.write === true || cls.sensitive === true || cls.writeWhen !== undefined))
294
+ .map(([op]) => op)
295
+ .sort();
296
+ }
297
+ /**
298
+ * 标了 `writeWhen` 却没标 `write` 的条目:写判据写了,可这条 op 根本不进写门禁
299
+ * —— 那份判据永远不会被问一次,等于没写(静默失效,正是 F2 的形状)。
300
+ */
301
+ export function writeWhenUnwritten() {
302
+ return Object.entries(OP_REGISTRY)
303
+ .filter(([, cls]) => cls.writeWhen !== undefined && cls.write !== true)
263
304
  .map(([op]) => op)
264
305
  .sort();
265
306
  }
@@ -278,6 +319,7 @@ export function auditOpRegistry(opNames, serviceWriteOps) {
278
319
  serviceWriteMissing: mismatch.missing,
279
320
  serviceWriteExtra: mismatch.extra,
280
321
  contradictory: contradictoryEntries(),
322
+ writeWhenUnwritten: writeWhenUnwritten(),
281
323
  total: names.length,
282
324
  };
283
325
  }
@@ -289,5 +331,6 @@ export function auditProblems(audit) {
289
331
  ...audit.serviceWriteMissing.map((op) => op + '(service 自报写、登记表未标)'),
290
332
  ...audit.serviceWriteExtra.map((op) => op + '(登记表标了写、service 未自报)'),
291
333
  ...audit.contradictory.map((op) => op + '(只读与写自相矛盾)'),
334
+ ...audit.writeWhenUnwritten.map((op) => op + '(有写判据却没标写)'),
292
335
  ];
293
336
  }
package/lib/ops/compat.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // 守卫仍作用在组装后的整张表上,见 index.ts)。
9
9
  import { EXPECTED_MIN_HOST_VERSION, EXPECTED_PEER_RANGE, VERIFIED_HOST_VERSION, isSourceInstall, routeFor, summarize, } from '../compat/probe.js';
10
10
  import { INJECT_DOMAIN_KEYS } from '../context-inject.js';
11
+ import { MCP_TOOL_PREFIX } from '../host-names.js';
11
12
  import { assessPresetReach } from '../compat/preset-reach.js';
12
13
  import { runtimeNotes } from '../compat/runtime-notes.js';
13
14
  import { pluginLog } from '../skills/service.js';
@@ -99,7 +100,7 @@ export function buildCompatOps(deps) {
99
100
  let mcpTools;
100
101
  try {
101
102
  const schemas = (await deps.tools.schemas());
102
- mcpTools = schemas.filter((s) => String(s && s.name || '').startsWith('mcp__')).length;
103
+ mcpTools = schemas.filter((s) => String(s && s.name || '').startsWith(MCP_TOOL_PREFIX)).length;
103
104
  }
104
105
  catch {
105
106
  mcpTools = undefined;
@@ -175,7 +176,10 @@ export function buildCompatOps(deps) {
175
176
  ['mcp-tool-visibility', '工具表可见性(停用 / 关掉的工具不下发)', 'mcp'],
176
177
  ];
177
178
  for (const [noteId, label, tab] of assemblyRows) {
178
- const note = notes.get(noteId);
179
+ // 注入通道有**两类**上报:装配失败(`context-injection`)与运行期失败
180
+ // (`context-injection-runtime`,2026-09-30 P1-8:某域取数抛异常 / 整段注入抛异常)。
181
+ // 合并到同一行 —— 用户关心的是"这条通道现在好不好",不是"哪一类失败"。
182
+ const note = notes.get(noteId) ?? (noteId === 'context-injection' ? notes.get('context-injection-runtime') : undefined);
179
183
  push({
180
184
  key: noteId, label, tab, state: note === undefined ? 'ok' : 'degraded',
181
185
  detail: note === undefined ? '装配正常,无降级上报' : note.detail,
@@ -76,14 +76,27 @@ export function buildPromptOps(deps) {
76
76
  // 场景绑定(含恒常的 `_shared` / `global`)、`~/.dsh/AGENTS.md` 的当前内容、
77
77
  // 进场景前保存的基线(退出场景后要恢复的那一份)。拒绝时逐条说明「谁在用」,
78
78
  // 用户知道该先改哪里。引用清单由 scene-prompt-sync 与显示/注入同源算出。
79
- // 探测失败(refs() 返回 null)时放行并记 warn:删预设不动 ~/.dsh/AGENTS.md,
80
- // 最坏是少一份副本,可重建 —— 不因一次探测故障把删除堵死。
79
+ //
80
+ // 探测**失败**时改为拒绝(2026-09-30 审查 F13)。原来的取舍是「放行并记 warn:删预设不动
81
+ // ~/.dsh/AGENTS.md,最坏是少一份副本,可重建」—— 这对「副本」成立,对**场景态**不成立:
82
+ // 删掉场景绑定的那一份,场景驱动会因为找不到预设而静默失效(`driver()` 返回 null),
83
+ // 而 AGENTS.md 停在场景态,用户看到的是「没场景驱动所以什么都没做」。
84
+ // `refs()` 返回 null 只代表**三类探测里至少一类读盘失败**(全新环境没有场景时它返回的是
85
+ // **空表**而不是 null),所以拒绝不会堵住正常路径。文案必须说清是「探测失败」而不是
86
+ // 「有引用」—— 否则用户会去改一堆并不存在的引用。
81
87
  'agentsmd-remove': async (args) => {
82
88
  const id = String((args && args.id) || '');
83
89
  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) || [] : [];
90
+ if (refs === null) {
91
+ deps.warn('prompts: reference probe failed; remove refused');
92
+ return {
93
+ ok: false,
94
+ code: 'error.agentsMd.refProbeFailed',
95
+ error: `无法确认「${id}」是否被引用,已拒绝删除:引用探测失败(场景绑定 / AGENTS.md 当前内容 / 进场景前的基线,这三类里至少有一类没读到)。请稍后重试。`,
96
+ params: { id },
97
+ };
98
+ }
99
+ const why = refs.get(id) || [];
87
100
  if (why.length) {
88
101
  const refsText = why.map(deps.promptRefReason).join(';');
89
102
  return {
@@ -4,13 +4,13 @@
4
4
  // 提示词改绑。动的是 index 的 scenes / active 切片与 AGENTS.md 投影,与记忆正文 ops 互不调用。
5
5
  import { mkdir, readdir, rename, rm } from 'node:fs/promises';
6
6
  import { join } from 'node:path';
7
- import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
7
+ import { listTrashEntries, moveIntoTrash, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
8
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
9
  import { normalizeActive, collapseActiveForNewScene } from '../memories/projection.js';
10
10
  import { readIndex, writeIndex, sceneRecordOf, normalizeScenePromptId, pathExists } from '../memories/index-io.js';
11
11
  /** 组装本域 op。rc 由 createMemoriesService 组装,契约见 ./ctx.ts。 */
12
12
  export function buildSceneRecordOps(rc) {
13
- const { ensureLayout, invalidateSnapshot, presetExistsSync, memoriesRoot, sceneRows, snapshot, stateDir } = rc;
13
+ const { ensureLayout, invalidateSnapshot, presetExistsSync, memoriesRoot, refuseOutsideRoot, sceneRows, snapshot, stateDir } = rc;
14
14
  /**
15
15
  * 设置"启用场景"(全局持久化,切换后**下一个请求即生效**,§4 对照表)。
16
16
  * - `args.scenes` 数组 → 启用集合;**至多一个非保留场景**(用户裁定:除「全局」外
@@ -39,7 +39,13 @@ export function buildSceneRecordOps(rc) {
39
39
  // 恒常启用的保留场景不入显式集合:`_shared`(公共基线)与 `global`(「全局」)。
40
40
  if (name === '' || name === SHARED_GROUP || name === GLOBAL_SCENE)
41
41
  continue;
42
- if (!isValidGroupPath(name)) {
42
+ // 启用集合里的场景名必须是**单个路径段**(2026-09-30 审查 P2-4)。此前这里走
43
+ // `isValidGroupPath`,于是 `a/b` 这种多段名**通过校验并被写进 `active`**,但它压根
44
+ // 不是一个场景:场景恒等于一级目录(见 `snapshot.ts` 的 `depth === 1` 与
45
+ // `projection.ts` 的 `sceneOf`),注入时桶名是 `a`、`active` 里写的是 `a/b` ——
46
+ // 两边对不上,用户看到的是"开关点了、场景没生效",且**没有任何报错**。
47
+ // 改成单段后这种名字会当场被拒(响亮地失败),与创建/改名的口径一致。
48
+ if (!isValidGroupSegment(name)) {
43
49
  return fail('error.rules.invalidGroup', `场景名非法:${name}(${SEGMENT_RULE_HINT})`);
44
50
  }
45
51
  if (seen.has(name))
@@ -63,8 +69,14 @@ export function buildSceneRecordOps(rc) {
63
69
  /**
64
70
  * 新建场景:写一条场景记录(索引 scenes 切片)+ 建空目录 `memories/<场景>/`。
65
71
  * 场景是**显式实体**——空场景(还没有记忆)也是合法场景,会出现在场景列表里。
66
- * 场景名是**单个路径段**(不允许 `a/b`):界面把场景当一级列表展示,
67
- * 允许多段只会让「记忆的场景」与「目录层级」两套语义互相打架。
72
+ *
73
+ * 场景名的统一口径(2026-09-30 审查 P2-4 收敛,此前全仓分成两派):
74
+ * - **命名路径**(新建 / 改名目标 / 启用集合)一律是**单个路径段**。场景恒等于一级目录
75
+ * (见 `snapshot.ts` 的 `depth === 1` 与 `projection.ts` 的 `sceneOf`),允许 `a/b`
76
+ * 只会让「场景」与「场景内的分组」两套语义互相打架,而且启用时桶名对不上(静默失效)。
77
+ * - **定位路径**(改名的旧名 / 锁定 / 删除 / 回收站恢复)按**完整路径**寻址,`isValidGroupPath`。
78
+ * 历史与导入留下的多段记录因此仍能被改名或删除 —— 否则它们成了"看得见、动不了"的死条目。
79
+ *
68
80
  * 幂等:已存在则更新描述/标签,不报错。`_shared` 与 `global` 是保留名。
69
81
  */
70
82
  async function rulesCreateScene(args) {
@@ -249,6 +261,18 @@ export function buildSceneRecordOps(rc) {
249
261
  index.archives[nextRaw] = index.archives[name];
250
262
  delete index.archives[name];
251
263
  }
264
+ // 该场景下的**记忆条目**也要跟着换前缀(2026-09-30 审查 P1-1)。不迁的话:改名后
265
+ // 旧键 `<旧场景>/<名>` 在磁盘上找不到对应文件,`buildSnapshot` 的清理会把它删掉
266
+ // (见 `memories/snapshot.ts` 的"索引有记录但文件已删"分支)→ 那条记忆失去索引条目
267
+ // → `projectRule` 的 `enabled ?? true` 把它**重新启用** —— 用户显式停用的记忆复活,
268
+ // 且 order / tags / pinned / note 一并丢失。记忆自己的改名路径(`ops/memory.ts`)
269
+ // 一直是迁索引的,场景这条漏了同一件事。
270
+ // 只换前缀、原值不动;`nextRaw` 的场景名已被上面的占用检查保证为空,不会覆盖。
271
+ const sceneRules = index.rules || {};
272
+ for (const id of Object.keys(sceneRules).filter((k) => k.startsWith(name + '/'))) {
273
+ sceneRules[nextRaw + id.slice(name.length)] = sceneRules[id];
274
+ delete sceneRules[id];
275
+ }
252
276
  const act = normalizeActive(index.active);
253
277
  if (act !== null && act.indexOf(name) >= 0)
254
278
  index.active = act.map((n) => (n === name ? nextRaw : n));
@@ -308,11 +332,15 @@ export function buildSceneRecordOps(rc) {
308
332
  /**
309
333
  * 删除场景:**仅空目录可删**(避免一次操作带走整组记忆)。
310
334
  * 同时删掉场景记录、把它从 `active` 集合里摘掉、清掉它的档案,避免悬空引用。
311
- * 保留场景 `global` 不可删除;场景名口径与创建一致(单个路径段)。
335
+ * 保留场景 `global` 不可删除。
336
+ *
337
+ * 场景名在这里按**完整路径**寻址(`isValidGroupPath`,2026-09-30 审查 P2-4):命名路径
338
+ * (创建 / 改名目标 / 启用集合)一律要求单段,而历史与导入留下的多段记录得能**被清掉** ——
339
+ * 否则它就成了"看得见、动不了"的死条目。删除只是定位 + 移入回收站,多段名不会造成歧义。
312
340
  */
313
341
  async function rulesRemoveScene(args) {
314
342
  const name = String((args && args.name) || '').trim();
315
- if (!isValidGroupSegment(name))
343
+ if (!isValidGroupPath(name))
316
344
  return fail('error.rules.invalidGroup', `场景名非法:${name || '(空)'}`);
317
345
  if (name === SHARED_GROUP)
318
346
  return fail('error.rules.invalidGroup', `_shared 是保留场景名,不可删除`);
@@ -333,6 +361,15 @@ export function buildSceneRecordOps(rc) {
333
361
  // 但一律**先移入回收站**(与场景记录、档案装在同一条条目里),所以「删错了」还能整条恢复:
334
362
  // 记忆文件按原来的相对路径放回,场景记录与档案也一起回来。
335
363
  const sceneDir = join(memoriesRoot, name);
364
+ // 删除前先确认落点仍在根内(realpath 口径)—— 与 `rulesRemove` / `rulesRestore` 同一道闸。
365
+ // 为什么删除侧最需要它(2026-09-30 审查 P1-2,已实测复现):`memories/<场景>` 若是指向根外的
366
+ // 目录链接(用户手工建、或旧布局迁移带进来的),下面 `collect` 的 `readdir` 会**列出链接目标
367
+ // 的内容**,于是 `from` 拼成 `<memoriesRoot>/<场景>/x.md` 看着在根内、`rename` 实际作用于
368
+ // **根外的真实文件** —— 删除场景会把 `D:\重要数据` 里的文件搬进回收站。`isValidGroupSegment`
369
+ // 挡不住(名字本身合法),`lstat` 也挡不住(只看末级)。
370
+ const deniedDir = await refuseOutsideRoot(sceneDir);
371
+ if (deniedDir)
372
+ return deniedDir;
336
373
  const moves = [];
337
374
  const collect = async (abs, rel) => {
338
375
  let items = [];
@@ -343,6 +380,12 @@ export function buildSceneRecordOps(rc) {
343
380
  return; // 目录不存在(只有记录的场景)或读不了 → 当作没有文件
344
381
  }
345
382
  for (const item of items) {
383
+ // 跳过符号链接(含 Windows junction),与 `snapshot.ts` 的 `discover` 同口径。
384
+ // 上面那道 realpath 断言挡的是「场景目录本身是链接」;这一条挡的是「场景目录内的某一项是
385
+ // 链接」—— 两者是不同的路径,缺一条就漏一条。链接本身也不搬走(它是环境相关的,
386
+ // 不该被回收站带到另一台机器上),留给后面的 `rm` 清掉。
387
+ if (item.isSymbolicLink())
388
+ continue;
346
389
  const childAbs = join(abs, item.name);
347
390
  const childRel = rel === '' ? item.name : `${rel}/${item.name}`;
348
391
  if (item.isDirectory())
@@ -352,10 +395,19 @@ export function buildSceneRecordOps(rc) {
352
395
  }
353
396
  };
354
397
  await collect(sceneDir, '');
398
+ // 该场景下每条记忆的索引条目(enabled / order / tags / pinned / note)一并收进回收站条目
399
+ // 的 `data`(2026-09-30 审查 P1-1)。不带的话:恢复出来的记忆全部按 `enabled ?? true`
400
+ // 重置 —— 用户停用的会复活,其余元数据归零。文件在回收站、元数据也得跟着在。
401
+ const sceneRuleSubset = {};
402
+ for (const id of Object.keys(index.rules || {})) {
403
+ if (id.startsWith(name + '/'))
404
+ sceneRuleSubset[id] = index.rules[id];
405
+ }
355
406
  const trashed = await moveToTrash('scenes', name, moves, {
356
407
  record: (index.scenes && index.scenes[name]) || null,
357
408
  archive: (index.archives && index.archives[name]) || null,
358
409
  memories: moves.map((m) => m.dest),
410
+ rules: sceneRuleSubset,
359
411
  });
360
412
  if (trashed.ok === false) {
361
413
  return fail('error.rules.ioFailed', `移入回收站失败:${trashed.error}`);
@@ -366,11 +418,21 @@ export function buildSceneRecordOps(rc) {
366
418
  }
367
419
  catch (e) {
368
420
  // 目录没清掉就把记忆放回去:宁可整个操作失败,也不要「文件在回收站、场景还留在列表里」。
421
+ let rollbackFailed = 0;
369
422
  for (const move of moves) {
370
423
  try {
371
424
  await moveOutOfTrash('scenes', trashed.id, move.dest, move.from);
372
425
  }
373
- catch { /* 尽力而为 */ }
426
+ catch {
427
+ rollbackFailed += 1;
428
+ }
429
+ }
430
+ // **回滚没做完就绝不 purge**:purge 会把条目整目录删掉,而没能放回的那些文件还在里面
431
+ // —— 那是一次静默的**永久删除**(用户记忆),且场景目录还留在原地(一个被掏空的场景)。
432
+ // 宁可留一条脏条目(界面看得见、能再点一次恢复),也不要"删干净"(审查 P0-2)。
433
+ if (rollbackFailed > 0) {
434
+ return fail('error.rules.ioFailed', `删除场景目录失败:${message(e)};另有 ${rollbackFailed} 个记忆文件没能放回原位,`
435
+ + `它们仍在回收站条目 ${trashed.id} 里(未删除),可从回收站恢复`);
374
436
  }
375
437
  try {
376
438
  await purgeTrashEntry('scenes', trashed.id);
@@ -392,6 +454,17 @@ export function buildSceneRecordOps(rc) {
392
454
  delete index.archives[name];
393
455
  dirty = true;
394
456
  }
457
+ // 记忆条目也跟着进了回收站(见上面的 `sceneRuleSubset`)→ 索引里同步删掉。不删的话会留下
458
+ // "索引有记录、磁盘没文件"的悬空键:`buildSnapshot` 下一轮也会清掉,但中间那一轮界面会
459
+ // 把它显示成"这条记忆不存在",且恢复时 `data.rules` 合并回来会与残留键打架。显式删干净。
460
+ if (index.rules) {
461
+ for (const id of Object.keys(index.rules)) {
462
+ if (id.startsWith(name + '/')) {
463
+ delete index.rules[id];
464
+ dirty = true;
465
+ }
466
+ }
467
+ }
395
468
  if (dirty)
396
469
  await writeIndex(stateDir, index);
397
470
  invalidateSnapshot();
@@ -404,6 +477,9 @@ export function buildSceneRecordOps(rc) {
404
477
  /**
405
478
  * 从回收站恢复一个场景:重建记忆目录 + 写回记录与档案。
406
479
  * 同名场景已存在时**拒绝**(绝不覆盖既有场景)。
480
+ *
481
+ * 场景名按**完整路径**寻址(2026-09-30 审查 P2-4,与删除侧同口径):回收站里的名字来自
482
+ * 当初删掉的那条记录,多段名同样得能恢复回来 —— 否则删得掉、恢复不了,数据就卡在回收站里。
407
483
  */
408
484
  async function sceneTrashRestore(args) {
409
485
  const id = String((args && args.id) || '').trim();
@@ -411,12 +487,18 @@ export function buildSceneRecordOps(rc) {
411
487
  if (!entry)
412
488
  return fail('error.rules.notFound', `回收站条目不存在:${id}`);
413
489
  const name = String(entry.name || '').trim();
414
- if (!isValidGroupSegment(name))
490
+ if (!isValidGroupPath(name))
415
491
  return fail('error.rules.invalidGroup', `回收站里的场景名非法:${name || '(空)'}`);
416
492
  const index = await readIndex(stateDir);
417
493
  if (index.scenes?.[name])
418
494
  return fail('error.rules.invalidGroup', `无法恢复,同名场景已存在:${name}`);
419
495
  const dir = join(memoriesRoot, name);
496
+ // 恢复是**写入**方向:落点若是指向根外的目录链接,`mkdir` 会顺着链接成功、随后
497
+ // `moveOutOfTrash` 把记忆文件写到**根外**去。与删除侧同一个根因(缺 realpath 根内断言),
498
+ // 2026-09-30 审查 P1-2 的相邻面 —— 修一处必须一起修,否则只是把同一个洞挪了个方向。
499
+ const deniedDir = await refuseOutsideRoot(dir);
500
+ if (deniedDir)
501
+ return deniedDir;
420
502
  let entries = [];
421
503
  try {
422
504
  entries = await readdir(dir);
@@ -443,13 +525,38 @@ export function buildSceneRecordOps(rc) {
443
525
  restored.push(String(rel));
444
526
  }
445
527
  catch (e) {
446
- return fail('error.rules.ioFailed', `恢复记忆文件失败(已放回 ${restored.length}/${files.length}):${message(e)}`);
528
+ // 已放回的文件**搬回回收站**再报错:原来这里是直接 return,已放回的那几个留在记忆目录里
529
+ // —— 下一次点恢复会撞上面那条「目录里已有内容」的检查,于是这个场景**永远恢复不了**
530
+ // (审查 P0-2)。回滚失败也如实说清楚,别让用户以为还能原样重试。
531
+ let backFailed = 0;
532
+ for (const doneRel of [...restored].reverse()) {
533
+ try {
534
+ await moveIntoTrash('scenes', id, join(dir, doneRel), doneRel);
535
+ }
536
+ catch {
537
+ backFailed += 1;
538
+ }
539
+ }
540
+ const tail = backFailed === 0
541
+ ? '(已放回的记忆文件已退回回收站,可重试)'
542
+ : `(另有 ${backFailed} 个文件没能退回回收站,它们现在在记忆目录「${name}」里,请先手工处理再重试)`;
543
+ return fail('error.rules.ioFailed', `恢复记忆文件失败:${message(e)}${tail}`);
447
544
  }
448
545
  }
449
546
  if (data.record && typeof data.record === 'object')
450
547
  index.scenes = { ...(index.scenes || {}), [name]: data.record };
451
548
  if (data.archive && typeof data.archive === 'object')
452
549
  index.archives = { ...(index.archives || {}), [name]: data.archive };
550
+ // 记忆条目随文件一起回来(删除时存进 `data.rules`,见 `rulesRemoveScene`)——
551
+ // 否则恢复出来的记忆全部按 `enabled ?? true` 重置,用户停用的会复活(审查 P1-1)。
552
+ // **只补不覆盖**:同名键若已存在以现有为准(上面已拒绝同名场景,正常情况下不会撞)。
553
+ if (data.rules && typeof data.rules === 'object' && !Array.isArray(data.rules)) {
554
+ const rules = index.rules || (index.rules = {});
555
+ for (const [ruleId, ruleEntry] of Object.entries(data.rules)) {
556
+ if (rules[ruleId] === undefined)
557
+ rules[ruleId] = ruleEntry;
558
+ }
559
+ }
453
560
  await writeIndex(stateDir, index);
454
561
  await purgeTrashEntry('scenes', id);
455
562
  invalidateSnapshot();
@@ -220,9 +220,47 @@ export function buildSessionOps(deps) {
220
220
  const sessionId = String((args && args.sessionId) || '').trim();
221
221
  if (!sessionId)
222
222
  return { ok: false, error: '缺少 sessionId' };
223
+ // 范围收敛(2026-09-30 审查 P1-7):单删也只允许删**已归档**的会话 —— 与批量路径
224
+ // (`deleteArchivedSessions` → `archivedSessionIdsForTarget` 的四种 scope 全部与
225
+ // `archivedSessionIds` 求交)同一口径,也与界面对齐(`history-list` 只回已归档会话,
226
+ // 删除按钮只出现在那份列表里)。原先这里直接 `registry.deleteSession(sessionId)`,而
227
+ // `deleteLocalSession` 只问「这个 id 在持久化里存在吗」—— 于是一个客户端 bug 或一次
228
+ // 手工请求就能**永久删除任意已持久化会话**,包括未归档的和当前正开着的。
229
+ // 判据必须是**权威归档集合**,不是摘要投影(2026-09-30 审查 F9)。
230
+ // `archivedSessionMetadata()` 是**有损**的:逐条 try,读不到 header 或 `createdAt`
231
+ // 非有限就 `continue` 且不抛(`workspace.ts` 的 `archivedSessionMetadata`)。拿它的
232
+ // `items` 拼门禁集合,就会把「确实已归档、只是头部暂时读不到」的会话判成未归档 →
233
+ // 回一句「只能删除已归档的会话:请先归档,再删除」——**诊断是错的**(真因是读失败)。
234
+ // 而批量路径(`deleteArchivedSessions` → `archivedSessionIdsForTarget`)用的是
235
+ // `requireState().archivedSessionIds`,同一份数据两条路给出相反结论。
236
+ // 这里改成与批量路径同源;`requireState` 缺失(旧宿主)才退回投影 —— 投影只会**更小**,
237
+ // 方向仍是 fail-closed(宁可这次删不掉,也不要凭未知状态做不可逆操作)。
238
+ let archived;
223
239
  try {
224
- await registry.deleteSession(sessionId);
225
- return { ok: true, sessionId, deleted: true };
240
+ const authoritative = typeof registry.requireState === 'function' ? registry.requireState() : undefined;
241
+ if (authoritative && Array.isArray(authoritative.archivedSessionIds)) {
242
+ archived = new Set(authoritative.archivedSessionIds.map((id) => String(id)));
243
+ }
244
+ else {
245
+ const meta = await registry.archivedSessionMetadata();
246
+ const items = meta && Array.isArray(meta.items) ? meta.items : [];
247
+ archived = new Set(items.map((i) => String(i && i.sessionId)));
248
+ }
249
+ }
250
+ catch (e) {
251
+ // 查不到归档状态 → **拒绝删除**(fail-closed):宁可这次删不掉,也不要凭一个未知状态
252
+ // 去执行不可逆操作。
253
+ return { ok: false, error: '无法确认会话的归档状态,已拒绝删除:' + deps.message(e) };
254
+ }
255
+ if (!archived.has(sessionId)) {
256
+ return { ok: false, error: '只能删除已归档的会话:请先归档,再删除' };
257
+ }
258
+ try {
259
+ const r = await registry.deleteSession(sessionId);
260
+ // 级联只吃已归档的子会话(见 `deleteDescendants`):未归档的会被**保留**,
261
+ // 这里如实带出去,界面/模型才不会以为「这一支已经全没了」。
262
+ const kept = (r && Array.isArray(r.keptUnarchivedDescendants)) ? r.keptUnarchivedDescendants : [];
263
+ return { ok: true, sessionId, deleted: true, ...(kept.length ? { keptUnarchivedDescendants: kept } : {}) };
226
264
  }
227
265
  catch (e) {
228
266
  return { ok: false, error: deps.message(e) };
@@ -294,8 +332,9 @@ export function buildSessionOps(deps) {
294
332
  if (!fileName || !content.trim())
295
333
  return { ok: false, error: '缺少文件内容' };
296
334
  let turns;
335
+ let format;
297
336
  try {
298
- const format = detectFormat(fileName, content);
337
+ format = detectFormat(fileName, content);
299
338
  turns = format === 'jsonl' ? parseJsonlTranscript(content)
300
339
  : format === 'markdown' ? parseMarkdownTranscript(content)
301
340
  : parseGenericText(content);
@@ -305,6 +344,13 @@ export function buildSessionOps(deps) {
305
344
  }
306
345
  if (!turns.length)
307
346
  return { ok: false, error: '未能从该文件中识别出对话内容' };
347
+ // 「折成一条 user 消息」这件事本身要出声(2026-09-30 审查 F12)。
348
+ // 嗅探(`detectFormat`)已经能救回「JSONL / Markdown 被改名成 .txt」那一种,但**真的**
349
+ // 没有说话人标记的文本仍会走 generic:多行内容全塞进 1 条 user turn、结构全失,
350
+ // 而 op 此前回的是 `{ok:true,count:1}` —— 零警告。这里把它标出来(`lossy` 给界面出
351
+ // 本地化文案,`warning` 给模型看原因原文)。
352
+ const lossy = format === 'generic' && turns.length === 1
353
+ && content.split(/\r?\n/).filter((line) => line.trim()).length >= 5;
308
354
  if (typeof deps.createSession !== 'function')
309
355
  return { ok: false, error: '当前环境不支持创建会话(agents.create 不可用)' };
310
356
  // 仅接受绝对路径的 cwd;非法或缺失时会话不带目录(归入未分组)。
@@ -334,7 +380,7 @@ export function buildSessionOps(deps) {
334
380
  if (live === undefined || live === null) {
335
381
  return { ok: false, error: `创建会话失败:宿主未登记该会话(${id}),未导入任何内容` };
336
382
  }
337
- return { ok: true, sessionId: id, count: turns.length };
383
+ return { ok: true, sessionId: id, count: turns.length, ...(lossy ? { format, lossy: true, warning: '整份文本没有识别出任何说话人标记(如 User: / Assistant:),已折成一条用户消息:原对话的分轮结构可能已丢失。若源文件是 JSONL 或 Markdown 转录稿,请按原扩展名(.jsonl / .md)重新导入。' } : {}) };
338
384
  }
339
385
  catch (e) {
340
386
  return { ok: false, error: '创建会话失败: ' + deps.message(e) };