@trazum/cli 1.41.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,59 @@
1
+ /**
2
+ * Where the store actually lives.
3
+ *
4
+ * The core decides what a record is and when two are the same; this decides
5
+ * where the bytes go. Split that way for the reason every module here is:
6
+ * `@trazum/core` stays browser-safe, and the CLI keeps its monopoly on I/O.
7
+ *
8
+ * **Append-only, one buffer per write.** A pull appends a single block and
9
+ * never rewrites what is already there. Two consequences worth stating: a
10
+ * crash during a write loses the tail of one block rather than a year of
11
+ * measurements, and two runs writing at once interleave whole blocks rather
12
+ * than half-lines. Compaction is a separate, explicit errand — `store
13
+ * --prune` — because collapsing a log is the one operation that destroys
14
+ * something, and it should never happen as a side effect of a pull.
15
+ *
16
+ * **A line that will not parse is kept, counted and skipped.** The store is a
17
+ * file a human may open, a backup may truncate and a merge may mangle. Losing
18
+ * the whole month because one line is broken would be the worst possible
19
+ * response; so would silently pretending the month is complete.
20
+ */
21
+ import type { ResolvedStore, StoreRecord } from '@trazum/core';
22
+ /** The directory name, relative to wherever the caller roots the store. */
23
+ export declare const STORE_DIR = ".trazum/store";
24
+ export interface StoreReadResult {
25
+ resolved: ResolvedStore;
26
+ /** Lines that would not parse: counted and named by file, never dropped quietly. */
27
+ unreadable: {
28
+ file: string;
29
+ line: number;
30
+ }[];
31
+ /** Files read, so an empty store can be told from an unread one. */
32
+ files: string[];
33
+ }
34
+ /**
35
+ * Reads every record in the store.
36
+ *
37
+ * Returns an empty result rather than throwing when the store does not exist:
38
+ * "you have not stored anything yet" is a state, not an error, and the caller
39
+ * says so in a sentence that names `trazum connect`.
40
+ */
41
+ export declare function readStore(root: string): Promise<StoreReadResult>;
42
+ /**
43
+ * Appends records, grouped into one write per month file.
44
+ *
45
+ * Nothing already on disk is read, rewritten or resolved here: convergence
46
+ * happens when the store is *read*, which is what keeps a write cheap enough
47
+ * to run on a schedule and impossible to corrupt by racing.
48
+ */
49
+ export declare function appendRecords(root: string, records: readonly StoreRecord[]): Promise<number>;
50
+ /**
51
+ * Rewrites the store with exactly the records given.
52
+ *
53
+ * The one operation that destroys something, so it is only ever reached from
54
+ * an explicit `--prune`. Each month file is written whole, and a month left
55
+ * with nothing is written empty rather than removed — a missing file and an
56
+ * empty one say different things to whoever looks next.
57
+ */
58
+ export declare function rewriteStore(root: string, records: readonly StoreRecord[]): Promise<void>;
59
+ //# sourceMappingURL=store-fs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store-fs.d.ts","sourceRoot":"","sources":["../src/store-fs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAKH,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAE/D,2EAA2E;AAC3E,eAAO,MAAM,SAAS,kBAAkB,CAAC;AAOzC,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,aAAa,CAAC;IACxB,oFAAoF;IACpF,UAAU,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC7C,oEAAoE;IACpE,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED;;;;;;GAMG;AACH,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC,CA2CtE;AAED;;;;;;GAMG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,WAAW,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAiBlG;AAED;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,WAAW,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA4B/F"}
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Where the store actually lives.
3
+ *
4
+ * The core decides what a record is and when two are the same; this decides
5
+ * where the bytes go. Split that way for the reason every module here is:
6
+ * `@trazum/core` stays browser-safe, and the CLI keeps its monopoly on I/O.
7
+ *
8
+ * **Append-only, one buffer per write.** A pull appends a single block and
9
+ * never rewrites what is already there. Two consequences worth stating: a
10
+ * crash during a write loses the tail of one block rather than a year of
11
+ * measurements, and two runs writing at once interleave whole blocks rather
12
+ * than half-lines. Compaction is a separate, explicit errand — `store
13
+ * --prune` — because collapsing a log is the one operation that destroys
14
+ * something, and it should never happen as a side effect of a pull.
15
+ *
16
+ * **A line that will not parse is kept, counted and skipped.** The store is a
17
+ * file a human may open, a backup may truncate and a merge may mangle. Losing
18
+ * the whole month because one line is broken would be the worst possible
19
+ * response; so would silently pretending the month is complete.
20
+ */
21
+ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
22
+ import { join } from 'node:path';
23
+ import { resolveStore } from '@trazum/core';
24
+ /** The directory name, relative to wherever the caller roots the store. */
25
+ export const STORE_DIR = '.trazum/store';
26
+ /** Records are filed by the UTC month their window starts in. */
27
+ function monthOf(record) {
28
+ return new Date(record.fromMs).toISOString().slice(0, 7);
29
+ }
30
+ /**
31
+ * Reads every record in the store.
32
+ *
33
+ * Returns an empty result rather than throwing when the store does not exist:
34
+ * "you have not stored anything yet" is a state, not an error, and the caller
35
+ * says so in a sentence that names `trazum connect`.
36
+ */
37
+ export async function readStore(root) {
38
+ const dir = join(root, STORE_DIR);
39
+ const records = [];
40
+ const unreadable = [];
41
+ const files = [];
42
+ let providers;
43
+ try {
44
+ const entries = await readdir(dir, { withFileTypes: true });
45
+ providers = entries.filter((e) => e.isDirectory()).map((e) => e.name);
46
+ }
47
+ catch {
48
+ return { resolved: resolveStore([]), unreadable, files };
49
+ }
50
+ for (const provider of providers.sort()) {
51
+ const providerDir = join(dir, provider);
52
+ let months;
53
+ try {
54
+ months = (await readdir(providerDir)).filter((name) => name.endsWith('.jsonl')).sort();
55
+ }
56
+ catch {
57
+ continue;
58
+ }
59
+ for (const month of months) {
60
+ const path = join(providerDir, month);
61
+ files.push(join(STORE_DIR, provider, month));
62
+ const text = await readFile(path, 'utf8');
63
+ for (const [index, line] of text.split('\n').entries()) {
64
+ if (line.trim() === '')
65
+ continue;
66
+ try {
67
+ const parsed = JSON.parse(line);
68
+ if (typeof parsed?.provider === 'string' && typeof parsed?.fromMs === 'number') {
69
+ records.push(parsed);
70
+ }
71
+ else {
72
+ unreadable.push({ file: join(STORE_DIR, provider, month), line: index + 1 });
73
+ }
74
+ }
75
+ catch {
76
+ unreadable.push({ file: join(STORE_DIR, provider, month), line: index + 1 });
77
+ }
78
+ }
79
+ }
80
+ }
81
+ return { resolved: resolveStore(records), unreadable, files };
82
+ }
83
+ /**
84
+ * Appends records, grouped into one write per month file.
85
+ *
86
+ * Nothing already on disk is read, rewritten or resolved here: convergence
87
+ * happens when the store is *read*, which is what keeps a write cheap enough
88
+ * to run on a schedule and impossible to corrupt by racing.
89
+ */
90
+ export async function appendRecords(root, records) {
91
+ if (records.length === 0)
92
+ return 0;
93
+ const byFile = new Map();
94
+ for (const record of records) {
95
+ const key = join(record.provider, `${monthOf(record)}.jsonl`);
96
+ const list = byFile.get(key) ?? [];
97
+ list.push(record);
98
+ byFile.set(key, list);
99
+ }
100
+ for (const [relative, list] of byFile) {
101
+ const path = join(root, STORE_DIR, relative);
102
+ await mkdir(join(path, '..'), { recursive: true });
103
+ const block = `${list.map((record) => JSON.stringify(record)).join('\n')}\n`;
104
+ await writeFile(path, block, { flag: 'a', mode: 0o600 });
105
+ }
106
+ return records.length;
107
+ }
108
+ /**
109
+ * Rewrites the store with exactly the records given.
110
+ *
111
+ * The one operation that destroys something, so it is only ever reached from
112
+ * an explicit `--prune`. Each month file is written whole, and a month left
113
+ * with nothing is written empty rather than removed — a missing file and an
114
+ * empty one say different things to whoever looks next.
115
+ */
116
+ export async function rewriteStore(root, records) {
117
+ const dir = join(root, STORE_DIR);
118
+ const existing = new Set();
119
+ try {
120
+ for (const provider of await readdir(dir)) {
121
+ for (const month of await readdir(join(dir, provider)).catch(() => [])) {
122
+ if (month.endsWith('.jsonl'))
123
+ existing.add(join(provider, month));
124
+ }
125
+ }
126
+ }
127
+ catch {
128
+ // Nothing stored yet: the writes below create what is needed.
129
+ }
130
+ const byFile = new Map();
131
+ for (const record of records) {
132
+ const key = join(record.provider, `${monthOf(record)}.jsonl`);
133
+ const list = byFile.get(key) ?? [];
134
+ list.push(record);
135
+ byFile.set(key, list);
136
+ }
137
+ for (const relative of new Set([...existing, ...byFile.keys()])) {
138
+ const list = byFile.get(relative) ?? [];
139
+ const path = join(dir, relative);
140
+ await mkdir(join(path, '..'), { recursive: true });
141
+ const block = list.length === 0 ? '' : `${list.map((r) => JSON.stringify(r)).join('\n')}\n`;
142
+ await writeFile(path, block, { mode: 0o600 });
143
+ }
144
+ }
145
+ //# sourceMappingURL=store-fs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store-fs.js","sourceRoot":"","sources":["../src/store-fs.ts"],"names":[],"mappings":"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"}
@@ -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.41.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.41.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
@@ -43,6 +43,8 @@ ${bold('USAGE')}
43
43
  trazum verify <plan.json> --against <newer.jsonl|dir> [options]
