@trazum/cli 1.8.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/index.js ADDED
@@ -0,0 +1,2824 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, stat, writeFile } from 'node:fs/promises';
3
+ import { join, resolve as resolvePath } from 'node:path';
4
+ import { BASELINE_FILENAME, BASELINE_VERSION, MAX_BASELINE_BYTES, breaches, compareToBaseline, formatBaseline, moneyIsComparable, parseBaseline, DEFAULT_USAGE, LOCALES, reviewAgeDays, RULES, comparePrompts, countTokensAnthropic, getModel, applyRewrites, computeSavings, profilePrompt, toPromptfoo, PHRASE_LANGUAGES, estimateTokens, formatUsd, formatSignedUsd, getMessages, nearestName, optimize, toOtlpMetrics, providerFromEnv, reorderForCache, sharedPrefixes, cacheableMinimum, findExamples, plannedCalls, pruneExamples, extractPrompts, promptId, hasMarker, SOURCE_EXTENSIONS, detectFromSource, evaluate, refineWithLlm, rejectionText, suggestRewrites, reviewExamples, withExactTokenCounts, } from '@trazum/core';
5
+ import { cacheDir, cacheStats, cachingProvider, clearCache } from './suggest-cache.js';
6
+ // Everything that reads the filesystem, on its own entry point so the web
7
+ // bundle cannot reach it. See packages/core/src/node.ts.
8
+ import { CONFIG_FILENAME, DEFAULT_EXTENSIONS, budgetFor, BUNDLED_CATALOGUE, SAFE_FETCH_INIT, applyPricingOverlay, catalogueFromOverlay, checkedEndpoint, openrouterOverlay, detectHost, loadConfig, walkPrompts, } from '@trazum/core/node';
9
+ import { contentAt, gitAvailable, namesByRevision, pathInRepository, repositoryRoot, revisionsFor, } from './git.js';
10
+ import { detectLocale, getCliMessages } from './i18n/index.js';
11
+ import { MAX_SUMMARY_CHARS, fitWithin, renderBlameMarkdown, renderCheckMarkdown, renderDiffMarkdown, renderRankMarkdown, } from './markdown.js';
12
+ // --------------------------------------------------------------------------
13
+ // Presentation
14
+ // --------------------------------------------------------------------------
15
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
16
+ const c = {
17
+ bold: (s) => (useColor ? `\u001b[1m${s}\u001b[22m` : s),
18
+ dim: (s) => (useColor ? `\u001b[2m${s}\u001b[22m` : s),
19
+ green: (s) => (useColor ? `\u001b[32m${s}\u001b[39m` : s),
20
+ red: (s) => (useColor ? `\u001b[31m${s}\u001b[39m` : s),
21
+ yellow: (s) => (useColor ? `\u001b[33m${s}\u001b[39m` : s),
22
+ cyan: (s) => (useColor ? `\u001b[36m${s}\u001b[39m` : s),
23
+ };
24
+ const VALUE_FLAGS = new Set([
25
+ 'level',
26
+ 'model',
27
+ 'calls',
28
+ 'output-tokens',
29
+ 'cache-hit-rate',
30
+ 'disable',
31
+ 'max-tokens',
32
+ 'cases',
33
+ 'concurrency',
34
+ 'max-growth',
35
+ 'export',
36
+ 'limit',
37
+ 'locale',
38
+ 'config',
39
+ 'markdown-out',
40
+ 'otlp-out',
41
+ 'pricing',
42
+ 'prompt',
43
+ 'out',
44
+ 'o',
45
+ ]);
46
+ function parseArgs(argv, t) {
47
+ const flags = new Map();
48
+ const asTyped = new Map();
49
+ const positional = [];
50
+ for (let i = 0; i < argv.length; i++) {
51
+ const arg = argv[i];
52
+ // The POSIX escape: everything after `--` is a path, whatever it looks
53
+ // like. Without it there is no way to name a file called `-x.txt` or
54
+ // `--output=…` on the command line at all — the parser sees a flag and
55
+ // refuses before the path reaches the code that knows what to do with it.
56
+ if (arg === '--') {
57
+ positional.push(...argv.slice(i + 1));
58
+ break;
59
+ }
60
+ if (!arg.startsWith('-') || arg === '-') {
61
+ positional.push(arg);
62
+ continue;
63
+ }
64
+ const typed = arg.replace(/^--?/, '');
65
+ let name = typed;
66
+ // `--no-batch` stores `batch: false`. This exists because a config file can
67
+ // switch a boolean on, and a setting that cannot be switched back off from
68
+ // the command line is one you have to edit the repository to escape.
69
+ let value = true;
70
+ if (name.startsWith('no-') && !VALUE_FLAGS.has(name)) {
71
+ name = name.slice(3);
72
+ value = false;
73
+ asTyped.set(name, typed);
74
+ }
75
+ if (VALUE_FLAGS.has(name)) {
76
+ if (value === false)
77
+ throw new Error(t.errors.cannotNegate(name));
78
+ const given = argv[++i];
79
+ if (given === undefined)
80
+ throw new Error(t.errors.optionNeedsValue(name));
81
+ flags.set(name === 'o' ? 'out' : name, given);
82
+ }
83
+ else {
84
+ flags.set(name, value);
85
+ }
86
+ }
87
+ return { command: positional[0] ?? '', positional: positional.slice(1), flags, asTyped };
88
+ }
89
+ /**
90
+ * Reads a boolean flag, honouring `--no-` and a project default.
91
+ *
92
+ * `flags.has(name)` is the wrong test once negation exists: `--no-batch` stores
93
+ * the key with the value `false`, and `has` would report it as set.
94
+ */
95
+ function boolFlag(args, name, fallback = false) {
96
+ const raw = args.flags.get(name);
97
+ return typeof raw === 'boolean' ? raw : fallback;
98
+ }
99
+ /**
100
+ * Reads `--locale` before the rest of the parsing, so even a parse error is
101
+ * reported in the language the user asked for.
102
+ */
103
+ function localeFromArgv(argv) {
104
+ const index = argv.indexOf('--locale');
105
+ const flag = index >= 0 ? argv[index + 1] : undefined;
106
+ return detectLocale(flag);
107
+ }
108
+ function stringFlag(args, name) {
109
+ const raw = args.flags.get(name);
110
+ return typeof raw === 'string' ? raw : undefined;
111
+ }
112
+ function numberFlag(args, name, fallback, t) {
113
+ const raw = args.flags.get(name);
114
+ if (raw === undefined || typeof raw === 'boolean')
115
+ return fallback;
116
+ const value = Number(raw);
117
+ if (!Number.isFinite(value) || value < 0) {
118
+ throw new Error(t.errors.mustBeNonNegative(name, raw));
119
+ }
120
+ return value;
121
+ }
122
+ /**
123
+ * Resolves the rule level: flag, then config, then `safe`.
124
+ *
125
+ * The layering order is the same for every setting in this file — the command
126
+ * line beats the project, and the project beats the built-in default. A config
127
+ * file that could override an explicit flag would make the flag a suggestion.
128
+ */
129
+ function levelFlag(args, config, t) {
130
+ const level = (args.flags.get('level') ?? config.level ?? 'safe');
131
+ if (level !== 'safe' && level !== 'aggressive') {
132
+ throw new Error(t.errors.badLevel(String(level)));
133
+ }
134
+ return level;
135
+ }
136
+ /**
137
+ * Usage profile from flags over config over detection over the built-in default.
138
+ *
139
+ * `detected` is what the source file said — an SDK import, a base URL, a quoted
140
+ * model id. It beats the default because reading the code is better than
141
+ * assuming, and loses to config because being told is better than reading.
142
+ */
143
+ function usageFrom(args, config, t, detected) {
144
+ const fromConfig = config.usage ?? {};
145
+ const model = stringFlag(args, 'model') ?? fromConfig.model ?? detected ?? DEFAULT_USAGE.model;
146
+ return {
147
+ model,
148
+ callsPerMonth: numberFlag(args, 'calls', fromConfig.callsPerMonth ?? DEFAULT_USAGE.callsPerMonth, t),
149
+ avgOutputTokens: numberFlag(args, 'output-tokens', fromConfig.avgOutputTokens ?? DEFAULT_USAGE.avgOutputTokens, t),
150
+ cacheHitRate: numberFlag(args, 'cache-hit-rate', fromConfig.cacheHitRate ?? DEFAULT_USAGE.cacheHitRate, t),
151
+ batchEligible: boolFlag(args, 'batch', fromConfig.batchEligible ?? false),
152
+ };
153
+ }
154
+ /**
155
+ * Prices for this run: `--pricing` beats the config's overlay, which beats the
156
+ * bundled catalogue — the same layering as every other setting.
157
+ */
158
+ /**
159
+ * OpenRouter's public catalogue. Overridable for an operator behind a mirror.
160
+ *
161
+ * Not a secret and not a credential: the models endpoint is unauthenticated,
162
+ * which is why this can be a flag rather than a key.
163
+ */
164
+ const OPENROUTER_MODELS_URL = process.env.TRAZUM_OPENROUTER_URL ?? 'https://openrouter.ai/api/v1/models';
165
+ /**
166
+ * Prices from a live source, and the reasoning for why this is opt-in.
167
+ *
168
+ * The bundled catalogue is a table somebody typed, so it is stale the day after
169
+ * it is written and it only ever covered the providers whoever typed it reached
170
+ * for. `--pricing-live` replaces the price half of it with today's figures for
171
+ * hundreds of models across dozens of providers.
172
+ *
173
+ * **Opt-in, because it is a network call.** Rule 1 of this project is that no
174
+ * feature makes a network call a prerequisite for optimising a prompt. This is
175
+ * the CLI reaching out on request and handing the core a value; the core never
176
+ * fetches anything, which is what keeps `optimize()` free, offline and
177
+ * deterministic.
178
+ *
179
+ * Through `checkedEndpoint` and `SAFE_FETCH_INIT` like every other outbound
180
+ * call here: URL validated before the request, redirects refused, so an
181
+ * endpoint that passes the check cannot answer `302` and send the request
182
+ * somewhere on the metadata network.
183
+ */
184
+ async function livePricing(source, t) {
185
+ const endpoint = checkedEndpoint(source, { name: 'openrouter' });
186
+ let payload;
187
+ try {
188
+ const response = await fetch(endpoint, { ...SAFE_FETCH_INIT, method: 'GET' });
189
+ if (!response.ok)
190
+ throw new Error(`${response.status} ${response.statusText}`);
191
+ payload = await response.json();
192
+ }
193
+ catch (error) {
194
+ const detail = error instanceof Error ? error.message : String(error);
195
+ throw new Error(t.errors.livePricingFailed(endpoint, detail));
196
+ }
197
+ const known = new Set(BUNDLED_CATALOGUE.models.map((model) => model.id));
198
+ const { overlay, skipped } = openrouterOverlay(payload, {
199
+ knownIds: known,
200
+ lastReviewed: new Date().toISOString().slice(0, 10),
201
+ });
202
+ const catalogue = applyPricingOverlay(BUNDLED_CATALOGUE, overlay, endpoint);
203
+ // Said out loud, on stderr so it never lands in `--json`. A price feed that
204
+ // silently dropped a third of its entries would leave somebody wondering why
205
+ // their model is still missing.
206
+ console.error(t.pricing.liveLoaded(catalogue.addedModels.length, catalogue.overriddenModels.length, skipped.length));
207
+ return catalogue;
208
+ }
209
+ async function pricingFor(args, loaded, t) {
210
+ const flag = stringFlag(args, 'pricing');
211
+ if (flag) {
212
+ const raw = await readFile(flag, 'utf8');
213
+ return catalogueFromOverlay(raw, flag);
214
+ }
215
+ // A file beats the network: somebody who wrote prices down meant them.
216
+ if (boolFlag(args, 'pricing-live'))
217
+ return livePricing(OPENROUTER_MODELS_URL, t);
218
+ return loaded.pricing;
219
+ }
220
+ /** Rules to disable: the flag replaces the config list rather than adding to it. */
221
+ function disabledRules(args, config) {
222
+ const flag = stringFlag(args, 'disable');
223
+ if (flag !== undefined) {
224
+ return flag.split(',').map((id) => id.trim()).filter(Boolean);
225
+ }
226
+ return config.disable;
227
+ }
228
+ /**
229
+ * Flags each command accepts. An unrecognised flag used to be accepted
230
+ * silently, which on a gate command means CI passing while the author believes
231
+ * a threshold is set — `--max-growh 5` would have been ignored and the build
232
+ * gone green. Silence is the wrong answer for a typo.
233
+ */
234
+ const GLOBAL_FLAGS = ['help', 'h', 'locale', 'json', 'config', 'pricing', 'pricing-live'];
235
+ const COMMAND_FLAGS = {
236
+ optimize: [
237
+ 'level', 'model', 'calls', 'output-tokens', 'cache-hit-rate', 'batch',
238
+ 'disable', 'llm', 'exact-tokens', 'diff', 'reorder', 'out', 'o',
239
+ 'tokens-only', 'cost', 'prompt', 'suggest', 'apply-suggestions',
240
+ 'cache-suggestions',
241
+ ],
242
+ check: ['max-tokens', 'level', 'exact-tokens', 'markdown-out', 'baseline'],
243
+ baseline: ['model', 'calls', 'output-tokens', 'cache-hit-rate', 'batch', 'exact-tokens', 'out', 'o'],
244
+ eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
245
+ prune: ['cases', 'concurrency', 'json', 'yes'],
246
+ diff: ['level', 'model', 'calls', 'output-tokens', 'batch', 'max-growth', 'optimized', 'markdown-out', 'all', 'prompt'],
247
+ models: [],
248
+ rank: ['level', 'model', 'calls', 'output-tokens', 'batch', 'disable', 'prompt', 'markdown-out'],
249
+ where: [],
250
+ rules: [],
251
+ blame: ['limit', 'model', 'calls', 'output-tokens', 'batch', 'prompt', 'markdown-out'],
252
+ doctor: ['level', 'model', 'calls', 'output-tokens', 'batch', 'disable', 'prompt', 'otlp-out'],
253
+ };
254
+ function rejectUnknownFlags(args, t) {
255
+ const known = COMMAND_FLAGS[args.command];
256
+ if (!known)
257
+ return;
258
+ const allowed = [...known, ...GLOBAL_FLAGS];
259
+ for (const name of args.flags.keys()) {
260
+ // `out` is stored under its long name even when given as `-o`, and a
261
+ // negated boolean under its base name, so both validate against the list.
262
+ if (allowed.includes(name))
263
+ continue;
264
+ // Quoted as typed, so `--no-nonsense` is not reported as `--nonsense`.
265
+ const spelled = args.asTyped.get(name) ?? name;
266
+ const nearest = nearestName(name, allowed);
267
+ throw new Error(nearest
268
+ ? t.errors.unknownFlagDidYouMean(spelled, nearest)
269
+ : t.errors.unknownFlag(spelled, allowed.slice().sort().join(', ')));
270
+ }
271
+ }
272
+ // --------------------------------------------------------------------------
273
+ // Line-by-line diff
274
+ // --------------------------------------------------------------------------
275
+ /**
276
+ * Largest diff this will attempt, in lines per side.
277
+ *
278
+ * The alignment table is quadratic: at 6,000 lines it is 36 million cells and
279
+ * roughly 288 MB before anything else runs. There is no prompt worth reading a
280
+ * 6,000-line diff of, so past this the diff is declined rather than the process
281
+ * being taken down by someone passing a large file.
282
+ */
283
+ const MAX_DIFF_LINES = 2500;
284
+ /** Longest common subsequence, used to align the two versions. */
285
+ function lcsTable(a, b) {
286
+ const table = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
287
+ for (let i = a.length - 1; i >= 0; i--) {
288
+ for (let j = b.length - 1; j >= 0; j--) {
289
+ table[i][j] =
290
+ a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
291
+ }
292
+ }
293
+ return table;
294
+ }
295
+ function renderDiff(before, after, t) {
296
+ const a = before.split('\n');
297
+ const b = after.split('\n');
298
+ if (a.length > MAX_DIFF_LINES || b.length > MAX_DIFF_LINES) {
299
+ return c.dim(t.report.diffTooLarge(Math.max(a.length, b.length), MAX_DIFF_LINES));
300
+ }
301
+ const table = lcsTable(a, b);
302
+ const lines = [];
303
+ let i = 0;
304
+ let j = 0;
305
+ while (i < a.length && j < b.length) {
306
+ if (a[i] === b[j]) {
307
+ lines.push(c.dim(` ${a[i]}`));
308
+ i++;
309
+ j++;
310
+ }
311
+ else if (table[i + 1][j] >= table[i][j + 1]) {
312
+ lines.push(c.red(`- ${a[i]}`));
313
+ i++;
314
+ }
315
+ else {
316
+ lines.push(c.green(`+ ${b[j]}`));
317
+ j++;
318
+ }
319
+ }
320
+ while (i < a.length)
321
+ lines.push(c.red(`- ${a[i++]}`));
322
+ while (j < b.length)
323
+ lines.push(c.green(`+ ${b[j++]}`));
324
+ return lines.join('\n');
325
+ }
326
+ // --------------------------------------------------------------------------
327
+ // Report
328
+ // --------------------------------------------------------------------------
329
+ /**
330
+ * The provider's name when the estimator was not calibrated for it.
331
+ *
332
+ * `estimateTokens` is a heuristic tuned against Claude's tokenizer, and the
333
+ * ±15% band descends from that. Printing the same band beside a GPT or Kimi
334
+ * figure states a precision nobody has measured for that family — and since the
335
+ * catalogue grew past Anthropic, that is most of it. Returns null when the model
336
+ * is Anthropic's, where the band is at least the claim it was written for.
337
+ */
338
+ function offFamilyName(modelId) {
339
+ const provider = getModel(modelId).provider;
340
+ if (provider === undefined || provider === 'anthropic')
341
+ return null;
342
+ return getModel(modelId).displayName;
343
+ }
344
+ /**
345
+ * Language codes as names, in the reader's language.
346
+ *
347
+ * Built from `PHRASE_LANGUAGES` rather than written out, so a language added to
348
+ * the dictionaries appears here without anybody remembering to edit a sentence.
349
+ */
350
+ function languageNames(codes, t) {
351
+ const names = codes.map((code) => t.languages[code] ?? code);
352
+ if (names.length <= 1)
353
+ return names[0] ?? '';
354
+ return `${names.slice(0, -1).join(', ')} ${t.languages.and} ${names[names.length - 1]}`;
355
+ }
356
+ function printReport(result, showDiff, t, examplesReview = null, reorder = null, tokensOnly = false, host = { id: 'terminal', displayName: 'terminal', billing: 'unknown', evidence: null }, suggestions = null) {
357
+ const n = (value) => value.toLocaleString(t.numberLocale);
358
+ const sourceNote = result.tokenSource === 'heuristic'
359
+ ? c.dim(t.report.estimated(offFamilyName(result.usage.model)))
360
+ : c.dim(t.report.exactCount());
361
+ console.log();
362
+ console.log(c.bold(t.report.inputTokens()));
363
+ console.log(` ${n(result.tokensBefore)} → ${c.green(n(result.tokensAfter))} ${c.bold(`-${result.reductionPct.toFixed(1)}%`)}${sourceNote}`);
364
+ // Before the rules, because the rearrangement is the bigger change and the
365
+ // one the reader has to make a judgement about.
366
+ //
367
+ // Only when there is something to say. "Nothing could safely move" with no
368
+ // refusals underneath is a heading, a blank line and a shrug — the reader
369
+ // asked for a rearrangement, there was none available, and the token count
370
+ // above already told them nothing changed.
371
+ if (reorder !== null && (reorder.moved.length > 0 || reorder.declined.length > 0)) {
372
+ console.log();
373
+ console.log(c.bold(t.report.reorderHeading()));
374
+ if (reorder.moved.length === 0) {
375
+ console.log(` ${c.dim(t.report.reorderNothing())}`);
376
+ }
377
+ else {
378
+ console.log(` ${t.report.reorderMoved(reorder.moved.length, n(reorder.tokensMoved))}`);
379
+ console.log(` ${c.green(t.report.reorderPrefix(n(reorder.prefixTokensBefore), n(reorder.prefixTokensAfter)))}`);
380
+ }
381
+ // Refusals are reported even when the move succeeded: a saving Trazum chose
382
+ // not to take is one the author cannot evaluate unless they are told.
383
+ if (reorder.declined.length > 0) {
384
+ console.log(` ${c.dim(t.report.reorderDeclined(reorder.declined.length))}`);
385
+ const SHOWN = 3;
386
+ for (const d of reorder.declined.slice(0, SHOWN)) {
387
+ const excerpt = truncate(d.text.trim().replace(/\s+/g, ' '), 48);
388
+ console.log(` ${c.dim(d.reason === 'uncovered-script'
389
+ ? t.report.reorderDeclinedScript(d.script ?? '')
390
+ : d.reason === 'backward-reference'
391
+ ? t.report.reorderDeclinedRef(d.phrase ?? '', excerpt)
392
+ : t.report.reorderDeclinedAfter(excerpt))}`);
393
+ }
394
+ // Say that the list was cut. A report that shows three of nine reads as
395
+ // "three" unless it admits otherwise.
396
+ if (reorder.declined.length > SHOWN) {
397
+ console.log(` ${c.dim(t.report.reorderDeclinedMore(reorder.declined.length - SHOWN))}`);
398
+ }
399
+ }
400
+ if (reorder.moved.length > 0)
401
+ console.log(` ${c.yellow(t.report.reorderReview())}`);
402
+ }
403
+ if (result.rules.length > 0) {
404
+ console.log();
405
+ console.log(c.bold(t.report.rulesApplied()));
406
+ for (const rule of result.rules) {
407
+ const tag = rule.level === 'aggressive'
408
+ ? c.yellow(t.report.levelAggressive())
409
+ : c.dim(t.report.levelSafe());
410
+ console.log(` ${tag} ${rule.title} ${c.dim(t.report.ruleHits(rule.hits, rule.tokensSaved))}`);
411
+ // What the rule actually did. Shown under the aggressive level by
412
+ // default because that is the one whose advice is "read the diff", and
413
+ // a diff of everything at once is not something anyone reads.
414
+ const showChanges = showDiff || rule.level === 'aggressive';
415
+ if (showChanges) {
416
+ for (const change of rule.changes) {
417
+ const from = c.red(truncate(change.before, 46));
418
+ const to = change.after ? c.green(truncate(change.after, 30)) : c.dim('—');
419
+ console.log(` ${from} ${c.dim('→')} ${to}`);
420
+ }
421
+ if (rule.hits > rule.changes.length && rule.changes.length > 0) {
422
+ console.log(c.dim(` ${t.report.moreChanges(rule.hits - rule.changes.length)}`));
423
+ }
424
+ }
425
+ }
426
+ }
427
+ else {
428
+ console.log();
429
+ console.log(c.dim(t.report.nothingToTrim()));
430
+ // Which languages the dictionaries actually cover. Only here, because this
431
+ // is the one branch where silence reads as "your prompt is already clean".
432
+ console.log(c.dim(t.report.dictionaryCoverage(languageNames(PHRASE_LANGUAGES, t))));
433
+ }
434
+ if (result.llm) {
435
+ console.log();
436
+ console.log(c.bold(t.report.llmPass()));
437
+ if (result.llm.applied) {
438
+ console.log(` ${c.green(t.report.llmApplied(result.llm.provider, result.llm.model, result.llm.tokensBefore, result.llm.tokensAfter))}`);
439
+ }
440
+ else {
441
+ console.log(` ${c.yellow(t.report.llmRejected(result.llm.rejectedReason ?? ''))}`);
442
+ }
443
+ }
444
+ // On a subscription there is no bill to reduce. Everything below this point
445
+ // would be arithmetic about tokens dressed as money, and "$184/month" told to
446
+ // somebody on a flat plan is wrong in the direction that matters most.
447
+ //
448
+ // What replaces it is the thing that *is* scarce there: the context window.
449
+ if (tokensOnly) {
450
+ printTokensOnly(result, host, t, n);
451
+ }
452
+ else {
453
+ printMoney(result, t, n);
454
+ }
455
+ // On a subscription, an advisory whose entire pitch is money is not weaker
456
+ // advice — it is not advice. "Use a cheaper model" saves nothing on a flat
457
+ // plan, and its detail text quotes dollars per month, so suppressing only the
458
+ // price tag beside the title left the money in the sentence underneath.
459
+ //
460
+ // The rest stay: an overflowing context window still fails the call, a
461
+ // contradiction is still wrong, redundant examples still cost tokens, and
462
+ // caching still buys latency and rate-limit headroom.
463
+ const MONEY_ONLY = new Set([
464
+ 'model-downgrade',
465
+ 'batch-api',
466
+ 'output-dominated',
467
+ 'promo-pricing',
468
+ 'prompt-caching-not-worth-it',
469
+ ]);
470
+ const advisories = tokensOnly
471
+ ? result.advisories.filter((a) => !MONEY_ONLY.has(a.id))
472
+ : result.advisories;
473
+ if (advisories.length > 0) {
474
+ console.log();
475
+ console.log(c.bold(t.report.beyondShortening()));
476
+ // The amount goes in a column of its own rather than trailing the title.
477
+ // Four advisories worth $506, $422, $170 and nothing are meant to be
478
+ // compared, and comparing them meant reading to the end of four different
479
+ // sentences to find where the numbers were.
480
+ //
481
+ // The advisory itself still applies on a subscription — caching and a
482
+ // smaller model both buy back context and rate-limit headroom. Only the
483
+ // price tag is meaningless, so only the price tag goes.
484
+ const amountOf = (a) => !tokensOnly && a.estimatedMonthlyUsd !== null ? formatUsd(a.estimatedMonthlyUsd) : '';
485
+ const width = Math.max(0, ...advisories.map((a) => amountOf(a).length));
486
+ // Indent the wrapped detail to the start of the title, so the prose forms
487
+ // one block instead of stepping around the numbers.
488
+ const gutter = ' '.repeat(4 + (width > 0 ? width + 2 : 0));
489
+ for (const advisory of advisories) {
490
+ const marker = advisory.severity === 'warning'
491
+ ? c.yellow('!')
492
+ : advisory.severity === 'opportunity'
493
+ ? c.cyan('→')
494
+ : c.dim('·');
495
+ const amount = amountOf(advisory);
496
+ const column = width > 0 ? `${c.green(amount.padStart(width))} ` : '';
497
+ console.log(` ${marker} ${column}${c.bold(advisory.title)}`);
498
+ console.log(`${gutter}${c.dim(wrap(advisory.detail, 78 - gutter.length, gutter))}`);
499
+ }
500
+ // What to do first. The rules trimmed $1.25 and the top advisory is worth
501
+ // $506; leaving the reader to notice that by comparing four numbers in four
502
+ // sentences is how the most valuable line in the report gets skipped.
503
+ const best = advisories.find((a) => (a.estimatedMonthlyUsd ?? 0) > 0);
504
+ if (!tokensOnly && best?.estimatedMonthlyUsd) {
505
+ const ruleSaving = result.savings.monthlySavingsUsd;
506
+ const line = t.report.biggestLeverDetail(best.title, formatUsd(best.estimatedMonthlyUsd), ruleSaving > 0 ? Math.round(best.estimatedMonthlyUsd / ruleSaving) : null);
507
+ console.log();
508
+ // Wrapped to the same width as everything else. An unwrapped closing line
509
+ // is the one that runs off a narrow terminal, and it is the line most
510
+ // worth reading.
511
+ console.log(` ${c.bold(t.report.biggestLever())} ${c.dim(wrap(line, 62, ' '))}`);
512
+ }
513
+ }
514
+ printSuggestions(suggestions, t, n);
515
+ printRest(result, showDiff, t, examplesReview, n);
516
+ }
517
+ /** The cost section, for anyone billed by the token. */
518
+ function printMoney(result, t, n) {
519
+ const { savings } = result;
520
+ console.log();
521
+ console.log(c.bold(t.report.costWith(savings.modelDisplayName)));
522
+ console.log(` ${t.report.usageLine(n(result.usage.callsPerMonth), result.usage.avgOutputTokens, result.usage.batchEligible)}`);
523
+ // Said, not assumed. Once prices can be overlaid locally, a figure from the
524
+ // bundled catalogue and a figure from somebody's JSON file look identical, and
525
+ // the reader has to be able to tell which one they are about to budget against.
526
+ const touched = [
527
+ ...result.pricingSource.overriddenModels,
528
+ ...result.pricingSource.addedModels,
529
+ ];
530
+ if (touched.length > 0) {
531
+ console.log(` ${c.yellow(t.report.pricingOverlaid(touched.join(', '), result.pricingSource.lastReviewed))}`);
532
+ }
533
+ console.log(` ${formatUsd(savings.perMonth.before.totalUsd)} → ` +
534
+ `${c.green(formatUsd(savings.perMonth.after.totalUsd))} ` +
535
+ c.bold(t.report.perMonthSaving(formatUsd(savings.monthlySavingsUsd), savings.monthlySavingsPct.toFixed(1))));
536
+ }
537
+ /**
538
+ * What the saving buys when there is no bill: room.
539
+ *
540
+ * The context window is the scarce thing inside an agent — every token the
541
+ * system prompt holds is one the conversation cannot. That is a real saving and
542
+ * a measurable one, and it is the honest answer to "what did I gain" on a plan
543
+ * that costs the same either way.
544
+ */
545
+ function printTokensOnly(result, host, t, n) {
546
+ const model = getModel(result.usage.model);
547
+ const saved = result.tokensBefore - result.tokensAfter;
548
+ console.log();
549
+ console.log(c.bold(t.report.tokensOnlyHeading(host.displayName)));
550
+ // Only claim the host bills by subscription when it does. Forced with the
551
+ // flag on GitHub Actions, the first version said "GitHub Actions bills by
552
+ // subscription", which is simply false.
553
+ console.log(` ${host.billing === 'subscription'
554
+ ? t.report.tokensOnlyWhy(host.displayName)
555
+ : t.report.tokensOnlyAsked()}`);
556
+ console.log();
557
+ console.log(` ${c.green(t.report.tokensSaved(n(saved)))}`);
558
+ // Share of the window, which is what a saved token is actually worth here.
559
+ const share = (tokens) => `${((tokens / model.contextWindow) * 100).toFixed(1)}%`;
560
+ console.log(` ${c.dim(t.report.windowUse(share(result.tokensBefore), share(result.tokensAfter), model.displayName, n(model.contextWindow)))}`);
561
+ console.log(` ${c.dim(t.report.tokensOnlyCost())}`);
562
+ }
563
+ /**
564
+ * The proposed rewrites.
565
+ *
566
+ * A list, not a diff, because that is the shape of the decision: each line is
567
+ * one phrase and its replacement, and the reader is answering "yes" or "no" to
568
+ * that phrase rather than to a rewritten prompt.
569
+ *
570
+ * Rejections are summarised rather than listed one by one. "Four proposals did
571
+ * not survive checking" is the useful fact; which four is noise unless you are
572
+ * debugging the model, and `--json` has them for when you are.
573
+ */
574
+ function printSuggestions(suggestions, t, n) {
575
+ if (!suggestions)
576
+ return;
577
+ const { result, applied, locale } = suggestions;
578
+ if (result.suggestions.length === 0) {
579
+ // Say so. A silent absence reads as "the flag did nothing".
580
+ console.log(`\n${c.bold(t.report.suggestHeading())}`);
581
+ console.log(` ${c.dim(t.report.suggestNothing(result.provider, result.model))}`);
582
+ if (result.rejected.length > 0) {
583
+ console.log(` ${c.dim(t.report.suggestRejected(result.rejected.length))}`);
584
+ }
585
+ return;
586
+ }
587
+ const total = result.suggestions.reduce((sum, s) => sum + s.tokensSaved, 0);
588
+ console.log(`\n${c.bold(t.report.suggestHeading())}`);
589
+ console.log(` ${c.dim(applied
590
+ ? t.report.suggestApplied(result.suggestions.length, n(total))
591
+ : t.report.suggestOffered(result.suggestions.length, n(total)))}`);
592
+ for (const s of result.suggestions) {
593
+ const after = s.after === '' ? c.dim(t.report.suggestRemoved()) : c.green(truncate(s.after, 40));
594
+ const times = s.offsets.length > 1 ? c.dim(` ×${s.offsets.length}`) : '';
595
+ console.log(` ${c.red(truncate(s.before, 44))} ${c.dim('→')} ${after}` +
596
+ ` ${c.dim(`~${n(s.tokensSaved)}`)}${times}`);
597
+ }
598
+ if (result.rejected.length > 0) {
599
+ console.log(` ${c.dim(t.report.suggestRejected(result.rejected.length))}`);
600
+ // The most common reason, named. Four rejections all saying "the model
601
+ // paraphrased what it quoted" is a fact about the model worth knowing.
602
+ const counts = new Map();
603
+ for (const r of result.rejected)
604
+ counts.set(r.reason, (counts.get(r.reason) ?? 0) + 1);
605
+ const [reason] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
606
+ console.log(` ${c.dim(rejectionText(reason, locale))}`);
607
+ }
608
+ if (!applied)
609
+ console.log(` ${c.dim(t.report.suggestHowToApply())}`);
610
+ }
611
+ function printRest(result, showDiff, t, examplesReview, n) {
612
+ if (examplesReview && examplesReview.groups.length > 0) {
613
+ console.log();
614
+ console.log(c.bold(t.report.examplesReview()));
615
+ console.log(c.dim(` ${t.report.examplesReviewNote(examplesReview.provider, examplesReview.model, examplesReview.exampleCount)}`));
616
+ for (const group of examplesReview.groups) {
617
+ console.log(` ${c.yellow(t.report.exampleRedundant(group.redundant, group.keep))}` +
618
+ c.dim(` (~${group.tokens} tokens)`));
619
+ if (group.reason)
620
+ console.log(` ${c.dim(group.reason)}`);
621
+ }
622
+ }
623
+ if (showDiff) {
624
+ console.log();
625
+ console.log(c.bold(t.report.diff()));
626
+ console.log(renderDiff(result.original, result.optimized, t));
627
+ }
628
+ console.log();
629
+ }
630
+ /** Shortens a snippet for the change list, keeping it on one line. */
631
+ function truncate(text, max) {
632
+ const clean = text.replace(/\s+/g, ' ').trim();
633
+ return clean.length <= max ? clean : `${clean.slice(0, max - 1)}\u2026`;
634
+ }
635
+ /** Wraps a paragraph to a given width. */
636
+ function wrap(text, width, indent) {
637
+ const words = text.split(/\s+/);
638
+ const lines = [];
639
+ let line = '';
640
+ for (const word of words) {
641
+ if (line.length + word.length + 1 > width) {
642
+ lines.push(line);
643
+ line = word;
644
+ }
645
+ else {
646
+ line = line ? `${line} ${word}` : word;
647
+ }
648
+ }
649
+ if (line)
650
+ lines.push(line);
651
+ return lines.join(`\n${indent}`);
652
+ }
653
+ // --------------------------------------------------------------------------
654
+ // Subcommands
655
+ // --------------------------------------------------------------------------
656
+ /**
657
+ * A provider's stand-in model, for when the code names who but not which.
658
+ *
659
+ * The same capability as the global default, so the figure is comparable with
660
+ * what Trazum would have printed anyway, and the cheapest at that capability so
661
+ * the guess errs downwards — overstating somebody's bill on a model they never
662
+ * chose is the worse direction to be wrong in.
663
+ */
664
+ function defaultModelFor(provider, pricing) {
665
+ // Nearest capability, not an exact match. Matching exactly returned nothing
666
+ // for OpenAI and DeepSeek — neither has a `large` model, so the code fell
667
+ // through to the global default and printed "goes to openai / priced as
668
+ // Claude Opus 5" anyway. A ladder with different rungs is the normal case,
669
+ // not an edge one.
670
+ const RANK = { small: 0, mid: 1, large: 2, frontier: 3 };
671
+ const want = RANK[getModel(DEFAULT_USAGE.model).capability] ?? 2;
672
+ const candidates = pricing.models.filter((m) => m.provider === provider && m.recommendable !== false);
673
+ const best = candidates.reduce((chosen, m) => {
674
+ if (chosen === null)
675
+ return m;
676
+ const distance = Math.abs((RANK[m.capability] ?? 2) - want);
677
+ const chosenDistance = Math.abs((RANK[chosen.capability] ?? 2) - want);
678
+ if (distance !== chosenDistance)
679
+ return distance < chosenDistance ? m : chosen;
680
+ // Same distance: the cheaper one, so the guess errs downwards. Overstating
681
+ // somebody's bill on a model they never chose is the worse way to be wrong.
682
+ return m.inputPerMTok < chosen.inputPerMTok ? m : chosen;
683
+ }, null);
684
+ return best?.id ?? null;
685
+ }
686
+ /**
687
+ * Reads a source file as the prompts it holds, rather than as one big prompt.
688
+ *
689
+ * Returns null for anything that is not a source file, which is the ordinary
690
+ * case: a `.txt` or `.md` prompt goes through untouched.
691
+ *
692
+ * For a source file it **refuses rather than guesses**. Optimising TypeScript
693
+ * as if it were prose does not produce a worse prompt, it produces broken code
694
+ * — `import OpenAI` came back as `Import OpenAI` from the capitalisation rule —
695
+ * and `-o` would write that over the file. A refusal with the marker syntax in
696
+ * it costs the reader one comment; the alternative cost them a compile.
697
+ */
698
+ function sourceFileOf(target, raw, pricing, wanted) {
699
+ const isSource = SOURCE_EXTENSIONS.some((ext) => target.toLowerCase().endsWith(ext));
700
+ if (!isSource)
701
+ return null;
702
+ // The catalogue in effect rather than the bundled one: an overlay can add a
703
+ // model, and a detection that cannot see it would fall back for no reason.
704
+ const detection = detectFromSource(raw, { models: pricing.models });
705
+ // An import names who, never which — so a file that plainly calls OpenAI was
706
+ // still being priced against Claude Opus 5. The provider's own stand-in is a
707
+ // guess about which of their models rather than about whose, which is the
708
+ // difference that matters. `trazum where` says which it picked and why.
709
+ const model = detection.model ??
710
+ (detection.provider !== null ? (defaultModelFor(detection.provider, pricing) ?? undefined) : undefined);
711
+ if (!hasMarker(raw)) {
712
+ throw new Error(t_sourceNeedsMarker(target));
713
+ }
714
+ const { prompts, declined } = extractPrompts(raw);
715
+ if (prompts.length === 0) {
716
+ const why = declined[0];
717
+ throw new Error(why
718
+ ? `${target}: the marker on line ${why.line} could not be read — ${why.detail}`
719
+ : `${target}: nothing was extracted from the markers in this file.`);
720
+ }
721
+ // One prompt is unambiguous. Several need naming, because optimising "the
722
+ // first one" silently is how the wrong prompt ends up rewritten.
723
+ const chosen = wanted !== undefined
724
+ ? prompts.find((p) => p.name === wanted || promptId(target, p) === wanted)
725
+ : prompts.length === 1
726
+ ? prompts[0]
727
+ : undefined;
728
+ if (!chosen) {
729
+ const names = prompts.map((p) => promptId(target, p)).join('\n ');
730
+ throw new Error(wanted !== undefined
731
+ ? `${target} has no marked prompt called "${wanted}". It holds:\n ${names}`
732
+ : `${target} holds ${prompts.length} marked prompts. Name one with --prompt:\n ${names}`);
733
+ }
734
+ return { text: chosen.text, ...(model ? { model } : {}) };
735
+ }
736
+ /** Kept as a function so the sentence is in one place rather than two. */
737
+ const t_sourceNeedsMarker = (target) => `${target} looks like source, not a prompt. Optimising it would rewrite your code — ` +
738
+ 'mark the prompt with a `// trazum:prompt` comment above the literal, or pass the ' +
739
+ 'prompt itself in a .txt file.';
740
+ /**
741
+ * Says which provider a prompt is actually sent to, and how it knows.
742
+ *
743
+ * Trazum priced one vendor, so the default cost nothing. Pricing seven made it a
744
+ * wrong number: a file calling OpenAI was billed against Claude Opus 5 without
745
+ * comment. This reads what the code already says instead.
746
+ *
747
+ * Every answer names the line it came from. A detection this command cannot
748
+ * justify is a guess, and the number that follows from it would be a guess too.
749
+ */
750
+ async function commandWhere(args, config, pricing, t) {
751
+ const host = detectHost();
752
+ console.log();
753
+ console.log(c.bold(t.where.hostHeading()));
754
+ console.log(` ${host.displayName}${host.evidence ? c.dim(` (${host.evidence})`) : ''}`);
755
+ // The reason this is worth printing at all. Inside a flat plan the monthly
756
+ // figure Trazum computes is arithmetic about tokens, not money anybody gets
757
+ // back, and saying so is more useful than saying nothing.
758
+ if (host.billing === 'subscription') {
759
+ console.log(` ${c.yellow(t.where.subscription(host.displayName))}`);
760
+ }
761
+ const target = args.positional[0];
762
+ if (target === undefined) {
763
+ console.log();
764
+ console.log(c.dim(t.where.noTarget()));
765
+ console.log();
766
+ return;
767
+ }
768
+ const source = await readFile(target, 'utf8');
769
+ const detection = detectFromSource(source, { models: pricing.models });
770
+ console.log();
771
+ console.log(c.bold(t.where.sourceHeading(target)));
772
+ if (detection.conflicts.length > 0) {
773
+ // Two answers is not a weaker version of one answer. Naming both and
774
+ // declining is the only honest output here.
775
+ console.log(` ${c.red(t.where.conflict())}`);
776
+ for (const e of detection.evidence.slice(0, 4)) {
777
+ console.log(` ${c.dim(t.where.evidenceLine(e.line ?? 0, e.kind, e.detail))}`);
778
+ }
779
+ console.log(` ${c.dim(t.where.conflictFallback())}`);
780
+ }
781
+ else if (detection.provider === null) {
782
+ console.log(` ${c.dim(t.where.nothingFound())}`);
783
+ }
784
+ else {
785
+ const model = detection.model ? getModel(detection.model) : null;
786
+ console.log(` ${detection.provider}${model ? ` · ${model.displayName}` : c.dim(t.where.providerOnly())}`);
787
+ for (const e of detection.evidence.slice(0, 3)) {
788
+ console.log(` ${c.dim(t.where.evidenceLine(e.line ?? 0, e.kind, e.detail))}`);
789
+ }
790
+ }
791
+ // What would actually be used, which is the question behind the question.
792
+ // Flags beat config, config beats detection, detection beats the default —
793
+ // and a reader deciding whether to pass --model needs to see which won.
794
+ //
795
+ // Knowing the provider but not the model is the common case: an import names
796
+ // who, never which. Falling through to the built-in default there would print
797
+ // "goes to openai" and "priced as Claude Opus 5" three lines apart, which is
798
+ // the wrong number this command exists to catch, produced by the command
799
+ // itself. A provider's own default is a guess, but it is a guess about which
800
+ // of their models rather than about whose.
801
+ const configured = config.usage?.model;
802
+ const detected = detection.model ??
803
+ (detection.provider !== null ? defaultModelFor(detection.provider, pricing) : null);
804
+ const effective = configured ?? detected ?? DEFAULT_USAGE.model;
805
+ const reason = configured
806
+ ? t.where.fromConfig()
807
+ : detection.model
808
+ ? t.where.fromDetection()
809
+ : detected
810
+ ? t.where.fromProviderDefault(detection.provider ?? '')
811
+ : t.where.fromDefault();
812
+ console.log();
813
+ console.log(c.bold(t.where.pricedAs()));
814
+ console.log(` ${getModel(effective).displayName} ${c.dim(reason)}`);
815
+ console.log();
816
+ }
817
+ function commandModels(t, pricing) {
818
+ const n = (value) => value.toLocaleString(t.numberLocale);
819
+ const col = t.models.columns;
820
+ console.log();
821
+ console.log(c.bold(t.models.title()) + c.dim(t.models.unit()));
822
+ console.log(c.dim(t.models.reviewedOn(pricing.lastReviewed, reviewAgeDays(pricing.lastReviewed, new Date()))));
823
+ console.log();
824
+ const rows = pricing.models.map((m) => ({
825
+ id: m.id,
826
+ input: m.promo ? `${m.promo.inputPerMTok} (→${m.inputPerMTok})` : String(m.inputPerMTok),
827
+ output: m.promo ? `${m.promo.outputPerMTok} (→${m.outputPerMTok})` : String(m.outputPerMTok),
828
+ context: `${n(m.contextWindow / 1000)}K`,
829
+ // An unknown minimum prints as a dash, not as zero. Zero is a claim —
830
+ // "caches from the first token" — and it is the wrong one.
831
+ cache: m.cacheMinTokens === null ? '—' : n(m.cacheMinTokens),
832
+ }));
833
+ const widths = {
834
+ id: Math.max(...rows.map((r) => r.id.length), col.model.length),
835
+ input: Math.max(...rows.map((r) => r.input.length), col.input.length),
836
+ output: Math.max(...rows.map((r) => r.output.length), col.output.length),
837
+ context: Math.max(...rows.map((r) => r.context.length), col.context.length),
838
+ cache: Math.max(...rows.map((r) => r.cache.length), col.cacheMin.length),
839
+ };
840
+ console.log(c.bold(` ${col.model.padEnd(widths.id)} ${col.input.padStart(widths.input)} ` +
841
+ `${col.output.padStart(widths.output)} ${col.context.padStart(widths.context)} ` +
842
+ `${col.cacheMin.padStart(widths.cache)}`));
843
+ for (const row of rows) {
844
+ console.log(` ${row.id.padEnd(widths.id)} ${row.input.padStart(widths.input)} ` +
845
+ `${row.output.padStart(widths.output)} ${row.context.padStart(widths.context)} ` +
846
+ `${row.cache.padStart(widths.cache)}`);
847
+ }
848
+ console.log();
849
+ console.log(c.dim(t.models.promoNote()));
850
+ console.log(c.dim(t.models.cacheNote()));
851
+ console.log(c.dim(t.models.batchNote()));
852
+ console.log();
853
+ }
854
+ function commandRules(t, locale) {
855
+ // Rule copy lives in the core catalogue, so `trazum rules` and the report
856
+ // never drift apart.
857
+ const copy = getMessages(locale).rules;
858
+ console.log();
859
+ console.log(c.bold(t.rules.title()));
860
+ console.log(c.dim(t.rules.disableHint()));
861
+ console.log();
862
+ for (const rule of RULES) {
863
+ const tag = rule.level === 'aggressive'
864
+ ? c.yellow(t.report.levelAggressive())
865
+ : c.dim(t.report.levelSafe());
866
+ console.log(` ${tag} ${c.bold(rule.id)} — ${copy[rule.id].title}`);
867
+ console.log(` ${c.dim(wrap(copy[rule.id].rationale, 74, ' '))}`);
868
+ console.log();
869
+ }
870
+ }
871
+ async function readInput(source, t) {
872
+ if (!source) {
873
+ throw new Error(t.errors.missingInputFile());
874
+ }
875
+ if (source === '-') {
876
+ const chunks = [];
877
+ for await (const chunk of process.stdin)
878
+ chunks.push(Buffer.from(chunk));
879
+ return Buffer.concat(chunks).toString('utf8');
880
+ }
881
+ return readFile(source, 'utf8');
882
+ }
883
+ async function commandOptimize(args, config, pricing, t, locale) {
884
+ const target = args.positional[0];
885
+ const raw = await readInput(target, t);
886
+ const level = levelFlag(args, config, t);
887
+ // A source file is not a prompt.
888
+ //
889
+ // Handed `src/prompts.ts`, this used to optimise the whole file — imports,
890
+ // `const client = new OpenAI();`, all of it — count the code as tokens the
891
+ // model would pay for, and then **rewrite the source**: the capitalisation
892
+ // rule turned `import OpenAI` into `Import OpenAI`, which does not compile.
893
+ // Writing that back over somebody's file is the worst thing in this
894
+ // repository's history, and it was the default behaviour.
895
+ const source = target !== undefined && target !== '-'
896
+ ? sourceFileOf(target, raw, pricing, stringFlag(args, 'prompt'))
897
+ : null;
898
+ const original = source ? source.text : raw;
899
+ // Detection sits between config and defaults, as everywhere: a flag beats
900
+ // config, config beats what the code says, and what the code says beats a
901
+ // built-in default that has no idea which provider you use.
902
+ const usage = usageFrom(args, config, t, source?.model);
903
+ const disableRules = disabledRules(args, config) ?? [];
904
+ for (const id of disableRules) {
905
+ if (!RULES.some((r) => r.id === id)) {
906
+ throw new Error(t.errors.unknownRuleInDisable(id));
907
+ }
908
+ }
909
+ // Reordering runs BEFORE the rules, and is opt-in.
910
+ //
911
+ // Before, because a rule that deletes a sentence changes which blocks exist;
912
+ // reordering first means the rearrangement is decided on the prompt the author
913
+ // wrote, which is the one they will review it against.
914
+ //
915
+ // Opt-in, and not part of `aggressive`, because every other transformation
916
+ // here deletes text whose absence is local while this one moves text, and
917
+ // order carries meaning. `aggressive` promises "read the diff"; this needs
918
+ // "decide whether the order mattered", which is a different question.
919
+ const reorder = boolFlag(args, 'reorder') ? reorderForCache(original, {
920
+ // A prefix below the model's cacheable minimum caches nothing at all, so a
921
+ // rearrangement that does not get it over the line buys nothing and there is
922
+ // no reason to hand the author a diff for it.
923
+ //
924
+ // `undefined` when the catalogue does not know the minimum, which `reorder`
925
+ // reads as "no floor to clear". That is the right way to be wrong here: the
926
+ // author asked for the rearrangement explicitly, and withholding it on a
927
+ // guess about a threshold nobody knows would be refusing to do the thing
928
+ // they asked for on no evidence.
929
+ minPrefixTokens: getModel(usage.model).cacheMinTokens ?? undefined,
930
+ }) : null;
931
+ const prompt = reorder?.text ?? original;
932
+ let result = optimize(prompt, {
933
+ level,
934
+ usage,
935
+ locale,
936
+ disableRules,
937
+ pricing,
938
+ });
939
+ // The diff has to show the move. Optimising the reordered text means
940
+ // `result.original` is the rearrangement, so a diff against it would show only
941
+ // the deletions — and hide the one change the report just told you to review.
942
+ if (reorder !== null && reorder.moved.length > 0) {
943
+ result = { ...result, original };
944
+ }
945
+ let examplesReview = null;
946
+ let suggestions = null;
947
+ // A flag that quietly does nothing is the same failure as a typo'd flag being
948
+ // accepted, which this CLI already refuses.
949
+ if (boolFlag(args, 'apply-suggestions') && !boolFlag(args, 'suggest')) {
950
+ throw new Error(t.errors.applyNeedsSuggest());
951
+ }
952
+ if (boolFlag(args, 'suggest')) {
953
+ const base = providerFromEnv();
954
+ if (!base)
955
+ throw new Error(t.errors.llmNotConfigured());
956
+ /**
957
+ * Opt-in, like everything else here that touches a model.
958
+ *
959
+ * A cache hit returns what the model said last time, and a model is not a
960
+ * pure function — answering from a week-old response without being asked
961
+ * would be a surprise in a tool that already makes you opt in twice to let
962
+ * one edit your prompt.
963
+ *
964
+ * The saving is not the API's prompt-caching discount, which cannot apply:
965
+ * the only stable prefix is a 291-token system prompt, below every model's
966
+ * minimum cacheable prefix, so marking it would silently cache nothing.
967
+ * See `suggest-cache.ts`.
968
+ */
969
+ const cached = boolFlag(args, 'cache-suggestions')
970
+ ? cachingProvider(base, { dir: cacheDir() })
971
+ : null;
972
+ const provider = cached ?? base;
973
+ // On the deterministic result rather than the text as written: the rules
974
+ // have already taken the easy wins, and asking the model to find them again
975
+ // spends a call to be told what Trazum knew for free.
976
+ suggestions = await suggestRewrites(result.optimized, provider, { locale });
977
+ // Said out loud, on stderr so it never lands in `--json`. A cache hit
978
+ // returns last week's answer, and a reader who does not know that will
979
+ // wonder why the model stopped noticing a phrase they just added.
980
+ if (cached)
981
+ console.error(t.cache.used(cached.hits, cached.misses));
982
+ // Opt in twice, deliberately. Listing is safe — nothing changes and the
983
+ // author reads eight one-line proposals. Applying is a model editing their
984
+ // prompt, which is the same class of act as `--reorder` and gets the same
985
+ // treatment: it does not happen because you asked to look.
986
+ if (boolFlag(args, 'apply-suggestions') && suggestions.suggestions.length > 0) {
987
+ const rewritten = applyRewrites(result.optimized, suggestions.suggestions);
988
+ result = {
989
+ ...result,
990
+ optimized: rewritten,
991
+ tokensAfter: estimateTokens(rewritten),
992
+ };
993
+ result = {
994
+ ...result,
995
+ tokensSaved: result.tokensBefore - result.tokensAfter,
996
+ reductionPct: result.tokensBefore > 0
997
+ ? ((result.tokensBefore - result.tokensAfter) / result.tokensBefore) * 100
998
+ : 0,
999
+ savings: computeSavings(result.tokensBefore, result.tokensAfter, result.usage, new Date(), pricing),
1000
+ };
1001
+ }
1002
+ }
1003
+ if (boolFlag(args, 'llm')) {
1004
+ const provider = providerFromEnv();
1005
+ if (!provider) {
1006
+ throw new Error(t.errors.llmNotConfigured());
1007
+ }
1008
+ result = await refineWithLlm(result, provider, { locale });
1009
+ // A second call, and only when there is something for it to judge:
1010
+ // `reviewExamples` returns null below two examples rather than paying for
1011
+ // a foregone answer. This is the paraphrase case the deterministic
1012
+ // detector refuses to guess at.
1013
+ examplesReview = await reviewExamples(result.optimized, provider);
1014
+ }
1015
+ if (boolFlag(args, 'exact-tokens')) {
1016
+ const apiKey = process.env.ANTHROPIC_API_KEY;
1017
+ if (!apiKey) {
1018
+ throw new Error(t.errors.exactTokensNeedsKey());
1019
+ }
1020
+ result = await withExactTokenCounts(result, countTokensAnthropic({ apiKey, model: result.usage.model }), pricing);
1021
+ }
1022
+ const outPath = stringFlag(args, 'out');
1023
+ if (outPath) {
1024
+ await writeFile(outPath, result.optimized, 'utf8');
1025
+ }
1026
+ if (boolFlag(args, 'json')) {
1027
+ // `reorder` goes in whenever the flag was passed, including when nothing
1028
+ // moved. A consumer reading `optimized` is reading text the author did not
1029
+ // write in that order, and it must not have to infer that from the diff.
1030
+ console.log(JSON.stringify({
1031
+ ...result,
1032
+ ...(examplesReview ? { examplesReview } : {}),
1033
+ ...(reorder ? { reorder } : {}),
1034
+ // Present whenever --suggest was passed, applied or not: a consumer
1035
+ // needs to tell "nothing was proposed" from "proposals are waiting".
1036
+ ...(suggestions
1037
+ ? { suggestions: { ...suggestions, applied: boolFlag(args, 'apply-suggestions') } }
1038
+ : {}),
1039
+ }, null, 2));
1040
+ return;
1041
+ }
1042
+ if (!process.stdout.isTTY && !outPath) {
1043
+ // Redirected to a file or another process: the prompt alone, no chrome.
1044
+ process.stdout.write(result.optimized);
1045
+ // Except that a rearrangement is not chrome. Everything else this command
1046
+ // does is a deletion the diff will show; `--reorder` moves text, and piping
1047
+ // it made both the move and the refusals invisible — which is the one thing
1048
+ // this module promises not to do. One line, on stderr, so the pipe carries
1049
+ // the prompt and nothing else.
1050
+ if (reorder !== null) {
1051
+ console.error(t.report.reorderPiped(reorder.moved.length, reorder.tokensMoved.toLocaleString(t.numberLocale), reorder.declined.length));
1052
+ }
1053
+ return;
1054
+ }
1055
+ // Tokens-only when the host bills by subscription: there is no bill to
1056
+ // reduce, so a monthly figure would be arithmetic about tokens dressed as
1057
+ // money. Either flag overrides it, because the host says where *Trazum* runs
1058
+ // and not where the prompt goes — somebody editing a production prompt inside
1059
+ // Cursor wants the dollars, and they should not have to leave the editor to
1060
+ // see them.
1061
+ const host = detectHost();
1062
+ const tokensOnly = boolFlag(args, 'cost')
1063
+ ? false
1064
+ : boolFlag(args, 'tokens-only') || host.billing === 'subscription';
1065
+ printReport(result, boolFlag(args, 'diff'), t, examplesReview, reorder, tokensOnly, host, suggestions
1066
+ ? { result: suggestions, applied: boolFlag(args, 'apply-suggestions'), locale }
1067
+ : null);
1068
+ if (outPath) {
1069
+ console.log(c.dim(t.report.wroteTo(outPath)));
1070
+ console.log();
1071
+ }
1072
+ }
1073
+ function counterFor(args, t) {
1074
+ if (!boolFlag(args, 'exact-tokens')) {
1075
+ return { count: (text) => Promise.resolve(estimateTokens(text)), source: 'heuristic' };
1076
+ }
1077
+ const apiKey = process.env.ANTHROPIC_API_KEY;
1078
+ if (!apiKey)
1079
+ throw new Error(t.errors.exactTokensNeedsKey());
1080
+ const exact = countTokensAnthropic({ apiKey });
1081
+ return { count: (text) => exact(text), source: 'external' };
1082
+ }
1083
+ async function judgeFile(path, text, budget, counter, level, locale, pricing) {
1084
+ const tokens = await counter.count(text);
1085
+ const maxTokens = budget?.maxTokens ?? null;
1086
+ // Over budget: work out whether optimising would be enough, so the CI failure
1087
+ // carries a concrete next step instead of just a red number. Only for the
1088
+ // files that failed — optimising all of them would triple the work of a
1089
+ // directory run that is fine.
1090
+ let optimizedTokens = null;
1091
+ if (maxTokens !== null && tokens > maxTokens) {
1092
+ optimizedTokens = await counter.count(optimize(text, { level, locale, pricing }).optimized);
1093
+ }
1094
+ return { path, tokens, maxTokens, pattern: budget?.pattern ?? null, optimizedTokens };
1095
+ }
1096
+ const isOverBudget = (v) => v.maxTokens !== null && v.tokens > v.maxTokens;
1097
+ /**
1098
+ * Token budget for CI: fails (exit code 1) when a prompt busts its budget, so a
1099
+ * template that grows unchecked breaks the build, not the bill.
1100
+ *
1101
+ * Given a directory it checks every prompt inside it against the budgets in
1102
+ * `trazum.config.json`, which is what makes a repository of prompts governable
1103
+ * as a whole rather than one CI step per file.
1104
+ */
1105
+ async function commandCheck(args, config, pricing, t, locale) {
1106
+ const target = args.positional[0];
1107
+ const level = levelFlag(args, config, t);
1108
+ const counter = counterFor(args, t);
1109
+ // A flag beats the config, as everywhere else. -1 means "not given".
1110
+ const flagBudget = numberFlag(args, 'max-tokens', -1, t);
1111
+ const asDirectory = target !== undefined && target !== '-' ? await isDirectory(target) : false;
1112
+ if (asDirectory) {
1113
+ await checkDirectory(target, args, flagBudget, config, counter, level, t, locale, pricing);
1114
+ return;
1115
+ }
1116
+ const prompt = await readInput(target, t);
1117
+ const n = (value) => value.toLocaleString(t.numberLocale);
1118
+ // A single file falls back to the config budget for its own path, so
1119
+ // `trazum check prompts/system.txt` works with no flag once budgets exist.
1120
+ const configBudget = target ? budgetFor(target, config.budgets) : null;
1121
+ const maxTokens = flagBudget >= 0 ? flagBudget : (configBudget?.maxTokens ?? -1);
1122
+ if (maxTokens < 0)
1123
+ throw new Error(t.errors.checkNeedsMaxTokens());
1124
+ // A source file carrying markers is not one prompt, it is several. Budgeting
1125
+ // the whole file would measure the code around them, which is not what the
1126
+ // author asked to govern.
1127
+ const embedded = target && target !== '-' && hasMarker(prompt) ? extractPrompts(prompt) : null;
1128
+ if (embedded !== null && (embedded.prompts.length > 0 || embedded.declined.length > 0)) {
1129
+ await checkEmbedded(target, embedded, { maxTokens, pattern: flagBudget >= 0 ? null : (configBudget?.pattern ?? null) }, args, counter, level, t, locale, pricing);
1130
+ return;
1131
+ }
1132
+ const verdict = await judgeFile(target ?? '-', prompt, { maxTokens, pattern: flagBudget >= 0 ? null : (configBudget?.pattern ?? null) }, counter, level, locale, pricing);
1133
+ const ok = !isOverBudget(verdict);
1134
+ // Written before anything can exit, and independently of --json, because the
1135
+ // whole point of the file is to survive a run that failed.
1136
+ await writeMarkdown(args, () => renderCheckMarkdown({
1137
+ target: target ?? '-',
1138
+ verdicts: [verdict],
1139
+ level,
1140
+ tokenSource: counter.source,
1141
+ truncated: false,
1142
+ t,
1143
+ }));
1144
+ if (boolFlag(args, 'json')) {
1145
+ console.log(JSON.stringify({
1146
+ ok,
1147
+ tokens: verdict.tokens,
1148
+ maxTokens,
1149
+ budgetPattern: verdict.pattern,
1150
+ tokenSource: counter.source,
1151
+ optimizedTokens: verdict.optimizedTokens,
1152
+ wouldFitOptimized: verdict.optimizedTokens !== null ? verdict.optimizedTokens <= maxTokens : null,
1153
+ }));
1154
+ }
1155
+ else if (ok) {
1156
+ console.log(`${c.green(t.check.okLabel())} ${t.check.ok(n(verdict.tokens), n(maxTokens))}`);
1157
+ }
1158
+ else {
1159
+ console.error(`${c.red(t.check.failedLabel())} ${t.check.failed(n(verdict.tokens), n(maxTokens))}`);
1160
+ if (verdict.optimizedTokens !== null) {
1161
+ console.error(verdict.optimizedTokens <= maxTokens
1162
+ ? t.check.wouldFit(level, n(verdict.optimizedTokens))
1163
+ : t.check.stillTooBig(n(verdict.optimizedTokens)));
1164
+ }
1165
+ }
1166
+ if (!ok)
1167
+ process.exitCode = 1;
1168
+ }
1169
+ async function isDirectory(path) {
1170
+ return stat(path)
1171
+ .then((info) => info.isDirectory())
1172
+ .catch(() => false);
1173
+ }
1174
+ /**
1175
+ * Writes the markdown report, if one was asked for.
1176
+ *
1177
+ * Takes a thunk so a run without `--markdown-out` never pays to render it, and
1178
+ * is called before any `process.exitCode` is set: a report that only appears
1179
+ * when the check passed is a report nobody needs.
1180
+ *
1181
+ * A failure to write is reported and swallowed. The exit code belongs to the
1182
+ * budget, not to the reporting — a full disk on a CI runner must not turn a
1183
+ * passing check into a failing build, and it must certainly not turn a failing
1184
+ * one into a confusing one.
1185
+ */
1186
+ async function writeMarkdown(args, render) {
1187
+ const path = stringFlag(args, 'markdown-out');
1188
+ if (!path)
1189
+ return;
1190
+ try {
1191
+ const body = fitWithin(render(), MAX_SUMMARY_CHARS, '\n_Trimmed: the report is larger than a step summary can hold._');
1192
+ await writeFile(path, `${body}\n`, 'utf8');
1193
+ }
1194
+ catch (error) {
1195
+ const message = error instanceof Error ? error.message : String(error);
1196
+ console.error(c.yellow(`Could not write ${path}: ${message}`));
1197
+ }
1198
+ }
1199
+ /**
1200
+ * Writes the OTLP payload, if one was asked for.
1201
+ *
1202
+ * Same shape and same posture as `writeMarkdown`: a thunk so a run without the
1203
+ * flag never pays to build it, and a write failure is reported and swallowed. A
1204
+ * full disk on a metrics runner must not turn a survey into a failure — the
1205
+ * survey is the thing somebody asked for, and the metrics are a copy of it.
1206
+ */
1207
+ async function writeOtlp(args, build) {
1208
+ const path = stringFlag(args, 'otlp-out');
1209
+ if (!path)
1210
+ return;
1211
+ try {
1212
+ await writeFile(path, `${JSON.stringify(build(), null, 2)}\n`, 'utf8');
1213
+ }
1214
+ catch (error) {
1215
+ const message = error instanceof Error ? error.message : String(error);
1216
+ console.error(c.yellow(`Could not write ${path}: ${message}`));
1217
+ }
1218
+ }
1219
+ /**
1220
+ * Checks every prompt under a directory.
1221
+ *
1222
+ * Two decisions worth naming. **A file with no budget is listed, not hidden**:
1223
+ * silently skipping it would let a prompt sit outside every pattern for months
1224
+ * while the report says everything is fine. And **finding no budget at all is
1225
+ * an error**, because "checked 40 files, 0 failures" from a run that measured
1226
+ * nothing is the most misleading output this tool could produce.
1227
+ */
1228
+ /**
1229
+ * Budgets each prompt marked inside a source file.
1230
+ *
1231
+ * The budget applies per prompt, not to the file: a file holding four prompts is
1232
+ * four things to govern, and summing them would fail a build because somebody
1233
+ * added a fifth short one.
1234
+ *
1235
+ * Declined markers are reported before the verdicts and are **a failure**, not a
1236
+ * note. The author marked a prompt to have it governed; if Trazum cannot read it
1237
+ * then it is not governed, and a green build saying otherwise is the same lie as
1238
+ * "0 failures" from a run that measured nothing.
1239
+ */
1240
+ async function checkEmbedded(path, extraction, budget, args, counter, level, t, locale, pricing) {
1241
+ const n = (value) => value.toLocaleString(t.numberLocale);
1242
+ const verdicts = [];
1243
+ for (const prompt of extraction.prompts) {
1244
+ verdicts.push(await judgeFile(promptId(path, prompt), prompt.text, budget, counter, level, locale, pricing));
1245
+ }
1246
+ const failures = verdicts.filter(isOverBudget);
1247
+ const ok = failures.length === 0 && extraction.declined.length === 0;
1248
+ await writeMarkdown(args, () => renderCheckMarkdown({
1249
+ target: path,
1250
+ verdicts,
1251
+ level,
1252
+ tokenSource: counter.source,
1253
+ truncated: false,
1254
+ t,
1255
+ }));
1256
+ if (boolFlag(args, 'json')) {
1257
+ console.log(JSON.stringify({
1258
+ ok,
1259
+ target: path,
1260
+ embedded: true,
1261
+ prompts: verdicts.map((v) => ({
1262
+ id: v.path,
1263
+ tokens: v.tokens,
1264
+ maxTokens: v.maxTokens,
1265
+ ok: !isOverBudget(v),
1266
+ })),
1267
+ declined: extraction.declined,
1268
+ }, null, 2));
1269
+ if (!ok)
1270
+ process.exitCode = 1;
1271
+ return;
1272
+ }
1273
+ console.log();
1274
+ console.log(c.bold(t.check.embeddedHeading(path, extraction.prompts.length)));
1275
+ for (const verdict of verdicts) {
1276
+ const over = isOverBudget(verdict);
1277
+ const label = over ? c.red(t.check.failedLabel()) : c.green(t.check.okLabel());
1278
+ console.log(` ${label} ${verdict.path} — ${n(verdict.tokens)}` +
1279
+ (verdict.maxTokens === null ? '' : ` / ${n(verdict.maxTokens)}`));
1280
+ }
1281
+ if (extraction.declined.length > 0) {
1282
+ console.log();
1283
+ console.log(c.red(t.check.declinedHeading(extraction.declined.length)));
1284
+ for (const declined of extraction.declined) {
1285
+ console.log(` ${c.dim(t.check.declinedAt(declined.line, declined.detail))}`);
1286
+ }
1287
+ }
1288
+ console.log();
1289
+ if (!ok)
1290
+ process.exitCode = 1;
1291
+ }
1292
+ /**
1293
+ * The scenario a baseline is recorded under, and the money it implies.
1294
+ *
1295
+ * Shared by `baseline` and the gate so both compute the monthly figure the same
1296
+ * way. `computeSavings` is asked for a before/after where both sides are the
1297
+ * same token count, because what is wanted here is the cost of a total, not a
1298
+ * saving — `perMonth.before.totalUsd` is that number.
1299
+ */
1300
+ function monthlyCostOf(tokens, usage, pricing) {
1301
+ return computeSavings(tokens, tokens, usage, new Date(), pricing).perMonth.before.totalUsd;
1302
+ }
1303
+ /** Today, as the ISO date a baseline records. */
1304
+ function isoDate() {
1305
+ return new Date().toISOString().slice(0, 10);
1306
+ }
1307
+ /**
1308
+ * `trazum baseline <dir>` — record what the estate costs now.
1309
+ *
1310
+ * Writes the file and says what to do with it. It never gates: recording is not
1311
+ * a verdict, and a command that could fail while writing the thing you would fix
1312
+ * the failure with is a loop.
1313
+ */
1314
+ async function commandBaseline(args, config, pricing, t, locale) {
1315
+ const root = args.positional[0] ?? '.';
1316
+ const n = (value) => value.toLocaleString(t.numberLocale);
1317
+ const counter = counterFor(args, t);
1318
+ const usage = usageFrom(args, config, t);
1319
+ // `level` is irrelevant to a baseline — it records what the prompts cost as
1320
+ // written, not what they would cost optimised — but `scanPrompts` wants one
1321
+ // for the advisory second pass that only runs on an over-budget file. Nothing
1322
+ // here is over budget, because nothing here has a budget.
1323
+ const { verdicts } = await scanPrompts(root, args, -1, config, counter, 'safe', t, locale, pricing);
1324
+ const files = {};
1325
+ for (const verdict of verdicts)
1326
+ files[verdict.path] = verdict.tokens;
1327
+ const tokens = Object.values(files).reduce((a, b) => a + b, 0);
1328
+ const document = {
1329
+ version: BASELINE_VERSION,
1330
+ recorded: isoDate(),
1331
+ scenario: usage,
1332
+ pricingReviewed: pricing.lastReviewed,
1333
+ totals: { tokens, monthlyUsd: monthlyCostOf(tokens, usage, pricing) },
1334
+ files,
1335
+ };
1336
+ const out = stringFlag(args, 'out') ?? stringFlag(args, 'o') ?? config.baseline?.path ?? BASELINE_FILENAME;
1337
+ await writeFile(out, formatBaseline(document), 'utf8');
1338
+ if (boolFlag(args, 'json')) {
1339
+ console.log(JSON.stringify({ path: out, files: verdicts.length, ...document.totals }));
1340
+ return;
1341
+ }
1342
+ console.log(`\n${c.green(t.baseline.recorded(out, n(verdicts.length), n(tokens)))}`);
1343
+ console.log(c.dim(t.baseline.recordedMoney(formatUsd(document.totals.monthlyUsd), usage.model, n(usage.callsPerMonth))));
1344
+ console.log();
1345
+ }
1346
+ /**
1347
+ * Reports a directory against its baseline, and returns whether it passed.
1348
+ *
1349
+ * Returns rather than exiting, so the caller decides how a breach combines with
1350
+ * a busted budget — they are two independent verdicts about the same run and
1351
+ * either one failing has to fail the build.
1352
+ */
1353
+ function reportBaseline(comparison, breached, baseline, path, usage, pricing, t) {
1354
+ const n = (value) => value.toLocaleString(t.numberLocale);
1355
+ const pct = (value) => `${value > 0 ? '+' : ''}${value.toFixed(1)}%`;
1356
+ const signed = (value) => `${value > 0 ? '+' : ''}${n(value)}`;
1357
+ console.log(` ${c.bold(t.baseline.heading())}`);
1358
+ const headline = comparison.delta === 0
1359
+ ? c.dim(t.baseline.unchanged(n(comparison.tokensAfter)))
1360
+ : comparison.delta > 0
1361
+ ? t.baseline.grew(n(comparison.delta), pct(comparison.deltaPct), n(comparison.tokensAfter))
1362
+ : t.baseline.shrank(n(-comparison.delta), pct(comparison.deltaPct), n(comparison.tokensAfter));
1363
+ console.log(` ${breached.length > 0 ? c.red(headline) : comparison.delta < 0 ? c.green(headline) : headline}`);
1364
+ // Only the directions that cost money are itemised. A list of everything that
1365
+ // shrank is a list nobody acts on, and it buries the two lines that matter.
1366
+ for (const [heading, changes] of [
1367
+ [t.baseline.grownHeading(comparison.grown.length), comparison.grown],
1368
+ [t.baseline.addedHeading(comparison.added.length), comparison.added],
1369
+ [t.baseline.removedHeading(comparison.removed.length), comparison.removed],
1370
+ ]) {
1371
+ if (changes.length === 0)
1372
+ continue;
1373
+ console.log(` ${c.dim(heading)}`);
1374
+ for (const change of changes) {
1375
+ console.log(` ${t.baseline.entry(change.path, n(change.before), n(change.after), signed(change.delta))}`);
1376
+ }
1377
+ }
1378
+ const money = moneyIsComparable(baseline, usage, pricing.lastReviewed);
1379
+ const now = monthlyCostOf(comparison.tokensAfter, usage, pricing);
1380
+ if (money.comparable) {
1381
+ console.log(` ${t.baseline.money(formatUsd(baseline.totals.monthlyUsd), formatUsd(now), formatSignedUsd(now - baseline.totals.monthlyUsd))}`);
1382
+ }
1383
+ else {
1384
+ // Two different measurements are not subtracted. Saying which one moved is
1385
+ // more use than a delta that means nothing.
1386
+ console.log(` ${c.yellow(money.pricingChanged
1387
+ ? t.baseline.moneyIncomparablePricing(baseline.pricingReviewed, pricing.lastReviewed)
1388
+ : t.baseline.moneyIncomparableScenario())}`);
1389
+ }
1390
+ for (const breach of breached) {
1391
+ console.log(` ${c.red(breach.kind === 'tokens'
1392
+ ? t.baseline.breachTokens(n(breach.actual), n(breach.limit))
1393
+ : t.baseline.breachPct(pct(breach.actual), `${breach.limit}%`))}`);
1394
+ }
1395
+ if (breached.length > 0)
1396
+ console.log(` ${c.dim(t.baseline.reRecord(path))}`);
1397
+ }
1398
+ /**
1399
+ * Walks a directory and counts every prompt in it.
1400
+ *
1401
+ * Extracted from `checkDirectory` when `baseline` arrived, because the two
1402
+ * commands have to agree about what a prompt is down to the last token. Two
1403
+ * walks would be two definitions of the estate — a marker convention read one
1404
+ * way here and another way there — and the baseline would then be a record of
1405
+ * files the gate does not check. One walk, one answer, and the budget resolution
1406
+ * comes along for free so `check` still sees exactly what it always did.
1407
+ */
1408
+ async function scanPrompts(root, args, flagBudget, config, counter, level, t, locale, pricing) {
1409
+ // Source files are walked alongside prompt files rather than opted into.
1410
+ // Requiring config to discover a marker somebody just wrote is how `eval` came
1411
+ // to be fully implemented and completely undiscoverable; an unmarked source
1412
+ // file costs one `includes()` and is dropped.
1413
+ const extensions = config.extensions ?? [...DEFAULT_EXTENSIONS, ...SOURCE_EXTENSIONS];
1414
+ const { files, truncated } = await walkPrompts(root, { extensions });
1415
+ if (files.length === 0) {
1416
+ throw new Error(t.errors.noPromptsFound(root, extensions.join(' ')));
1417
+ }
1418
+ // --exact-tokens over a directory is one API round trip per file, and another
1419
+ // for each file that fails. `eval` prints its call count before spending
1420
+ // anything for the same reason: a command that looks hung gets killed, and
1421
+ // then nobody trusts it again.
1422
+ if (counter.source === 'external' && !boolFlag(args, 'json')) {
1423
+ console.log(c.dim(t.check.exactCountsCost(files.length)));
1424
+ }
1425
+ const verdicts = [];
1426
+ const declined = [];
1427
+ for (const relativePath of files) {
1428
+ const text = await readFile(join(root, relativePath), 'utf8');
1429
+ // Budgets are keyed on paths as written in the repository, so a pattern like
1430
+ // `prompts/**` has to be matched against the path including the root the
1431
+ // user passed — not against the name relative to it.
1432
+ const keyed = joinPosix(root, relativePath);
1433
+ const fromConfig = budgetFor(keyed, config.budgets);
1434
+ const budget = fromConfig ?? (flagBudget >= 0 ? { maxTokens: flagBudget, pattern: null } : null);
1435
+ const isSource = SOURCE_EXTENSIONS.some((ext) => relativePath.toLowerCase().endsWith(ext));
1436
+ if (isSource) {
1437
+ // A source file is only a prompt file if it says so. One that does not is
1438
+ // dropped silently — it was never something the author asked to govern,
1439
+ // and listing it as unbudgeted would bury the files that are.
1440
+ if (!hasMarker(text))
1441
+ continue;
1442
+ const extraction = extractPrompts(text);
1443
+ for (const prompt of extraction.prompts) {
1444
+ const id = promptId(keyed, prompt);
1445
+ const own = budgetFor(id, config.budgets) ?? budget;
1446
+ verdicts.push(await judgeFile(id, prompt.text, own, counter, level, locale, pricing));
1447
+ }
1448
+ for (const entry of extraction.declined) {
1449
+ declined.push({ path: keyed, line: entry.line, detail: entry.detail });
1450
+ }
1451
+ continue;
1452
+ }
1453
+ verdicts.push(await judgeFile(keyed, text, budget, counter, level, locale, pricing));
1454
+ }
1455
+ if (verdicts.length === 0 && declined.length === 0) {
1456
+ throw new Error(t.errors.noPromptsFound(root, extensions.join(' ')));
1457
+ }
1458
+ return { verdicts, declined, truncated, extensions };
1459
+ }
1460
+ async function checkDirectory(root, args, flagBudget, config, counter, level, t, locale, pricing) {
1461
+ const n = (value) => value.toLocaleString(t.numberLocale);
1462
+ const { verdicts, declined, truncated, extensions } = await scanPrompts(root, args, flagBudget, config, counter, level, t, locale, pricing);
1463
+ /**
1464
+ * The baseline gate, when the config declares one and `--no-baseline` did not
1465
+ * switch it off for this run.
1466
+ *
1467
+ * Read before the budget verdict is reported so a missing or malformed file
1468
+ * fails the run loudly rather than after a green summary. A gate the config
1469
+ * asked for and could not run is not a pass: that is the whole reason
1470
+ * `parseBaseline` throws on everything.
1471
+ */
1472
+ const wantsBaseline = config.baseline !== undefined && boolFlag(args, 'baseline', true);
1473
+ let baselineOutcome = null;
1474
+ if (wantsBaseline) {
1475
+ const path = config.baseline.path;
1476
+ let raw;
1477
+ try {
1478
+ raw = await readFile(path, 'utf8');
1479
+ }
1480
+ catch {
1481
+ throw new Error(t.errors.baselineMissing(path));
1482
+ }
1483
+ if (Buffer.byteLength(raw) > MAX_BASELINE_BYTES) {
1484
+ throw new Error(t.errors.baselineTooBig(path, MAX_BASELINE_BYTES));
1485
+ }
1486
+ const document = parseBaseline(raw, path);
1487
+ const current = {};
1488
+ for (const verdict of verdicts)
1489
+ current[verdict.path] = verdict.tokens;
1490
+ const comparison = compareToBaseline(document, current);
1491
+ baselineOutcome = {
1492
+ comparison,
1493
+ breached: breaches(comparison, config.baseline),
1494
+ document,
1495
+ path,
1496
+ usage: usageFrom(args, config, t),
1497
+ };
1498
+ }
1499
+ // A budget ceiling is no longer the only thing that can govern a directory: a
1500
+ // baseline governs it too, and a repository using only a baseline is not an
1501
+ // unmeasured one. Without this, adopting `baseline` alone would fail every run
1502
+ // with "no budget covers anything here".
1503
+ if (!wantsBaseline && verdicts.every((v) => v.maxTokens === null)) {
1504
+ throw new Error(t.errors.noBudgetsApply(root, CONFIG_FILENAME));
1505
+ }
1506
+ const failures = verdicts.filter(isOverBudget);
1507
+ await writeMarkdown(args, () => renderCheckMarkdown({
1508
+ target: root,
1509
+ verdicts,
1510
+ level,
1511
+ tokenSource: counter.source,
1512
+ truncated,
1513
+ // The same outcome the exit code was computed from, so a pull-request
1514
+ // comment and a red build can never disagree about whether the branch got
1515
+ // more expensive.
1516
+ baseline: baselineOutcome
1517
+ ? {
1518
+ comparison: baselineOutcome.comparison,
1519
+ breached: baselineOutcome.breached,
1520
+ money: {
1521
+ before: baselineOutcome.document.totals.monthlyUsd,
1522
+ after: monthlyCostOf(baselineOutcome.comparison.tokensAfter, baselineOutcome.usage, pricing),
1523
+ comparable: moneyIsComparable(baselineOutcome.document, baselineOutcome.usage, pricing.lastReviewed).comparable,
1524
+ },
1525
+ path: baselineOutcome.path,
1526
+ }
1527
+ : undefined,
1528
+ t,
1529
+ }));
1530
+ if (boolFlag(args, 'json')) {
1531
+ console.log(JSON.stringify({
1532
+ ok: failures.length === 0 && declined.length === 0,
1533
+ root,
1534
+ tokenSource: counter.source,
1535
+ truncated,
1536
+ declined,
1537
+ files: verdicts.map((v) => ({
1538
+ path: v.path,
1539
+ tokens: v.tokens,
1540
+ maxTokens: v.maxTokens,
1541
+ budgetPattern: v.pattern,
1542
+ ok: !isOverBudget(v),
1543
+ optimizedTokens: v.optimizedTokens,
1544
+ wouldFitOptimized: v.optimizedTokens !== null && v.maxTokens !== null
1545
+ ? v.optimizedTokens <= v.maxTokens
1546
+ : null,
1547
+ })),
1548
+ }, null, 2));
1549
+ if (failures.length > 0 || declined.length > 0)
1550
+ process.exitCode = 1;
1551
+ return;
1552
+ }
1553
+ // Every column is sized from the whole set before anything is printed, so the
1554
+ // paths line up down the page whatever the locale calls OK and FAILED, and
1555
+ // whether or not a row has a budget. A ragged table is one nobody scans.
1556
+ const labelWidth = Math.max(t.check.okLabel().length, t.check.failedLabel().length) + 2;
1557
+ const tokenWidth = Math.max(...verdicts.map((v) => n(v.tokens).length));
1558
+ const budgetWidth = Math.max(0, ...verdicts.map((v) => (v.maxTokens === null ? 0 : n(v.maxTokens).length)));
1559
+ console.log();
1560
+ console.log(c.bold(t.check.directoryHeading(root, verdicts.length)));
1561
+ console.log();
1562
+ for (const verdict of verdicts) {
1563
+ const tokens = n(verdict.tokens).padStart(tokenWidth);
1564
+ if (verdict.maxTokens === null) {
1565
+ // Blanks where " / <budget>" would be, so the path column does not shift.
1566
+ console.log(` ${c.dim('—'.padEnd(labelWidth))}${tokens}${' '.repeat(3 + budgetWidth)} ` +
1567
+ `${verdict.path} ${c.dim(t.check.noBudget())}`);
1568
+ continue;
1569
+ }
1570
+ const over = isOverBudget(verdict);
1571
+ const plain = over ? t.check.failedLabel() : t.check.okLabel();
1572
+ const label = (over ? c.red(plain) : c.green(plain)) + ' '.repeat(labelWidth - plain.length);
1573
+ const budget = n(verdict.maxTokens).padEnd(budgetWidth);
1574
+ console.log(` ${label}${tokens} / ${budget} ${verdict.path}`);
1575
+ if (over && verdict.optimizedTokens !== null) {
1576
+ console.log(` ${' '.repeat(labelWidth)}${c.dim(verdict.optimizedTokens <= verdict.maxTokens
1577
+ ? t.check.wouldFit(level, n(verdict.optimizedTokens))
1578
+ : t.check.stillTooBig(n(verdict.optimizedTokens)))}`);
1579
+ }
1580
+ }
1581
+ // A marker Trazum could not read is a failure, not a footnote. The author
1582
+ // marked that prompt to have it governed; it is not being governed, and a
1583
+ // green summary alongside would be the same lie as "0 failures" from a run
1584
+ // that measured nothing.
1585
+ if (declined.length > 0) {
1586
+ console.log();
1587
+ console.log(c.red(t.check.declinedHeading(declined.length)));
1588
+ for (const entry of declined) {
1589
+ console.log(` ${c.dim(`${entry.path} ${t.check.declinedAt(entry.line, entry.detail)}`)}`);
1590
+ }
1591
+ }
1592
+ console.log();
1593
+ const summary = t.check.directorySummary(failures.length, verdicts.length);
1594
+ // Three independent verdicts about one run: a busted budget, an unreadable
1595
+ // marker, and drift past the baseline. Any of them failing fails the build —
1596
+ // an && here would let a breach ride out on a green budget.
1597
+ // The summary sentence counts budgets, so its colour follows budgets. `bad`
1598
+ // is the run's verdict and folds in the baseline: three independent findings
1599
+ // about one run, and any of them failing fails the build. An && here would let
1600
+ // a breach ride out on a green budget.
1601
+ const budgetBad = failures.length > 0 || declined.length > 0;
1602
+ const bad = budgetBad || (baselineOutcome?.breached.length ?? 0) > 0;
1603
+ console.log(` ${budgetBad ? c.red(summary) : c.green(summary)}`);
1604
+ if (truncated)
1605
+ console.log(` ${c.yellow(t.check.walkTruncated())}`);
1606
+ // After the per-file summary, because the two answer different questions and
1607
+ // the wider one reads last: budgets are about files, the baseline is about the
1608
+ // repository. Printing it first put "All 2 within budget" underneath a failed
1609
+ // gate, which reads as a contradiction.
1610
+ if (baselineOutcome && !boolFlag(args, 'json')) {
1611
+ console.log();
1612
+ reportBaseline(baselineOutcome.comparison, baselineOutcome.breached, baselineOutcome.document, baselineOutcome.path, baselineOutcome.usage, pricing, t);
1613
+ }
1614
+ console.log();
1615
+ if (bad)
1616
+ process.exitCode = 1;
1617
+ }
1618
+ /** Joins two path fragments for display and glob matching, always with `/`. */
1619
+ function joinPosix(root, relativePath) {
1620
+ const trimmed = root.replace(/[\\/]+$/, '').replace(/\\/g, '/');
1621
+ if (trimmed === '' || trimmed === '.')
1622
+ return relativePath;
1623
+ return `${trimmed}/${relativePath}`;
1624
+ }
1625
+ /**
1626
+ * Runs both prompt versions over a set of inputs and reports whether the
1627
+ * optimisation changed the answers.
1628
+ *
1629
+ * This is the only command that spends real money, and it spends it three
1630
+ * times per case: the original twice to measure the model's own variance, the
1631
+ * optimised once. The doubled original is what makes the answer mean anything
1632
+ * — without it, "diverged on 3 of 10" could be better than the original
1633
+ * manages against itself. The cost is printed before any call goes out.
1634
+ */
1635
+ /**
1636
+ * `trazum prune <file> --cases <file>` — which few-shot examples earn their tokens.
1637
+ *
1638
+ * The most expensive command here, and the only one that says what it will cost
1639
+ * and then stops. `eval` spends `3 × cases`, which is predictable enough to just
1640
+ * do. This spends `(2 + examples) × cases`, which for a nine-example prompt over
1641
+ * twenty cases is 220 calls — the sort of number somebody should agree to rather
1642
+ * than discover in a bill. So it prints the figure and requires `--yes`.
1643
+ *
1644
+ * The wording of the output matters as much as the measurement. An example whose
1645
+ * removal changes nothing **on these inputs** is not an example to delete: it may
1646
+ * exist for the boundary case somebody hit in production last March, which these
1647
+ * twenty cases do not contain. The report says "no effect on these inputs" and
1648
+ * never "delete this", and nothing here edits the prompt.
1649
+ */
1650
+ async function commandPrune(args, t) {
1651
+ const prompt = await readInput(args.positional[0], t);
1652
+ const casesPath = stringFlag(args, 'cases');
1653
+ if (!casesPath)
1654
+ throw new Error(t.errors.evalNeedsCases());
1655
+ const inputs = parseCases(await readFile(casesPath, 'utf8'));
1656
+ if (inputs.length === 0)
1657
+ throw new Error(t.errors.evalNoCases(casesPath));
1658
+ const examples = findExamples(prompt, estimateTokens);
1659
+ if (examples.length < 2)
1660
+ throw new Error(t.prune.needsExamples());
1661
+ const calls = plannedCalls(examples.length, inputs.length);
1662
+ // Printed before the key is even looked up, so somebody weighing it up does not
1663
+ // need a configured provider to see the number.
1664
+ console.log();
1665
+ console.log(c.bold(t.prune.estimate(examples.length, inputs.length, calls)));
1666
+ if (!boolFlag(args, 'yes')) {
1667
+ console.log(c.yellow(` ${t.prune.needsConsent()}`));
1668
+ return;
1669
+ }
1670
+ const provider = providerFromEnv();
1671
+ if (!provider)
1672
+ throw new Error(t.errors.llmNotConfigured());
1673
+ const report = await pruneExamples(prompt, inputs, provider, {
1674
+ concurrency: numberFlag(args, 'concurrency', 3, t),
1675
+ });
1676
+ if (boolFlag(args, 'json')) {
1677
+ console.log(JSON.stringify(report, null, 2));
1678
+ return;
1679
+ }
1680
+ const pct = (value) => `${(value * 100).toFixed(0)}%`;
1681
+ console.log();
1682
+ console.log(c.bold(t.prune.heading(provider.model)));
1683
+ console.log(` ${c.dim(t.prune.selfAgreement(pct(report.selfAgreement)))}`);
1684
+ console.log();
1685
+ for (const contribution of report.contributions) {
1686
+ /**
1687
+ * The mark points at what the reader can act on, which is the *recoverable*
1688
+ * ones — and the first draft had it backwards, putting a green tick beside
1689
+ * "0% agreement without it". That reads as approval next to the one line
1690
+ * meaning "this example is load-bearing, leave it alone". Only visible by
1691
+ * running it.
1692
+ */
1693
+ const needed = contribution.verdict === 'diverges';
1694
+ const unknown = contribution.verdict === 'inconclusive';
1695
+ const mark = unknown ? c.yellow('?') : needed ? c.dim('·') : c.green('→');
1696
+ const label = unknown
1697
+ ? t.prune.verdictUnknown()
1698
+ : needed
1699
+ ? t.prune.verdictNeeded()
1700
+ : t.prune.verdictRecoverable();
1701
+ console.log(` ${mark} ${t.prune.line(contribution.index + 1, contribution.tokens, pct(contribution.agreementWithout))}`
1702
+ + ` ${unknown ? c.yellow(label) : needed ? c.dim(label) : c.green(label)}`);
1703
+ /**
1704
+ * The first line that is not the header, because the header is the same on
1705
+ * every block. Printing `contribution.text`'s first non-empty line showed
1706
+ * "Example:" four times over, which identifies nothing — again, only visible
1707
+ * by running it.
1708
+ */
1709
+ const lines = contribution.text.split('\n').filter((line) => line.trim() !== '');
1710
+ const body = lines.find((line) => !/^\s*(?:#+\s*)?(?:example|ejemplo)\b[\s:.-]*$/i.test(line));
1711
+ console.log(` ${c.dim(truncate((body ?? lines[0] ?? '').trim(), 60))}`);
1712
+ }
1713
+ console.log();
1714
+ if (report.recoverableTokens > 0) {
1715
+ console.log(` ${t.prune.recoverable(report.recoverableTokens)}`);
1716
+ }
1717
+ console.log(` ${c.dim(wrap(t.prune.caveat(), 74, ' '))}`);
1718
+ console.log();
1719
+ console.log(c.dim(` ${t.eval.callsMade(report.callsMade)}`));
1720
+ }
1721
+ async function commandEval(args, config, t, locale) {
1722
+ const prompt = await readInput(args.positional[0], t);
1723
+ const level = levelFlag(args, config, t);
1724
+ const casesPath = stringFlag(args, 'cases');
1725
+ if (!casesPath)
1726
+ throw new Error(t.errors.evalNeedsCases());
1727
+ const inputs = parseCases(await readFile(casesPath, 'utf8'));
1728
+ if (inputs.length === 0)
1729
+ throw new Error(t.errors.evalNoCases(casesPath));
1730
+ // Export short-circuits before the provider is even looked up. Writing a
1731
+ // suite for somebody else's harness must not require a key or spend a call:
1732
+ // the whole point is to hand the run over.
1733
+ const exportTo = stringFlag(args, 'export');
1734
+ if (exportTo !== undefined) {
1735
+ await exportEvalSuite(exportTo, prompt, inputs, { args, config, level, locale, t });
1736
+ return;
1737
+ }
1738
+ const provider = providerFromEnv();
1739
+ if (!provider)
1740
+ throw new Error(t.errors.llmNotConfigured());
1741
+ const optimized = optimize(prompt, { level, locale }).optimized;
1742
+ if (optimized === prompt) {
1743
+ console.log(c.yellow(t.eval.nothingToCompare()));
1744
+ return;
1745
+ }
1746
+ console.log();
1747
+ console.log(c.dim(t.eval.starting(inputs.length, inputs.length * 3, provider.model)));
1748
+ const report = await evaluate(prompt, optimized, inputs, provider, {
1749
+ concurrency: numberFlag(args, 'concurrency', 3, t),
1750
+ });
1751
+ if (boolFlag(args, 'json')) {
1752
+ console.log(JSON.stringify(report, null, 2));
1753
+ return;
1754
+ }
1755
+ const pct = (value) => `${(value * 100).toFixed(0)}%`;
1756
+ console.log();
1757
+ console.log(c.bold(t.eval.heading()));
1758
+ console.log(` ${t.eval.selfAgreement(pct(report.selfAgreement))}`);
1759
+ console.log(` ${t.eval.crossAgreement(pct(report.crossAgreement))}`);
1760
+ console.log();
1761
+ const verdict = t.eval.verdict(report.verdict);
1762
+ const paint = report.verdict === 'diverges'
1763
+ ? c.red
1764
+ : report.verdict === 'inconclusive'
1765
+ ? c.yellow
1766
+ : c.green;
1767
+ console.log(` ${paint(verdict.label)}`);
1768
+ console.log(` ${c.dim(wrap(verdict.detail, 74, ' '))}`);
1769
+ // The worst cases first: if anything broke, it is what the reader came for.
1770
+ const worst = [...report.cases]
1771
+ .sort((a, b) => a.crossSimilarity - b.crossSimilarity)
1772
+ .slice(0, 3)
1773
+ .filter((entry) => entry.crossSimilarity < 0.999);
1774
+ if (worst.length > 0) {
1775
+ console.log();
1776
+ console.log(c.bold(t.eval.mostChanged()));
1777
+ for (const entry of worst) {
1778
+ console.log(` ${c.dim(truncate(entry.input, 62))}`);
1779
+ console.log(` ${t.eval.caseAgreement(pct(entry.crossSimilarity), pct(entry.selfSimilarity))}`);
1780
+ }
1781
+ }
1782
+ console.log();
1783
+ console.log(c.dim(` ${t.eval.callsMade(report.callsMade)}`));
1784
+ console.log();
1785
+ if (report.verdict === 'diverges')
1786
+ process.exitCode = 1;
1787
+ }
1788
+ /** One case per line, or a JSON array of strings. Blank lines and # comments ignored. */
1789
+ /**
1790
+ * Writes a before/after suite for an external harness.
1791
+ *
1792
+ * `trazum eval` measures semantic agreement, which is the question Trazum is
1793
+ * qualified to ask and not the one a team needs answered before shipping.
1794
+ * Theirs is whether the classifier still hits 94% — an assertion about their
1795
+ * task, which this tool has no business inventing. So the suite is handed over
1796
+ * with both prompts and every case wired up, and the assertions left blank on
1797
+ * purpose.
1798
+ */
1799
+ async function exportEvalSuite(format, prompt, inputs, context) {
1800
+ const { args, config, level, locale, t } = context;
1801
+ if (format !== 'promptfoo') {
1802
+ throw new Error(t.errors.unknownExportFormat(format, 'promptfoo'));
1803
+ }
1804
+ const optimized = optimize(prompt, { level, locale }).optimized;
1805
+ if (optimized === prompt) {
1806
+ // Two identical prompts is a suite that can only ever report "no change",
1807
+ // and an hour of somebody's API budget to find that out.
1808
+ console.log(c.yellow(t.eval.nothingToCompare()));
1809
+ return;
1810
+ }
1811
+ const usage = usageFrom(args, config, t);
1812
+ const { config: suite, warnings } = toPromptfoo(prompt, optimized, inputs, {
1813
+ model: usage.model,
1814
+ level,
1815
+ });
1816
+ const body = `${JSON.stringify(suite, null, 2)}\n`;
1817
+ const outPath = stringFlag(args, 'out');
1818
+ if (outPath) {
1819
+ await writeFile(outPath, body, 'utf8');
1820
+ }
1821
+ else {
1822
+ process.stdout.write(body);
1823
+ }
1824
+ // Warnings to stderr, so a redirected suite is the suite alone — and so they
1825
+ // are still seen when it is.
1826
+ if (warnings.length > 0) {
1827
+ console.error();
1828
+ console.error(t.eval.exportWarnings(warnings.length));
1829
+ for (const warning of warnings)
1830
+ console.error(` ${warning.detail}`);
1831
+ }
1832
+ if (outPath) {
1833
+ console.error();
1834
+ const seeded = suite.defaultTest?.assert?.length ?? 0;
1835
+ console.error(t.eval.exportWrote(outPath, inputs.length, seeded));
1836
+ }
1837
+ }
1838
+ function parseCases(raw) {
1839
+ const trimmed = raw.trim();
1840
+ if (trimmed.startsWith('[')) {
1841
+ try {
1842
+ const parsed = JSON.parse(trimmed);
1843
+ if (Array.isArray(parsed)) {
1844
+ return parsed.filter((value) => typeof value === 'string');
1845
+ }
1846
+ }
1847
+ catch {
1848
+ // Fall through to line mode: a file that merely starts with "[" is more
1849
+ // likely a prompt than a broken JSON document.
1850
+ }
1851
+ }
1852
+ return trimmed
1853
+ .split('\n')
1854
+ .map((line) => line.trim())
1855
+ .filter((line) => line.length > 0 && !line.startsWith('#'));
1856
+ }
1857
+ /**
1858
+ * Compares two versions of a prompt. Built for a pull request: it reports by
1859
+ * default and only fails the build when a growth limit was explicitly asked
1860
+ * for, because a tool that fails a build nobody armed gets removed from the
1861
+ * pipeline rather than fixed.
1862
+ */
1863
+ async function commandDiff(args, config, pricing, t, locale) {
1864
+ const [beforePath, afterPath] = args.positional;
1865
+ if (!beforePath || !afterPath)
1866
+ throw new Error(t.errors.diffNeedsTwoFiles());
1867
+ if (boolFlag(args, 'all')) {
1868
+ await diffDirectories(beforePath, afterPath, args, config, pricing, t, locale);
1869
+ return;
1870
+ }
1871
+ const [before, after] = await Promise.all([
1872
+ readInput(beforePath, t),
1873
+ readInput(afterPath, t),
1874
+ ]);
1875
+ const comparison = comparePrompts(before, after, {
1876
+ level: levelFlag(args, config, t),
1877
+ locale,
1878
+ optimizeBoth: boolFlag(args, 'optimized'),
1879
+ usage: usageFrom(args, config, t),
1880
+ pricing,
1881
+ });
1882
+ await writeMarkdown(args, () => renderDiffMarkdown({
1883
+ comparison,
1884
+ beforePath,
1885
+ afterPath,
1886
+ optimized: boolFlag(args, 'optimized'),
1887
+ locale,
1888
+ t,
1889
+ }));
1890
+ if (boolFlag(args, 'json')) {
1891
+ console.log(JSON.stringify(comparison, null, 2));
1892
+ }
1893
+ else {
1894
+ printComparison(comparison, beforePath, afterPath, boolFlag(args, 'optimized'), t);
1895
+ }
1896
+ // The gate stays opt-in, and a config file counts as opting in: a repository
1897
+ // that wrote down `"maxGrowth": 25` has armed it as deliberately as a flag
1898
+ // would. What has not changed is that *absent* both, growth alone exits 0.
1899
+ const limit = typeof args.flags.get('max-growth') === 'string'
1900
+ ? numberFlag(args, 'max-growth', 0, t)
1901
+ : config.maxGrowth;
1902
+ if (limit !== undefined && comparison.tokenDelta > limit) {
1903
+ console.error(`\n${c.red(t.diff.overLimit(comparison.tokenDelta, limit))}`);
1904
+ process.exitCode = 1;
1905
+ }
1906
+ }
1907
+ function printComparison(comparison, beforePath, afterPath, optimized, t) {
1908
+ const n = (value) => value.toLocaleString(t.numberLocale);
1909
+ const grew = comparison.tokenDelta > 0;
1910
+ const paint = grew ? c.red : comparison.tokenDelta < 0 ? c.green : c.dim;
1911
+ const signed = (value) => `${value > 0 ? '+' : ''}${n(value)}`;
1912
+ console.log();
1913
+ console.log(c.bold(t.diff.heading(beforePath, afterPath)));
1914
+ if (optimized)
1915
+ console.log(c.dim(` ${t.diff.measuringOptimised()}`));
1916
+ console.log();
1917
+ console.log(` ${n(comparison.tokensBefore)} → ${n(comparison.tokensAfter)} tokens ` +
1918
+ paint(`${signed(comparison.tokenDelta)} (${signed(Math.round(comparison.deltaPct))}%)`));
1919
+ console.log(` ${t.diff.monthly(formatSignedUsd(comparison.monthlyDeltaUsd), n(comparison.usage.callsPerMonth), getModel(comparison.usage.model).displayName)}`);
1920
+ const { rules, advisories } = comparison;
1921
+ const copy = getMessages(t.locale).rules;
1922
+ if (advisories.appeared.length > 0) {
1923
+ console.log();
1924
+ console.log(c.yellow(` ${t.diff.advisoriesAppeared()}`));
1925
+ for (const id of advisories.appeared)
1926
+ console.log(` ! ${id}`);
1927
+ }
1928
+ if (advisories.resolved.length > 0) {
1929
+ console.log();
1930
+ console.log(c.green(` ${t.diff.advisoriesResolved()}`));
1931
+ for (const id of advisories.resolved)
1932
+ console.log(` ✓ ${id}`);
1933
+ }
1934
+ if (rules.newlyFiring.length > 0) {
1935
+ console.log();
1936
+ console.log(` ${t.diff.rulesNewlyFiring()}`);
1937
+ for (const id of rules.newlyFiring)
1938
+ console.log(` ${c.dim(copy[id].title)}`);
1939
+ }
1940
+ if (rules.noLongerFiring.length > 0) {
1941
+ console.log();
1942
+ console.log(` ${t.diff.rulesNoLongerFiring()}`);
1943
+ for (const id of rules.noLongerFiring)
1944
+ console.log(` ${c.dim(copy[id].title)}`);
1945
+ }
1946
+ console.log();
1947
+ }
1948
+ // --------------------------------------------------------------------------
1949
+ // --------------------------------------------------------------------------
1950
+ // blame
1951
+ // --------------------------------------------------------------------------
1952
+ /**
1953
+ * How many revisions to walk unless told otherwise.
1954
+ *
1955
+ * Each one is a `git show` and a token count, so this is a wall-clock budget as
1956
+ * much as a display choice. Twenty is enough to see a trend and fast enough to
1957
+ * feel instant on a normal file.
1958
+ */
1959
+ /**
1960
+ * How many unbudgeted paths `doctor` names before summarising the rest.
1961
+ *
1962
+ * Capped and *counted*: a survey that prints forty paths buries the finding it
1963
+ * was meant to deliver, and one that silently shows the first eight claims there
1964
+ * were eight.
1965
+ */
1966
+ const DOCTOR_LIST_LIMIT = 8;
1967
+ const BLAME_DEFAULT_LIMIT = 20;
1968
+ const BLAME_MAX_LIMIT = 500;
1969
+ /**
1970
+ * `trazum blame <file>` — what happened to this prompt's cost, and who did it.
1971
+ *
1972
+ * Git already knows who changed a prompt and when. What it does not know is
1973
+ * that a three-line addition to a system prompt at 50,000 calls a month is a
1974
+ * bill, not a diff. This walks the file's history, counts the tokens at each
1975
+ * commit, and puts the two facts on the same line.
1976
+ *
1977
+ * Reads history and nothing else: no writes, no network, and the one place that
1978
+ * runs git is `git.ts`, which is written as if it were the whole attack surface.
1979
+ */
1980
+ async function commandBlame(args, config, pricing, t) {
1981
+ const target = args.positional[0];
1982
+ if (!target)
1983
+ throw new Error(t.errors.missingInputFile());
1984
+ const cwd = process.cwd();
1985
+ const root = repositoryRoot(cwd);
1986
+ if (root === null) {
1987
+ // Two failures with the same symptom, and the distinction is the whole of
1988
+ // the fix: install git, or run this somewhere else.
1989
+ throw new Error(gitAvailable(cwd) ? t.blame.notARepository() : t.blame.gitMissing());
1990
+ }
1991
+ const repoPath = pathInRepository(root, resolvePath(cwd, target));
1992
+ if (repoPath === null)
1993
+ throw new Error(t.blame.outsideRepository(target));
1994
+ const limit = Math.min(Math.max(1, Math.floor(numberFlag(args, 'limit', BLAME_DEFAULT_LIMIT, t))), BLAME_MAX_LIMIT);
1995
+ // One extra, so the oldest shown revision still has something to be a change
1996
+ // *from*. Without it the first row reports "added" for a file that existed.
1997
+ const revisions = revisionsFor(repoPath, { cwd: root, max: limit + 1 });
1998
+ if (revisions.length === 0)
1999
+ throw new Error(t.blame.noHistory(repoPath));
2000
+ const wanted = stringFlag(args, 'prompt');
2001
+ /**
2002
+ * Tokens in the prompt at a commit.
2003
+ *
2004
+ * A source file is measured through the same marker extraction `optimize`
2005
+ * uses, so `blame src/prompts.ts --prompt support` tracks the prompt rather
2006
+ * than the file around it — otherwise every refactor of the imports would
2007
+ * read as prompt growth.
2008
+ */
2009
+ const names = namesByRevision(repoPath, root, limit + 1);
2010
+ const tokensAt = (revision) => {
2011
+ // The name at *that* commit, which is not today's name once a rename is in
2012
+ // the history. Reading with today's name returned "did not exist" for every
2013
+ // revision before the move.
2014
+ const name = names.get(revision.sha) ?? null;
2015
+ const path = name ?? repoPath;
2016
+ const text = contentAt(revision.sha, path, root);
2017
+ if (text === null)
2018
+ return { tokens: null, name: null };
2019
+ const source = sourceFileOf(path, text, pricing, wanted);
2020
+ return {
2021
+ tokens: estimateTokens(source ? source.text : text),
2022
+ name: name !== null && name !== repoPath ? name : null,
2023
+ };
2024
+ };
2025
+ // Oldest first while computing, so a delta is against the revision before it.
2026
+ const measured = revisions
2027
+ .slice()
2028
+ .reverse()
2029
+ .map((revision) => ({ revision, ...tokensAt(revision) }));
2030
+ const rows = measured.map((entry, index) => {
2031
+ const previous = index > 0 ? measured[index - 1].tokens : null;
2032
+ return {
2033
+ revision: entry.revision,
2034
+ tokens: entry.tokens,
2035
+ delta: entry.tokens !== null && previous !== null ? entry.tokens - previous : null,
2036
+ name: entry.name,
2037
+ };
2038
+ });
2039
+ // Drop the extra oldest revision now that it has served as a baseline, and
2040
+ // put the newest first: this is a history, and histories are read backwards.
2041
+ const shown = rows.slice(revisions.length > limit ? 1 : 0).reverse();
2042
+ const truncatedHistory = revisions.length > limit;
2043
+ await writeMarkdown(args, () => renderBlameMarkdown({
2044
+ repoPath,
2045
+ rows: shown,
2046
+ truncated: truncatedHistory,
2047
+ netCost: netCostOf(shown, args, config, pricing, t),
2048
+ t,
2049
+ }));
2050
+ printBlame(shown, { repoPath, args, config, pricing, t, truncated: truncatedHistory });
2051
+ }
2052
+ /**
2053
+ * What the movement across a history costs per month, or `null`.
2054
+ *
2055
+ * Extracted so the terminal report and the markdown report read one number
2056
+ * rather than each computing it. The file's own doc comment claims a discrepancy
2057
+ * between a pull-request comment and the job log is "impossible by construction";
2058
+ * two copies of this arithmetic is exactly how that claim stops being true.
2059
+ *
2060
+ * Oldest as "before" and newest as "after", so a prompt that grew reports a
2061
+ * negative saving — which is the honest word for it. The sign becomes a `+`/`−`
2062
+ * on the money here rather than being left for the reader.
2063
+ */
2064
+ function netCostOf(rows, args, config, pricing, t) {
2065
+ const measured = rows.filter((r) => r.tokens !== null);
2066
+ const newest = measured[0];
2067
+ const oldest = measured[measured.length - 1];
2068
+ if (!newest || !oldest || newest === oldest || newest.tokens === oldest.tokens)
2069
+ return null;
2070
+ const usage = usageFrom(args, config, t);
2071
+ const model = pricing.models.find((m) => m.id === usage.model);
2072
+ if (!model)
2073
+ return null;
2074
+ const savings = computeSavings(oldest.tokens, newest.tokens, usage, new Date(), pricing);
2075
+ const monthly = -savings.monthlySavingsUsd;
2076
+ return {
2077
+ amount: `${monthly >= 0 ? '+' : '\u2212'}${formatUsd(Math.abs(monthly))}`,
2078
+ modelDisplayName: model.displayName,
2079
+ callsPerMonth: usage.callsPerMonth,
2080
+ };
2081
+ }
2082
+ /**
2083
+ * The report.
2084
+ *
2085
+ * A table, newest first, and then the two things the table alone does not say:
2086
+ * what the whole history added up to in money, and which single commit did the
2087
+ * most damage. "Tokens grew 40%" is a fact; "+310 tokens, Dana, 'add escalation
2088
+ * rules'" is something somebody can go and look at.
2089
+ */
2090
+ function printBlame(rows, context) {
2091
+ const { repoPath, args, config, pricing, t, truncated } = context;
2092
+ const n = (value) => value.toLocaleString(t.numberLocale);
2093
+ const measured = rows.filter((r) => r.tokens !== null);
2094
+ const newest = measured[0];
2095
+ const oldest = measured[measured.length - 1];
2096
+ if (boolFlag(args, 'json')) {
2097
+ console.log(JSON.stringify({
2098
+ path: repoPath,
2099
+ truncated,
2100
+ revisions: rows.map((row) => ({
2101
+ sha: row.revision.sha,
2102
+ author: row.revision.author,
2103
+ date: row.revision.date,
2104
+ subject: row.revision.subject,
2105
+ tokens: row.tokens,
2106
+ delta: row.delta,
2107
+ ...(row.name ? { path: row.name } : {}),
2108
+ })),
2109
+ }, null, 2));
2110
+ return;
2111
+ }
2112
+ console.log(`\n${c.bold(t.blame.heading(repoPath, rows.length))}\n`);
2113
+ const cols = t.blame.columns;
2114
+ const widths = {
2115
+ when: Math.max(cols.when.length, 10),
2116
+ tokens: Math.max(cols.tokens.length, ...rows.map((r) => (r.tokens === null ? t.blame.goneAt().length : n(r.tokens).length))),
2117
+ change: Math.max(cols.change.length, 7),
2118
+ who: Math.min(20, Math.max(cols.who.length, ...rows.map((r) => r.revision.author.length))),
2119
+ };
2120
+ console.log(c.dim([
2121
+ cols.when.padEnd(widths.when),
2122
+ cols.tokens.padStart(widths.tokens),
2123
+ cols.change.padStart(widths.change),
2124
+ cols.who.padEnd(widths.who),
2125
+ cols.commit,
2126
+ ].join(' ')));
2127
+ for (const row of rows) {
2128
+ const when = row.revision.date.slice(0, 10);
2129
+ const tokens = row.tokens === null ? c.dim(t.blame.goneAt()) : n(row.tokens);
2130
+ // A rise is the thing worth seeing, so it is the thing that gets colour.
2131
+ // A fall is good news and does not need to shout.
2132
+ const change = row.delta === null
2133
+ ? c.dim(row.tokens === null ? '' : t.blame.addedAt())
2134
+ : row.delta > 0
2135
+ ? c.red(`+${n(row.delta)}`)
2136
+ : row.delta < 0
2137
+ ? c.green(n(row.delta))
2138
+ : c.dim('·');
2139
+ const rawTokens = row.tokens === null ? t.blame.goneAt() : n(row.tokens);
2140
+ const rawChange = row.delta === null
2141
+ ? row.tokens === null
2142
+ ? ''
2143
+ : t.blame.addedAt()
2144
+ : row.delta > 0
2145
+ ? `+${n(row.delta)}`
2146
+ : row.delta < 0
2147
+ ? n(row.delta)
2148
+ : '·';
2149
+ console.log([
2150
+ when.padEnd(widths.when),
2151
+ // Padded on the raw text, coloured after: an ANSI escape has length
2152
+ // and padEnd would count it, so every coloured cell would come out
2153
+ // short by exactly the width of the escape sequence.
2154
+ ' '.repeat(Math.max(0, widths.tokens - rawTokens.length)) + tokens,
2155
+ ' '.repeat(Math.max(0, widths.change - rawChange.length)) + change,
2156
+ truncate(row.revision.author, widths.who).padEnd(widths.who),
2157
+ c.dim(`${row.revision.shortSha} ${truncate(row.revision.subject, 48)}`),
2158
+ ].join(' '));
2159
+ }
2160
+ if (truncated)
2161
+ console.log(`\n${c.dim(t.blame.truncated(rows.length))}`);
2162
+ const renamed = rows.find((row) => row.name !== null);
2163
+ if (renamed?.name)
2164
+ console.log(c.dim(t.blame.followedRename(renamed.name)));
2165
+ if (newest && oldest && newest !== oldest) {
2166
+ const delta = newest.tokens - oldest.tokens;
2167
+ const pct = oldest.tokens === 0 ? '—' : `${delta >= 0 ? '+' : ''}${((delta / oldest.tokens) * 100).toFixed(0)}%`;
2168
+ console.log(`\n${t.blame.net(n(oldest.tokens), n(newest.tokens), `${delta >= 0 ? '+' : ''}${n(delta)}`, pct)}`);
2169
+ // What the movement costs, priced through the same usage profile every
2170
+ // other command uses — so `--calls` and `--model` mean here what they mean
2171
+ // in `optimize`, and a figure from one is comparable with the other. Shared
2172
+ // with the markdown renderer, so the comment and the log cannot disagree.
2173
+ const cost = netCostOf(rows, args, config, pricing, t);
2174
+ if (cost) {
2175
+ console.log(c.dim(t.blame.netCost(cost.amount, cost.modelDisplayName, n(cost.callsPerMonth))));
2176
+ }
2177
+ }
2178
+ // The single worst commit, which is the question the command is really for.
2179
+ const worst = rows
2180
+ .filter((row) => row.delta !== null && row.delta > 0)
2181
+ .sort((a, b) => b.delta - a.delta)[0];
2182
+ if (worst) {
2183
+ console.log(`\n${c.bold(t.blame.biggestRise())}`);
2184
+ console.log(` ${t.blame.biggestRiseDetail(n(worst.delta), worst.revision.author, truncate(worst.revision.subject, 60), worst.revision.shortSha)}`);
2185
+ }
2186
+ console.log(`\n${c.dim(t.blame.estimateNote())}\n`);
2187
+ }
2188
+ /**
2189
+ * `trazum rank <dir>` — which of these prompts to fix first.
2190
+ *
2191
+ * The obvious design is a complexity score out of a hundred, and it is the
2192
+ * wrong one. A number nobody can reproduce by hand cannot be argued with, and
2193
+ * the weights that combine four measurements into one get tuned until the
2194
+ * ranking looks right — which is fitting the metric to the answer.
2195
+ *
2196
+ * So this sorts on the one quantity that is not a matter of opinion: **what
2197
+ * optimising each prompt would actually save**, obtained by running the rules
2198
+ * rather than by evaluating a formula. The structural measurements are printed
2199
+ * beside it as the *explanation* — "1,204 tokens across 8 sentences" says why a
2200
+ * prompt is worth looking at, and the recoverable figure says whether it is
2201
+ * worth looking at before the other thirty-nine.
2202
+ */
2203
+ async function commandRank(args, config, pricing, t, locale) {
2204
+ const root = args.positional[0] ?? '.';
2205
+ const level = levelFlag(args, config, t);
2206
+ const usage = usageFrom(args, config, t);
2207
+ const extensions = config.extensions ?? [...DEFAULT_EXTENSIONS, ...SOURCE_EXTENSIONS];
2208
+ const { files, truncated } = await walkPrompts(root, { extensions });
2209
+ if (files.length === 0) {
2210
+ throw new Error(t.errors.noPromptsFound(root, extensions.join(' ')));
2211
+ }
2212
+ const ranked = [];
2213
+ let skipped = 0;
2214
+ for (const file of files) {
2215
+ const raw = await readFile(join(root, file), 'utf8');
2216
+ // A source file contributes its marked prompt, or nothing. Ranking
2217
+ // `src/prompts.ts` by the size of its imports would put the wrong file at
2218
+ // the top of a list whose whole job is to point somewhere.
2219
+ //
2220
+ // `sourceFileOf` *throws* for a source file with no marker, which is the
2221
+ // right answer for `optimize` — you named that file, and optimising it
2222
+ // would rewrite your code. It is the wrong answer here: one unmarked `.ts`
2223
+ // in a repository would abort the ranking of the other thirty-nine. Caught,
2224
+ // counted, and reported at the end rather than swallowed.
2225
+ let source;
2226
+ try {
2227
+ source = sourceFileOf(file, raw, pricing, stringFlag(args, 'prompt'));
2228
+ }
2229
+ catch {
2230
+ skipped++;
2231
+ continue;
2232
+ }
2233
+ if (source === null && SOURCE_EXTENSIONS.some((ext) => file.toLowerCase().endsWith(ext))) {
2234
+ skipped++;
2235
+ continue;
2236
+ }
2237
+ const text = source ? source.text : raw;
2238
+ if (text.trim() === '')
2239
+ continue;
2240
+ const result = optimize(text, { level, locale, usage, pricing, disableRules: disabledRules(args, config) });
2241
+ ranked.push({
2242
+ path: file,
2243
+ profile: profilePrompt(text),
2244
+ recoverable: result.tokensSaved,
2245
+ recoverableUsd: result.savings.monthlySavingsUsd,
2246
+ promptName: source ? (stringFlag(args, 'prompt') ?? null) : null,
2247
+ });
2248
+ }
2249
+ if (ranked.length === 0) {
2250
+ throw new Error(t.errors.noPromptsFound(root, extensions.join(' ')));
2251
+ }
2252
+ ranked.sort((a, b) => b.recoverableUsd - a.recoverableUsd || b.recoverable - a.recoverable);
2253
+ // Before the print and independently of --json, as in `check`: the file's whole
2254
+ // job is to survive the run, and a report that only appears on the happy path
2255
+ // is a report nobody can rely on.
2256
+ await writeMarkdown(args, () => renderRankMarkdown({
2257
+ root,
2258
+ ranked,
2259
+ level,
2260
+ modelDisplayName: pricing.models.find((m) => m.id === usage.model)?.displayName ?? usage.model,
2261
+ callsPerMonth: usage.callsPerMonth,
2262
+ truncated,
2263
+ skipped,
2264
+ t,
2265
+ }));
2266
+ printRank(ranked, { root, args, usage, pricing, t, truncated, skipped });
2267
+ }
2268
+ /**
2269
+ * The ranking, and the numbers that explain it.
2270
+ *
2271
+ * Every column is a measurement with a definition in `profile.ts`, printed with
2272
+ * its units. There is deliberately no total, no grade and no index: the reader
2273
+ * is meant to look down the first column, pick a file, and know why.
2274
+ */
2275
+ function printRank(ranked, context) {
2276
+ const { root, args, usage, pricing, t, truncated, skipped } = context;
2277
+ const n = (value) => value.toLocaleString(t.numberLocale);
2278
+ if (boolFlag(args, 'json')) {
2279
+ console.log(JSON.stringify({
2280
+ root,
2281
+ truncated,
2282
+ skippedSourceFiles: skipped,
2283
+ usage,
2284
+ prompts: ranked.map((entry) => ({
2285
+ path: entry.path,
2286
+ ...entry.profile,
2287
+ recoverableTokens: entry.recoverable,
2288
+ recoverableUsdPerMonth: entry.recoverableUsd,
2289
+ })),
2290
+ }, null, 2));
2291
+ return;
2292
+ }
2293
+ const model = pricing.models.find((m) => m.id === usage.model);
2294
+ console.log(`\n${c.bold(t.rank.heading(root, ranked.length))}`);
2295
+ console.log(c.dim(t.rank.subheading(model?.displayName ?? usage.model, n(usage.callsPerMonth))));
2296
+ console.log();
2297
+ const cols = t.rank.columns;
2298
+ const widths = {
2299
+ save: Math.max(cols.recoverable.length, ...ranked.map((r) => formatUsd(r.recoverableUsd).length)),
2300
+ back: Math.max(cols.tokensBack.length, ...ranked.map((r) => n(r.recoverable).length)),
2301
+ tokens: Math.max(cols.tokens.length, ...ranked.map((r) => n(r.profile.tokens).length)),
2302
+ density: Math.max(cols.density.length, 6),
2303
+ };
2304
+ console.log(c.dim([
2305
+ cols.recoverable.padStart(widths.save),
2306
+ cols.tokensBack.padStart(widths.back),
2307
+ cols.tokens.padStart(widths.tokens),
2308
+ cols.density.padStart(widths.density),
2309
+ cols.notes,
2310
+ ].join(' ')));
2311
+ for (const entry of ranked) {
2312
+ const { profile } = entry;
2313
+ const notes = [];
2314
+ if (profile.examples > 0)
2315
+ notes.push(t.rank.noteExamples(profile.examples, n(profile.exampleTokens)));
2316
+ if (profile.formatTokens > 0)
2317
+ notes.push(t.rank.noteFormat(n(profile.formatTokens)));
2318
+ // Only when it is a large enough share to change the answer: "3% of this
2319
+ // is code" is true of nearly everything and tells nobody anything.
2320
+ const protectedShare = profile.tokens === 0 ? 0 : profile.protectedTokens / profile.tokens;
2321
+ if (protectedShare >= 0.25)
2322
+ notes.push(t.rank.noteProtected(Math.round(protectedShare * 100)));
2323
+ // Money *and* tokens, side by side, and that is the fix for a real
2324
+ // misreading. Four prompts showed "$0.25" and looked like four equivalent
2325
+ // jobs; three of them recovered a single token, which at 50,000 calls is
2326
+ // twenty-five cents and no work worth doing. Rather than invent a threshold
2327
+ // — any cutoff here would be a number nobody could check — the count is
2328
+ // printed beside the money. "1" is self-evidently nothing and "36" is
2329
+ // self-evidently something, with no judgement of ours in between.
2330
+ console.log([
2331
+ formatUsd(entry.recoverableUsd).padStart(widths.save),
2332
+ n(entry.recoverable).padStart(widths.back),
2333
+ n(profile.tokens).padStart(widths.tokens),
2334
+ profile.tokensPerSentence.toFixed(1).padStart(widths.density),
2335
+ `${entry.path}${notes.length > 0 ? c.dim(` — ${notes.join(', ')}`) : ''}`,
2336
+ ].join(' '));
2337
+ }
2338
+ if (truncated)
2339
+ console.log(`\n${c.dim(t.check.walkTruncated())}`);
2340
+ // Named rather than silent: a repository where most prompts live in code
2341
+ // would otherwise show a short list and look complete.
2342
+ if (skipped > 0)
2343
+ console.log(`\n${c.dim(t.rank.skipped(skipped))}`);
2344
+ console.log(`\n${c.dim(t.rank.densityNote())}`);
2345
+ console.log(`${c.dim(t.rank.recoverableNote())}\n`);
2346
+ }
2347
+ async function main() {
2348
+ const argv = process.argv.slice(2);
2349
+ let locale = localeFromArgv(argv);
2350
+ let t = getCliMessages(locale);
2351
+ const args = parseArgs(argv, t);
2352
+ /**
2353
+ * An errand, not a mode of a command — so it runs with no command named, and
2354
+ * before the config is loaded.
2355
+ *
2356
+ * Both halves of that are deliberate. `trazum --clear-suggestion-cache` with
2357
+ * nothing else on the line is how somebody will type it, and the first
2358
+ * version sat below the help branch, where `!args.command` had already
2359
+ * printed the usage text and returned: the flag did nothing, and said nothing
2360
+ * about doing nothing. Loading the config first would be the same mistake one
2361
+ * layer down — a cache you cannot empty because an unrelated `trazum.config.json`
2362
+ * fails to parse is a cache somebody deletes by hand, guessing at the path.
2363
+ */
2364
+ if (boolFlag(args, 'clear-suggestion-cache')) {
2365
+ const dir = cacheDir();
2366
+ const before = cacheStats(dir);
2367
+ const removed = clearCache(dir);
2368
+ console.log(t.cache.cleared(removed, before.bytes, dir));
2369
+ return;
2370
+ }
2371
+ if (boolFlag(args, 'help') || boolFlag(args, 'h') || !args.command) {
2372
+ console.log(t.help({
2373
+ model: DEFAULT_USAGE.model,
2374
+ callsPerMonth: DEFAULT_USAGE.callsPerMonth,
2375
+ avgOutputTokens: DEFAULT_USAGE.avgOutputTokens,
2376
+ cacheHitRate: DEFAULT_USAGE.cacheHitRate,
2377
+ locales: LOCALES,
2378
+ }, c.bold));
2379
+ return;
2380
+ }
2381
+ rejectUnknownFlags(args, t);
2382
+ // Loaded before dispatch so every command sees the same settings, and after
2383
+ // flag validation so a typo is reported before any file is touched. An
2384
+ // invalid config throws here rather than quietly reverting to defaults —
2385
+ // "defaults" for a budget means "no budget", which means a green build.
2386
+ const loaded = await loadConfig({ explicit: stringFlag(args, 'config') });
2387
+ const { config } = loaded;
2388
+ const pricing = await pricingFor(args, loaded, t);
2389
+ // The config only gets to choose the locale when nothing more explicit did.
2390
+ if (config.locale && !stringFlag(args, 'locale')) {
2391
+ locale = detectLocale(undefined, process.env, config.locale);
2392
+ t = getCliMessages(locale);
2393
+ }
2394
+ switch (args.command) {
2395
+ case 'optimize':
2396
+ await commandOptimize(args, config, pricing, t, locale);
2397
+ break;
2398
+ case 'check':
2399
+ await commandCheck(args, config, pricing, t, locale);
2400
+ break;
2401
+ case 'baseline':
2402
+ await commandBaseline(args, config, pricing, t, locale);
2403
+ break;
2404
+ case 'eval':
2405
+ await commandEval(args, config, t, locale);
2406
+ break;
2407
+ case 'prune':
2408
+ await commandPrune(args, t);
2409
+ break;
2410
+ case 'diff':
2411
+ await commandDiff(args, config, pricing, t, locale);
2412
+ break;
2413
+ case 'models':
2414
+ commandModels(t, pricing);
2415
+ break;
2416
+ case 'where':
2417
+ await commandWhere(args, config, pricing, t);
2418
+ break;
2419
+ case 'rules':
2420
+ commandRules(t, locale);
2421
+ break;
2422
+ case 'doctor':
2423
+ await commandDoctor(args, config, pricing, t, locale);
2424
+ return;
2425
+ case 'rank':
2426
+ await commandRank(args, config, pricing, t, locale);
2427
+ break;
2428
+ case 'blame':
2429
+ await commandBlame(args, config, pricing, t);
2430
+ break;
2431
+ default:
2432
+ throw new Error(t.errors.unknownCommand(args.command));
2433
+ }
2434
+ }
2435
+ main().catch((error) => {
2436
+ const message = error instanceof Error ? error.message : String(error);
2437
+ const t = getCliMessages(localeFromArgv(process.argv.slice(2)));
2438
+ console.error(`\n${c.red(t.errors.errorLabel())}: ${message}\n`);
2439
+ process.exitCode = 1;
2440
+ });
2441
+ /**
2442
+ * `trazum doctor [dir]` — the survey before the gate.
2443
+ *
2444
+ * Every other command answers a question about one prompt, or ranks prompts
2445
+ * against each other. This one answers "what is wrong with this repository", and
2446
+ * it does so **without inventing a single new judgement**.
2447
+ *
2448
+ * That is the design constraint worth stating, because the obvious way to build
2449
+ * this command is the wrong one. A health check invites a score, a grade, a
2450
+ * traffic light — numbers assembled from weights nobody can reproduce, which get
2451
+ * quietly tuned until the output looks right. `rank` already refused that. So
2452
+ * every finding here is an advisory that `optimize` would raise on that prompt on
2453
+ * its own, summed: the "37 prompts only need a cheaper model" line is 37 copies of
2454
+ * the `model-downgrade` advisory, each reproducible by running `trazum optimize`
2455
+ * on the file named. Nothing is computed here that cannot be checked there.
2456
+ *
2457
+ * **It exits 0 even when it finds things.** `trazum check` is the gate and fails
2458
+ * builds; this is the survey. The model recommendation is a keyword heuristic, and
2459
+ * gating a build on a keyword heuristic is how people learn to re-run until green
2460
+ * — which costs more than the tool ever saves.
2461
+ *
2462
+ * Deliberately not included: anything needing a model. "Prompts that exceed their
2463
+ * own `--suggest` recommendations" would mean an LLM call per prompt, and `doctor`
2464
+ * is the command you run on forty files before you have decided to spend anything.
2465
+ */
2466
+ async function commandDoctor(args, config, pricing, t, locale) {
2467
+ const root = args.positional[0] ?? '.';
2468
+ const level = levelFlag(args, config, t);
2469
+ const usage = usageFrom(args, config, t);
2470
+ const extensions = config.extensions ?? [...DEFAULT_EXTENSIONS, ...SOURCE_EXTENSIONS];
2471
+ const { files, truncated } = await walkPrompts(root, { extensions });
2472
+ if (files.length === 0) {
2473
+ throw new Error(t.errors.noPromptsFound(root, extensions.join(' ')));
2474
+ }
2475
+ const seen = [];
2476
+ let skipped = 0;
2477
+ for (const file of files) {
2478
+ const raw = await readFile(join(root, file), 'utf8');
2479
+ // Same contract as `rank`: a source file contributes its marked prompt or
2480
+ // nothing, and an unmarked one is counted rather than allowed to abort a
2481
+ // survey of the other thirty-nine.
2482
+ let source;
2483
+ try {
2484
+ source = sourceFileOf(file, raw, pricing, stringFlag(args, 'prompt'));
2485
+ }
2486
+ catch {
2487
+ skipped++;
2488
+ continue;
2489
+ }
2490
+ if (source === null && SOURCE_EXTENSIONS.some((ext) => file.toLowerCase().endsWith(ext))) {
2491
+ skipped++;
2492
+ continue;
2493
+ }
2494
+ const text = source ? source.text : raw;
2495
+ if (text.trim() === '')
2496
+ continue;
2497
+ const result = optimize(text, {
2498
+ level,
2499
+ locale,
2500
+ usage,
2501
+ pricing,
2502
+ disableRules: disabledRules(args, config),
2503
+ });
2504
+ seen.push({
2505
+ path: file,
2506
+ // As written, not as optimised: a budget governs the file on disk, and this
2507
+ // is the number `check` would compare against it.
2508
+ tokens: result.tokensBefore,
2509
+ budget: budgetFor(file, config.budgets),
2510
+ advisories: result.advisories,
2511
+ text,
2512
+ });
2513
+ }
2514
+ if (seen.length === 0) {
2515
+ throw new Error(t.errors.noPromptsFound(root, extensions.join(' ')));
2516
+ }
2517
+ const unbudgeted = seen.filter((d) => d.budget === null);
2518
+ const overBudget = seen.filter((d) => d.budget !== null && d.tokens > d.budget.maxTokens);
2519
+ /** Advisories rolled up by id, worst money first. */
2520
+ const findings = [];
2521
+ for (const diagnosis of seen) {
2522
+ for (const advisory of diagnosis.advisories) {
2523
+ let finding = findings.find((f) => f.id === advisory.id);
2524
+ if (!finding) {
2525
+ finding = { id: advisory.id, title: advisory.title, prompts: 0, monthlyUsd: null };
2526
+ findings.push(finding);
2527
+ }
2528
+ finding.prompts++;
2529
+ if (advisory.estimatedMonthlyUsd !== null) {
2530
+ finding.monthlyUsd = (finding.monthlyUsd ?? 0) + advisory.estimatedMonthlyUsd;
2531
+ }
2532
+ }
2533
+ }
2534
+ // Money first, then breadth. An advisory with no figure attached is not
2535
+ // worthless — `context-overflow` means the call fails — so it sorts by how many
2536
+ // prompts raised it rather than falling to the bottom as a zero.
2537
+ findings.sort((a, b) => (b.monthlyUsd ?? 0) - (a.monthlyUsd ?? 0) || b.prompts - a.prompts);
2538
+ /**
2539
+ * The one finding here that is not a rolled-up advisory.
2540
+ *
2541
+ * Everything above is `optimize` run on one file and summed, which is the
2542
+ * constraint this command was built around — every line reproducible on a
2543
+ * single prompt. This is the deliberate exception, and it earns it by being
2544
+ * the only question that cannot be asked of one file: whether a preamble
2545
+ * shared by twelve prompts is byte-identical in any two of them.
2546
+ *
2547
+ * Gated on the model's own cacheable minimum, so a shared prefix too short to
2548
+ * cache is not reported as an opportunity — the same refusal `reorderForCache`
2549
+ * makes.
2550
+ */
2551
+ const prefixGroups = sharedPrefixes(seen.map((d) => ({ path: d.path, text: d.text })), { minTokens: cacheableMinimum(pricing.models.find((m) => m.id === usage.model)) });
2552
+ // Before the print, like every other file this repository writes: a report that
2553
+ // only appears when the terminal output was also wanted is a report a scheduled
2554
+ // job cannot rely on.
2555
+ await writeOtlp(args, () => toOtlpMetrics({
2556
+ prompts: seen.map((d) => ({
2557
+ path: d.path,
2558
+ tokens: d.tokens,
2559
+ overBudget: d.budget !== null && d.tokens > d.budget.maxTokens,
2560
+ budgeted: d.budget !== null,
2561
+ })),
2562
+ findings: findings.map((f) => ({ id: f.id, prompts: f.prompts, monthlyUsd: f.monthlyUsd })),
2563
+ model: usage.model,
2564
+ callsPerMonth: usage.callsPerMonth,
2565
+ }, Date.now()));
2566
+ printDoctor({ root, seen, unbudgeted, overBudget, findings, prefixGroups, skipped, truncated }, { args, usage, pricing, t });
2567
+ }
2568
+ function printDoctor(report, context) {
2569
+ const { root, seen, unbudgeted, overBudget, findings, prefixGroups, skipped, truncated } = report;
2570
+ const { args, usage, pricing, t } = context;
2571
+ const n = (value) => value.toLocaleString(t.numberLocale);
2572
+ if (boolFlag(args, 'json')) {
2573
+ console.log(JSON.stringify({
2574
+ root,
2575
+ prompts: seen.length,
2576
+ skippedSourceFiles: skipped,
2577
+ truncated,
2578
+ usage,
2579
+ pricingLastReviewed: pricing.lastReviewed,
2580
+ unbudgeted: unbudgeted.map((d) => d.path),
2581
+ overBudget: overBudget.map((d) => ({
2582
+ path: d.path,
2583
+ tokens: d.tokens,
2584
+ maxTokens: d.budget.maxTokens,
2585
+ pattern: d.budget.pattern,
2586
+ })),
2587
+ findings: findings.map((f) => ({
2588
+ id: f.id,
2589
+ prompts: f.prompts,
2590
+ estimatedMonthlyUsd: f.monthlyUsd,
2591
+ })),
2592
+ // No `estimatedMonthlyUsd` here, and consumers should not add one: see
2593
+ // shared-prefix.ts for why the cost model cannot price this.
2594
+ sharedPrefixes: prefixGroups.map((group) => ({
2595
+ paths: group.paths,
2596
+ tokens: group.tokens,
2597
+ blocks: group.blocks,
2598
+ drift: group.drift,
2599
+ })),
2600
+ }, null, 2));
2601
+ return;
2602
+ }
2603
+ const model = pricing.models.find((m) => m.id === usage.model);
2604
+ console.log(`\n${c.bold(t.doctor.heading(root, seen.length))}`);
2605
+ console.log(c.dim(t.doctor.subheading(model?.displayName ?? usage.model, n(usage.callsPerMonth))));
2606
+ console.log(c.dim(t.doctor.pricesReviewed(pricing.lastReviewed, reviewAgeDays(pricing.lastReviewed, new Date()))));
2607
+ // Budgets first. Everything below is money; this is whether anything is
2608
+ // watching at all, and an unwatched prompt is how the money got there.
2609
+ console.log(`\n${c.bold(t.doctor.budgetsHeading())}`);
2610
+ if (unbudgeted.length === 0 && overBudget.length === 0) {
2611
+ console.log(` ${c.green('✓')} ${t.doctor.everyPromptBudgeted(seen.length)}`);
2612
+ }
2613
+ if (overBudget.length > 0) {
2614
+ console.log(` ${c.red('✗')} ${t.doctor.overBudget(overBudget.length)}`);
2615
+ for (const d of overBudget) {
2616
+ console.log(` ${d.path} ${c.red(`${n(d.tokens)} / ${n(d.budget.maxTokens)}`)} ${c.dim(`(${d.budget.pattern})`)}`);
2617
+ }
2618
+ }
2619
+ if (unbudgeted.length > 0) {
2620
+ console.log(` ${c.yellow('!')} ${t.doctor.unbudgeted(unbudgeted.length, seen.length)}`);
2621
+ for (const d of unbudgeted.slice(0, DOCTOR_LIST_LIMIT)) {
2622
+ console.log(` ${c.dim(d.path)}`);
2623
+ }
2624
+ if (unbudgeted.length > DOCTOR_LIST_LIMIT) {
2625
+ console.log(` ${c.dim(t.doctor.andMore(unbudgeted.length - DOCTOR_LIST_LIMIT))}`);
2626
+ }
2627
+ }
2628
+ if (findings.length > 0) {
2629
+ console.log(`\n${c.bold(t.doctor.findingsHeading())}`);
2630
+ const width = Math.max(...findings.map((f) => (f.monthlyUsd === null ? 1 : formatUsd(f.monthlyUsd).length)));
2631
+ for (const finding of findings) {
2632
+ const money = finding.monthlyUsd === null
2633
+ ? ' '.repeat(width + 1)
2634
+ : c.green(`~${formatUsd(finding.monthlyUsd).padStart(width)}`);
2635
+ console.log(` ${money} ${finding.title} ${c.dim(t.doctor.acrossPrompts(finding.prompts))}`);
2636
+ }
2637
+ console.log(`\n ${c.dim(t.doctor.findingsNote())}`);
2638
+ }
2639
+ /**
2640
+ * Its own section, below the money, and not among the findings.
2641
+ *
2642
+ * Every line above carries a dollar figure or is one advisory `optimize` would
2643
+ * raise on a single file. This is neither, and putting it in that list would
2644
+ * make it look like a finding with the money left off — which is how a reader
2645
+ * concludes the tool forgot to compute something rather than that it declined
2646
+ * to guess.
2647
+ */
2648
+ if (prefixGroups.length > 0) {
2649
+ console.log(`\n${c.bold(t.doctor.sharedPrefixHeading())}`);
2650
+ for (const group of prefixGroups) {
2651
+ console.log(` ${c.yellow('!')} ${t.doctor.sharedPrefixGroup(group.paths.length, n(group.tokens), group.drift)}`);
2652
+ for (const path of group.paths.slice(0, DOCTOR_LIST_LIMIT)) {
2653
+ console.log(` ${c.dim(path)}`);
2654
+ }
2655
+ if (group.paths.length > DOCTOR_LIST_LIMIT) {
2656
+ console.log(` ${c.dim(t.doctor.andMore(group.paths.length - DOCTOR_LIST_LIMIT))}`);
2657
+ }
2658
+ console.log(` ${c.dim(t.doctor.sharedPrefixFix(group.drift))}`);
2659
+ }
2660
+ console.log(`\n ${c.dim(t.doctor.sharedPrefixNoFigure())}`);
2661
+ }
2662
+ if (skipped > 0)
2663
+ console.log(`\n${c.dim(t.rank.skipped(skipped))}`);
2664
+ if (truncated)
2665
+ console.log(`\n${c.dim(t.check.walkTruncated())}`);
2666
+ // Stated at the end, where somebody deciding what to do with the output is
2667
+ // looking. A survey that exits 1 becomes a gate, and a gate on a keyword
2668
+ // heuristic teaches people to re-run until green.
2669
+ console.log(`\n${c.dim(t.doctor.notAGate())}\n`);
2670
+ }
2671
+ /**
2672
+ * `trazum diff --all <before> <after>` — a whole prompt library, before and after.
2673
+ *
2674
+ * `diff` answers the question for one prompt. A team refactoring forty of them
2675
+ * wants the same question answered forty times and totalled, and running the
2676
+ * command forty times by hand loses the total — which is the figure the decision
2677
+ * actually turns on.
2678
+ *
2679
+ * **Prompts that exist on only one side are named, not silently skipped.** A
2680
+ * refactor that deletes a prompt and a refactor that renames one look identical
2681
+ * from a token count, and both are things a reviewer has to know about. Reporting
2682
+ * only the pairs would let a deletion read as a saving.
2683
+ *
2684
+ * `--max-growth` applies **per prompt**, not to the total, which follows the rule
2685
+ * `check` already states about budgets: a library is forty things to govern, and
2686
+ * summing them would pass a refactor that quietly doubled one prompt because
2687
+ * another shrank.
2688
+ */
2689
+ async function diffDirectories(beforeRoot, afterRoot, args, config, pricing, t, locale) {
2690
+ const level = levelFlag(args, config, t);
2691
+ const usage = usageFrom(args, config, t);
2692
+ const optimizeBoth = boolFlag(args, 'optimized');
2693
+ const extensions = config.extensions ?? [...DEFAULT_EXTENSIONS, ...SOURCE_EXTENSIONS];
2694
+ const [beforeWalk, afterWalk] = await Promise.all([
2695
+ walkPrompts(beforeRoot, { extensions }),
2696
+ walkPrompts(afterRoot, { extensions }),
2697
+ ]);
2698
+ if (beforeWalk.files.length === 0 && afterWalk.files.length === 0) {
2699
+ throw new Error(t.errors.noPromptsFound(`${beforeRoot}, ${afterRoot}`, extensions.join(' ')));
2700
+ }
2701
+ const beforeFiles = new Set(beforeWalk.files);
2702
+ const afterFiles = new Set(afterWalk.files);
2703
+ /** The prompt at a path, or null when the file holds no marked prompt. */
2704
+ const textAt = async (root, file) => {
2705
+ const raw = await readFile(join(root, file), 'utf8');
2706
+ let source;
2707
+ try {
2708
+ source = sourceFileOf(file, raw, pricing, stringFlag(args, 'prompt'));
2709
+ }
2710
+ catch {
2711
+ return null;
2712
+ }
2713
+ if (source === null && SOURCE_EXTENSIONS.some((ext) => file.toLowerCase().endsWith(ext))) {
2714
+ return null;
2715
+ }
2716
+ return source ? source.text : raw;
2717
+ };
2718
+ const pairs = [];
2719
+ let skipped = 0;
2720
+ for (const file of [...beforeFiles].filter((f) => afterFiles.has(f)).sort()) {
2721
+ const [before, after] = await Promise.all([textAt(beforeRoot, file), textAt(afterRoot, file)]);
2722
+ if (before === null || after === null) {
2723
+ skipped++;
2724
+ continue;
2725
+ }
2726
+ pairs.push({
2727
+ path: file,
2728
+ comparison: comparePrompts(before, after, { level, locale, optimizeBoth, usage, pricing }),
2729
+ });
2730
+ }
2731
+ const removed = [...beforeFiles].filter((f) => !afterFiles.has(f)).sort();
2732
+ const added = [...afterFiles].filter((f) => !beforeFiles.has(f)).sort();
2733
+ if (pairs.length === 0 && removed.length === 0 && added.length === 0) {
2734
+ throw new Error(t.errors.noPromptsFound(`${beforeRoot}, ${afterRoot}`, extensions.join(' ')));
2735
+ }
2736
+ // Worst first: a reviewer reads the top of this list and stops.
2737
+ pairs.sort((a, b) => b.comparison.tokenDelta - a.comparison.tokenDelta);
2738
+ printDirectoryDiff({ beforeRoot, afterRoot, pairs, removed, added, skipped, optimizeBoth }, { args, usage, pricing, t });
2739
+ const limit = typeof args.flags.get('max-growth') === 'string'
2740
+ ? numberFlag(args, 'max-growth', 0, t)
2741
+ : config.maxGrowth;
2742
+ if (limit !== undefined) {
2743
+ // Per prompt, not on the total. Summing would pass a refactor that doubled one
2744
+ // prompt because another happened to shrink — and the prompt that doubled is
2745
+ // the one somebody has to look at.
2746
+ const over = pairs.filter((p) => p.comparison.tokenDelta > limit);
2747
+ if (over.length > 0) {
2748
+ console.error(`\n${c.red(t.diff.someOverLimit(over.length, limit))}`);
2749
+ for (const p of over) {
2750
+ console.error(` ${p.path} ${c.red(`+${p.comparison.tokenDelta}`)}`);
2751
+ }
2752
+ process.exitCode = 1;
2753
+ }
2754
+ }
2755
+ }
2756
+ function printDirectoryDiff(report, context) {
2757
+ const { beforeRoot, afterRoot, pairs, removed, added, skipped, optimizeBoth } = report;
2758
+ const { args, usage, pricing, t } = context;
2759
+ const n = (value) => value.toLocaleString(t.numberLocale);
2760
+ const totalTokens = pairs.reduce((sum, p) => sum + p.comparison.tokenDelta, 0);
2761
+ const totalMonthly = pairs.reduce((sum, p) => sum + p.comparison.monthlyDeltaUsd, 0);
2762
+ if (boolFlag(args, 'json')) {
2763
+ console.log(JSON.stringify({
2764
+ before: beforeRoot,
2765
+ after: afterRoot,
2766
+ optimized: optimizeBoth,
2767
+ usage,
2768
+ totals: { tokenDelta: totalTokens, monthlyDeltaUsd: totalMonthly, prompts: pairs.length },
2769
+ prompts: pairs.map((p) => ({
2770
+ path: p.path,
2771
+ tokensBefore: p.comparison.tokensBefore,
2772
+ tokensAfter: p.comparison.tokensAfter,
2773
+ tokenDelta: p.comparison.tokenDelta,
2774
+ monthlyDeltaUsd: p.comparison.monthlyDeltaUsd,
2775
+ })),
2776
+ removed,
2777
+ added,
2778
+ skippedSourceFiles: skipped,
2779
+ }, null, 2));
2780
+ return;
2781
+ }
2782
+ const model = pricing.models.find((m) => m.id === usage.model);
2783
+ console.log(`\n${c.bold(t.diff.heading(beforeRoot, afterRoot))}`);
2784
+ console.log(c.dim(t.diff.allSubheading(pairs.length)));
2785
+ if (optimizeBoth)
2786
+ console.log(c.dim(t.diff.measuringOptimised()));
2787
+ // The convention, before any number. Every figure here is after minus before,
2788
+ // which is the opposite of the rest of Trazum, and a reader arriving from
2789
+ // `optimize` has the other one loaded.
2790
+ console.log(`\n${c.dim(t.diff.signConvention())}`);
2791
+ if (pairs.length > 0) {
2792
+ console.log();
2793
+ const width = Math.max(...pairs.map((p) => signedTokens(p.comparison.tokenDelta, n).length));
2794
+ for (const pair of pairs) {
2795
+ const delta = pair.comparison.tokenDelta;
2796
+ const text = signedTokens(delta, n).padStart(width);
2797
+ const paint = delta > 0 ? c.red : delta < 0 ? c.green : c.dim;
2798
+ console.log(` ${paint(text)} ${pair.path}`);
2799
+ }
2800
+ }
2801
+ if (removed.length > 0 || added.length > 0) {
2802
+ // Named rather than folded into the totals. A prompt that vanished is not a
2803
+ // saving of its whole token count; it is a question.
2804
+ console.log();
2805
+ for (const path of removed)
2806
+ console.log(` ${c.dim(t.diff.onlyBefore())} ${path}`);
2807
+ for (const path of added)
2808
+ console.log(` ${c.dim(t.diff.onlyAfter())} ${path}`);
2809
+ console.log(` ${c.dim(t.diff.onlyOneSideNote())}`);
2810
+ }
2811
+ if (skipped > 0)
2812
+ console.log(`\n${c.dim(t.rank.skipped(skipped))}`);
2813
+ if (pairs.length > 0) {
2814
+ const paint = totalTokens > 0 ? c.red : totalTokens < 0 ? c.green : c.dim;
2815
+ console.log(`\n${c.bold(t.diff.allTotal(signedTokens(totalTokens, n), pairs.length))}`);
2816
+ console.log(paint(t.diff.monthly(formatSignedUsd(totalMonthly), n(usage.callsPerMonth), model?.displayName ?? usage.model)));
2817
+ }
2818
+ console.log();
2819
+ }
2820
+ /** A token delta with its sign, always. A bare `40` is unreadable either way. */
2821
+ function signedTokens(delta, n) {
2822
+ return `${delta > 0 ? '+' : ''}${n(delta)}`;
2823
+ }
2824
+ //# sourceMappingURL=index.js.map