@astudioplus/compressor 0.1.0

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 (108) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +20 -0
  3. package/README.md +167 -0
  4. package/dist/adapters/agents-md.d.ts +2 -0
  5. package/dist/adapters/agents-md.js +91 -0
  6. package/dist/adapters/apply.d.ts +3 -0
  7. package/dist/adapters/apply.js +83 -0
  8. package/dist/adapters/claude-code.d.ts +2 -0
  9. package/dist/adapters/claude-code.js +403 -0
  10. package/dist/adapters/copilot.d.ts +2 -0
  11. package/dist/adapters/copilot.js +418 -0
  12. package/dist/adapters/cursor.d.ts +2 -0
  13. package/dist/adapters/cursor.js +149 -0
  14. package/dist/adapters/index.d.ts +11 -0
  15. package/dist/adapters/index.js +19 -0
  16. package/dist/adapters/markers.d.ts +7 -0
  17. package/dist/adapters/markers.js +129 -0
  18. package/dist/adapters/types.d.ts +44 -0
  19. package/dist/adapters/types.js +1 -0
  20. package/dist/bench/ablate.d.ts +35 -0
  21. package/dist/bench/ablate.js +163 -0
  22. package/dist/bench/cell.d.ts +33 -0
  23. package/dist/bench/cell.js +437 -0
  24. package/dist/bench/results.d.ts +37 -0
  25. package/dist/bench/results.js +157 -0
  26. package/dist/bench/runner.d.ts +24 -0
  27. package/dist/bench/runner.js +121 -0
  28. package/dist/bench/tasks.d.ts +4 -0
  29. package/dist/bench/tasks.js +147 -0
  30. package/dist/bench/types.d.ts +109 -0
  31. package/dist/bench/types.js +1 -0
  32. package/dist/claude/transcripts.d.ts +30 -0
  33. package/dist/claude/transcripts.js +154 -0
  34. package/dist/cli/commands/benchmark.d.ts +33 -0
  35. package/dist/cli/commands/benchmark.js +203 -0
  36. package/dist/cli/commands/compress.d.ts +8 -0
  37. package/dist/cli/commands/compress.js +45 -0
  38. package/dist/cli/commands/count.d.ts +5 -0
  39. package/dist/cli/commands/count.js +25 -0
  40. package/dist/cli/commands/hook.d.ts +6 -0
  41. package/dist/cli/commands/hook.js +30 -0
  42. package/dist/cli/commands/init.d.ts +16 -0
  43. package/dist/cli/commands/init.js +76 -0
  44. package/dist/cli/commands/report.d.ts +90 -0
  45. package/dist/cli/commands/report.js +464 -0
  46. package/dist/cli/commands/savings.d.ts +38 -0
  47. package/dist/cli/commands/savings.js +196 -0
  48. package/dist/cli/commands/set-mode.d.ts +5 -0
  49. package/dist/cli/commands/set-mode.js +13 -0
  50. package/dist/cli/commands/stats.d.ts +5 -0
  51. package/dist/cli/commands/stats.js +51 -0
  52. package/dist/cli/commands/status.d.ts +1 -0
  53. package/dist/cli/commands/status.js +11 -0
  54. package/dist/cli/commands/uninstall.d.ts +7 -0
  55. package/dist/cli/commands/uninstall.js +22 -0
  56. package/dist/cli/index.d.ts +2 -0
  57. package/dist/cli/index.js +146 -0
  58. package/dist/copilot-hook-entry.d.ts +1 -0
  59. package/dist/copilot-hook-entry.js +36 -0
  60. package/dist/copilot-hook.js +1000 -0
  61. package/dist/engine/detect.d.ts +2 -0
  62. package/dist/engine/detect.js +47 -0
  63. package/dist/engine/index.d.ts +4 -0
  64. package/dist/engine/index.js +90 -0
  65. package/dist/engine/policy.d.ts +2 -0
  66. package/dist/engine/policy.js +48 -0
  67. package/dist/engine/tiers/code.d.ts +7 -0
  68. package/dist/engine/tiers/code.js +206 -0
  69. package/dist/engine/tiers/logs.d.ts +4 -0
  70. package/dist/engine/tiers/logs.js +139 -0
  71. package/dist/engine/tiers/structural.d.ts +28 -0
  72. package/dist/engine/tiers/structural.js +199 -0
  73. package/dist/engine/types.d.ts +71 -0
  74. package/dist/engine/types.js +5 -0
  75. package/dist/hook/copilot.d.ts +5 -0
  76. package/dist/hook/copilot.js +136 -0
  77. package/dist/hook/core.d.ts +36 -0
  78. package/dist/hook/core.js +138 -0
  79. package/dist/hook/exit.d.ts +22 -0
  80. package/dist/hook/exit.js +56 -0
  81. package/dist/hook/post-tool-use.d.ts +5 -0
  82. package/dist/hook/post-tool-use.js +57 -0
  83. package/dist/hook-entry.d.ts +1 -0
  84. package/dist/hook-entry.js +35 -0
  85. package/dist/hook.js +946 -0
  86. package/dist/index.d.ts +15 -0
  87. package/dist/index.js +16 -0
  88. package/dist/ledger/read.d.ts +9 -0
  89. package/dist/ledger/read.js +91 -0
  90. package/dist/ledger/write.d.ts +29 -0
  91. package/dist/ledger/write.js +61 -0
  92. package/dist/packs/atoms.d.ts +3 -0
  93. package/dist/packs/atoms.js +108 -0
  94. package/dist/packs/modes.d.ts +3 -0
  95. package/dist/packs/modes.js +34 -0
  96. package/dist/packs/render.d.ts +24 -0
  97. package/dist/packs/render.js +115 -0
  98. package/dist/packs/types.d.ts +32 -0
  99. package/dist/packs/types.js +1 -0
  100. package/dist/paths.d.ts +29 -0
  101. package/dist/paths.js +87 -0
  102. package/dist/tokens/estimate.d.ts +12 -0
  103. package/dist/tokens/estimate.js +23 -0
  104. package/dist/tokens/exact.d.ts +5 -0
  105. package/dist/tokens/exact.js +16 -0
  106. package/dist/tokens/index.d.ts +2 -0
  107. package/dist/tokens/index.js +2 -0
  108. package/package.json +77 -0
