# dsh-dcp

Deterministic context-compaction backend for dsh (DeepSeek Harness): **context
compaction without an LLM call**, works out of the box.

**Requires dsh >= 0.1.5-rc.2** — this plugin targets the dsh RC/stable line only (CI and releases resolve the newest of the `latest`/`next` dist-tags at runtime). **The alpha line is no longer supported.**

> [简体中文](README.md) · **English**

## Why

dsh compacts conversation context by default with `compaction-basic`, which
asks an LLM to re-summarize older messages on every compaction — costly, slow,
and non-deterministic. dsh-dcp is a pure-code port of the ideas behind
[opencode-dcp](https://github.com/Opencode-DCP/opencode-dynamic-context-pruning)
(dedup, error cleanup, "technical summary instead of prose"):

- **Zero LLM calls**: compaction itself costs no extra tokens
- **Deterministic**: identical input always yields identical output
- **CJK-friendly**: verbatim user text / paths / commands / errors, priced at
  real CJK density
- **Inherits all official safety**: triggers, retained tail, transaction
  locks, tool-pairing — dsh's own machinery, only the summarizer is replaced

## Effects

### vs. the official compaction-basic

| | compaction-basic | dsh-dcp |
|---|---|---|
| Summarization | LLM rewrite per compaction | deterministic code extraction |
| LLM calls per compaction | 1 | **0** |
| Determinism | may differ run to run | identical input → identical output |
| Summary content | semantic | verbatim hard facts (paths/commands/errors/todos/user text) |
| Chinese | model re-transcribes | kept verbatim + CJK-aware pricing |
| Triggers/retention/overflow/safety | official | **inherited, identical** |
| Checkpoint format | official | compatible (mutually mergeable) |

It also borrows the dedup / error-purge / `/dcp` / technical-summary ideas from
[opencode-dcp](https://github.com/Opencode-DCP/opencode-dynamic-context-pruning),
re-implemented against dsh's compaction seam — that one serves opencode, this
one serves dsh.

### CJK adaptation

Content is kept verbatim (no re-transcription into English); tokens are priced
at real CJK density (~2 chars/token for Chinese/Japanese/Korean/full-width)
instead of the host's flat 4 chars/token that underestimates Chinese — so CJK
sessions get a budget that reflects real cost, and checkpoints stay
information-dense.

### Real dsh session

~80k tokens of history → ~700-token checkpoint (**~100x**), zero LLM calls;
cache-hit rate is barely affected (any backend pays one "cold request" right
after a compaction).

A checkpoint produced on a real session (Chinese content kept verbatim):

```
## Primary Request and Intent
- 帮我把登录页的重定向 bug 修掉

## Files and Code
- /app/src/auth/login.ts — W×1 R×1

## Errors and Fixes
- bash: FAIL src/auth.test.ts

## Pending Jobs
- add regression test

## Critical Context
- dsh-dcp 确定性压缩了 12 条消息 / 8 次工具调用（未调用 LLM 摘要）
```

## Not in scope

- **No semantic summarization**: it preserves facts that appeared, it does not
  "understand" code. Need deep semantic checkpoints? Stick with the official
  `compaction-basic`
- **Things dsh already does, deliberately not re-implemented**:
  - tool-result pruning (`compaction-tool-result-pruner`, deterministic by size)
  - trigger policy, retained tail, overflow recovery (inherited from official; this plugin only adds the round-interval trigger, see below)
  - `/compact` command, UI checkpoint cards (shipped with dsh)

## Install

**Recommended: pair it with our dsh-tui-pi** (the TUI already depends on
dsh-dcp):

```bash
npm i @aiwayds/dsh-tui-pi
dsh plugin add @aiwayds/dsh-dcp     # activates dcp; the bundle auto-mounts
```

**Standalone:**

```bash
npm i @aiwayds/dsh-dcp
npx dsh-dcp-setup                   # safe: date-stamped backup → append-only → idempotent checks
```

> dsh-dcp plugs into dsh's compaction seam and only affects profiles that
> mount it. The web profile does not bundle the TUI, so it keeps the official
> backend and is unaffected.

## Uninstall

**Bundle path** (`dsh plugin add`, or listed under `bundles`):

```sh
dsh plugin --profile <name> remove @aiwayds/dsh-dcp
```

The host reconciles automatically: the bundles entry is spliced, the patch layer drops with the package, and the stock LLM summarizer (`compaction-basic`) re-enables.

**Setup-script path** (the patch block `npx dsh-dcp-setup` wrote):

```bash
npx dsh-dcp-setup --remove                        # default home patch
npx dsh-dcp-setup --remove --profile tui          # a named profile
npx dsh-dcp-setup --remove /path/to/cordis.patch.yml
```

`--remove` strips only the setup-written mount block (including any config you tuned), with the same date-stamped backup before modifying; hand-written mounts are left alone; the file is deleted when nothing else remains. Two WARN lines matter: a leftover `compaction-basic` disable entry keeps the stock summarizer off (remove it by hand if it was only for dsh-dcp), and another dsh-dcp mount in the same file is untouched.

Removing the package without the reverse step leaves the mount pointing at the vanished absolute entry path, and the profile boot fails with module-not-found.

## /dcp command

| Command | Effect |
|---|---|
| `/dcp` | compact now (zero LLM); same as `/dcp compact` |
| `/dcp status` | status: config, compaction count, tokens saved |
| `/dcp help` / `--help` / `-h` | show the command grammar |
| `/dcp set <k> <v>` | adjust a knob for this session, with a persist hint |

Settable: `dedup`, `purgeErrors`, `maxItems`, `maxItemChars`,
`maxSummaryTokens`, `language`, `tokenEstimate`, `thresholdRatio`,
`roundInterval`, `notice`, `onModelSwitch`, `modelSwitchMinTokens`.

The bare `/dcp` and `/dcp compact` hit the same manual compaction seam:
the common action takes zero arguments, so nobody has to remember a
subcommand, while the read-only and tuning verbs stay behind explicit
arguments (`/dcp status` for the block, `/dcp help` for usage).

> **Behavior change in 0.11.0**: the bare `/dcp` now compacts instead of
> showing status; the status output moved to `/dcp status`. After
> upgrading, use `/dcp status` to inspect — the bare command compacts.

The `/dcp status` block also lists every session that has compacted (subagents
included): `per-session: session-1 (2 compactions, ~444 tokens), child
(1 compaction, ~22 tokens)`. Compactions count per session; disposed
sessions (one-shot subagents included) fall out of the overview
automatically, and the list is capped at the first 10 sessions (`+N more`
for the rest) so the status stays one line.

## Triggers

| Trigger | When | Notes |
|---|---|---|
| Pressure | before every step | tokens ≥ `thresholdRatio` (inherited upstream default 0.8; this plugin's bundle mounts 0.7 — see config table) × context window |
| Overflow recovery | on a provider context-window error | inherited |
| **Round interval** | every `roundInterval` assistant messages | added by this plugin; one round = one LLM roundtrip (each tool-iteration response counts, so one-shot subagents trigger too). **Default 50**: first compaction after message 50, then every 50 more (100, 150, …); any compaction (pressure included) restarts the clock. Fires at the first idle boundary after the count is reached (below the pressure threshold too). `0` disables; requires the default `auto: true` |
| **Model switch** | the session's effective provider/model route changes | added by this plugin (`onModelSwitch`). Default `notice`: appends one row suggesting `/dcp compact` first to shadow the old model's history and save tokens; `auto` compacts at the session's next idle boundary; `off` disables. Two gates: switches within 10 assistant messages of the last compaction are ignored (nothing stale to shadow), and switches while the context is below `modelSwitchMinTokens` (default 32768, `0` disables this gate) are ignored (not enough to be worth shadowing); `auto` requires the default `auto: true`, otherwise it degrades to `notice` |
| Manual | `/dcp` (bare), `/dcp compact`, `/compact` | anytime |

- **Subagents are covered**: in-process subagents (including continuable and one-shot children) dispatch through the same events, so pressure/overflow/round/model-switch triggers count and fire per child session independently. The round trigger counts assistant messages, so a one-shot subagent whose whole run is a single turn (many tool iterations) triggers too.
- **How model-switch detection works**: the per-request `request/context` routing snapshot is folded per session (a provider or model change alone counts as a switch), covering every switch entry point — TUI `/model`, web clients, changed default-model settings. A session's first observed request only seeds the baseline. `notice: false` silences the compaction rows only; the switch row is controlled by `onModelSwitch` independently. The size floor is measured by the host token meter (`measure().surfaceTokens`) and fails open when unavailable.
- **Visibility**: after every trigger event a one-line notice row (`dcp: compacted N history items (~X tokens, trigger)`) is appended to the session; frontends render it as a collapsed row. Note the row also rides the model request context (~15–25 tokens per compaction), and it is **on by default since 0.4.0** — disable with `notice: false`. `/dcp status` stats count every committed region (a pressure retry loop may commit several).

## Configuration

All optional, defaults work out of the box:

| Key | Default | Meaning |
|---|---|---|
| `thresholdRatio` | 0.8 | pressure trigger (inherited upstream compaction-basic default 0.8; this plugin's bundle patch mounts 0.7, recommended for CJK-heavy sessions) |
| `roundInterval` | 50 | compact every N assistant messages (one LLM roundtrip) (0 disables). Default 50: 50, 100, 150… — the clock restarts after every compaction |
| `onModelSwitch` | `notice` | after a model switch: `notice` suggests `/dcp compact` (default); `auto` compacts at the next idle boundary; `off` disables |
| `modelSwitchMinTokens` | 32768 | minimum priced context size (measured by the host token meter) for a model switch to be announced; `0` disables this gate. 32k default ≈ the post-compaction baseline (~16% of the window) plus some real growth |
| `notice` | `true` | append the one-line compaction notice to the session |
| `language` | `zh` | summary language; `zh` also enables Chinese error/"待办：" detection |
| `tokenEstimate` | `cjk` | CJK (zh/ja/ko/full-width) at ~2 chars/token; `ascii` matches the host |
| `dedup` | `true` | annotate repeated tool calls |
| `purgeErrors` | `true` | collapse stale errors into one note |
| `maxItems` / `maxItemChars` | 10 / 200 | summary density |
| `maxSummaryTokens` | 2048 | summary token budget |

> **Upgrade note (0.5.0)**: the `roundInterval` counter switched from completed turns to assistant messages — the same value now triggers more often (a single turn usually contains several assistant messages).

## Bundled skill

The package registers a `dsh-dcp-config` skill (`skills/dsh-dcp-config/SKILL.md`, served via
`ctx.skills.registerProvider`): ask the agent to tune compaction, configure dcp,
or debug compaction behavior and the guide loads automatically — it carries an
interactive `ask_user_question` tuning wizard (collect the expectation first,
then map it to keys) and the persistent recipe for the `config:` section of the
cordis.patch.yml mount block.

## Design reference

- [Opencode-DCP/opencode-dynamic-context-pruning](https://github.com/Opencode-DCP/opencode-dynamic-context-pruning)
- dsh official compaction seam: `docs/subsystems/compaction.md` (deepseek-harness)

## Development

```bash
npm install && npm test     # 65 tests: extractor/compaction/command/config/triggers/setup
```

## License

MIT
