dsh-plugin-tool-management 0.14.0 → 0.16.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 (44) hide show
  1. package/CHANGELOG.md +585 -519
  2. package/docs/images/1-EN.png +0 -0
  3. package/docs/images/1.png +0 -0
  4. package/docs/images/2-EN.png +0 -0
  5. package/docs/images/2.png +0 -0
  6. package/docs/images/3-EN.png +0 -0
  7. package/docs/images/3.png +0 -0
  8. package/docs/images/4-EN.png +0 -0
  9. package/docs/images/4.png +0 -0
  10. package/docs/images/5-EN.png +0 -0
  11. package/docs/images/5.png +0 -0
  12. package/docs/images/6-EN.png +0 -0
  13. package/docs/images/6.png +0 -0
  14. package/docs/images/7-EN.png +0 -0
  15. package/docs/images/7.png +0 -0
  16. package/docs/images/8-EN.png +0 -0
  17. package/docs/images/8.png +0 -0
  18. package/docs/update.md +409 -365
  19. package/lib/audit-log.js +157 -0
  20. package/lib/client.js +3915 -2545
  21. package/lib/compat/patch-dialect.js +3 -1
  22. package/lib/compat/preset-reach.js +37 -6
  23. package/lib/compat/probe.js +281 -11
  24. package/lib/context-inject.js +5 -3
  25. package/lib/index.js +236 -30
  26. package/lib/mcp/loader-token.js +53 -28
  27. package/lib/mcp/manager.js +311 -9
  28. package/lib/memories/archive-engine.js +70 -2
  29. package/lib/memories/archive.js +8 -1
  30. package/lib/memories/index-io.js +6 -0
  31. package/lib/memories/service.js +9 -0
  32. package/lib/op-registry.js +18 -4
  33. package/lib/ops/compat.js +121 -35
  34. package/lib/ops/scene-records.js +34 -0
  35. package/lib/ops/scene-sync.js +12 -1
  36. package/lib/ops/sessions.js +23 -8
  37. package/lib/ops/snapshot.js +596 -0
  38. package/lib/scenes/candidates.js +10 -5
  39. package/lib/sessions/bridge.js +5 -0
  40. package/lib/sessions/workspace.js +17 -5
  41. package/lib/skills/core.js +12 -3
  42. package/lib/tools/deps.js +1 -1
  43. package/lib/tools/table.js +5 -1
  44. package/package.json +113 -108
