133563825as-ai/oha-whale-compress ↗★ 2

dsh-oha-whale-compress

压缩会话插件:侧边栏 + 输入框常驻「压缩」按钮,一键把当前会话压缩成 checkpoint;可自定义自动压缩触发阈值(token 绝对量),输入框下方按钮可开关。哦鲸鲸品牌。 适合需要管理长会话 Token 占用、自定义自动压缩阈值的用户。

パッケージ
dsh-oha-whale-compress
互換性
未検証
Cordis ピア範囲
^4.0.1
バージョン
1.0.0
ライセンス
MIT
最終更新
2026/09/27

インストール

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:133563825as-ai/oha-whale-compress

ドキュメント

README 全文を読む ↗

dsh-oha-whale-compress

DeepSeek Harness 专用「压缩会话」插件(哦鲸鲸 family,与 dsh-session-manager 同品牌设计)。

功能

  • 侧边栏底部入口(挨着「会话管理」):点开完整面板。
  • 完整面板:自动压缩开关与阈值 · 输入框下方按钮开关 · 压缩用 provider / model · 一键「压缩当前会话」。
  • 自动压缩:上下文估算超过阈值时自动压一次。阈值是绝对 token 数,可开关、可改(默认 409600,即 400K)。
  • 输入框下方常驻「压缩」按钮:可一键显示 / 隐藏,关掉后输入框下方恢复干净;点它开的是减半面板。
  • 减半面板:只留两个开关 + 压缩按钮 + 结果,去掉选 API、保留条数与提示词。
  • 查看结果:展示当前会话大小(节点数 / token 数)与本次压缩的状态 / checkpoint 文本。

两个开关在完整面板与减半面板里都显示 —— 减半面板正是从输入框下方的按钮点进来的,开关得在它自己面前可调。两个开关都是拨动即存;阈值输入在失焦或回车时落盘(边打字边存会把中间态写进配置)。

自动压缩怎么工作

框架自带的自动压缩按窗口比例判定(contextWindow * thresholdRatio),表达不了「到 400K 就压」这种绝对值。本插件的做法是把判定权拆开:

  1. 注入 preset 时把父类的 thresholdRatio 让到一个哑值(0.02),父类从此不再实际把关 —— 但仍保留 2% 窗口的兜底,万一插件那条路径失效也不至于彻底失控;
  2. 子类 WhaleCompactionEngine.compactIfNeeded 接管「步进压力」这一路:
    • 未到 autoThresholdTokens → 直接返回 null,父类根本不会跑;
    • 到了阈值 → 交给父类,此时它的哑阈值必然已被越过,压缩照常发生;
  3. context-overflow(模型已经报窗口溢出)是兜底路径,不受阈值与开关管辖,一律放行。

配置落在 ~/.dsh/storages/dsh-oha-whale-compress/config.json:

字段默认含义
autoCompresstrue自动压缩总开关
autoThresholdTokens409600触发线(绝对 token 数)
dockButtontrue输入框下方按钮是否显示

为什么 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/configureGET / POST读 / 写面板配置(provider / model / keepN / promptTemplate / prompt / autoCompress / autoThresholdTokens / dockButton)
/oha-whale-compress/statusGET当前会话大小(?sessionId=,nodes/totalTokens),非破坏性
/oha-whale-compress/compressPOST{sessionId} → 触发 /compact
/oha-whale-compress/iconGET返回 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//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 服务是隔离的(压缩 group isolate),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')(模型目录)。
  • sessions store 里没有 current 字段。 它只有 byId / list;useSessions((s) => s.current) 恒为 undefined —— 这就是面板永远显示「未打开会话」的根因。当前会话要靠 uiSession.current 的绑定值,或从 byId 里找 retainedBy.mainView > 0 的那一个(本插件两条路都走,前者优先)。
  • 异步加载的值不要直接喂给开关。 面板 state 若用默认值起跳、等 fetch 回来再改成真实值,开关就会当着用户的面重播一次切换动画 —— 每次打开面板都重播一次。初值应当取模块级 config store 里已经加载好的那份。注意那份必须在所有 useState 之前取,否则撞 const 的 TDZ。

License

MIT