@trazum/cli 1.42.0 → 1.43.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.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * One cycle of watching, and the state that survives a restart.
3
+ *
4
+ * `--once` is the primitive: pull the window, keep it, evaluate the gates,
5
+ * emit what crossed, save state. A cron entry runs exactly that, and so does
6
+ * every test. The foreground loop is this function in a timer, so there is one
7
+ * code path and no daemon-only behaviour that nobody exercises.
8
+ *
9
+ * **The state file is what makes a restart honest.** Without it a resumed
10
+ * watcher re-alerts on yesterday's crossing (noise nobody reads) and implies
11
+ * it was watching the whole time (a claim it cannot make). With it, the
12
+ * crossing stays quiet and the unwatched stretch gets named once.
13
+ */
14
+ import type { WatchCrossing } from '@trazum/core';
15
+ export declare const WATCH_STATE_FILE = ".trazum/watch.json";
16
+ export declare const WATCH_STATE_VERSION = 1;
17
+ export interface WatchState {
18
+ v: number;
19
+ /** When the last cycle ran, so a long silence can be told from a first run. */
20
+ lastCycleMs: number;
21
+ /** How far the measurements reached, for the coverage gap. */
22
+ lastCoveredToMs: number | null;
23
+ /** Gate keys already alerted on, so a restart is not amnesia. */
24
+ fired: Record<string, number>;
25
+ }
26
+ export declare function readWatchState(root: string): Promise<WatchState | null>;
27
+ export declare function writeWatchState(root: string, state: WatchState): Promise<void>;
28
+ /**
29
+ * Whether a webhook URL is one this tool will post to.
30
+ *
31
+ * **This is not the SSRF case and the difference matters.** `checkedEndpoint`
32
+ * exists because a *request body* must never name a host: an anonymous caller
33
+ * pointing a shared server at an internal address is somebody else's machine
34
+ * reaching somewhere it was never meant to. Here the URL is in the operator's
35
+ * own config, on their own machine, and pointing it at their own alerting
36
+ * daemon on loopback is the ordinary case rather than the attack.
37
+ *
38
+ * So loopback is allowed and plain http is allowed *only* there, while two
39
+ * rules stay absolute: no credentials embedded in the URL, because a URL ends
40
+ * up in logs and shell history; and https everywhere else, because an alert
41
+ * carries spend figures across a network.
42
+ */
43
+ export type WebhookRejection = 'invalid-url' | 'credentials-in-url' | 'insecure-scheme';
44
+ export declare function checkWebhook(raw: string): {
45
+ ok: true;
46
+ url: URL;
47
+ } | {
48
+ ok: false;
49
+ reason: WebhookRejection;
50
+ };
51
+ /**
52
+ * The alert payload.
53
+ *
54
+ * Figures and gate names, never prompt text — the store has never held any and
55
+ * neither does this. Every crossing carries its own provenance, so a receiver
56
+ * that fans these into a dashboard cannot lose track of what kind of number it
57
+ * is holding.
58
+ */
59
+ export interface WatchAlert {
60
+ schemaVersion: 1;
61
+ firedAtMs: number;
62
+ crossings: WatchCrossing[];
63
+ }
64
+ export declare function postWebhook(url: URL, alert: WatchAlert, fetchImpl?: typeof fetch): Promise<{
65
+ ok: boolean;
66
+ status: number | null;
67
+ error: string | null;
68
+ }>;
69
+ //# sourceMappingURL=watch-run.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"watch-run.d.ts","sourceRoot":"","sources":["../src/watch-run.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAKH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD,eAAO,MAAM,gBAAgB,uBAAuB,CAAC;AAErD,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,MAAM,WAAW,UAAU;IACzB,CAAC,EAAE,MAAM,CAAC;IACV,+EAA+E;IAC/E,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,iEAAiE;IACjE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC/B;AAED,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAU7E;AAED,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAIpF;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,gBAAgB,GAAG,aAAa,GAAG,oBAAoB,GAAG,iBAAiB,CAAC;AAExF,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,GAAG,EAAE,GAAG,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,gBAAgB,CAAA;CAAE,CAkB1G;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,UAAU;IACzB,aAAa,EAAE,CAAC,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,aAAa,EAAE,CAAC;CAC5B;AAED,wBAAsB,WAAW,CAC/B,GAAG,EAAE,GAAG,EACR,KAAK,EAAE,UAAU,EACjB,SAAS,GAAE,OAAO,KAAa,GAC9B,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC,CAmBvE"}
@@ -0,0 +1,79 @@
1
+ /**
2
+ * One cycle of watching, and the state that survives a restart.
3
+ *
4
+ * `--once` is the primitive: pull the window, keep it, evaluate the gates,
5
+ * emit what crossed, save state. A cron entry runs exactly that, and so does
6
+ * every test. The foreground loop is this function in a timer, so there is one
7
+ * code path and no daemon-only behaviour that nobody exercises.
8
+ *
9
+ * **The state file is what makes a restart honest.** Without it a resumed
10
+ * watcher re-alerts on yesterday's crossing (noise nobody reads) and implies
11
+ * it was watching the whole time (a claim it cannot make). With it, the
12
+ * crossing stays quiet and the unwatched stretch gets named once.
13
+ */
14
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
15
+ import { dirname, join } from 'node:path';
16
+ import { SAFE_FETCH_INIT } from '@trazum/core/node';
17
+ export const WATCH_STATE_FILE = '.trazum/watch.json';
18
+ export const WATCH_STATE_VERSION = 1;
19
+ export async function readWatchState(root) {
20
+ try {
21
+ const parsed = JSON.parse(await readFile(join(root, WATCH_STATE_FILE), 'utf8'));
22
+ if (parsed?.v !== WATCH_STATE_VERSION)
23
+ return null;
24
+ return parsed;
25
+ }
26
+ catch {
27
+ // No state, or state this version cannot read: a first cycle either way,
28
+ // which is a state the caller reports rather than an error.
29
+ return null;
30
+ }
31
+ }
32
+ export async function writeWatchState(root, state) {
33
+ const path = join(root, WATCH_STATE_FILE);
34
+ await mkdir(dirname(path), { recursive: true });
35
+ await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
36
+ }
37
+ export function checkWebhook(raw) {
38
+ let url;
39
+ try {
40
+ url = new URL(raw);
41
+ }
42
+ catch {
43
+ return { ok: false, reason: 'invalid-url' };
44
+ }
45
+ if (url.username !== '' || url.password !== '') {
46
+ return { ok: false, reason: 'credentials-in-url' };
47
+ }
48
+ const loopback = url.hostname === 'localhost' ||
49
+ url.hostname === '127.0.0.1' ||
50
+ url.hostname === '[::1]' ||
51
+ url.hostname === '::1';
52
+ if (url.protocol === 'https:')
53
+ return { ok: true, url };
54
+ if (url.protocol === 'http:' && loopback)
55
+ return { ok: true, url };
56
+ return { ok: false, reason: 'insecure-scheme' };
57
+ }
58
+ export async function postWebhook(url, alert, fetchImpl = fetch) {
59
+ try {
60
+ const response = await fetchImpl(url.toString(), {
61
+ ...SAFE_FETCH_INIT,
62
+ method: 'POST',
63
+ headers: { 'content-type': 'application/json' },
64
+ body: JSON.stringify(alert),
65
+ signal: AbortSignal.timeout(10_000),
66
+ });
67
+ return { ok: response.ok, status: response.status, error: null };
68
+ }
69
+ catch (error) {
70
+ /**
71
+ * A webhook that will not deliver must not take the alert down with it.
72
+ * The exit code and the stdout event have already carried the crossing;
73
+ * losing those because a receiver is down would make the quietest failure
74
+ * the loudest one.
75
+ */
76
+ return { ok: false, status: null, error: error instanceof Error ? error.message : String(error) };
77
+ }
78
+ }
79
+ //# sourceMappingURL=watch-run.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"watch-run.js","sourceRoot":"","sources":["../src/watch-run.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trazum/cli",
3
- "version": "1.42.0",
3
+ "version": "1.43.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.42.0"
40
+ "@trazum/core": "1.43.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^26.2.0",
package/src/i18n/en.ts CHANGED
@@ -44,6 +44,7 @@ ${bold('USAGE')}
44
44
  trazum history <dir-of-stored-reports> [options]