package/lib/ops/compat.js CHANGED
@@ -64,6 +64,9 @@ export function buildCompatOps(deps) {
64
64
  host: { version: assessment.identity.version, modules: assessment.identity.modules },
65
65
  sameAsHost: assessment.identity.sameAsHost,
66
66
  unverified: assessment.identity.unverified,
67
+ // 身份校验的说明(目前只有「宿主在 app.asar 里」一种):不是阻塞项,界面据此
68
+ // 解释那一排「无法比较」,而不是留给用户一句「两份拷贝,去 --fix」。
69
+ notes: assessment.identity.notes,
67
70
  findings,
68
71
  degraded,
69
72
  blockers: assessment.identity.blockers,
@@ -129,15 +132,35 @@ export function buildCompatOps(deps) {
129
132
  const domainsOn = INJECT_DOMAIN_KEYS.filter((key) => settings.domains[key] !== false);
130
133
  const token = await deps.readTokenState();
131
134
  const lockedScenes = await deps.lockedSceneNames();
132
- const disabledTools = deps.disabledToolCount();
135
+ const disabledTools = deps.disabledToolSummary();
133
136
  const routeOf = (operation) => assessment === undefined ? undefined : routeFor(assessment, operation);
134
- /** 一行一个功能点:状态三层合成,`detail` 写明依据。 */
135
137
  const rows = [];
136
- const push = (key, label, tab, state, detail) => rows.push({ key, label, tab, state, detail });
137
- // 前置项:插件挂载(这一行本身说明 12 个 inject 都解析了 —— 否则 apply 根本不会跑);
138
+ const push = (row) => rows.push(row);
139
+ /** note 降级行的公共字段:透传 note 的 detailKey / params,没有结构化键就只带原文。 */
140
+ const noteFields = (note) => note === undefined ? {}
141
+ : { ...(note.detailKey === undefined ? {} : { detailKey: note.detailKey }), ...(note.params === undefined ? {} : { params: note.params }) };
142
+ /** 路由判定行的公共字段:可用带 via;不可用带缺失能力的 id + 原始标签(客户端出各语言标签)。 */
143
+ const routeFields = (decision) => {
144
+ if (decision === undefined)
145
+ return {};
146
+ return decision.via === 'none'
147
+ ? { refusals: decision.refusals.map((item) => ({ id: item.id, label: item.label })) }
148
+ : { via: decision.via };
149
+ };
150
+ const routeDetail = (decision) => decision === undefined ? '能力探测不可用(归档服务未挂载)'
151
+ : decision.via === 'none' ? '宿主缺少该路径所需能力:' + decision.refusals.map((item) => item.label).join('、')
152
+ : `可用(路径:${decision.via})`;
153
+ // 前置项:插件挂载(这一行本身说明 inject 都解析了 —— 否则 apply 根本不会跑);
138
154
  // 启动期那段 `!!js` 表达式若抛错,插件同样不会挂上(它的 try/catch 就是为了不抛)。
139
155
  const mountNote = notes.get('cordis-original-symbol');
140
- push('mount', '插件挂载', 'compat', mountNote === undefined ? 'ok' : 'degraded', mountNote === undefined ? '12 个 inject 服务全部解析,插件已挂载(详见 doctor 的挂载心跳)' : mountNote.detail);
156
+ push({
157
+ key: 'mount', label: '插件挂载', tab: 'compat',
158
+ state: mountNote === undefined ? 'ok' : 'degraded',
159
+ detail: mountNote === undefined
160
+ ? `${deps.injectServiceCount} 个 inject 服务全部解析,插件已挂载(详见 doctor 的挂载心跳)`
161
+ : mountNote.detail,
162
+ ...(mountNote === undefined ? { params: { count: deps.injectServiceCount } } : noteFields(mountNote)),
163
+ });
141
164
  // 装配层:有上报就是降级,附上报里的原因;没有就是正常。
142
165
  const assemblyRows = [
143
166
  ['patch-write-guard', '宿主配置写入(补丁校验)', 'mcp'],
@@ -149,52 +172,115 @@ export function buildCompatOps(deps) {
149
172
  ];
150
173
  for (const [noteId, label, tab] of assemblyRows) {
151
174
  const note = notes.get(noteId);
152
- push(noteId, label, tab, note === undefined ? 'ok' : 'degraded', note === undefined ? '装配正常,无降级上报' : note.detail);
175
+ push({
176
+ key: noteId, label, tab, state: note === undefined ? 'ok' : 'degraded',
177
+ detail: note === undefined ? '装配正常,无降级上报' : note.detail,
178
+ ...noteFields(note),
179
+ });
153
180
  }
154
181
  // 宿主能力层:删除 / 归档 / 恢复 / 批量各自由路由判定回答(与服务端执行同源)。
155
182
  const deleteRoute = routeOf('delete');
156
- push('session-delete', '会话删除', 'sessions', deleteRoute === undefined ? 'unknown' : deleteRoute.via === 'none' ? 'unavailable' : 'ok', deleteRoute === undefined ? '能力探测不可用(归档服务未挂载)'
157
- : deleteRoute.via === 'none' ? '宿主缺少该路径所需能力:' + deleteRoute.refusals.map((item) => item.label).join('、')
158
- : `可用(路径:${deleteRoute.via})`);
183
+ push({
184
+ key: 'session-delete', label: '会话删除', tab: 'sessions',
185
+ state: deleteRoute === undefined ? 'unknown' : deleteRoute.via === 'none' ? 'unavailable' : 'ok',
186
+ detail: routeDetail(deleteRoute), ...routeFields(deleteRoute),
187
+ });
159
188
  for (const [operation, label] of [['archive', '归档'], ['unarchive', '恢复'], ['batch', '批量操作'], ['list', '历史列表']]) {
160
189
  const decision = routeOf(operation);
161
- push('session-' + operation, `会话${label}`, 'sessions', decision === undefined ? 'unknown' : decision.via === 'none' ? 'unavailable' : 'ok', decision === undefined ? '能力探测不可用(归档服务未挂载)'
162
- : decision.via === 'none' ? '宿主缺少该路径所需能力:' + decision.refusals.map((item) => item.label).join('、')
163
- : `可用(路径:${decision.via})`);
190
+ push({
191
+ key: 'session-' + operation, label: `会话${label}`, tab: 'sessions',
192
+ state: decision === undefined ? 'unknown' : decision.via === 'none' ? 'unavailable' : 'ok',
193
+ detail: routeDetail(decision), ...routeFields(decision),
194
+ });
164
195
  }
165
196
  // 用户配置层:注入域 / 令牌 / 场景锁定 / 停用工具数。
166
- push('injection-domains', '注入域开关', 'compat', domainsOn.length === 0 ? 'disabled' : 'ok', domainsOn.length === 0 ? '五个注入域全部关闭(用户设置)' : `${domainsOn.length}/5 个域开启:${domainsOn.join('、')}`);
167
- push('token', '访问令牌', 'compat', !token.active ? 'disabled' : token.accepted ? 'ok' : 'locked', !token.active ? '未启用(宿主没配令牌,或令牌功能被关掉)'
168
- : token.accepted ? '已生效且本次启动已通过验证' : '已生效,本次启动尚未验证:写操作与对话会被拦住,到本页下方填写令牌');
169
- push('scene-lock', '场景锁定', 'scenes', lockedScenes.length === 0 ? 'ok' : 'locked', lockedScenes.length === 0 ? '无锁定场景' : `锁定中:${lockedScenes.join('、')}(写门禁按锁定场景生效)`);
170
- push('mcp-tools', 'MCP 工具停用', 'mcp', disabledTools === 0 ? 'ok' : 'partial', disabledTools === 0 ? '没有停用的工具' : `${disabledTools} 个工具处于停用态(执行拦截 + 可见性摘除)`);
171
- // 工具表按每个请求付钱:这一行回答"这一轮实际发出去多少"。关掉的工具整份不进请求,
172
- // 但代价是模型调不到它们(本插件的面板不受影响)——所以是 partial,不是 ok。
173
- // **出厂默认关掉的那几条不算**:那是插件替用户做的一个可逆选择,不是用户关出了
174
- // 一个缺口。把默认态报成 partial 违背本页口径(琥珀只留给"该做却没做"),也永远
175
- // 无法消掉 —— 用户打开它们反而会被罚一个 ok。
176
- // 末尾那句是**逐会话**的差额:官方 `skill` 工具在场的会话里我们那份加载器会再让位
177
- // 一个(见 index.ts 的 CARRIER_DUPLICATES),本表的数字是全局口径、不含它。
197
+ // 分母必须取自 INJECT_DOMAIN_KEYS:0.14.0 场景与记忆分家后域从 5 个变 6 个,
198
+ // 写死的「/5」把 6/6 报成 6/5(2026-09-28 用户实测)。
199
+ const domainTotal = INJECT_DOMAIN_KEYS.length;
200
+ push({
201
+ key: 'injection-domains', label: '注入域开关', tab: 'compat',
202
+ state: domainsOn.length === 0 ? 'disabled' : 'ok',
203
+ detail: domainsOn.length === 0 ? `${domainTotal} 个注入域全部关闭(用户设置)` : `${domainsOn.length}/${domainTotal} 个域开启:${domainsOn.join('、')}`,
204
+ domains: domainsOn, count: domainsOn.length, total: domainTotal,
205
+ });
206
+ push({
207
+ key: 'token', label: '访问令牌', tab: 'compat',
208
+ state: !token.active ? 'disabled' : token.accepted ? 'ok' : 'locked',
209
+ detail: !token.active ? '未启用(宿主没配令牌,或令牌功能被关掉)'
210
+ : token.accepted ? '已生效且本次启动已通过验证' : '已生效,本次启动尚未验证:写操作与对话会被拦住,到本页下方填写令牌',
211
+ });
212
+ push({
213
+ key: 'scene-lock', label: '场景锁定', tab: 'scenes',
214
+ state: lockedScenes.length === 0 ? 'ok' : 'locked-scene',
215
+ detail: lockedScenes.length === 0 ? '无锁定场景' : `锁定中:${lockedScenes.join('、')}(写门禁按锁定场景生效)`,
216
+ scenes: lockedScenes,
217
+ });
218
+ // 停用是用户配置或场景收窄的**事实**,不是故障:功能本身的好坏由上面「工具表可见性」
219
+ // 装配行报(`mcp-tool-visibility` 降级 = 拦截/摘除没装上),这一行只如实陈述停用构成,
220
+ // 状态恒为 ok —— 否则"只开了几台 MCP"的用户会一直看到琥珀,以为出了问题
221
+ // (2026-09-28 用户裁定:琥珀只留给功能出问题)。
222
+ // 「N 个工具」也说不清整台停用:一条 `*` 通配背后是那台服务器的全部工具,
223
+ // 按条目数报会把 6 台整台停用读成"6 个工具"。两个口径分开说:台数只数 `*`,工具数只数单独停用的条目。
224
+ const disabledPart = disabledTools.servers > 0 && disabledTools.tools > 0
225
+ ? `${disabledTools.servers} 台服务器整台停用 + ${disabledTools.tools} 个工具单独停用`
226
+ : disabledTools.servers > 0
227
+ ? `${disabledTools.servers} 台服务器整台停用`
228
+ : disabledTools.tools > 0
229
+ ? `${disabledTools.tools} 个工具单独停用`
230
+ : '没有停用的工具';
231
+ push({
232
+ key: 'mcp-tools', label: 'MCP 工具停用', tab: 'mcp', state: 'ok',
233
+ detail: disabledPart + (disabledTools.servers + disabledTools.tools === 0 ? '' : '(执行拦截 + 可见性摘除)'),
234
+ servers: disabledTools.servers, tools: disabledTools.tools,
235
+ });
236
+ // 工具表按每个请求付钱:这一行回答"这一轮实际发出去多少"。关掉(无论出厂默认还是
237
+ // 用户自己关)都是**配置事实**而非故障 —— 状态恒为 ok,琥珀只留给功能出问题
238
+ // (口径与 MCP 工具停用一致,2026-09-28 用户裁定)。出厂默认关掉的那几条单说:
239
+ // 那是插件替用户做的一个可逆选择,与用户自己关的分开计数。
240
+ // 说明只保留一句关了多少:省多少 tok、去哪开关、面板不受影响、skill_manager_read
241
+ // 让位这些次级信息都不上功能总览(正常行一行读完,2026-09-28 用户反馈);
242
+ // 逐会话的让位差额(index.ts 的 CARRIER_DUPLICATES)在别处自有交代。
178
243
  const table = deps.toolTableReport();
179
- const carrierNote = ';官方 `skill` 工具在场的会话,`skill_manager_read` 还会自动让位一份';
180
244
  const userOff = table.hiddenCount - table.defaultHiddenCount;
181
245
  const offPart = table.hiddenCount === 0
182
- ? `${table.totalCount} 个工具全部下发(≈${table.totalTok} tok/轮)`
246
+ ? `${table.totalCount} 个工具全部下发`
183
247
  : (userOff === 0
184
248
  ? `出厂默认关掉 ${table.defaultHiddenCount}/${table.totalCount} 个`
185
- : `关掉 ${table.hiddenCount}/${table.totalCount} 个(含出厂默认 ${table.defaultHiddenCount} 个)`)
186
- + `:一轮少发 ≈${table.hiddenTok} tok(现在 ≈${table.visibleTok} tok/轮,到「兼容」页的「模型工具表」可逐条打开;面板不受影响)`;
187
- push('tool-table', '模型工具表', 'compat', userOff === 0 ? 'ok' : 'partial', offPart + carrierNote);
188
- push('native-delete', '宿主原生删除入口', 'compat', notes.has('workspace.delete-native') ? 'partial' : 'ok', notes.get('workspace.delete-native')?.detail ?? '宿主未提供原生删除入口(本插件自有完整序列)');
249
+ : `关掉 ${table.hiddenCount}/${table.totalCount} 个(含出厂默认 ${table.defaultHiddenCount} 个)`);
250
+ push({
251
+ key: 'tool-table', label: '模型工具表', tab: 'compat', state: 'ok', detail: offPart,
252
+ hidden: table.hiddenCount, total: table.totalCount, defaults: table.defaultHiddenCount,
253
+ });
254
+ const nativeNote = notes.get('workspace.delete-native');
255
+ push({
256
+ key: 'native-delete', label: '宿主原生删除入口', tab: 'compat',
257
+ state: nativeNote ? 'partial' : 'ok',
258
+ detail: nativeNote?.detail ?? '宿主未提供原生删除入口(本插件自有完整序列)',
259
+ ...noteFields(nativeNote),
260
+ });
189
261
  // 身份 / 版本:与 compat-status 同一份数据。
190
262
  const identity = assessment?.identity;
191
263
  const identityIssue = identity !== undefined && (identity.blockers.length > 0 || Object.values(identity.sameAsHost).some((same) => same === false));
192
- push('host-identity', '宿主身份与模块', 'compat', assessment === undefined ? 'unknown' : identityIssue ? 'degraded' : 'ok', assessment === undefined ? '能力探测不可用(归档服务未挂载)'
193
- : identityIssue ? '存在阻塞项:' + (identity?.blockers ?? []).join(';')
194
- : `模块与宿主同源(宿主 DSH ${identity?.version ?? '?'})`);
264
+ // 归档宿主(桌面版):不是阻塞项,但"模块与宿主同源"这句话对它不成立(同版本、
265
+ // 不同文件),所以总览那一行要换句话讲,而不是继续报一句善意的假话。
266
+ const asarHost = identity?.notes.find((note) => note.kind === 'asar-host');
267
+ push({
268
+ key: 'host-identity', label: '宿主身份与模块', tab: 'compat',
269
+ state: assessment === undefined ? 'unknown' : identityIssue ? 'degraded' : 'ok',
270
+ detail: assessment === undefined ? '能力探测不可用(归档服务未挂载)'
271
+ : identityIssue ? '存在阻塞项:' + (identity?.blockers ?? []).join(';')
272
+ : asarHost !== undefined
273
+ ? `官方包与宿主同版本,但宿主跑在 ${asarHost.asarName} 里(插件在归档外)`
274
+ : `模块与宿主同源(宿主 DSH ${identity?.version ?? '?'})`,
275
+ ...(assessment === undefined ? {} : {
276
+ version: identity?.version ?? '?',
277
+ blockers: identity?.blockers ?? [],
278
+ ...(asarHost === undefined ? {} : { asarHost: true, asarName: asarHost.asarName }),
279
+ }),
280
+ });
195
281
  // 无独立装配点的域:没有上报就是正常(如实说明依据是"没有降级上报")。
196
282
  for (const [key, label, tab] of [['memory', '记忆', 'memory'], ['prompts', '提示词', 'prompts'], ['subagents', '子智能体', 'subagents'], ['scenes', '场景档案', 'scenes']]) {
197
- push(key, label, tab, 'ok', '无降级上报(装配失败会出现在这里)');
283
+ push({ key, label, tab, state: 'ok', detail: '无降级上报(装配失败会出现在这里)' });
198
284
  }
199
285
  return { ok: true, rows, generatedAt: Date.now() };
200
286
  }
@@ -230,7 +316,7 @@ export function buildCompatOps(deps) {
230
316
  ...(r.failed.length ? { failedCount: r.failed.length, failedNames: r.failed } : {}),
231
317
  };
232
318
  },
