Skip to content

dsh-cache-guard

Verified

dsh-cache-guard ยท v0.2.0 ยท MIT ยท Web UI

Host- and agent-layer DeepSeek Harness plugin that prices every automatic context rewrite before it lands, asks the human by default, and reports what the provider had to re-read cold.

Install

dsh plugin add dsh-cache-guard

Confirm the layer applied with dsh --profile default --dump-config โ€” see the install guide.

Source

Tags

Readme

๐ŸงŠ dsh-cache-guard

Host- and agent-layer DeepSeek Harness (DSH) plugin that prices every automatic context rewrite before it lands, asks the human by default, and reports what the provider had to re-read cold.

npm version CI Node.js License: MIT GitHub

๐Ÿ“‹ Table of Contents

What It Prevents

A provider reuses its cached request prefix only up to the last unchanged token. Two automatic operations rewrite the model-visible surface, and both therefore re-bill the retained context at full price โ€” without anyone being asked:

Operation What it rewrites Why it is expensive
Tool-result pruning (dsh-compaction-tool-result-pruner) Oversized tool results, in place It starts at the oldest candidate, so everything after it becomes a cache miss
Summarization (dsh-compaction-basic) An older span, replaced by one checkpoint The checkpoint invalidates from the replaced position, and the summary itself is a full-prefix model call

Measured on a real session (deepseek-v4.1-flash, 1M window): the pruner rewrote 28 tool results at once, freed 93k tokens, and the very next request re-read 720,764 tokens at full price instead of 16,768 โ€” $0.11 for one request, three steps before that session ended.

dsh-cache-guard sits in front of both operations, prices them first, and asks.

What It Shows

A dialog before the change, with the numbers that decide it:

Context rewrite
An automatic context rewrite is ready. Allow it?
  Context: 839k of 1.05M (rewrite at 839k)
  Prunes 28 old tool results: 93k freed
  Then summarizes: ~4k checkpoint (estimated) instead of 746k
  Breaks the cache at position 0: 255k re-read in full (95% of the request)
  [ Allow once ]  [ Not now ]  [ Always allow (this session) ]

And a chip in the composer's tool row โ€” inside the input card, right after the access-mode chip, built with the same geometry as its siblings (28px pill, design-system glyph, chevron that rotates while open). It carries the mode only; clicking it opens a small menu with the numbers. The glyph follows the mode โ€” a question mark while the guard asks, a check while it runs unprompted โ€” and the label collapses to glyph + chevron in a narrow composer, exactly as the neighbouring chips do.

[ (?) Cache: ask โŒ„ ]    โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
                        โ”‚ Context 837k of 1.05M ยท rewrite at 839k    โ”‚
                        โ”‚ last rewrite: 28 tool results pruned ยท     โ”‚
                        โ”‚ 724k re-read in full (declined, nothing    โ”‚
                        โ”‚ changed)                                   โ”‚
                        โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚
                        โ”‚ (?) Ask before every rewrite           โœ“   โ”‚
                        โ”‚     Pruning and summarizing wait for your  โ”‚
                        โ”‚     approval.                              โ”‚
                        โ”‚ (โœ“) Allow automatically                    โ”‚
                        โ”‚     Runs without asking; its cost shows up โ”‚
                        โ”‚     here afterwards.                       โ”‚
                        โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

Its menu rows follow the harness Menu: a leading 16px glyph, the label with its hint, and a trailing check for the selected row โ€” the selection is a check, never a colour fill, exactly as in the neighbouring menus.

The long sentence also sits in the chip's tooltip, so hovering shows it without opening the menu. The host keeps the chosen mode per session.

Where It Is Active

Two halves with different reach:

Half Lives in Reach
host the profile bundle every session: the pill, its menu, and the mode endpoints
engine the agent preset, inside the compaction group only sessions composed on the guarded preset: the veto, the dialog, and the live numbers

A session composed on a preset without the engine row โ€” the shipped standard, for example โ€” compacts and prunes exactly as before, and the pill has nothing to report there. That is why the installer makes the guarded preset the default: new sessions are guarded without picking anything, and the composition they run is still the shipped one. Sessions that started earlier keep the composition they began with.

How It Intercepts

