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 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
@@ -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
+ }