dsh-command-context-trim 0.1.0 → 0.1.1
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 +31 -1
- package/README.md +34 -7
- package/README.zh.md +17 -3
- package/lib/apply.js +4 -2
- package/lib/index.js +13 -4
- package/lib/plan.js +73 -16
- package/lib/session-compat.js +80 -0
- package/package.json +11 -9
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,35 @@ All notable changes to this project are documented here. This project adheres to
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.1.1] - 2026-09-15
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **DeepSeek Harness 0.1.5 compatibility.** 0.1.5 renamed the positional replacement marker
|
|
13
|
+
(`{op: 'replace', start, end}` → `{op: 'replace', startSeq, endSeq}`), so every trim failed with
|
|
14
|
+
`session event "user/message" carries an invalid replace surfaceOp`. The plugin now probes the accepted shape
|
|
15
|
+
once against the harness actually installed and writes that one, so the same build works on the 0.1.2 and
|
|
16
|
+
0.1.5 lines without a version check.
|
|
17
|
+
- **The system prompt is never trimmed — and no longer eats head protection.** 0.1.5 moved the system prompt
|
|
18
|
+
from the request header onto the surface as node 0 (`system/message`). A position-only "protect the first
|
|
19
|
+
node" rule would have protected the *system prompt* and exposed the **user's original request** as the first
|
|
20
|
+
elidable message. System nodes are now barriers: never elided and never crossed, and `protectHeadNodes` counts
|
|
21
|
+
only non-barrier nodes, so it keeps protecting the task statement.
|
|
22
|
+
|
|
23
|
+
### Notes
|
|
24
|
+
|
|
25
|
+
- No configuration changes. The only user-visible difference is the "fixed request overhead" refusal, which now
|
|
26
|
+
says "tool schemas and other non-surface request data": on 0.1.5 the system prompt is surface content rather
|
|
27
|
+
than header content.
|
|
28
|
+
- Verified on harness 0.1.2-rc.1 and 0.1.5-rc.2 (40 tests each, same build).
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- Integration tests against the **real** `ctx.tokenMeter` (bare cordis context + stub projection registry): a trim's
|
|
34
|
+
measured saving equals the `compaction/prune` shadow price it claims minus the replacement marker, and a *fresh* meter
|
|
35
|
+
folding the replayed log reaches the identical total — the replay property the claim exists for.
|
|
36
|
+
|
|
8
37
|
## [0.1.0] - 2026-09-15
|
|
9
38
|
|
|
10
39
|
### Added
|
|
@@ -32,5 +61,6 @@ All notable changes to this project are documented here. This project adheres to
|
|
|
32
61
|
content stays in the durable session log. v1 has no `/untrim`.
|
|
33
62
|
- Requires a harness that exposes `ctx.commands`, `ctx.tokenMeter`, and `ctx.llm` (DeepSeek Harness 0.1.2-rc.1 or later).
|
|
34
63
|
|
|
35
|
-
[Unreleased]: https://github.com/snailium/dsh-command-context-trim/compare/v0.1.
|
|
64
|
+
[Unreleased]: https://github.com/snailium/dsh-command-context-trim/compare/v0.1.1...HEAD
|
|
65
|
+
[0.1.1]: https://github.com/snailium/dsh-command-context-trim/compare/v0.1.0...v0.1.1
|
|
36
66
|
[0.1.0]: https://github.com/snailium/dsh-command-context-trim/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ synchronous appends, zero LLM calls — it works precisely when every request is
|
|
|
22
22
|
## Install
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
# from npm
|
|
25
|
+
# from npm
|
|
26
26
|
dsh plugin --profile web add dsh-command-context-trim
|
|
27
27
|
|
|
28
28
|
# from a checkout
|
|
@@ -78,6 +78,8 @@ Design consequences:
|
|
|
78
78
|
original content stays in the durable session log, so a trim is auditable and recoverable by hand.
|
|
79
79
|
- **No tool-call/result pair is ever split.** Cut edges are chosen with
|
|
80
80
|
`toolPairingBalancedBefore`/`After` from `@deepseek-ai/dsh-compaction`.
|
|
81
|
+
- **The system prompt is never trimmed** (harness 0.1.5+ carries it as a surface node): it is a barrier that no
|
|
82
|
+
elided span may touch or cross.
|
|
81
83
|
- **Mutual exclusion with everything else.** The handler runs inside `agent.runMaintenance()`, which fails unless the agent
|
|
82
84
|
is idle, so it cannot interleave with a turn, `/compact`, or automatic compaction; it also refuses while an unmatched
|
|
83
85
|
`compaction/start` is open.
|
|
@@ -93,6 +95,26 @@ Design consequences:
|
|
|
93
95
|
Within those bounds the policy is **oldest-first, least-long-possible**: the elided span starts at the oldest balanced cut
|
|
94
96
|
and grows only until it frees exactly enough tokens.
|
|
95
97
|
|
|
98
|
+
## Compatibility
|
|
99
|
+
|
|
100
|
+
| Harness | State |
|
|
101
|
+
|---|---|
|
|
102
|
+
| 0.1.2-rc.1 (`latest`) | ✅ full suite green |
|
|
103
|
+
| 0.1.5-rc.2 (`next`) | ✅ full suite green |
|
|
104
|
+
|
|
105
|
+
Two harness changes between those lines are handled without a version check:
|
|
106
|
+
|
|
107
|
+
- **The replacement marker was renamed** — `{op: 'replace', start, end}` became `{op: 'replace', startSeq, endSeq}`.
|
|
108
|
+
The plugin probes a throwaway detached session with each known shape at first use and writes the accepted one.
|
|
109
|
+
- **The system prompt moved onto the surface** — 0.1.5 carries it as `system/message` node 0 instead of
|
|
110
|
+
`header.system`. System nodes are treated as **barriers**: they are never elided, no elided span crosses one,
|
|
111
|
+
and `protectHeadNodes` counts only non-barrier nodes, so head protection keeps covering the task statement
|
|
112
|
+
rather than the system prompt.
|
|
113
|
+
|
|
114
|
+
Because of the second change, the fixed request overhead is now the tool schemas plus any other non-surface
|
|
115
|
+
request data; on 0.1.2 it also included the system prompt. A trim's budget itself is unaffected — it comes from
|
|
116
|
+
the token meter's total, whichever way the harness splits that total.
|
|
117
|
+
|
|
96
118
|
## Configuration
|
|
97
119
|
|
|
98
120
|
Override on the `context-trim` row of a profile patch (the bundle's own `cordis.patch.yml` lists the full default set):
|
|
@@ -128,21 +150,26 @@ npm run link:harness # or resolve @deepseek-ai from a local dsh installation i
|
|
|
128
150
|
```
|
|
129
151
|
|
|
130
152
|
Tests cover the pure planner and argument parser, the surface mutation against a real `Session` (including log replay),
|
|
131
|
-
|
|
153
|
+
the plugin's command registration and end-to-end trim over a stub context, and — against the real `ctx.tokenMeter` — that a
|
|
154
|
+
trim's measured saving equals the shadow price it claims and that a fresh meter replaying the trimmed log lands on the very
|
|
155
|
+
same total. CI runs the suite on Node 22 and 24;
|
|
132
156
|
releases go out through `.github/workflows/publish.yml`, which is manual-only (`workflow_dispatch`).
|
|
133
157
|
|
|
134
158
|
### Verification status
|
|
135
159
|
|
|
136
160
|
| Check | State |
|
|
137
161
|
|---|---|
|
|
138
|
-
| `npm test` (
|
|
162
|
+
| `npm test` (34 tests: planner, args, surface apply + log replay, plugin handler) | ✅ passing |
|
|
139
163
|
| Isolated `DSH_HOME` install (`dsh plugin add file:…`) reconciling dependency **and** bundle layer | ✅ verified |
|
|
140
164
|
| Composed profile tree contains the `context-trim` insert row (`dsh --dump-config`) | ✅ verified |
|
|
141
165
|
| 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`) | ✅
|
|
143
|
-
|
|
|
144
|
-
|
|
|
145
|
-
|
|
|
166
|
+
| Same suite against the pinned **published** harness packages (`npm ci`) | ✅ 34 passing |
|
|
167
|
+
| Integration against the **real** `ctx.tokenMeter`: measured drop equals the claimed shadow price, and a fresh meter replaying the trimmed log reaches the identical total | ✅ 4 tests |
|
|
168
|
+
| Same suite on harness 0.1.5-rc.2 (renamed marker + surface system prompt) | ✅ 40 passing |
|
|
169
|
+
| CI workflow (Node 22 / 24) | ✅ green |
|
|
170
|
+
| npm release via GitHub Actions | ✅ 0.1.0 published with provenance (`+ dsh-command-context-trim@0.1.0`) |
|
|
171
|
+
| Isolated profile install **from the npm registry** (dependency + bundle layer + composed insert row) | ✅ 0.1.0 |
|
|
172
|
+
| End-to-end in the web GUI against a small-window model | ⏳ harness ready, not yet run |
|
|
146
173
|
|
|
147
174
|
## License
|
|
148
175
|
|
package/README.zh.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
## 安装
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
dsh plugin --profile web add dsh-command-context-trim #
|
|
20
|
+
dsh plugin --profile web add dsh-command-context-trim # 从 npm 安装
|
|
21
21
|
dsh plugin --profile web add file:/path/to/dsh-command-context-trim # 从源码
|
|
22
22
|
```
|
|
23
23
|
|
|
@@ -58,6 +58,20 @@ dsh plugin --profile web add file:/path/to/dsh-command-context-trim # 从源
|
|
|
58
58
|
保护:开头 `protectHeadNodes`(默认 1,即任务声明)、末尾最近 `retainRatio` 窗口(下限 `minTailTokens`)、以及最后一条消息永不裁剪。
|
|
59
59
|
在保护集之间采用**最旧优先、够用即止**:从最旧的平衡切点开始,只增长到刚好释放够 token。
|
|
60
60
|
|
|
61
|
+
## 兼容性
|
|
62
|
+
|
|
63
|
+
| Harness | 状态 |
|
|
64
|
+
|---|---|
|
|
65
|
+
| 0.1.2-rc.1(`latest`) | ✅ 全套测试通过 |
|
|
66
|
+
| 0.1.5-rc.2(`next`) | ✅ 全套测试通过 |
|
|
67
|
+
|
|
68
|
+
两处 0.1.5 变更已在不做版本号判断的前提下兼容:
|
|
69
|
+
|
|
70
|
+
- **替换标记改名**:`{op:'replace', start, end}` → `{op:'replace', startSeq, endSeq}`。插件首次使用时用一个一次性游离 session 探测本机 harness 接受哪种形状,再按该形状写入。
|
|
71
|
+
- **system prompt 从 header 搬到了 surface**(0.1.5 作为 `system/message` 节点 0)。system 节点被当作**屏障**:永不裁剪、任何被裁区间都不得跨越它;同时 `protectHeadNodes` 只统计非屏障节点,因此"保护头部"保护的仍是**用户的任务声明**,而不是 system prompt。
|
|
72
|
+
|
|
73
|
+
因此"固定请求开销"的含义变为工具 schema + 其它非 surface 请求数据(0.1.2 上还包含 system prompt);裁剪预算本身不受影响,因为它来自 token meter 的总量。
|
|
74
|
+
|
|
61
75
|
## 配置
|
|
62
76
|
|
|
63
77
|
在 profile patch 的 `context-trim` 行上覆盖(`cordis.patch.yml` 里列出了全部默认值):
|
|
@@ -82,10 +96,10 @@ npm test # node --test
|
|
|
82
96
|
npm run link:harness # 也可改为从本地 dsh 安装的依赖闭包解析 @deepseek-ai
|
|
83
97
|
```
|
|
84
98
|
|
|
85
|
-
已验证:
|
|
99
|
+
已验证:34 个测试全部通过(选段算法、参数解析、真实 Session 上的 surface 改写与日志重放、插件命令注册与端到端裁剪,以及用**真实 `ctx.tokenMeter`** 验证「实测降幅 == 声明的 shadow price」和「新进程重放裁剪后日志得到完全一致的总量」);
|
|
86
100
|
隔离 `DSH_HOME` 安装后 dependency 与 bundle 层均正确 reconcile;`dsh --dump-config` 中出现 `context-trim` 行;profile 启动无加载错误。
|
|
87
101
|
CI 在 Node 22/24 上跑同一套测试;发布通过 `.github/workflows/publish.yml`(手动 `workflow_dispatch`)。
|
|
88
|
-
|
|
102
|
+
npm 0.1.1 已发布(带 provenance),并已在隔离 profile 里从 registry 安装验证;尚未执行:Web GUI 里的真实小窗口端到端验证(mock provider 与隔离实例已就绪)。
|
|
89
103
|
|
|
90
104
|
## License
|
|
91
105
|
|
package/lib/apply.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* @module dsh-command-context-trim/apply
|
|
22
22
|
*/
|
|
23
23
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
24
|
+
import { replacementOp } from './session-compat.js';
|
|
24
25
|
|
|
25
26
|
/** Plugin name written into every replacement message's source marker. */
|
|
26
27
|
export const TRIM_PLUGIN = 'dsh-command-context-trim';
|
|
@@ -81,17 +82,18 @@ export function isTrimMarkerSource(source) {
|
|
|
81
82
|
* @param session - session whose surface is rewritten.
|
|
82
83
|
* @param plan - committed span plan naming the shadowed range.
|
|
83
84
|
* @param marker - replacement message built for that plan.
|
|
85
|
+
* @param keys - replacement-marker keys this harness accepts ({@link replaceKeys}).
|
|
84
86
|
* @returns the appended replacement event.
|
|
85
87
|
* @throws when the session rejects the append (surface contract violation).
|
|
86
88
|
*/
|
|
87
|
-
export function applyTrim(session, plan, marker) {
|
|
89
|
+
export function applyTrim(session, plan, marker, keys) {
|
|
88
90
|
session.append('compaction/prune', {
|
|
89
91
|
shadowedRange: { start: plan.startSeq, end: plan.endSeq },
|
|
90
92
|
shadowedSeqs: [...plan.shadowedSeqs],
|
|
91
93
|
shadowedTokenCount: plan.shadowedTokens
|
|
92
94
|
});
|
|
93
95
|
return session.append('user/message', marker, {
|
|
94
|
-
surfaceOp:
|
|
96
|
+
surfaceOp: replacementOp(keys, plan.startSeq, plan.endSeq),
|
|
95
97
|
sourceEventSeqs: [...plan.shadowedSeqs]
|
|
96
98
|
});
|
|
97
99
|
}
|
package/lib/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import { applyTrim, createMarkerMessage, provisionalMarker } from './apply.js';
|
|
|
22
22
|
import { budgetFor, resolveConfig, retentionFor } from './config.js';
|
|
23
23
|
import { planTrim } from './plan.js';
|
|
24
24
|
import { resolveTarget, targetHeader } from './target.js';
|
|
25
|
+
import { replaceKeys } from './session-compat.js';
|
|
25
26
|
|
|
26
27
|
/** Cordis plugin name. */
|
|
27
28
|
export const name = 'context-trim';
|
|
@@ -114,7 +115,15 @@ async function trimOnce(ctx, config, invocation, parsed, agentSignal) {
|
|
|
114
115
|
const label = target === undefined ? `an explicit ${budget}-token budget` : `${target.label} (window ${target.contextWindow})`;
|
|
115
116
|
const header = target === undefined ? undefined : targetHeader(session, target);
|
|
116
117
|
const measurement = ctx.tokenMeter.measure(session, header);
|
|
117
|
-
const nodes = measurement.nodes.map((node) => ({
|
|
118
|
+
const nodes = measurement.nodes.map((node) => ({
|
|
119
|
+
seq: node.seq,
|
|
120
|
+
heuristicTokens: node.heuristicTokens,
|
|
121
|
+
// Harness 0.1.5+ carries the system prompt as surface node 0. It is never
|
|
122
|
+
// elidable, and no elided span may cross it: dropping it would strip the
|
|
123
|
+
// model's instructions, and letting it consume head protection would expose
|
|
124
|
+
// the user's original request instead.
|
|
125
|
+
...(session.eventAt(node.seq)?.type === 'system/message' ? { barrier: true } : {})
|
|
126
|
+
}));
|
|
118
127
|
if (nodes.length === 0) {
|
|
119
128
|
return { kind: 'success', text: `Nothing to trim: ${session.id} has no model-visible messages yet.` };
|
|
120
129
|
}
|
|
@@ -148,7 +157,7 @@ async function trimOnce(ctx, config, invocation, parsed, agentSignal) {
|
|
|
148
157
|
markerTokens = finalMarkerTokens;
|
|
149
158
|
}
|
|
150
159
|
if (parsed.check) return { kind: 'success', text: preview(plan, label, markerTokens) };
|
|
151
|
-
const replacement = applyTrim(session, plan, marker);
|
|
160
|
+
const replacement = applyTrim(session, plan, marker, replaceKeys());
|
|
152
161
|
const after = ctx.tokenMeter.measure(session, header);
|
|
153
162
|
return { kind: 'success', text: report(plan, after, label, markerTokens), sourceEventSeq: replacement.seq };
|
|
154
163
|
}
|
|
@@ -179,7 +188,7 @@ function describeNonSpan(plan, label) {
|
|
|
179
188
|
return {
|
|
180
189
|
kind: 'error',
|
|
181
190
|
text: [
|
|
182
|
-
`Fixed request overhead alone (~${plan.envelopeTokens} tokens of
|
|
191
|
+
`Fixed request overhead alone (~${plan.envelopeTokens} tokens of tool schemas and other non-surface request data) exceeds the ${plan.budget}-token budget for ${label}.`,
|
|
183
192
|
'Trimming conversation history cannot help: raise the backend context size (the model\'s contextWindow in settings.yaml, or the server\'s context flag) or reduce mounted tools and skills, then retry.'
|
|
184
193
|
].join('\n')
|
|
185
194
|
};
|
|
@@ -193,7 +202,7 @@ function describeNonSpan(plan, label) {
|
|
|
193
202
|
kind: 'error',
|
|
194
203
|
text: [
|
|
195
204
|
`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}).`,
|
|
205
|
+
`Protected content: task statement ~${plan.protectedHeadTokens} tokens, recent tail ~${plan.protectedTailTokens} tokens (retain target ~${plan.retainTokens})${plan.hasBarrier ? ', plus the system prompt, which is never trimmed' : ''}.`,
|
|
197
206
|
'Try /compact (it summarizes instead of dropping), a larger window, or /trim with an explicit budget after another reduction.'
|
|
198
207
|
].join('\n')
|
|
199
208
|
};
|
package/lib/plan.js
CHANGED
|
@@ -8,6 +8,17 @@
|
|
|
8
8
|
* and the original request are the two pieces of high-value context; everything
|
|
9
9
|
* between them is what a smaller window can afford to lose.
|
|
10
10
|
*
|
|
11
|
+
* Two node classes are never elidable:
|
|
12
|
+
*
|
|
13
|
+
* - **Barriers** (`barrier: true`) — the system prompt. Harness 0.1.5 moved it
|
|
14
|
+
* from the request header onto the surface as node 0, which makes it
|
|
15
|
+
* *trimmable* by position: dropping it would strip the model's instructions,
|
|
16
|
+
* and letting it consume the head-protection budget would expose the user's
|
|
17
|
+
* original request instead. Barriers are therefore untouchable and split the
|
|
18
|
+
* elidable space into regions, and `protectHeadNodes` counts only non-barrier
|
|
19
|
+
* nodes so it keeps protecting the task statement.
|
|
20
|
+
* - **The final node**, and the retained tail.
|
|
21
|
+
*
|
|
11
22
|
* Nothing here touches a session: the planner receives measured node prices and
|
|
12
23
|
* two balance predicates, so every branch is directly testable.
|
|
13
24
|
*
|
|
@@ -18,17 +29,19 @@
|
|
|
18
29
|
* @typedef {object} TrimNode
|
|
19
30
|
* @property {number} seq - surface event sequence of the node.
|
|
20
31
|
* @property {number} heuristicTokens - the token meter's heuristic price for it.
|
|
32
|
+
* @property {boolean} [barrier] - true for a node that may never be elided and
|
|
33
|
+
* that no elided span may cross (the system prompt).
|
|
21
34
|
*/
|
|
22
35
|
|
|
23
36
|
/**
|
|
24
37
|
* @typedef {object} TrimPlanInput
|
|
25
38
|
* @property {readonly TrimNode[]} nodes - current surface nodes in model-visible order.
|
|
26
|
-
* @property {number} envelopeTokens - non-surface request price (
|
|
39
|
+
* @property {number} envelopeTokens - non-surface request price (tool schemas and other fixed request data).
|
|
27
40
|
* @property {number} budget - target total request size in tokens.
|
|
28
41
|
* @property {number} markerCost - priced replacement marker, including configured slack.
|
|
29
42
|
* @property {number} retainTokens - preferred verbatim recent-tail budget.
|
|
30
43
|
* @property {number} minTailTokens - absolute floor for that retained tail.
|
|
31
|
-
* @property {number} protectHeadNodes - leading nodes that must never be elided.
|
|
44
|
+
* @property {number} protectHeadNodes - leading non-barrier nodes that must never be elided.
|
|
32
45
|
* @property {boolean} allowTailTrim - whether the elided span may reach into the retained tail.
|
|
33
46
|
* @property {(seq: number) => boolean} isBalancedBefore - tool-pairing balance before a node.
|
|
34
47
|
* @property {(seq: number) => boolean} isBalancedAfter - tool-pairing balance after a node.
|
|
@@ -45,7 +58,8 @@ export function planTrim(input) {
|
|
|
45
58
|
totalTokens: input.envelopeTokens + surfaceTokens,
|
|
46
59
|
surfaceTokens,
|
|
47
60
|
envelopeTokens: input.envelopeTokens,
|
|
48
|
-
budget: input.budget
|
|
61
|
+
budget: input.budget,
|
|
62
|
+
hasBarrier: input.nodes.some((node) => node.barrier === true)
|
|
49
63
|
};
|
|
50
64
|
if (common.totalTokens <= input.budget) return { kind: 'fits', ...common };
|
|
51
65
|
if (input.envelopeTokens >= input.budget) return { kind: 'envelope', ...common };
|
|
@@ -75,7 +89,7 @@ export function planTrim(input) {
|
|
|
75
89
|
...common,
|
|
76
90
|
need,
|
|
77
91
|
maxFreeable: weakest?.maxFreeable ?? 0,
|
|
78
|
-
protectedHeadTokens:
|
|
92
|
+
protectedHeadTokens: protectedHeadTokens(input.nodes, input.protectHeadNodes),
|
|
79
93
|
protectedTailTokens: weakest?.protectedTailTokens ?? 0,
|
|
80
94
|
retainTokens: configuredRetention
|
|
81
95
|
};
|
|
@@ -95,21 +109,39 @@ function attemptSpan(input) {
|
|
|
95
109
|
kind: 'shortfall',
|
|
96
110
|
maxFreeable,
|
|
97
111
|
protectedTailTokens: tailTokens(nodes, tailStart),
|
|
98
|
-
protectedHeadTokens:
|
|
112
|
+
protectedHeadTokens: protectedHeadTokens(nodes, input.protectHeadNodes)
|
|
99
113
|
});
|
|
100
|
-
const
|
|
101
|
-
if (lastElidable <
|
|
114
|
+
const headEnd = protectedHeadEnd(nodes, input.protectHeadNodes);
|
|
115
|
+
if (lastElidable < headEnd) return shortfall(0);
|
|
116
|
+
let weakest = null;
|
|
117
|
+
for (const region of elidableRegions(nodes, headEnd, lastElidable)) {
|
|
118
|
+
const attempt = attemptRegion(nodes, region, input);
|
|
119
|
+
if (attempt === null) continue;
|
|
120
|
+
if (attempt.kind === 'span') return attempt;
|
|
121
|
+
if (weakest === null || attempt.maxFreeable > weakest.maxFreeable) weakest = attempt;
|
|
122
|
+
}
|
|
123
|
+
return shortfall(weakest?.maxFreeable ?? 0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Grow the smallest balanced span from one region's oldest balanced cut.
|
|
128
|
+
* @param nodes - full priced surface.
|
|
129
|
+
* @param region - inclusive index range free of barriers.
|
|
130
|
+
* @param input - planning input carrying `need`, `markerCost`, and the balance predicates.
|
|
131
|
+
* @returns a `span` plan, or this region's best `shortfall`.
|
|
132
|
+
*/
|
|
133
|
+
function attemptRegion(nodes, region, input) {
|
|
102
134
|
let start = -1;
|
|
103
|
-
for (let index =
|
|
135
|
+
for (let index = region.start; index <= region.end; index += 1) {
|
|
104
136
|
if (input.isBalancedBefore(nodes[index].seq)) {
|
|
105
137
|
start = index;
|
|
106
138
|
break;
|
|
107
139
|
}
|
|
108
140
|
}
|
|
109
|
-
if (start === -1) return
|
|
141
|
+
if (start === -1) return null;
|
|
110
142
|
let accumulated = 0;
|
|
111
143
|
let best = null;
|
|
112
|
-
for (let index = start; index <=
|
|
144
|
+
for (let index = start; index <= region.end; index += 1) {
|
|
113
145
|
accumulated += nodes[index].heuristicTokens;
|
|
114
146
|
if (!input.isBalancedAfter(nodes[index].seq)) continue;
|
|
115
147
|
const freedTokens = accumulated - input.markerCost;
|
|
@@ -126,9 +158,34 @@ function attemptSpan(input) {
|
|
|
126
158
|
}
|
|
127
159
|
if (freedTokens >= input.need) break;
|
|
128
160
|
}
|
|
129
|
-
return best === null ?
|
|
130
|
-
|
|
131
|
-
|
|
161
|
+
return best === null ? null : best.freedTokens >= input.need ? { kind: 'span', ...best } : { kind: 'shortfall', maxFreeable: best.freedTokens };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Index just past the `protectHeadNodes`-th non-barrier node. */
|
|
165
|
+
function protectedHeadEnd(nodes, protectHeadNodes) {
|
|
166
|
+
let counted = 0;
|
|
167
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
168
|
+
if (nodes[index].barrier === true) continue;
|
|
169
|
+
counted += 1;
|
|
170
|
+
if (counted >= protectHeadNodes) return index + 1;
|
|
171
|
+
}
|
|
172
|
+
return nodes.length;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Maximal runs of elidable indices inside `[from, to]`. */
|
|
176
|
+
function elidableRegions(nodes, from, to) {
|
|
177
|
+
const regions = [];
|
|
178
|
+
let start = null;
|
|
179
|
+
for (let index = from; index <= to; index += 1) {
|
|
180
|
+
if (nodes[index].barrier === true) {
|
|
181
|
+
if (start !== null) regions.push({ start, end: index - 1 });
|
|
182
|
+
start = null;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (start === null) start = index;
|
|
186
|
+
}
|
|
187
|
+
if (start !== null) regions.push({ start, end: to });
|
|
188
|
+
return regions;
|
|
132
189
|
}
|
|
133
190
|
|
|
134
191
|
/**
|
|
@@ -162,9 +219,9 @@ function tailTokens(nodes, tailStart) {
|
|
|
162
219
|
return total;
|
|
163
220
|
}
|
|
164
221
|
|
|
165
|
-
/** Tokens held by the protected
|
|
166
|
-
function
|
|
222
|
+
/** Tokens held by the protected prefix, barriers included. */
|
|
223
|
+
function protectedHeadTokens(nodes, protectHeadNodes) {
|
|
167
224
|
let total = 0;
|
|
168
|
-
for (let index = 0; index <
|
|
225
|
+
for (let index = 0; index < protectedHeadEnd(nodes, protectHeadNodes); index += 1) total += nodes[index].heuristicTokens;
|
|
169
226
|
return total;
|
|
170
227
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version tolerance for the harness's positional replacement marker.
|
|
3
|
+
*
|
|
4
|
+
* DeepSeek Harness renamed the surface-replacement keys in 0.1.5
|
|
5
|
+
* (`{op:'replace', start, end}` → `{op:'replace', startSeq, endSeq}`), and the
|
|
6
|
+
* session rejects any other shape at append time. Rather than parse a version
|
|
7
|
+
* string, the accepted shape is probed once against the harness actually
|
|
8
|
+
* installed: a throwaway detached session appends one replacement with each
|
|
9
|
+
* candidate and reports the first accepted.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-command-context-trim/session-compat
|
|
12
|
+
*/
|
|
13
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
14
|
+
import { Session } from '@deepseek-ai/dsh-session';
|
|
15
|
+
|
|
16
|
+
/** Candidate key sets, newest harness first. */
|
|
17
|
+
const CANDIDATES = Object.freeze([
|
|
18
|
+
Object.freeze({ start: 'startSeq', end: 'endSeq' }),
|
|
19
|
+
Object.freeze({ start: 'start', end: 'end' })
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/** Cached probe result for the installed harness. */
|
|
23
|
+
let cached;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The replacement-marker keys this harness accepts.
|
|
27
|
+
* @returns `{ start, end }` key names.
|
|
28
|
+
* @throws when the harness accepts none of the known shapes.
|
|
29
|
+
*/
|
|
30
|
+
export function replaceKeys() {
|
|
31
|
+
cached ??= detectReplaceKeys();
|
|
32
|
+
return cached;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Probe every known marker shape against a detached session.
|
|
37
|
+
* @returns the first accepted key set.
|
|
38
|
+
* @throws when no known shape is accepted.
|
|
39
|
+
*/
|
|
40
|
+
export function detectReplaceKeys() {
|
|
41
|
+
for (const candidate of CANDIDATES) {
|
|
42
|
+
if (acceptsReplacement(candidate)) return candidate;
|
|
43
|
+
}
|
|
44
|
+
throw new Error(
|
|
45
|
+
'context-trim: this harness build accepts neither the 0.1.5+ nor the legacy positional replacement marker; ' +
|
|
46
|
+
'report it with the installed @deepseek-ai/dsh-session version'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the positional replacement marker in the harness's own key spelling.
|
|
52
|
+
* @param keys - key set returned by {@link replaceKeys}.
|
|
53
|
+
* @param startSeq - inclusive first shadowed surface seq.
|
|
54
|
+
* @param endSeq - inclusive last shadowed surface seq.
|
|
55
|
+
* @returns the marker to pass as `surfaceOp`.
|
|
56
|
+
*/
|
|
57
|
+
export function replacementOp(keys, startSeq, endSeq) {
|
|
58
|
+
return { op: 'replace', [keys.start]: startSeq, [keys.end]: endSeq };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether one detached session accepts a replacement written with these keys. */
|
|
62
|
+
function acceptsReplacement(keys) {
|
|
63
|
+
try {
|
|
64
|
+
const session = Session.create('context-trim-probe');
|
|
65
|
+
session.append('user/message', probeMessage('probe first'), { surfaceOp: 'append' });
|
|
66
|
+
session.append('user/message', probeMessage('probe second'), { surfaceOp: 'append' });
|
|
67
|
+
session.append('user/message', probeMessage('probe marker'), {
|
|
68
|
+
surfaceOp: replacementOp(keys, 1, 1),
|
|
69
|
+
sourceEventSeqs: [1]
|
|
70
|
+
});
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One frozen probe message; detached sessions never publish it. */
|
|
78
|
+
function probeMessage(text) {
|
|
79
|
+
return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'context-trim-probe' } });
|
|
80
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-command-context-trim",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"author": "snailium",
|
|
@@ -35,14 +35,14 @@
|
|
|
35
35
|
}
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@deepseek-ai/cordis": "
|
|
39
|
-
"@deepseek-ai/dsh-commands": "
|
|
40
|
-
"@deepseek-ai/dsh-compaction": "
|
|
41
|
-
"@deepseek-ai/dsh-invariants": "
|
|
42
|
-
"@deepseek-ai/dsh-llm": "
|
|
43
|
-
"@deepseek-ai/dsh-session": "
|
|
44
|
-
"@deepseek-ai/dsh-token-meter": "
|
|
45
|
-
"@deepseek-ai/schemastery": "
|
|
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
46
|
},
|
|
47
47
|
"peerDependenciesMeta": {
|
|
48
48
|
"@deepseek-ai/cordis": {
|
|
@@ -71,9 +71,11 @@
|
|
|
71
71
|
}
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
74
75
|
"@deepseek-ai/dsh-compaction": "0.1.2-rc.1",
|
|
75
76
|
"@deepseek-ai/dsh-llm": "0.1.2-rc.1",
|
|
76
77
|
"@deepseek-ai/dsh-session": "0.1.2-rc.1",
|
|
78
|
+
"@deepseek-ai/dsh-token-meter": "^0.1.2-rc.1",
|
|
77
79
|
"@deepseek-ai/schemastery": "3.18.2"
|
|
78
80
|
},
|
|
79
81
|
"keywords": [
|