dsh-compact-relay 0.4.2 → 0.5.1

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.
package/TUTORIAL.md CHANGED
@@ -15,7 +15,8 @@
15
15
  | 琥珀(调整色) | 压缩区(断点1 → 交接线) | 点指示灯 →「压缩旧会话」,或输入 `/bc` |
16
16
  | 红(上限色 / 内圆红点) | 交接区(交接线之后),或摘要太多 | 点指示灯 →「交接新会话」,或输入 `/handoff` |
17
17
  | 警示红(原生线) | DSH 原生自动压缩线的**实际触发点**(紫色径向刻度;deepseek-flash 约 67.8%,配置阈值 80%);越过实际触发点后整环红色闪烁 | 再往前 DSH 会自己压历史,先压缩或交接 |
18
- | 品红(锁定色) | 这里锁了一条重要消息(按它在对话里的位置显示,可多个) | 知道「我锁的东西在哪」 |
18
+
19
+ **环只表达容量**:0.5.1 起锁定消息不再在环上标位置(位置标记归 0.6 时间线)。锁定了几条、有没有失效,看悬停卡的「已锁定 N 条 / 已失效 M 条」和菜单里的「已锁 N 条 · 失效 M」。
19
20
 
20
21
  **断点**(环上的小缺口)=「我判断这里适合压缩」的位置:上下文进入可压缩区间(最小压缩线到压缩线之间)时,系统会主动在提示里告诉模型当前容量,模型判断「刚完成一个自然节点」时**可能**会记录一个压缩节点;到了压缩线也会自动出现。压缩后环变短,断点自然消失。
21
22
 
@@ -35,7 +36,27 @@
35
36
 
36
37
  菜单里也有同名入口,效果一样。
37
38
 
38
- ## 3. 设置在哪
39
+ ## 3. 交接之后:接力条怎么用
40
+
41
+ 交接完成后,新会话的输入卡上方会出现一条「已接力自 历史 N:<原名>」,点开是一个导航菜单(0.5.0 起):
42
+
43
+ | 菜单项 | 干什么 |
44
+ |---|---|
45
+ | 查看交接单 | 打开这一跳的九段交接单(旧记录没存路径时显示「暂无交接单」,不猜) |
46
+ | 去新会话 | 父会话侧才有:切回这次交接出来的新会话(它被归档过会先自动恢复) |
47
+ | 回到原会话 | 切回上一个会话;已归档时先「取消归档」再切,不会撞墙 |
48
+ | 恢复旧会话 | 旧会话被自动归档时把它恢复出来(侧栏「全部对话(显示已归档)」里也有) |
49
+ | 完整交接链 | 点开才读取,一次列出「历史 3 / 历史 2 / 历史 1」(祖先没打开也能读);每行可开那一跳的交接单、点整行跳过去(最多 8 层;某层未加载时给「打开并继续」) |
50
+ | 路线图 | 跨会话时间线的占位,0.6 之后 |
51
+
52
+ 几个常见现象:
53
+
54
+ - 一条会话既接过别人、又交出去过(中段会话),菜单里会同时有「回到原会话」和「去新会话」。
55
+ - 祖先会话交接后接力条**不会**消失(0.5.1 修复);0.5.0 之前生成的老链也会补上接力条。
56
+ - 交接预览里有一个勾选框「交接后自动归档旧会话」(默认开)。**旧会话在侧栏置顶时**,归档会取消置顶,所以预览会先问你:点「交接并归档(取消置顶)」才归档,点「只交接,保留置顶」就都留着。
57
+ - 锁定消息被压缩带走后不会凭空消失:悬停卡与菜单会标「已失效 N 条(随原生压缩移除)」;如果 DSH 原生压缩**即将**覆盖锁定消息,插件会提前提示「先交接(/handoff)或解锁」。
58
+
59
+ ## 4. 设置在哪
39
60
 
40
61
  打开 DSH「设置」→ 左侧「压缩接力」。页面标题下有一句话说明这页做什么、边界在哪(只动本插件的压缩/交接历史,不改模型回复);6 个区块:压缩提示 → 压缩行为 → DSH 原生自动压缩 → 颜色与外观 → 帮助 → 关于与危险操作。
41
62
 
@@ -48,11 +69,11 @@
48
69
  - **DSH 原生自动压缩**:状态 + 一键关闭/恢复(会先确认,改 profile 配置前备份,重启生效);原生开着时交接线硬上限 = **实际触发线 −5pp**(0.4.2 起;deepseek-flash 1M 下是 62%),超限保存会被拒绝并提示目标值,设置页有「按实际线重算」一键改成上限
49
70
  - **三条线(deepseek-flash 1M 默认)**:40% 压缩提醒线 → 60% 交接提醒线 → 67.8% 原生实际触发线(配置阈值 80% 只是理论上限,输出预留 + DSH 余量把真实触发点压到 67.8%)。交接线晚于实际触发线时,环上交接刻度、悬停卡、`/handoff` 菜单三处都会告警
50
71
  - **交接单目录**:九段交接单保存位置
51
- - **颜色**:选 5 个基准色(默认/填满/调整/上限/锁定),浅色/深色两套自动派生;每行都有「作用」和「调整后」两行说明
72
+ - **颜色**:选 5 个基准色(默认/填满/调整/上限/锁定),浅色/深色两套自动派生;每行都有「作用」和「调整后」两行说明。锁定色 0.5.1 起保留给 0.6 时间线的 pin 位置标记,环上不再画
52
73
 
53
74
  改完点「保存」,**立即生效,不需要重启**。
54
75
 
55
- ## 4. 常见问题
76
+ ## 5. 常见问题
56
77
 
57
78
  - **指示灯显示「上下文未知」**:会话刚打开,等半秒到 1.5 秒会自己出数。
