@trazum/cli 1.39.0 → 1.40.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trazum/cli",
3
- "version": "1.39.0",
3
+ "version": "1.40.0",
4
4
  "description": "Trazum CLI: find where your LLM bill goes, price every finding per month, and enforce token budgets in CI.",
5
5
  "license": "MIT",
6
6
  "author": "David Mu\u00f1oz Rey",
@@ -37,7 +37,7 @@
37
37
  "prepublishOnly": "npm run build && npm test"
38
38
  },
39
39
  "dependencies": {
40
- "@trazum/core": "1.39.0"
40
+ "@trazum/core": "1.40.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^26.2.0",
package/src/i18n/en.ts CHANGED
@@ -41,6 +41,7 @@ ${bold('USAGE')}
41
41
  trazum route <log.jsonl> --prompt-file <file> --cases <file> --yes
42
42
  trazum plan <log.jsonl|dir> [options]
43
43
  trazum verify <plan.json> --against <newer.jsonl|dir> [options]
44
+ trazum history <dir-of-stored-reports> [options]
44
45
  trazum diff <before> <after> [options]
45
46
  trazum diff --all <dir> <dir> [options]
46
47
  trazum rank <dir> [options]
@@ -318,6 +319,20 @@ ${bold('OPTIONS FOR plan')}
318
319
  a plan that hides its assumptions is advice pretending to be arithmetic.
319
320
  Projected savings and money already spent are separate totals throughout.
320
321
 
322
+ ${bold('OPTIONS FOR history')}
323
+ --markdown-out <file> Also write the series as Markdown, for a CI job
324
+ summary or a pull request comment.
325
+ --json The history as data.
326
+
327
+ Takes a directory of stored reports — the --json documents profile already
328
+ writes — plus any saved plans beside them, and builds the series no
329
+ pairwise comparison can see: a workload climbing a little every period, a
330
+ model share rising since a date, a cache share decaying slowly enough that
331
+ no single week's report called it a finding, and the same action planned
332
+ again and again with nothing executing it. Derived from stored reports,
333
+ never re-parsed logs, so a year of JSON is enough and the raw logs can be
334
+ thrown away. Shapes are named; nothing is forecast.
335
+
321
336
  ${bold('OPTIONS FOR verify')}
322
337
  --against <log|dir> The newer usage log the plan is held to. Required.
323
338
  --gate Exit 1 when an action did not produce what the
@@ -1432,6 +1447,39 @@ ${bold('EXAMPLES')}
1432
1447
  `Plan written to ${path}, dated. Keep it: a prediction nobody wrote down is a prediction nobody can be held to.`,
1433
1448
  },
1434
1449
 