45
45
  trazum connect <anthropic|openai> [options]
46
46
  trazum store [--prune] [options]
47
+ trazum watch [--once | --interval 15m] [options]
47
48
  trazum diff <before> <after> [options]
48
49
  trazum diff --all <dir> <dir> [options]
49
50
  trazum rank <dir> [options]
@@ -321,6 +322,35 @@ ${bold('OPTIONS FOR plan')}
321
322
  a plan that hides its assumptions is advice pretending to be arithmetic.
322
323
  Projected savings and money already spent are separate totals throughout.
323
324
 
325
+ ${bold('OPTIONS FOR watch')}
326
+ --once One cycle: measure, keep, evaluate, emit,
327
+ remember. What a cron entry runs. The default.
328
+ --interval <n>m|h Stay in the foreground and repeat. Minimum five
329
+ minutes: usage APIs are rate limited, and a tight
330
+ loop is a way to get your own key throttled.
331
+ --webhook <url> POST the crossings somewhere. https only, except
332
+ loopback; a URL carrying credentials is refused,
333
+ because URLs end up in logs and shell history.
334
+ --payload <file> Evaluate a usage payload you already have,
335
+ instead of the store.
336
+ --json The cycle as data: crossings, abstentions, gap.
337
+
338
+ Evaluates the spend gates from your config — maxUsd, maxDayUsd,
339
+ maxCacheLossUsd — against what has been measured, and tells you the
340
+ afternoon it happens rather than three weeks later. Exits 1 when something
341
+ crossed, so cron mails it and CI fails.
342
+
343
+ An alert fires on a measured crossing and never on a projection: "you have
344
+ spent $412 of a $400 budget" is a fact and "you will exceed" is a forecast,
345
+ which this tool does not make at any window length. A day still being
346
+ measured is reported as not yet judgeable rather than passed — but a day
347
+ already over budget fires whatever the hour, because it does not become less
348
+ over budget at midnight.
349
+
350
+ A restart does not re-alert on a crossing already reported, and names the
351
+ stretch it was not watching, because a watcher that resumes in silence
352
+ implies coverage it did not have.
353
+
324
354
  ${bold('OPTIONS FOR store')}