58
79
  - **没有「可压」内容**:会话太短,或近期内容需要保留原文,或锁定把范围挡住了(先 `/unpin`)。
@@ -61,8 +82,11 @@
61
82
  - **提示「交接线已晚」/ 保存被拒**:说明当前交接线晚于 DSH 原生压缩线,DSH 会先自己压历史。三种处理:① 点设置页「按实际线重算」(一键改成实际线 −5pp,最省事);② 手动把交接线调到提示的目标值;③ 关掉「DSH 原生自动压缩」(不再有截止线)。实际触发线算不出时不会告警,也不会因此拒绝保存。
62
83
  - **设置保存了但没变**:设置栏保存即时生效;改代码或 profile 配置才需要重启 DSH。
63
84
  - **在 headless / QQ 桥上发 `/bc` 没反应**:指示灯和 `/bc`、`/handoff` 依附桌面输入框与斜杠命令,headless 会话和 QQ / 微信桥接链路不会加载它们,`/bc` 会被当成普通消息。插件本身不报错、也不额外烧模型调用;要在电脑上开 DSH 桌面版才能用。
85
+ - **接力条写着「暂无交接单」**:这条接力记录里没有交接单路径。0.5.0 之前的老链只把路径写在父会话记录里,插件会先向父会话只读回查一次;仍读不到就保持「暂无交接单」,不会猜一个路径给你。
86
+ - **「完整交接链」里某一行读不到**:默认整条链由服务端只读持久化日志,祖先没打开也能读。老宿主没有该端点、且那一层还没被加载时,菜单会显示「上一层会话未打开」+「打开并继续」:点它会先取消归档(幂等)再打开那一层,加载后自动续读;也可以点「重试」从断点继续(已经读到的层不会重复拉)。
87
+ - **点「去新会话」没切过去**:先看提示。新会话如果被归档过,插件会先「取消归档」再切;宿主不支持恢复会话时,从侧栏「全部对话(显示已归档)」里点一下即可。
64
88
 
65
- ## 5. 更多
89
+ ## 6. 更多
66
90
 
67
91
  - 安装、完整说明见仓库 `README.md`;设计文档见 `ARCHITECTURE.md`。
68
92
  - 遇到 bug / 想提改进:到仓库 Issues 提一条(设置页「帮助」区的「反馈 / 提 bug」入口会直接打开);也可以发邮件到 cfanzx@136.com。
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import fs from 'node:fs';
19
19
  import path from 'node:path';
20
+ import { clipCodeUnits } from '../core/clip.js';
20
21
  import { actionError } from '../core/errors.js';
21
22
  import { pinAnchorOf } from '../core/pin-anchors.js';
22
23
  import { handoffFileName, renderHandoffDoc } from '../core/handoff-doc.js';
@@ -32,8 +33,9 @@ import { checkHandoffReadiness } from '../core/readiness.js';
32
33
  import { changedFilesOf } from '../dsh/session-files.js';
33
34
  import { errorCodeOf, localIso } from '../dsh/diag-counters.js';
34
35
  import { surfaceMessages } from '../dsh/prefix.js';
35
- import { handoffTitlePlanOf, createHandoffSession, sourceTitleOf } from '../dsh/sessions.js';
36
+ import { handoffTitlePlanOf, createHandoffSession, renameHistoryOf, sourceTitleOf } from '../dsh/sessions.js';
36
37
  import { writeRelayRecord } from '../dsh/relay.js';
38
+ import { archiveHandoffSource, handoffArchiveNoteOf, isSessionPinned } from '../dsh/handoff-archive.js';
37
39
  import { withLock } from './lock.js';
38
40
  import { beginInflight, cancelErrorOf, combinedSignal, finishInflight } from './support.js';
39
41
 
