dsh-oha-whale-compress
DeepSeek Harness 专用「压缩会话」插件(哦鲸鲸 family,与 dsh-session-manager 同品牌设计)。
功能
- 侧边栏底部入口(挨着「会话管理」):点开完整面板。
- 完整面板:自动压缩开关与阈值 · 输入框下方按钮开关 · 压缩用 provider / model · 一键「压缩当前会话」。
- 自动压缩:上下文估算超过阈值时自动压一次。阈值是绝对 token 数,可开关、可改(默认
409600,即 400K)。 - 输入框下方常驻「压缩」按钮:可一键显示 / 隐藏,关掉后输入框下方恢复干净;点它开的是减半面板。
- 减半面板:只留两个开关 + 压缩按钮 + 结果,去掉选 API、保留条数与提示词。
- 查看结果:展示当前会话大小(节点数 / token 数)与本次压缩的状态 / checkpoint 文本。
两个开关在完整面板与减半面板里都显示 —— 减半面板正是从输入框下方的按钮点进来的,开关得在它自己面前可调。两个开关都是拨动即存;阈值输入在失焦或回车时落盘(边打字边存会把中间态写进配置)。
自动压缩怎么工作
框架自带的自动压缩按窗口比例判定(contextWindow * thresholdRatio),表达不了「到 400K 就压」这种绝对值。本插件的做法是把判定权拆开:
- 注入 preset 时把父类的
thresholdRatio让到一个哑值(0.02),父类从此不再实际把关 —— 但仍保留 2% 窗口的兜底,万一插件那条路径失效也不至于彻底失控; - 子类
WhaleCompactionEngine.compactIfNeeded接管「步进压力」这一路:- 未到
autoThresholdTokens→ 直接返回null,父类根本不会跑; - 到了阈值 → 交给父类,此时它的哑阈值必然已被越过,压缩照常发生;
- 未到
context-overflow(模型已经报窗口溢出)是兜底路径,不受阈值与开关管辖,一律放行。
配置落在 ~/.dsh/storages/dsh-oha-whale-compress/config.json:
| 字段 | 默认 | 含义 |
|---|---|---|
autoCompress |
true |
自动压缩总开关 |
autoThresholdTokens |
409600 |
触发线(绝对 token 数) |
dockButton |
true |
输入框下方按钮是否显示 |
为什么 preset 里会多出一段托管块
自动压缩保留多少由框架的 selectCompactableRange 决定 —— 那是 dsh-compaction-basic 的内部函数,既不在 @deepseek-ai/dsh-compaction 的导出里,插件也触及不到。所以面板没有、也不该有「保留多少条」这类选项(早期版本有过一个 keepN,从头到尾没接上,已删除)。实际保留量写在这段托管块里:retainTokens: 40000,即压缩后保留尾部约 40K tokens。
retainTokens 用绝对值而不是 retainRatio,一是语义直接,二是可以绕开上游「retainRatio 必须小于 thresholdRatio」的加载期校验(那条校验只在比例存在时触发)。托管块由注入器写入、由还原精确删除,不会碰你自己写的 config。
触发链路
侧边栏 / 常驻按钮 → POST /oha-whale-compress/compress {sessionId}
→ host 取 agent = ctx.get('agents').get(sessionId)
→ ctx.get('commands').execute(agent, '/compact', [], signal)
→ (command-compact 在压缩 group 里执行 compactNow,真正压缩)
→ 返回 { ok, message }
手动压缩必须走框架
ctx.get('commands').execute(agent, '/compact', ...),绝不直写会话日志。
自动压缩走的是另一条路:agent/pre-step → compactIfNeeded('pressure') → 本插件先判阈值 → 再交父类。
HTTP 路由
| 路由 | 方法 | 作用 |
|---|---|---|
/oha-whale-compress/configure |
GET / POST | 读 / 写面板配置(provider / model / keepN / promptTemplate / prompt / autoCompress / autoThresholdTokens / dockButton) |
/oha-whale-compress/status |
GET | 当前会话大小(?sessionId=,nodes/totalTokens),非破坏性 |
/oha-whale-compress/compress |
POST | {sessionId} → 触发 /compact |
/oha-whale-compress/icon |
GET | 返回 assets/icon.png(image/png,max-age=3600) |
把引擎挂到 preset 上
自动压缩要真的按本插件的阈值走,preset 里那行引擎名必须换成 dsh-oha-whale-compress/engine。
运行时的权威源是 profile 的 cordis.patch.yml:每个 preset 在 config.plugins 里内联一份完整 composition,而 @deepseek-ai/dsh-agent-preset 的 schema 里 plugins 是必填字段 —— 所以 ~/.dsh/.agent-presets/<id>/agent.cordis.yml 那套目录不是加载源,改它没有任何效果(踩过)。
一个文件里内联着四个 preset,因此要批量替换:
cd /root/dsha-oha-whale-compress && node --input-type=module -e "
const { injectPreset } = await import('file:///root/dsha-oha-whale-compress/src/injector.js')
const r = await injectPreset({
presetPath: '/root/.dsh/profiles/web/cordis.patch.yml',
userRoot: '/root/.dsh/profiles',
mode: 'inject',
expectedCount: 4
})
console.log(r)"
还原把 mode 换成 'restore' 即可。整个注入器守五条规矩:只写 user 根之内、绝不重新序列化 YAML(注释与 !!js 表达式原样保留)、唯一性或期望数量硬校验、先备份、幂等。
expectedCount 不是装饰:数量对不上就报 count-mismatch 并拒绝写入 —— 宁可不动,也不猜着改。
dsh-purge 的「应用补丁」也会写
cordis.patch.yml。如果哪天应用完补丁发现阈值不生效了,重跑一遍上面的注入即可。
安装
dsh plugin --profile web add dsh-oha-whale-compress
或源码方式:dsh plugin --profile web add file:/root/dsha-oha-whale-compress。
生效条件
| 改了什么 | 怎么生效 |
|---|---|
src/*(host 半) |
重启 dsh |
client/client.js |
刷新页面 |
| preset 的引擎行(注入 / 还原) | 重启 dsh(loader 不监听 preset 文件) |
血泪教训(勿踩)
- 插件只硬 inject
webServer,其余服务一律ctx.get()运行时懒取,取不到优雅返回(如 503),绝不阻塞激活 —— 否则整棵插件树崩,Web 打不开。 compaction服务是隔离的(压缩 groupisolate),profile 插件取不到,只能走/compact命令触发;要改压缩策略,只能像本插件这样顶替 preset 里的引擎行。package.json的exports必须声明./engine:preset 行写的是dsh-oha-whale-compress/engine,缺了它加载直接ERR_PACKAGE_PATH_NOT_EXPORTED。宿主只换解析基点,不会绕过 ESM 的 exports 白名单。- 测试夹具要跟着宿主的会话 API 走:0.1.7 起
dsh-compaction-basic的摘要调用会读agent.session.toolHistory(),假的 session 漏了这个方法,所有走原生支路的用例会一起红。 - 反转布尔写错了不报错,只会让一整块 UI 静默不渲染。
Overlay里把完整面板的判定写成full: mode.mode !== 'full',方向一反,if (full)包住的字段(开关、阈值、API、提示词、保存按钮)一次都没出现过,面板却"看着正常"。改完模式判定,务必把每个字段挨个点一遍。 shell.overlay和conversation.composer.dock的 slot props 是空对象。 宿主渲染它们时写的是renderSlot(name, {}),所以props.useSessions/props.sessionId/props.modelDirectories全是undefined。这些数据得自己从 client 侧服务取:ctx.get('uiSession').current(当前会话绑定)、ctx.get('modelDirectories')(模型目录)。sessionsstore 里没有current字段。 它只有byId/list;useSessions((s) => s.current)恒为undefined—— 这就是面板永远显示「未打开会话」的根因。当前会话要靠uiSession.current的绑定值,或从byId里找retainedBy.mainView > 0的那一个(本插件两条路都走,前者优先)。- 异步加载的值不要直接喂给开关。 面板 state 若用默认值起跳、等 fetch 回来再改成真实值,开关就会当着用户的面重播一次切换动画 —— 每次打开面板都重播一次。初值应当取模块级 config store 里已经加载好的那份。注意那份必须在所有
useState之前取,否则撞const的 TDZ。
License
MIT
No comments yet. Be the first to write one.