evalbuff 0.0.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.
Files changed (86) hide show
  1. package/README.md +79 -0
  2. package/dist/carve-features.d.ts +42 -0
  3. package/dist/carve-features.d.ts.map +1 -0
  4. package/dist/carve-features.js +305 -0
  5. package/dist/carve-features.js.map +1 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +42 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/docs-refactor.d.ts +4 -0
  11. package/dist/docs-refactor.d.ts.map +1 -0
  12. package/dist/docs-refactor.js +122 -0
  13. package/dist/docs-refactor.js.map +1 -0
  14. package/dist/docs-writer.d.ts +4 -0
  15. package/dist/docs-writer.d.ts.map +1 -0
  16. package/dist/docs-writer.js +122 -0
  17. package/dist/docs-writer.js.map +1 -0
  18. package/dist/eval-helpers.d.ts +19 -0
  19. package/dist/eval-helpers.d.ts.map +1 -0
  20. package/dist/eval-helpers.js +327 -0
  21. package/dist/eval-helpers.js.map +1 -0
  22. package/dist/eval-runner.d.ts +42 -0
  23. package/dist/eval-runner.d.ts.map +1 -0
  24. package/dist/eval-runner.js +193 -0
  25. package/dist/eval-runner.js.map +1 -0
  26. package/dist/judge.d.ts +22 -0
  27. package/dist/judge.d.ts.map +1 -0
  28. package/dist/judge.js +284 -0
  29. package/dist/judge.js.map +1 -0
  30. package/dist/perfect-feature.d.ts +2 -0
  31. package/dist/perfect-feature.d.ts.map +1 -0
  32. package/dist/perfect-feature.js +666 -0
  33. package/dist/perfect-feature.js.map +1 -0
  34. package/dist/report.d.ts +31 -0
  35. package/dist/report.d.ts.map +1 -0
  36. package/dist/report.js +249 -0
  37. package/dist/report.js.map +1 -0
  38. package/dist/run-evalbuff.d.ts +12 -0
  39. package/dist/run-evalbuff.d.ts.map +1 -0
  40. package/dist/run-evalbuff.js +383 -0
  41. package/dist/run-evalbuff.js.map +1 -0
  42. package/dist/runners/claude.d.ts +10 -0
  43. package/dist/runners/claude.d.ts.map +1 -0
  44. package/dist/runners/claude.js +80 -0
  45. package/dist/runners/claude.js.map +1 -0
  46. package/dist/runners/codebuff.d.ts +24 -0
  47. package/dist/runners/codebuff.d.ts.map +1 -0
  48. package/dist/runners/codebuff.js +88 -0
  49. package/dist/runners/codebuff.js.map +1 -0
  50. package/dist/runners/codex.d.ts +8 -0
  51. package/dist/runners/codex.d.ts.map +1 -0
  52. package/dist/runners/codex.js +131 -0
  53. package/dist/runners/codex.js.map +1 -0
  54. package/dist/runners/index.d.ts +5 -0
  55. package/dist/runners/index.d.ts.map +1 -0
  56. package/dist/runners/index.js +4 -0
  57. package/dist/runners/index.js.map +1 -0
  58. package/dist/runners/runner.d.ts +11 -0
  59. package/dist/runners/runner.d.ts.map +1 -0
  60. package/dist/runners/runner.js +2 -0
  61. package/dist/runners/runner.js.map +1 -0
  62. package/dist/test-repo-utils.d.ts +21 -0
  63. package/dist/test-repo-utils.d.ts.map +1 -0
  64. package/dist/test-repo-utils.js +109 -0
  65. package/dist/test-repo-utils.js.map +1 -0
  66. package/dist/trace-compressor.d.ts +130 -0
  67. package/dist/trace-compressor.d.ts.map +1 -0
  68. package/dist/trace-compressor.js +680 -0
  69. package/dist/trace-compressor.js.map +1 -0
  70. package/dist/tui/data.d.ts +84 -0
  71. package/dist/tui/data.d.ts.map +1 -0
  72. package/dist/tui/data.js +80 -0
  73. package/dist/tui/data.js.map +1 -0
  74. package/dist/tui/events.d.ts +86 -0
  75. package/dist/tui/events.d.ts.map +1 -0
  76. package/dist/tui/events.js +52 -0
  77. package/dist/tui/events.js.map +1 -0
  78. package/dist/vendor/error.d.ts +18 -0
  79. package/dist/vendor/error.d.ts.map +1 -0
  80. package/dist/vendor/error.js +64 -0
  81. package/dist/vendor/error.js.map +1 -0
  82. package/dist/vendor/print-mode.d.ts +75 -0
  83. package/dist/vendor/print-mode.d.ts.map +1 -0
  84. package/dist/vendor/print-mode.js +2 -0
  85. package/dist/vendor/print-mode.js.map +1 -0
  86. package/package.json +46 -0