325
355
  --prune Drop measurements older than the retention
326
356
  policy, and compact the append log to what the
@@ -1506,6 +1536,49 @@ ${bold('EXAMPLES')}
1506
1536
  `Plan written to ${path}, dated. Keep it: a prediction nobody wrote down is a prediction nobody can be held to.`,
1507
1537
  },
1508
1538
 
1539
+ watch: {
1540
+ noThresholds: () =>
1541
+ 'Watching needs something to watch for. Set spend.maxUsd, spend.maxDayUsd or spend.maxCacheLossUsd in trazum.config.json — a watcher with no threshold is a green light nobody earned.',
1542
+ nothingToWatch: (dir) =>
1543
+ `Nothing has been measured yet: the store at ${dir} is empty. Fill it with "trazum connect <provider> --store" first — watching nothing would report that everything is fine.`,
1544
+ intervalTooTight: () =>
1545
+ '--interval must be at least 5m. Usage APIs are rate limited, and a tight loop is a way to get your own key throttled by a tool that exists to save you money.',
1546
+ badWebhook: (reason) =>
1547
+ reason === 'credentials-in-url'
1548
+ ? 'That webhook URL carries credentials. URLs end up in logs, shell history and error messages, so this one is refused — put the secret in a header your receiver checks, or in the receiver itself.'
1549
+ : reason === 'insecure-scheme'
1550
+ ? 'A webhook must be https, except on loopback. An alert carries your spend figures, and sending them in the clear across a network is a leak you did not ask for.'
1551
+ : 'That webhook is not a URL this tool can parse.',
1552
+ crossed: (gate, measured, limit, day) => {
1553
+ const what =
1554
+ gate === 'maxUsd'
1555
+ ? 'Total spend'
1556
+ : gate === 'maxDayUsd'
1557
+ ? `Spend on ${day}`
1558
+ : 'Money lost to caching';
1559
+ return `CROSSED — ${what} is ${measured} against a limit of ${limit}. Measured, not projected.`;
1560
+ },
1561
+ stillOver: (gate, measured, limit, day) => {
1562
+ const what =
1563
+ gate === 'maxUsd'
1564
+ ? 'Total spend'
1565
+ : gate === 'maxDayUsd'
1566
+ ? `Spend on ${day}`
1567
+ : 'Money lost to caching';
1568
+ return `STILL OVER — ${what} is ${measured} against a limit of ${limit}, and was already reported. Quiet is not clean.`;
1569
+ },
1570
+ notJudgeable: (gate, reason, covered) =>
1571
+ reason === 'window-too-short'
1572
+ ? `${gate} cannot be judged yet: this period is ${covered ?? 'partly'} measured, and a threshold over part of a day is a threshold over something else. Not a pass — it will be judged when the day is in.`
1573
+ : `${gate} cannot be judged on this source, which does not serve what the gate is written against. Not a pass: a gate silently skipped reads exactly like a gate that keeps passing.`,
1574
+ gap: (from, to) =>
1575
+ `Nothing was watching between ${from} and ${to}. Whatever crossed in that stretch was not seen, and this line exists so a resumed watcher does not imply coverage it did not have.`,
1576
+ allWithin: (gates) => `Within every threshold: ${gates} gates evaluated against measured spend.`,
1577
+ webhookFailed: (status) =>
1578
+ `The webhook did not deliver (${status}). The crossing is still in the exit code and in the output above — a receiver being down must not be the quietest failure in the room.`,
1579
+ watching: (minutes) => `Watching every ${minutes} minutes. Ctrl-C stops it.`,
1580
+ },
1581
+
1509
1582
  store: {
1510
1583
  appended: (count, dir) => `Kept ${count} measurements in ${dir}.`,
1511
1584
  empty: (dir) =>
package/src/i18n/es.ts CHANGED
@@ -31,6 +31,7 @@ ${bold('USO')}
31
31
  trazum history <dir-de-informes-guardados> [opciones]
32
32
  trazum connect <anthropic|openai> [opciones]
33
33
  trazum store [--prune] [opciones]
34
+ trazum watch [--once | --interval 15m] [opciones]
34
35
  trazum diff <antes> <después> [opciones]
35
36
  trazum diff --all <dir> <dir> [opciones]
36
37
  trazum rank <dir> [opciones]
@@ -328,6 +329,37 @@ ${bold('OPCIONES DE plan')}
328
329
  consejo haciéndose pasar por aritmética. El ahorro proyectado y el dinero ya
329
330
  gastado son totales separados en todas partes.
330
331
 
332
+ ${bold('OPCIONES DE watch')}
333
+ --once Una vuelta: medir, guardar, evaluar, emitir,
334
+ recordar. Lo que ejecuta una entrada de cron. Es
335
+ lo que se hace por defecto.
336
+ --interval <n>m|h Se queda en primer plano y repite. Mínimo cinco
337
+ minutos: las APIs de uso están limitadas por
338
+ tasa, y un bucle apretado es una forma de que te
339
+ estrangulen tu propia clave.
340
+ --webhook <url> Envía los cruces por POST. Solo https, salvo en
341
+ loopback; una URL con credenciales se rechaza,
342
+ porque las URLs acaban en logs e historiales.
343
+ --payload <fichero> Evalúa un payload de uso que ya tengas, en vez
344
+ del almacén.
345
+ --json La vuelta como datos: cruces, abstenciones, hueco.
346
+
347
+ Evalúa los gates de gasto de tu configuración — maxUsd, maxDayUsd,
348
+ maxCacheLossUsd — contra lo que se ha medido, y te lo dice la tarde en que
349
+ pasa en vez de tres semanas después. Sale con 1 cuando algo cruzó, así que
350
+ cron te lo manda y CI falla.
351
+
352
+ Una alerta salta por un cruce medido y nunca por una proyección: "has
353
+ gastado $412 de un presupuesto de $400" es un hecho y "vas a excederte" es un
354
+ pronóstico, que esta herramienta no hace a ninguna escala de ventana. Un día
355
+ que todavía se está midiendo se reporta como aún no juzgable en vez de
356
+ aprobarse — pero un día que ya se pasó del presupuesto salta a cualquier
357
+ hora, porque no se pasa menos a medianoche.
358
+
359
+ Un reinicio no vuelve a avisar de un cruce ya reportado, y nombra el tramo
360
+ que no estuvo vigilando, porque un vigilante que se reanuda en silencio
361
+ insinúa una cobertura que no tuvo.
362
+
331
363
  ${bold('OPCIONES DE store')}
332
364
  --prune Borra las mediciones más antiguas que la política
333
365
  de retención y compacta el log a lo que el
@@ -1531,6 +1563,49 @@ ${bold('EJEMPLOS')}
1531
1563
  `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.`,
1532
1564
  },
