Skip to content
dsh.fish
Bundle

dsh-oha-whale-compress

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

Source
133563825as-ai
stars
2 stars
License
MIT
Updated
Updated 17 days ago

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`:

| 字段 | 默认 | 含义 |
|---|---|---|
| `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,因此要批量替换:

```sh
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`。如果哪天应用完补丁发现阈值不生效了,重跑一遍上面的注入即可。

## 安装

```sh
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

Install

dsh plugin --profile web add github:133563825as-ai/oha-whale-compress

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source