Bundle
dsh-idle-compactor
Idle-triggered context compaction for DeepSeek Harness: compact a session once it has gone quiet past a token floor
- Source
- QuanhuZeYu
- License
- MIT
- Updated
- Updated 3 days ago
Readme
# dsh-idle-compactor
[English](README.md) | [简体中文](README.zh.md)
Idle-triggered context compaction for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness).
A session that has grown past a token floor and then gone quiet gets compacted on its own, so you come
back to a condensed history instead of paying for the stale tail on every request.
Built-in automatic compaction knows two triggers only: request `pressure`, and a provider-confirmed
context overflow. It never asks how long a session has sat unused. This plugin adds that axis — idle
time and context size, and both must clear their floor before anything happens.
## Requirements
- DeepSeek Harness with the `compaction` seam and `tokenMeter` (verified against `0.1.2-alpha.3`).
- No runtime dependencies. Every `@deepseek-ai/*` import in the source is `import type` and is erased,
so `lib/index.js` is a single self-contained ESM file.
## Install
```sh
git clone https://github.com/QuanhuZeYu/dsh-idle-compactor.git
dsh plugin --profile web add ./dsh-idle-compactor
# pnpm 9 refuses to add a dependency to a workspace root without the flag:
dsh plugin --profile web add -w ./dsh-idle-compactor
```
Then restart the profile: a patch layer reloads live, bundle mounting does not.
The bundle carries its committed `lib/` output, so a git or path install needs no build script and no
`allowBuilds` allowance. `dsh plugin add` is what appends the bundle to the profile layer stack, which
makes its `cordis.patch.yml` take effect. Doing it by hand means writing the same two things into
`~/.dsh/profiles/web/package.json`:
```json
{
"dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-idle-compactor"] } },
"dependencies": { "dsh-idle-compactor": "link:../../dsh-idle-compactor" }
}
```
## What it does
Every sweep (default: every 30 s) walks the live agents in this process and compacts the ones that clear
all of these bars:
| Bar | Where it comes from |
|---|---|
| The agent is `idle` — no driver, no maintenance task | `agent.status` |
| Its newest session-log event is older than `idleMs` | `session.events.at(-1).time` (durable, so a restart cannot fake a fresh session out of compaction) |
| Its measured request pressure is at least `thresholdTokens` | `ctx.tokenMeter.measure(session).totalTokens` |
| Its model-visible surface is at least `thresholdTokens` too | `.surfaceTokens` from the same measurement |
| Nothing new has landed since the last checkpoint | per-session `compactedAt` watermark |
| Its cooldown or failure backoff has elapsed | `cooldownMs`, `retryBackoffMs` |
| It is not archived | `ctx.workspaceRegistry.archivedSessionIds` |
| It is not waiting on background work | its own outstanding `ctx.jobs`, its inbox, and the live sessions it parents |
Archiving a session hides it from every grouping surface and deliberately leaves a live agent
running, so the sweep names archived ids explicitly: hiding a conversation is not a request to
rewrite what it shows the model. A session whose archive set is still loading is skipped too —
nothing is compacted on an unclassified session.
### Why pressure and surface are both required
`totalTokens` is request pressure: an anchor carrying the previous successful call's provider usage,
repriced only by the next call. `surfaceTokens` is the content compaction actually shrinks. A session
compacted moments ago therefore reads high pressure over a small surface — re-summarizing it would spend
detail to reclaim nothing — so both halves must clear the floor before a sweep acts. Those skips are
logged at debug level.
This is also why the context figure in the UI can stay high after a compaction you can see in the log:
the number is the pressure anchor, and the next request is what reprices it.
Compaction runs through the standard seam `ctx.compaction.compactNow(agent, signal)` — the same entry
point `/compact` uses: an idle-session maintenance transaction with the backend's own range
selection and retention policy. The plugin picks no range and writes no summary.
### Why quiet is not the same as finished
A parent that ends its turn while a background child is still working is `idle` by the loop's own
measure, and that wait is bounded by the child rather than by `idleMs`: a delegated run can take ten
minutes or three hours. Condensing the parent's history mid-wait would rewrite the context the
settlement is about to return to, so the sweep consults three further ledgers:
- the session's own outstanding jobs — `ctx.jobs.list(agent)`, kept down to the snapshots this session
owns, which is also why a long background `pwsh` build holds its session;
- the session's inbox, which carries a notice that has landed but not yet opened a turn;
- the live sessions this one parents, directly or transitively.
The third ledger is not redundant with the first. The harness registers no job for a continuable child
or for a child woken by `send_message`, and a child driver still reports `idle` in the window between
accepting a prompt and starting its turn — so a descendant that logged anything inside the quiet window
counts as work in flight, including one still inside its creation window. A child that has gone quiet for
`idleMs` stops holding the parent, and a child that is not resident in this process never held it: there
is nothing left to wake the parent for.
`waitWarnMs` reports a long wait instead of cutting it short: once an episode crosses it, the sweep logs
one warning for that episode and keeps waiting.
### Why the bundle also enables a host-plane backend
An agent preset mounts `compaction-basic` inside an `isolate` realm, and `dsh-web-app` disables the host
copy, so a host fiber cannot read the realm instance. The bundle patch therefore re-enables the host row
with `auto: false`: it answers idle `compactNow()` requests and registers no pressure compaction of its
own, leaving the preset backend as the one that reacts to request pressure. A side effect users of the
`minimal` preset will appreciate: those sessions have no compaction backend at all otherwise, and now
they can be compacted too.
## Configuration
Set it in the profile's `cordis.patch.yml` (later layers win, and a patch replaces the whole config, so
restate every key you keep):
```yaml
- id: idle-compactor
config:
thresholdTokens: 131072 # 128K
idleMs: 600000 # 10 minutes
```
| Key | Default | Meaning |
|---|---|---|
| `enabled` | `true` | Register no sweep at all when false. |
| `thresholdTokens` | `131072` | Context floor. Use `128000` for a decimal 128K. |
| `idleMs` | `600000` | Quiet window before a session is eligible. |
| `scanMs` | `30000` | Sweep interval; bounds how long after `idleMs` the compaction lands. |
| `includeSubagents` | `false` | Also compact sessions whose header carries `origin: subagent`. |
| `excludeArchived` | `true` | Skip sessions the workspace registry reports as archived. |
| `skipAwaitingWork` | `true` | Never compact a session with background work outstanding — its own or a live descendant's. |
| `waitWarnMs` | `10800000` | Log one warning per wait episode once it has held a session this long. |
| `cooldownMs` | `60000` | Minimum spacing between compactions of one session. |
| `retryBackoffMs` | `300000` | Wait after a failed attempt or a "no compactable range" result. |
| `maxPerScan` | `1` | Sessions per sweep, bounding concurrent summarization calls. |
| `dryRun` | `false` | Log what would be compacted; write nothing. |
Unknown keys and out-of-range values throw at load.
## Model experience
- One summarization request per landed compaction, made by the compaction backend on the session's
routed model. No extra request per turn, no injected notice, no prompt section.
- The only model-visible result is the checkpoint node that replaces the compacted range.
- A session below `thresholdTokens` is measured once and then re-armed for the next interval instead of
being re-measured on every tick.
## What compaction does not do
The durable log is never rewritten. A compaction appends `compaction/start`, `compaction/summary`, and
`compaction/end`, then lands one `user/message` carrying `surfaceOp: replace` over the selected span;
`shadowedSeqs` names every event it moved out of the model-visible surface, so the original text stays in
the session log and a consumer can read it back. Neither the storage index nor the archive flag changes.
## Known limitations and deferred work
- **Live agents only.** A session with no agent in this process has no surface to measure and no agent to
run maintenance against, so closed or never-opened sessions wait until they are opened. A cold path
would have to resume an agent per candidate; that is also why `includeSubagents` defaults to false —
background children are already owned by their parent lifecycle.
- **A running turn is never interrupted.** Eligibility requires `agent.status === "idle"`, and
`compactNow` is a between-turn maintenance task; a session whose turn has been running for hours is not
idle in either sense. Its between-turn twin — quiet while a child works — is held by
`skipAwaitingWork`, so a parent whose wait is unbounded is compacted only after that wait ends.
- **No settings panel.** Configuration lives in the patch layer; a `settings.section` with hot reload is
the natural follow-up.
## Development
```sh
npm install # typescript + vitest as dev dependencies
npm run link-dsh -- <path-to-dsh-checkout> # resolve the harness packages from a checkout
npm run build # src/index.ts -> lib/index.js
npm test # 12 integration tests
npm run typecheck:tests # typecheck the specs too (tsconfig.check.json)
```
Most `@deepseek-ai/*` packages the source is typed against are not on the public registry, so
`scripts/link-checkout.mjs` links a local DeepSeek Harness checkout into this project's own
(gitignored) `node_modules`. It writes nothing into that checkout, and when several checkouts sit side
by side it refuses to guess — pass the one you mean. `npm install` is optional: without it the script
borrows the checkout's own `typescript` and `vitest`, and the npm scripts call those by file, not by bin
shim, so a borrowed copy works exactly like an installed one.
`tests/idle-compactor.spec.ts` composes a real agent loop, a real session log, a real token meter and a
real `BasicCompactionEngine` (only its summarizer is scripted), and asserts the durable
`compaction/summary` events the run leaves behind rather than mock call counts. It exercises the built
bundle by default — the same artifact the profile loads; point `DSH_IDLE_COMPACTOR_BUNDLE` elsewhere to
check a different build.
Covers: an over-threshold idle session lands exactly one compaction and its measured context shrinks; a
second quiet window with no new activity does not repeat it; fresh activity re-arms it; a below-threshold
session is untouched; a surface already under the floor is left alone while pressure reads over it; an
archived session is untouched, including while the archive set is still loading; `dryRun` writes nothing;
bad configuration throws. Waiting is covered from both sides: a session holding an outstanding job and a
session whose child is mid-turn are left alone and then compacted once the wait ends, while an unrelated
mid-turn session or a job owned by someone else compacts on schedule.
## Layout
```
src/index.ts the plugin (type-only imports, zero runtime dependencies)
lib/ committed build output - what the profile loads
cordis.patch.yml the bundle layer: the plugin row plus the host-plane backend
tests/ integration tests against a composed harness
scripts/link-checkout.mjs dev-only: resolve harness packages from a checkout, read-only
vitest.config.mjs standalone test root; no harness checkout config involved
tsconfig.json erasable-syntax-only ESM, declarations into lib/types
tsconfig.check.json test-plane typecheck: src plus specs, with Node ambient types
```
## License
MIT — see [LICENSE](LICENSE).
Install
dsh plugin --profile web add github:QuanhuZeYu/dsh-idle-compactor
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-idle-compactor from the hub
- This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.