dsh-command-context-trim 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,75 @@ All notable changes to this project are documented here. This project adheres to
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.2.0] - 2026-09-16
9
+
10
+ ### Added
11
+
12
+ - **Automatic trimming on the context wall.** A `prepend`ed `agent/request-error` listener reacts to
13
+ `CONTEXT_WINDOW_EXCEEDED`, frees space with no model call, and asks the loop to retry. Only when it cannot free
14
+ anything does the waterfall continue into DSH's own recovery (prune + summarize) — so a session that hits the wall
15
+ is repaired by *dropping* the oldest span first and only pays for summarization when dropping cannot help.
16
+ This is the unattended form of `/trim`; it is the same execution, invoked by the harness instead of a human.
17
+ - Configuration `autoTrim` (default `true`) and `maxAutoTrimRetries` (default `1`, per overflow episode).
18
+ - **The elided span is chosen by explicit preference tiers.** Elision always starts at the oldest balanced cut, and the
19
+ search is graded: (1) stay outside the retained tail and keep the final message, with the configured retention relaxed
20
+ step by step only if the fit otherwise fails; (2) reach into the retained tail, still keeping the final message;
21
+ (3) last resort — include the final message, typically the current step's assistant tool-call plus its tool result,
22
+ which can only be removed as a pair. `allowTailTrim: false` ends the list after tier 1. The plan and every rendered
23
+ result state when the last-resort tier was used.
24
+ - **Planner anchor changed: the newest `user/message` is protected, the final node is not.** The previous rule
25
+ ("never elide the final surface node") deadlocked the most common overflow shape — one large assistant tool-call whose
26
+ tool result is the last node could not be removed as a pair, so only a handful of tokens were freeable while the
27
+ request stayed over the wall (observed live: "largest balanced span frees ~4 of the ~4631 tokens needed"). The newest
28
+ human instruction is now a **barrier** (never elided, never crossed) and everything after it stays eligible, tool
29
+ pairing still enforced on both cut edges.
30
+
31
+ ### Notes
32
+
33
+ - **Scope: the context wall only.** The listener fires exclusively for `CONTEXT_WINDOW_EXCEEDED`. Ordinary
34
+ threshold compaction (`agent/pre-step` pressure), `/compact`, and the tool-result pruner are untouched — this is
35
+ asserted by a test that pins the registered listener set.
36
+ - Why `prepend` is required: `agent/request-error` is a Cordis waterfall and compaction registers its summarization
37
+ recovery on the same event. Cordis stores listeners in registration order and `{ prepend: true }` unshifts, so this
38
+ listener runs first even when compaction is mounted later inside an agent-preset isolate realm (as it is in a web
39
+ profile). Returning `{ kind: 'retry' }` without calling `next()` vetoes summarization for that attempt.
40
+ - The per-episode budget resets when a completed assistant message lands or the agent goes idle, mirroring
41
+ compaction's own overflow accounting.
42
+ - Trade-off, stated plainly: an automatic trim **drops** the oldest span rather than summarizing it. That is the
43
+ point on a small local window — the summarizer must fit the region it is condensing and frequently cannot — but it
44
+ does mean the dropped text is replaced by a marker instead of a summary. `/compact` remains available, and the full
45
+ text stays in the durable session log.
46
+
47
+
48
+ ## [0.1.1] - 2026-09-15
49
+
50
+ ### Fixed
51
+
52
+ - **DeepSeek Harness 0.1.5 compatibility.** 0.1.5 renamed the positional replacement marker
53
+ (`{op: 'replace', start, end}` → `{op: 'replace', startSeq, endSeq}`), so every trim failed with
54
+ `session event "user/message" carries an invalid replace surfaceOp`. The plugin now probes the accepted shape
55
+ once against the harness actually installed and writes that one, so the same build works on the 0.1.2 and
56
+ 0.1.5 lines without a version check.
57
+ - **The system prompt is never trimmed — and no longer eats head protection.** 0.1.5 moved the system prompt
58
+ from the request header onto the surface as node 0 (`system/message`). A position-only "protect the first
59
+ node" rule would have protected the *system prompt* and exposed the **user's original request** as the first
60
+ elidable message. System nodes are now barriers: never elided and never crossed, and `protectHeadNodes` counts
61
+ only non-barrier nodes, so it keeps protecting the task statement.
62
+
63
+ ### Notes
64
+
65
+ - No configuration changes. The only user-visible difference is the "fixed request overhead" refusal, which now
66
+ says "tool schemas and other non-surface request data": on 0.1.5 the system prompt is surface content rather
67
+ than header content.
68
+ - Verified on harness 0.1.2-rc.1 and 0.1.5-rc.2 (40 tests each, same build).
69
+
70
+
71
+ ### Added
72
+
73
+ - Integration tests against the **real** `ctx.tokenMeter` (bare cordis context + stub projection registry): a trim's
74
+ measured saving equals the `compaction/prune` shadow price it claims minus the replacement marker, and a *fresh* meter
75
+ folding the replayed log reaches the identical total — the replay property the claim exists for.
76
+
8
77
  ## [0.1.0] - 2026-09-15