@@ -96,13 +98,15 @@ async function collectHandoffFacts(deps, agent) {
96
98
  * 不记 message(message 可能带交接单路径/会话内容)。行为与文案不变。
97
99
  *
98
100
  * @param {{ctx:object, cfg:object, lock:object, engine:object, log?:object, diag?:object}} deps
99
- * @param {{agent:object, signal?:AbortSignal, commandId?:string, edits?:Record<string,string>}} input
101
+ * @param {{agent:object, signal?:AbortSignal, commandId?:string, edits?:Record<string,string>, archive?:boolean, confirmUnpin?:boolean}} input
100
102
  * edits 只允许 `{约束?:string, 下一步?:string}`(P2-1;非法字段抛 code='edits')
103
+ * archive 默认 true(0.5.0 行为);false = 只交接不归档
104
+ * confirmUnpin 默认 false:旧会话在侧栏置顶时,必须显式确认才归档(归档会取消置顶)
101
105
  */
102
- export async function handoff(deps, { agent, signal, commandId, edits } = {}) {
106
+ export async function handoff(deps, { agent, signal, commandId, edits, archive, confirmUnpin } = {}) {
103
107
  const startedAt = Date.now();
104
108
  try {
105
- const outcome = await runHandoff(deps, { agent, signal, commandId, edits });
109
+ const outcome = await runHandoff(deps, { agent, signal, commandId, edits, archive, confirmUnpin });
106
110
  deps.diag?.bumpAction('handoff', true);
107
111
  return outcome;
108
112
  } catch (error) {
@@ -117,9 +121,9 @@ export async function handoff(deps, { agent, signal, commandId, edits } = {}) {
117
121
  /**
118
122
  * 交接流程本体。
119
123
  * @param {{ctx:object, cfg:object, lock:object, engine:object, log?:object}} deps
120
- * @param {{agent:object, signal?:AbortSignal, commandId?:string}} input
124
+ * @param {{agent:object, signal?:AbortSignal, commandId?:string, edits?:object, archive?:boolean, confirmUnpin?:boolean}} input
121
125
  */
122
- async function runHandoff(deps, { agent, signal, commandId, edits }) {
126
+ async function runHandoff(deps, { agent, signal, commandId, edits, archive, confirmUnpin }) {
123
127
  if (agent === undefined || agent === null) throw actionError('handoff', '当前没有打开的会话,无法交接');
124
128
  const session = agent.session;
125
129
  if (session === undefined) throw actionError('handoff', '没有活动会话,无法交接');
@@ -231,6 +235,10 @@ async function runHandoff(deps, { agent, signal, commandId, edits }) {
231
235
  agent,
232
236
  doc,
233
237
  title: titlePlan.title,
238
+ // 0.5.0:交接单已经落盘,把同一个路径写进子会话 relay 记录(接力条
239
+ // 「查看交接单」不再依赖回查父会话);父记录仍写同一份 path。
240
+ path: file,
241
+ plan: titlePlan,
234
242
  log: deps.log,
235
243
  });
236
244
  } catch (error) {
@@ -243,8 +251,21 @@ async function runHandoff(deps, { agent, signal, commandId, edits }) {
243
251
  throw error;
244
252
  }
245
253
 
254
+ // 0.5.0 命名反转:新链在**子会话建好之后**把旧会话改名「历史 N:<原名>」,
255
+ // 新会话已经继承干净原名(createHandoffSession 里改名)。旧链(handoff-n)不动,
256
+ // 保持「交接第 N 次:」一个字符不改(链级 cutover)。
257
+ // 改名是可见副作用:失败只 warn,交接照常成功;成功后 renameHistoryOf 内部 flush。
258
+ const historyRename = await renameHistoryOf(deps.ctx, session, {
259
+ index: titlePlan.index,
260
+ baseTitle: titlePlan.baseTitle,
261
+ scheme: titlePlan.scheme,
262
+ log: deps.log,
263
+ });
264
+
246
265
  // A5:把接力链写成父会话里的结构化记录;写失败不阻断交接(标题解析仍可兜底)。
247
266
  // 0.4 P2-2:canonical 字段 parentId/childId/ordinal/at/atIso 补齐;旧字段 parentSessionId/index 保留兼容。
267
+ // 0.5.0:父/子记录都写 scheme(本次链的命名方案)+ titleSeq(各自 rename 的 eventSeq);
268
+ // 新链父记录再写 historyTitle(旧会话改名后的标题),供审计/幂等核对。
248
269
  const relayAt = Date.now();
249
270
  const relayWrote = writeRelayRecord(deps.engine, session, {
250
271
  parentId: session.id,
@@ -258,17 +279,53 @@ async function runHandoff(deps, { agent, signal, commandId, edits }) {
258
279
  planId: compactOutcome?.planId ?? null,
259
280
  title: titlePlan.title,
260
281
  baseTitle: titlePlan.baseTitle,
282
+ scheme: titlePlan.scheme,
283
+ titleSeq: historyRename.titleSeq,
284
+ // historyTitle 只在改名真的生效时写;失败/服务不可用时不能记一个不存在的标题。
285
+ ...(titlePlan.scheme === 'history-n' && (historyRename.renamed || historyRename.reason === 'already')
286
+ ? { historyTitle: historyRename.title }
287
+ : {}),
261
288
  });
262
289
  if (!relayWrote) deps.log?.warn?.('compact-relay: 接力链记录写入失败,下次交接将回退到标题解析');
290
+ // renameHistoryOf 的 flush 发生在父记录之前;这里再 flush 一次,保证旧会话的
291
+ // 「历史 N」标题与这条父记录一起落盘(失败只 warn,不影响交接结果)。
292
+ try {
293
+ await deps.ctx.sessions?.flush?.(session);
294
+ } catch (error) {
295
+ deps.log?.warn?.(`compact-relay: 旧会话接力链记录落盘失败:${error instanceof Error ? error.message : String(error)}`);
296
+ }
297
+
298
+ // 0.5.0:交接成功后自动归档旧会话(让列表只剩新会话)。
299
+ // 0.5.1 批 2:archive:false = 只交接不归档;旧会话在侧栏置顶时必须显式
300
+ // confirmUnpin 才归档(宿主归档会在同一次写盘里取消置顶 —— dsh-workspace 源码)。
301
+ // 顺序要求:子会话已建好、旧会话已改名、父记录已 flush —— 全部完成之后才归档;
302
+ // 归档失败只 warn + 在返回文案里说明,绝不回滚/阻断交接。
303
+ // 不传 stopActivity:自动流程不替用户取消旧会话里仍在跑的 turn/subagent/job/schedule;
304
+ // 只对收尾中的 turn 做有限等待重试,仍活跃就保留可见并提示(宁可多一行,不静默杀任务)。
305
+ const archiveRequested = archive !== false;
306
+ let archiveResult;
307
+ if (!archiveRequested) {
308
+ archiveResult = { status: 'skipped', archived: false, activityKinds: [] };
309
+ } else if (isSessionPinned(deps.ctx, session.id) === true && confirmUnpin !== true) {
310
+ archiveResult = { status: 'pinned', archived: false, activityKinds: [] };
311
+ deps.log?.warn?.(
312
+ `compact-relay: 旧会话 ${session.id} 在侧栏置顶,未确认前不自动归档(归档会取消置顶;交接已完成)`,
313
+ );
314
+ } else {
315
+ try {
316
+ archiveResult = await archiveHandoffSource(deps.ctx, session.id, { log: deps.log });
317
+ } catch (error) {
318
+ // archiveHandoffSource 设计上不抛;这里再兜一层,任何替身/内核异常都不影响交接结果。
319
+ deps.log?.warn?.(`compact-relay: 旧会话归档异常:${error instanceof Error ? error.message : String(error)}(交接已完成)`);
320
+ archiveResult = { status: 'failed', archived: false, activityKinds: [] };
321
+ }
322
+ }
323
+ const archiveNote = handoffArchiveNoteOf(archiveResult);
263
324
 
264
325
  // BUG-004:失败原因可能是「摘要模型调用了工具」这类可操作信息,以前只写「日志有记录」
265
326
  // (用户看不到 host 日志);这里带上短原因 + 可手动 /bc 的建议。
266
- const briefWarning =
267
- compactWarning === null
268
- ? ''
269
- : compactWarning.length > 120
270
- ? `${compactWarning.slice(0, 119)}…`
271
- : compactWarning;
327
+ // 0.5.1 D03:截断收口到 core/clip(原手写 120 字预算,文案不变)。
328
+ const briefWarning = compactWarning === null ? '' : clipCodeUnits(compactWarning, 120);
272
329
  const compactNote =
273
330
  compactOutcome !== null && compactOutcome.compacted === true
274
331
  ? `已将 ${compactOutcome.shadowedSeqs.length} 条旧会话压成 1 条摘要;`
@@ -300,11 +357,14 @@ async function runHandoff(deps, { agent, signal, commandId, edits }) {
300
357
  path: file,
301
358
  readiness,
302
359
  started: created.started,
360
+ // 0.5.0:归档结果结构化返回(HTTP 暂不下发;客户端看 message/接力条状态即可)。
361
+ archive: { status: archiveResult.status, archived: archiveResult.archived === true },
303
362
  message:
304
363
  `交接完成:${compactNote}` +
305
364
  `交接单已存至 ${file};` +
306
365
  `新会话(${created.childId})已就绪` +
307
366
  (created.started ? ',正在切换。' : ',正在切换(首轮未自动启动,可在新会话里发一条消息继续)。') +
367
+ archiveNote +
308
368
  // A5 兜底:结构化 childId 没传下来时,客户端可从文本里提取。
309
369
  `\n#child=${created.childId}`,
310
370
  };
@@ -176,16 +176,17 @@ export function listPins(deps, { agent } = {}) {
176
176
  }
177
177
 
178
178
  /**
179
- * 菜单用:有锁定就清空,没有就锁最近一轮。
179
+ * 菜单用:有锁定(含全部已失效的快照)就清空,没有就锁最近一轮。
180
180
  *
181
- * P3-03:判断必须和 readState 的展示同口径(`livePinnedSeqs`)。只看日志里的最新
182
- * 快照时,快照 seq 已被压缩/清屏带走的场景会让菜单显示「锁定重要消息」(未锁),
183
- * 点下去却走 unpinAll → 用户看到的是「明明没锁却被解锁」。
181
+ * 0.5.1 批 2:菜单在「全部 pin 已被原生压缩带走」时显示「解除失效锁定(N 条)」,
182
+ * 所以这里必须按**快照**判定:快照非空 → unpinAll,不能因为 live=0 就反过来新锁
183
+ * 一条(按钮文案与动作会完全相反)。`pinLastTurn`/`pinByIndex` 内部仍只带 live
184
+ * 集合进新快照,顺手清掉过期 seq。
184
185
  * @param {{engine:object}} deps
185
186
  * @param {{agent:object}} input
186
187
  */
187
188
  export function togglePin(deps, { agent } = {}) {
188
189
  const session = agent?.session;
189
- if (session === null || session === undefined) throw actionError('pin', '没有活动会话');
190
- return livePinnedSeqs(session).length > 0 ? unpinAll(deps, { agent }) : pinLastTurn(deps, { agent });
190
+ if (session === null || session === undefined) throw actionError('pin', '当前没有活动会话');
191
+ return latestPinnedSeqs(sessionLog(session)).length > 0 ? unpinAll(deps, { agent }) : pinLastTurn(deps, { agent });
191
192
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 0.5.0:恢复归档的旧会话(交接后的恢复入口,HTTP `action: 'recover'`)。
3
+ *
4
+ * 这是客户端接力条恢复入口的**兜底**:新版客户端优先直接调
5
+ * `ctx.uiWorkspace.unarchiveSession(sessionId)`(宿主 Client 侧同一条接缝,
6
+ * 会同时更新本地归档集合);老客户端/客户端拿不到 `unarchiveSession` 时,
7
+ * 走本插件自己的 HTTP 动作,服务端调 `workspaceRegistry.unarchiveSession`。
8
+ *
9
+ * 幂等:宿主 unarchiveSession 对不在归档集合里的 id 直接 no-op,所以重复点
10
+ * 「恢复旧会话」不会报错、也不会改 workspace 位置(归档从不改成员关系)。
11
+ */
12
+ import { actionError } from '../core/errors.js';
13
+ import { archivedSessionIdsOf, isSessionArchived, workspaceRegistryOf } from '../dsh/handoff-archive.js';
14
+
15
+ function errorMessageOf(error) {
16
+ return error instanceof Error ? error.message : String(error);
17
+ }
18
+
19
+ /**
20
+ * 把旧会话从归档集合里恢复。
21
+ * @param {{ctx:object, log?:{warn?:Function}}} deps
22
+ * @param {{sessionId:string}} input 要恢复的会话 id(不是当前会话)
23
+ * @returns {Promise<{ok:true, recovered:boolean, message:string, archivedSessionIds:string[]|null}>}
24
+ */
25
+ export async function recoverArchivedSession(deps, { sessionId } = {}) {
26
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
27
+ throw actionError('recover', '缺少要恢复的会话 id');
28
+ }
29
+ const registry = workspaceRegistryOf(deps.ctx);
30
+ if (registry === null || typeof registry.unarchiveSession !== 'function') {
31
+ throw actionError('recover', '当前 DSH 宿主没有 Workspace 归档恢复接口,无法恢复旧会话');
32
+ }
33
+
34
+ const wasArchived = isSessionArchived(deps.ctx, sessionId);
35
+ try {
36
+ await registry.unarchiveSession(sessionId);
37
+ } catch (error) {
38
+ deps.log?.warn?.(`compact-relay: 恢复归档会话 ${sessionId} 失败:${errorMessageOf(error)}`);
39
+ throw actionError('recover', `恢复旧会话失败:${errorMessageOf(error)}`, error);
40
+ }
41
+
42
+ return {
43
+ ok: true,
44
+ // false 表示本来就没归档(幂等 no-op),仍然算「恢复成功」——调用方可以直接打开。
45
+ recovered: wasArchived !== false,
46
+ message: wasArchived === false ? '这个会话本来就没有归档,可直接打开。' : '已恢复旧会话,可从侧栏重新打开。',
47
+ archivedSessionIds: archivedSessionIdsOf(deps.ctx),
48
+ };
49
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 完整交接链(只读)动作层 —— HTTP 适配层与 dsh 读取器之间的薄包装。
3
+ *
4
+ * 0.5.1 DCR-05:客户端点「完整交接链」时一次取回整条链;服务端按需只读持久化日志,
5
+ * **不把祖先会话加载进 ctx.sessions**(宿主对未加载会话的 readState 返回 sessionId:null,
6
+ * 逐级 GET 会停在断点)。读取细节见 dsh/relay-chain.js。
7
+ */
8
+ import { readRelayChain } from '../dsh/relay-chain.js';
9
+
10
+ /**
11
+ * @param {{ctx:object}} deps
12
+ * @param {{sessionId?:string}} [input]
13
+ * @returns {ReturnType<typeof readRelayChain>}
14
+ */
15
+ export function readCompactRelayChain(deps, { sessionId } = {}) {
16
+ return readRelayChain(deps?.ctx, { sessionId });
17
+ }
@@ -6,14 +6,19 @@
6
6
  */
7
7
  import { paletteForBothSchemes } from '../core/colors.js';
8
8
  import { adviceFor, ratioOf, thresholdsForWindow } from '../core/policy.js';
9
+ import { nativePinnedRiskOf } from '../core/native-pin-risk.js';
10
+ import { pinHealthOf } from '../core/pin-health.js';
11
+ import { latestPinnedSeqs } from '../core/pins.js';
9
12
  import { tokenOf } from '../core/tokens.js';
10
13
  import { measurePressure, routeBudgetOf } from '../dsh/pressure.js';
11
14
  import { selectCompactableRange } from '../dsh/select-range.js';
12
- import { compressHint as compressHintOf, countSummaries, lastCheckpointSurfaceSeq, livePinnedSeqs, liveSessionOf, pickAgent, surfaceSeqs } from '../dsh/session-view.js';
15
+ import { compressHint as compressHintOf, countSummaries, firstCompactableIdx, lastCheckpointSurfaceSeq, liveSessionOf, pickAgent, sessionLog, surfaceSeqs } from '../dsh/session-view.js';
13
16
  import { compressHintIsActive } from '../core/compress-hint.js';
14
17
  import { readNativeCompaction, resolveNativePolicy } from '../dsh/native-compaction.js';
15
- import { nativeLineOf } from '../core/native-line.js';
16
- import { relayRecordOf } from '../dsh/relay.js';
18
+ import { nativeLineOf, nativeRetainTokensOf } from '../core/native-line.js';
19
+ import { legacyChainParentIdOf, legacyRelayChainOf, relayChainOf, relayInboundRecordOf, relayOutboundRecordOf, relayRecordOf } from '../dsh/relay.js';
20
+ import { isSessionArchived, isSessionPinned, workspaceRegistryOf } from '../dsh/handoff-archive.js';
21
+ import { sourceTitleOf } from '../dsh/sessions.js';
17
22
 
18
23
  /**
19
24
  * 动作层统一解析「这次动作作用在哪个 agent」:只认显式 sessionId,
@@ -95,10 +100,20 @@ export async function readState(deps, { sessionId, agent } = {}) {
95
100
  const compactRangeReason = compactRange !== null && !hasLiveAgent ? 'no_agent' : null;
96
101
 
97
102
  // M6:钉住集合(菜单显示「清除钉住(N 条)」,也能在无 agent 时读 live session)。
98
- const pinnedSeqs = target === null ? [] : livePinnedSeqs(target.session);
103
+ // 0.5.1 批 2(C 方案):pin 快照 vs 当前 surface ——
104
+ // live = 还在 surface 上(环/计数/解锁仍按这些);
105
+ // stale = 快照里有、surface 上没了(原生压缩/其它压缩/历史变化带走)——
106
+ // 单独下发,界面标「已失效」,既不假装还在,也不凭空消失。
107
+ const surface = target === null ? [] : surfaceSeqs(target.session);
108
+ const events = target === null ? [] : sessionLog(target.session);
109
+ const pinHealth =
110
+ target === null || surface.length === 0
111
+ ? // 没有 surface(会话刚打开/尚未回放)时不能把全部 pin 判成失效:那是「还不知道」。
112
+ { live: [], stale: [], cause: null, causes: { native: 0, other: 0, unknown: 0 } }
113
+ : pinHealthOf({ pinnedSeqs: latestPinnedSeqs(events), surfaceSeqs: surface, log: events });
114
+ const pinnedSeqs = pinHealth.live;
99
115
  // 0.3.1:锁定消息在 surface 里的相对位置(0–1,方案 A:消息在历史中的位置),
100
116
  // 指示灯环上按位置画锁定块;多个锁定按位置从早到晚排序。
101
- const surface = target === null ? [] : surfaceSeqs(target.session);
102
117
  const surfaceIndex = new Map(surface.map((seq, index) => [seq, index]));
103
118
  const pinMarks = pinnedSeqs
104
119
  .filter((seq) => surfaceIndex.has(seq))
@@ -144,6 +159,89 @@ export async function readState(deps, { sessionId, agent } = {}) {
144
159
  }
145
160
  }
146
161
 
162
+ // 0.5.1 批 2:预判「下一次原生自动压缩会覆盖哪些锁定消息」——事前提示。
163
+ //
164
+ // 宿主信号:原生自动压缩只在 step boundary 触发(pressure ≥ 真实触发线后),
165
+ // 选段 = `selectCompactableRange(session, measurement, retainTokens)`:从第一个非
166
+ // system 节点起,逐字保留尾部 retainTokens,再按工具配对平衡回退。这里用同一份
167
+ // 定价节点(measured.nodes)+ 同一套保留预算复刻该范围,只读、不触发任何压缩。
168
+ //
169
+ // 只在「自动压缩开着 + 已到真实触发线 + 节点定价与 surface 对齐」时预警;
170
+ // 算不出/对不齐就返回 null(宁可不报,也不误报会覆盖)。
171
+ let pinRisk = null;
172
+ if (
173
+ target !== null &&
174
+ pinnedSeqs.length > 0 &&
175
+ nativeCfg.autoEnabled !== false &&
176
+ nativeLine !== null &&
177
+ nativeLine.ok === true &&
178
+ Number.isFinite(measured.tokens) &&
179
+ measured.tokens >= nativeLine.thresholdTokens &&
180
+ nativeBudget !== null &&
181
+ Array.isArray(measured.nodes) &&
182
+ measured.nodes.length === surface.length &&
183
+ measured.nodes.every((node, index) => node?.seq === surface[index])
184
+ ) {
185
+ const retainTokens = nativeRetainTokensOf({
186
+ contextWindow: nativeBudget.contextWindow,
187
+ reservedCompletionTokens: nativeBudget.reservedCompletionTokens,
188
+ retainRatio: nativePolicy.retainRatio,
189
+ retainTokens: nativePolicy.retainTokens,
190
+ });
191
+ if (retainTokens !== null) {
192
+ const risk = nativePinnedRiskOf({
193
+ pinnedSeqs,
194
+ nodes: measured.nodes,
195
+ firstIdx: firstCompactableIdx(target.session),
196
+ retainTokens,
197
+ isBalancedBefore: (seq) => {
198
+ try {
199
+ return deps.engine?.toolPairingBalancedBefore?.(target.session, seq) === true;
200
+ } catch {
201
+ return true; // 判定失败时按「可切」处理:只影响预警范围,不会真的压
202
+ }
203
+ },
204
+ });
205
+ if (risk !== null) pinRisk = { count: risk.count, seqs: risk.seqs };
206
+ }
207
+ }
208
+
209
+ //
210
+ // `relay` 是旧字段:日志里**最近一条**记录(出站/入站都可能)——语义一个字节不改,
211
+ // 下一次交接的链号递增(handoffTitlePlanOf)也依赖它。
212
+ // `chain` 改成「本会话入站记录」(childId===本会话)的只读投影:祖先会话再次交接后,
213
+ // 最近的记录变成出站(childId=下一代),旧实现照它下发 → 客户端「是不是本会话入站」
214
+ // 判定不成立 → 接力条消失(DCR-01)。没有入站记录时(DCR-02)用会话头
215
+ // header.parentSession + 标题兜底合成只读链(排除 subagent 会话与 fork 出来的会话),
216
+ // 让 0.5.0 之前生成、子会话没有记录的老链也能显示接力条。
217
+ const relay = target === null ? null : relayRecordOf(target.session);
218
+ const inbound = target === null ? null : relayInboundRecordOf(target.session);
219
+ const chain =
220
+ target === null
221
+ ? null
222
+ : inbound !== null
223
+ ? relayChainOf(inbound)
224
+ : // DCR-02:只有会话头看起来像老链时才去读标题(普通会话不付这次查找开销)。
225
+ legacyChainParentIdOf(target.session) === null
226
+ ? null
227
+ : legacyRelayChainOf({ session: target.session, title: sourceTitleOf(deps.ctx, target.session) });
228
+ // DCR-01:`next` = 本会话最近一条**出站**记录的只读投影(只加不改)。接力条不看它,
229
+ // 但「父→子入口」与「子会话向父会话补交接单 path」需要出站那一跳的信息。
230
+ const next = target === null ? null : relayChainOf(relayOutboundRecordOf(target.session));
231
+ // 0.5.0:旧会话是否已被自动归档(接力条据此显示「恢复旧会话」)。这是宿主
232
+ // registry 的独立状态,不塞进 chain(chain 仍然只是链投影)。
233
+ // 0.5.1 DCR-01:用**入站** parentId 查(出站记录里的父会话是本会话自己,会查错)。
234
+ // 拿不到 registry/归档集合 → null(不知道,客户端按「可尝试恢复」处理)。
235
+ const parentArchived =
236
+ chain === null || chain.parentId === null ? null : isSessionArchived(deps.ctx, chain.parentId);
237
+ // 0.5.1 批 3:出站记录的目标会话(下一代)是否已在归档集合里。父会话侧的「去新会话」
238
+ // 用它决定要不要先 unarchive(宿主拒绝打开归档会话;unarchive 幂等)。
239
+ // 和 parentArchived 完全对称:各查各的 id,读不到 → null(不猜 false)。
240
+ const nextArchived = next === null || next.childId === null ? null : isSessionArchived(deps.ctx, next.childId);
241
+ // 0.5.1 DCR-03:宿主有没有「恢复归档会话」的接缝(HTTP recover 动作调的就是它)。
242
+ // 客户端拿不到 ctx.uiWorkspace.unarchiveSession 时据此隐藏「恢复旧会话」死入口。
243
+ const capabilities = { unarchive: typeof workspaceRegistryOf(deps.ctx)?.unarchiveSession === 'function' };
244
+
147
245
  return {
148
246
  revision: ++stateRevision,
149
247
  sessionId: target?.id ?? null,
@@ -154,7 +252,19 @@ export async function readState(deps, { sessionId, agent } = {}) {
154
252
  summaries,
155
253
  advice,
156
254
  level: advice === 'none' ? 'idle' : advice,
157
- pins: { count: pinnedSeqs.length, seqs: pinnedSeqs.slice(-20), marks: pinMarks },
255
+ // 0.5.1 批 2:stale = 快照里有、surface 上已没有的 pin(被压缩/历史变化带走)。
256
+ // `count` 仍是 live 集合(老客户端语义不变);`stale` / `atRisk` 只加不改。
257
+ pins: {
258
+ count: pinnedSeqs.length,
259
+ seqs: pinnedSeqs.slice(-20),
260
+ marks: pinMarks,
261
+ stale: {
262
+ count: pinHealth.stale.length,
263
+ seqs: pinHealth.stale.slice(-20),
264
+ ...pinHealth.causes,
265
+ },
266
+ atRisk: pinRisk,
267
+ },
158
268
  compactRange,
159
269
  // 0.3.5:compactRange 非 null 但无活 agent 时为 'no_agent',其它情况 null。
160
270
  compactRangeReason,
@@ -164,8 +274,27 @@ export async function readState(deps, { sessionId, agent } = {}) {
164
274
  colors: { ...deps.cfg.colors, palette: paletteForBothSchemes(deps.cfg.colors) },
165
275
  // A4:设置文件有误时随状态下发,设置页/客户端可提示(正常为 null)。
166
276
  settingsError: deps.settings?.error ?? null,
167
- // A5:最近一次交接的接力链记录(没有就是 null)。
168
- relay: target === null ? null : relayRecordOf(target.session),
277
+ // A5:最近一次交接的接力链记录(没有就是 null)。0.5.0 F4:旧字段原样保留。
278
+ relay,
279
+ // 0.5.0 F4 / 0.5.1 DCR-01:本会话**入站**记录的只读链投影
280
+ // {ordinal, baseTitle, parentId, childId, scheme, path?};老链(无记录)可能是
281
+ // header 兜底合成的(多一个 synthesized:true)。没有链信息 → null。
282
+ chain,
283
+ // 0.5.1 DCR-01:最近一条**出站**记录的只读投影(本会话交接给下一代那一跳)。
284
+ // 只加不改:老客户端忽略;接力条不用它。没有出站记录 → null。
285
+ next,
286
+ // 0.5.0:链上父会话(入站 parentId)当前是否在 workspace 归档集合里(true/false);
287
+ // 没有父会话或宿主没有可读的归档状态 → null。只加不改。
288
+ parentArchived,
289
+ // 0.5.1 批 3:出站目标(next.childId)是否在同一归档集合里。父会话侧「去新会话」
290
+ // 据此先恢复再切;没有出站记录或读不到归档集合 → null。只加不改。
291
+ nextArchived,
292
+ // 0.5.1 批 2:本会话(agent.session,不是 agent.id)是否在侧栏置顶。
293
+ // 交接前客户端据此弹「归档会取消置顶」确认;读不到置顶集合 → null(不猜)。
294
+ sessionPinned: target === null || target.session === undefined ? null : isSessionPinned(deps.ctx, target.session.id),
295
+ // 0.5.1 DCR-03:宿主能力位(目前只有「能不能恢复归档会话」)。老客户端忽略;
296
+ // 新客户端在缺 `unarchive` 能力时隐藏恢复入口,不把用户带进必然失败的入口。
297
+ capabilities,
169
298
  // 0.3.1:模型判断的「适合压缩」断点位置(没有或已失效为 null)。
170
299
  compressHint: activeHint,
171
300
  // 0.3.1:DSH 原生自动压缩(环上的报警最高阈值 / 设置页开关的数据源)。
@@ -8,6 +8,8 @@ import { readCompactDiffs } from '../actions/compact-diff.js';
8
8
  import { readDiagnostics } from '../actions/diagnostics.js';
9
9
  import { handoff, previewHandoff } from '../actions/handoff.js';
10
10
  import { listHandoffDocs, readHandoffDoc } from '../actions/handoff-docs.js';
11
+ import { recoverArchivedSession } from '../actions/recover.js';
12
+ import { readCompactRelayChain } from '../actions/relay-chain.js';
11
13
  import { togglePin } from '../actions/pin.js';
12
14
  import { setNativeAutoCompactionEnabled } from '../actions/native-compaction.js';
13
15
  import { readState, resolveTarget } from '../actions/state.js';
@@ -182,6 +184,38 @@ export function registerHttp(webCtx, deps) {
182
184
  });
183
185
  webCtx.effect(() => stateStop, 'compact-relay: state endpoint');
184
186
 
187
+ // 0.5.1 DCR-05:完整交接链(一次读完,最多 8 层)。
188
+ //
189
+ // 旧客户端逐级 GET `/api/compact-relay-state?sessionId=父id`,宿主对未加载的祖先返回
190
+ // `sessionId:null` → 整条链只剩 1 层。这里改为服务端只读持久化日志(不加载会话),
191
+ // 把整条链一次返回;旧宿主没有 sessionPersistence 时返回 `ok:false`,客户端回落逐级读。
192
+ const chainStop = webCtx.webServer.register({
193
+ kind: 'exact',
194
+ path: '/api/compact-relay-chain',
195
+ handler: (req, res) => {
196
+ void (async () => {
197
+ try {
198
+ const rejection = hostRejection(webCtx, req);
199
+ if (rejection !== undefined) return denyAuth(res, rejection);
200
+ if (req.method !== 'GET') return send(res, 405, { ok: false, message: '只支持 GET' });
201
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
202
+ const sessionId = url.searchParams.get('sessionId') ?? '';
203
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
204
+ return send(res, 400, { ok: false, message: '缺少 sessionId' });
205
+ }
206
+ send(res, 200, await readCompactRelayChain(deps, { sessionId }));
207
+ } catch (error) {
208
+ try {
209
+ send(res, 500, { ok: false, message: errorText(error) });
210
+ } catch {
211
+ /* 连接可能已断 */
212
+ }
213
+ }
214
+ })();
215
+ },
216
+ });
217
+ webCtx.effect(() => chainStop, 'compact-relay: relay chain endpoint');
218
+
185
219
  const streamStop = webCtx.webServer.register({
186
220
  kind: 'exact',
187
221
  path: '/api/compact-relay-stream',
@@ -382,13 +416,23 @@ export function registerHttp(webCtx, deps) {
382
416
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
383
417
  return send(res, 400, { ok: false, message: '请求体必须是 JSON 对象' });
384
418
  }
419
+ // 0.5.1 批 2:交接的归档开关 / 置顶确认只接受布尔值;类型错在动历史前就拒绝。
420
+ if (parsed.archive !== undefined && typeof parsed.archive !== 'boolean') {
421
+ return send(res, 400, { ok: false, message: 'archive 必须是 true/false(false = 只交接、不归档旧会话)' });
422
+ }
423
+ if (parsed.confirmUnpin !== undefined && typeof parsed.confirmUnpin !== 'boolean') {
424
+ return send(res, 400, { ok: false, message: 'confirmUnpin 必须是 true/false(true = 确认归档并取消旧会话置顶)' });
425
+ }
385
426
  // 多数动作作用在具体会话上:缺 sessionId 或类型错时绝不能退化成「最后一个 agent」
386
427
  // (2026-10-19 debug:旧实现会压错会话)。
387
- // 例外:native-auto 改的是 DSH profile 配置(全局开关),不属于任何会话——
388
- // UI-003(2026-10-20):以前这里对所有 action 都先要 sessionId,设置页没带 →
389
- // 恒 400「缺少 sessionId」,于是「关闭自动压缩」永远写不进去、也看不到反馈。
428
+ // 例外:
429
+ // - native-auto 改的是 DSH profile 配置(全局开关),不属于任何会话——
430
+ // UI-003(2026-10-20):以前这里对所有 action 都先要 sessionId,设置页没带 →
431
+ // 恒 400「缺少 sessionId」,于是「关闭自动压缩」永远写不进去、也看不到反馈。
432
+ // - recover 作用在**已归档**的旧会话上,它通常没有活 agent;不能走 resolveTarget
433
+ // (否则恒 409「没有活动会话」)。sessionId 由 recover 分支自己校验。
390
434
  let agent = null;
391
- if (parsed.action !== 'native-auto') {
435
+ if (parsed.action !== 'native-auto' && parsed.action !== 'recover') {
392
436
  if (typeof parsed.sessionId !== 'string' || parsed.sessionId.length === 0) {
393
437
  return send(res, 400, { ok: false, message: '缺少 sessionId' });
394
438
  }
@@ -396,6 +440,15 @@ export function registerHttp(webCtx, deps) {
396
440
  if (agent === null) return send(res, 409, { ok: false, message: '没有活动会话' });
397
441
  }
398
442
 
443
+ if (parsed.action === 'recover') {
444
+ // 0.5.0:恢复被自动归档的旧会话(接力条恢复入口的 HTTP 兜底)。
445
+ if (typeof parsed.sessionId !== 'string' || parsed.sessionId.length === 0) {
446
+ return send(res, 400, { ok: false, message: '缺少要恢复的会话 sessionId' });
447
+ }
448
+ const outcome = await recoverArchivedSession(deps, { sessionId: parsed.sessionId });
449
+ return send(res, 200, outcome);
450
+ }
451
+
399
452
  if (parsed.action === 'preview') {
400
453
  // M6:只算不写——先告诉用户「要压哪段、多少 token」,再让他确认。
401
454
  // A3:返回 planId,confirm 会带着它回来校验「同一段范围」。
@@ -449,12 +502,19 @@ export function registerHttp(webCtx, deps) {
449
502
  }
450
503
  if (parsed.action === 'handoff') {
451
504
  // 0.4 P2-1:`edits` 只允许「约束」「下一步」两段(多余的段/非法类型由 actions 拒绝)。
452
- const outcome = await handoff(deps, { agent, edits: parsed.edits });
505
+ // 0.5.1 批 2:archive 缺省 true(0.5.0 行为);confirmUnpin 只有用户确认「归档会取消置顶」时才传。
506
+ const outcome = await handoff(deps, {
507
+ agent,
508
+ edits: parsed.edits,
509
+ archive: parsed.archive,
510
+ confirmUnpin: parsed.confirmUnpin,
511
+ });
453
512
  return send(res, 200, {
454
513
  ok: true,
455
514
  message: outcome.message,
456
515
  childId: outcome.childId,
457
516
  path: outcome.path,
517
+ archive: outcome.archive,
458
518
  });
459
519
  }
460
520
  return send(res, 400, { ok: false, message: `未知动作:${String(parsed.action)}` });