@@ -0,0 +1,464 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { aggregate, readRun } from "../../bench/results.js";
4
+ export function parseFormat(value) {
5
+ if (value === 'table' || value === 'md' || value === 'json') {
6
+ return value;
7
+ }
8
+ throw new Error(`unknown --format '${value}' (expected table|md|json)`);
9
+ }
10
+ function deltasAgainst(baseline, others) {
11
+ return others.map((a) => ({
12
+ variantId: a.variantId,
13
+ // a variant with zero valid cells has medians of 0 by construction —
14
+ // never report that as a -100% win
15
+ outputPct: a.valid === 0 || baseline.valid === 0 || baseline.medianOutput === 0
16
+ ? null
17
+ : ((a.medianOutput - baseline.medianOutput) / baseline.medianOutput) * 100,
18
+ costPct: a.medianCostUsd === null ||
19
+ baseline.medianCostUsd === null ||
20
+ baseline.medianCostUsd === 0
21
+ ? null
22
+ : ((a.medianCostUsd - baseline.medianCostUsd) / baseline.medianCostUsd) * 100,
23
+ successPp: a.successRate === null || baseline.successRate === null
24
+ ? null
25
+ : (a.successRate - baseline.successRate) * 100,
26
+ }));
27
+ }
28
+ export function computeDeltas(aggregates) {
29
+ const full = aggregates.find((a) => a.variantId === 'full');
30
+ if (full === undefined) {
31
+ return null;
32
+ }
33
+ return deltasAgainst(full, aggregates.filter((a) => a.variantId !== 'full'));
34
+ }
35
+ /**
36
+ * The ablation gate question: for each <baseline>-minus/plus-<atom> variant,
37
+ * the marginal output & success delta vs that baseline — not vs full.
38
+ */
39
+ export function computeAblationDeltas(aggregates) {
40
+ const groups = [];
41
+ for (const baselineId of ['optimized', 'slim']) {
42
+ const prefix = `${baselineId}-`;
43
+ const members = aggregates.filter((a) => a.variantId.startsWith(`${prefix}minus-`) ||
44
+ a.variantId.startsWith(`${prefix}plus-`));
45
+ if (members.length === 0) {
46
+ continue;
47
+ }
48
+ const baseline = aggregates.find((a) => a.variantId === baselineId);
49
+ if (baseline === undefined) {
50
+ continue;
51
+ }
52
+ groups.push({ baselineId, deltas: deltasAgainst(baseline, members) });
53
+ }
54
+ return groups.length === 0 ? null : groups;
55
+ }
56
+ /** Linear-interpolated median (matches results.ts aggregation); 0 on empty input. */
57
+ function medianOf(values) {
58
+ const sorted = [...values].sort((x, y) => x - y);
59
+ if (sorted.length === 0)
60
+ return 0;
61
+ const pos = (sorted.length - 1) * 0.5;
62
+ const lo = Math.floor(pos);
63
+ const a = sorted[lo] ?? 0;
64
+ const b = sorted[Math.ceil(pos)] ?? a;
65
+ return a + (b - a) * (pos - lo);
66
+ }
67
+ function orderVariantIds(ids) {
68
+ const unique = [...new Set(ids)];
69
+ const rest = unique.filter((id) => id !== 'full').sort();
70
+ return unique.includes('full') ? ['full', ...rest] : rest;
71
+ }
72
+ /**
73
+ * Composite map key for a task x variant pair. taskIds may legally contain
74
+ * spaces (tasks.ts only requires a non-empty string), so the separator must
75
+ * be a character that cannot appear in either id: NUL - always written as
76
+ * the escape '\u0000', never a raw byte (a raw NUL makes this file binary
77
+ * to git and invisibly fragile to retyping).
78
+ */
79
+ function pairKey(taskId, variantId) {
80
+ return `${taskId}\u0000${variantId}`;
81
+ }
82
+ export function computeByTask(results) {
83
+ const byPair = new Map();
84
+ for (const row of results) {
85
+ const key = pairKey(row.taskId, row.variantId);
86
+ const rows = byPair.get(key);
87
+ if (rows === undefined) {
88
+ byPair.set(key, [row]);
89
+ }
90
+ else {
91
+ rows.push(row);
92
+ }
93
+ }
94
+ const taskIds = [...new Set(results.map((r) => r.taskId))].sort();
95
+ const variantIds = orderVariantIds(results.map((r) => r.variantId));
96
+ const cells = taskIds.map((taskId) => variantIds.map((variantId) => {
97
+ const rows = byPair.get(pairKey(taskId, variantId));
98
+ if (rows === undefined) {
99
+ return null;
100
+ }
101
+ const valid = rows.filter((r) => r.error === undefined || r.error === null);
102
+ const judged = valid.filter((r) => typeof r.success === 'boolean');
103
+ const successes = judged.filter((r) => r.success === true).length;
104
+ return {
105
+ taskId,
106
+ variantId,
107
+ cells: rows.length,
108
+ valid: valid.length,
109
+ judged: judged.length,
110
+ successes,
111
+ successFraction: judged.length === 0 ? null : successes / judged.length,
112
+ medianOutput: medianOf(valid.map((r) => r.usage.output)),
113
+ medianContext: medianOf(valid.map((r) => r.usage.input + r.usage.cacheCreation + r.usage.cacheRead)),
114
+ };
115
+ }));
116
+ return { taskIds, variantIds, cells };
117
+ }
118
+ /** A served model matching the requested one, allowing alias→dated-ID forms
119
+ * (requested 'claude-haiku-4-5' served 'claude-haiku-4-5-20251001'). */
120
+ function servedMatchesRequested(served, requested) {
121
+ return served.some((model) => model === requested || model.includes(requested));
122
+ }
123
+ export function findIssues(results) {
124
+ const vacuous = new Set();
125
+ const substituted = [];
126
+ const unknownServed = [];
127
+ const skipped = [];
128
+ const errors = [];
129
+ const denied = [];
130
+ for (const row of results) {
131
+ const cell = `${row.taskId} × ${row.variantId} trial ${row.trial}`;
132
+ if (row.baselineCheckPassed === true) {
133
+ vacuous.add(row.taskId);
134
+ }
135
+ if (row.permissionDenials > 0) {
136
+ denied.push(`${cell}: ${row.permissionDenials} permission denial(s) — usage inflated by retries`);
137
+ }
138
+ if (row.error !== undefined && row.error !== null) {
139
+ if (row.error.startsWith('skipped: ')) {
140
+ skipped.push(`${cell}: ${row.error}`);
141
+ }
142
+ else {
143
+ errors.push(`${cell}: ${row.error}`);
144
+ }
145
+ }
146
+ else if (row.servedModels.length === 0) {
147
+ unknownServed.push(`${cell}: result JSON reported no modelUsage`);
148
+ }
149
+ else if (!servedMatchesRequested(row.servedModels, row.model)) {
150
+ substituted.push(`${cell}: requested ${row.model}, served [${row.servedModels.join(', ')}]`);
151
+ }
152
+ }
153
+ return {
154
+ vacuousTasks: [...vacuous].sort(),
155
+ substitutedCells: substituted,
156
+ unknownServedCells: unknownServed,
157
+ skippedCells: skipped,
158
+ errorCells: errors,
159
+ deniedCells: denied,
160
+ };
161
+ }
162
+ export function buildRunReport(runId, meta, results) {
163
+ const aggregates = aggregate(results);
164
+ return {
165
+ runId,
166
+ meta,
167
+ aggregates,
168
+ deltas: computeDeltas(aggregates),
169
+ ablationDeltas: computeAblationDeltas(aggregates),
170
+ byTask: computeByTask(results),
171
+ issues: findIssues(results),
172
+ };
173
+ }
174
+ export async function loadRunReport(outDir, runId) {
175
+ const { meta, results } = await readRun(outDir, runId);
176
+ if (meta === null && results.length === 0) {
177
+ throw new Error(`run '${runId}' not found in ${outDir}`);
178
+ }
179
+ return buildRunReport(runId, meta, results);
180
+ }
181
+ function isRecord(value) {
182
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
183
+ }
184
+ async function runTimestamp(outDir, runId) {
185
+ try {
186
+ const parsed = JSON.parse(await readFile(path.join(outDir, `${runId}.meta.json`), 'utf8'));
187
+ if (isRecord(parsed) && typeof parsed['startedAt'] === 'string') {
188
+ const t = Date.parse(parsed['startedAt']);
189
+ if (Number.isFinite(t)) {
190
+ return t;
191
+ }
192
+ }
193
+ }
194
+ catch {
195
+ // fall through to mtime
196
+ }
197
+ for (const suffix of ['.jsonl', '.meta.json']) {
198
+ try {
199
+ return (await stat(path.join(outDir, `${runId}${suffix}`))).mtimeMs;
200
+ }
201
+ catch {
202
+ continue;
203
+ }
204
+ }
205
+ return null;
206
+ }
207
+ export async function latestRunId(outDir) {
208
+ let entries;
209
+ try {
210
+ entries = await readdir(outDir);
211
+ }
212
+ catch {
213
+ return null;
214
+ }
215
+ const ids = new Set();
216
+ for (const name of entries) {
217
+ const match = /^(.+)\.meta\.json$/.exec(name) ?? /^(.+)\.jsonl$/.exec(name);
218
+ const id = match?.[1];
219
+ if (id !== undefined) {
220
+ ids.add(id);
221
+ }
222
+ }
223
+ let best = null;
224
+ for (const id of ids) {
225
+ const at = await runTimestamp(outDir, id);
226
+ if (at !== null && (best === null || at > best.at)) {
227
+ best = { id, at };
228
+ }
229
+ }
230
+ return best?.id ?? null;
231
+ }
232
+ const fmtInt = (n) => Math.round(n).toLocaleString('en-US');
233
+ const fmtPct = (rate) => rate === null ? 'n/a' : `${(rate * 100).toFixed(1)}%`;
234
+ const fmtMoney = (usd) => usd === null ? 'n/a' : `$${usd.toFixed(4)}`;
235
+ const fmtSigned = (value, unit) => value === null ? 'n/a' : `${value >= 0 ? '+' : ''}${value.toFixed(1)}${unit}`;
236
+ const TABLE_HEADERS = [
237
+ 'variant',
238
+ 'cells',
239
+ 'errors',
240
+ 'success%',
241
+ 'in',
242
+ 'out',
243
+ 'out IQR',
244
+ 'cacheW',
245
+ 'cacheR',
246
+ 'cost',
247
+ 'turns',
248
+ 'duration',
249
+ ];
250
+ function aggregateRow(a) {
251
+ return [
252
+ a.variantId,
253
+ String(a.cells),
254
+ String(a.errors),
255
+ fmtPct(a.successRate),
256
+ fmtInt(a.medianInput),
257
+ fmtInt(a.medianOutput),
258
+ `${fmtInt(a.iqrOutput[0])}–${fmtInt(a.iqrOutput[1])}`,
259
+ fmtInt(a.medianCacheCreation),
260
+ fmtInt(a.medianCacheRead),
261
+ fmtMoney(a.medianCostUsd),
262
+ fmtInt(a.medianTurns),
263
+ `${fmtInt(a.medianDurationMs)}ms`,
264
+ ];
265
+ }
266
+ function plainTable(headers, rows) {
267
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((row) => (row[i] ?? '').length)));
268
+ const line = (cells) => cells.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(' ').trimEnd();
269
+ return [line(headers), ...rows.map(line)].join('\n');
270
+ }
271
+ function pipeTable(headers, rows) {
272
+ const line = (cells) => `| ${cells.join(' | ')} |`;
273
+ return [
274
+ line(headers),
275
+ line(headers.map(() => '---')),
276
+ ...rows.map(line),
277
+ ].join('\n');
278
+ }
279
+ function deltaLines(deltas) {
280
+ return deltas.map((d) => `${d.variantId}: output ${fmtSigned(d.outputPct, '%')}, cost ${fmtSigned(d.costPct, '%')}, success ${fmtSigned(d.successPp, 'pp')}`);
281
+ }
282
+ function issueLines(issues) {
283
+ const lines = [];
284
+ for (const taskId of issues.vacuousTasks) {
285
+ lines.push(`VACUOUS FIXTURE: task '${taskId}' — its check passed BEFORE the agent ran; cells prove nothing`);
286
+ }
287
+ for (const cell of issues.substitutedCells) {
288
+ lines.push(`MODEL SUBSTITUTION: ${cell}`);
289
+ }
290
+ for (const cell of issues.unknownServedCells) {
291
+ lines.push(`SERVED MODEL UNKNOWN: ${cell}`);
292
+ }
293
+ for (const cell of issues.deniedCells) {
294
+ lines.push(`PERMISSION DENIALS: ${cell}`);
295
+ }
296
+ if (issues.skippedCells.length > 0) {
297
+ lines.push(`SKIPPED (${issues.skippedCells.length} cells, never scheduled):`, ...issues.skippedCells.map((cell) => ` ${cell}`));
298
+ }
299
+ if (issues.errorCells.length > 0) {
300
+ lines.push(`INFRA ERRORS (${issues.errorCells.length} cells, excluded from success%):`, ...issues.errorCells.map((cell) => ` ${cell}`));
301
+ }
302
+ return lines;
303
+ }
304
+ function toolCallLines(aggregates) {
305
+ const lines = [];
306
+ for (const a of aggregates) {
307
+ const entries = Object.entries(a.toolCallTotals).sort(([x], [y]) => x.localeCompare(y));
308
+ if (entries.length === 0) {
309
+ continue;
310
+ }
311
+ lines.push(`${a.variantId}: ${entries.map(([name, n]) => `${name} ${n}`).join(', ')}`);
312
+ }
313
+ return lines;
314
+ }
315
+ function byTaskMatrix(byTask, pick) {
316
+ const headers = ['task', ...byTask.variantIds];
317
+ const rows = byTask.taskIds.map((taskId, i) => [
318
+ taskId,
319
+ ...byTask.variantIds.map((_, j) => {
320
+ const cell = byTask.cells[i]?.[j] ?? null;
321
+ // a pair with no rows, or whose every row errored, has nothing to report
322
+ return cell === null || cell.valid === 0 ? '—' : pick(cell);
323
+ }),
324
+ ]);
325
+ return { headers, rows };
326
+ }
327
+ function headerLines(report) {
328
+ const lines = [`run ${report.runId}`];
329
+ if (report.meta !== null) {
330
+ lines.push(`suite ${report.meta.suite} | model ${report.meta.model} | trials ${report.meta.trials} | started ${report.meta.startedAt}`);
331
+ }
332
+ return lines;
333
+ }
334
+ const USAGE_NOTE = 'medians of actual usage reported by claude (result JSON)';
335
+ export function formatReport(report, format) {
336
+ if (format === 'json') {
337
+ return JSON.stringify({
338
+ runId: report.runId,
339
+ meta: report.meta,
340
+ aggregates: report.aggregates,
341
+ deltas: report.deltas,
342
+ ablationDeltas: report.ablationDeltas,
343
+ byTask: report.byTask,
344
+ issues: report.issues,
345
+ }, null, 2);
346
+ }
347
+ const table = format === 'md' ? pipeTable : plainTable;
348
+ const rows = report.aggregates.map(aggregateRow);
349
+ const sections = [
350
+ headerLines(report).join('\n'),
351
+ `${table(TABLE_HEADERS, rows)}\n\n${USAGE_NOTE}`,
352
+ ];
353
+ if (report.byTask.taskIds.length > 0) {
354
+ const out = byTaskMatrix(report.byTask, (c) => `${fmtInt(c.medianOutput)} (${c.successes}/${c.judged})`);
355
+ const ctx = byTaskMatrix(report.byTask, (c) => fmtInt(c.medianContext));
356
+ sections.push(format === 'md'
357
+ ? `## per-task median output tokens (success)\n\n${table(out.headers, out.rows)}`
358
+ : `per-task median output tokens (success):\n${table(out.headers, out.rows)}`, format === 'md'
359
+ ? `## per-task median context volume\n\n${table(ctx.headers, ctx.rows)}`
360
+ : `per-task median context volume:\n${table(ctx.headers, ctx.rows)}`);
361
+ }
362
+ const toolCalls = toolCallLines(report.aggregates);
363
+ if (toolCalls.length > 0) {
364
+ sections.push(format === 'md'
365
+ ? `## Tool calls by tool\n\n${toolCalls.map((l) => `- ${l}`).join('\n')}`
366
+ : `tool calls by tool:\n${toolCalls.map((l) => ` ${l}`).join('\n')}`);
367
+ }
368
+ if (report.deltas !== null && report.deltas.length > 0) {
369
+ const body = deltaLines(report.deltas);
370
+ sections.push(format === 'md'
371
+ ? `## Deltas vs full\n\n${body.map((l) => `- ${l}`).join('\n')}`
372
+ : `deltas vs full:\n${body.map((l) => ` ${l}`).join('\n')}`);
373
+ }
374
+ for (const group of report.ablationDeltas ?? []) {
375
+ const body = deltaLines(group.deltas);
376
+ sections.push(format === 'md'
377
+ ? `## Ablation deltas vs ${group.baselineId}\n\n${body.map((l) => `- ${l}`).join('\n')}`
378
+ : `ablation deltas vs ${group.baselineId} (marginal per-atom impact):\n${body.map((l) => ` ${l}`).join('\n')}`);
379
+ }
380
+ const issues = issueLines(report.issues);
381
+ if (issues.length > 0) {
382
+ sections.push(format === 'md'
383
+ ? `## Data quality\n\n${issues.map((l) => `- ${l}`).join('\n')}`
384
+ : `DATA QUALITY:\n${issues.map((l) => ` ${l}`).join('\n')}`);
385
+ }
386
+ return sections.join('\n\n');
387
+ }
388
+ export function formatComparison(a, b, format) {
389
+ if (format === 'json') {
390
+ return JSON.stringify({
391
+ runA: { runId: a.runId, aggregates: a.aggregates, issues: a.issues },
392
+ runB: { runId: b.runId, aggregates: b.aggregates, issues: b.issues },
393
+ }, null, 2);
394
+ }
395
+ const byIdA = new Map(a.aggregates.map((agg) => [agg.variantId, agg]));
396
+ const byIdB = new Map(b.aggregates.map((agg) => [agg.variantId, agg]));
397
+ const variantIds = [...new Set([...byIdA.keys(), ...byIdB.keys()])];
398
+ const pair = (agg, pick) => (agg === undefined ? '—' : pick(agg));
399
+ const headers = [
400
+ 'variant',
401
+ `cells ${a.runId}`,
402
+ `cells ${b.runId}`,
403
+ 'success% A',
404
+ 'success% B',
405
+ 'out A',
406
+ 'out B',
407
+ 'cost A',
408
+ 'cost B',
409
+ ];
410
+ const rows = variantIds.map((variantId) => {
411
+ const aggA = byIdA.get(variantId);
412
+ const aggB = byIdB.get(variantId);
413
+ return [
414
+ variantId,
415
+ pair(aggA, (agg) => String(agg.cells)),
416
+ pair(aggB, (agg) => String(agg.cells)),
417
+ pair(aggA, (agg) => fmtPct(agg.successRate)),
418
+ pair(aggB, (agg) => fmtPct(agg.successRate)),
419
+ pair(aggA, (agg) => fmtInt(agg.medianOutput)),
420
+ pair(aggB, (agg) => fmtInt(agg.medianOutput)),
421
+ pair(aggA, (agg) => fmtMoney(agg.medianCostUsd)),
422
+ pair(aggB, (agg) => fmtMoney(agg.medianCostUsd)),
423
+ ];
424
+ });
425
+ const table = format === 'md' ? pipeTable : plainTable;
426
+ const issues = [
427
+ ...issueLines(a.issues).map((l) => `${a.runId}: ${l}`),
428
+ ...issueLines(b.issues).map((l) => `${b.runId}: ${l}`),
429
+ ];
430
+ const sections = [
431
+ `compare A=${a.runId} vs B=${b.runId}`,
432
+ `${table(headers, rows)}\n\n${USAGE_NOTE}`,
433
+ ];
434
+ if (issues.length > 0) {
435
+ sections.push(format === 'md'
436
+ ? `## Data quality\n\n${issues.map((l) => `- ${l}`).join('\n')}`
437
+ : `DATA QUALITY:\n${issues.map((l) => ` ${l}`).join('\n')}`);
438
+ }
439
+ return sections.join('\n\n');
440
+ }
441
+ export async function runReport(opts) {
442
+ const format = parseFormat(opts.format);
443
+ const outDir = path.resolve(opts.out);
444
+ if (opts.compare !== undefined) {
445
+ if (opts.compare.length !== 2) {
446
+ throw new Error(`--compare takes exactly two run ids, got ${opts.compare.length}`);
447
+ }
448
+ const [runA, runB] = opts.compare;
449
+ if (runA === undefined || runB === undefined) {
450
+ throw new Error('--compare takes exactly two run ids');
451
+ }
452
+ const [reportA, reportB] = await Promise.all([
453
+ loadRunReport(outDir, runA),
454
+ loadRunReport(outDir, runB),
455
+ ]);
456
+ console.log(formatComparison(reportA, reportB, format));
457
+ return;
458
+ }
459
+ const runId = opts.run ?? (await latestRunId(outDir));
460
+ if (runId === null) {
461
+ throw new Error(`no benchmark runs found in ${outDir} — run 'compressor benchmark' first`);
462
+ }
463
+ console.log(formatReport(await loadRunReport(outDir, runId), format));
464
+ }
@@ -0,0 +1,38 @@
1
+ import type { LedgerEvent } from '../../ledger/write.ts';
2
+ export type SavingsDimension = 'day' | 'tool' | 'mode';
3
+ export interface SavingsOptions {
4
+ /** lookback window: '7d', '30d', ... or 'all' */
5
+ since?: string;
6
+ /** aggregation dimension: day|tool|mode */
7
+ by?: string;
8
+ /** write a self-contained HTML report to this path */
9
+ html?: string;
10
+ /** ledger directory override */
11
+ ledgerDir?: string;
12
+ }
13
+ export interface SavingsRow {
14
+ label: string;
15
+ savedChars: number;
16
+ savedTokens: number;
17
+ events: number;
18
+ }
19
+ export declare function parseSince(value: string): Date | undefined;
20
+ /**
21
+ * Human label for the lookback window. Totals MUST state their window:
22
+ * the default is 30d, and an unqualified headline (especially in the
23
+ * shareable HTML artifact) reads as all-time.
24
+ */
25
+ export declare function windowLabel(since: string): string;
26
+ /** Group savings by dimension. Days sort ascending; tool/mode by size. */
27
+ export declare function aggregateSavings(events: readonly LedgerEvent[], by: SavingsDimension): SavingsRow[];
28
+ /**
29
+ * Empty states must distinguish "no ledger at all" (hook not installed /
30
+ * never fired — point at `compressor init`) from "no events INSIDE the
31
+ * window" (the hook works fine; suggesting a reinstall would misdirect).
32
+ * `eventsOutsideWindow` is the all-time count when the window filtered
33
+ * everything out.
34
+ */
35
+ export declare function renderEmpty(dir: string, window: string, eventsOutsideWindow?: number): string;
36
+ export declare function renderSavings(events: readonly LedgerEvent[], by: SavingsDimension, dir: string, window: string): string;
37
+ export declare function renderSavingsHtml(events: readonly LedgerEvent[], dir: string, window: string): string;
38
+ export declare function runSavings(opts: SavingsOptions): Promise<void>;