Skip to content

About

Model-free /trim command for DeepSeek Harness — drop the oldest, least valuable span of context so a session can keep running on a smaller-window model, without any model call.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

dsh-command-context-trim

A model-free /trim command and a compaction-threshold tuner for DeepSeek Harness: give a small-window local model every token it can actually use, compact it less often, and repair a blown context window without a single model call.

中文说明 →

Why

Switching a long session from a large-window cloud model to a smaller local model makes the next request exceed the new model's window. The established workaround is /compact before switching — and that is exactly where it hurts: compaction summarizes, so its summarizer call must fit the (now smaller) window while holding the very region being condensed. It frequently fails for the same reason the original request failed.

DSH's automatic context-overflow recovery has the same property: it retries by summarizing with the routed model, so on a too-small local window the recovery call overflows too, and the original error is preserved.

/trim needs no model at all. It measures the current request under the target route, picks the oldest tool-pairing-balanced span that frees exactly enough tokens, and shadows that span with one short marker message. Two synchronous appends, zero LLM calls — it works precisely when every request is failing.

The other half of this plugin goes after the cause instead of the symptom: it raises where compaction fires, which on a small local window is the difference between using most of the context or less than half of it.

What this buys a local model

DSH reserves a fixed 65536-token headroom whatever the window is, and the trigger is min(thresholdRatio × W, W − R − headroom) where R is the route's own output reserve. On a million-token cloud route that barely matters. On a local card it decides everything:

route W R dsh default trigger tuned (headroomTokens: 0, r = 0.8) usable context
Qwen3.8:27b @ Intel Arc B70 131072 16384 49152 — 37.5 % 104857 — 80.0 % +55705 tokens, 2.1×
Bonsai 2 @ RTX 5060 8GB 40960 8192 no pressure path at all (the route cannot compact early) 32768 — 80.0 % +32768 — but measured slower: see When it does not pay
deepseek-official (cloud) 1000000 256000 678464 — 67.8 % 744000 — 74.4 % barely moves — tuning matters where the window is small

Three consequences, in the order they matter:

  1. More usable context, so the model reasons over more of the real history. On the routes above the ratio is no longer capped by a fixed reserve, so the session keeps roughly twice the conversation before anything is condensed.
  2. Fewer compactions where the stock headroom was capping the trigger — and compaction is the expensive part. It costs a model call that blocks the turn (measured at 209–372 s with a local summarizer), so compacting later and less often removes those stalls. See Small windows get tuned too below before enabling this on a small window: there the same mechanism works in reverse.
  3. When the window is blown anyway, it is repaired without a model. /trim shadows the oldest balanced span with a marker synchronously, with zero LLM calls: the turn continues in milliseconds instead of waiting minutes for a summarizer that may overflow for the very reason the request did. Installed in a profile, it also runs itself on CONTEXT_WINDOW_EXCEEDED (emergencyTrim), so the wall does not end the turn.

Small windows get tuned too, on purpose

Earlier releases shipped a the removed tuneStockDisabledRoutes switch switch and left routes whose message budget sits at or below dsh's 65536 at stock, on the strength of one measurement: on Bonsai 2 @ RTX 5060 8GB (40960 / 8192), same task, single route, tuning confirmed end to end, the 80 % trigger took compactions from 15 to 24, and with a local summarizer at 209–372 s per call that reads as a net loss.

That reasoning assumes the overflow path picks the slack up, and it does not. Where the stock headroom disables the trigger, the route has no proactive trigger at all — compaction happens only after a request has already blown the window, and that is a systematic gap rather than a rare edge: with nothing scheduled, whether a run ever compacts depends on where its own context happens to land, and a run that lands badly has no scheduled point at which to recover. "Fewer compactions" is then not a saving. A bounded 209–372 s wait is a price; a stalled task is not a bounded cost.

So every route with a declared contextWindow and a message budget is tuned now, and the switch is gone. Where the requested ratio is unreachable because the route's own output reserve is larger than (1 − r) × W, the note says so out loud with the ratio that actually lands and what would move it, rather than silently reporting the target:

qwen38: 80.0 % is unreachable inside this route — the output reserve caps the trigger at
66.7 % (~32768 tokens). Raise contextWindow or lower maxTokens to move it.

/trim and the overflow auto-trim remain the model-free nets underneath all of this: the trigger schedules the compaction, the pruner keeps one tool result from dominating, and the overflow listener is still what answers a request that blows the window regardless of any of it.

Two honest limits. Where the route's own reserve is larger than (1 − r) × W, the ratio is unreachable by construction — ovms (81920/65536) and opencode-go are capped at 20 % and 61.6 % respectively; the tuner moves those to their real ceiling rather than pretending. And the tuning applies where compaction is composed on the profile plane (headless, tui); a web profile keeps compaction inside each session's agent-preset realm, where a runtime write cannot reach a running session — see Retuning the live process below.

The preset route is locked and has to stay resolvable

A session's preset is fixed once it starts (agent-preset/locked), and a fork inherits the parent's preset and is locked the same way: buildForkSeed copies the parent's turn/start events into the child (dsh-session/lib/index.js:884-893), and forking requires a completed turn to begin with, so the child's turnBoundary.lastTurn is already above zero when it is created.

The sharper edge is permanence. agentPresets.resolve(id) has no fallback — an unknown explicit id throws agent-preset/not-found (dsh-agent-preset-registry/lib/index.js:603-610) — so if a preset id disappears, every session bound to it can be neither resumed nor forked, and the lock means it cannot be re-pointed at another one. A dsh upgrade overwrites the shipped presets, and a generated preset can be deleted by hand; the id you generate is therefore a durable interface. Keep it stable across plugin upgrades, keep at least one copy, and use /context-tune preset default so that new sessions pick it up — existing ones will not follow.

On headless/tui the profile-plane path (autoTuneCompaction, prunerThresholdChars) has none of these problems, because there dsh-base inserts both rows into the profile and a patch layer retunes them on every boot. Prefer that plane where it exists; the preset route is the web-shaped exception.

Which route to use

Both routes can set every compaction setting — thresholds, the pruner, and the summarizer (summarizationProvider / summarizationModel are ordinary keys on compaction-basic, per route via modelPolicies entries). Reach for the profile plane first, because it is retunable on every boot and carries no lock.

You want to Use Why
retune thresholds or the pruner (headless, tui) autoTuneCompaction, the settings card, or a patch layer applied at boot or live; nothing persists that can break
pick the summarizer (headless, tui) the profile plane (summarizationProvider / summarizationModel, or modelPolicies per route) ordinary config keys — no preset needed
do either one in web /context-tune preset the web host plane disables both rows, so nothing else can reach them
a different config per session or task /context-tune preset presets are per session; the profile is per profile
change a session that already started start a new session locked at the first turn, and a fork inherits the lock

Install

# from npm
dsh plugin --profile web add dsh-command-context-trim

# from a checkout
dsh plugin --profile web add file:/path/to/dsh-command-context-trim

The package declares dsh.bundle.patch, so dsh plugin add also adds it to the profile's dsh.profile.bundles and its insert row (plugin id context-trim) is composed automatically. Then restart the profile's app (/trim appears in the slash-command menu with its argument hint).

Usage

/trim                       fit the active model window
/trim check                 report the plan, change nothing
/trim 32k                   fit an explicit 32768-token budget
/trim lc:/models/qwen.gguf  fit that route's declared window

Typical use:

  1. Switch the session to the smaller local model.
  2. Run /trim (or run it before switching, while the large model is still active — the command targets the newest model/selection intent, so a pending switch is honored).
  3. Continue the conversation.

