2JumpSinA/dsh-context-guard

dsh-context-guard

长会话越跑越贵,平台到点还会把上下文压掉。这个插件做三件事:在会话标题旁显示当前上下文占了多少,鼠标停上去能看到这个数是怎么来的;过 45% 时往工作目录的 HANDOVER.md 写一份交接草稿,记下改过哪些文件、花了多少钱、最近几轮在聊什么;到 60% 提醒你一次该收尾了。平台压缩发生时,它把压缩前的数字记一行,事后还查得到。不调用模型,也不改动对话内容。它不阻止压缩,拿不回已经被压掉的部分。

미분류context-managementcontext-windowcost-optimization

dsh-context-guard

npm package: `@2jumpsina/dsh-context-guard` · 中文

A DeepSeek Harness plugin that tells you to wrap up and start a new session before the session gets too long.

It consumes the contextPressure projection that the official dsh-token-meter has already computed, and after a turn has ended it speaks up on two thresholds: "write the handoff into a document first, then start a new session." It then helps turn that sentence into an action — once the threshold is crossed it drafts a handoff block into the session working directory.

Context tax = 0: it registers no model-facing tool, injects no prompt section, and sends no extra request. The host does the arithmetic, the UI does the talking.

🆕 Current release is live on npm `latest` (0.7.x): 45% silently drafts the handoff, 60% is the only reminder;
the compaction point shows up in the badge tooltip and in the banner body (computed live as
min(window×0.8, window − reserve − 64K), with its source; marked "estimated" when the platform's
compaction config is unreadable); and the moment the platform compacts, the pre-compaction reading is
captured into the compaction ledger inside HANDOVER.md.
Since v0.5.0 the WeChat push and cross-session trend are gone (their config keys retired from the
schema in v0.7.2; leftover values in old profiles are ignored safely). See HANDOVER.md §46–§48.
⚠️ Do not run `npm i dsh-context-guard` — that unscoped name is a third party's package
(by greenlv). This plugin's name is scoped: `@2jumpsina/dsh-context-guard`, published on npm
(see Install).
🙂 Got it running? Send me a line — what I want most is to hear what could be better.
One line is enough: what it got right in your setup, and what felt off.

⚠️ It writes to your working directory (read this before installing)

  • On by default (handoverOnWarn: true). When a session's occupancy crosses warnRatio (default 45%), the plugin creates/rewrites handoverPath (default HANDOVER.md) in that session's working directory, and then refreshes it every further 5 percentage points.
  • What it writes are machine facts: session id, title, working directory, occupancy, turn/call counts, ledger cost, the files this session wrote, previews of the last few prompts and replies, plus empty "to be filled in" slots for the agent.

⇒ It may contain sensitive content (file paths, fragments of your questions and replies), and it may end up committed by git.

  • Suggestions: add HANDOVER.md to your .gitignore; or point handoverPath at a location outside any repository; or simply set handoverOnWarn: false.
  • Boundaries: a missing parent directory is refused; an existing target that is not a text file (.json/.yml/…) is refused; a symlink pointing elsewhere is refused; writes are atomic (temp file + rename) with tight permissions (0o600). Any failure is recorded as status only — it never affects the badge, the banner, or the push.

Why this plugin has to exist

In one line: a long session is not a technical failure, it is a bill that grows every turn. This plugin exists to put that bill in front of you at the one moment you can still act on it — after a turn has ended and before the next request goes out.

The problem is measurable

Cost ≈ (average prompt size) × (number of calls) × (unit price). A long session pushes the first two factors up at the same time, and the unit price itself rises with occupancy. Measured against the ledger of the machine this plugin was calibrated on (its local session-projection cache: 84 sessions / 14 days, ¥252.29 self-reported) and the official DeepSeek pricing table:

