dsh-command-context-trim 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -13,15 +13,19 @@
13
13
  * synchronous appends, zero LLM calls, so it works precisely when every request
14
14
  * is failing.
15
15
  *
16
+ * The same execution also runs **automatically** on the context wall: a
17
+ * prepended `agent/request-error` listener trims on `CONTEXT_WINDOW_EXCEEDED` and
18
+ * retries, and only hands the problem to compaction (prune + summarize) when it
19
+ * cannot free anything — see `./auto-trim.js`.
20
+ *
16
21
  * @module dsh-command-context-trim
17
22
  */
18
- import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compaction';
19
23
  import z from '@deepseek-ai/schemastery';
20
24
  import { parseTrimArguments, USAGE } from './args.js';
21
- import { 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
+ import { registerAutoTrim } from './auto-trim.js';
26
+ import { resolveConfig } from './config.js';
27
+ import { describeError } from './render.js';
28
+ import { executeTrim } from './trim-session.js';
25
29
 
26
30
  /** Cordis plugin name. */
27
31
  export const name = 'context-trim';
@@ -29,7 +33,7 @@ export const name = 'context-trim';
29
33
  /** Services required before the command can be registered. */
30
34
  export const inject = ['commands', 'tokenMeter', 'llm'];
31
35
 
32
- /** Loader-facing configuration shape; ranges are enforced by {@link resolveConfig}. */
36
+ /** Loader-facing configuration shape; ranges are enforced by `resolveConfig`. */
33
37
  export const Config = z.object({
34
38
  targetRatio: z.number(),
35
39
  reserveOutputTokens: z.number(),
@@ -38,11 +42,14 @@ export const Config = z.object({
38
42
  minTailTokens: z.number(),
39
43
  protectHeadNodes: z.number(),
40
44
  allowTailTrim: z.boolean(),
41
- markerSlackTokens: z.number()
45
+ markerSlackTokens: z.number(),
46
+ autoTrim: z.boolean(),
47
+ maxAutoTrimRetries: z.number()
42
48
  });
43
49
 
44
50
  /**
45
- * Register `/trim` for every composed human-command adapter.
51
+ * Register `/trim` for every composed human-command adapter, and the automatic
52
+ * context-overflow handler for every agent.
46
53
  * @param ctx - context carrying the command registry, token meter, and LLM service.
47
54
  * @param config - untrusted plugin configuration.
48
55
  */
@@ -69,6 +76,7 @@ export function apply(ctx, config) {
69
76
  handler
70
77
  });
71
78
  }, 'context-trim lifecycle');
79
+ registerAutoTrim(ctx, resolved);
72
80
  }
73
81
 
74
82
  /**
@@ -83,7 +91,17 @@ async function execute(ctx, config, invocation) {
83
91
  if (parsed.error !== undefined) return { kind: 'error', text: `${parsed.error}\n${USAGE}` };
84
92
  let running;
85
93
  try {
86
- running = invocation.agent.runMaintenance((agentSignal) => trimOnce(ctx, config, invocation, parsed, agentSignal));
94
+ running = invocation.agent.runMaintenance(async (agentSignal) => {
95
+ const signal = AbortSignal.any([invocation.signal, agentSignal]);
96
+ const { result } = await executeTrim(ctx, config, {
97
+ agent: invocation.agent,
98
+ signal,
99
+ requestedRoute: parsed.route,
100
+ explicitBudget: parsed.budget,
101
+ check: parsed.check
102
+ });
103
+ return result;
104
+ });
87
105
  } catch (error) {
88
106
  return { kind: 'error', text: `Trim needs an idle agent: ${describeError(error)}` };
89
107
  }
@@ -94,139 +112,3 @@ async function execute(ctx, config, invocation) {
94
112
  return { kind: 'error', text: `Trim failed: ${describeError(error)}` };
95
113
  }
96
114
  }
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 CHANGED
@@ -8,6 +8,21 @@
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 newest `user/message`** — the live human instruction — plus the
21
+ * retained tail. An earlier rule protected the final surface node outright,
22
+ * which deadlocked exactly the common overflow shape: a large assistant
23
+ * tool-call whose tool result is the last node could not be removed as a pair,
24
+ * leaving only a few tokens freeable while the request stayed over the wall.
25
+ *
11
26
  * Nothing here touches a session: the planner receives measured node prices and
12
27
  * two balance predicates, so every branch is directly testable.
13
28
  *
@@ -18,17 +33,21 @@
18
33
  * @typedef {object} TrimNode
19
34
  * @property {number} seq - surface event sequence of the node.
20
35
  * @property {number} heuristicTokens - the token meter's heuristic price for it.
36
+ * @property {boolean} [barrier] - true for a node that may never be elided and
37
+ * that no elided span may cross (the system prompt).
38
+ * @property {boolean} [userMessage] - true for human prompts; the newest one is
39
+ * treated as a barrier so an ongoing instruction is never elided.
21
40
  */
