dsh-compact-relay 0.5.1 → 0.5.2
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 +87 -1
- package/README.md +2 -1
- package/TUTORIAL.md +2 -0
- package/lib/actions/force-archive.js +113 -0
- package/lib/actions/handoff.js +26 -22
- package/lib/actions/state.js +14 -3
- package/lib/adapters/http.js +40 -1
- package/lib/client.js +384 -5
- package/lib/core/config-schema.js +2 -2
- package/lib/core/relay-record.js +128 -0
- package/lib/dsh/handoff-writer.js +1 -1
- package/lib/dsh/model-task.js +14 -10
- package/lib/dsh/route.js +19 -0
- package/lib/dsh/sessions.js +19 -12
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,92 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.5.
|
|
3
|
+
## 0.5.2(2026-10-28)
|
|
4
|
+
|
|
5
|
+
> 从 0.5.1 发布点 `e3c5ab3` 拉出的补丁批次(分支 `fix/0.5.2`),npm / GitHub 已发布。
|
|
6
|
+
> 按 `dcr-架构梳理与重构方案-2026-10-26.md` 的 0.5.x 窗口推进:**S1 契约快照 → S2 record 构造器 →
|
|
7
|
+
> 批 C 强制归档入口 + 文档口径修复**。测试 **347 → 375 PASS**,bundle 重建且 `--check` 通过。
|
|
8
|
+
>
|
|
9
|
+
> **契约变化(全部 additive)**:`readState` 增 `parentPinned`(旧会话是否在侧栏置顶)与
|
|
10
|
+
> `capabilities.forceArchive`(宿主有没有 `archiveSession`);`/api/compact-relay-action` 增
|
|
11
|
+
> `action:'force-archive'`(必须显式 `confirmStop:true`,旧会话置顶时还需 `confirmUnpin:true`)。
|
|
12
|
+
> 自动归档的行为一个字节不改:**默认不强制、不 stopActivity**。
|
|
13
|
+
|
|
14
|
+
### 缓存对齐:侧调用沿用主请求的推理档位(2026-10-27 P1 取证修复)
|
|
15
|
+
|
|
16
|
+
- **根因(已实验定位)**:摘要/交接单调用的前缀回放逐字节正确(LCP 15 条 vs 主请求 14/14,tools
|
|
17
|
+
与 toolHistory 一致),唯一致命差异是 `reasoningEffort`——旧实现固定传 `'off'`,主循环是 `'max'`;
|
|
18
|
+
宿主把它翻成 wire 的 `thinking: enabled|disabled`,缓存前缀在**第一条 assistant 消息**的渲染处断开。
|
|
19
|
+
隔离 A/B(同一组 prompt、真实模型):`off` 命中 **74.0%**(复跑 73.8–74.4),改成主循环同值
|
|
20
|
+
**96.9–98.2%**;生产 prompt 20–50 万 token,于是同样的断点表现为 **2–5%** 命中(≈系统提示词 11K)。
|
|
21
|
+
- **修法**:`prepareModelCall` 改为优先沿用 `session.requestHeader().config.reasoningEffort`
|
|
22
|
+
(仅当 provider/model 与本次目标一致;否则退回旧的「模型支持 off 就 off」兜底)。
|
|
23
|
+
摘要与交接单共用这条前置,所以两者都吃到缓存;`cacheAlignedControls` 的注释同步纠正。
|
|
24
|
+
- **配套**:`summary.maxTokens` 与 `handoff.maxTokens` 默认 8,192 → **16,384**,兜住重新开启的
|
|
25
|
+
思考输出,避免 2026-10-19「思考吃光上限 → 截断」旧 bug 回归;截断重试逻辑不变。
|
|
26
|
+
- **测试**:新增「P1 cache 对齐」1 条(header 有档位 → 摘要/交接单同档);更新旧断言(侧调用不再
|
|
27
|
+
是 off、输出上限 16384);非推理模型用例补真实 header 形状(无 reasoningEffort)后仍走兜底。
|
|
28
|
+
- **生产验证(2026-10-27 16:2x)**:隔离 A/B 两次 96.9%;真机重启后临时会话压缩 38 条/41,149 token,
|
|
29
|
+
摘要 prompt 114,032(未命中 **368**),命中 **99.7%**(修复前生产 2–5%)。素材在
|
|
30
|
+
`dcr-ctx-audit/forensics/findings.md §7`。
|
|
31
|
+
|
|
32
|
+
### S1 契约快照(生产行为零变化)
|
|
33
|
+
|
|
34
|
+
- 新增 `test/contract-golden.mjs`:锁 `readState` 帧(6 场景的键集合 + 结构/类型 + 链投影取值)、
|
|
35
|
+
父/子 relay record(**键顺序** + null/省略语义 + 新链/改名失败/老链三条真实流程)、标题格式,
|
|
36
|
+
另有负向自检(改键 / 改值 / 改类型 / 换键序都必须被抓到)。
|
|
37
|
+
- 口径修正:record 的**键顺序**是 JSONL 字节契约(顺序敏感,`Object.keys` 深比较);只有 `readState`
|
|
38
|
+
帧才用「排序后的键集合」。`dcr-架构梳理与重构方案-2026-10-26.md` §S1 已按实际实现同步。
|
|
39
|
+
|
|
40
|
+
### S2 `core/relay-record.js`:record 形状的唯一构造器(字节级不变)
|
|
41
|
+
|
|
42
|
+
- 新增 `lib/core/relay-record.js`:`relayRecordOfForm({role,...})` + `parentRelayRecordOf` /
|
|
43
|
+
`childRelayRecordOf`。父/子 record 的键顺序、0.3 旧字段双写(`parentSessionId`/`index`)、
|
|
44
|
+
`planId` 只父、`historyTitle` 条件写、`path` 条件写、`titleSeq` 恒写 null 全部收口到一处;
|
|
45
|
+
`actions/handoff.js` 与 `dsh/sessions.js` 只传语义参数。
|
|
46
|
+
- 文档口径:§S2 表与附录 C 的 `atIso` 改为实际口径 —— 父/子**两处都是** `localIso(new Date(at))`
|
|
47
|
+
(原表写成「子 `new Date(at)`、父 `at`」,是评审时的已知漂移)。
|
|
48
|
+
|
|
49
|
+
### 批 C:强制归档旧会话(0.5.x 计划 P4 / L 项)
|
|
50
|
+
|
|
51
|
+
- **入口**:接力条菜单「强制归档旧会话(会停止后台任务)」——旧会话还没归档时出现;
|
|
52
|
+
宿主没有 `archiveSession`(`capabilities.forceArchive=false`)时隐藏,不做必然失败的入口。
|
|
53
|
+
- **二次确认**:面板明确写「会停止旧会话上正在跑的任务(turn / 子代理 / 后台任务 / 定时任务),
|
|
54
|
+
中断后无法恢复」;旧会话在侧栏置顶时补一行「归档会同时取消置顶(宿主行为)」并带
|
|
55
|
+
`confirmUnpin:true`(与交接前的置顶确认同口径)。
|
|
56
|
+
- **服务端**:新增 `lib/actions/force-archive.js`,是生产代码里**唯一**调用
|
|
57
|
+
`workspaceRegistry.archiveSession(id, { stopActivity:true })` 的地方(静态守卫锁死);
|
|
58
|
+
没有 `confirmStop:true` 一律拒绝;已归档幂等(不再停任务);置顶未确认返回 `pinned`(不静默
|
|
59
|
+
unpin);失败抛可读错误(HTTP 400 带具体原因),不静默、不假装成功。
|
|
60
|
+
- **告知文案**:归档成功 / 此前已归档 / 置顶未归档三套全部沿用 `handoffArchiveNoteOf`
|
|
61
|
+
(含「归档会取消置顶」与「恢复旧会话」入口)。
|
|
62
|
+
- **默认仍不强制**:交接的自动归档照旧不带 `stopActivity`,旧会话有活跃任务时保留可见
|
|
63
|
+
(单测断言 `options.stopActivity === undefined`)。
|
|
64
|
+
- **测试**:新增 `test/force-archive.mjs`(18 条:`stopActivity` 透传 / 缺确认拒绝 / 幂等 /
|
|
65
|
+
置顶确认 / 失败可读 / HTTP 不要求活 agent / `readState` 能力位 / 默认不强制 / 静态守卫);
|
|
66
|
+
`test/relay-bar.mjs` 增 8 条组件级用例(入口 / 二次确认 / 透传 / 失败 / 置顶 / 服务端回报
|
|
67
|
+
pinned 后重发 / 能力缺失隐藏 / 已归档不再显示)。合计 **347 → 375 PASS**。
|
|
68
|
+
|
|
69
|
+
### UI 修复:交接预览草稿框被 flex 压成 0 高
|
|
70
|
+
|
|
71
|
+
- **症状**:真机截图确认,交接预览弹窗里九段草稿的只读预览框塌成 14px 一条缝
|
|
72
|
+
(上下边框各 1px + padding 6px×2,内容高度 0),九段草稿完全看不见,只能看到第一行文字被切掉的
|
|
73
|
+
上半截。
|
|
74
|
+
- **根因**:弹窗外层是 `display:flex; flex-direction:column; maxHeight:min(72vh, 520px); overflow-y:auto`,
|
|
75
|
+
子项默认 `flex-shrink:1`;草稿预览框是 `overflow:auto` 的滚动容器,自动最小尺寸为 0,
|
|
76
|
+
浏览器于是把它压到 0 高来满足 maxHeight,弹窗自身反而不出现滚动条。内容一变长(就绪警告 +
|
|
77
|
+
两个可编辑 textarea)就必然触发。
|
|
78
|
+
- **修法**:内容包裹层加 `flexShrink:0` —— 保持自然高度,超出 maxHeight 时由弹窗自己滚动,
|
|
79
|
+
不再压扁子项;草稿预览框同样加 `flexShrink:0`(防御性,任何 flex 上下文都不许压它)。
|
|
80
|
+
两处各带一个测试标记(`data-cr-handoff-body` / `data-cr-handoff-draft`),
|
|
81
|
+
产物 `lib/client.js` 由 `node scripts/build-client.mjs` 重建,`--check` 通过。
|
|
82
|
+
- **状态**:已随 0.5.2 发布。
|
|
83
|
+
|
|
84
|
+
### 文档
|
|
85
|
+
|
|
86
|
+
- `README.md` / `TUTORIAL.md` 补 0.5.2 用法(强制归档入口、二次确认、置顶提示、默认不强制)。
|
|
87
|
+
- `dcr-架构梳理与重构方案-2026-10-26.md`:§S1 的 record 顺序口径、§S2 + 附录 C 的 `atIso` 口径修正。
|
|
88
|
+
|
|
89
|
+
## 0.5.1(2026-10-26 · 已发布)
|
|
4
90
|
|
|
5
91
|
> 分支 `fix/0.5.1`(从 `main` = 0.5.0 拉出,只带 0.5.x 需要的改动;0.6 的 timeline/marks/milestone
|
|
6
92
|
> 不进来)。**四批功能合并定稿 + 1 条设计修正**:批 1 核心 bug(DCR-01/02/03/04 + D01 + D03)、
|
package/README.md
CHANGED
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
- **pin 失效标注**:被压缩(尤其 DSH 原生自动压缩)带走的锁定消息不再凭空消失——悬停卡与菜单标「已失效 N 条(随原生压缩移除)」;原生压缩**即将**覆盖锁定消息时会提前提示「先交接(/handoff)或解锁」。
|
|
62
62
|
- **置顶确认**:交接后归档旧会话会取消它的侧栏置顶(宿主行为)。旧会话置顶时,交接预览先问一次:点「交接并归档(取消置顶)」才归档,点「只交接,保留置顶」则旧会话和置顶都留着。
|
|
63
63
|
- **归档开关**:交接预览里可以勾掉「交接后自动归档旧会话」(默认开);首次引导里也写了这个选项。
|
|
64
|
+
- **强制归档(0.5.2)**:旧会话还有任务在跑时,自动归档会「保留可见、不停止任务」。确实要清理时,接力条菜单里有「强制归档旧会话(会停止后台任务)」:点开还会再确认一次,确认面板明确写「会停止旧会话上正在跑的任务(turn / 子代理 / 后台任务 / 定时任务),中断后无法恢复」;旧会话在侧栏置顶时会补「归档会同时取消置顶(宿主行为)」。**默认不会自动做这一步**,只有你在确认面板里点「强制归档(停止任务)」才会执行。
|
|
64
65
|
|
|
65
66
|
> 老链(0.5.0 之前生成的会话)没有交接单路径记录,界面显示「暂无交接单」而不是猜一个——它会先向父会话只读回查一次。
|
|
66
67
|
|
|
@@ -170,7 +171,7 @@ dsh plugin --profile desktop add github:cfanzx/dsh-compact-relay
|
|
|
170
171
|
|
|
171
172
|
## 版本状态与开源许可
|
|
172
173
|
|
|
173
|
-
- **当前版本**:**0.5.0
|
|
174
|
+
- **当前版本**:**0.5.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、三处告警 + 一键重算);**0.5.0** 无痕交接(命名反转「历史 N」+ 新会话继承原名、输入框上方接力条、交接后自动归档旧会话 + 恢复入口);**0.5.1** 收尾(祖先会话交接后接力条消失、0.5.0 前老链无条、归档入口死路与文案;pin 失效标注、原生压缩提前保护、置顶确认、归档开关、完整交接链、父→子入口;完整链在祖先未加载时只显示 1 层——改为服务端只读持久化日志一次读全,老宿主保留「打开并继续」兜底;设计定稿环只表达容量,pin 位置标记归 0.6 时间线);**0.5.2** 契约快照 golden、record 形状唯一构造器、强制归档旧会话入口 + 二次确认/置顶确认、摘要缓存对齐(对话模型沿用主循环推理档位,生产命中 2–5% → 99.7%)。已在 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)。
|
|
174
175
|
- **开源许可**:采用 [MIT](LICENSE) 许可证,欢迎通过 [GitHub Issues](https://github.com/cfanzx/dsh-compact-relay/issues) 与 Pull Requests 提交反馈。
|
|
175
176
|
|
|
176
177
|
## 参考与致谢
|
package/TUTORIAL.md
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
| 去新会话 | 父会话侧才有:切回这次交接出来的新会话(它被归档过会先自动恢复) |
|
|
47
47
|
| 回到原会话 | 切回上一个会话;已归档时先「取消归档」再切,不会撞墙 |
|
|
48
48
|
| 恢复旧会话 | 旧会话被自动归档时把它恢复出来(侧栏「全部对话(显示已归档)」里也有) |
|
|
49
|
+
| 强制归档旧会话 | 旧会话还有任务在跑也能收起(0.5.2):点开先二次确认,面板写明「会停止旧会话上正在跑的任务(turn / 子代理 / 后台任务 / 定时任务),中断后无法恢复」;确认后才停止任务并归档。**默认不会自动做**,旧会话置顶时还会说明「归档会同时取消置顶」 |
|
|
49
50
|
| 完整交接链 | 点开才读取,一次列出「历史 3 / 历史 2 / 历史 1」(祖先没打开也能读);每行可开那一跳的交接单、点整行跳过去(最多 8 层;某层未加载时给「打开并继续」) |
|
|
50
51
|
| 路线图 | 跨会话时间线的占位,0.6 之后 |
|
|
51
52
|
|
|
@@ -54,6 +55,7 @@
|
|
|
54
55
|
- 一条会话既接过别人、又交出去过(中段会话),菜单里会同时有「回到原会话」和「去新会话」。
|
|
55
56
|
- 祖先会话交接后接力条**不会**消失(0.5.1 修复);0.5.0 之前生成的老链也会补上接力条。
|
|
56
57
|
- 交接预览里有一个勾选框「交接后自动归档旧会话」(默认开)。**旧会话在侧栏置顶时**,归档会取消置顶,所以预览会先问你:点「交接并归档(取消置顶)」才归档,点「只交接,保留置顶」就都留着。
|
|
58
|
+
- 旧会话还有任务在跑时,自动归档会**保留可见、不停止任务**(不静默杀后台任务)。确实要清理时,用接力条菜单的「强制归档旧会话(会停止后台任务)」;它一定会先弹二次确认,确认面板会写清「会停止旧会话上正在跑的任务」,**只有你点了确认才会真的停任务**——别把这个入口当成默认行为。
|
|
57
59
|
- 锁定消息被压缩带走后不会凭空消失:悬停卡与菜单会标「已失效 N 条(随原生压缩移除)」;如果 DSH 原生压缩**即将**覆盖锁定消息,插件会提前提示「先交接(/handoff)或解锁」。
|
|
58
60
|
|
|
59
61
|
## 4. 设置在哪
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.5.2 L 项:**强制归档旧会话**(显式用户确认后才停止后台任务)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:自动归档(`dsh/handoff-archive.js:archiveHandoffSource`)在旧会话还有
|
|
5
|
+
* turn/subagent/job/schedule 时**故意不归档**(`status:'active'`)—— 静默杀后台任务
|
|
6
|
+
* 比「旧会话多留一行」破坏性大得多。但这也意味着「旧会话一直有任务在跑」时用户
|
|
7
|
+
* 没有清理出路(0.5.x 补丁计划 P4)。这里补的就是那条**显式**出路:
|
|
8
|
+
*
|
|
9
|
+
* - 只有 `confirmStop === true`(用户在二次确认里点过「强制归档(停止任务)」)
|
|
10
|
+
* 才会调用 `workspaceRegistry.archiveSession(sessionId, { stopActivity: true })`;
|
|
11
|
+
* - 没有确认 → 直接拒绝(不静默降级成普通归档,也不静默停止任务);
|
|
12
|
+
* - 旧会话在侧栏置顶时要求 `confirmUnpin === true`(宿主归档会在同一次写盘里
|
|
13
|
+
* 取消置顶),否则返回 `pinned` + 现有置顶文案,**不归档**;
|
|
14
|
+
* - 失败抛可读错误(`code:'force-archive'`),不吞、不假装成功。
|
|
15
|
+
*
|
|
16
|
+
* 宿主实证(`@deepseek-ai/dsh-workspace/lib/index.js`):
|
|
17
|
+
* - `archiveSession(id)` 先跑 `workspace/session-activity` waterfall,有 provider
|
|
18
|
+
* 报活就抛 `WorkspaceActiveSessionError`(带 `activity` 数组);
|
|
19
|
+
* - `archiveSession(id, { stopActivity:true })` 跳过活动检查、先写归档集合,再
|
|
20
|
+
* `workspace/session-stop` 停止旧会话的 turn/subagent/job/schedule;
|
|
21
|
+
* - 归档与置顶互斥:同一笔写盘会把该 id 从 `pinnedSessionIds` 移除。
|
|
22
|
+
*/
|
|
23
|
+
import { actionError } from '../core/errors.js';
|
|
24
|
+
import {
|
|
25
|
+
archivedSessionIdsOf,
|
|
26
|
+
handoffArchiveNoteOf,
|
|
27
|
+
isSessionArchived,
|
|
28
|
+
isSessionPinned,
|
|
29
|
+
workspaceRegistryOf,
|
|
30
|
+
} from '../dsh/handoff-archive.js';
|
|
31
|
+
|
|
32
|
+
function errorMessageOf(error) {
|
|
33
|
+
return error instanceof Error ? error.message : String(error);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 强制归档旧会话(会停止它上面正在跑的 turn / subagent / job / schedule)。
|
|
38
|
+
*
|
|
39
|
+
* 返回值(全部 `ok:true`;失败一律抛 `actionError('force-archive', …)`):
|
|
40
|
+
* - `status:'archived'` 刚写入归档集合(已停止任务);
|
|
41
|
+
* - `status:'already'` 之前就已经归档(幂等,不再写盘、不再停任务);
|
|
42
|
+
* - `status:'pinned'` 已置顶且未确认取消置顶 —— **没有归档**,note 是现有置顶文案。
|
|
43
|
+
* 三种情况的 `note` 都复用 `handoffArchiveNoteOf`(与交接自动归档同一套告知文案)。
|
|
44
|
+
*
|
|
45
|
+
* @param {{ctx:object, log?:{warn?:Function}}} deps
|
|
46
|
+
* @param {{sessionId:string, confirmStop:boolean, confirmUnpin?:boolean}} input
|
|
47
|
+
* sessionId 是**旧会话** id(不是当前会话);confirmStop 必须是 true(二次确认的凭证)。
|
|
48
|
+
* @returns {Promise<{ok:true, status:'archived'|'already'|'pinned', archived:boolean, note:string, archivedSessionIds:string[]|null}>}
|
|
49
|
+
*/
|
|
50
|
+
export async function forceArchiveSession(deps, { sessionId, confirmStop, confirmUnpin } = {}) {
|
|
51
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
52
|
+
throw actionError('force-archive', '缺少要强制归档的会话 id');
|
|
53
|
+
}
|
|
54
|
+
// 防御性:HTTP 层与客户端都要求显式确认;动作层再挡一次,避免任何调用方误传。
|
|
55
|
+
if (confirmStop !== true) {
|
|
56
|
+
throw actionError('force-archive', '强制归档会停止旧会话上正在跑的任务,需要显式确认(confirmStop:true)');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const registry = workspaceRegistryOf(deps.ctx);
|
|
60
|
+
if (registry === null || typeof registry.archiveSession !== 'function') {
|
|
61
|
+
throw actionError('force-archive', '当前 DSH 宿主没有 Workspace 归档接口,无法强制归档旧会话');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 幂等:已在归档集合里就什么都不做(尤其不能再 stopActivity 一次)。
|
|
65
|
+
if (isSessionArchived(deps.ctx, sessionId) === true) {
|
|
66
|
+
return {
|
|
67
|
+
ok: true,
|
|
68
|
+
status: 'already',
|
|
69
|
+
archived: true,
|
|
70
|
+
note: handoffArchiveNoteOf({ status: 'already', archived: true }),
|
|
71
|
+
archivedSessionIds: archivedSessionIdsOf(deps.ctx),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 置顶保护:归档会取消置顶(宿主在同一笔写盘里做),必须用户单独确认;
|
|
76
|
+
// 读不到置顶集合(null)时不猜 —— 按「未置顶」继续,由宿主自己决定。
|
|
77
|
+
if (confirmUnpin !== true && isSessionPinned(deps.ctx, sessionId) === true) {
|
|
78
|
+
deps.log?.warn?.(
|
|
79
|
+
`compact-relay: 旧会话 ${sessionId} 在侧栏置顶,未确认取消置顶前不强制归档(归档会取消置顶)`,
|
|
80
|
+
);
|
|
81
|
+
return {
|
|
82
|
+
ok: true,
|
|
83
|
+
status: 'pinned',
|
|
84
|
+
archived: false,
|
|
85
|
+
// 0.5.2 审查 P1-3:本场景没有「交接确认」可点,不能复用交接流程的 pinned 文案。
|
|
86
|
+
note: '旧会话已置顶,本次未归档(归档会取消置顶);确认「仍然归档(取消置顶)」后才会归档并可恢复。',
|
|
87
|
+
archivedSessionIds: archivedSessionIdsOf(deps.ctx),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
// 唯一允许传 stopActivity 的地方:用户已确认「会停止旧会话上正在跑的任务」。
|
|
93
|
+
await registry.archiveSession(sessionId, { stopActivity: true });
|
|
94
|
+
} catch (error) {
|
|
95
|
+
deps.log?.warn?.(`compact-relay: 强制归档旧会话 ${sessionId} 失败:${errorMessageOf(error)}`);
|
|
96
|
+
const unknown = error !== null && typeof error === 'object' && error.name === 'WorkspaceUnknownSessionError';
|
|
97
|
+
throw actionError(
|
|
98
|
+
'force-archive',
|
|
99
|
+
unknown
|
|
100
|
+
? `强制归档失败:宿主里找不到这个会话(${errorMessageOf(error)})`
|
|
101
|
+
: `强制归档失败:${errorMessageOf(error)}`,
|
|
102
|
+
error,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
status: 'archived',
|
|
109
|
+
archived: true,
|
|
110
|
+
note: handoffArchiveNoteOf({ status: 'archived', archived: true }),
|
|
111
|
+
archivedSessionIds: archivedSessionIdsOf(deps.ctx),
|
|
112
|
+
};
|
|
113
|
+
}
|
package/lib/actions/handoff.js
CHANGED
|
@@ -30,6 +30,7 @@ import { buildSiteSnapshot } from '../dsh/site-info.js';
|
|
|
30
30
|
import { generateHandoffDoc } from '../dsh/handoff-writer.js';
|
|
31
31
|
import { buildHandoffProgramSections } from '../core/handoff-facts.js';
|
|
32
32
|
import { checkHandoffReadiness } from '../core/readiness.js';
|
|
33
|
+
import { parentRelayRecordOf } from '../core/relay-record.js';
|
|
33
34
|
import { changedFilesOf } from '../dsh/session-files.js';
|
|
34
35
|
import { errorCodeOf, localIso } from '../dsh/diag-counters.js';
|
|
35
36
|
import { surfaceMessages } from '../dsh/prefix.js';
|
|
@@ -263,29 +264,32 @@ async function runHandoff(deps, { agent, signal, commandId, edits, archive, conf
|
|
|
263
264
|
});
|
|
264
265
|
|
|
265
266
|
// A5:把接力链写成父会话里的结构化记录;写失败不阻断交接(标题解析仍可兜底)。
|
|
266
|
-
// 0.
|
|
267
|
-
//
|
|
268
|
-
//
|
|
267
|
+
// 0.5.2 S2:形状(含 0.3 旧字段双写、planId、条件写入的 historyTitle、**键顺序**)
|
|
268
|
+
// 全部由 core/relay-record 的唯一构造器负责,这里只传语义参数。
|
|
269
|
+
// historyTitle 只在改名真的生效(renamed / already)时传;失败/服务不可用时
|
|
270
|
+
// 不能记一个不存在的标题 —— 构造器对空/非字符串一律不写这个键。
|
|
269
271
|
const relayAt = Date.now();
|
|
270
|
-
const relayWrote = writeRelayRecord(
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
272
|
+
const relayWrote = writeRelayRecord(
|
|
273
|
+
deps.engine,
|
|
274
|
+
session,
|
|
275
|
+
parentRelayRecordOf({
|
|
276
|
+
parentId: session.id,
|
|
277
|
+
childId: created.childId,
|
|
278
|
+
ordinal: titlePlan.index,
|
|
279
|
+
at: relayAt,
|
|
280
|
+
atIso: localIso(new Date(relayAt)),
|
|
281
|
+
path: file,
|
|
282
|
+
planId: compactOutcome?.planId,
|
|
283
|
+
title: titlePlan.title,
|
|
284
|
+
baseTitle: titlePlan.baseTitle,
|
|
285
|
+
scheme: titlePlan.scheme,
|
|
286
|
+
titleSeq: historyRename.titleSeq,
|
|
287
|
+
historyTitle:
|
|
288
|
+
titlePlan.scheme === 'history-n' && (historyRename.renamed || historyRename.reason === 'already')
|
|
289
|
+
? historyRename.title
|
|
290
|
+
: undefined,
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
289
293
|
if (!relayWrote) deps.log?.warn?.('compact-relay: 接力链记录写入失败,下次交接将回退到标题解析');
|
|
290
294
|
// renameHistoryOf 的 flush 发生在父记录之前;这里再 flush 一次,保证旧会话的
|
|
291
295
|
// 「历史 N」标题与这条父记录一起落盘(失败只 warn,不影响交接结果)。
|
package/lib/actions/state.js
CHANGED
|
@@ -238,9 +238,17 @@ export async function readState(deps, { sessionId, agent } = {}) {
|
|
|
238
238
|
// 用它决定要不要先 unarchive(宿主拒绝打开归档会话;unarchive 幂等)。
|
|
239
239
|
// 和 parentArchived 完全对称:各查各的 id,读不到 → null(不猜 false)。
|
|
240
240
|
const nextArchived = next === null || next.childId === null ? null : isSessionArchived(deps.ctx, next.childId);
|
|
241
|
+
// 0.5.2 L 项:链上父会话(旧会话)是否在侧栏置顶 —— 强制归档的二次确认要提前说明
|
|
242
|
+
// 「归档会取消置顶」(宿主在同一笔写盘里 unpin)。没有父会话/读不到 → null(不猜)。
|
|
243
|
+
const parentPinned = chain === null || chain.parentId === null ? null : isSessionPinned(deps.ctx, chain.parentId);
|
|
241
244
|
// 0.5.1 DCR-03:宿主有没有「恢复归档会话」的接缝(HTTP recover 动作调的就是它)。
|
|
242
245
|
// 客户端拿不到 ctx.uiWorkspace.unarchiveSession 时据此隐藏「恢复旧会话」死入口。
|
|
243
|
-
|
|
246
|
+
// 0.5.2 L 项:`forceArchive` = 宿主有没有 archiveSession(接力条据此显示「强制归档旧会话」;
|
|
247
|
+
// 缺这个接缝时隐藏入口——点了必然失败)。
|
|
248
|
+
const capabilities = {
|
|
249
|
+
unarchive: typeof workspaceRegistryOf(deps.ctx)?.unarchiveSession === 'function',
|
|
250
|
+
forceArchive: typeof workspaceRegistryOf(deps.ctx)?.archiveSession === 'function',
|
|
251
|
+
};
|
|
244
252
|
|
|
245
253
|
return {
|
|
246
254
|
revision: ++stateRevision,
|
|
@@ -289,11 +297,14 @@ export async function readState(deps, { sessionId, agent } = {}) {
|
|
|
289
297
|
// 0.5.1 批 3:出站目标(next.childId)是否在同一归档集合里。父会话侧「去新会话」
|
|
290
298
|
// 据此先恢复再切;没有出站记录或读不到归档集合 → null。只加不改。
|
|
291
299
|
nextArchived,
|
|
300
|
+
// 0.5.2 L 项:入站父会话(旧会话)是否在侧栏置顶。强制归档的二次确认用它在
|
|
301
|
+
// 「会停止后台任务」之外再说明「归档会取消置顶」;没有父会话/读不到 → null(不猜)。
|
|
302
|
+
parentPinned,
|
|
292
303
|
// 0.5.1 批 2:本会话(agent.session,不是 agent.id)是否在侧栏置顶。
|
|
293
304
|
// 交接前客户端据此弹「归档会取消置顶」确认;读不到置顶集合 → null(不猜)。
|
|
294
305
|
sessionPinned: target === null || target.session === undefined ? null : isSessionPinned(deps.ctx, target.session.id),
|
|
295
|
-
// 0.5.1 DCR-03
|
|
296
|
-
// 新客户端在缺 `unarchive`
|
|
306
|
+
// 0.5.1 DCR-03:宿主能力位(能不能恢复归档会话 / 能不能强制归档)。老客户端忽略;
|
|
307
|
+
// 新客户端在缺 `unarchive` 能力时隐藏恢复入口,缺 `forceArchive` 时隐藏强制归档入口。
|
|
297
308
|
capabilities,
|
|
298
309
|
// 0.3.1:模型判断的「适合压缩」断点位置(没有或已失效为 null)。
|
|
299
310
|
compressHint: activeHint,
|
package/lib/adapters/http.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { betterCompact, cancelBetterCompact, previewBetterCompact } from '../actions/better-compact.js';
|
|
7
7
|
import { readCompactDiffs } from '../actions/compact-diff.js';
|
|
8
8
|
import { readDiagnostics } from '../actions/diagnostics.js';
|
|
9
|
+
import { forceArchiveSession } from '../actions/force-archive.js';
|
|
9
10
|
import { handoff, previewHandoff } from '../actions/handoff.js';
|
|
10
11
|
import { listHandoffDocs, readHandoffDoc } from '../actions/handoff-docs.js';
|
|
11
12
|
import { recoverArchivedSession } from '../actions/recover.js';
|
|
@@ -423,6 +424,14 @@ export function registerHttp(webCtx, deps) {
|
|
|
423
424
|
if (parsed.confirmUnpin !== undefined && typeof parsed.confirmUnpin !== 'boolean') {
|
|
424
425
|
return send(res, 400, { ok: false, message: 'confirmUnpin 必须是 true/false(true = 确认归档并取消旧会话置顶)' });
|
|
425
426
|
}
|
|
427
|
+
// 0.5.2 L 项:强制归档的破坏性动作只有用户点过二次确认才允许执行
|
|
428
|
+
// (confirmStop:true = 会停止旧会话上正在跑的 turn/subagent/job/schedule)。
|
|
429
|
+
if (parsed.confirmStop !== undefined && typeof parsed.confirmStop !== 'boolean') {
|
|
430
|
+
return send(res, 400, {
|
|
431
|
+
ok: false,
|
|
432
|
+
message: 'confirmStop 必须是 true/false(true = 确认停止旧会话上的任务后归档)',
|
|
433
|
+
});
|
|
434
|
+
}
|
|
426
435
|
// 多数动作作用在具体会话上:缺 sessionId 或类型错时绝不能退化成「最后一个 agent」
|
|
427
436
|
// (2026-10-19 debug:旧实现会压错会话)。
|
|
428
437
|
// 例外:
|
|
@@ -431,8 +440,10 @@ export function registerHttp(webCtx, deps) {
|
|
|
431
440
|
// 恒 400「缺少 sessionId」,于是「关闭自动压缩」永远写不进去、也看不到反馈。
|
|
432
441
|
// - recover 作用在**已归档**的旧会话上,它通常没有活 agent;不能走 resolveTarget
|
|
433
442
|
// (否则恒 409「没有活动会话」)。sessionId 由 recover 分支自己校验。
|
|
443
|
+
// - force-archive 同样作用在**旧会话**(交接前的那个)上,通常没有活 agent 可解;
|
|
444
|
+
// 它自己的动作层会校验 sessionId 与 confirmStop,不走 resolveTarget。
|
|
434
445
|
let agent = null;
|
|
435
|
-
if (parsed.action !== 'native-auto' && parsed.action !== 'recover') {
|
|
446
|
+
if (parsed.action !== 'native-auto' && parsed.action !== 'recover' && parsed.action !== 'force-archive') {
|
|
436
447
|
if (typeof parsed.sessionId !== 'string' || parsed.sessionId.length === 0) {
|
|
437
448
|
return send(res, 400, { ok: false, message: '缺少 sessionId' });
|
|
438
449
|
}
|
|
@@ -449,6 +460,26 @@ export function registerHttp(webCtx, deps) {
|
|
|
449
460
|
return send(res, 200, outcome);
|
|
450
461
|
}
|
|
451
462
|
|
|
463
|
+
if (parsed.action === 'force-archive') {
|
|
464
|
+
// 0.5.2 L 项:用户确认「会停止旧会话上正在跑的任务」后强制归档(唯一 stopActivity 调用点)。
|
|
465
|
+
if (typeof parsed.sessionId !== 'string' || parsed.sessionId.length === 0) {
|
|
466
|
+
return send(res, 400, { ok: false, message: '缺少要强制归档的旧会话 sessionId' });
|
|
467
|
+
}
|
|
468
|
+
if (parsed.confirmStop !== true) {
|
|
469
|
+
return send(res, 400, {
|
|
470
|
+
ok: false,
|
|
471
|
+
code: 'force-archive',
|
|
472
|
+
message: '强制归档会停止旧会话上正在跑的任务,需要显式确认(confirmStop:true)',
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
const outcome = await forceArchiveSession(deps, {
|
|
476
|
+
sessionId: parsed.sessionId,
|
|
477
|
+
confirmStop: true,
|
|
478
|
+
confirmUnpin: parsed.confirmUnpin,
|
|
479
|
+
});
|
|
480
|
+
return send(res, 200, outcome);
|
|
481
|
+
}
|
|
482
|
+
|
|
452
483
|
if (parsed.action === 'preview') {
|
|
453
484
|
// M6:只算不写——先告诉用户「要压哪段、多少 token」,再让他确认。
|
|
454
485
|
// A3:返回 planId,confirm 会带着它回来校验「同一段范围」。
|
|
@@ -541,6 +572,14 @@ export function registerHttp(webCtx, deps) {
|
|
|
541
572
|
if (code === 'busy') {
|
|
542
573
|
return send(res, 409, { ok: false, code, message: errorText(error) });
|
|
543
574
|
}
|
|
575
|
+
// 0.5.2 L 项:强制归档失败要把**具体原因**带回界面(不静默、不吞成通用文案)。
|
|
576
|
+
if (code === 'force-archive') {
|
|
577
|
+
return send(res, 400, {
|
|
578
|
+
ok: false,
|
|
579
|
+
code,
|
|
580
|
+
message: error instanceof Error ? error.message : String(error),
|
|
581
|
+
});
|
|
582
|
+
}
|
|
544
583
|
if (error?.statusCode === 413) {
|
|
545
584
|
return send(res, 413, { ok: false, message: '请求体太大' });
|
|
546
585
|
}
|
package/lib/client.js
CHANGED
|
@@ -2790,7 +2790,10 @@ window.__ModuleLoader__.load({
|
|
|
2790
2790
|
? h('div', { style: { fontSize: 11, color: theme.subText } }, '正在生成九段草稿并做就绪检查…(只预览,不写盘、不建会话)')
|
|
2791
2791
|
: h(
|
|
2792
2792
|
'div',
|
|
2793
|
-
{
|
|
2793
|
+
{
|
|
2794
|
+
'data-cr-handoff-body': '1',
|
|
2795
|
+
style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, flexShrink: 0 },
|
|
2796
|
+
},
|
|
2794
2797
|
h(
|
|
2795
2798
|
'div',
|
|
2796
2799
|
{
|
|
@@ -2810,11 +2813,13 @@ window.__ModuleLoader__.load({
|
|
|
2810
2813
|
h(
|
|
2811
2814
|
'div',
|
|
2812
2815
|
{
|
|
2816
|
+
'data-cr-handoff-draft': '1',
|
|
2813
2817
|
style: {
|
|
2814
2818
|
fontSize: 11,
|
|
2815
2819
|
lineHeight: '16px',
|
|
2816
2820
|
overflow: 'auto',
|
|
2817
2821
|
maxHeight: 220,
|
|
2822
|
+
flexShrink: 0,
|
|
2818
2823
|
border: `1px solid ${theme.panelBorder}`,
|
|
2819
2824
|
borderRadius: 8,
|
|
2820
2825
|
padding: '6px 8px',
|
|
@@ -3435,6 +3440,34 @@ window.__ModuleLoader__.load({
|
|
|
3435
3440
|
return typeof data.parentArchived === 'boolean' ? data.parentArchived : null;
|
|
3436
3441
|
}
|
|
3437
3442
|
|
|
3443
|
+
/**
|
|
3444
|
+
* 0.5.2 L 项:宿主有没有强制归档的接缝(`readState.capabilities.forceArchive`)。
|
|
3445
|
+
*
|
|
3446
|
+
* 三重取值:true / false / null(老插件没有这个字段 → 不知道,仍显示入口;
|
|
3447
|
+
* 调用失败会给可读错误,不静默)。false = 宿主没有 `archiveSession` → 隐藏死入口。
|
|
3448
|
+
* @param {object|null|undefined} data `/api/compact-relay-state` 的响应
|
|
3449
|
+
* @returns {boolean|null}
|
|
3450
|
+
*/
|
|
3451
|
+
function relayBarForceArchiveCapabilityOf(data) {
|
|
3452
|
+
if (data === null || typeof data !== 'object' || data.ok === false) return null;
|
|
3453
|
+
const capabilities = data.capabilities;
|
|
3454
|
+
if (capabilities === null || typeof capabilities !== 'object') return null;
|
|
3455
|
+
return typeof capabilities.forceArchive === 'boolean' ? capabilities.forceArchive : null;
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3458
|
+
/**
|
|
3459
|
+
* 0.5.2 L 项:链上旧会话(入站 parentId)是否在侧栏置顶(`readState.parentPinned`)。
|
|
3460
|
+
*
|
|
3461
|
+
* 置顶与归档互斥:宿主 `archiveSession` 在同一笔写盘里把该 id 从置顶集合移除。
|
|
3462
|
+
* 强制归档的二次确认要在置顶时明确写「会同时取消置顶」,所以这里只读判断。
|
|
3463
|
+
* @param {object|null|undefined} data `/api/compact-relay-state` 的响应
|
|
3464
|
+
* @returns {boolean|null}
|
|
3465
|
+
*/
|
|
3466
|
+
function relayBarParentPinnedOf(data) {
|
|
3467
|
+
if (data === null || typeof data !== 'object' || data.ok === false) return null;
|
|
3468
|
+
return typeof data.parentPinned === 'boolean' ? data.parentPinned : null;
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3438
3471
|
/**
|
|
3439
3472
|
* 接力条摘要:新链「历史 N:原名」,旧链「交接第 N 次:原名」。
|
|
3440
3473
|
* @param {object} chain relayBarChainOf 的结果
|
|
@@ -3620,6 +3653,15 @@ window.__ModuleLoader__.load({
|
|
|
3620
3653
|
// 0.5.1 DCR-03:服务端能不能恢复归档会话(readState.capabilities.unarchive)。
|
|
3621
3654
|
// null = 老插件没有这个字段(不知道 → 仍显示入口,恢复调用幂等)。
|
|
3622
3655
|
const [serverUnarchive, setServerUnarchive] = useState(null);
|
|
3656
|
+
// 0.5.2 L 项:服务端能不能强制归档(readState.capabilities.forceArchive)。
|
|
3657
|
+
// null = 老插件没有这个字段(不知道 → 仍显示入口,失败会给可读错误)。
|
|
3658
|
+
const [serverForceArchive, setServerForceArchive] = useState(null);
|
|
3659
|
+
// 0.5.2 L 项:旧会话(入站 parentId)是否在侧栏置顶(readState.parentPinned)。
|
|
3660
|
+
// 置顶时二次确认要提前说明「归档会同时取消置顶」。
|
|
3661
|
+
const [parentPinned, setParentPinned] = useState(null);
|
|
3662
|
+
// 0.5.2 L 项:强制归档面板 —— null = 关;否则
|
|
3663
|
+
// {phase:'confirm'|'busy'|'error'|'done', status?, note?, message?}。
|
|
3664
|
+
const [forceView, setForceView] = useState(null);
|
|
3623
3665
|
// 0.5.1 DCR-03:打开菜单时 +1,触发一次静默刷新(别处归档/取消归档能立刻反映)。
|
|
3624
3666
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
3625
3667
|
// 0.5.1 批 3:完整交接链(null = 没展开;点「完整交接链」后才逐级去读)。
|
|
@@ -3666,6 +3708,7 @@ window.__ModuleLoader__.load({
|
|
|
3666
3708
|
setOpen(false);
|
|
3667
3709
|
setDoc(null);
|
|
3668
3710
|
setNotice(null);
|
|
3711
|
+
setForceView(null);
|
|
3669
3712
|
}, [abortDoc, closeChainView]);
|
|
3670
3713
|
|
|
3671
3714
|
// 从交接单详情回到菜单列表:取消读取,避免「返回后又被晚到的响应撑开」。
|
|
@@ -3689,6 +3732,9 @@ window.__ModuleLoader__.load({
|
|
|
3689
3732
|
setParentArchived(null);
|
|
3690
3733
|
setNextArchived(null);
|
|
3691
3734
|
setServerUnarchive(null);
|
|
3735
|
+
setServerForceArchive(null);
|
|
3736
|
+
setParentPinned(null);
|
|
3737
|
+
setForceView(null);
|
|
3692
3738
|
setOpen(false);
|
|
3693
3739
|
setDoc(null);
|
|
3694
3740
|
setNotice(null);
|
|
@@ -3758,6 +3804,9 @@ window.__ModuleLoader__.load({
|
|
|
3758
3804
|
setParentArchived(relayBarParentArchivedOf(data));
|
|
3759
3805
|
setNextArchived(relayBarNextArchivedOf(data));
|
|
3760
3806
|
setServerUnarchive(relayBarUnarchiveCapabilityOf(data));
|
|
3807
|
+
// 0.5.2 L 项:强制归档的能力位与旧会话置顶状态(二次确认文案用)。
|
|
3808
|
+
setServerForceArchive(relayBarForceArchiveCapabilityOf(data));
|
|
3809
|
+
setParentPinned(relayBarParentPinnedOf(data));
|
|
3761
3810
|
// F3 的子会话记录不带 path(交接单路径只写在父记录里):向父会话读回
|
|
3762
3811
|
// 同一条链,childId 指回本会话且链号一致时才采用它的 path。只读、失败
|
|
3763
3812
|
// 就保持「暂无交接单」;不碰宿主逻辑,也不改交接流程。
|
|
@@ -3775,6 +3824,8 @@ window.__ModuleLoader__.load({
|
|
|
3775
3824
|
setParentArchived(null);
|
|
3776
3825
|
setNextArchived(null);
|
|
3777
3826
|
setServerUnarchive(null);
|
|
3827
|
+
setServerForceArchive(null);
|
|
3828
|
+
setParentPinned(null);
|
|
3778
3829
|
}
|
|
3779
3830
|
} else schedule();
|
|
3780
3831
|
} catch {
|
|
@@ -3864,6 +3915,8 @@ window.__ModuleLoader__.load({
|
|
|
3864
3915
|
abortDoc();
|
|
3865
3916
|
setNotice(null);
|
|
3866
3917
|
setDoc(null);
|
|
3918
|
+
// 0.5.2 L 项:重开菜单一律回到主列表(强制归档确认面板不残留、不误触)。
|
|
3919
|
+
setForceView(null);
|
|
3867
3920
|
// 每次开/关都把完整链收掉:重开菜单从主列表开始(不预加载,也不留旧会话的链)。
|
|
3868
3921
|
closeChainView();
|
|
3869
3922
|
// 0.5.1 DCR-03:打开菜单前刷新一次 state —— 归档集合可能在别处被改(侧栏
|
|
@@ -4373,6 +4426,96 @@ window.__ModuleLoader__.load({
|
|
|
4373
4426
|
// 两个方向都没有 → 什么都不渲染(不占 composerStack 的高度)。
|
|
4374
4427
|
if (chain === null && next === null) return null;
|
|
4375
4428
|
|
|
4429
|
+
// ------------------------------------------------------------------
|
|
4430
|
+
// 0.5.2 L 项:强制归档旧会话(显式二次确认;唯一会 stopActivity 的用户入口)
|
|
4431
|
+
// ------------------------------------------------------------------
|
|
4432
|
+
|
|
4433
|
+
/**
|
|
4434
|
+
* 打开强制归档的二次确认面板(只改本地视图,不发请求)。
|
|
4435
|
+
*
|
|
4436
|
+
* 默认行为不变:自动归档遇到活跃会话仍然「保留可见不归档」;只有用户在这个
|
|
4437
|
+
* 确认面板里再点一次「强制归档(停止任务)」才会调用服务端。
|
|
4438
|
+
* @param {string} target 旧会话 id(入站 parentId)
|
|
4439
|
+
*/
|
|
4440
|
+
const openForceConfirm = useCallback(
|
|
4441
|
+
(target) => {
|
|
4442
|
+
if (typeof target !== 'string' || target.length === 0) return;
|
|
4443
|
+
abortDoc();
|
|
4444
|
+
setNotice(null);
|
|
4445
|
+
setDoc(null);
|
|
4446
|
+
closeChainView();
|
|
4447
|
+
setForceView({ phase: 'confirm', target });
|
|
4448
|
+
},
|
|
4449
|
+
[abortDoc, closeChainView],
|
|
4450
|
+
);
|
|
4451
|
+
|
|
4452
|
+
/**
|
|
4453
|
+
* 执行强制归档:POST `action:'force-archive'`(服务端 → registry.archiveSession(id,{stopActivity:true}))。
|
|
4454
|
+
*
|
|
4455
|
+
* 只有用户点过确认才会走到这里。成功时复用 `handoffArchiveNoteOf` 的现有三套文案
|
|
4456
|
+
* (archived / already / pinned,含「归档会取消置顶」的置顶提示);失败时可读错误
|
|
4457
|
+
* 写进面板,不静默。`pinned` 不是失败:它表示宿主里旧会话其实还置顶、需要用户
|
|
4458
|
+
* 再确认一次「归档并取消置顶」,面板会给出这个出口。
|
|
4459
|
+
*
|
|
4460
|
+
* @param {string} target 旧会话 id(入站 parentId)
|
|
4461
|
+
* @param {{confirmUnpin?:boolean}} [options] true = 用户已确认「归档会取消置顶」
|
|
4462
|
+
*/
|
|
4463
|
+
const runForceArchive = useCallback((target, options = {}) => {
|
|
4464
|
+
const confirmUnpin = options.confirmUnpin === true;
|
|
4465
|
+
if (typeof target !== 'string' || target.length === 0) return Promise.resolve();
|
|
4466
|
+
setNotice(null);
|
|
4467
|
+
setForceView({ phase: 'busy', target, confirmUnpin });
|
|
4468
|
+
return (async () => {
|
|
4469
|
+
try {
|
|
4470
|
+
const res = await fetch('/api/compact-relay-action', {
|
|
4471
|
+
method: 'POST',
|
|
4472
|
+
headers: { 'content-type': 'application/json' },
|
|
4473
|
+
cache: 'no-store',
|
|
4474
|
+
body: JSON.stringify({
|
|
4475
|
+
action: 'force-archive',
|
|
4476
|
+
sessionId: target,
|
|
4477
|
+
// 二次确认的凭证:服务端没有 confirmStop:true 一律拒绝(不会静默停任务)。
|
|
4478
|
+
confirmStop: true,
|
|
4479
|
+
...(confirmUnpin ? { confirmUnpin: true } : {}),
|
|
4480
|
+
}),
|
|
4481
|
+
});
|
|
4482
|
+
const data = await res.json().catch(() => null);
|
|
4483
|
+
if (!res.ok || data === null || typeof data !== 'object' || data.ok === false) {
|
|
4484
|
+
const message =
|
|
4485
|
+
data !== null && typeof data === 'object' && typeof data.message === 'string' && data.message.length > 0
|
|
4486
|
+
? data.message
|
|
4487
|
+
: '强制归档接口不可用';
|
|
4488
|
+
throw new Error(message);
|
|
4489
|
+
}
|
|
4490
|
+
// archived / already 都意味着旧会话已在归档集合里(already 幂等,没有重复停任务)。
|
|
4491
|
+
if (data.status === 'archived' || data.status === 'already') setParentArchived(true);
|
|
4492
|
+
setForceView({
|
|
4493
|
+
phase: 'done',
|
|
4494
|
+
target,
|
|
4495
|
+
status: typeof data.status === 'string' ? data.status : 'archived',
|
|
4496
|
+
note:
|
|
4497
|
+
typeof data.note === 'string' && data.note.length > 0
|
|
4498
|
+
? data.note
|
|
4499
|
+
: '旧会话已归档(可在新会话输入框上方「已接力自 …」→「恢复旧会话」找回)。',
|
|
4500
|
+
});
|
|
4501
|
+
} catch (error) {
|
|
4502
|
+
setForceView({
|
|
4503
|
+
phase: 'error',
|
|
4504
|
+
target,
|
|
4505
|
+
confirmUnpin,
|
|
4506
|
+
message: '强制归档失败:' + (error && error.message ? error.message : '未知错误'),
|
|
4507
|
+
});
|
|
4508
|
+
}
|
|
4509
|
+
})();
|
|
4510
|
+
}, []);
|
|
4511
|
+
|
|
4512
|
+
/** 从确认/错误/结果面板回到主菜单列表(不关整个菜单,用户可继续用其他入口)。 */
|
|
4513
|
+
const backFromForce = useCallback(() => {
|
|
4514
|
+
setNotice(null);
|
|
4515
|
+
setForceView(null);
|
|
4516
|
+
}, []);
|
|
4517
|
+
|
|
4518
|
+
|
|
4376
4519
|
const hasParent = chain !== null && chain.parentId !== null;
|
|
4377
4520
|
const hasNext = next !== null && next.childId !== null;
|
|
4378
4521
|
// 父会话侧(只有出站记录)这一行的摘要文案:它没有「已接力自」的来路。
|
|
@@ -4384,11 +4527,17 @@ window.__ModuleLoader__.load({
|
|
|
4384
4527
|
// 都显示——恢复调用幂等,宁可多一个入口,也不让用户的旧会话找不回来。
|
|
4385
4528
|
// 0.5.1 DCR-03:宿主客户端与服务端都没有恢复能力时**隐藏**入口(点了必然失败)。
|
|
4386
4529
|
const showRestore = hasParent && parentArchived !== false && canRestore;
|
|
4530
|
+
// 0.5.2 L 项:强制归档入口 —— 旧会话**还没归档**时才需要它(已归档没什么可强制的);
|
|
4531
|
+
// 服务端明确说没有 archiveSession 能力(false)时隐藏死入口;null(老插件没字段)
|
|
4532
|
+
// 仍显示,失败时会给出可读错误。
|
|
4533
|
+
const canForceArchive = serverForceArchive !== false;
|
|
4534
|
+
const showForceArchive = hasParent && parentArchived !== true && canForceArchive;
|
|
4387
4535
|
const vw = typeof window === 'undefined' ? 1280 : window.innerWidth;
|
|
4388
4536
|
const vh = typeof window === 'undefined' ? 900 : window.innerHeight;
|
|
4389
4537
|
const chainOpen = chainView !== null && doc === null;
|
|
4390
|
-
const
|
|
4391
|
-
const
|
|
4538
|
+
const forceOpen = forceView !== null && doc === null && chainView === null;
|
|
4539
|
+
const menuWidth = Math.min(doc !== null ? 420 : chainOpen ? 336 : forceOpen ? 320 : 272, Math.max(240, vw - 16));
|
|
4540
|
+
const estimatedHeight = doc !== null ? 360 : chainOpen ? 380 : forceOpen ? 300 : 168;
|
|
4392
4541
|
const anchorTop = anchor === null ? vh - 96 : anchor.top;
|
|
4393
4542
|
const anchorBottom = anchor === null ? vh - 40 : anchor.bottom;
|
|
4394
4543
|
const openUp = anchorTop >= Math.min(estimatedHeight, vh - 16) + 12;
|
|
@@ -4635,6 +4784,219 @@ window.__ModuleLoader__.load({
|
|
|
4635
4784
|
),
|
|
4636
4785
|
);
|
|
4637
4786
|
|
|
4787
|
+
/**
|
|
4788
|
+
* 0.5.2 L 项:强制归档的二次确认 / 执行中 / 失败 / 结果面板。
|
|
4789
|
+
*
|
|
4790
|
+
* 破坏性后果写在标题下的第一段(「会停止旧会话上正在跑的任务」),而不是只靠
|
|
4791
|
+
* 按钮文案;置顶时再补一行「归档会同时取消置顶(宿主行为)」,与交接前的置顶
|
|
4792
|
+
* 确认同一口径。只有「强制归档(停止任务)」这个按钮会真的发请求。
|
|
4793
|
+
*/
|
|
4794
|
+
const forcePanel =
|
|
4795
|
+
forceView === null
|
|
4796
|
+
? null
|
|
4797
|
+
: h(
|
|
4798
|
+
'div',
|
|
4799
|
+
{
|
|
4800
|
+
role: 'dialog',
|
|
4801
|
+
'aria-label': '强制归档旧会话',
|
|
4802
|
+
'data-cr-force-archive': '1',
|
|
4803
|
+
'data-cr-force-phase': forceView.phase,
|
|
4804
|
+
'data-cr-force-target': typeof forceView.target === 'string' ? forceView.target : '',
|
|
4805
|
+
style: { display: 'flex', flexDirection: 'column', minHeight: 0 },
|
|
4806
|
+
},
|
|
4807
|
+
h(
|
|
4808
|
+
'div',
|
|
4809
|
+
{
|
|
4810
|
+
style: {
|
|
4811
|
+
display: 'flex',
|
|
4812
|
+
alignItems: 'center',
|
|
4813
|
+
gap: 8,
|
|
4814
|
+
padding: '7px 10px',
|
|
4815
|
+
borderBottom: `1px solid ${theme.panelBorder}`,
|
|
4816
|
+
fontSize: 12,
|
|
4817
|
+
fontWeight: 600,
|
|
4818
|
+
},
|
|
4819
|
+
},
|
|
4820
|
+
h('span', { style: { flex: 1, minWidth: 0 } }, '强制归档旧会话'),
|
|
4821
|
+
forceView.phase === 'busy'
|
|
4822
|
+
? null
|
|
4823
|
+
: h(
|
|
4824
|
+
'button',
|
|
4825
|
+
{
|
|
4826
|
+
type: 'button',
|
|
4827
|
+
'data-cr-force-back': '1',
|
|
4828
|
+
onClick: backFromForce,
|
|
4829
|
+
style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 6, padding: '2px 8px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
|
|
4830
|
+
},
|
|
4831
|
+
'← 返回',
|
|
4832
|
+
),
|
|
4833
|
+
),
|
|
4834
|
+
forceView.phase === 'confirm'
|
|
4835
|
+
? h(
|
|
4836
|
+
'div',
|
|
4837
|
+
{
|
|
4838
|
+
'data-cr-force-confirm': '1',
|
|
4839
|
+
style: { display: 'flex', flexDirection: 'column', gap: 8, padding: '10px 12px', fontSize: 11, lineHeight: '16px' },
|
|
4840
|
+
},
|
|
4841
|
+
h('div', { style: { fontWeight: 600, color: theme.panelFg } }, `将强制归档旧会话「${label}」。`),
|
|
4842
|
+
h(
|
|
4843
|
+
'div',
|
|
4844
|
+
{
|
|
4845
|
+
'data-cr-force-warning': '1',
|
|
4846
|
+
style: {
|
|
4847
|
+
padding: '6px 8px',
|
|
4848
|
+
borderRadius: 8,
|
|
4849
|
+
border: `1px solid ${COLORS.error}`,
|
|
4850
|
+
color: COLORS.error,
|
|
4851
|
+
background: theme.hover,
|
|
4852
|
+
},
|
|
4853
|
+
},
|
|
4854
|
+
'会停止旧会话上正在跑的任务(turn / 子代理 / 后台任务 / 定时任务),中断后无法恢复。',
|
|
4855
|
+
),
|
|
4856
|
+
parentPinned === true
|
|
4857
|
+
? h('div', { 'data-cr-force-pin': '1', style: { color: theme.subText } }, '旧会话已在侧栏置顶:归档会同时取消置顶(宿主行为)。')
|
|
4858
|
+
: parentPinned === null
|
|
4859
|
+
? h('div', { 'data-cr-force-pin': '1', style: { color: theme.subText } }, '宿主没提供置顶状态:若旧会话在侧栏置顶,归档会同时取消置顶(宿主行为)。')
|
|
4860
|
+
: null,
|
|
4861
|
+
h(
|
|
4862
|
+
'div',
|
|
4863
|
+
{ style: { color: theme.subText } },
|
|
4864
|
+
'这是显式操作:默认自动归档遇到活跃会话会「保留可见、不停止任务」,只有你在这里确认后才会停止旧会话上的任务。',
|
|
4865
|
+
),
|
|
4866
|
+
h(
|
|
4867
|
+
'div',
|
|
4868
|
+
{ style: { display: 'flex', justifyContent: 'flex-end', gap: 8 } },
|
|
4869
|
+
h(
|
|
4870
|
+
'button',
|
|
4871
|
+
{
|
|
4872
|
+
type: 'button',
|
|
4873
|
+
'data-cr-force-cancel': '1',
|
|
4874
|
+
onClick: backFromForce,
|
|
4875
|
+
style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 7, padding: '4px 10px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
|
|
4876
|
+
},
|
|
4877
|
+
'取消',
|
|
4878
|
+
),
|
|
4879
|
+
h(
|
|
4880
|
+
'button',
|
|
4881
|
+
{
|
|
4882
|
+
type: 'button',
|
|
4883
|
+
'data-cr-force-run': '1',
|
|
4884
|
+
onClick: () => {
|
|
4885
|
+
void runForceArchive(forceView.target, { confirmUnpin: parentPinned === true });
|
|
4886
|
+
},
|
|
4887
|
+
style: {
|
|
4888
|
+
border: 'none',
|
|
4889
|
+
borderRadius: 7,
|
|
4890
|
+
padding: '4px 10px',
|
|
4891
|
+
fontSize: 11,
|
|
4892
|
+
fontWeight: 600,
|
|
4893
|
+
cursor: 'pointer',
|
|
4894
|
+
background: COLORS.error,
|
|
4895
|
+
color: '#ffffff',
|
|
4896
|
+
},
|
|
4897
|
+
},
|
|
4898
|
+
parentPinned === true ? '强制归档并取消置顶(停止任务)' : '强制归档(停止任务)',
|
|
4899
|
+
),
|
|
4900
|
+
),
|
|
4901
|
+
)
|
|
4902
|
+
: forceView.phase === 'busy'
|
|
4903
|
+
? h(
|
|
4904
|
+
'div',
|
|
4905
|
+
{ 'data-cr-force-busy': '1', style: { padding: '12px', fontSize: 11, lineHeight: '16px', color: theme.subText } },
|
|
4906
|
+
'正在强制归档并停止旧会话上的任务…',
|
|
4907
|
+
)
|
|
4908
|
+
: forceView.phase === 'error'
|
|
4909
|
+
? h(
|
|
4910
|
+
'div',
|
|
4911
|
+
{ 'data-cr-force-error': '1', style: { display: 'flex', flexDirection: 'column', gap: 8, padding: '10px 12px', fontSize: 11, lineHeight: '16px' } },
|
|
4912
|
+
h('div', { style: { color: COLORS.error } }, String(forceView.message)),
|
|
4913
|
+
h(
|
|
4914
|
+
'div',
|
|
4915
|
+
{ style: { display: 'flex', justifyContent: 'flex-end', gap: 8 } },
|
|
4916
|
+
h(
|
|
4917
|
+
'button',
|
|
4918
|
+
{
|
|
4919
|
+
type: 'button',
|
|
4920
|
+
'data-cr-force-error-back': '1',
|
|
4921
|
+
onClick: backFromForce,
|
|
4922
|
+
style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 7, padding: '4px 10px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
|
|
4923
|
+
},
|
|
4924
|
+
'返回菜单',
|
|
4925
|
+
),
|
|
4926
|
+
h(
|
|
4927
|
+
'button',
|
|
4928
|
+
{
|
|
4929
|
+
type: 'button',
|
|
4930
|
+
'data-cr-force-retry': '1',
|
|
4931
|
+
onClick: () => {
|
|
4932
|
+
void runForceArchive(forceView.target, { confirmUnpin: forceView.confirmUnpin === true });
|
|
4933
|
+
},
|
|
4934
|
+
style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 7, padding: '4px 10px', fontSize: 11, fontWeight: 600, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
|
|
4935
|
+
},
|
|
4936
|
+
'重试',
|
|
4937
|
+
),
|
|
4938
|
+
),
|
|
4939
|
+
)
|
|
4940
|
+
: h(
|
|
4941
|
+
'div',
|
|
4942
|
+
{ 'data-cr-force-done': '1', style: { display: 'flex', flexDirection: 'column', gap: 8, padding: '10px 12px', fontSize: 11, lineHeight: '16px' } },
|
|
4943
|
+
h('div', { 'data-cr-force-note': '1', style: { color: theme.panelFg } }, String(forceView.note)),
|
|
4944
|
+
// pinned = 宿主里旧会话其实还置顶(状态过期):不静默取消置顶,
|
|
4945
|
+
// 给一个明确的二次出口,用户点了才带 confirmUnpin 重发。
|
|
4946
|
+
forceView.status === 'pinned'
|
|
4947
|
+
? h(
|
|
4948
|
+
'div',
|
|
4949
|
+
{ style: { display: 'flex', justifyContent: 'flex-end', gap: 8 } },
|
|
4950
|
+
h(
|
|
4951
|
+
'button',
|
|
4952
|
+
{
|
|
4953
|
+
type: 'button',
|
|
4954
|
+
'data-cr-force-close': '1',
|
|
4955
|
+
onClick: closeMenu,
|
|
4956
|
+
style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 7, padding: '4px 10px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
|
|
4957
|
+
},
|
|
4958
|
+
'关闭',
|
|
4959
|
+
),
|
|
4960
|
+
h(
|
|
4961
|
+
'button',
|
|
4962
|
+
{
|
|
4963
|
+
type: 'button',
|
|
4964
|
+
'data-cr-force-unpin': '1',
|
|
4965
|
+
onClick: () => {
|
|
4966
|
+
void runForceArchive(forceView.target, { confirmUnpin: true });
|
|
4967
|
+
},
|
|
4968
|
+
style: { border: 'none', borderRadius: 7, padding: '4px 10px', fontSize: 11, fontWeight: 600, cursor: 'pointer', background: COLORS.error, color: '#ffffff' },
|
|
4969
|
+
},
|
|
4970
|
+
'仍然归档(取消置顶)',
|
|
4971
|
+
),
|
|
4972
|
+
)
|
|
4973
|
+
: h(
|
|
4974
|
+
'div',
|
|
4975
|
+
{ style: { display: 'flex', justifyContent: 'flex-end', gap: 8 } },
|
|
4976
|
+
h(
|
|
4977
|
+
'button',
|
|
4978
|
+
{
|
|
4979
|
+
type: 'button',
|
|
4980
|
+
'data-cr-force-close': '1',
|
|
4981
|
+
onClick: closeMenu,
|
|
4982
|
+
style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 7, padding: '4px 10px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
|
|
4983
|
+
},
|
|
4984
|
+
'关闭',
|
|
4985
|
+
),
|
|
4986
|
+
h(
|
|
4987
|
+
'button',
|
|
4988
|
+
{
|
|
4989
|
+
type: 'button',
|
|
4990
|
+
'data-cr-force-done-back': '1',
|
|
4991
|
+
onClick: backFromForce,
|
|
4992
|
+
style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 7, padding: '4px 10px', fontSize: 11, fontWeight: 600, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
|
|
4993
|
+
},
|
|
4994
|
+
'返回菜单',
|
|
4995
|
+
),
|
|
4996
|
+
),
|
|
4997
|
+
),
|
|
4998
|
+
);
|
|
4999
|
+
|
|
4638
5000
|
const menu =
|
|
4639
5001
|
open === true
|
|
4640
5002
|
? h(
|
|
@@ -4653,7 +5015,7 @@ window.__ModuleLoader__.load({
|
|
|
4653
5015
|
display: 'flex',
|
|
4654
5016
|
flexDirection: 'column',
|
|
4655
5017
|
overflow: 'hidden',
|
|
4656
|
-
padding: doc === null && chainView === null ? 5 : 0,
|
|
5018
|
+
padding: doc === null && chainView === null && forceView === null ? 5 : 0,
|
|
4657
5019
|
borderRadius: 12,
|
|
4658
5020
|
...panelChrome(theme),
|
|
4659
5021
|
color: theme.panelFg,
|
|
@@ -4668,9 +5030,10 @@ window.__ModuleLoader__.load({
|
|
|
4668
5030
|
notice,
|
|
4669
5031
|
)
|
|
4670
5032
|
: null,
|
|
4671
|
-
// 交接单面板 /
|
|
5033
|
+
// 交接单面板 / 完整链面板 / 强制归档确认面板都替换主列表;「← 返回」按来源回主菜单。
|
|
4672
5034
|
docPanel ??
|
|
4673
5035
|
chainPanel ??
|
|
5036
|
+
forcePanel ??
|
|
4674
5037
|
h(
|
|
4675
5038
|
'div',
|
|
4676
5039
|
{ style: { display: 'flex', flexDirection: 'column' } },
|
|
@@ -4712,6 +5075,22 @@ window.__ModuleLoader__.load({
|
|
|
4712
5075
|
},
|
|
4713
5076
|
)
|
|
4714
5077
|
: null,
|
|
5078
|
+
hasParent && showForceArchive
|
|
5079
|
+
? relayBarItem(
|
|
5080
|
+
'强制归档旧会话(会停止后台任务)',
|
|
5081
|
+
parentPinned === true
|
|
5082
|
+
? '旧会话已置顶:归档会同时取消置顶;会先二次确认'
|
|
5083
|
+
: '旧会话还有任务在跑也能收起;会先二次确认',
|
|
5084
|
+
'force-archive',
|
|
5085
|
+
() => {
|
|
5086
|
+
openForceConfirm(chain.parentId);
|
|
5087
|
+
},
|
|
5088
|
+
{
|
|
5089
|
+
right: '›',
|
|
5090
|
+
title: `强制归档旧会话 ${chain.parentId}:二次确认后会停止它上面正在跑的任务再归档`,
|
|
5091
|
+
},
|
|
5092
|
+
)
|
|
5093
|
+
: null,
|
|
4715
5094
|
hasParent
|
|
4716
5095
|
? relayBarItem('完整交接链', '一次读取整条链,回看前几次会话与交接单', 'chain', openChainList, {
|
|
4717
5096
|
right: '›',
|
|
@@ -62,8 +62,8 @@ export const CONFIG_FIELDS = Object.freeze([
|
|
|
62
62
|
field(['thresholds', 'maxSummaries'], { label: '摘要份数上限', kind: 'positiveInt', default: 6, editable: true, view: true }),
|
|
63
63
|
field(['contextWindowFallback'], { label: '兜底窗口', kind: 'positiveInt', default: 1_000_000 }),
|
|
64
64
|
field(['retention', 'recentTailTokens'], { label: '保留近期原文', kind: 'nonNegativeInt', default: 40_000, editable: true, view: true }),
|
|
65
|
-
field(['summary', 'maxTokens'], { label: '摘要输出上限', kind: 'positiveInt', default:
|
|
66
|
-
field(['handoff', 'maxTokens'], { label: '交接单输出上限', kind: 'positiveInt', default:
|
|
65
|
+
field(['summary', 'maxTokens'], { label: '摘要输出上限', kind: 'positiveInt', default: 16_384 }),
|
|
66
|
+
field(['handoff', 'maxTokens'], { label: '交接单输出上限', kind: 'positiveInt', default: 16_384 }),
|
|
67
67
|
field(['timeouts', 'llmMs'], { label: '模型调用超时', kind: 'positiveInt', default: 180_000 }),
|
|
68
68
|
field(['dev', 'testRangeNodes'], { label: '测试取段条数', kind: 'positiveInt', default: 4 }),
|
|
69
69
|
field(['handoffDir'], {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 接力链 record 的**唯一构造点**(0.5.2 S2)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有这个模块:0.5.1 之前父记录在 `actions/handoff.js` 手写 13–14 个键、
|
|
5
|
+
* 子记录在 `dsh/sessions.js` 手写 9–10 个键 —— 两个写点各拼一份,加字段要改两处,
|
|
6
|
+
* 漏一处就是静默降级(读侧 `normalizeRelayRecord` 对缺失/非法字段一律当没有)。
|
|
7
|
+
* 现在「一条 record 长什么样」只有这一处实现,两个写点只负责传语义参数。
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ **这是 record 的字节契约,不是普通的对象字面量,不许「统一美化」**:
|
|
10
|
+
* 1. 键的**插入顺序**就是 `JSON.stringify` 的顺序(会话日志是 JSONL)。父/子两套
|
|
11
|
+
* 顺序**故意不同**,都必须逐字节保留 —— `test/contract-golden.mjs` 锁住了顺序;
|
|
12
|
+
* 2. 父记录多写 0.3 旧字段 `parentSessionId` / `index`(canonical 是 `parentId` /
|
|
13
|
+
* `ordinal`,新写入双写,旧记录仍按旧名可读);
|
|
14
|
+
* 3. 父记录写 `planId`(可能为 **null**);子记录**没有**这个键;
|
|
15
|
+
* 4. 父记录 `historyTitle` **条件写入**(改名没真的生效就不写这个键,不能记一个
|
|
16
|
+
* 不存在的标题);子记录没有这个键;
|
|
17
|
+
* 5. 父/子 `path` 都是**条件写入**(`typeof path === 'string' && path.length > 0`):
|
|
18
|
+
* 子记录在旧调用方/落盘失败时拿不到交接单,就不写键、不写空串;父记录的调用方
|
|
19
|
+
* 保证交接单已落盘(`path.join(handoffDir, handoffFileName(...))`,必然非空),
|
|
20
|
+
* 所以父记录实际总是带 path —— golden 里有断言;
|
|
21
|
+
* 6. 父/子都写 `titleSeq`,**即使为 null**(不是「省略 null」的统一策略:
|
|
22
|
+
* 0.4.x 记录没有这个键,0.5.0 起显式写 null 表示「拿不到宿主 eventSeq」)。
|
|
23
|
+
*
|
|
24
|
+
* 纯函数:不读文件/环境/时间,不 import 任何 `node:` 模块;`atIso` 由调用方按本地
|
|
25
|
+
* 时区口径算好传进来(core 不依赖 dsh 层),保证可在浏览器侧内联(build-client)。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** record 的两个角色(写点只有这两个:交接的父会话、新建的子会话)。 */
|
|
29
|
+
export const RELAY_RECORD_ROLES = Object.freeze(['parent', 'child']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 按角色产出一条 relay record(唯一构造)。
|
|
33
|
+
*
|
|
34
|
+
* 两个角色共用的字段:`parentId` / `childId` / `ordinal` / `title` / `baseTitle` /
|
|
35
|
+
* `scheme` / `titleSeq` / `at` / `atIso`(`title` 是本次新会话标题)。
|
|
36
|
+
*
|
|
37
|
+
* @param {object} input
|
|
38
|
+
* @param {'parent'|'child'} input.role 父记录(旧会话日志)还是子记录(新会话日志)
|
|
39
|
+
* @param {string} input.parentId 父会话 id(写 `parentId`,parent 另写 `parentSessionId`)
|
|
40
|
+
* @param {string} input.childId 子会话 id
|
|
41
|
+
* @param {number} input.ordinal 链号(parent 另写 `index`)
|
|
42
|
+
* @param {string} input.title 本次新会话标题
|
|
43
|
+
* @param {string} input.baseTitle 链上原始标题(不带序号前缀)
|
|
44
|
+
* @param {'handoff-n'|'history-n'} input.scheme 本次链的命名方案
|
|
45
|
+
* @param {number|null} [input.titleSeq] 本次 rename 的宿主 eventSeq(拿不到就是 null)
|
|
46
|
+
* @param {string} [input.path] 交接单路径(非空字符串才写键)
|
|
47
|
+
* @param {string|null} [input.planId] 压缩计划 id(**仅 parent**;null 表示这次没压缩)
|
|
48
|
+
* @param {string} [input.historyTitle] 旧会话改名后的「历史 N」标题(**仅 parent**,且只在改名生效时传)
|
|
49
|
+
* @param {number} input.at 记录时间(epoch ms)
|
|
50
|
+
* @param {string} input.atIso 记录时间(本地 ISO,含时区偏移)
|
|
51
|
+
* @returns {object} 可直接交给 `engine.appendPluginRecord` 的 record
|
|
52
|
+
*/
|
|
53
|
+
export function relayRecordOfForm({
|
|
54
|
+
role,
|
|
55
|
+
parentId,
|
|
56
|
+
childId,
|
|
57
|
+
ordinal,
|
|
58
|
+
baseTitle,
|
|
59
|
+
title,
|
|
60
|
+
scheme,
|
|
61
|
+
titleSeq = null,
|
|
62
|
+
path,
|
|
63
|
+
planId,
|
|
64
|
+
historyTitle,
|
|
65
|
+
at,
|
|
66
|
+
atIso,
|
|
67
|
+
} = {}) {
|
|
68
|
+
if (role === 'parent') {
|
|
69
|
+
return {
|
|
70
|
+
parentId,
|
|
71
|
+
childId,
|
|
72
|
+
ordinal,
|
|
73
|
+
at,
|
|
74
|
+
atIso,
|
|
75
|
+
// 0.3 旧字段双写(旧记录只读兼容);顺序与 0.5.1 手写写法逐字节一致。
|
|
76
|
+
parentSessionId: parentId,
|
|
77
|
+
index: ordinal,
|
|
78
|
+
...(typeof path === 'string' && path.length > 0 ? { path } : {}),
|
|
79
|
+
planId: planId ?? null,
|
|
80
|
+
title,
|
|
81
|
+
baseTitle,
|
|
82
|
+
scheme,
|
|
83
|
+
titleSeq,
|
|
84
|
+
// 改名真的生效(renamed / already)时调用方才传 historyTitle;空串也当没有。
|
|
85
|
+
...(typeof historyTitle === 'string' && historyTitle.length > 0 ? { historyTitle } : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (role === 'child') {
|
|
89
|
+
return {
|
|
90
|
+
parentId,
|
|
91
|
+
childId,
|
|
92
|
+
ordinal,
|
|
93
|
+
baseTitle,
|
|
94
|
+
title,
|
|
95
|
+
scheme,
|
|
96
|
+
titleSeq,
|
|
97
|
+
// 旧调用方/落盘失败拿不到交接单 → 不写这个键(不猜、不写空串)。
|
|
98
|
+
...(typeof path === 'string' && path.length > 0 ? { path } : {}),
|
|
99
|
+
at,
|
|
100
|
+
atIso,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// 角色是代码常量,传错属于开发期错误(不是用户输入),直接抛。
|
|
104
|
+
throw new TypeError(`relayRecordOfForm: 未知 role「${String(role)}」(只认 ${RELAY_RECORD_ROLES.join(' / ')})`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 父记录(写在**旧会话**日志里;子会话建好、旧会话改名之后)。
|
|
109
|
+
*
|
|
110
|
+
* 多写 0.3 旧字段 `parentSessionId` / `index` 和 `planId`,可选 `historyTitle`。
|
|
111
|
+
* @param {Omit<Parameters<typeof relayRecordOfForm>[0], 'role'>} input
|
|
112
|
+
* @returns {object}
|
|
113
|
+
*/
|
|
114
|
+
export function parentRelayRecordOf(input = {}) {
|
|
115
|
+
return relayRecordOfForm({ ...input, role: 'parent' });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 子记录(写在**新会话**日志里;`followup` 之后、`flush` 之前)。
|
|
120
|
+
*
|
|
121
|
+
* 只写 canonical 字段 + `titleSeq` + 条件 `path`;不写 `parentSessionId` / `index` /
|
|
122
|
+
* `planId` / `historyTitle`(这些是父会话侧的旧字段/审计字段)。
|
|
123
|
+
* @param {Omit<Parameters<typeof relayRecordOfForm>[0], 'role'>} input
|
|
124
|
+
* @returns {object}
|
|
125
|
+
*/
|
|
126
|
+
export function childRelayRecordOf(input = {}) {
|
|
127
|
+
return relayRecordOfForm({ ...input, role: 'child' });
|
|
128
|
+
}
|
|
@@ -49,7 +49,7 @@ export async function generateHandoffDoc({ ctx, engine, agent, maxTokens, inputT
|
|
|
49
49
|
const prefix = surfaceMessages(session);
|
|
50
50
|
if (prefix.length === 0) throw actionError('handoff', '这个会话还没有可交接的内容(没有模型可见的消息)');
|
|
51
51
|
|
|
52
|
-
//
|
|
52
|
+
// reasoningEffort 由 prepareModelCall 决定:优先沿用主请求档位(cache 对齐);拿不到 header 才回退「支持 off 就 off」。
|
|
53
53
|
const controls = cacheAlignedControls(session, target);
|
|
54
54
|
let lastProblems = [];
|
|
55
55
|
|
package/lib/dsh/model-task.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* 两个任务不是同一种产物(一个压成六段摘要、一个写九段交接单,校验/重试规则
|
|
6
6
|
* 也不同),所以**不合并重试循环**;但下面四件事完全一样、以前各写一遍:
|
|
7
7
|
* 1. 前置:会话存在性 → 解析 provider/model 路由 → 窗口预算预检 → 推理档位;
|
|
8
|
-
* 2. cache 对齐参数:复用上次请求的 temperature/stop
|
|
9
|
-
*
|
|
8
|
+
* 2. cache 对齐参数:复用上次请求的 temperature/stop;reasoningEffort 由
|
|
9
|
+
* prepareModelCall 显式决定(2026-10-27 起优先沿用主请求的档位,见下);
|
|
10
10
|
* 3. 单次流式调用 + finishProblem 归一(超限/工具调用/中断);
|
|
11
11
|
* 4. 成功结果的公共元信息(provider/model/maxTokens/attempts/usage)。
|
|
12
12
|
*
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { actionError } from '../core/errors.js';
|
|
16
16
|
import { assertBudget, callStream, reasoningEffortFor } from './llm-call.js';
|
|
17
|
-
import { cacheControls, routeOf } from './route.js';
|
|
17
|
+
import { cacheControls, cacheEffortOf, routeOf } from './route.js';
|
|
18
18
|
import { finishProblem } from './stream.js';
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -33,7 +33,7 @@ import { finishProblem } from './stream.js';
|
|
|
33
33
|
* @param {string} input.code 动作错误码
|
|
34
34
|
* @param {string} input.noSessionMessage 会话不存在时的用户可见错误文案
|
|
35
35
|
* @param {string} input.noTargetMessage 拿不到 provider/model 时的用户可见错误文案
|
|
36
|
-
* @returns {Promise<{target:{provider:string, model:string}, reasoningEffort:
|
|
36
|
+
* @returns {Promise<{target:{provider:string, model:string}, reasoningEffort:string|undefined}>}
|
|
37
37
|
*/
|
|
38
38
|
export async function prepareModelCall({
|
|
39
39
|
ctx,
|
|
@@ -53,18 +53,22 @@ export async function prepareModelCall({
|
|
|
53
53
|
|
|
54
54
|
// 架构 A2:调模型前先做窗口预算预检;超窗直接报 budget,不白等一次失败请求。
|
|
55
55
|
await assertBudget({ ctx, target, inputTokens, maxTokens, label });
|
|
56
|
-
//
|
|
57
|
-
|
|
56
|
+
// 2026-10-27 P1 取证:reasoningEffort 会切换 provider 的 thinking 开关,是缓存前缀的
|
|
57
|
+
// 断点。摘要/交接单回放的是主请求的前缀,所以默认**沿用主请求的档位**(cache 对齐);
|
|
58
|
+
// 只有拿不到 header(无请求头 / 模型已切走)时,才退回旧策略:
|
|
59
|
+
// P2-5 的「模型目录支持 off 就 off」(省思考 token;这是从 P1 之前的行为,保留作兜底)。
|
|
60
|
+
const headerEffort = cacheEffortOf(session, target);
|
|
61
|
+
const reasoningEffort = headerEffort !== undefined ? headerEffort : await reasoningEffortFor(ctx, target);
|
|
58
62
|
|
|
59
63
|
return { target, reasoningEffort };
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
/**
|
|
63
|
-
* cache 对齐参数:复用上次请求的 temperature/stop
|
|
67
|
+
* cache 对齐参数:复用上次请求的 temperature/stop;reasoningEffort 不从这里拿。
|
|
64
68
|
*
|
|
65
|
-
* 2026-10-
|
|
66
|
-
*
|
|
67
|
-
*
|
|
69
|
+
* reasoningEffort 由 prepareModelCall 决定(2026-10-27 起优先与主请求一致,保证缓存命中)。
|
|
70
|
+
* 2026-10-19 的旧坑(max 思考把 maxTokens 吃光)已由「summary/handoff.maxTokens 提到
|
|
71
|
+
* 16K + 保留截断重试」兜住,不再是「一律 off」的理由。
|
|
68
72
|
*
|
|
69
73
|
* @param {object} session
|
|
70
74
|
* @param {{provider:string, model:string}} target
|
package/lib/dsh/route.js
CHANGED
|
@@ -51,6 +51,25 @@ export function routeOf(ctx, session, agent) {
|
|
|
51
51
|
return asRoute(agent?.options);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 复用「上一条真实请求」的推理档位(cache 对齐用)。
|
|
57
|
+
*
|
|
58
|
+
* 2026-10-27 P1 取证:摘要/交接单回放的是上一条主请求的前缀,但 reasoningEffort
|
|
59
|
+
* 会切换 provider 的 thinking 开关;一旦与主请求不同,缓存前缀会在第一条
|
|
60
|
+
* assistant 消息的渲染处断开(生产实测命中 2–5%,隔离 A/B:off 74.0% vs max 96.9%)。
|
|
61
|
+
* 所以只要 header 里的 provider/model 与本次目标一致,就原样沿用它的 reasoningEffort。
|
|
62
|
+
*
|
|
63
|
+
* @param {object} session
|
|
64
|
+
* @param {{provider: string, model: string}} target
|
|
65
|
+
* @returns {string|undefined}
|
|
66
|
+
*/
|
|
67
|
+
export function cacheEffortOf(session, target) {
|
|
68
|
+
const config = session?.requestHeader?.()?.config;
|
|
69
|
+
if (config === undefined || config.provider !== target.provider || config.model !== target.model) return undefined;
|
|
70
|
+
return typeof config.reasoningEffort === 'string' && config.reasoningEffort.length > 0 ? config.reasoningEffort : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
54
73
|
/**
|
|
55
74
|
* 复用上次真实请求的采样控制参数,让附带调用尽量贴近它的前缀缓存。
|
|
56
75
|
* @param {object} session
|
package/lib/dsh/sessions.js
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
truncateTitleUtf8,
|
|
29
29
|
} from '../core/relay.js';
|
|
30
30
|
import { HANDOFF_PREAMBLE } from '../core/prompts.js';
|
|
31
|
+
import { childRelayRecordOf } from '../core/relay-record.js';
|
|
31
32
|
import { localIso } from './diag-counters.js';
|
|
32
33
|
import { cacheControls, routeOf } from './route.js';
|
|
33
34
|
import { relayRecordOf, writeRelayRecord } from './relay.js';
|
|
@@ -444,20 +445,26 @@ export async function createHandoffSession({ ctx, engine, session, agent, doc, t
|
|
|
444
445
|
// 0.5.0 补:交接单已由调用方落盘,path 一并写进子记录 —— 接力条在子会话里直接
|
|
445
446
|
// 拿到「查看交接单」的路径,父会话未加载/已归档时不再退化为「暂无交接单」。
|
|
446
447
|
// path 拿不到(旧调用方/失败)就不写这个键,不猜、不写空串。
|
|
448
|
+
// 0.5.2 S2:record 形状(含键顺序、不写父会话侧字段、条件 path)由
|
|
449
|
+
// core/relay-record 的唯一构造器负责,这里只传语义参数。
|
|
447
450
|
const titlePlan = plan ?? handoffTitlePlanOf(ctx, session);
|
|
448
451
|
const relayAt = Date.now();
|
|
449
|
-
const relayWrote = writeRelayRecord(
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
452
|
+
const relayWrote = writeRelayRecord(
|
|
453
|
+
engine,
|
|
454
|
+
childSession,
|
|
455
|
+
childRelayRecordOf({
|
|
456
|
+
parentId: session.id,
|
|
457
|
+
childId,
|
|
458
|
+
ordinal: titlePlan.index,
|
|
459
|
+
baseTitle: titlePlan.baseTitle,
|
|
460
|
+
title,
|
|
461
|
+
scheme: titlePlan.scheme,
|
|
462
|
+
titleSeq,
|
|
463
|
+
path,
|
|
464
|
+
at: relayAt,
|
|
465
|
+
atIso: localIso(new Date(relayAt)),
|
|
466
|
+
}),
|
|
467
|
+
);
|
|
461
468
|
if (!relayWrote) {
|
|
462
469
|
log?.warn?.(`compact-relay: 新会话 ${childId} 接力链记录写入失败(下次从该会话交接将回退到标题解析)`);
|
|
463
470
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-compact-relay",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "DeepSeek Harness 上下文指示器插件:提供容量监控、压缩预览与确认、九段式交接单生成及消息锁定功能。 · Context indicator for DSH: preview-then-confirm compaction, 9-section session handoff, and pinned messages.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"node": ">=22"
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
|
-
"test": "node scripts/build-client.mjs --check && node --check lib/client.js && node test/smoke.mjs && node test/handoff.mjs && node test/plugin-load.mjs && node test/contract.mjs && node test/compact-log.mjs && node test/compress-cue.mjs && node test/state-passive.mjs && node test/native-compaction.mjs && node test/markdown-lite.mjs && node test/clip.mjs && node test/diagnostics.mjs && node test/pin-contract.mjs && node test/ledger.mjs && node test/handoff-p2.mjs && node test/relay-title-scheme.mjs && node test/relay-chain.mjs && node test/relay-chain-endpoint.mjs && node test/handoff-naming.mjs && node test/native-line.mjs && node test/handoff-guard.mjs && node test/settings-ux.mjs && node test/pin-health.mjs && node test/archive.mjs && node test/relay-bar.mjs",
|
|
56
|
+
"test": "node scripts/build-client.mjs --check && node --check lib/client.js && node test/smoke.mjs && node test/handoff.mjs && node test/plugin-load.mjs && node test/contract.mjs && node test/compact-log.mjs && node test/compress-cue.mjs && node test/state-passive.mjs && node test/native-compaction.mjs && node test/markdown-lite.mjs && node test/clip.mjs && node test/diagnostics.mjs && node test/pin-contract.mjs && node test/ledger.mjs && node test/handoff-p2.mjs && node test/relay-title-scheme.mjs && node test/relay-chain.mjs && node test/relay-chain-endpoint.mjs && node test/handoff-naming.mjs && node test/native-line.mjs && node test/handoff-guard.mjs && node test/settings-ux.mjs && node test/pin-health.mjs && node test/archive.mjs && node test/force-archive.mjs && node test/relay-bar.mjs && node test/relay-record.mjs && node test/contract-golden.mjs",
|
|
57
57
|
"test:e2e": "node test/e2e/smoke.mjs",
|
|
58
58
|
"test:e2e:settings": "node test/e2e/settings-ui.mjs",
|
|
59
59
|
"test:e2e:settings-ux": "node test/e2e/settings-ux.mjs",
|