9
78
 
10
79
  ### Added
@@ -32,5 +101,7 @@ All notable changes to this project are documented here. This project adheres to
32
101
  content stays in the durable session log. v1 has no `/untrim`.
33
102
  - Requires a harness that exposes `ctx.commands`, `ctx.tokenMeter`, and `ctx.llm` (DeepSeek Harness 0.1.2-rc.1 or later).
34
103
 
35
- [Unreleased]: https://github.com/snailium/dsh-command-context-trim/compare/v0.1.0...HEAD
104
+ [Unreleased]: https://github.com/snailium/dsh-command-context-trim/compare/v0.2.0...HEAD
105
+ [0.2.0]: https://github.com/snailium/dsh-command-context-trim/compare/v0.1.1...v0.2.0
106
+ [0.1.1]: https://github.com/snailium/dsh-command-context-trim/compare/v0.1.0...v0.1.1
36
107
  [0.1.0]: https://github.com/snailium/dsh-command-context-trim/releases/tag/v0.1.0
package/README.md CHANGED
@@ -22,7 +22,7 @@ synchronous appends, zero LLM calls — it works precisely when every request is
22
22
  ## Install
23
23
 
24
24
  ```bash
25
- # from npm, once published
25
+ # from npm
26
26
  dsh plugin --profile web add dsh-command-context-trim
27
27
 
28
28
  # from a checkout
@@ -78,21 +78,92 @@ Design consequences:
78
78
  original content stays in the durable session log, so a trim is auditable and recoverable by hand.
79
79
  - **No tool-call/result pair is ever split.** Cut edges are chosen with
80
80
  `toolPairingBalancedBefore`/`After` from `@deepseek-ai/dsh-compaction`.
81
+ - **The system prompt is never trimmed** (harness 0.1.5+ carries it as a surface node): it is a barrier that no
82
+ elided span may touch or cross.
81
83
  - **Mutual exclusion with everything else.** The handler runs inside `agent.runMaintenance()`, which fails unless the agent
82
84
  is idle, so it cannot interleave with a turn, `/compact`, or automatic compaction; it also refuses while an unmatched
83
85
  `compaction/start` is open.
84
86
 
87
+ ## Automatic trimming on the context wall
88
+
89
+ `autoTrim` (default on) makes the same model-free reduction happen without anyone typing a command: a **prepended**
90
+ `agent/request-error` listener reacts to `CONTEXT_WINDOW_EXCEEDED`, trims, and asks the loop to retry.
91
+
92
+ ```
93
+ request fails (context wall)
94
+ ├─ prepended: context-trim → trim a span, no model call → retry ← wins when it can free space
95
+ └─ next(): compaction-basic → prune tool results → summarize (LLM) ← only when trimming cannot help
96
+ ```
97
+
98
+ Why `prepend` is the whole trick: `agent/request-error` is a Cordis **waterfall**, and compaction registers its own
99
+ summarization recovery on the same event. Cordis keeps listeners in registration order and `{ prepend: true }`
100
+ unshifts to the front, so this plugin runs first even though compaction is mounted later — in a web profile it lives
101
+ inside an agent-preset isolate realm, which no host-plane plugin can out-order by mount position. Returning
102
+ `{ kind: 'retry' }` without calling `next()` vetoes summarization for that attempt.
103
+
104
+ Scope, deliberately narrow:
105
+
106
+ | Event | Behaviour |
107
+ |---|---|
108
+ | `CONTEXT_WINDOW_EXCEEDED` on `agent/request-error` | trim, then retry |
109
+ | any other request failure | untouched (`next()`) |
110
+ | ordinary threshold compaction (`agent/pre-step` pressure) | **never touched** |
111
+ | `/compact`, the tool-result pruner | **never touched** |
112
+
113
+ The per-episode retry budget (`maxAutoTrimRetries`, default 1) resets when a completed assistant message lands or the
114
+ agent goes idle, mirroring compaction's own overflow accounting. Set `autoTrim: false` to keep trimming manual.
115
+
116
+ Trade-off, stated plainly: an automatic trim **drops** the oldest span instead of summarizing it. On a small local
117
+ window that is the point — the summarizer must fit the region it is condensing and frequently cannot — but the dropped
118
+ text is replaced by a marker rather than a summary. `/compact` stays available, and the full text remains in the
119
+ durable session log.
120
+
85
121
  ## What is protected
86
122
 
87
123
  | Protected | Why |
88
124
  |---|---|
89
125
  | Leading `protectHeadNodes` nodes (default 1) | The task statement — dropping it destroys the point of the conversation. |
90
126
  | 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. |
91
- | The final surface node | Never elided, even when trimming into the tail. |
127
+ | 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. |
128
+ | 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). |
92
129
 
93
130
  Within those bounds the policy is **oldest-first, least-long-possible**: the elided span starts at the oldest balanced cut
94
131
  and grows only until it frees exactly enough tokens.
95
132
 
133
+ Elision always starts at the **oldest** balanced cut, and the search is graded so that the cheapest loss is tried first:
134
+
135
+ 1. a span that stays **outside the retained tail** and keeps the **final message** (the configured retention, relaxed step
136
+ by step only if the fit otherwise fails);
137
+ 2. a span that may reach **into the retained tail**, still keeping the final message;
138
+ 3. **last resort** — a span that includes the final message, typically the current step's assistant tool-call plus its tool
139
+ result, which can only be removed as a pair.
140
+
141
+ Protecting the final message outright deadlocks the most common overflow shape: one large assistant tool-call whose tool
142
+ result is the last node cannot be removed as a pair, which left a handful of freeable tokens while the request stayed over
143
+ the wall (observed live: "largest balanced span frees ~4 of the ~4631 tokens needed"). The newest user message is the real
144
+ anchor and stays a hard barrier in every tier. With `allowTailTrim: false` the search ends after tier 1, so the retained
145
+ tail is a hard boundary and the final message is never dropped.
146
+
147
+ ## Compatibility
148
+
149
+ | Harness | State |
150
+ |---|---|
151
+ | 0.1.2-rc.1 (`latest`) | ✅ full suite green |
152
+ | 0.1.5-rc.2 (`next`) | ✅ full suite green |
153
+
154
+ Two harness changes between those lines are handled without a version check:
155
+
156
+ - **The replacement marker was renamed** — `{op: 'replace', start, end}` became `{op: 'replace', startSeq, endSeq}`.
157
+ The plugin probes a throwaway detached session with each known shape at first use and writes the accepted one.
158
+ - **The system prompt moved onto the surface** — 0.1.5 carries it as `system/message` node 0 instead of
159
+ `header.system`. System nodes are treated as **barriers**: they are never elided, no elided span crosses one,
160
+ and `protectHeadNodes` counts only non-barrier nodes, so head protection keeps covering the task statement
161
+ rather than the system prompt.
162
+
163
+ Because of the second change, the fixed request overhead is now the tool schemas plus any other non-surface
164
+ request data; on 0.1.2 it also included the system prompt. A trim's budget itself is unaffected — it comes from
165
+ the token meter's total, whichever way the harness splits that total.
166
+
96
167
  ## Configuration
97
168
 
98
169
  Override on the `context-trim` row of a profile patch (the bundle's own `cordis.patch.yml` lists the full default set):
@@ -104,8 +175,10 @@ Override on the `context-trim` row of a profile patch (the bundle's own `cordis.
104
175
  | `retainRatio` / `retainTokens` | `0.16` / — | Recent tail kept verbatim (mutually exclusive forms) |
