dsh-context-pruner 0.0.0-stage → 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/README.md +784 -2
- package/cordis.patch.yml +19 -0
- package/lib/backend.js +190 -0
- package/lib/client.js +1230 -0
- package/lib/compact.js +407 -0
- package/lib/index.js +795 -0
- package/lib/inspect.js +390 -0
- package/lib/rounds.js +160 -0
- package/package.json +59 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-context-pruner host plugin.
|
|
3
|
+
*
|
|
4
|
+
* Registers `/prune`, which can both READ the context surface and COMPACT a
|
|
5
|
+
* chosen span of it.
|
|
6
|
+
*
|
|
7
|
+
* ## Command grammar
|
|
8
|
+
*
|
|
9
|
+
* /prune Every round ranked BIGGEST FIRST, so you can
|
|
10
|
+
* see which rounds cost the most. Not time order.
|
|
11
|
+
* /prune list [n|all|a-b] Read rounds in time order, with previews.
|
|
12
|
+
* /prune drop <a>-<b> COMPACT rounds a through b into one checkpoint.
|
|
13
|
+
* Everything outside that range is untouched.
|
|
14
|
+
* /prune drop <a>-<b> --yes Skip the confirmation echo.
|
|
15
|
+
*
|
|
16
|
+
* ## Why a confirmation step
|
|
17
|
+
*
|
|
18
|
+
* `drop` irreversibly changes what the model will see: the chosen rounds stop
|
|
19
|
+
* being sent and are replaced by one summary. The retired content stays in the
|
|
20
|
+
* session log for replay, but the model never sees it again. So `drop` first
|
|
21
|
+
* reports exactly which rounds would be replaced and what would remain, and
|
|
22
|
+
* requires `--yes` to proceed. That makes the destructive step deliberate
|
|
23
|
+
* rather than an accidental keystroke.
|
|
24
|
+
*
|
|
25
|
+
* ## Why the span is resolved to node seqs here
|
|
26
|
+
*
|
|
27
|
+
* Rounds are a presentation concept this plugin owns; the compaction seam
|
|
28
|
+
* speaks in surface node seqs. `drop` resolves the round range into the exact
|
|
29
|
+
* inclusive node span, sends that span, and lets the transaction reject an
|
|
30
|
+
* unbalanced edge rather than silently splitting a tool call from its result.
|
|
31
|
+
*
|
|
32
|
+
* @module dsh-context-pruner
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { CommandDefinitionId } from "@deepseek-ai/dsh-commands/brand";
|
|
36
|
+
import { ManualCompactionError, toolPairingBalancedAfter, toolPairingBalancedBefore } from "@deepseek-ai/dsh-compaction";
|
|
37
|
+
import { describeNodes, formatTokens, groupRounds, renderRounds } from "./inspect.js";
|
|
38
|
+
import { compactExplicitRegion, checkBalance } from "./compact.js";
|
|
39
|
+
import { CorrectiveCompactionEngine, DEFAULT_CORRECTIVE_INSTRUCTION } from "./backend.js";
|
|
40
|
+
|
|
41
|
+
export { CorrectiveCompactionEngine, DEFAULT_CORRECTIVE_INSTRUCTION } from "./backend.js";
|
|
42
|
+
|
|
43
|
+
const name = "context-pruner";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Hard requirements: the command registry and the token meter, both on the
|
|
47
|
+
* host plane.
|
|
48
|
+
*
|
|
49
|
+
* `compaction`, `llm` and `sessions` are deliberately NOT listed. In a Web
|
|
50
|
+
* profile the compaction backend is mounted INSIDE each agent preset (the
|
|
51
|
+
* host rows `compaction-basic` and `command-compact` are `disabled: true`),
|
|
52
|
+
* so requiring `compaction` here would leave this plugin permanently
|
|
53
|
+
* `pending` — the listing half would die along with the destructive half.
|
|
54
|
+
*
|
|
55
|
+
* Instead the read path never touches compaction, and `drop` resolves
|
|
56
|
+
* `ctx.get('compaction')` at call time (see {@link summarizeForDrop}), which
|
|
57
|
+
* is a host-plane service lookup that works because `ctx.llm` is what the
|
|
58
|
+
* summarizer actually needs.
|
|
59
|
+
*/
|
|
60
|
+
const inject = ["commands", "tokenMeter"];
|
|
61
|
+
|
|
62
|
+
/** Rounds shown when the user does not ask for a specific count. */
|
|
63
|
+
const DEFAULT_ROUNDS = 15;
|
|
64
|
+
|
|
65
|
+
const USAGE = [
|
|
66
|
+
"Usage:",
|
|
67
|
+
" /prune rounds ranked biggest-first (what costs the most)",
|
|
68
|
+
" /prune list read the newest 15 rounds in time order",
|
|
69
|
+
" /prune list <n> read the newest n rounds (NOT round number n)",
|
|
70
|
+
" /prune list <a>-<b> read rounds a through b",
|
|
71
|
+
" /prune list seq <a>-<b> read the rounds overlapping a surface seq span",
|
|
72
|
+
" /prune list all read every round",
|
|
73
|
+
"",
|
|
74
|
+
" /prune drop <a>-<b> compact rounds a-b into one checkpoint (asks first)",
|
|
75
|
+
" /prune drop seq <a>-<b> compact a surface seq span (what the panel sends)",
|
|
76
|
+
" /prune drop <a>-<b> --yes compact without asking"
|
|
77
|
+
].join("\n");
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Gather the inspected surface once, so every branch shares one measurement.
|
|
81
|
+
*
|
|
82
|
+
* @param ctx - context carrying the token meter.
|
|
83
|
+
* @param agent - agent whose session is inspected.
|
|
84
|
+
* @returns `{ measurement, descriptors, rounds }`.
|
|
85
|
+
*/
|
|
86
|
+
function inspect(ctx, agent) {
|
|
87
|
+
const session = agent.session;
|
|
88
|
+
const measurement = ctx.tokenMeter.measure(session);
|
|
89
|
+
const descriptors = describeNodes(session, measurement.nodes);
|
|
90
|
+
return { measurement, descriptors, rounds: groupRounds(descriptors) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse the argument to `/prune list`.
|
|
95
|
+
*
|
|
96
|
+
* @param raw - text after `list`.
|
|
97
|
+
* @returns a selection, or `{ error }` describing a malformed argument.
|
|
98
|
+
*/
|
|
99
|
+
function parseSelection(raw) {
|
|
100
|
+
const text = raw.trim();
|
|
101
|
+
if (text.length === 0) return { kind: "newest", count: DEFAULT_ROUNDS };
|
|
102
|
+
if (text === "all") return { kind: "all" };
|
|
103
|
+
if (/^\d+$/.test(text)) {
|
|
104
|
+
// `list 0` would silently become `list 1`. Say so instead: "the newest
|
|
105
|
+
// zero rounds" is not a meaningful request, and quietly showing one
|
|
106
|
+
// round hides the fact that the argument was not understood.
|
|
107
|
+
const count = Number.parseInt(text, 10);
|
|
108
|
+
if (count === 0) return { error: "`list 0` would show no rounds; give a count of 1 or more." };
|
|
109
|
+
return { kind: "newest", count };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The `seq` form mirrors `drop seq <a>-<b>`, so the exact line the panel
|
|
113
|
+
// seeds into the composer can also be READ back before it is run.
|
|
114
|
+
const seqRange = /^seq\s+(\d+)\s*-\s*(\d+)$/i.exec(text);
|
|
115
|
+
if (seqRange !== null) {
|
|
116
|
+
return {
|
|
117
|
+
kind: "seq",
|
|
118
|
+
from: Number.parseInt(seqRange[1], 10),
|
|
119
|
+
to: Number.parseInt(seqRange[2], 10)
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const range = /^(\d+)\s*-\s*(\d+)$/.exec(text);
|
|
124
|
+
if (range !== null) {
|
|
125
|
+
const from = Number.parseInt(range[1], 10);
|
|
126
|
+
const to = Number.parseInt(range[2], 10);
|
|
127
|
+
if (from > to) return { error: `Range ${from}-${to} is reversed.` };
|
|
128
|
+
return { kind: "range", from, to };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { error: `Unrecognised argument: ${text}` };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Render the plain `/prune` summary.
|
|
136
|
+
*
|
|
137
|
+
* @param inspection - result of {@link inspect}.
|
|
138
|
+
* @returns human-facing text.
|
|
139
|
+
*/
|
|
140
|
+
function renderSummary(inspection) {
|
|
141
|
+
const { measurement, descriptors, rounds } = inspection;
|
|
142
|
+
|
|
143
|
+
if (rounds.length === 0) return "Nothing on the context surface yet.";
|
|
144
|
+
|
|
145
|
+
const lines = [];
|
|
146
|
+
lines.push("Context at a glance");
|
|
147
|
+
lines.push(
|
|
148
|
+
` ${descriptors.length} nodes in ${rounds.length} round${rounds.length === 1 ? "" : "s"}, ~${measurement.surfaceTokens} tokens (request pressure ~${measurement.totalTokens})`
|
|
149
|
+
);
|
|
150
|
+
lines.push("");
|
|
151
|
+
|
|
152
|
+
// A raw per-node share rounds to 0% across hundreds of nodes and hides the
|
|
153
|
+
// real story. Ranking whole rounds is what actually surfaces the weight.
|
|
154
|
+
//
|
|
155
|
+
// The heading states the sort order explicitly: "heaviest rounds" alone
|
|
156
|
+
// left a reader unsure whether the list was chronological or ranked.
|
|
157
|
+
const heaviest = [...rounds].sort((a, b) => b.tokens - a.tokens).slice(0, 8);
|
|
158
|
+
const shownTokens = heaviest.reduce((sum, round) => sum + round.tokens, 0);
|
|
159
|
+
const share = measurement.surfaceTokens === 0
|
|
160
|
+
? 0
|
|
161
|
+
: Math.round((shownTokens / measurement.surfaceTokens) * 100);
|
|
162
|
+
|
|
163
|
+
// Quote a share only when the list is a real subset; "100% of the total"
|
|
164
|
+
// is noise when the session is short enough that every round is shown.
|
|
165
|
+
const scope =
|
|
166
|
+
heaviest.length < rounds.length
|
|
167
|
+
? ` — these ${heaviest.length} of ${rounds.length} rounds hold ~${formatTokens(shownTokens)} tok (${share}% of the total)`
|
|
168
|
+
: ` — all ${rounds.length} shown`;
|
|
169
|
+
|
|
170
|
+
lines.push(`Biggest rounds (largest first, not in time order)${scope}:`);
|
|
171
|
+
for (const round of heaviest) {
|
|
172
|
+
// The seq span is printed so this listing is actionable on its own: a
|
|
173
|
+
// reader who identifies the heaviest round can copy the span straight
|
|
174
|
+
// into `drop seq <a>-<b>` without a second `/prune list` round trip.
|
|
175
|
+
lines.push(
|
|
176
|
+
` round ${String(round.index).padStart(3)} ${formatTokens(round.tokens).padStart(6)} tok seq ${String(round.startSeq).padEnd(5)} ${(round.title ?? round.opener).preview}`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
lines.push("");
|
|
180
|
+
|
|
181
|
+
lines.push("Reading this: the rounds at the top use the most context, so removing");
|
|
182
|
+
lines.push("them frees the most. Rounds near the bottom barely matter.");
|
|
183
|
+
lines.push("");
|
|
184
|
+
lines.push("Next: to see WHAT a round contains, read it in time order:");
|
|
185
|
+
lines.push(" `/prune list` the newest 15 rounds");
|
|
186
|
+
lines.push(" `/prune list 0-3` rounds 0 through 3");
|
|
187
|
+
lines.push(" `/prune list all` everything");
|
|
188
|
+
lines.push("Read-only: this command changes nothing in the session.");
|
|
189
|
+
return lines.join("\n");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Render `/prune list`.
|
|
194
|
+
*
|
|
195
|
+
* @param inspection - result of {@link inspect}.
|
|
196
|
+
* @param selection - parsed selection.
|
|
197
|
+
* @returns human-facing text.
|
|
198
|
+
*/
|
|
199
|
+
function renderList(inspection, selection) {
|
|
200
|
+
const { rounds } = inspection;
|
|
201
|
+
|
|
202
|
+
if (rounds.length === 0) return "Nothing on the context surface yet.";
|
|
203
|
+
|
|
204
|
+
let chosen;
|
|
205
|
+
let caption;
|
|
206
|
+
|
|
207
|
+
if (selection.kind === "all") {
|
|
208
|
+
chosen = rounds;
|
|
209
|
+
caption = `All ${rounds.length} rounds`;
|
|
210
|
+
} else if (selection.kind === "seq") {
|
|
211
|
+
// A seq selection names surface nodes, not round numbers, so it is
|
|
212
|
+
// matched by POSITION RANGE: every round whose span overlaps the
|
|
213
|
+
// requested node window. That makes it the readable counterpart of the
|
|
214
|
+
// exact line the panel seeds into the composer.
|
|
215
|
+
const nodes = inspection.measurement.nodes.map((node) => node.seq);
|
|
216
|
+
const fromIdx = nodes.indexOf(selection.from);
|
|
217
|
+
const toIdx = nodes.indexOf(selection.to);
|
|
218
|
+
if (fromIdx === -1 || toIdx === -1) {
|
|
219
|
+
return `Surface seq ${fromIdx === -1 ? selection.from : selection.to} is not on the surface.`;
|
|
220
|
+
}
|
|
221
|
+
chosen = rounds.filter((round) => {
|
|
222
|
+
const startIdx = nodes.indexOf(round.startSeq);
|
|
223
|
+
const endIdx = nodes.indexOf(round.endSeq);
|
|
224
|
+
return startIdx <= toIdx && endIdx >= Math.min(fromIdx, toIdx);
|
|
225
|
+
});
|
|
226
|
+
if (chosen.length === 0) {
|
|
227
|
+
return `No rounds overlap the span seq ${selection.from}-${selection.to}.`;
|
|
228
|
+
}
|
|
229
|
+
caption = `Seq ${selection.from}-${selection.to}`;
|
|
230
|
+
} else if (selection.kind === "range") {
|
|
231
|
+
chosen = rounds.filter((round) => round.index >= selection.from && round.index <= selection.to);
|
|
232
|
+
if (chosen.length === 0) {
|
|
233
|
+
return `No rounds in ${selection.from}-${selection.to}. This session has rounds 0-${rounds.length - 1}.`;
|
|
234
|
+
}
|
|
235
|
+
caption = `Rounds ${selection.from}-${selection.to}`;
|
|
236
|
+
} else {
|
|
237
|
+
const count = Math.max(1, selection.count);
|
|
238
|
+
chosen = rounds.slice(-count);
|
|
239
|
+
caption = `Newest ${chosen.length} of ${rounds.length} rounds`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const shown = new Set(chosen);
|
|
243
|
+
const shownTokens = chosen.reduce((sum, round) => sum + round.tokens, 0);
|
|
244
|
+
const lines = [];
|
|
245
|
+
|
|
246
|
+
lines.push(`${caption} (~${formatTokens(shownTokens)} tokens shown)`);
|
|
247
|
+
lines.push(" round size spans what was said");
|
|
248
|
+
lines.push(...renderRounds(rounds, (round) => shown.has(round)));
|
|
249
|
+
|
|
250
|
+
if (chosen.length < rounds.length) {
|
|
251
|
+
lines.push("");
|
|
252
|
+
lines.push(
|
|
253
|
+
` ... ${rounds.length - chosen.length} other rounds not shown; use \`/prune list all\` to include them`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return lines.join("\n");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Parse a `drop` argument into the surface spans to compact.
|
|
262
|
+
*
|
|
263
|
+
* Three forms exist, and the differences matter:
|
|
264
|
+
*
|
|
265
|
+
* `drop seq 6050-6947, 9000-9100` — explicit SURFACE SEQ spans. Unambiguous:
|
|
266
|
+
* seqs are durable identifiers, and this is
|
|
267
|
+
* the form the UI sends. Commas allow a
|
|
268
|
+
* SCATTERED selection to be compacted in
|
|
269
|
+
* one command: rounds 6, 9, 10 and 11
|
|
270
|
+
* become `seq a-b, c-d` rather than two
|
|
271
|
+
* commands the user must run in turn.
|
|
272
|
+
* `drop 3-7, 12-14` — ROUND spans, resolved against this
|
|
273
|
+
* command's own round numbering.
|
|
274
|
+
*
|
|
275
|
+
* A round span is only safe because both sides use THIS command's numbering. A
|
|
276
|
+
* client that numbers rounds by its own rule and sends those indices compacts
|
|
277
|
+
* the wrong span — which is exactly what happened when the panel sent
|
|
278
|
+
* `drop 9-11` for a span the host numbered differently. Hence the explicit seq
|
|
279
|
+
* form for programmatic callers.
|
|
280
|
+
*
|
|
281
|
+
* Every span must be non-overlapping and strictly ascending, so the resulting
|
|
282
|
+
* replacement is unambiguous and the transaction can validate one contiguous
|
|
283
|
+
* region at a time.
|
|
284
|
+
*
|
|
285
|
+
* @param session - session supplying the surface.
|
|
286
|
+
* @param rounds - rounds from {@link inspect}, for the round form.
|
|
287
|
+
* @param spec - the argument without `--yes`.
|
|
288
|
+
* @returns `{ spans, label }` or `{ error }`. `spans` are `{start, end}` pairs.
|
|
289
|
+
*/
|
|
290
|
+
function resolveDropSpans(session, rounds, spec) {
|
|
291
|
+
const parts = spec.split(",").map((part) => part.trim()).filter((part) => part.length > 0);
|
|
292
|
+
// Distinguish "no range was given at all" from "a range could not be
|
|
293
|
+
// parsed". Both used to return `{ error: null }`, so `/prune drop garbage`
|
|
294
|
+
// was reported as "you gave me no range" rather than "I could not parse
|
|
295
|
+
// `garbage`" — a misleading diagnosis of a mistake the user can fix.
|
|
296
|
+
if (parts.length === 0) return { empty: true };
|
|
297
|
+
|
|
298
|
+
const nodes = session.surface.nodes;
|
|
299
|
+
const spans = [];
|
|
300
|
+
|
|
301
|
+
for (const part of parts) {
|
|
302
|
+
const seqForm = /^seq\s+(\d+)\s*-\s*(\d+)$/i.exec(part);
|
|
303
|
+
if (seqForm !== null) {
|
|
304
|
+
const start = Number.parseInt(seqForm[1], 10);
|
|
305
|
+
const end = Number.parseInt(seqForm[2], 10);
|
|
306
|
+
if (!nodes.includes(start)) return { error: `Surface seq ${start} is not on the surface.` };
|
|
307
|
+
if (!nodes.includes(end)) return { error: `Surface seq ${end} is not on the surface.` };
|
|
308
|
+
if (nodes.indexOf(start) > nodes.indexOf(end)) {
|
|
309
|
+
return { error: `Surface seq ${start} comes after ${end} on the surface.` };
|
|
310
|
+
}
|
|
311
|
+
// The label travels ON the span: keeping it in a parallel array
|
|
312
|
+
// meant the sort below reordered the spans while leaving the labels
|
|
313
|
+
// behind, so an overlap error named two spans that do not overlap.
|
|
314
|
+
spans.push({ start, end, label: `seq ${start}-${end}` });
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const roundForm = /^(\d+)\s*-\s*(\d+)$/.exec(part);
|
|
319
|
+
if (roundForm === null) return { error: `Unrecognised range: ${part}` };
|
|
320
|
+
|
|
321
|
+
const from = Number.parseInt(roundForm[1], 10);
|
|
322
|
+
const to = Number.parseInt(roundForm[2], 10);
|
|
323
|
+
const span = resolveRoundSpan(rounds, from, to);
|
|
324
|
+
if (span.error !== undefined) return { error: span.error };
|
|
325
|
+
spans.push({ start: span.startSeq, end: span.endSeq, label: `rounds ${from}-${to}` });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Order the spans by surface position, and reject an overlap: two spans
|
|
329
|
+
// sharing nodes would make the intended replacement ambiguous.
|
|
330
|
+
spans.sort((a, b) => nodes.indexOf(a.start) - nodes.indexOf(b.start));
|
|
331
|
+
for (let i = 1; i < spans.length; i += 1) {
|
|
332
|
+
const previousEnd = nodes.indexOf(spans[i - 1].end);
|
|
333
|
+
const currentStart = nodes.indexOf(spans[i].start);
|
|
334
|
+
if (currentStart <= previousEnd) {
|
|
335
|
+
// Name the pair by INPUT order where possible, because that is the
|
|
336
|
+
// order the user typed and can compare against.
|
|
337
|
+
return {
|
|
338
|
+
error: `Spans ${spans[i - 1].label} and ${spans[i].label} overlap on the surface; select non-overlapping rounds.`
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return { spans, label: spans.map((span) => span.label).join(" + ") };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Resolve a round range into the inclusive surface-node span it covers.
|
|
348
|
+
*
|
|
349
|
+
* @param rounds - rounds from {@link inspect}.
|
|
350
|
+
* @param from - first round index, inclusive.
|
|
351
|
+
* @param to - last round index, inclusive.
|
|
352
|
+
* @returns `{ startSeq, endSeq, rounds }` or `{ error }`.
|
|
353
|
+
*/
|
|
354
|
+
function resolveRoundSpan(rounds, from, to) {
|
|
355
|
+
if (from > to) return { error: `Range ${from}-${to} is reversed.` };
|
|
356
|
+
if (rounds.length === 0) return { error: "There is nothing on the context surface to compact." };
|
|
357
|
+
|
|
358
|
+
const last = rounds.length - 1;
|
|
359
|
+
if (from < 0 || to > last) {
|
|
360
|
+
return { error: `Rounds ${from}-${to} are out of range. This session has rounds 0-${last}.` };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const chosen = rounds.filter((round) => round.index >= from && round.index <= to);
|
|
364
|
+
return {
|
|
365
|
+
startSeq: chosen[0].startSeq,
|
|
366
|
+
endSeq: chosen[chosen.length - 1].endSeq,
|
|
367
|
+
rounds: chosen
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Execute `/prune drop <a>-<b> [--yes]`.
|
|
373
|
+
*
|
|
374
|
+
* Two phases: without `--yes` it reports exactly what would happen and stops;
|
|
375
|
+
* with `--yes` it runs the transaction. The confirmation exists because the
|
|
376
|
+
* model stops seeing the chosen rounds — the log keeps them for replay, but
|
|
377
|
+
* the conversation itself cannot get them back.
|
|
378
|
+
*
|
|
379
|
+
* @param ctx - context carrying the meter, compaction seam and summarizer deps.
|
|
380
|
+
* @param invocation - command invocation.
|
|
381
|
+
* @param args - text after `drop`.
|
|
382
|
+
* @returns the command result.
|
|
383
|
+
*/
|
|
384
|
+
async function handleDrop(ctx, invocation, args) {
|
|
385
|
+
// `--yes` is a FLAG, not a positional token: it is recognised wherever it
|
|
386
|
+
// appears and every occurrence is removed. A regex that replaced only the
|
|
387
|
+
// first match also consumed an adjacent character, so
|
|
388
|
+
// `/prune drop 2-3 --yes, 6-7` — the natural way to confirm a multi-span
|
|
389
|
+
// line the panel itself generates — was rejected as "no range given".
|
|
390
|
+
//
|
|
391
|
+
// The split is per comma-separated part as well as per whitespace, because
|
|
392
|
+
// the flag can sit against a neighbour (`1-1 --yes, 3-3`).
|
|
393
|
+
// Detection and removal must agree: both work on the same token split, so a
|
|
394
|
+
// form that has its flag stripped is always also treated as confirmed.
|
|
395
|
+
// Deriving `confirmed` from a separate regex let `1-1,--yes,3-3` strip the
|
|
396
|
+
// flag from the spec while reporting it was not confirmed — the preview
|
|
397
|
+
// would print a command the user had already confirmed.
|
|
398
|
+
const parts = args
|
|
399
|
+
.split(",")
|
|
400
|
+
.map((part) => part.split(/\s+/).filter((token) => token.length > 0));
|
|
401
|
+
const confirmed = parts.some((tokens) => tokens.includes("--yes"));
|
|
402
|
+
const spec = parts
|
|
403
|
+
.map((tokens) => tokens.filter((token) => token !== "--yes").join(" "))
|
|
404
|
+
.filter((part) => part.length > 0)
|
|
405
|
+
.join(", ");
|
|
406
|
+
|
|
407
|
+
const inspection = inspect(ctx, invocation.agent);
|
|
408
|
+
const resolved = resolveDropSpans(invocation.agent.session, inspection.rounds, spec);
|
|
409
|
+
if (resolved.empty === true) {
|
|
410
|
+
return {
|
|
411
|
+
kind: "error",
|
|
412
|
+
text: [
|
|
413
|
+
"`drop` needs at least one range:",
|
|
414
|
+
" /prune drop 3-7 (rounds)",
|
|
415
|
+
" /prune drop seq 6050-6947 (surface seqs)",
|
|
416
|
+
" /prune drop 6-6, 9-11 (several ranges, comma-separated)",
|
|
417
|
+
"",
|
|
418
|
+
USAGE
|
|
419
|
+
].join("\n")
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
if (resolved.error !== undefined) return { kind: "error", text: resolved.error };
|
|
423
|
+
|
|
424
|
+
const nodes = invocation.agent.session.surface.nodes;
|
|
425
|
+
const { spans, label } = resolved;
|
|
426
|
+
|
|
427
|
+
// The canonical, always-re-parseable rendering of what was selected. Echoing
|
|
428
|
+
// the raw argument instead would print any empty parts the user typed
|
|
429
|
+
// (`seq 5-5 , ,`), which re-parses only because empty parts are filtered —
|
|
430
|
+
// so the "To proceed:" line could hand back a command that does not work.
|
|
431
|
+
const canonical = spans.map((span) => `seq ${span.start}-${span.end}`).join(", ");
|
|
432
|
+
|
|
433
|
+
// Every span must be a balanced boundary before anything is written, so a
|
|
434
|
+
// bad selection is refused once rather than midway through a partial run.
|
|
435
|
+
for (let i = 0; i < spans.length; i += 1) {
|
|
436
|
+
const balanceError = checkBalance(
|
|
437
|
+
invocation.agent.session,
|
|
438
|
+
{ start: spans[i].start, end: spans[i].end },
|
|
439
|
+
toolPairingBalancedBefore,
|
|
440
|
+
toolPairingBalancedAfter
|
|
441
|
+
);
|
|
442
|
+
if (balanceError !== undefined) {
|
|
443
|
+
// Name the boundary and the remedy concretely. "Try shifting that
|
|
444
|
+
// range by one round" was guesswork: the usual fix is to EXTEND the
|
|
445
|
+
// span so the assistant's tool call and its result stay together,
|
|
446
|
+
// and the user needs the seq to act on.
|
|
447
|
+
return {
|
|
448
|
+
kind: "error",
|
|
449
|
+
text: [
|
|
450
|
+
`Cannot compact span ${i + 1} of ${spans.length} (seq ${spans[i].start}..${spans[i].end}): ${balanceError}.`,
|
|
451
|
+
"That boundary separates a tool call from its result, which would corrupt the remaining conversation.",
|
|
452
|
+
`Extend the span by one round at that end, or use the round form (\`/prune drop <a>-<b>\`), which never splits a step.`
|
|
453
|
+
].join("\n")
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Everything about to leave the model's view, described well enough to make
|
|
459
|
+
// an irreversible decision from. This is the only place that can catch a
|
|
460
|
+
// mis-aimed span before it commits, so it reports composition and the single
|
|
461
|
+
// largest node rather than repeating the same opener previews the listing
|
|
462
|
+
// already showed.
|
|
463
|
+
const spanDetails = spans.map((span) => {
|
|
464
|
+
const startIdx = nodes.indexOf(span.start);
|
|
465
|
+
const endIdx = nodes.indexOf(span.end);
|
|
466
|
+
const seqs = nodes.slice(startIdx, endIdx + 1);
|
|
467
|
+
const priced = inspection.measurement.nodes.slice(startIdx, endIdx + 1);
|
|
468
|
+
// Rounds whose START seq falls inside the span. A round that merely
|
|
469
|
+
// reaches into the span from before it is not "touched" — nothing of it
|
|
470
|
+
// is dropped — and counting it overstated the estimate.
|
|
471
|
+
const contained = inspection.rounds.filter((round) => {
|
|
472
|
+
const roundIdx = nodes.indexOf(round.startSeq);
|
|
473
|
+
return roundIdx >= startIdx && roundIdx <= endIdx;
|
|
474
|
+
});
|
|
475
|
+
const largest = priced.reduce(
|
|
476
|
+
(best, node) => (best === undefined || node.tokens > best.tokens ? node : best),
|
|
477
|
+
undefined
|
|
478
|
+
);
|
|
479
|
+
return { span, seqs, priced, contained, largest };
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const droppedSeqs = new Set(spanDetails.flatMap((detail) => detail.seqs));
|
|
483
|
+
const droppedTokens = inspection.measurement.nodes
|
|
484
|
+
.filter((node) => droppedSeqs.has(node.seq))
|
|
485
|
+
.reduce((sum, node) => sum + node.tokens, 0);
|
|
486
|
+
const keptTokens = inspection.measurement.surfaceTokens - droppedTokens;
|
|
487
|
+
|
|
488
|
+
// A span that does not begin at a round boundary and end at one cuts a
|
|
489
|
+
// conversation turn in half: the round's remaining nodes stay on the surface
|
|
490
|
+
// with their human question or its answer missing. The round form can never
|
|
491
|
+
// produce this, but the seq form — which is what the panel sends — can, so a
|
|
492
|
+
// user editing a seeded line can create it by accident.
|
|
493
|
+
//
|
|
494
|
+
// Alignment is judged against the rounds the span CONTAINS: a span is clean
|
|
495
|
+
// when it starts no LATER than its first contained round's first node and
|
|
496
|
+
// ends exactly at its last contained round's end. It may start EARLIER —
|
|
497
|
+
// pulling in the leading system prompt or runtime snapshot is legitimate and
|
|
498
|
+
// is what the panel does when a user selects only the first visible round.
|
|
499
|
+
//
|
|
500
|
+
// An earlier version required the span's endpoints to be members of the
|
|
501
|
+
// GLOBAL sets of round starts/ends, which false-positived on a legitimate
|
|
502
|
+
// "system head plus whole rounds" span and also suppressed the round-count
|
|
503
|
+
// sentence for a correct command.
|
|
504
|
+
const midRound = spans
|
|
505
|
+
.map((span, i) => {
|
|
506
|
+
const startIdx = nodes.indexOf(span.start);
|
|
507
|
+
const endIdx = nodes.indexOf(span.end);
|
|
508
|
+
const contained = inspection.rounds.filter((round) => {
|
|
509
|
+
const roundIdx = nodes.indexOf(round.startSeq);
|
|
510
|
+
return roundIdx >= startIdx && roundIdx <= endIdx;
|
|
511
|
+
});
|
|
512
|
+
// Nothing contained means the span addresses no whole round at all.
|
|
513
|
+
if (contained.length === 0) return i + 1;
|
|
514
|
+
const first = contained[0];
|
|
515
|
+
const last = contained[contained.length - 1];
|
|
516
|
+
const startsCleanly = nodes.indexOf(first.startSeq) >= startIdx;
|
|
517
|
+
const endsCleanly = last.endSeq === span.end;
|
|
518
|
+
return startsCleanly && endsCleanly ? null : i + 1;
|
|
519
|
+
})
|
|
520
|
+
.filter((index) => index !== null);
|
|
521
|
+
|
|
522
|
+
if (!confirmed) {
|
|
523
|
+
const lines = [];
|
|
524
|
+
const droppedRounds = spanDetails.flatMap((detail) => detail.contained);
|
|
525
|
+
const notices = droppedRounds.reduce((sum, round) => sum + round.injected, 0);
|
|
526
|
+
|
|
527
|
+
lines.push(
|
|
528
|
+
`Would compact ${label} — ${spans.length} span${spans.length === 1 ? "" : "s"}, ` +
|
|
529
|
+
`${droppedRounds.length} round${droppedRounds.length === 1 ? "" : "s"}, ` +
|
|
530
|
+
`${droppedSeqs.size} nodes, ~${formatTokens(droppedTokens)} tok:`
|
|
531
|
+
);
|
|
532
|
+
lines.push("");
|
|
533
|
+
|
|
534
|
+
if (midRound.length > 0) {
|
|
535
|
+
lines.push(
|
|
536
|
+
` WARNING: span ${midRound.join(", ")} cuts through a conversation round rather than covering whole rounds.`
|
|
537
|
+
);
|
|
538
|
+
lines.push(
|
|
539
|
+
" Part of a round would be replaced while the rest stays, so the model would see"
|
|
540
|
+
);
|
|
541
|
+
lines.push(" an answer with its question missing (or the reverse). Prefer whole rounds.");
|
|
542
|
+
lines.push("");
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
for (let i = 0; i < spanDetails.length; i += 1) {
|
|
546
|
+
const detail = spanDetails[i];
|
|
547
|
+
lines.push(` span ${i + 1} seq ${detail.span.start}..${detail.span.end} ~${formatTokens(detail.priced.reduce((sum, n) => sum + n.tokens, 0))} tok`);
|
|
548
|
+
for (const round of detail.contained) {
|
|
549
|
+
const notice =
|
|
550
|
+
round.injected > 0 ? ` (+${round.injected} notice${round.injected === 1 ? "" : "s"})` : "";
|
|
551
|
+
lines.push(
|
|
552
|
+
` round ${String(round.index).padStart(3)} ${formatTokens(round.tokens).padStart(6)} tok ${(round.title ?? round.opener).preview}${notice}`
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Composition, so a round whose opener hides a long tool chain is not
|
|
558
|
+
// mistaken for a short question.
|
|
559
|
+
const droppedNodes = inspection.measurement.nodes.filter((node) => droppedSeqs.has(node.seq));
|
|
560
|
+
const byRole = new Map();
|
|
561
|
+
for (const descriptor of inspection.descriptors) {
|
|
562
|
+
if (!droppedSeqs.has(descriptor.seq)) continue;
|
|
563
|
+
byRole.set(descriptor.role, (byRole.get(descriptor.role) ?? 0) + 1);
|
|
564
|
+
}
|
|
565
|
+
const composition = [...byRole.entries()]
|
|
566
|
+
.map(([role, count]) => `${count} ${role}`)
|
|
567
|
+
.join(", ");
|
|
568
|
+
lines.push("");
|
|
569
|
+
lines.push(` inside: ${composition || "(nothing)"}`);
|
|
570
|
+
if (notices > 0) {
|
|
571
|
+
lines.push(` ${notices} of these nodes are harness notices, not conversation.`);
|
|
572
|
+
}
|
|
573
|
+
const biggest = spanDetails
|
|
574
|
+
.map((detail) => detail.largest)
|
|
575
|
+
.filter((node) => node !== undefined)
|
|
576
|
+
.sort((a, b) => b.tokens - a.tokens)[0];
|
|
577
|
+
if (biggest !== undefined) {
|
|
578
|
+
const descriptor = inspection.descriptors.find((d) => d.seq === biggest.seq);
|
|
579
|
+
lines.push(
|
|
580
|
+
` largest single node: seq ${biggest.seq} (~${formatTokens(biggest.tokens)} tok) — ${descriptor?.preview ?? ""}`
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
lines.push("");
|
|
585
|
+
lines.push(
|
|
586
|
+
`BECOMES: ${spans.length} checkpoint${spans.length === 1 ? "" : "s"} replacing those ${droppedSeqs.size} nodes.`
|
|
587
|
+
);
|
|
588
|
+
// Only quote a round count when every span covers whole rounds; a
|
|
589
|
+
// round-cutting span drops nodes without retiring a round, so the naive
|
|
590
|
+
// arithmetic could report the round count INCREASING — pruning can never
|
|
591
|
+
// add a round, and a sentence that says it can destroys trust in the
|
|
592
|
+
// whole preview.
|
|
593
|
+
if (midRound.length === 0) {
|
|
594
|
+
const after = inspection.rounds.length - droppedRounds.length + spans.length;
|
|
595
|
+
lines.push(
|
|
596
|
+
`The surface goes from ${inspection.rounds.length} rounds to ${after}, and ~${formatTokens(keptTokens)} tok stay as they are.`
|
|
597
|
+
);
|
|
598
|
+
} else {
|
|
599
|
+
lines.push(
|
|
600
|
+
`The surface keeps roughly ${formatTokens(keptTokens)} tok of its ${formatTokens(inspection.measurement.surfaceTokens)} tok; ` +
|
|
601
|
+
"the round count is not quoted because the spans are not round-aligned."
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
lines.push("");
|
|
605
|
+
// What the model will actually be told. This is the real risk of the
|
|
606
|
+
// operation: the checkpoint ASSERTS a conclusion, so a span the user has
|
|
607
|
+
// not re-read lets the summarizer decide what counts as true.
|
|
608
|
+
lines.push("The checkpoints are CORRECTIVE: each marks the dropped reasoning as");
|
|
609
|
+
lines.push('"Retired Exploration — DO NOT TRUST" and asserts an Authoritative Conclusion.');
|
|
610
|
+
lines.push("Whatever the summarizer concludes is what the model believes afterwards.");
|
|
611
|
+
lines.push("This cannot be undone: the session log keeps the originals for replay, but the");
|
|
612
|
+
lines.push("conversation itself cannot get them back.");
|
|
613
|
+
lines.push("");
|
|
614
|
+
lines.push(`To proceed: /prune drop ${canonical} --yes`);
|
|
615
|
+
return { kind: "success", text: lines.join("\n") };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
try {
|
|
619
|
+
// One transaction per span. They are independent regions, and the
|
|
620
|
+
// transaction's own lock serializes them, so a failure part-way leaves
|
|
621
|
+
// the already-committed spans in place and reports how far it got.
|
|
622
|
+
const results = [];
|
|
623
|
+
for (let i = 0; i < spans.length; i += 1) {
|
|
624
|
+
const span = spans[i];
|
|
625
|
+
try {
|
|
626
|
+
const result = await compactExplicitRegion({
|
|
627
|
+
session: invocation.agent.session,
|
|
628
|
+
agent: invocation.agent,
|
|
629
|
+
start: span.start,
|
|
630
|
+
end: span.end,
|
|
631
|
+
summarize: (input, agent, signal) => summarizeForDrop(ctx, input, agent, signal),
|
|
632
|
+
meter: ctx.tokenMeter,
|
|
633
|
+
signal: invocation.signal,
|
|
634
|
+
sourceCommandId: invocation.commandId,
|
|
635
|
+
balancedBefore: toolPairingBalancedBefore,
|
|
636
|
+
balancedAfter: toolPairingBalancedAfter,
|
|
637
|
+
flush: async () => {
|
|
638
|
+
// Durability is best-effort here: the `sessions` service may be
|
|
639
|
+
// absent depending on the composition, and a missing flush
|
|
640
|
+
// must not turn a committed compaction into a failure.
|
|
641
|
+
const sessions = ctx.get("sessions");
|
|
642
|
+
if (sessions !== undefined && sessions !== null && typeof sessions.flush === "function") {
|
|
643
|
+
await sessions.flush(invocation.agent.session);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
results.push({ span, result });
|
|
648
|
+
} catch (error) {
|
|
649
|
+
if (results.length === 0) throw error;
|
|
650
|
+
return {
|
|
651
|
+
kind: "error",
|
|
652
|
+
text: `Compacted ${results.length} of ${spans.length} spans; span ${i + 1} (seq ${span.start}..${span.end}) failed:\n${String(error?.message ?? error)}`
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const totalNodes = results.reduce((sum, r) => sum + r.result.shadowedSeqs.length, 0);
|
|
658
|
+
const totalTokens = results.reduce((sum, r) => sum + r.result.shadowedTokenCount, 0);
|
|
659
|
+
return {
|
|
660
|
+
kind: "success",
|
|
661
|
+
text: [
|
|
662
|
+
`Compacted ${label} (${results.length} checkpoint${results.length === 1 ? "" : "s"}, ${totalNodes} nodes, ~${totalTokens} tokens).`,
|
|
663
|
+
"Everything outside those spans is unchanged."
|
|
664
|
+
].join(" "),
|
|
665
|
+
sourceEventSeq: results[results.length - 1].result.summarySeq
|
|
666
|
+
};
|
|
667
|
+
} catch (error) {
|
|
668
|
+
if (invocation.signal.aborted) {
|
|
669
|
+
// State the outcome, not just the event. The transaction closes its
|
|
670
|
+
// lock and rethrows before any span commits, so nothing changed —
|
|
671
|
+
// but a user who cancelled mid-way cannot know that unaided.
|
|
672
|
+
return {
|
|
673
|
+
kind: "error",
|
|
674
|
+
text: "Prune cancelled. Nothing was changed; no spans were committed."
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
if (error instanceof ManualCompactionError) {
|
|
678
|
+
// Surface the underlying reason when it is more specific than the
|
|
679
|
+
// classification: "no provider/model available" must not be
|
|
680
|
+
// reported as a shrink failure, which would send the user hunting
|
|
681
|
+
// for a range problem that does not exist.
|
|
682
|
+
const cause = error.cause?.message ?? "";
|
|
683
|
+
const detail = cause.length > 0 ? ` — ${cause}` : "";
|
|
684
|
+
const message =
|
|
685
|
+
error.code === "busy"
|
|
686
|
+
? "Prune needs an idle agent: a turn is running, or another compaction holds the lock. Retry when idle."
|
|
687
|
+
: error.code === "changed"
|
|
688
|
+
? "The history changed while the summary was being written, so nothing was replaced. Retry."
|
|
689
|
+
: error.code === "summary"
|
|
690
|
+
? `Prune could not write a checkpoint for those rounds${detail}`
|
|
691
|
+
: error.code === "persistence"
|
|
692
|
+
? "Prune succeeded but the session could not be saved. Inspect the session before continuing."
|
|
693
|
+
: "Prune did not finish cleanly; some history may have changed. Inspect before retrying.";
|
|
694
|
+
return { kind: "error", text: message };
|
|
695
|
+
}
|
|
696
|
+
throw error;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Produce the correction checkpoint for a drop, reusing the plugin's backend.
|
|
702
|
+
*
|
|
703
|
+
* The corrective instruction is the whole point of this plugin: the replaced
|
|
704
|
+
* span is summarised as a RETIRED direction plus an authoritative conclusion,
|
|
705
|
+
* not as a neutral transcript, so the model stops treating the dropped rounds
|
|
706
|
+
* as live reasoning.
|
|
707
|
+
*
|
|
708
|
+
* `summarize()` is a prototype method that reads only `this.ctx` and
|
|
709
|
+
* `this.config`, so it can be applied to a plain object without constructing a
|
|
710
|
+
* Cordis service. That keeps this call independent of how (or whether) the
|
|
711
|
+
* corrective backend is mounted as `ctx.compaction`.
|
|
712
|
+
*
|
|
713
|
+
* @param ctx - context carrying the LLM service.
|
|
714
|
+
* @param input - replayed conversation prefix to condense.
|
|
715
|
+
* @param agent - supplies routed-model history and fallback model.
|
|
716
|
+
* @param signal - optional cancellation.
|
|
717
|
+
* @returns `{ summary, provider, model, ... }`.
|
|
718
|
+
*/
|
|
719
|
+
async function summarizeForDrop(ctx, input, agent, signal) {
|
|
720
|
+
// Resolved at call time rather than injected, because `compaction` lives
|
|
721
|
+
// inside the agent preset in a Web profile while this plugin is mounted on
|
|
722
|
+
// the host plane.
|
|
723
|
+
const llm = ctx.get("llm");
|
|
724
|
+
if (llm === undefined || llm === null) {
|
|
725
|
+
throw new Error(
|
|
726
|
+
"the llm service is unavailable, so no summary can be written; `drop` needs a booted agent with a model route"
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const mounted = ctx.get("compaction");
|
|
731
|
+
const receiver = {
|
|
732
|
+
ctx: { llm },
|
|
733
|
+
config: {
|
|
734
|
+
summarizationProvider: "",
|
|
735
|
+
summarizationModel: "",
|
|
736
|
+
maxTokens: 8192,
|
|
737
|
+
...(mounted?.config === undefined ? {} : mounted.config)
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
return CorrectiveCompactionEngine.prototype.summarize.call(receiver, input, agent, signal);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Register `/prune`.
|
|
745
|
+
*
|
|
746
|
+
* @param ctx - context carrying the command registry, meter and compaction seam.
|
|
747
|
+
*/
|
|
748
|
+
function apply(ctx) {
|
|
749
|
+
const handler = (invocation) => {
|
|
750
|
+
const raw = invocation.rawInput.trim();
|
|
751
|
+
|
|
752
|
+
if (raw.length === 0) {
|
|
753
|
+
return { kind: "success", text: renderSummary(inspect(ctx, invocation.agent)) };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// `list` opens the listing branch; anything else is a usage error. A
|
|
757
|
+
// separator is required after `list` so `listx` is rejected rather than
|
|
758
|
+
// silently treated as a count.
|
|
759
|
+
if (raw === "list") {
|
|
760
|
+
return {
|
|
761
|
+
kind: "success",
|
|
762
|
+
text: renderList(inspect(ctx, invocation.agent), { kind: "newest", count: DEFAULT_ROUNDS })
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (raw.startsWith("list") && /^list\s/.test(raw)) {
|
|
767
|
+
const selection = parseSelection(raw.slice(4));
|
|
768
|
+
if (selection.error !== undefined) {
|
|
769
|
+
return { kind: "error", text: `${selection.error}\n\n${USAGE}` };
|
|
770
|
+
}
|
|
771
|
+
return { kind: "success", text: renderList(inspect(ctx, invocation.agent), selection) };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (raw === "drop" || (raw.startsWith("drop") && /^drop\s/.test(raw))) {
|
|
775
|
+
return handleDrop(ctx, invocation, raw === "drop" ? "" : raw.slice(4).trim());
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// A bare USAGE dump leaves the user unsure whether their input was even
|
|
779
|
+
// seen. Name the offending token, then show the grammar.
|
|
780
|
+
const first = raw.split(/\s+/)[0];
|
|
781
|
+
return { kind: "error", text: `Unknown argument: ${first}\n\n${USAGE}` };
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
ctx.effect(function* () {
|
|
785
|
+
yield ctx.commands.register({
|
|
786
|
+
definitionId: CommandDefinitionId("dsh-context-pruner"),
|
|
787
|
+
name: "prune",
|
|
788
|
+
description: "Inspect the context, or compact chosen rounds into one checkpoint",
|
|
789
|
+
input: { hint: "list [n|all|a-b|seq a-b] | drop <a>-<b> [seq a-b] [--yes]" },
|
|
790
|
+
handler
|
|
791
|
+
});
|
|
792
|
+
}, "context-pruner lifecycle");
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export { apply, inject, name };
|