@trazum/cli 1.29.0 → 1.31.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/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +16 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +16 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +36 -0
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +146 -4
- package/dist/index.js.map +1 -1
- package/dist/markdown.d.ts +13 -0
- package/dist/markdown.d.ts.map +1 -1
- package/dist/markdown.js +20 -1
- package/dist/markdown.js.map +1 -1
- package/package.json +2 -2
- package/src/i18n/en.ts +24 -0
- package/src/i18n/es.ts +24 -0
- package/src/i18n/types.ts +36 -0
- package/src/index.ts +175 -4
- package/src/markdown.ts +29 -1
package/src/index.ts
CHANGED
|
@@ -20,7 +20,11 @@ import {
|
|
|
20
20
|
countTokensAnthropic,
|
|
21
21
|
DEFAULT_USAGE,
|
|
22
22
|
detectFromSource,
|
|
23
|
+
coverageDrift,
|
|
23
24
|
driversBetween,
|
|
25
|
+
explainGateFailure,
|
|
26
|
+
gateMargin,
|
|
27
|
+
GATE_MARGIN_TIGHT,
|
|
24
28
|
estimateTokens,
|
|
25
29
|
evaluate,
|
|
26
30
|
extractPrompts,
|
|
@@ -2249,6 +2253,19 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
|
|
|
2249
2253
|
* profiles yesterday's log has a daily budget without Trazum ever
|
|
2250
2254
|
* guessing what a day is.
|
|
2251
2255
|
*/
|
|
2256
|
+
/**
|
|
2257
|
+
* The gate verdicts, kept so the markdown summary can carry them.
|
|
2258
|
+
*
|
|
2259
|
+
* Collected by wrapping `console.error` for the duration of `applyGates`
|
|
2260
|
+
* rather than by threading a return value through every gate. That is the
|
|
2261
|
+
* unusual choice here and it is deliberate: a gate added later reaches the
|
|
2262
|
+
* summary without anyone remembering to register it, and the alternative —
|
|
2263
|
+
* one push per verdict at a dozen call sites — is a list that goes stale
|
|
2264
|
+
* silently. Colour is stripped, because a summary is markdown and an
|
|
2265
|
+
* escape sequence in it is noise a reader has to look past.
|
|
2266
|
+
*/
|
|
2267
|
+
const gateVerdicts: string[] = [];
|
|
2268
|
+
let gateFailed = false;
|
|
2252
2269
|
const applyGates = (): void => {
|
|
2253
2270
|
/**
|
|
2254
2271
|
* Before any verdict: whether the gated figure is the whole bill. A gate
|
|
@@ -2306,6 +2323,52 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
|
|
|
2306
2323
|
console.error(c.dim(t.profile.labelBudgetWindowed()));
|
|
2307
2324
|
}
|
|
2308
2325
|
|
|
2326
|
+
/**
|
|
2327
|
+
* Why a gate failed and how much room a pass had — written once, called by
|
|
2328
|
+
* every gate, because four hand-rolled copies of the same three sentences
|
|
2329
|
+
* is four chances for one of them to soften.
|
|
2330
|
+
*/
|
|
2331
|
+
const explainFailure = (overUsd: number, { namesLargest = false } = {}): void => {
|
|
2332
|
+
const why = explainGateFailure(report, levers, overUsd);
|
|
2333
|
+
// The day gate already names its own day's biggest label; repeating the
|
|
2334
|
+
// whole bill's biggest slice under it reads as the same sentence twice.
|
|
2335
|
+
if (why.largest !== null && !namesLargest) {
|
|
2336
|
+
const name = why.largest.label === UNLABELLED ? t.profile.unlabelled() : why.largest.label;
|
|
2337
|
+
console.error(
|
|
2338
|
+
c.dim(wrap(t.profile.gateLargest(name, why.largest.model, formatUsd(why.largest.usd), pct(why.largest.share)), 74, ' ')),
|
|
2339
|
+
);
|
|
2340
|
+
}
|
|
2341
|
+
if (why.lever !== null) {
|
|
2342
|
+
const leverName = why.lever.label === UNLABELLED ? t.profile.unlabelled() : why.lever.label;
|
|
2343
|
+
// The action, not the slice's current model: a slice with only a batch
|
|
2344
|
+
// price has no destination, and naming the model it already runs on as
|
|
2345
|
+
// somewhere to move it would be plainly false.
|
|
2346
|
+
const route = why.lever.route;
|
|
2347
|
+
const action =
|
|
2348
|
+
route !== null && why.lever.batch !== null
|
|
2349
|
+
? t.profile.gateLeverBoth(route.candidate.displayName)
|
|
2350
|
+
: route !== null
|
|
2351
|
+
? t.profile.gateLeverRoute(route.candidate.displayName)
|
|
2352
|
+
: t.profile.gateLeverBatch();
|
|
2353
|
+
console.error(
|
|
2354
|
+
c.dim(
|
|
2355
|
+
wrap(
|
|
2356
|
+
t.profile.gateLever(leverName, action, formatUsd(why.lever.combinedUsd), formatUsd(why.overageUsd), why.coversIt),
|
|
2357
|
+
74,
|
|
2358
|
+
' ',
|
|
2359
|
+
),
|
|
2360
|
+
),
|
|
2361
|
+
);
|
|
2362
|
+
}
|
|
2363
|
+
};
|
|
2364
|
+
/** How much room a pass had, said only when tight, threshold in the copy. */
|
|
2365
|
+
const explainMargin = (judgedUsd: number, limitUsd: number): void => {
|
|
2366
|
+
const margin = gateMargin(judgedUsd, limitUsd);
|
|
2367
|
+
if (margin !== null && margin < GATE_MARGIN_TIGHT) {
|
|
2368
|
+
console.error(c.yellow(wrap(t.profile.gateMarginTight(pct(margin), formatUsd(limitUsd - judgedUsd)), 74, ' ')));
|
|
2369
|
+
}
|
|
2370
|
+
};
|
|
2371
|
+
|
|
2309
2372
|
if (typeof args.flags.get('max-usd') === 'string' || config.spend?.maxUsd !== undefined) {
|
|
2310
2373
|
const maxUsd =
|
|
2311
2374
|
typeof args.flags.get('max-usd') === 'string'
|
|
@@ -2313,14 +2376,51 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
|
|
|
2313
2376
|
: config.spend!.maxUsd!;
|
|
2314
2377
|
if (report.total.totalUsd > maxUsd) {
|
|
2315
2378
|
console.error(c.red(t.profile.maxUsdFailed(formatUsd(report.total.totalUsd), formatUsd(maxUsd))));
|
|
2379
|
+
/**
|
|
2380
|
+
* What to change, next to the fact that something must. A red build in
|
|
2381
|
+
* CI is the one place nobody opens the full report, so the failure
|
|
2382
|
+
* carries its own next step: which slice holds the money, and the one
|
|
2383
|
+
* lever the report already priced. Nothing here is a recommendation —
|
|
2384
|
+
* whether that model can do the work is the reader's to judge, and the
|
|
2385
|
+
* copy says so.
|
|
2386
|
+
*/
|
|
2387
|
+
explainFailure(report.total.totalUsd - maxUsd);
|
|
2316
2388
|
process.exitCode = 1;
|
|
2317
2389
|
} else {
|
|
2318
2390
|
console.error(c.dim(t.profile.maxUsdOk(formatUsd(report.total.totalUsd), formatUsd(maxUsd))));
|
|
2391
|
+
explainMargin(report.total.totalUsd, maxUsd);
|
|
2319
2392
|
}
|
|
2320
2393
|
}
|
|
2321
2394
|
if (typeof args.flags.get('max-growth-usd') === 'string' && againstDelta !== null) {
|
|
2322
2395
|
const maxGrowth = numberFlag(args, 'max-growth-usd', 0, t);
|
|
2323
|
-
|
|
2396
|
+
/**
|
|
2397
|
+
* A comparison that went blind fails before it is judged.
|
|
2398
|
+
*
|
|
2399
|
+
* The dollars can hold flat while the current log stopped recording a
|
|
2400
|
+
* field the previous one carried — and every finding that needed the
|
|
2401
|
+
* field is now silent for a reason that has nothing to do with spend.
|
|
2402
|
+
* A gate passing there would be certifying a comparison it could not
|
|
2403
|
+
* make: "not measured" is not "did not grow", the same refusal
|
|
2404
|
+
* --max-day-usd makes on a clockless log and --max-session-usd on a
|
|
2405
|
+
* sessionless one. Only a collapse fails; a field that appeared means
|
|
2406
|
+
* this side can see more, which is never a reason to refuse.
|
|
2407
|
+
*/
|
|
2408
|
+
const blinded = previous !== null
|
|
2409
|
+
? coverageDrift(previous.fieldCoverage, report.fieldCoverage).filter((d) => d.delta < 0)
|
|
2410
|
+
: [];
|
|
2411
|
+
const worst = blinded[0];
|
|
2412
|
+
if (worst !== undefined) {
|
|
2413
|
+
console.error(
|
|
2414
|
+
c.red(
|
|
2415
|
+
t.profile.maxGrowthCoverageLost(
|
|
2416
|
+
blinded.map((d) => t.profile.coverageField(d.field)).join(', '),
|
|
2417
|
+
pct(worst.was),
|
|
2418
|
+
pct(worst.now),
|
|
2419
|
+
),
|
|
2420
|
+
),
|
|
2421
|
+
);
|
|
2422
|
+
process.exitCode = 1;
|
|
2423
|
+
} else if (againstDelta > maxGrowth) {
|
|
2324
2424
|
console.error(c.red(t.profile.maxGrowthUsdFailed(formatSignedUsd(againstDelta), formatUsd(maxGrowth))));
|
|
2325
2425
|
process.exitCode = 1;
|
|
2326
2426
|
}
|
|
@@ -2399,9 +2499,11 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
|
|
|
2399
2499
|
console.error(
|
|
2400
2500
|
c.red(`${t.profile.maxDayFailed(worst.day, formatUsd(worst.usd), formatUsd(maxDay))}${suspect}`),
|
|
2401
2501
|
);
|
|
2502
|
+
explainFailure(worst.usd - maxDay, { namesLargest: true });
|
|
2402
2503
|
process.exitCode = 1;
|
|
2403
2504
|
} else {
|
|
2404
2505
|
console.error(c.dim(t.profile.maxDayOk(worst.day, formatUsd(worst.usd), formatUsd(maxDay))));
|
|
2506
|
+
explainMargin(worst.usd, maxDay);
|
|
2405
2507
|
/**
|
|
2406
2508
|
* Calls with no clock are in the bill above and in no day below, so
|
|
2407
2509
|
* the worst day is a floor by exactly that much. Said only on a
|
|
@@ -2439,15 +2541,41 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
|
|
|
2439
2541
|
console.error(
|
|
2440
2542
|
c.red(t.profile.maxSessionFailed(formatUsd(report.sessionSpend.maxUsd), formatUsd(maxSession), n(report.sessionSpend.sessions))),
|
|
2441
2543
|
);
|
|
2544
|
+
explainFailure(report.sessionSpend.maxUsd - maxSession);
|
|
2442
2545
|
process.exitCode = 1;
|
|
2443
2546
|
} else {
|
|
2444
2547
|
console.error(
|
|
2445
2548
|
c.dim(t.profile.maxSessionOk(formatUsd(report.sessionSpend.maxUsd), formatUsd(maxSession), n(report.sessionSpend.sessions))),
|
|
2446
2549
|
);
|
|
2550
|
+
explainMargin(report.sessionSpend.maxUsd, maxSession);
|
|
2447
2551
|
}
|
|
2448
2552
|
}
|
|
2449
2553
|
};
|
|
2450
2554
|
|
|
2555
|
+
/**
|
|
2556
|
+
* Run the gates, keeping what they said. Exit codes and stderr behave
|
|
2557
|
+
* exactly as before — this only also remembers, so `--markdown-out` can put
|
|
2558
|
+
* the verdict where the person reading CI will actually see it.
|
|
2559
|
+
*/
|
|
2560
|
+
const recordGates = (): void => {
|
|
2561
|
+
const original = console.error;
|
|
2562
|
+
console.error = (...parts: unknown[]): void => {
|
|
2563
|
+
const text = parts.map((part) => String(part)).join(' ');
|
|
2564
|
+
// Colour stripped and the terminal's wrap collapsed: markdown re-wraps
|
|
2565
|
+
// to its own width, and the escape sequences and hanging indents that
|
|
2566
|
+
// make a terminal readable are noise a summary reader looks past.
|
|
2567
|
+
// eslint-disable-next-line no-control-regex
|
|
2568
|
+
gateVerdicts.push(text.replace(/\u001b\[[0-9;]*m/g, '').replace(/\s+/g, ' ').trim());
|
|
2569
|
+
original(...(parts as []));
|
|
2570
|
+
};
|
|
2571
|
+
try {
|
|
2572
|
+
applyGates();
|
|
2573
|
+
} finally {
|
|
2574
|
+
console.error = original;
|
|
2575
|
+
gateFailed = process.exitCode === 1;
|
|
2576
|
+
}
|
|
2577
|
+
};
|
|
2578
|
+
|
|
2451
2579
|
/**
|
|
2452
2580
|
* The side files the caller asked for. Written on **both** output paths:
|
|
2453
2581
|
* under --json the human rendering returns early, and the first version of
|
|
@@ -2483,6 +2611,9 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
|
|
|
2483
2611
|
? { window: { since: stringFlag(args, 'since') ?? '—', until: stringFlag(args, 'until') ?? '—' } }
|
|
2484
2612
|
: {}),
|
|
2485
2613
|
...(pricingStale !== null ? { stalePricing: pricingStale } : {}),
|
|
2614
|
+
// The verdict, where the person reading CI will see it. recordGates()
|
|
2615
|
+
// runs before the side files for exactly this.
|
|
2616
|
+
...(gateVerdicts.length > 0 ? { gates: { failed: gateFailed, lines: gateVerdicts } } : {}),
|
|
2486
2617
|
// The repricing, when --what-if was given: computed once above and
|
|
2487
2618
|
// handed over, so the summary in a pull request cannot disagree
|
|
2488
2619
|
// with the terminal about what a move would cost.
|
|
@@ -2608,8 +2739,8 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
|
|
|
2608
2739
|
2,
|
|
2609
2740
|
),
|
|
2610
2741
|
);
|
|
2742
|
+
recordGates();
|
|
2611
2743
|
await writeSideFiles();
|
|
2612
|
-
applyGates();
|
|
2613
2744
|
return;
|
|
2614
2745
|
}
|
|
2615
2746
|
|
|
@@ -3643,6 +3774,46 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
|
|
|
3643
3774
|
console.log(` ${d.delta > 0 ? c.yellow(line) : c.dim(line)}`);
|
|
3644
3775
|
}
|
|
3645
3776
|
}
|
|
3777
|
+
|
|
3778
|
+
/**
|
|
3779
|
+
* What the comparison stopped being able to see.
|
|
3780
|
+
*
|
|
3781
|
+
* Every figure above is dollars, and dollars cannot tell a finding that
|
|
3782
|
+
* was fixed from a finding whose field the log stopped recording — both
|
|
3783
|
+
* are silence. This is the only section that can, so it is loud: a
|
|
3784
|
+
* collapse in coverage invalidates whichever findings depended on it,
|
|
3785
|
+
* and reading the drop as good news is the specific mistake it exists
|
|
3786
|
+
* to prevent.
|
|
3787
|
+
*/
|
|
3788
|
+
const drifts = coverageDrift(previous.fieldCoverage, report.fieldCoverage);
|
|
3789
|
+
if (drifts.length > 0) {
|
|
3790
|
+
console.log();
|
|
3791
|
+
for (const drift of drifts) {
|
|
3792
|
+
const line = t.profile.coverageDrift(
|
|
3793
|
+
t.profile.coverageField(drift.field),
|
|
3794
|
+
pct(drift.was),
|
|
3795
|
+
pct(drift.now),
|
|
3796
|
+
);
|
|
3797
|
+
console.log(
|
|
3798
|
+
drift.delta < 0
|
|
3799
|
+
? ` ${c.yellow('!')} ${c.bold(wrap(line, 74, ' '))}`
|
|
3800
|
+
: ` ${c.dim(wrap(line, 74, ' '))}`,
|
|
3801
|
+
);
|
|
3802
|
+
/**
|
|
3803
|
+
* Which findings went with it, named. "Some findings are silent" is
|
|
3804
|
+
* not something a reader can act on; knowing that conversation
|
|
3805
|
+
* growth and the cache-TTL fit are now silence rather than absence
|
|
3806
|
+
* tells them exactly which sections of this report to distrust.
|
|
3807
|
+
*/
|
|
3808
|
+
if (drift.delta < 0) {
|
|
3809
|
+
const silenced = t.profile.coverageSilenced(drift.field);
|
|
3810
|
+
if (silenced !== '') console.log(` ${c.dim(wrap(silenced, 72, ' '))}`);
|
|
3811
|
+
}
|
|
3812
|
+
}
|
|
3813
|
+
if (drifts.some((d) => d.delta < 0)) {
|
|
3814
|
+
console.log(` ${c.dim(wrap(t.profile.coverageDriftWhy(), 74, ' '))}`);
|
|
3815
|
+
}
|
|
3816
|
+
}
|
|
3646
3817
|
}
|
|
3647
3818
|
}
|
|
3648
3819
|
|
|
@@ -3700,9 +3871,9 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
|
|
|
3700
3871
|
|
|
3701
3872
|
reportProfileGaps(report, t, n, pricingStale);
|
|
3702
3873
|
|
|
3703
|
-
|
|
3874
|
+
recordGates();
|
|
3704
3875
|
|
|
3705
|
-
|
|
3876
|
+
await writeSideFiles();
|
|
3706
3877
|
}
|
|
3707
3878
|
|
|
3708
3879
|
/**
|
package/src/markdown.ts
CHANGED
|
@@ -723,6 +723,16 @@ export interface ProfileMarkdownInput {
|
|
|
723
723
|
levers: BillLevers;
|
|
724
724
|
cache: CacheEconomics;
|
|
725
725
|
t: CliMessages;
|
|
726
|
+
/**
|
|
727
|
+
* The gate verdicts, when any gate was armed.
|
|
728
|
+
*
|
|
729
|
+
* They reached the terminal on stderr and stopped there, so a CI run
|
|
730
|
+
* summary carried the whole report and not the one sentence explaining why
|
|
731
|
+
* the build was red — the reader had to open the raw log to find it. These
|
|
732
|
+
* arrive already rendered by the caller, which owns the thresholds and the
|
|
733
|
+
* copy; this is a rendering and must not decide anything a gate decides.
|
|
734
|
+
*/
|
|
735
|
+
gates?: { failed: boolean; lines: string[] };
|
|
726
736
|
/**
|
|
727
737
|
* The `--since`/`--until` values as the user typed them, when a window was
|
|
728
738
|
* applied. Passed through rather than re-derived from `timeWindow`'s epoch
|
|
@@ -783,7 +793,7 @@ export interface ProfileMarkdownInput {
|
|
|
783
793
|
* reading CI instead of machines.
|
|
784
794
|
*/
|
|
785
795
|
export function renderProfileMarkdown(input: ProfileMarkdownInput): string {
|
|
786
|
-
const { report, levers, cache, t, window, stalePricing, against, whatIf, pressure = [] } = input;
|
|
796
|
+
const { report, levers, cache, t, window, stalePricing, against, whatIf, pressure = [], gates } = input;
|
|
787
797
|
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
788
798
|
const pct = (share: number): string => `${(share * 100).toFixed(1)}%`;
|
|
789
799
|
const shares = sharesOf(report.total);
|
|
@@ -793,6 +803,24 @@ export function renderProfileMarkdown(input: ProfileMarkdownInput): string {
|
|
|
793
803
|
const lines: string[] = [];
|
|
794
804
|
lines.push(`### ${t.profile.heading()}`);
|
|
795
805
|
lines.push('');
|
|
806
|
+
/**
|
|
807
|
+
* The verdict first, when a gate was armed.
|
|
808
|
+
*
|
|
809
|
+
* A run summary that carried the whole report and not the sentence
|
|
810
|
+
* explaining why the build is red made the reader open the raw log to find
|
|
811
|
+
* it — which is the same failure the explanation itself exists to fix, one
|
|
812
|
+
* surface further out. A failure is quoted so it survives being skimmed; a
|
|
813
|
+
* pass is stated plainly and does not shout.
|
|
814
|
+
*/
|
|
815
|
+
if (gates !== undefined && gates.lines.length > 0) {
|
|
816
|
+
// One mark, on the verdict. The lines under it explain that verdict and
|
|
817
|
+
// are not themselves failures — marking each would turn one red build
|
|
818
|
+
// into a wall of crosses and make the actual verdict harder to find.
|
|
819
|
+
const [verdict, ...rest] = gates.lines;
|
|
820
|
+
lines.push(gates.failed ? `> ❌ **${mdText(verdict!)}**` : `_${mdText(verdict!)}_`);
|
|
821
|
+
for (const line of rest) lines.push(gates.failed ? `> ${mdText(line)}` : `_${mdText(line)}_`);
|
|
822
|
+
lines.push('');
|
|
823
|
+
}
|
|
796
824
|
lines.push(`**${t.profile.spent(t.profile.calls(report.total.calls), formatUsd(report.total.totalUsd))}**`);
|
|
797
825
|
lines.push('');
|
|
798
826
|
// The span, under the same rule as the terminal: stated, never extrapolated,
|