dsh-compact-relay 0.4.1 → 0.5.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.
- package/CHANGELOG.md +79 -0
- package/README.en.md +11 -1
- package/README.md +12 -2
- package/ROADMAP.md +54 -1
- package/TUTORIAL.md +5 -3
- package/lib/actions/handoff.js +50 -1
- package/lib/actions/recover.js +49 -0
- package/lib/actions/settings.js +20 -12
- package/lib/actions/state.js +21 -3
- package/lib/adapters/http.js +20 -5
- package/lib/client.js +1642 -139
- package/lib/core/handoff-guard.js +219 -0
- package/lib/core/relay.js +83 -6
- package/lib/dsh/handoff-archive.js +179 -0
- package/lib/dsh/relay.js +39 -1
- package/lib/dsh/sessions.js +206 -15
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,84 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0(2026-10-26)
|
|
4
|
+
|
|
5
|
+
> 0.5.0 功能批次:地基 F1–F6 + **命名反转 + 接力条 + 交接后自动归档旧会话/恢复入口**。双向跳转的「父会话 → 子会话」入口与里程碑面板尚未实现;其余已提交并发布(2026-10-26)。
|
|
6
|
+
|
|
7
|
+
### F1+F2:历史前缀纯函数 + relay record 白名单(commit `5397863`)
|
|
8
|
+
|
|
9
|
+
- **F1(`lib/core/relay.js`)**:新增 `HISTORY_PREFIX='历史'`、`historyTitleOf(n, base)`(生成「历史 N:<原名>」,空格/全角冒号/序号写法与 `relayTitleOf` 同一套规则,原名先 trim,空题回落「会话」);`splitHandoffTitle` 返回值新增 `scheme`——`handoff-n`(交接第 N 次)/ `history-n`(历史 N)/ `legacy`(旧「交接:」「接棒:」堆叠、无前缀、空/脏输入)。`baseTitle` / `handedOff` 的字段名与语义不变,旧标题解析结果逐字节不变。
|
|
10
|
+
- **F2(`lib/dsh/relay.js`)**:`normalizeRelayRecord` 白名单放行可选 `scheme` / `titleSeq` / `historyTitle`;旧记录缺失/非法一律读成 `null`(不猜、不补默认值),其余字段与旧记录读取语义不变。父会话写出的 relay record 形状不变(不写这三个字段)。
|
|
11
|
+
- **测试**:新增 `test/relay-title-scheme.mjs`(生成/解析/往返/scheme 不混用/空题与脏输入/>80 字节不截断)。
|
|
12
|
+
|
|
13
|
+
### F3:子会话自带 relay record(commit `b4878b8`)
|
|
14
|
+
|
|
15
|
+
- **`lib/dsh/sessions.js`**:`createHandoffSession` 在 `followup` 之后、`flush` 之前,向**子会话**写一条 `plugin:compact-relay/relay`:`parentId` / `childId` / `ordinal` / `baseTitle` / `title` / `scheme='handoff-n'` / `titleSeq` / `at` / `atIso`。走 plugin record 通道(`ignorable`,不进模型上下文、不算 token、压缩碰不到)。
|
|
16
|
+
- **为什么**:0.5.0 把旧会话改名「历史 N:<原名>」、新会话继承原名后,子会话标题里不再有「交接第 N 次」;链号若仍靠标题解析会算成 0,第 2 次交接会重复「第 1 次」。record 让链号自带。
|
|
17
|
+
- **父记录不动**:父会话的 relay record 仍由 `actions/handoff.js` 写 0.3/0.4 的现场形状(canonical + 旧字段 + `path`/`title`/`baseTitle`),0.5.0 起父/子记录都带 `path`(交接单路径);本次只在子会话多一条记录,标题/文档/交接流程不变。
|
|
18
|
+
- **测试**:`test/handoff.mjs` 增子会话 record 字段、`titleSeq`、两跳链式(第 1 次 → 第 2 次不重号)断言。
|
|
19
|
+
|
|
20
|
+
### F4:`readState` 增只读 `chain` 投影(commit `cc6f86d`)
|
|
21
|
+
|
|
22
|
+
- **`lib/dsh/relay.js`**:新增 `relayChainOf(record)`,把最近一条 relay record 投影成 `{ordinal, baseTitle, parentId, childId, scheme, path?}`——字段全部取自记录本身,不解析标题、不补默认序号;记录里没有的字段给 `null`,没有 `path` 就不出现这个键;不是对象返回 `null`。
|
|
23
|
+
- **`lib/actions/state.js`**:`readState` 新增 `chain`(没有记录为 `null`),旧字段 `relay` 原样保留(0.3 旧名 `parentSessionId`/`index` 仍可读)。纯 additive:老客户端忽略新字段;SSE 帧 +~110B。
|
|
24
|
+
- **测试**:`test/state-passive.mjs` 增「新记录全字段 / 0.3 旧记录缺字段 null / 无记录 chain=null / 旧字段不变」四组断言。
|
|
25
|
+
|
|
26
|
+
### 命名反转(第一个用户可见功能,commit d340dff)
|
|
27
|
+
|
|
28
|
+
- **行为**:新链交接后,旧会话改名「历史 N:<原名>」,新会话继承干净原名(不带序号);第 2 次交接为「历史 2:X / 历史 1:X / X」,列表读起来是一条对话。旧链(已存在的「交接第 N 次:」链、0.3 旧 record、旧「交接:/接棒:」堆叠)继续旧命名,旧会话标题一个字符不改(链级 cutover,不迁移、不混用)。
|
|
29
|
+
- **`lib/core/relay.js`**:新增 `MAX_TITLE_BYTES=80` 与 `truncateTitleUtf8`(与宿主 `session-title` 同口径的 UTF-8 安全截断);`historyTitleOf(index, base, maxBytes?)` 支持预算——前缀先计入、超长只缩 base,不传时保持 0.4.3 纯函数行为。
|
|
30
|
+
- **`lib/dsh/sessions.js`**:
|
|
31
|
+
- `handoffTitlePlanOf` 返回新增 `scheme`(`history-n`/`handoff-n`)与 `historyTitle`;有 record 时方案以 record 为准(旧记录 scheme=null → 旧链),无 record 时干净标题/「历史 N」→ 新链,「交接第 N 次」/旧前缀 → 旧链,读不到标题 → 保守旧链。
|
|
32
|
+
- `createHandoffSession` 接受可选 `plan`(文档/双写同一份);子会话 record 的 `scheme` 改为本次链实际方案(新链 `history-n`)。
|
|
33
|
+
- 新增 `renameHistoryOf`:子会话建好后把旧会话改名「历史 N:<原名>」,成功后 `await sessions.flush`;改名失败只 warn 不阻断;当前标题已是目标(含宿主按更小预算截断)时跳过,同一链重复执行不重复加前缀。scheme=`handoff-n` 时不读标题服务、不改名。
|
|
34
|
+
- **`lib/actions/handoff.js`**:交接编排在 `createHandoffSession` 之后调用 `renameHistoryOf`;父 record 写出新增 `scheme` + `titleSeq`(旧会话 rename 的 eventSeq),新链再写 `historyTitle`;父记录写完后再次 flush 旧会话,保证标题与 record 一起落盘。
|
|
35
|
+
- **有意行为变化**:F2/F3 时期「父记录不写 scheme/titleSeq/historyTitle」的约束随本功能更新——父/子记录现在都写 `scheme` + `titleSeq`;F5 链不变量测试已改为断言新形状。
|
|
36
|
+
- **测试**:新增 `test/handoff-naming.mjs`(第 1/2 次新链标题与序号、父/子 titleSeq 各取自己 rename、旧链 cutover 不改旧标题、0.3 旧记录不迁移、80 字节预算、幂等 guard、rename 失败降级含端到端不阻断);更新 `test/handoff.mjs` 与 `test/relay-chain.mjs` 的父记录/标题期望;`test/harness/host.mjs` 支持 `sessionTitleText` 与函数式 `renameEventSeq`。
|
|
37
|
+
|
|
38
|
+
### 交接后自动归档旧会话 + 恢复入口(commit 369a235)
|
|
39
|
+
|
|
40
|
+
- **宿主接缝(0.2.1-rc.3 app.asar 实测)**:
|
|
41
|
+
- 服务端 `@deepseek-ai/dsh-workspace/lib/index.js` 的 `WorkspaceRegistry.archiveSession(sessionId, { stopActivity })`;插件通过 `ctx.get('workspaceRegistry')` 调用(建子会话已在用同一服务)。
|
|
42
|
+
- 不带 `stopActivity` 时先跑 `workspace/session-activity` waterfall,任何 provider(运行中的 turn / subagent / job / schedule)报活都会在写盘前抛 `WorkspaceActiveSessionError`;带 `stopActivity: true` 会先写归档集合、再停旧会话的工作。**自动交接不传 stopActivity**——静默取消用户后台任务比多留一行破坏性大。
|
|
43
|
+
- `unarchiveSession(id)` 幂等且不做存在性探测;归档集合是 registry 全局状态,不改变 workspace 成员/排序,恢复后旧会话回到原位。
|
|
44
|
+
- 客户端 `ctx.uiWorkspace.unarchiveSession(id)` 与侧栏「取消归档」是同一接缝(`dsh-client-ui-workspace` 的 `UiWorkspaceService`)。
|
|
45
|
+
- **`lib/dsh/handoff-archive.js`**:自动归档只传 `sessionId`;已归档 → `already` 幂等;旧会话有活 → 等 150ms 重试一次后降级 `active`,保留旧会话可见并只 warn;无 registry / 找不到会话 / 写盘失败分别降级 `unavailable` / `unknown` / `failed`;任何分支都不抛错。`handoffArchiveNoteOf` 统一生成用户文案(失败固定含「归档失败,不影响交接」)。
|
|
46
|
+
- **`lib/actions/handoff.js`**:归档顺序在**子会话建好、旧会话改名、父 record flush**之后;成功 message 追加「旧会话已归档(可在新会话输入框上方「已接力自 …」→「恢复旧会话」找回;侧栏「全部会话」也能取消归档)」,失败追加原因且不影响 `ok/childId/path`。
|
|
47
|
+
- **恢复入口**:
|
|
48
|
+
- 服务端 `lib/actions/recover.js` + HTTP `action: 'recover'`:直接调 `workspaceRegistry.unarchiveSession`,跳过 `resolveTarget`(归档会话通常没有活 agent),幂等并回报剩余归档集合。
|
|
49
|
+
- `readState` 新增 additive 字段 `parentArchived`(true/false;没有父会话或宿主不给归档状态为 null),`chain` 仍是 relay record 的纯投影。
|
|
50
|
+
- 客户端接力条下拉新增「恢复旧会话」:已归档或宿主未给状态时显示;优先 `ctx.uiWorkspace.unarchiveSession`,老宿主回退 HTTP recover;成功后更新本地归档状态并自动切回旧会话。「回到原会话」在已归档时也先恢复,避免用户撞「归档会话不可打开」。
|
|
51
|
+
- **兼容/边界**:旧链(`handoff-n`)与 0.3/0.4 旧 record 同样归档,命名逻辑一个字符不改;宿主接缝缺失/失败一律降级为「不归档」并 warn;不改交接单正文、压缩行为、命名反转逻辑。
|
|
52
|
+
- **测试**:新增 `test/archive.mjs` 16 组(归档参数/幂等/活跃重试/失败降级/旧链/恢复动作/HTTP 路由/产物静态断言);`test/relay-bar.mjs` 新增 5 组恢复交互;`test/state-passive.mjs` 增 `parentArchived` 断言。`npm test` **257 PASS / 0 FAIL**。
|
|
53
|
+
- **未真机验证**:本环境未启动真实 DSH 会话点验「交接 → 自动归档 → 接力条恢复」全链路;宿主证据来自 app.asar 源码与同形 fake ctx。真机需确认:①交接完成后侧栏旧会话消失、新会话自动打开;②「恢复旧会话」后旧会话回原位且可打开;③旧会话有后台任务时保留可见并提示任务族。
|
|
54
|
+
|
|
55
|
+
### 验证
|
|
56
|
+
|
|
57
|
+
- `npm test` 全绿:**257 PASS / 0 FAIL**(历史基线:F1+F2 后 204 → F3 后 206 → F4 后 207 → F5 后 213 → 命名反转后 220 → 接力条后 236;本次自动归档 + 恢复入口新增 21)。
|
|
58
|
+
- 未 commit、未发布、未打 tag;0.5.0 剩余:双向跳转的父会话→子会话入口、里程碑面板。
|
|
59
|
+
|
|
60
|
+
## 0.4.2(2026-10-26 · 交接线保命补丁)
|
|
61
|
+
|
|
62
|
+
### 修复:交接提醒可能晚于 DSH 原生压缩(0.4.1 的三个洞)
|
|
63
|
+
|
|
64
|
+
- **现象**:① 服务端保存校验只看配置阈值(80%),手改设置文件能把交接线设到 70%,而 UI 按实际触发线只允许 66%;② 交接线 60% 是死值——换到实际触发线更低的模型(如 55%)时,指示灯/菜单照旧按 60% 提醒,DSH 会先自己压历史;③ 已保存的交接线高于当前模型实际线时,除设置页一行 warning 外没有可见提示。
|
|
65
|
+
- **修复(P0-1 运行时判定)**:新增共享纯函数 `lib/core/handoff-guard.js`(`HANDOFF_MARGIN_PP=5`、`handoffCapOf()`、`handoffGuard()`):**交接线硬上限 = 实际触发线 −5pp,向下取整到整数百分位**(deepseek-flash 1M:67.8% → 62%)。违规时**只告警、不静默改**:指示灯悬停卡 + 环上交接刻度旁小警示、`/handoff` 菜单项、设置页三处;设置页提供「按实际线重算」一键(二次确认,目标 = 实际线 −5pp),目标 ≤ 压缩线时拒绝并提示先降压缩线。
|
|
66
|
+
- **修复(P0-2 服务端同口径)**:`save(patch, { nativeRatio })` 接受客户端传入的当前模型实际触发线,按实际线 −5pp 校验;未带(旧客户端 / 无会话)退回配置阈值 −1pp 防呆,行为同 0.4.1。超限一律 400 + `field=thresholds.handoffRatio` + 「最高可设 X%」,不夹紧、不落盘。实际触发线算不出(`native.unavailable`)时不告警、不阻断,保存退回配置阈值;DSH 原生自动压缩关闭时不校验。
|
|
67
|
+
- **修复(P1 换模型跟随)**:客户端状态刷新即重新判定;观察到路由模型变化且首次发现违规时弹一次 toast(localStorage 记上次路由,同路由不重复打扰)。指示灯环上只加警示节点,不改分段/断点逻辑。
|
|
68
|
+
- **兼容性**:`nativeRatio` 为可选参数,旧客户端不带 → 0.4.1 行为;旧设置文件原样加载,不静默改写。兜底上限从「< 配置阈值」明确为「≤ 配置阈值 −1pp」(与 0.4.1 提示文案「最高 79%」一致;0.4.1 代码实际接受 79.5%,0.4.2 起按文案执行,仅影响亚百分点手改值)。
|
|
69
|
+
- **文档(P2)**:README / README.en / TUTORIAL 写清三条线 **40% 压缩线 / 60% 交接线 / 67.8% 原生实际触发线**,删掉「原生线 −1%」旧口径;CHANGELOG / ROADMAP 同步。
|
|
70
|
+
- **测试**:新增 `test/handoff-guard.mjs`(纯函数 12 组边界 + 服务端保存校验 8 组:带/不带 nativeRatio、0.62/0.63 边界、非法参数、旧设置文件、压缩线冲突、原生关闭;客户端产物接线断言);`test/plugin-load.mjs` 增加 HTTP 集成(临时 profile:`nativeRatio=0.678` 保存 65% → 400 含「最高可设 62%」、62% → 200、不带 → 200 兼容、85% → 400 仍按 62% 而非 79%)。`npm test` 全绿。
|
|
71
|
+
- **验证边界**:设置页/悬停卡/菜单/换模型 toast 的真机点验需在真实 DSH 会话里确认(本仓库测试环境无 Chrome 自动化);服务端与纯函数、构建一致性已自动化覆盖。
|
|
72
|
+
|
|
73
|
+
### 修复:设置页保存条 + 交接线输入被静默改写(真机反馈)
|
|
74
|
+
|
|
75
|
+
- **保存条(上一提交)**:设置页很长,保存按钮原在最底部;改成粘性底部工具条(`position:sticky; bottom:0`),含保存 / 恢复默认 / 未保存指示 / 保存结果,保存失败文案同时出现在条内与交接线行内。
|
|
76
|
+
- **现象(真机)**:「输入 80 他直接改成 99 然后报错」。交接线输入框里打 80,输入框自己变成 99(=0.99),点保存再报错。
|
|
77
|
+
- **根因(客户端三处叠加,`main@2b4d10b` 的 `lib/client/50-settings.js`)**:① `:549` 每次按键都 `Math.min(0.99, Math.max(0.05, value))`——夹紧上限写死 99%,和真实上限 62% 无关;② `:542`/`:408` 输入框 value 是派生值 `Math.round(ratio*100)`、**没有本地草稿**,所以中间态(60 后面打 8 → 608)当场被夹成 0.99 并渲染成「99」,用户输入消失;③ `:540` `max` 是 62 只作浏览器提示,`type=number` 打字不受约束,代码却按 99 夹。同族问题:最小线/压缩线也是「按键即夹紧」,清空输入框会被 `Number('')=0` 夹成 5%,无法清空重打。
|
|
78
|
+
- **修复**:百分比输入加本地草稿(按键只记草稿 + 原样写入 `value/100`,空串/非数字不改表单;失焦/加载/保存成功后回落表单派生值);`max` 用当前 cap(62%,算不出时 100)并新增「最高 62%」行内徽标;三个百分比输入聚焦/点击即全选(避免在旧值后面追加);超限仍由服务端 400 +「最高可设 X%」拒绝,**不夹紧、不落盘**。
|
|
79
|
+
- **视觉打磨**:交接线行重排(控制行 / 体量估算 / 上限说明 / amber 告警 / 红色失败 / 右对齐「按实际线重算」动作行);告警与失败块改淡色底(7–8%)+ 同色描边;保存条按钮与状态文字对齐、状态独立一行胶囊(红/绿)、高度 47px,深浅主题都用现有 token(探针:条底 rgb(35,35,36)/rgb(255,255,255),错误文字均为红色主导)。
|
|
80
|
+
- **测试**:`test/settings-ux.mjs` 新增「输入 80 输入框仍是 80(不是 99)→ 保存 400 → 条内+行内双处报错 → 改 62 保存成功」;`npm test` **197 PASS / 0 FAIL**;`test/e2e/settings-ux.mjs`(隔离 home + 无头 Chrome)真键盘输入 80/62 全流程 PASS,截图见 `default-workspace/2026-10-25-收尾/`。
|
|
81
|
+
|
|
3
82
|
## 0.4.1(2026-10-25)
|
|
4
83
|
|
|
5
84
|
### 修复:原生压缩线显示口径不一致(设置页仍写 80%,指示灯写实际触发 67.8%)
|
package/README.en.md
CHANGED
|
@@ -67,6 +67,16 @@ Running `/pin` marks messages that must be preserved during compaction. Pinned m
|
|
|
67
67
|
|
|
68
68
|