Success output:

Trimmed 47 messages (seqs 12-105, ~52310 tokens → 63-token marker) for lc:/models/Qwen3.8-27B-Q4_K_M.gguf (window 90000).
Request size: ~118204 → ~66310 tokens (target 73612).

Every refusal explains itself and changes nothing — for example when the fixed request overhead (system prompt + tool schemas) alone exceeds the budget, or when the protected head/tail cannot be relaxed far enough.

How it works

One trim is two synchronously adjacent appends:

# Event Why
1 compaction/prune The token meter's shadow-price claim. The meter's persisted projections are O(1) and cannot re-price a replaced range, so a replacement must be preceded by an event stating the exact price of the range it shadows; without it the fold records a zero delta and reported occupancy drifts.
2 user/message with surfaceOp: {op: 'replace', start, end} The replacement itself. sourceEventSeqs cites every shadowed node, as the surface contract requires.

user/message is the only surface-eligible event an idle command may append: assistant/message requires an open step and a tool/result replacement requires an open turn, so a trim performed between turns — which is exactly when a user needs one — has no other legal node type.

Design consequences:

  • The human transcript is untouched. Replacements are model-only; the transcript reads append-origin events. The full original content stays in the durable session log, so a trim is auditable and recoverable by hand.
  • No tool-call/result pair is ever split. Cut edges are chosen with toolPairingBalancedBefore/After from @deepseek-ai/dsh-compaction.
  • The system prompt is never trimmed (harness 0.1.5+ carries it as a surface node): it is a barrier that no elided span may touch or cross.
  • Mutual exclusion with everything else. The handler runs inside agent.runMaintenance(), which fails unless the agent is idle, so it cannot interleave with a turn, /compact, or automatic compaction; it also refuses while an unmatched compaction/start is open.

Tokenizer calibration

DSH's token meter prices content at a fixed four characters per token. A real session is denser than that, so the meter under-counts the request it is about to send. On a production session the gap was measured at 1.16×–1.32× per route (median 1.18, p90 1.28) — enough that a request the meter calls 12 000 tokens can be 15 800 on the wire, past a 15 000-token window.

The plugin now learns the gap instead of assuming it, and works in the provider's unit throughout.

How the factor is learned

Every assistant/message that carries usage is one request the provider has already priced:

realPrompt  = usage.inputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
estPrompt   = the surface as it stood before that message  +  the envelope's tool schemas
factor      = realPrompt / estPrompt

The assistant message itself is excluded: it was the answer, not part of the request that produced it. The cache fields are included because dsh keeps them disjoint (inputTokens is the uncached part).

Samples are kept per provider:model route, in a window of the last 50. The route's factor is the p90 of its samples, never the mean: a single log-heavy request must not move the factor, but a run of them should. The factor is never below 1.0 — a meter that over-counts is not a reason to discard context.

A route needs 3 samples before its factor is believed. Below that, and for every route never seen, the factor is 1 and the plugin behaves exactly as it did before.

A route without a learned factor is not left to the meter's estimate alone. Once the meter has adopted the provider's own usage as its baseline, its total is in real tokens while its per-node prices stay heuristic, and the ratio between the two is a density measured on the very request being priced. The plugin uses the larger of the learned factor and that implied one, so a request the provider has already counted is never priced below what the provider charged for it — and a route that was just switched to is still priced from the last request the provider actually answered.

Where it is applied

The factor converts the meter's per-node prices, so the planner compares one unit against another:

Where Before After
/trim's budget check the estimate the real size
/trim's "already within budget" the estimate the real size
the automatic trim's ladder the estimate of the failing request the real size of the failing request
the trim's own report the estimate the real size

The request envelope is priced on its own terms — the tool schemas' own price — rather than as the difference between the meter's total and its surface. That difference only equals the envelope while the meter estimates everything; once the meter has adopted the provider's real usage as its baseline, the difference is the gap between the real and the estimated surface, and charging it as the envelope would inflate the very request the trim is trying to fit.

What it changes, concretely

