dsh-command-context-trim 0.1.1 → 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,46 @@ 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
+
8
48
  ## [0.1.1] - 2026-09-15
9
49
 
10
50
  ### Fixed
@@ -61,6 +101,7 @@ All notable changes to this project are documented here. This project adheres to
61
101
  content stays in the durable session log. v1 has no `/untrim`.
62
102
  - Requires a harness that exposes `ctx.commands`, `ctx.tokenMeter`, and `ctx.llm` (DeepSeek Harness 0.1.2-rc.1 or later).
63
103
 
64
- [Unreleased]: https://github.com/snailium/dsh-command-context-trim/compare/v0.1.1...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
65
106
  [0.1.1]: https://github.com/snailium/dsh-command-context-trim/compare/v0.1.0...v0.1.1
66
107
  [0.1.0]: https://github.com/snailium/dsh-command-context-trim/releases/tag/v0.1.0
package/README.md CHANGED
@@ -84,17 +84,66 @@ Design consequences:
84
84
  is idle, so it cannot interleave with a turn, `/compact`, or automatic compaction; it also refuses while an unmatched
85
85
  `compaction/start` is open.
86
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
+
87
121
  ## What is protected
88
122
 
89
123
  | Protected | Why |
90
124
  |---|---|
91
125
  | Leading `protectHeadNodes` nodes (default 1) | The task statement — dropping it destroys the point of the conversation. |
92
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. |
93
- | 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). |
94
129
 
95
130
  Within those bounds the policy is **oldest-first, least-long-possible**: the elided span starts at the oldest balanced cut
96
131
  and grows only until it frees exactly enough tokens.
97
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
+
98
147
  ## Compatibility
99
148
 
100
149
  | Harness | State |
@@ -126,8 +175,10 @@ Override on the `context-trim` row of a profile patch (the bundle's own `cordis.
126
175
  | `retainRatio` / `retainTokens` | `0.16` / — | Recent tail kept verbatim (mutually exclusive forms) |
127
176
  | `minTailTokens` | `2048` | Absolute floor for that tail |
128
177
  | `protectHeadNodes` | `1` | Leading nodes that are never trimmed |
129
- | `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 |
130
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 |
131
182
 
132
183
  ## Limits
133
184
 
@@ -143,6 +194,23 @@ Override on the `context-trim` row of a profile patch (the bundle's own `cordis.
143
194
 
144
195
  ## Development
145
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
+
146
214
  ```bash
147
215
  npm install # the harness contracts this plugin builds on, pinned as devDependencies
148
216
  npm test # node --test
@@ -159,13 +227,14 @@ releases go out through `.github/workflows/publish.yml`, which is manual-only (`
159
227
 
160
228
  | Check | State |
161
229
  |---|---|
162
- | `npm test` (34 tests: planner, args, surface apply + log replay, plugin handler) | ✅ passing |
230
+ | `npm test` (51 tests: planner, args, surface apply + log replay, plugin handler, automatic overflow path) | ✅ passing |
163
231
  | Isolated `DSH_HOME` install (`dsh plugin add file:…`) reconciling dependency **and** bundle layer | ✅ verified |
164
232
  | Composed profile tree contains the `context-trim` insert row (`dsh --dump-config`) | ✅ verified |
165
233
  | Profile boot with the plugin mounted (no load error) | ✅ reaches the credential check cleanly |
166
- | Same suite against the pinned **published** harness packages (`npm ci`) | ✅ 34 passing |
234
+ | Same suite against the pinned **published** harness packages (`npm ci`) | ✅ 51 passing |
167
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 |
168
- | Same suite on harness 0.1.5-rc.2 (renamed marker + surface system prompt) | ✅ 40 passing |
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 |
169
238
  | CI workflow (Node 22 / 24) | ✅ green |
170
239
  | npm release via GitHub Actions | ✅ 0.1.0 published with provenance (`+ dsh-command-context-trim@0.1.0`) |
171
240
  | Isolated profile install **from the npm registry** (dependency + bundle layer + composed insert row) | ✅ 0.1.0 |
package/README.zh.md CHANGED
@@ -53,9 +53,38 @@ 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
 
61
90
  ## 兼容性
@@ -99,7 +128,7 @@ npm run link:harness # 也可改为从本地 dsh 安装的依赖闭包解析 @
99
128
  已验证:34 个测试全部通过(选段算法、参数解析、真实 Session 上的 surface 改写与日志重放、插件命令注册与端到端裁剪,以及用**真实 `ctx.tokenMeter`** 验证「实测降幅 == 声明的 shadow price」和「新进程重放裁剪后日志得到完全一致的总量」);
100
129
  隔离 `DSH_HOME` 安装后 dependency 与 bundle 层均正确 reconcile;`dsh --dump-config` 中出现 `context-trim` 行;profile 启动无加载错误。
101
130
  CI 在 Node 22/24 上跑同一套测试;发布通过 `.github/workflows/publish.yml`(手动 `workflow_dispatch`)。
102
- npm 0.1.1 已发布(带 provenance),并已在隔离 profile 里从 registry 安装验证;尚未执行:Web GUI 里的真实小窗口端到端验证(mock provider 与隔离实例已就绪)。
131
+ npm 0.2.0(新增撞墙自动 trim),并已在隔离 profile 里从 registry 安装验证;尚未执行:Web GUI 里的真实小窗口端到端验证(mock provider 与隔离实例已就绪)。
103
132
 
104
133
  ## License
105
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
@@ -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
 
package/lib/index.js CHANGED
@@ -13,16 +13,19 @@
13
13
  * synchronous appends, zero LLM calls, so it works precisely when every request
14
14
  * is failing.
15
15
  *
16
+ * The same execution also runs **automatically** on the context wall: a
17
+ * prepended `agent/request-error` listener trims on `CONTEXT_WINDOW_EXCEEDED` and
18
+ * retries, and only hands the problem to compaction (prune + summarize) when it
19
+ * cannot free anything — see `./auto-trim.js`.
20
+ *
16
21
  * @module dsh-command-context-trim
17
22
  */
18
- import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compaction';
19
23
  import z from '@deepseek-ai/schemastery';
20
24
  import { parseTrimArguments, USAGE } from './args.js';
21
- import { applyTrim, createMarkerMessage, provisionalMarker } from './apply.js';
22
- import { budgetFor, resolveConfig, retentionFor } from './config.js';
23
- import { planTrim } from './plan.js';
24
- import { resolveTarget, targetHeader } from './target.js';
25
- import { replaceKeys } from './session-compat.js';
25
+ import { registerAutoTrim } from './auto-trim.js';
26
+ import { resolveConfig } from './config.js';
27
+ import { describeError } from './render.js';
28
+ import { executeTrim } from './trim-session.js';
26
29
 
27
30
  /** Cordis plugin name. */
28
31
  export const name = 'context-trim';
@@ -30,7 +33,7 @@ export const name = 'context-trim';
30
33
  /** Services required before the command can be registered. */
31
34
  export const inject = ['commands', 'tokenMeter', 'llm'];
32
35
 
33
- /** Loader-facing configuration shape; ranges are enforced by {@link resolveConfig}. */
36
+ /** Loader-facing configuration shape; ranges are enforced by `resolveConfig`. */
34
37
  export const Config = z.object({
35
38
  targetRatio: z.number(),
36
39
  reserveOutputTokens: z.number(),
@@ -39,11 +42,14 @@ export const Config = z.object({
39
42
  minTailTokens: z.number(),
40
43
  protectHeadNodes: z.number(),
41
44
  allowTailTrim: z.boolean(),
42
- markerSlackTokens: z.number()
45
+ markerSlackTokens: z.number(),
46
+ autoTrim: z.boolean(),
47
+ maxAutoTrimRetries: z.number()
43
48
  });
44
49
 
45
50
  /**
46
- * Register `/trim` for every composed human-command adapter.
51
+ * Register `/trim` for every composed human-command adapter, and the automatic
52
+ * context-overflow handler for every agent.
47
53
  * @param ctx - context carrying the command registry, token meter, and LLM service.
48
54
  * @param config - untrusted plugin configuration.
49
55
  */
@@ -70,6 +76,7 @@ export function apply(ctx, config) {
70
76
  handler
71
77
  });
72
78
  }, 'context-trim lifecycle');