|
|
69
69
|
|
|
70
|
+
## The three lines (deepseek-flash, 1M window defaults)
|
|
71
|
+
|
|
72
|
+
| Line | Default | Meaning | How to change |
|
|
73
|
+
| --- | --- | --- | --- |
|
|
74
|
+
| Compaction line | 40% | The ring turns amber and the menu suggests "compact old session" | Settings → Compact Relay |
|
|
75
|
+
| Handoff line | 60% | The inner dot turns red and the menu suggests "hand off to a new session" | Settings → Compact Relay; **hard cap = actual trigger −5pp** (62% for deepseek-flash) |
|
|
76
|
+
| Native auto-compaction line | 67.8% | Where DSH actually auto-compacts, computed from its token budget (the configured 80% is only a theoretical ceiling; output reservation + DSH headroom pull it down to 67.8% for deepseek-flash) | `thresholdRatio` of `compaction-basic` in the profile (default 80%) |
|
|
77
|
+
|
|
78
|
+
**Why the handoff line keeps a 5-point margin**: the handoff reminder must fire before DSH compacts on its own, otherwise there is no time to generate a handoff document and open the new session. Since 0.4.2 the handoff line is hard-capped at **actual trigger −5pp** (floored to a whole percent; 62% for deepseek-flash on a 1M window). Going over **rejects the save and shows the target value** — values are never silently clamped. Existing higher values are not rewritten behind your back, but the settings page keeps warning and offers a one-click "recalculate from actual line". When the actual trigger line is unavailable (no session / host did not report it), nothing is warned or blocked and save validation falls back to the configured threshold guard (same as 0.4.1).
|
|
79
|
+
|
|
70
80
|
## Installation
|
|
71
81
|
|
|
72
82
|
**Requirements**: DSH Desktop or `dsh web` 0.2.x (tested on 0.2.1-rc.2), Node.js 22+ (the plugin uses the runtime bundled with DSH). Running the source test suite requires Node **22.15+** (the harness uses `node:module.registerHooks`). The suite is verified on macOS and Windows 11 (Node 24.22).
|
|
@@ -143,7 +153,7 @@ The first time a session is opened, the ring plays a brief highlight animation w
|
|
|
143
153
|
|
|
144
154
|
## Version and license
|
|
145
155
|
|
|
146
|
-
- **Current version**: 0.4.
|
|
156
|
+
- **Current version**: 0.4.2 (0.3.1 was the first public release; 0.3.2 fixed settings links and contact details; 0.3.3 added Windows support; 0.3.4 fixed the compression-point judgment mechanism; 0.3.5 fixed the disabled compress button; 0.3.6 fixed the native-line parsing scope; 0.4.0 hardened the judgment mechanism, added diagnostics, pin retention, ledger, handoff metadata and the real native trigger line; 0.4.1 unified the native-line display: the ring, the settings page and the teaching card all show the actual trigger point with the configured threshold annotated; 0.4.2 is the handoff-line safety patch: hard cap = actual trigger −5pp with the same rule in the UI and on the server, over-limit saves are rejected with the target value, warnings in three places plus a one-click recalculation, a one-off notice after switching models, and documentation of the 40/60/67.8 lines). Tested on macOS (DSH 0.2.1-rc.2) and Windows 11 (Node 24.22), using only public DSH exports. See [CHANGELOG.md](CHANGELOG.md) for changes, [ROADMAP.md](ROADMAP.md) for plans and known limitations, and [DEVELOPMENT.md](https://github.com/cfanzx/dsh-compact-relay/blob/main/DEVELOPMENT.md) for build and release workflows.
|
|
147
157
|
- **License**: [MIT](LICENSE). Feedback and Pull Requests are welcome via [GitHub Issues](https://github.com/cfanzx/dsh-compact-relay/issues).
|
|
148
158
|
|
|
149
159
|
## Credits
|
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
### 1. 状态可视化监控
|
|
32
32
|
|
|
33
|
-
指示环按压缩评估逻辑分段显色,分别标识模型或阈值判定的建议压缩点、已锁定消息位置,以及 DSH 原生自动压缩线(按宿主实际 token 预算算出的触发点:deepseek-flash 约 67.8%,配置阈值默认 80
|
|
33
|
+
指示环按压缩评估逻辑分段显色,分别标识模型或阈值判定的建议压缩点、已锁定消息位置,以及 DSH 原生自动压缩线(按宿主实际 token 预算算出的触发点:deepseek-flash 约 67.8%,配置阈值默认 80%)。当上下文占用越过原生自动压缩线时,整环呈红色闪烁告警。交接提醒线晚于当前模型的实际触发线(−5pp)时,环上交接刻度旁会出现红色小警示,悬停卡与 `/handoff` 菜单也会同步标出「交接线已晚」。
|
|
34
34
|
|
|
35
35
|
上下文进入「可压缩区间」(最小压缩线到压缩线之间)时,插件会在系统提示里主动告诉模型当前容量与区间,模型据此判断「刚完成一个自然节点」时是否记录压缩节点(0.3.4 起;此前模型看不到容量,判断条件不可执行)。
|
|
36
36
|
|
|
@@ -67,6 +67,16 @@
|
|
|
67
67
|
|
|
68
68
|

|
|
69
69
|
|
|
70
|
+
## 三条线(deepseek-flash 1M 默认)
|
|
71
|
+
|
|
72
|
+
| 线 | 默认值 | 含义 | 怎么调 |
|
|
73
|
+
| --- | --- | --- | --- |
|
|
74
|
+
| 压缩提醒线 | 40% | 到线变琥珀色,菜单建议「压缩旧会话」 | 设置 → 压缩接力 |
|
|
75
|
+
| 交接提醒线 | 60% | 到线内圆亮红点,菜单建议「交接新会话」 | 设置 → 压缩接力;**硬上限 = 实际触发线 −5pp**(deepseek-flash 下 62%) |
|
|
76
|
+
| 原生自动压缩线 | 67.8% | DSH 按 token 预算真正触发自动压缩的点(配置阈值 80% 只是理论上限,deepseek-flash 的输出预留 + DSH 余量把它压到 67.8%) | profile 里 `compaction-basic` 的 `thresholdRatio`(默认 80%) |
|
|
77
|
+
|
|
78
|
+
**交接线为什么要留 5 个百分点**:交接提醒必须早于 DSH 自己压历史,才有时间生成交接单、开新会话。所以 0.4.2 起交接线有硬上限 **实际触发线 −5pp**(取整到整数百分位;deepseek-flash 1M 窗口下是 62%):超过时**拒绝保存并提示目标值**,不静默夹紧;已存的高值不会被偷偷改写,但设置页会常驻告警,并可点「按实际线重算」一键改成上限。实际触发线算不出(无会话 / 宿主没给)时不告警、不阻断,保存校验退回配置阈值防呆(与 0.4.1 一致)。
|
|
79
|
+
|
|
70
80
|
## 安装
|
|
71
81
|
|
|
72
82
|
**环境要求**:DSH 桌面版或 `dsh web` 0.2.x(已于 0.2.1-rc.2 测试通过),Node.js 22+(插件跟随 DSH 自带运行时)。若要在源码里跑测试,需要 Node **22.15+**(测试脚手架用到 `node:module.registerHooks`)。测试已在 macOS 与 Windows 11(Node 24.22)实测全部通过。
|
|
@@ -143,7 +153,7 @@ dsh plugin --profile desktop add github:cfanzx/dsh-compact-relay
|
|
|
143
153
|
|
|
144
154
|
## 版本状态与开源许可
|
|
145
155
|
|
|
146
|
-
- **当前版本**:0.4.
|
|
156
|
+
- **当前版本**:0.4.2(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(UI 与后台同口径,超限拒绝保存并给目标值)、三处告警 + 一键重算、换模型后一次性提示,文档写清 40/60/67.8 三条线)。已在 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)。
|
|
147
157
|
- **开源许可**:采用 [MIT](LICENSE) 许可证,欢迎通过 [GitHub Issues](https://github.com/cfanzx/dsh-compact-relay/issues) 与 Pull Requests 提交反馈。
|
|
148
158
|
|
|
149
159
|
## 参考与致谢
|
package/ROADMAP.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 压缩接力 · 目标与路线图
|
|
2
2
|
|
|
3
|
-
> 版本基准:0.4.
|
|
3
|
+
> 版本基准:0.4.2(2026-10-25)|状态:0.3 已发布(npm + GitHub),0.3.6 已发布,0.4.0 已发布(npm latest + GitHub tag/Release),0.4.1 已发布;**0.4.2 交接线保命补丁已实现、待验收与发布**;**0.5.0 命名反转 + 接力条 + 交接后自动归档/恢复已实现(未提交、未发布);双向跳转的父→子入口与里程碑面板待实现(见 §三 0.5.0)**
|
|
4
4
|
> 维护方式:每次发布前更新"当前状态"与勾选进度;方向变化必须同时改本文件和 memory 项目页。
|
|
5
5
|
|
|
6
6
|
## 一、最终目标(North Star)
|
|
@@ -59,6 +59,17 @@
|
|
|
59
59
|
- ✅ **P0-2 诊断包最小版**:本机生成、白名单 + 脱敏的诊断信息(设置页「生成并预览 / 复制 / 打开问题报告页」+ 只读 `GET /api/compact-relay-diagnostics`),并接入压缩/交接/锁定计数与耗时(commit `41699c8`;`npm test` exit 0,含 T1–T9 与真机 e2e 断言)。
|
|
60
60
|
- 🚧 **P0-1 判断机制加强**(代码已落地,待真实任务验收):cue 文案 v2(4 类完成信号 + 被拒无副作用)、注入门控(10%–40%、距上次注入 ≥3 轮、按会话不落盘)、工具描述 v2(用户/模型都可调用、被拒不需要致歉、典型调用时机)、cue/mark 计量(`cueInjected` / `markCalls` / `markAccepted` / `markRejected` 直接进诊断包 counters)。验收口径不变(标准 B):3 次真实长任务里 ≥2 次模型自发调用 `mark_compress_point`。
|
|
61
61
|
|
|
62
|
+
**0.4.2 交接线保命补丁(已实现、待 Lead 验收;发布须用户点头)**
|
|
63
|
+
|
|
64
|
+
> 范围定稿:`dcr-0.4.2-范围定稿-2026-10-25.md`|实施规划:`docs/0.4.2-implementation-plan.md`
|
|
65
|
+
|
|
66
|
+
- ✅ **P0-1 交接线硬规矩**:硬上限 = 实际触发线 −5pp(整数百分位;deepseek-flash 1M 下 62%),共享纯函数 `core/handoff-guard.js`;违规只告警不静默改(指示灯悬停卡 + 环上交接刻度警示、`/handoff` 菜单、设置页三处);设置页「按实际线重算」一键 + 二次确认,目标 ≤ 压缩线时拒绝并提示先降压缩线。
|
|
67
|
+
- ✅ **P0-2 服务端同口径**:`save()` 接受可选 `nativeRatio`,按实际线 −5pp 校验;未带退回配置阈值 −1pp 防呆(兼容 0.4.1 客户端);超限 400 + `field` + 目标值,不落盘。
|
|
68
|
+
- ✅ **P1 换模型跟随**:状态刷新即重判;观察路由变化后首次违规弹一次 toast;算不出实际线不告警、不阻断。
|
|
69
|
+
- ✅ **P2 文档三线**:README / README.en / TUTORIAL 写清 40% / 60% / 67.8%,删掉「原生线 −1%」旧文案;CHANGELOG 0.4.2 段同步。
|
|
70
|
+
- ⏳ 待真机点验:设置页 max=62 与提示、环上警示、菜单 ⚠、换模型 toast、截图存档(本仓库自动化无 Chrome/CDP)。
|
|
71
|
+
- 明确不做(0.4.2 边界):历史前缀 / 接力条(0.5.0)、自动触发(1.0)、改宿主触发逻辑、环上 A/B/C 画法。**自动归档旧会话已从 1.0 挪入 0.5.0**(见 §三 0.5.0 第 4 条)。
|
|
72
|
+
|
|
62
73
|
1. ✅ **交接单六段 → 九段**(0.3 已提前交付):总体目标 / 约束 / 已完成 / 当前进度 / 改过的文件 / 关键决策 / 已知问题 / 未完成 / 下一步。
|
|
63
74
|
- 文件改动、关键决策由程序收集(`core/handoff-facts.js`),模型只写 7 段、编不了。
|
|
64
75
|
2. ✅ **交接就绪检查**(0.3 已提前交付):九段齐全/占位阻断,路径核对只警告;预览卡里先看再确认。
|
|
@@ -68,8 +79,49 @@
|
|
|
68
79
|
|
|
69
80
|
验收:真实长任务交接后新会话**无需补充说明**即可继续;交接单一次通过率 ≥90%。
|
|
70
81
|
|
|
82
|
+
### 0.5.0 无痕交接(命名反转 + 接力条 + 自动归档/恢复已实现;父→子跳转/里程碑面板待实现)
|
|
83
|
+
|
|
84
|
+
> 范围口径:`default-workspace/dcr-版本路线图-2026-10-26.md` + `dcr-无痕handoff-运行目标与地基层-2026-10-25.md`。0.5.0 是用户可见的第一次大更新;**地基本身不单独发版**,随 0.5.0 一起发。
|
|
85
|
+
|
|
86
|
+
**用户能看到的变化(0.5.0 范围):**
|
|
87
|
+
|
|
88
|
+
1. **命名反转**:交接后旧会话改名「历史 N:<原名>」,新会话继承原名;旧链(交接第 N 次)一个字符不动;列表读起来是一条对话。
|
|
89
|
+
2. **接力条**:新会话输入框上方一行「已接力自 历史 1 ▾」,下拉含查看交接单 / 回到原会话 / 恢复旧会话 / 路线图占位;落位 `conversation.input.dock`(输入框单行时间线;早期审查记录的 `conversation.composer.dock` 同属输入框区域,宿主两个槽都在)。
|
|
90
|
+
3. **双向跳转**:接力条回父会话(已实现,已归档时先自动恢复);父会话侧回子会话(待实现,需要父会话接力记录/入口)。
|
|
91
|
+
4. **交接后自动归档旧会话**:交接成功(子会话已建、旧会话已改名、record 已落盘)即归档旧会话;交接完成提示 + 接力条「恢复旧会话」两处告知,一键恢复(正式 unarchive API),恢复后回原位;旧会话仍有 turn/subagent/job/schedule 时**不自动停止任务**,有限等待后保留可见并提示;失败不阻断交接。
|
|
92
|
+
|
|
93
|
+
**命名反转(第一个用户可见功能,已实现、未提交):**
|
|
94
|
+
|
|
95
|
+
- 新链:旧会话「历史 N:<原名>」,新会话继承干净原名;第 2 次为「历史 2:X / 历史 1:X / X」。
|
|
96
|
+
- 旧链:交接第 N 次 / 0.3 旧 record / 旧堆叠前缀继续旧命名,旧会话标题一个字符不改(链级 cutover)。
|
|
97
|
+
- 数据面:父/子 relay record 都写 `scheme` + `titleSeq`,新链父记录带 `historyTitle`;链号只从 record 续。
|
|
98
|
+
- 80 字节:前缀先计入、超长只缩 base;`renameHistoryOf` 幂等 + flush,失败只 warn。
|
|
99
|
+
- 测试:`test/handoff-naming.mjs`(7 组)+ F5 期望更新;接力条 `test/relay-bar.mjs`;自动归档/恢复 `test/archive.mjs`。`npm test` **257 PASS / 0 FAIL**。
|
|
100
|
+
|
|
101
|
+
**地基进度(数据面 / 纯函数 / 测试):**
|
|
102
|
+
|
|
103
|
+
| # | 内容 | 状态 |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| F1 | 历史前缀纯函数:`HISTORY_PREFIX` / `historyTitleOf` / `splitHandoffTitle` 返回 `scheme`(`handoff-n` / `history-n` / `legacy`) | ✅ 已提交 `5397863` |
|
|
106
|
+
| F2 | relay record 白名单放行 `scheme` / `titleSeq` / `historyTitle`(旧记录读成 null;父会话记录写出形状不变) | ✅ 已提交 `5397863` |
|
|
107
|
+
| F3 | 建子会话时往**子会话**写 relay record(ordinal/baseTitle/scheme/titleSeq)——不做这步第 2 次交接序号会重复 | ✅ 已提交 `b4878b8` |
|
|
108
|
+
| F4 | `readState.relay` 扩成只读 `chain`(ordinal/baseTitle/parentId/childId/scheme/path),旧字段原样保留 | ✅ 已提交 `cc6f86d` |
|
|
109
|
+
| F5 | 链不变量测试 `test/relay-chain.mjs`(三跳 1→2→3、双写形状、scheme 不混、旧链逐字节、chain 投影),并入 `npm test` | ✅ 已完成(未提交) |
|
|
110
|
+
| F6 | ROADMAP / CHANGELOG / ARCHITECTURE 登记 0.5.0 地基 | ✅ 本次(未提交) |
|
|
111
|
+
| 命名反转 | 旧会话「历史 N」/新会话继承原名/旧链 cutover(`dsh/sessions.js` + `actions/handoff.js`) | ✅ 已实现(未提交) |
|
|
112
|
+
| 自动归档/恢复 | 交接成功后 `workspaceRegistry.archiveSession`(不 stopActivity)+ `recover` 动作 + 接力条「恢复旧会话」+ `readState.parentArchived` | ✅ 已实现(未提交) |
|
|
113
|
+
|
|
114
|
+
**宿主侧没有接缝(0.5.0 不追):**
|
|
115
|
+
|
|
116
|
+
- **消息流拼接不可行**:`conversation.chat.flow` 是 `single` 槽且已被宿主 ChatFlow 注册,`conversation.chat.node` 只按宿主 entryKey 渲染 —— 旧消息拼不进新会话,只能「接力条 + 跳旧会话」。
|
|
117
|
+
- **通栏接力条不可行**:宿主 header 没有通栏 band,接力条落在输入框内的 `conversation.input.dock`(单行;早期审查记录的 `conversation.composer.dock` 同区域)。
|
|
118
|
+
|
|
119
|
+
**验收:** 交接 2 次后列表为 `历史 2:X / 历史 1:X / X`;接力条「查看交接单 / 回到原会话 / 恢复旧会话 / 路线图占位」可用;旧会话自动归档且能从接力条恢复;旧链标题不变;`npm test` 全绿 + 真机截图。
|
|
120
|
+
|
|
71
121
|
### 0.5 项目记忆(1–2 月)
|
|
72
122
|
|
|
123
|
+
> 0.5.0 实际范围是上面的「无痕交接」;本节为早期规划,不随 0.5.0 交付。
|
|
124
|
+
|
|
73
125
|
1. **项目上下文包**:目标 / 关键决策 / 待办 / 改过的文件,新会话自动带上。
|
|
74
126
|
2. **接力链视图**:父子会话树/时间线,一键跳转。
|
|
75
127
|
3. **Memory 双向**:读取项目页作为上下文,写回交接单(先跑稳单向再开)。
|
|
@@ -153,6 +205,7 @@
|
|
|
153
205
|
- ✅ 0.3.6 已发布(BUG-012):原生压缩线解析只认 `config:` 直接子键(按缩进判断),修复 `modelPolicies` 嵌套 `thresholdRatio` 被误报为全局线(Windows 真实会话误显示 50%);真实触发线按模型 route 计算留 0.4。
|
|
154
206
|
- ✅ 0.4.0 判断机制与诊断包(2026-10-24 完成):P0-2 诊断包最小版已交付(commit `41699c8`,`npm test` 全绿 + 隔离 e2e 真机验证);P0-1 判断机制加强(cue 文案 v2 + 注入门控 10%–40%/≥3 轮 + 工具描述 v2 + cue/mark 计量)代码已落地、测试全绿,标准 B 已达标(3/3)并随 0.4.0 发布。
|
|
155
207
|
- ✅ 真实压缩 e2e:`test/e2e/real-compact.mjs`(真实模型 + 真实压缩事务 + `maxSummaries=1` 合并旧摘要分支)6 项断言通过。
|
|
208
|
+
- ✅ 0.5.0 功能已落地(**未发布**):F1–F6 地基 + 命名反转 + 接力条 + 交接后自动归档/恢复入口;`test/relay-chain.mjs`、`test/relay-bar.mjs`、`test/archive.mjs` 并入 `npm test`(当前 **257 PASS / 0 FAIL**)。剩余:父→子跳转入口、里程碑面板。
|
|
156
209
|
- ✅ P3-01~14(13 修/1 确认无需修)+ BUG-004 + P2-T1 已收口。
|
|
157
210
|
- ⏳ 市场收录 PR:仓库满 1 天(2026-10-21 19:09 后)提,yml 与说明已备。
|
|
158
211
|
- ⏳ 陌生人安装路径验证(npm 安装 → 重启 → 设置/指示灯)—— 发布后第一次外部用户安装时一并确认。
|
package/TUTORIAL.md
CHANGED
|
@@ -42,10 +42,11 @@
|
|
|
42
42
|
- **指示灯怎么看**:设置页「帮助」区顶部的教学卡片,5 个示意环(未满灰 / 走满蓝 / 压缩区琥珀 / 交接区红 / 原生线),随你的配色和阈值实时变
|
|
43
43
|
- **再看一次初次引导**:「帮助」区的按钮,点了关掉设置页就会重播引导(手动重播时长更长)
|
|
44
44
|
- **最小压缩线**:低于它压缩不划算(默认 5%,会随保留量和窗口自动抬高)
|
|
45
|
-
- **压缩提醒线 / 交接提醒线**:按上下文窗口的百分比算(1M 窗口默认 40% / 60
|
|
45
|
+
- **压缩提醒线 / 交接提醒线**:按上下文窗口的百分比算(1M 窗口默认 40% / 60%),换小窗口模型自动缩放;三条线的关系见下一条
|
|
46
46
|
- **摘要份数上限**:压到几份摘要后建议交接(默认 6)
|
|
47
47
|
- **保留近期原文**:压缩时这段 token 内的近期消息保持原文(默认 40K)
|
|
48
|
-
- **DSH 原生自动压缩**:状态 + 一键关闭/恢复(会先确认,改 profile
|
|
48
|
+
- **DSH 原生自动压缩**:状态 + 一键关闭/恢复(会先确认,改 profile 配置前备份,重启生效);原生开着时交接线硬上限 = **实际触发线 −5pp**(0.4.2 起;deepseek-flash 1M 下是 62%),超限保存会被拒绝并提示目标值,设置页有「按实际线重算」一键改成上限
|
|
49
|
+
- **三条线(deepseek-flash 1M 默认)**:40% 压缩提醒线 → 60% 交接提醒线 → 67.8% 原生实际触发线(配置阈值 80% 只是理论上限,输出预留 + DSH 余量把真实触发点压到 67.8%)。交接线晚于实际触发线时,环上交接刻度、悬停卡、`/handoff` 菜单三处都会告警
|
|
49
50
|
- **交接单目录**:九段交接单保存位置
|
|
50
51
|
- **颜色**:选 5 个基准色(默认/填满/调整/上限/锁定),浅色/深色两套自动派生;每行都有「作用」和「调整后」两行说明
|
|
51
52
|
|
|
@@ -56,7 +57,8 @@
|
|
|
56
57
|
- **指示灯显示「上下文未知」**:会话刚打开,等半秒到 1.5 秒会自己出数。
|
|
57
58
|
- **没有「可压」内容**:会话太短,或近期内容需要保留原文,或锁定把范围挡住了(先 `/unpin`)。
|
|
58
59
|
- **压缩到一半取消了**:历史不会变、锁会释放;已发出的模型请求可能仍计费。
|
|
59
|
-
-
|
|
60
|
+
- **换模型后阈值变了**:阈值按窗口比例算,小窗口模型会自动变小,这是预期;交接线还有一条硬上限「当前模型实际触发线 −5pp」,换到实际线更低的模型后旧交接线会变成「已晚」。
|
|
61
|
+
- **提示「交接线已晚」/ 保存被拒**:说明当前交接线晚于 DSH 原生压缩线,DSH 会先自己压历史。三种处理:① 点设置页「按实际线重算」(一键改成实际线 −5pp,最省事);② 手动把交接线调到提示的目标值;③ 关掉「DSH 原生自动压缩」(不再有截止线)。实际触发线算不出时不会告警,也不会因此拒绝保存。
|
|
60
62
|
- **设置保存了但没变**:设置栏保存即时生效;改代码或 profile 配置才需要重启 DSH。
|
|
61
63
|
- **在 headless / QQ 桥上发 `/bc` 没反应**:指示灯和 `/bc`、`/handoff` 依附桌面输入框与斜杠命令,headless 会话和 QQ / 微信桥接链路不会加载它们,`/bc` 会被当成普通消息。插件本身不报错、也不额外烧模型调用;要在电脑上开 DSH 桌面版才能用。
|
|
62
64
|
|
package/lib/actions/handoff.js
CHANGED
|
@@ -32,8 +32,9 @@ import { checkHandoffReadiness } from '../core/readiness.js';
|
|
|
32
32
|
import { changedFilesOf } from '../dsh/session-files.js';
|
|
33
33
|
import { errorCodeOf, localIso } from '../dsh/diag-counters.js';
|
|
34
34
|
import { surfaceMessages } from '../dsh/prefix.js';
|
|
35
|
-
import { handoffTitlePlanOf, createHandoffSession, sourceTitleOf } from '../dsh/sessions.js';
|
|
35
|
+
import { handoffTitlePlanOf, createHandoffSession, renameHistoryOf, sourceTitleOf } from '../dsh/sessions.js';
|
|
36
36
|
import { writeRelayRecord } from '../dsh/relay.js';
|
|
37
|
+
import { archiveHandoffSource, handoffArchiveNoteOf } from '../dsh/handoff-archive.js';
|
|
37
38
|
import { withLock } from './lock.js';
|
|
38
39
|
import { beginInflight, cancelErrorOf, combinedSignal, finishInflight } from './support.js';
|
|
39
40
|
|
|
@@ -231,6 +232,10 @@ async function runHandoff(deps, { agent, signal, commandId, edits }) {
|
|
|
231
232
|
agent,
|
|
232
233
|
doc,
|
|
233
234
|
title: titlePlan.title,
|
|
235
|
+
// 0.5.0:交接单已经落盘,把同一个路径写进子会话 relay 记录(接力条
|
|
236
|
+
// 「查看交接单」不再依赖回查父会话);父记录仍写同一份 path。
|
|
237
|
+
path: file,
|
|
238
|
+
plan: titlePlan,
|
|
234
239
|
log: deps.log,
|
|
235
240
|
});
|
|
236
241
|
} catch (error) {
|
|
@@ -243,8 +248,21 @@ async function runHandoff(deps, { agent, signal, commandId, edits }) {
|
|
|
243
248
|
throw error;
|
|
244
249
|
}
|
|
245
250
|
|
|
251
|
+
// 0.5.0 命名反转:新链在**子会话建好之后**把旧会话改名「历史 N:<原名>」,
|
|
252
|
+
// 新会话已经继承干净原名(createHandoffSession 里改名)。旧链(handoff-n)不动,
|
|
253
|
+
// 保持「交接第 N 次:」一个字符不改(链级 cutover)。
|
|
254
|
+
// 改名是可见副作用:失败只 warn,交接照常成功;成功后 renameHistoryOf 内部 flush。
|
|
255
|
+
const historyRename = await renameHistoryOf(deps.ctx, session, {
|
|
256
|
+
index: titlePlan.index,
|
|
257
|
+
baseTitle: titlePlan.baseTitle,
|
|
258
|
+
scheme: titlePlan.scheme,
|
|
259
|
+
log: deps.log,
|
|
260
|
+
});
|
|
261
|
+
|
|
246
262
|
// A5:把接力链写成父会话里的结构化记录;写失败不阻断交接(标题解析仍可兜底)。
|
|
247
263
|
// 0.4 P2-2:canonical 字段 parentId/childId/ordinal/at/atIso 补齐;旧字段 parentSessionId/index 保留兼容。
|
|
264
|
+
// 0.5.0:父/子记录都写 scheme(本次链的命名方案)+ titleSeq(各自 rename 的 eventSeq);
|
|
265
|
+
// 新链父记录再写 historyTitle(旧会话改名后的标题),供审计/幂等核对。
|
|
248
266
|
const relayAt = Date.now();
|
|
249
267
|
const relayWrote = writeRelayRecord(deps.engine, session, {
|
|
250
268
|
parentId: session.id,
|
|
@@ -258,8 +276,36 @@ async function runHandoff(deps, { agent, signal, commandId, edits }) {
|
|
|
258
276
|
planId: compactOutcome?.planId ?? null,
|
|
259
277
|
title: titlePlan.title,
|
|
260
278
|
baseTitle: titlePlan.baseTitle,
|
|
279
|
+
scheme: titlePlan.scheme,
|
|
280
|
+
titleSeq: historyRename.titleSeq,
|
|
281
|
+
// historyTitle 只在改名真的生效时写;失败/服务不可用时不能记一个不存在的标题。
|
|
282
|
+
...(titlePlan.scheme === 'history-n' && (historyRename.renamed || historyRename.reason === 'already')
|
|
283
|
+
? { historyTitle: historyRename.title }
|
|
284
|
+
: {}),
|
|
261
285
|
});
|
|
262
286
|
if (!relayWrote) deps.log?.warn?.('compact-relay: 接力链记录写入失败,下次交接将回退到标题解析');
|
|
287
|
+
// renameHistoryOf 的 flush 发生在父记录之前;这里再 flush 一次,保证旧会话的
|
|
288
|
+
// 「历史 N」标题与这条父记录一起落盘(失败只 warn,不影响交接结果)。
|
|
289
|
+
try {
|
|
290
|
+
await deps.ctx.sessions?.flush?.(session);
|
|
291
|
+
} catch (error) {
|
|
292
|
+
deps.log?.warn?.(`compact-relay: 旧会话接力链记录落盘失败:${error instanceof Error ? error.message : String(error)}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// 0.5.0:交接成功后自动归档旧会话(让列表只剩新会话)。
|
|
296
|
+
// 顺序要求:子会话已建好、旧会话已改名、父记录已 flush —— 全部完成之后才归档;
|
|
297
|
+
// 归档失败只 warn + 在返回文案里说明,绝不回滚/阻断交接。
|
|
298
|
+
// 不传 stopActivity:自动流程不替用户取消旧会话里仍在跑的 turn/subagent/job/schedule;
|
|
299
|
+
// 只对收尾中的 turn 做有限等待重试,仍活跃就保留可见并提示(宁可多一行,不静默杀任务)。
|
|
300
|
+
let archive;
|
|
301
|
+
try {
|
|
302
|
+
archive = await archiveHandoffSource(deps.ctx, session.id, { log: deps.log });
|
|
303
|
+
} catch (error) {
|
|
304
|
+
// archiveHandoffSource 设计上不抛;这里再兜一层,任何替身/内核异常都不影响交接结果。
|
|
305
|
+
deps.log?.warn?.(`compact-relay: 旧会话归档异常:${error instanceof Error ? error.message : String(error)}(交接已完成)`);
|
|
306
|
+
archive = { status: 'failed', archived: false, activityKinds: [] };
|
|
307
|
+
}
|
|
308
|
+
const archiveNote = handoffArchiveNoteOf(archive);
|
|
263
309
|
|
|
264
310
|
// BUG-004:失败原因可能是「摘要模型调用了工具」这类可操作信息,以前只写「日志有记录」
|
|
265
311
|
// (用户看不到 host 日志);这里带上短原因 + 可手动 /bc 的建议。
|
|
@@ -300,11 +346,14 @@ async function runHandoff(deps, { agent, signal, commandId, edits }) {
|
|
|
300
346
|
path: file,
|
|
301
347
|
readiness,
|
|
302
348
|
started: created.started,
|
|
349
|
+
// 0.5.0:归档结果结构化返回(HTTP 暂不下发;客户端看 message/接力条状态即可)。
|
|
350
|
+
archive: { status: archive.status, archived: archive.archived === true },
|
|
303
351
|
message:
|
|
304
352
|
`交接完成:${compactNote}` +
|
|
305
353
|
`交接单已存至 ${file};` +
|
|
306
354
|
`新会话(${created.childId})已就绪` +
|
|
307
355
|
(created.started ? ',正在切换。' : ',正在切换(首轮未自动启动,可在新会话里发一条消息继续)。') +
|
|
356
|
+
archiveNote +
|
|
308
357
|
// A5 兜底:结构化 childId 没传下来时,客户端可从文本里提取。
|
|
309
358
|
`\n#child=${created.childId}`,
|
|
310
359
|
};
|
|
@@ -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
|
+
}
|
package/lib/actions/settings.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { resolveConfig } from '../config.js';
|
|
14
14
|
import { paletteForBothSchemes } from '../core/colors.js';
|
|
15
|
+
import { handoffGuard } from '../core/handoff-guard.js';
|
|
15
16
|
import { isPlainObject } from '../core/object.js';
|
|
16
17
|
import { readNativeCompaction } from '../dsh/native-compaction.js';
|
|
17
18
|
import { filterSettingsPatch, publicConfigOf } from '../core/config-schema.js';
|
|
@@ -125,9 +126,12 @@ export function createSettingsStore({ config = {}, file = defaultSettingsPath()
|
|
|
125
126
|
/**
|
|
126
127
|
* 合并保存一组设置;先校验再写盘,最后热替换当前配置。
|
|
127
128
|
* @param {object} patch
|
|
129
|
+
* @param {{nativeRatio?:number|null}} [options]
|
|
130
|
+
* 0.4.2:客户端传入的「当前模型实际触发线」(state.native.ratio)。
|
|
131
|
+
* 带了就按「实际线 −5pp」校验交接线;没带 / 非法(旧客户端、无会话)退回配置阈值防呆。
|
|
128
132
|
* @returns {object} 最新 view
|
|
129
133
|
*/
|
|
130
|
-
save(patch) {
|
|
134
|
+
save(patch, { nativeRatio = null } = {}) {
|
|
131
135
|
const filtered = filterSettingsPatch(patch);
|
|
132
136
|
if (Object.keys(filtered).length === 0) throw new Error('没有可保存的设置项');
|
|
133
137
|
const nextUser = deepMerge(user, filtered);
|
|
@@ -140,18 +144,22 @@ export function createSettingsStore({ config = {}, file = defaultSettingsPath()
|
|
|
140
144
|
wrapped.field = error !== null && typeof error === 'object' ? error.field : undefined;
|
|
141
145
|
throw wrapped;
|
|
142
146
|
}
|
|
143
|
-
// 0.
|
|
147
|
+
// 0.4.2(P0-2):交接线硬上限与 UI 同口径 —— 实际触发线 −5pp(整数百分位)。
|
|
148
|
+
// 实际线算不出(未带 nativeRatio / 无会话)时退回配置阈值 −1pp 防呆,与 0.4.1 一致;
|
|
149
|
+
// 原生自动压缩关闭时不校验(没有「DSH 先压」的截止线)。一律拒绝 + 给目标值,不静默夹紧。
|
|
144
150
|
const nativeCfg = readNativeCompaction();
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
151
|
+
const guard = handoffGuard({
|
|
152
|
+
actualRatio: nativeRatio,
|
|
153
|
+
configuredRatio: nativeCfg.thresholdRatio,
|
|
154
|
+
handoffRatio: next.thresholds.handoffRatio,
|
|
155
|
+
compactRatio: next.thresholds.compactRatio,
|
|
156
|
+
autoEnabled: nativeCfg.autoEnabled,
|
|
157
|
+
fallback: 'configured',
|
|
158
|
+
});
|
|
159
|
+
if (!guard.ok) {
|
|
160
|
+
const wrapped = new Error(guard.message);
|
|
161
|
+
wrapped.field = 'thresholds.handoffRatio';
|
|
162
|
+
throw wrapped;
|
|
155
163
|
}
|
|
156
164
|
// A4:信封装盘;旧扁平文件在这时自动升级,不丢用户设置。
|
|
157
165
|
writeSettingsFile(file, nextUser);
|
package/lib/actions/state.js
CHANGED
|
@@ -13,7 +13,8 @@ import { compressHint as compressHintOf, countSummaries, lastCheckpointSurfaceSe
|
|
|
13
13
|
import { compressHintIsActive } from '../core/compress-hint.js';
|
|
14
14
|
import { readNativeCompaction, resolveNativePolicy } from '../dsh/native-compaction.js';
|
|
15
15
|
import { nativeLineOf } from '../core/native-line.js';
|
|
16
|
-
import { relayRecordOf } from '../dsh/relay.js';
|
|
16
|
+
import { relayChainOf, relayRecordOf } from '../dsh/relay.js';
|
|
17
|
+
import { isSessionArchived } from '../dsh/handoff-archive.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* 动作层统一解析「这次动作作用在哪个 agent」:只认显式 sessionId,
|
|
@@ -144,6 +145,17 @@ export async function readState(deps, { sessionId, agent } = {}) {
|
|
|
144
145
|
}
|
|
145
146
|
}
|
|
146
147
|
|
|
148
|
+
// A5 / 0.5.0 F4:接力链记录只读一次。`relay` 是旧字段(原样下发,一个不改);
|
|
149
|
+
// `chain` 是它的只读投影,链号/原题/父子/scheme/path 直接给出,客户端不再自己解析。
|
|
150
|
+
// 没有记录 → 两者都是 null;旧记录没有的字段 → chain 里为 null(不猜)。
|
|
151
|
+
const relay = target === null ? null : relayRecordOf(target.session);
|
|
152
|
+
const chain = relay === null ? null : relayChainOf(relay);
|
|
153
|
+
// 0.5.0:旧会话是否已被自动归档(接力条据此显示「恢复旧会话」)。这是宿主
|
|
154
|
+
// registry 的独立状态,不塞进 chain(chain 仍然只是 relay 记录的纯投影)。
|
|
155
|
+
// 拿不到 registry/归档集合 → null(不知道,客户端按「可尝试恢复」处理)。
|
|
156
|
+
const parentArchived =
|
|
157
|
+
chain === null || chain.parentId === null ? null : isSessionArchived(deps.ctx, chain.parentId);
|
|
158
|
+
|
|
147
159
|
return {
|
|
148
160
|
revision: ++stateRevision,
|
|
149
161
|
sessionId: target?.id ?? null,
|
|
@@ -164,8 +176,14 @@ export async function readState(deps, { sessionId, agent } = {}) {
|
|
|
164
176
|
colors: { ...deps.cfg.colors, palette: paletteForBothSchemes(deps.cfg.colors) },
|
|
165
177
|
// A4:设置文件有误时随状态下发,设置页/客户端可提示(正常为 null)。
|
|
166
178
|
settingsError: deps.settings?.error ?? null,
|
|
167
|
-
// A5:最近一次交接的接力链记录(没有就是 null)。
|
|
168
|
-
relay
|
|
179
|
+
// A5:最近一次交接的接力链记录(没有就是 null)。0.5.0 F4:旧字段原样保留。
|
|
180
|
+
relay,
|
|
181
|
+
// 0.5.0 F4:同一条记录的只读链投影 {ordinal, baseTitle, parentId, childId, scheme, path?};
|
|
182
|
+
// 只加不改:老客户端忽略它,新客户端不再解析标题。没有记录 → null。
|
|
183
|
+
chain,
|
|
184
|
+
// 0.5.0:链上父会话当前是否在 workspace 归档集合里(true/false);没有父会话或
|
|
185
|
+
// 宿主没有可读的归档状态 → null。只加不改。
|
|
186
|
+
parentArchived,
|
|
169
187
|
// 0.3.1:模型判断的「适合压缩」断点位置(没有或已失效为 null)。
|
|
170
188
|
compressHint: activeHint,
|
|
171
189
|
// 0.3.1:DSH 原生自动压缩(环上的报警最高阈值 / 设置页开关的数据源)。
|
package/lib/adapters/http.js
CHANGED
|
@@ -8,6 +8,7 @@ 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';
|
|
11
12
|
import { togglePin } from '../actions/pin.js';
|
|
12
13
|
import { setNativeAutoCompactionEnabled } from '../actions/native-compaction.js';
|
|
13
14
|
import { readState, resolveTarget } from '../actions/state.js';
|
|
@@ -294,7 +295,9 @@ export function registerHttp(webCtx, deps) {
|
|
|
294
295
|
deps.hub?.refreshAll?.();
|
|
295
296
|
return send(res, 200, { ok: true, ...settingsPayloadOf(deps) });
|
|
296
297
|
}
|
|
297
|
-
|
|
298
|
+
// 0.4.2:客户端可选带 nativeRatio(当前模型实际触发线),服务端按实际线 −5pp
|
|
299
|
+
// 校验交接线(与 UI 同口径);不带则退回配置阈值防呆(兼容 0.4.1 客户端)。
|
|
300
|
+
deps.settings.save(parsed, { nativeRatio: parsed.nativeRatio });
|
|
298
301
|
deps.hub?.refreshAll?.();
|
|
299
302
|
return send(res, 200, { ok: true, ...settingsPayloadOf(deps) });
|
|
300
303
|
} catch (error) {
|
|
@@ -382,11 +385,14 @@ export function registerHttp(webCtx, deps) {
|
|
|
382
385
|
}
|
|
383
386
|
// 多数动作作用在具体会话上:缺 sessionId 或类型错时绝不能退化成「最后一个 agent」
|
|
384
387
|
// (2026-10-19 debug:旧实现会压错会话)。
|
|
385
|
-
// 例外:
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
+
// 例外:
|
|
389
|
+
// - native-auto 改的是 DSH profile 配置(全局开关),不属于任何会话——
|
|
390
|
+
// UI-003(2026-10-20):以前这里对所有 action 都先要 sessionId,设置页没带 →
|
|
391
|
+
// 恒 400「缺少 sessionId」,于是「关闭自动压缩」永远写不进去、也看不到反馈。
|
|
392
|
+
// - recover 作用在**已归档**的旧会话上,它通常没有活 agent;不能走 resolveTarget
|
|
393
|
+
// (否则恒 409「没有活动会话」)。sessionId 由 recover 分支自己校验。
|
|
388
394
|
let agent = null;
|
|
389
|
-
if (parsed.action !== 'native-auto') {
|
|
395
|
+
if (parsed.action !== 'native-auto' && parsed.action !== 'recover') {
|
|
390
396
|
if (typeof parsed.sessionId !== 'string' || parsed.sessionId.length === 0) {
|
|
391
397
|
return send(res, 400, { ok: false, message: '缺少 sessionId' });
|
|
392
398
|
}
|
|
@@ -394,6 +400,15 @@ export function registerHttp(webCtx, deps) {
|
|
|
394
400
|
if (agent === null) return send(res, 409, { ok: false, message: '没有活动会话' });
|
|
395
401
|
}
|
|
396
402
|
|
|
403
|
+
if (parsed.action === 'recover') {
|
|
404
|
+
// 0.5.0:恢复被自动归档的旧会话(接力条恢复入口的 HTTP 兜底)。
|
|
405
|
+
if (typeof parsed.sessionId !== 'string' || parsed.sessionId.length === 0) {
|
|
406
|
+
return send(res, 400, { ok: false, message: '缺少要恢复的会话 sessionId' });
|
|
407
|
+
}
|
|
408
|
+
const outcome = await recoverArchivedSession(deps, { sessionId: parsed.sessionId });
|
|
409
|
+
return send(res, 200, outcome);
|
|
410
|
+
}
|
|
411
|
+
|
|
397
412
|
if (parsed.action === 'preview') {
|
|
398
413
|
// M6:只算不写——先告诉用户「要压哪段、多少 token」,再让他确认。
|
|
399
414
|
// A3:返回 planId,confirm 会带着它回来校验「同一段范围」。
|