dsh-command-context-trim 0.2.0 → 0.2.2

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,62 @@ All notable changes to this project are documented here. This project adheres to
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.2.2] - 2026-09-22
9
+
10
+ ### Added
11
+
12
+ - **Cheap reduction first: oversized tool results are slimmed in place before any span is elided.** On the wall the
13
+ plugin now performs the same head/marker/tail transform DSH's own pruner performs — keeping the node, its tool call and
14
+ the prefix up to it — and only elides a whole span when that is not enough. It delegates to the official
15
+ `toolResultPruner` service when that service is reachable from the plugin's context (0.1.2; a 0.1.5 headless profile
16
+ where compaction stays on the host plane) and performs the identical transform itself when it is not (a 0.1.5 web
17
+ profile hides the pruner inside an agent-preset isolate realm). New configuration: `preferInPlacePrune` (default true)
18
+ and `pruneThresholdChars` / `pruneHeadChars` / `pruneTailChars` (8192 / 4096 / 1024, mirroring DSH's defaults).
19
+ - The in-place marker is `[... tool result middle trimmed to fit the context window ...]`, deliberately distinct from
20
+ DSH's `[... tool result middle pruned ...]`, so a session log shows which producer slimmed a node.
21
+
22
+ ### Notes
23
+
24
+ - Verified end to end in the real `dsh-container` image (0.1.5-rc.2, isolated home, mock backend): a request of 23,429
25
+ tokens was refused, the plugin delegated to the official pruner, the retry came back at 19,995 tokens, the turn
26
+ completed, and the session log shows **no `compaction/start`** and **no span elided** — the pruner's `tool/result`
27
+ replacement kept `callId` and the step. The earlier span path was verified the same way (33,373 → 18,431 tokens).
28
+ The inline fallback (used only where the service is unreachable, i.e. a 0.1.5 web profile) is unit-tested against the
29
+ same semantics but not yet exercised end to end.
30
+ - `scripts/mock-overflow-server.mjs` gained `TOOL_COMMAND` / `TOOL_DESCRIPTION`: a bash tool call without a `description`
31
+ fails argument validation, which silently produced tiny error results instead of real tool output during testing.
32
+
33
+
34
+ ## [0.2.1] - 2026-09-22
35
+
36
+ ### Fixed
37
+
38
+ - **A wall hit is no longer a one-shot.** Reported from a 32k backend while `settings.yaml` declared `contextWindow:
39
+ 90000`: the first automatic trim targeted `(90000 - 8192) * 0.9 ~= 73.6k`, the retry was rejected again, and with
40
+ `maxAutoTrimRetries: 1` spent the recovery fell through to compaction — whose summarisation request is itself over the
41
+ real limit, so the turn died. The plugin now adapts instead of trusting a declaration it has just seen contradicted:
42
+ - the **first** attempt still trusts the declared window (cheap, and correct when the window is honest);
43
+ - every **later** attempt inside the same episode retargets to `failingRequestTokens * (1 - autoTrimShrink)`, i.e. it
44
+ halves the request that was just rejected — a geometric descent that converges however wrong the declared window is;
45
+ - a repeat attempt also logs an actionable warning: the routed model's `contextWindow` is larger than the backend
46
+ actually serves, fix it in `settings.yaml` (and the server's own context flag).
47
+ - Defaults changed accordingly: `maxAutoTrimRetries` **1 → 3**, and a new `autoTrimShrink` (**0.5**).
48
+
49
+ - **Automatic-trim decisions now log at `warn`.** An unattended rewrite of the user's context has to be visible in the
50
+ host log: a test run recorded the trim in the session log while the app log showed nothing, because `info` is filtered
51
+ at the default level.
52
+ - The README now explains how to tell this plugin's `compaction/prune` from DSH's tool-result pruner in a session log
53
+ (producer of the following event, and pre-step vs in-step position) and the two `usage` accounting traps.
54
+
55
+ ### Notes
56
+
57
+ - The honest fix for a mismatched backend is still the setting itself: with `contextWindow: 32768` the very first trim
58
+ targets ~22k and succeeds without the adaptive path. The adaptive path exists so a wrong declaration degrades into
59
+ "more trimming than necessary" rather than a dead turn.
60
+ - Nothing else changed: the event, its gating (`CONTEXT_WINDOW_EXCEEDED` only), the elision preference tiers and the
61
+ newest-user-message barrier are untouched.
62
+
63
+
8
64
  ## [0.2.0] - 2026-09-16
9
65
 
10
66
  ### Added
@@ -101,7 +157,9 @@ All notable changes to this project are documented here. This project adheres to
101
157
  content stays in the durable session log. v1 has no `/untrim`.
102
158
  - Requires a harness that exposes `ctx.commands`, `ctx.tokenMeter`, and `ctx.llm` (DeepSeek Harness 0.1.2-rc.1 or later).
103
159
 
104
- [Unreleased]: https://github.com/snailium/dsh-command-context-trim/compare/v0.2.0...HEAD
160
+ [Unreleased]: https://github.com/snailium/dsh-command-context-trim/compare/v0.2.2...HEAD
161
+ [0.2.2]: https://github.com/snailium/dsh-command-context-trim/compare/v0.2.1...v0.2.2
162
+ [0.2.1]: https://github.com/snailium/dsh-command-context-trim/compare/v0.2.0...v0.2.1
105
163
  [0.2.0]: https://github.com/snailium/dsh-command-context-trim/compare/v0.1.1...v0.2.0
106
164
  [0.1.1]: https://github.com/snailium/dsh-command-context-trim/compare/v0.1.0...v0.1.1
107
165
  [0.1.0]: https://github.com/snailium/dsh-command-context-trim/releases/tag/v0.1.0
package/README.md CHANGED
@@ -110,9 +110,30 @@ Scope, deliberately narrow:
110
110
  | ordinary threshold compaction (`agent/pre-step` pressure) | **never touched** |
111
111
  | `/compact`, the tool-result pruner | **never touched** |
112
112
 
113
- The per-episode retry budget (`maxAutoTrimRetries`, default 1) resets when a completed assistant message lands or the
113
+ The per-episode retry budget (`maxAutoTrimRetries`, default 3) resets when a completed assistant message lands or the
114
114
  agent goes idle, mirroring compaction's own overflow accounting. Set `autoTrim: false` to keep trimming manual.
115
115
 
116
+ **The cheap reduction runs first.** On the wall the plugin slims oversized tool results **in place** (head + marker + tail,
117
+ the same transform DSH's own pruner performs) and only elides a whole span when that is not enough:
118
+
119
+ ```
120
+ request refused → in-place tool-result slim → elide one span → compaction (prune + summarize)
121
+ ```
122
+
123
+ It calls the official `toolResultPruner` service when that service is reachable from the plugin's context (0.1.2, and a
124
+ 0.1.5 headless profile where compaction stays on the host plane) and performs the same transform itself when it is not (a
125
+ 0.1.5 web profile hides the pruner inside an agent-preset isolate realm). A slim keeps the node, its tool call and the
126
+ prefix up to it — the marker written in place says `[... tool result middle trimmed to fit the context window ...]`, which
127
+ is how a log shows that *this* plugin slimmed a node rather than DSH's pruner (`[... tool result middle pruned ...]`).
128
+ Set `preferInPlacePrune: false` to go straight to span elision.
129
+
130
+ **A wrong `contextWindow` degrades into extra trimming, not a dead turn.** The first attempt trusts the declared window.
131
+ If the retry is rejected again, the declaration has just been contradicted, so every later attempt in that episode
132
+ retargets to `failingRequestTokens * (1 - autoTrimShrink)` — by default *halving* the request that was rejected — which
133
+ converges however far the backend is below its declaration, and logs a warning naming the setting to fix. With an honest
134
+ window the first attempt succeeds and the adaptive path never runs. The real fix for a mismatched backend is the setting:
135
+ `contextWindow: 32768` makes the first trim target ~22k and fit.
136
+
116
137
  Trade-off, stated plainly: an automatic trim **drops** the oldest span instead of summarizing it. On a small local
117
138
  window that is the point — the summarizer must fit the region it is condensing and frequently cannot — but the dropped
118
139
  text is replaced by a marker rather than a summary. `/compact` stays available, and the full text remains in the
@@ -171,14 +192,17 @@ Override on the `context-trim` row of a profile patch (the bundle's own `cordis.
171
192
  | Key | Default | Meaning |
172
193
  |---|---|---|
173
194
  | `targetRatio` | `0.9` | `budget = floor((contextWindow - reserveOutputTokens) * targetRatio)` |
174
- | `reserveOutputTokens` | `8192` | Output space kept for the model's own reply |
195
+ | `reserveOutputTokens` | `8192` | Output space kept for the model's own reply. Keep it plus the provider's own `maxTokens` inside the real window: a 32k server with `maxTokens: 16384` exhausts the window with prompt + output even when every prompt fits |
175
196
  | `retainRatio` / `retainTokens` | `0.16` / — | Recent tail kept verbatim (mutually exclusive forms) |
176
197
  | `minTailTokens` | `2048` | Absolute floor for that tail |
177
198
  | `protectHeadNodes` | `1` | Leading nodes that are never trimmed |
178
199
  | `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 |
179
200
  | `markerSlackTokens` | `64` | Slack added to the priced marker so the post-trim request stays under budget |
180
201
  | `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 |
202
+ | `maxAutoTrimRetries` | `3` | Automatic trims allowed per overflow episode before compaction takes over |
203
+ | `autoTrimShrink` | `0.5` | After a repeat overflow, retarget to this fraction of the rejected request (geometric descent when the declared window is wrong) |
204
+ | `preferInPlacePrune` | `true` | Slim oversized tool results in place before planning any span; uses the official pruner when reachable |
205
+ | `pruneThresholdChars` / `pruneHeadChars` / `pruneTailChars` | `8192` / `4096` / `1024` | In-place slim budgets, mirroring DSH's own pruner defaults |
182
206
 
183
207
  ## Limits
184
208
 
@@ -192,6 +216,28 @@ Override on the `context-trim` row of a profile patch (the bundle's own `cordis.
192
216
  - **Heuristic pricing.** Budgets use the token meter's own estimate — the same numbers `/compact` and the GUI context bar
193
217
  use. Provider-reported usage drifts slightly from it.
194
218
 
219
+ ## Reading the session log
220
+
221
+ `compaction/prune` has **two producers**, and only one of them is this plugin:
222
+
223
+ | Producer | Where | Followed by | Shape |
224
+ |---|---|---|---|
225
+ | **this plugin**, span elision | inside a step, right after a failed attempt | `user/message` whose `source.plugin` is `dsh-command-context-trim` | a whole span, both cut edges tool-pairing balanced |
226
+ | **this plugin**, in-place slim | same place, before any span is planned | `tool/result` replacing exactly one node | one `tool/result`, marker `[... tool result middle trimmed to fit the context window ...]`, `callId` kept |
227
+ | **DSH's tool-result pruner** | in compaction's own path | `tool/result` replacing exactly one node | a single `tool/result`, its `tool/call` kept |
228
+
229
+ Position is the other tell: a prune **between `step/end` and `step/start`** belongs to compaction's *pressure*
230
+ path (and this plugin, being overflow-only, is deliberately not involved); a prune **inside a step, after an
231
+ `assistant/attempt`** is an overflow recovery. A session with no `assistant/attempt` events never hit
232
+ `CONTEXT_WINDOW_EXCEEDED` at all, so this plugin never ran in it.
233
+
234
+ Two accounting traps when checking whether a trim helped: `assistant/message` `usage` is **per request**
235
+ (`input + cacheRead + output` for that call), not a session total — and a tool result is appended *after* the
236
+ request that produced its tool call, so comparing consecutive `usage.total` values measures "content was added",
237
+ not "the trim freed nothing". Compare the rejected attempt with the retry instead. Also, an in-place replacement of
238
+ the *last* node keeps the cached prefix (so `cacheRead` stays high); only a mid-conversation cut — this plugin, or
239
+ compaction rewriting the head — breaks it, which shows up as `cacheRead` dropping and `input` jumping on the next call.
240
+
195
241
  ## Development
196
242
 
197
243
  ### End-to-end overflow check (no model needed)
@@ -227,14 +273,15 @@ releases go out through `.github/workflows/publish.yml`, which is manual-only (`
227
273
 
228
274
  | Check | State |
229
275
  |---|---|
230
- | `npm test` (51 tests: planner, args, surface apply + log replay, plugin handler, automatic overflow path) | ✅ passing |
276
+ | `npm test` (72 tests: planner, args, surface apply + log replay, plugin handler, automatic overflow path, in-place slim, config) | ✅ passing |
231
277
  | Isolated `DSH_HOME` install (`dsh plugin add file:…`) reconciling dependency **and** bundle layer | ✅ verified |
232
278
  | Composed profile tree contains the `context-trim` insert row (`dsh --dump-config`) | ✅ verified |
233
279
  | Profile boot with the plugin mounted (no load error) | ✅ reaches the credential check cleanly |
234
- | Same suite against the pinned **published** harness packages (`npm ci`) | ✅ 51 passing |
280
+ | Same suite against the pinned **published** harness packages (`npm ci`) | ✅ 72 passing |
235
281
  | 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
282
  | 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 |
283
+ | Same suite on the newer harness line (renamed replacement marker + surface system prompt); CI resolves it via the `next` tag, currently 0.1.5-rc.3 | ✅ 72 passing |
284
+ | **End-to-end in the real `dsh-container` image (0.1.5-rc.2, isolated home, mock backend)**: span path (33,373 → 18,431 tokens, `compaction/start` = 0) and in-place slim path (23,429 → 19,995 tokens, no span elided, pruner delegated to the official service) | ✅ both verified |
238
285
  | CI workflow (Node 22 / 24) | ✅ green |
239
286
  | npm release via GitHub Actions | ✅ 0.1.0 published with provenance (`+ dsh-command-context-trim@0.1.0`) |
240
287
  | Isolated profile install **from the npm registry** (dependency + bundle layer + composed insert row) | ✅ 0.1.0 |
package/README.zh.md CHANGED
@@ -72,7 +72,17 @@ agent-preset 的 isolate realm 里挂载的(web profile 里就是这样),
72
72
  **范围刻意收窄**:只有 `CONTEXT_WINDOW_EXCEEDED` 才触发;其它请求错误、普通阈值 compaction(`agent/pre-step` 压力路径)、
73
73
  `/compact`、工具结果 pruner **一律不碰**(有测试锁定注册的监听器集合)。
74
74
 
75
- 每轮溢出 epis 的额度由 `maxAutoTrimRetries`(默认 1)限制,收到完成的 assistant 消息或 agent 空闲即重置。
75
+ 每轮溢出 episode 的额度由 `maxAutoTrimRetries`(默认 3)限制,收到完成的 assistant 消息或 agent 空闲即重置。
76
+
77
+ **便宜的那一步先做**:撞墙时先把超长 tool result **原地瘦身**(head + 标记 + tail,与 DSH 自带 pruner 同一变换),不够才整段裁剪:
78
+
79
+ ```
80
+ 请求被拒 → tool result 原地瘦身 → 整段裁剪一段 → compaction(prune + summarize)
81
+ ```
82
+
83
+ 官方 `toolResultPruner` 服务在插件上下文里可见时(0.1.2、以及 compaction 仍在宿主层的 0.1.5 headless)直接委托它;不可见时(0.1.5 web profile 把 pruner 藏在 agent-preset isolate realm 里)自己做同样的变换。瘦身保留节点、tool call 与其之前的前缀;写入的标记是 `[... tool result middle trimmed to fit the context window ...]`,与 DSH 自己的 `[... tool result middle pruned ...]` 可区分。`preferInPlacePrune: false` 可跳过这一步。
84
+
85
+ **声明窗口与实际不符时,退化成"多裁一点"而不是"回合死掉"**:第一次仍按声明的 `contextWindow` 定目标;若重试又被拒(说明声明已被事实推翻),此后同一 episode 内改为按 `failingRequestTokens × (1 − autoTrimShrink)` 重定目标——默认**减半**刚被拒的那次请求,几何收敛,并打印告警指出该去改 `settings.yaml` 的哪个设置。窗口声明正确时第一次就成功,自适应路径根本不会触发。根治办法仍是配置本身:`contextWindow: 32768` 时首次裁剪目标约 22k,直接命中。
76
86
  代价如实说:自动 trim 是**丢弃**最旧一段而不是摘要它——在小窗口下这正是要点,但被丢的内容只会变成一条占位标记。
77
87
  `/compact` 仍在,原文也仍在会话日志里。
78
88
 
@@ -127,8 +137,8 @@ npm run link:harness # 也可改为从本地 dsh 安装的依赖闭包解析 @
127
137
 
128
138
  已验证:34 个测试全部通过(选段算法、参数解析、真实 Session 上的 surface 改写与日志重放、插件命令注册与端到端裁剪,以及用**真实 `ctx.tokenMeter`** 验证「实测降幅 == 声明的 shadow price」和「新进程重放裁剪后日志得到完全一致的总量」);
129
139
  隔离 `DSH_HOME` 安装后 dependency 与 bundle 层均正确 reconcile;`dsh --dump-config` 中出现 `context-trim` 行;profile 启动无加载错误。
130
- CI 在 Node 22/24 上跑同一套测试;发布通过 `.github/workflows/publish.yml`(手动 `workflow_dispatch`)。
131
- npm 0.2.0(新增撞墙自动 trim),并已在隔离 profile 里从 registry 安装验证;尚未执行:Web GUI 里的真实小窗口端到端验证(mock provider 与隔离实例已就绪)。
140
+ CI 在 Node 22/24 × harness 0.1.2-rc.1/`next`(当前解析到 0.1.5-rc.3)四腿矩阵上跑同一套 61 项测试;发布通过 `.github/workflows/publish.yml`(手动 `workflow_dispatch`)。
141
+ npm 0.2.2(撞墙自动 trim:先原地瘦身、再整段裁剪、最后才摘要),并已在隔离 profile 里从 registry 安装验证;尚未执行:Web GUI 里的真实小窗口端到端验证(mock provider 与隔离实例已就绪)。
132
142
 
133
143
  ## License
134
144
 
package/cordis.patch.yml CHANGED
@@ -32,4 +32,20 @@
32
32
  # Ordinary threshold compaction is never touched.
33
33
  autoTrim: true
34
34
  # Automatic trims allowed per overflow episode before compaction takes over.
35
- maxAutoTrimRetries: 1
35
+ # More than one is needed when the declared contextWindow is larger than the
36
+ # backend really serves: see autoTrimShrink below.
37
+ maxAutoTrimRetries: 3
38
+ # After a repeat overflow the target becomes this fraction of the request that
39
+ # was just rejected (0.5 = halve it), which converges even when the declared
40
+ # window is wrong. The first attempt still trusts the declared window.
41
+ autoTrimShrink: 0.5
42
+ # Before eliding any span, slim oversized tool results IN PLACE (head +
43
+ # marker + tail), the same cheap reduction DSH's own pruner performs. Uses
44
+ # the official `toolResultPruner` service when it is reachable and performs
45
+ # the same transform itself when it is not (a 0.1.5 web profile hides it in
46
+ # an agent-preset isolate realm). A span is elided only when that is not
47
+ # enough.
48
+ preferInPlacePrune: true
49
+ pruneThresholdChars: 8192
50
+ pruneHeadChars: 4096
51
+ pruneTailChars: 1024
package/lib/auto-trim.js CHANGED
@@ -20,10 +20,18 @@
20
20
  * `{ kind: 'retry' }` without calling `next()` vetoes the rest of the chain for
21
21
  * that attempt; calling `next()` hands the problem to compaction.
22
22
  *
23
+ * Before any span is elided, oversized tool results are slimmed **in place** (the
24
+ * cheap, information-preserving reduction DSH itself uses); a span is only elided
25
+ * when that is not enough. See `./prune-first.js`.
26
+ *
27
+ * Every decision here logs at `warn`: an unattended rewrite of the user's context
28
+ * must be visible in the host log, not filtered behind an info level (an earlier
29
+ * run recorded a trim in the session log while the app log showed nothing).
30
+ *
23
31
  * @module dsh-command-context-trim/auto-trim
24
32
  */
25
33
  import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm';
26
- import { logLine, describeError } from './render.js';
34
+ import { logLineFor, describeError } from './render.js';
27
35
  import { executeTrim } from './trim-session.js';
28
36
 
29
37
  /**
@@ -44,25 +52,42 @@ export function registerAutoTrim(ctx, config) {
44
52
  if (failure?.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next();
45
53
  const used = spent.get(agent) ?? 0;
46
54
  if (used >= config.maxAutoTrimRetries) {
47
- log(ctx, 'info', `context-overflow auto-trim: retry budget spent (${used}); leaving recovery to compaction`);
55
+ log(ctx, 'warn', `context-overflow auto-trim: retry budget spent (${used}); leaving recovery to compaction`);
48
56
  return next();
49
57
  }
58
+ // First attempt trusts the routed model's declared window. Every later
59
+ // attempt has proof that the declared window is wrong — this exact request
60
+ // was rejected — so the target becomes a fraction of the size that just
61
+ // failed, which converges even when the backend is far smaller than
62
+ // settings.yaml claims.
63
+ const failingTotal = ctx.tokenMeter.measure(agent.session).totalTokens;
64
+ const budgetCeiling =
65
+ used === 0 ? undefined : Math.max(1, Math.floor(failingTotal * (1 - config.autoTrimShrink)));
66
+ if (budgetCeiling !== undefined) {
67
+ log(
68
+ ctx,
69
+ 'warn',
70
+ `context-overflow auto-trim: attempt ${used + 1} — a request of ~${failingTotal} tokens was still rejected; ` +
71
+ `retargeting to ~${budgetCeiling} tokens. If this repeats, the routed model's contextWindow is larger than the ` +
72
+ `backend actually serves — fix it in settings.yaml (and in the server's own context flag).`
73
+ );
74
+ }
50
75
  const generation = agent.session.surface.replaceGeneration;
51
76
  let outcome;
52
77
  try {
53
- outcome = await executeTrim(ctx, config, { agent, signal, routedOnly: true });
78
+ outcome = await executeTrim(ctx, config, { agent, signal, routedOnly: true, budgetCeiling, inPlaceFirst: true });
54
79
  } catch (error) {
55
80
  log(ctx, 'warn', `context-overflow auto-trim failed (${describeError(error)}); leaving recovery to compaction`);
56
81
  return next();
57
82
  }
58
83
  if (signal.aborted) return next();
59
84
  if (agent.session.surface.replaceGeneration <= generation) {
60
- log(ctx, 'info', `context-overflow auto-trim: nothing safely trimmable; leaving recovery to compaction`);
85
+ log(ctx, 'warn', `context-overflow auto-trim: nothing safely trimmable; leaving recovery to compaction`);
61
86
  return next();
62
87
  }
63
88
  spent.set(agent, used + 1);
64
89
  actors.set(agent.session, agent);
65
- log(ctx, 'info', `context-overflow auto-trim: ${logLine(outcome.plan, outcome.after, outcome.label)}`);
90
+ log(ctx, 'warn', `context-overflow auto-trim: ${logLineFor(outcome)}`);
66
91
  return { kind: 'retry' };
67
92
  },
68
93
  { prepend: true }
package/lib/config.js CHANGED
@@ -7,6 +7,8 @@
7
7
  * @module dsh-command-context-trim/config
8
8
  */
9
9
 
10
+ import { TRIM_MARKER } from './prune-first.js';
11
+
10
12
  /** Built-in defaults, mirrored by the bundle patch's commented config block. */
11
13
  export const DEFAULTS = Object.freeze({
12
14
  targetRatio: 0.9,
@@ -17,7 +19,12 @@ export const DEFAULTS = Object.freeze({
17
19
  allowTailTrim: true,
18
20
  markerSlackTokens: 64,
19
21
  autoTrim: true,
20
- maxAutoTrimRetries: 1
22
+ maxAutoTrimRetries: 3,
23
+ autoTrimShrink: 0.5,
24
+ preferInPlacePrune: true,
25
+ pruneThresholdChars: 8192,
26
+ pruneHeadChars: 4096,
27
+ pruneTailChars: 1024
21
28
  });
22
29
 
23
30
  /** Every key this plugin accepts. */
@@ -31,7 +38,12 @@ const CONFIG_KEYS = new Set([
31
38
  'allowTailTrim',
32
39
  'markerSlackTokens',
33
40
  'autoTrim',
34
- 'maxAutoTrimRetries'
41
+ 'maxAutoTrimRetries',
42
+ 'autoTrimShrink',
43
+ 'preferInPlacePrune',
44
+ 'pruneThresholdChars',
45
+ 'pruneHeadChars',
46
+ 'pruneTailChars'
35
47
  ]);
36
48
 
37
49
  /**
@@ -54,13 +66,29 @@ export function resolveConfig(config = {}) {
54
66
  const markerSlackTokens = config.markerSlackTokens ?? DEFAULTS.markerSlackTokens;
55
67
  const autoTrim = config.autoTrim ?? DEFAULTS.autoTrim;
56
68
  const maxAutoTrimRetries = config.maxAutoTrimRetries ?? DEFAULTS.maxAutoTrimRetries;
69
+ const autoTrimShrink = config.autoTrimShrink ?? DEFAULTS.autoTrimShrink;
70
+ const preferInPlacePrune = config.preferInPlacePrune ?? DEFAULTS.preferInPlacePrune;
71
+ const pruneThresholdChars = config.pruneThresholdChars ?? DEFAULTS.pruneThresholdChars;
72
+ const pruneHeadChars = config.pruneHeadChars ?? DEFAULTS.pruneHeadChars;
73
+ const pruneTailChars = config.pruneTailChars ?? DEFAULTS.pruneTailChars;
57
74
  assertRatio('targetRatio', targetRatio);
58
75
  assertNonNegativeInteger('reserveOutputTokens', reserveOutputTokens);
59
76
  assertNonNegativeInteger('minTailTokens', minTailTokens);
60
77
  assertNonNegativeInteger('protectHeadNodes', protectHeadNodes);
61
78
  assertNonNegativeInteger('markerSlackTokens', markerSlackTokens);
62
79
  assertNonNegativeInteger('maxAutoTrimRetries', maxAutoTrimRetries);
80
+ assertRatio('autoTrimShrink', autoTrimShrink);
63
81
  if (typeof autoTrim !== 'boolean') throw new Error('ContextTrimConfig: autoTrim must be a boolean');
82
+ if (typeof preferInPlacePrune !== 'boolean') throw new Error('ContextTrimConfig: preferInPlacePrune must be a boolean');
83
+ assertNonNegativeInteger('pruneHeadChars', pruneHeadChars);
84
+ assertNonNegativeInteger('pruneTailChars', pruneTailChars);
85
+ assertPositiveInteger('pruneThresholdChars', pruneThresholdChars);
86
+ // Mirror the official pruner's load-time guard: the emitted head + marker + tail
87
+ // must fit inside the threshold, or a rewrite could not both shrink and comply.
88
+ const emitted = pruneHeadChars + TRIM_MARKER.length + pruneTailChars;
89
+ if (emitted > pruneThresholdChars) {
90
+ throw new Error(`ContextTrimConfig: pruneHeadChars + marker + pruneTailChars (${emitted}) must be at most pruneThresholdChars (${pruneThresholdChars})`);
91
+ }
64
92
  if (typeof allowTailTrim !== 'boolean') throw new Error('ContextTrimConfig: allowTailTrim must be a boolean');
65
93
  const retention = resolveRetention(config);
66
94
  if (retention.retainRatio !== undefined && retention.retainRatio >= targetRatio) {
@@ -75,7 +103,12 @@ export function resolveConfig(config = {}) {
75
103
  allowTailTrim,
76
104
  markerSlackTokens,
77
105
  autoTrim,
78
- maxAutoTrimRetries
106
+ maxAutoTrimRetries,
107
+ autoTrimShrink,
108
+ preferInPlacePrune,
109
+ pruneThresholdChars,
110
+ pruneHeadChars,
111
+ pruneTailChars
79
112
  });
80
113
  }
81
114
 
@@ -130,6 +163,12 @@ function assertRatio(name, value) {
130
163
  }
131
164
  }
132
165
 
166
+ function assertPositiveInteger(name, value) {
167
+ if (!Number.isInteger(value) || value <= 0) {
168
+ throw new Error(`ContextTrimConfig: ${name} (${String(value)}) must be a positive integer`);
169
+ }
170
+ }
171
+
133
172
  function assertNonNegativeInteger(name, value) {
134
173
  if (!Number.isInteger(value) || value < 0) {
135
174
  throw new Error(`ContextTrimConfig: ${name} (${String(value)}) must be a non-negative integer`);
@@ -0,0 +1,131 @@
1
+ /**
2
+ * In-place tool-result slimming, run **before** any span elision.
3
+ *
4
+ * DSH already owns this idea (`@deepseek-ai/dsh-compaction-tool-result-pruner`),
5
+ * but a host-plane plugin can only reach that service where compaction itself is
6
+ * mounted on the host plane. In a 0.1.5 web profile the host row is disabled and
7
+ * the pruner is re-mounted inside an agent-preset isolate realm, where
8
+ * `ctx.get('toolResultPruner')` resolves nothing. So this module prefers the
9
+ * official service when it is visible and otherwise performs the same transform
10
+ * itself, which keeps the cheap reduction ahead of the expensive one:
11
+ *
12
+ * slim oversized tool results in place → elide a whole span → compaction
13
+ *
14
+ * The transform mirrors the official one: text is measured and sliced in Unicode
15
+ * code points (never splitting a surrogate pair), the head and tail are kept, one
16
+ * marker replaces the removed middle, non-text blocks keep their order, and the
17
+ * replacement carries the complete original event data except `content` — so the
18
+ * tool call keeps its result and the step stays intact.
19
+ *
20
+ * A `tool/result` replacement is only legal inside an open turn, which is exactly
21
+ * the state the automatic overflow path runs in; the idle `/trim` command never
22
+ * uses this module.
23
+ *
24
+ * @module dsh-command-context-trim/prune-first
25
+ */
26
+ import { freezeMessage } from '@deepseek-ai/dsh-llm';
27
+ import { replaceKeys, replacementOp } from './session-compat.js';
28
+
29
+ /**
30
+ * Marker replacing the removed middle. Deliberately worded for the model and
31
+ * distinct from DSH's own `[... tool result middle pruned ...]`, so a session log
32
+ * shows which producer slimmed a node.
33
+ */
34
+ export const TRIM_MARKER = '\n\n[... tool result middle trimmed to fit the context window ...]\n\n';
35
+
36
+ /**
37
+ * Slim every over-budget tool result on the current surface.
38
+ * @param ctx - plugin context; `ctx.get('toolResultPruner')` is used when present.
39
+ * @param session - session whose surface is rewritten.
40
+ * @param config - resolved configuration.
41
+ * @returns `{ pruned, via, replacementSeq }`; `pruned` is the number of nodes rewritten.
42
+ */
43
+ export function shrinkOversizedToolResults(ctx, session, config) {
44
+ if (config.preferInPlacePrune !== true) return { pruned: 0, via: 'disabled' };
45
+ const official = ctx.get?.('toolResultPruner');
46
+ if (official !== undefined && typeof official.pruneSession === 'function') {
47
+ const outcome = official.pruneSession(session);
48
+ const pruned = Array.isArray(outcome?.pruned) ? outcome.pruned.length : 0;
49
+ return { pruned, via: 'service', replacementSeq: outcome?.pruned?.at(-1)?.replacementSeq };
50
+ }
51
+ const keys = replaceKeys();
52
+ let pruned = 0;
53
+ let replacementSeq;
54
+ // Snapshot first: replacements are appended while iterating.
55
+ for (const seq of [...session.surface.nodes]) {
56
+ const event = session.eventAt(seq);
57
+ if (event?.type !== 'tool/result') continue;
58
+ const result = event.data.message.content[0];
59
+ const content = pruneContent(result.content, config);
60
+ if (content === null) continue;
61
+ const message = freezeMessage({
62
+ ...event.data.message,
63
+ content: [{ ...result, content }]
64
+ });
65
+ session.append('compaction/prune', {
66
+ shadowedRange: { start: seq, end: seq },
67
+ shadowedSeqs: [seq],
68
+ shadowedTokenCount: ctx.tokenMeter.estimateMessage(event.data.message)
69
+ });
70
+ const replacement = session.append('tool/result', { ...event.data, message }, {
71
+ surfaceOp: replacementOp(keys, seq, seq),
72
+ sourceEventSeqs: [seq]
73
+ });
74
+ pruned += 1;
75
+ replacementSeq = replacement.seq;
76
+ }
77
+ return { pruned, via: 'inline', ...(replacementSeq === undefined ? {} : { replacementSeq }) };
78
+ }
79
+
80
+ /**
81
+ * Measure tool-result text in Unicode code points; non-text blocks cost zero.
82
+ * @param blocks - tool-result content blocks.
83
+ * @returns total code points across text blocks.
84
+ */
85
+ export function measureContent(blocks) {
86
+ let chars = 0;
87
+ for (const block of blocks) if (block.type === 'text') chars += codePointLength(block.text);
88
+ return chars;
89
+ }
90
+
91
+ /**
92
+ * Replace an over-budget text middle while retaining rich-block order.
93
+ * @param blocks - original tool-result content.
94
+ * @param config - resolved configuration with the character budgets.
95
+ * @returns rewritten content, or `null` when the text is already within budget.
96
+ */
97
+ export function pruneContent(blocks, config) {
98
+ const totalChars = measureContent(blocks);
99
+ if (totalChars <= config.pruneThresholdChars) return null;
100
+ const removedStart = config.pruneHeadChars;
101
+ const removedEnd = totalChars - config.pruneTailChars;
102
+ const pruned = [];
103
+ let consumed = 0;
104
+ let markerInserted = false;
105
+ for (const block of blocks) {
106
+ if (block.type !== 'text') {
107
+ pruned.push(block);
108
+ continue;
109
+ }
110
+ const points = Array.from(block.text);
111
+ const blockStart = consumed;
112
+ const blockEnd = blockStart + points.length;
113
+ const headEnd = Math.min(points.length, Math.max(0, removedStart - blockStart));
114
+ const tailStart = Math.min(points.length, Math.max(0, removedEnd - blockStart));
115
+ const marker = blockStart < removedEnd && blockEnd > removedStart && !markerInserted ? TRIM_MARKER : '';
116
+ if (marker.length > 0) markerInserted = true;
117
+ const text = points.slice(0, headEnd).join('') + marker + points.slice(tailStart).join('');
118
+ if (text.length > 0) pruned.push({ ...block, text });
119
+ consumed = blockEnd;
120
+ }
121
+ if (!markerInserted) return null;
122
+ const charsAfter = measureContent(pruned);
123
+ // Refuse a rewrite that would not actually be smaller and within budget.
124
+ if (charsAfter > config.pruneThresholdChars || charsAfter >= totalChars) return null;
125
+ return pruned;
126
+ }
127
+
128
+ /** Count Unicode code points without splitting surrogate pairs. */
129
+ function codePointLength(text) {
130
+ return Array.from(text).length;
131
+ }
package/lib/render.js CHANGED
@@ -113,3 +113,21 @@ export function describeError(error) {
113
113
  return '<unrenderable thrown value>';
114
114
  }
115
115
  }
116
+
117
+ /**
118
+ * Render one automatic-path outcome, which may be an in-place slim with no span
119
+ * elision, a span elision, or a slim followed by a span elision.
120
+ * @param outcome - the object returned by `executeTrim`.
121
+ * @returns a single-line account.
122
+ */
123
+ export function logLineFor(outcome) {
124
+ if (outcome.plan === undefined) {
125
+ return (
126
+ `slimmed ${outcome.pruned.pruned} oversized tool result(s) in place for ${outcome.label}; ` +
127
+ `request ~${outcome.before.totalTokens} → ~${outcome.after.totalTokens} tokens`
128
+ );
129
+ }
130
+ const base = logLine(outcome.plan, outcome.after, outcome.label);
131
+ if (outcome.pruned === undefined || outcome.pruned.pruned === 0) return base;
132
+ return `${outcome.pruned.pruned} tool result(s) slimmed in place, then ${base}`;
133
+ }
@@ -14,6 +14,7 @@ import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-a
14
14
  import { applyTrim, createMarkerMessage, provisionalMarker } from './apply.js';
15
15
  import { budgetFor, retentionFor } from './config.js';
16
16
  import { planTrim } from './plan.js';
17
+ import { shrinkOversizedToolResults } from './prune-first.js';
17
18
  import { describeNonSpan, preview, report } from './render.js';
18
19
  import { replaceKeys } from './session-compat.js';
19
20
  import { resolveTarget, targetHeader } from './target.js';
@@ -26,6 +27,10 @@ import { resolveTarget, targetHeader } from './target.js';
26
27
  * @property {number} [explicitBudget] - explicit token budget instead of a resolved window.
27
28
  * @property {boolean} [check] - plan only, mutate nothing.
28
29
  * @property {boolean} [routedOnly] - target the route the last durable request used.
30
+ * @property {number} [budgetCeiling] - hard upper bound on the target budget, used when a
31
+ * request of a known size has just been rejected and the declared window cannot be trusted.
32
+ * @property {boolean} [inPlaceFirst] - slim oversized tool results in place before planning a
33
+ * span. Only legal inside an open turn, so the automatic path sets it and `/trim` never does.
29
34
  */
30
35
 
31
36
  /**
@@ -38,16 +43,43 @@ import { resolveTarget, targetHeader } from './target.js';
38
43
  * surface was actually rewritten.
39
44
  */
40
45
  export async function executeTrim(ctx, config, request) {
41
- const { agent, signal, requestedRoute, explicitBudget, check = false, routedOnly = false } = request;
46
+ const { agent, signal, requestedRoute, explicitBudget, check = false, routedOnly = false, budgetCeiling, inPlaceFirst = false } = request;
42
47
  signal?.throwIfAborted?.();
43
48
  const session = agent.session;
44
49
  assertNoOpenCompaction(session);
45
50
  const target =
46
51
  explicitBudget === undefined ? await resolveTarget(ctx, agent, requestedRoute, signal, { routedOnly }) : undefined;
47
- const budget = explicitBudget ?? budgetFor(target.contextWindow, config);
48
- const label = target === undefined ? `an explicit ${budget}-token budget` : `${target.label} (window ${target.contextWindow})`;
52
+ const windowBudget = explicitBudget ?? budgetFor(target.contextWindow, config);
53
+ const budget = budgetCeiling === undefined ? windowBudget : Math.min(windowBudget, budgetCeiling);
54
+ const baseLabel = target === undefined ? `an explicit ${budget}-token budget` : `${target.label} (window ${target.contextWindow})`;
55
+ const label = budget === windowBudget ? baseLabel : `${baseLabel}, capped at ${budget}`;
49
56
  const header = target === undefined ? undefined : targetHeader(session, target);
50
- const measurement = ctx.tokenMeter.measure(session, header);
57
+ let measurement = ctx.tokenMeter.measure(session, header);
58
+ // Cheap reduction first: a single oversized tool result is slimmed in place
59
+ // (keeping the node, its tool call and the prefix up to it) and a span is only
60
+ // elided when that is not enough.
61
+ let pruned;
62
+ if (inPlaceFirst) {
63
+ const shrunk = shrinkOversizedToolResults(ctx, session, config);
64
+ if (shrunk.pruned > 0) {
65
+ const afterPrune = ctx.tokenMeter.measure(session, header);
66
+ pruned = shrunk;
67
+ if (afterPrune.totalTokens <= budget) {
68
+ return {
69
+ result: {
70
+ kind: 'success',
71
+ text: `Slimmed ${shrunk.pruned} oversized tool result(s) in place for ${label}: ~${measurement.totalTokens} → ~${afterPrune.totalTokens} tokens (target ${budget}).`,
72
+ ...(shrunk.replacementSeq === undefined ? {} : { sourceEventSeq: shrunk.replacementSeq })
73
+ },
74
+ label,
75
+ before: measurement,
76
+ after: afterPrune,
77
+ pruned: shrunk
78
+ };
79
+ }
80
+ measurement = afterPrune;
81
+ }
82
+ }
51
83
  const nodes = measurement.nodes.map((node) => {
52
84
  const type = session.eventAt(node.seq)?.type;
53
85
  return {
@@ -65,6 +97,10 @@ export async function executeTrim(ctx, config, request) {
65
97
  if (nodes.length === 0) {
66
98
  return { result: { kind: 'success', text: `Nothing to trim: ${session.id} has no model-visible messages yet.` } };
67
99
  }
100
+ const notePrune = (result) =>
101
+ pruned === undefined
102
+ ? result
103
+ : { ...result, text: `${result.text}\n(Note: ${pruned.pruned} oversized tool result(s) were already slimmed in place.)` };
68
104
  // The retained tail scales with the capacity being fitted: the target window
69
105
  // when one is known, otherwise the explicit budget itself.
70
106
  const retainTokens = retentionFor(target?.contextWindow ?? budget, config);
@@ -84,18 +120,18 @@ export async function executeTrim(ctx, config, request) {
84
120
  });
85
121
  let markerTokens = ctx.tokenMeter.estimateMessage(provisionalMarker());
86
122
  let plan = planFor(markerTokens);
87
- if (plan.kind !== 'span') return { result: describeNonSpan(plan, label), plan, label, before: measurement };
123
+ if (plan.kind !== 'span') return { result: notePrune(describeNonSpan(plan, label)), plan, label, before: measurement, ...(pruned === undefined ? {} : { pruned }) };
88
124
  let marker = createMarkerMessage(plan, target?.label ?? `budget ${budget}`, budget);
89
125
  const finalMarkerTokens = ctx.tokenMeter.estimateMessage(marker);
90
126
  if (finalMarkerTokens > markerTokens) {
91
127
  // The marker now carries real numbers; re-plan once so its own price is exact.
92
128
  plan = planFor(finalMarkerTokens);
93
- if (plan.kind !== 'span') return { result: describeNonSpan(plan, label), plan, label, before: measurement };
129
+ if (plan.kind !== 'span') return { result: notePrune(describeNonSpan(plan, label)), plan, label, before: measurement, ...(pruned === undefined ? {} : { pruned }) };
94
130
  marker = createMarkerMessage(plan, target?.label ?? `budget ${budget}`, budget);
95
131
  markerTokens = finalMarkerTokens;
96
132
  }
97
133
  if (check) {
98
- return { result: { kind: 'success', text: preview(plan, label, markerTokens) }, plan, label, before: measurement };
134
+ return { result: { kind: 'success', text: preview(plan, label, markerTokens) }, plan, label, before: measurement, ...(pruned === undefined ? {} : { pruned }) };
99
135
  }
100
136
  const replacement = applyTrim(session, plan, marker, replaceKeys());
101
137
  const after = ctx.tokenMeter.measure(session, header);
@@ -106,7 +142,8 @@ export async function executeTrim(ctx, config, request) {
106
142
  before: measurement,
107
143
  after,
108
144
  markerTokens,
109
- replacement
145
+ replacement,
146
+ ...(pruned === undefined ? {} : { pruned })
110
147
  };
111
148
  }
112
149
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-command-context-trim",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Model-free /trim for DeepSeek Harness — drop the oldest, least valuable span of context on demand (the `/trim` command) or automatically when a request hits the model's context wall, without any model call.",
5
5
  "license": "MIT",
6
6
  "author": "snailium",