105
176
  | `minTailTokens` | `2048` | Absolute floor for that tail |
106
177
  | `protectHeadNodes` | `1` | Leading nodes that are never trimmed |
107
- | `allowTailTrim` | `true` | Let the elided span reach into the retained tail when necessary |
178
+ | `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 |
108
179
  | `markerSlackTokens` | `64` | Slack added to the priced marker so the post-trim request stays under budget |
180
+ | `autoTrim` | `true` | Trim automatically on `CONTEXT_WINDOW_EXCEEDED`; never fires on ordinary compaction |
181
+ | `maxAutoTrimRetries` | `1` | Automatic trims allowed per overflow episode before compaction takes over |
109
182
 
110
183
  ## Limits
111
184
 
@@ -121,6 +194,23 @@ Override on the `context-trim` row of a profile patch (the bundle's own `cordis.
121
194
 
122
195
  ## Development
123
196
 
197
+ ### End-to-end overflow check (no model needed)
198
+
199
+ `scripts/mock-overflow-server.mjs` is a stateful OpenAI-compatible endpoint that enforces a **real** limit lower than the
200
+ `contextWindow` the harness is told, and answers the first `TOOL_STEPS` requests with a tool call so one turn keeps
201
+ looping and grows past the real limit — the context wall, without a model switch:
202
+
203
+ ```bash
204
+ node scripts/mock-overflow-server.mjs & # PORT=4185 TOKEN_LIMIT=12000 TOOL_STEPS=4
205
+ # point an ISOLATED profile at it (provider with contextWindow 20000, baseURL .../v1), then:
206
+ DSH_HOME=$(mktemp -d) dsh --profile headless "..." # see the isolated-home procedure in dsh-plugin-packaging
207
+ ```
208
+
209
+ A passing run leaves this in the session log: `assistant/attempt` (the wall), then exactly one `compaction/prune` + one
210
+ `user/message` replacement, then a **succeeding** retry — and **zero** `compaction/start`, proving the request was
211
+ repaired by trimming and that summarisation never ran.
212
+
213
+
124
214
  ```bash
125
215
  npm install # the harness contracts this plugin builds on, pinned as devDependencies
126
216
  npm test # node --test
@@ -128,21 +218,27 @@ npm run link:harness # or resolve @deepseek-ai from a local dsh installation i
128
218
  ```
