dsh-context-keeper
Two things for a DeepSeek Harness agent:
- A host plugin that teaches it how to reason well — one persistent system-prompt section, visible on every request.
- An offline analyzer that reads what it actually thought, from the session files on disk.
It does not cut reasoning short. That was the original goal; measurement killed it. See Why this does not reduce thinking.
What v0.4 adds: a context badge & reminder
A colour dot in every turn's footer (the conversation.chat.turnTail
seat — the same place and shape as dsh-cost-meter's usage chip). It reads the
official contextPressure projection and paints the band — green (<50%) /
yellow (50–65%) / orange (65–80%) / red (≥80%) — with the occupancy percent;
the action hint ("建议交接" / "该交接了") only appears from orange up. Hover shows
the full sentence.
A reminder panel under the input (conversation.composer.dock) that appears
at orange/red only. It states the band and offers an 开新会话 button: it
calls the client Remote session.create + session.prompt to make a fresh
session in the same workspace and seed it with "read the latest handoff package
under .context-keeper/handoff and continue". The new session is a full agent,
so it reads the package itself — the button does not inline the package (that
would spend tokens again).
Both are pure presentation plus one explicit user click. They never enter the model input on their own and cost zero tokens until you press the button.
Honest limits (verified against this build's source):
- DSH's
ReasoningRowheader (above the thinking block) is hard-wired with no extension seat, so the footer is the nearest always-visible open spot.- After creating the new session the button cannot switch you to it — the slot API has no navigation; open it from the sidebar. The panel says so.
- Command names must match
^[a-z][a-z0-9_-]*$and third-party commands render their name (no separate displaylabel), so/handoffand/context-watchcannot be shown asHandoff/Context-Watchin the menu. Readability goes into the Chinese one-line description shown next to each name instead.
The client half is plugin/client.js (declared under dsh.client in
package.json); the host half is unchanged. No config path needed.
What v0.3 adds: context budget & handoff
v0.2 taught the agent how to think. v0.3 handles whether the window can hold it. Two human commands, both reading the session file DSH already writes and calling a model zero times:
/context-watch— occupancy, band (green/yellow/orange/red), headroom, growth per turn, and the last compaction's reclaim./handoff— write a portable handoff package plus a full faithful archive to<workspace>/.context-keeper/handoff/, so a fresh session can continue without re-deriving anything. It reuses DSH's owncompaction/summarycheckpoint as the "early context" (free multi-level distillation) and quotes the recent tail verbatim.
A short, static context-budget line is added to the system prompt. It holds no numbers on purpose: a changing number in the system prompt would invalidate the provider's prefix cache on every request, which costs more than it saves.
The design and the two references it distils are in docs/distill-design-v3.md.
Install
As a DSH plugin
dsh plugin --profile <your-profile> add dsh-context-keeper
Then restart the profile. The plugin registers one prompt section named
context-keeper:how-to-reason at order 650, which places it with the other policy
sections — after the deployment persona, before the tool catalog.
To turn it off without uninstalling, either set enabled: false in the config
block, or add a patch row:
- id: context-keeper
disabled: true
Config options:
| Option | Default | Meaning |
|---|---|---|
enabled |
true |
false registers nothing |
text |
built-in rule | Replace the rule text entirely |
order |
650 |
Section position in the assembled prompt |
maxChars |
2000 |
Truncate at a paragraph boundary |
As a CLI
git clone https://github.com/Orliba/dsh-context-keeper
cd dsh-context-keeper
node bin/cli.mjs --help
No build step, no dependencies, Node >= 22 (uses built-in zlib.zstdDecompressSync).
The plugin: what it puts in the prompt
The rule is five lines, each naming an observable behaviour rather than a virtue:
Decide before acting. Before the first tool call, settle what you are doing, why, and how you will know it worked. Once you state a plan, execute it; do not reopen the plan mid-execution.
Change a conclusion only with a reason. …
When reasoning stalls, gather evidence instead of thinking harder. …
Match depth to risk. Read-only exploration may be quick. Irreversible actions deserve a second look. …
Say what you do not know. …
Full text and the reasoning behind each line: plugin/rule.mjs and docs/rule-text.md.
Why a prompt section and not a stream interceptor
The first design watched the reasoning stream and reacted — lower the effort tier when a trace looked wasteful, stop a turn that ran away. Three measurements, on real sessions, removed the target:
| Hypothesis | Measured | Result |
|---|---|---|
| Looping traces repeat sentences | 0.1% sentence repetition | dead |
| Nearby steps re-derive earlier steps | 0.3% of step pairs above Jaccard 0.35 | dead |
| Early commitment + long tail = waste | top candidates read by hand: both productive | dead |
It also depended on two fragile places: llm/stream (no in-tree consumer) and
agent/turn-stopping (fires only after the turn already ended).
A prompt section is the opposite kind of intervention. It is declared once, it is visible on every request, and it needs no detection — the model does not have to be caught in a bad pattern in order to learn from it. Numbers and raw data: docs/calibration.md.
Failure behaviour
systemPrompt.section() is a public registry method, but this plugin still
refuses to take a host down if a future build changes shape. If the method is
missing, it warns once and registers nothing — the entry stays loaded so the
rest of the profile is unaffected.
Two failure modes were found the hard way during development, both now covered by tests:
- an undeclared service throws at activation (
cannot get property X without inject) - a declared but never provided service leaves the entry pending forever
(
waiting for service: X) — this is what happens if you declareconsole, which is a Node global, not a harness service
The CLI: reading what it thought
# every session under a DSH home, sorted by thinking volume
node bin/cli.mjs scan "$DSH_HOME/sessions"
# one session, aggregate
node bin/cli.mjs report path/to/session.v4.jsonl.zstd
# longest traces first
node bin/cli.mjs traces path/to/session.v4.jsonl.zstd --limit 20
# export candidates for human review, with a verdict checkbox per sample
node bin/cli.mjs samples path/to/session.v4.jsonl.zstd --n 5 --out ./review
# context budget: occupancy, band, headroom, last compaction
node bin/cli.mjs context path/to/session.v4.jsonl.zstd
# write a portable handoff package + full archive for a session
node bin/cli.mjs handoff path/to/session.v4.jsonl.zstd --out ./handoff
--json works on report, traces, and scan.
| Metric | Meaning |
|---|---|
tokens |
Estimated thinking tokens |
paras |
Paragraphs in the trace |
commit@ |
Where the first commitment to a plan lands, as a share of the trace |
recommit |
Commitments appearing after that first one |
retract |
Strong retractions ("scratch that", "I was wrong") |
tail-eff |
Share of post-commitment paragraphs naming a new artifact or number |
churn |
0..1 composite of the three above |
Verdicts: lean, normal, churn-risk, unclear (no commitment found).
Reading the numbers honestly
tokensis an estimate (CJK ≈ 1 char/token, Latin ≈ 4 chars/token). Fine for ratios inside one trace, not billing-grade.commit@matches English and Chinese commitment patterns. Traces in other languages land inunclearrather than being mis-scored.churnis a heuristic never validated against a corpus where waste is known to exist. Treatchurn-riskas "worth a look", not a diagnosis.
Session storage facts
Two things that silently corrupt any independent analysis:
- Multi-frame zstd. Session files are a sequence of independently framed
zstd blocks, not one stream. Node's
zstdDecompressSyncreads only the first frame.src/zstd.mjssplits on the magic number and decodes each frame. - Reasoning is stored four times per attempt —
content[],replayState.blocks[],chunk.block, andchunk.replayState.blocks[]. Walking the event tree counts every trace 4x, with no error and no symptom. Readdata.message.content[]only;src/session.mjsexplains why.
Layout
bin/cli.mjs command wiring
src/session.mjs session file -> reasoning records + full detail (users/tools/compactions)
src/metrics.mjs pure metric functions
src/budget.mjs pure context-budget math (occupancy, bands, ETA)
src/handoff.mjs pure handoff packaging + archive rendering
src/zstd.mjs multi-frame decoder
src/render.mjs table/report formatting
plugin/index.mjs the host plugin (rule sections + /context-watch + /handoff)
plugin/client.js the client half (turnTail context badge, colour dot)
plugin/rule.mjs the rule text
test/ plugin, budget, handoff, client-badge, activation and e2e prompt tests
docs/ calibration data and design notes
Run the tests:
npm test # everything below, in order
node test/plugin.test.mjs # unit: what gets registered
node test/activation.test.mjs # cordis injection rules
node test/client-badge.test.mjs # badge math + parity with the host + reminder helpers
node test/client-boot.test.mjs # mounts the client bundle under the real loader shape
License
MIT
No comments yet. Be the first to write one.