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 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.0...HEAD
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, once published
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
- and the plugin's command registration and end-to-end trim over a stub context. CI runs the suite on Node 22 and 24;
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` (30 tests: planner, args, surface apply + replay, plugin handler) | ✅ passing |
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`) | ✅ 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 |
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 # 发布到 npm
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
- 已验证:30 个测试全部通过(选段算法、参数解析、真实 Session 上的 surface 改写与日志重放、插件命令注册与端到端裁剪);
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
- 尚未执行:npm 首发(Trusted Publishing 无法创建全新包名,需要一次首发布引导)、以及 Web GUI 里的真实小窗口端到端验证。
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: { op: 'replace', start: plan.startSeq, end: plan.endSeq },
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) => ({ seq: node.seq, heuristicTokens: node.heuristicTokens }));
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 system prompt and tool schemas) exceeds the ${plan.budget}-token budget for ${label}.`,
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 (system prompt + tool schemas).
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: headTokens(input.nodes, input.protectHeadNodes),
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: headTokens(nodes, input.protectHeadNodes)
112
+ protectedHeadTokens: protectedHeadTokens(nodes, input.protectHeadNodes)
99
113
  });
100
- const from = Math.min(input.protectHeadNodes, nodes.length - 1);
101
- if (lastElidable < from) return shortfall(0);
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 = from; index <= lastElidable; index += 1) {
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 shortfall(0);
141
+ if (start === -1) return null;
110
142
  let accumulated = 0;
111
143
  let best = null;
112
- for (let index = start; index <= lastElidable; index += 1) {
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 ? shortfall(0) : best.freedTokens >= input.need
130
- ? { kind: 'span', ...best }
131
- : shortfall(best.freedTokens);
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 leading nodes. */
166
- function headTokens(nodes, protectHeadNodes) {
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 < Math.min(protectHeadNodes, nodes.length); index += 1) total += nodes[index].heuristicTokens;
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.0",
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": "^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"
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": [