1533
1565
 
1566
+ watch: {
1567
+ noThresholds: () =>
1568
+ 'Vigilar necesita algo que vigilar. Define spend.maxUsd, spend.maxDayUsd o spend.maxCacheLossUsd en trazum.config.json — un vigilante sin umbral es una luz verde que nadie se ha ganado.',
1569
+ nothingToWatch: (dir) =>
1570
+ `Todavía no se ha medido nada: el almacén de ${dir} está vacío. Llénalo primero con "trazum connect <proveedor> --store" — vigilar la nada reportaría que todo está bien.`,
1571
+ intervalTooTight: () =>
1572
+ '--interval tiene que ser de al menos 5m. Las APIs de uso están limitadas por tasa, y un bucle apretado es una forma de que una herramienta que existe para ahorrarte dinero acabe estrangulando tu propia clave.',
1573
+ badWebhook: (reason) =>
1574
+ reason === 'credentials-in-url'
1575
+ ? 'Esa URL de webhook lleva credenciales. Las URLs acaban en logs, historiales de shell y mensajes de error, así que se rechaza — pon el secreto en una cabecera que tu receptor compruebe, o en el propio receptor.'
1576
+ : reason === 'insecure-scheme'
1577
+ ? 'Un webhook tiene que ser https, salvo en loopback. Una alerta lleva tus cifras de gasto, y mandarlas en claro por una red es una fuga que no pediste.'
1578
+ : 'Ese webhook no es una URL que esta herramienta pueda parsear.',
1579
+ crossed: (gate, measured, limit, day) => {
1580
+ const what =
1581
+ gate === 'maxUsd'
1582
+ ? 'El gasto total'
1583
+ : gate === 'maxDayUsd'
1584
+ ? `El gasto del ${day}`
1585
+ : 'El dinero perdido con la caché';
1586
+ return `CRUZADO — ${what} es ${measured} contra un límite de ${limit}. Medido, no proyectado.`;
1587
+ },
1588
+ stillOver: (gate, measured, limit, day) => {
1589
+ const what =
1590
+ gate === 'maxUsd'
1591
+ ? 'El gasto total'
1592
+ : gate === 'maxDayUsd'
1593
+ ? `El gasto del ${day}`
1594
+ : 'El dinero perdido con la caché';
1595
+ return `SIGUE POR ENCIMA — ${what} es ${measured} contra un límite de ${limit}, y ya se avisó. Callado no es limpio.`;
1596
+ },
1597
+ notJudgeable: (gate, reason, covered) =>
1598
+ reason === 'window-too-short'
1599
+ ? `${gate} todavía no se puede juzgar: este período está medido al ${covered ?? 'parcialmente'}, y un umbral sobre parte de un día es un umbral sobre otra cosa. No es un aprobado — se juzgará cuando el día esté completo.`
1600
+ : `${gate} no se puede juzgar en esta fuente, que no sirve aquello sobre lo que está escrito el gate. No es un aprobado: un gate saltado en silencio se lee exactamente igual que un gate que lleva tiempo pasando.`,
1601
+ gap: (from, to) =>
1602
+ `Nada estuvo vigilando entre ${from} y ${to}. Lo que cruzara en ese tramo no se vio, y esta línea existe para que un vigilante reanudado no insinúe una cobertura que no tuvo.`,
1603
+ allWithin: (gates) => `Dentro de todos los umbrales: ${gates} gates evaluados contra gasto medido.`,
1604
+ webhookFailed: (status) =>
1605
+ `El webhook no se entregó (${status}). El cruce sigue en el código de salida y en la salida de arriba — que un receptor esté caído no puede ser el fallo más silencioso de la sala.`,
1606
+ watching: (minutes) => `Vigilando cada ${minutes} minutos. Ctrl-C lo para.`,
1607
+ },
1608
+
1534
1609
  store: {
1535
1610
  appended: (count, dir) => `Guardadas ${count} mediciones en ${dir}.`,
1536
1611
  empty: (dir) =>
package/src/i18n/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CannotTellReason, EvalVerdict, Locale, PlanActionKind, PlanAssumption, RuleLevel, VerifyOutcome } from '@trazum/core';
1
+ import type { CannotTellReason, EvalVerdict, Locale, NotJudgeable, PlanActionKind, PlanAssumption, RuleLevel, VerifyOutcome, WatchGate } from '@trazum/core';
2
2
 