44
44
  trazum history <dir-of-stored-reports> [options]
45
45
  trazum connect <anthropic|openai> [options]
46
+ trazum store [--prune] [options]
47
+ trazum watch [--once | --interval 15m] [options]
46
48
  trazum diff <before> <after> [options]
47
49
  trazum diff --all <dir> <dir> [options]
48
50
  trazum rank <dir> [options]
@@ -320,6 +322,54 @@ ${bold('OPTIONS FOR plan')}
320
322
  a plan that hides its assumptions is advice pretending to be arithmetic.
321
323
  Projected savings and money already spent are separate totals throughout.
322
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
+
354
+ ${bold('OPTIONS FOR store')}
355
+ --prune Drop measurements older than the retention
356
+ policy, and compact the append log to what the
357
+ store already resolves to. Says what went.
358
+ --keep <n>d Retention for this run, when the config has none.
359
+ --dry-run With --prune: say what would go, and delete
360
+ nothing.
361
+ --json The inventory as data.
362
+
363
+ Says what the local store holds: how many measurements, over what span, per
364
+ provider, and what a prune would take. The store keeps aggregates and
365
+ billing fields — never prompt text, never completion text, never a
366
+ credential — so it is a file a team can back up without a privacy review.
367
+
368
+ Pruning is the one operation here that destroys something, so it refuses to
369
+ run without a retention policy: set "store": {"keepDays": 90} in the config
370
+ or pass --keep. Deleting measurements on a policy nobody wrote down is not a
371
+ default anybody should get by accident.
372
+
323
373
  ${bold('OPTIONS FOR connect')}
