# 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 -- # 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).