@trazum/cli 1.34.0 → 1.36.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.
- package/README.md +11 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +37 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +37 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +35 -0
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +201 -24
- package/dist/index.js.map +1 -1
- package/dist/markdown.d.ts +14 -0
- package/dist/markdown.d.ts.map +1 -1
- package/dist/markdown.js +55 -1
- package/dist/markdown.js.map +1 -1
- package/package.json +2 -2
- package/src/i18n/en.ts +54 -0
- package/src/i18n/es.ts +54 -0
- package/src/i18n/types.ts +35 -0
- package/src/index.ts +253 -29
- package/src/markdown.ts +94 -1
package/src/index.ts
CHANGED
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
coverageDrift,
|
|
24
24
|
driversBetween,
|
|
25
25
|
explainGateFailure,
|
|
26
|
+
labelCoverage,
|
|
27
|
+
measuredUsage,
|
|
26
28
|
gateMargin,
|
|
27
29
|
GATE_MARGIN_TIGHT,
|
|
28
30
|
estimateTokens,
|
|
@@ -72,6 +74,7 @@ import {
|
|
|
72
74
|
import { cacheDir, cacheStats, cachingProvider, clearCache } from './suggest-cache.js';
|
|
73
75
|
import { dayOf, formatGap, median, spanDays } from './time.js';
|
|
74
76
|
import type {
|
|
77
|
+
MeasuredUsage,
|
|
75
78
|
BaselineBreach,
|
|
76
79
|
BaselineChange,
|
|
77
80
|
BaselineComparison,
|
|
@@ -165,6 +168,7 @@ interface Args {
|
|
|
165
168
|
|
|
166
169
|
const VALUE_FLAGS = new Set([
|
|
167
170
|
'against',
|
|
171
|
+
'from-log',
|
|
168
172
|
// `route` takes a path here, and the flag is deliberately not `--prompt`:
|
|
169
173
|
// everywhere else in this tool `--prompt` names a marked prompt *inside* a
|
|
170
174
|
// source file, and reusing it for a path would be a trap laid for the reader.
|
|
@@ -305,6 +309,23 @@ function levelFlag(args: Args, config: TrazumConfig, t: CliMessages): RuleLevel
|
|
|
305
309
|
* model id. It beats the default because reading the code is better than
|
|
306
310
|
* assuming, and loses to config because being told is better than reading.
|
|
307
311
|
*/
|
|
312
|
+
/**
|
|
313
|
+
* One usage log, gzip included, shared by every command that reads one.
|
|
314
|
+
*
|
|
315
|
+
* A `.gz` that will not decompress is an error naming the file — skipping it
|
|
316
|
+
* would be a figure quietly missing a day, the failure this repository
|
|
317
|
+
* refuses everywhere it can occur.
|
|
318
|
+
*/
|
|
319
|
+
async function readUsageLog(file: string, t: CliMessages): Promise<string> {
|
|
320
|
+
if (!file.endsWith('.gz')) return readFile(file, 'utf8');
|
|
321
|
+
const compressed = await readFile(file);
|
|
322
|
+
try {
|
|
323
|
+
return gunzipSync(compressed).toString('utf8');
|
|
324
|
+
} catch (error) {
|
|
325
|
+
throw new Error(t.profile.badGzip(file, error instanceof Error ? error.message : String(error)));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
308
329
|
function usageFrom(
|
|
309
330
|
args: Args,
|
|
310
331
|
config: TrazumConfig,
|
|
@@ -436,11 +457,11 @@ const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
436
457
|
'level', 'model', 'calls', 'output-tokens', 'cache-hit-rate', 'batch',
|
|
437
458
|
'disable', 'llm', 'exact-tokens', 'diff', 'reorder', 'out', 'o',
|
|
438
459
|
'tokens-only', 'cost', 'prompt', 'suggest', 'apply-suggestions',
|
|
439
|
-
'cache-suggestions',
|
|
460
|
+
'cache-suggestions', 'from-log', 'label', 'all-labels',
|
|
440
461
|
],
|
|
441
462
|
check: ['max-tokens', 'level', 'exact-tokens', 'markdown-out', 'baseline'],
|
|
442
463
|
baseline: ['model', 'calls', 'output-tokens', 'cache-hit-rate', 'batch', 'exact-tokens', 'out', 'o'],
|
|
443
|
-
profile: ['json', 'pricing', 'pricing-live', 'against', 'what-if', 'markdown-out', 'csv-out', 'csv-shape', 'max-usd', 'max-growth-usd', 'max-cache-loss-usd', 'max-day-usd', 'max-session-usd', 'label', 'since', 'until', 'dry-run'],
|
|
464
|
+
profile: ['json', 'pricing', 'pricing-live', 'against', 'what-if', 'markdown-out', 'csv-out', 'csv-shape', 'max-usd', 'max-growth-usd', 'max-cache-loss-usd', 'max-day-usd', 'max-session-usd', 'label', 'since', 'until', 'dry-run', 'markdown-summary'],
|
|
444
465
|
route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
|
|
445
466
|
eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
|
|
446
467
|
prune: ['cases', 'concurrency', 'json', 'yes'],
|
|
@@ -629,6 +650,8 @@ function printReport(
|
|
|
629
650
|
suggestions: { result: SuggestResult; applied: boolean; locale: Locale } | null = null,
|
|
630
651
|
/** They named a scenario, and the host is suppressing the money anyway. */
|
|
631
652
|
namedScenario = false,
|
|
653
|
+
/** Present when the usage came from a log rather than from typing. */
|
|
654
|
+
measured: MeasuredUsage | null = null,
|
|
632
655
|
): void {
|
|
633
656
|
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
634
657
|
const sourceNote =
|
|
@@ -776,7 +799,7 @@ function printReport(
|
|
|
776
799
|
if (tokensOnly) {
|
|
777
800
|
printTokensOnly(result, host, t, n, namedScenario);
|
|
778
801
|
} else {
|
|
779
|
-
printMoney(result, t, n);
|
|
802
|
+
printMoney(result, t, n, measured);
|
|
780
803
|
}
|
|
781
804
|
|
|
782
805
|
// On a subscription, an advisory whose entire pitch is money is not weaker
|
|
@@ -852,17 +875,61 @@ function printReport(
|
|
|
852
875
|
}
|
|
853
876
|
|
|
854
877
|
/** The cost section, for anyone billed by the token. */
|
|
855
|
-
function printMoney(
|
|
878
|
+
function printMoney(
|
|
879
|
+
result: OptimizationResult,
|
|
880
|
+
t: CliMessages,
|
|
881
|
+
n: (v: number) => string,
|
|
882
|
+
/** Present when the usage came from a log rather than from typing. */
|
|
883
|
+
measured: MeasuredUsage | null = null,
|
|
884
|
+
): void {
|
|
856
885
|
const { savings } = result;
|
|
857
886
|
console.log();
|
|
858
887
|
console.log(c.bold(t.report.costWith(savings.modelDisplayName)));
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
888
|
+
/**
|
|
889
|
+
* The usage line names its provenance. "1,000 calls/month" typed and
|
|
890
|
+
* "1,043 calls measured over 12 days, scaled" are different claims about
|
|
891
|
+
* the same multiplication, and the reader budgeting on the result must
|
|
892
|
+
* know which one they are holding. Under the week floor nothing is scaled
|
|
893
|
+
* and nothing says "month": the figures cover exactly the period measured.
|
|
894
|
+
*/
|
|
895
|
+
if (measured !== null) {
|
|
896
|
+
if (measured.scaled !== null) {
|
|
897
|
+
console.log(
|
|
898
|
+
` ${t.report.usageLineMeasured(
|
|
899
|
+
n(measured.calls),
|
|
900
|
+
measured.scaled.fromDays.toFixed(1),
|
|
901
|
+
n(result.usage.callsPerMonth),
|
|
902
|
+
result.usage.avgOutputTokens,
|
|
903
|
+
result.usage.batchEligible,
|
|
904
|
+
)}`,
|
|
905
|
+
);
|
|
906
|
+
} else {
|
|
907
|
+
console.log(
|
|
908
|
+
` ${t.report.usageLineMeasuredPeriod(
|
|
909
|
+
n(measured.calls),
|
|
910
|
+
measured.spanDays === null ? null : measured.spanDays.toFixed(1),
|
|
911
|
+
result.usage.avgOutputTokens,
|
|
912
|
+
result.usage.batchEligible,
|
|
913
|
+
)}`,
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
if (measured.models.count > 1) {
|
|
917
|
+
console.log(
|
|
918
|
+
` ${c.dim(wrap(t.report.measuredModelShare(measured.models.chosen, `${(measured.models.chosenShareOfSpend * 100).toFixed(0)}%`, n(measured.models.count)), 74, ' '))}`,
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
if (measured.outputUnmeasured) {
|
|
922
|
+
console.log(` ${c.dim(wrap(t.report.measuredNoOutput(), 74, ' '))}`);
|
|
923
|
+
}
|
|
924
|
+
} else {
|
|
925
|
+
console.log(
|
|
926
|
+
` ${t.report.usageLine(
|
|
927
|
+
n(result.usage.callsPerMonth),
|
|
928
|
+
result.usage.avgOutputTokens,
|
|
929
|
+
result.usage.batchEligible,
|
|
930
|
+
)}`,
|
|
931
|
+
);
|
|
932
|
+
}
|
|
866
933
|
// Said, not assumed. Once prices can be overlaid locally, a figure from the
|
|
867
934
|
// bundled catalogue and a figure from somebody's JSON file look identical, and
|
|
868
935
|
// the reader has to be able to tell which one they are about to budget against.
|
|
@@ -875,17 +942,27 @@ function printMoney(result: OptimizationResult, t: CliMessages, n: (v: number) =
|
|
|
875
942
|
` ${c.yellow(t.report.pricingOverlaid(touched.join(', '), result.pricingSource.lastReviewed))}`,
|
|
876
943
|
);
|
|
877
944
|
}
|
|
945
|
+
const periodOnly = measured !== null && measured.scaled === null;
|
|
878
946
|
console.log(
|
|
879
947
|
` ${formatUsd(savings.perMonth.before.totalUsd)} → ` +
|
|
880
948
|
`${c.green(formatUsd(savings.perMonth.after.totalUsd))} ` +
|
|
881
949
|
c.bold(
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
950
|
+
periodOnly
|
|
951
|
+
? t.report.perPeriodSaving(
|
|
952
|
+
formatUsd(savings.monthlySavingsUsd),
|
|
953
|
+
savings.monthlySavingsPct.toFixed(1),
|
|
954
|
+
)
|
|
955
|
+
: t.report.perMonthSaving(
|
|
956
|
+
formatUsd(savings.monthlySavingsUsd),
|
|
957
|
+
savings.monthlySavingsPct.toFixed(1),
|
|
958
|
+
),
|
|
886
959
|
),
|
|
887
960
|
);
|
|
888
|
-
|
|
961
|
+
if (periodOnly) {
|
|
962
|
+
console.log(
|
|
963
|
+
` ${c.dim(wrap(t.report.periodNotScaled(measured!.spanDays === null ? null : measured!.spanDays.toFixed(1)), 74, ' '))}`,
|
|
964
|
+
);
|
|
965
|
+
}
|
|
889
966
|
}
|
|
890
967
|
|
|
891
968
|
/**
|
|
@@ -1372,6 +1449,97 @@ async function commandOptimize(
|
|
|
1372
1449
|
t: CliMessages,
|
|
1373
1450
|
locale: Locale,
|
|
1374
1451
|
): Promise<void> {
|
|
1452
|
+
/**
|
|
1453
|
+
* `--all-labels`: every mapped prompt against its own measured traffic,
|
|
1454
|
+
* ranked by what the change is worth — the list a person actually wants,
|
|
1455
|
+
* which is "which prompt do I edit first".
|
|
1456
|
+
*
|
|
1457
|
+
* Requires `--from-log`, because ranking estimated savings that were all
|
|
1458
|
+
* multiplied by the same typed guess ranks the prompts by length, and calls
|
|
1459
|
+
* that a priority. And it renders both coverage mismatches at the end: a
|
|
1460
|
+
* prompt mapped to a label with no traffic is dead weight or a rename, and
|
|
1461
|
+
* a label carrying real money with no prompt mapped is the workload nobody
|
|
1462
|
+
* can optimise because nobody said where it lives.
|
|
1463
|
+
*/
|
|
1464
|
+
if (boolFlag(args, 'all-labels')) {
|
|
1465
|
+
const fromLogPath = stringFlag(args, 'from-log');
|
|
1466
|
+
if (fromLogPath === undefined) throw new Error(t.errors.allLabelsNeedsLog());
|
|
1467
|
+
const labelsMap = config.labels ?? {};
|
|
1468
|
+
if (Object.keys(labelsMap).length === 0) throw new Error(t.errors.allLabelsNeedsMap());
|
|
1469
|
+
const report = profileUsage(await readUsageLog(fromLogPath, t), { catalogue: pricing });
|
|
1470
|
+
const coverage = labelCoverage(report, labelsMap);
|
|
1471
|
+
const level = levelFlag(args, config, t);
|
|
1472
|
+
|
|
1473
|
+
interface Row {
|
|
1474
|
+
label: string;
|
|
1475
|
+
path: string;
|
|
1476
|
+
tokensBefore: number;
|
|
1477
|
+
tokensAfter: number;
|
|
1478
|
+
savingUsd: number;
|
|
1479
|
+
periodOnly: boolean;
|
|
1480
|
+
spentUsd: number;
|
|
1481
|
+
}
|
|
1482
|
+
const rows: Row[] = [];
|
|
1483
|
+
const unreadable: { label: string; path: string }[] = [];
|
|
1484
|
+
for (const { label, promptPath } of coverage.joined) {
|
|
1485
|
+
const m = measuredUsage(report, label, { batchEligible: config.usage?.batchEligible ?? false });
|
|
1486
|
+
if (m === null) continue;
|
|
1487
|
+
let text: string;
|
|
1488
|
+
try {
|
|
1489
|
+
text = await readFile(promptPath, 'utf8');
|
|
1490
|
+
} catch {
|
|
1491
|
+
unreadable.push({ label, path: promptPath });
|
|
1492
|
+
continue;
|
|
1493
|
+
}
|
|
1494
|
+
const r = optimize(text, { level, usage: m.profile, locale, pricing });
|
|
1495
|
+
rows.push({
|
|
1496
|
+
label,
|
|
1497
|
+
path: promptPath,
|
|
1498
|
+
tokensBefore: r.tokensBefore,
|
|
1499
|
+
tokensAfter: r.tokensAfter,
|
|
1500
|
+
savingUsd: r.savings.monthlySavingsUsd,
|
|
1501
|
+
periodOnly: m.scaled === null,
|
|
1502
|
+
spentUsd: m.spentUsd,
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
rows.sort((a, b) => b.savingUsd - a.savingUsd);
|
|
1506
|
+
|
|
1507
|
+
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
1508
|
+
console.log(c.bold(t.report.allLabelsHeading(n(rows.length))));
|
|
1509
|
+
for (const row of rows) {
|
|
1510
|
+
const saving = row.periodOnly
|
|
1511
|
+
? t.report.allLabelsRowPeriod(formatUsd(row.savingUsd))
|
|
1512
|
+
: t.report.allLabelsRow(formatUsd(row.savingUsd));
|
|
1513
|
+
console.log(
|
|
1514
|
+
` ${row.savingUsd > 0 ? c.green('→') : c.dim('·')} ${c.bold(row.label)} ${saving} ${c.dim(`${row.path} · ${n(row.tokensBefore)} → ${n(row.tokensAfter)} tokens · ${formatUsd(row.spentUsd)} measured`)}`,
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
if (rows.length > 0) {
|
|
1518
|
+
console.log(` ${c.dim(wrap(t.report.allLabelsFooter(), 74, ' '))}`);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/**
|
|
1522
|
+
* The mismatches, both directions, never silently. These are the two
|
|
1523
|
+
* failures neither side can see alone.
|
|
1524
|
+
*/
|
|
1525
|
+
for (const gap of coverage.trafficWithoutPrompt.slice(0, 5)) {
|
|
1526
|
+
console.log(
|
|
1527
|
+
` ${c.yellow('!')} ${wrap(t.report.allLabelsUnmapped(gap.label, formatUsd(gap.spentUsd)), 74, ' ')}`,
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
for (const dead of coverage.mappedWithoutTraffic) {
|
|
1531
|
+
console.log(
|
|
1532
|
+
` ${c.dim(wrap(t.report.allLabelsDead(dead.label, dead.promptPath), 74, ' '))}`,
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
for (const miss of unreadable) {
|
|
1536
|
+
console.log(
|
|
1537
|
+
` ${c.yellow('!')} ${wrap(t.report.allLabelsUnreadable(miss.label, miss.path), 74, ' ')}`,
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1375
1543
|
const target = args.positional[0];
|
|
1376
1544
|
const raw = await readInput(target, t);
|
|
1377
1545
|
const level = levelFlag(args, config, t);
|
|
@@ -1393,7 +1561,61 @@ async function commandOptimize(
|
|
|
1393
1561
|
// Detection sits between config and defaults, as everywhere: a flag beats
|
|
1394
1562
|
// config, config beats what the code says, and what the code says beats a
|
|
1395
1563
|
// built-in default that has no idea which provider you use.
|
|
1396
|
-
|
|
1564
|
+
let usage = usageFrom(args, config, t, source?.model);
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* `--from-log`: the multiplication stops guessing.
|
|
1568
|
+
*
|
|
1569
|
+
* The saving printed below is `token delta × usage`, and until now every
|
|
1570
|
+
* part of `usage` was typed by a human. A usage log knows the real call
|
|
1571
|
+
* count, the real output size, the real cache share and the model the
|
|
1572
|
+
* calls actually went to — so `--from-log` measures them, and the typed
|
|
1573
|
+
* flags are refused beside it rather than merged: measuring and typing the
|
|
1574
|
+
* same figure is a contradiction, not a preference order.
|
|
1575
|
+
*/
|
|
1576
|
+
const fromLog = stringFlag(args, 'from-log');
|
|
1577
|
+
let measured: MeasuredUsage | null = null;
|
|
1578
|
+
if (fromLog !== undefined) {
|
|
1579
|
+
for (const flag of ['calls', 'output-tokens', 'cache-hit-rate', 'model']) {
|
|
1580
|
+
if (args.flags.get(flag) !== undefined) {
|
|
1581
|
+
throw new Error(t.errors.fromLogConflict(flag));
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
const report = profileUsage(await readUsageLog(fromLog, t), { catalogue: pricing });
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* Which label this prompt is. `--label` says it outright; otherwise the
|
|
1588
|
+
* config's `labels` map is read in reverse — it maps labels to prompt
|
|
1589
|
+
* files, and the file on the command line is looked up among its values.
|
|
1590
|
+
* Ambiguity (two labels mapped to one file) is an error naming both,
|
|
1591
|
+
* never a silent first match.
|
|
1592
|
+
*/
|
|
1593
|
+
let label = stringFlag(args, 'label');
|
|
1594
|
+
if (label === undefined && target !== undefined && config.labels !== undefined) {
|
|
1595
|
+
const hits = Object.entries(config.labels)
|
|
1596
|
+
.filter(([, path]) => resolvePath(path) === resolvePath(target))
|
|
1597
|
+
.map(([name]) => name);
|
|
1598
|
+
if (hits.length > 1) throw new Error(t.errors.fromLogAmbiguousLabel(target, hits.join(', ')));
|
|
1599
|
+
label = hits[0];
|
|
1600
|
+
}
|
|
1601
|
+
if (label === undefined) {
|
|
1602
|
+
const available = report.byLabel
|
|
1603
|
+
.map((row) => (row.label === UNLABELLED ? t.profile.unlabelled() : row.label))
|
|
1604
|
+
.join(', ');
|
|
1605
|
+
throw new Error(t.errors.fromLogNeedsLabel(available || '—'));
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
measured = measuredUsage(report, label, {
|
|
1609
|
+
batchEligible: boolFlag(args, 'batch', config.usage?.batchEligible ?? false),
|
|
1610
|
+
});
|
|
1611
|
+
if (measured === null) {
|
|
1612
|
+
const available = report.byLabel
|
|
1613
|
+
.map((row) => (row.label === UNLABELLED ? t.profile.unlabelled() : row.label))
|
|
1614
|
+
.join(', ');
|
|
1615
|
+
throw new Error(t.errors.fromLogLabelEmpty(label, available || '—'));
|
|
1616
|
+
}
|
|
1617
|
+
usage = measured.profile;
|
|
1618
|
+
}
|
|
1397
1619
|
|
|
1398
1620
|
const disableRules = disabledRules(args, config) ?? [];
|
|
1399
1621
|
for (const id of disableRules) {
|
|
@@ -1586,7 +1808,15 @@ async function commandOptimize(
|
|
|
1586
1808
|
// Cursor wants the dollars, and they should not have to leave the editor to
|
|
1587
1809
|
// see them.
|
|
1588
1810
|
const host = detectHost();
|
|
1589
|
-
|
|
1811
|
+
/**
|
|
1812
|
+
* `--from-log` implies `--cost`, and the reasoning is different from the
|
|
1813
|
+
* `--calls` case documented below: `--calls` is a typed scenario parameter,
|
|
1814
|
+
* but a usage log with billed token counts is *evidence* — proof this
|
|
1815
|
+
* prompt's traffic goes to a metered API, whatever the terminal running
|
|
1816
|
+
* the command bills like. Withholding the money there would suppress
|
|
1817
|
+
* exactly the figures the person measured in order to see.
|
|
1818
|
+
*/
|
|
1819
|
+
const tokensOnly = boolFlag(args, 'cost') || measured !== null
|
|
1590
1820
|
? false
|
|
1591
1821
|
: boolFlag(args, 'tokens-only') || host.billing === 'subscription';
|
|
1592
1822
|
/**
|
|
@@ -1609,6 +1839,7 @@ async function commandOptimize(
|
|
|
1609
1839
|
? { result: suggestions, applied: boolFlag(args, 'apply-suggestions'), locale }
|
|
1610
1840
|
: null,
|
|
1611
1841
|
namedScenario,
|
|
1842
|
+
measured,
|
|
1612
1843
|
);
|
|
1613
1844
|
if (outPath) {
|
|
1614
1845
|
console.log(c.dim(t.report.wroteTo(outPath)));
|
|
@@ -2031,16 +2262,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
|
|
|
2031
2262
|
* alternative — skipping it — is a total quietly missing a day, which is
|
|
2032
2263
|
* the failure this repository refuses in every other place it can occur.
|
|
2033
2264
|
*/
|
|
2034
|
-
const
|
|
2035
|
-
if (!file.endsWith('.gz')) return readFile(file, 'utf8');
|
|
2036
|
-
const compressed = await readFile(file);
|
|
2037
|
-
try {
|
|
2038
|
-
return gunzipSync(compressed).toString('utf8');
|
|
2039
|
-
} catch (error) {
|
|
2040
|
-
throw new Error(t.profile.badGzip(file, error instanceof Error ? error.message : String(error)));
|
|
2041
|
-
}
|
|
2042
|
-
};
|
|
2043
|
-
const logTexts = await Promise.all(logFiles.map((file) => readLog(file)));
|
|
2265
|
+
const logTexts = await Promise.all(logFiles.map((file) => readUsageLog(file, t)));
|
|
2044
2266
|
// A file that does not end in a newline would otherwise glue its last record
|
|
2045
2267
|
// to the next file's first one, and both would be reported as unreadable.
|
|
2046
2268
|
const raw = logTexts.map((text) => (text.endsWith('\n') ? text : `${text}\n`)).join('');
|
|
@@ -2206,7 +2428,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
|
|
|
2206
2428
|
// The same reader as the log itself, so `--against last-month.jsonl.gz`
|
|
2207
2429
|
// works: a comparison that could only read one of the two formats would
|
|
2208
2430
|
// be a flag that fails on exactly the rotated file it exists to read.
|
|
2209
|
-
? profileUsage(await
|
|
2431
|
+
? profileUsage(await readUsageLog(againstPath, t), {
|
|
2210
2432
|
catalogue: pricing,
|
|
2211
2433
|
label: onlyLabel,
|
|
2212
2434
|
// The same window on both sides, for the same reason as the label:
|
|
@@ -2703,6 +2925,8 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
|
|
|
2703
2925
|
// The verdict, where the person reading CI will see it. recordGates()
|
|
2704
2926
|
// runs before the side files for exactly this.
|
|
2705
2927
|
...(gateVerdicts.length > 0 ? { gates: { failed: gateFailed, lines: gateVerdicts } } : {}),
|
|
2928
|
+
// The short form, for a reader who is not in the terminal.
|
|
2929
|
+
...(boolFlag(args, 'markdown-summary') ? { summary: true } : {}),
|
|
2706
2930
|
// The repricing, when --what-if was given: computed once above and
|
|
2707
2931
|
// handed over, so the summary in a pull request cannot disagree
|
|
2708
2932
|
// with the terminal about what a move would cost.
|
package/src/markdown.ts
CHANGED
|
@@ -733,6 +733,20 @@ export interface ProfileMarkdownInput {
|
|
|
733
733
|
* copy; this is a rendering and must not decide anything a gate decides.
|
|
734
734
|
*/
|
|
735
735
|
gates?: { failed: boolean; lines: string[] };
|
|
736
|
+
/**
|
|
737
|
+
* `--markdown-summary`: the short form, for a pull-request body or a weekly
|
|
738
|
+
* note rather than a full report.
|
|
739
|
+
*
|
|
740
|
+
* The person who owns the budget usually does not run the CLI, and handing
|
|
741
|
+
* them the whole report is handing them a document to skim — where the one
|
|
742
|
+
* figure that changed is as easy to miss as it was in the terminal. The
|
|
743
|
+
* summary states what changed, the single lever worth the most, and stops.
|
|
744
|
+
*
|
|
745
|
+
* It is a *view*, never a different set of figures: every number in it is
|
|
746
|
+
* taken from the same report the full rendering uses, so a reader who opens
|
|
747
|
+
* both cannot find them disagreeing.
|
|
748
|
+
*/
|
|
749
|
+
summary?: boolean;
|
|
736
750
|
/**
|
|
737
751
|
* The `--since`/`--until` values as the user typed them, when a window was
|
|
738
752
|
* applied. Passed through rather than re-derived from `timeWindow`'s epoch
|
|
@@ -793,7 +807,7 @@ export interface ProfileMarkdownInput {
|
|
|
793
807
|
* reading CI instead of machines.
|
|
794
808
|
*/
|
|
795
809
|
export function renderProfileMarkdown(input: ProfileMarkdownInput): string {
|
|
796
|
-
const { report, levers, cache, t, window, stalePricing, against, whatIf, pressure = [], gates } = input;
|
|
810
|
+
const { report, levers, cache, t, window, stalePricing, against, whatIf, pressure = [], gates, summary = false } = input;
|
|
797
811
|
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
798
812
|
const pct = (share: number): string => `${(share * 100).toFixed(1)}%`;
|
|
799
813
|
const shares = sharesOf(report.total);
|
|
@@ -812,6 +826,85 @@ export function renderProfileMarkdown(input: ProfileMarkdownInput): string {
|
|
|
812
826
|
* surface further out. A failure is quoted so it survives being skimmed; a
|
|
813
827
|
* pass is stated plainly and does not shout.
|
|
814
828
|
*/
|
|
829
|
+
/**
|
|
830
|
+
* The summary: what changed, the biggest lever, and nothing else.
|
|
831
|
+
*
|
|
832
|
+
* Returned before the full rendering rather than filtered out of it, so a
|
|
833
|
+
* section added later cannot leak into the short form by forgetting to opt
|
|
834
|
+
* out. Every figure here comes from the same report the long form uses.
|
|
835
|
+
*/
|
|
836
|
+
if (summary) {
|
|
837
|
+
const short: string[] = [];
|
|
838
|
+
short.push(`### ${t.profile.heading()}`);
|
|
839
|
+
short.push('');
|
|
840
|
+
if (gates !== undefined && gates.lines.length > 0) {
|
|
841
|
+
const [verdict] = gates.lines;
|
|
842
|
+
short.push(gates.failed ? `> ❌ **${mdText(verdict!)}**` : `_${mdText(verdict!)}_`);
|
|
843
|
+
short.push('');
|
|
844
|
+
}
|
|
845
|
+
short.push(`**${mdText(t.profile.spent(t.profile.calls(report.total.calls), formatUsd(report.total.totalUsd)))}**`);
|
|
846
|
+
short.push('');
|
|
847
|
+
// What changed, when there is a previous log to change from. Without one
|
|
848
|
+
// the summary states the bill and says so, rather than implying stability
|
|
849
|
+
// nobody measured.
|
|
850
|
+
if (against !== undefined) {
|
|
851
|
+
const delta = report.total.totalUsd - against.previousTotalUsd;
|
|
852
|
+
const growthPct =
|
|
853
|
+
against.previousTotalUsd > 0
|
|
854
|
+
? `${delta >= 0 ? '+' : ''}${((delta / against.previousTotalUsd) * 100).toFixed(1)}%`
|
|
855
|
+
: '—';
|
|
856
|
+
short.push(
|
|
857
|
+
mdText(
|
|
858
|
+
t.profile.againstTotals(
|
|
859
|
+
formatUsd(against.previousTotalUsd),
|
|
860
|
+
formatUsd(report.total.totalUsd),
|
|
861
|
+
formatSignedUsd(delta),
|
|
862
|
+
growthPct,
|
|
863
|
+
t.profile.calls(against.previousCalls),
|
|
864
|
+
t.profile.calls(report.total.calls),
|
|
865
|
+
),
|
|
866
|
+
),
|
|
867
|
+
);
|
|
868
|
+
short.push('');
|
|
869
|
+
// The one driver that moved most — not five, because a summary that
|
|
870
|
+
// lists everything is the report again with a shorter heading.
|
|
871
|
+
const [driver] = against.labelDrivers;
|
|
872
|
+
if (driver !== undefined) {
|
|
873
|
+
const shown = driver.key === UNLABELLED ? t.profile.unlabelled() : driver.key;
|
|
874
|
+
short.push(
|
|
875
|
+
`- ${mdText(
|
|
876
|
+
driver.was === null
|
|
877
|
+
? t.profile.againstDriverNew(formatSignedUsd(driver.delta), shown)
|
|
878
|
+
: driver.now === null
|
|
879
|
+
? t.profile.againstDriverGone(formatSignedUsd(driver.delta), shown)
|
|
880
|
+
: t.profile.againstDriver(formatSignedUsd(driver.delta), shown, formatUsd(driver.was), formatUsd(driver.now)),
|
|
881
|
+
)}`,
|
|
882
|
+
);
|
|
883
|
+
short.push('');
|
|
884
|
+
}
|
|
885
|
+
} else {
|
|
886
|
+
short.push(`_${mdText(t.profile.summaryNoComparison())}_`);
|
|
887
|
+
short.push('');
|
|
888
|
+
}
|
|
889
|
+
// The single lever worth the most, with the hedge every lever carries.
|
|
890
|
+
const [lever] = levers.slices;
|
|
891
|
+
if (lever !== undefined) {
|
|
892
|
+
short.push(
|
|
893
|
+
`- ${mdText(
|
|
894
|
+
t.profile.leverSlice(
|
|
895
|
+
showLabel(lever.label),
|
|
896
|
+
lever.modelName,
|
|
897
|
+
formatUsd(lever.combinedUsd),
|
|
898
|
+
pct(lever.shareOfBill),
|
|
899
|
+
),
|
|
900
|
+
)}`,
|
|
901
|
+
);
|
|
902
|
+
short.push('');
|
|
903
|
+
}
|
|
904
|
+
short.push(`_${mdText(t.profile.summaryFooter())}_`);
|
|
905
|
+
return short.join('\n');
|
|
906
|
+
}
|
|
907
|
+
|
|
815
908
|
if (gates !== undefined && gates.lines.length > 0) {
|
|
816
909
|
// One mark, on the verdict. The lines under it explain that verdict and
|
|
817
910
|
// are not themselves failures — marking each would turn one red build
|