233
- // 注入实况(只读):最近活跃会话里模型**真正看到**的五域文本 + 那一段对话的投递统计。
319
+ // 注入实况(只读):最近活跃会话里模型**真正看到**的各域文本 + 那一段对话的投递统计。
234
320
  // 回答"勾了开关到底送没送到"——界面配置与实际注入不一致时,这里一眼可见。
235
321
  'injection-live': async () => {
236
322
  const live = deps.getContextInjectorLive();
@@ -108,6 +108,18 @@ export function buildSceneRecordOps(rc) {
108
108
  if (name === GLOBAL_SCENE)
109
109
  next.label = GLOBAL_SCENE_LABEL;
110
110
  index.scenes[name] = next;
111
+ // 模型工具表方案绑定(0.15.0 用户裁定:入口改到「修改场景」表单):绑定存**档案**,
112
+ // 只在真的绑了东西时才建档案存根 —— 不绑就保持「没有档案」的原语义
113
+ //(没有档案与有空档案在进入场景的预览提示上不等价)。
114
+ const toolTablePreset = args && args.toolTablePreset !== undefined ? String(args.toolTablePreset).trim() : '';
115
+ if (toolTablePreset.length > 64) {
116
+ return fail('error.rules.invalidGroup', `工具表方案名过长(≤64 字符)`);
117
+ }
118
+ if (toolTablePreset !== '') {
119
+ if (!index.archives)
120
+ index.archives = {};
121
+ index.archives[name] = { ...(index.archives[name] || {}), toolTablePreset };
122
+ }
111
123
  await writeIndex(stateDir, index);
112
124
  invalidateSnapshot();
113
125
  return { ok: true, scene: sceneRecordOf(name, next), ...(collapsed ? { collapsedActive: true } : {}) };
@@ -158,6 +170,28 @@ export function buildSceneRecordOps(rc) {
158
170
  else
159
171
  next.prompt = prompt;
160
172
  }
173
+ if (args && args.toolTablePreset !== undefined) {
174
+ // 模型工具表方案绑定(0.15.0 用户裁定:入口改到「修改场景」表单):绑定存**档案**,
175
+ // 记录与档案同在一份索引 JSON,这一次读-改-写顺带落掉。''= 解绑(删字段;档案因此
176
+ // 变空时整条删掉,与档案保存「空档案不落条目」的约定一致);不传这个参数 = 完全不碰
177
+ // 这一域(旧调用方兼容)。绑了不存在的方案在这里不硬失败 —— 引擎在进出场景时按
178
+ // stale 丢弃并报告,和档案保存对悬空绑定的处理同口径。
179
+ const toolTablePreset = String(args.toolTablePreset).trim();
180
+ if (toolTablePreset.length > 64) {
181
+ return fail('error.rules.invalidGroup', `工具表方案名过长(≤64 字符)`);
182
+ }
183
+ if (!index.archives)
184
+ index.archives = {};
185
+ const archive = { ...((index.archives && index.archives[name]) || {}) };
186
+ if (toolTablePreset === '')
187
+ delete archive.toolTablePreset;
188
+ else
189
+ archive.toolTablePreset = toolTablePreset;
190
+ if (Object.keys(archive).length > 0)
191
+ index.archives[name] = archive;
192
+ else
193
+ delete index.archives[name];
194
+ }
161
195
  if (args && args.label !== undefined && name !== GLOBAL_SCENE) {
162
196
  const label = String(args.label).trim();
163
197
  if (label === '')
@@ -10,7 +10,18 @@ export function buildSceneSyncOps(deps) {
10
10
  // 的 `agentsMd` 字段(`applied` / `restored` / `unchanged` / `error`)。
11
11
  // 组装时放在 memoriesService.ops 的 spread **之后**,显式覆盖同名 op。
12
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)),
13
+ 'rules-update-scene': async (args) => {
14
+ const res = await deps.rulesOps['rules-update-scene'](args);
15
+ // 工具表方案绑定改动(0.15.0):改的是**当前启用场景**且这次真的带了这一参数时即时生效;
16
+ // 改名在场景处于模式中时会被拦(rulesUpdateScene 的 sceneInMode),所以这里 args.name 就是生效场景的名字。
17
+ if (res && res.ok !== false && deps.onToolTablePresetChanged && args && args.toolTablePreset !== undefined) {
18
+ try {
19
+ await deps.onToolTablePresetChanged(String(args.name || ''), String(args.toolTablePreset));
20
+ }
21
+ catch { /* 即时生效失败不拦保存;下次进/退场景会对齐 */ }
22
+ }
23
+ return deps.withAgentsMdSync(res);
24
+ },
14
25
  'rules-create-scene': async (args) => deps.withAgentsMdSync(await deps.rulesOps['rules-create-scene'](args)),
15
26
  'rules-remove-scene': async (args) => deps.withAgentsMdSync(await deps.rulesOps['rules-remove-scene'](args)),
16
27
  };
@@ -54,7 +54,27 @@ export function buildSessionOps(deps) {
54
54
  ? (await registry.archivedSessionDetails()).items
55
55
  : (await registry.archivedSessionMetadata()).items;
56
56
  const archived = new Set(archivedItems.map((i) => i.sessionId));
57
+ // 官方契约(dsh-session-projection-cache 源码):cachedSnapshot(meta, keys?) 两参,
58
+ // keys 缺省 = 全部 wire 键;keys 传非可迭代值会在 viewCheckpoint 里 new Set 抛错。
59
+ // 旧格式代际的缓存记录过不了 cachedSnapshot 的身份校验,官方另有只认标题的
60
+ // cachedPredecessorTitle 提示面(api-session-controller 的列表路径正是两者相或)。
57
61
  const cache = deps.host('sessionProjectionCache');
62
+ const titleOf = (header) => {
63
+ if (!cache)
64
+ return undefined;
65
+ try {
66
+ const snap = typeof cache.cachedSnapshot === 'function' ? cache.cachedSnapshot(header, ['title']) : undefined;
67
+ const current = snap && snap.values ? snap.values.title : undefined;
68
+ if (typeof current === 'string')
69
+ return current;
70
+ const legacy = typeof cache.cachedPredecessorTitle === 'function' ? cache.cachedPredecessorTitle(header) : undefined;
71
+ const legacyTitle = legacy && legacy.values ? legacy.values.title : undefined;
72
+ return typeof legacyTitle === 'string' ? legacyTitle : undefined;
73
+ }
74
+ catch {
75
+ return undefined;
76
+ }
77
+ };
58
78
  const items = [];
59
79
  for (const h of headers) {
60
80
  const sessionId = h && typeof h.id === 'string' ? h.id : undefined;
@@ -71,14 +91,9 @@ export function buildSessionOps(deps) {
71
91
  item.cwd = h.cwd;
72
92
  if (typeof h.createdAt === 'number' && Number.isFinite(h.createdAt))
73
93
  item.createdAt = h.createdAt;
74
- if (cache && typeof cache.cachedSnapshot === 'function') {
75
- try {
76
- const snap = cache.cachedSnapshot(h, 0, ['title']);
77
- if (snap && snap.values && typeof snap.values.title === 'string')
78
- item.title = snap.values.title;
79
- }
80
- catch { /* title best-effort */ }
81
- }
94
+ const title = titleOf(h);
95
+ if (title !== undefined)
96
+ item.title = title;
82
97
  // 工作区目录可能已被删除/移动(孤儿会话):best-effort 标记,供导出选择时识别。
83
98
  if (item.cwd) {
84
99
  try {