@@ -0,0 +1,680 @@
1
+ /**
2
+ * trace-compressor.ts — Compress verbose agent traces by extracting large
3
+ * tool outputs into sidecar files, keeping an inline trace with stable
4
+ * content-hash pointers and human-readable summaries.
5
+ *
6
+ * Supports:
7
+ * - JSON-lines (JSONL) streams — PrintModeEvent lines from the Claude runner
8
+ * - Plain-text traces — free-form text with fenced / tagged blocks
9
+ *
10
+ * Sidecar IDs are derived from SHA-256(content), so identical content across
11
+ * different trace runs always produces the same pointer and only one file.
12
+ *
13
+ * Usage (programmatic):
14
+ * import { compressTrace, restoreTrace } from './trace-compressor'
15
+ *
16
+ * const { compressed, manifest, stats } = await compressTrace(rawTrace, {
17
+ * sidecarDir: '/tmp/run/trace.sidecars',
18
+ * threshold: 2048,
19
+ * summarize: 'heuristic',
20
+ * })
21
+ *
22
+ * Usage (CLI):
23
+ * bun run src/trace-compressor.ts trace.txt [options]
24
+ */
25
+ import crypto from 'crypto';
26
+ import fs from 'fs';
27
+ import path from 'path';
28
+ import Anthropic from '@anthropic-ai/sdk';
29
+ // =============================================================================
30
+ // Constants
31
+ // =============================================================================
32
+ const DEFAULT_THRESHOLD = 2_048;
33
+ /** Sentinel wrappers used for string-typed sidecar pointers inside JSONL fields. */
34
+ const STR_SENTINEL_OPEN = '[[sidecar:';
35
+ const STR_SENTINEL_CLOSE = ']]';
36
+ /** Regex patterns for block detection in plain-text traces.
37
+ *
38
+ * FENCE_RE captures:
39
+ * m[1] = opening line including its newline (e.g. "```python\n")
40
+ * m[2] = body (no leading/trailing newline)
41
+ * m[3] = "\n" + closing fence (e.g. "\n```")
42
+ *
43
+ * Keeping the newline before the closing fence in m[3] ensures that restoring
44
+ * m[2] from a sidecar does not produce an extra blank line.
45
+ *
46
+ * XML_BLOCK_RE captures:
47
+ * m[1] = tag name
48
+ * m[2] = optional attributes (may be undefined)
49
+ * m[3] = body (including surrounding whitespace/newlines)
50
+ */
51
+ const FENCE_RE = /(`{3}[^\n]*\n)([\s\S]*?)(\n`{3})/g;
52
+ const XML_BLOCK_RE = /<(result|output|tool_result|content)(\s[^>]*)?>(\s*[\s\S]*?)<\/\1>/gi;
53
+ // LABEL_BLOCK_RE stops at a blank line (two consecutive newlines). This
54
+ // prevents it from accidentally swallowing fenced blocks or the next step that
55
+ // follow the labelled content. The `s` flag makes `.` match newlines so the
56
+ // negative-lookahead `(?!\n\n)` can detect blank-line boundaries.
57
+ const LABEL_BLOCK_RE = /((?:Result|Output|Tool output|Response|Tool result):\s*\n)((?:(?!\n\n).)+)/gis;
58
+ // =============================================================================
59
+ // Utility — stable content hashing
60
+ // =============================================================================
61
+ function contentHash(data) {
62
+ return crypto.createHash('sha256').update(data, 'utf8').digest('hex').slice(0, 12);
63
+ }
64
+ // =============================================================================
65
+ // Utility — sidecar file I/O
66
+ // =============================================================================
67
+ /**
68
+ * Write content to a sidecar file named by its content hash.
69
+ * Idempotent: if the file already exists (same hash = same content) it is left
70
+ * unchanged.
71
+ */
72
+ function writeSidecar(sidecarDir, content, ext) {
73
+ const sidecarId = contentHash(content);
74
+ const file = `sidecar_${sidecarId}.${ext}`;
75
+ const filePath = path.join(sidecarDir, file);
76
+ if (!fs.existsSync(filePath)) {
77
+ fs.writeFileSync(filePath, content, 'utf8');
78
+ }
79
+ return { sidecarId, file };
80
+ }
81
+ // =============================================================================
82
+ // Utility — summarisation strategies
83
+ // =============================================================================
84
+ function summarizeHeuristic(content) {
85
+ const lines = content.trim().split('\n');
86
+ const byteCount = Buffer.byteLength(content, 'utf8');
87
+ const preview = content.slice(0, 140).replace(/\s+/g, ' ').trim();
88
+ const ellipsis = content.length > 140 ? '…' : '';
89
+ return `[${byteCount.toLocaleString('en')} bytes, ${lines.length.toLocaleString('en')} lines] ${preview}${ellipsis}`;
90
+ }
91
+ async function summarizeClaude(client, content, hint) {
92
+ const contextNote = hint ? ` (from ${hint})` : '';
93
+ const excerpt = content.slice(0, 6_000);
94
+ const truncNote = content.length > 6_000 ? '\n[…output truncated for summarisation…]' : '';
95
+ const prompt = `Summarise the following tool output${contextNote} in a single sentence (≤ 25 words). ` +
96
+ `Be concrete — mention counts, key values, or the main action.\n\n` +
97
+ `\`\`\`\n${excerpt}${truncNote}\n\`\`\``;
98
+ const response = await client.messages.create({
99
+ model: 'claude-haiku-4-5',
100
+ max_tokens: 128,
101
+ messages: [{ role: 'user', content: prompt }],
102
+ });
103
+ const block = response.content[0];
104
+ return block.type === 'text' ? block.text.trim() : summarizeHeuristic(content);
105
+ }
106
+ // =============================================================================
107
+ // Format detection
108
+ // =============================================================================
109
+ /**
110
+ * Auto-detect whether a trace is JSONL or plain text.
111
+ * Votes based on whether ≥60 % of sampled lines parse as JSON objects.
112
+ */
113
+ export function detectFormat(content) {
114
+ const lines = content.split('\n').filter((l) => l.trim());
115
+ if (lines.length === 0)
116
+ return 'text';
117
+ const sample = lines.slice(0, Math.min(20, lines.length));
118
+ const hits = sample.filter((l) => {
119
+ try {
120
+ const v = JSON.parse(l);
121
+ return typeof v === 'object' && v !== null;
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ }).length;
127
+ return hits / sample.length >= 0.6 ? 'jsonl' : 'text';
128
+ }
129
+ // =============================================================================
130
+ // String-sentinel helpers (for string-typed fields in JSONL)
131
+ // =============================================================================
132
+ /** Type-guard: detects a string sidecar pointer, e.g. "[[sidecar:abc123|…]]". */
133
+ export function isStrSidecarPointer(v) {
134
+ return (typeof v === 'string' &&
135
+ v.startsWith(STR_SENTINEL_OPEN) &&
136
+ v.endsWith(STR_SENTINEL_CLOSE));
137
+ }
138
+ /** Build the inline string sentinel from a SidecarRef. */
139
+ function buildStrPointer(ref) {
140
+ return `${STR_SENTINEL_OPEN}${ref.sidecarId}|${ref.file}|${ref.byteCount}|${ref.summary}${STR_SENTINEL_CLOSE}`;
141
+ }
142
+ /** Parse the components out of a string sentinel. */
143
+ function parseStrPointer(pointer) {
144
+ if (!isStrSidecarPointer(pointer))
145
+ return null;
146
+ const inner = pointer.slice(STR_SENTINEL_OPEN.length, pointer.length - STR_SENTINEL_CLOSE.length);
147
+ // Split on | but limit to 4 parts so summary can contain pipes
148
+ const idx1 = inner.indexOf('|');
149
+ const idx2 = inner.indexOf('|', idx1 + 1);
150
+ const idx3 = inner.indexOf('|', idx2 + 1);
151
+ if (idx1 < 0 || idx2 < 0 || idx3 < 0)
152
+ return null;
153
+ return {
154
+ sidecarId: inner.slice(0, idx1),
155
+ file: inner.slice(idx1 + 1, idx2),
156
+ byteCount: parseInt(inner.slice(idx2 + 1, idx3), 10),
157
+ summary: inner.slice(idx3 + 1),
158
+ };
159
+ }
160
+ // =============================================================================
161
+ // Type guard — object SidecarRef
162
+ // =============================================================================
163
+ /** Type-guard: detects a SidecarRef object embedded in a parsed JSON value. */
164
+ export function isSidecarRef(v) {
165
+ return (typeof v === 'object' &&
166
+ v !== null &&
167
+ v.__sidecar__ === true);
168
+ }
169
+ // =============================================================================
170
+ // JSONL — compression
171
+ // =============================================================================
172
+ /**
173
+ * Recursively walk a parsed JSON value and extract any sub-tree that, when
174
+ * serialised, exceeds the byte threshold.
175
+ *
176
+ * Strings are replaced with an inline string sentinel (so the field type
177
+ * stays `string` in the output JSON).
178
+ * Arrays / objects are replaced with an object SidecarRef.
179
+ */
180
+ async function compressValue(value, hint, ctx) {
181
+ // ── String ───────────────────────────────────────────────────────────────
182
+ if (typeof value === 'string') {
183
+ const bytes = Buffer.byteLength(value, 'utf8');
184
+ if (bytes > ctx.threshold) {
185
+ const summary = await ctx.summarize(value, hint);
186
+ const { sidecarId, file } = writeSidecar(ctx.sidecarDir, value, 'txt');
187
+ const ref = {
188
+ __sidecar__: true,
189
+ sidecarId,
190
+ file,
191
+ byteCount: bytes,
192
+ summary,
193
+ contentType: 'text',
194
+ };
195
+ ctx.entries.push({ sidecarId, file, byteCount: bytes, summary, contentType: 'text', hint });
196
+ return buildStrPointer(ref);
197
+ }
198
+ return value;
199
+ }
200
+ // ── Array ────────────────────────────────────────────────────────────────
201
+ if (Array.isArray(value)) {
202
+ const serialised = JSON.stringify(value);
203
+ const bytes = Buffer.byteLength(serialised, 'utf8');
204
+ if (bytes > ctx.threshold) {
205
+ const summary = await ctx.summarize(serialised, hint);
206
+ const { sidecarId, file } = writeSidecar(ctx.sidecarDir, serialised, 'json');
207
+ const ref = {
208
+ __sidecar__: true,
209
+ sidecarId,
210
+ file,
211
+ byteCount: bytes,
212
+ summary,
213
+ contentType: 'json',
214
+ };
215
+ ctx.entries.push({ sidecarId, file, byteCount: bytes, summary, contentType: 'json', hint });
216
+ return ref;
217
+ }
218
+ // Small enough — recurse into items
219
+ return Promise.all(value.map((item, i) => compressValue(item, `${hint}[${i}]`, ctx)));
220
+ }
221
+ // ── Object ───────────────────────────────────────────────────────────────
222
+ if (typeof value === 'object' && value !== null) {
223
+ const result = {};
224
+ for (const [k, v] of Object.entries(value)) {
225
+ result[k] = await compressValue(v, `${hint}.${k}`, ctx);
226
+ }
227
+ return result;
228
+ }
229
+ // ── Primitive ────────────────────────────────────────────────────────────
230
+ return value;
231
+ }
232
+ /**
233
+ * Compress one parsed JSONL event object.
234
+ *
235
+ * Applies targeted extraction for well-known large-content fields in
236
+ * PrintModeEvent variants (tool_result.output, text.text, reasoning_delta.text),
237
+ * then falls back to a general recursive sweep for unknown event types.
238
+ */
239
+ async function compressEvent(event, lineNum, ctx) {
240
+ const type = typeof event.type === 'string' ? event.type : 'unknown';
241
+ const toolName = typeof event.toolName === 'string' ? event.toolName : '';
242
+ const baseHint = toolName
243
+ ? `${type}:${toolName}:line ${lineNum}`
244
+ : `${type}:line ${lineNum}`;
245
+ // ── tool_result — output array ──────────────────────────────────────────
246
+ if (type === 'tool_result' && Array.isArray(event.output)) {
247
+ const serialised = JSON.stringify(event.output);
248
+ const bytes = Buffer.byteLength(serialised, 'utf8');
249
+ if (bytes > ctx.threshold) {
250
+ const summary = await ctx.summarize(serialised, baseHint);
251
+ const { sidecarId, file } = writeSidecar(ctx.sidecarDir, serialised, 'json');
252
+ const ref = {
253
+ __sidecar__: true,
254
+ sidecarId,
255
+ file,
256
+ byteCount: bytes,
257
+ summary,
258
+ contentType: 'json',
259
+ };
260
+ ctx.entries.push({ sidecarId, file, byteCount: bytes, summary, contentType: 'json', hint: baseHint });
261
+ return { ...event, output: ref };
262
+ }
263
+ // Output array small overall — recurse into individual items
264
+ const compressedOutput = await Promise.all(event.output.map((item, i) => compressValue(item, `${baseHint}.output[${i}]`, ctx)));
265
+ return { ...event, output: compressedOutput };
266
+ }
267
+ // ── text / reasoning_delta — text field ─────────────────────────────────
268
+ if ((type === 'text' || type === 'reasoning_delta') &&
269
+ typeof event.text === 'string') {
270
+ const bytes = Buffer.byteLength(event.text, 'utf8');
271
+ if (bytes > ctx.threshold) {
272
+ const summary = await ctx.summarize(event.text, baseHint);
273
+ const { sidecarId, file } = writeSidecar(ctx.sidecarDir, event.text, 'txt');
274
+ const ref = {
275
+ __sidecar__: true,
276
+ sidecarId,
277
+ file,
278
+ byteCount: bytes,
279
+ summary,
280
+ contentType: 'text',
281
+ };
282
+ ctx.entries.push({ sidecarId, file, byteCount: bytes, summary, contentType: 'text', hint: baseHint });
283
+ return { ...event, text: buildStrPointer(ref) };
284
+ }
285
+ return event;
286
+ }
287
+ // ── Generic fallback ─────────────────────────────────────────────────────
288
+ const result = {};
289
+ for (const [k, v] of Object.entries(event)) {
290
+ result[k] = await compressValue(v, `${baseHint}.${k}`, ctx);
291
+ }
292
+ return result;
293
+ }
294
+ async function compressJsonl(lines, ctx) {
295
+ const output = [];
296
+ let lineNum = 0;
297
+ for (const raw of lines) {
298
+ const trimmed = raw.trimEnd();
299
+ if (!trimmed) {
300
+ output.push(raw);
301
+ continue;
302
+ }
303
+ lineNum++;
304
+ let parsed;
305
+ try {
306
+ parsed = JSON.parse(trimmed);
307
+ }
308
+ catch {
309
+ output.push(raw); // non-JSON line — pass through
310
+ continue;
311
+ }
312
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
313
+ output.push(raw);
314
+ continue;
315
+ }
316
+ ctx.lineNum = lineNum;
317
+ const compressed = await compressEvent(parsed, lineNum, ctx);
318
+ output.push(JSON.stringify(compressed));
319
+ }
320
+ return output;
321
+ }
322
+ // =============================================================================
323
+ // Plain-text — compression
324
+ // =============================================================================
325
+ /** Build the multi-line inline pointer used in plain-text traces. */
326
+ function buildTextPointer(ref) {
327
+ return (`[[SIDECAR:${ref.file}]]\n` +
328
+ ` bytes : ${ref.byteCount.toLocaleString('en')}\n` +
329
+ ` summary : ${ref.summary}\n` +
330
+ ` sidecarId: ${ref.sidecarId}`);
331
+ }
332
+ /**
333
+ * Async variant of String.replace that uses a stateful regex and awaits each
334
+ * replacement function. Processes matches in reverse-index order so string
335
+ * positions remain valid.
336
+ */
337
+ async function replaceAsync(str, re, replacer) {
338
+ const segments = [];
339
+ const rx = new RegExp(re.source, re.flags.replace('g', '') + 'g');
340
+ let m;
341
+ while ((m = rx.exec(str)) !== null) {
342
+ const replacement = await replacer(m);
343
+ segments.push({ index: m.index, len: m[0].length, replacement });
344
+ }
345
+ // Rebuild string applying replacements in reverse order
346
+ let result = str;
347
+ for (let i = segments.length - 1; i >= 0; i--) {
348
+ const { index, len, replacement } = segments[i];
349
+ result = result.slice(0, index) + replacement + result.slice(index + len);
350
+ }
351
+ return result;
352
+ }
353
+ async function compressPlainText(text, ctx) {
354
+ let blockIndex = 0;
355
+ /**
356
+ * If `body` exceeds the threshold, extract it and return the pointer;
357
+ * otherwise return `full` unchanged.
358
+ */
359
+ async function maybeExtract(full, body, hint, ext, wrap) {
360
+ const bytes = Buffer.byteLength(body, 'utf8');
361
+ if (bytes <= ctx.threshold)
362
+ return full;
363
+ const summary = await ctx.summarize(body, hint);
364
+ const { sidecarId, file } = writeSidecar(ctx.sidecarDir, body, ext);
365
+ const ref = {
366
+ __sidecar__: true,
367
+ sidecarId,
368
+ file,
369
+ byteCount: bytes,
370
+ summary,
371
+ contentType: 'text',
372
+ };
373
+ ctx.entries.push({ sidecarId, file, byteCount: bytes, summary, contentType: 'text', hint });
374
+ return wrap(buildTextPointer(ref));
375
+ }
376
+ // 1. Markdown fenced code blocks ``` … ```
377
+ text = await replaceAsync(text, FENCE_RE, async (m) => {
378
+ blockIndex++;
379
+ return maybeExtract(m[0], m[2], // captured body between fences
380
+ `fenced-block:${blockIndex}`, 'txt',
381
+ // m[3] already starts with "\n" (captured before closing fence),
382
+ // so no extra newline is needed here.
383
+ (ptr) => `${m[1]}${ptr}${m[3]}`);
384
+ });
385
+ // 2. XML-style <result>…</result>, <output>…</output>, etc.
386
+ text = await replaceAsync(text, XML_BLOCK_RE, async (m) => {
387
+ blockIndex++;
388
+ const tag = m[1];
389
+ const attrs = m[2] ?? '';
390
+ const body = m[3];
391
+ return maybeExtract(m[0], body, `xml-${tag}:${blockIndex}`, 'txt', (ptr) => `<${tag}${attrs}>${ptr}</${tag}>`);
392
+ });
393
+ // 3. Label-prefix blocks ("Result:\n…")
394
+ text = await replaceAsync(text, LABEL_BLOCK_RE, async (m) => {
395
+ blockIndex++;
396
+ return maybeExtract(m[0], m[2], `label-${m[1].trim()}:${blockIndex}`, 'txt', (ptr) => `${m[1]}${ptr}`);
397
+ });
398
+ return text;
399
+ }
400
+ // =============================================================================
401
+ // Main public API
402
+ // =============================================================================
403
+ /**
404
+ * Compress a trace string, extracting all large blocks to sidecar files.
405
+ *
406
+ * @param traceContent Raw trace (JSONL or plain text)
407
+ * @param opts Options — only `sidecarDir` is required
408
+ * @returns Compressed text, manifest metadata, and statistics
409
+ */
410
+ export async function compressTrace(traceContent, opts) {
411
+ const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
412
+ const summarizeMode = opts.summarize ?? 'heuristic';
413
+ const fmt = !opts.format || opts.format === 'auto'
414
+ ? detectFormat(traceContent)
415
+ : opts.format;
416
+ fs.mkdirSync(opts.sidecarDir, { recursive: true });
417
+ let claudeClient = null;
418
+ const summarize = async (content, hint) => {
419
+ if (summarizeMode === 'claude') {
420
+ claudeClient ??= new Anthropic({ apiKey: opts.anthropicApiKey });
421
+ return summarizeClaude(claudeClient, content, hint);
422
+ }
423
+ if (summarizeMode === 'none') {
424
+ return `[${Buffer.byteLength(content, 'utf8').toLocaleString('en')} bytes]`;
425
+ }
426
+ return summarizeHeuristic(content);
427
+ };
428
+ const ctx = {
429
+ sidecarDir: opts.sidecarDir,
430
+ threshold,
431
+ summarize,
432
+ entries: [],
433
+ lineNum: 0,
434
+ };
435
+ let compressed;
436
+ if (fmt === 'jsonl') {
437
+ const compressedLines = await compressJsonl(traceContent.split('\n'), ctx);
438
+ compressed = compressedLines.join('\n');
439
+ }
440
+ else {
441
+ compressed = await compressPlainText(traceContent, ctx);
442
+ }
443
+ const manifest = {
444
+ version: 1,
445
+ created: new Date().toISOString(),
446
+ threshold,
447
+ format: fmt,
448
+ entries: ctx.entries,
449
+ };
450
+ fs.writeFileSync(path.join(opts.sidecarDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
451
+ const originalBytes = Buffer.byteLength(traceContent, 'utf8');
452
+ const compressedBytes = Buffer.byteLength(compressed, 'utf8');
453
+ const reductionPct = originalBytes > 0
454
+ ? Math.round((1 - compressedBytes / originalBytes) * 1000) / 10
455
+ : 0;
456
+ return {
457
+ compressed,
458
+ manifest,
459
+ stats: { format: fmt, originalBytes, compressedBytes, sidecarCount: ctx.entries.length, reductionPct },
460
+ };
461
+ }
462
+ // =============================================================================
463
+ // Restoration
464
+ // =============================================================================
465
+ /** Read a sidecar file and return the original value. */
466
+ function readSidecar(sidecarDir, file, contentType) {
467
+ const raw = fs.readFileSync(path.join(sidecarDir, file), 'utf8');
468
+ return contentType === 'json' ? JSON.parse(raw) : raw;
469
+ }
470
+ function restoreValue(value, sidecarDir) {
471
+ // String sentinel
472
+ if (isStrSidecarPointer(value)) {
473
+ const info = parseStrPointer(value);
474
+ if (info)
475
+ return readSidecar(sidecarDir, info.file, 'text');
476
+ return value;
477
+ }
478
+ // Object SidecarRef
479
+ if (isSidecarRef(value)) {
480
+ return readSidecar(sidecarDir, value.file, value.contentType);
481
+ }
482
+ if (Array.isArray(value)) {
483
+ return value.map((item) => restoreValue(item, sidecarDir));
484
+ }
485
+ if (typeof value === 'object' && value !== null) {
486
+ const result = {};
487
+ for (const [k, v] of Object.entries(value)) {
488
+ result[k] = restoreValue(v, sidecarDir);
489
+ }
490
+ return result;
491
+ }
492
+ return value;
493
+ }
494
+ function restoreJsonl(content, sidecarDir) {
495
+ return content
496
+ .split('\n')
497
+ .map((raw) => {
498
+ const trimmed = raw.trimEnd();
499
+ if (!trimmed)
500
+ return raw;
501
+ let parsed;
502
+ try {
503
+ parsed = JSON.parse(trimmed);
504
+ }
505
+ catch {
506
+ return raw;
507
+ }
508
+ return JSON.stringify(restoreValue(parsed, sidecarDir));
509
+ })
510
+ .join('\n');
511
+ }
512
+ /** Matches the multi-line plain-text pointer block produced by buildTextPointer. */
513
+ const TEXT_POINTER_RE = /\[\[SIDECAR:([^\]]+)\]\]\n[ \t]+bytes\s*:[ \t]+[\d,]+\n[ \t]+summary\s*:[ \t]+.+\n[ \t]+sidecarId\s*:[ \t]+[a-f0-9]+/g;
514
+ function restorePlainText(content, sidecarDir) {
515
+ // Iterate until stable: handles cases where a sidecar's content itself
516
+ // contains embedded sidecar pointers (e.g. when a label block was extracted
517
+ // after fence compression had already placed a pointer inside its range).
518
+ let prev = '';
519
+ let current = content;
520
+ while (prev !== current) {
521
+ prev = current;
522
+ current = current.replace(TEXT_POINTER_RE, (match, file) => {
523
+ const filePath = path.join(sidecarDir, file);
524
+ if (!fs.existsSync(filePath))
525
+ return match;
526
+ return fs.readFileSync(filePath, 'utf8');
527
+ });
528
+ }
529
+ return current;
530
+ }
531
+ /**
532
+ * Restore a compressed trace to its original form by expanding all sidecar
533
+ * references.
534
+ *
535
+ * @param compressedContent Text produced by `compressTrace`
536
+ * @param sidecarDir Directory containing the sidecar files
537
+ * @param format 'auto' (default) to detect, or 'jsonl' / 'text'
538
+ * @returns Original trace text
539
+ */
540
+ export function restoreTrace(compressedContent, sidecarDir, format = 'auto') {
541
+ const fmt = format === 'auto' ? detectFormat(compressedContent) : format;
542
+ return fmt === 'jsonl'
543
+ ? restoreJsonl(compressedContent, sidecarDir)
544
+ : restorePlainText(compressedContent, sidecarDir);
545
+ }
546
+ // =============================================================================
547
+ // Convenience wrapper — used by report.ts integration
548
+ // =============================================================================
549
+ /**
550
+ * Compress a trace and write both the compressed file and sidecars to disk in
551
+ * one call. Returns the paths for downstream use.
552
+ */
553
+ export async function compressAndSave(tracePath, content, threshold = DEFAULT_THRESHOLD, summarize = 'heuristic') {
554
+ const compressedPath = tracePath + '.compressed';
555
+ const sidecarDir = tracePath + '.sidecars';
556
+ const result = await compressTrace(content, { sidecarDir, threshold, summarize });
557
+ fs.writeFileSync(compressedPath, result.compressed, 'utf8');
558
+ return { compressedPath, sidecarDir, stats: result.stats };
559
+ }
560
+ // =============================================================================
561
+ // CLI — bun run src/trace-compressor.ts
562
+ // =============================================================================
563
+ if (import.meta.main) {
564
+ const args = process.argv.slice(2);
565
+ const getFlag = (name, def = '') => {
566
+ const i = args.indexOf(`--${name}`);
567
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : def;
568
+ };
569
+ const hasFlag = (name) => args.includes(`--${name}`);
570
+ const positional = args.filter((a) => !a.startsWith('--') && args[args.indexOf(a) - 1] !== '--output'
571
+ && args[args.indexOf(a) - 1] !== '--sidecar-dir'
572
+ && args[args.indexOf(a) - 1] !== '--threshold'
573
+ && args[args.indexOf(a) - 1] !== '--format'
574
+ && args[args.indexOf(a) - 1] !== '--summarize');
575
+ if (args.length === 0 || hasFlag('help') || hasFlag('h')) {
576
+ console.log(`
577
+ trace-compressor — extract large tool outputs from agent traces into sidecar files.
578
+
579
+ Usage:
580
+ bun run src/trace-compressor.ts <input> [options]
581
+ bun run src/trace-compressor.ts --restore <compressed> [options]
582
+
583
+ Arguments:
584
+ <input> Trace file to compress (use "-" for stdin)
585
+
586
+ Compression options:
587
+ --output <path> Output file (default: <input>.compressed, or stdout for stdin)
588
+ --sidecar-dir <path> Sidecar directory (default: <output>.sidecars)
589
+ --threshold <bytes> Extract if larger than N bytes (default: ${DEFAULT_THRESHOLD})
590
+ --format auto|jsonl|text Force input format (default: auto-detect)
591
+ --summarize heuristic|claude|none
592
+ Inline summary strategy (default: heuristic)
593
+
594
+ Restore options:
595
+ --restore Expand sidecar refs back to original content
596
+ --sidecar-dir <path> Sidecar directory (required with --restore)
597
+
598
+ General:
599
+ --help Show this message
600
+ `.trim());
601
+ process.exit(0);
602
+ }
603
+ const isRestore = hasFlag('restore');
604
+ if (isRestore) {
605
+ const inputPath = positional[0];
606
+ if (!inputPath) {
607
+ console.error('Error: provide the compressed trace file as the first positional argument');
608
+ process.exit(1);
609
+ }
610
+ const inputText = inputPath === '-'
611
+ ? fs.readFileSync('/dev/stdin', 'utf8')
612
+ : fs.readFileSync(inputPath, 'utf8');
613
+ const sidecarDir = getFlag('sidecar-dir') || (inputPath !== '-' ? inputPath + '.sidecars' : '');
614
+ if (!sidecarDir) {
615
+ console.error('Error: --sidecar-dir is required when reading from stdin with --restore');
616
+ process.exit(1);
617
+ }
618
+ const outputPath = getFlag('output') || (inputPath !== '-'
619
+ ? inputPath.replace(/\.compressed$/, '') + '.restored'
620
+ : '-');
621
+ const format = (getFlag('format') || 'auto');
622
+ const restored = restoreTrace(inputText, sidecarDir, format);
623
+ if (outputPath === '-') {
624
+ process.stdout.write(restored);
625
+ }
626
+ else {
627
+ fs.writeFileSync(outputPath, restored, 'utf8');
628
+ console.error(`✓ Restored → ${outputPath}`);
629
+ }
630
+ }
631
+ else {
632
+ // Compress mode
633
+ const inputPath = positional[0];
634
+ if (!inputPath) {
635
+ console.error('Error: provide the trace file as the first positional argument');
636
+ process.exit(1);
637
+ }
638
+ const rawContent = inputPath === '-'
639
+ ? fs.readFileSync('/dev/stdin', 'utf8')
640
+ : fs.readFileSync(inputPath, 'utf8');
641
+ const outputPath = getFlag('output') ||
642
+ (inputPath !== '-' ? inputPath + '.compressed' : '-');
643
+ const sidecarDir = getFlag('sidecar-dir') ||
644
+ ((outputPath !== '-' ? outputPath : 'trace') + '.sidecars');
645
+ const threshold = parseInt(getFlag('threshold') || String(DEFAULT_THRESHOLD), 10);
646
+ const format = (getFlag('format') || 'auto');
647
+ const summarize = (getFlag('summarize') || 'heuristic');
648
+ const { compressed, manifest, stats } = await compressTrace(rawContent, {
649
+ sidecarDir,
650
+ threshold,
651
+ format,
652
+ summarize,
653
+ });
654
+ if (outputPath === '-') {
655
+ process.stdout.write(compressed);
656
+ }
657
+ else {
658
+ fs.writeFileSync(outputPath, compressed, 'utf8');
659
+ }
660
+ // Stats → stderr (always)
661
+ const saved = stats.originalBytes - stats.compressedBytes;
662
+ console.error(`\n✓ Compressed (${stats.format})` +
663
+ ` ${stats.originalBytes.toLocaleString('en')} → ${stats.compressedBytes.toLocaleString('en')} bytes` +
664
+ ` (${stats.reductionPct}% reduction, ${saved.toLocaleString('en')} bytes saved)` +
665
+ ` ${stats.sidecarCount} sidecar(s) in ${sidecarDir}/`);
666
+ if (outputPath !== '-') {
667
+ console.error(` Inline trace : ${outputPath}`);
668
+ }
669
+ console.error(` Manifest : ${path.join(sidecarDir, 'manifest.json')}`);
670
+ if (manifest.entries.length > 0) {
671
+ console.error(`\n Extracted:`);
672
+ const colWidth = Math.max(...manifest.entries.map((e) => e.file.length)) + 2;
673
+ for (const e of manifest.entries) {
674
+ const bytes = e.byteCount.toLocaleString('en').padStart(12);
675
+ console.error(` ${e.file.padEnd(colWidth)} ${bytes} bytes ${e.hint}`);
676
+ }
677
+ }
678
+ }
679
+ }
680
+ //# sourceMappingURL=trace-compressor.js.map