324
374
  --since <when> The window to pull. A UTC day, an ISO timestamp,
325
375
  --until <when> a relative window (7d, 24h) or "now". Defaults to
@@ -330,6 +380,9 @@ ${bold('OPTIONS FOR connect')}
330
380
  --payload <file> Price a usage payload you already have, instead of
331
381
  pulling one. No credential, no network — the same
332
382
  arithmetic on the same shape.
383
+ --store Keep what was pulled in the local store, so the
384
+ next run does not download it again and "trazum
385
+ history --store" has a series.
333
386
  -o, --out <file> Save the priced report as JSON.
334
387
  --markdown-out <file> Also write it as Markdown, for a CI job summary.
335
388
  --json The report as data.
@@ -350,6 +403,12 @@ ${bold('OPTIONS FOR connect')}
350
403
  that quietly describes less traffic than you asked about.
351
404
 
352
405
  ${bold('OPTIONS FOR history')}
406
+ --store Build the series from the local store instead of
407
+ a directory of stored reports. Bucketed sources
408
+ carry no label, so the label series is absent and
409
+ said to be — the model-share and cache-share
410
+ series are what a series exists for, and both
411
+ work.
353
412
  --markdown-out <file> Also write the series as Markdown, for a CI job
354
413
  summary or a pull request comment.
355
414
  --json The history as data.
@@ -1477,6 +1536,80 @@ ${bold('EXAMPLES')}
1477
1536
  `Plan written to ${path}, dated. Keep it: a prediction nobody wrote down is a prediction nobody can be held to.`,
1478
1537
  },