Both operations run through one method โ€” compactIfNeeded on the live compaction engine. Pruning is the first phase inside that call, summarization the second, and the provider-overflow recovery path enters the same method. compaction-basic registers its listeners with this bound to the engine instance and calls this.compactIfNeeded(...) internally, so replacing the instance method intercepts both paths. Wrapping the compaction service would not.

The guard therefore:

  1. prices the pending plan before anything is touched โ€” which tool results are over the pruner's budget (the real pruner's own pruneContent decides, without mutating), which span a summarization would replace, and how many tokens break the cache at which surface position;
  2. asks (manual mode) or reports (auto mode);
  3. runs the operation itself on accept: the engine's public compactRegion transaction first, then the pruner. On decline โ€” or when there is nothing to decide โ€” it returns null, which the engine reads as nothing to compact.

The guard never calls the original method. That is what keeps the engine from running its own two-phase pass behind the guard's back, and it is why the phases can be paired: the checkpoint lands at the oldest replaced position, so every pruning rewrite that follows sits behind that cache break and adds nothing to it. The reverse order would pay the break twice whenever the summary lands anyway, and would leave a pruned surface behind if the range were rejected. The engine's transaction is atomic, so a rejected range throws before anything is written.

Nothing is imported from the compaction packages and no engine code is changed: the guard reads the engine's public config, calls its public compactRegion, and restores the original method when it unloads. Everything the harness owns around the operation โ€” the transaction, the event lifecycle, the persistence, and the retry accounting in the calling listeners โ€” stays the harness's.

Pruning and summarizing are one operation

The engine's own pass tries the model-free phase first and summarizes only if the surface is still too large. A pass that stops there has a bad price: pruning rewrites in place, so the surface stays almost as large (measured: 839k โ†’ 737k) and the next request pays the cache break at nearly full size โ€” 720,764 tokens re-read cold, for a pass that freed 93k and needed 186.

The guard always plans both. The same session, priced with the span the guard would replace, comes to 255k cold: the surface drops to the retention tail plus a checkpoint, and the cache break is paid once.

Guarantees

  • A guard bug never blocks the engine. If pricing throws, the original call runs and the failure is logged.
  • Fail closed on silence. With the mode set to manual and no question provider registered, the automatic rewrite is declined and the log says how to allow it (mode: auto).
  • No repeat nagging. A decline mutes the guard for the rest of the current turn instead of asking at every step.
  • No new session event types. The bundle adds no SessionEventMap member: an event type this build does not know would make the session log unreadable to the very build that wrote it. The client surface derives everything from existing events and the guard's own state.
  • Zero runtime dependencies. The package imports nothing outside itself, so it cannot bind to a second copy of a harness class.

Installation

dsh plugin --profile web add file:C:/path/to/plugins/dsh-cache-guard

The engine half must be mounted inside the preset's compaction group โ€” that group isolates the compaction and toolResultPruner services, so a row outside it cannot see the engine it has to wrap. The installer writes a preset that includes the shipped composition and patches that one row in:

node tools/install-profile.mjs --profile web --preset cache-guard
# ~/.dsh/.agent-presets/cache-guard/agent.cordis.yml
- id: base
  name: 'cordis:include'
  config:
    path: 'file:///<dsh>/config/agent-presets/standard/agent.cordis.yml'
    patches:
      - id: compaction
        insert:
          - id: cache-guard
            name: 'file:///<you>/.dsh/profiles/web/node_modules/dsh-cache-guard/engine.js'
            config:
              mode: manual

cordis:include applies patches to the entries it reads, and an insert patch carrying an id pushes its rows into that group's child list. The preset is therefore the shipped composition plus one row, not a copy: a harness update to the shipped preset applies here too. The same file shape works for code, cordis, or a preset of your own โ€” change --source.

A preset is chosen when a session starts, so a running session keeps the composition it began with: restart the harness and start a new session, picking cache-guard in the preset chip. The log then shows dsh-cache-guard: engine guarded (mode manual), and the pill's menu shows the live context line.

Profiles