129
219
 
130
220
  Tests cover the pure planner and argument parser, the surface mutation against a real `Session` (including log replay),
131
- and the plugin's command registration and end-to-end trim over a stub context. CI runs the suite on Node 22 and 24;
221
+ the plugin's command registration and end-to-end trim over a stub context, and against the real `ctx.tokenMeter` that a
222
+ trim's measured saving equals the shadow price it claims and that a fresh meter replaying the trimmed log lands on the very
223
+ same total. CI runs the suite on Node 22 and 24;
132
224
  releases go out through `.github/workflows/publish.yml`, which is manual-only (`workflow_dispatch`).
133
225
 
134
226
  ### Verification status
135
227
 
136
228
  | Check | State |
137
229
  |---|---|
138
- | `npm test` (30 tests: planner, args, surface apply + replay, plugin handler) | ✅ passing |
230
+ | `npm test` (51 tests: planner, args, surface apply + log replay, plugin handler, automatic overflow path) | ✅ passing |
139
231
  | Isolated `DSH_HOME` install (`dsh plugin add file:…`) reconciling dependency **and** bundle layer | ✅ verified |
140
232
  | Composed profile tree contains the `context-trim` insert row (`dsh --dump-config`) | ✅ verified |
141
233
  | Profile boot with the plugin mounted (no load error) | ✅ reaches the credential check cleanly |
142
- | Same suite against the pinned **published** harness packages (`npm ci`) | ✅ 30 passing |
143
- | CI workflow (Node 22 / 24) | first run pending |
144
- | npm release via GitHub Actions | first publish pending (Trusted Publishing cannot create a brand-new package; see the `npm-publish` procedure) |
145
- | End-to-end in the web GUI against a small-window model | not yet run |
234
+ | Same suite against the pinned **published** harness packages (`npm ci`) | ✅ 51 passing |
235
+ | 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 |
236
+ | Real-`cordis` proof that a `prepend`ed waterfall listener runs first and vetoes the chain (the mechanism the automatic path depends on) | 3 tests |
237
+ | Same suite on harness 0.1.5-rc.2 (renamed marker + surface system prompt) | 51 passing |
238
+ | CI workflow (Node 22 / 24) | ✅ green |
239
+ | npm release via GitHub Actions | ✅ 0.1.0 published with provenance (`+ dsh-command-context-trim@0.1.0`) |
240
+ | Isolated profile install **from the npm registry** (dependency + bundle layer + composed insert row) | ✅ 0.1.0 |
241
+ | End-to-end in the web GUI against a small-window model | ⏳ harness ready, not yet run |
146
242
 