1479
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
+
1582
+ store: {
1583
+ appended: (count, dir) => `Kept ${count} measurements in ${dir}.`,
1584
+ empty: (dir) =>
1585
+ `The store at ${dir} is empty. Fill it with "trazum connect <provider> --store" — that is a state, not an error.`,
1586
+ heading: (records, usd, from, to) =>
1587
+ `The store: ${records} measurements · ${usd} · ${from} → ${to}`,
1588
+ providerRow: (provider, records, span, models) =>
1589
+ `${provider} ${records} measurements · ${span} · ${models} models`,
1590
+ holds: (files) =>
1591
+ `Held in ${files} files: token counts, billed dollars and the account's own workspace and key identifiers. Never prompt text, never completion text, never a credential — this is a file you can back up without a privacy review.`,
1592
+ possiblyDouble: (count) =>
1593
+ `${count} records could not be told apart from another — a window of no length, or a record naming no model. They are kept whole rather than merged, so a total built on them may count the same spend twice. Saying so beats a smaller number nobody can check.`,
1594
+ unknownVersion: (count) =>
1595
+ `${count} records come from a newer schema than this version knows, so they are kept and left out of the figures above rather than guessed at. Upgrade to read them.`,
1596
+ unreadable: (file, line) =>
1597
+ `${file} line ${line} would not parse, so it is not in the figures above. The rest of the file was read — one broken line must not lose a month.`,
1598
+ retention: (days) => `Retention: ${days} days, from "store.keepDays". Run "trazum store --prune" to apply it.`,
1599
+ noRetention: () =>
1600
+ 'No retention policy is configured, so nothing is ever deleted on its own. Set "store": {"keepDays": 90} when you want one.',
1601
+ pruneNeedsPolicy: () =>
1602
+ 'Pruning needs a retention policy: set "store": {"keepDays": 90} in trazum.config.json, or pass --keep 90d for this run. Deleting measurements on a policy nobody wrote down is not a default you should get by accident.',
1603
+ pruneDryRun: (count, days, span, usd) =>
1604
+ span === null
1605
+ ? `Nothing is older than ${days} days, so a prune would delete nothing.`
1606
+ : `A prune would delete ${count} measurements older than ${days} days, covering ${span} and ${usd} of measured spend. Nothing was deleted — this was --dry-run.`,
1607
+ pruned: (count, days, span, usd, kept) =>
1608
+ span === null
1609
+ ? `Nothing was older than ${days} days. ${kept} measurements kept, and the append log compacted.`
1610
+ : `Deleted ${count} measurements older than ${days} days, covering ${span} and ${usd} of measured spend. ${kept} kept, and the append log compacted to what the store already resolved to.`,
1611
+ },
1612
+
1480
1613
  connect: {
1481
1614
  noTarget: (providers) =>
1482
1615
  `Name a provider to read your bill from: trazum connect anthropic. Available: ${providers}. The credential comes from the environment and is never stored — add --dry-run to see exactly what would be called and which variable it would be read from.`,
@@ -1514,7 +1647,8 @@ ${bold('EXAMPLES')}
1514
1647
  needsThree: (count) =>
1515
1648
  `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.`,
1516
1649
  heading: (periods, from, to) => `The long run: ${periods} periods, ${from} → ${to}`,
1517
- periodRow: (name, usd, calls, days) => `${name} ${usd} · ${calls} calls · ${days} days`,
1650
+ periodRow: (name, usd, calls, days) =>
1651
+ calls === null ? `${name} ${usd} · ${days} days` : `${name} ${usd} · ${calls} calls · ${days} days`,
1518
1652
  runLabel: (label, periods, sinceName, from, to) =>
1519
1653
  `${label} has climbed for ${periods} consecutive periods since ${sinceName}: ${from} → ${to}. A shape, not a forecast.`,
1520
1654
  runModel: (model, periods, sinceName, from, to) =>
@@ -1535,6 +1669,8 @@ ${bold('EXAMPLES')}
1535
1669
  const span = first !== null && last !== null ? ` (${first} → ${last})` : '';
1536
1670
  return `${what} has been planned ${appearances} times${span} and is still in the newest plan — a decision nobody is revisiting.`;
1537
1671
  },
1672
+ storeNoLabels: () =>
1673
+ 'This series comes from the store, and a usage API groups by model and workspace rather than by workload — so there is no label series here at all. Absent, not empty: nothing above says a workload did or did not move.',
1538
1674
  undated: (name) => `${name} carries no span, so it is on no timeline above — named, never silently absorbed.`,
1539
1675
  unrecognized: (name) => `${name} is neither a stored report nor a saved plan, so it is in no series above.`,
1540
1676
  footer: () =>