dsh-session-merge
Verifieddsh-session-merge · v0.6.0 · MIT
DSH 会话合并/追加:fork 克隆、全类型复制、时间窗混合、surface 折叠、JSONL 区间写入;三层校验(本地预检 / 六层深度 / 官方 open)。实测 DSH 桌面端 0.2.1-rc.5。
Install
dsh plugin add dsh-session-merge Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
Readme
🔀 dsh-session-merge — 合并、追加 DSH 会话
简体中文 · English
想合并 DSH 会话,直接拼文件不行——格式、压缩节点、seq 引用全是坑。
这个插件走 DSH 自己的写入接口:不删一个事件,写进去的每个字节 DSH 都认。
- 🎯 来自一次真实的手滑 —— 不是通用工具。做它是因为弄 QQ agent 时不小心开了新会话,想把两段合回去,当时没找到现成的合并方式。
- 🔒 完整性优先 —— 合并/追加都不删事件。
/compact也只是把之前的内容生成摘要,原文永远还在会话里。 - 📐 算切点,不靠猜 ——
planCompact按 turn 边界算每段 ≤ 预算的位置,避开已有压缩记录。 - 🛡️ 三层校验 —— 写入前预检 / 六层深度 / 官方
open,报错文案与 DSH 界面一字不差。 - 🧱 断点续写 —— 每批 1.4~1.5 MB,压一次写一批。
⭐ 如果你也有"想把几个会话拼成一个"的需求,点个 star。
一行安装:dsh plugin --profile desktop add dsh-session-merge
⚡ 快速开始
dsh plugin --profile desktop add dsh-session-merge
# 重启 DSH → 插件列表里出现「会话合并」
装完先跑三个只读动作,不写任何东西:
session_merge_build({ inspect: true, targetId: "<会话id>" }) // 看结构
session_merge_build({ verify: true, targetId: "<会话id>" }) // DSH 能不能打开
session_merge_build({ planCompact: true, targetId: "<会话id>" }) // 切点和体积
🧩 它解决什么
历史散在多个会话里:QQ agent 开错窗口、并行起了两条线、fork 出去又回不来。想回头查"那天到底怎么定的"得来回翻;想把它们并成一条——直接拼文件 DSH 是打不开的:
| 直接拼会踩的坑 | 为什么 |
|---|---|
| 压缩节点指向错位 | shadowedRange / shadowedSeqs 存的是旧 seq,搬过去全指错 |
| seq 不连续 | DSH 要求 seq 从 0 起密集递增,拼出来就断 |
| surface 折叠指错 | 会话里可能有 surfaceOp:{op:'replace'},它的 startSeq/endSeq 指着旧 seq |
| 工具调用对不上 | callId / toolCallId 跨会话不匹配,孤儿结果一堆 |
| 时间线早就有分叉 | 并行会话本来就交错,硬拼会来回跳 |
插件做的事就两件:合并(fullCopy / mixCopy)和追加(fromJsonl)。
合并完得到的是一个正常会话:老历史在前、新对话接在后面,压缩节点跟着走,/compact 照常能用。代价是 surface 变大——换来一个完整、连贯、可检索的容器。
真正难的不是"把事件搬过去",而是搬完 DSH 还认。这就是三层校验存在的理由。
🛡️ 三层校验(核心)
| 层 | 谁 | 查什么 | 何时跑 |
|---|---|---|---|
| 轻量预检 | validate-full.mjs |
surface 折叠、生命周期、compaction 配对、header 字段、seq 连续、sourceEventSeqs |
每次写入前自动 |
| 六层深度 | deep-validate.mjs |
帧 / header / 路径 / 事件稠密与类型表 / 继承关系 / 内嵌流重放 | 写入后、排障时 |
| 官方校验 | verify |
直接调 persistence.open |
要"和 DSH 完全一样"的结论时 |
深度校验可独立跑:
node bin/validate.mjs <会话id前缀|目录|文件> [--json] [--no-replay] [--strict-replay]
node bin/validate.mjs --all # 批量校验全部会话
📏 硬约束(实测)
| 项 | 数值 | 来源 |
|---|---|---|
| 单次请求上限 | 1,048,576 tokens | 模型硬限 |
| surface 字节/token | 3.8 | 22.52 MB ≈ 6.2M tokens 反推 |
| 安全线 | surface ≤ 3.4 MB | ≈900K tokens,再大 /compact 发不出去 |
能不能压,看的是当前可见 surface 的体积,不是界面上那个百分比。
🧰 完整能力
一个工具 session_merge_build,11 个模式均可独立调用:
| 参数 | 类型 | 做什么 |
|---|---|---|
inspect |
只读 | 事件 / turn / surface 体积 / 压缩节点 / 时间范围 |
verify |
只读 | 官方 open 校验(可单独调,也可配 inspect) |
planCompact |
只读 | 算可压缩切点:cuts[] + 每段体积 |
makeRoot |
写 | 把会话改造成合法根(修断链) |
forkClone |
写 | 官方 sessions.fork 克隆,保留压缩节点 |
fullCopy |
写 | 多源全类型复制,按 assistant 回复去重 |
mixCopy |
写 | 按时间窗混合,byteBudget 到量停 |
fromJsonl |
写 | 按区间写 JSONL,自动补 turn 边界 / 自动 seq 偏移 |
copySequential |
写 | 单源直线复制,重映射全部 seq 引用 |
resumeOnly |
写 | 只 resume 触发落盘 |
autoCompact |
写 | 触发压缩(spawn 脚本走 RPC);autoCompactAfter 可在写入后自动压 |
写入都支持 dryRun。用法细节:FEATURES.md · CONFIG.md · API.md
🩺 另外两个用途
修断链会话 —— 以前合并过、父会话后来被删掉的,会报 parentSession 错误打不开。makeRoot 把它改造成合法根(四处标记同时改)。用法见 FEATURES.md。
救压不动的会话 —— 不是主用途:DSH 有自动压缩,真卡死很少见。真遇到 requested N tokens > 1,048,576 时,解法是分段压缩(不删不折):planCompact 算切点 → 按切点 forkClone → 每段手动 /compact → fullCopy 拼回 → verify。完整步骤见 排障手册。
🔁 分批写入(压完要重启)
单个会话装不下全部历史时,按这个循环:
追加一批(surface ≤ 2.4 MB)
→ autoCompact 压缩(surface 压回接近 0)
→ 重启 DSH(释放写句柄)
→ 追加下一批
→ 重复 → verify
压缩是单独一步,不用手点 /compact:
// 1. 追加
session_merge_build({ mixCopy: true, sources: ["<源>"], targetId: "<目标>", byteBudget: 2400000 })
// 2. 压缩(spawn 调 shared/compact-session.mjs 走 RPC)
session_merge_build({ autoCompact: true, targetId: "<目标>" })
也可以在一次调用里带
autoCompactAfter: true,写入后按阈值(默认 2.4 MB)自动决定压不压;但两步分开更直观。
为什么压完必须重启:压缩会把会话加载进 DSH,agent 持有写句柄不释放。插件试过 releaseHandle、acquireLease、detachEntered、agent.dispose 全部无效——句柄归 agent 生命周期管,插件没权限回收。DSH 也没有"闲置自动卸载"机制(源码里 unloadAfter / evictAfter 均不存在,closeInitiators 只在插件销毁时触发)。
所以这是硬限制,不是可以绕过去的:
| 操作 | 可行性 |
|---|---|
| 追加 → 压缩 | ✅ |
| 压缩后立刻再追加 | ❌ 句柄被占 |
| 压缩后重启 → 再追加 | ✅ |
⚖️ 代价与边界
| 是 | 不是 |
|---|---|
| 一次性的合并/救援工具 | 日常插件 |
| 保证上下文完整(0 删除) | 省钱工具(只会让 surface 变大) |
| 三层校验兜底 | 记忆系统(压缩后 AI 读到的仍是摘要) |
真正该长期活下来的东西在记忆库(记忆卡 / memory_recall / 日记)。会话只是"最近这段"的容器。
跨会话搬运时确实会丢的:
| 丢的 | 原因 | 防法 |
|---|---|---|
| 空回复的唤醒记录 | 去重被判重复 | fullCopy 空回复不参与去重 |
| 未闭合的 turn | 源会话被强杀 / 切点落在 turn 中间 | 切点自动补到 turn/end,只按整块搬 |
| 未结算的工具调用 | callId 对不上 |
丢弃孤儿 call/result |
收件箱事件(agent/inbox/spliced) |
target:'next-turn' + start 索引对不上新会话时点 |
一律跳过(不在 surface 上,跳过无损) |
| 严格时间顺序 | 并行会话本就交错 | 保留真实时间戳 |
🧪 实测记录
用一个 49,513 事件 / 1,310 turn / 22.52 MB 的真实大会话跑过全量功能:
| 功能 | 结果 |
|---|---|
inspect |
22.52 MB(含水下)/ 可见 2.56 MB |
planCompact(800 KB 预算) |
3 段(0.81 / 0.77 / 0.81 MB) |
forkClone |
49,514 条 seed,verify 通过 |
fullCopy |
47,233 条,跳过 spliced 2,041 / end-seed 88 |
mixCopy |
47,069 条 / 22 MB |
copySequential |
49,496 条 / 44 压缩记录,verify 通过 |
fromJsonl |
seq 偏移 49,496,写入后仍可打开 |
makeRoot |
49,513 条 / 0 问题 / 修 delivery 18 |
另外扫了 80 个本机会话作为横向样本:10 个含 surfaceOp:{op:'replace'}(共 279 条),1 个末尾有未闭合 turn(进程被强杀)。
这轮实测修掉 7 个 bug,细节见 CHANGELOG。
📚 文档
INSTALL · FEATURES · CONFIG · API · CHANGELOG · 排障手册 · English
🧩 兼容性
| 项 | 值 |
|---|---|
| 平台 | DSH 桌面端(Electron),实测 0.2.1-rc.5 |
| 会话格式 | v4(session.v4.jsonl.zstd) |
| 操作系统 | Windows 实测;路径全走 node:path |
📄 License
MIT © 2026 HCQM