147
243
  ## License
148
244
 
package/README.zh.md CHANGED
@@ -17,7 +17,7 @@
17
17
  ## 安装
18
18
 
19
19
  ```bash
20
- dsh plugin --profile web add dsh-command-context-trim # 发布到 npm
20
+ dsh plugin --profile web add dsh-command-context-trim # npm 安装
21
21
  dsh plugin --profile web add file:/path/to/dsh-command-context-trim # 从源码
22
22
  ```
23
23
 
@@ -53,11 +53,54 @@ dsh plugin --profile web add file:/path/to/dsh-command-context-trim # 从源
53
53
  - **与其它机制互斥**:命令在 `agent.runMaintenance()` 内执行(非 idle 直接失败),不会与回合、`/compact`、自动压缩交错;
54
54
  存在未闭合的 `compaction/start` 时也会拒绝执行。
55
55
 
56
+ ## 撞墙自动 trim
57
+
58
+ `autoTrim`(默认开)让同一套"无模型调用"的裁剪在无人值守时发生:用 **`prepend`** 注册的 `agent/request-error`
59
+ 监听器在 `CONTEXT_WINDOW_EXCEEDED` 时裁剪并请求重试。
60
+
61
+ ```
62
+ 请求撞墙
63
+ ├─ prepend: context-trim → 裁剪一段,零模型调用 → retry ← 能腾出空间时由它解决
64
+ └─ next(): compaction-basic → 先 prune 工具结果再摘要(LLM) ← 只在 trim 无能为力时
65
+ ```
66
+
67
+ 为什么 `prepend` 是关键:`agent/request-error` 是 Cordis 的 **waterfall**,compaction 也在同一事件上注册了自己的
68
+ 摘要恢复;Cordis 按注册顺序存放监听器,`{ prepend: true }` 会 `unshift` 到最前,所以即使 compaction 是稍后在
69
+ agent-preset 的 isolate realm 里挂载的(web profile 里就是这样),本插件依然先执行。不调用 `next()` 即否决该次
70
+ 摘要。
71
+
72
+ **范围刻意收窄**:只有 `CONTEXT_WINDOW_EXCEEDED` 才触发;其它请求错误、普通阈值 compaction(`agent/pre-step` 压力路径)、
73
+ `/compact`、工具结果 pruner **一律不碰**(有测试锁定注册的监听器集合)。
74
+
75
+ 每轮溢出 epis 的额度由 `maxAutoTrimRetries`(默认 1)限制,收到完成的 assistant 消息或 agent 空闲即重置。
76
+ 代价如实说:自动 trim 是**丢弃**最旧一段而不是摘要它——在小窗口下这正是要点,但被丢的内容只会变成一条占位标记。
77
+ `/compact` 仍在,原文也仍在会话日志里。
78
+
56
79
  ## 保护集与选段策略
57
80
 
58
- 保护:开头 `protectHeadNodes`(默认 1,即任务声明)、末尾最近 `retainRatio` 窗口(下限 `minTailTokens`)、以及最后一条消息永不裁剪。
81
+ 保护与**优先级**(永远从最旧处开始裁,按"损失最小"逐档尝试):
82
+
83
+ 1. 留在保留尾部之外,且**保留最后一条**(先按配置的保留量;实在放不下才逐级放宽保留量);
84
+ 2. 可以进入保留尾部,但仍**保留最后一条**;
85
+ 3. **最后一档**才允许把最后一条纳入——通常就是当前这步的 assistant tool-call 与它的 tool-result(两者只能成对移除)。
86
+
87
+ 另外:开头 `protectHeadNodes`(默认 1,即任务声明)与**最新的那条 `user/message`(你当前的指令)是硬屏障**——永不裁剪、也不被跨越。把最后一条按位置硬保护会卡死最常见的溢出形态(实测探针:`largest balanced span frees ~4 of the ~4631 tokens needed`)。`allowTailTrim: false` 时搜索在第 1 档后结束:保留尾部成为硬边界,最后一条永不丢弃。
59
88
  在保护集之间采用**最旧优先、够用即止**:从最旧的平衡切点开始,只增长到刚好释放够 token。