1450
+ history: {
1451
+ noTarget: () =>
1452
+ 'Point this at a directory of stored reports: trazum history reports/. It reads the --json documents "trazum profile" writes (and any saved plans beside them) and builds the series no pairwise comparison can see.',
1453
+ needsThree: (count) =>
1454
+ `A series needs at least three dated reports, and this directory has ${count}. Two reports is a comparison, and "trazum profile --against" already does that better.`,
1455
+ heading: (periods, from, to) => `The long run: ${periods} periods, ${from} → ${to}`,
1456
+ periodRow: (name, usd, calls, days) => `${name} ${usd} · ${calls} calls · ${days} days`,
1457
+ runLabel: (label, periods, sinceName, from, to) =>
1458
+ `${label} has climbed for ${periods} consecutive periods since ${sinceName}: ${from} → ${to}. A shape, not a forecast.`,
1459
+ runModel: (model, periods, sinceName, from, to) =>
1460
+ `${model}'s share of the bill has climbed for ${periods} consecutive periods since ${sinceName}: ${from} → ${to}. The totals can look flat while the mix moves under them.`,
1461
+ runCache: (periods, sinceName, from, to) =>
1462
+ `The cache share has decayed for ${periods} consecutive periods since ${sinceName}: ${from} → ${to} — slowly enough that no single report called it a finding, which is exactly why a series exists.`,
1463
+ repeated: (kind, label, model, appearances, first, last) => {
1464
+ const what =
1465
+ kind === 'route'
1466
+ ? `Routing ${label} (${model})`
1467
+ : kind === 'batch'
1468
+ ? `Batching ${label} (${model})`
1469
+ : kind === 'route+batch'
1470
+ ? `Routing and batching ${label} (${model})`
1471
+ : kind === 'fix-truncation'
1472
+ ? `Fixing the truncation retries on ${label} (${model})`
1473
+ : `Fixing the cache on ${label} (${model})`;
1474
+ const span = first !== null && last !== null ? ` (${first} → ${last})` : '';
1475
+ return `${what} has been planned ${appearances} times${span} and is still in the newest plan — a decision nobody is revisiting.`;
1476
+ },
1477
+ undated: (name) => `${name} carries no span, so it is on no timeline above — named, never silently absorbed.`,
1478
+ unrecognized: (name) => `${name} is neither a stored report nor a saved plan, so it is in no series above.`,
1479
+ footer: () =>
1480
+ 'A series names shapes, not futures. Twenty points make a trend visible; they do not make next month knowable — where these lines go next is yours to judge.',
1481
+ },
1482
+
1435
1483
  verify: {
1436
1484
  noTarget: () =>
1437
1485
  'Point this at a saved plan and a newer log: trazum verify plan.json --against usage.jsonl. It says, per action, whether the change arrived, did not arrive, or cannot be told — and never fewer than those three.',
package/src/i18n/es.ts CHANGED
@@ -28,6 +28,7 @@ ${bold('USO')}
28
28
  trazum route <log.jsonl> --prompt-file <fichero> --cases <fichero> --yes
29
29
  trazum plan <log.jsonl|dir> [opciones]
30
30
  trazum verify <plan.json> --against <nuevo.jsonl|dir> [opciones]
31
+ trazum history <dir-de-informes-guardados> [opciones]
31
32
  trazum diff <antes> <después> [opciones]
32
33
  trazum diff --all <dir> <dir> [opciones]
33
34
  trazum rank <dir> [opciones]
@@ -325,6 +326,21 @@ ${bold('OPCIONES DE plan')}
325
326
  consejo haciéndose pasar por aritmética. El ahorro proyectado y el dinero ya
326
327
  gastado son totales separados en todas partes.
327
328
 
329
+ ${bold('OPCIONES DE history')}
330
+ --markdown-out <fichero> Escribe además la serie como Markdown, para un
331
+ resumen de CI o un comentario de pull request.
332
+ --json La historia como datos.
333
+
334
+ Toma un directorio de informes guardados — los documentos --json que
335
+ profile ya escribe — más los planes guardados que haya al lado, y construye
336
+ la serie que ninguna comparación por pares puede ver: una carga que sube un
337
+ poco cada período, una cuota de modelo creciendo desde una fecha, una cuota
338
+ de caché decayendo tan despacio que ningún informe semanal lo llamó
339
+ hallazgo, y la misma acción planificada una y otra vez sin que nadie la
340
+ ejecute. Derivada de informes guardados, nunca de registros re-parseados:
341
+ un año de JSON basta y los registros crudos pueden tirarse. Las formas se
342
+ nombran; nada se pronostica.
343
+
328
344
  ${bold('OPCIONES DE verify')}
329
345
  --against <log|dir> El registro de uso posterior al que se somete el
330
346
  plan. Obligatorio.
@@ -1454,6 +1470,39 @@ ${bold('EJEMPLOS')}
1454
1470
  `Plan escrito en ${path}, con fecha. Guárdalo: una predicción que nadie apuntó es una predicción que no se le puede exigir a nadie.`,
1455
1471
  },
1456
1472
 