3
3
  /**
4
4
  * The CLI's own message catalogue.
@@ -1072,6 +1072,32 @@ export interface CliMessages {
1072
1072
  wrote(path: string): string;
1073
1073
  };
1074
1074
 
1075
+ /**
1076
+ * `trazum watch` — the gates, evaluated as the money moves.
1077
+ *
1078
+ * The copy here carries the rule that makes an alert at 3am trustworthy: a
1079
+ * crossing is measured, never projected, and a period too short to judge is
1080
+ * said to be rather than passed.
1081
+ */
1082
+ watch: {
1083
+ /** Watching with no threshold configured is a green light nobody earned. */
1084
+ noThresholds(): string;
1085
+ nothingToWatch(dir: string): string;
1086
+ intervalTooTight(): string;
1087
+ badWebhook(reason: 'invalid-url' | 'credentials-in-url' | 'insecure-scheme'): string;
1088
+ /** A measured crossing. `day` names the afternoon when the gate is a day gate. */
1089
+ crossed(gate: WatchGate, measured: string, limit: string, day: string | null): string;
1090
+ /** Still over the limit, and already reported — quiet, but not clean. */
1091
+ stillOver(gate: WatchGate, measured: string, limit: string, day: string | null): string;
1092
+ /** Neither a pass nor a failure: this cannot be judged yet, and why. */
1093
+ notJudgeable(gate: WatchGate, reason: NotJudgeable, covered: string | null): string;
1094
+ /** The stretch nobody was watching, named rather than implied away. */
1095
+ gap(from: string, to: string): string;
1096
+ allWithin(gates: string): string;
1097
+ webhookFailed(status: string): string;
1098
+ watching(minutes: string): string;
1099
+ };
1100
+
1075
1101
  /**
1076
1102
  * `trazum store` — the measurements kept on disk.
1077
1103
  *
package/src/index.ts CHANGED
@@ -20,6 +20,8 @@ import {
20
20
  normalizeAnthropicUsage,
21
21
  normalizeOpenAIUsage,
22
22
  bucketsFromRecords,
23
+ evaluateWatch,
24
+ firedKey,
23
25
  pruneRecords,
24
26
  recordsFromBuckets,
25
27
  storeInventory,
@@ -146,6 +148,13 @@ import {
146
148
  import type { Revision } from './git.js';
147
149
  import { fetchProviderUsage } from './connect.js';
148
150
  import { STORE_DIR, appendRecords, readStore, rewriteStore } from './store-fs.js';
151
+ import {
152
+ WATCH_STATE_VERSION,
153
+ checkWebhook,
154
+ postWebhook,
155
+ readWatchState,
156
+ writeWatchState,
157
+ } from './watch-run.js';
149
158
  import { detectLocale, getCliMessages } from './i18n/index.js';
150
159
  import {
151
160
  MAX_SUMMARY_CHARS,
@@ -196,6 +205,8 @@ const VALUE_FLAGS = new Set([
196
205
  'min-usd',
197
206
  'payload',
198
207
  'keep',
208
+ 'interval',
209
+ 'webhook',
199
210
  // `route` takes a path here, and the flag is deliberately not `--prompt`:
200
211
  // everywhere else in this tool `--prompt` names a marked prompt *inside* a
201
212
  // source file, and reusing it for a path would be a trap laid for the reader.
@@ -501,6 +512,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
501
512
  history: ['store', 'json', 'markdown-out'],
502
513
  connect: ['since', 'until', 'payload', 'store', 'json', 'out', 'markdown-out', 'pricing', 'pricing-live', 'dry-run'],
503
514
  store: ['prune', 'keep', 'json', 'pricing', 'pricing-live', 'dry-run'],
515
+ watch: ['once', 'interval', 'since', 'payload', 'webhook', 'json', 'pricing', 'pricing-live'],
504
516
  route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
505
517
  eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
506
518
  prune: ['cases', 'concurrency', 'json', 'yes'],
@@ -2282,6 +2294,213 @@ function parseWhen(
2282
2294
  throw new Error(t.profile.badWhen(flag, value));
2283
2295
  }
2284
2296
 
2297
+ /**
2298
+ * `trazum watch` — the afternoon it happened, said that afternoon.
2299
+ *
2300
+ * One cycle is the primitive: measure, keep, evaluate, emit, remember. The
2301
+ * loop is that cycle in a timer, so a cron entry and a foreground watcher run
2302
+ * exactly the same code and the tests exercise the thing that ships.
2303
+ *
2304
+ * Three transports, all boring on purpose: a non-zero exit code so cron mails
2305
+ * it, a JSON event on stdout so any pipeline can read it, and a webhook for
2306
+ * the operator who already has somewhere for alerts to go. No hosted service
2307
+ * and no account.
2308
+ */
2309
+ async function commandWatch(
2310
+ args: Args,
2311
+ config: TrazumConfig,
2312
+ pricing: PricingCatalogue,
2313
+ t: CliMessages,
2314
+ ): Promise<void> {
2315
+ const root = process.cwd();
2316
+ const asJson = boolFlag(args, 'json');
2317
+ const n = (value: number): string => value.toLocaleString(t.numberLocale);
2318
+ const day = (msValue: number): string => new Date(msValue).toISOString().slice(0, 10);
2319
+
2320
+ const thresholds = {
2321
+ maxUsd: config.spend?.maxUsd,
2322
+ maxDayUsd: config.spend?.maxDayUsd,
2323
+ maxCacheLossUsd: config.spend?.maxCacheLossUsd,
2324
+ };
2325
+ if (
2326
+ thresholds.maxUsd === undefined &&
2327
+ thresholds.maxDayUsd === undefined &&
2328
+ thresholds.maxCacheLossUsd === undefined
2329
+ ) {
2330
+ throw new Error(t.watch.noThresholds());
2331
+ }
2332
+
2333
+ /**
2334
+ * A webhook is a new outbound surface, so it is checked before anything is
2335
+ * sent: credentials in a URL end up in logs and shell history, and an alert
2336
+ * carrying spend figures over plain http across a network is a leak the
2337
+ * operator did not ask for. Loopback is the exception, because pointing a
2338
+ * watcher at your own alerting daemon is the ordinary case.
2339
+ */
2340
+ const webhookRaw = stringFlag(args, 'webhook');
2341
+ let webhook: URL | null = null;
2342
+ if (webhookRaw !== undefined) {
2343
+ const checked = checkWebhook(webhookRaw);
2344
+ if (!checked.ok) throw new Error(t.watch.badWebhook(checked.reason));
2345
+ webhook = checked.url;
2346
+ }
2347
+
2348
+ const intervalRaw = stringFlag(args, 'interval');
2349
+ const once = boolFlag(args, 'once') || intervalRaw === undefined;
2350
+ let intervalMs = 0;
2351
+ if (!once) {
2352
+ const match = /^(\d+)(m|h)$/.exec(intervalRaw!);
2353
+ const amount = match === null ? NaN : Number(match[1]);
2354
+ intervalMs = match?.[2] === 'h' ? amount * 3_600_000 : amount * 60_000;
2355
+ // Usage APIs are rate limited, and a tight loop is a way to get somebody's
2356
+ // key throttled by a tool that was supposed to save them money.
2357
+ if (!Number.isFinite(intervalMs) || intervalMs < 5 * 60_000) {
2358
+ throw new Error(t.watch.intervalTooTight());
2359
+ }
2360
+ }
2361
+
2362
+ const cycle = async (): Promise<number> => {
2363
+ const state = await readWatchState(root);
2364
+ const nowMs = Date.now();
2365
+
2366
+ /**
2367
+ * Where the measurements come from: a saved payload when one is named
2368
+ * (which is how this is tested and how an air-gapped run works), and the
2369
+ * store otherwise. A cycle that found nothing to measure says so — a
2370
+ * watcher over nothing is a green light nobody earned.
2371
+ */
2372
+ const payloadPath = stringFlag(args, 'payload');
2373
+ let pull;
2374
+ if (payloadPath !== undefined) {
2375
+ pull = normalizeAnthropicUsage(JSON.parse(await readFile(payloadPath, 'utf8')));
2376
+ } else {
2377
+ const { resolved } = await readStore(root);
2378
+ if (resolved.records.length === 0) throw new Error(t.watch.nothingToWatch(STORE_DIR));
2379
+ pull = {
2380
+ provider: 'store',
2381
+ granularity: 'bucketed' as const,
2382
+ buckets: bucketsFromRecords(resolved.records),
2383
+ window: null,
2384
+ gaps: [],
2385
+ unavailable: [],
2386
+ };
2387
+ }
2388
+
2389
+ const report = bucketedProfile(pull, { catalogue: pricing });
2390
+ const cache = bucketedCacheEconomics(report);
2391
+ const result = evaluateWatch({
2392
+ report,
2393
+ thresholds,
2394
+ cacheDeltaUsd: cache.verdict === 'no-cache' ? undefined : cache.deltaUsd,
2395
+ nowMs,
2396
+ lastCoveredToMs: state?.lastCoveredToMs ?? undefined,
2397
+ alreadyFired: new Set(Object.keys(state?.fired ?? {})),
2398
+ });
2399
+
2400
+ if (asJson) {
2401
+ console.log(JSON.stringify({ schemaVersion: 1, firedAtMs: nowMs, ...result }, null, 2));
2402
+ } else {
2403
+ if (result.gap !== null) {
2404
+ console.log(c.yellow(wrap(t.watch.gap(day(result.gap.fromMs), day(result.gap.toMs)), 76, ' ')));
2405
+ }
2406
+ for (const crossing of result.crossings) {
2407
+ console.log(
2408
+ c.red(
2409
+ wrap(
2410
+ t.watch.crossed(
2411
+ crossing.gate,
2412
+ formatUsd(crossing.measuredUsd),
2413
+ formatUsd(crossing.limitUsd),
2414
+ crossing.day,
2415
+ ),
2416
+ 76,
2417
+ ' ',
2418
+ ),
2419
+ ),
2420
+ );
2421
+ }
2422
+ for (const abstention of result.abstentions) {
2423
+ console.log(
2424
+ c.dim(
2425
+ wrap(
2426
+ t.watch.notJudgeable(
2427
+ abstention.gate,
2428
+ abstention.reason,
2429
+ abstention.detail === null
2430
+ ? null
2431
+ : `${Math.round((abstention.detail.coveredMs / abstention.detail.neededMs) * 100)}%`,
2432
+ ),
2433
+ 76,
2434
+ ' ',
2435
+ ),
2436
+ ),
2437
+ );
2438
+ }
2439
+ for (const still of result.suppressed) {
2440
+ console.log(
2441
+ c.yellow(
2442
+ wrap(
2443
+ t.watch.stillOver(
2444
+ still.gate,
2445
+ formatUsd(still.measuredUsd),
2446
+ formatUsd(still.limitUsd),
2447
+ still.day,
2448
+ ),
2449
+ 76,
2450
+ ' ',
2451
+ ),
2452
+ ),
2453
+ );
2454
+ }
2455
+ if (
2456
+ result.crossings.length === 0 &&
2457
+ result.suppressed.length === 0 &&
2458
+ result.abstentions.length === 0
2459
+ ) {
2460
+ console.log(c.green(wrap(t.watch.allWithin(n(Object.keys(thresholds).filter((k) => thresholds[k as keyof typeof thresholds] !== undefined).length)), 76, ' ')));
2461
+ }
2462
+ }
2463
+
2464
+ if (webhook !== null && result.crossings.length > 0) {
2465
+ const sent = await postWebhook(webhook, {
2466
+ schemaVersion: 1,
2467
+ firedAtMs: nowMs,
2468
+ crossings: result.crossings,
2469
+ });
2470
+ if (!sent.ok) {
2471
+ // Reported and swallowed: the exit code and the event already carried
2472
+ // the crossing, and losing those because a receiver is down would make
2473
+ // the quietest failure the loudest one.
2474
+ console.error(c.yellow(t.watch.webhookFailed(sent.status === null ? sent.error ?? '' : String(sent.status))));
2475
+ }
2476
+ }
2477
+
2478
+ const fired = { ...(state?.fired ?? {}) };
2479
+ for (const crossing of result.crossings) fired[firedKey(crossing.gate, crossing.day)] = nowMs;
2480
+ await writeWatchState(root, {
2481
+ v: WATCH_STATE_VERSION,
2482
+ lastCycleMs: nowMs,
2483
+ lastCoveredToMs: report.span?.toMs ?? state?.lastCoveredToMs ?? null,
2484
+ fired,
2485
+ });
2486
+
2487
+ return result.crossings.length + result.suppressed.length;
2488
+ };
2489
+
2490
+ const crossed = await cycle();
2491
+ // Still over is still a failure: only the alert was already sent.
2492
+ if (crossed > 0) process.exitCode = 1;
2493
+ if (once) return;
2494
+
2495
+ console.log(c.dim(t.watch.watching(String(Math.round(intervalMs / 60_000)))));
2496
+ // The loop is the cycle in a timer and nothing more, so the primitive above
2497
+ // is the only thing that ever needs testing.
2498
+ for (;;) {
2499
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
2500
+ await cycle();
2501
+ }
2502
+ }
2503
+
2285
2504
  /**
2286
2505
  * `trazum store` — what is kept, and what a prune would take.
2287
2506
  *
@@ -6866,6 +7085,9 @@ async function main(): Promise<void> {
6866
7085
  case 'store':
6867
7086
  await commandStore(args, config, pricing, t);
6868
7087
  break;
7088
+ case 'watch':
7089
+ await commandWatch(args, config, pricing, t);
7090
+ break;
6869
7091
  case 'route':
6870
7092
  await commandRoute(args, pricing, t);
6871
7093
  break;