60
89
 
90
+ ## 兼容性
91
+
92
+ | Harness | 状态 |
93
+ |---|---|
94
+ | 0.1.2-rc.1(`latest`) | ✅ 全套测试通过 |
95
+ | 0.1.5-rc.2(`next`) | ✅ 全套测试通过 |
96
+
97
+ 两处 0.1.5 变更已在不做版本号判断的前提下兼容:
98
+
99
+ - **替换标记改名**:`{op:'replace', start, end}` → `{op:'replace', startSeq, endSeq}`。插件首次使用时用一个一次性游离 session 探测本机 harness 接受哪种形状,再按该形状写入。
100
+ - **system prompt 从 header 搬到了 surface**(0.1.5 作为 `system/message` 节点 0)。system 节点被当作**屏障**:永不裁剪、任何被裁区间都不得跨越它;同时 `protectHeadNodes` 只统计非屏障节点,因此"保护头部"保护的仍是**用户的任务声明**,而不是 system prompt。
101
+
102
+ 因此"固定请求开销"的含义变为工具 schema + 其它非 surface 请求数据(0.1.2 上还包含 system prompt);裁剪预算本身不受影响,因为它来自 token meter 的总量。
103
+
61
104
  ## 配置
62
105
 
63
106
  在 profile patch 的 `context-trim` 行上覆盖(`cordis.patch.yml` 里列出了全部默认值):
@@ -82,10 +125,10 @@ npm test # node --test
82
125
  npm run link:harness # 也可改为从本地 dsh 安装的依赖闭包解析 @deepseek-ai
