dsh-context-guardian 0.1.0-alpha.2

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.
@@ -0,0 +1,851 @@
1
+ /**
2
+ * Deterministic VCC-style region compiler for `dsh-compaction-instant`.
3
+ *
4
+ * Ports the "compiler" principle of https://github.com/lllyasviel/VCC
5
+ * (`skills/conversation-compiler/scripts/VCC.py`) into the DSH vocabulary:
6
+ *
7
+ * - the durable session event log is the lossless layer (VCC's `.txt`);
8
+ * - this module compiles one shadowed surface region into a compact,
9
+ * reference-linked view (VCC's `.min.txt`) using ONLY original tokens —
10
+ * no model call, no paraphrase — which is what makes it instant and
11
+ * near-lossless;
12
+ * - every truncation and elision keeps a `(seq N)` pointer back to the
13
+ * exact durable event, so nothing is unrecoverable;
14
+ * - noise handling mirrors VCC's brief-mode rules: sanitize control bytes,
15
+ * strip known noise XML wrappers, hide reasoning, collapse every tool
16
+ * call into ONE line (key argument for whitelisted tools, name-only for
17
+ * the rest, VCC-style `-> result N` pointer instead of the result entry),
18
+ * and render images/documents as labels.
19
+ *
20
+ * This module is dependency-free (no cordis, no dsh imports) so it can be
21
+ * unit-tested and reused outside a running harness. Messages are read as
22
+ * plain `{ role, content, source }` values — exactly what
23
+ * `Session.deriveEventMessage` projects.
24
+ *
25
+ * @module dsh-compaction-instant/compiler
26
+ */
27
+
28
+ // ── tokenizer (VCC `_tokenize` / `_TOK_RE`) ────────────────────────────────
29
+
30
+ const TOKEN_RE = /[a-zA-Z]+|[0-9]+|[^\sa-zA-Z0-9]|\s+/g;
31
+
32
+ /** Whether a UTF-16 code unit can be an unpaired high surrogate. */
33
+ function isHighSurrogate(code) {
34
+ return code >= 0xd800 && code <= 0xdbff;
35
+ }
36
+
37
+ /** Whether a UTF-16 code unit can be an unpaired low surrogate. */
38
+ function isLowSurrogate(code) {
39
+ return code >= 0xdc00 && code <= 0xdfff;
40
+ }
41
+
42
+ /**
43
+ * Move the end of a slice to a code-point boundary: a cut must never leave a
44
+ * high surrogate without its low half (a lone surrogate renders as garbage
45
+ * for the model and every UI). Only ASCII-derived budgets slice, so the
46
+ * adjustment costs at most one code unit.
47
+ * @param text - sliced text.
48
+ * @returns the text without a trailing unpaired high surrogate.
49
+ */
50
+ function completeCodePointAtEnd(text) {
51
+ return isHighSurrogate(text.charCodeAt(text.length - 1)) ? text.slice(0, -1) : text;
52
+ }
53
+
54
+ /**
55
+ * Move the start of a slice to a code-point boundary (a tail excerpt must
56
+ * never begin inside a surrogate pair).
57
+ * @param text - sliced text.
58
+ * @returns the text without a leading unpaired low surrogate.
59
+ */
60
+ function completeCodePointAtStart(text) {
61
+ return isLowSurrogate(text.charCodeAt(0)) ? text.slice(1) : text;
62
+ }
63
+ const ANSI_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
64
+ const CTRL_RE = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
65
+
66
+ /**
67
+ * Split text into fixed-density tokens under the same character-class
68
+ * heuristic VCC uses: letter runs, digit runs, single punctuation chars, and
69
+ * whitespace runs. Deterministic, offline, and consistent for every budget
70
+ * decision in this module.
71
+ *
72
+ * The single-regex pass is deliberately kept: an `Intl.Segmenter`-driven
73
+ * equivalent was implemented and benchmarked, and the regex won on every
74
+ * workload (6–17× segmenter throughput; 486–13 500 ops/s on 64 KB inputs,
75
+ * 1.7 M ops/s on short messages), so the segmenter version was dropped.
76
+ * @param text - source text.
77
+ * @returns the token list (whitespace tokens included, never counted).
78
+ */
79
+ export function tokenize(text) {
80
+ return text.match(TOKEN_RE) ?? [];
81
+ }
82
+
83
+ /**
84
+ * DeepSeek's documented character→token conversion (quick_start/token_usage):
85
+ * about 0.3 token per English character and 0.6 token per Chinese character.
86
+ * The tokenizer still varies per model, so this is an estimate — the real
87
+ * usage comes from the model's `usage` field.
88
+ * @param char - one code point.
89
+ * @returns true when the character carries the 0.6 CJK weight.
90
+ */
91
+ export function isCjkChar(char) {
92
+ const code = char.codePointAt(0);
93
+ return (
94
+ (code >= 0x3400 && code <= 0x4dbf) || // CJK Extension A
95
+ (code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
96
+ (code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs
97
+ (code >= 0x3000 && code <= 0x303f) || // CJK Symbols and Punctuation
98
+ (code >= 0xff00 && code <= 0xffef) // Fullwidth Forms
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Estimate tokens of a text under the DeepSeek docs conversion (0.3 per
104
+ * non-CJK character, 0.6 per CJK character). Counts accumulate as integer
105
+ * tenths so long inputs stay exact under ceil.
106
+ * @param text - arbitrary text.
107
+ * @returns the estimated token count (may be fractional).
108
+ */
109
+ export function estimateDeepSeekTokens(text) {
110
+ let nonCjk = 0;
111
+ let cjk = 0;
112
+ for (const char of text) {
113
+ if (isCjkChar(char)) cjk += 1;
114
+ else nonCjk += 1;
115
+ }
116
+ return (nonCjk * 3 + cjk * 6) / 10;
117
+ }
118
+
119
+ /** Count the non-whitespace tokens of a text under {@link tokenize}. */
120
+ export function countTokens(text) {
121
+ let count = 0;
122
+ for (const token of tokenize(text)) if (token.trim().length > 0) count += 1;
123
+ return count;
124
+ }
125
+
126
+ /**
127
+ * Size one emitted entry the way every budget in this module is enforced:
128
+ * the VCC word-class token count, floored at the DeepSeek-doc character
129
+ * density equivalent (0.3 token per non-CJK char, 0.6 per CJK char) so the
130
+ * total checkpoint cap also sees pathological long runs.
131
+ * @param text - entry text.
132
+ * @returns the budget-relevant size in tokens.
133
+ */
134
+ export function estimateEntryTokens(text) {
135
+ return Math.max(countTokens(text), Math.ceil(estimateDeepSeekTokens(text)));
136
+ }
137
+
138
+ /**
139
+ * Strip carriage returns, ANSI escapes, and control bytes the way VCC's
140
+ * `_sanitize` does — deterministic cleanup, never token-changing rewrites.
141
+ * @param text - raw text.
142
+ * @returns sanitized text.
143
+ */
144
+ export function sanitize(text) {
145
+ if (typeof text !== "string") return "";
146
+ let out = text;
147
+ if (out.includes("\r")) out = out.replaceAll("\r", "");
148
+ if (out.includes("\x1b")) out = out.replace(ANSI_RE, "");
149
+ return out.replace(CTRL_RE, "");
150
+ }
151
+
152
+ // ── truncation (VCC `_trunc`) ──────────────────────────────────────────────
153
+
154
+ /**
155
+ * Truncate text to a non-whitespace-token budget, preserving exact original
156
+ * tokens and appending a VCC-style provenance note when something was cut.
157
+ *
158
+ * The fixed tokenizer counts a whole unbroken run of letters or digits as one
159
+ * token, so a pathological output (a 100 KB base64 blob, a minified file)
160
+ * could otherwise pass any token budget; every budget is therefore enforced
161
+ * twice — by token count AND by a `limit * 4` character ceiling (≈ the
162
+ * tokenizer's real-density equivalent).
163
+ * @param text - text to bound.
164
+ * @param limit - non-whitespace token budget; `0` keeps nothing.
165
+ * @param ref - provenance appended as `...(truncated from {ref})` on a cut.
166
+ * @returns `{ text, truncated }` — `truncated` is true when anything was cut.
167
+ */
168
+ export function truncateTokens(text, limit, ref) {
169
+ const tokens = tokenize(text);
170
+ let count = 0;
171
+ let cut = tokens.length;
172
+ for (let index = 0; index < tokens.length; index += 1) {
173
+ const token = tokens[index];
174
+ if (token.trim().length === 0) continue;
175
+ count += 1;
176
+ if (count > limit) {
177
+ cut = index;
178
+ break;
179
+ }
180
+ }
181
+ let out = cut >= tokens.length ? text : tokens.slice(0, cut).join("");
182
+ let truncated = cut < tokens.length;
183
+ const maxChars = Math.max(32, limit * 4);
184
+ if (out.length > maxChars) {
185
+ out = completeCodePointAtEnd(out.slice(0, maxChars));
186
+ truncated = true;
187
+ } else if (truncated) {
188
+ out = completeCodePointAtEnd(out);
189
+ }
190
+ if (!truncated) return { text, truncated: false };
191
+ out = out.replace(/\s+$/u, "");
192
+ const note = ref === undefined ? "...(truncated)" : `...(truncated from ${ref})`;
193
+ return { text: out + note, truncated: true };
194
+ }
195
+
196
+ /**
197
+ * Keep the LAST `limit` non-whitespace tokens of a text, additionally capped
198
+ * at `limit * 4` characters. The end of the text is always preserved.
199
+ * @param text - text to bound.
200
+ * @param limit - non-whitespace token budget.
201
+ * @param ref - provenance prepended as `...(elided before {ref})`.
202
+ * @returns `{ text, truncated }`.
203
+ */
204
+ function tailTokens(text, limit, ref) {
205
+ const tokens = tokenize(text);
206
+ let count = 0;
207
+ let startIdx = tokens.length;
208
+ for (let index = tokens.length - 1; index >= 0; index -= 1) {
209
+ if (tokens[index].trim().length > 0) {
210
+ count += 1;
211
+ if (count > limit) break;
212
+ }
213
+ startIdx = index;
214
+ }
215
+ let out = startIdx === 0 ? text : tokens.slice(startIdx).join("");
216
+ let truncated = startIdx > 0;
217
+ const maxChars = Math.max(32, limit * 4);
218
+ if (out.length > maxChars) {
219
+ out = completeCodePointAtStart(out.slice(out.length - maxChars));
220
+ truncated = true;
221
+ } else if (truncated) {
222
+ out = completeCodePointAtStart(out);
223
+ }
224
+ if (!truncated) return { text, truncated: false };
225
+ const note = ref === undefined ? "...(elided before)" : `...(elided before ${ref})`;
226
+ return { text: note + out, truncated: true };
227
+ }
228
+
229
+ // ── noise stripping (VCC `_BRIEF_STRIP_RE` family) ─────────────────────────
230
+
231
+ const NOISE_UNWRAP_RE = /<\/?(?:command-name|command-args)>/g;
232
+
233
+ /**
234
+ * The VCC brief-mode noise XML patterns, adapted to harness markup. Applied
235
+ * with the `s` flag; the replacement set is configurable via
236
+ * `InstantCompactionConfig.noisePatterns`.
237
+ */
238
+ export const DEFAULT_NOISE_PATTERNS = [
239
+ "<ide_opened_file>.*?</ide_opened_file>",
240
+ "<ide_selection>.*?</ide_selection>",
241
+ "<system-reminder>.*?</system-reminder>",
242
+ "<command-message>.*?</command-message>",
243
+ "<task-notification>.*?</task-notification>"
244
+ ];
245
+
246
+ /** Compile untrusted noise-pattern sources, throwing on invalid regex. */
247
+ export function compileNoisePatterns(sources) {
248
+ return sources.map((source, index) => {
249
+ try {
250
+ return new RegExp(source, "s");
251
+ } catch (error) {
252
+ throw new Error(`noisePatterns[${index}] (${JSON.stringify(source)}) is not a valid regular expression: ${error instanceof Error ? error.message : String(error)}`);
253
+ }
254
+ });
255
+ }
256
+
257
+ /**
258
+ * Strip configured noise XML blocks from user text and unwrap command-name /
259
+ * command-args tags (VCC `_strip_noise_xml` + `_BRIEF_UNWRAP_RE`).
260
+ * @param text - user text.
261
+ * @param patterns - compiled noise regexes.
262
+ * @returns the stripped text, trimmed.
263
+ */
264
+ export function stripNoiseXml(text, patterns) {
265
+ let out = text;
266
+ for (const pattern of patterns) out = out.replace(pattern, "");
267
+ out = out.replace(NOISE_UNWRAP_RE, "");
268
+ return out.trim();
269
+ }
270
+
271
+ // ── tool-call one-liner (VCC `_tool_summary`) ──────────────────────────────
272
+
273
+ /** VCC's `_TOOL_SUMMARY_FIELDS`, extended for the DSH tool catalog. */
274
+ export const DEFAULT_TOOL_KEY_FIELDS = Object.freeze({
275
+ read: "file_path",
276
+ edit: "file_path",
277
+ write: "file_path",
278
+ glob: "pattern",
279
+ grep: "pattern",
280
+ bash: "command",
281
+ web_search: "query",
282
+ skill: "name",
283
+ ralph: "objective",
284
+ subagent: "description",
285
+ subagent_fork: "description",
286
+ create_goal: "objective",
287
+ update_goal: "objective",
288
+ workflow: "name",
289
+ interrupt_agent: "agent_id",
290
+ job_kill: "job_id",
291
+ job_output: "job_id",
292
+ send_message: "message"
293
+ });
294
+
295
+ /**
296
+ * Tools whose key argument is kept in the one-liner. Everything else renders
297
+ * name-only (`* tool (seq N)`) — bookkeeping tools carry no semantic payload
298
+ * worth the tokens (VCC's brief mode keeps `Read`/`Edit`/… args and drops the
299
+ * rest; `_BRIEF_HIDE_TOOLS` goes further and hides TodoWrite/ToolSearch
300
+ * entirely, which `hideTools` config covers here).
301
+ */
302
+ export const DEFAULT_ARG_TOOLS = Object.freeze([
303
+ "read",
304
+ "write",
305
+ "edit",
306
+ "glob",
307
+ "grep",
308
+ "bash",
309
+ "shell",
310
+ "web_search",
311
+ "skill",
312
+ "subagent",
313
+ "subagent_fork",
314
+ "ralph",
315
+ "workflow"
316
+ ]);
317
+
318
+ /**
319
+ * First-line projection for a tool-call one-liner: multiline arguments
320
+ * collapse to the first physical line plus a `[+ N lines]` marker, so
321
+ * scripted commands (bash heredocs, multi-line pipelines) stay one compact
322
+ * line instead of echoing the whole payload.
323
+ * @param value - the key-argument string.
324
+ * @returns the display value.
325
+ */
326
+ export function oneLineArg(value) {
327
+ const lines = value.split(/\r?\n/u);
328
+ if (lines.length === 1) return value;
329
+ const first = lines[0] !== "" || lines.every((line) => line.trim() === "")
330
+ ? lines[0]
331
+ : lines.find((line) => line.trim() !== "");
332
+ return `${first} [+ ${lines.length - 1} lines]`;
333
+ }
334
+
335
+ /**
336
+ * Pick the key argument for a tool-call one-liner: the tool-specific field
337
+ * first, then the first string-valued argument, else nothing.
338
+ * @param name - tool name.
339
+ * @param input - parsed arguments object (or null when unparseable).
340
+ * @param keyFields - merged tool-name → preferred-field map.
341
+ * @returns the display value, or undefined.
342
+ */
343
+ export function pickToolKeyArg(name, input, keyFields) {
344
+ if (input === null || typeof input !== "object" || Array.isArray(input)) return undefined;
345
+ const preferred = keyFields[name];
346
+ if (preferred !== undefined) {
347
+ const value = input[preferred];
348
+ if (typeof value === "string" && value.length > 0) return value;
349
+ }
350
+ for (const value of Object.values(input)) {
351
+ if (typeof value === "string" && value.length > 0) return value;
352
+ }
353
+ return undefined;
354
+ }
355
+
356
+ /**
357
+ * Safely parse a tool-call's raw JSON arguments string.
358
+ * @param arguments_ - raw JSON as produced by the model.
359
+ * @returns the parsed value, or null when it is not valid JSON.
360
+ */
361
+ export function parseToolArguments(arguments_) {
362
+ try {
363
+ return JSON.parse(arguments_);
364
+ } catch {
365
+ return null;
366
+ }
367
+ }
368
+
369
+ // ── content projection helpers ─────────────────────────────────────────────
370
+
371
+ /**
372
+ * Project nested tool-result blocks into one plain-text string: text blocks
373
+ * are joined, images and documents render as VCC-style labels.
374
+ * @param blocks - nested result content.
375
+ * @returns projected text.
376
+ */
377
+ export function projectToolResultText(blocks) {
378
+ const parts = [];
379
+ for (const block of blocks) {
380
+ if (block.type === "text") {
381
+ const text = sanitize(block.text ?? "");
382
+ if (text.length > 0) parts.push(text);
383
+ } else if (block.type === "image") parts.push("[image]");
384
+ else if (block.type === "document") parts.push("[document]");
385
+ else parts.push(`[${String(block.type)}]`);
386
+ }
387
+ return parts.join("\n");
388
+ }
389
+
390
+ /** Whether a message source identifies a landed compaction checkpoint node. */
391
+ export function isCheckpointSource(source) {
392
+ return source !== undefined && source.kind === "plugin" && source.plugin === "compact";
393
+ }
394
+
395
+ /**
396
+ * Head/tail excerpt of a tool result under one shared token budget. The tail
397
+ * is anchored to the end of the text at character granularity, so a result
398
+ * whose tail matters more than its head (file listings, logs) keeps its end.
399
+ * @param text - projected result text.
400
+ * @param budget - combined non-whitespace token budget for head + tail.
401
+ * @param ref - provenance for the elision marker.
402
+ * @returns the excerpt, or the full text when it already fits.
403
+ */
404
+ export function excerptToolResult(text, budget, ref) {
405
+ const overBudget = countTokens(text) > budget || text.length > budget * 4;
406
+ if (budget <= 0 || !overBudget) return text;
407
+ const half = Math.floor(budget / 2);
408
+ const headLimit = Math.max(8, half);
409
+ const tailLimit = Math.max(8, budget - headLimit);
410
+ const head = truncateTokens(text, headLimit, ref);
411
+ const tail = tailTokens(text, tailLimit, ref);
412
+ return `${head.text}\n...(elided from ${ref})\n${tail.text}`;
413
+ }
414
+
415
+ // ── the region compiler ────────────────────────────────────────────────────
416
+
417
+ const CHECKPOINT_PREAMBLE = "This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.";
418
+ const CHECKPOINT_OPEN_TAG = "<compacted-checkpoint>";
419
+ const CHECKPOINT_CLOSE_TAG = "</compacted-checkpoint>";
420
+
421
+ /**
422
+ * Model-facing guide framed into the head of every checkpoint: tells the
423
+ * agent how to recover content the compiler elided or truncated, using the
424
+ * two recall tools that read the append-only durable log.
425
+ */
426
+ export const RECALL_GUIDE = "RECALL: append-only log — nothing is lost. `recall` restores original content: type \"seq\", id \"3-7\"; \"result\", id \"3\"; \"checkpoint\", id \"1\" (`[checkpoint N]` = dropped). `search` finds by keyword.";
427
+
428
+ /** Per-node reference marker: the durable seq is the lossless pointer. */
429
+ function seqRef(seq) {
430
+ return `seq ${seq}`;
431
+ }
432
+
433
+ /**
434
+ * Build marker for this compiler revision. Bumped whenever the compiled
435
+ * output format changes, so debug logs can prove which code version ran.
436
+ */
437
+ export const COMPILER_REV = "vcc-compiler-debug-2";
438
+
439
+ /**
440
+ * Emit one debug line through the configured sink (and stderr). The sink is
441
+ * installed by the engine (`config.debugSink`) when `debug` is enabled; the
442
+ * compiler itself stays dependency-free.
443
+ * @param config - resolved compiler configuration.
444
+ * @param tag - short component tag for the line.
445
+ * @param line - the diagnostic text.
446
+ */
447
+ function debugLog(config, tag, line) {
448
+ if (config.debug !== true) return;
449
+ const text = `[dsh-compaction-instant] ${tag}: ${line}`;
450
+ try {
451
+ if (typeof config.debugSink === "function") config.debugSink(text);
452
+ } catch {
453
+ /* a failing debug sink must never break a compaction */
454
+ }
455
+ try {
456
+ console.error(text);
457
+ } catch {
458
+ /* stderr may be unavailable in embedded contexts */
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Compile one ordered region into compact entries.
464
+ * @param nodes - ordered `{ seq, message }` projections of the shadowed
465
+ * surface nodes; `message` may be null (non-projecting node).
466
+ * @param config - resolved compiler-relevant configuration (see index.js).
467
+ * @param budgets - optional per-block budgets; defaults to the config values
468
+ * (the budget-cap loop passes scaled budgets on retry).
469
+ * @returns `{ entries, stats }` where each entry is one line-group string.
470
+ */
471
+ export function compileNodes(nodes, config, budgets) {
472
+ const effective = budgets ?? {
473
+ textTokens: config.textTokens,
474
+ userTextTokens: config.userTextTokens,
475
+ toolCallTokens: config.toolCallTokens,
476
+ toolResultExcerptTokens: config.toolResultExcerptTokens
477
+ };
478
+ const keyFields = { ...DEFAULT_TOOL_KEY_FIELDS, ...(config.toolKeyFields ?? {}) };
479
+ const patterns = config.noisePatterns ?? [];
480
+ // Empty lists mean "unset": the cordis config pipeline injects `[]` for
481
+ // absent array keys, so fall back to the defaults instead of rendering
482
+ // every tool call name-only.
483
+ const argTools = config.toolArgTools?.length > 0 ? config.toolArgTools : DEFAULT_ARG_TOOLS;
484
+ const hiddenTools = new Set(config.hideTools ?? []);
485
+ debugLog(config, "compile", `rev=${COMPILER_REV} nodes=${nodes.length} argTools=[${argTools.join(",")}] hideTools=[${[...hiddenTools].join(",")}] budgets=${JSON.stringify(effective)}`);
486
+ const entries = [];
487
+ const stats = {
488
+ nodes: nodes.length,
489
+ entries: 0,
490
+ toolCalls: 0,
491
+ toolResults: 0,
492
+ images: 0,
493
+ documents: 0,
494
+ reasoningElided: 0,
495
+ noiseElided: 0,
496
+ checkpoints: 0,
497
+ tokens: 0,
498
+ elidedToolRows: 0,
499
+ elidedRows: 0
500
+ };
501
+ // Pre-pass over the ordered nodes: map each tool-call id to the seq of its
502
+ // result node, so every call one-liner can carry a VCC-style result pointer
503
+ // (`(seq N -> result M)`) even though results no longer occupy entries.
504
+ const resultSeqByCallId = new Map();
505
+ for (const node of nodes) {
506
+ const message = node.message;
507
+ if (message === null || message === undefined || message.role !== "user" || message.content === undefined) continue;
508
+ const first = message.content[0];
509
+ if (first !== undefined && first.type === "tool-result" && typeof first.toolCallId === "string") {
510
+ resultSeqByCallId.set(first.toolCallId, node.seq);
511
+ }
512
+ }
513
+ let lastRole;
514
+ let open = false;
515
+
516
+ /**
517
+ * Append one compiled entry carrying a deletion-priority kind. Kinds order
518
+ * the cap-elision passes in compileRegion: `result`/`tool`/`media`/`note`
519
+ * rows are dropped before `text`/`reasoning`/`checkpoint` rows, so when a
520
+ * checkpoint must shrink, the conversation's words survive its logs.
521
+ * @param seq - durable seq the entry derives from.
522
+ * @param text - rendered entry text.
523
+ * @param kind - entry kind (see the kind list above).
524
+ */
525
+ const pushEntry = (seq, text, kind) => {
526
+ entries.push({ seq, text, kind });
527
+ stats.entries += 1;
528
+ stats.tokens += estimateEntryTokens(text);
529
+ };
530
+ const roleHeader = (role, seq) => {
531
+ if (!open || lastRole !== role) {
532
+ open = true;
533
+ lastRole = role;
534
+ return `[${role}]\n`;
535
+ }
536
+ return "";
537
+ };
538
+
539
+ for (const node of nodes) {
540
+ const message = node.message;
541
+ if (message === null || message === undefined || message.content === undefined) continue;
542
+ if (message.role === "assistant") {
543
+ let header = "";
544
+ for (const block of message.content) {
545
+ if (block.type === "text") {
546
+ const text = sanitize(block.text ?? "");
547
+ if (text.trim().length === 0) continue;
548
+ header = roleHeader("assistant", node.seq);
549
+ const kept = truncateTokens(text, effective.textTokens, seqRef(node.seq));
550
+ pushEntry(node.seq, header + kept.text, "text");
551
+ continue;
552
+ }
553
+ if (block.type === "reasoning") {
554
+ if (config.includeReasoning !== true) {
555
+ stats.reasoningElided += 1;
556
+ continue;
557
+ }
558
+ const text = sanitize(block.text ?? "");
559
+ if (text.trim().length === 0) continue;
560
+ header = roleHeader("assistant", node.seq);
561
+ const kept = truncateTokens(text, effective.textTokens, seqRef(node.seq));
562
+ pushEntry(node.seq, header + kept.text, "reasoning");
563
+ continue;
564
+ }
565
+ if (block.type === "tool-call") {
566
+ const name = block.name ?? "unknown";
567
+ stats.toolCalls += 1;
568
+ if (hiddenTools.has(name)) {
569
+ debugLog(config, "tool", `seq=${node.seq} name=${name} HIDDEN (hideTools)`);
570
+ continue;
571
+ }
572
+ header = roleHeader("assistant", node.seq);
573
+ // One line, always: key argument only for whitelisted tools, and a
574
+ // VCC-style pointer to the result node (`* read "a.js" (seq 2 ->
575
+ // result 3)`) so the dropped result stays one recall away.
576
+ let oneLine;
577
+ let diag;
578
+ if (argTools.includes(name)) {
579
+ const parsed = parseToolArguments(block.arguments);
580
+ const arg = pickToolKeyArg(name, parsed, keyFields);
581
+ oneLine = arg === undefined ? `* ${name}` : `* ${name} "${oneLineArg(arg)}"`;
582
+ diag = `whitelist=yes argsType=${typeof block.arguments} argsLen=${block.arguments === null || block.arguments === undefined ? 0 : String(block.arguments).length} argsHead=${JSON.stringify(String(block.arguments).slice(0, 60))} parse=${parsed === null ? "FAIL" : "ok"} key=${keyFields[name] ?? "(fallback)"} arg=${arg === undefined ? "(none)" : JSON.stringify(String(arg).slice(0, 60))}`;
583
+ } else {
584
+ oneLine = `* ${name}`;
585
+ diag = `whitelist=no argsType=${typeof block.arguments} argsLen=${block.arguments === null || block.arguments === undefined ? 0 : String(block.arguments).length} argsHead=${JSON.stringify(String(block.arguments).slice(0, 60))}`;
586
+ }
587
+ const resultSeq = resultSeqByCallId.get(block.id);
588
+ const ref = resultSeq === undefined ? seqRef(node.seq) : `${seqRef(node.seq)} -> result ${resultSeq}`;
589
+ const kept = truncateTokens(oneLine, effective.toolCallTokens, seqRef(node.seq));
590
+ debugLog(config, "tool", `seq=${node.seq} name=${name} ${diag} line=${JSON.stringify(oneLine.slice(0, 80))} truncated=${kept.truncated} ref=${ref}`);
591
+ pushEntry(node.seq, header + `${kept.text} (${ref})`, "tool");
592
+ continue;
593
+ }
594
+ if (block.type === "image") {
595
+ stats.images += 1;
596
+ header = roleHeader("assistant", node.seq);
597
+ pushEntry(node.seq, header + `[image] (${seqRef(node.seq)})`, "media");
598
+ continue;
599
+ }
600
+ if (block.type === "document") {
601
+ stats.documents += 1;
602
+ header = roleHeader("assistant", node.seq);
603
+ pushEntry(node.seq, header + `[document] (${seqRef(node.seq)})`, "media");
604
+ continue;
605
+ }
606
+ /* unknown merge-extensible block types render as labels */
607
+ {
608
+ header = roleHeader("assistant", node.seq);
609
+ pushEntry(node.seq, header + `[${String(block.type)}] (${seqRef(node.seq)})`, "media");
610
+
611
+ }
612
+ }
613
+ /* reasoning-only and empty assistant messages produce no entry */
614
+ continue;
615
+ }
616
+ if (message.role === "user") {
617
+ const first = message.content[0];
618
+ if (first !== undefined && first.type === "tool-result") {
619
+ // Tool results never occupy an entry: the content is either absorbed
620
+ // into the following assistant text or one recall away via the call
621
+ // line's `-> result N` pointer (VCC drops results from brief mode too).
622
+ stats.toolResults += 1;
623
+ continue;
624
+ }
625
+ if (isCheckpointSource(message.source)) {
626
+ stats.checkpoints += 1;
627
+ const text = projectToolResultText(message.content);
628
+ if (text.length > 0) {
629
+ // The checkpoint node is a user/message in the durable protocol
630
+ // (surface replacement only allows message nodes), but it is
631
+ // harness-generated framing — display it as [system].
632
+ const header = roleHeader("system", node.seq);
633
+ pushEntry(node.seq, header + text, "checkpoint");
634
+ }
635
+ continue;
636
+ }
637
+ let surviving = 0;
638
+ let header = "";
639
+ for (const block of message.content) {
640
+ if (block.type === "text") {
641
+ let text = sanitize(block.text ?? "");
642
+ if (config.stripNoiseXml !== false) text = stripNoiseXml(text, patterns);
643
+ if (text.length === 0) continue;
644
+ surviving += 1;
645
+ header = roleHeader("user", node.seq);
646
+ const kept = truncateTokens(text, effective.userTextTokens, seqRef(node.seq));
647
+ pushEntry(node.seq, header + kept.text, "text");
648
+ } else if (block.type === "image") {
649
+ stats.images += 1;
650
+ surviving += 1;
651
+ header = roleHeader("user", node.seq);
652
+ pushEntry(node.seq, header + `[image] (${seqRef(node.seq)})`, "media");
653
+ } else if (block.type === "document") {
654
+ stats.documents += 1;
655
+ surviving += 1;
656
+ header = roleHeader("user", node.seq);
657
+ pushEntry(node.seq, header + `[document] (${seqRef(node.seq)})`, "media");
658
+ } else {
659
+ const text = sanitize(block.text ?? "");
660
+ if (text.length === 0) continue;
661
+ surviving += 1;
662
+ header = roleHeader("user", node.seq);
663
+ const kept = truncateTokens(text, effective.userTextTokens, seqRef(node.seq));
664
+ pushEntry(node.seq, header + kept.text, "text");
665
+ }
666
+ }
667
+ if (surviving === 0) {
668
+ stats.noiseElided += 1;
669
+ pushEntry(node.seq, `${roleHeader("user", node.seq)}* [user message elided: noise-only] (${seqRef(node.seq)})`, "note");
670
+ }
671
+ }
672
+ }
673
+ return { entries, stats };
674
+ }
675
+
676
+ /**
677
+ * Per-budget elision floors: conversation text keeps a meaningful minimum
678
+ * even when the total cap forces heavy rescaling, while tool one-liners and
679
+ * result excerpts may collapse to near-nothing first.
680
+ */
681
+ const BUDGET_FLOORS = Object.freeze({
682
+ textTokens: 32,
683
+ userTextTokens: 32,
684
+ toolCallTokens: 8,
685
+ toolResultExcerptTokens: 8
686
+ });
687
+
688
+ /** Entry kinds dropped by the first (low-value) elision pass. */
689
+ const LOW_VALUE_KINDS = new Set(["result", "tool", "media", "note"]);
690
+
691
+ /**
692
+ * Scale ONLY the conversation-text budgets toward a total cap, never below
693
+ * the per-key floor. Tool-call rows stay one-liners at their configured
694
+ * budget: conversation shrinks first, tool rows are sacrificed whole by the
695
+ * elision pass, so tool calls can never squeeze the dialogue out.
696
+ * @param budgets - budget map to scale.
697
+ * @param factor - uniform scale factor for text budgets.
698
+ * @returns the scaled budget map (tool budgets untouched).
699
+ */
700
+ function scaleBudgets(budgets, factor) {
701
+ const scaled = { ...budgets };
702
+ for (const key of ["textTokens", "userTextTokens"]) {
703
+ scaled[key] = Math.max(BUDGET_FLOORS[key] ?? 8, Math.floor(budgets[key] * factor));
704
+ }
705
+ return scaled;
706
+ }
707
+
708
+ /** Render the provenance range of one dropped-entry list. */
709
+ function seqRangeOf(dropped) {
710
+ const first = dropped[0].seq;
711
+ const last = dropped[dropped.length - 1].seq;
712
+ return first === last ? `seq ${first}` : `seqs ${first}-${last}`;
713
+ }
714
+
715
+ /**
716
+ * Compile a region to completion under the total checkpoint cap: budgets are
717
+ * rescaled toward `config.maxTokens` up to a fixed number of attempts, then
718
+ * the oldest low-value rows (tool/result/media/note) are front-elided first,
719
+ * and only then the oldest remaining entries — so the conversation's words
720
+ * survive elision before its logs do. The newest entries always survive —
721
+ * recency is the value ranking, as in VCC chunking.
722
+ * @param nodes - ordered `{ seq, message }` projections.
723
+ * @param config - resolved compiler configuration.
724
+ * @returns `{ entries, stats, capped }` — `capped` records cap enforcement.
725
+ */
726
+ export function compileRegion(nodes, config) {
727
+ const baseBudgets = {
728
+ textTokens: config.textTokens,
729
+ userTextTokens: config.userTextTokens,
730
+ toolCallTokens: config.toolCallTokens,
731
+ toolResultExcerptTokens: config.toolResultExcerptTokens
732
+ };
733
+ let budgets = baseBudgets;
734
+ let result = compileNodes(nodes, config, budgets);
735
+ let attempts = 0;
736
+ const maxAttempts = 4;
737
+ while (result.stats.tokens > config.maxTokens && attempts < maxAttempts) {
738
+ budgets = scaleBudgets(budgets, config.maxTokens / result.stats.tokens * 0.95);
739
+ result = compileNodes(nodes, config, budgets);
740
+ attempts += 1;
741
+ }
742
+ let capped = false;
743
+ if (result.stats.tokens > config.maxTokens) {
744
+ const entries = result.entries;
745
+ // Phase A: drop the oldest low-value rows (tool/result/media/note)
746
+ // anywhere in the list first, so conversation text and prior-checkpoint
747
+ // knowledge survive elision before the logs do. The marker's seq range is
748
+ // the dropped rows' span (approximate: surviving text may sit inside it).
749
+ const toolRows = [];
750
+ while (entries.length > 1 && result.stats.tokens > config.maxTokens) {
751
+ const index = entries.findIndex((entry, position) => position < entries.length - 1 && LOW_VALUE_KINDS.has(entry.kind));
752
+ if (index === -1) break;
753
+ const dropped = entries.splice(index, 1)[0];
754
+ result.stats.tokens -= estimateEntryTokens(dropped.text);
755
+ toolRows.push(dropped);
756
+ }
757
+ if (toolRows.length > 0) {
758
+ capped = true;
759
+ result.stats.elidedToolRows += toolRows.length;
760
+ entries.unshift({ seq: toolRows[0].seq, text: `[${toolRows.length} tool/result entries elided: ${seqRangeOf(toolRows)}]`, kind: "note" });
761
+ result.stats.tokens += estimateEntryTokens(entries[0].text);
762
+ }
763
+ const elided = [];
764
+ const droppedCheckpoints = [];
765
+ while (entries.length > 1 && result.stats.tokens > config.maxTokens) {
766
+ const dropped = entries.shift();
767
+ result.stats.tokens -= estimateEntryTokens(dropped.text);
768
+ elided.push(dropped);
769
+ if (dropped.kind === "checkpoint") droppedCheckpoints.push(dropped);
770
+ }
771
+ if (elided.length > 0) {
772
+ capped = true;
773
+ result.stats.elidedRows += elided.length;
774
+ // Dropped prior checkpoints never vanish silently: each leaves a
775
+ // single `[checkpoint N]` line the agent can restore in full via
776
+ // recall(type:"checkpoint", id:"N").
777
+ const markerLines = [`[${elided.length} earlier entries elided: ${seqRangeOf(elided)}]`];
778
+ for (const dropped of droppedCheckpoints) {
779
+ const ordinal = config.checkpointOrdinals?.get(dropped.seq);
780
+ const ref = ordinal === undefined ? `@ seq ${dropped.seq}` : String(ordinal);
781
+ markerLines.push(`[checkpoint ${ref}]`);
782
+ }
783
+ entries.unshift({ seq: elided[0].seq, text: markerLines.join("\n"), kind: "note" });
784
+ result.stats.tokens += estimateEntryTokens(entries[0].text);
785
+ }
786
+ if (result.stats.tokens > config.maxTokens && entries.length === 1) {
787
+ capped = true;
788
+ const hard = truncateTokens(entries[0].text, Math.max(8, config.maxTokens - 4), "total cap");
789
+ entries[0] = { seq: entries[0].seq, text: hard.text, kind: entries[0].kind };
790
+ result.stats.tokens = estimateEntryTokens(entries[0].text);
791
+ }
792
+ }
793
+ return { ...result, capped };
794
+ }
795
+
796
+ /**
797
+ * Normalize one compiled entry to its text: entries are `{ seq, text }`
798
+ * pairs, but a custom compile hook may supply plain strings.
799
+ * @param entry - entry value.
800
+ * @returns the entry's text.
801
+ */
802
+ export function entryText(entry) {
803
+ return typeof entry === "string" ? entry : entry.text;
804
+ }
805
+
806
+ /**
807
+ * Join compiled entries with separators that keep tool runs compact:
808
+ * consecutive `tool` rows join with a single newline (no blank line), while
809
+ * every other boundary keeps the blank line that separates entry groups.
810
+ * Strings (guide, header, elision markers) behave like `text` entries.
811
+ * @param entries - ordered entry texts (strings or `{ seq, text, kind }` pairs).
812
+ * @returns the joined body text.
813
+ */
814
+ export function joinCompiledEntries(entries) {
815
+ let out = "";
816
+ let previousKind;
817
+ for (const entry of entries) {
818
+ const kind = typeof entry === "string" ? "text" : entry.kind;
819
+ if (out.length > 0) out += previousKind === "tool" && kind === "tool" ? "\n" : "\n\n";
820
+ out += typeof entry === "string" ? entry : entry.text;
821
+ previousKind = kind;
822
+ }
823
+ return out;
824
+ }
825
+
826
+ /**
827
+ * Frame compiled entries as the durable replacement checkpoint content:
828
+ * the shared preamble, the recall guide, then one tagged block (guide first,
829
+ * then the optional intro line, the optional header line, the entries, and
830
+ * the optional retention footer). Prior-checkpoint text copied verbatim
831
+ * (with its own backend's tags) nests inside harmlessly.
832
+ * @param entries - ordered entry texts (strings or `{ seq, text }` pairs).
833
+ * @param headerLine - optional first line summarizing the compiled region.
834
+ * @param introLine - optional first line summarizing the compaction itself.
835
+ * @param footerLine - optional tail line reporting the verbatim retention.
836
+ * @returns plain text content blocks.
837
+ */
838
+ export function frameCheckpoint(entries, headerLine, introLine, footerLine) {
839
+ const body = [RECALL_GUIDE];
840
+ if (introLine !== undefined) body.push(introLine);
841
+ if (headerLine !== undefined) body.push(headerLine);
842
+ body.push(...entries);
843
+ if (footerLine !== undefined) body.push(footerLine);
844
+ return [
845
+ { type: "text", text: `${CHECKPOINT_PREAMBLE}\n\n${CHECKPOINT_OPEN_TAG}` },
846
+ { type: "text", text: joinCompiledEntries(body) },
847
+ { type: "text", text: CHECKPOINT_CLOSE_TAG }
848
+ ];
849
+ }
850
+
851
+ export { CHECKPOINT_CLOSE_TAG, CHECKPOINT_OPEN_TAG, CHECKPOINT_PREAMBLE };