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/inspect.js
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surface inspection for dsh-context-pruner.
|
|
3
|
+
*
|
|
4
|
+
* Turns the raw token-meter node list into something a human can actually use:
|
|
5
|
+
* readable conversation rounds with content previews, instead of bare seq and
|
|
6
|
+
* token numbers.
|
|
7
|
+
*
|
|
8
|
+
* The motivating failure: a first version printed 466 nodes of `seq / tokens /
|
|
9
|
+
* share`, where every share rounded to 0% and no line said what the node
|
|
10
|
+
* contained. The numbers were correct and useless.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-context-pruner/inspect
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { isHumanTurn } from "./rounds.js";
|
|
16
|
+
|
|
17
|
+
/** How much message text to show per round line. */
|
|
18
|
+
const PREVIEW_CHARS = 96;
|
|
19
|
+
|
|
20
|
+
/** Roles that can open a new conversation round. */
|
|
21
|
+
const ROUND_OPENER_ROLES = new Set(["user"]);
|
|
22
|
+
|
|
23
|
+
/** Opening tag of a framed compaction checkpoint body. */
|
|
24
|
+
const SUMMARY_OPEN_TAG = "<compacted-summary>";
|
|
25
|
+
|
|
26
|
+
/** Closing tag of a framed compaction checkpoint body. */
|
|
27
|
+
const SUMMARY_CLOSE_TAG = "</compacted-summary>";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Opening sentences of the fixed checkpoint boilerplate.
|
|
31
|
+
*
|
|
32
|
+
* Both framings ship: "This is an automatically generated checkpoint…" from the
|
|
33
|
+
* stock backend and "This is a correction checkpoint…" from this plugin's. The
|
|
34
|
+
* two real openings are named rather than matching a bare "This is ", which
|
|
35
|
+
* would strip ordinary prose that happens to begin that way.
|
|
36
|
+
*/
|
|
37
|
+
const CHECKPOINT_OPENINGS = [
|
|
38
|
+
"This is an automatically generated checkpoint",
|
|
39
|
+
"This is a correction checkpoint"
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Remove checkpoint boilerplate from the head of a block.
|
|
44
|
+
*
|
|
45
|
+
* The boilerplate is fixed English text. It is usually its own block, but a
|
|
46
|
+
* producer may emit it in the SAME block as the framed body. Dropping the whole
|
|
47
|
+
* block on a prefix match would then discard the summary along with it, so only
|
|
48
|
+
* the boilerplate's own span is removed and whatever follows is kept.
|
|
49
|
+
*
|
|
50
|
+
* @param text - full text of one content block.
|
|
51
|
+
* @returns the text after the boilerplate, or `text` when there is none.
|
|
52
|
+
*/
|
|
53
|
+
function stripCheckpointPreamble(text) {
|
|
54
|
+
for (const opening of CHECKPOINT_OPENINGS) {
|
|
55
|
+
if (!text.startsWith(opening)) continue;
|
|
56
|
+
const end = text.indexOf("\n\n", opening.length);
|
|
57
|
+
if (end !== -1) return text.slice(end + 2);
|
|
58
|
+
// No paragraph break: the whole block is boilerplate UNLESS a framing
|
|
59
|
+
// tag follows, in which case the tag and whatever it wraps are content.
|
|
60
|
+
// Returning "" unconditionally here would discard the summary whenever a
|
|
61
|
+
// producer emitted the preamble and the body in a single block.
|
|
62
|
+
const tag = text.indexOf(SUMMARY_OPEN_TAG, opening.length);
|
|
63
|
+
return tag === -1 ? "" : text.slice(tag);
|
|
64
|
+
}
|
|
65
|
+
return text;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether a block is nothing but checkpoint boilerplate.
|
|
70
|
+
*
|
|
71
|
+
* @param text - full text of one content block.
|
|
72
|
+
* @returns true when the block carries no content beyond the preamble.
|
|
73
|
+
*/
|
|
74
|
+
function isCheckpointPreamble(text) {
|
|
75
|
+
return stripCheckpointPreamble(text).trim().length === 0 && text.trim().length > 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Extract the meaningful body of a compaction checkpoint block.
|
|
80
|
+
*
|
|
81
|
+
* A checkpoint message is framed across several content blocks:
|
|
82
|
+
*
|
|
83
|
+
* block 0: "<boilerplate preamble>"
|
|
84
|
+
* block 1: "<compacted-summary>\n\n<the actual summary>"
|
|
85
|
+
* block 2: "</compacted-summary>"
|
|
86
|
+
*
|
|
87
|
+
* The boilerplate is ~285 characters of fixed English text that every
|
|
88
|
+
* checkpoint repeats. Showing it in a listing is actively harmful: the reader
|
|
89
|
+
* sees the same sentence on every checkpoint node and never learns what the
|
|
90
|
+
* summary actually says.
|
|
91
|
+
*
|
|
92
|
+
* Two things make this exact, and both are pinned by `test/rounds.mjs` so the
|
|
93
|
+
* browser panel cannot drift away from it again:
|
|
94
|
+
*
|
|
95
|
+
* - The PREAMBLE is removed by its own opening words. Removing the whole
|
|
96
|
+
* block on a prefix match would discard the body when a producer emits both
|
|
97
|
+
* in one block.
|
|
98
|
+
* - The opening tag leads a LATER block, not the tail of block 0, so the tag
|
|
99
|
+
* is searched for anywhere in the block rather than assumed at its end.
|
|
100
|
+
*
|
|
101
|
+
* @param text - full text of one content block.
|
|
102
|
+
* @returns the body after the opening tag, or `text` when there is none.
|
|
103
|
+
*/
|
|
104
|
+
function stripCheckpointOpening(text) {
|
|
105
|
+
const withoutPreamble = stripCheckpointPreamble(text);
|
|
106
|
+
const start = withoutPreamble.indexOf(SUMMARY_OPEN_TAG);
|
|
107
|
+
if (start === -1) return withoutPreamble;
|
|
108
|
+
return withoutPreamble.slice(start + SUMMARY_OPEN_TAG.length);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether a block is nothing but the closing framing tag.
|
|
113
|
+
*
|
|
114
|
+
* @param text - full text of one content block.
|
|
115
|
+
* @returns true when the block carries only framing.
|
|
116
|
+
*/
|
|
117
|
+
function isClosingTagBlock(text) {
|
|
118
|
+
return text.trim() === SUMMARY_CLOSE_TAG;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Flatten one message's content into a single preview line.
|
|
123
|
+
*
|
|
124
|
+
* Non-text blocks are summarised by kind so a tool call or image is still
|
|
125
|
+
* visible in the listing rather than silently vanishing. Checkpoint framing is
|
|
126
|
+
* stripped so a summarized node previews its conclusion.
|
|
127
|
+
*
|
|
128
|
+
* This is the union of every framing rule: the preamble is dropped, the opening
|
|
129
|
+
* tag is honoured wherever it appears, the closing tag is removed inline, and a
|
|
130
|
+
* block left empty by any of those is discarded rather than contributing a
|
|
131
|
+
* blank entry. The browser panel's `stripCheckpointFraming` in `lib/client.js`
|
|
132
|
+
* applies the identical rule, and `test/rounds.mjs` asserts the two agree on a
|
|
133
|
+
* table of real layouts — the two sides diverging here is what previously made
|
|
134
|
+
* every checkpoint preview as the same sentence of English boilerplate.
|
|
135
|
+
*
|
|
136
|
+
* @param message - derived message for a surface node.
|
|
137
|
+
* @param max - maximum characters to keep.
|
|
138
|
+
* @returns a collapsed one-line preview.
|
|
139
|
+
*/
|
|
140
|
+
export function previewMessage(message, max = PREVIEW_CHARS) {
|
|
141
|
+
if (message === null || message === undefined) return "(no message)";
|
|
142
|
+
|
|
143
|
+
const parts = [];
|
|
144
|
+
for (const block of message.content ?? []) {
|
|
145
|
+
// A block can be null or carry a non-string `text` (`content: [null]`
|
|
146
|
+
// round-trips through `deriveEventMessage`, and a partially streamed
|
|
147
|
+
// block can hold a placeholder). Unguarded, these THREW out of
|
|
148
|
+
// `describeNodes` and took down the whole `/prune` listing — a
|
|
149
|
+
// read-only command failing on malformed history.
|
|
150
|
+
if (block === null || block === undefined || typeof block !== "object") continue;
|
|
151
|
+
|
|
152
|
+
if (block.type === "text") {
|
|
153
|
+
if (typeof block.text !== "string") continue;
|
|
154
|
+
if (isClosingTagBlock(block.text)) continue;
|
|
155
|
+
// The closing tag may also sit inline, after the body in the same
|
|
156
|
+
// block, so it is removed rather than only whole-block-matched.
|
|
157
|
+
const body = stripCheckpointOpening(block.text).split(SUMMARY_CLOSE_TAG).join("");
|
|
158
|
+
if (body.trim().length === 0) continue;
|
|
159
|
+
parts.push(body);
|
|
160
|
+
} else if (block.type === "tool-call") {
|
|
161
|
+
parts.push(`[tool:${block.name ?? "?"}]`);
|
|
162
|
+
} else if (block.type === "image") {
|
|
163
|
+
parts.push("[image]");
|
|
164
|
+
} else if (typeof block.type === "string") {
|
|
165
|
+
parts.push(`[${block.type}]`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const text = parts.join(" ").replace(/\s+/g, " ").trim();
|
|
170
|
+
if (text.length === 0) return "(empty)";
|
|
171
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build a readable node descriptor for every surface position.
|
|
176
|
+
*
|
|
177
|
+
* @param session - live session whose surface is inspected.
|
|
178
|
+
* @param nodes - token-priced nodes from the meter, in surface order.
|
|
179
|
+
* @returns one descriptor per node, aligned with `nodes`.
|
|
180
|
+
*/
|
|
181
|
+
export function describeNodes(session, nodes) {
|
|
182
|
+
return nodes.map((node, position) => {
|
|
183
|
+
const event = session.eventAt(node.seq);
|
|
184
|
+
const message = session.deriveEventMessage(event);
|
|
185
|
+
const role = message?.role ?? event?.type ?? "unknown";
|
|
186
|
+
const preview = previewMessage(message);
|
|
187
|
+
// The message's own text, untruncated, so classification sees the real
|
|
188
|
+
// prefix rather than the 96-character preview.
|
|
189
|
+
const text = messageText(message);
|
|
190
|
+
return {
|
|
191
|
+
position,
|
|
192
|
+
seq: node.seq,
|
|
193
|
+
tokens: node.tokens,
|
|
194
|
+
role,
|
|
195
|
+
preview,
|
|
196
|
+
// `source` is the harness's own structured marker for an injected
|
|
197
|
+
// message; `isHumanTurn` prefers it and falls back to text prefixes.
|
|
198
|
+
source: message?.source,
|
|
199
|
+
opensRound:
|
|
200
|
+
ROUND_OPENER_ROLES.has(role) && isHumanTurn(text, message?.source)
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Flatten a message's text blocks without truncation.
|
|
207
|
+
*
|
|
208
|
+
* Used for classification, which must see the whole prefix rather than the
|
|
209
|
+
* truncated preview. Guards block shape for the same reason `previewMessage`
|
|
210
|
+
* does: `content: [null]` and non-string `text` both occur in real histories,
|
|
211
|
+
* and an unguarded read takes down the entire listing.
|
|
212
|
+
*
|
|
213
|
+
* @param message - derived message for a surface node.
|
|
214
|
+
* @returns the concatenated text, or an empty string.
|
|
215
|
+
*/
|
|
216
|
+
function messageText(message) {
|
|
217
|
+
if (message === null || message === undefined) return "";
|
|
218
|
+
const parts = [];
|
|
219
|
+
for (const block of message.content ?? []) {
|
|
220
|
+
if (block === null || block === undefined || typeof block !== "object") continue;
|
|
221
|
+
if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
222
|
+
}
|
|
223
|
+
return parts.join(" ").replace(/\s+/g, " ").trim();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Group node descriptors into conversation rounds.
|
|
228
|
+
*
|
|
229
|
+
* A round opens at each genuine HUMAN turn and absorbs every following node
|
|
230
|
+
* until the next one. This is the unit a human reasons about ("that round where
|
|
231
|
+
* we tried the wrong fix"), and it is what makes a 466-node session legible.
|
|
232
|
+
*
|
|
233
|
+
* Machine notices — background-job reports, runtime context, system reminders,
|
|
234
|
+
* model-change notices, slash-command echoes — are folded into the round they
|
|
235
|
+
* arrived in rather than opening a round of their own. They share the
|
|
236
|
+
* `user/message` surface type, so an opener-per-user-message rule counted 31
|
|
237
|
+
* rounds on a session holding 16 human turns; because the extras sit BETWEEN
|
|
238
|
+
* the human turns, the resulting indices were not merely offset but
|
|
239
|
+
* non-uniform, which made `/prune drop <a>-<b>` address the wrong span.
|
|
240
|
+
*
|
|
241
|
+
* Nodes BEFORE the first human turn are held and then absorbed into that first
|
|
242
|
+
* round. This matters on essentially every real session: the shipped
|
|
243
|
+
* `SystemPromptProjection` commits the rendered system prompt as surface node 0,
|
|
244
|
+
* and a runtime-context snapshot commonly follows it. Starting a round on those
|
|
245
|
+
* nodes invents a round whose heading is scaffolding AND splits the span, so the
|
|
246
|
+
* `lib/client.js` panel — which must produce the same indices — mirrors this
|
|
247
|
+
* hold-over exactly (pinned by `test/rounds.mjs`).
|
|
248
|
+
*
|
|
249
|
+
* `startSeq`/`endSeq` keep their meaning — the first and last surface node
|
|
250
|
+
* BELONGING to the round — so a notice that arrived before the round's human
|
|
251
|
+
* message stays inside the span rather than becoming un-addressable. Only
|
|
252
|
+
* `title`, the node used for display, prefers the human message.
|
|
253
|
+
*
|
|
254
|
+
* @param descriptors - descriptors from {@link describeNodes}.
|
|
255
|
+
* @returns rounds, each with its node range, token total, and previews.
|
|
256
|
+
*/
|
|
257
|
+
export function groupRounds(descriptors) {
|
|
258
|
+
const rounds = [];
|
|
259
|
+
let current = null;
|
|
260
|
+
// Nodes seen before the first human turn. A session commonly opens with a
|
|
261
|
+
// runtime snapshot, and those nodes must land INSIDE the first round so the
|
|
262
|
+
// span covering the round is complete and nothing becomes un-droppable.
|
|
263
|
+
// Starting a round on them instead both invented a round and split the span.
|
|
264
|
+
let leading = [];
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Create a round from the given node, or from the held-over leading nodes
|
|
268
|
+
* plus it.
|
|
269
|
+
*
|
|
270
|
+
* @param descriptor - the node opening the round.
|
|
271
|
+
* @param held - nodes to prepend, if any.
|
|
272
|
+
* @returns the new round.
|
|
273
|
+
*/
|
|
274
|
+
const openRound = (descriptor, held) => {
|
|
275
|
+
const nodes = [...held, descriptor];
|
|
276
|
+
const round = {
|
|
277
|
+
index: rounds.length,
|
|
278
|
+
startPosition: nodes[0].position,
|
|
279
|
+
endPosition: descriptor.position,
|
|
280
|
+
startSeq: nodes[0].seq,
|
|
281
|
+
endSeq: descriptor.seq,
|
|
282
|
+
tokens: nodes.reduce((sum, node) => sum + node.tokens, 0),
|
|
283
|
+
nodes,
|
|
284
|
+
opener: descriptor,
|
|
285
|
+
// The node shown as this round's heading. A round that opens with
|
|
286
|
+
// injected context must still be titled by the human message, or the
|
|
287
|
+
// listing presents scaffolding as "what was said".
|
|
288
|
+
title: descriptor.opensRound ? descriptor : null,
|
|
289
|
+
// Machine notices folded in, so the listing can disclose how much of
|
|
290
|
+
// a span is scaffolding rather than conversation. Only user-role
|
|
291
|
+
// messages the classifier rejected are notices: a leading
|
|
292
|
+
// `system/message` is not a harness notice, and counting every held
|
|
293
|
+
// node reported `(+3 notices)` for a round holding one.
|
|
294
|
+
injected: [...held, descriptor].filter(
|
|
295
|
+
(node) => node.role === "user" && !node.opensRound
|
|
296
|
+
).length
|
|
297
|
+
};
|
|
298
|
+
rounds.push(round);
|
|
299
|
+
return round;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
for (const descriptor of descriptors) {
|
|
303
|
+
if (current === null) {
|
|
304
|
+
if (descriptor.opensRound) {
|
|
305
|
+
current = openRound(descriptor, leading);
|
|
306
|
+
leading = [];
|
|
307
|
+
} else {
|
|
308
|
+
// Hold it: whether it deserves its own round is not known until a
|
|
309
|
+
// human turn decides.
|
|
310
|
+
leading.push(descriptor);
|
|
311
|
+
}
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (descriptor.opensRound) {
|
|
316
|
+
current = openRound(descriptor, []);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
current.endPosition = descriptor.position;
|
|
321
|
+
current.endSeq = descriptor.seq;
|
|
322
|
+
current.tokens += descriptor.tokens;
|
|
323
|
+
if (descriptor.role === "user" && !descriptor.opensRound) current.injected += 1;
|
|
324
|
+
current.nodes.push(descriptor);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// A session with no human turn at all (a bare system prompt, or scaffolding
|
|
328
|
+
// only) still has to render: emit every held node as one round. The nodes
|
|
329
|
+
// are passed as `held` with no opener, so their surface order is preserved —
|
|
330
|
+
// calling `openRound(leading[0], leading.slice(1))` instead put the tail
|
|
331
|
+
// BEFORE the head and reversed the round.
|
|
332
|
+
if (current === null && leading.length > 0) openRound(leading[leading.length - 1], leading.slice(0, -1));
|
|
333
|
+
|
|
334
|
+
return rounds;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Render a compact token count, e.g. `12.3k`.
|
|
339
|
+
*
|
|
340
|
+
* @param tokens - raw token count.
|
|
341
|
+
* @returns a short human-readable string.
|
|
342
|
+
*/
|
|
343
|
+
export function formatTokens(tokens) {
|
|
344
|
+
if (tokens >= 1000) {
|
|
345
|
+
const thousands = tokens / 1000;
|
|
346
|
+
return `${thousands >= 10 ? Math.round(thousands) : thousands.toFixed(1)}k`;
|
|
347
|
+
}
|
|
348
|
+
return String(tokens);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Render a list of rounds as an aligned table.
|
|
353
|
+
*
|
|
354
|
+
* @param rounds - rounds from {@link groupRounds}.
|
|
355
|
+
* @param selected - optional predicate choosing which rounds to render.
|
|
356
|
+
* @returns the rendered lines.
|
|
357
|
+
*/
|
|
358
|
+
export function renderRounds(rounds, selected = () => true) {
|
|
359
|
+
const chosen = rounds.filter(selected);
|
|
360
|
+
if (chosen.length === 0) return [" (no rounds to show)"];
|
|
361
|
+
|
|
362
|
+
const widest = Math.max(...chosen.map((round) => formatTokens(round.tokens).length));
|
|
363
|
+
const lines = [];
|
|
364
|
+
|
|
365
|
+
for (const round of chosen) {
|
|
366
|
+
const size = formatTokens(round.tokens).padStart(widest);
|
|
367
|
+
const span =
|
|
368
|
+
round.startPosition === round.endPosition
|
|
369
|
+
? `#${round.startPosition}`
|
|
370
|
+
: `#${round.startPosition}-${round.endPosition}`;
|
|
371
|
+
// The heading is the round's HUMAN message, not its first node. A round
|
|
372
|
+
// can open with injected context, and titling it with that notice is what
|
|
373
|
+
// produced rows reading `background job pwsh-20 (...)`.
|
|
374
|
+
const heading = round.title ?? round.opener;
|
|
375
|
+
const notices =
|
|
376
|
+
round.injected > 0 ? ` (+${round.injected} notice${round.injected === 1 ? "" : "s"})` : "";
|
|
377
|
+
lines.push(
|
|
378
|
+
` ${String(round.index).padStart(3)} ${size} tok ${span.padEnd(12)} ${heading.preview}${notices}`
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
// Show a short reaction line for substantial rounds so the listing
|
|
382
|
+
// conveys the actual exchange, not just the opening question.
|
|
383
|
+
const reply = round.nodes.find((node) => node.role === "assistant");
|
|
384
|
+
if (reply !== undefined && round.nodes.length > 1) {
|
|
385
|
+
lines.push(` └ ${reply.preview}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return lines;
|
|
390
|
+
}
|
package/lib/rounds.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Round classification for dsh-context-pruner — the single source of truth.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this module exists
|
|
5
|
+
*
|
|
6
|
+
* The harness delivers machine notices through the SAME `user/message` surface
|
|
7
|
+
* type as real conversation. Verified in the DSH checkout, every one of these
|
|
8
|
+
* is built with `createUserMessage({ ..., source: { kind, form } })`:
|
|
9
|
+
*
|
|
10
|
+
* | source.kind | form | what it is |
|
|
11
|
+
* |----------------------|----------------|-----------------------------------|
|
|
12
|
+
* | `tool-jobs` | `notice` | "background job ... completed" |
|
|
13
|
+
* | `runtime-context` | `snapshot` | cwd / time / tmux snapshot |
|
|
14
|
+
* | `agent-instructions` | `instructions` | `<system-reminder>` and skill body|
|
|
15
|
+
* | `agent-message` | — | teammate message delivery |
|
|
16
|
+
* | `session-reference` | — | `@file` reference expansion |
|
|
17
|
+
* | `skill-invocation` | — | slash-skill body |
|
|
18
|
+
* | `model-selection` | — | "[model changed: ...]" |
|
|
19
|
+
*
|
|
20
|
+
* Treating those as conversation openers is not cosmetic. On the session the
|
|
21
|
+
* README measured (31 `user/message`, of which 16 were human), an opener-per-
|
|
22
|
+
* user-message rule reports **31 rounds where the truth is 16** — and because
|
|
23
|
+
* the extra openers are sprinkled BETWEEN the human turns, the mapping is not a
|
|
24
|
+
* constant offset: host round 9 is a different round from reality's round 9.
|
|
25
|
+
*
|
|
26
|
+
* That matters most for `/prune drop <a>-<b>`, which resolves a round range
|
|
27
|
+
* through this same numbering: a hand-typed range lands on the wrong span, and
|
|
28
|
+
* the preview echoes the same wrong numbering, so nothing reveals the mistake.
|
|
29
|
+
*
|
|
30
|
+
* ## Why the classifier is shared rather than duplicated
|
|
31
|
+
*
|
|
32
|
+
* The browser panel has no access to the host's module graph, so it carries its
|
|
33
|
+
* own copy of this logic inside the bundle. Duplicating the rule is what let
|
|
34
|
+
* the two sides drift apart in the first place (the panel counted human turns
|
|
35
|
+
* while the host counted every user message). `test/rounds.mjs` pins the two
|
|
36
|
+
* copies together by extracting the shipped classifier from `lib/client.js` and
|
|
37
|
+
* running it against the same table this module is tested with, so a change to
|
|
38
|
+
* one side that is not mirrored fails the suite.
|
|
39
|
+
*
|
|
40
|
+
* ## Why `source` is preferred over text matching
|
|
41
|
+
*
|
|
42
|
+
* The structured marker survives copy changes; a text prefix does not. The five
|
|
43
|
+
* prefixes below are heuristics kept only as a fallback for messages whose
|
|
44
|
+
* `source` is absent — notably the browser panel, whose event window may not
|
|
45
|
+
* carry `source` on every entry. `/^Current runtime context\./` in particular
|
|
46
|
+
* would silently stop matching if the system-prompt plugin reworded its header.
|
|
47
|
+
*
|
|
48
|
+
* @module dsh-context-pruner/rounds
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `source.kind` values that never represent a human turn.
|
|
53
|
+
*
|
|
54
|
+
* Every one is a harness-injected message delivered through `user/message`.
|
|
55
|
+
* `model-selection` is the "[model changed: ...]" notice.
|
|
56
|
+
*/
|
|
57
|
+
const MACHINE_SOURCE_KINDS = new Set([
|
|
58
|
+
"tool-jobs",
|
|
59
|
+
"runtime-context",
|
|
60
|
+
"agent-instructions",
|
|
61
|
+
"agent-message",
|
|
62
|
+
"session-reference",
|
|
63
|
+
"skill-invocation",
|
|
64
|
+
"model-selection"
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `source.form` values that mark a message as injected rather than typed.
|
|
69
|
+
*
|
|
70
|
+
* `form` is the more precise signal where it is present: it distinguishes a
|
|
71
|
+
* `notice`/`snapshot`/`instructions` injection from a user-authored message
|
|
72
|
+
* that happens to share a `kind`.
|
|
73
|
+
*/
|
|
74
|
+
const MACHINE_SOURCE_FORMS = new Set(["notice", "snapshot", "instructions"]);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Command names that are known to be dispatched as commands rather than chat.
|
|
78
|
+
*
|
|
79
|
+
* Only used to disambiguate a slash-leading line when no `source` marker is
|
|
80
|
+
* available. A name here means "this is an echo, not a prompt"; anything else
|
|
81
|
+
* that merely starts with a slash is treated as human, so a pasted absolute
|
|
82
|
+
* path is never silently folded into the previous round.
|
|
83
|
+
*/
|
|
84
|
+
const KNOWN_COMMANDS = new Set([
|
|
85
|
+
"prune",
|
|
86
|
+
"compact",
|
|
87
|
+
"clear",
|
|
88
|
+
"help",
|
|
89
|
+
"init",
|
|
90
|
+
"model",
|
|
91
|
+
"new",
|
|
92
|
+
"resume",
|
|
93
|
+
"review",
|
|
94
|
+
"status",
|
|
95
|
+
"quit",
|
|
96
|
+
"exit"
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Decide whether a message is a genuine human turn.
|
|
101
|
+
*
|
|
102
|
+
* @param text - the message's own text, flattened. Only the head is inspected.
|
|
103
|
+
* @param source - the optional structured `source` marker from the message.
|
|
104
|
+
* @returns true when this message should open a conversation round.
|
|
105
|
+
*/
|
|
106
|
+
export function isHumanTurn(text, source) {
|
|
107
|
+
if (isMachineSource(source)) return false;
|
|
108
|
+
|
|
109
|
+
const head = String(text ?? "").trimStart().slice(0, 120);
|
|
110
|
+
|
|
111
|
+
// An empty message is not a turn. Falling through to `true` let a
|
|
112
|
+
// contentless `user/message` open a round.
|
|
113
|
+
if (head.length === 0) return false;
|
|
114
|
+
|
|
115
|
+
// Slash-command echoes are an action, not a turn.
|
|
116
|
+
//
|
|
117
|
+
// Verified in the DSH checkout, a submitted `/line` does NOT become a chat
|
|
118
|
+
// message: the composer routes it to `adjudicate` and the command domain
|
|
119
|
+
// records a `command/run` LIFECYCLE event, not a `user/message`. So this
|
|
120
|
+
// branch is defence-in-depth for a composition that echoes the line, and it
|
|
121
|
+
// is deliberately NARROW because a bare leading slash would swallow real
|
|
122
|
+
// prompts that begin with a path ("/tmp cleanup needed", "/home/u/x") and
|
|
123
|
+
// silently fold them into the previous round — the wrong-span failure this
|
|
124
|
+
// classifier exists to prevent.
|
|
125
|
+
//
|
|
126
|
+
// A registered command name is a single token that either ends the line or
|
|
127
|
+
// is followed by an argument. Requiring the argument form to look like
|
|
128
|
+
// flags/known-command arguments keeps prose starting with a path as human.
|
|
129
|
+
const commandish = /^\/([a-z][\w-]*)(?::([\w-]+))?(\s|$)/i.exec(head);
|
|
130
|
+
if (commandish !== null) {
|
|
131
|
+
const rest = head.slice(commandish[0].length).trim();
|
|
132
|
+
// `/name` alone, or `/name <args>` where the name is a known plugin
|
|
133
|
+
// command, is an echo. A path-like first word followed by prose is not.
|
|
134
|
+
if (rest.length === 0) return false;
|
|
135
|
+
if (KNOWN_COMMANDS.has(commandish[1].toLowerCase())) return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Text fallbacks, for messages carrying no `source`.
|
|
139
|
+
if (/^background job /i.test(head)) return false;
|
|
140
|
+
if (/^<system-reminder>/.test(head)) return false;
|
|
141
|
+
if (/^\[model changed:/.test(head)) return false;
|
|
142
|
+
if (/^Current runtime context\./.test(head)) return false;
|
|
143
|
+
if (/^This session is being continued from/.test(head)) return false;
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Whether a `source` marker identifies a harness injection.
|
|
149
|
+
*
|
|
150
|
+
* @param source - a message's `source` field, when present.
|
|
151
|
+
* @returns true when the marker names a machine delivery.
|
|
152
|
+
*/
|
|
153
|
+
export function isMachineSource(source) {
|
|
154
|
+
if (source === null || source === undefined || typeof source !== "object") return false;
|
|
155
|
+
if (typeof source.kind === "string" && MACHINE_SOURCE_KINDS.has(source.kind)) return true;
|
|
156
|
+
if (typeof source.form === "string" && MACHINE_SOURCE_FORMS.has(source.form)) return true;
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export { MACHINE_SOURCE_KINDS, MACHINE_SOURCE_FORMS };
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-context-pruner",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "DSH 插件:纠错式上下文裁剪。用 /prune 把已经走错方向的旧对话压成一条「结论修正」摘要,让模型忘掉错误答案 X、只记住正确结论 Y,而不是把 10 轮试错原样留在上下文里继续误导。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./backend": "./lib/backend.js",
|
|
10
|
+
"./client": {
|
|
11
|
+
"default": "./lib/client.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"dsh": {
|
|
16
|
+
"bundle": {
|
|
17
|
+
"patch": "./cordis.patch.yml"
|
|
18
|
+
},
|
|
19
|
+
"client": {
|
|
20
|
+
"inject": [
|
|
21
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
22
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right",
|
|
23
|
+
"@deepseek-ai/dsh-client-ui-primitives",
|
|
24
|
+
"@deepseek-ai/dsh-client-ui-session"
|
|
25
|
+
],
|
|
26
|
+
"platform": "web"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"lib",
|
|
31
|
+
"cordis.patch.yml",
|
|
32
|
+
"README.md"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node test/run.mjs"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"deepseek",
|
|
39
|
+
"harness",
|
|
40
|
+
"dsh",
|
|
41
|
+
"dsh-plugin",
|
|
42
|
+
"compaction",
|
|
43
|
+
"context",
|
|
44
|
+
"prune"
|
|
45
|
+
],
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@deepseek-ai/schemastery": "~3.18.4"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@deepseek-ai/cordis": "~4.0.5",
|
|
52
|
+
"@deepseek-ai/dsh-commands": "0.2.1-rc.2",
|
|
53
|
+
"@deepseek-ai/dsh-compaction": "0.2.1-rc.2",
|
|
54
|
+
"@deepseek-ai/dsh-compaction-basic": "0.2.1-rc.2",
|
|
55
|
+
"@deepseek-ai/dsh-llm": "0.2.1-rc.2",
|
|
56
|
+
"@deepseek-ai/dsh-util-values": "0.2.1-rc.2"
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=20"
|
|
60
|
+
}
|
|
61
|
+
}
|