dsh-command-context-trim 0.1.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 +36 -0
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/README.zh.md +92 -0
- package/cordis.patch.yml +29 -0
- package/lib/apply.js +97 -0
- package/lib/args.js +53 -0
- package/lib/config.js +127 -0
- package/lib/index.js +232 -0
- package/lib/plan.js +170 -0
- package/lib/target.js +100 -0
- package/package.json +100 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. This project adheres to
|
|
4
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-09-15
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`/trim` — model-free context trimming.** Drops the oldest tool-pairing-balanced span of the model-visible surface so a
|
|
13
|
+
session built against a large-window cloud model can keep running on a smaller local model. It makes no model call at
|
|
14
|
+
all, which is the case `/compact` cannot rescue: compaction summarizes, so its summarizer must fit the very region it is
|
|
15
|
+
condensing in the now-smaller window.
|
|
16
|
+
- Forms: `/trim` (fit the active or pending model window), `/trim check` (plan only, change nothing),
|
|
17
|
+
`/trim 32k` (explicit budget), `/trim provider:model` (fit another route's declared window).
|
|
18
|
+
- Protected context: the leading task statement (`protectHeadNodes`), the most recent messages (`retainRatio` /
|
|
19
|
+
`minTailTokens`), and the final surface node. Within those bounds the policy is oldest-first and frees only what the
|
|
20
|
+
budget requires; retention is relaxed only when the fit is otherwise impossible, and the result says so.
|
|
21
|
+
- Configuration: `targetRatio`, `reserveOutputTokens`, `retainRatio` / `retainTokens`, `minTailTokens`,
|
|
22
|
+
`protectHeadNodes`, `allowTailTrim`, `markerSlackTokens`.
|
|
23
|
+
- Every refusal (fixed request overhead already over budget, no balanced cut point, not enough freeable) explains itself
|
|
24
|
+
and writes nothing to the session.
|
|
25
|
+
|
|
26
|
+
### Notes
|
|
27
|
+
|
|
28
|
+
- One trim appends exactly two events: a `compaction/prune` shadow-price claim for the meter's O(1) projections, then a
|
|
29
|
+
`user/message` positional replacement citing every shadowed node. `user/message` is the only surface-eligible event an
|
|
30
|
+
idle command may append — `assistant/message` needs an open step and a `tool/result` replacement needs an open turn.
|
|
31
|
+
- Nothing is lost: replacements are model-only (the human transcript reads append-origin events) and the full original
|
|
32
|
+
content stays in the durable session log. v1 has no `/untrim`.
|
|
33
|
+
- Requires a harness that exposes `ctx.commands`, `ctx.tokenMeter`, and `ctx.llm` (DeepSeek Harness 0.1.2-rc.1 or later).
|
|
34
|
+
|
|
35
|
+
[Unreleased]: https://github.com/snailium/dsh-command-context-trim/compare/v0.1.0...HEAD
|
|
36
|
+
[0.1.0]: https://github.com/snailium/dsh-command-context-trim/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 snailium
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# dsh-command-context-trim
|
|
2
|
+
|
|
3
|
+
A **model-free `/trim` command** for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness): drop the oldest,
|
|
4
|
+
least valuable span of a conversation so a session can continue on a **smaller-context model** — without a single model call.
|
|
5
|
+
|
|
6
|
+
[中文说明 →](README.zh.md)
|
|
7
|
+
|
|
8
|
+
## Why
|
|
9
|
+
|
|
10
|
+
Switching a long session from a large-window cloud model to a smaller local model makes the *next* request exceed the new
|
|
11
|
+
model's window. The established workaround is `/compact` before switching — and that is exactly where it hurts:
|
|
12
|
+
compaction **summarizes**, so its summarizer call must fit the (now smaller) window while holding the very region being
|
|
13
|
+
condensed. It frequently fails for the same reason the original request failed.
|
|
14
|
+
|
|
15
|
+
DSH's automatic context-overflow recovery has the same property: it retries by summarizing with the routed model, so on a
|
|
16
|
+
too-small local window the recovery call overflows too, and the original error is preserved.
|
|
17
|
+
|
|
18
|
+
`/trim` needs no model at all. It measures the current request under the target route, picks the oldest
|
|
19
|
+
tool-pairing-balanced span that frees exactly enough tokens, and shadows that span with one short marker message. Two
|
|
20
|
+
synchronous appends, zero LLM calls — it works precisely when every request is failing.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# from npm, once published
|
|
26
|
+
dsh plugin --profile web add dsh-command-context-trim
|
|
27
|
+
|
|
28
|
+
# from a checkout
|
|
29
|
+
dsh plugin --profile web add file:/path/to/dsh-command-context-trim
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The package declares `dsh.bundle.patch`, so `dsh plugin add` also adds it to the profile's `dsh.profile.bundles` and its
|
|
33
|
+
insert row (plugin id `context-trim`) is composed automatically. Then restart the profile's app (`/trim` appears in the
|
|
34
|
+
slash-command menu with its argument hint).
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
/trim fit the active model window
|
|
40
|
+
/trim check report the plan, change nothing
|
|
41
|
+
/trim 32k fit an explicit 32768-token budget
|
|
42
|
+
/trim lc:/models/qwen.gguf fit that route's declared window
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Typical use:
|
|
46
|
+
|
|
47
|
+
1. Switch the session to the smaller local model.
|
|
48
|
+
2. Run `/trim` (or run it *before* switching, while the large model is still active — the command targets the newest
|
|
49
|
+
`model/selection` intent, so a pending switch is honored).
|
|
50
|
+
3. Continue the conversation.
|
|
51
|
+
|
|
52
|
+
Success output:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
Trimmed 47 messages (seqs 12-105, ~52310 tokens → 63-token marker) for lc:/models/Qwen3.8-27B-Q4_K_M.gguf (window 90000).
|
|
56
|
+
Request size: ~118204 → ~66310 tokens (target 73612).
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Every refusal explains itself and changes nothing — for example when the fixed request overhead (system prompt + tool
|
|
60
|
+
schemas) alone exceeds the budget, or when the protected head/tail cannot be relaxed far enough.
|
|
61
|
+
|
|
62
|
+
## How it works
|
|
63
|
+
|
|
64
|
+
One trim is two synchronously adjacent appends:
|
|
65
|
+
|
|
66
|
+
| # | Event | Why |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| 1 | `compaction/prune` | The token meter's **shadow-price claim**. The meter's persisted projections are O(1) and cannot re-price a replaced range, so a replacement must be preceded by an event stating the exact price of the range it shadows; without it the fold records a zero delta and reported occupancy drifts. |
|
|
69
|
+
| 2 | `user/message` with `surfaceOp: {op: 'replace', start, end}` | The replacement itself. `sourceEventSeqs` cites every shadowed node, as the surface contract requires. |
|
|
70
|
+
|
|
71
|
+
`user/message` is **the only surface-eligible event an idle command may append**: `assistant/message` requires an open
|
|
72
|
+
step and a `tool/result` replacement requires an open turn, so a trim performed between turns — which is exactly when a
|
|
73
|
+
user needs one — has no other legal node type.
|
|
74
|
+
|
|
75
|
+
Design consequences:
|
|
76
|
+
|
|
77
|
+
- **The human transcript is untouched.** Replacements are model-only; the transcript reads append-origin events. The full
|
|
78
|
+
original content stays in the durable session log, so a trim is auditable and recoverable by hand.
|
|
79
|
+
- **No tool-call/result pair is ever split.** Cut edges are chosen with
|
|
80
|
+
`toolPairingBalancedBefore`/`After` from `@deepseek-ai/dsh-compaction`.
|
|
81
|
+
- **Mutual exclusion with everything else.** The handler runs inside `agent.runMaintenance()`, which fails unless the agent
|
|
82
|
+
is idle, so it cannot interleave with a turn, `/compact`, or automatic compaction; it also refuses while an unmatched
|
|
83
|
+
`compaction/start` is open.
|
|
84
|
+
|
|
85
|
+
## What is protected
|
|
86
|
+
|
|
87
|
+
| Protected | Why |
|
|
88
|
+
|---|---|
|
|
89
|
+
| Leading `protectHeadNodes` nodes (default 1) | The task statement — dropping it destroys the point of the conversation. |
|
|
90
|
+
| Recent tail (`retainRatio` of the window, floor `minTailTokens`) | Recency is what a coding agent needs; retention is relaxed only when the fit is otherwise impossible, and the result says so. |
|
|
91
|
+
| The final surface node | Never elided, even when trimming into the tail. |
|
|
92
|
+
|
|
93
|
+
Within those bounds the policy is **oldest-first, least-long-possible**: the elided span starts at the oldest balanced cut
|
|
94
|
+
and grows only until it frees exactly enough tokens.
|
|
95
|
+
|
|
96
|
+
## Configuration
|
|
97
|
+
|
|
98
|
+
Override on the `context-trim` row of a profile patch (the bundle's own `cordis.patch.yml` lists the full default set):
|
|
99
|
+
|
|
100
|
+
| Key | Default | Meaning |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `targetRatio` | `0.9` | `budget = floor((contextWindow - reserveOutputTokens) * targetRatio)` |
|
|
103
|
+
| `reserveOutputTokens` | `8192` | Output space kept for the model's own reply |
|
|
104
|
+
| `retainRatio` / `retainTokens` | `0.16` / — | Recent tail kept verbatim (mutually exclusive forms) |
|
|
105
|
+
| `minTailTokens` | `2048` | Absolute floor for that tail |
|
|
106
|
+
| `protectHeadNodes` | `1` | Leading nodes that are never trimmed |
|
|
107
|
+
| `allowTailTrim` | `true` | Let the elided span reach into the retained tail when necessary |
|
|
108
|
+
| `markerSlackTokens` | `64` | Slack added to the priced marker so the post-trim request stays under budget |
|
|
109
|
+
|
|
110
|
+
## Limits
|
|
111
|
+
|
|
112
|
+
- **It drops content; it does not summarize.** A trimmed detail is gone from the model's view (still in the log). When you
|
|
113
|
+
want a *summary* instead, use `/compact`; the two complement each other (`/trim` first makes a later `/compact` cheaper
|
|
114
|
+
and more likely to succeed).
|
|
115
|
+
- **It cannot shrink the fixed envelope.** System prompt and tool schemas are outside the surface; if they alone exceed the
|
|
116
|
+
budget, `/trim` says so instead of pretending.
|
|
117
|
+
- **Nothing is restored.** There is no `/untrim` in v1: re-inserting earlier content as surface nodes would need
|
|
118
|
+
`assistant`/`tool` events, which are only legal inside an open turn.
|
|
119
|
+
- **Heuristic pricing.** Budgets use the token meter's own estimate — the same numbers `/compact` and the GUI context bar
|
|
120
|
+
use. Provider-reported usage drifts slightly from it.
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
npm install # the harness contracts this plugin builds on, pinned as devDependencies
|
|
126
|
+
npm test # node --test
|
|
127
|
+
npm run link:harness # or resolve @deepseek-ai from a local dsh installation instead of npm
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Tests cover the pure planner and argument parser, the surface mutation against a real `Session` (including log replay),
|
|
131
|
+
and the plugin's command registration and end-to-end trim over a stub context. CI runs the suite on Node 22 and 24;
|
|
132
|
+
releases go out through `.github/workflows/publish.yml`, which is manual-only (`workflow_dispatch`).
|
|
133
|
+
|
|
134
|
+
### Verification status
|
|
135
|
+
|
|
136
|
+
| Check | State |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `npm test` (30 tests: planner, args, surface apply + replay, plugin handler) | ✅ passing |
|
|
139
|
+
| Isolated `DSH_HOME` install (`dsh plugin add file:…`) reconciling dependency **and** bundle layer | ✅ verified |
|
|
140
|
+
| Composed profile tree contains the `context-trim` insert row (`dsh --dump-config`) | ✅ verified |
|
|
141
|
+
| Profile boot with the plugin mounted (no load error) | ✅ reaches the credential check cleanly |
|
|
142
|
+
| Same suite against the pinned **published** harness packages (`npm ci`) | ✅ 30 passing |
|
|
143
|
+
| CI workflow (Node 22 / 24) | ⏳ first run pending |
|
|
144
|
+
| npm release via GitHub Actions | ⏳ first publish pending (Trusted Publishing cannot create a brand-new package; see the `npm-publish` procedure) |
|
|
145
|
+
| End-to-end in the web GUI against a small-window model | ⏳ not yet run |
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# dsh-command-context-trim
|
|
2
|
+
|
|
3
|
+
[English →](README.md)
|
|
4
|
+
|
|
5
|
+
给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 增加一个**不调用任何模型**的 `/trim` 命令:
|
|
6
|
+
把对话里最旧、最不重要的一段上下文裁掉,让会话能切到**窗口更小的模型**上继续跑。
|
|
7
|
+
|
|
8
|
+
## 为什么需要它
|
|
9
|
+
|
|
10
|
+
把长会话从云端大窗口模型切到本地小窗口模型后,下一次请求会超出新模型的窗口。常规做法是切换前先 `/compact`,
|
|
11
|
+
但 compaction 的本质是**摘要**:它必须让摘要请求本身也塞进(已经变小的)窗口,而摘要的输入正是那段要压缩的历史——
|
|
12
|
+
于是它常常因为和原请求同样的原因失败。DSH 内置的溢出自动恢复同理:它也是用 routed 模型去做摘要,本地窗口太小就一样失败。
|
|
13
|
+
|
|
14
|
+
`/trim` 完全不依赖模型:它先按目标模型重新测算当前请求,选出**最旧的、工具调用/结果配对平衡的**一段,刚好释放到预算
|
|
15
|
+
以内,再用一条极短的占位消息把这段遮蔽掉。两次同步 append,零次 LLM 调用——所以它在“所有请求都失败”的场景下照样能用。
|
|
16
|
+
|
|
17
|
+
## 安装
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
dsh plugin --profile web add dsh-command-context-trim # 发布到 npm 后
|
|
21
|
+
dsh plugin --profile web add file:/path/to/dsh-command-context-trim # 从源码
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
包内声明了 `dsh.bundle.patch`,`dsh plugin add` 会自动把它加进 profile 的 `dsh.profile.bundles`,并组合其 insert 行
|
|
25
|
+
(插件 id `context-trim`)。重启对应 profile 后,斜杠菜单里会出现 `/trim`。
|
|
26
|
+
|
|
27
|
+
## 用法
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
/trim 按当前/待切换模型的窗口裁到预算内
|
|
31
|
+
/trim check 只报告计划,不修改任何东西
|
|
32
|
+
/trim 32k 指定 32768 token 预算
|
|
33
|
+
/trim lc:/models/qwen.gguf 按指定 route 声明的窗口裁
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
典型流程:切到本地小模型 → 执行 `/trim`(也可以在切换前执行:命令会读取最新的 `model/selection` 意图)→ 继续对话。
|
|
37
|
+
|
|
38
|
+
## 实现要点
|
|
39
|
+
|
|
40
|
+
一次裁剪 = 两次**紧邻**的 append:
|
|
41
|
+
|
|
42
|
+
1. `compaction/prune` —— token meter 的 **shadow-price 记账**。meter 的持久化投影是 O(1) 的,无法为被替换的范围重新计价,
|
|
43
|
+
所以替换事件前必须有一条声明被遮蔽范围精确价格的计量事件,否则投影按 0 增量记账,上下文占用显示会漂移。
|
|
44
|
+
2. `user/message` + `surfaceOp:{op:'replace',start,end}` —— 替换本体,`sourceEventSeqs` 覆盖全部被遮蔽节点。
|
|
45
|
+
|
|
46
|
+
`user/message` 是**空闲态唯二合法的 surface 事件类型**:`assistant/message` 需要 open step,`tool/result` 替换需要 open turn,
|
|
47
|
+
而“回合之间”正是用户需要裁剪的时刻。
|
|
48
|
+
|
|
49
|
+
由此带来的性质:
|
|
50
|
+
|
|
51
|
+
- **人类记录不受影响**:替换是 model-only,transcript 只读 append 来源事件;原始内容完整保留在持久化日志里,可审计、可人工恢复。
|
|
52
|
+
- **绝不切断工具调用/结果配对**:切点由 `@deepseek-ai/dsh-compaction` 的 `toolPairingBalancedBefore/After` 决定。
|
|
53
|
+
- **与其它机制互斥**:命令在 `agent.runMaintenance()` 内执行(非 idle 直接失败),不会与回合、`/compact`、自动压缩交错;
|
|
54
|
+
存在未闭合的 `compaction/start` 时也会拒绝执行。
|
|
55
|
+
|
|
56
|
+
## 保护集与选段策略
|
|
57
|
+
|
|
58
|
+
保护:开头 `protectHeadNodes`(默认 1,即任务声明)、末尾最近 `retainRatio` 窗口(下限 `minTailTokens`)、以及最后一条消息永不裁剪。
|
|
59
|
+
在保护集之间采用**最旧优先、够用即止**:从最旧的平衡切点开始,只增长到刚好释放够 token。
|
|
60
|
+
|
|
61
|
+
## 配置
|
|
62
|
+
|
|
63
|
+
在 profile patch 的 `context-trim` 行上覆盖(`cordis.patch.yml` 里列出了全部默认值):
|
|
64
|
+
|
|
65
|
+
`targetRatio`(0.9)、`reserveOutputTokens`(8192)、`retainRatio`(0.16)/`retainTokens`、`minTailTokens`(2048)、
|
|
66
|
+
`protectHeadNodes`(1)、`allowTailTrim`(true)、`markerSlackTokens`(64)。
|
|
67
|
+
|
|
68
|
+
预算公式:`budget = floor((contextWindow - reserveOutputTokens) * targetRatio)`。
|
|
69
|
+
|
|
70
|
+
## 局限
|
|
71
|
+
|
|
72
|
+
- 它是**丢弃**而不是摘要。需要“浓缩保留”时用 `/compact`;两者互补(先 `/trim` 会让随后的 `/compact` 更省、更易成功)。
|
|
73
|
+
- **无法缩减固定开销**(system prompt + 工具 schema 不在 surface 上);如果固定开销本身超预算,命令会明确报错而不是假装成功。
|
|
74
|
+
- v1 **没有 `/untrim`**:把旧内容重新变回 surface 需要 append `assistant`/`tool` 事件,而那只在 open turn 内合法。
|
|
75
|
+
- 计价使用 token meter 的启发式估算(与 `/compact`、GUI 上下文条一致),与提供商真实 usage 略有偏差。
|
|
76
|
+
|
|
77
|
+
## 开发与验证
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npm install # 本插件依赖的 harness 契约已固定为 devDependencies
|
|
81
|
+
npm test # node --test
|
|
82
|
+
npm run link:harness # 也可改为从本地 dsh 安装的依赖闭包解析 @deepseek-ai
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
已验证:30 个测试全部通过(选段算法、参数解析、真实 Session 上的 surface 改写与日志重放、插件命令注册与端到端裁剪);
|
|
86
|
+
隔离 `DSH_HOME` 安装后 dependency 与 bundle 层均正确 reconcile;`dsh --dump-config` 中出现 `context-trim` 行;profile 启动无加载错误。
|
|
87
|
+
CI 在 Node 22/24 上跑同一套测试;发布通过 `.github/workflows/publish.yml`(手动 `workflow_dispatch`)。
|
|
88
|
+
尚未执行:npm 首发(Trusted Publishing 无法创建全新包名,需要一次首发布引导)、以及 Web GUI 里的真实小窗口端到端验证。
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# dsh-command-context-trim bundle patch.
|
|
2
|
+
#
|
|
3
|
+
# Install into a profile:
|
|
4
|
+
# dsh plugin --profile <profile> add dsh-command-context-trim
|
|
5
|
+
#
|
|
6
|
+
# The insert activates the plugin; the config block below is the full default
|
|
7
|
+
# set, shown so overrides are a copy/paste away. Remove `config` entirely to use
|
|
8
|
+
# the built-in defaults (`lib/config.js`).
|
|
9
|
+
- insert:
|
|
10
|
+
- id: context-trim
|
|
11
|
+
name: dsh-command-context-trim
|
|
12
|
+
config:
|
|
13
|
+
# Fraction of the usable window a trim aims for: budget =
|
|
14
|
+
# floor((contextWindow - reserveOutputTokens) * targetRatio).
|
|
15
|
+
targetRatio: 0.9
|
|
16
|
+
# Output tokens kept available for the model's own reply.
|
|
17
|
+
reserveOutputTokens: 8192
|
|
18
|
+
# Recent tail kept verbatim, as a fraction of the model's context window.
|
|
19
|
+
retainRatio: 0.16
|
|
20
|
+
# Absolute floor for that retained tail.
|
|
21
|
+
minTailTokens: 2048
|
|
22
|
+
# Leading surface nodes never trimmed (the task statement).
|
|
23
|
+
protectHeadNodes: 1
|
|
24
|
+
# Allow the elided span to reach into the retained tail when the oldest
|
|
25
|
+
# span alone cannot free enough tokens.
|
|
26
|
+
allowTailTrim: true
|
|
27
|
+
# Slack added to the priced replacement marker, so the post-trim request
|
|
28
|
+
# stays under budget even though the marker text carries real numbers.
|
|
29
|
+
markerSlackTokens: 64
|
package/lib/apply.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surface mutation for one trim.
|
|
3
|
+
*
|
|
4
|
+
* Two synchronously adjacent appends do the whole job:
|
|
5
|
+
*
|
|
6
|
+
* 1. `compaction/prune` — the token meter's shadow-price claim. The meter's
|
|
7
|
+
* persisted projections are O(1) (they cannot re-price a replaced range), so
|
|
8
|
+
* a replacement must be preceded by an event stating the exact price of the
|
|
9
|
+
* range it shadows. Without it the fold records a zero delta and the reported
|
|
10
|
+
* context occupancy drifts from reality.
|
|
11
|
+
* 2. `user/message` with a positional replace — the replacement itself. It is
|
|
12
|
+
* model-only: the human transcript reads append-origin events, so nothing the
|
|
13
|
+
* user already saw is rewritten, and the full original content stays in the
|
|
14
|
+
* durable session log.
|
|
15
|
+
*
|
|
16
|
+
* A `user/message` is the only surface-eligible event an idle command may
|
|
17
|
+
* append: `assistant/message` requires an open step and a `tool/result`
|
|
18
|
+
* replacement requires an open turn, so a trim performed between turns — which
|
|
19
|
+
* is exactly when a user needs one — has no other legal node type.
|
|
20
|
+
*
|
|
21
|
+
* @module dsh-command-context-trim/apply
|
|
22
|
+
*/
|
|
23
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
24
|
+
|
|
25
|
+
/** Plugin name written into every replacement message's source marker. */
|
|
26
|
+
export const TRIM_PLUGIN = 'dsh-command-context-trim';
|
|
27
|
+
|
|
28
|
+
/** Provenance frozen into every replacement message. */
|
|
29
|
+
const TRIM_SOURCE = Object.freeze({ kind: 'plugin', plugin: TRIM_PLUGIN });
|
|
30
|
+
|
|
31
|
+
/** Framing used to price a marker before the plan's exact numbers are known. */
|
|
32
|
+
const PROVISIONAL_MARKER =
|
|
33
|
+
'[context-trim] an earlier span of this conversation was removed from this request to fit the model context window. ' +
|
|
34
|
+
'The task statement and the most recent messages are unchanged: continue the current task directly.';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build the provisional marker, whose price seeds planning.
|
|
38
|
+
* @returns a frozen user message used only for measurement.
|
|
39
|
+
*/
|
|
40
|
+
export function provisionalMarker() {
|
|
41
|
+
return createUserMessage({
|
|
42
|
+
content: [{ type: 'text', text: PROVISIONAL_MARKER }],
|
|
43
|
+
source: TRIM_SOURCE
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build the replacement message describing one committed trim.
|
|
49
|
+
* @param plan - the committed span plan.
|
|
50
|
+
* @param label - human label of the target route.
|
|
51
|
+
* @param budget - target request size in tokens.
|
|
52
|
+
* @returns the frozen replacement user message.
|
|
53
|
+
*/
|
|
54
|
+
export function createMarkerMessage(plan, label, budget) {
|
|
55
|
+
return createUserMessage({
|
|
56
|
+
content: [
|
|
57
|
+
{
|
|
58
|
+
type: 'text',
|
|
59
|
+
text:
|
|
60
|
+
`[context-trim] ${plan.shadowedSeqs.length} earlier messages (~${plan.shadowedTokens} tokens) were removed ` +
|
|
61
|
+
`from this request to fit the ${label} context window (target ${budget} tokens). ` +
|
|
62
|
+
'The task statement and the most recent messages are unchanged: continue the current task directly, ' +
|
|
63
|
+
'and ask the user or re-read a file when a removed detail is needed.'
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
source: TRIM_SOURCE
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether one message source identifies a context-trim replacement.
|
|
72
|
+
* @param source - message source restored from a surface node.
|
|
73
|
+
* @returns true when the source carries this plugin's marker.
|
|
74
|
+
*/
|
|
75
|
+
export function isTrimMarkerSource(source) {
|
|
76
|
+
return source?.kind === 'plugin' && source.plugin === TRIM_PLUGIN;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Apply one committed plan to a session surface.
|
|
81
|
+
* @param session - session whose surface is rewritten.
|
|
82
|
+
* @param plan - committed span plan naming the shadowed range.
|
|
83
|
+
* @param marker - replacement message built for that plan.
|
|
84
|
+
* @returns the appended replacement event.
|
|
85
|
+
* @throws when the session rejects the append (surface contract violation).
|
|
86
|
+
*/
|
|
87
|
+
export function applyTrim(session, plan, marker) {
|
|
88
|
+
session.append('compaction/prune', {
|
|
89
|
+
shadowedRange: { start: plan.startSeq, end: plan.endSeq },
|
|
90
|
+
shadowedSeqs: [...plan.shadowedSeqs],
|
|
91
|
+
shadowedTokenCount: plan.shadowedTokens
|
|
92
|
+
});
|
|
93
|
+
return session.append('user/message', marker, {
|
|
94
|
+
surfaceOp: { op: 'replace', start: plan.startSeq, end: plan.endSeq },
|
|
95
|
+
sourceEventSeqs: [...plan.shadowedSeqs]
|
|
96
|
+
});
|
|
97
|
+
}
|
package/lib/args.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/trim` argument parsing: `[check] [<tokens>|k|m] [<provider>:<model>]`.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately tiny and total: every rejection carries a human sentence, and
|
|
5
|
+
* no argument form reaches the session mutator without being understood.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-command-context-trim/args
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Multipliers accepted after a numeric budget. */
|
|
11
|
+
const UNITS = Object.freeze({ k: 1_000, K: 1_000, m: 1_000_000, M: 1_000_000 });
|
|
12
|
+
|
|
13
|
+
/** Help text returned with every argument failure. */
|
|
14
|
+
export const USAGE = [
|
|
15
|
+
'Usage: /trim [check] [<tokens>|k] [<provider>:<model>]',
|
|
16
|
+
' /trim fit the active model window',
|
|
17
|
+
' /trim check report the plan, change nothing',
|
|
18
|
+
' /trim 32k fit an explicit 32768-token budget',
|
|
19
|
+
' /trim lc:/models/qwen.gguf fit that route\'s declared window'
|
|
20
|
+
].join('\n');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse one `/trim` invocation.
|
|
24
|
+
* @param rawInput - text after the command name, verbatim.
|
|
25
|
+
* @returns `{ check, budget?, route? }`, or `{ error }` for an unrecognized form.
|
|
26
|
+
*/
|
|
27
|
+
export function parseTrimArguments(rawInput) {
|
|
28
|
+
const result = { check: false };
|
|
29
|
+
const fields = rawInput.trim().split(/\s+/u).filter((field) => field.length > 0);
|
|
30
|
+
for (const field of fields) {
|
|
31
|
+
if (field === 'check' || field === '--check' || field === '--dry-run') {
|
|
32
|
+
if (result.check) return { error: 'duplicate "check"' };
|
|
33
|
+
result.check = true;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const numeric = /^(\d+(?:\.\d+)?)([kKmM])?$/u.exec(field);
|
|
37
|
+
if (numeric !== null) {
|
|
38
|
+
if (result.budget !== undefined) return { error: `duplicate token budget "${field}"` };
|
|
39
|
+
const value = Math.floor(Number(numeric[1]) * (UNITS[numeric[2]] ?? 1));
|
|
40
|
+
if (!Number.isSafeInteger(value) || value <= 0) return { error: `invalid token budget "${field}"` };
|
|
41
|
+
result.budget = value;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const route = /^([A-Za-z0-9._-]+):(.+)$/u.exec(field);
|
|
45
|
+
if (route !== null) {
|
|
46
|
+
if (result.route !== undefined) return { error: `duplicate target route "${field}"` };
|
|
47
|
+
result.route = { provider: route[1], model: route[2] };
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
return { error: `unrecognized argument "${field}"` };
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration resolution for the model-free `/trim` command.
|
|
3
|
+
*
|
|
4
|
+
* Every field is validated and detached at plugin load, so a bad profile patch
|
|
5
|
+
* fails loudly once at boot instead of silently distorting a trim later.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-command-context-trim/config
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Built-in defaults, mirrored by the bundle patch's commented config block. */
|
|
11
|
+
export const DEFAULTS = Object.freeze({
|
|
12
|
+
targetRatio: 0.9,
|
|
13
|
+
reserveOutputTokens: 8192,
|
|
14
|
+
retainRatio: 0.16,
|
|
15
|
+
minTailTokens: 2048,
|
|
16
|
+
protectHeadNodes: 1,
|
|
17
|
+
allowTailTrim: true,
|
|
18
|
+
markerSlackTokens: 64
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** Every key this plugin accepts. */
|
|
22
|
+
const CONFIG_KEYS = new Set([
|
|
23
|
+
'targetRatio',
|
|
24
|
+
'reserveOutputTokens',
|
|
25
|
+
'retainRatio',
|
|
26
|
+
'retainTokens',
|
|
27
|
+
'minTailTokens',
|
|
28
|
+
'protectHeadNodes',
|
|
29
|
+
'allowTailTrim',
|
|
30
|
+
'markerSlackTokens'
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validate one untrusted configuration object and fill in defaults.
|
|
35
|
+
* @param config - raw plugin configuration from the loader.
|
|
36
|
+
* @returns a detached frozen configuration.
|
|
37
|
+
* @throws when a key is unknown or a value is out of range.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveConfig(config = {}) {
|
|
40
|
+
for (const key of Object.keys(config)) {
|
|
41
|
+
if (!CONFIG_KEYS.has(key)) {
|
|
42
|
+
throw new Error(`ContextTrimConfig: unknown key "${key}" (allowed: ${[...CONFIG_KEYS].join(', ')})`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const targetRatio = config.targetRatio ?? DEFAULTS.targetRatio;
|
|
46
|
+
const reserveOutputTokens = config.reserveOutputTokens ?? DEFAULTS.reserveOutputTokens;
|
|
47
|
+
const minTailTokens = config.minTailTokens ?? DEFAULTS.minTailTokens;
|
|
48
|
+
const protectHeadNodes = config.protectHeadNodes ?? DEFAULTS.protectHeadNodes;
|
|
49
|
+
const allowTailTrim = config.allowTailTrim ?? DEFAULTS.allowTailTrim;
|
|
50
|
+
const markerSlackTokens = config.markerSlackTokens ?? DEFAULTS.markerSlackTokens;
|
|
51
|
+
assertRatio('targetRatio', targetRatio);
|
|
52
|
+
assertNonNegativeInteger('reserveOutputTokens', reserveOutputTokens);
|
|
53
|
+
assertNonNegativeInteger('minTailTokens', minTailTokens);
|
|
54
|
+
assertNonNegativeInteger('protectHeadNodes', protectHeadNodes);
|
|
55
|
+
assertNonNegativeInteger('markerSlackTokens', markerSlackTokens);
|
|
56
|
+
if (typeof allowTailTrim !== 'boolean') throw new Error('ContextTrimConfig: allowTailTrim must be a boolean');
|
|
57
|
+
const retention = resolveRetention(config);
|
|
58
|
+
if (retention.retainRatio !== undefined && retention.retainRatio >= targetRatio) {
|
|
59
|
+
throw new Error(`ContextTrimConfig: retainRatio (${retention.retainRatio}) must be less than targetRatio (${targetRatio})`);
|
|
60
|
+
}
|
|
61
|
+
return Object.freeze({
|
|
62
|
+
targetRatio,
|
|
63
|
+
reserveOutputTokens,
|
|
64
|
+
...retention,
|
|
65
|
+
minTailTokens,
|
|
66
|
+
protectHeadNodes,
|
|
67
|
+
allowTailTrim,
|
|
68
|
+
markerSlackTokens
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Choose one explicit retention form, rejecting the ambiguous pair. */
|
|
73
|
+
function resolveRetention(config) {
|
|
74
|
+
const retainRatio = config.retainRatio;
|
|
75
|
+
const retainTokens = config.retainTokens;
|
|
76
|
+
if (retainRatio !== undefined && retainTokens !== undefined) {
|
|
77
|
+
throw new Error('ContextTrimConfig: retainRatio and retainTokens are mutually exclusive');
|
|
78
|
+
}
|
|
79
|
+
if (retainTokens !== undefined) {
|
|
80
|
+
assertNonNegativeInteger('retainTokens', retainTokens);
|
|
81
|
+
return { retainTokens };
|
|
82
|
+
}
|
|
83
|
+
const resolved = retainRatio ?? DEFAULTS.retainRatio;
|
|
84
|
+
assertRatio('retainRatio', resolved);
|
|
85
|
+
return { retainRatio: resolved };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Request-token budget a trim aims for on one model capacity.
|
|
90
|
+
* @param contextWindow - positive adapter-owned capacity of the target model.
|
|
91
|
+
* @param config - resolved configuration.
|
|
92
|
+
* @returns the target total request size in tokens.
|
|
93
|
+
* @throws when the reserved output budget leaves no usable window.
|
|
94
|
+
*/
|
|
95
|
+
export function budgetFor(contextWindow, config) {
|
|
96
|
+
if (!Number.isInteger(contextWindow) || contextWindow <= 0) {
|
|
97
|
+
throw new Error(`context trim: contextWindow (${String(contextWindow)}) must be a positive integer`);
|
|
98
|
+
}
|
|
99
|
+
const usable = contextWindow - config.reserveOutputTokens;
|
|
100
|
+
if (usable <= 0) {
|
|
101
|
+
throw new Error(`context trim: reserveOutputTokens (${config.reserveOutputTokens}) leaves no room inside the ${contextWindow}-token window`);
|
|
102
|
+
}
|
|
103
|
+
return Math.max(1, Math.floor(usable * config.targetRatio));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Verbatim recent-tail budget resolved for one model capacity.
|
|
108
|
+
* @param contextWindow - positive adapter-owned capacity of the target model.
|
|
109
|
+
* @param config - resolved configuration.
|
|
110
|
+
* @returns tokens kept verbatim at the end of the conversation.
|
|
111
|
+
*/
|
|
112
|
+
export function retentionFor(contextWindow, config) {
|
|
113
|
+
const configured = config.retainTokens ?? Math.floor(contextWindow * config.retainRatio);
|
|
114
|
+
return Math.max(config.minTailTokens, configured);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function assertRatio(name, value) {
|
|
118
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) {
|
|
119
|
+
throw new Error(`ContextTrimConfig: ${name} (${String(value)}) must be a number in (0, 1]`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function assertNonNegativeInteger(name, value) {
|
|
124
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
125
|
+
throw new Error(`ContextTrimConfig: ${name} (${String(value)}) must be a non-negative integer`);
|
|
126
|
+
}
|
|
127
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/trim` — model-free context trimming for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* A session built against a large-window cloud model can exceed a smaller local
|
|
5
|
+
* model's window the moment the model is switched. `/compact` cannot rescue that
|
|
6
|
+
* situation reliably, because compaction *summarizes*: its summarizer call must
|
|
7
|
+
* itself fit the (now smaller) window while holding the very region being
|
|
8
|
+
* condensed, so it fails for the same reason the original request failed.
|
|
9
|
+
*
|
|
10
|
+
* `/trim` needs no model at all. It measures the current request under the
|
|
11
|
+
* target route, picks the oldest tool-pairing-balanced span that frees exactly
|
|
12
|
+
* enough tokens, and shadows that span with one short marker message — two
|
|
13
|
+
* synchronous appends, zero LLM calls, so it works precisely when every request
|
|
14
|
+
* is failing.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-command-context-trim
|
|
17
|
+
*/
|
|
18
|
+
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compaction';
|
|
19
|
+
import z from '@deepseek-ai/schemastery';
|
|
20
|
+
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
|
+
|
|
26
|
+
/** Cordis plugin name. */
|
|
27
|
+
export const name = 'context-trim';
|
|
28
|
+
|
|
29
|
+
/** Services required before the command can be registered. */
|
|
30
|
+
export const inject = ['commands', 'tokenMeter', 'llm'];
|
|
31
|
+
|
|
32
|
+
/** Loader-facing configuration shape; ranges are enforced by {@link resolveConfig}. */
|
|
33
|
+
export const Config = z.object({
|
|
34
|
+
targetRatio: z.number(),
|
|
35
|
+
reserveOutputTokens: z.number(),
|
|
36
|
+
retainRatio: z.number(),
|
|
37
|
+
retainTokens: z.number(),
|
|
38
|
+
minTailTokens: z.number(),
|
|
39
|
+
protectHeadNodes: z.number(),
|
|
40
|
+
allowTailTrim: z.boolean(),
|
|
41
|
+
markerSlackTokens: z.number()
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Register `/trim` for every composed human-command adapter.
|
|
46
|
+
* @param ctx - context carrying the command registry, token meter, and LLM service.
|
|
47
|
+
* @param config - untrusted plugin configuration.
|
|
48
|
+
*/
|
|
49
|
+
export function apply(ctx, config) {
|
|
50
|
+
const resolved = resolveConfig(config ?? {});
|
|
51
|
+
const active = new Set();
|
|
52
|
+
const handler = (invocation) => {
|
|
53
|
+
const operation = execute(ctx, resolved, invocation);
|
|
54
|
+
active.add(operation);
|
|
55
|
+
const retire = () => {
|
|
56
|
+
active.delete(operation);
|
|
57
|
+
};
|
|
58
|
+
operation.then(retire, retire);
|
|
59
|
+
return operation;
|
|
60
|
+
};
|
|
61
|
+
ctx.effect(function* () {
|
|
62
|
+
yield async () => {
|
|
63
|
+
await Promise.allSettled(active);
|
|
64
|
+
};
|
|
65
|
+
yield ctx.commands.register({
|
|
66
|
+
name: 'trim',
|
|
67
|
+
description: 'Drop the least valuable span of context to fit the active model window (no model call)',
|
|
68
|
+
input: { hint: '[check] [tokens|k] [provider:model]' },
|
|
69
|
+
handler
|
|
70
|
+
});
|
|
71
|
+
}, 'context-trim lifecycle');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Execute one `/trim` invocation inside an idle-agent reservation.
|
|
76
|
+
* @param ctx - plugin context.
|
|
77
|
+
* @param config - resolved configuration.
|
|
78
|
+
* @param invocation - command invocation from the UI adapter.
|
|
79
|
+
* @returns a command result.
|
|
80
|
+
*/
|
|
81
|
+
async function execute(ctx, config, invocation) {
|
|
82
|
+
const parsed = parseTrimArguments(invocation.rawInput);
|
|
83
|
+
if (parsed.error !== undefined) return { kind: 'error', text: `${parsed.error}\n${USAGE}` };
|
|
84
|
+
let running;
|
|
85
|
+
try {
|
|
86
|
+
running = invocation.agent.runMaintenance((agentSignal) => trimOnce(ctx, config, invocation, parsed, agentSignal));
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return { kind: 'error', text: `Trim needs an idle agent: ${describeError(error)}` };
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
return await running;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (invocation.signal.aborted) return { kind: 'error', text: 'Trim cancelled.' };
|
|
94
|
+
return { kind: 'error', text: `Trim failed: ${describeError(error)}` };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Plan and apply one trim; the whole session-visible mutation happens here.
|
|
100
|
+
* @param ctx - plugin context.
|
|
101
|
+
* @param config - resolved configuration.
|
|
102
|
+
* @param invocation - command invocation (agent, signal).
|
|
103
|
+
* @param parsed - parsed command arguments.
|
|
104
|
+
* @param agentSignal - cancellation owned by the maintenance reservation.
|
|
105
|
+
* @returns a command result.
|
|
106
|
+
*/
|
|
107
|
+
async function trimOnce(ctx, config, invocation, parsed, agentSignal) {
|
|
108
|
+
const signal = AbortSignal.any([invocation.signal, agentSignal]);
|
|
109
|
+
signal.throwIfAborted();
|
|
110
|
+
const session = invocation.agent.session;
|
|
111
|
+
assertNoOpenCompaction(session);
|
|
112
|
+
const target = parsed.budget === undefined ? await resolveTarget(ctx, invocation.agent, parsed.route, signal) : undefined;
|
|
113
|
+
const budget = parsed.budget ?? budgetFor(target.contextWindow, config);
|
|
114
|
+
const label = target === undefined ? `an explicit ${budget}-token budget` : `${target.label} (window ${target.contextWindow})`;
|
|
115
|
+
const header = target === undefined ? undefined : targetHeader(session, target);
|
|
116
|
+
const measurement = ctx.tokenMeter.measure(session, header);
|
|
117
|
+
const nodes = measurement.nodes.map((node) => ({ seq: node.seq, heuristicTokens: node.heuristicTokens }));
|
|
118
|
+
if (nodes.length === 0) {
|
|
119
|
+
return { kind: 'success', text: `Nothing to trim: ${session.id} has no model-visible messages yet.` };
|
|
120
|
+
}
|
|
121
|
+
// The retained tail scales with the capacity being fitted: the target window
|
|
122
|
+
// when one is known, otherwise the explicit budget itself.
|
|
123
|
+
const retainTokens = retentionFor(target?.contextWindow ?? budget, config);
|
|
124
|
+
const envelopeTokens = Math.max(0, measurement.totalTokens - measurement.surfaceTokens);
|
|
125
|
+
const planFor = (markerTokens) =>
|
|
126
|
+
planTrim({
|
|
127
|
+
nodes,
|
|
128
|
+
envelopeTokens,
|
|
129
|
+
budget,
|
|
130
|
+
markerCost: markerTokens + config.markerSlackTokens,
|
|
131
|
+
retainTokens,
|
|
132
|
+
minTailTokens: config.minTailTokens,
|
|
133
|
+
protectHeadNodes: config.protectHeadNodes,
|
|
134
|
+
allowTailTrim: config.allowTailTrim,
|
|
135
|
+
isBalancedBefore: (seq) => toolPairingBalancedBefore(session, seq),
|
|
136
|
+
isBalancedAfter: (seq) => toolPairingBalancedAfter(session, seq)
|
|
137
|
+
});
|
|
138
|
+
let markerTokens = ctx.tokenMeter.estimateMessage(provisionalMarker());
|
|
139
|
+
let plan = planFor(markerTokens);
|
|
140
|
+
if (plan.kind !== 'span') return describeNonSpan(plan, label);
|
|
141
|
+
let marker = createMarkerMessage(plan, target?.label ?? `budget ${budget}`, budget);
|
|
142
|
+
const finalMarkerTokens = ctx.tokenMeter.estimateMessage(marker);
|
|
143
|
+
if (finalMarkerTokens > markerTokens) {
|
|
144
|
+
// The marker now carries real numbers; re-plan once so its own price is exact.
|
|
145
|
+
plan = planFor(finalMarkerTokens);
|
|
146
|
+
if (plan.kind !== 'span') return describeNonSpan(plan, label);
|
|
147
|
+
marker = createMarkerMessage(plan, target?.label ?? `budget ${budget}`, budget);
|
|
148
|
+
markerTokens = finalMarkerTokens;
|
|
149
|
+
}
|
|
150
|
+
if (parsed.check) return { kind: 'success', text: preview(plan, label, markerTokens) };
|
|
151
|
+
const replacement = applyTrim(session, plan, marker);
|
|
152
|
+
const after = ctx.tokenMeter.measure(session, header);
|
|
153
|
+
return { kind: 'success', text: report(plan, after, label, markerTokens), sourceEventSeq: replacement.seq };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Refuse to rewrite a surface while a compaction bracket is open. */
|
|
157
|
+
function assertNoOpenCompaction(session) {
|
|
158
|
+
for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
|
|
159
|
+
const event = session.eventAt(seq);
|
|
160
|
+
if (event === undefined) continue;
|
|
161
|
+
if (event.type === 'compaction/end') return;
|
|
162
|
+
// A seed boundary proves any earlier unmatched start belongs to a previous lifecycle.
|
|
163
|
+
if (event.type === 'session/end-seed') return;
|
|
164
|
+
if (event.type === 'compaction/start') {
|
|
165
|
+
throw new Error('a compaction is already in progress in this session; wait for it to finish, then retry');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Render every non-span outcome as a final command result. */
|
|
171
|
+
function describeNonSpan(plan, label) {
|
|
172
|
+
switch (plan.kind) {
|
|
173
|
+
case 'fits':
|
|
174
|
+
return {
|
|
175
|
+
kind: 'success',
|
|
176
|
+
text: `Already within budget: ~${plan.totalTokens} / ${plan.budget} tokens for ${label}. Nothing to trim.`
|
|
177
|
+
};
|
|
178
|
+
case 'envelope':
|
|
179
|
+
return {
|
|
180
|
+
kind: 'error',
|
|
181
|
+
text: [
|
|
182
|
+
`Fixed request overhead alone (~${plan.envelopeTokens} tokens of system prompt and tool schemas) exceeds the ${plan.budget}-token budget for ${label}.`,
|
|
183
|
+
'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.'
|
|
184
|
+
].join('\n')
|
|
185
|
+
};
|
|
186
|
+
case 'no-span':
|
|
187
|
+
return {
|
|
188
|
+
kind: 'error',
|
|
189
|
+
text: `Nothing safely trimmable for ${label}: ${plan.reason}, or no tool-pairing balanced cut exists.`
|
|
190
|
+
};
|
|
191
|
+
case 'insufficient':
|
|
192
|
+
return {
|
|
193
|
+
kind: 'error',
|
|
194
|
+
text: [
|
|
195
|
+
`Cannot free enough for ${label}: the largest balanced span frees ~${plan.maxFreeable} of the ~${plan.need} tokens needed.`,
|
|
196
|
+
`Protected content: task statement ~${plan.protectedHeadTokens} tokens, recent tail ~${plan.protectedTailTokens} tokens (retain target ~${plan.retainTokens}).`,
|
|
197
|
+
'Try /compact (it summarizes instead of dropping), a larger window, or /trim with an explicit budget after another reduction.'
|
|
198
|
+
].join('\n')
|
|
199
|
+
};
|
|
200
|
+
default:
|
|
201
|
+
return { kind: 'error', text: `Trim could not plan a reduction (${String(plan.kind)}).` };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Render a dry run. */
|
|
206
|
+
function preview(plan, label, markerTokens) {
|
|
207
|
+
return [
|
|
208
|
+
`Would trim ${plan.shadowedSeqs.length} messages (seqs ${plan.startSeq}-${plan.endSeq}, ~${plan.shadowedTokens} tokens → ${markerTokens}-token marker) for ${label}.`,
|
|
209
|
+
`Request size: ~${plan.totalTokens} → ~${plan.projectedTotal} tokens (target ${plan.budget}). Nothing was changed.`
|
|
210
|
+
].join('\n');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Render a committed trim against the re-measured request. */
|
|
214
|
+
function report(plan, after, label, markerTokens) {
|
|
215
|
+
const lines = [
|
|
216
|
+
`Trimmed ${plan.shadowedSeqs.length} messages (seqs ${plan.startSeq}-${plan.endSeq}, ~${plan.shadowedTokens} tokens → ${markerTokens}-token marker) for ${label}.`,
|
|
217
|
+
`Request size: ~${plan.totalTokens} → ~${after.totalTokens} tokens (target ${plan.budget}).`
|
|
218
|
+
];
|
|
219
|
+
if (plan.relaxedRetention) {
|
|
220
|
+
lines.push(`Retention relaxed to ~${plan.retainTokens} tokens to reach the budget.`);
|
|
221
|
+
}
|
|
222
|
+
return lines.join('\n');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Render a thrown value without trusting its string coercion. */
|
|
226
|
+
function describeError(error) {
|
|
227
|
+
try {
|
|
228
|
+
return error instanceof Error ? error.message : String(error);
|
|
229
|
+
} catch {
|
|
230
|
+
return '<unrenderable thrown value>';
|
|
231
|
+
}
|
|
232
|
+
}
|
package/lib/plan.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure trim planning.
|
|
3
|
+
*
|
|
4
|
+
* The policy is *oldest-first, least-long-possible*: the elided span starts at
|
|
5
|
+
* the oldest cut that keeps every tool-call/result pair intact, and grows only
|
|
6
|
+
* until it frees exactly enough tokens. The task statement (leading nodes) and
|
|
7
|
+
* the most recent messages are protected, because for a coding agent recency
|
|
8
|
+
* and the original request are the two pieces of high-value context; everything
|
|
9
|
+
* between them is what a smaller window can afford to lose.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here touches a session: the planner receives measured node prices and
|
|
12
|
+
* two balance predicates, so every branch is directly testable.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-command-context-trim/plan
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} TrimNode
|
|
19
|
+
* @property {number} seq - surface event sequence of the node.
|
|
20
|
+
* @property {number} heuristicTokens - the token meter's heuristic price for it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {object} TrimPlanInput
|
|
25
|
+
* @property {readonly TrimNode[]} nodes - current surface nodes in model-visible order.
|
|
26
|
+
* @property {number} envelopeTokens - non-surface request price (system prompt + tool schemas).
|
|
27
|
+
* @property {number} budget - target total request size in tokens.
|
|
28
|
+
* @property {number} markerCost - priced replacement marker, including configured slack.
|
|
29
|
+
* @property {number} retainTokens - preferred verbatim recent-tail budget.
|
|
30
|
+
* @property {number} minTailTokens - absolute floor for that retained tail.
|
|
31
|
+
* @property {number} protectHeadNodes - leading nodes that must never be elided.
|
|
32
|
+
* @property {boolean} allowTailTrim - whether the elided span may reach into the retained tail.
|
|
33
|
+
* @property {(seq: number) => boolean} isBalancedBefore - tool-pairing balance before a node.
|
|
34
|
+
* @property {(seq: number) => boolean} isBalancedAfter - tool-pairing balance after a node.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Plan one model-free trim.
|
|
39
|
+
* @param input - measured surface, budgets, and balance predicates.
|
|
40
|
+
* @returns a plan: `fits`, `envelope`, `no-span`, `insufficient`, or `span`.
|
|
41
|
+
*/
|
|
42
|
+
export function planTrim(input) {
|
|
43
|
+
const surfaceTokens = input.nodes.reduce((total, node) => total + node.heuristicTokens, 0);
|
|
44
|
+
const common = {
|
|
45
|
+
totalTokens: input.envelopeTokens + surfaceTokens,
|
|
46
|
+
surfaceTokens,
|
|
47
|
+
envelopeTokens: input.envelopeTokens,
|
|
48
|
+
budget: input.budget
|
|
49
|
+
};
|
|
50
|
+
if (common.totalTokens <= input.budget) return { kind: 'fits', ...common };
|
|
51
|
+
if (input.envelopeTokens >= input.budget) return { kind: 'envelope', ...common };
|
|
52
|
+
if (input.nodes.length < 2) {
|
|
53
|
+
return { kind: 'no-span', ...common, reason: 'the conversation surface holds fewer than two messages' };
|
|
54
|
+
}
|
|
55
|
+
const need = common.totalTokens - input.budget;
|
|
56
|
+
const configuredRetention = input.retainTokens;
|
|
57
|
+
let weakest = null;
|
|
58
|
+
for (const retainTokens of retentionLadder(configuredRetention, input.minTailTokens)) {
|
|
59
|
+
const attempt = attemptSpan({ ...input, retainTokens, need });
|
|
60
|
+
if (attempt.kind === 'span') {
|
|
61
|
+
return {
|
|
62
|
+
kind: 'span',
|
|
63
|
+
...common,
|
|
64
|
+
...attempt,
|
|
65
|
+
need,
|
|
66
|
+
retainTokens,
|
|
67
|
+
relaxedRetention: retainTokens < configuredRetention,
|
|
68
|
+
projectedTotal: common.totalTokens - attempt.freedTokens
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (weakest === null || attempt.maxFreeable > weakest.maxFreeable) weakest = { ...attempt, retainTokens };
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
kind: 'insufficient',
|
|
75
|
+
...common,
|
|
76
|
+
need,
|
|
77
|
+
maxFreeable: weakest?.maxFreeable ?? 0,
|
|
78
|
+
protectedHeadTokens: headTokens(input.nodes, input.protectHeadNodes),
|
|
79
|
+
protectedTailTokens: weakest?.protectedTailTokens ?? 0,
|
|
80
|
+
retainTokens: configuredRetention
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Try one retention budget, returning the smallest oldest-anchored balanced span
|
|
86
|
+
* that frees at least `need` tokens.
|
|
87
|
+
* @param input - planning input plus the retention budget under test.
|
|
88
|
+
* @returns a `span` plan, or the `shortfall` diagnostics for this retention.
|
|
89
|
+
*/
|
|
90
|
+
function attemptSpan(input) {
|
|
91
|
+
const nodes = input.nodes;
|
|
92
|
+
const tailStart = tailStartIndex(nodes, input.retainTokens);
|
|
93
|
+
const lastElidable = input.allowTailTrim ? nodes.length - 2 : Math.min(nodes.length - 2, tailStart - 1);
|
|
94
|
+
const shortfall = (maxFreeable) => ({
|
|
95
|
+
kind: 'shortfall',
|
|
96
|
+
maxFreeable,
|
|
97
|
+
protectedTailTokens: tailTokens(nodes, tailStart),
|
|
98
|
+
protectedHeadTokens: headTokens(nodes, input.protectHeadNodes)
|
|
99
|
+
});
|
|
100
|
+
const from = Math.min(input.protectHeadNodes, nodes.length - 1);
|
|
101
|
+
if (lastElidable < from) return shortfall(0);
|
|
102
|
+
let start = -1;
|
|
103
|
+
for (let index = from; index <= lastElidable; index += 1) {
|
|
104
|
+
if (input.isBalancedBefore(nodes[index].seq)) {
|
|
105
|
+
start = index;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (start === -1) return shortfall(0);
|
|
110
|
+
let accumulated = 0;
|
|
111
|
+
let best = null;
|
|
112
|
+
for (let index = start; index <= lastElidable; index += 1) {
|
|
113
|
+
accumulated += nodes[index].heuristicTokens;
|
|
114
|
+
if (!input.isBalancedAfter(nodes[index].seq)) continue;
|
|
115
|
+
const freedTokens = accumulated - input.markerCost;
|
|
116
|
+
if (best === null || freedTokens > best.freedTokens) {
|
|
117
|
+
best = {
|
|
118
|
+
startIndex: start,
|
|
119
|
+
endIndex: index,
|
|
120
|
+
startSeq: nodes[start].seq,
|
|
121
|
+
endSeq: nodes[index].seq,
|
|
122
|
+
shadowedSeqs: nodes.slice(start, index + 1).map((node) => node.seq),
|
|
123
|
+
shadowedTokens: accumulated,
|
|
124
|
+
freedTokens
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (freedTokens >= input.need) break;
|
|
128
|
+
}
|
|
129
|
+
return best === null ? shortfall(0) : best.freedTokens >= input.need
|
|
130
|
+
? { kind: 'span', ...best }
|
|
131
|
+
: shortfall(best.freedTokens);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Descending retention budgets to try: the configured one first, then half of
|
|
136
|
+
* it, then the configured floor. A trim exists to keep the session usable, so a
|
|
137
|
+
* configured tail that makes the fit impossible is relaxed rather than obeyed —
|
|
138
|
+
* and the result reports that it happened.
|
|
139
|
+
* @param configured - preferred verbatim recent-tail budget.
|
|
140
|
+
* @param floor - absolute minimum retained tail.
|
|
141
|
+
* @returns distinct retention budgets, strongest first.
|
|
142
|
+
*/
|
|
143
|
+
function retentionLadder(configured, floor) {
|
|
144
|
+
const candidates = [configured, Math.max(floor, Math.floor(configured / 2)), floor];
|
|
145
|
+
return [...new Set(candidates.filter((value) => value >= 0 && value <= configured))];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Index of the first node retained verbatim for one tail budget. */
|
|
149
|
+
function tailStartIndex(nodes, retainTokens) {
|
|
150
|
+
let accumulated = 0;
|
|
151
|
+
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
152
|
+
accumulated += nodes[index].heuristicTokens;
|
|
153
|
+
if (accumulated >= retainTokens) return index;
|
|
154
|
+
}
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Tokens held by the nodes from `tailStart` to the end. */
|
|
159
|
+
function tailTokens(nodes, tailStart) {
|
|
160
|
+
let total = 0;
|
|
161
|
+
for (let index = tailStart; index < nodes.length; index += 1) total += nodes[index].heuristicTokens;
|
|
162
|
+
return total;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Tokens held by the protected leading nodes. */
|
|
166
|
+
function headTokens(nodes, protectHeadNodes) {
|
|
167
|
+
let total = 0;
|
|
168
|
+
for (let index = 0; index < Math.min(protectHeadNodes, nodes.length); index += 1) total += nodes[index].heuristicTokens;
|
|
169
|
+
return total;
|
|
170
|
+
}
|
package/lib/target.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve which model a trim is fitting, and price the request envelope under
|
|
3
|
+
* that model's route.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-command-context-trim/target
|
|
6
|
+
*/
|
|
7
|
+
import { canonicalHeader } from '@deepseek-ai/dsh-session';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Latest durable model intent for a session: the newest `model/selection` (a
|
|
11
|
+
* pending switch) or the newest `request/header` (the route actually used),
|
|
12
|
+
* whichever the log records last. Scanning backwards and taking the first hit
|
|
13
|
+
* is exactly the rule the harness's own model-selection projection applies:
|
|
14
|
+
* a selection later consumed by a matching request is superseded by that
|
|
15
|
+
* request's header, and a newer selection supersedes both.
|
|
16
|
+
* @param session - session whose log is read.
|
|
17
|
+
* @returns `{ provider, model, source }`, or undefined when nothing was routed yet.
|
|
18
|
+
*/
|
|
19
|
+
export function latestRoute(session) {
|
|
20
|
+
for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
|
|
21
|
+
const event = session.eventAt(seq);
|
|
22
|
+
if (event === undefined) continue;
|
|
23
|
+
if (event.type === 'model/selection') {
|
|
24
|
+
return { provider: event.data.provider, model: event.data.model, source: 'selection' };
|
|
25
|
+
}
|
|
26
|
+
if (event.type === 'request/header') {
|
|
27
|
+
const config = event.data.header.config;
|
|
28
|
+
return { provider: config.provider, model: config.model, source: 'request' };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the target route and its declared context capacity.
|
|
36
|
+
* @param ctx - context carrying the LLM service.
|
|
37
|
+
* @param agent - agent whose session supplies the durable model intent.
|
|
38
|
+
* @param requested - explicit `{ provider, model }` from the command line, if any.
|
|
39
|
+
* @param signal - cancellation signal for model resolution.
|
|
40
|
+
* @returns `{ provider, model, contextWindow, label }`.
|
|
41
|
+
* @throws when no route can be determined, the route is unknown, or its adapter declares no window.
|
|
42
|
+
*/
|
|
43
|
+
export async function resolveTarget(ctx, agent, requested, signal) {
|
|
44
|
+
const route = requested ?? latestRoute(agent.session) ?? agentRoute(agent);
|
|
45
|
+
if (route === undefined) {
|
|
46
|
+
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
|
+
}
|
|
48
|
+
let info;
|
|
49
|
+
try {
|
|
50
|
+
info = await ctx.llm.resolveModelInfo(route.provider, route.model, signal);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`unknown model route ${route.provider}:${route.model} (${describeError(error)}); ` +
|
|
54
|
+
'pass an explicit budget instead (e.g. /trim 32k)'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const contextWindow = info?.context?.contextWindow;
|
|
58
|
+
if (!Number.isInteger(contextWindow) || contextWindow <= 0) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`the adapter for ${route.provider}:${route.model} declares no usable contextWindow ` +
|
|
61
|
+
'(set it on that model entry in settings.yaml, or pass an explicit budget such as /trim 32k)'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return { provider: route.provider, model: route.model, contextWindow, label: `${route.provider}:${route.model}` };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Re-price the session's current request envelope under another route by
|
|
69
|
+
* overriding the logged header's model config. The token meter reuses provider
|
|
70
|
+
* usage only when the header matches its anchor exactly, so overriding the
|
|
71
|
+
* config forces a fresh heuristic estimate sized for the target model's route.
|
|
72
|
+
* @param session - session whose envelope is repriced.
|
|
73
|
+
* @param target - resolved target route.
|
|
74
|
+
* @returns a canonical header for the target route, or undefined before any request.
|
|
75
|
+
*/
|
|
76
|
+
export function targetHeader(session, target) {
|
|
77
|
+
const current = session.requestHeader();
|
|
78
|
+
if (current === undefined) return undefined;
|
|
79
|
+
return canonicalHeader({
|
|
80
|
+
...current,
|
|
81
|
+
config: { ...current.config, provider: target.provider, model: target.model }
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Per-agent configured route, used only before any request was routed. */
|
|
86
|
+
function agentRoute(agent) {
|
|
87
|
+
const provider = agent.options?.provider;
|
|
88
|
+
const model = agent.options?.model;
|
|
89
|
+
if (typeof provider !== 'string' || provider.length === 0 || typeof model !== 'string' || model.length === 0) return undefined;
|
|
90
|
+
return { provider, model, source: 'agent-options' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Render a thrown value without trusting its string coercion. */
|
|
94
|
+
function describeError(error) {
|
|
95
|
+
try {
|
|
96
|
+
return error instanceof Error ? error.message : String(error);
|
|
97
|
+
} catch {
|
|
98
|
+
return '<unrenderable thrown value>';
|
|
99
|
+
}
|
|
100
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-command-context-trim",
|
|
3
|
+
"version": "0.1.0",
|
|
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.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "snailium",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "lib/index.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"lib",
|
|
18
|
+
"cordis.patch.yml",
|
|
19
|
+
"README.md",
|
|
20
|
+
"README.zh.md",
|
|
21
|
+
"CHANGELOG.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": "^22.19 || >=24"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test",
|
|
29
|
+
"link:harness": "bash scripts/dev-link-harness.sh",
|
|
30
|
+
"prepublishOnly": "npm test"
|
|
31
|
+
},
|
|
32
|
+
"dsh": {
|
|
33
|
+
"bundle": {
|
|
34
|
+
"patch": "./cordis.patch.yml"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
39
|
+
"@deepseek-ai/dsh-commands": "^0.1.2-rc.1",
|
|
40
|
+
"@deepseek-ai/dsh-compaction": "^0.1.2-rc.1",
|
|
41
|
+
"@deepseek-ai/dsh-invariants": "^0.1.2-rc.1",
|
|
42
|
+
"@deepseek-ai/dsh-llm": "^0.1.2-rc.1",
|
|
43
|
+
"@deepseek-ai/dsh-session": "^0.1.2-rc.1",
|
|
44
|
+
"@deepseek-ai/dsh-token-meter": "^0.1.2-rc.1",
|
|
45
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"@deepseek-ai/cordis": {
|
|
49
|
+
"optional": true
|
|
50
|
+
},
|
|
51
|
+
"@deepseek-ai/dsh-commands": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"@deepseek-ai/dsh-compaction": {
|
|
55
|
+
"optional": true
|
|
56
|
+
},
|
|
57
|
+
"@deepseek-ai/dsh-invariants": {
|
|
58
|
+
"optional": true
|
|
59
|
+
},
|
|
60
|
+
"@deepseek-ai/dsh-llm": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"@deepseek-ai/dsh-session": {
|
|
64
|
+
"optional": true
|
|
65
|
+
},
|
|
66
|
+
"@deepseek-ai/dsh-token-meter": {
|
|
67
|
+
"optional": true
|
|
68
|
+
},
|
|
69
|
+
"@deepseek-ai/schemastery": {
|
|
70
|
+
"optional": true
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@deepseek-ai/dsh-compaction": "0.1.2-rc.1",
|
|
75
|
+
"@deepseek-ai/dsh-llm": "0.1.2-rc.1",
|
|
76
|
+
"@deepseek-ai/dsh-session": "0.1.2-rc.1",
|
|
77
|
+
"@deepseek-ai/schemastery": "3.18.2"
|
|
78
|
+
},
|
|
79
|
+
"keywords": [
|
|
80
|
+
"dsh",
|
|
81
|
+
"deepseek-harness",
|
|
82
|
+
"plugin",
|
|
83
|
+
"context",
|
|
84
|
+
"context-window",
|
|
85
|
+
"trim",
|
|
86
|
+
"compaction",
|
|
87
|
+
"local-model"
|
|
88
|
+
],
|
|
89
|
+
"repository": {
|
|
90
|
+
"type": "git",
|
|
91
|
+
"url": "git+https://github.com/snailium/dsh-command-context-trim.git"
|
|
92
|
+
},
|
|
93
|
+
"homepage": "https://github.com/snailium/dsh-command-context-trim#readme",
|
|
94
|
+
"bugs": {
|
|
95
|
+
"url": "https://github.com/snailium/dsh-command-context-trim/issues"
|
|
96
|
+
},
|
|
97
|
+
"publishConfig": {
|
|
98
|
+
"access": "public"
|
|
99
|
+
}
|
|
100
|
+
}
|