The preset roots and the settings document belong to the harness home, not to one profile, so:

  • The guarded preset is available to every profile on that machine, including custom ones.
  • The engine half is self-contained (it resolves every dependency from its own directory), so one installed copy serves every profile โ€” the preset row can point at any profile's node_modules, and the others still load it.
  • The pill and its mode menu need the host half in that profile: run the installer once per profile that should have the UI (--profile studio, โ€ฆ). Without it the guard still asks and still declines, but nothing on screen can switch modes.
  • --set-default writes the machine-wide agent-presets.default. That value layers over a profile's own composition default โ€” a deployment whose bundle sets default: studio would start new sessions on the guarded preset too โ€” so it is opt-in, and --remove-default takes it back.
  • A profile without a question provider (headless, automation) has nothing to ask: in manual mode the guard then declines every automatic rewrite, and the session eventually hits its window. Give such a profile's preset row mode: auto (the installer's --mode auto) or keep it on an unguarded preset.

For plugin development, install it as a link so edits apply without reinstalling (file: entries are copies that pnpm does not refresh):

dsh plugin --profile web add link:C:/path/to/plugins/dsh-cache-guard

Configuration

Both halves read the same keys; the preset row wins over the host row.

Key Default Meaning
mode manual manual asks before an automatic surface change; auto only reports it
pricePerMTokens unset full-price input rate per million tokens; adds a currency amount to the dialog and the pill
estimatedSummaryTokens 4000 assumed checkpoint size used when pricing a planned summarization

The trigger threshold belongs to the engine, not to the guard: it is the compaction-basic row's thresholdRatio (default 0.8, i.e. compact once 80% of the routed window is in use). The installer can set it โ€” it patches that row's config in the generated preset, which is the whole of that row's config because a patch replaces rather than merges:

node tools/install-profile.mjs --threshold 0.9

Verification & Testing

npm test                                   # unit and contract tests
node tools/simulate-session.mjs 02abdc01   # price a real session's first automatic rewrite
node tools/install-profile.mjs --dry-run   # show what the installer would change
dsh web --dump-config | Select-String cache-guard

tools/simulate-session.mjs truncates a real session log to the moment before its first automatic rewrite, rebuilds a real Session, prices the pending plan through the real token meter and the real pruner, and compares the prediction with the provider usage the log recorded afterwards. On the session above it predicted 28 rewrites, the first change at surface position 8, and 724,447 cold tokens โ€” against 28 rewrites and 720,764 actual full-price tokens: 0.5 % off.

Known Limitations

  • The cold split is calibrated, not guaranteed. The request total comes from the harness's own pressure number (provider-anchored where the session has provider usage); the fixed part is read from the session's cheapest real request. A very large opening message makes the fixed part slightly high and the cold estimate slightly low.
  • The checkpoint size is an estimate โ€” a planned summarization is priced with estimatedSummaryTokens, because the real summary does not exist before it runs. Prune numbers are exact, and the plan states which of the two it is.
  • The per-session mode is process-local. "Allow automatically for this session" lasts until the harness restarts; a durable record would need a session event type this build knows, which an out-of-repo plugin cannot add.
  • A declined overflow still ends the turn. At a provider-confirmed context overflow the window is already exhausted, so declining preserves the original provider error.
  • The plan mirrors the engine's resolution. The trigger ratio, the retention budget, the routed provider/model (from the durable request/header the engine itself reads), and the safe-cut rule are recomputed here from the public engine config and the session surface โ€” because the guard selects the range it replaces. A change upstream must be followed here; a range the engine rejects leaves the surface untouched and logs the failure.
  • A session composed without the engine half is not guarded. The veto lives in the agent preset, so a session started on a preset that lacks the row (the shipped standard, for instance) compacts exactly as before. Pick the guarded preset per session, or set the machine-wide default knowing it applies to every profile of that harness home.
  • A profile with no question provider blocks rather than spends. With mode: manual and nothing to ask, the guard declines and logs it; the session then runs to its window limit. Use mode: auto for a headless profile's row.
  • Everything but the harness vocabulary is English. Code, comments, docs, dialog copy, and the pill are English; only the harness terms the guard reports (compaction/prune, thresholdRatio, retainRatio) keep their upstream spelling.

๐Ÿค Contributing

Keep the layout: one module per concern under lib/, one test file per module under test/, every claimed measurement traceable to a real session. Run npm test before and after a change.

๐Ÿ”— Links

License

MIT ยฉ loonylabs-dev


Maintained by loonylabs-dev