Back to home@QuanhuZeYu

dsh-idle-compactor

Idle-triggered context compaction plugin for DeepSeek Harness: compact a session once it goes quiet past a configurable token floor.

Stars
0
Language
TypeScript
Created
Sep 4, 2026
Updated
Sep 4, 2026

Introduction

dsh-idle-compactor

English | 简体中文

Idle-triggered context compaction for 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

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:

{
  "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:

BarWhere it comes from
The agent is idle — no driver, no maintenance taskagent.status
Its newest session-log event is older than idleMssession.events.at(-1).time (durable, so a restart cannot fake a fresh session out of compaction)
Its measured request pressure is at least thresholdTokensctx.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 checkpointper-session compactedAt watermark
Its cooldown or failure backoff has elapsedcooldownMs, retryBackoffMs
It is not archivedctx.workspaceRegistry.archivedSessionIds
It is not waiting on background workits 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):

- id: idle-compactor
  config:
    thresholdTokens: 131072   # 128K
    idleMs: 600000            # 10 minutes
KeyDefaultMeaning
enabledtrueRegister no sweep at all when false.
thresholdTokens131072Context floor. Use 128000 for a decimal 128K.
idleMs600000Quiet window before a session is eligible.
scanMs30000Sweep interval; bounds how long after idleMs the compaction lands.
includeSubagentsfalseAlso compact sessions whose header carries origin: subagent.
excludeArchivedtrueSkip sessions the workspace registry reports as archived.
skipAwaitingWorktrueNever compact a session with background work outstanding — its own or a live descendant's.
waitWarnMs10800000Log one warning per wait episode once it has held a session this long.
cooldownMs60000Minimum spacing between compactions of one session.
retryBackoffMs300000Wait after a failed attempt or a "no compactable range" result.
maxPerScan1Sessions per sweep, bounding concurrent summarization calls.
dryRunfalseLog 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

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.