Measurement · Value Self-check (price table × contextTimeline.cost, against the ledger total) · ¥254.29 vs ¥252.29 ⇒ 0.8% deviation; per-request rows cover 85.4% of the total Unit price by occupancy · ¥0.011/call at 0–10% → ¥0.045/call at 70–80% (4.2×) Where the historical spend sits · calls at ≥40% occupancy: 37.4% of spend · at ≥60%: 20.0% Session peaks · Observed 75/75 sessions never exceed ~80%; the real point comes from the formula (≈ 67.8% on this machine's deepseek-flash) Interruption cost, if the line is drawn at 40% / 50% / 60% · 0.78 / 0.57 / 0.50 prompts per day

And one real session, from the very repository this plugin was built in: 584 calls, 274M tokens, ¥31.66 — of which 98.6% was re-reading context and 0.17% was model output. That is the failure shape being addressed here: no crash, no error message, just an invoice.

Why this guard is the cheap half of the trade

The formula above has a shape worth spelling out. A session's total is

total ≈ Σ over turns ( prompt_i × unit_price(occupancy_i) )

and both factors move the same way: prompt_i grows monotonically with every turn, and the unit price itself rises with occupancy (measured above: ¥0.011/call at 0–10% → ¥0.045/call at 70–80%). A session therefore does not get linearly more expensive as it runs, it gets super-linearly more expensive — which is why the last third of a long session can cost far more than the first third while containing the same number of turns.

That is the entire reason this plugin is worth installing: its marginal cost is exactly zero.

  • It registers no model-facing tool, injects no prompt section, and sends no extra request ⇒ **it adds no

tokens to any request** — it does not appear in the ledger at all.

  • The only thing it ever costs you is the handoff at the end, and that handoff is not a fee paid to this

plugin: it is work you would have to do anyway, usually worse (from memory, after the session has already been abandoned).

  • It speaks at the turn boundary. A warning inside a turn is useless — that prompt is already on the

wire and already paid for; a warning two turns later is an invoice you can no longer avoid.

So the trade is not "spend money to save money". It is: put a free readout on a bill you are already paying, at the only moment when the number can still change the decision.

Why nothing else already catches it

  • The platform compacts; it does not negotiate. The automatic point is min(window × 0.8, window − reservedOutput − 65,536)

(≈ 67.8% on this machine's deepseek-flash), and it fires without asking. Compaction is a safety net for the session, not a warning to you — by the time it fires you have already paid for the whole climb. Since v0.6.1 the hard-tier banner prints that point explicitly; what it compresses away is gone — the only trace is a ledger row if one was written (the wording deliberately does not claim "the reading is lost forever").

  • The model cannot be the meter. projectedTokens is computed by dsh-token-meter from the real request;

the model cannot see how large its own prompt is, and a warning is only meaningful after a turn ends (that turn's prompt is already on the wire). Worse, any mechanism that makes the model "mind the context" has to inject a prompt section or register a tool — that is adding context tax to solve a context-tax problem.

  • You have no readout. Occupancy is observable, but "time to wrap up" is not one reading: it needs

hysteresis, a cooldown, edge triggering, cross-session counting, and it has to fire when you *open* an already-full historical session. Nobody watches a percentage while working.

What it does instead — and deliberately does not

  • Context tax = 0: no model-facing tool, no prompt injection, no extra request. A guard against context bloat

must not itself consume context.

  • It only consumes what the official meter already computed — it does not re-measure, estimate or guess.
  • It speaks once per level, at the end of a turn: two levels + hysteresis + edge trigger + cooldown.
  • fail-closed: with no denominator it does not judge at all, and the badge shows ctx — instead of 0%.
  • It turns the sentence into an action: crossing warnRatio drafts a machine-facts handoff block (session,

occupancy, ledger cost, the files this session wrote, previews of the last few prompts/replies, plus "to be filled in" slots) into the session working directory — zero LLM, zero extra request.

  • No network at all (v0.5.0 removed the WeChat push); capabilities are fs-read / fs-write only.

What this section does *not* claim

  • The savings are a counterfactual upper bound, not a promise. Replaying this machine's ledger: the 2741

calls made at ≥40% occupancy would have saved ¥59 (23%) had they happened at 0–20%; the ≥60% batch would have saved ¥34 (14%). Wrapping up is not free — it costs exactly one handoff. The only claim is that this decision is better made with the number in front of you.

  • The thresholds are a calibration, not an optimum. 0.45 / 0.60 come from those 84 sessions; they are

defaults, and every one of them is configurable.

  • The sample is one machine, one provider, two weeks. Prices and compaction behaviour change.
  • It does not judge whether your task is worth finishing. It says one thing: continuing from here costs

several times more per turn than starting fresh — and here is the handoff draft.

What it does

Half · What it does host (lib/index.js) · Subscribes to ctx.sessionProjections.onChanged and feeds contextPressure to the pure policy; settles each turn on turn/end; owns the settings; judges once immediately on session/created (new or restored from storage); folds compaction/end and request/header into the decision; registers the verdict as the `contextGuard` wire projection for the browser half; registers GET /api/context-guard/state (a read-only debug mirror); extrapolates the verdict to WeChat when configured; and drafts the handoff block when a threshold is crossed client (lib/client.js, a hand-written single-file bundle) · Session-header badge (live occupancy; numbers only, never speaks) plus a one-shot banner on threshold crossing (ratio + next step + pointer to the handoff document); level/turn/cooldown come only from useProjection("contextGuard") — read-only, no local state machine, no thresholds, no extra requests (see the Architecture section below)

Behaviour notes:

  • Judgement timing = on projection change (not `turn/end`): contextPressure updates after every

request and the plugin judges right then; turn/end only advances the turn counter and decays the cooldown, while compaction/end and request/header feed the decision. ⚠️ Earlier revisions here claimed "never mid-turn", which did not match the implementation. After the 2026-10-17 code review the decision was keep the implementation, fix the docs: moving judgement to turn/end means that if that event is ever missed the plugin goes completely silent (a miss), while judging on projection change at worst warns slightly early. See HANDOVER.md §33.

  • Speaks once: two levels + hysteresis (re-armed only after falling back below warn − hysteresis) + edge triggering + a cooldown of N turns.
  • At a level crossing you get two choices: Write the handoff, I will copy it (write one machine-facts draft right away and surface a copy-ready opening line) and Got it (just close, no side effect).
  • fail-closed: with no denominator (contextWindow absent) it does not judge at all, and the badge explicitly shows ctx — instead of pretending to be 0%.

Architecture (v0.4.3): the host judges, the browser only reads (one decision)

One place decides, one place displays, and a single wire projection links them:

Layer · Where · Responsibility Decision · lib/engine.mjs (host only) · The state machine is a pure fold (init + apply(state, event)): level / armed / turn / cooldown / hysteresis are computed once, and compaction/* folds into that state Projection · lib/index.js (host) · Registers the verdict as the `contextGuard` wire projection: { level, armed, turn, cooldown, ratio } — flat JSON, same reference while unchanged Display · lib/client-source.js (browser) · useProjection("contextGuard"), read-only (badge + one-shot banner); no thresholds, no state machine, no extra requests Debug mirror · GET /api/context-guard/state · A read-only mirror: current host config / recent decisions / ledger counters / handoff stats. The browser half no longer copies thresholds from it

Two things this buys:

  • One decision. The browser half used to carry its own state machine and copy the thresholds from

the state route on boot ⇒ two cooldowns, two hysteresis rules, and settleTurn wired to a banner button (one click could advance the turn counter client-side). The decision now lives only in lib/engine.mjs and is not even in the bundle — structurally: test/client-sync.test.mjs also statically bans the hostSync / setConfig / settleTurn identifiers.

  • It shows what it was told. The debug surface window.__DSH_CONTEXT_GUARD__.store.dump() (all

sessions) and viewOf(sessionId) (one) records what the host projection said, not what the client computed. When the projection is absent (older host / no token-meter), the badge still shows occupancy but no banner appears — better to stay quiet than to reintroduce a second judge.

Install

⚠️ Do not run `npm i dsh-context-guard`. The unscoped name
dsh-context-guard belongs to a third party (greenlv ; latest 0.2.1, now
deprecated in favour of dsh-completion-guard). That command installs their plugin, not this one.
This plugin's name is scoped — `@2jumpsina/dsh-context-guard` — and is published on npm.

Install into DSH (recommended)

dsh plugin is a thin wrapper around pnpm: it runs package management inside the profile and then registers the new bundle. One command, everything else is automatic: