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/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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }