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 +42 -1
- package/README.md +74 -5
- package/README.zh.md +31 -2
- package/cordis.patch.yml +6 -0
- package/lib/auto-trim.js +87 -0
- package/lib/config.js +13 -3
- package/lib/index.js +27 -154
- package/lib/plan.js +75 -12
- package/lib/render.js +115 -0
- package/lib/target.js +18 -2
- package/lib/trim-session.js +129 -0
- package/package.json +4 -3
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.
|
|
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
|
|
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` |
|
|
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` (
|
|
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`) | ✅
|
|
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
|
-
|
|
|
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
|
-
|
|
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.
|
|
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
|
package/lib/auto-trim.js
ADDED
|
@@ -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 {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
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
|
|
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) =>
|
|
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
|
|
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
|
|
73
|
-
const attempt = attemptSpan({ ...input,
|
|
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
|
-
|
|
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
|
-
/**
|
|
176
|
-
function
|
|
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
|
|
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.
|
|
4
|
-
"description": "Model-free /trim
|
|
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": {
|