83
126
  ```
84
127
 
85
- 已验证:30 个测试全部通过(选段算法、参数解析、真实 Session 上的 surface 改写与日志重放、插件命令注册与端到端裁剪);
128
+ 已验证:34 个测试全部通过(选段算法、参数解析、真实 Session 上的 surface 改写与日志重放、插件命令注册与端到端裁剪,以及用**真实 `ctx.tokenMeter`** 验证「实测降幅 == 声明的 shadow price」和「新进程重放裁剪后日志得到完全一致的总量」);
86
129
  隔离 `DSH_HOME` 安装后 dependency 与 bundle 层均正确 reconcile;`dsh --dump-config` 中出现 `context-trim` 行;profile 启动无加载错误。
87
130
  CI 在 Node 22/24 上跑同一套测试;发布通过 `.github/workflows/publish.yml`(手动 `workflow_dispatch`)。
88
- 尚未执行:npm 首发(Trusted Publishing 无法创建全新包名,需要一次首发布引导)、以及 Web GUI 里的真实小窗口端到端验证。
131
+ npm 0.2.0(新增撞墙自动 trim),并已在隔离 profile 里从 registry 安装验证;尚未执行:Web GUI 里的真实小窗口端到端验证(mock provider 与隔离实例已就绪)。
89
132
 
90
133
  ## License
91
134
 
package/cordis.patch.yml CHANGED
@@ -27,3 +27,9 @@
27
27
  # Slack added to the priced replacement marker, so the post-trim request
28
28
  # stays under budget even though the marker text carries real numbers.
29
29
  markerSlackTokens: 64
30
+ # Trim automatically when a request hits the model's context wall
31
+ # (CONTEXT_WINDOW_EXCEEDED) instead of going straight to compaction.
32
+ # Ordinary threshold compaction is never touched.
33
+ autoTrim: true
34
+ # Automatic trims allowed per overflow episode before compaction takes over.
35
+ maxAutoTrimRetries: 1
package/lib/apply.js CHANGED
@@ -21,6 +21,7 @@
21
21
  * @module dsh-command-context-trim/apply
22
22
  */
23
23
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
24
+ import { replacementOp } from './session-compat.js';
24
25
 
25
26
  /** Plugin name written into every replacement message's source marker. */
26
27
  export const TRIM_PLUGIN = 'dsh-command-context-trim';
@@ -81,17 +82,18 @@ export function isTrimMarkerSource(source) {
81
82
  * @param session - session whose surface is rewritten.
82
83
  * @param plan - committed span plan naming the shadowed range.
83
84
  * @param marker - replacement message built for that plan.
85
+ * @param keys - replacement-marker keys this harness accepts ({@link replaceKeys}).
84
86
  * @returns the appended replacement event.
85
87
  * @throws when the session rejects the append (surface contract violation).
86
88
  */
87
- export function applyTrim(session, plan, marker) {
89
+ export function applyTrim(session, plan, marker, keys) {
88
90
  session.append('compaction/prune', {
89
91
  shadowedRange: { start: plan.startSeq, end: plan.endSeq },
90
92
  shadowedSeqs: [...plan.shadowedSeqs],
91
93
  shadowedTokenCount: plan.shadowedTokens
92
94
  });
93
95
  return session.append('user/message', marker, {
94
- surfaceOp: { op: 'replace', start: plan.startSeq, end: plan.endSeq },
96
+ surfaceOp: replacementOp(keys, plan.startSeq, plan.endSeq),
95
97
  sourceEventSeqs: [...plan.shadowedSeqs]
96
98
  });
97
99
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Automatic trimming when a request hits the model's context wall.
3
+ *
4
+ * This is the unattended neighbour of `/trim`: a **prepended**
5
+ * `agent/request-error` listener reacts to `CONTEXT_WINDOW_EXCEEDED`, frees space
6
+ * with no model call, and asks the loop to retry. Only when it cannot help does
7
+ * the waterfall continue into DSH's own recovery — which prunes oversized tool
8
+ * results and then *summarizes*. That ordering is the whole point: dropping the
9
+ * oldest span is cheap and works when every request is failing, whereas the
10
+ * summarizer must itself fit the window while holding the region it is condensing,
11
+ * so on a small local window it frequently fails for the same reason the original
12
+ * request did.
13
+ *
14
+ * Why `prepend` matters: `agent/request-error` is a Cordis **waterfall**, and
15
+ * `@deepseek-ai/dsh-compaction-basic` registers its summarization recovery on the
16
+ * same event. Listeners are stored in registration order and `{ prepend: true }`
17
+ * unshifts to the front, so this listener runs first regardless of which bundle
18
+ * mounted compaction (in a web profile compaction lives inside an agent-preset
19
+ * isolate realm, mounted later than any host-plane plugin). Returning
20
+ * `{ kind: 'retry' }` without calling `next()` vetoes the rest of the chain for
21
+ * that attempt; calling `next()` hands the problem to compaction.
22
+ *
23
+ * @module dsh-command-context-trim/auto-trim
24
+ */
25
+ import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm';
26
+ import { logLine, describeError } from './render.js';
27
+ import { executeTrim } from './trim-session.js';
28
+
29
+ /**
30
+ * Register automatic context-overflow trimming.
31
+ * @param ctx - plugin context (token meter, LLM service, events, logger).
32
+ * @param config - resolved configuration.
33
+ * @returns nothing; listeners are owned by the plugin's fiber and disposed with it.
34
+ */
35
+ export function registerAutoTrim(ctx, config) {
36
+ if (config.autoTrim !== true) return;
37
+ /** agent -> automatic trims already spent in the current overflow episode. */
38
+ const spent = new WeakMap();
39
+ /** session -> agent, so a successful assistant message can reset the budget. */
40
+ const actors = new WeakMap();
41
+ ctx.on(
42
+ 'agent/request-error',
43
+ async ({ agent, failure, signal }, next) => {
44
+ if (failure?.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next();
45
+ const used = spent.get(agent) ?? 0;
46
+ if (used >= config.maxAutoTrimRetries) {
47
+ log(ctx, 'info', `context-overflow auto-trim: retry budget spent (${used}); leaving recovery to compaction`);
48
+ return next();
49
+ }
50
+ const generation = agent.session.surface.replaceGeneration;
51
+ let outcome;
52
+ try {
53
+ outcome = await executeTrim(ctx, config, { agent, signal, routedOnly: true });
54
+ } catch (error) {
55
+ log(ctx, 'warn', `context-overflow auto-trim failed (${describeError(error)}); leaving recovery to compaction`);
56
+ return next();
57
+ }
58
+ if (signal.aborted) return next();
59
+ if (agent.session.surface.replaceGeneration <= generation) {
60
+ log(ctx, 'info', `context-overflow auto-trim: nothing safely trimmable; leaving recovery to compaction`);
61
+ return next();
62
+ }
63
+ spent.set(agent, used + 1);
64
+ actors.set(agent.session, agent);
65
+ log(ctx, 'info', `context-overflow auto-trim: ${logLine(outcome.plan, outcome.after, outcome.label)}`);
66
+ return { kind: 'retry' };
67
+ },
68
+ { prepend: true }
69
+ );
70
+ // Mirror compaction-basic's accounting: one overflow episode's budget resets
71
+ // once the conversation advances or the agent goes idle.
72
+ ctx.on('agent/status', ({ agent, status }) => {
73
+ if (status === 'idle') spent.delete(agent);
74
+ });
75
+ ctx.on('session/event', (session, event) => {
76
+ if (event.type !== 'assistant/message') return;
77
+ const agent = actors.get(session);
78
+ if (agent !== undefined) spent.delete(agent);
79
+ });
80
+ }
81
+
82
+ /** Log through the context logger when it is available. */
83
+ function log(ctx, level, message) {
84
+ const logger = ctx.logger;
85
+ if (logger?.[level] === undefined) return;
86
+ logger[level](`context-trim: ${message}`);
87
+ }
package/lib/config.js CHANGED
@@ -15,7 +15,9 @@ export const DEFAULTS = Object.freeze({
15
15
  minTailTokens: 2048,
16
16
  protectHeadNodes: 1,
17
17
  allowTailTrim: true,
18
- markerSlackTokens: 64
18
+ markerSlackTokens: 64,
19
+ autoTrim: true,
20
+ maxAutoTrimRetries: 1
19
21
  });
20
22
 
21
23
  /** Every key this plugin accepts. */
@@ -27,7 +29,9 @@ const CONFIG_KEYS = new Set([
27
29
  'minTailTokens',
28
30
  'protectHeadNodes',
29
31
  'allowTailTrim',
30
- 'markerSlackTokens'
32
+ 'markerSlackTokens',
33
+ 'autoTrim',
34
+ 'maxAutoTrimRetries'
31
35
  ]);
32
36
 
33
37
  /**
@@ -48,11 +52,15 @@ export function resolveConfig(config = {}) {
48
52
  const protectHeadNodes = config.protectHeadNodes ?? DEFAULTS.protectHeadNodes;
49
53
  const allowTailTrim = config.allowTailTrim ?? DEFAULTS.allowTailTrim;
50
54
  const markerSlackTokens = config.markerSlackTokens ?? DEFAULTS.markerSlackTokens;
55
+ const autoTrim = config.autoTrim ?? DEFAULTS.autoTrim;
56
+ const maxAutoTrimRetries = config.maxAutoTrimRetries ?? DEFAULTS.maxAutoTrimRetries;
51
57
  assertRatio('targetRatio', targetRatio);
52
58
  assertNonNegativeInteger('reserveOutputTokens', reserveOutputTokens);
53
59
  assertNonNegativeInteger('minTailTokens', minTailTokens);
54
60
  assertNonNegativeInteger('protectHeadNodes', protectHeadNodes);
55
61
  assertNonNegativeInteger('markerSlackTokens', markerSlackTokens);
62
+ assertNonNegativeInteger('maxAutoTrimRetries', maxAutoTrimRetries);
63
+ if (typeof autoTrim !== 'boolean') throw new Error('ContextTrimConfig: autoTrim must be a boolean');
56
64
  if (typeof allowTailTrim !== 'boolean') throw new Error('ContextTrimConfig: allowTailTrim must be a boolean');
57
65
  const retention = resolveRetention(config);
58
66
  if (retention.retainRatio !== undefined && retention.retainRatio >= targetRatio) {
@@ -65,7 +73,9 @@ export function resolveConfig(config = {}) {
65
73
  minTailTokens,
66
74
  protectHeadNodes,
67
75
  allowTailTrim,
68
- markerSlackTokens
76
+ markerSlackTokens,
77
+ autoTrim,
78
+ maxAutoTrimRetries
69
79
  });
70
80
  }
71
81