79
+ registerAutoTrim(ctx, resolved);
73
80
  }
74
81
 
75
82
  /**
@@ -84,7 +91,17 @@ async function execute(ctx, config, invocation) {
84
91
  if (parsed.error !== undefined) return { kind: 'error', text: `${parsed.error}\n${USAGE}` };
85
92
  let running;
86
93
  try {
87
- running = invocation.agent.runMaintenance((agentSignal) => trimOnce(ctx, config, invocation, parsed, agentSignal));
94
+ running = invocation.agent.runMaintenance(async (agentSignal) => {
95
+ const signal = AbortSignal.any([invocation.signal, agentSignal]);
96
+ const { result } = await executeTrim(ctx, config, {
97
+ agent: invocation.agent,
98
+ signal,
99
+ requestedRoute: parsed.route,
100
+ explicitBudget: parsed.budget,
101
+ check: parsed.check
102
+ });
103
+ return result;
104
+ });
88
105
  } catch (error) {
89
106
  return { kind: 'error', text: `Trim needs an idle agent: ${describeError(error)}` };
90
107
  }
@@ -95,147 +112,3 @@ async function execute(ctx, config, invocation) {
95
112
  return { kind: 'error', text: `Trim failed: ${describeError(error)}` };
96
113
  }
97
114
  }
98
-
99
- /**
100
- * Plan and apply one trim; the whole session-visible mutation happens here.
101
- * @param ctx - plugin context.
102
- * @param config - resolved configuration.
103
- * @param invocation - command invocation (agent, signal).
104
- * @param parsed - parsed command arguments.
105
- * @param agentSignal - cancellation owned by the maintenance reservation.
106
- * @returns a command result.
107
- */
108
- async function trimOnce(ctx, config, invocation, parsed, agentSignal) {
109
- const signal = AbortSignal.any([invocation.signal, agentSignal]);
110
- signal.throwIfAborted();
111
- const session = invocation.agent.session;
112
- assertNoOpenCompaction(session);
113
- const target = parsed.budget === undefined ? await resolveTarget(ctx, invocation.agent, parsed.route, signal) : undefined;
114
- const budget = parsed.budget ?? budgetFor(target.contextWindow, config);
115
- const label = target === undefined ? `an explicit ${budget}-token budget` : `${target.label} (window ${target.contextWindow})`;
116
- const header = target === undefined ? undefined : targetHeader(session, target);
117
- const measurement = ctx.tokenMeter.measure(session, header);
118
- const nodes = measurement.nodes.map((node) => ({
119
- seq: node.seq,
120
- heuristicTokens: node.heuristicTokens,
121
- // Harness 0.1.5+ carries the system prompt as surface node 0. It is never
122
- // elidable, and no elided span may cross it: dropping it would strip the
123
- // model's instructions, and letting it consume head protection would expose
124
- // the user's original request instead.
125
- ...(session.eventAt(node.seq)?.type === 'system/message' ? { barrier: true } : {})
126
- }));
127
- if (nodes.length === 0) {
128
- return { kind: 'success', text: `Nothing to trim: ${session.id} has no model-visible messages yet.` };
129
- }
130
- // The retained tail scales with the capacity being fitted: the target window
131
- // when one is known, otherwise the explicit budget itself.
132
- const retainTokens = retentionFor(target?.contextWindow ?? budget, config);
133
- const envelopeTokens = Math.max(0, measurement.totalTokens - measurement.surfaceTokens);
134
- const planFor = (markerTokens) =>
135
- planTrim({
136
- nodes,
137
- envelopeTokens,
138
- budget,
139
- markerCost: markerTokens + config.markerSlackTokens,
140
- retainTokens,
141
- minTailTokens: config.minTailTokens,
142
- protectHeadNodes: config.protectHeadNodes,
143
- allowTailTrim: config.allowTailTrim,
144
- isBalancedBefore: (seq) => toolPairingBalancedBefore(session, seq),
145
- isBalancedAfter: (seq) => toolPairingBalancedAfter(session, seq)
146
- });
147
- let markerTokens = ctx.tokenMeter.estimateMessage(provisionalMarker());
148
- let plan = planFor(markerTokens);
149
- if (plan.kind !== 'span') return describeNonSpan(plan, label);
150
- let marker = createMarkerMessage(plan, target?.label ?? `budget ${budget}`, budget);
151
- const finalMarkerTokens = ctx.tokenMeter.estimateMessage(marker);
152
- if (finalMarkerTokens > markerTokens) {
153
- // The marker now carries real numbers; re-plan once so its own price is exact.
154
- plan = planFor(finalMarkerTokens);
155
- if (plan.kind !== 'span') return describeNonSpan(plan, label);
156
- marker = createMarkerMessage(plan, target?.label ?? `budget ${budget}`, budget);
157
- markerTokens = finalMarkerTokens;
158
- }
159
- if (parsed.check) return { kind: 'success', text: preview(plan, label, markerTokens) };
160
- const replacement = applyTrim(session, plan, marker, replaceKeys());
161
- const after = ctx.tokenMeter.measure(session, header);
162
- return { kind: 'success', text: report(plan, after, label, markerTokens), sourceEventSeq: replacement.seq };
163
- }
164
-
165
- /** Refuse to rewrite a surface while a compaction bracket is open. */
166
- function assertNoOpenCompaction(session) {
167
- for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
168
- const event = session.eventAt(seq);
169
- if (event === undefined) continue;
170
- if (event.type === 'compaction/end') return;
171
- // A seed boundary proves any earlier unmatched start belongs to a previous lifecycle.
172
- if (event.type === 'session/end-seed') return;
173
- if (event.type === 'compaction/start') {
174
- throw new Error('a compaction is already in progress in this session; wait for it to finish, then retry');
175
- }
176
- }
177
- }
178
-
179
- /** Render every non-span outcome as a final command result. */
180
- function describeNonSpan(plan, label) {
181
- switch (plan.kind) {
182
- case 'fits':
183
- return {
184
- kind: 'success',
185
- text: `Already within budget: ~${plan.totalTokens} / ${plan.budget} tokens for ${label}. Nothing to trim.`
186
- };
187
- case 'envelope':
188
- return {
189
- kind: 'error',
190
- text: [
191
- `Fixed request overhead alone (~${plan.envelopeTokens} tokens of tool schemas and other non-surface request data) exceeds the ${plan.budget}-token budget for ${label}.`,
192
- 'Trimming conversation history cannot help: raise the backend context size (the model\'s contextWindow in settings.yaml, or the server\'s context flag) or reduce mounted tools and skills, then retry.'
193
- ].join('\n')
194
- };
195
- case 'no-span':
196
- return {
197
- kind: 'error',
198
- text: `Nothing safely trimmable for ${label}: ${plan.reason}, or no tool-pairing balanced cut exists.`
199
- };
200
- case 'insufficient':
201
- return {
202
- kind: 'error',
203
- text: [
204
- `Cannot free enough for ${label}: the largest balanced span frees ~${plan.maxFreeable} of the ~${plan.need} tokens needed.`,
205
- `Protected content: task statement ~${plan.protectedHeadTokens} tokens, recent tail ~${plan.protectedTailTokens} tokens (retain target ~${plan.retainTokens})${plan.hasBarrier ? ', plus the system prompt, which is never trimmed' : ''}.`,
206
- 'Try /compact (it summarizes instead of dropping), a larger window, or /trim with an explicit budget after another reduction.'
207
- ].join('\n')
208
- };
209
- default:
210
- return { kind: 'error', text: `Trim could not plan a reduction (${String(plan.kind)}).` };
211
- }
212
- }
213
-
214
- /** Render a dry run. */
215
- function preview(plan, label, markerTokens) {
216
- return [
217
- `Would trim ${plan.shadowedSeqs.length} messages (seqs ${plan.startSeq}-${plan.endSeq}, ~${plan.shadowedTokens} tokens → ${markerTokens}-token marker) for ${label}.`,
218
- `Request size: ~${plan.totalTokens} → ~${plan.projectedTotal} tokens (target ${plan.budget}). Nothing was changed.`
219
- ].join('\n');
220
- }
221
-
222
- /** Render a committed trim against the re-measured request. */
223
- function report(plan, after, label, markerTokens) {
224
- const lines = [
225
- `Trimmed ${plan.shadowedSeqs.length} messages (seqs ${plan.startSeq}-${plan.endSeq}, ~${plan.shadowedTokens} tokens → ${markerTokens}-token marker) for ${label}.`,
226
- `Request size: ~${plan.totalTokens} → ~${after.totalTokens} tokens (target ${plan.budget}).`
227
- ];
228
- if (plan.relaxedRetention) {
229
- lines.push(`Retention relaxed to ~${plan.retainTokens} tokens to reach the budget.`);
230
- }
231
- return lines.join('\n');
232
- }
233
-
234
- /** Render a thrown value without trusting its string coercion. */
235
- function describeError(error) {
236
- try {
237
- return error instanceof Error ? error.message : String(error);
238
- } catch {
239
- return '<unrenderable thrown value>';
240
- }
241
- }
package/lib/plan.js CHANGED
@@ -17,7 +17,11 @@
17
17
  * original request instead. Barriers are therefore untouchable and split the
