dsh-command-context-trim 0.1.1 → 0.2.2

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,16 +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 { replaceKeys } from './session-compat.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';
26
29
 
27
30
  /** Cordis plugin name. */
28
31
  export const name = 'context-trim';
@@ -30,7 +33,7 @@ export const name = 'context-trim';
30
33
  /** Services required before the command can be registered. */
31
34
  export const inject = ['commands', 'tokenMeter', 'llm'];
32
35
 
33
- /** Loader-facing configuration shape; ranges are enforced by {@link resolveConfig}. */
36
+ /** Loader-facing configuration shape; ranges are enforced by `resolveConfig`. */
34
37
  export const Config = z.object({
35
38
  targetRatio: z.number(),
36
39
  reserveOutputTokens: z.number(),
@@ -39,11 +42,14 @@ export const Config = z.object({
39
42
  minTailTokens: z.number(),
40
43
  protectHeadNodes: z.number(),
41
44
  allowTailTrim: z.boolean(),
42
- markerSlackTokens: z.number()
45
+ markerSlackTokens: z.number(),
46
+ autoTrim: z.boolean(),
47
+ maxAutoTrimRetries: z.number()
43
48
  });
44
49
 
45
50
  /**
46
- * 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.
47
53
  * @param ctx - context carrying the command registry, token meter, and LLM service.
48
54
  * @param config - untrusted plugin configuration.
49
55
  */
@@ -70,6 +76,7 @@ export function apply(ctx, config) {
70
76
  handler
71
77
  });
72
78
  }, 'context-trim lifecycle');
79
+ registerAutoTrim(ctx, resolved);
73
80
  }
74
81
 
