dsh-context-fold 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/index.js ADDED
@@ -0,0 +1,1783 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { appendPluginRecord, pluginRecordOf } from "@deepseek-ai/dsh-session";
3
+ import { freezeMessage } from "@deepseek-ai/dsh-llm";
4
+ // src/config.ts
5
+ /**
6
+ * Configuration resolution for deterministic tool-result folding.
7
+ *
8
+ * Every knob is a plain integer or boolean, so the same content always yields
9
+ * the same decision on the same log prefix (design §2 goal 3).
10
+ *
11
+ * @module dsh-context-fold/config
12
+ */
13
+ /** Low-friction defaults from design §6.9. */
14
+ const DEFAULT_CONFIG = deepFreeze({
15
+ minFoldChars: 1e3,
16
+ minBatchChars: 8e3,
17
+ keepRecentSteps: 6,
18
+ staleSteps: 12,
19
+ batchSpanSteps: 20,
20
+ foldReasoning: true,
21
+ foldPlainTurnReasoning: false,
22
+ enabled: true,
23
+ dryRun: false
24
+ });
25
+ /** Config schema shown in the settings UI. */
26
+ const Config = z.object({
27
+ minFoldChars: z.number().step(1).min(300).default(DEFAULT_CONFIG.minFoldChars).description("Skip tool results smaller than this many characters. Never below 300."),
28
+ minBatchChars: z.number().step(1).min(1).default(DEFAULT_CONFIG.minBatchChars).description("Only fold a batch of candidates whose combined size reaches this many characters."),
29
+ keepRecentSteps: z.number().step(1).min(0).default(DEFAULT_CONFIG.keepRecentSteps).description("Never fold a result from the newest N steps."),
30
+ staleSteps: z.number().step(1).min(1).default(DEFAULT_CONFIG.staleSteps).description("Age (in steps) after which a result may fold inside one long turn."),
31
+ batchSpanSteps: z.number().step(1).min(1).default(DEFAULT_CONFIG.batchSpanSteps).description("Split a turn longer than this many steps into contiguous sub-batches."),
32
+ foldReasoning: z.boolean().default(DEFAULT_CONFIG.foldReasoning).description("Fold reasoning blocks on historical tool-call turns. Reasoning on plain turns is never re-sent, so it is not folded."),
33
+ foldPlainTurnReasoning: z.boolean().default(DEFAULT_CONFIG.foldPlainTurnReasoning).description("Also fold reasoning on plain turns. Enable when the route re-sends it (e.g. dsh-router-core); leave off for dsh-llm-deepseek, which drops it."),
34
+ enabled: z.boolean().default(DEFAULT_CONFIG.enabled).description("Turn folding on or off."),
35
+ dryRun: z.boolean().default(DEFAULT_CONFIG.dryRun).description("Compute and report candidates without mutating the session.")
36
+ });
37
+ /**
38
+ * Resolve and validate a partly-specified configuration.
39
+ *
40
+ * @param config - raw plugin configuration.
41
+ * @returns a detached, frozen configuration.
42
+ * @throws when a budget is not a safe integer or `minFoldChars` is below 300.
43
+ */
44
+ function resolveConfig(config = {}) {
45
+ const resolved = {
46
+ minFoldChars: config.minFoldChars ?? DEFAULT_CONFIG.minFoldChars,
47
+ minBatchChars: config.minBatchChars ?? DEFAULT_CONFIG.minBatchChars,
48
+ keepRecentSteps: config.keepRecentSteps ?? DEFAULT_CONFIG.keepRecentSteps,
49
+ staleSteps: config.staleSteps ?? DEFAULT_CONFIG.staleSteps,
50
+ batchSpanSteps: config.batchSpanSteps ?? DEFAULT_CONFIG.batchSpanSteps,
51
+ foldReasoning: config.foldReasoning ?? DEFAULT_CONFIG.foldReasoning,
52
+ foldPlainTurnReasoning: config.foldPlainTurnReasoning ?? DEFAULT_CONFIG.foldPlainTurnReasoning,
53
+ enabled: config.enabled ?? DEFAULT_CONFIG.enabled,
54
+ dryRun: config.dryRun ?? DEFAULT_CONFIG.dryRun
55
+ };
56
+ for (const [key, value] of Object.entries(resolved)) {
57
+ if (key === "enabled" || key === "dryRun" || key === "foldReasoning" || key === "foldPlainTurnReasoning") continue;
58
+ assertInteger(key, value, key === "minFoldChars" ? 300 : key === "minBatchChars" ? 1 : 0);
59
+ }
60
+ if (resolved.staleSteps < 1) throw new Error(`context-fold: staleSteps (${resolved.staleSteps}) must be at least 1`);
61
+ if (resolved.batchSpanSteps < 1) throw new Error(`context-fold: batchSpanSteps (${resolved.batchSpanSteps}) must be at least 1`);
62
+ return deepFreeze(resolved);
63
+ }
64
+ function assertInteger(name, value, minimum) {
65
+ if (!Number.isSafeInteger(value) || value < minimum) throw new Error(`context-fold: ${name} (${String(value)}) must be an integer of at least ${minimum}`);
66
+ }
67
+ /** Recursively freeze a configuration object. */
68
+ function deepFreeze(value) {
69
+ if (typeof value === "object" && value !== null) {
70
+ for (const child of Object.values(value)) deepFreeze(child);
71
+ Object.freeze(value);
72
+ }
73
+ return value;
74
+ }
75
+ // src/format.ts
76
+ /** Fixed prefix identifying a folded node. A `startsWith` test is the idempotency check. */
77
+ const FOLD_MARKER = "[context-fold]";
78
+ /**
79
+ * Whether already-folded content is being inspected.
80
+ *
81
+ * @param text - current model-visible text of a node.
82
+ * @returns true when the node is a fold this plugin produced.
83
+ */
84
+ function isFoldedText(text) {
85
+ return text.startsWith(FOLD_MARKER);
86
+ }
87
+ /**
88
+ * Render one folded node.
89
+ *
90
+ * @param plan - the fold being rendered.
91
+ * @param locator - where this node's full original now lives.
92
+ * @param roster - the batch's other members with their locators (used on the head only).
93
+ * @param total - total member count of the batch, including the head.
94
+ * @returns the replacement content text.
95
+ */
96
+ function renderFoldedText(plan, locator, roster, total) {
97
+ const lines = [
98
+ `${FOLD_MARKER} [tool: ${plan.facts.toolName}] ${formatNumber$2(plan.facts.chars)} chars folded at turn ${plan.turn}, step ${plan.step}.${plan.head ? " (batch head)" : ""}`,
99
+ `Conclusion: ${plan.conclusion}`,
100
+ `Full result: ${locator}`
101
+ ];
102
+ if (plan.head && roster.length > 0) {
103
+ lines.push(`Same batch (${roster.length} more):`);
104
+ for (const entry of roster) lines.push(` - ${entry.toolName} (callId ${entry.callId}): ${entry.locator}`);
105
+ }
106
+ if (!plan.head && plan.headCallId !== void 0) {
107
+ const others = Math.max(0, total - 1);
108
+ lines.push(`Batch head: ${plan.headCallId} (callId) — lists ${others} more result${others === 1 ? "" : "s"} from this batch.`);
109
+ }
110
+ return lines.join("\n");
111
+ }
112
+ /** Group digits for readability. */
113
+ function formatNumber$2(value) {
114
+ return value.toLocaleString("en-US");
115
+ }
116
+ // src/rules.ts
117
+ const NOTICE_LOCATION = " Full formatted result stored at: ";
118
+ /** Longest conclusion line before ellipsis. */
119
+ const LINE_BUDGET = 120;
120
+ /** Longest total conclusion body. */
121
+ const BODY_BUDGET = 320;
122
+ const SHELL_TOOLS = /* @__PURE__ */ new Set([
123
+ "ops_bash",
124
+ "pwsh",
125
+ "bash",
126
+ "shell",
127
+ "ops_pwsh",
128
+ "wsl"
129
+ ]);
130
+ const READ_TOOLS = /* @__PURE__ */ new Set([
131
+ "read",
132
+ "ops_read_files",
133
+ "ops_inspect_local_file",
134
+ "wsl-read",
135
+ "ops_read_pdf",
136
+ "ops_read_docx"
137
+ ]);
138
+ const SEARCH_TOOLS = /* @__PURE__ */ new Set([
139
+ "grep",
140
+ "glob",
141
+ "ops_grep",
142
+ "ops_glob",
143
+ "ops_result",
144
+ "wsl-grep",
145
+ "ops_discover_tools"
146
+ ]);
147
+ const WEB_TOOLS = /* @__PURE__ */ new Set([
148
+ "web_fetch",
149
+ "web_search",
150
+ "platform_search",
151
+ "domain_search",
152
+ "research",
153
+ "web_tools",
154
+ "video_search",
155
+ "multi_search",
156
+ "advanced_search"
157
+ ]);
158
+ /**
159
+ * Count Unicode code points without splitting surrogate pairs, matching the
160
+ * pruner's measurement so sizes are comparable across plugins.
161
+ *
162
+ * @param text - text to measure.
163
+ * @returns the code-point count.
164
+ */
165
+ function codePointLength(text) {
166
+ return Array.from(text).length;
167
+ }
168
+ /**
169
+ * Model-visible text of one result, including image/file placeholders.
170
+ *
171
+ * @param content - ordered result blocks.
172
+ * @returns concatenated text with a stable placeholder for non-text blocks.
173
+ */
174
+ function textOf(content) {
175
+ let text = "";
176
+ for (const block of content) if (block.type === "text") text += block.text;
177
+ else if (block.type === "image") text += `\n[image ${block.attachment.attachmentId}]\n`;
178
+ else if (block.type === "file") text += `\n[file ${block.attachment.attachmentId}]\n`;
179
+ return text;
180
+ }
181
+ /**
182
+ * Whether a result can be folded without losing anything structural.
183
+ *
184
+ * Image and unknown blocks are never folded: their placeholder text is not the
185
+ * content, and phase 1 must not trade an attachment for a sentence about it.
186
+ *
187
+ * @param content - ordered result blocks.
188
+ * @returns true when every block is text.
189
+ */
190
+ function isFoldableContent(content) {
191
+ return content.every((block) => block.type === "text");
192
+ }
193
+ /**
194
+ * Find the spill notice an already-bounded result carries, so folding reuses
195
+ * that locator instead of spilling the same text twice (design §6.5).
196
+ *
197
+ * @param text - result text.
198
+ * @returns the parsed notice, or undefined when the result was never spilled.
199
+ */
200
+ function parseSpillNotice(text) {
201
+ const at = text.indexOf(NOTICE_LOCATION);
202
+ if (at < 0) return void 0;
203
+ const rest = text.slice(at + 34);
204
+ const end = rest.indexOf(". ");
205
+ const locator = (end < 0 ? rest : rest.slice(0, end)).replace(/\)\s*$/, "").trim();
206
+ if (locator === "") return void 0;
207
+ const head = text.slice(0, at);
208
+ const bytes = /([\d,]+)\s+bytes\s+omitted/i.exec(head)?.[1];
209
+ return {
210
+ locator,
211
+ bytes: bytes === void 0 ? void 0 : Number(bytes.replaceAll(",", ""))
212
+ };
213
+ }
214
+ /**
215
+ * Produce the one-line conclusion for a result.
216
+ *
217
+ * The rule is chosen by tool family, mirroring design §6.3, and falls back to
218
+ * the first line plus a size for anything unlisted. A conclusion is always
219
+ * shorter than the result it replaces; callers still compare sizes before
220
+ * folding, because a pathological result can defeat any rule.
221
+ *
222
+ * @param facts - candidate facts.
223
+ * @returns the conclusion body (without the fold header), or undefined when the
224
+ * result carries no foldable text.
225
+ */
226
+ function conclude(facts) {
227
+ const text = textOf(facts.content);
228
+ if (text.trim() === "") return void 0;
229
+ const args = parseArgs(facts.args);
230
+ let body;
231
+ if (facts.toolName.startsWith("cordis_inspect")) body = inspectConclusion(text);
232
+ else if (SHELL_TOOLS.has(facts.toolName)) body = shellConclusion(text, facts);
233
+ else if (READ_TOOLS.has(facts.toolName)) body = readConclusion(text, args);
234
+ else if (SEARCH_TOOLS.has(facts.toolName)) body = searchConclusion(text, args);
235
+ else if (WEB_TOOLS.has(facts.toolName)) body = webConclusion(text, args);
236
+ else body = fallbackConclusion(text);
237
+ const capped = cap(body, BODY_BUDGET);
238
+ return capped === "" ? void 0 : capped;
239
+ }
240
+ /** Shell: exit code, first stdout line, last stderr line, line count. */
241
+ function shellConclusion(text, facts) {
242
+ const parts = [];
243
+ const exit = /\[exit code:\s*(-?\d+)\s*\]/i.exec(text);
244
+ if (exit !== null) parts.push(`exit ${exit[1]}`);
245
+ const stderrAt = /^\[stderr\]$/m.exec(text);
246
+ const stdout = (stderrAt === null ? text : text.slice(0, stderrAt.index)).split("\n").map((line) => line.trim()).filter((line) => line !== "" && !/^\[cwd:/.test(line) && !/^\[exit code:/.test(line));
247
+ if (stdout[0] !== void 0) parts.push(`first ${quote(cap(stdout[0], LINE_BUDGET))}`);
248
+ if (stderrAt !== null) {
249
+ const tail = lastNonEmpty(text.slice(stderrAt.index + stderrAt[0].length).split("\n").filter((line) => !/^\[exit code:/.test(line.trim())).join("\n"));
250
+ if (tail !== "") parts.push(`stderr ${quote(cap(tail, LINE_BUDGET))}`);
251
+ }
252
+ parts.push(`${countLines(text)} lines, ${formatNumber$1(facts.chars)} chars`);
253
+ return parts.join("; ");
254
+ }
255
+ /** Read: path, line range, first content line. */
256
+ function readConclusion(text, args) {
257
+ const parts = [];
258
+ const path = args.path ?? args.file_path ?? /^=== FILE:\s*(.+?)(?:\s+\(|$)/m.exec(text)?.[1];
259
+ if (path !== void 0) parts.push(`path ${quote(cap(path.trim(), LINE_BUDGET))}`);
260
+ const range = /\(Lines (\d+)-(\d+) of (\d+)\)/.exec(text);
261
+ if (range !== null) parts.push(`lines ${range[1]}-${range[2]} of ${range[3]}`);
262
+ const first = firstNonEmpty(text.split("\n").filter((line) => !/^=== FILE:/.test(line) && !/^\[Notice:/.test(line)));
263
+ if (first !== "") parts.push(`first ${quote(cap(first, LINE_BUDGET))}`);
264
+ parts.push(`${countLines(text)} lines total`);
265
+ return parts.join("; ");
266
+ }
267
+ /** Search: pattern, hit count, first few matched paths. */
268
+ function searchConclusion(text, args) {
269
+ const parts = [];
270
+ const pattern = args.pattern ?? args.query;
271
+ if (pattern !== void 0) parts.push(`pattern ${quote(cap(pattern, LINE_BUDGET))}`);
272
+ const files = /* @__PURE__ */ new Set();
273
+ for (const match of text.matchAll(/^=== FILE:\s*(.+?)(?:\s+\(|$)/gm)) files.add(match[1].trim());
274
+ const hits = text.split("\n").filter((line) => line.trim() !== "" && !/^\[Notice:/.test(line)).length;
275
+ parts.push(`${hits} non-empty lines`);
276
+ if (files.size > 0) {
277
+ const shown = [...files].slice(0, 3);
278
+ parts.push(`${files.size} files: ${shown.join(", ")}`);
279
+ }
280
+ if (files.size === 0) {
281
+ const firstPath = text.split("\n").map((line) => line.trim()).find((line) => line !== "" && !/^\[Notice:/.test(line));
282
+ if (firstPath !== void 0) parts.push(`first ${quote(cap(firstPath, LINE_BUDGET))}`);
283
+ }
284
+ return parts.join("; ");
285
+ }
286
+ /** Inspect: section headings only, never the declaration bodies. */
287
+ function inspectConclusion(text) {
288
+ const headings = [];
289
+ for (const match of text.matchAll(/^#{1,6}\s+(.+)$/gm)) {
290
+ const heading = match[1].trim();
291
+ if (heading !== "" && !headings.includes(heading)) headings.push(heading);
292
+ if (headings.length >= 5) break;
293
+ }
294
+ if (headings.length > 0) return `sections: ${headings.join(", ")}; ${countLines(text)} lines`;
295
+ return fallbackConclusion(text);
296
+ }
297
+ /** Web: URL or query, then the first meaningful line. */
298
+ function webConclusion(text, args) {
299
+ const parts = [];
300
+ const url = args.url ?? args.query;
301
+ if (url !== void 0) parts.push(`url ${quote(cap(url, LINE_BUDGET))}`);
302
+ const first = firstNonEmpty(text.split("\n").filter((line) => !/^#{1,6}\s/.test(line)));
303
+ if (first !== "") parts.push(`first ${quote(cap(first, LINE_BUDGET))}`);
304
+ parts.push(`${countLines(text)} lines, ${formatNumber$1(codePointLength(text))} chars`);
305
+ return parts.join("; ");
306
+ }
307
+ /** Unknown tools: first line plus the measured size. */
308
+ function fallbackConclusion(text) {
309
+ return `first ${quote(cap(firstNonEmpty(text.split("\n")), LINE_BUDGET))}; ${countLines(text)} lines, ${formatNumber$1(codePointLength(text))} chars`;
310
+ }
311
+ /** Parse the small argument subset the rules quote, tolerating malformed JSON. */
312
+ function parseArgs(raw) {
313
+ if (raw === void 0 || raw.trim() === "") return {};
314
+ let parsed;
315
+ try {
316
+ parsed = JSON.parse(raw);
317
+ } catch {
318
+ return {};
319
+ }
320
+ if (typeof parsed !== "object" || parsed === null) return {};
321
+ const out = {};
322
+ for (const key of [
323
+ "path",
324
+ "file_path",
325
+ "pattern",
326
+ "query",
327
+ "url",
328
+ "command",
329
+ "description"
330
+ ]) {
331
+ const value = parsed[key];
332
+ if (typeof value === "string" && value !== "") out[key] = value;
333
+ }
334
+ return out;
335
+ }
336
+ /** First non-empty line, trimmed. */
337
+ function firstNonEmpty(lines) {
338
+ for (const line of lines) {
339
+ const trimmed = line.trim();
340
+ if (trimmed !== "") return trimmed;
341
+ }
342
+ return "";
343
+ }
344
+ /** Last non-empty line, trimmed. */
345
+ function lastNonEmpty(text) {
346
+ const lines = text.split("\n");
347
+ for (let index = lines.length - 1; index >= 0; index--) {
348
+ const trimmed = lines[index].trim();
349
+ if (trimmed !== "") return trimmed;
350
+ }
351
+ return "";
352
+ }
353
+ /** Line count of a text. */
354
+ function countLines(text) {
355
+ if (text === "") return 0;
356
+ return text.split("\n").length;
357
+ }
358
+ /** Cap by code point with a trailing ellipsis. */
359
+ function cap(text, budget) {
360
+ const points = Array.from(text);
361
+ return points.length <= budget ? text : `${points.slice(0, budget - 1).join("")}…`;
362
+ }
363
+ /** Render a quoted, single-line excerpt. */
364
+ function quote(text) {
365
+ return `"${text.replaceAll("\"", "'").replaceAll("\n", " ")}"`;
366
+ }
367
+ /** Group digits for readability; the value itself is unchanged. */
368
+ function formatNumber$1(value) {
369
+ return value.toLocaleString("en-US");
370
+ }
371
+ // src/reasoning.ts
372
+ /** Marker opening a folded reasoning block. Must be distinctive: `startsWith` is the idempotency test. */
373
+ const REASONING_MARKER = `${FOLD_MARKER} [reasoning]`;
374
+ /** Durable record type carrying one reasoning-fold decision. */
375
+ const REASONING_EVENT = "plugin:context-fold/reasoning";
376
+ /**
377
+ * Prefix kept from the head of a folded reasoning block.
378
+ *
379
+ * Reasoning carries its conclusion at the end (the model reasons towards a
380
+ * decision), but the opening line names the intent. Keeping a head and a tail
381
+ * would double the per-block overhead for little added value, so the policy
382
+ * keeps a head only and leaves the full text reachable through the spill path
383
+ * recorded in the block itself. Zero is not allowed: a bare pointer with no
384
+ * conclusion is the failure mode the design forbids.
385
+ */
386
+ const HEAD_CHARS = 240;
387
+ /** Smallest reasoning block worth folding. Below this the pointer costs as much as the text. */
388
+ const MIN_REASONING_CHARS = 1200;
389
+ /**
390
+ * Whether a reasoning block is already folded.
391
+ *
392
+ * @param text - reasoning text.
393
+ * @returns true for a block this plugin produced.
394
+ */
395
+ function isFoldedReasoning(text) {
396
+ return text.startsWith(REASONING_MARKER);
397
+ }
398
+ /**
399
+ * Whether a message is a tool-call turn, i.e. a turn that requested tool work.
400
+ *
401
+ * This is a **policy** filter, not a token-accounting claim. Reasoning is re-sent
402
+ * differently per route:
403
+ *
404
+ * - `dsh-llm-deepseek`'s `serializeAssistant` emits `reasoning_content` only when
405
+ * `toolCalls.length > 0` ("it is ignored on plain turns, so we drop it there"),
406
+ * so a plain turn's reasoning is free on that route.
407
+ * - `dsh-router-core`'s `wireMessages` — the route this profile actually runs —
408
+ * does **not** gate it: `...reasoning.length > 0 ? { reasoning_content } : {}`
409
+ * is unconditional, so plain-turn reasoning IS re-sent and does cost tokens.
410
+ *
411
+ * Restricting to tool-call turns therefore leaves real bytes on the table on the
412
+ * router route. It stays the default only because the conservative shape matches
413
+ * phase 1's "completed stage" intent and the measured tool-call share dominates;
414
+ * `foldPlainTurnReasoning` widens it for routes that replay plain-turn reasoning.
415
+ *
416
+ * @param content - assistant content blocks.
417
+ * @returns true when the message carries at least one tool call.
418
+ */
419
+ function isToolCallTurn(content) {
420
+ return content.some((block) => block.type === "tool-call");
421
+ }
422
+ /**
423
+ * Build the replacement text for one reasoning block.
424
+ *
425
+ * The head is preserved verbatim, so no information is invented and the
426
+ * conclusion-like opening of the thought stays visible. The character count and
427
+ * the full-length note make the omission visible, which is the difference
428
+ * between a cautious model and a fluent one.
429
+ *
430
+ * When a `locator` is supplied the folded text inlines it, so the omission is
431
+ * **recoverable**: `read`/`grep` on that path returns the original reasoning.
432
+ * Without it the note is a pointer to nothing — it tells the model detail is
433
+ * missing while offering no way to fetch it — which is why the fold path always
434
+ * tries to spill first (design §12.7).
435
+ *
436
+ * @param text - original reasoning text.
437
+ * @param locator - spill path holding the full original, when one was written.
438
+ * @returns folded text, or undefined when folding would not shorten it.
439
+ */
440
+ function foldReasoningText(text, locator) {
441
+ if (isFoldedReasoning(text)) return void 0;
442
+ const chars = codePointLength(text);
443
+ if (chars < MIN_REASONING_CHARS) return void 0;
444
+ const head = Array.from(text).slice(0, HEAD_CHARS).join("").trimEnd();
445
+ const omitted = `[… ${formatNumber(chars - codePointLength(head))} chars of earlier reasoning omitted]`;
446
+ const folded = [
447
+ `${REASONING_MARKER} ${formatNumber(chars)} chars folded.`,
448
+ head,
449
+ locator === void 0 ? omitted : `${omitted} Full text: ${locator} — use read (offset/limit) or grep on that path.`
450
+ ].join("\n");
451
+ return codePointLength(folded) < chars ? folded : void 0;
452
+ }
453
+ /** Group digits for readability. */
454
+ function formatNumber(value) {
455
+ return value.toLocaleString("en-US");
456
+ }
457
+ /**
458
+ * Plan reasoning folds for one snapshot.
459
+ *
460
+ * A node qualifies when it is a tool-call turn (where reasoning is re-sent most,
461
+ * and where the measurable win is), when it is old enough by the same age rules
462
+ * phase 1 uses, and when at least one reasoning block is large enough to shrink.
463
+ *
464
+ * The plan itself does not spill: writing to the store is side-effectful, so it
465
+ * happens in `recordReasoningFolds`, which fills in the locators and re-derives
466
+ * the conclusion text with them.
467
+ *
468
+ * @param candidates - current assistant nodes, in surface order.
469
+ * @param currentTurn - turn being evaluated.
470
+ * @param currentStep - step being evaluated.
471
+ * @param config - resolved configuration.
472
+ * @returns plans in surface order.
473
+ */
474
+ function planReasoningFolds(candidates, currentTurn, currentStep, config) {
475
+ const plans = [];
476
+ for (const node of candidates) {
477
+ if (!isToolCallTurn(node.content) && !config.foldPlainTurnReasoning) continue;
478
+ if (!(node.turn < currentTurn || currentStep - node.step >= config.staleSteps)) continue;
479
+ if (node.step > currentStep - config.keepRecentSteps) continue;
480
+ const indexes = [];
481
+ const chars = [];
482
+ let removed = 0;
483
+ for (const [index, block] of node.content.entries()) {
484
+ if (block.type !== "reasoning") continue;
485
+ const folded = foldReasoningText(block.text);
486
+ if (folded === void 0) continue;
487
+ indexes.push(index);
488
+ chars.push(codePointLength(block.text));
489
+ removed += codePointLength(block.text) - codePointLength(folded);
490
+ }
491
+ if (indexes.length === 0) continue;
492
+ plans.push({
493
+ seq: node.seq,
494
+ indexes,
495
+ chars,
496
+ charsRemoved: removed
497
+ });
498
+ }
499
+ return plans;
500
+ }
501
+ /**
502
+ * Project one assistant message with selected reasoning blocks folded.
503
+ *
504
+ * Message identity and every other block are preserved verbatim, which is what
505
+ * `replayedAssistant` and `foreignAssistant` both require: the block count and
506
+ * block types are unchanged, so even a signed route would still validate.
507
+ *
508
+ * @param message - message to project.
509
+ * @param indexes - reasoning block indexes to fold, strictly increasing.
510
+ * @returns an immutable projected copy.
511
+ * @throws when an index is out of range or does not name a reasoning block.
512
+ */
513
+ function projectReasoning(message, indexes, locators) {
514
+ const content = message.content.map((block, index) => {
515
+ if (!indexes.includes(index)) return block;
516
+ if (block.type !== "reasoning") throw new Error(`context-fold: block ${index} is not reasoning`);
517
+ const locator = locators?.[indexes.indexOf(index)];
518
+ const folded = foldReasoningText(block.text, locator);
519
+ if (folded === void 0) throw new Error(`context-fold: block ${index} cannot be folded again`);
520
+ return {
521
+ ...block,
522
+ text: folded
523
+ };
524
+ });
525
+ return freezeCopy({
526
+ ...message,
527
+ content
528
+ });
529
+ }
530
+ /** Deep-freeze a detached copy, matching the host's immutability contract for projected messages. */
531
+ function freezeCopy(value) {
532
+ const freeze = (node) => {
533
+ if (typeof node !== "object" || node === null) return;
534
+ for (const child of Object.values(node)) freeze(child);
535
+ Object.freeze(node);
536
+ };
537
+ const copy = structuredClone(value);
538
+ freeze(copy);
539
+ return copy;
540
+ }
541
+ /**
542
+ * Collect the assistant nodes a decision event may target.
543
+ *
544
+ * @param session - session to read.
545
+ * @returns candidates in surface order.
546
+ */
547
+ function reasoningCandidates(session) {
548
+ const candidates = [];
549
+ for (const seq of [...session.surface.nodes]) {
550
+ const event = session.eventAt(seq);
551
+ if (event?.type !== "assistant/message") continue;
552
+ const message = session.deriveEventMessage(event);
553
+ if (message === null || message.role !== "assistant") continue;
554
+ candidates.push({
555
+ seq,
556
+ turn: event.data.turn,
557
+ step: event.data.step,
558
+ content: message.content
559
+ });
560
+ }
561
+ return candidates;
562
+ }
563
+ /**
564
+ * Append one decision record covering the planned folds.
565
+ *
566
+ * The record is self-describing: it stores the exact content indexes it covers,
567
+ * so replay never has to re-derive *which* blocks were chosen from a count. The
568
+ * record is durable and pure — replaying the log through the registered
569
+ * projection reproduces exactly the same request content.
570
+ *
571
+ * @param session - session to extend.
572
+ * @param plans - folds to record.
573
+ * @param spill - optional spill store; when supplied, every folded block's full
574
+ * text is written first and its locator is recorded so the projection can
575
+ * inline a real recovery path.
576
+ * @returns the record's sequence number.
577
+ */
578
+ async function recordReasoningFolds(session, plans, spill) {
579
+ const targets = [];
580
+ for (const plan of plans) {
581
+ const locators = [];
582
+ const event = session.eventAt(plan.seq);
583
+ const message = event === void 0 ? null : session.deriveEventMessage(event);
584
+ for (const [position, index] of plan.indexes.entries()) {
585
+ const block = message !== null && message.role === "assistant" ? message.content[index] : void 0;
586
+ const original = block !== void 0 && block.type === "reasoning" ? block.text : void 0;
587
+ if (spill === void 0 || original === void 0) {
588
+ locators.push(void 0);
589
+ continue;
590
+ }
591
+ try {
592
+ const saved = await spill.saveText({
593
+ owner: { sessionId: session.header.id },
594
+ source: {
595
+ kind: "tool",
596
+ toolName: "reasoning",
597
+ callId: `reasoning-${String(plan.seq)}-${String(index)}`,
598
+ label: "reasoning"
599
+ },
600
+ suggestedName: `reasoning-${String(plan.seq)}-${String(index)}.txt`,
601
+ content: original
602
+ });
603
+ locators.push(saved.locator);
604
+ } catch {
605
+ locators.push(void 0);
606
+ }
607
+ }
608
+ targets.push({
609
+ seq: plan.seq,
610
+ indexes: [...plan.indexes],
611
+ chars: [...plan.chars],
612
+ ...locators.some((locator) => locator !== void 0) ? { locators } : {}
613
+ });
614
+ }
615
+ return appendPluginRecord(session, REASONING_EVENT, { targets });
616
+ }
617
+ /**
618
+ * Read every decision record in a log.
619
+ *
620
+ * Records are read back through {@link pluginRecordOf}, which validates that the
621
+ * type carries the `plugin:` prefix; the owner still validates `data` before use,
622
+ * because a restored record carries whatever an earlier build wrote.
623
+ *
624
+ * @param session - session to inspect.
625
+ * @returns decisions in log order, with unreadable payloads dropped.
626
+ */
627
+ function reasoningDecisions(session) {
628
+ const decisions = [];
629
+ for (const event of session.snapshotEvents()) {
630
+ if (event.type !== "plugin:context-fold/reasoning") continue;
631
+ const record = pluginRecordOf(event);
632
+ if (record === void 0) continue;
633
+ const data = record.data;
634
+ if (typeof data !== "object" || data === null || !Array.isArray(data.targets)) continue;
635
+ decisions.push(data);
636
+ }
637
+ return decisions;
638
+ }
639
+ /**
640
+ * The projection definition registered with `ctx.sessions`.
641
+ *
642
+ * It is deliberately **tolerant of stale targets** rather than strict: a
643
+ * decision is durable, but the surface it referred to is not frozen — a
644
+ * `compaction` replacement can remove a node the decision named. Throwing there
645
+ * would make every later `deriveMessages()` fail and brick the session, which
646
+ * violates the design's "a session never errors" anti-goal. A target whose node
647
+ * is no longer current, or whose reasoning blocks are already folded, is
648
+ * therefore skipped: the fold it described is simply no longer applicable.
649
+ *
650
+ * Each target's `chars` array is only a length hint — how many reasoning blocks
651
+ * that decision intended to fold. The projection folds that many still-unfolded
652
+ * reasoning blocks, which keeps replay correct even after a later decision has
653
+ * already folded a sibling block.
654
+ */
655
+ /**
656
+ * The projection definition registered with `ctx.sessions`.
657
+ *
658
+ * **Why a cast is needed, and why it is correct:** `SessionMessageProjection<T>`
659
+ * constrains `T extends keyof SessionEventMap`, so the host's *types* do not
660
+ * admit a `plugin:` record type. The *runtime* does: `SurfaceManager` resolves a
661
+ * projection with `projections.find(item => item.type === event.type)` **before**
662
+ * it looks at `surfaceOp`, so any committed event type can drive one. The cast is
663
+ * therefore only widening a compile-time parameter that the implementation
664
+ * already accepts.
665
+ *
666
+ * A projection must **not** be registered for a surface event type
667
+ * (`assistant/message`, `user/message`, `tool/result`): `planSurfaceEvent`
668
+ * returns a `project` plan *instead of* an append/replace plan, and
669
+ * `applySurfacePlan` only pushes nodes for `append`, so such a registration would
670
+ * silently drop every message of that type off the surface. A non-surface
671
+ * plugin-owned record is the only correct carrier.
672
+ *
673
+ * It is deliberately **tolerant of stale targets** rather than strict: a
674
+ * decision is durable, but the surface it referred to is not frozen — a
675
+ * `compaction` replacement can remove a node the decision named. Throwing there
676
+ * would make every later derivation fail and brick the session, which violates
677
+ * the design's "a session never errors" anti-goal.
678
+ */
679
+ const reasoningProjection = {
680
+ type: REASONING_EVENT,
681
+ project(event, context) {
682
+ const data = event.data;
683
+ if (typeof data !== "object" || data === null || !Array.isArray(data.targets) || data.targets.length === 0) return /* @__PURE__ */ new Map();
684
+ const nodes = new Set(context.nodes);
685
+ const updates = /* @__PURE__ */ new Map();
686
+ for (const target of data.targets) {
687
+ if (typeof target !== "object" || target === null) continue;
688
+ if (!Number.isSafeInteger(target.seq) || !Array.isArray(target.indexes) || target.indexes.length === 0) continue;
689
+ if (!nodes.has(target.seq) || updates.has(target.seq)) continue;
690
+ const source = context.events[target.seq - context.baseSeq];
691
+ if (source?.type !== "assistant/message") continue;
692
+ const message = context.messages.get(target.seq) ?? source.data.message;
693
+ if (message.role !== "assistant") continue;
694
+ const indexes = [];
695
+ const locators = [];
696
+ for (const [position, index] of target.indexes.entries()) {
697
+ const block = message.content[index];
698
+ if (block === void 0 || block.type !== "reasoning") continue;
699
+ if (isFoldedReasoning(block.text)) continue;
700
+ if (foldReasoningText(block.text) === void 0) continue;
701
+ indexes.push(index);
702
+ locators.push(target.locators?.[position]);
703
+ }
704
+ if (indexes.length === 0) continue;
705
+ updates.set(target.seq, projectReasoning(message, indexes, locators));
706
+ }
707
+ return updates;
708
+ }
709
+ };
710
+ // src/plan.ts
711
+ /**
712
+ * Decide what to fold for one snapshot.
713
+ *
714
+ * Candidate rules, in the order they are checked (design §6.1): error results
715
+ * never fold, recently-used results never fold, already-folded nodes never
716
+ * fold, nodes still cited by a later event never fold, and a node folds only
717
+ * when it is at least `minFoldChars` and a conclusion exists that is smaller
718
+ * than the result.
719
+ *
720
+ * @param input - one detached snapshot plus configuration.
721
+ * @returns the decision; an empty `batches` array means "do nothing".
722
+ */
723
+ function planFolds(input) {
724
+ const { config } = input;
725
+ const skipped = [];
726
+ const candidates = [];
727
+ for (const node of input.nodes) {
728
+ const text = textOfContent(node.content);
729
+ const chars = codePointLength(text);
730
+ const skip = (reason) => {
731
+ skipped.push({
732
+ seq: node.seq,
733
+ toolName: node.toolName,
734
+ chars,
735
+ reason
736
+ });
737
+ };
738
+ if (node.isError) {
739
+ skip("error");
740
+ continue;
741
+ }
742
+ if (isFoldedText(text)) {
743
+ skip("folded");
744
+ continue;
745
+ }
746
+ if (!isFoldableContent(node.content)) {
747
+ skip("unfoldable");
748
+ continue;
749
+ }
750
+ if (!(node.turn < input.currentTurn || input.currentStep - node.step >= config.staleSteps)) {
751
+ skip("alive");
752
+ continue;
753
+ }
754
+ if (node.step > input.currentStep - config.keepRecentSteps) {
755
+ skip("recent");
756
+ continue;
757
+ }
758
+ if (isReferencedAfter(node.callId, node.seq, input.mentions)) {
759
+ skip("referenced");
760
+ continue;
761
+ }
762
+ if (chars < config.minFoldChars) {
763
+ skip("small");
764
+ continue;
765
+ }
766
+ if (conclude(factsOf(node)) === void 0) {
767
+ skip("no-conclusion");
768
+ continue;
769
+ }
770
+ candidates.push(node);
771
+ }
772
+ const candidateChars = candidates.reduce((total, node) => total + codePointLength(textOfContent(node.content)), 0);
773
+ const batches = [];
774
+ for (const group of groupByBatch(candidates, config.batchSpanSteps)) {
775
+ const chars = group.reduce((total, node) => total + codePointLength(textOfContent(node.content)), 0);
776
+ if (chars < config.minBatchChars) continue;
777
+ const ordered = [...group].sort((a, b) => {
778
+ const size = codePointLength(textOfContent(b.content)) - codePointLength(textOfContent(a.content));
779
+ return size !== 0 ? size : a.seq - b.seq;
780
+ });
781
+ const head = ordered[0];
782
+ if (head === void 0) continue;
783
+ batches.push({
784
+ key: `${group[0].turn}:${group[0].step}-${group[group.length - 1].step}`,
785
+ callId: head.callId,
786
+ members: ordered.map((node) => ({
787
+ seq: node.seq,
788
+ facts: factsOf(node),
789
+ turn: node.turn,
790
+ step: node.step,
791
+ conclusion: conclude(factsOf(node)) ?? "",
792
+ original: textOfContent(node.content),
793
+ head: node.seq === head.seq,
794
+ ...node.seq === head.seq ? {} : { headCallId: head.callId }
795
+ })),
796
+ chars
797
+ });
798
+ }
799
+ return {
800
+ batches,
801
+ skipped,
802
+ candidateChars
803
+ };
804
+ }
805
+ /** Detect a node that a later event still cites (design §6.2). */
806
+ function isReferencedAfter(callId, seq, mentions) {
807
+ for (const mention of mentions) if (mention.seq > seq && mention.text.includes(callId)) return true;
808
+ return false;
809
+ }
810
+ /** Group candidates into batches by turn, splitting long turns by step range. */
811
+ function groupByBatch(nodes, batchSpanSteps) {
812
+ const byTurn = /* @__PURE__ */ new Map();
813
+ for (const node of nodes) {
814
+ const list = byTurn.get(node.turn);
815
+ if (list === void 0) byTurn.set(node.turn, [node]);
816
+ else list.push(node);
817
+ }
818
+ const groups = [];
819
+ for (const turn of [...byTurn.keys()].sort((a, b) => a - b)) {
820
+ const sorted = [...byTurn.get(turn)].sort((a, b) => a.step - b.step || a.seq - b.seq);
821
+ const first = sorted[0];
822
+ if (first === void 0) continue;
823
+ let bucket = [];
824
+ let bucketStart = first.step;
825
+ for (const node of sorted) {
826
+ if (node.step - bucketStart >= batchSpanSteps && bucket.length > 0) {
827
+ groups.push(bucket);
828
+ bucket = [];
829
+ bucketStart = node.step;
830
+ }
831
+ bucket.push(node);
832
+ }
833
+ if (bucket.length > 0) groups.push(bucket);
834
+ }
835
+ return groups;
836
+ }
837
+ /** Build facts from a snapshot. */
838
+ function factsOf(node) {
839
+ return {
840
+ toolName: node.toolName,
841
+ callId: node.callId,
842
+ content: node.content,
843
+ chars: codePointLength(textOfContent(node.content)),
844
+ ...node.args === void 0 ? {} : { args: node.args }
845
+ };
846
+ }
847
+ /** Concatenate text blocks of one result. */
848
+ function textOfContent(content) {
849
+ let text = "";
850
+ for (const block of content) if (block.type === "text") text += block.text;
851
+ return text;
852
+ }
853
+ // src/session.ts
854
+ /**
855
+ * Read the current model-visible state of one node.
856
+ *
857
+ * @param session - session owning the node.
858
+ * @param seq - a surface seq taken from `session.surface.nodes` at evaluation time.
859
+ * @returns the node, or undefined when the seq no longer resolves to a tool result.
860
+ */
861
+ function currentNode(session, seq) {
862
+ const event = session.eventAt(seq);
863
+ if (event === void 0) return void 0;
864
+ const message = session.deriveEventMessage(event);
865
+ if (message === null || message.role !== "tool") return void 0;
866
+ if (event.type !== "tool/result") return void 0;
867
+ return {
868
+ event,
869
+ message,
870
+ text: textOf$1(message.content)
871
+ };
872
+ }
873
+ /**
874
+ * Build the detached evaluation input for one moment in the session.
875
+ *
876
+ * Surface seqs are always sourced fresh from `session.surface.nodes`, never
877
+ * cached across evaluations (design §6.7), because a replacement splices the new
878
+ * seq over the old one and the old seq leaves the surface.
879
+ *
880
+ * @param session - session to read.
881
+ * @returns a snapshot that does not reference live session objects.
882
+ */
883
+ function snapshotInput(session) {
884
+ const nodes = [];
885
+ const calls = /* @__PURE__ */ new Map();
886
+ const mentions = [];
887
+ for (const event of session.snapshotEvents()) switch (event.type) {
888
+ case "tool/call":
889
+ calls.set(event.data.callId, {
890
+ name: event.data.name,
891
+ args: event.data.arguments
892
+ });
893
+ mentions.push({
894
+ seq: event.seq,
895
+ text: `${event.data.callId} ${event.data.arguments}`
896
+ });
897
+ break;
898
+ case "assistant/message": {
899
+ let text = "";
900
+ for (const block of event.data.message.content) if (block.type === "text") text += block.text;
901
+ else if (block.type === "tool-call") {
902
+ text += `${block.id} ${block.name} ${block.arguments}`;
903
+ if (!calls.has(block.id)) calls.set(block.id, {
904
+ name: block.name,
905
+ args: block.arguments
906
+ });
907
+ }
908
+ if (text !== "") mentions.push({
909
+ seq: event.seq,
910
+ text
911
+ });
912
+ break;
913
+ }
914
+ case "user/message": {
915
+ const text = textOf$1(event.data.content);
916
+ if (text !== "") mentions.push({
917
+ seq: event.seq,
918
+ text
919
+ });
920
+ break;
921
+ }
922
+ }
923
+ for (const seq of [...session.surface.nodes]) {
924
+ const current = currentNode(session, seq);
925
+ if (current === void 0) continue;
926
+ const { event, message } = current;
927
+ if (event.type !== "tool/result") continue;
928
+ const call = calls.get(message.toolCallId);
929
+ nodes.push({
930
+ seq,
931
+ turn: event.data.turn,
932
+ step: event.data.step,
933
+ toolName: call?.name ?? "unknown",
934
+ callId: message.toolCallId,
935
+ ...call === void 0 ? {} : { args: call.args },
936
+ content: message.content,
937
+ isError: message.isError === true
938
+ });
939
+ }
940
+ return {
941
+ nodes,
942
+ mentions
943
+ };
944
+ }
945
+ /**
946
+ * Plan one evaluation without touching the session.
947
+ *
948
+ * @param session - session to read.
949
+ * @param currentTurn - the turn being evaluated.
950
+ * @param currentStep - the step being evaluated.
951
+ * @param config - resolved configuration.
952
+ * @returns the decision.
953
+ */
954
+ function planFor(session, currentTurn, currentStep, config) {
955
+ const { nodes, mentions } = snapshotInput(session);
956
+ return planFolds({
957
+ nodes,
958
+ mentions,
959
+ currentTurn,
960
+ currentStep,
961
+ config
962
+ });
963
+ }
964
+ /**
965
+ * Execute one planned batch.
966
+ *
967
+ * Per node (design §6.6): resolve the node's *current* message, spill the full
968
+ * current text, append the shadow price, then append the replacement. A node
969
+ * whose spill fails is left untouched — folding without a reachable original
970
+ * would be lossy. A node whose replacement is rejected fails that node only;
971
+ * earlier replacements stay durable and the batch is not retried.
972
+ *
973
+ * @param host - session, spill store, meter, and configuration.
974
+ * @param batch - the batch to fold.
975
+ * @returns landed folds, failures, and the characters removed.
976
+ */
977
+ async function foldBatch(host, batch) {
978
+ const folded = [];
979
+ const failed = [];
980
+ const spilled = /* @__PURE__ */ new Map();
981
+ const roster = [];
982
+ for (const plan of batch.members) try {
983
+ const ref = await spillFor(host, plan);
984
+ spilled.set(plan.seq, ref);
985
+ roster.push({
986
+ toolName: plan.facts.toolName,
987
+ callId: plan.facts.callId,
988
+ locator: ref.locator
989
+ });
990
+ } catch (error) {
991
+ failed.push({
992
+ seq: plan.seq,
993
+ toolName: plan.facts.toolName,
994
+ reason: describe(error)
995
+ });
996
+ }
997
+ const landed = batch.members.filter((plan) => spilled.has(plan.seq));
998
+ for (const plan of landed) {
999
+ const ref = spilled.get(plan.seq);
1000
+ const siblings = roster.filter((entry) => entry.callId !== plan.facts.callId);
1001
+ try {
1002
+ const text = renderFoldedText(plan, ref.locator, siblings, landed.length);
1003
+ if (Array.from(text).length >= plan.facts.chars) {
1004
+ failed.push({
1005
+ seq: plan.seq,
1006
+ toolName: plan.facts.toolName,
1007
+ reason: "the folded node would not be smaller than the result"
1008
+ });
1009
+ continue;
1010
+ }
1011
+ folded.push(applyFold(host, plan, text, ref.locator, ref.bytes));
1012
+ } catch (error) {
1013
+ failed.push({
1014
+ seq: plan.seq,
1015
+ toolName: plan.facts.toolName,
1016
+ reason: describe(error)
1017
+ });
1018
+ }
1019
+ }
1020
+ return {
1021
+ folded,
1022
+ failed,
1023
+ charsRemoved: folded.reduce((total, fold) => total + (fold.charsBefore - fold.charsAfter), 0)
1024
+ };
1025
+ }
1026
+ /**
1027
+ * Spill one node's full current text and return its locator.
1028
+ *
1029
+ * A result that already carries a spill notice is not spilled twice: its
1030
+ * existing locator is reused (design §6.5).
1031
+ *
1032
+ * @param host - host services.
1033
+ * @param plan - the node to spill.
1034
+ * @returns the locator to cite and the bytes this fold wrote.
1035
+ */
1036
+ async function spillFor(host, plan) {
1037
+ const current = currentNode(host.session, plan.seq);
1038
+ if (current === void 0) throw new Error("the node left the surface before its fold landed");
1039
+ const existing = parseSpillNotice(current.text);
1040
+ if (existing !== void 0) return {
1041
+ locator: existing.locator,
1042
+ bytes: 0
1043
+ };
1044
+ const ref = await host.spillStore.saveText({
1045
+ owner: { sessionId: host.session.header.id },
1046
+ source: {
1047
+ kind: "tool",
1048
+ toolName: plan.facts.toolName,
1049
+ callId: current.message.toolCallId,
1050
+ label: "result"
1051
+ },
1052
+ suggestedName: `${plan.facts.toolName}.txt`,
1053
+ content: current.text
1054
+ });
1055
+ return {
1056
+ locator: ref.locator,
1057
+ bytes: ref.bytes
1058
+ };
1059
+ }
1060
+ /**
1061
+ * Apply one fold through the replace protocol.
1062
+ *
1063
+ * @param host - host services.
1064
+ * @param plan - the node to rewrite.
1065
+ * @param text - replacement content text.
1066
+ * @param locator - the spill locator cited in the text.
1067
+ * @param spillBytes - bytes written to reach that locator.
1068
+ * @returns the landed fold record.
1069
+ * @throws when the host rejects the replacement.
1070
+ */
1071
+ function applyFold(host, plan, text, locator, spillBytes) {
1072
+ const current = currentNode(host.session, plan.seq);
1073
+ if (current === void 0) throw new Error("the node left the surface before its fold landed");
1074
+ const content = [{
1075
+ type: "text",
1076
+ text
1077
+ }];
1078
+ const message = freezeMessage({
1079
+ ...current.message,
1080
+ content
1081
+ });
1082
+ host.session.append("compaction/prune", {
1083
+ shadowedRange: {
1084
+ start: plan.seq,
1085
+ end: plan.seq
1086
+ },
1087
+ shadowedSeqs: [plan.seq],
1088
+ shadowedTokenCount: host.estimateMessage(current.message)
1089
+ });
1090
+ return {
1091
+ replacementSeq: host.session.append("tool/result", {
1092
+ ...current.event.data,
1093
+ message
1094
+ }, {
1095
+ surfaceOp: {
1096
+ op: "replace",
1097
+ startSeq: plan.seq,
1098
+ endSeq: plan.seq
1099
+ },
1100
+ sourceEventSeqs: [plan.seq]
1101
+ }).seq,
1102
+ originalSeq: plan.seq,
1103
+ callId: plan.facts.callId,
1104
+ toolName: plan.facts.toolName,
1105
+ charsBefore: plan.facts.chars,
1106
+ charsAfter: Array.from(text).length,
1107
+ locator,
1108
+ spillBytes
1109
+ };
1110
+ }
1111
+ /** Concatenate text blocks. */
1112
+ function textOf$1(content) {
1113
+ let text = "";
1114
+ for (const block of content) if (block.type === "text") text += block.text;
1115
+ return text;
1116
+ }
1117
+ /** Human-readable error text. */
1118
+ function describe(error) {
1119
+ return error instanceof Error ? error.message : String(error);
1120
+ }
1121
+ // src/measure.ts
1122
+ const MARKER_HEADER = /^\[context-fold\] \[tool: ([^\]]+)\] ([\d,]+) chars folded at turn (\d+), step (\d+)\.( \(batch head\))?/;
1123
+ /**
1124
+ * Derive a report from one session's current log.
1125
+ *
1126
+ * @param session - session to inspect.
1127
+ * @returns the fold report.
1128
+ */
1129
+ function reportOf(session) {
1130
+ const events = session.snapshotEvents();
1131
+ const folds = [];
1132
+ for (const seq of [...session.surface.nodes]) {
1133
+ const current = currentNode(session, seq);
1134
+ if (current === void 0) continue;
1135
+ if (!isFoldedText(current.text)) continue;
1136
+ const header = MARKER_HEADER.exec(current.text);
1137
+ const charsAfter = codePointLength(current.text);
1138
+ folds.push({
1139
+ seq,
1140
+ turn: header === null ? 0 : Number(header[3]),
1141
+ step: header === null ? 0 : Number(header[4]),
1142
+ toolName: header?.[1] ?? "unknown",
1143
+ charsBefore: header === null ? charsAfter : Number(header[2].replaceAll(",", "")),
1144
+ charsAfter,
1145
+ reReads: countReReads(events, seq, current.text),
1146
+ head: header?.[5] !== void 0
1147
+ });
1148
+ }
1149
+ return {
1150
+ folds,
1151
+ charsRemoved: folds.reduce((total, fold) => total + Math.max(0, fold.charsBefore - fold.charsAfter), 0),
1152
+ reReads: folds.reduce((total, fold) => total + fold.reReads, 0),
1153
+ rereadNodes: folds.filter((fold) => fold.reReads > 0).length,
1154
+ reasoningFolds: reasoningFoldsOf(session, events)
1155
+ };
1156
+ }
1157
+ /**
1158
+ * Derive reasoning folds from their durable decision records.
1159
+ *
1160
+ * Only the folded blocks are measured, so a message that intentionally keeps a
1161
+ * short reasoning block does not have that block's characters attributed to the
1162
+ * fold. Rows are keyed by `seq` + block index: two decisions may legitimately
1163
+ * cover different blocks of one message, but a repeated decision for the same
1164
+ * block must not be counted twice.
1165
+ *
1166
+ * @param session - session to inspect.
1167
+ * @param events - the session's event log.
1168
+ * @returns one record per folded block, in decision order.
1169
+ */
1170
+ function reasoningFoldsOf(session, events) {
1171
+ const records = [];
1172
+ const seen = /* @__PURE__ */ new Set();
1173
+ for (const event of events) {
1174
+ if (event.type !== "plugin:context-fold/reasoning") continue;
1175
+ const data = event.data;
1176
+ for (const target of data.targets ?? []) {
1177
+ const indexes = target.indexes ?? [];
1178
+ const message = session.eventAt(target.seq);
1179
+ const derived = message === void 0 ? null : session.deriveEventMessage(message);
1180
+ if (derived === null || derived.role !== "assistant") continue;
1181
+ for (const [position, index] of indexes.entries()) {
1182
+ const key = `${target.seq}:${index}`;
1183
+ if (seen.has(key)) continue;
1184
+ const block = derived.content[index];
1185
+ if (block === void 0 || block.type !== "reasoning") continue;
1186
+ seen.add(key);
1187
+ records.push({
1188
+ seq: target.seq,
1189
+ blocks: 1,
1190
+ charsBefore: target.chars?.[position] ?? codePointLength(block.text),
1191
+ charsAfter: codePointLength(block.text)
1192
+ });
1193
+ }
1194
+ }
1195
+ }
1196
+ return records;
1197
+ }
1198
+ /**
1199
+ * Count later tool calls that cite a folded node.
1200
+ *
1201
+ * Two signals, both cheap substring tests over a bounded window: the node's
1202
+ * call id, and its spill locator. Design §6.2 records the known limitation — a
1203
+ * model that re-runs the original tool with identical arguments is not
1204
+ * detected here; the argument-hash refinement is out of phase 1.
1205
+ *
1206
+ * @param events - the session's event log.
1207
+ * @param seq - the folded node's seq.
1208
+ * @param text - the folded node's text, source of the spill locator.
1209
+ * @returns the number of later citing events.
1210
+ */
1211
+ function countReReads(events, seq, text) {
1212
+ const headCallId = /Batch head: (\S+) \(callId\)/.exec(text)?.[1];
1213
+ const locator = /^Full result: (.+)$/m.exec(text)?.[1];
1214
+ const needles = [];
1215
+ if (headCallId !== void 0) needles.push(headCallId);
1216
+ if (locator !== void 0) needles.push(locator.trim());
1217
+ if (needles.length === 0) return 0;
1218
+ let count = 0;
1219
+ for (const event of events) {
1220
+ if (event.seq <= seq) continue;
1221
+ const searched = searchableText(event);
1222
+ if (searched === void 0) continue;
1223
+ if (needles.some((needle) => searched.includes(needle))) count++;
1224
+ }
1225
+ return count;
1226
+ }
1227
+ /** Text one later event contributes to the re-read search. */
1228
+ function searchableText(event) {
1229
+ switch (event.type) {
1230
+ case "tool/call": return `${event.data.callId} ${event.data.name} ${event.data.arguments}`;
1231
+ case "user/message": {
1232
+ let text = "";
1233
+ for (const block of event.data.content) if (block.type === "text") text += block.text;
1234
+ return text;
1235
+ }
1236
+ case "assistant/message": {
1237
+ let text = "";
1238
+ for (const block of event.data.message.content) if (block.type === "text") text += block.text;
1239
+ else if (block.type === "tool-call") text += `${block.id} ${block.name} ${block.arguments}`;
1240
+ return text;
1241
+ }
1242
+ default: return;
1243
+ }
1244
+ }
1245
+ /**
1246
+ * Render the `/context-fold` report.
1247
+ *
1248
+ * @param report - derived report.
1249
+ * @param dryRun - whether the plugin is configured to mutate nothing.
1250
+ * @returns markdown text for the command result.
1251
+ */
1252
+ function renderReport(report, dryRun) {
1253
+ const lines = [`${FOLD_MARKER} ${report.folds.length} folded node(s) on the surface.`];
1254
+ if (report.reasoningFolds.length > 0) {
1255
+ const removed = report.reasoningFolds.reduce((total, fold) => total + Math.max(0, fold.charsBefore - fold.charsAfter), 0);
1256
+ lines.push(`Reasoning folded in ${report.reasoningFolds.length} message(s): ${removed.toLocaleString("en-US")} chars removed.`);
1257
+ }
1258
+ if (report.folds.length === 0) {
1259
+ if (report.reasoningFolds.length > 0) {
1260
+ if (dryRun) lines.push("dryRun is on: nothing is mutated; candidates are reported by the log line.");
1261
+ return lines.join("\n");
1262
+ }
1263
+ lines.push(dryRun ? "dryRun is on: nothing is mutated; candidates are reported by the log line." : "Nothing has been folded in this session yet.");
1264
+ return lines.join("\n");
1265
+ }
1266
+ lines.push(`Characters removed: ${report.charsRemoved.toLocaleString("en-US")}`);
1267
+ lines.push(`Re-read signals: ${report.reReads} across ${report.rereadNodes} folded node(s)`);
1268
+ lines.push("");
1269
+ lines.push("| seq | turn.step | tool | before | after | re-reads |");
1270
+ lines.push("| --- | --- | --- | ---: | ---: | ---: |");
1271
+ for (const fold of report.folds) lines.push(`| ${fold.seq} | ${fold.turn}.${fold.step} | ${fold.toolName} | ${fold.charsBefore} | ${fold.charsAfter} | ${fold.reReads} |`);
1272
+ if (dryRun) lines.push("", "dryRun is on: the numbers above describe folds landed earlier, not new mutations.");
1273
+ return lines.join("\n");
1274
+ }
1275
+ // src/counterfactual.ts
1276
+ /**
1277
+ * DeepSeek's published relative pricing, normalised to uncached input = 1.
1278
+ *
1279
+ * Cache read is ~0.1×, cache write ~1× (a write and a plain read are priced
1280
+ * alike), output 4× but output is not affected by folding so it is not modelled.
1281
+ * These are *ratios*, so the result can be read as "token-equivalents" without
1282
+ * needing a currency.
1283
+ */
1284
+ const DEFAULT_PRICE_MODEL = Object.freeze({
1285
+ uncachedInput: 1,
1286
+ cacheRead: .1,
1287
+ cacheWrite: 1
1288
+ });
1289
+ /**
1290
+ * Extract per-step provider usage from a log.
1291
+ *
1292
+ * @param events - session events in seq order.
1293
+ * @returns one record per step that reported usage, in seq order.
1294
+ */
1295
+ function usageOf(events) {
1296
+ const steps = [];
1297
+ for (const event of events) {
1298
+ if (event.type !== "assistant/message") continue;
1299
+ const data = event.data;
1300
+ const usage = data?.usage;
1301
+ if (usage === void 0 || usage === null) continue;
1302
+ const read = (key) => {
1303
+ const value = usage[key];
1304
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
1305
+ };
1306
+ steps.push({
1307
+ turn: typeof data?.turn === "number" ? data.turn : 0,
1308
+ step: typeof data?.step === "number" ? data.step : 0,
1309
+ input: read("inputTokens"),
1310
+ cacheRead: read("cacheReadTokens"),
1311
+ cacheWrite: read("cacheWriteTokens"),
1312
+ seq: event.seq
1313
+ });
1314
+ }
1315
+ return steps;
1316
+ }
1317
+ /**
1318
+ * Price one step's prompt under the model.
1319
+ *
1320
+ * @param usage - the step's observed billing.
1321
+ * @param price - price model.
1322
+ * @returns prompt cost in price units (uncached-input-equivalents).
1323
+ */
1324
+ function promptCostOf(usage, price) {
1325
+ return usage.input * price.uncachedInput + usage.cacheRead * price.cacheRead + usage.cacheWrite * price.cacheWrite;
1326
+ }
1327
+ /** Total tokens one step sent. */
1328
+ function promptTokensOf(usage) {
1329
+ return usage.input + usage.cacheRead + usage.cacheWrite;
1330
+ }
1331
+ /**
1332
+ * Build the counterfactual comparison.
1333
+ *
1334
+ * @param folds - folded nodes present on the current surface.
1335
+ * @param steps - observed per-step usage, in seq order.
1336
+ * @param options - fold step numbers per fold, for the invalidation window, plus the price model.
1337
+ * @returns the comparison, with every assumption stated.
1338
+ */
1339
+ function counterfactualOf(folds, steps, options = {}) {
1340
+ const price = options.price ?? DEFAULT_PRICE_MODEL;
1341
+ const tokensNow = folds.reduce((total, fold) => total + fold.tokensAfter, 0);
1342
+ const tokensIfUnfolded = folds.reduce((total, fold) => total + fold.tokensBefore, 0);
1343
+ const tokensSavedPerStep = Math.max(0, tokensIfUnfolded - tokensNow);
1344
+ const hasRealUsage = steps.length > 0;
1345
+ const totalPromptTokens = steps.reduce((total, step) => total + promptTokensOf(step), 0);
1346
+ const totalCacheRead = steps.reduce((total, step) => total + step.cacheRead, 0);
1347
+ const actualPromptCost = steps.reduce((total, step) => total + promptCostOf(step, price), 0);
1348
+ const foldSteps = options.foldSteps;
1349
+ const remainingSteps = foldSteps === void 0 || foldSteps.length === 0 ? steps.length : steps.filter((step) => foldSteps.some((foldStep) => step.step > foldStep)).length;
1350
+ const cacheReadShare = totalPromptTokens === 0 ? void 0 : totalCacheRead / totalPromptTokens;
1351
+ const savedTokenRate = cacheReadShare === void 0 ? price.uncachedInput : cacheReadShare * price.cacheRead + (1 - cacheReadShare) * price.uncachedInput;
1352
+ const grossSaving = tokensSavedPerStep * remainingSteps * savedTokenRate;
1353
+ let invalidationCost = 0;
1354
+ if (foldSteps !== void 0) for (const foldStep of foldSteps) {
1355
+ const at = steps.find((step) => step.step >= foldStep);
1356
+ if (at === void 0) continue;
1357
+ const prompt = promptTokensOf(at);
1358
+ invalidationCost += prompt * (price.uncachedInput - price.cacheRead);
1359
+ }
1360
+ const counterfactualPromptCost = actualPromptCost + tokensSavedPerStep * remainingSteps * savedTokenRate;
1361
+ const assumptions = [];
1362
+ assumptions.push(hasRealUsage ? `Prices are DeepSeek ratios (cache read ${price.cacheRead}×, write ${price.cacheWrite}×, uncached ${price.uncachedInput}×), applied to this session's observed usage.` : "This session reported no provider usage, so even the \"actual\" figures are heuristic token counts, not billed amounts.");
1363
+ if (cacheReadShare !== void 0) assumptions.push(`Saved tokens are priced at this session's observed cache-read share (${(cacheReadShare * 100).toFixed(1)}%), not at the uncached rate.`);
1364
+ if (foldSteps === void 0 || foldSteps.length === 0) assumptions.push("Fold step numbers were unavailable, so every observed step is credited with the saving. Treat the figure as an upper bound.");
1365
+ else assumptions.push(`Each fold is charged one prefix invalidation: the whole prompt after it re-billed from cache-read to uncached once.`);
1366
+ assumptions.push("The counterfactual assumes the same number of steps and the same cache behaviour as the real session — a folded prompt might have hit cache differently.");
1367
+ return {
1368
+ tokensNow,
1369
+ tokensIfUnfolded,
1370
+ tokensSavedPerStep,
1371
+ remainingSteps,
1372
+ grossSaving,
1373
+ invalidationCost,
1374
+ netSaving: grossSaving - invalidationCost,
1375
+ actualPromptCost,
1376
+ counterfactualPromptCost,
1377
+ hasRealUsage,
1378
+ ...cacheReadShare === void 0 ? {} : { cacheReadShare },
1379
+ priceModel: price,
1380
+ assumptions
1381
+ };
1382
+ }
1383
+ // src/routes.ts
1384
+ /**
1385
+ * Extract the spill locator a folded node's text carries.
1386
+ *
1387
+ * Phase 1 writes `Full result: <path>`; phase 2 writes `Full text: <path> —`.
1388
+ *
1389
+ * @param text - folded node text.
1390
+ * @returns the locator, or undefined when the fold has no recovery path.
1391
+ */
1392
+ function locatorOf(text) {
1393
+ return /(?:Full result|Full text): (\S+)/.exec(text)?.[1];
1394
+ }
1395
+ /**
1396
+ * Build the `/report` payload for one session.
1397
+ *
1398
+ * @param session - session to inspect.
1399
+ * @param deps - token meter and spill access.
1400
+ * @returns the payload the tab renders.
1401
+ */
1402
+ function buildReport(session, deps) {
1403
+ const tool = reportOf(session);
1404
+ const decisions = reasoningDecisions(session);
1405
+ const folds = [];
1406
+ for (const fold of tool.folds) {
1407
+ const event = session.eventAt(fold.seq);
1408
+ const message = event === void 0 ? null : session.deriveEventMessage(event);
1409
+ const content = message !== null && message.role === "tool" ? message.content : void 0;
1410
+ const tokensAfter = content === void 0 ? 0 : deps.estimateMessage(content);
1411
+ const inline = inlineTextOf(content) ?? "";
1412
+ const locator = locatorOf(inline);
1413
+ folds.push({
1414
+ seq: fold.seq,
1415
+ kind: "tool-result",
1416
+ turn: fold.turn,
1417
+ step: fold.step,
1418
+ toolName: fold.toolName,
1419
+ charsBefore: fold.charsBefore,
1420
+ charsAfter: fold.charsAfter,
1421
+ tokensBefore: estimateFromChars(fold.charsBefore),
1422
+ tokensAfter,
1423
+ inline,
1424
+ recoverable: locator !== void 0,
1425
+ ...locator === void 0 ? {} : { locator }
1426
+ });
1427
+ }
1428
+ for (const decision of decisions) for (const target of decision.targets) {
1429
+ const event = session.eventAt(target.seq);
1430
+ const message = event === void 0 ? null : session.deriveEventMessage(event);
1431
+ if (message === null || message.role !== "assistant") continue;
1432
+ const assistantEvent = session.eventAt(target.seq);
1433
+ const turn = assistantEvent?.type === "assistant/message" ? assistantEvent.data.turn : 0;
1434
+ const step = assistantEvent?.type === "assistant/message" ? assistantEvent.data.step : 0;
1435
+ for (const [position, index] of target.indexes.entries()) {
1436
+ const block = message.content[index];
1437
+ if (block === void 0 || block.type !== "reasoning") continue;
1438
+ const locator = target.locators?.[position];
1439
+ folds.push({
1440
+ seq: target.seq,
1441
+ kind: "reasoning",
1442
+ turn,
1443
+ step,
1444
+ charsBefore: target.chars?.[position] ?? 0,
1445
+ charsAfter: Array.from(block.text).length,
1446
+ tokensBefore: estimateFromChars(target.chars?.[position] ?? 0),
1447
+ tokensAfter: deps.estimateMessage([{
1448
+ type: "text",
1449
+ text: block.text
1450
+ }]),
1451
+ inline: block.text,
1452
+ recoverable: locator !== void 0,
1453
+ ...locator === void 0 ? {} : { locator }
1454
+ });
1455
+ }
1456
+ }
1457
+ return {
1458
+ folds,
1459
+ counterfactual: counterfactualOf(folds.map((fold) => ({
1460
+ seq: fold.seq,
1461
+ kind: fold.kind,
1462
+ charsBefore: fold.charsBefore,
1463
+ charsAfter: fold.charsAfter,
1464
+ tokensBefore: fold.tokensBefore,
1465
+ tokensAfter: fold.tokensAfter
1466
+ })), usageOf(session.snapshotEvents()), { foldSteps: folds.map((fold) => fold.step).filter((step) => step > 0) }),
1467
+ reReads: tool.reReads
1468
+ };
1469
+ }
1470
+ /** Text of a tool message's single result block. */
1471
+ function inlineTextOf(content) {
1472
+ if (content === void 0) return void 0;
1473
+ let text = "";
1474
+ for (const block of content) if (block.type === "text" && typeof block.text === "string") text += block.text;
1475
+ return text;
1476
+ }
1477
+ /**
1478
+ * Approximate tokens from a character count.
1479
+ *
1480
+ * Only used for the *before* side, where the text is gone. The ratio matches the
1481
+ * token meter's own CJK-aware heuristic closely enough for an estimate that is
1482
+ * already labelled as one.
1483
+ */
1484
+ function estimateFromChars(chars) {
1485
+ return Math.round(chars / 3.5);
1486
+ }
1487
+ /**
1488
+ * Read the full original behind one fold.
1489
+ *
1490
+ * @param deps - spill access.
1491
+ * @param locator - recovery path recorded on the fold.
1492
+ * @returns the text, or undefined when it cannot be read.
1493
+ */
1494
+ async function readOriginal(deps, locator) {
1495
+ if (deps.spillStore === void 0) return void 0;
1496
+ const reader = deps.spillStore;
1497
+ if (typeof reader.readText !== "function") return void 0;
1498
+ try {
1499
+ return await reader.readText(locator);
1500
+ } catch {
1501
+ return;
1502
+ }
1503
+ }
1504
+ /** Box a successful value. */
1505
+ function ok(value) {
1506
+ return {
1507
+ ok: true,
1508
+ value
1509
+ };
1510
+ }
1511
+ /** Box a failure. */
1512
+ function fail(error) {
1513
+ return {
1514
+ ok: false,
1515
+ error
1516
+ };
1517
+ }
1518
+ /** JSON response matching the host's own convention. */
1519
+ function json(value, status = 200) {
1520
+ return Response.json(value, {
1521
+ status,
1522
+ headers: { "cache-control": "no-store" }
1523
+ });
1524
+ }
1525
+ /**
1526
+ * Register the two read-only routes the tab uses.
1527
+ *
1528
+ * @param register - the host route registrar.
1529
+ * @param deps - session and spill access.
1530
+ * @returns a disposer for both routes.
1531
+ */
1532
+ function registerRoutes(register, deps) {
1533
+ const report = register({
1534
+ path: "/api/context-fold/report",
1535
+ methods: ["POST"],
1536
+ requestBody: "buffered",
1537
+ async fetch(request) {
1538
+ let body;
1539
+ try {
1540
+ body = await request.json();
1541
+ } catch {
1542
+ return json(fail("body is not JSON"), 400);
1543
+ }
1544
+ const sessionId = typeof body.sessionId === "string" ? body.sessionId : "";
1545
+ if (sessionId === "") return json(fail("missing sessionId"), 400);
1546
+ const session = deps.sessionOf(sessionId);
1547
+ if (session === void 0) return json(fail(`unknown session ${sessionId}`), 404);
1548
+ try {
1549
+ return json(ok(buildReport(session, deps)));
1550
+ } catch (error) {
1551
+ return json(fail(error instanceof Error ? error.message : String(error)), 500);
1552
+ }
1553
+ }
1554
+ });
1555
+ const original = register({
1556
+ path: "/api/context-fold/original",
1557
+ methods: ["POST"],
1558
+ requestBody: "buffered",
1559
+ async fetch(request) {
1560
+ let body;
1561
+ try {
1562
+ body = await request.json();
1563
+ } catch {
1564
+ return json(fail("body is not JSON"), 400);
1565
+ }
1566
+ const locator = typeof body.locator === "string" ? body.locator : "";
1567
+ if (locator === "") return json(fail("missing locator"), 400);
1568
+ try {
1569
+ const text = await readOriginal(deps, locator);
1570
+ return text === void 0 ? json(fail("original not readable"), 404) : json(ok({ text }));
1571
+ } catch (error) {
1572
+ return json(fail(error instanceof Error ? error.message : String(error)), 500);
1573
+ }
1574
+ }
1575
+ });
1576
+ return () => {
1577
+ report();
1578
+ original();
1579
+ };
1580
+ }
1581
+ // src/index.ts
1582
+ /** Cordis plugin identity. */
1583
+ const name = "context-fold";
1584
+ /** The plugin needs a token meter; without a spill store it no-ops with a warning (design §6.5). */
1585
+ const inject = ["tokenMeter"];
1586
+ /**
1587
+ * Mount the fold policy.
1588
+ *
1589
+ * @param ctx - plugin context.
1590
+ * @param rawConfig - configuration from the settings row.
1591
+ */
1592
+ function apply(ctx, rawConfig = {}) {
1593
+ const config = resolveConfig(rawConfig);
1594
+ if (!config.enabled) {
1595
+ ctx.logger.info("context-fold: disabled by configuration");
1596
+ return;
1597
+ }
1598
+ const states = /* @__PURE__ */ new Map();
1599
+ let warnedMissingSpill = false;
1600
+ let warnedMissingMeter = false;
1601
+ /** Run one idempotent evaluation for an agent at a boundary. */
1602
+ async function evaluate(agent, currentTurn, currentStep) {
1603
+ const session = agent.session;
1604
+ const sessionId = session.header.id;
1605
+ let state = states.get(sessionId);
1606
+ if (state === void 0) {
1607
+ state = {
1608
+ boundary: "",
1609
+ running: false
1610
+ };
1611
+ states.set(sessionId, state);
1612
+ }
1613
+ const boundary = `${currentTurn}:${currentStep}:${session.surface.contentGeneration}`;
1614
+ if (state.running || state.boundary === boundary) return void 0;
1615
+ state.running = true;
1616
+ state.boundary = boundary;
1617
+ try {
1618
+ const spillStore = ctx.get("spillStore");
1619
+ if (spillStore === void 0) {
1620
+ if (!warnedMissingSpill) {
1621
+ warnedMissingSpill = true;
1622
+ ctx.logger.warn("context-fold: no ctx.spillStore backend is loaded; folding is a no-op");
1623
+ }
1624
+ return;
1625
+ }
1626
+ const tokenMeter = ctx.get("tokenMeter");
1627
+ if (tokenMeter === void 0) {
1628
+ if (!warnedMissingMeter) {
1629
+ warnedMissingMeter = true;
1630
+ ctx.logger.warn("context-fold: no ctx.tokenMeter service; folding is a no-op");
1631
+ }
1632
+ return;
1633
+ }
1634
+ const decision = planFor(session, currentTurn, currentStep, config);
1635
+ const reasoningPlans = config.foldReasoning ? planReasoningFolds(reasoningCandidates(session), currentTurn, currentStep, config) : [];
1636
+ if (config.dryRun) {
1637
+ ctx.logger.info(describeDecision("dryRun: would fold", decision, config));
1638
+ if (reasoningPlans.length > 0) {
1639
+ const chars = reasoningPlans.reduce((total, plan) => total + plan.charsRemoved, 0);
1640
+ ctx.logger.info(`dryRun: would fold reasoning in ${reasoningPlans.length} message(s), removing ${chars} chars`);
1641
+ }
1642
+ return {
1643
+ decision,
1644
+ folded: [],
1645
+ failed: [],
1646
+ charsRemoved: 0
1647
+ };
1648
+ }
1649
+ if (reasoningPlans.length > 0) try {
1650
+ await recordReasoningFolds(session, reasoningPlans, spillStore);
1651
+ const chars = reasoningPlans.reduce((total, plan) => total + plan.charsRemoved, 0);
1652
+ ctx.logger.info(`context-fold: folded reasoning in ${reasoningPlans.length} message(s), removed ${chars} chars`);
1653
+ } catch (error) {
1654
+ ctx.logger.warn(`context-fold: reasoning fold failed: ${error instanceof Error ? error.message : String(error)}`);
1655
+ }
1656
+ if (decision.batches.length === 0) return {
1657
+ decision,
1658
+ folded: [],
1659
+ failed: [],
1660
+ charsRemoved: 0
1661
+ };
1662
+ const folded = [];
1663
+ const failed = [];
1664
+ const host = {
1665
+ session,
1666
+ spillStore,
1667
+ config,
1668
+ estimateMessage: (message) => tokenMeter.estimateMessage(message)
1669
+ };
1670
+ for (const batch of decision.batches) {
1671
+ const result = await foldBatch(host, batch);
1672
+ folded.push(...result.folded);
1673
+ failed.push(...result.failed);
1674
+ }
1675
+ const charsRemoved = folded.reduce((total, fold) => total + (fold.charsBefore - fold.charsAfter), 0);
1676
+ if (folded.length > 0 || failed.length > 0) ctx.logger.info(`context-fold: folded ${folded.length} node(s), removed ${charsRemoved} chars, ${failed.length} failure(s)`);
1677
+ return {
1678
+ decision,
1679
+ folded,
1680
+ failed,
1681
+ charsRemoved
1682
+ };
1683
+ } catch (error) {
1684
+ state.boundary = "";
1685
+ ctx.logger.warn(`context-fold: evaluation failed: ${error instanceof Error ? error.message : String(error)}`);
1686
+ return;
1687
+ } finally {
1688
+ state.running = false;
1689
+ }
1690
+ }
1691
+ ctx.on("agent/pre-step", async (payload, next) => {
1692
+ const decision = await next();
1693
+ await evaluate(payload.agent, payload.turn, payload.step);
1694
+ return decision;
1695
+ });
1696
+ ctx.on("agent/turn-stopping", async (payload) => {
1697
+ await evaluate(payload.agent, payload.turn, latestStep(payload.agent.session));
1698
+ });
1699
+ ctx.inject(["commands"], (scope) => {
1700
+ scope.commands.register({
1701
+ name: "context-fold",
1702
+ description: "Report what dsh-context-fold has folded in this session, and how often the folded content was read again.",
1703
+ handler: (invocation) => ({
1704
+ kind: "success",
1705
+ text: renderReport(reportOf(invocation.agent.session), config.dryRun)
1706
+ })
1707
+ });
1708
+ });
1709
+ ctx.inject(["sessions"], (scope) => {
1710
+ const sessions = scope.sessions;
1711
+ if (sessions === void 0 || typeof sessions.registerMessageProjection !== "function") {
1712
+ ctx.logger.warn("context-fold: no ctx.sessions.registerMessageProjection; reasoning folds stay inactive");
1713
+ return;
1714
+ }
1715
+ try {
1716
+ sessions.registerMessageProjection(reasoningProjection);
1717
+ } catch (error) {
1718
+ ctx.logger.warn(`context-fold: reasoning projection not registered: ${error instanceof Error ? error.message : String(error)}`);
1719
+ }
1720
+ });
1721
+ ctx.inject(["connection"], (scope) => {
1722
+ const connection = scope.connection;
1723
+ if (connection === void 0) {
1724
+ ctx.logger.warn("context-fold: no ctx.connection service; the conversation tab has no data source");
1725
+ return;
1726
+ }
1727
+ const tokenMeter = ctx.get("tokenMeter");
1728
+ registerRoutes(connection, {
1729
+ sessionOf: (id) => {
1730
+ return ctx.get("sessions")?.get(id);
1731
+ },
1732
+ estimateMessage: (message) => tokenMeter === void 0 ? 0 : tokenMeter.estimateMessage(message),
1733
+ ...ctx.get("spillStore") === void 0 ? {} : { spillStore: ctx.get("spillStore") }
1734
+ });
1735
+ });
1736
+ ctx.on("session/disposed", (session) => {
1737
+ states.delete(session.header.id);
1738
+ });
1739
+ if (ctx.get("spillStore") === void 0) ctx.logger.warn("context-fold: no ctx.spillStore backend at mount; folding stays inert until one is loaded");
1740
+ ctx.logger.info(`context-fold: enabled (minFoldChars ${config.minFoldChars}, minBatchChars ${config.minBatchChars}, keepRecentSteps ${config.keepRecentSteps}, staleSteps ${config.staleSteps}, batchSpanSteps ${config.batchSpanSteps}, dryRun ${config.dryRun})`);
1741
+ }
1742
+ /**
1743
+ * Highest step number recorded in a session log, used at the turn boundary.
1744
+ *
1745
+ * @param session - session to scan.
1746
+ * @returns the highest step, or 0 for a session without steps.
1747
+ */
1748
+ function latestStep(session) {
1749
+ let step = 0;
1750
+ for (const event of session.snapshotEvents()) {
1751
+ const value = stepOf(event);
1752
+ if (value !== void 0 && value > step) step = value;
1753
+ }
1754
+ return step;
1755
+ }
1756
+ /** The step of any step-carrying event. */
1757
+ function stepOf(event) {
1758
+ if (event.type !== "tool/result" && event.type !== "tool/call" && event.type !== "assistant/message") return void 0;
1759
+ const data = event.data;
1760
+ return typeof data.step === "number" ? data.step : void 0;
1761
+ }
1762
+ /**
1763
+ * One-line summary of a decision for the log during `dryRun`.
1764
+ *
1765
+ * @param prefix - leading phrase.
1766
+ * @param decision - the decision to describe.
1767
+ * @param config - resolved configuration.
1768
+ * @returns a single log line.
1769
+ */
1770
+ function describeDecision(prefix, decision, config) {
1771
+ const nodes = decision.batches.reduce((total, batch) => total + batch.members.length, 0);
1772
+ const reasons = /* @__PURE__ */ new Map();
1773
+ for (const skip of decision.skipped) reasons.set(skip.reason, (reasons.get(skip.reason) ?? 0) + 1);
1774
+ const detail = [...reasons].map(([reason, count]) => `${reason}=${count}`).join(" ");
1775
+ return `context-fold: ${prefix} ${nodes} node(s) in ${decision.batches.length} batch(es); candidate chars ${decision.candidateChars}; skipped ${detail === "" ? "none" : detail}; thresholds minFoldChars=${config.minFoldChars} minBatchChars=${config.minBatchChars}`;
1776
+ }
1777
+ var src_default = {
1778
+ name,
1779
+ inject,
1780
+ Config,
1781
+ apply
1782
+ };
1783
+ export { Config, DEFAULT_CONFIG, FOLD_MARKER, REASONING_EVENT, REASONING_MARKER, apply, codePointLength, conclude, src_default as default, describeDecision, foldReasoningText, inject, isFoldableContent, isFoldedReasoning, isFoldedText, isToolCallTurn, name, parseSpillNotice, planFolds, planFor, planReasoningFolds, projectReasoning, reasoningCandidates, reasoningProjection, recordReasoningFolds, renderReport, reportOf, resolveConfig, snapshotInput, textOf };