Skip to content
dsh.fish
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

  • 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.
Source