1473
+ history: {
1474
+ noTarget: () =>
1475
+ 'Apunta esto a un directorio de informes guardados: trazum history informes/. Lee los documentos --json que escribe "trazum profile" (y los planes guardados que haya al lado) y construye la serie que ninguna comparación por pares puede ver.',
1476
+ needsThree: (count) =>
1477
+ `Una serie necesita al menos tres informes con fecha, y este directorio tiene ${count}. Dos informes son una comparación, y "trazum profile --against" ya la hace mejor.`,
1478
+ heading: (periods, from, to) => `La larga distancia: ${periods} períodos, ${from} → ${to}`,
1479
+ periodRow: (name, usd, calls, days) => `${name} ${usd} · ${calls} llamadas · ${days} días`,
1480
+ runLabel: (label, periods, sinceName, from, to) =>
1481
+ `${label} lleva ${periods} períodos consecutivos subiendo desde ${sinceName}: ${from} → ${to}. Una forma, no un pronóstico.`,
1482
+ runModel: (model, periods, sinceName, from, to) =>
1483
+ `La cuota de ${model} en la factura lleva ${periods} períodos consecutivos subiendo desde ${sinceName}: ${from} → ${to}. Los totales pueden parecer planos mientras la mezcla se mueve debajo.`,
1484
+ runCache: (periods, sinceName, from, to) =>
1485
+ `La cuota de caché lleva ${periods} períodos consecutivos decayendo desde ${sinceName}: ${from} → ${to} — tan despacio que ningún informe suelto lo llamó hallazgo, que es exactamente para lo que existe una serie.`,
1486
+ repeated: (kind, label, model, appearances, first, last) => {
1487
+ const what =
1488
+ kind === 'route'
1489
+ ? `Enrutar ${label} (${model})`
1490
+ : kind === 'batch'
1491
+ ? `Agrupar en batch ${label} (${model})`
1492
+ : kind === 'route+batch'
1493
+ ? `Enrutar y agrupar ${label} (${model})`
1494
+ : kind === 'fix-truncation'
1495
+ ? `Arreglar los reintentos por truncado de ${label} (${model})`
1496
+ : `Arreglar la caché de ${label} (${model})`;
1497
+ const span = first !== null && last !== null ? ` (${first} → ${last})` : '';
1498
+ return `${what} se ha planificado ${appearances} veces${span} y sigue en el plan más reciente — una decisión que nadie está revisando.`;
1499
+ },
1500
+ undated: (name) => `${name} no lleva período, así que no está en ninguna línea de tiempo de arriba — nombrado, nunca absorbido en silencio.`,
1501
+ unrecognized: (name) => `${name} no es ni un informe guardado ni un plan guardado, así que no está en ninguna serie de arriba.`,
1502
+ footer: () =>
1503
+ 'Una serie nombra formas, no futuros. Veinte puntos hacen visible una tendencia; no hacen conocible el mes que viene — adónde van estas líneas después lo juzgas tú.',
1504
+ },
1505
+
1457
1506
  verify: {
1458
1507
  noTarget: () =>
1459
1508
  'Apunta esto a un plan guardado y a un registro posterior: trazum verify plan.json --against usage.jsonl. Dice, por acción, si el cambio llegó, no llegó o no se puede saber — y nunca menos de esos tres.',
package/src/i18n/types.ts CHANGED
@@ -1072,6 +1072,28 @@ export interface CliMessages {
1072
1072
  wrote(path: string): string;
1073
1073
  };
1074
1074
 
1075
+ /**
1076
+ * `trazum history` — many reports over many periods, as one series.
1077
+ *
1078
+ * Shapes are named as consecutive movement, never a fitted line, and no
1079
+ * series becomes a forecast: where the line goes next stays the reader's.
1080
+ */
1081
+ history: {
1082
+ noTarget(): string;
1083
+ /** Under three dated reports there is no series — only the comparison --against already does. */
1084
+ needsThree(count: string): string;
1085
+ heading(periods: string, from: string, to: string): string;
1086
+ periodRow(name: string, usd: string, calls: string, days: string): string;
1087
+ runLabel(label: string, periods: string, sinceName: string, from: string, to: string): string;
1088
+ runModel(model: string, periods: string, sinceName: string, from: string, to: string): string;
1089
+ runCache(periods: string, sinceName: string, from: string, to: string): string;
1090
+ /** The same action in plan after plan: a decision nobody is executing. */
1091
+ repeated(kind: PlanActionKind, label: string, model: string, appearances: string, first: string | null, last: string | null): string;
1092
+ undated(name: string): string;
1093
+ unrecognized(name: string): string;
1094
+ footer(): string;
1095
+ };
1096
+
1075
1097
  /**
1076
1098
  * `trazum verify` — the plan held to the log that came after it.
1077
1099
  *
package/src/index.ts CHANGED
@@ -11,7 +11,9 @@ import {
11
11
  cacheableMinimum,
12
12
  analyzeCachePrefix,
13
13
  billLevers,
14
+ buildHistory,
14
15
  buildPlan,
16
+ storedReportFrom,
15
17
  verifyPlan,
16
18
  cacheEconomics,
17
19
  cacheHitRate,
@@ -79,8 +81,10 @@ import { cacheDir, cacheStats, cachingProvider, clearCache } from './suggest-cac
79
81
  import { dayOf, formatGap, median, spanDays } from './time.js';
80
82
  import type {
81
83
  FleetSource,
84
+ HistoryRun,
82
85
  MeasuredUsage,
83
86
  PlanDocument,
87
+ StoredReport,
84
88
  VerifiedAction,
85
89
  BaselineBreach,
86
90
  BaselineChange,
@@ -479,6 +483,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
479
483
  profile: ['json', 'pricing', 'pricing-live', 'against', 'what-if', 'markdown-out', 'csv-out', 'csv-shape', 'max-usd', 'max-growth-usd', 'max-cache-loss-usd', 'max-day-usd', 'max-session-usd', 'label', 'since', 'until', 'dry-run', 'markdown-summary', 'by-source'],
480
484
  plan: ['json', 'out', 'markdown-out', 'min-usd', 'pricing', 'pricing-live'],
481
485
  verify: ['against', 'gate', 'json', 'markdown-out', 'pricing', 'pricing-live'],
486
+ history: ['json', 'markdown-out'],
482
487
  route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
483
488
  eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
484
489
  prune: ['cases', 'concurrency', 'json', 'yes'],
@@ -2218,6 +2223,126 @@ function isoDate(): string {
2218
2223
  * metered API calls somebody was actually billed for — the bill exists wherever
2219
2224
  * Trazum happens to be running, so the host has no bearing on it.
2220
2225
  */
2226
+ /**
2227
+ * `trazum history <dir>` — many reports over many periods, as one series.
2228
+ *
2229
+ * Derived from *stored* `--json` documents, never re-parsed logs: a team can
2230
+ * keep a year of reports and throw the raw logs away, which is what the
2231
+ * privacy story requires anyway. Shapes are named — a climb, a decay, the
2232
+ * same action planned twice — and no series, however long, becomes a
2233
+ * forecast.
2234
+ */
2235
+ async function commandHistory(args: Args, t: CliMessages): Promise<void> {
2236
+ const path = args.positional[0];
2237
+ if (path === undefined) throw new Error(t.history.noTarget());
2238
+ const target = await stat(path).catch(() => null);
2239
+ if (!target?.isDirectory()) throw new Error(t.history.noTarget());
2240
+
2241
+ const entries = await readdir(path, { withFileTypes: true });
2242
+ const files = entries
2243
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
2244
+ .map((entry) => join(path, entry.name))
2245
+ .sort((a, b) => a.localeCompare(b));
2246
+
2247
+ const reports: StoredReport[] = [];
2248
+ const plans: (PlanDocument & { createdAt?: string })[] = [];
2249
+ const unrecognized: string[] = [];
2250
+ for (const file of files) {
2251
+ let parsed: unknown;
2252
+ try {
2253
+ parsed = JSON.parse(await readFile(file, 'utf8'));
2254
+ } catch {
2255
+ unrecognized.push(file);
2256
+ continue;
2257
+ }
2258
+ const report = storedReportFrom(file, parsed);
2259
+ if (report !== null) {
2260
+ reports.push(report);
2261
+ continue;
2262
+ }
2263
+ const maybePlan = parsed as PlanDocument & { createdAt?: string };
2264
+ if (maybePlan?.schemaVersion === 1 && Array.isArray(maybePlan.actions)) {
2265
+ plans.push(maybePlan);
2266
+ continue;
2267
+ }
2268
+ unrecognized.push(file);
2269
+ }
2270
+
2271
+ const history = buildHistory(reports, plans);
2272
+ if (history.periods.length < 3) {
2273
+ throw new Error(t.history.needsThree(String(history.periods.length)));
2274
+ }
2275
+
2276
+ const stamped = { ...history, unrecognizedFiles: unrecognized };
2277
+ const n = (value: number): string => value.toLocaleString(t.numberLocale);
2278
+ const day = (ms: number): string => new Date(ms).toISOString().slice(0, 10);
2279
+ const pct = (value: number): string => `${(value * 100).toFixed(1)}%`;
2280
+
2281
+ const runLine = (run: HistoryRun): string => {
2282
+ if (run.kind === 'label-spend-climbing') {
2283
+ const name = run.subject === UNLABELLED ? t.profile.unlabelled() : run.subject;
2284
+ return t.history.runLabel(name, n(run.periods), run.sinceName, formatUsd(run.from), formatUsd(run.to));
2285
+ }
2286
+ if (run.kind === 'model-share-climbing') {
2287
+ return t.history.runModel(run.subject, n(run.periods), run.sinceName, pct(run.from), pct(run.to));
2288
+ }
2289
+ return t.history.runCache(n(run.periods), run.sinceName, pct(run.from), pct(run.to));
2290
+ };
2291
+
2292
+ const lines = (md: boolean): string[] => {
2293
+ const out: string[] = [];
2294
+ const first = history.periods[0]!;
2295
+ const last = history.periods[history.periods.length - 1]!;
2296
+ const heading = t.history.heading(n(history.periods.length), day(first.fromMs), day(last.toMs));
2297
+ out.push(md ? `## ${heading}` : heading);
2298
+ for (const period of history.periods) {
2299
+ const row = t.history.periodRow(
2300
+ period.name,
2301
+ formatUsd(period.totalUsd),
2302
+ n(period.calls),
2303
+ ((period.toMs - period.fromMs) / 86_400_000).toFixed(1),
2304
+ );
2305
+ out.push(md ? `- ${row}` : ` ${row}`);
2306
+ }
2307
+ if (history.runs.length > 0) out.push('');
2308
+ for (const run of history.runs) {
2309
+ out.push(md ? `- ${runLine(run)}` : ` ! ${runLine(run)}`);
2310
+ }
2311
+ if (history.repeatedPlanActions.length > 0) out.push('');
2312
+ for (const repeat of history.repeatedPlanActions) {
2313
+ const name = repeat.label === UNLABELLED ? t.profile.unlabelled() : repeat.label;
2314
+ const row = t.history.repeated(
2315
+ repeat.kind,
2316
+ name,
2317
+ repeat.model,
2318
+ n(repeat.appearances),
2319
+ repeat.firstPlanned?.slice(0, 10) ?? null,
2320
+ repeat.lastPlanned?.slice(0, 10) ?? null,
2321
+ );
2322
+ out.push(md ? `- ${row}` : ` ! ${row}`);
2323
+ }
2324
+ for (const name of history.undatedReports) {
2325
+ out.push(md ? `- ${t.history.undated(name)}` : ` ${t.history.undated(name)}`);
2326
+ }
2327
+ for (const name of unrecognized) {
2328
+ out.push(md ? `- ${t.history.unrecognized(name)}` : ` ${t.history.unrecognized(name)}`);
2329
+ }
2330
+ out.push('');
2331
+ out.push(md ? `_${t.history.footer()}_` : ` ${t.history.footer()}`);
2332
+ return out;
2333
+ };
2334
+
2335
+ await writeMarkdown(args, () => lines(true).join('\n'));
2336
+
2337
+ if (boolFlag(args, 'json')) {
2338
+ console.log(JSON.stringify(stamped, null, 2));
2339
+ return;
2340
+ }
2341
+ const [head, ...rest] = lines(false);
2342
+ console.log(c.bold(head!));
2343
+ for (const row of rest) console.log(row === '' ? '' : wrap(row, 76, ' '));
2344
+ }
2345
+
2221
2346
  /**
2222
2347
  * `trazum verify <plan.json> --against <newer.jsonl|dir>` — did it work?
2223
2348
  *
@@ -6302,6 +6427,9 @@ async function main(): Promise<void> {
6302
6427
  case 'verify':
6303
6428
  await commandVerify(args, pricing, t);
6304
6429
  break;
6430
+ case 'history':
6431
+ await commandHistory(args, t);
6432
+ break;
6305
6433
  case 'route':
6306
6434
  await commandRoute(args, pricing, t);
6307
6435
  break;