The same session, the same window, before and after the factor was learned (measured on an isolated dsh 0.2.0-rc.2 with a mock provider whose tokenizer is twice as dense as dsh's assumption):

/trim  →  Already within budget: ~6791 / 11527 tokens for mock:mock-model (window 21000). Nothing to trim.
/trim  →  Cannot free enough for mock:mock-model (window 21000): the largest balanced span frees ~0 of the ~2408
           tokens needed. Protected content: task statement ~3044 tokens, recent tail ~3354 tokens …

The first line is the meter talking (6791 estimated tokens, which fit). The second is the provider talking (13 896 real tokens, which did not). The uncalibrated plugin would have left the request alone and the backend would have refused it.

Where the table lives

<profile>/context-trim-calibration.json, beside the profile patch. It holds the raw observations, not a factor: the policy that turns samples into a factor can change without invalidating what was measured. A missing or corrupt file is ignored — a factor the plugin never measured must not be believed.

Delete the file to forget everything and start over; a fresh profile starts empty and behaves exactly as it did before calibration existed.

What it does not do

  • The compaction threshold is untouched. A static threshold cannot be simultaneously right for the anchored and the unanchored case: dividing it by the factor would make compaction fire ~20 % early in the common case, where dsh's own meter has already adopted the provider's real usage. The calibrated trim ladder is the backstop for the unanchored case.
  • It does not change the context window. The factor corrects how the request is counted, not how large the window is.

Automatic trimming on the context wall

autoTrim (default on) makes the same model-free reduction happen without anyone typing a command: a prepended agent/request-error listener reacts to CONTEXT_WINDOW_EXCEEDED, trims, and asks the loop to retry.

request fails (context wall)
  ├─ prepended: context-trim   → trim a span, no model call → retry      ← wins when it can free space
  └─ next(): compaction-basic  → prune tool results → summarize (LLM)    ← only when trimming cannot help

Why prepend is the whole trick: agent/request-error is a Cordis waterfall, and compaction registers its own summarization recovery on the same event. Cordis keeps listeners in registration order and { prepend: true } unshifts to the front, so this plugin runs first even though compaction is mounted later — in a web profile it lives inside an agent-preset isolate realm, which no host-plane plugin can out-order by mount position. Returning { kind: 'retry' } without calling next() vetoes summarization for that attempt.

Scope, deliberately narrow:

Event Behaviour
CONTEXT_WINDOW_EXCEEDED on agent/request-error trim, then retry
any other request failure untouched (next())
ordinary threshold compaction (agent/pre-step pressure) never touched
/compact, the tool-result pruner never touched

The per-episode retry budget (maxAutoTrimRetries, default 3) resets when a completed assistant message lands or the agent goes idle, mirroring compaction's own overflow accounting. Set autoTrim: false to keep trimming manual.

If compaction cannot recover either, trim steps in. When the plugin declines, it calls next() — running compaction's own listener — and inspects the result. A value means compaction pruned and/or summarized and the loop is already retrying. undefined means the original request error is about to be preserved and the turn will fail; at that point the plugin takes one emergency trim (at most once per overflow episode), in which the head-protected nodes — the original task statement — become elidable too. The newest human instruction is still never crossed. Set emergencyTrim: false to keep the old hand-off behaviour.

The cheap reduction runs first. On the wall the plugin slims oversized tool results in place (head + marker + tail, the same transform DSH's own pruner performs) and only elides a whole span when that is not enough:

request refused → in-place tool-result slim  →  elide one span  →  compaction (prune + summarize)

It calls the official toolResultPruner service when that service is reachable from the plugin's context (0.1.2, and a 0.1.5 headless profile where compaction stays on the host plane) and performs the same transform itself when it is not (a 0.1.5 web profile hides the pruner inside an agent-preset isolate realm). A slim keeps the node, its tool call and the prefix up to it — the marker written in place says [... tool result middle trimmed to fit the context window ...], which is how a log shows that this plugin slimmed a node rather than DSH's pruner ([... tool result middle pruned ...]). Set preferInPlacePrune: false to go straight to span elision.

A wrong contextWindow degrades into extra trimming, not a dead turn. The first attempt trusts the declared window. If the retry is rejected again, the declaration has just been contradicted, so every later attempt in that episode retargets to failingRequestTokens * (1 - autoTrimShrink) — by default halving the request that was rejected — which converges however far the backend is below its declaration, and logs a warning naming the setting to fix. With an honest window the first attempt succeeds and the adaptive path never runs. The real fix for a mismatched backend is the setting: contextWindow: 32768 makes the first trim target ~22k and fit.

Trade-off, stated plainly: an automatic trim drops the oldest span instead of summarizing it. On a small local window that is the point — the summarizer must fit the region it is condensing and frequently cannot — but the dropped text is replaced by a marker rather than a summary. /compact stays available, and the full text remains in the durable session log.

When the provider cuts a reply short

A reply that ends on a length stop is not an error: the request succeeded, the provider stopped generating. The automatic trim listens for CONTEXT_WINDOW_EXCEEDED, so it never sees this case, and the user is left with a turn that is silently missing its answer.

The plugin therefore hints, and never trims. On an assistant message whose finish chunk reports max-tokens, or on a turn/end whose reason is max-tokens, it writes one line into the session:

[context-trim] the provider cut the previous reply short (a length stop). The request carried 122999 tokens of a
131072-token window, over the 103219-token budget. It was allowed 16384 tokens of output and produced 1. Run /trim to
shrink the context.

The size is the provider's own count — the prompt it charged, cache reads and writes included — so it carries no ~. The plugin's per-route factor is only a fallback for a reply whose usage did not land, and it is printed with the ~ the rest of the plugin uses for its own arithmetic: that factor is a prior over the route's recent history and can be far off for one request whose content has a different density.

The two numbers are the point, because a length stop has two very different causes:

The request was… What stopped the reply What the hint says
over the budget the context, most likely run /trim
within the budget the provider's own output limit the context is not what stopped it — "continue" is the fix

One hint per episode: once the provider has cut a reply, the next replies in the same stretch are cut for the same reason, and a note per reply would bury the conversation. A reply that ends normally clears the episode. The hint is written one tick after the event, because a session/event listener runs inside the commit that published the event and an append re-entered from there is refused outright; the tick also lets the turn close first. The same line is written to the host log at warn level, because an unattended observation of the user's context must be visible there too.

What is protected

Protected Why
Leading protectHeadNodes nodes (default 1) The task statement — dropping it destroys the point of the conversation.
Recent tail (retainRatio of the window, floor minTailTokens) Recency is what a coding agent needs; retention is relaxed only when the fit is otherwise impossible, and the result says so.
The newest user/message The live human instruction. It is never elided and no span may cross it, so an ongoing request cannot be dropped. Older user messages are ordinary nodes.
The final surface message A preference, not a prohibition. Kept whenever any older span can free enough; dropped only as a last resort, and typically only together with its tool call (they can only be removed as a pair).

Within those bounds the policy is oldest-first, least-long-possible: the elided span starts at the oldest balanced cut and grows only until it frees exactly enough tokens.

Elision always starts at the oldest balanced cut, and the search is graded so that the cheapest loss is tried first:

  1. a span that stays outside the retained tail and keeps the final message (the configured retention, relaxed step by step only if the fit otherwise fails);
  2. a span that may reach into the retained tail, still keeping the final message;
  3. last resort — a span that includes the final message, typically the current step's assistant tool-call plus its tool result, which can only be removed as a pair.

Protecting the final message outright deadlocks the most common overflow shape: one large assistant tool-call whose tool result is the last node cannot be removed as a pair, which left a handful of freeable tokens while the request stayed over the wall (observed live: "largest balanced span frees ~4 of the ~4631 tokens needed"). The newest user message is the real anchor and stays a hard barrier in every tier. With allowTailTrim: false the search ends after tier 1, so the retained tail is a hard boundary and the final message is never dropped.

Compatibility

Which plugin version to install on which dsh

There is no peer dependency to enforce this, so it is stated here and has to be read: dsh 0.1.5 can only run this plugin's 0.1.x line. From 0.2.0 onwards, install the latest. 0.1.7 and 0.2.0 are close enough that the same build serves both.

dsh install why
0.1.2-rc.1 0.1.x the pinned devDependency floor
0.1.5-rc.2 / 0.1.5-rc.3 0.1.x only the settings card needs configForms and schemastery's .volatile(), and 0.1.5 has neither — see below
0.1.7-rc.2 latest (0.6.x) unchanged in 0.2.0 for everything this plugin does
0.2.0-rc.2 latest (0.6.x) the first release that saves a card write correctly on this line
0.2.1-rc.2 latest (0.6.x) verified against 0.2.1-rc.2: no API change this plugin depends on, both rows load and both cards render

Why 0.1.5 is fenced off rather than merely degraded: 0.1.5 predates both halves of the card contract. Its web host serves no configForms service, so there is no form to render and nothing to save into, and its schemastery predates .volatile(), which is the only thing that puts a field on a card at all. The plugin still loads there and /trim still works — the client half detects the missing service and registers nothing rather than throwing inside the page — so the failure is a card that silently never appears, not a boot error. That is the worst shape for a version boundary, which is why it is called out here instead of being left to be discovered.

The 0.2.0 line is where the save path changed. A 0.1.7-era build stages a boolean as the string "true", which .volatile() used to let through; 0.2.0 validates the operation against the schema and refuses it in band, with no HTTP error and no console output. Fixed in 0.5.0, which writes correctly typed values. So: 0.2.0 needs 0.5.0 or later even before the two-row split and the rename.

Marker provenance under session format v4. The replacement message carries the source kind plugin:dsh-command-context-trim — the plugin:<plugin id> shape v4 requires — because 0.1.7's admission path refuses the retired {kind: 'plugin'} wrapper with format v4 message requires a producer-owned source kind. Markers written before 0.3.1 are still recognised by this plugin, and dsh rewrites them when it upgrades a v3 session. This is not a 0.1.7-only release: running each line's own admission validator gives

harness line this version's marker the retired wrapper
0.1.2-rc.1 accepted (the line ships no row-admission validator; the only kind !== "plugin" checks belong to other plugins' own markers) accepted
0.1.5-rc.3 accepted (assertV3RowAdmission) accepted
0.1.7-rc.2 accepted (assertV4RowAdmission) rejected — the bug 0.3.1 fixes

so 0.3.0 is the version that is restricted, to older lines being unaffected and 0.1.7 broken. Re-check the shape against an installed dsh any time (a dsh upgrade could change the rule):

node scripts/check-session-marker.mjs --prefix <dir with node_modules/@deepseek-ai>

One host half loads on every supported harness line; two features are scoped to the line that introduced the plane they need. Verified state, by feature:

Harness /trim automatic trim emergency trim /context-tune preset settings card
0.1.2-rc.1 (the pinned devDependency floor) ✅ ✅ ✅ ❌ clear error ❌ absent
0.1.5-rc.3 ✅ ✅ ✅ ❌ clear error ❌ absent
0.1.7-rc.2 (next) ✅ ✅ ✅ ✅ ✅ (schemastery ≥ 3.18.4)
0.2.0-rc.2 ✅ ✅ ✅ ✅ ✅ (two cards, one per row — needs 0.5.0+)
0.2.1-rc.2 ✅ ✅ ✅ ✅ ✅ (verified on 0.2.1-rc.2: 2 components running, both Configure controls present)

What the ❌ entries mean in practice:

  • Where auto-tuning actually applies (measured, not assumed). In a web profile the host plane does not receive the session agents' lifecycle events — the agents are created inside each session's preset realm — so neither the automatic retune nor the preset-definition sync fires there: a web profile logs the boot-time refusal and then nothing, however many sessions run. That is why web is a manual workflow: /context-tune preset inplace writes the tuning, /context-tune preset check tells you when a newly added route made it stale, and a fork (or a new session) applies it. The automatic paths remain meaningful for profiles whose host plane does own the agents (headless, TUI).

Auto-sync keeps the preset definition current, and check says what is missing. With autoTuneCompaction on, a web profile cannot retune a running session (compaction lives inside its preset realm), so the plugin does the one thing that plane allows: on the first new session it keeps the preset definition in sync — one write per preset id per process, never blocking a turn — which is what makes the next fork, new session, or restart already correct when a model is added to the profile. /context-tune preset check reports the gap directly: how many configured routes the current preset covers, and an explicit warning for any uncovered route at or below 64K, where inheriting the tuned top level would enable the pressure trigger — the configuration measured slower (15 -> 24 compactions).

To undo the tuning, run /context-tune reset. It removes the marker blocks this plugin wrote into the profile patch, so each preset falls back to its own definition. With no argument it reports and removes them all; with a preset id it removes only that one; add check to report without writing. It recognises its own rows by the marker rather than by matching a description, so another tool's blocks are listed and left alone. The preset lock still applies: like every other change here, a removal lands for NEW sessions, or for a session after a restart.

/context-tune rescue <id> brings a session back whose preset id disappeared. resolve() has no fallback, so a session bound to a preset that an upgrade (or a manual cleanup) removed can be neither resumed nor forked — and the lock plus assertPresetUnchanged mean it can never be re-pointed at a different id. The repair is to make the id resolvable again, which is also the moment to decide what it is: /context-tune rescue <id> clones a donor preset (--from <preset>, default standard) and, unless --untuned is given, applies the same tuning /context-tune preset inplace would (compaction trigger plus the derived pruner threshold). The report says out loud what the clone cannot restore: the session's history does not depend on the plugin list, but its future turns do, so a preset that mounted extra tools will not get them back from a donor. It refuses an id that still exists.

/context-tune preset inplace removes the picking step. Instead of declaring <base>-tuned it writes an override row for the base preset's own id (- id: preset-standard + a complete config:), which is exactly how the Web editor persists a preset edit. New sessions keep using standard and pick the tuning up with no picker interaction, and the same row doubles as the repair for a session whose custom preset disappeared in an upgrade — recreating the id is what makes that session loadable again (its history does not depend on the plugin list, only its future turns do). The cost is that the shipped preset's own plugin list is shadowed until the row is removed, and dsh overwrites the shipped copy on every upgrade.

A conversation that has already started gets the tuning when the process restarts. The preset id is sticky — it comes from the session's agentPreset projection, and assertPresetUnchanged refuses a different one — but the definition behind that id is re-resolved on every adopt: createOrAdopt and resumeObserved both call composeAgent(presetForObservation(observation)) and hand the fresh composition to agents.resume, and retain(id) returns the generation the current definition activated. So a session that was not running while you edited the preset picks the tuning up by simply being resumed — no fork involved.

A fork earns its place in exactly one case: the session is live when the definition changes. A live agent is bound to the generation it composed, and that binding keeps the old realm alive, so the edit cannot reach it — fork (or wait for the next restart). The fork has a new session id and is locked like any started session (it inherits the parent's turn/start events), which is harmless because it already runs the tuned values.

/context-tune preset writes the pruner too. It splices both rows of the preset it clones: compaction-basic's trigger and tool-result-pruner's thresholdChars, the latter from the same derivation auto-tune uses (max(8192, min(32768, 2 × (contextWindow − maxTokens))), smallest routed window wins). That matters in a web profile, where the host-plane rows are disabled and the preset is the only place the pruner can be configured — a generated preset that left thresholdChars at 8192 would keep clipping every whole-file read.

/context-tune preset needs the preset plane (agentPresets), the config-editor service and profileContext.patchPath. Every package behind those — dsh-agent-preset-registry, dsh-config-editor — first appears at 0.1.7-alpha.1, so an older harness gets "this profile composes no agent-preset registry" instead of a half-working command. Nothing else depends on them: the command lives in its own module, imported only when it runs.

  • The settings card needs a 0.1.7 web host (dsh-client-modules, dsh-client-ui-primitives) and schemastery 3.18.4 for .volatile(), which is what makes an entry a settings namespace at all. Two things keep an older harness harmless: the schema marks those fields through a capability probe, so the plugin still imports (an unconditional .volatile() call broke 0.1.5 outright until the compat matrix caught it), and the browser half returns immediately when configForms is missing rather than registering into services that do not exist.

Two harness changes are handled without any version check, because both are shape-probed from the session itself:

  • The replacement marker was renamed — {op: 'replace', start, end} became {op: 'replace', startSeq, endSeq}. The plugin probes a throwaway detached session with each known shape at first use and writes the accepted one.
  • The system prompt moved onto the surface — 0.1.5 carries it as system/message node 0 instead of header.system. System nodes are treated as barriers: they are never elided, no elided span crosses one, and protectHeadNodes counts only non-barrier nodes, so head protection keeps covering the task statement rather than the system prompt.

Because of the second change, the fixed request overhead is now the tool schemas plus any other non-surface request data; on 0.1.2 it also included the system prompt. A trim's budget itself is unaffected — it comes from the token meter's total, whichever way the harness splits that total.

The manifest declares the same four lines under dsh.compatibility.dshReleases (compatible for 0.1.2-rc.1, 0.1.5-rc.2, 0.1.5-rc.3 and 0.1.7-rc.2 — exactly the versions this table has evidence for; everything else stays undeclared, which a catalog reads as unknown rather than as support).

What the matrix proves, and what it does not: each CI leg installs that harness line's real packages and runs the suite against them (that is what caught the schemastery regression), so this is contract-level verification rather than a boot. Live instances have been booted on 0.1.5 (the dsh-container E2E below) and on 0.1.7 (the isolated-instance checks below); a booted 0.1.5 web host — and therefore the browser half on that line — has never been exercised.

The settings card

The Plugins page card shows effective values, not overrides: a field the deployment never set displays the default the plugin will actually use, because a blank control is how "why is my new model still untuned" becomes unanswerable. It is grouped into two sections — Compaction (trigger, summarization route, the two auto-tune switches) and Prune (the tool-result pruner threshold) — and the pruner is one mode select plus a number box that exists only in Custom:

Control Lands on the setting
Disabled 0 (leave the pruner alone)
Auto auto (derive from the routed window)
Custom the character count typed into the box

Auto tune compaction at runtime carries, in bold, the one caveat that matters: headless profile only — a web profile must use the /context-tune preset command (session agents live inside preset realms there and their lifecycle events never reach the host plane). The switches are the shell's own Switch primitive; the mode select is a plain <select>, because the primitives ship no select.

Configuration

Override on the context-trim row of a profile patch (the bundle's own cordis.patch.yml lists the full default set):

Key Default Meaning
targetRatio 0.9 budget = floor((contextWindow - reserve) * targetRatio), where reserve = max(reserveOutputTokens, the route's own output allowance)
reserveOutputTokens 8192 Output space kept for the model's own reply. This is a floor: the route's own allowance (the request's maxTokens, or the adapter's defaultMaxTokens) is reserved in full on top of it, because a provider rejects a request whose prompt plus its allowance exceeds the window — a 32k server with maxTokens: 16384 exhausts the window with prompt + output even when every prompt fits
retainRatio / retainTokens 0.16 / — Recent tail kept verbatim (mutually exclusive forms)
minTailTokens 2048 Absolute floor for that tail
protectHeadNodes 1 Leading nodes that are never trimmed
allowTailTrim true Enable tiers 2–3 (reach into the retained tail; as a last resort include the final message). false ends the search after tier 1, making the retained tail a hard boundary
markerSlackTokens 64 Slack added to the priced marker so the post-trim request stays under budget
autoTrim true Trim automatically on CONTEXT_WINDOW_EXCEEDED; never fires on ordinary compaction
maxAutoTrimRetries 3 Automatic trims allowed per overflow episode before compaction takes over
autoTrimShrink 0.5 After a repeat overflow, retarget to this fraction of the rejected request (geometric descent when the declared window is wrong)
preferInPlacePrune true Slim oversized tool results in place before planning any span; uses the official pruner when reachable
compactionTargetRatio 0.8 Trigger fraction written into a preset generated by /context-tune preset (shapes the preset only, never this plugin's trimming)
compactionRoute unset Optional {provider, model} pair for the generated preset's summarization call
reasoningBudget 0 (DSH_TRIM_REASONING_BUDGET) Tokens reserved in headroom for reasoning/thinking models. Floored into headroomTokens = max(reasoningBudget, ratioHeadroom) so thinking models retain space for thoughts before compaction fires
prunerThresholdChars auto (DSH_TRIM_PRUNER) The tool-result pruner's clip threshold. auto derives it per route as max(8192, min(32768, 2 × (contextWindow − maxTokens))), smallest routed window wins, so one whole-file read is not clipped away on a small window (dsh's stock value is 8192). An integer overrides it; 0 opts out and leaves the pruner alone. Same plane as the compaction row, so the same reachability rule applies — a web profile keeps the pruner inside each session's preset and the plugin reports that instead of writing. On a small window this is the lever that matters (see When it does not pay); with a pressure trigger enabled it makes prompts bigger, so think twice there
autoTuneCompaction false Retune this process's compaction row (only where compaction is on the profile plane). A web or production profile should set this (via its settings card or a patch layer); the DSH_TRIM_AUTO_TUNE environment variable that overrides it is an automation/CI switch only
pruneThresholdChars / pruneHeadChars / pruneTailChars 8192 / 4096 / 1024 In-place slim budgets, mirroring DSH's own pruner defaults

Permissions and failure bounds

What the plugin touches, stated so a reviewer does not have to infer it:

Capability State Detail
Files yes, deliberately /context-tune preset writes one file: the profile patch ($DSH_HOME/profiles/<profile>/cordis.patch.yml). It writes atomically (temp file + rename), keeps one rolling backup (…bak-trim-preset), and confines its own content to a marker-delimited block it can replace. lib/preset-tune.js holds the runtime's only node:fs import. Trimming itself touches no file: it edits the in-memory session surface through the documented surfaceOp mechanism.
Network no No socket, no HTTP client, no fetch. /context-tune preset asks the host's llm service for the configured routes (listModels / resolveModelInfo), which resolves from local metadata; the plugin itself never opens a connection.
Command no No child process, no shell, no exec/spawn.
Credentials no No key, token or credential is read, logged or forwarded. Provider credentials stay with the llm-pi-ai row. The runtime reads exactly one environment variable, DSH_TRIM_AUTO_TUNE, as an opt-in boolean feature flag.
Protected DSH behaviour no It never disables, replaces or shadows an official component. A generated preset is a clone of the preset in use with only the compaction group's config replaced — a new preset id, the original left untouched.
Diagnostics one stderr line and one session record per retune The cordis logger only reaches a user when a profile wires an exporter (a headless profile does not), so a retune — rare, and a behaviour change — also prints one line to stderr and appends one context-trim/tuned non-surface session record for analysis. Nothing per request, and never anything in the model's context or the token bill.
Test fixtures fixtures/, not shipped fixtures/mock-overflow-server.mjs and test/compat/mock-llm.py bind 127.0.0.1 for tests only. They are outside lib/, are never loaded at runtime, and are excluded from the published files list.

Failure bounds:

  • trimming is model-free and additive: it shadows context with one marker message and never summarizes;
  • when nothing can be freed it declines and compaction proceeds — it never forces a partial trim;
  • the automatic path is bounded: maxAutoTrimRetries attempts, one emergency trim per overflow episode, every decision logged;
  • a /context-tune preset write that fails leaves the previous file intact (temp + rename) and reports the error instead of half-writing;
  • the plugin never throws out of the overflow listener: a failure to trim is reported and the turn continues on the existing path.

Limits

  • It drops content; it does not summarize. A trimmed detail is gone from the model's view (still in the log). When you want a summary instead, use /compact; the two complement each other (/trim first makes a later /compact cheaper and more likely to succeed).
  • It cannot shrink the fixed envelope. System prompt and tool schemas are outside the surface; if they alone exceed the budget, /trim says so instead of pretending.
  • Nothing is restored. There is no /untrim in v1: re-inserting earlier content as surface nodes would need assistant/tool events, which are only legal inside an open turn.
  • Heuristic pricing, calibrated against the provider. Budgets use the token meter's own estimate — the same numbers /compact and the GUI context bar use — corrected by the factor the plugin has learned for that route (see Tokenizer calibration). Before a route has been measured, the numbers are the meter's estimate and can understate the real request by ~20 %.

Tuning compaction's threshold (/context-tune preset)

DSH decides when to compact from thresholdTokens = min(contextWindow × thresholdRatio, messageBudget − headroomTokens) with headroomTokens defaulting to 65536. That default, not the ratio, decides the trigger on any window below ~370k: a 131072-token route compacts at 37.5 %, not 80 %. The ratio cannot move it alone, and compaction's policy is read at composition time inside a preset isolate realm, so a plugin cannot retune it at runtime.

/context-tune preset therefore writes a preset:

/context-tune preset          # generate + land a tuned preset from the configured routes
/context-tune preset inplace  # override the base preset's own id instead of adding an id (see below)
/context-tune preset check    # print the generated row + route coverage, write nothing
/context-tune preset list     # the routes a generated preset would cover, and the trigger each gets
/context-tune preset default  # also make the generated preset the default for NEW sessions
/context-tune preset p:m      # ... using p:m as the summarization route for this run
/context-tune rescue <id>     # recreate a preset id that went missing (--from <donor>, --untuned)
/context-tune reset [check] [preset-id]  # remove the tuning rows this plugin wrote

/context-tune preset check is the staleness signal: it counts the routes the current preset covers and warns about an uncovered route at or below a 64K message budget, the one case where inheriting the tuned top level enables the pressure trigger instead of leaving it at stock. Add a model to the profile, run check, and re-run /context-tune preset inplace when it reports a gap:

coverage: 4 route(s) configured, 3 covered by this preset, 1 not covered.
⚠ uncovered SMALL route bonsai-8gb//models/mtp-lean.gguf (40960 − 8192 ≤ 65536): without a policy it inherits the
  tuned top level, which enables the pressure trigger on a route where that measured slower. Re-run /context-tune preset inplace…

It clones the preset the session is using (via agentPresets.readDocument, so the clone always matches the installed dsh), replaces only compaction-basic's config, and appends a marker-delimited row to the profile patch:

# >>> dsh-command-context-trim: preset-standard-tuned (generated; delete this block to drop the preset) >>>
- insert:
    - id: preset-standard-tuned
      name: '@deepseek-ai/dsh-agent-preset'
      config:
        id: standard-tuned
        order: 1.5
        plugins: …            # the cloned composition
# <<< dsh-command-context-trim: preset-standard-tuned <<<

The generated compaction-basic config carries a per-route modelPolicies entry with the headroom that lets compactionTargetRatio decide, and an explicit maxTokens (headroom would otherwise supply it). Routes whose output reserve makes the target unreachable are reported as capped rather than silently written. Then pick the new preset on the New Session screen — if it is not listed yet, restart dsh (a patchReload: startup profile ignores patch edits until boot). A session's preset is locked once its first turn starts (agent-preset/locked from the registry), because switching one recomposes the preset's whole isolate realm: an already-running session cannot switch, which is why /context-tune preset default exists — it sets the default for every new session instead. That write is guarded: the command waits (bounded, ~2.5 s) for the generated preset to appear as a healthy registration before pointing the default at it, because the new-session path resolves the default and an unknown id would fail there.

scripts/verify-preset-artifact.mjs checks the whole thing against a real installation without writing anything.

The route field is free text rather than a dropdown: dsh 0.1.7's shared settings form exposes only settingsNumberField and settingsTextField (see @deepseek-ai/dsh-client-ui-primitives' typed surface), and drawing a custom control would step outside the staged-form contract — the frame, the override/reset semantics and the fenced write all come from the shared form. /context-tune preset list is the inventory view instead: it prints every routable provider/model with its window, output reserve and the trigger it would get.

Both keys are also editable in the GUI: Plugins → Context trim shows a card with the compaction trigger and the summarization route. Only fields marked .volatile() appear there, and a volatile field arrives at the plugin as a live handle, so every read goes through readConfig and an edit takes effect on the next invocation without a reload.

Retuning the live process (/context-tune tune)

Where compaction is composed on the profile plane — anything built on dsh-base, i.e. headless and tui — its threshold is an ordinary row's config, and cordis applies a config change by restarting the fiber (Fiber.update() resolves the new config and calls restart(), i.e. dispose plus a fresh apply; there is no "hot-reloadable only" restriction). So the tuning can be written at runtime:

/context-tune tune            # retune this process's compaction row from the routes it can see, then report
/context-tune tune check      # print the diff, write nothing

The routes come from the adapter (ctx.llm.resolveModelInfo) — the same source compaction itself uses — so a profile that claims 128k while the backend really serves 40k is corrected rather than trusted. The write is skipped when nothing changes, and it is refused with an explanation where it cannot work: in a web profile compaction lives inside each session's agent-preset isolate realm (ctx.get('compaction') is empty from the host plane), and a session's preset cannot change once it has started — tune the preset there instead (see /context-tune preset).

autoTuneCompaction: true (off by default) does that automatically, and the trigger set was reviewed rather than accumulated: the agent's creation (agent/created, the earliest point and the only one a one-shot run is guaranteed to reach), every request before it is sent (agent/request — the guarantee that a route change is applied before the request that would use it), a session's model/selection (the precise "the route changed" signal, available before the next request rather than only inside it), and an idle moment in a long-lived profile — the one trigger that takes the write outside a step entirely. A system-prompt insertion is deliberately not a trigger: it is committed even earlier in the same step (the surface is built before the request hook), so it adds a second recompute for that step while still needing a guess at the agent — the per-request hook already covers the moment. The write is immediate and intentionally not awaited: a one-shot dsh headless "task" never reaches an idle moment, and that is exactly where automated sessions live. It is safe early — the first trigger fires before anything can be compacting — and idempotent, so an unchanged route writes nothing and a per-request re-check costs no restarts. A write does restart the compaction row, so a later write can cancel a compaction that is in flight; that is the one trade-off, and the log says so.

The pruner is the other half of a small window. /context-tune tune also writes tool-result-pruner when prunerThresholdChars is set (--pruner-threshold-chars for the generator), on the same profile plane and with the same guard: where the pruner lives inside a session preset — a web profile — the plugin says so and writes nothing. Raising it means a read of a whole source file survives instead of being clipped to a head and a tail, which is what breaks the re-read loop; on a route whose pressure trigger is enabled it also makes each request bigger, so it is the right lever for the small-window case and a deliberate trade elsewhere.

Automation only: DSH_TRIM_AUTO_TUNE, DSH_TRIM_PRUNER. The environment variable (1/0, true/false, yes/no, on/off; empty means unset) switches the automatic retune on or off for one process, overriding the profile's own autoTuneCompaction. It exists so a container, a CI harness or a benchmark script can enable the behaviour without editing a profile. A normal web or production profile should not set it: the override would silently win over the profile's setting, and in a web profile a runtime retune has nowhere to land anyway — compaction there lives inside each session's agent-preset isolate realm. Configure a real profile through its settings card, or through a patch layer, instead. (A refusal like that is reported once, not once per request, so a stray variable in the wrong environment cannot flood a service log.)

Automating a tuned threshold (headless runs)

A headless profile composes compaction itself — its tree carries compaction-basic, command-compact and tool-result-pruner on the profile plane — and it never resolves a session preset; only the session API, the web client and the agent-preset row do. Measured in one clean DSH_HOME: with a generated preset plus agent-preset-registry.selectedDefault, and then with that preset's tool-web row deleted as a control, the first request carried the same 24 tools every time — the preset rows sat in the tree unused.

So automated runs take the other route: write the tuning onto the profile's own compaction row.

The window and output reserve differ per backend, so the generator resolves them from the best source available: an explicit --routes list or a --dump of the composed profile (its llm-pi-ai provider table carries contextWindow/maxTokens per model) as the base, with --context-window/--max-tokens/ --model as an override for the numbers a backend really serves. Nothing usable in any of them is an error rather than a guess; --window-agnostic is the deliberate ratio-only overlay (which needs no window, because threshold = min(r·W, W − R) is relative by construction).

DSH_HOME=$DSH_HOME dsh --profile headless --patch /path/to/route.yml --dump-config > /tmp/dump.yml
node scripts/make-preset-patch.mjs --mode host --ratio 0.8 --dump /tmp/dump.yml --out /tmp/host.yml
DSH_HOME=$DSH_HOME dsh --profile headless --patch /tmp/host.yml "do the task"

Step-by-step for a fresh environment, written to be handed to another session: docs/headless-compaction-tuning.md. fixtures/headless-tuned-preset/run-headless-check.sh performs that drill against a fresh temporary home, asserts --dump-config carries the tuned config on compaction-basic, and runs one real session. Its README records what the drill does not assert, and why: the New Session picker label reflects a client/host draft rather than the default, and a session created with the default carries no agent-preset/selected event, so neither is usable as an automated assertion.

Reading the session log

compaction/prune has two producers, and only one of them is this plugin:

Producer Where Followed by Shape
this plugin, span elision inside a step, right after a failed attempt user/message whose source.plugin is dsh-command-context-trim a whole span, both cut edges tool-pairing balanced
this plugin, in-place slim same place, before any span is planned tool/result replacing exactly one node one tool/result, marker [... tool result middle trimmed to fit the context window ...], callId kept
DSH's tool-result pruner in compaction's own path tool/result replacing exactly one node a single tool/result, its tool/call kept

Position is the other tell: a prune between step/end and step/start belongs to compaction's pressure path (and this plugin, being overflow-only, is deliberately not involved); a prune inside a step, after an assistant/attempt is an overflow recovery. A session with no assistant/attempt events never hit CONTEXT_WINDOW_EXCEEDED at all, so this plugin never ran in it.

Two accounting traps when checking whether a trim helped: assistant/message usage is per request (input + cacheRead + output for that call), not a session total — and a tool result is appended after the request that produced its tool call, so comparing consecutive usage.total values measures "content was added", not "the trim freed nothing". Compare the rejected attempt with the retry instead. Also, an in-place replacement of the last node keeps the cached prefix (so cacheRead stays high); only a mid-conversation cut — this plugin, or compaction rewriting the head — breaks it, which shows up as cacheRead dropping and input jumping on the next call.

Development

It lives in fixtures/ rather than under test/ on purpose: node --test executes every JavaScript file under test/, so a server placed there would hang the suite.

End-to-end overflow check (no model needed)

fixtures/mock-overflow-server.mjs is a stateful OpenAI-compatible endpoint that enforces a real limit lower than the contextWindow the harness is told, and answers the first TOOL_STEPS requests with a tool call so one turn keeps looping and grows past the real limit — the context wall, without a model switch:

node fixtures/mock-overflow-server.mjs &            # PORT=4185 TOKEN_LIMIT=12000 TOOL_STEPS=4
# point an ISOLATED profile at it (provider with contextWindow 20000, baseURL .../v1), then:
DSH_HOME=$(mktemp -d) dsh --profile headless "..."   # see the isolated-home procedure in dsh-plugin-packaging

A passing run leaves this in the session log: assistant/attempt (the wall), then exactly one compaction/prune + one user/message replacement, then a succeeding retry — and zero compaction/start, proving the request was repaired by trimming and that summarisation never ran.

npm install            # the harness contracts this plugin builds on, pinned as devDependencies
npm test               # node --test (all 173 unit tests)
npm run build:client   # assemble modular sources under src/client/ into lib/client.js
npm run link:harness   # or resolve @deepseek-ai from a local dsh installation instead of npm

Client Bundle Workflow (src/client/ → lib/client.js)

The browser settings cards are authored as modular source files under src/client/:

  • constants.js: Field definitions (TRIM_FIELDS, TUNE_FIELDS), defaults, namespaces.
  • locales.js: English and Chinese i18n dictionaries and label lookup helpers.
  • helpers.js: Pure helpers (asBoolean, effective, parseEdit, displayText).
  • form.js: Stateful React controller class (TrimForm).
  • components.js: Card views (TrimCard, trimCardBody, switch controls).
  • lifecycle.js: Slot registration and Cordis page lifecycle.

Do not edit lib/client.js directly. Edit src/client/ and run:

npm run build:client

The test suite (test/client-bundle.test.js) verifies that lib/client.js is byte-identical to src/client/ assembly; any unbuilt changes or manual edits fail npm test immediately.

Tests cover the pure planner and argument parser, the surface mutation against a real Session (including log replay), the plugin's command registration and end-to-end trim over a stub context, and — against the real ctx.tokenMeter — that a trim's measured saving equals the shadow price it claims and that a fresh meter replaying the trimmed log lands on the very same total. CI runs the suite on Node 22 and 24; releases go out through .github/workflows/publish.yml, which is manual-only (workflow_dispatch).

Verification status

Check State
npm test (149 tests: planner, args, surface apply + log replay, plugin handler, automatic overflow path, in-place slim, config, preset modes incl. inplace/rescue, coverage report, sync reporting) ✅ passing
Isolated DSH_HOME install (dsh plugin add file:…) reconciling dependency and bundle layer ✅ verified
Composed profile tree contains the context-trim insert row (dsh --dump-config) ✅ verified
Profile boot with the plugin mounted (no load error) ✅ reaches the model-provider check cleanly
Same suite against the pinned published harness packages (npm ci) ✅ 103 passing
Integration against the real ctx.tokenMeter: measured drop equals the claimed shadow price, and a fresh meter replaying the trimmed log reaches the identical total ✅ 4 tests
Real-cordis proof that a prepended waterfall listener runs first and vetoes the chain (the mechanism the automatic path depends on) ✅ 3 tests
Same suite on 0.1.5-rc.3 (renamed replacement marker + surface system prompt) ✅ passing
Same suite on 0.1.7-rc.2 (flat tool-result messages, session.deriveEventMessage) ✅ passing
Same suite with schemastery 3.18.2 forced in, the version that broke the 0.1.5 leg (capability probe) ✅ passing
scripts/verify-preset-artifact.mjs: the generated preset row, cloned from the shipped standard preset and parsed by the harness's own YAML stack ✅ 19 plugin entries, isolate realm intact, 3 model policies
Isolated 0.1.7 instance (start-isolated-dsh.sh; own DSH_HOME, port 3091, mock provider): a generated row appended to the profile patch appears in the preset menu as Standard (tuned 75%) and is selectable — no restart, no runtime registration ✅ verified
Same instance: the Plugins page lists the card under Official with its summary line, both fields render, a staged edit saves, and the value lands as a context-trim row in the profile patch ✅ verified
Headless client-bundle test (fake window.__ModuleLoader__): summary one-liner, two-field form body, older-host no-op ✅ 3 tests
End-to-end in the real dsh-container image (0.1.5-rc.2, isolated home, mock backend): span path (33,373 → 18,431 tokens, compaction/start = 0) and in-place slim path (23,429 → 19,995 tokens, no span elided, pruner delegated to the official service) ✅ both verified
/context-tune preset inplace in an isolated 0.1.7 instance: one bare override row for preset-standard (no insert:, no new id), config.id: standard, registry accepts it and the picker keeps the same preset ✅ verified
Same instance: the derived pruner threshold lands in that row (thresholdChars: 32768, stock 8192 gone) and a re-run is idempotent, with no stacked name suffix ✅ verified
/context-tune rescue <id> in an isolated instance: a bare override row for the missing id whose config.id matches it, tuned by default, refusing an id that still exists ✅ verified
A live web instance logs the boot-time compaction is not reachable from this plane and then nothing per session — session agents are created inside preset realms, so their lifecycle events never reach the host plane ✅ measured (see Which route to use)
Bonsai 2 headless re-run with DSH_TRIM_AUTO_TUNE=1 (session-2577e98b…, 0.102): the tuned record carries thresholdChars: 32768 and compaction/prune dropped 17 → 2 against the earlier 0.3.2 run ✅ before/after verified
CI workflow (Node 22 / 24) ✅ green
npm release via GitHub Actions ✅ 0.1.0 published with provenance (+ dsh-command-context-trim@0.1.0)
Isolated profile install from the npm registry (dependency + bundle layer + composed insert row) ✅ 0.1.0
End-to-end in a real 0.1.7 boot (isolated home, mock backend): a request REFUSED at 10,664 tokens came back ACCEPTED at 7,230 after the automatic trim, with compaction/start = 0 ✅ verified
Tokenizer calibration, measured on a production session (2 805 requests, 5 routes, replayed through the token meter's own fold): the provider/meter ratio per route ✅ 1.013–1.324, per-route medians 1.16–1.32
Calibration unit tests: p90-not-mean, the floor at 1.0, the 3-sample bootstrap, the sample window, the route key, the envelope price, a corrupt snapshot ignored ✅ 17 tests
Calibration integration against the real dsh-token-meter: a session whose real prompt is over the window is trimmed where the uncalibrated planner reports a fit; an anchored request is never priced below what the provider already charged; the learned factor recovers the density the provider charged (2.400 ± 0.05); the table survives a restart; the repeat-overflow ceiling is derived from the real failing size; the budget reserves the route's output allowance ✅ 8 tests
Output reserve: the budget reserves the route's own allowance (the request's maxTokens, else the adapter's defaultMaxTokens) on top of the configured floor, and the trim's report names it ✅ 4 tests
Truncation hint: a length stop is reported with the request size and the window, the over-budget and within-budget causes are told apart, one hint per episode, nothing is ever trimmed, the append survives a real SessionStore commit boundary, and the provider's own count wins over the plugin's prior ✅ 7 tests
Calibration end to end in an isolated dsh 0.2.0-rc.2 (own DSH_HOME, port 3091, mock provider whose tokenizer is twice as dense as dsh's assumption): the factor is learned from the provider's usage and persisted to <profile>/context-trim-calibration.json ✅ learned 2.015–2.052 against a mock density of 2.0
Same instance, the decisive A/B — the same session and the same window, with and without the learned factor ✅ uncalibrated: "Already within budget: ~6791 / 11527 … Nothing to trim"; calibrated: "frees ~0 of the ~2408 tokens needed" (13 896 real tokens, 6 791 estimated)

License

MIT


The arithmetic

Every number this plugin computes, with the defaults it starts from. Token-to-character work uses dsh's own CHARS_PER_TOKEN = 4 (@deepseek-ai/dsh-token-meter).

1. The trim budget (what /trim fits the surface into)

allowance = max(route.defaultMaxTokens,        # the route's own output allowance
                the logged header's maxTokens) # the allowance this session actually asks for
reserve  = max(reserveOutputTokens, allowance) # reserveOutputTokens: 8192, a floor
usable   = contextWindow − reserve
budget   = max(1, floor(usable × targetRatio)) # targetRatio: 0.9
retain   = max(minTailTokens,                          # minTailTokens: 2048
              floor(contextWindow × retainRatio))      # retainRatio: 0.16

The reserve is the route's whole output allowance, not a guess: a request plus the output it may produce has to fit the window, or the provider refuses the request (context window exceeds limit) — and a reasoning model spends that allowance before it writes anything.

/trim <budget> (e.g. /trim 32k) replaces budget outright; budgetCeiling then takes min(budget, ceiling) when the automatic path has to come in under a specific number.

2. What the planner compares

surfaceTokens  = Σ node.heuristicTokens                       # the token meter's price per surface node
envelopeTokens = measurement.totalTokens − measurement.surfaceTokens   # tool schemas and other fixed request data
totalTokens    = envelopeTokens + surfaceTokens
fits           ⇔ totalTokens ≤ budget
need           = totalTokens − budget
projectedTotal = totalTokens − freedTokens                    # after eliding a span

An elided span is replaced by one marker whose price is markerTokens + markerSlackTokens (markerSlackTokens: 64), and the planner re-runs once when the marker's real numbers make it more expensive than the provisional estimate. Leading nodes are protected by protectHeadNodes (1), the newest human prompt is never elided, and tool-call/result pairs are only cut where the pairing stays balanced (allowTailTrim: true).

3. The compaction trigger (what auto-tune and /context-tune preset write)

Mirrors @deepseek-ai/dsh-compaction-basic's resolveCompactSpec as read from dsh 0.1.7-rc.2:

reservedCompletion = the routed request's maxTokens      # the output reserve, read from request/header
messageBudget      = contextWindow − reservedCompletion
pressureBudget     = messageBudget − headroomTokens      # dsh's stock headroomTokens: 65536
thresholdTokens    = floor(min(contextWindow × thresholdRatio, pressureBudget))
retainTokens       = retainTokens ?? floor(messageBudget × retainRatio)   # compaction's own retainRatio: 0.16

Two consequences, both of which the tuner exists to handle:

  • thresholdRatio alone does not decide the trigger. The headroom term does whenever headroomTokens > messageBudget − contextWindow × thresholdRatio, so a 131072-token route with dsh's stock 65536 compacts at 37.5 %, not the configured 80 %.
  • headroomTokens doubles as the default maxTokens for compaction's summarization call, so a generated preset states maxTokens explicitly.

The headroom that lets the ratio decide, and the gate it produces:

headroomForRatio = max(0, messageBudget − floor(contextWindow × thresholdRatio))
tunedGate        = floor(min(contextWindow × thresholdRatio, messageBudget − headroomForRatio))
                 = floor(contextWindow × thresholdRatio)        # whenever headroomForRatio > 0

4. The tool-result pruner threshold (derived by auto-tune since 0.3.5)

prunerThresholdChars = max(8192, min(32768, 2 × (contextWindow − maxTokens)))

i.e. one tool result may hold up to half the route's message budget, capped at 32 KB and floored at dsh's stock 8192 so it can never clip more aggressively than stock does. With several routes the smallest value wins. 'auto' (the default) derives it, 0 leaves the pruner alone, a positive integer overrides it, and DSH_TRIM_PRUNER=auto|0|<n> is the environment form. The plugin's own in-place slim (preferInPlacePrune: true) delegates to dsh's pruner service, so this one value governs both paths; when that service is absent it uses pruneThresholdChars: 8192 with pruneHeadChars: 4096 / pruneTailChars: 1024 itself.

5. Worked examples

Route W maxTokens stock gate tuned gate pruner threshold
Bonsai 2 (small) 40,960 8,192 none (pressureBudget −32,768) none — left at stock max(8192, min(32768, 2×32768)) = 32,768
Bonsai 2 (first tuned run) 40,960 16,384 none (pressureBudget −40,960) none — left at stock 32,768
Our 128K route 131,072 16,384 floor(min(104857, 49152)) = 49,152 (37.5 %) 104,857 (80 %) 32,768
deepseek-official 1,000,000 256,000 floor(min(800000, 678464)) = 678,464 (67.8 %) 744,000 (74.4 %) 32,768

6. Automatic trimming

autoTrim: true retries a failed request by shrinking its budget: nextCeiling = max(1, floor(failingTotal × (1 − autoTrimShrink))) with autoTrimShrink: 0.5 and at most maxAutoTrimRetries: 3 attempts before it gives up and reports what it measured.

failingTotal is the real size of the request that was just rejected — the meter's estimate corrected by the route's calibration factor. A ceiling derived from the estimate would be a ceiling on a number the backend never saw, and the ladder would keep aiming at a target that is still over the real window.

7. The calibration factor

realPrompt = usage.inputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
estPrompt  = surface before the assistant message  +  estimateEnvelopeTokens(header)
factor     = clamp(p90(samples of realPrompt / estPrompt), ≥ 1.0)      # per provider:model route
  • Samples: one per completed request, kept in a window of the last 50, per route.
  • Minimum: 3 samples, otherwise the factor is 1.
  • p90, not the mean: a run of log-heavy requests moves the factor; a single one does not.
  • Never below 1.0: over-counting is not a reason to discard context.
  • The envelope is the tool schemas' own price: ceil(JSON.stringify(header.tools).length / 4) + 4.

The factor multiplies the meter's per-node prices, so the planner's need (real) and freedTokens (real) are one unit, and the request total the plugin reports is the request's real size.

About

Model-free /trim command for DeepSeek Harness — drop the oldest, least valuable span of context so a session can keep running on a smaller-window model, without any model call.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages