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.
- package/README.md +79 -0
- package/dist/carve-features.d.ts +42 -0
- package/dist/carve-features.d.ts.map +1 -0
- package/dist/carve-features.js +305 -0
- package/dist/carve-features.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +42 -0
- package/dist/cli.js.map +1 -0
- package/dist/docs-refactor.d.ts +4 -0
- package/dist/docs-refactor.d.ts.map +1 -0
- package/dist/docs-refactor.js +122 -0
- package/dist/docs-refactor.js.map +1 -0
- package/dist/docs-writer.d.ts +4 -0
- package/dist/docs-writer.d.ts.map +1 -0
- package/dist/docs-writer.js +122 -0
- package/dist/docs-writer.js.map +1 -0
- package/dist/eval-helpers.d.ts +19 -0
- package/dist/eval-helpers.d.ts.map +1 -0
- package/dist/eval-helpers.js +327 -0
- package/dist/eval-helpers.js.map +1 -0
- package/dist/eval-runner.d.ts +42 -0
- package/dist/eval-runner.d.ts.map +1 -0
- package/dist/eval-runner.js +193 -0
- package/dist/eval-runner.js.map +1 -0
- package/dist/judge.d.ts +22 -0
- package/dist/judge.d.ts.map +1 -0
- package/dist/judge.js +284 -0
- package/dist/judge.js.map +1 -0
- package/dist/perfect-feature.d.ts +2 -0
- package/dist/perfect-feature.d.ts.map +1 -0
- package/dist/perfect-feature.js +666 -0
- package/dist/perfect-feature.js.map +1 -0
- package/dist/report.d.ts +31 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +249 -0
- package/dist/report.js.map +1 -0
- package/dist/run-evalbuff.d.ts +12 -0
- package/dist/run-evalbuff.d.ts.map +1 -0
- package/dist/run-evalbuff.js +383 -0
- package/dist/run-evalbuff.js.map +1 -0
- package/dist/runners/claude.d.ts +10 -0
- package/dist/runners/claude.d.ts.map +1 -0
- package/dist/runners/claude.js +80 -0
- package/dist/runners/claude.js.map +1 -0
- package/dist/runners/codebuff.d.ts +24 -0
- package/dist/runners/codebuff.d.ts.map +1 -0
- package/dist/runners/codebuff.js +88 -0
- package/dist/runners/codebuff.js.map +1 -0
- package/dist/runners/codex.d.ts +8 -0
- package/dist/runners/codex.d.ts.map +1 -0
- package/dist/runners/codex.js +131 -0
- package/dist/runners/codex.js.map +1 -0
- package/dist/runners/index.d.ts +5 -0
- package/dist/runners/index.d.ts.map +1 -0
- package/dist/runners/index.js +4 -0
- package/dist/runners/index.js.map +1 -0
- package/dist/runners/runner.d.ts +11 -0
- package/dist/runners/runner.d.ts.map +1 -0
- package/dist/runners/runner.js +2 -0
- package/dist/runners/runner.js.map +1 -0
- package/dist/test-repo-utils.d.ts +21 -0
- package/dist/test-repo-utils.d.ts.map +1 -0
- package/dist/test-repo-utils.js +109 -0
- package/dist/test-repo-utils.js.map +1 -0
- package/dist/trace-compressor.d.ts +130 -0
- package/dist/trace-compressor.d.ts.map +1 -0
- package/dist/trace-compressor.js +680 -0
- package/dist/trace-compressor.js.map +1 -0
- package/dist/tui/data.d.ts +84 -0
- package/dist/tui/data.d.ts.map +1 -0
- package/dist/tui/data.js +80 -0
- package/dist/tui/data.js.map +1 -0
- package/dist/tui/events.d.ts +86 -0
- package/dist/tui/events.d.ts.map +1 -0
- package/dist/tui/events.js +52 -0
- package/dist/tui/events.js.map +1 -0
- package/dist/vendor/error.d.ts +18 -0
- package/dist/vendor/error.d.ts.map +1 -0
- package/dist/vendor/error.js +64 -0
- package/dist/vendor/error.js.map +1 -0
- package/dist/vendor/print-mode.d.ts +75 -0
- package/dist/vendor/print-mode.d.ts.map +1 -0
- package/dist/vendor/print-mode.js +2 -0
- package/dist/vendor/print-mode.js.map +1 -0
- 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
|