22
41
 
23
42
  /**
24
43
  * @typedef {object} TrimPlanInput
25
44
  * @property {readonly TrimNode[]} nodes - current surface nodes in model-visible order.
26
- * @property {number} envelopeTokens - non-surface request price (system prompt + tool schemas).
45
+ * @property {number} envelopeTokens - non-surface request price (tool schemas and other fixed request data).
27
46
  * @property {number} budget - target total request size in tokens.
28
47
  * @property {number} markerCost - priced replacement marker, including configured slack.
29
48
  * @property {number} retainTokens - preferred verbatim recent-tail budget.
30
49
  * @property {number} minTailTokens - absolute floor for that retained tail.
31
- * @property {number} protectHeadNodes - leading nodes that must never be elided.
50
+ * @property {number} protectHeadNodes - leading non-barrier nodes that must never be elided.
32
51
  * @property {boolean} allowTailTrim - whether the elided span may reach into the retained tail.
33
52
  * @property {(seq: number) => boolean} isBalancedBefore - tool-pairing balance before a node.
34
53
  * @property {(seq: number) => boolean} isBalancedAfter - tool-pairing balance after a node.
@@ -45,7 +64,8 @@ export function planTrim(input) {
45
64
  totalTokens: input.envelopeTokens + surfaceTokens,
46
65
  surfaceTokens,
47
66
  envelopeTokens: input.envelopeTokens,
48
- budget: input.budget
67
+ budget: input.budget,
68
+ hasBarrier: input.nodes.some((node) => node.barrier === true)
49
69
  };
50
70
  if (common.totalTokens <= input.budget) return { kind: 'fits', ...common };
51
71
  if (input.envelopeTokens >= input.budget) return { kind: 'envelope', ...common };
@@ -55,32 +75,67 @@ export function planTrim(input) {
55
75
  const need = common.totalTokens - input.budget;
56
76
  const configuredRetention = input.retainTokens;
57
77
  let weakest = null;
58
- for (const retainTokens of retentionLadder(configuredRetention, input.minTailTokens)) {
59
- const attempt = attemptSpan({ ...input, retainTokens, need });
78
+ for (const configuration of attemptConfigurations(input, configuredRetention)) {
79
+ const attempt = attemptSpan({ ...input, ...configuration, need });
60
80
  if (attempt.kind === 'span') {
61
81
  return {
62
82
  kind: 'span',
63
83
  ...common,
64
84
  ...attempt,
65
85
  need,
66
- retainTokens,
67
- relaxedRetention: retainTokens < configuredRetention,
86
+ retainTokens: configuration.retainTokens,
87
+ relaxedRetention: configuration.retainTokens < configuredRetention,
88
+ reachedFinalNode: configuration.allowFinalNode === true,
68
89
  projectedTotal: common.totalTokens - attempt.freedTokens
69
90
  };
70
91
  }
71
- if (weakest === null || attempt.maxFreeable > weakest.maxFreeable) weakest = { ...attempt, retainTokens };
92
+ if (weakest === null || attempt.maxFreeable > weakest.maxFreeable) weakest = { ...attempt, retainTokens: configuration.retainTokens };
72
93
  }
73
94
  return {
74
95
  kind: 'insufficient',
75
96
  ...common,
76
97
  need,
77
98
  maxFreeable: weakest?.maxFreeable ?? 0,
78
- protectedHeadTokens: headTokens(input.nodes, input.protectHeadNodes),
99
+ protectedHeadTokens: protectedHeadTokens(input.nodes, input.protectHeadNodes),
79
100
  protectedTailTokens: weakest?.protectedTailTokens ?? 0,
101
+ protectedUserTokens: weakest?.protectedUserTokens ?? 0,
80
102
  retainTokens: configuredRetention
81
103
  };
82
104
  }
83
105
 
106
+ /**
107
+ * The search order, most conservative first. The final surface node is a
108
+ * *preference*, not a law: keeping it is always tried before dropping it, and
109
+ * dropping it happens only when no other span can free enough.
110
+ *
111
+ * 1. stay outside the retained tail and keep the final node (the configured
112
+ * retention, relaxed step by step only if the fit otherwise fails);
113
+ * 2. may reach into the retained tail, still keeping the final node;
114
+ * 3. last resort: a span that includes the final node (typically the current
115
+ * step's assistant tool-call plus its tool result, which can only be removed
116
+ * as a pair).
117
+ *
118
+ * Elision always starts at the oldest balanced cut, so within every tier the
119
+ * oldest content goes first. A strict `allowTailTrim: false` ends the list after
120
+ * tier 1: the retained tail then is a hard boundary.
121
+ * @param input - planning input.
122
+ * @param configuredRetention - the preferred verbatim recent-tail budget.
123
+ * @returns ordered attempt configurations.
124
+ */
125
+ function attemptConfigurations(input, configuredRetention) {
126
+ const insideTail = retentionLadder(configuredRetention, input.minTailTokens).map((retainTokens) => ({
127
+ retainTokens,
128
+ tailHard: true,
129
+ allowFinalNode: false
130
+ }));
131
+ if (input.allowTailTrim !== true) return insideTail;
132
+ return [
133
+ ...insideTail,
134
+ { retainTokens: configuredRetention, tailHard: false, allowFinalNode: false },
135
+ { retainTokens: configuredRetention, tailHard: false, allowFinalNode: true }
136
+ ];
137
+ }
138
+
84
139
  /**
85
140
  * Try one retention budget, returning the smallest oldest-anchored balanced span
86
141
  * that frees at least `need` tokens.
@@ -90,26 +145,52 @@ export function planTrim(input) {
90
145
  function attemptSpan(input) {
91
146
  const nodes = input.nodes;
92
147
  const tailStart = tailStartIndex(nodes, input.retainTokens);
93
- const lastElidable = input.allowTailTrim ? nodes.length - 2 : Math.min(nodes.length - 2, tailStart - 1);
148
+ // The newest human instruction is the anchor: it must never be elided, and no
149
+ // span may cross it.
150
+ const newestUserIndex = newestUserMessageIndex(nodes);
151
+ // How far right a span may reach: a hard tail boundary when this attempt keeps
152
+ // the retained tail, otherwise the end of the surface.
153
+ const tailFloor = input.tailHard === true ? Math.min(nodes.length - 1, tailStart - 1) : nodes.length - 1;
154
+ // The final node is kept unless this attempt is the last-resort tier.
155
+ const lastElidable = input.allowFinalNode === true ? tailFloor : Math.min(tailFloor, nodes.length - 2);
94
156
  const shortfall = (maxFreeable) => ({
95
157
  kind: 'shortfall',
96
158
  maxFreeable,
97
159
  protectedTailTokens: tailTokens(nodes, tailStart),
98
- protectedHeadTokens: headTokens(nodes, input.protectHeadNodes)
160
+ protectedHeadTokens: protectedHeadTokens(nodes, input.protectHeadNodes),
161
+ protectedUserTokens: newestUserIndex === -1 ? 0 : nodes[newestUserIndex].heuristicTokens
99
162
  });
100
- const from = Math.min(input.protectHeadNodes, nodes.length - 1);
101
- if (lastElidable < from) return shortfall(0);
163
+ const headEnd = protectedHeadEnd(nodes, input.protectHeadNodes);
164
+ if (lastElidable < headEnd) return shortfall(0);
165
+ let weakest = null;
166
+ for (const region of elidableRegions(nodes, headEnd, lastElidable, newestUserIndex)) {
167
+ const attempt = attemptRegion(nodes, region, input);
168
+ if (attempt === null) continue;
169
+ if (attempt.kind === 'span') return attempt;
170
+ if (weakest === null || attempt.maxFreeable > weakest.maxFreeable) weakest = attempt;
171
+ }
172
+ return shortfall(weakest?.maxFreeable ?? 0);
173
+ }
174
+
175
+ /**
176
+ * Grow the smallest balanced span from one region's oldest balanced cut.
177
+ * @param nodes - full priced surface.
178
+ * @param region - inclusive index range free of barriers.
179
+ * @param input - planning input carrying `need`, `markerCost`, and the balance predicates.
180
+ * @returns a `span` plan, or this region's best `shortfall`.
181
+ */
182
+ function attemptRegion(nodes, region, input) {
102
183
  let start = -1;
103
- for (let index = from; index <= lastElidable; index += 1) {
184
+ for (let index = region.start; index <= region.end; index += 1) {
104
185
  if (input.isBalancedBefore(nodes[index].seq)) {
105
186
  start = index;
106
187
  break;
107
188
  }
108
189
  }
109
- if (start === -1) return shortfall(0);
190
+ if (start === -1) return null;
110
191
  let accumulated = 0;
111
192
  let best = null;
112
- for (let index = start; index <= lastElidable; index += 1) {
193
+ for (let index = start; index <= region.end; index += 1) {
113
194
  accumulated += nodes[index].heuristicTokens;
114
195
  if (!input.isBalancedAfter(nodes[index].seq)) continue;
115
196
  const freedTokens = accumulated - input.markerCost;
@@ -126,9 +207,48 @@ function attemptSpan(input) {
126
207
  }
127
208
  if (freedTokens >= input.need) break;
128
209
  }
129
- return best === null ? shortfall(0) : best.freedTokens >= input.need
130
- ? { kind: 'span', ...best }
131
- : shortfall(best.freedTokens);
210
+ return best === null ? null : best.freedTokens >= input.need ? { kind: 'span', ...best } : { kind: 'shortfall', maxFreeable: best.freedTokens };
211
+ }
212
+
213
+ /** Index just past the `protectHeadNodes`-th non-barrier node. */
214
+ function protectedHeadEnd(nodes, protectHeadNodes) {
215
+ let counted = 0;
216
+ for (let index = 0; index < nodes.length; index += 1) {
217
+ if (nodes[index].barrier === true) continue;
218
+ counted += 1;
219
+ if (counted >= protectHeadNodes) return index + 1;
220
+ }
221
+ return nodes.length;
222
+ }
223
+
224
+ /** Index of the newest human prompt on the surface, or -1 when there is none. */
225
+ function newestUserMessageIndex(nodes) {
226
+ let found = -1;
227
+ for (let index = 0; index < nodes.length; index += 1) if (nodes[index].userMessage === true) found = index;
228
+ return found;
229
+ }
230
+
231
+ /**
232
+ * Maximal runs of elidable indices inside `[from, to]`.
233
+ * @param nodes - full priced surface.
234
+ * @param from - inclusive first index to consider.
235
+ * @param to - inclusive last index to consider.
236
+ * @param extraBarrier - one additional index treated as a barrier (the newest user message).
237
+ * @returns contiguous index runs a span may occupy.
238
+ */
239
+ function elidableRegions(nodes, from, to, extraBarrier) {
240
+ const regions = [];
241
+ let start = null;
242
+ for (let index = from; index <= to; index += 1) {
243
+ if (nodes[index].barrier === true || index === extraBarrier) {
244
+ if (start !== null) regions.push({ start, end: index - 1 });
245
+ start = null;
246
+ continue;
247
+ }
248
+ if (start === null) start = index;
249
+ }
250
+ if (start !== null) regions.push({ start, end: to });
251
+ return regions;
132
252
  }
133
253
 
134
254
  /**
@@ -162,9 +282,9 @@ function tailTokens(nodes, tailStart) {
162
282
  return total;
163
283
  }
164
284
 
165
- /** Tokens held by the protected leading nodes. */
166
- function headTokens(nodes, protectHeadNodes) {
285
+ /** Tokens held by the protected prefix, barriers included. */
286
+ function protectedHeadTokens(nodes, protectHeadNodes) {
167
287
  let total = 0;
168
- for (let index = 0; index < Math.min(protectHeadNodes, nodes.length); index += 1) total += nodes[index].heuristicTokens;
288
+ for (let index = 0; index < protectedHeadEnd(nodes, protectHeadNodes); index += 1) total += nodes[index].heuristicTokens;
169
289
  return total;
170
290
  }
package/lib/render.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Command-result and log rendering, shared by the `/trim` command and the
3
+ * automatic context-overflow path.
4
+ *
5
+ * @module dsh-command-context-trim/render
6
+ */
7
+
8
+ /**
9
+ * Render every non-span planning outcome as a final command result.
10
+ * @param plan - the plan returned by `planTrim`.
11
+ * @param label - human label of the target route.
12
+ * @returns a command result.
13
+ */
14
+ export function describeNonSpan(plan, label) {
15
+ switch (plan.kind) {
16
+ case 'fits':
17
+ return {
18
+ kind: 'success',
19
+ text: `Already within budget: ~${plan.totalTokens} / ${plan.budget} tokens for ${label}. Nothing to trim.`
20
+ };
21
+ case 'envelope':
22
+ return {
23
+ kind: 'error',
24
+ text: [
25
+ `Fixed request overhead alone (~${plan.envelopeTokens} tokens of tool schemas and other non-surface request data) exceeds the ${plan.budget}-token budget for ${label}.`,
26
+ 'Trimming conversation history cannot help: raise the backend context size (the model\'s contextWindow in settings.yaml, or the server\'s context flag) or reduce mounted tools and skills, then retry.'
27
+ ].join('\n')
28
+ };
29
+ case 'no-span':
30
+ return {
31
+ kind: 'error',
32
+ text: `Nothing safely trimmable for ${label}: ${plan.reason}, or no tool-pairing balanced cut exists.`
33
+ };
34
+ case 'insufficient':
35
+ return {
36
+ kind: 'error',
37
+ text: [
38
+ `Cannot free enough for ${label}: the largest balanced span frees ~${plan.maxFreeable} of the ~${plan.need} tokens needed.`,
39
+ `Protected content: task statement ~${plan.protectedHeadTokens} tokens, recent tail ~${plan.protectedTailTokens} tokens (retain target ~${plan.retainTokens})` +
40
+ `${plan.protectedUserTokens > 0 ? `, newest instruction ~${plan.protectedUserTokens} tokens (never elided)` : ''}` +
41
+ `${plan.hasBarrier ? ', plus the system prompt, which is never trimmed' : ''}.`,
42
+ 'Try /compact (it summarizes instead of dropping), a larger window, or /trim with an explicit budget after another reduction.'
43
+ ].join('\n')
44
+ };
45
+ default:
46
+ return { kind: 'error', text: `Trim could not plan a reduction (${String(plan.kind)}).` };
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Render a dry run.
52
+ * @param plan - a committed span plan.
53
+ * @param label - human label of the target route.
54
+ * @param markerTokens - measured price of the replacement marker.
55
+ * @returns the dry-run text.
56
+ */
57
+ export function preview(plan, label, markerTokens) {
58
+ return [
59
+ `Would trim ${plan.shadowedSeqs.length} messages (seqs ${plan.startSeq}-${plan.endSeq}, ~${plan.shadowedTokens} tokens → ${markerTokens}-token marker) for ${label}.`,
60
+ `Request size: ~${plan.totalTokens} → ~${plan.projectedTotal} tokens (target ${plan.budget}). Nothing was changed.`,
61
+ ...(plan.reachedFinalNode === true
62
+ ? ['Note: nothing else can free enough — this plan has to remove the final surface message as a tool-call/result pair.']
63
+ : [])
64
+ ].join('\n');
65
+ }
66
+
67
+ /**
68
+ * Render a committed trim against the re-measured request.
69
+ * @param plan - the committed span plan.
70
+ * @param after - post-trim measurement.
71
+ * @param label - human label of the target route.
72
+ * @param markerTokens - measured price of the replacement marker.
73
+ * @returns the report text.
74
+ */
75
+ export function report(plan, after, label, markerTokens) {
76
+ const lines = [
77
+ `Trimmed ${plan.shadowedSeqs.length} messages (seqs ${plan.startSeq}-${plan.endSeq}, ~${plan.shadowedTokens} tokens → ${markerTokens}-token marker) for ${label}.`,
78
+ `Request size: ~${plan.totalTokens} → ~${after.totalTokens} tokens (target ${plan.budget}).`
79
+ ];
80
+ if (plan.relaxedRetention) {
81
+ lines.push(`Retention relaxed to ~${plan.retainTokens} tokens to reach the budget.`);
82
+ }
83
+ if (plan.reachedFinalNode === true) {
84
+ lines.push('Nothing else could free enough: the final surface message was removed as part of a tool-call/result pair.');
85
+ }
86
+ return lines.join('\n');
87
+ }
88
+
89
+ /**
90
+ * Render a committed trim as one log line, for the automatic path.
91
+ * @param plan - the committed span plan.
92
+ * @param after - post-trim measurement.
93
+ * @param label - human label of the target route.
94
+ * @returns a single-line account.
95
+ */
96
+ export function logLine(plan, after, label) {
97
+ return (
98
+ `freed ~${plan.freedTokens} tokens over ${plan.shadowedSeqs.length} messages (seqs ${plan.startSeq}-${plan.endSeq}) for ${label}; ` +
99
+ `request ~${plan.totalTokens} → ~${after.totalTokens} tokens (budget ${plan.budget})` +
100
+ `${plan.reachedFinalNode === true ? ' (last resort: the final message went as a tool-call/result pair)' : ''}`
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Render a thrown value without trusting its string coercion.
106
+ * @param error - the caught value (`unknown` in catch clauses).
107
+ * @returns a printable message.
108
+ */
109
+ export function describeError(error) {
110
+ try {
111
+ return error instanceof Error ? error.message : String(error);
112
+ } catch {
113
+ return '<unrenderable thrown value>';
114
+ }
115
+ }
@@ -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
+ }