75
82
  /**
@@ -84,7 +91,17 @@ async function execute(ctx, config, invocation) {
84
91
  if (parsed.error !== undefined) return { kind: 'error', text: `${parsed.error}\n${USAGE}` };
85
92
  let running;
86
93
  try {
87
- 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
+ });
88
105
  } catch (error) {
89
106
  return { kind: 'error', text: `Trim needs an idle agent: ${describeError(error)}` };
90
107
  }
@@ -95,147 +112,3 @@ async function execute(ctx, config, invocation) {
95
112
  return { kind: 'error', text: `Trim failed: ${describeError(error)}` };
96
113
  }
97
114
  }
98
-
99
- /**
100
- * Plan and apply one trim; the whole session-visible mutation happens here.
101
- * @param ctx - plugin context.
102
- * @param config - resolved configuration.
103
- * @param invocation - command invocation (agent, signal).
104
- * @param parsed - parsed command arguments.
105
- * @param agentSignal - cancellation owned by the maintenance reservation.
106
- * @returns a command result.
107
- */
108
- async function trimOnce(ctx, config, invocation, parsed, agentSignal) {
109
- const signal = AbortSignal.any([invocation.signal, agentSignal]);
110
- signal.throwIfAborted();
111
- const session = invocation.agent.session;
112
- assertNoOpenCompaction(session);
113
- const target = parsed.budget === undefined ? await resolveTarget(ctx, invocation.agent, parsed.route, signal) : undefined;
114
- const budget = parsed.budget ?? budgetFor(target.contextWindow, config);
115
- const label = target === undefined ? `an explicit ${budget}-token budget` : `${target.label} (window ${target.contextWindow})`;
116
- const header = target === undefined ? undefined : targetHeader(session, target);
117
- const measurement = ctx.tokenMeter.measure(session, header);
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
- }));
127
- if (nodes.length === 0) {
128
- return { kind: 'success', text: `Nothing to trim: ${session.id} has no model-visible messages yet.` };
129
- }
130
- // The retained tail scales with the capacity being fitted: the target window
131
- // when one is known, otherwise the explicit budget itself.
132
- const retainTokens = retentionFor(target?.contextWindow ?? budget, config);
133
- const envelopeTokens = Math.max(0, measurement.totalTokens - measurement.surfaceTokens);
134
- const planFor = (markerTokens) =>
135
- planTrim({
136
- nodes,
137
- envelopeTokens,
138
- budget,
139
- markerCost: markerTokens + config.markerSlackTokens,
140
- retainTokens,
141
- minTailTokens: config.minTailTokens,
142
- protectHeadNodes: config.protectHeadNodes,
143
- allowTailTrim: config.allowTailTrim,
144
- isBalancedBefore: (seq) => toolPairingBalancedBefore(session, seq),
145
- isBalancedAfter: (seq) => toolPairingBalancedAfter(session, seq)
146
- });
147
- let markerTokens = ctx.tokenMeter.estimateMessage(provisionalMarker());
148
- let plan = planFor(markerTokens);
149
- if (plan.kind !== 'span') return describeNonSpan(plan, label);
150
- let marker = createMarkerMessage(plan, target?.label ?? `budget ${budget}`, budget);
151
- const finalMarkerTokens = ctx.tokenMeter.estimateMessage(marker);
152
- if (finalMarkerTokens > markerTokens) {
153
- // The marker now carries real numbers; re-plan once so its own price is exact.
154
- plan = planFor(finalMarkerTokens);
155
- if (plan.kind !== 'span') return describeNonSpan(plan, label);
156
- marker = createMarkerMessage(plan, target?.label ?? `budget ${budget}`, budget);
157
- markerTokens = finalMarkerTokens;
158
- }
159
- if (parsed.check) return { kind: 'success', text: preview(plan, label, markerTokens) };
160
- const replacement = applyTrim(session, plan, marker, replaceKeys());
161
- const after = ctx.tokenMeter.measure(session, header);
162
- return { kind: 'success', text: report(plan, after, label, markerTokens), sourceEventSeq: replacement.seq };
163
- }
164
-
165
- /** Refuse to rewrite a surface while a compaction bracket is open. */
166
- function assertNoOpenCompaction(session) {
167
- for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
168
- const event = session.eventAt(seq);
169
- if (event === undefined) continue;
170
- if (event.type === 'compaction/end') return;
171
- // A seed boundary proves any earlier unmatched start belongs to a previous lifecycle.
172
- if (event.type === 'session/end-seed') return;
173
- if (event.type === 'compaction/start') {
174
- throw new Error('a compaction is already in progress in this session; wait for it to finish, then retry');
175
- }
176
- }
177
- }
178
-
179
- /** Render every non-span outcome as a final command result. */
180
- function describeNonSpan(plan, label) {
181
- switch (plan.kind) {
182
- case 'fits':
183
- return {
184
- kind: 'success',
185
- text: `Already within budget: ~${plan.totalTokens} / ${plan.budget} tokens for ${label}. Nothing to trim.`
186
- };
187
- case 'envelope':
188
- return {
189
- kind: 'error',
190
- text: [
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}.`,
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.'
193
- ].join('\n')
194
- };
195
- case 'no-span':
196
- return {
197
- kind: 'error',
198
- text: `Nothing safely trimmable for ${label}: ${plan.reason}, or no tool-pairing balanced cut exists.`
199
- };
200
- case 'insufficient':
201
- return {
202
- kind: 'error',
203
- text: [
204
- `Cannot free enough for ${label}: the largest balanced span frees ~${plan.maxFreeable} of the ~${plan.need} tokens needed.`,
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' : ''}.`,
206
- 'Try /compact (it summarizes instead of dropping), a larger window, or /trim with an explicit budget after another reduction.'
207
- ].join('\n')
208
- };
209
- default:
210
- return { kind: 'error', text: `Trim could not plan a reduction (${String(plan.kind)}).` };
211
- }
212
- }
213
-
214
- /** Render a dry run. */
215
- function preview(plan, label, markerTokens) {
216
- return [
217
- `Would trim ${plan.shadowedSeqs.length} messages (seqs ${plan.startSeq}-${plan.endSeq}, ~${plan.shadowedTokens} tokens → ${markerTokens}-token marker) for ${label}.`,
218
- `Request size: ~${plan.totalTokens} → ~${plan.projectedTotal} tokens (target ${plan.budget}). Nothing was changed.`
219
- ].join('\n');
220
- }
221
-
222
- /** Render a committed trim against the re-measured request. */
223
- function report(plan, after, label, markerTokens) {
224
- const lines = [
225
- `Trimmed ${plan.shadowedSeqs.length} messages (seqs ${plan.startSeq}-${plan.endSeq}, ~${plan.shadowedTokens} tokens → ${markerTokens}-token marker) for ${label}.`,
226
- `Request size: ~${plan.totalTokens} → ~${after.totalTokens} tokens (target ${plan.budget}).`
227
- ];
228
- if (plan.relaxedRetention) {
229
- lines.push(`Retention relaxed to ~${plan.retainTokens} tokens to reach the budget.`);
230
- }
231
- return lines.join('\n');
232
- }
233
-
234
- /** Render a thrown value without trusting its string coercion. */
235
- function describeError(error) {
236
- try {
237
- return error instanceof Error ? error.message : String(error);
238
- } catch {
239
- return '<unrenderable thrown value>';
240
- }
241
- }
package/lib/plan.js CHANGED
@@ -17,7 +17,11 @@
17
17
  * original request instead. Barriers are therefore untouchable and split the
