dsh-compact-relay 0.5.0 → 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/CHANGELOG.md +294 -0
- package/README.en.md +4 -3
- package/README.md +22 -5
- package/TUTORIAL.md +29 -5
- package/lib/actions/handoff.js +32 -21
- package/lib/actions/pin.js +7 -6
- package/lib/actions/relay-chain.js +17 -0
- package/lib/actions/state.js +127 -16
- package/lib/adapters/http.js +48 -1
- package/lib/client.js +1088 -149
- package/lib/core/clip.js +92 -0
- package/lib/core/errors.js +3 -1
- package/lib/core/native-line.js +27 -0
- package/lib/core/native-pin-risk.js +106 -0
- package/lib/core/pin-anchors.js +3 -1
- package/lib/core/pin-health.js +112 -0
- package/lib/dsh/compaction-log.js +16 -9
- package/lib/dsh/compaction-pair.js +27 -1
- package/lib/dsh/diag-counters.js +4 -1
- package/lib/dsh/diagnostics.js +3 -1
- package/lib/dsh/handoff-archive.js +47 -1
- package/lib/dsh/native-compaction.js +60 -4
- package/lib/dsh/relay-chain.js +238 -0
- package/lib/dsh/relay.js +108 -4
- package/lib/dsh/session-view.js +16 -10
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,299 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.1(2026-10-26 · 未发布,待 Lead 验收)
|
|
4
|
+
|
|
5
|
+
> 分支 `fix/0.5.1`(从 `main` = 0.5.0 拉出,只带 0.5.x 需要的改动;0.6 的 timeline/marks/milestone
|
|
6
|
+
> 不进来)。**四批功能合并定稿 + 1 条设计修正**:批 1 核心 bug(DCR-01/02/03/04 + D01 + D03)、
|
|
7
|
+
> 批 2 交互(pin 失效标注 / 原生压缩保护 / 置顶保护 / 归档开关)、批 3 功能补全(完整交接链 /
|
|
8
|
+
> 父→子入口)、批 4 完整链在祖先未加载时只显示 1 层(DCR-05)、**设计定稿:环只表达容量
|
|
9
|
+
> (pin 位置标记归 0.6 时间线)**。
|
|
10
|
+
> 测试 **257 → 340 PASS**,bundle 重建且 `--check` 通过。
|
|
11
|
+
>
|
|
12
|
+
> **契约变化(全部 additive,老客户端忽略新字段即可)**:`readState` 增 `chain`(入站投影)/
|
|
13
|
+
> `next`(出站投影)/ `nextArchived` / `parentArchived` / `capabilities.unarchive` /
|
|
14
|
+
> `sessionPinned` / `pins.stale` / `pins.atRisk`;`handoff` 增 `archive` / `confirmUnpin` 两个
|
|
15
|
+
> 入参(默认值保持 0.5.0 行为);`relay` 旧字段一个字节不改。
|
|
16
|
+
>
|
|
17
|
+
> **降级说明**:宿主缺 `workspaceRegistry`(归档/置顶/`unarchive` 任一)时对应能力一律降级为
|
|
18
|
+
> 「不知道 / 不归档 / 隐藏入口」并只 warn,不阻断交接;老插件(0.5.0)没有 `next` 字段时父→子
|
|
19
|
+
> 入口不显示;原生压缩预判依赖宿主 `retainRatio` 默认值,文案统一写「预计」,实际损失由
|
|
20
|
+
> `pins.stale` 兜底。
|
|
21
|
+
|
|
22
|
+
### 批 1:核心 bug 修复(DCR-01 / DCR-02 / DCR-03 / DCR-04 / D01 / D03;测试 257 → 278)
|
|
23
|
+
|
|
24
|
+
#### DCR-01(P1)祖先会话再次交接后接力条消失
|
|
25
|
+
|
|
26
|
+
- `dsh/relay.js` 新增 `relayInboundRecordOf` / `relayOutboundRecordOf`(按 `childId` 与 `session.id` 的关系分开找);`relayRecordOf` 语义**不变**(仍是最近一条记录,`handoffTitlePlanOf` 的链号递增依赖它)。
|
|
27
|
+
- `actions/state.js`:`chain` 改为**本会话入站记录**(`childId === session.id`)的只读投影;旧字段 `relay` 一个字节不改;`parentArchived` 用入站 `parentId` 查归档集合(出站记录里的父会话是本会话自己,会查错);新增 `next`(最近一条出站记录的投影,只加不改,供后续父→子入口与「子会话向父会话补交接单 path」)。
|
|
28
|
+
- 客户端 `client/35-relay-bar.js` 的入站判定(`chain.childId === sessionId`)保持不变;`loadParentPath` 改用父会话状态里的 `next`(老插件没有 `next` → 回落 `chain`)。
|
|
29
|
+
- 回归:`test/relay-chain.mjs`(中段会话:入站在前、出站在后)、`test/state-passive.mjs`(只有出站记录的会话 + 入站/出站读取器单测)。
|
|
30
|
+
|
|
31
|
+
#### DCR-02(P1)0.5.0 之前的老链永远没有接力条
|
|
32
|
+
|
|
33
|
+
- `dsh/relay.js` 新增 `legacyRelayChainOf({session, title})`:没有入站记录时,若 `header.parentSession` 存在、`header.origin !== 'subagent'`、`header.isSeeded !== true`(宿主语义:`isSeeded` = fork 继承前缀,不是交接),且标题能解析出链号(`splitHandoffTitle(...).handedOff > 0`),合成只读 chain(`ordinal` / `baseTitle` / `scheme` / `childId=self` / `parentId=header.parentSession`,多一个 `synthesized:true`)。**不写日志、不改任何记录**;不带 `path`(老链的交接单路径只写在父会话记录里,客户端 `loadParentPath` 只读回查)。
|
|
34
|
+
- 回归:`test/relay-chain.mjs`(老链合成 / 「历史 N」标题 / 四种不合成:subagent、fork、无 `parentSession`、干净标题 / 有 record 时 record 优先)。
|
|
35
|
+
|
|
36
|
+
#### DCR-03(P2)归档状态不刷新 / `parentArchived=null` 死入口
|
|
37
|
+
|
|
38
|
+
- 客户端 `client/35-relay-bar.js`:
|
|
39
|
+
- 打开接力条菜单时**静默刷新一次 state**(`refreshKey` 触发;只有真的换会话才清链,刷新失败不重排队重试);
|
|
40
|
+
- `parentArchived === null`(宿主没给状态)时,「回到原会话」也先 `unarchive` 再 `open`(unarchive 幂等);
|
|
41
|
+
- 恢复失败会**换通道重试一轮**(宿主 `ctx.uiWorkspace.unarchiveSession` ⇄ 插件 HTTP `recover`;只有一条通道时同通道重试),仍失败才提示;
|
|
42
|
+
- 客户端与服务端都没有恢复能力时**隐藏「恢复旧会话」**。
|
|
43
|
+
- `actions/state.js` 新增 `capabilities.unarchive`(宿主有没有 `workspaceRegistry.unarchiveSession`)。
|
|
44
|
+
- 回归:`test/relay-bar.mjs`(打开菜单刷 state / 未知状态先恢复 / 双通道重试 / 同通道重试 / 无能力隐藏入口 / `next` 补 path)、`test/state-passive.mjs` 与 `test/relay-chain.mjs`(capabilities)。
|
|
45
|
+
|
|
46
|
+
#### DCR-04(P2)归档提示文案指向不存在的视图
|
|
47
|
+
|
|
48
|
+
- `dsh/handoff-archive.js`:`「全部会话」` → 宿主真实视图标签 `「全部对话(显示已归档)」` + 真实动作 `「取消归档」`(实测 `dsh-client-ui-workspace` 的 `viewOptions` 三选一与归档行动作)。
|
|
49
|
+
- 回归:`test/archive.mjs`(新文案存在、不再出现「全部会话」)。
|
|
50
|
+
|
|
51
|
+
#### D01(正确性)压缩「已提交」判定两份不一致
|
|
52
|
+
|
|
53
|
+
- `dsh/compaction-pair.js` 新增唯一判定 `isCommittedCompaction({compactionId, end, replace})`:**没有 `compactionId` 的旧日志也要求 replace**;有 id 时还要求同 id 的 `compaction/end` 存在且没有 `error`。
|
|
54
|
+
- `dsh/session-view.js` 的 `countSummaries` 与 `dsh/compaction-log.js` 的 `collectCompactions().committed` 都改为调用它(原来两份实现,在「无 id 且无 replace」的旧日志上给出 `1` / `false` 两个答案)。
|
|
55
|
+
- **行为变化**:没有 id、也没有 replace 的 summary 不再算「已压缩」(`readState.summaries` 与 advice 阈值会少算这一条)。`test/smoke.mjs` 两处旧期望随之更新。
|
|
56
|
+
- **未随本分支做**:0.6 的 `timeline.summaryCount` 改正向累计 —— 0.5.1 分支没有 `lib/dsh/timeline.js`,随 0.6 分支做。
|
|
57
|
+
- 回归:`test/compact-log.mjs`(countSummaries 与 committed 同口径交叉校验)。
|
|
58
|
+
|
|
59
|
+
#### D03(重复)文本截断 6 处 → 2 个共享函数
|
|
60
|
+
|
|
61
|
+
- 新增 `lib/core/clip.js`:`clipChars`(按码点,emoji 不切半)与 `clipCodeUnits`(按 UTF-16 码元,0.4/0.5 既有行为逐字节兼容);省略号预算显式命名(`'inside'` 默认 = 算进上限 / `'append'` = 多 1 字符 / `false` = 不补)。
|
|
62
|
+
- 0.5.1 迁移 6 处:`core/pin-anchors.js`(60 字摘要)、`dsh/compaction-log.js`(200 字预览,`'append'` 保留「多 1 字符」既有输出)、`dsh/diagnostics.js`、`dsh/diag-counters.js`、`core/errors.js`(140 字原因)、`actions/handoff.js`(120 字警示)。
|
|
63
|
+
- **随 0.6 做**:`core/handoff-facts.js:oneLineNote`、`core/milestone.js:clipText`(0.6 工作树新增的两份逐行相同实现)→ 换 `clipChars`。`core/relay.js:truncateTitleUtf8` 按评审结论**不并进来**(UTF-8 字节预算 = 宿主 `session-title` 契约克隆,0.5.2 S3 再迁 `dsh/title-budget.js`)。
|
|
64
|
+
- 回归:新增 `test/clip.mjs`(码点/码元/append/边界 + 三个迁移点逐字节),已加入 `npm test`。
|
|
65
|
+
|
|
66
|
+
### 批 2:pin 失效标注 + 原生压缩保护 + 归档交互(测试 278 → 311)
|
|
67
|
+
|
|
68
|
+
#### 1. pin 失效标注(C 方案,DCR 批 2-3)
|
|
69
|
+
|
|
70
|
+
- `core/pin-health.js`(新增):`pinHealthOf({pinnedSeqs, surfaceSeqs, log})` 把 pin 快照拆成
|
|
71
|
+
`live`(仍在 surface)与 `stale`(被压缩/历史变化带走),并只读日志归因:
|
|
72
|
+
`compaction/start.turn` 非空 = **native**(原生自动压缩);`turn:null` = **other**(本插件 /bc、
|
|
73
|
+
宿主 /compact);找不到覆盖该 seq 的 replace = **unknown**(撤销/分支)。`staleCauseTextOf`
|
|
74
|
+
统一文案口径。
|
|
75
|
+
- `actions/state.js`:`pins` 增加 additive 字段
|
|
76
|
+
`stale:{count,seqs,native,other,unknown}`;`pins.count/seqs/marks` 仍是 live 集合(0.5.0
|
|
77
|
+
语义不变,老客户端照用)。失效 pin **不画到环上**(不假装还在),但也不消失。
|
|
78
|
+
- 客户端 `client/30-indicator.js`:指示灯悬停卡显示「已失效 N 条(随原生压缩移除)」;菜单的
|
|
79
|
+
锁定项显示「已锁 N 条 · 失效 M」/「解除失效锁定(M 条)」;`data-cr-pins-stale` 取证属性。
|
|
80
|
+
- `actions/pin.js` 的 `togglePin` 改为按**快照**判定:全部 pin 已失效时菜单显示「解除失效锁定」,
|
|
81
|
+
点下去必须清空记录,不能因为 live=0 反而新锁一条(P3-03 的旧结论随菜单文案一起更新)。
|
|
82
|
+
- 回归:`test/pin-health.mjs`(live/stale/三种原因/混合原因 + state 下发)、`test/relay-bar.mjs`
|
|
83
|
+
(指示灯悬停卡文案)。
|
|
84
|
+
|
|
85
|
+
#### 2. 原生压缩吃掉 pin 的提前保护(事前,非事后)
|
|
86
|
+
|
|
87
|
+
- 宿主可用信号:`compaction-basic` 的 `thresholdRatio` / `headroomTokens` / `retainRatio` /
|
|
88
|
+
`retainTokens` 可读;真实触发线(0.4 P2-3)与当前压力每帧都有;选段算法在宿主源码里是
|
|
89
|
+
纯函数(保留尾部 `retainTokens` + 工具配对平衡回退)。
|
|
90
|
+
- `dsh/native-compaction.js`:`parseCompactionOverride` / `parseModelPolicies` /
|
|
91
|
+
`resolveNativePolicy` 增加 `retainRatio` / `retainTokens`(只认 config 直属子键;路由级可覆盖)。
|
|
92
|
+
- `core/native-pin-risk.js`(新增):`nativePinnedRiskOf` 按 compaction-basic 的
|
|
93
|
+
`selectCompactableRange` 同口径复刻选段,返回下一次原生压缩会覆盖的 pinned seq。
|
|
94
|
+
- `actions/state.js`:只在「原生自动压缩开 + 当前压力 ≥ 真实触发线 + 节点定价与 surface 对齐」
|
|
95
|
+
时下发 `pins.atRisk:{count,seqs}`(否则 null,不制造噪音)。
|
|
96
|
+
- 客户端:悬停卡一行「⚠ 原生压缩预计覆盖 N 条锁定消息」+ 菜单交接项提示 + 同一风险签名只弹
|
|
97
|
+
一次 toast,建议「先交接(/handoff)或解锁」。`data-cr-pins-risk` 取证属性。
|
|
98
|
+
- **为什么是事前**:触发线、保留预算、当前压力都是可读的,选段是纯函数;无需等压缩事件。
|
|
99
|
+
**限制**:宿主没有「即将压缩」事件,真正执行在 step boundary,且默认 `retainRatio` 随宿主
|
|
100
|
+
版本(0.2.1-rc.3 = 0.16;用户显式配置优先),所以文案写「预计」。真发生后 `pins.stale`
|
|
101
|
+
仍会兜底标出实际损失。
|
|
102
|
+
- 回归:`test/pin-health.mjs`(宿主选段同口径:保留尾部/配对平衡回退/system head/未到线不报
|
|
103
|
+
+ retainRatio/retainTokens 解析)、`test/relay-bar.mjs`(预警文案与主动提示)。
|
|
104
|
+
|
|
105
|
+
#### 3. 置顶保护(归档会取消置顶)
|
|
106
|
+
|
|
107
|
+
- 宿主实证(`@deepseek-ai/dsh-workspace/lib/index.js`):`WorkspaceRegistry.pinnedSessionIds`
|
|
108
|
+
可读;`archiveSession` 在**同一次 durable write** 里把该 id 从置顶集合移除(置顶与归档互斥)。
|
|
109
|
+
- `dsh/handoff-archive.js`:新增 `pinnedSessionIdsOf` / `isSessionPinned`(读不到 → null,不猜
|
|
110
|
+
false);`handoffArchiveNoteOf` 增加 `skipped` / `pinned` 文案。
|
|
111
|
+
- `actions/handoff.js`:`handoff(..., {archive = true, confirmUnpin = false})`。旧会话在侧栏置顶
|
|
112
|
+
且未 `confirmUnpin:true` 时**不归档**(`status:'pinned'`),旧会话与置顶都保留;旧客户端
|
|
113
|
+
(不传 confirmUnpin)自动降级为不归档,不会静默取消置顶。
|
|
114
|
+
- 客户端交接预览:`sessionPinned === true` 时弹「旧会话已在侧栏置顶:归档会取消置顶」,提供
|
|
115
|
+
「交接并归档(取消置顶)」与「只交接,保留置顶」两个出口;取消 = 关闭预览、什么都不做。
|
|
116
|
+
- `actions/state.js` 新增 `sessionPinned`(true/false/null)。
|
|
117
|
+
- 回归:`test/archive.mjs`(置顶未确认不归档 / 确认才归档 / archive:false 优先 / 读不到置顶
|
|
118
|
+
集合 → null / 文案)、`test/relay-bar.mjs`(两个出口的 HTTP body)。
|
|
119
|
+
|
|
120
|
+
#### 4. 归档开关 + 首次引导
|
|
121
|
+
|
|
122
|
+
- `handoff` 新增可选 `archive`(默认 `true`,0.5.0 行为不变);HTTP
|
|
123
|
+
`/api/compact-relay-action {action:'handoff', archive:false}` = 只交接、不归档旧会话(返回
|
|
124
|
+
`archive.status='skipped'` + 文案);`archive` / `confirmUnpin` 类型错 → 400(动历史前拒绝)。
|
|
125
|
+
- 交接预览新增勾选框「交接后自动归档旧会话(默认开)」;取消勾选后不弹置顶确认、确认按钮变
|
|
126
|
+
「确认交接(不归档)」。
|
|
127
|
+
- 首次引导(onboarding)文案明确写:「交接后会默认自动归档旧会话(侧栏收起,可取消勾选
|
|
128
|
+
『交接后归档』;旧会话在侧栏置顶时会先问你,因为归档会取消置顶)」。
|
|
129
|
+
- 回归:`test/archive.mjs`(archive:false / 默认 true / HTTP 透传与 400)、`test/relay-bar.mjs`
|
|
130
|
+
(勾选状态与请求体)、`test/archive.mjs` 产物守卫(onboarding 文案)。
|
|
131
|
+
|
|
132
|
+
#### 批 2 的宿主限制 / 待真机验证
|
|
133
|
+
|
|
134
|
+
- 原生压缩预判是「同口径复刻 + 估算」,依赖宿主 0.2.1-rc.3 的默认 `retainRatio=0.16`;宿主升级
|
|
135
|
+
改默认值时预判会偏(用户显式配置 retainRatio/retainTokens 不受影响)。文案已写「预计」,
|
|
136
|
+
实际损失由 `pins.stale` 兜底。
|
|
137
|
+
- 置顶状态读的是 workspace registry 的全局置顶集合;老宿主没有该字段 → `sessionPinned=null`,
|
|
138
|
+
客户端不弹确认、服务端也不拦(`isSessionPinned` 返回 null → 按未置顶处理,保持 0.5.0 自动归档)。
|
|
139
|
+
- 客户端新增交互(归档勾选 / 置顶确认 / 失效标注 / 预警 toast)需要重启宿主加载新 bundle 后
|
|
140
|
+
真机点一遍;本轮只做了 bundle 重建 + 组件级单测(mini React,不引 jsdom)。
|
|
141
|
+
|
|
142
|
+
### 批 3:完整交接链 + 父→子入口(测试 311 → 324)
|
|
143
|
+
|
|
144
|
+
#### 1. 完整交接链列表(接力条下拉「完整交接链」)
|
|
145
|
+
|
|
146
|
+
- **数据**:当前会话 `readState.chain`(入站;0.5.0 之前的老链走 DCR-02 的 `synthesized` 兜底)→
|
|
147
|
+
逐级向 `chain.parentId` 读父会话 state 的 `chain`,直到没有 `parentId`。
|
|
148
|
+
- **频率**:按需——点「完整交接链」之前**不预加载**祖先;展开后**每层一次** state GET(串行、
|
|
149
|
+
不并发、不轮询)。链路读完后不常驻任何定时器。
|
|
150
|
+
- **上限/去重**:`MAX_CHAIN_HOPS = 8`;`seen` 按 sessionId 去重 → 自指/成环立刻停;到上限时
|
|
151
|
+
界面明说「只显示最近 8 层」(不假装到头)。
|
|
152
|
+
- **每行**:「历史 N:<原名>」(旧链沿用「交接第 N 次:<原名>」,与接力条同一把尺子)+ 行内
|
|
153
|
+
「交接单」入口 + 整行点击跳转。N/原名取「下一层(子)会话的入站 chain」——正好是那一层会话
|
|
154
|
+
改名后的标题。
|
|
155
|
+
- **行内交接单**:优先用该层记录里的 `path`;旧记录没写 path 时用该层会话 state 的出站
|
|
156
|
+
`next.path`(`childId` 与链号都对得上才采用)。只认 `handoff-*.md` basename,走已有
|
|
157
|
+
`/api/compact-relay-handoffs?file=`;没有 path 时禁用并标「暂无交接单」。
|
|
158
|
+
- **跳转**:`ctx.uiWorkspace.openSession(sessionId)`。祖先会话默认已被交接流程归档,所以与
|
|
159
|
+
「回到原会话」同口径:先 `unarchive`(宿主 API,老宿主退回 HTTP `recover`,幂等)再 open。
|
|
160
|
+
- **失败/读不到**:停在断点,已读到的行保留,显示「无法读取更早的链路」+「重试」;重试
|
|
161
|
+
**从断点续读**(已读到的层不重复拉)。「← 返回」回主菜单 —— 链读失败不阻断其他入口。
|
|
162
|
+
- **「没加载」不等于「到头」**:宿主返回的 `sessionId` 对不上(重启后祖先会话通常不在内存里)
|
|
163
|
+
按「读不到」处理——停在断点 + 重试,而不是静默少列一层;用户先打开那个会话再重试就能续上。
|
|
164
|
+
- 回归:`test/relay-bar.mjs` 新增 8 组(按需/每层一次 GET、8 跳上限、成环去重、失败重试续读、
|
|
165
|
+
未加载也不误判到头、行内交接单 basename、无 path 禁用、行点击先恢复再切)。
|
|
166
|
+
|
|
167
|
+
#### 2. 父→子入口(TD-01)
|
|
168
|
+
|
|
169
|
+
- `actions/state.js` 新增 additive 字段 `nextArchived`(`next.childId` 是否在 workspace 归档
|
|
170
|
+
集合里;与 `parentArchived` 完全对称,各查各的 id,读不到 → null)。
|
|
171
|
+
- 父会话侧(有出站记录时)**也渲染接力条**:一行「已交接给新会话」+ 菜单「去新会话」;
|
|
172
|
+
中段会话同时有「回到原会话」与「去新会话」两个方向,外加「完整交接链」。
|
|
173
|
+
- 点击 → `openSession(next.childId)`;`nextArchived` 为 true 或未知(null)且宿主有恢复能力时
|
|
174
|
+
先 unarchive 再 open(复用批 1 的双通道恢复逻辑)。
|
|
175
|
+
- 两个防御:老插件没有 `next` 字段 → 不显示「去新会话」(**不回落** `chain`:老形态的 chain
|
|
176
|
+
是最近一条记录,可能是入站,回落会出现「跳回自己」的死入口);`next.childId === 本会话`
|
|
177
|
+
的脏记录一律不认。
|
|
178
|
+
- 回归:`test/relay-bar.mjs` 新增 4 组(父会话侧渲染与跳转、脏 next 不显示、归档/未知先恢复、
|
|
179
|
+
中段会话两个方向)+ `test/state-passive.mjs`(`nextArchived`:在/不在归档集合、只看出站 id、
|
|
180
|
+
无 registry、无 next → null)。
|
|
181
|
+
|
|
182
|
+
### 批 4:完整交接链在祖先未加载时只显示 1 层(DCR-05;测试 324 → 339)
|
|
183
|
+
|
|
184
|
+
- **真机现象**:点接力条「完整交接链」只显示 1 层(当前会话),并提示「无法读取更早的链路」。
|
|
185
|
+
- **根因**:客户端逐级 GET `/api/compact-relay-state?sessionId=<parentId>`;宿主对**未加载**的
|
|
186
|
+
会话返回 `sessionId:null`(`readState` 的 `target` 为 null),客户端把它当「读不到」停在断点。
|
|
187
|
+
重启后祖先会话通常都不在内存里,所以这是常态而不是异常。
|
|
188
|
+
- **宿主能力实证**(DSH 0.2.1-rc.3 app.asar):
|
|
189
|
+
- `@deepseek-ai/dsh-session-persistence` 是根服务 `ctx.sessionPersistence`;
|
|
190
|
+
- `stat(id)` → `{header, revision, sizeBytes?}`,**不读事件日志**;
|
|
191
|
+
- `open(id, 'read')` → 只读 `SessionHandle`,`read()` 返回 `{events, eventState}`,`close()`
|
|
192
|
+
释放;**不创建 live Session、不写任何状态、不占写锁**;
|
|
193
|
+
- JSONL 后端对**已归档**会话同样可读(归档只改 Workspace registry 集合,不动持久化文件)。
|
|
194
|
+
- **方案**:优先方案成立 —— 新增服务端只读读取器 `dsh/relay-chain.js`:
|
|
195
|
+
- `readRelayChain(ctx, {sessionId})` 从当前会话开始,按「子会话入站 relay 记录」逐层向上
|
|
196
|
+
(与旧客户端同一套语义),最多 8 层、`seen` 去重防成环;
|
|
197
|
+
- 每层优先用 live 会话(内存里最新),未加载则 `stat` 读 header + `open(id,'read')` 读日志;
|
|
198
|
+
- 交接单 path 优先用该跳入站记录,缺了再从父会话日志找 `childId+ordinal` 对得上的出站记录
|
|
199
|
+
(0.3/0.4 老记录只在父会话写 path);
|
|
200
|
+
- 0.5.0 前没有 relay 记录的老链同样逐层用 `header.parentSession` + `session/title` 兜底合成;
|
|
201
|
+
- 某一层读不到时返回**已读到的行** + `unreadable:{sessionId, reason}`,不整块清空。
|
|
202
|
+
- **端点**:`actions/relay-chain.js`(薄包装)+ `GET /api/compact-relay-chain?sessionId=`;
|
|
203
|
+
与其它 GET 端点同一套宿主 `connection.requestRejection` 鉴权/同源口径。一次返回整条链,
|
|
204
|
+
彻底消除逐级 GET 和未加载问题。
|
|
205
|
+
- **客户端**:
|
|
206
|
+
- 点「完整交接链」先打整链端点,一次渲染(`test/relay-bar.mjs` 断言祖先 state GET = 0);
|
|
207
|
+
- 老插件/响应异常 → **回落**原有逐级 state 读法(兼容 + 兜底),行为与 0.5.1 批 3 一致;
|
|
208
|
+
- 兜底断点从「错误提示」改成可操作入口:宿主明确回 `sessionId:null` 时显示
|
|
209
|
+
**「上一层会话未打开」+「打开并继续」**;点击先双通道 `unarchive`(复用 DCR-03,幂等;
|
|
210
|
+
无归档记录也直接继续)→ `openSession(parentId)` → 等宿主加载(≤8 次)→ **自动 `loadChain` 续读**;
|
|
211
|
+
加载不上则提示「已打开上一层会话,再点完整交接链继续」;
|
|
212
|
+
- 整链端点 `unreadable.reason==='not-found'`(持久化里根本没有这一层)**不显示**「打开并继续」
|
|
213
|
+
(打开也救不回来),只保留重试——与 DCR-03「不造必然失败的入口」同口径。
|
|
214
|
+
- **保留**:读不到时已读行不丢、返回主菜单不受影响、8 跳上限、sessionId 去重、行内交接单
|
|
215
|
+
basename 校验、每行跳转前先 unarchive。
|
|
216
|
+
- **回归**:新增 `test/relay-chain-endpoint.mjs`(12 条:祖先全未加载仍读全 / live 优先 /
|
|
217
|
+
8 跳上限 / 成环去重 / 断点保留 / 无持久化 / 老链 header+标题兜底 / subagent+fork 不合成 /
|
|
218
|
+
HTTP 200+400+405 / 无服务 ok:false / 鉴权 401);`test/relay-bar.mjs` 新增 3 条(未加载
|
|
219
|
+
入口、点击后 unarchive+openSession+自动续读、整链端点一次读全 + 部分失败入口),共 46 条。
|
|
220
|
+
|
|
221
|
+
### 设计定稿:环只表达容量(pin 位置标记归 0.6 时间线)
|
|
222
|
+
|
|
223
|
+
- **决策(用户拍板)**:环管「未来」(容量、阈值、断点、原生线刻度);pin 的位置是「历史」。
|
|
224
|
+
在环上画锁定消息位置属于复现历史功能,而 0.6 的时间线会承接 pin 历史(`state.pins.marks`
|
|
225
|
+
与 marks/timeline 已备好)。所以 0.5.1 起**环只表达容量,不再画 pin 位置块**;
|
|
226
|
+
「已锁定 N 条 / 已失效 M 条」作为状态保留在悬停卡与菜单。
|
|
227
|
+
- **客户端** `lib/client/30-indicator.js`:删除环上的 pin 位置块渲染(`pinBlocks` 那组带
|
|
228
|
+
`data-cr-pin-mark` 的 `circle`,含品红弧段);环 SVG 只剩分段弧/断点/原生线刻度/交接线警示/
|
|
229
|
+
中心点。**`state.pins.marks` 数据读取保留**(状态归一化照旧,0.6 时间线消费),注释写明
|
|
230
|
+
「位置标记归 0.6 时间线,环不再画」。
|
|
231
|
+
- **保留不动**:悬停卡「已锁定 N 条 / 已失效 M 条」、菜单「已锁 N 条 · 失效 M」、
|
|
232
|
+
`data-cr-pins` / `data-cr-pins-stale` / `data-cr-pin-stale` 取证属性;`colors.base.pin`
|
|
233
|
+
(锁定颜色)留在设置与 colors 契约里,作为 0.6 时间线的 pin 标记颜色。
|
|
234
|
+
- **清点**:`data-cr-pin-mark` 此前只被指示灯使用(设置页教学图本来就不画 pin,演示段无引用);
|
|
235
|
+
删除后源码与 bundle 全库无残留。
|
|
236
|
+
- **回归**:`test/relay-bar.mjs` 新增「有 pin 也不画位置块」+ 悬停卡/菜单计数仍在;
|
|
237
|
+
`test/archive.mjs` 产物守卫改为「源码/bundle 不得含 `data-cr-pin-mark`,但必须保留 `marks`
|
|
238
|
+
读取与 0.6 时间线注释」。测试 **339 → 340 PASS**。
|
|
239
|
+
|
|
240
|
+
### 文档
|
|
241
|
+
|
|
242
|
+
- **ARCHITECTURE**:`readState` 契约补 `chain`(入站)/ `next`(出站)/ `nextArchived` /
|
|
243
|
+
`capabilities.unarchive` / `pins.stale` / `pins.atRisk` / `sessionPinned` / `parentArchived`;
|
|
244
|
+
§8.8 读侧补「两侧都渲染」口径;§8.10(完整交接链的逐级读法、上限/去重/断点重试、
|
|
245
|
+
父→子入口的两个防御);新增 §8.11(DCR-05:服务端只读持久化日志一次读整链、端点契约、
|
|
246
|
+
客户端回落与「打开并继续」);新增 §8.12(设计定稿:环只表达容量、`pins.marks` 数据面保留、
|
|
247
|
+
测试/产物守卫);§5 补**帧体积口径**(实测 ≈ 2.1KB/帧,0.5.1 additive ≈ +300B,
|
|
248
|
+
完整链默认 **1 次** `/api/compact-relay-chain`,老宿主回落 ≤ 8 次 GET)。
|
|
249
|
+
- **README / TUTORIAL**:补 0.5.1 用法(接力条「完整交接链」、父→子「去新会话」、pin 失效
|
|
250
|
+
标注、交接时的置顶确认、交接后归档开关);指示环图例与锁定说明去掉「品红色块」,
|
|
251
|
+
统一成「环只表达容量,pin 位置标记归 0.6 时间线;已锁定/失效条数看悬停卡与菜单」
|
|
252
|
+
(README.en 同步)。
|
|
253
|
+
- 口径与已知限制见下节。
|
|
254
|
+
|
|
255
|
+
### 已知限制与口径(0.5.1 定稿)
|
|
256
|
+
|
|
257
|
+
- **DL-02(置顶丢失)**:宿主 `archiveSession` 在**同一次 durable write** 里把该会话从置顶
|
|
258
|
+
集合移除(置顶与归档互斥),这是宿主行为、不是插件副作用。0.5.1 起这件事不再静默:交接
|
|
259
|
+
预览在会话置顶时先问一次(「交接并归档(取消置顶)」/「只交接,保留置顶」),服务端在没拿
|
|
260
|
+
到 `confirmUnpin:true` 时**拒绝自动归档**(`status:'pinned'`,旧会话与置顶都保留);旧客户端
|
|
261
|
+
(不传 confirmUnpin)在置顶会话上自动降级为不归档。**边界**:用户在侧栏手动归档置顶会话时,
|
|
262
|
+
那一步的确认归宿主管辖,本插件不介入。
|
|
263
|
+
- **DL-03(分叉未定义)**:fork(宿主 `isSeeded=true`)与 subagent 会话不在接力链语义里,
|
|
264
|
+
老链兜底(DCR-02)明确排除这两类。真正的「分叉 → 各走一条链 → 再合并」**没有定义**:链记录
|
|
265
|
+
只存单亲 `parentId`,没有分支判据,插件按「各聊各的」处理(两条分叉各自成链,互不感知)。
|
|
266
|
+
0.6 再谈(需要宿主的血缘/分支信号)。
|
|
267
|
+
- **DL-04(有 pin 时程序段变化)**:九段交接单里「当前进度」「改过的文件」两段由**程序收集**
|
|
268
|
+
(`core/handoff-facts.js`);有 pin 时「当前进度」会多出「锁定消息 N 条」+ 最多 10 条锚点
|
|
269
|
+
(seq + 角色 + ≤60 字摘要)。所以同一会话在不同 pin 状态下重新生成交接单,**程序段内容会变**,
|
|
270
|
+
不保证与上一次逐字节相同(模型段本来也不保证)。这是有意的:交接单要反映交接那一刻的事实。
|
|
271
|
+
0.5.1 批 2 起只统计**还活着**的 pin(被压缩带走的失效 pin 不进交接单,改在指示灯界面标注)。
|
|
272
|
+
- **「去新会话」只指向最近一次出站**:`readState.next` 是**最近一条**出站记录,所以同一条会话
|
|
273
|
+
交接过两次时,更早的那个下一代会从「去新会话」里消失(会话本身还在,侧栏能找到);这是
|
|
274
|
+
`next` 的语义决定的,跨会话全时间线留到 0.6 的「路线图」面板。
|
|
275
|
+
- **旧链兜底无 path**:0.5.0 之前的老链(子会话没有 relay record)由 `legacyRelayChainOf` 用
|
|
276
|
+
会话头 + 标题合成**只读**链,**不带 path**(老链的交接单路径只写在父会话记录里)。客户端缺
|
|
277
|
+
path 时会向父会话只读回查一次,仍拿不到就显示「暂无交接单」并禁用入口——不伪造、不猜路径。
|
|
278
|
+
完整交接链同理:某一层没有记录路径时,那一行的「交接单」禁用,但跳转照常可用。
|
|
279
|
+
- **整链端点依赖 `ctx.sessionPersistence`**:没有该服务的宿主(老版本/裁剪构建)回落旧的逐级
|
|
280
|
+
state 读法;此时祖先未加载会停在断点并显示「上一层会话未打开」+「打开并继续」,不再只给
|
|
281
|
+
错误提示。
|
|
282
|
+
|
|
283
|
+
### 验证
|
|
284
|
+
|
|
285
|
+
- `npm test` 全绿:**339 PASS / 0 FAIL**(历史基线:0.5.0 收尾 257 → 批 1 278 → 批 2 311 →
|
|
286
|
+
批 3 324 → 批 4 **339**;批 4 新增 15 条:12 条整链服务端/端点测试 + 3 组接力条组件测试);
|
|
287
|
+
`node scripts/build-client.mjs --check` 通过(bundle 279147 字节)。
|
|
288
|
+
- 四批合计新增回归:DCR-01/02/03/04、D01、D03(批 1)、pin 失效/原生压缩预判/置顶/归档开关
|
|
289
|
+
(批 2)、完整交接链/父→子(批 3)、整链端点/未加载「打开并继续」(批 4),以及
|
|
290
|
+
`test/clip.mjs`、`test/pin-health.mjs`、`test/relay-chain-endpoint.mjs` 三个新文件。
|
|
291
|
+
- **未 commit、未发布、未打 tag**(按范围定稿:发布前须 Lead 验收)。
|
|
292
|
+
- **待真机验证**:① 旧链会话(b3ae4bac 等 4 个)打开后有接力条;② 祖先会话再次交接后接力条
|
|
293
|
+
不消失;③ 置顶会话交接时的确认弹层;④ 完整交接链:重启后祖先**未加载**时也能一次列全(新
|
|
294
|
+
`/api/compact-relay-chain`),以及老宿主断点「打开并继续」→ 自动续读;⑤ 父会话侧「去新会话」。
|
|
295
|
+
本轮只做组件级单测(mini React)+ 服务端单测,没有真机截图。
|
|
296
|
+
|
|
3
297
|
## 0.5.0(2026-10-26)
|
|
4
298
|
|
|
5
299
|
> 0.5.0 功能批次:地基 F1–F6 + **命名反转 + 接力条 + 交接后自动归档旧会话/恢复入口**。双向跳转的「父会话 → 子会话」入口与里程碑面板尚未实现;其余已提交并发布(2026-10-26)。
|
package/README.en.md
CHANGED
|
@@ -30,7 +30,7 @@ The plugin mounts beside the existing composer. Default parameters are tuned for
|
|
|
30
30
|
|
|
31
31
|
### 1. Occupancy visualization
|
|
32
32
|
|
|
33
|
-
The ring is colored in segments according to the compression-assessment logic: the suggested compression point determined by the model or configured threshold,
|
|
33
|
+
The ring is colored in segments according to the compression-assessment logic and expresses capacity only: the suggested compression point determined by the model or configured threshold, and the DSH native auto-compaction line (the actual trigger point computed from the host token budget: ~67.8% for deepseek-flash, while the configured threshold defaults to 80%). Since 0.5.1 the ring no longer draws pin positions (position marks are reserved for the 0.6 timeline); pin status is shown as counts in the hover card and menu. When occupancy crosses the actual trigger point, the ring flashes red.
|
|
34
34
|
|
|
35
35
|
When occupancy enters the compressible band (between the minimum compression line and the compression line), the plugin proactively tells the model the current occupancy and band through a dynamic system-prompt context, so it can decide whether to record a compression node after finishing a natural milestone (since 0.3.4; before that the model could not see occupancy, so the condition was not actionable).
|
|
36
36
|
|
|
@@ -48,7 +48,7 @@ Running `/handoff` generates a handoff document with nine standard sections (ove
|
|
|
48
48
|
|
|
49
49
|
### 4. Message pinning
|
|
50
50
|
|
|
51
|
-
Running `/pin` marks messages that must be preserved during compaction.
|
|
51
|
+
Running `/pin` marks messages that must be preserved during compaction. Pin status and counts appear in the hover card ("N pinned") and the menu (locked row / "N locked · M stale"); since 0.5.1 the ring no longer draws pin position blocks (position marks belong to the 0.6 timeline).
|
|
52
52
|
|
|
53
53
|

|
|
54
54
|
|
|
@@ -62,9 +62,10 @@ Running `/pin` marks messages that must be preserved during compaction. Pinned m
|
|
|
62
62
|
| Red arc | The handoff threshold has been passed; switching to a new session is recommended |
|
|
63
63
|
| Ring notch | Suggested compression point (the system proactively prompts the model to assess inside the compressible band; shown after the model records one or the compression line is reached) |
|
|
64
64
|
| Violet tick | DSH native auto-compaction line: the actual trigger point computed as `min(window × thresholdRatio, window − reserved output − headroomTokens)` (~67.8% for deepseek-flash; the configured `thresholdRatio` defaults to 80% and is only a theoretical ceiling) |
|
|
65
|
-
| Magenta blocks | Distribution of pinned messages in the history |
|
|
66
65
|
| Full-ring red flash | The native auto-compaction line has been crossed; DSH native auto-compaction is imminent |
|
|
67
66
|
|
|
67
|
+
> Since 0.5.1 the ring expresses capacity only and no longer marks pin positions; "N pinned / M stale" is shown in the hover card and menu (position marks belong to the 0.6 timeline).
|
|
68
|
+
|
|
68
69
|

|
|
69
70
|
|
|
70
71
|
## The three lines (deepseek-flash, 1M window defaults)
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
[](https://www.npmjs.com/package/dsh-compact-relay)
|
|
10
10
|
[](https://www.deepseek.com/harness/)
|
|
11
11
|
[](LICENSE)
|
|
12
|
-
[](CHANGELOG.md)
|
|
13
13
|
|
|
14
14
|
</div>
|
|
15
15
|
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
### 1. 状态可视化监控
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
指示环按压缩评估逻辑分段显色,只表达容量:模型或阈值判定的建议压缩点,以及 DSH 原生自动压缩线(按宿主实际 token 预算算出的触发点:deepseek-flash 约 67.8%,配置阈值默认 80%)。0.5.1 起环上不再画锁定消息的位置(位置标记归 0.6 时间线),锁定状态以悬停卡/菜单的条数显示。当上下文占用越过原生自动压缩线时,整环呈红色闪烁告警。交接提醒线晚于当前模型的实际触发线(−5pp)时,环上交接刻度旁会出现红色小警示,悬停卡与 `/handoff` 菜单也会同步标出「交接线已晚」。
|
|
34
34
|
|
|
35
35
|
上下文进入「可压缩区间」(最小压缩线到压缩线之间)时,插件会在系统提示里主动告诉模型当前容量与区间,模型据此判断「刚完成一个自然节点」时是否记录压缩节点(0.3.4 起;此前模型看不到容量,判断条件不可执行)。
|
|
36
36
|
|
|
@@ -48,10 +48,22 @@
|
|
|
48
48
|
|
|
49
49
|
### 4. 关键消息锁定
|
|
50
50
|
|
|
51
|
-
执行 `/pin`
|
|
51
|
+
执行 `/pin` 命令可标记压缩时必须保留的消息。锁定状态与条数显示在悬停卡(「已锁定 N 条」)和菜单(锁定行 /「已锁 N 条 · 失效 M」)里;0.5.1 起环上不再画 pin 位置块(位置标记归 0.6 时间线)。
|
|
52
52
|
|
|
53
53
|

|
|
54
54
|
|
|
55
|
+
### 5. 接力条:交接链与双向跳转
|
|
56
|
+
|
|
57
|
+
交接之后,输入卡上方会出现一条「已接力自 历史 N:<原名>」的接力条;点开它就是一个会话导航菜单:
|
|
58
|
+
|
|
59
|
+
- **完整交接链**:点「完整交接链」才**按需**读取(不展开不发请求),一次列出整条链「历史 N:<原名>」(默认服务端只读持久化日志,祖先会话没打开也能读;老宿主回落逐级查询)。每行都能打开那一跳的交接单、点整行跳到那个会话(已归档的会先自动恢复)。最多回看 8 层,链路异常成环会自动停;某一层实在读不到时保留已读行 +「无法读取更早的链路 / 上一层会话未打开」,老宿主还会给「打开并继续」(打开那一层后自动续读),不影响菜单其他入口。
|
|
60
|
+
- **父 → 子**:把接力棒交出去之后,父会话侧的接力条变成「已交接给新会话」,「去新会话」一键切回这次交接出来的新会话(新会话若被归档过,也会先恢复再切)。
|
|
61
|
+
- **pin 失效标注**:被压缩(尤其 DSH 原生自动压缩)带走的锁定消息不再凭空消失——悬停卡与菜单标「已失效 N 条(随原生压缩移除)」;原生压缩**即将**覆盖锁定消息时会提前提示「先交接(/handoff)或解锁」。
|
|
62
|
+
- **置顶确认**:交接后归档旧会话会取消它的侧栏置顶(宿主行为)。旧会话置顶时,交接预览先问一次:点「交接并归档(取消置顶)」才归档,点「只交接,保留置顶」则旧会话和置顶都留着。
|
|
63
|
+
- **归档开关**:交接预览里可以勾掉「交接后自动归档旧会话」(默认开);首次引导里也写了这个选项。
|
|
64
|
+
|
|
65
|
+
> 老链(0.5.0 之前生成的会话)没有交接单路径记录,界面显示「暂无交接单」而不是猜一个——它会先向父会话只读回查一次。
|
|
66
|
+
|
|
55
67
|
## 指示环图例说明
|
|
56
68
|
|
|
57
69
|
| 指示环视觉元素 | 状态含义 |
|
|
@@ -62,9 +74,10 @@
|
|
|
62
74
|
| 红色弧 | 已越过交接阈值,建议切换新会话 |
|
|
63
75
|
| 环形缺口 | 建议压缩点(系统在可压缩区间主动提示模型判断;模型记录或到达压缩线时自动出现) |
|
|
64
76
|
| 紫色刻度 | DSH 原生自动压缩线:按 `min(窗口 × thresholdRatio, 窗口 − 输出预留 − headroomTokens)` 算出的**实际触发点**(deepseek-flash 约 67.8%;`thresholdRatio` 配置默认 80%,只是理论上限) |
|
|
65
|
-
| 品红色块 | 已锁定消息在历史记录中的分布位置 |
|
|
66
77
|
| 整环红色闪烁 | 已越过原生自动压缩线,即将触发 DSH 原生自动压缩 |
|
|
67
78
|
|
|
79
|
+
> 0.5.1 起环只表达容量,不再标注 pin 位置;「已锁定 N 条 / 已失效 M 条」在悬停卡与菜单查看(位置标记归 0.6 时间线)。
|
|
80
|
+
|
|
68
81
|

|
|
69
82
|
|
|
70
83
|
## 三条线(deepseek-flash 1M 默认)
|
|
@@ -150,10 +163,14 @@ dsh plugin --profile desktop add github:cfanzx/dsh-compact-relay
|
|
|
150
163
|
- **依附桌面输入框**:环形指示器、`/bc`、`/handoff` 依附 DSH 的输入框与斜杠命令;headless 会话或 QQ / 微信等桥接链路不会加载它们,在这些链路里发 `/bc` 会被当成普通消息——这是桥接链路的能力边界,不是插件故障。
|
|
151
164
|
- **界面语言限制**:当前版本用户界面仅提供简体中文,英文界面已列入后续计划(排期未定)。
|
|
152
165
|
- **模型判断提示的前置**:可压缩区间的动态提示依赖 DSH 0.2.x 的根服务 `systemPrompt`(与 memory 等插件同款);它是启发式提示,不保证模型每次都会记录压缩节点,但条件从「不可判断」变成「可判断」。
|
|
166
|
+
- **接力链只认单亲血缘**:链记录只有 `parentId`/`childId`,「分叉 → 各走一条链 → 再合并」**没有定义**(fork 出来的副本与 subagent 会话不参与接力链,按各自独立处理)。「去新会话」只指向**最近一次**交接出来的会话(同一会话交接过两次时,更早的那个下一代请从侧栏找)。0.5.0 之前的老链由会话头 + 标题兜底合成,**没有交接单路径**,界面显示「暂无交接单」(不伪造、不猜)。
|
|
167
|
+
- **归档与置顶互斥**:宿主归档会话时会同时取消置顶。插件在**自动**归档前置顶会话会先问(见上),但你在侧栏手动归档置顶会话时,那一步的确认归宿主。
|
|
168
|
+
- **交接单的程序段随现场变化**:有锁定消息时,「当前进度」程序段会列出锁定条数与锚点摘要,所以不同 pin 状态下重新生成交接单,**程序段内容会变**(这是有意的:交接单要反映交接那一刻的事实)。
|
|
169
|
+
- **完整交接链是按需读取**:展开才查(默认一次 `/api/compact-relay-chain` 由服务端只读持久化 header/日志,最多 8 层;祖先未加载也能读;不展开零额外请求)。老宿主没有该端点时回落逐级 state 查询;某层未加载会显示「上一层会话未打开」+「打开并继续」。归档会话的记录能读,但**跳转前会先恢复**(否则会被宿主拒绝)。
|
|
153
170
|
|
|
154
171
|
## 版本状态与开源许可
|
|
155
172
|
|
|
156
|
-
-
|
|
173
|
+
- **当前版本**:**0.5.0**(已发布);**0.5.1** 收尾补丁在 `fix/0.5.1` 分支待验收(未发布)。版本线:0.3.1 首个公开版本;0.3.2 修设置页链接与联系方式;0.3.3 Windows 适配;0.3.4 修判断机制并补交接单渲染;0.3.5 修无活 agent 时压缩按钮误禁;0.3.6 修原生压缩线解析作用域(BUG-012);**0.4.0** 判断机制加强(cue 文案 v2 + 注入门控 + 计量)、诊断包、压缩预览建议锁定、pin 保留契约、轻账本累计、交接预览两段可编辑、接力链元数据、原生自动压缩真实触发线;**0.4.1** 统一原生压缩线显示口径;**0.4.2** 交接线保命补丁(硬上限 = 实际触发线 −5pp、三处告警 + 一键重算);**0.5.0** 无痕交接(命名反转「历史 N」+ 新会话继承原名、输入框上方接力条、交接后自动归档旧会话 + 恢复入口);**0.5.1** 收尾(祖先会话交接后接力条消失、0.5.0 前老链无条、归档入口死路与文案;pin 失效标注、原生压缩提前保护、置顶确认、归档开关、完整交接链、父→子入口;完整链在祖先未加载时只显示 1 层——改为服务端只读持久化日志一次读全,老宿主保留「打开并继续」兜底;设计定稿环只表达容量,pin 位置标记归 0.6 时间线)。已在 macOS(DSH 0.2.1-rc.2)与 Windows 11(Node 24.22)完成测试,仅调用 DSH 公开导出接口。变更记录详见 [CHANGELOG.md](CHANGELOG.md),后续规划与已知限制详见 [ROADMAP.md](ROADMAP.md),构建与发布流程详见 [DEVELOPMENT.md](https://github.com/cfanzx/dsh-compact-relay/blob/main/DEVELOPMENT.md)。
|
|
157
174
|
- **开源许可**:采用 [MIT](LICENSE) 许可证,欢迎通过 [GitHub Issues](https://github.com/cfanzx/dsh-compact-relay/issues) 与 Pull Requests 提交反馈。
|
|
158
175
|
|
|
159
176
|
## 参考与致谢
|
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
|
-
##
|
|
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
|
-
##
|
|
89
|
+
## 6. 更多
|
|
66
90
|
|
|
67
91
|
- 安装、完整说明见仓库 `README.md`;设计文档见 `ARCHITECTURE.md`。
|
|
68
92
|
- 遇到 bug / 想提改进:到仓库 Issues 提一条(设置页「帮助」区的「反馈 / 提 bug」入口会直接打开);也可以发邮件到 cfanzx@136.com。
|
package/lib/actions/handoff.js
CHANGED
|
@@ -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';
|
|
@@ -34,7 +35,7 @@ import { errorCodeOf, localIso } from '../dsh/diag-counters.js';
|
|
|
34
35
|
import { surfaceMessages } from '../dsh/prefix.js';
|
|
35
36
|
import { handoffTitlePlanOf, createHandoffSession, renameHistoryOf, sourceTitleOf } from '../dsh/sessions.js';
|
|
36
37
|
import { writeRelayRecord } from '../dsh/relay.js';
|
|
37
|
-
import { archiveHandoffSource, handoffArchiveNoteOf } from '../dsh/handoff-archive.js';
|
|
38
|
+
import { archiveHandoffSource, handoffArchiveNoteOf, isSessionPinned } from '../dsh/handoff-archive.js';
|
|
38
39
|
import { withLock } from './lock.js';
|
|
39
40
|
import { beginInflight, cancelErrorOf, combinedSignal, finishInflight } from './support.js';
|
|
40
41
|
|
|
@@ -97,13 +98,15 @@ async function collectHandoffFacts(deps, agent) {
|
|
|
97
98
|
* 不记 message(message 可能带交接单路径/会话内容)。行为与文案不变。
|
|
98
99
|
*
|
|
99
100
|
* @param {{ctx:object, cfg:object, lock:object, engine:object, log?:object, diag?:object}} deps
|
|
100
|
-
* @param {{agent:object, signal?:AbortSignal, commandId?:string, edits?:Record<string,string
|
|
101
|
+
* @param {{agent:object, signal?:AbortSignal, commandId?:string, edits?:Record<string,string>, archive?:boolean, confirmUnpin?:boolean}} input
|
|
101
102
|
* edits 只允许 `{约束?:string, 下一步?:string}`(P2-1;非法字段抛 code='edits')
|
|
103
|
+
* archive 默认 true(0.5.0 行为);false = 只交接不归档
|
|
104
|
+
* confirmUnpin 默认 false:旧会话在侧栏置顶时,必须显式确认才归档(归档会取消置顶)
|
|
102
105
|
*/
|
|
103
|
-
export async function handoff(deps, { agent, signal, commandId, edits } = {}) {
|
|
106
|
+
export async function handoff(deps, { agent, signal, commandId, edits, archive, confirmUnpin } = {}) {
|
|
104
107
|
const startedAt = Date.now();
|
|
105
108
|
try {
|
|
106
|
-
const outcome = await runHandoff(deps, { agent, signal, commandId, edits });
|
|
109
|
+
const outcome = await runHandoff(deps, { agent, signal, commandId, edits, archive, confirmUnpin });
|
|
107
110
|
deps.diag?.bumpAction('handoff', true);
|
|
108
111
|
return outcome;
|
|
109
112
|
} catch (error) {
|
|
@@ -118,9 +121,9 @@ export async function handoff(deps, { agent, signal, commandId, edits } = {}) {
|
|
|
118
121
|
/**
|
|
119
122
|
* 交接流程本体。
|
|
120
123
|
* @param {{ctx:object, cfg:object, lock:object, engine:object, log?:object}} deps
|
|
121
|
-
* @param {{agent:object, signal?:AbortSignal, commandId?:string}} input
|
|
124
|
+
* @param {{agent:object, signal?:AbortSignal, commandId?:string, edits?:object, archive?:boolean, confirmUnpin?:boolean}} input
|
|
122
125
|
*/
|
|
123
|
-
async function runHandoff(deps, { agent, signal, commandId, edits }) {
|
|
126
|
+
async function runHandoff(deps, { agent, signal, commandId, edits, archive, confirmUnpin }) {
|
|
124
127
|
if (agent === undefined || agent === null) throw actionError('handoff', '当前没有打开的会话,无法交接');
|
|
125
128
|
const session = agent.session;
|
|
126
129
|
if (session === undefined) throw actionError('handoff', '没有活动会话,无法交接');
|
|
@@ -293,28 +296,36 @@ async function runHandoff(deps, { agent, signal, commandId, edits }) {
|
|
|
293
296
|
}
|
|
294
297
|
|
|
295
298
|
// 0.5.0:交接成功后自动归档旧会话(让列表只剩新会话)。
|
|
299
|
+
// 0.5.1 批 2:archive:false = 只交接不归档;旧会话在侧栏置顶时必须显式
|
|
300
|
+
// confirmUnpin 才归档(宿主归档会在同一次写盘里取消置顶 —— dsh-workspace 源码)。
|
|
296
301
|
// 顺序要求:子会话已建好、旧会话已改名、父记录已 flush —— 全部完成之后才归档;
|
|
297
302
|
// 归档失败只 warn + 在返回文案里说明,绝不回滚/阻断交接。
|
|
298
303
|
// 不传 stopActivity:自动流程不替用户取消旧会话里仍在跑的 turn/subagent/job/schedule;
|
|
299
304
|
// 只对收尾中的 turn 做有限等待重试,仍活跃就保留可见并提示(宁可多一行,不静默杀任务)。
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
+
}
|
|
307
322
|
}
|
|
308
|
-
const archiveNote = handoffArchiveNoteOf(
|
|
323
|
+
const archiveNote = handoffArchiveNoteOf(archiveResult);
|
|
309
324
|
|
|
310
325
|
// BUG-004:失败原因可能是「摘要模型调用了工具」这类可操作信息,以前只写「日志有记录」
|
|
311
326
|
// (用户看不到 host 日志);这里带上短原因 + 可手动 /bc 的建议。
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
? ''
|
|
315
|
-
: compactWarning.length > 120
|
|
316
|
-
? `${compactWarning.slice(0, 119)}…`
|
|
317
|
-
: compactWarning;
|
|
327
|
+
// 0.5.1 D03:截断收口到 core/clip(原手写 120 字预算,文案不变)。
|
|
328
|
+
const briefWarning = compactWarning === null ? '' : clipCodeUnits(compactWarning, 120);
|
|
318
329
|
const compactNote =
|
|
319
330
|
compactOutcome !== null && compactOutcome.compacted === true
|
|
320
331
|
? `已将 ${compactOutcome.shadowedSeqs.length} 条旧会话压成 1 条摘要;`
|
|
@@ -347,7 +358,7 @@ async function runHandoff(deps, { agent, signal, commandId, edits }) {
|
|
|
347
358
|
readiness,
|
|
348
359
|
started: created.started,
|
|
349
360
|
// 0.5.0:归档结果结构化返回(HTTP 暂不下发;客户端看 message/接力条状态即可)。
|
|
350
|
-
archive: { status:
|
|
361
|
+
archive: { status: archiveResult.status, archived: archiveResult.archived === true },
|
|
351
362
|
message:
|
|
352
363
|
`交接完成:${compactNote}` +
|
|
353
364
|
`交接单已存至 ${file};` +
|