18
18
  * elidable space into regions, and `protectHeadNodes` counts only non-barrier
19
19
  * nodes so it keeps protecting the task statement.
20
- * - **The final node**, and the retained tail.
20
+ * - **The newest `user/message`** the live human instruction — plus the
21
+ * retained tail. An earlier rule protected the final surface node outright,
22
+ * which deadlocked exactly the common overflow shape: a large assistant
23
+ * tool-call whose tool result is the last node could not be removed as a pair,
24
+ * leaving only a few tokens freeable while the request stayed over the wall.
21
25
  *
22
26
  * Nothing here touches a session: the planner receives measured node prices and
23
27
  * two balance predicates, so every branch is directly testable.
@@ -31,6 +35,8 @@
31
35
  * @property {number} heuristicTokens - the token meter's heuristic price for it.
32
36
  * @property {boolean} [barrier] - true for a node that may never be elided and
33
37
  * that no elided span may cross (the system prompt).
38
+ * @property {boolean} [userMessage] - true for human prompts; the newest one is
39
+ * treated as a barrier so an ongoing instruction is never elided.
34
40
  */
35
41
 
36
42
  /**
@@ -69,20 +75,21 @@ export function planTrim(input) {
69
75
  const need = common.totalTokens - input.budget;
70
76
  const configuredRetention = input.retainTokens;
71
77
  let weakest = null;
72
- for (const retainTokens of retentionLadder(configuredRetention, input.minTailTokens)) {
73
- const attempt = attemptSpan({ ...input, retainTokens, need });
78
+ for (const configuration of attemptConfigurations(input, configuredRetention)) {
79
+ const attempt = attemptSpan({ ...input, ...configuration, need });
74
80
  if (attempt.kind === 'span') {
75
81
  return {
76
82
  kind: 'span',
77
83
  ...common,
78
84
  ...attempt,
79
85
  need,
80
- retainTokens,
81
- relaxedRetention: retainTokens < configuredRetention,
86
+ retainTokens: configuration.retainTokens,
87
+ relaxedRetention: configuration.retainTokens < configuredRetention,
88
+ reachedFinalNode: configuration.allowFinalNode === true,
82
89
  projectedTotal: common.totalTokens - attempt.freedTokens
83
90
  };
84
91
  }
85
- if (weakest === null || attempt.maxFreeable > weakest.maxFreeable) weakest = { ...attempt, retainTokens };
92
+ if (weakest === null || attempt.maxFreeable > weakest.maxFreeable) weakest = { ...attempt, retainTokens: configuration.retainTokens };
86
93
  }
87
94
  return {
88
95
  kind: 'insufficient',
@@ -91,10 +98,44 @@ export function planTrim(input) {
91
98
  maxFreeable: weakest?.maxFreeable ?? 0,
92
99
  protectedHeadTokens: protectedHeadTokens(input.nodes, input.protectHeadNodes),
93
100
  protectedTailTokens: weakest?.protectedTailTokens ?? 0,
101
+ protectedUserTokens: weakest?.protectedUserTokens ?? 0,
94
102
  retainTokens: configuredRetention
95
103
  };
96
104
  }
97
105
 
106
+ /**
107
+ * The search order, most conservative first. The final surface node is a
108
+ * *preference*, not a law: keeping it is always tried before dropping it, and
109
+ * dropping it happens only when no other span can free enough.
110
+ *
111
+ * 1. stay outside the retained tail and keep the final node (the configured
112
+ * retention, relaxed step by step only if the fit otherwise fails);
113
+ * 2. may reach into the retained tail, still keeping the final node;
114
+ * 3. last resort: a span that includes the final node (typically the current
115
+ * step's assistant tool-call plus its tool result, which can only be removed
116
+ * as a pair).
117
+ *
118
+ * Elision always starts at the oldest balanced cut, so within every tier the
119
+ * oldest content goes first. A strict `allowTailTrim: false` ends the list after
120
+ * tier 1: the retained tail then is a hard boundary.
121
+ * @param input - planning input.
122
+ * @param configuredRetention - the preferred verbatim recent-tail budget.
123
+ * @returns ordered attempt configurations.
124
+ */
125
+ function attemptConfigurations(input, configuredRetention) {
126
+ const insideTail = retentionLadder(configuredRetention, input.minTailTokens).map((retainTokens) => ({
127
+ retainTokens,
128
+ tailHard: true,
129
+ allowFinalNode: false
130
+ }));
131
+ if (input.allowTailTrim !== true) return insideTail;
132
+ return [
133
+ ...insideTail,
134
+ { retainTokens: configuredRetention, tailHard: false, allowFinalNode: false },
135
+ { retainTokens: configuredRetention, tailHard: false, allowFinalNode: true }
136
+ ];
137
+ }
138
+
98
139
  /**
99
140
  * Try one retention budget, returning the smallest oldest-anchored balanced span
100
141
  * that frees at least `need` tokens.
@@ -104,17 +145,25 @@ export function planTrim(input) {
104
145
  function attemptSpan(input) {
105
146
  const nodes = input.nodes;
106
147
  const tailStart = tailStartIndex(nodes, input.retainTokens);
107
- const lastElidable = input.allowTailTrim ? nodes.length - 2 : Math.min(nodes.length - 2, tailStart - 1);
148
+ // The newest human instruction is the anchor: it must never be elided, and no
149
+ // span may cross it.
150
+ const newestUserIndex = newestUserMessageIndex(nodes);
151
+ // How far right a span may reach: a hard tail boundary when this attempt keeps
152
+ // the retained tail, otherwise the end of the surface.
153
+ const tailFloor = input.tailHard === true ? Math.min(nodes.length - 1, tailStart - 1) : nodes.length - 1;
154
+ // The final node is kept unless this attempt is the last-resort tier.
155
+ const lastElidable = input.allowFinalNode === true ? tailFloor : Math.min(tailFloor, nodes.length - 2);
108
156
  const shortfall = (maxFreeable) => ({
109
157
  kind: 'shortfall',
110
158
  maxFreeable,
111
159
  protectedTailTokens: tailTokens(nodes, tailStart),
112
- protectedHeadTokens: protectedHeadTokens(nodes, input.protectHeadNodes)
160
+ protectedHeadTokens: protectedHeadTokens(nodes, input.protectHeadNodes),
161
+ protectedUserTokens: newestUserIndex === -1 ? 0 : nodes[newestUserIndex].heuristicTokens
113
162
  });
114
163
  const headEnd = protectedHeadEnd(nodes, input.protectHeadNodes);
115
164
  if (lastElidable < headEnd) return shortfall(0);
116
165
  let weakest = null;
117
- for (const region of elidableRegions(nodes, headEnd, lastElidable)) {
166
+ for (const region of elidableRegions(nodes, headEnd, lastElidable, newestUserIndex)) {
118
167
  const attempt = attemptRegion(nodes, region, input);
119
168
  if (attempt === null) continue;
120
169
  if (attempt.kind === 'span') return attempt;
@@ -172,12 +221,26 @@ function protectedHeadEnd(nodes, protectHeadNodes) {
172
221
  return nodes.length;
173
222
  }
174
223
 
175
- /** Maximal runs of elidable indices inside `[from, to]`. */
176
- function elidableRegions(nodes, from, to) {
224
+ /** Index of the newest human prompt on the surface, or -1 when there is none. */
225
+ function newestUserMessageIndex(nodes) {
226
+ let found = -1;
227
+ for (let index = 0; index < nodes.length; index += 1) if (nodes[index].userMessage === true) found = index;
228
+ return found;
229
+ }
230
+
231
+ /**
232
+ * Maximal runs of elidable indices inside `[from, to]`.
233
+ * @param nodes - full priced surface.
234
+ * @param from - inclusive first index to consider.
235
+ * @param to - inclusive last index to consider.
236
+ * @param extraBarrier - one additional index treated as a barrier (the newest user message).
237
+ * @returns contiguous index runs a span may occupy.
238
+ */
239
+ function elidableRegions(nodes, from, to, extraBarrier) {
177
240
  const regions = [];
178
241
  let start = null;
179
242
  for (let index = from; index <= to; index += 1) {
180
- if (nodes[index].barrier === true) {
243
+ if (nodes[index].barrier === true || index === extraBarrier) {
181
244
  if (start !== null) regions.push({ start, end: index - 1 });
182
245
  start = null;
183
246
  continue;
package/lib/render.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Command-result and log rendering, shared by the `/trim` command and the
3
+ * automatic context-overflow path.
4
+ *
5
+ * @module dsh-command-context-trim/render
6
+ */
7
+
8
+ /**
9
+ * Render every non-span planning outcome as a final command result.
10
+ * @param plan - the plan returned by `planTrim`.
11
+ * @param label - human label of the target route.
12
+ * @returns a command result.
13
+ */
14
+ export function describeNonSpan(plan, label) {
15
+ switch (plan.kind) {
16
+ case 'fits':
17
+ return {
18
+ kind: 'success',
19
+ text: `Already within budget: ~${plan.totalTokens} / ${plan.budget} tokens for ${label}. Nothing to trim.`
20
+ };
21
+ case 'envelope':
22
+ return {
23
+ kind: 'error',
24
+ text: [
25
+ `Fixed request overhead alone (~${plan.envelopeTokens} tokens of tool schemas and other non-surface request data) exceeds the ${plan.budget}-token budget for ${label}.`,
26
+ 'Trimming conversation history cannot help: raise the backend context size (the model\'s contextWindow in settings.yaml, or the server\'s context flag) or reduce mounted tools and skills, then retry.'
27
+ ].join('\n')
28
+ };
29
+ case 'no-span':
30
+ return {
31
+ kind: 'error',
32
+ text: `Nothing safely trimmable for ${label}: ${plan.reason}, or no tool-pairing balanced cut exists.`
33
+ };
34
+ case 'insufficient':
35
+ return {
36
+ kind: 'error',
37
+ text: [
38
+ `Cannot free enough for ${label}: the largest balanced span frees ~${plan.maxFreeable} of the ~${plan.need} tokens needed.`,
39
+ `Protected content: task statement ~${plan.protectedHeadTokens} tokens, recent tail ~${plan.protectedTailTokens} tokens (retain target ~${plan.retainTokens})` +
40
+ `${plan.protectedUserTokens > 0 ? `, newest instruction ~${plan.protectedUserTokens} tokens (never elided)` : ''}` +
41
+ `${plan.hasBarrier ? ', plus the system prompt, which is never trimmed' : ''}.`,
42
+ 'Try /compact (it summarizes instead of dropping), a larger window, or /trim with an explicit budget after another reduction.'
43
+ ].join('\n')
44
+ };
45
+ default:
46
+ return { kind: 'error', text: `Trim could not plan a reduction (${String(plan.kind)}).` };
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Render a dry run.
52
+ * @param plan - a committed span plan.
53
+ * @param label - human label of the target route.
54
+ * @param markerTokens - measured price of the replacement marker.
55
+ * @returns the dry-run text.
56
+ */
57
+ export function preview(plan, label, markerTokens) {
58
+ return [
59
+ `Would trim ${plan.shadowedSeqs.length} messages (seqs ${plan.startSeq}-${plan.endSeq}, ~${plan.shadowedTokens} tokens → ${markerTokens}-token marker) for ${label}.`,
60
+ `Request size: ~${plan.totalTokens} → ~${plan.projectedTotal} tokens (target ${plan.budget}). Nothing was changed.`,
61
+ ...(plan.reachedFinalNode === true
62
+ ? ['Note: nothing else can free enough — this plan has to remove the final surface message as a tool-call/result pair.']
63
+ : [])
64
+ ].join('\n');
65
+ }
66
+
67
+ /**
68
+ * Render a committed trim against the re-measured request.
69
+ * @param plan - the committed span plan.
70
+ * @param after - post-trim measurement.
71
+ * @param label - human label of the target route.
72
+ * @param markerTokens - measured price of the replacement marker.
73
+ * @returns the report text.
74
+ */
75
+ export function report(plan, after, label, markerTokens) {
76
+ const lines = [
77
+ `Trimmed ${plan.shadowedSeqs.length} messages (seqs ${plan.startSeq}-${plan.endSeq}, ~${plan.shadowedTokens} tokens → ${markerTokens}-token marker) for ${label}.`,
78
+ `Request size: ~${plan.totalTokens} → ~${after.totalTokens} tokens (target ${plan.budget}).`
79
+ ];
80
+ if (plan.relaxedRetention) {
81
+ lines.push(`Retention relaxed to ~${plan.retainTokens} tokens to reach the budget.`);
82
+ }
83
+ if (plan.reachedFinalNode === true) {
84
+ lines.push('Nothing else could free enough: the final surface message was removed as part of a tool-call/result pair.');
85
+ }
86
+ return lines.join('\n');
87
+ }
88
+
89
+ /**
90
+ * Render a committed trim as one log line, for the automatic path.
91
+ * @param plan - the committed span plan.
92
+ * @param after - post-trim measurement.
93
+ * @param label - human label of the target route.
94
+ * @returns a single-line account.
95
+ */
96
+ export function logLine(plan, after, label) {
97
+ return (
98
+ `freed ~${plan.freedTokens} tokens over ${plan.shadowedSeqs.length} messages (seqs ${plan.startSeq}-${plan.endSeq}) for ${label}; ` +
99
+ `request ~${plan.totalTokens} → ~${after.totalTokens} tokens (budget ${plan.budget})` +
100
+ `${plan.reachedFinalNode === true ? ' (last resort: the final message went as a tool-call/result pair)' : ''}`
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Render a thrown value without trusting its string coercion.
106
+ * @param error - the caught value (`unknown` in catch clauses).
107
+ * @returns a printable message.
108
+ */
109
+ export function describeError(error) {
110
+ try {
111
+ return error instanceof Error ? error.message : String(error);
112
+ } catch {
113
+ return '<unrenderable thrown value>';
114
+ }
115
+ }
package/lib/target.js CHANGED
@@ -40,8 +40,10 @@ export function latestRoute(session) {
40
40
  * @returns `{ provider, model, contextWindow, label }`.
41
41
  * @throws when no route can be determined, the route is unknown, or its adapter declares no window.
42
42
  */
43
- export async function resolveTarget(ctx, agent, requested, signal) {
44
- const route = requested ?? latestRoute(agent.session) ?? agentRoute(agent);
43
+ export async function resolveTarget(ctx, agent, requested, signal, options = {}) {
44
+ const primary =
45
+ options.routedOnly === true ? routedTarget(agent.session) ?? latestRoute(agent.session) : latestRoute(agent.session);
46
+ const route = requested ?? primary ?? agentRoute(agent);
45
47
  if (route === undefined) {
46
48
  throw new Error('cannot determine the target model: select a model in this session first, or pass an explicit budget (e.g. /trim 32k)');
47
49
  }
@@ -82,6 +84,20 @@ export function targetHeader(session, target) {
82
84
  });
83
85
  }
84
86
 
87
+ /**
88
+ * The route the last durable request was actually built for: the one that just
89
+ * overflowed during automatic recovery.
90
+ * @param session - session whose log is read.
91
+ * @returns `{ provider, model, source }`, or undefined when nothing was routed yet.
92
+ */
93
+ export function routedTarget(session) {
94
+ const config = session.requestHeader()?.config;
95
+ if (config === undefined) return undefined;
96
+ if (typeof config.provider !== 'string' || config.provider.length === 0) return undefined;
97
+ if (typeof config.model !== 'string' || config.model.length === 0) return undefined;
98
+ return { provider: config.provider, model: config.model, source: 'request' };
99
+ }
100
+
85
101
  /** Per-agent configured route, used only before any request was routed. */
86
102
  function agentRoute(agent) {
87
103
  const provider = agent.options?.provider;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * One trim execution, shared by the `/trim` command and the automatic
3
+ * context-overflow path.
4
+ *
5
+ * Everything session-visible happens here: measure the current request under the
6
+ * target route, plan the oldest balanced span, and apply it as two synchronous
7
+ * appends. The caller owns only *when* to invoke it (`/trim` inside an idle-agent
8
+ * reservation, the automatic path inside a failing step) and how to render the
9
+ * outcome.
10
+ *
11
+ * @module dsh-command-context-trim/trim-session
12
+ */
13
+ import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compaction';
14
+ import { applyTrim, createMarkerMessage, provisionalMarker } from './apply.js';
15
+ import { budgetFor, retentionFor } from './config.js';
16
+ import { planTrim } from './plan.js';
17
+ import { describeNonSpan, preview, report } from './render.js';
18
+ import { replaceKeys } from './session-compat.js';
19
+ import { resolveTarget, targetHeader } from './target.js';
20
+
21
+ /**
22
+ * @typedef {object} TrimRequest
23
+ * @property {object} agent - agent owning the session to trim.
24
+ * @property {AbortSignal} [signal] - cancellation for the whole execution.
25
+ * @property {{ provider: string, model: string }} [requestedRoute] - explicit target route.
26
+ * @property {number} [explicitBudget] - explicit token budget instead of a resolved window.
27
+ * @property {boolean} [check] - plan only, mutate nothing.
28
+ * @property {boolean} [routedOnly] - target the route the last durable request used.
29
+ */
30
+
31
+ /**
32
+ * Execute one trim.
33
+ * @param ctx - plugin context (token meter, LLM service).
34
+ * @param config - resolved configuration.
35
+ * @param request - what to trim and how far.
36
+ * @returns `{ result, plan?, label?, before?, after?, replacement?, markerTokens? }`;
37
+ * `result` is a command result, and `replacement` is present only when the
38
+ * surface was actually rewritten.
39
+ */
40
+ export async function executeTrim(ctx, config, request) {
41
+ const { agent, signal, requestedRoute, explicitBudget, check = false, routedOnly = false } = request;
42
+ signal?.throwIfAborted?.();
43
+ const session = agent.session;
44
+ assertNoOpenCompaction(session);
45
+ const target =
46
+ 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})`;
49
+ const header = target === undefined ? undefined : targetHeader(session, target);
50
+ const measurement = ctx.tokenMeter.measure(session, header);
51
+ const nodes = measurement.nodes.map((node) => {
52
+ const type = session.eventAt(node.seq)?.type;
53
+ return {
54
+ seq: node.seq,
55
+ heuristicTokens: node.heuristicTokens,
56
+ // Harness 0.1.5+ carries the system prompt as surface node 0. It is never
57
+ // elidable, and no elided span may cross it: dropping it would strip the
58
+ // model's instructions, and letting it consume head protection would expose
59
+ // the user's original request instead.
60
+ ...(type === 'system/message' ? { barrier: true } : {}),
61
+ // The newest human prompt is the anchor the planner must never elide.
62
+ ...(type === 'user/message' ? { userMessage: true } : {})
63
+ };
64
+ });
65
+ if (nodes.length === 0) {
66
+ return { result: { kind: 'success', text: `Nothing to trim: ${session.id} has no model-visible messages yet.` } };
67
+ }
68
+ // The retained tail scales with the capacity being fitted: the target window
69
+ // when one is known, otherwise the explicit budget itself.
70
+ const retainTokens = retentionFor(target?.contextWindow ?? budget, config);
71
+ const envelopeTokens = Math.max(0, measurement.totalTokens - measurement.surfaceTokens);
72
+ const planFor = (markerTokens) =>
73
+ planTrim({
74
+ nodes,
75
+ envelopeTokens,
76
+ budget,
77
+ markerCost: markerTokens + config.markerSlackTokens,
78
+ retainTokens,
79
+ minTailTokens: config.minTailTokens,
80
+ protectHeadNodes: config.protectHeadNodes,
81
+ allowTailTrim: config.allowTailTrim,
82
+ isBalancedBefore: (seq) => toolPairingBalancedBefore(session, seq),
83
+ isBalancedAfter: (seq) => toolPairingBalancedAfter(session, seq)
84
+ });
85
+ let markerTokens = ctx.tokenMeter.estimateMessage(provisionalMarker());
86
+ let plan = planFor(markerTokens);
87
+ if (plan.kind !== 'span') return { result: describeNonSpan(plan, label), plan, label, before: measurement };
88
+ let marker = createMarkerMessage(plan, target?.label ?? `budget ${budget}`, budget);
89
+ const finalMarkerTokens = ctx.tokenMeter.estimateMessage(marker);
90
+ if (finalMarkerTokens > markerTokens) {
91
+ // The marker now carries real numbers; re-plan once so its own price is exact.
92
+ plan = planFor(finalMarkerTokens);
93
+ if (plan.kind !== 'span') return { result: describeNonSpan(plan, label), plan, label, before: measurement };
94
+ marker = createMarkerMessage(plan, target?.label ?? `budget ${budget}`, budget);
95
+ markerTokens = finalMarkerTokens;
96
+ }
97
+ if (check) {
98
+ return { result: { kind: 'success', text: preview(plan, label, markerTokens) }, plan, label, before: measurement };
99
+ }
100
+ const replacement = applyTrim(session, plan, marker, replaceKeys());
101
+ const after = ctx.tokenMeter.measure(session, header);
102
+ return {
103
+ result: { kind: 'success', text: report(plan, after, label, markerTokens), sourceEventSeq: replacement.seq },
104
+ plan,
105
+ label,
106
+ before: measurement,
107
+ after,
108
+ markerTokens,
109
+ replacement
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Refuse to rewrite a surface while a compaction bracket is open.
115
+ * @param session - session whose log is inspected.
116
+ * @throws when an unmatched `compaction/start` is open in the current lifecycle.
117
+ */
118
+ export function assertNoOpenCompaction(session) {
119
+ for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
120
+ const event = session.eventAt(seq);
121
+ if (event === undefined) continue;
122
+ if (event.type === 'compaction/end') return;
123
+ // A seed boundary proves any earlier unmatched start belongs to a previous lifecycle.
124
+ if (event.type === 'session/end-seed') return;
125
+ if (event.type === 'compaction/start') {
126
+ throw new Error('a compaction is already in progress in this session; wait for it to finish, then retry');
127
+ }
128
+ }
129
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-command-context-trim",
3
- "version": "0.1.1",
4
- "description": "Model-free /trim command for DeepSeek Harness — drop the oldest, least valuable span of conversation context so a session can continue on a smaller-window model, without any model call.",
3
+ "version": "0.2.0",
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",
7
7
  "type": "module",
@@ -27,7 +27,8 @@
27
27
  "scripts": {
28
28
  "test": "node --test",
29
29
  "link:harness": "bash scripts/dev-link-harness.sh",
30
- "prepublishOnly": "npm test"
30
+ "prepublishOnly": "npm test",
31
+ "e2e:mock": "node scripts/mock-overflow-server.mjs"
31
32
  },
32
33
  "dsh": {
33
34
  "bundle": {