18
18
  * elidable space into regions, and `protectHeadNodes` counts only non-barrier
19
19
  * nodes so it keeps protecting the task statement.
20
- * - **The final node**, and the retained tail.
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.
21
25
  *
22
26
  * Nothing here touches a session: the planner receives measured node prices and
23
27
  * two balance predicates, so every branch is directly testable.
@@ -31,6 +35,8 @@
31
35
  * @property {number} heuristicTokens - the token meter's heuristic price for it.
32
36
  * @property {boolean} [barrier] - true for a node that may never be elided and
33
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.
34
40
  */
35
41
 
36
42
  /**
@@ -69,20 +75,21 @@ export function planTrim(input) {
69
75
  const need = common.totalTokens - input.budget;
70
76
  const configuredRetention = input.retainTokens;
71
77
  let weakest = null;
72
- for (const retainTokens of retentionLadder(configuredRetention, input.minTailTokens)) {
73
- const attempt = attemptSpan({ ...input, retainTokens, need });
78
+ for (const configuration of attemptConfigurations(input, configuredRetention)) {
79
+ const attempt = attemptSpan({ ...input, ...configuration, need });
74
80
  if (attempt.kind === 'span') {
75
81
  return {
76
82
  kind: 'span',
77
83
  ...common,
78
84
  ...attempt,
79
85
  need,
80
- retainTokens,
81
- relaxedRetention: retainTokens < configuredRetention,
86
+ retainTokens: configuration.retainTokens,
87
+ relaxedRetention: configuration.retainTokens < configuredRetention,
88
+ reachedFinalNode: configuration.allowFinalNode === true,
82
89
  projectedTotal: common.totalTokens - attempt.freedTokens
83
90
  };
84
91
  }
85
- if (weakest === null || attempt.maxFreeable > weakest.maxFreeable) weakest = { ...attempt, retainTokens };
92
+ if (weakest === null || attempt.maxFreeable > weakest.maxFreeable) weakest = { ...attempt, retainTokens: configuration.retainTokens };
86
93
  }
87
94
  return {
88
95
  kind: 'insufficient',
@@ -91,10 +98,44 @@ export function planTrim(input) {
91
98
  maxFreeable: weakest?.maxFreeable ?? 0,
92
99
  protectedHeadTokens: protectedHeadTokens(input.nodes, input.protectHeadNodes),
93
100
  protectedTailTokens: weakest?.protectedTailTokens ?? 0,
101
+ protectedUserTokens: weakest?.protectedUserTokens ?? 0,
94
102
  retainTokens: configuredRetention
95
103
  };
96
104
  }
97
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
+
98
139
  /**
99
140
  * Try one retention budget, returning the smallest oldest-anchored balanced span
100
141
  * that frees at least `need` tokens.
@@ -104,17 +145,25 @@ export function planTrim(input) {
104
145
  function attemptSpan(input) {
105
146
  const nodes = input.nodes;
106
147
  const tailStart = tailStartIndex(nodes, input.retainTokens);
107
- 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);
108
156
  const shortfall = (maxFreeable) => ({
109
157
  kind: 'shortfall',
110
158
  maxFreeable,
111
159
  protectedTailTokens: tailTokens(nodes, tailStart),
112
- protectedHeadTokens: protectedHeadTokens(nodes, input.protectHeadNodes)
160
+ protectedHeadTokens: protectedHeadTokens(nodes, input.protectHeadNodes),
161
+ protectedUserTokens: newestUserIndex === -1 ? 0 : nodes[newestUserIndex].heuristicTokens
113
162
  });
114
163
  const headEnd = protectedHeadEnd(nodes, input.protectHeadNodes);
115
164
  if (lastElidable < headEnd) return shortfall(0);
116
165
  let weakest = null;
117
- for (const region of elidableRegions(nodes, headEnd, lastElidable)) {
166
+ for (const region of elidableRegions(nodes, headEnd, lastElidable, newestUserIndex)) {
118
167
  const attempt = attemptRegion(nodes, region, input);
119
168
  if (attempt === null) continue;
120
169
  if (attempt.kind === 'span') return attempt;
@@ -172,12 +221,26 @@ function protectedHeadEnd(nodes, protectHeadNodes) {
172
221
  return nodes.length;
173
222
  }
174
223
 
175
- /** Maximal runs of elidable indices inside `[from, to]`. */
176
- function elidableRegions(nodes, from, to) {
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) {
177
240
  const regions = [];
178
241
  let start = null;
179
242
  for (let index = from; index <= to; index += 1) {
180
- if (nodes[index].barrier === true) {
243
+ if (nodes[index].barrier === true || index === extraBarrier) {
181
244
  if (start !== null) regions.push({ start, end: index - 1 });
182
245
  start = null;
183
246
  continue;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * In-place tool-result slimming, run **before** any span elision.
3
+ *
4
+ * DSH already owns this idea (`@deepseek-ai/dsh-compaction-tool-result-pruner`),
5
+ * but a host-plane plugin can only reach that service where compaction itself is
6
+ * mounted on the host plane. In a 0.1.5 web profile the host row is disabled and
7
+ * the pruner is re-mounted inside an agent-preset isolate realm, where
8
+ * `ctx.get('toolResultPruner')` resolves nothing. So this module prefers the
9
+ * official service when it is visible and otherwise performs the same transform
10
+ * itself, which keeps the cheap reduction ahead of the expensive one:
11
+ *
12
+ * slim oversized tool results in place → elide a whole span → compaction
13
+ *
14
+ * The transform mirrors the official one: text is measured and sliced in Unicode
15
+ * code points (never splitting a surrogate pair), the head and tail are kept, one
16
+ * marker replaces the removed middle, non-text blocks keep their order, and the
17
+ * replacement carries the complete original event data except `content` — so the
18
+ * tool call keeps its result and the step stays intact.
19
+ *
20
+ * A `tool/result` replacement is only legal inside an open turn, which is exactly
21
+ * the state the automatic overflow path runs in; the idle `/trim` command never
22
+ * uses this module.
23
+ *
24
+ * @module dsh-command-context-trim/prune-first
25
+ */
26
+ import { freezeMessage } from '@deepseek-ai/dsh-llm';
27
+ import { replaceKeys, replacementOp } from './session-compat.js';
28
+
29
+ /**
30
+ * Marker replacing the removed middle. Deliberately worded for the model and
31
+ * distinct from DSH's own `[... tool result middle pruned ...]`, so a session log
32
+ * shows which producer slimmed a node.
33
+ */
34
+ export const TRIM_MARKER = '\n\n[... tool result middle trimmed to fit the context window ...]\n\n';
35
+
36
+ /**
37
+ * Slim every over-budget tool result on the current surface.
38
+ * @param ctx - plugin context; `ctx.get('toolResultPruner')` is used when present.
39
+ * @param session - session whose surface is rewritten.
40
+ * @param config - resolved configuration.
41
+ * @returns `{ pruned, via, replacementSeq }`; `pruned` is the number of nodes rewritten.
42
+ */
43
+ export function shrinkOversizedToolResults(ctx, session, config) {
44
+ if (config.preferInPlacePrune !== true) return { pruned: 0, via: 'disabled' };
45
+ const official = ctx.get?.('toolResultPruner');
46
+ if (official !== undefined && typeof official.pruneSession === 'function') {
47
+ const outcome = official.pruneSession(session);
48
+ const pruned = Array.isArray(outcome?.pruned) ? outcome.pruned.length : 0;
49
+ return { pruned, via: 'service', replacementSeq: outcome?.pruned?.at(-1)?.replacementSeq };
50
+ }
51
+ const keys = replaceKeys();
52
+ let pruned = 0;
53
+ let replacementSeq;
54
+ // Snapshot first: replacements are appended while iterating.
55
+ for (const seq of [...session.surface.nodes]) {
56
+ const event = session.eventAt(seq);
57
+ if (event?.type !== 'tool/result') continue;
58
+ const result = event.data.message.content[0];
59
+ const content = pruneContent(result.content, config);
60
+ if (content === null) continue;
61
+ const message = freezeMessage({
62
+ ...event.data.message,
63
+ content: [{ ...result, content }]
64
+ });
65
+ session.append('compaction/prune', {
66
+ shadowedRange: { start: seq, end: seq },
67
+ shadowedSeqs: [seq],
68
+ shadowedTokenCount: ctx.tokenMeter.estimateMessage(event.data.message)
69
+ });
70
+ const replacement = session.append('tool/result', { ...event.data, message }, {
71
+ surfaceOp: replacementOp(keys, seq, seq),
72
+ sourceEventSeqs: [seq]
73
+ });
74
+ pruned += 1;
75
+ replacementSeq = replacement.seq;
76
+ }
77
+ return { pruned, via: 'inline', ...(replacementSeq === undefined ? {} : { replacementSeq }) };
78
+ }
79
+
80
+ /**
81
+ * Measure tool-result text in Unicode code points; non-text blocks cost zero.
82
+ * @param blocks - tool-result content blocks.
83
+ * @returns total code points across text blocks.
84
+ */
85
+ export function measureContent(blocks) {
86
+ let chars = 0;
87
+ for (const block of blocks) if (block.type === 'text') chars += codePointLength(block.text);
88
+ return chars;
89
+ }
90
+
91
+ /**
92
+ * Replace an over-budget text middle while retaining rich-block order.
93
+ * @param blocks - original tool-result content.
94
+ * @param config - resolved configuration with the character budgets.
95
+ * @returns rewritten content, or `null` when the text is already within budget.
96
+ */
97
+ export function pruneContent(blocks, config) {
98
+ const totalChars = measureContent(blocks);
99
+ if (totalChars <= config.pruneThresholdChars) return null;
100
+ const removedStart = config.pruneHeadChars;
101
+ const removedEnd = totalChars - config.pruneTailChars;
102
+ const pruned = [];
103
+ let consumed = 0;
104
+ let markerInserted = false;
105
+ for (const block of blocks) {
106
+ if (block.type !== 'text') {
107
+ pruned.push(block);
108
+ continue;
109
+ }
110
+ const points = Array.from(block.text);
111
+ const blockStart = consumed;
112
+ const blockEnd = blockStart + points.length;
113
+ const headEnd = Math.min(points.length, Math.max(0, removedStart - blockStart));
114
+ const tailStart = Math.min(points.length, Math.max(0, removedEnd - blockStart));
115
+ const marker = blockStart < removedEnd && blockEnd > removedStart && !markerInserted ? TRIM_MARKER : '';
116
+ if (marker.length > 0) markerInserted = true;
117
+ const text = points.slice(0, headEnd).join('') + marker + points.slice(tailStart).join('');
118
+ if (text.length > 0) pruned.push({ ...block, text });
119
+ consumed = blockEnd;
120
+ }
121
+ if (!markerInserted) return null;
122
+ const charsAfter = measureContent(pruned);
123
+ // Refuse a rewrite that would not actually be smaller and within budget.
124
+ if (charsAfter > config.pruneThresholdChars || charsAfter >= totalChars) return null;
125
+ return pruned;
126
+ }
127
+
128
+ /** Count Unicode code points without splitting surrogate pairs. */
129
+ function codePointLength(text) {
130
+ return Array.from(text).length;
131
+ }
package/lib/render.js ADDED
@@ -0,0 +1,133 @@
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
+ }
116
+
117
+ /**
118
+ * Render one automatic-path outcome, which may be an in-place slim with no span
119
+ * elision, a span elision, or a slim followed by a span elision.
120
+ * @param outcome - the object returned by `executeTrim`.
121
+ * @returns a single-line account.
122
+ */
123
+ export function logLineFor(outcome) {
124
+ if (outcome.plan === undefined) {
125
+ return (
126
+ `slimmed ${outcome.pruned.pruned} oversized tool result(s) in place for ${outcome.label}; ` +
127
+ `request ~${outcome.before.totalTokens} → ~${outcome.after.totalTokens} tokens`
128
+ );
129
+ }
130
+ const base = logLine(outcome.plan, outcome.after, outcome.label);
131
+ if (outcome.pruned === undefined || outcome.pruned.pruned === 0) return base;
132
+ return `${outcome.pruned.pruned} tool result(s) slimmed in place, then ${base}`;
133
+ }
package/lib/target.js CHANGED
@@ -40,8 +40,10 @@ export function latestRoute(session) {
40
40
  * @returns `{ provider, model, contextWindow, label }`.
41
41
  * @throws when no route can be determined, the route is unknown, or its adapter declares no window.
42
42
  */
43
- export async function resolveTarget(ctx, agent, requested, signal) {
44
- const route = requested ?? latestRoute(agent.session) ?? agentRoute(agent);
43
+ export async function resolveTarget(ctx, agent, requested, signal, options = {}) {
44
+ const primary =
45
+ options.routedOnly === true ? routedTarget(agent.session) ?? latestRoute(agent.session) : latestRoute(agent.session);
46
+ const route = requested ?? primary ?? agentRoute(agent);
45
47
  if (route === undefined) {
46
48
  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
49
  }
@@ -82,6 +84,20 @@ export function targetHeader(session, target) {
82
84
  });
83
85
  }
84
86
 
87
+ /**
88
+ * The route the last durable request was actually built for: the one that just
89
+ * overflowed during automatic recovery.
90
+ * @param session - session whose log is read.
91
+ * @returns `{ provider, model, source }`, or undefined when nothing was routed yet.
92
+ */
93
+ export function routedTarget(session) {
94
+ const config = session.requestHeader()?.config;
95
+ if (config === undefined) return undefined;
96
+ if (typeof config.provider !== 'string' || config.provider.length === 0) return undefined;
97
+ if (typeof config.model !== 'string' || config.model.length === 0) return undefined;
98
+ return { provider: config.provider, model: config.model, source: 'request' };
99
+ }
100
+
85
101
  /** Per-agent configured route, used only before any request was routed. */
86
102
  function agentRoute(agent) {
87
103
  const provider = agent.options?.provider;