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/client.js
ADDED
|
@@ -0,0 +1,1230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-context-pruner browser plugin — step 1: the Context tab renders rounds.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is a hand-written bundle
|
|
5
|
+
*
|
|
6
|
+
* Shipped client plugins are produced by rolldown into a
|
|
7
|
+
* `window.__ModuleLoader__.load({ id, factory })` wrapper. That wrapper is a
|
|
8
|
+
* small, stable contract and `dsh-client-ui-brand-official` (2 KB) is a
|
|
9
|
+
* complete worked example of exactly this shape, so this plugin is authored
|
|
10
|
+
* directly in that format: no build step, and the source a maintainer reads is
|
|
11
|
+
* the artifact the browser runs. The body only registers the factory; every
|
|
12
|
+
* side effect lives inside it and runs at materialization.
|
|
13
|
+
*
|
|
14
|
+
* ## Verified integration points
|
|
15
|
+
*
|
|
16
|
+
* These were read from the shipped type declarations rather than guessed;
|
|
17
|
+
* three earlier guesses were wrong and are noted so they are not re-made:
|
|
18
|
+
*
|
|
19
|
+
* - Services come from `ctx.get(name)` / `ctx.<name>`, NOT `ctx.sessions.get(id)`.
|
|
20
|
+
* - Session data arrives as framework-injected props on a session-scoped
|
|
21
|
+
* slot body (`sessionId`, `useSessions`, `useTabInfo`, `actions`), and the
|
|
22
|
+
* event stream is `ctx.sessions.binding(id).eventSource`.
|
|
23
|
+
* - `ctx.slots.register(spec, Component)` is called directly; the generator
|
|
24
|
+
* form is for `ctx.effect`.
|
|
25
|
+
*
|
|
26
|
+
* ## Scope of this step
|
|
27
|
+
*
|
|
28
|
+
* Read-only: the tab lists rounds with their token weight. Selection and the
|
|
29
|
+
* Prune button arrive in step 2, after this renders correctly in a browser —
|
|
30
|
+
* client behavior cannot be proven by a Node test.
|
|
31
|
+
*/
|
|
32
|
+
window.__ModuleLoader__.load({
|
|
33
|
+
id: "dsh-context-pruner",
|
|
34
|
+
factory: (require) => {
|
|
35
|
+
var module = { exports: {} };
|
|
36
|
+
var exports = module.exports;
|
|
37
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
38
|
+
|
|
39
|
+
const React = require("react");
|
|
40
|
+
const jsxRuntime = require("react/jsx-runtime");
|
|
41
|
+
|
|
42
|
+
const h = jsxRuntime.jsx;
|
|
43
|
+
const jsxs = jsxRuntime.jsxs;
|
|
44
|
+
|
|
45
|
+
/** Tab type id; the body and title slots register under this same key. */
|
|
46
|
+
const TAB_ID = "dsh-context-pruner";
|
|
47
|
+
|
|
48
|
+
/** Tab kind: the discriminator `ctx.sidebarRight.openTab` names. */
|
|
49
|
+
const TAB_KIND = "context-pruner";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Cordis SERVICE names this plugin waits for before activating.
|
|
53
|
+
*
|
|
54
|
+
* These are service names, not package names. Declaring package names
|
|
55
|
+
* here (an earlier mistake) leaves the entry permanently `pending`,
|
|
56
|
+
* because Cordis waits for services under exactly these strings:
|
|
57
|
+
*
|
|
58
|
+
* dsh-context-pruner: pending (waiting for services:
|
|
59
|
+
* @deepseek-ai/dsh-client-ui-slots, ...)
|
|
60
|
+
*
|
|
61
|
+
* `sidebarRight` is the controller that renders the column, and every
|
|
62
|
+
* shipped tab provider injects it (`dsh-client-ui-sidebar-files` lists
|
|
63
|
+
* `slots, locale, sidebarRightTabs, sidebarRight, ...`). Omitting it
|
|
64
|
+
* registers the body successfully while the pane never resolves it —
|
|
65
|
+
* the tab chip appears and the panel stays blank, which is exactly the
|
|
66
|
+
* symptom this list was corrected for.
|
|
67
|
+
*/
|
|
68
|
+
const inject = ["slots", "sidebarRightTabs", "sidebarRight", "sessions", "conversation"];
|
|
69
|
+
|
|
70
|
+
/** Opening tag of a framed compaction checkpoint body. */
|
|
71
|
+
const SUMMARY_OPEN_TAG = "<compacted-summary>";
|
|
72
|
+
|
|
73
|
+
/** Closing tag of a framed compaction checkpoint body. */
|
|
74
|
+
const SUMMARY_CLOSE_TAG = "</compacted-summary>";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `source.kind` values that never represent a human turn.
|
|
78
|
+
*
|
|
79
|
+
* Verified in the DSH checkout: every harness injection is built with
|
|
80
|
+
* `createUserMessage({ ..., source: { kind, form } })` and delivered
|
|
81
|
+
* through the same `user/message` surface type as real conversation.
|
|
82
|
+
*/
|
|
83
|
+
const MACHINE_SOURCE_KINDS = new Set([
|
|
84
|
+
"tool-jobs",
|
|
85
|
+
"runtime-context",
|
|
86
|
+
"agent-instructions",
|
|
87
|
+
"agent-message",
|
|
88
|
+
"session-reference",
|
|
89
|
+
"skill-invocation",
|
|
90
|
+
"model-selection"
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
/** `source.form` values that mark an injected rather than typed message. */
|
|
94
|
+
const MACHINE_SOURCE_FORMS = new Set(["notice", "snapshot", "instructions"]);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Command names dispatched as commands rather than chat.
|
|
98
|
+
*
|
|
99
|
+
* Mirrors `KNOWN_COMMANDS` in `lib/rounds.js`, so the two sides classify
|
|
100
|
+
* a slash-leading line identically. Used only when no `source` marker is
|
|
101
|
+
* available.
|
|
102
|
+
*/
|
|
103
|
+
const KNOWN_COMMANDS = new Set([
|
|
104
|
+
"prune",
|
|
105
|
+
"compact",
|
|
106
|
+
"clear",
|
|
107
|
+
"help",
|
|
108
|
+
"init",
|
|
109
|
+
"model",
|
|
110
|
+
"new",
|
|
111
|
+
"resume",
|
|
112
|
+
"review",
|
|
113
|
+
"status",
|
|
114
|
+
"quit",
|
|
115
|
+
"exit"
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Opening sentences of the fixed checkpoint boilerplate.
|
|
120
|
+
*
|
|
121
|
+
* Both framings ship: "This is an automatically generated checkpoint…"
|
|
122
|
+
* from the stock backend and "This is a correction checkpoint…" from this
|
|
123
|
+
* plugin's. The two real openings are named rather than matching a bare
|
|
124
|
+
* "This is ", which would strip ordinary prose starting that way.
|
|
125
|
+
*
|
|
126
|
+
* Kept in sync with `CHECKPOINT_OPENINGS` in `lib/inspect.js`; the parity
|
|
127
|
+
* is asserted by `test/rounds.mjs`, which executes these functions out of
|
|
128
|
+
* the shipped bundle against the host's output.
|
|
129
|
+
*/
|
|
130
|
+
const CHECKPOINT_OPENINGS = [
|
|
131
|
+
"This is an automatically generated checkpoint",
|
|
132
|
+
"This is a correction checkpoint"
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Remove checkpoint boilerplate from the head of a block.
|
|
137
|
+
*
|
|
138
|
+
* The boilerplate is fixed English text. It is usually its own block, but
|
|
139
|
+
* a producer may emit it in the SAME block as the framed body — with or
|
|
140
|
+
* without a paragraph break. Dropping the whole block on a prefix match
|
|
141
|
+
* would then discard the summary along with it, so only the boilerplate's
|
|
142
|
+
* own span is removed and whatever follows is kept.
|
|
143
|
+
*
|
|
144
|
+
* @param text - one text block's content.
|
|
145
|
+
* @returns the text after the boilerplate, or `text` when there is none.
|
|
146
|
+
*/
|
|
147
|
+
function stripCheckpointPreamble(text) {
|
|
148
|
+
for (const opening of CHECKPOINT_OPENINGS) {
|
|
149
|
+
if (!text.startsWith(opening)) continue;
|
|
150
|
+
const end = text.indexOf("\n\n", opening.length);
|
|
151
|
+
if (end !== -1) return text.slice(end + 2);
|
|
152
|
+
const tag = text.indexOf(SUMMARY_OPEN_TAG, opening.length);
|
|
153
|
+
return tag === -1 ? "" : text.slice(tag);
|
|
154
|
+
}
|
|
155
|
+
return text;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Whether a block is nothing but checkpoint boilerplate.
|
|
160
|
+
*
|
|
161
|
+
* @param text - one text block's content.
|
|
162
|
+
* @returns true when the block carries no content beyond the preamble.
|
|
163
|
+
*/
|
|
164
|
+
function isCheckpointPreamble(text) {
|
|
165
|
+
return stripCheckpointPreamble(text).trim().length === 0 && text.trim().length > 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Whether a `source` marker identifies a harness injection.
|
|
170
|
+
*
|
|
171
|
+
* Mirrors `isMachineSource` in `lib/rounds.js`. The panel cannot import
|
|
172
|
+
* the host module, so the rule is restated here — and
|
|
173
|
+
* `test/rounds.mjs` extracts this function from the shipped bundle and
|
|
174
|
+
* runs it against the same table the host module is tested with, so the
|
|
175
|
+
* two copies cannot drift apart silently.
|
|
176
|
+
*
|
|
177
|
+
* The structured marker is preferred over text matching: it survives
|
|
178
|
+
* reworded notices, which the prefix list would not.
|
|
179
|
+
*
|
|
180
|
+
* @param source - a message's `source` field, when present.
|
|
181
|
+
* @returns true when the marker names a machine delivery.
|
|
182
|
+
*/
|
|
183
|
+
function isMachineSource(source) {
|
|
184
|
+
if (source === null || source === undefined || typeof source !== "object") return false;
|
|
185
|
+
if (
|
|
186
|
+
typeof source.kind === "string" &&
|
|
187
|
+
MACHINE_SOURCE_KINDS.has(source.kind)
|
|
188
|
+
) {
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
if (typeof source.form === "string" && MACHINE_SOURCE_FORMS.has(source.form)) return true;
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Group a session's model-visible surface into conversation rounds.
|
|
197
|
+
*
|
|
198
|
+
* A round opens at each user message and absorbs everything until the
|
|
199
|
+
* next one — the unit a reader actually reasons about ("that round
|
|
200
|
+
* where we tried the wrong fix").
|
|
201
|
+
*
|
|
202
|
+
* The client has no session surface object; it has the event window.
|
|
203
|
+
* Surface membership is therefore derived from each event's own
|
|
204
|
+
* `surfaceOp` marker: `append` enters the tail, and a `replace` shadow
|
|
205
|
+
* retires the range it names. Compaction events carry no marker and are
|
|
206
|
+
* log-only, so they are skipped naturally.
|
|
207
|
+
*
|
|
208
|
+
* @param entries - event-window entries from the binding's eventSource.
|
|
209
|
+
* @param hasMore - whether older events remain outside the window.
|
|
210
|
+
* @returns `{ rounds, hasMore }` in surface order.
|
|
211
|
+
*/
|
|
212
|
+
function readRounds(entries, hasMore = false) {
|
|
213
|
+
const shadowed = new Set();
|
|
214
|
+
const nodes = [];
|
|
215
|
+
|
|
216
|
+
for (const entry of entries ?? []) {
|
|
217
|
+
if (entry?.type !== "event") continue;
|
|
218
|
+
const event = entry.event;
|
|
219
|
+
if (event === undefined || event === null) continue;
|
|
220
|
+
|
|
221
|
+
const op = event.surfaceOp;
|
|
222
|
+
if (op === undefined || op === null) continue;
|
|
223
|
+
|
|
224
|
+
if (typeof op === "object" && op.op === "replace") {
|
|
225
|
+
// A replacement retires its named range and takes that
|
|
226
|
+
// position; record the shadowed seqs so they are skipped.
|
|
227
|
+
//
|
|
228
|
+
// Fall back to the replace op's own endpoints when the entry
|
|
229
|
+
// omits `sourceEventSeqs`: without that, a replacement would
|
|
230
|
+
// shadow nothing and already-retired rounds would reappear.
|
|
231
|
+
const cited =
|
|
232
|
+
Array.isArray(event.sourceEventSeqs) && event.sourceEventSeqs.length > 0
|
|
233
|
+
? event.sourceEventSeqs
|
|
234
|
+
: [op.startSeq, op.endSeq];
|
|
235
|
+
for (const seq of cited) shadowed.add(seq);
|
|
236
|
+
nodes.push(event);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
nodes.push(event);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const live = nodes.filter((event) => !shadowed.has(event.seq));
|
|
243
|
+
|
|
244
|
+
const rounds = [];
|
|
245
|
+
let current = null;
|
|
246
|
+
// Nodes seen before the first human turn. This MUST mirror
|
|
247
|
+
// `groupRounds` in `lib/inspect.js`, or the two sides disagree on
|
|
248
|
+
// every real session: the shipped `SystemPromptProjection` commits
|
|
249
|
+
// the rendered system prompt as surface node 0, so without the
|
|
250
|
+
// hold-over the panel opens a bogus round 0 on the system prompt and
|
|
251
|
+
// every later index is off by one. That is precisely the wrong-span
|
|
252
|
+
// symptom this panel was fixed for.
|
|
253
|
+
let leading = [];
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Start a round at `descriptor`, absorbing any held-over nodes.
|
|
257
|
+
*
|
|
258
|
+
* @param descriptor - the node opening the round.
|
|
259
|
+
* @param held - nodes to prepend, in order.
|
|
260
|
+
*/
|
|
261
|
+
const openRound = (descriptor, held) => {
|
|
262
|
+
const first = held.length > 0 ? held[0] : descriptor;
|
|
263
|
+
const absorbed = held.reduce((sum, node) => sum + node.tokens, 0);
|
|
264
|
+
const notices = [...held, descriptor].filter(
|
|
265
|
+
(node) => node.role === "user" && !node.opensRound
|
|
266
|
+
).length;
|
|
267
|
+
current = {
|
|
268
|
+
index: rounds.length,
|
|
269
|
+
tokens: descriptor.tokens + absorbed,
|
|
270
|
+
position: first.position,
|
|
271
|
+
// The span the UI needs: the first and last surface node seq
|
|
272
|
+
// of this round. `startSeq` is what a drop range resolves
|
|
273
|
+
// against, so both ends are tracked.
|
|
274
|
+
startSeq: first.seq,
|
|
275
|
+
endSeq: descriptor.seq,
|
|
276
|
+
// The heading shown for this round. A round may open with
|
|
277
|
+
// injected context, so the human message supplies the title
|
|
278
|
+
// rather than the first node.
|
|
279
|
+
title: descriptor.opensRound ? descriptor.preview : null,
|
|
280
|
+
preview: descriptor.preview,
|
|
281
|
+
injected: notices
|
|
282
|
+
};
|
|
283
|
+
rounds.push(current);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
for (let position = 0; position < live.length; position += 1) {
|
|
287
|
+
const event = live[position];
|
|
288
|
+
const descriptor = describeEvent(event);
|
|
289
|
+
descriptor.position = position;
|
|
290
|
+
|
|
291
|
+
if (current === null) {
|
|
292
|
+
if (descriptor.opensRound) {
|
|
293
|
+
openRound(descriptor, leading);
|
|
294
|
+
leading = [];
|
|
295
|
+
} else {
|
|
296
|
+
// Hold it: whether it deserves its own round is not known
|
|
297
|
+
// until a human turn decides.
|
|
298
|
+
leading.push(descriptor);
|
|
299
|
+
}
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Only a REAL human message opens a round. The harness injects
|
|
304
|
+
// background-job reports, runtime context, system reminders and
|
|
305
|
+
// model-change notices as `user/message` too; treating those as
|
|
306
|
+
// round openers both showed meaningless rows and fragmented the
|
|
307
|
+
// conversation (16 human turns arrived as 30 "rounds").
|
|
308
|
+
if (descriptor.opensRound) {
|
|
309
|
+
openRound(descriptor, []);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
current.tokens += descriptor.tokens;
|
|
314
|
+
current.endSeq = event.seq;
|
|
315
|
+
if (descriptor.role === "user" && !descriptor.opensRound) current.injected += 1;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// A session with no human turn at all must still render one round.
|
|
319
|
+
if (current === null && leading.length > 0) {
|
|
320
|
+
openRound(leading[leading.length - 1], leading.slice(0, -1));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return { rounds, hasMore };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Whether a user message came from the human rather than the harness.
|
|
328
|
+
*
|
|
329
|
+
* The harness delivers several machine notices through the same
|
|
330
|
+
* `user/message` surface type. They are not conversation turns, and
|
|
331
|
+
* treating them as such is what made the panel show rows reading
|
|
332
|
+
* `background job pwsh-20 (pwsh: Start-Sleep ...)`.
|
|
333
|
+
*
|
|
334
|
+
* The structured `source` marker is checked first — it is what the
|
|
335
|
+
* harness itself attaches, so it survives a reworded notice. The text
|
|
336
|
+
* prefixes remain as a fallback for entries whose `source` is absent
|
|
337
|
+
* from the loaded event window.
|
|
338
|
+
*
|
|
339
|
+
* @param text - the message's flattened text.
|
|
340
|
+
* @param source - the message's optional structured `source` marker.
|
|
341
|
+
* @returns true when this is a genuine human turn.
|
|
342
|
+
*/
|
|
343
|
+
function isHumanTurn(text, source) {
|
|
344
|
+
if (isMachineSource(source)) return false;
|
|
345
|
+
const head = String(text ?? "").trimStart().slice(0, 120);
|
|
346
|
+
// An empty message is not a turn.
|
|
347
|
+
if (head.length === 0) return false;
|
|
348
|
+
if (/^background job /i.test(head)) return false;
|
|
349
|
+
if (/^<system-reminder>/.test(head)) return false;
|
|
350
|
+
if (/^\[model changed:/.test(head)) return false;
|
|
351
|
+
if (/^Current runtime context\./.test(head)) return false;
|
|
352
|
+
if (/^This session is being continued from/.test(head)) return false;
|
|
353
|
+
// A slash COMMAND echo is an action, not a turn. Deliberately narrow:
|
|
354
|
+
// matching a bare leading "/" swallowed real prompts that begin with
|
|
355
|
+
// a path ("/tmp cleanup needed"), silently folding them into the
|
|
356
|
+
// previous round and shifting every later index.
|
|
357
|
+
//
|
|
358
|
+
// Mirrors `isHumanTurn` in `lib/rounds.js`; the parity is asserted by
|
|
359
|
+
// `test/rounds.mjs`, which executes this function out of the bundle.
|
|
360
|
+
const commandish = /^\/([a-z][\w-]*)(?::([\w-]+))?(\s|$)/i.exec(head);
|
|
361
|
+
if (commandish !== null) {
|
|
362
|
+
const rest = head.slice(commandish[0].length).trim();
|
|
363
|
+
if (rest.length === 0) return false;
|
|
364
|
+
if (KNOWN_COMMANDS.has(commandish[1].toLowerCase())) return false;
|
|
365
|
+
}
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Extract the message a surface event carries.
|
|
371
|
+
*
|
|
372
|
+
* Three of the five surface event types nest the message under
|
|
373
|
+
* `data.message` — `assistant/message`, `tool/result`, and
|
|
374
|
+
* `system/message` — while `user/message` carries it directly in
|
|
375
|
+
* `data`. Treating `tool/result` as a flat payload is what made every
|
|
376
|
+
* tool row preview as raw scaffolding text instead of its output.
|
|
377
|
+
*
|
|
378
|
+
* @param event - a surface event.
|
|
379
|
+
* @returns the message payload, or undefined.
|
|
380
|
+
*/
|
|
381
|
+
function messageOf(event) {
|
|
382
|
+
if (event?.type === "user/message") return event.data;
|
|
383
|
+
return event?.data?.message;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Derive a role, preview and token estimate for one surface event.
|
|
388
|
+
*
|
|
389
|
+
* @param event - a surface event carrying `surfaceOp`.
|
|
390
|
+
* @returns `{ role, preview, tokens, opensRound }`.
|
|
391
|
+
*/
|
|
392
|
+
function describeEvent(event) {
|
|
393
|
+
const role =
|
|
394
|
+
event.type === "user/message"
|
|
395
|
+
? "user"
|
|
396
|
+
: event.type === "assistant/message"
|
|
397
|
+
? "assistant"
|
|
398
|
+
: event.type === "tool/result"
|
|
399
|
+
? "tool"
|
|
400
|
+
: "system";
|
|
401
|
+
|
|
402
|
+
const message = messageOf(event);
|
|
403
|
+
|
|
404
|
+
// The content array is stripped as a WHOLE, because checkpoint
|
|
405
|
+
// framing spans blocks: the preamble is one block, the opening tag
|
|
406
|
+
// leads the next, and the closing tag is a third. A per-block API is
|
|
407
|
+
// what let the host and this panel drift into two different rules
|
|
408
|
+
// that each handled only its own layout.
|
|
409
|
+
const stripped = stripCheckpointFraming(message?.content ?? []);
|
|
410
|
+
|
|
411
|
+
let text = flattened(stripped);
|
|
412
|
+
|
|
413
|
+
// A tool result with no text (or an assistant turn that only called
|
|
414
|
+
// tools) still deserves a label: an empty preview tells the reader
|
|
415
|
+
// nothing about why the row exists.
|
|
416
|
+
if (text.length === 0) {
|
|
417
|
+
if (role === "tool") text = message?.isError ? "(tool error)" : "(tool output)";
|
|
418
|
+
else if (role === "assistant") text = "(assistant called a tool)";
|
|
419
|
+
else text = `(${event.type})`;
|
|
420
|
+
}
|
|
421
|
+
if (text.length > 140) text = `${text.slice(0, 140)}…`;
|
|
422
|
+
|
|
423
|
+
// The message's own text, untruncated, so classification sees the
|
|
424
|
+
// real prefix rather than the 140-character preview.
|
|
425
|
+
const humanText = role === "user" ? flattened(stripped) : "";
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
role,
|
|
429
|
+
// The event's own seq, so a round can name its span without the
|
|
430
|
+
// caller having to re-associate descriptors with events.
|
|
431
|
+
seq: event.seq,
|
|
432
|
+
preview: text,
|
|
433
|
+
tokens: Math.ceil(totalCharacters(stripped) / 4),
|
|
434
|
+
// Only a genuine human user message starts a conversation round.
|
|
435
|
+
// The structured `source` marker is preferred over the text
|
|
436
|
+
// prefixes, so a reworded notice is still classified correctly.
|
|
437
|
+
opensRound: role === "user" && isHumanTurn(humanText, message?.source)
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Collapse stripped blocks into one whitespace-normalised string.
|
|
443
|
+
*
|
|
444
|
+
* @param blocks - blocks from {@link stripCheckpointFraming}.
|
|
445
|
+
* @returns the flattened text.
|
|
446
|
+
*/
|
|
447
|
+
function flattened(blocks) {
|
|
448
|
+
return blocks
|
|
449
|
+
.map((block) => block.text)
|
|
450
|
+
.join(" ")
|
|
451
|
+
.replace(/\s+/g, " ")
|
|
452
|
+
.trim();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Sum the character weight of stripped blocks, for token estimation.
|
|
457
|
+
*
|
|
458
|
+
* @param blocks - blocks from {@link stripCheckpointFraming}.
|
|
459
|
+
* @returns the total character count.
|
|
460
|
+
*/
|
|
461
|
+
function totalCharacters(blocks) {
|
|
462
|
+
return blocks.reduce((sum, block) => sum + block.characters, 0);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Summarize one tool call as `tool:name (first arg)`.
|
|
467
|
+
*
|
|
468
|
+
* @param block - a `tool-call` content block.
|
|
469
|
+
* @returns a short label.
|
|
470
|
+
*/
|
|
471
|
+
function describeToolCall(block) {
|
|
472
|
+
const name = block.name ?? "?";
|
|
473
|
+
let hint = "";
|
|
474
|
+
try {
|
|
475
|
+
const args = typeof block.arguments === "string" ? JSON.parse(block.arguments) : block.arguments;
|
|
476
|
+
if (args && typeof args === "object") {
|
|
477
|
+
// The first string-valued field usually names the target:
|
|
478
|
+
// a path, a pattern, a command.
|
|
479
|
+
for (const key of ["command", "file_path", "path", "pattern", "query", "url"]) {
|
|
480
|
+
if (typeof args[key] === "string" && args[key].length > 0) {
|
|
481
|
+
hint = args[key].replace(/\s+/g, " ").slice(0, 70);
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
} catch {
|
|
487
|
+
/* arguments may be a partial JSON string while streaming */
|
|
488
|
+
}
|
|
489
|
+
return hint.length > 0 ? `[${name}: ${hint}]` : `[${name}]`;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Strip compaction framing from a whole content array.
|
|
494
|
+
*
|
|
495
|
+
* A checkpoint message is three text blocks:
|
|
496
|
+
*
|
|
497
|
+
* [0] "<boilerplate preamble>"
|
|
498
|
+
* [1] "<compacted-summary>\n\n...the actual summary..."
|
|
499
|
+
* [2] "</compacted-summary>"
|
|
500
|
+
*
|
|
501
|
+
* The opening tag leads block 1, NOT the tail of block 0 — which is why
|
|
502
|
+
* scanning for the tag inside the boilerplate block found nothing and
|
|
503
|
+
* every checkpoint previewed as the same fixed English sentence.
|
|
504
|
+
*
|
|
505
|
+
* Taking the ARRAY rather than one block is what makes the rule exact:
|
|
506
|
+
* the preamble is dropped by its own opening words, a block carrying the
|
|
507
|
+
* opening tag keeps only the body, the closing tag is removed inline,
|
|
508
|
+
* and a block that is left empty is discarded entirely. The host's
|
|
509
|
+
* `previewMessage` in `lib/inspect.js` applies the same union of rules,
|
|
510
|
+
* and `test/rounds.mjs` pins the two implementations together.
|
|
511
|
+
*
|
|
512
|
+
* @param content - a message's content blocks.
|
|
513
|
+
* @returns `{ type, text, characters }` entries, text-bearing only.
|
|
514
|
+
*/
|
|
515
|
+
function stripCheckpointFraming(content) {
|
|
516
|
+
const out = [];
|
|
517
|
+
|
|
518
|
+
for (const block of content ?? []) {
|
|
519
|
+
if (block === null || block === undefined) continue;
|
|
520
|
+
|
|
521
|
+
if (block.type === "tool-call") {
|
|
522
|
+
// Name the tool and its first meaningful argument, so a call
|
|
523
|
+
// row reads as "what was asked" rather than "[tool:?]".
|
|
524
|
+
out.push({
|
|
525
|
+
type: "tool-call",
|
|
526
|
+
label: describeToolCall(block),
|
|
527
|
+
text: describeToolCall(block),
|
|
528
|
+
characters: JSON.stringify(block.arguments ?? "").length
|
|
529
|
+
});
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (block.type === "image") {
|
|
534
|
+
out.push({ type: "image", text: "[image]", characters: 1024 });
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (block.type !== "text" || typeof block.text !== "string") continue;
|
|
539
|
+
|
|
540
|
+
// The fixed preamble carries no summary content, but the block
|
|
541
|
+
// may also carry the framed body, so only the preamble's own span
|
|
542
|
+
// is removed.
|
|
543
|
+
const withoutPreamble = stripCheckpointPreamble(block.text);
|
|
544
|
+
|
|
545
|
+
const open = withoutPreamble.indexOf(SUMMARY_OPEN_TAG);
|
|
546
|
+
let body =
|
|
547
|
+
open === -1
|
|
548
|
+
? withoutPreamble
|
|
549
|
+
: withoutPreamble.slice(open + SUMMARY_OPEN_TAG.length);
|
|
550
|
+
body = body.split(SUMMARY_CLOSE_TAG).join("");
|
|
551
|
+
if (body.trim().length === 0) continue;
|
|
552
|
+
|
|
553
|
+
out.push({ type: "text", text: body, characters: block.text.length });
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return out;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Render a compact token figure.
|
|
561
|
+
*
|
|
562
|
+
* @param tokens - estimated tokens.
|
|
563
|
+
* @returns a short label such as `3.4k`.
|
|
564
|
+
*/
|
|
565
|
+
function formatTokens(tokens) {
|
|
566
|
+
if (tokens >= 1000) {
|
|
567
|
+
const thousands = tokens / 1000;
|
|
568
|
+
return `${thousands >= 10 ? Math.round(thousands) : thousands.toFixed(1)}k`;
|
|
569
|
+
}
|
|
570
|
+
return String(tokens);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Read the live event window for a session.
|
|
575
|
+
*
|
|
576
|
+
* The binding is created by retaining the session; `eventSource` is an
|
|
577
|
+
* observable snapshot of the contiguous window.
|
|
578
|
+
*
|
|
579
|
+
* `revision` is the field that matters for freshness: it advances on
|
|
580
|
+
* every accepted window mutation, including a COMPACTION. `openState` in
|
|
581
|
+
* the session snapshot does not, which is why a panel keyed only on it
|
|
582
|
+
* kept showing rounds that had already been compacted away.
|
|
583
|
+
*
|
|
584
|
+
* @param ctx - client root context.
|
|
585
|
+
* @param sessionId - the session to inspect.
|
|
586
|
+
* @returns `{ entries, hasMore, revision }`, or an empty window when unavailable.
|
|
587
|
+
*/
|
|
588
|
+
function readWindow(ctx, sessionId) {
|
|
589
|
+
const empty = { entries: [], hasMore: false, revision: -1 };
|
|
590
|
+
if (sessionId === undefined || sessionId === null) return empty;
|
|
591
|
+
const sessions = ctx.sessions ?? ctx.get("sessions");
|
|
592
|
+
if (sessions === undefined || sessions === null) return empty;
|
|
593
|
+
if (typeof sessions.binding !== "function") return empty;
|
|
594
|
+
const binding = sessions.binding(sessionId);
|
|
595
|
+
const source = binding?.eventSource;
|
|
596
|
+
if (source === undefined || source === null) return empty;
|
|
597
|
+
const window = typeof source.getSnapshot === "function" ? source.getSnapshot() : undefined;
|
|
598
|
+
return {
|
|
599
|
+
entries: window?.entries ?? [],
|
|
600
|
+
hasMore: window?.hasMore === true,
|
|
601
|
+
revision: typeof window?.revision === "number" ? window.revision : -1
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* The session's event source, when it is retained and observable.
|
|
607
|
+
*
|
|
608
|
+
* @param ctx - client root context.
|
|
609
|
+
* @param sessionId - the session to inspect.
|
|
610
|
+
* @returns the observable snapshot source, or undefined.
|
|
611
|
+
*/
|
|
612
|
+
function eventSource(ctx, sessionId) {
|
|
613
|
+
if (sessionId === undefined || sessionId === null) return undefined;
|
|
614
|
+
const sessions = ctx.sessions ?? ctx.get("sessions");
|
|
615
|
+
if (sessions === undefined || sessions === null) return undefined;
|
|
616
|
+
if (typeof sessions.binding !== "function") return undefined;
|
|
617
|
+
return sessions.binding(sessionId)?.eventSource;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* The session face that owns paging, when the session is retained.
|
|
622
|
+
*
|
|
623
|
+
* @param ctx - client root context.
|
|
624
|
+
* @param sessionId - the session to inspect.
|
|
625
|
+
* @returns the session face, or undefined.
|
|
626
|
+
*/
|
|
627
|
+
function sessionFace(ctx, sessionId) {
|
|
628
|
+
const sessions = ctx.sessions ?? ctx.get("sessions");
|
|
629
|
+
if (sessions === undefined || sessions === null) return undefined;
|
|
630
|
+
if (typeof sessions.binding !== "function") return undefined;
|
|
631
|
+
return sessions.binding(sessionId)?.session;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Page history backwards until the whole log is loaded.
|
|
636
|
+
*
|
|
637
|
+
* `eventSource` is a *contiguous window*, not the complete log: a long
|
|
638
|
+
* session opens with only its tail, and `hasMore` reports that older
|
|
639
|
+
* events exist. Listing just that window is what made a 28-round
|
|
640
|
+
* session show only 4 rounds. `loadOlder()` extends the window by at
|
|
641
|
+
* least 50 messages per call, so this loops until `hasMore` clears.
|
|
642
|
+
*
|
|
643
|
+
* @param ctx - client root context.
|
|
644
|
+
* @param sessionId - the session to load.
|
|
645
|
+
* @param limit - maximum pages to fetch, guarding a runaway loop.
|
|
646
|
+
* @returns a promise resolving when paging settles.
|
|
647
|
+
*/
|
|
648
|
+
async function loadFullHistory(ctx, sessionId, limit = 40) {
|
|
649
|
+
const session = sessionFace(ctx, sessionId);
|
|
650
|
+
if (session === undefined || typeof session.loadOlder !== "function") return;
|
|
651
|
+
for (let page = 0; page < limit; page += 1) {
|
|
652
|
+
const { hasMore } = readWindow(ctx, sessionId);
|
|
653
|
+
if (!hasMore) return;
|
|
654
|
+
try {
|
|
655
|
+
await session.loadOlder();
|
|
656
|
+
} catch {
|
|
657
|
+
// Paging can fail while a session is still opening; the
|
|
658
|
+
// window already loaded stays usable.
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* The Context tab body.
|
|
666
|
+
*
|
|
667
|
+
* The slot framework supplies the session standard kit (`sessionId`,
|
|
668
|
+
* `useSession`) and whatever the registration declared under `inject` —
|
|
669
|
+
* here the plugin's own `readRounds` face, so the component stays free
|
|
670
|
+
* of service lookups and testable on its own.
|
|
671
|
+
*
|
|
672
|
+
* @param props - slot props.
|
|
673
|
+
* @returns the rendered panel.
|
|
674
|
+
*/
|
|
675
|
+
function ContextPrunerBody(props) {
|
|
676
|
+
const sessionId = props.sessionId;
|
|
677
|
+
const read = props.readRounds;
|
|
678
|
+
const prune = props.pruneRounds;
|
|
679
|
+
// The composer's own draft/submit actions: the only path that turns
|
|
680
|
+
// a slash line into a command rather than a chat message.
|
|
681
|
+
const inputActions = props.inputActions;
|
|
682
|
+
|
|
683
|
+
// Hooks run unconditionally and in a stable order: the session
|
|
684
|
+
// snapshot is also what re-renders the list as history changes.
|
|
685
|
+
const sessionSnapshot =
|
|
686
|
+
typeof props.useSession === "function" ? props.useSession((s) => s) : undefined;
|
|
687
|
+
|
|
688
|
+
const [selected, setSelected] = React.useState(() => new Set());
|
|
689
|
+
const [busy, setBusy] = React.useState(false);
|
|
690
|
+
const [outcome, setOutcome] = React.useState(null);
|
|
691
|
+
// Bumped when a history page lands, so the listing re-reads a window
|
|
692
|
+
// that has grown.
|
|
693
|
+
const [pages, setPages] = React.useState(0);
|
|
694
|
+
const [loading, setLoading] = React.useState(false);
|
|
695
|
+
// Bumped by the event source on every accepted window mutation, so
|
|
696
|
+
// the list follows compactions and new turns. `openState` alone does
|
|
697
|
+
// NOT change when history is rewritten.
|
|
698
|
+
const [revision, setRevision] = React.useState(0);
|
|
699
|
+
|
|
700
|
+
// `openState` is the observable signal that history arrived or a turn
|
|
701
|
+
// settled, so the list refreshes with it.
|
|
702
|
+
const rowsKey = sessionSnapshot ? String(sessionSnapshot.openState) : "none";
|
|
703
|
+
|
|
704
|
+
// Subscribe to the event window itself. A compaction appends
|
|
705
|
+
// surface-replacing events without touching `openState`, so a panel
|
|
706
|
+
// keyed only on that kept listing rounds that no longer exist — the
|
|
707
|
+
// "compacted but still shown" report.
|
|
708
|
+
React.useEffect(() => {
|
|
709
|
+
if (typeof props.subscribeEvents !== "function") return undefined;
|
|
710
|
+
if (sessionId === undefined || sessionId === null) return undefined;
|
|
711
|
+
return props.subscribeEvents(sessionId, () => setRevision((value) => value + 1));
|
|
712
|
+
}, [props.subscribeEvents, sessionId]);
|
|
713
|
+
|
|
714
|
+
// A long session opens with only the TAIL of its log: `eventSource`
|
|
715
|
+
// is a contiguous window and `hasMore` reports older events. Without
|
|
716
|
+
// paging, a 28-round session lists 4 rounds — the window, not the
|
|
717
|
+
// whole conversation. This effect pulls every page in.
|
|
718
|
+
React.useEffect(() => {
|
|
719
|
+
if (typeof props.loadFullHistory !== "function") return undefined;
|
|
720
|
+
if (sessionId === undefined || sessionId === null) return undefined;
|
|
721
|
+
let cancelled = false;
|
|
722
|
+
setLoading(true);
|
|
723
|
+
props
|
|
724
|
+
.loadFullHistory(sessionId)
|
|
725
|
+
.then(() => {
|
|
726
|
+
if (!cancelled) setPages((value) => value + 1);
|
|
727
|
+
})
|
|
728
|
+
.catch(() => {})
|
|
729
|
+
.then(() => {
|
|
730
|
+
if (!cancelled) setLoading(false);
|
|
731
|
+
});
|
|
732
|
+
return () => {
|
|
733
|
+
cancelled = true;
|
|
734
|
+
};
|
|
735
|
+
}, [props.loadFullHistory, sessionId]);
|
|
736
|
+
|
|
737
|
+
const window = React.useMemo(
|
|
738
|
+
() => (typeof read === "function" ? read(sessionId) : { rounds: [], hasMore: false }),
|
|
739
|
+
[read, sessionId, rowsKey, pages, revision]
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
const rounds = window.rounds;
|
|
743
|
+
const hasMore = window.hasMore === true;
|
|
744
|
+
|
|
745
|
+
const total = rounds.reduce((sum, round) => sum + round.tokens, 0);
|
|
746
|
+
|
|
747
|
+
// Selection is keyed by the round's START SEQ, never by its index.
|
|
748
|
+
// An index is a position in the currently loaded list, and that list
|
|
749
|
+
// re-numbers whenever older pages arrive or a compaction lands: a
|
|
750
|
+
// selection made against the previous list would then name a
|
|
751
|
+
// different round, and the panel would seed a command for the wrong
|
|
752
|
+
// span (observed: row "#15 seq 9426" produced `drop seq 8055-8310`).
|
|
753
|
+
// Seqs are durable identities, so a selection survives re-numbering.
|
|
754
|
+
const selectedRounds = rounds.filter((round) => selected.has(round.startSeq));
|
|
755
|
+
const selectedTokens = selectedRounds.reduce((sum, round) => sum + round.tokens, 0);
|
|
756
|
+
|
|
757
|
+
const toggle = (startSeq) => {
|
|
758
|
+
setOutcome(null);
|
|
759
|
+
setSelected((previous) => {
|
|
760
|
+
const next = new Set(previous);
|
|
761
|
+
if (next.has(startSeq)) next.delete(startSeq);
|
|
762
|
+
else next.add(startSeq);
|
|
763
|
+
return next;
|
|
764
|
+
});
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
const clear = () => {
|
|
768
|
+
setSelected(new Set());
|
|
769
|
+
setOutcome(null);
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
const runPrune = async () => {
|
|
773
|
+
if (selectedRounds.length === 0 || busy) return;
|
|
774
|
+
setBusy(true);
|
|
775
|
+
setOutcome(null);
|
|
776
|
+
try {
|
|
777
|
+
const result = await prune(sessionId, selectedRounds, inputActions);
|
|
778
|
+
if (result?.kind === "error") {
|
|
779
|
+
setOutcome({ kind: "error", text: result.text });
|
|
780
|
+
} else {
|
|
781
|
+
setSelected(new Set());
|
|
782
|
+
setOutcome({ kind: "ok", text: result?.text ?? "Pruned." });
|
|
783
|
+
}
|
|
784
|
+
} catch (error) {
|
|
785
|
+
setOutcome({ kind: "error", text: String(error?.message ?? error) });
|
|
786
|
+
}
|
|
787
|
+
setBusy(false);
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
if (sessionId === undefined || sessionId === null) {
|
|
791
|
+
return h(Notice, { children: "Select a session to inspect its context." });
|
|
792
|
+
}
|
|
793
|
+
if (rounds.length === 0) {
|
|
794
|
+
// Distinguish "still loading" from "genuinely empty": the same
|
|
795
|
+
// branch covers a window that has not arrived yet, and telling
|
|
796
|
+
// the user a session has no history while it is still paging in
|
|
797
|
+
// is a wrong diagnosis.
|
|
798
|
+
return h(Notice, {
|
|
799
|
+
children:
|
|
800
|
+
hasMore || loading
|
|
801
|
+
? "Still loading this session's history…"
|
|
802
|
+
: "This session has no conversation history yet."
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return jsxs("div", {
|
|
807
|
+
style: { display: "flex", flexDirection: "column", height: "100%", minHeight: 0, fontSize: "13px" },
|
|
808
|
+
key: "pruner-root",
|
|
809
|
+
children: [
|
|
810
|
+
// Header: totals plus the selection summary.
|
|
811
|
+
jsxs("div", {
|
|
812
|
+
style: {
|
|
813
|
+
padding: "10px 12px",
|
|
814
|
+
borderBottom: "1px solid rgba(128,128,128,0.25)",
|
|
815
|
+
display: "flex",
|
|
816
|
+
flexDirection: "column",
|
|
817
|
+
gap: "6px",
|
|
818
|
+
flex: "none"
|
|
819
|
+
},
|
|
820
|
+
key: "header",
|
|
821
|
+
children: [
|
|
822
|
+
jsxs("div", {
|
|
823
|
+
style: { display: "flex", justifyContent: "space-between", gap: "8px", opacity: 0.75 },
|
|
824
|
+
key: "totals",
|
|
825
|
+
children: [
|
|
826
|
+
h("span", {
|
|
827
|
+
key: "count",
|
|
828
|
+
children: `${rounds.length} round${rounds.length === 1 ? "" : "s"}${hasMore || loading ? " (loading older…)" : ""}`
|
|
829
|
+
}),
|
|
830
|
+
h("span", { children: `~${formatTokens(total)} tok total`, key: "tokens" })
|
|
831
|
+
]
|
|
832
|
+
}),
|
|
833
|
+
// Never present a partial window as the whole
|
|
834
|
+
// conversation: if older pages are still outstanding the
|
|
835
|
+
// listing is silently under-reporting, and a reader
|
|
836
|
+
// choosing what to prune would be working from a
|
|
837
|
+
// truncated view.
|
|
838
|
+
hasMore
|
|
839
|
+
? h("div", {
|
|
840
|
+
key: "partial",
|
|
841
|
+
style: { fontSize: "12px", opacity: 0.75, lineHeight: "1.4" },
|
|
842
|
+
children:
|
|
843
|
+
"Showing the newest part of this session only; older history is still loading. Rounds below are numbered from the loaded window."
|
|
844
|
+
})
|
|
845
|
+
: null,
|
|
846
|
+
jsxs("div", {
|
|
847
|
+
style: { display: "flex", gap: "8px", alignItems: "center", flexWrap: "wrap" },
|
|
848
|
+
key: "actions",
|
|
849
|
+
children: [
|
|
850
|
+
h("button", {
|
|
851
|
+
key: "prune",
|
|
852
|
+
type: "button",
|
|
853
|
+
disabled: busy || selectedRounds.length === 0,
|
|
854
|
+
onClick: runPrune,
|
|
855
|
+
style: {
|
|
856
|
+
padding: "5px 12px",
|
|
857
|
+
borderRadius: "6px",
|
|
858
|
+
border: "1px solid rgba(128,128,128,0.35)",
|
|
859
|
+
background: selectedRounds.length === 0 ? "transparent" : "var(--dsw-alias-bg-accent, #3b6fe0)",
|
|
860
|
+
color: selectedRounds.length === 0 ? "inherit" : "#fff",
|
|
861
|
+
cursor: busy || selectedRounds.length === 0 ? "default" : "pointer",
|
|
862
|
+
opacity: busy || selectedRounds.length === 0 ? 0.5 : 1,
|
|
863
|
+
fontSize: "12px"
|
|
864
|
+
},
|
|
865
|
+
children: busy
|
|
866
|
+
? "Preparing…"
|
|
867
|
+
: selectedRounds.length === 0
|
|
868
|
+
? "Prepare prune"
|
|
869
|
+
: `Prepare prune (${selectedRounds.length})`
|
|
870
|
+
}),
|
|
871
|
+
h("button", {
|
|
872
|
+
key: "clear",
|
|
873
|
+
type: "button",
|
|
874
|
+
disabled: busy || selectedRounds.length === 0,
|
|
875
|
+
onClick: clear,
|
|
876
|
+
style: {
|
|
877
|
+
background: "none",
|
|
878
|
+
border: "none",
|
|
879
|
+
cursor: selectedRounds.length === 0 ? "default" : "pointer",
|
|
880
|
+
opacity: selectedRounds.length === 0 ? 0.35 : 0.75,
|
|
881
|
+
fontSize: "12px",
|
|
882
|
+
color: "inherit",
|
|
883
|
+
padding: 0
|
|
884
|
+
},
|
|
885
|
+
children: "Clear"
|
|
886
|
+
}),
|
|
887
|
+
selectedRounds.length > 0
|
|
888
|
+
? h("span", {
|
|
889
|
+
key: "frees",
|
|
890
|
+
style: { opacity: 0.65, fontSize: "12px" },
|
|
891
|
+
children: `frees ~${formatTokens(selectedTokens)} tok`
|
|
892
|
+
})
|
|
893
|
+
: null
|
|
894
|
+
]
|
|
895
|
+
}),
|
|
896
|
+
outcome !== null
|
|
897
|
+
? h("div", {
|
|
898
|
+
key: "outcome",
|
|
899
|
+
// Announced to assistive tech: without a live
|
|
900
|
+
// region a screen-reader user gets no signal
|
|
901
|
+
// that the composer was seeded, because the
|
|
902
|
+
// change happens in a different widget.
|
|
903
|
+
role: "status",
|
|
904
|
+
"aria-live": "polite",
|
|
905
|
+
style: {
|
|
906
|
+
fontSize: "12px",
|
|
907
|
+
lineHeight: "1.4",
|
|
908
|
+
color: outcome.kind === "error" ? "var(--dsw-alias-danger, #d9534f)" : "inherit",
|
|
909
|
+
opacity: outcome.kind === "error" ? 1 : 0.9
|
|
910
|
+
},
|
|
911
|
+
children: outcome.text
|
|
912
|
+
})
|
|
913
|
+
: null
|
|
914
|
+
]
|
|
915
|
+
}),
|
|
916
|
+
// The scrollable round list, one checkbox per round.
|
|
917
|
+
h("div", {
|
|
918
|
+
key: "list",
|
|
919
|
+
style: { overflowY: "auto", flex: "1 1 auto", padding: "4px" },
|
|
920
|
+
children: rounds.map((round) => {
|
|
921
|
+
const isSelected = selected.has(round.startSeq);
|
|
922
|
+
return h("label", {
|
|
923
|
+
key: `round-${round.startSeq}`,
|
|
924
|
+
style: {
|
|
925
|
+
display: "flex",
|
|
926
|
+
gap: "8px",
|
|
927
|
+
alignItems: "flex-start",
|
|
928
|
+
padding: "6px 8px",
|
|
929
|
+
borderRadius: "6px",
|
|
930
|
+
cursor: "pointer",
|
|
931
|
+
background: isSelected ? "rgba(80,140,255,0.12)" : "transparent"
|
|
932
|
+
},
|
|
933
|
+
children: [
|
|
934
|
+
h("input", {
|
|
935
|
+
key: "box",
|
|
936
|
+
type: "checkbox",
|
|
937
|
+
checked: isSelected,
|
|
938
|
+
onChange: () => toggle(round.startSeq),
|
|
939
|
+
// The visible text is the preview alone, which
|
|
940
|
+
// omits the index/tokens/seq the meta row shows.
|
|
941
|
+
"aria-label": `Round ${round.index}, ${formatTokens(round.tokens)} tokens, seq ${round.startSeq}`,
|
|
942
|
+
style: { marginTop: "2px", flex: "none" }
|
|
943
|
+
}),
|
|
944
|
+
jsxs("span", {
|
|
945
|
+
key: "text",
|
|
946
|
+
style: { display: "flex", flexDirection: "column", gap: "2px", minWidth: 0 },
|
|
947
|
+
children: [
|
|
948
|
+
jsxs("span", {
|
|
949
|
+
key: "meta",
|
|
950
|
+
style: {
|
|
951
|
+
display: "flex",
|
|
952
|
+
gap: "8px",
|
|
953
|
+
fontSize: "11px",
|
|
954
|
+
opacity: 0.65,
|
|
955
|
+
fontVariantNumeric: "tabular-nums"
|
|
956
|
+
},
|
|
957
|
+
children: [
|
|
958
|
+
h("span", { children: `#${round.index}`, key: "idx" }),
|
|
959
|
+
h("span", { children: `${formatTokens(round.tokens)} tok`, key: "tok" }),
|
|
960
|
+
h("span", { children: `seq ${round.startSeq}`, key: "seq" }),
|
|
961
|
+
// How much of this round is harness
|
|
962
|
+
// scaffolding rather than conversation.
|
|
963
|
+
// Without it a round that is mostly job
|
|
964
|
+
// reports is indistinguishable from one
|
|
965
|
+
// that is mostly discussion.
|
|
966
|
+
round.injected > 0
|
|
967
|
+
? h("span", {
|
|
968
|
+
key: "notices",
|
|
969
|
+
children: `+${round.injected} notice${round.injected === 1 ? "" : "s"}`
|
|
970
|
+
})
|
|
971
|
+
: null
|
|
972
|
+
]
|
|
973
|
+
}),
|
|
974
|
+
h("span", {
|
|
975
|
+
key: "preview",
|
|
976
|
+
style: { fontSize: "12px", lineHeight: "1.4", wordBreak: "break-word" },
|
|
977
|
+
// The HUMAN message heads the round; a round
|
|
978
|
+
// that opens with injected context would
|
|
979
|
+
// otherwise be titled by scaffolding.
|
|
980
|
+
children: round.title ?? round.preview
|
|
981
|
+
})
|
|
982
|
+
]
|
|
983
|
+
})
|
|
984
|
+
]
|
|
985
|
+
});
|
|
986
|
+
})
|
|
987
|
+
})
|
|
988
|
+
]
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* A muted single-line message used for empty states.
|
|
994
|
+
*
|
|
995
|
+
* `children` must live in the props object: the automatic JSX runtime's
|
|
996
|
+
* `jsx(type, props)` takes two arguments, so a third positional child is
|
|
997
|
+
* silently dropped and the component renders an empty element.
|
|
998
|
+
*
|
|
999
|
+
* @param props - `{ children }`.
|
|
1000
|
+
* @returns the rendered notice.
|
|
1001
|
+
*/
|
|
1002
|
+
function Notice(props) {
|
|
1003
|
+
return h("div", {
|
|
1004
|
+
style: { padding: "16px 12px", opacity: 0.6, fontSize: "13px" },
|
|
1005
|
+
children: props.children
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* The tab chip's label.
|
|
1011
|
+
*
|
|
1012
|
+
* @returns the rendered title.
|
|
1013
|
+
*/
|
|
1014
|
+
function ContextPrunerTitle() {
|
|
1015
|
+
return h("span", { children: "Context" });
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Seed the composer with the prune command for the selected rounds.
|
|
1020
|
+
*
|
|
1021
|
+
* ## Why the command carries SEQS, not round indices
|
|
1022
|
+
*
|
|
1023
|
+
* A round index is only meaningful relative to whoever numbered the
|
|
1024
|
+
* rounds. The panel numbers by "genuine human turn"; the host command
|
|
1025
|
+
* numbers every user-role surface event, so its count runs higher (69
|
|
1026
|
+
* vs 66 on one real session) and the two disagree from early on —
|
|
1027
|
+
* `#9` named seq 742 to the panel and seq 608 to the host.
|
|
1028
|
+
*
|
|
1029
|
+
* Sending those indices compacted a DIFFERENT span than the one the user
|
|
1030
|
+
* selected, with nothing in the UI revealing it. Surface seqs are durable
|
|
1031
|
+
* identifiers both sides agree on, so the panel sends
|
|
1032
|
+
* `/prune drop seq <start>-<end> --yes`.
|
|
1033
|
+
*
|
|
1034
|
+
* @param ctx - client root context.
|
|
1035
|
+
* @param sessionId - session to act on.
|
|
1036
|
+
* @param rounds - the selected round objects (they carry startSeq/endSeq).
|
|
1037
|
+
* @param inputActions - the composer's own draft/submit actions.
|
|
1038
|
+
* @returns `{ kind: 'ok' | 'error', text }`.
|
|
1039
|
+
*/
|
|
1040
|
+
async function pruneRounds(ctx, sessionId, rounds, inputActions) {
|
|
1041
|
+
if (!Array.isArray(rounds) || rounds.length === 0) {
|
|
1042
|
+
return { kind: "ok", text: "Nothing selected." };
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// The manual fallback deliberately names the SEQ form. The round-index
|
|
1046
|
+
// form (`drop 3-7`) is the one the host and this panel used to number
|
|
1047
|
+
// differently, and it is not what any row in this list displays, so
|
|
1048
|
+
// instructing the user to type it would send them back into the
|
|
1049
|
+
// wrong-span bug this panel was fixed for.
|
|
1050
|
+
const fallback =
|
|
1051
|
+
"This panel cannot reach the composer, so it cannot seed the command. " +
|
|
1052
|
+
"Either retry Prepare prune, or type it yourself using the seq shown on each row: " +
|
|
1053
|
+
"/prune drop seq <startSeq>-<endSeq> --yes";
|
|
1054
|
+
|
|
1055
|
+
// A slash command is NOT a chat message: `conversation.sendSession`
|
|
1056
|
+
// would deliver it as an ordinary prompt, and the model would answer
|
|
1057
|
+
// that it has no such tool. Only the composer's own draft/submit path
|
|
1058
|
+
// participates in the command transaction.
|
|
1059
|
+
const actions = inputActions;
|
|
1060
|
+
if (
|
|
1061
|
+
actions === undefined ||
|
|
1062
|
+
actions === null ||
|
|
1063
|
+
typeof actions.setDraft !== "function"
|
|
1064
|
+
) {
|
|
1065
|
+
return { kind: "error", text: fallback };
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Contiguous runs of SELECTED rounds become one seq span each, so a
|
|
1069
|
+
// scattered selection never swallows the unselected rounds between
|
|
1070
|
+
// them. Selecting 6, 9, 10, 11 yields two spans: `6-6` and `9-11`.
|
|
1071
|
+
const sorted = [...rounds].sort((a, b) => a.index - b.index);
|
|
1072
|
+
const ranges = [];
|
|
1073
|
+
let current = null;
|
|
1074
|
+
for (const round of sorted) {
|
|
1075
|
+
if (current === null) {
|
|
1076
|
+
current = { first: round, last: round };
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
// Contiguous only when the round indices are adjacent; their seqs
|
|
1080
|
+
// are not required to be.
|
|
1081
|
+
if (round.index === current.last.index + 1) {
|
|
1082
|
+
current.last = round;
|
|
1083
|
+
} else {
|
|
1084
|
+
ranges.push(current);
|
|
1085
|
+
current = { first: round, last: round };
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
if (current !== null) ranges.push(current);
|
|
1089
|
+
|
|
1090
|
+
// EVERY span rides one command: the host accepts a comma-separated
|
|
1091
|
+
// list, so a scattered selection is one Enter rather than one per
|
|
1092
|
+
// range. Seeding only the first (an earlier limitation) left the
|
|
1093
|
+
// user to run the rest by hand.
|
|
1094
|
+
const spec = ranges
|
|
1095
|
+
.map((range) => `seq ${range.first.startSeq}-${range.last.endSeq}`)
|
|
1096
|
+
.join(", ");
|
|
1097
|
+
const line = `/prune drop ${spec} --yes`;
|
|
1098
|
+
|
|
1099
|
+
try {
|
|
1100
|
+
actions.setDraft(line);
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
return { kind: "error", text: `Could not seed the composer: ${String(error?.message ?? error)}` };
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// We deliberately do NOT call submit(): a programmatic submit does
|
|
1106
|
+
// not execute a slash command (verified: no `command/run` event is
|
|
1107
|
+
// produced), and delivering the text as a prompt instead would cost
|
|
1108
|
+
// a model turn and answer nonsense. Command dispatch belongs to the
|
|
1109
|
+
// composer's own Enter transaction, so the panel seeds the line and
|
|
1110
|
+
// hands the trigger to the user.
|
|
1111
|
+
//
|
|
1112
|
+
// The message names the action's destructiveness and states that
|
|
1113
|
+
// nothing has happened yet. An earlier wording ("Ready: <line> —
|
|
1114
|
+
// press Enter") read as a completed step, and the line itself ends
|
|
1115
|
+
// in `--yes`, so a user skimming it could commit an irreversible
|
|
1116
|
+
// replacement without a second look.
|
|
1117
|
+
return {
|
|
1118
|
+
kind: "ok",
|
|
1119
|
+
text: [
|
|
1120
|
+
`Nothing has changed yet. ${ranges.length === 1 ? "A command is" : `${ranges.length} spans are`} ready in the input box:`,
|
|
1121
|
+
line,
|
|
1122
|
+
"Press Enter there to run it. This REPLACES those rounds with one summary and cannot be undone."
|
|
1123
|
+
].join("\n")
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Register the tab type and its body/title seats.
|
|
1129
|
+
*
|
|
1130
|
+
* Stage one declares the type; stage two supplies the body and chip
|
|
1131
|
+
* under the definition's `id` in the keyed seats.
|
|
1132
|
+
*
|
|
1133
|
+
* The `inject` face is the pattern the shipped sidebar tabs use: the
|
|
1134
|
+
* registration hands the component its data-reading closure instead of
|
|
1135
|
+
* letting the component reach for services, so the component stays pure
|
|
1136
|
+
* and the service dependency is declared where it is resolved.
|
|
1137
|
+
*
|
|
1138
|
+
* @param ctx - client root context.
|
|
1139
|
+
*/
|
|
1140
|
+
function apply(ctx) {
|
|
1141
|
+
// `inject` must be a FACTORY, not a plain object: the slot framework
|
|
1142
|
+
// calls it as `inject(sessionId, actions)` and spreads the returned
|
|
1143
|
+
// object onto the component's props. Passing an object throws
|
|
1144
|
+
// `TypeError: inject is not a function` inside the renderer, which
|
|
1145
|
+
// leaves the pane blank — the exact failure this shape fixes.
|
|
1146
|
+
// The shipped tab providers do the same (`filesFace(...)` returns a
|
|
1147
|
+
// `(sessionId, actions) => ({...})` closure).
|
|
1148
|
+
const injectFace = (sessionId) => ({
|
|
1149
|
+
// `readRounds` reports `hasMore` alongside the rounds, so the
|
|
1150
|
+
// panel can tell "this is the whole conversation" from "this is
|
|
1151
|
+
// the window we happen to have loaded".
|
|
1152
|
+
readRounds: (id) => {
|
|
1153
|
+
const target = id ?? sessionId;
|
|
1154
|
+
const window = readWindow(ctx, target);
|
|
1155
|
+
return readRounds(window.entries, window.hasMore);
|
|
1156
|
+
},
|
|
1157
|
+
loadFullHistory: (id) => loadFullHistory(ctx, id ?? sessionId),
|
|
1158
|
+
// `ObservableSnapshot.subscribe` publishes on every accepted
|
|
1159
|
+
// window mutation, which is what makes the list follow a
|
|
1160
|
+
// compaction instead of freezing on stale rounds.
|
|
1161
|
+
subscribeEvents: (id, listener) => {
|
|
1162
|
+
const source = eventSource(ctx, id ?? sessionId);
|
|
1163
|
+
if (source === undefined || typeof source.subscribe !== "function") return undefined;
|
|
1164
|
+
return source.subscribe(listener);
|
|
1165
|
+
},
|
|
1166
|
+
pruneRounds: (id, rounds, actions) => pruneRounds(ctx, id ?? sessionId, rounds, actions)
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
ctx.effect(
|
|
1170
|
+
() =>
|
|
1171
|
+
ctx.slots.inject("sidebar.right.pane.tab", () =>
|
|
1172
|
+
ctx.slots.register(
|
|
1173
|
+
{ name: "sidebar.right.pane.tab", key: TAB_ID, inject: injectFace },
|
|
1174
|
+
ContextPrunerBody
|
|
1175
|
+
)
|
|
1176
|
+
),
|
|
1177
|
+
"context-pruner: tab body"
|
|
1178
|
+
);
|
|
1179
|
+
|
|
1180
|
+
ctx.effect(
|
|
1181
|
+
() =>
|
|
1182
|
+
ctx.slots.inject("sidebar.right.pane.tab.title", () =>
|
|
1183
|
+
ctx.slots.register(
|
|
1184
|
+
{ name: "sidebar.right.pane.tab.title", key: TAB_ID },
|
|
1185
|
+
ContextPrunerTitle
|
|
1186
|
+
)
|
|
1187
|
+
),
|
|
1188
|
+
"context-pruner: tab title"
|
|
1189
|
+
);
|
|
1190
|
+
|
|
1191
|
+
ctx.effect(
|
|
1192
|
+
() =>
|
|
1193
|
+
ctx.sidebarRightTabs.register({
|
|
1194
|
+
id: TAB_ID,
|
|
1195
|
+
kind: TAB_KIND,
|
|
1196
|
+
// The shipped tab types declare a band; the default is
|
|
1197
|
+
// `extension`, which is the band that takes over another
|
|
1198
|
+
// type's kind. Declaring `builtin` keeps this row's
|
|
1199
|
+
// conflict semantics identical to a shipped tab.
|
|
1200
|
+
priority: "builtin",
|
|
1201
|
+
title: () => "Context",
|
|
1202
|
+
// Registering a type does NOT open a tab: the strip stays
|
|
1203
|
+
// empty until something calls `openTab(kind)`. The guide
|
|
1204
|
+
// is the sidebar's own "open a page" surface, so an entry
|
|
1205
|
+
// here is what makes the tab reachable without a command
|
|
1206
|
+
// or a rebuilt shell.
|
|
1207
|
+
guide: [
|
|
1208
|
+
{
|
|
1209
|
+
// Distinct from TAB_ID, matching the shipped
|
|
1210
|
+
// pattern (`sidebar-files` uses id "workspace"
|
|
1211
|
+
// with type id `FILES_ID`). Sharing the two made
|
|
1212
|
+
// the guide card and the tab type collide on one
|
|
1213
|
+
// identity, which the registry reports as a
|
|
1214
|
+
// duplicate id.
|
|
1215
|
+
id: "context-pruner-guide",
|
|
1216
|
+
order: 60,
|
|
1217
|
+
title: () => "Context",
|
|
1218
|
+
description: () => "Inspect conversation rounds and prune the ones you choose"
|
|
1219
|
+
}
|
|
1220
|
+
]
|
|
1221
|
+
}),
|
|
1222
|
+
"context-pruner: tab type"
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
exports.apply = apply;
|
|
1227
|
+
exports.inject = inject;
|
|
1228
|
+
return module.exports;
|
|
1229
|
+
}
|
|
1230
|
+
});
|