@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.
package/src/index.ts CHANGED
@@ -19,6 +19,12 @@ import {
19
19
  CONNECTORS,
20
20
  normalizeAnthropicUsage,
21
21
  normalizeOpenAIUsage,
22
+ bucketsFromRecords,
23
+ evaluateWatch,
24
+ firedKey,
25
+ pruneRecords,
26
+ recordsFromBuckets,
27
+ storeInventory,
22
28
  storedReportFrom,
23
29
  verifyPlan,
24
30
  cacheEconomics,
@@ -141,6 +147,14 @@ import {
141
147
  } from './git.js';
142
148
  import type { Revision } from './git.js';
143
149
  import { fetchProviderUsage } from './connect.js';
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';
144
158
  import { detectLocale, getCliMessages } from './i18n/index.js';
145
159
  import {
146
160
  MAX_SUMMARY_CHARS,
@@ -190,6 +204,9 @@ const VALUE_FLAGS = new Set([
190
204
  'from-log',
191
205
  'min-usd',
192
206
  'payload',
207
+ 'keep',
208
+ 'interval',
209
+ 'webhook',
193
210
  // `route` takes a path here, and the flag is deliberately not `--prompt`:
194
211
  // everywhere else in this tool `--prompt` names a marked prompt *inside* a
195
212
  // source file, and reusing it for a path would be a trap laid for the reader.
@@ -492,8 +509,10 @@ const COMMAND_FLAGS: Record<string, string[]> = {
492
509
  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'],
493
510
  plan: ['json', 'out', 'markdown-out', 'min-usd', 'pricing', 'pricing-live'],
494
511
  verify: ['against', 'gate', 'json', 'markdown-out', 'pricing', 'pricing-live'],
495
- history: ['json', 'markdown-out'],
496
- connect: ['since', 'until', 'payload', 'json', 'out', 'markdown-out', 'pricing', 'pricing-live', 'dry-run'],
512
+ history: ['store', 'json', 'markdown-out'],
513
+ connect: ['since', 'until', 'payload', 'store', 'json', 'out', 'markdown-out', 'pricing', 'pricing-live', 'dry-run'],
514
+ store: ['prune', 'keep', 'json', 'pricing', 'pricing-live', 'dry-run'],
515
+ watch: ['once', 'interval', 'since', 'payload', 'webhook', 'json', 'pricing', 'pricing-live'],
497
516
  route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
498
517
  eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
499
518
  prune: ['cases', 'concurrency', 'json', 'yes'],
@@ -2275,6 +2294,369 @@ function parseWhen(
2275
2294
  throw new Error(t.profile.badWhen(flag, value));
2276
2295
  }
2277
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
+
2504
+ /**
2505
+ * `trazum store` — what is kept, and what a prune would take.
2506
+ *
2507
+ * The store is the one thing in this product that *deletes* something, so the
2508
+ * errands around it are written to make that visible: the inventory says what
2509
+ * is there and how far back, and `--prune` names what went with the span it
2510
+ * covered. Retention with no policy written down is refused rather than
2511
+ * defaulted — deleting measurements on a guess is not something anybody
2512
+ * should receive by accident.
2513
+ */
2514
+ async function commandStore(
2515
+ args: Args,
2516
+ config: TrazumConfig,
2517
+ pricing: PricingCatalogue,
2518
+ t: CliMessages,
2519
+ ): Promise<void> {
2520
+ const root = process.cwd();
2521
+ const { resolved, unreadable, files } = await readStore(root);
2522
+ const inventory = storeInventory(resolved);
2523
+ const n = (value: number): string => value.toLocaleString(t.numberLocale);
2524
+ const day = (msValue: number): string => new Date(msValue).toISOString().slice(0, 10);
2525
+
2526
+ const priced = bucketedProfile(
2527
+ {
2528
+ provider: 'store',
2529
+ granularity: 'bucketed',
2530
+ buckets: bucketsFromRecords(resolved.records),
2531
+ window: inventory.span,
2532
+ gaps: [],
2533
+ unavailable: [],
2534
+ },
2535
+ { catalogue: pricing },
2536
+ );
2537
+
2538
+ if (boolFlag(args, 'prune')) {
2539
+ const keepFlag = stringFlag(args, 'keep');
2540
+ const keepDays = keepFlag !== undefined
2541
+ ? Number(/^(\d+)d?$/.exec(keepFlag)?.[1] ?? NaN)
2542
+ : config.store?.keepDays;
2543
+ if (keepDays === undefined || !Number.isFinite(keepDays) || keepDays <= 0) {
2544
+ throw new Error(t.store.pruneNeedsPolicy());
2545
+ }
2546
+ const cutoff = Date.now() - keepDays * 86_400_000;
2547
+ const result = pruneRecords(resolved.records, cutoff);
2548
+ const droppedUsd = bucketedProfile(
2549
+ {
2550
+ provider: 'store',
2551
+ granularity: 'bucketed',
2552
+ buckets: bucketsFromRecords(result.dropped),
2553
+ window: null,
2554
+ gaps: [],
2555
+ unavailable: [],
2556
+ },
2557
+ { catalogue: pricing },
2558
+ ).total.totalUsd;
2559
+
2560
+ if (boolFlag(args, 'dry-run')) {
2561
+ console.log(
2562
+ wrap(
2563
+ t.store.pruneDryRun(
2564
+ n(result.dropped.length),
2565
+ String(keepDays),
2566
+ result.droppedSpan === null
2567
+ ? null
2568
+ : `${day(result.droppedSpan.fromMs)} → ${day(result.droppedSpan.toMs)}`,
2569
+ formatUsd(droppedUsd),
2570
+ ),
2571
+ 76,
2572
+ ' ',
2573
+ ),
2574
+ );
2575
+ return;
2576
+ }
2577
+
2578
+ // The prune also collapses the append log to what the store resolves to,
2579
+ // which is the only moment a rewrite is safe: it is what the reader was
2580
+ // already seeing.
2581
+ await rewriteStore(root, result.kept);
2582
+ console.log(
2583
+ wrap(
2584
+ t.store.pruned(
2585
+ n(result.dropped.length),
2586
+ String(keepDays),
2587
+ result.droppedSpan === null
2588
+ ? null
2589
+ : `${day(result.droppedSpan.fromMs)} → ${day(result.droppedSpan.toMs)}`,
2590
+ formatUsd(droppedUsd),
2591
+ n(result.kept.length),
2592
+ ),
2593
+ 76,
2594
+ ' ',
2595
+ ),
2596
+ );
2597
+ return;
2598
+ }
2599
+
2600
+ if (boolFlag(args, 'json')) {
2601
+ console.log(JSON.stringify({ ...inventory, totalUsd: priced.total.totalUsd, unreadable }, null, 2));
2602
+ return;
2603
+ }
2604
+
2605
+ /**
2606
+ * Empty means *nothing at all* — not "nothing I could resolve".
2607
+ *
2608
+ * Records the store could not tell apart, lines it could not parse and
2609
+ * records from a newer schema are all real measurements sitting on disk.
2610
+ * Reporting an empty store over them would hide exactly what the reader
2611
+ * needs to see, which is the failure this whole module is written against.
2612
+ */
2613
+ const nothingAtAll =
2614
+ inventory.totalRecords === 0 &&
2615
+ inventory.possiblyDouble === 0 &&
2616
+ inventory.unknownVersion === 0 &&
2617
+ unreadable.length === 0;
2618
+ if (nothingAtAll) {
2619
+ console.log(wrap(t.store.empty(STORE_DIR), 76, ' '));
2620
+ return;
2621
+ }
2622
+
2623
+ console.log(
2624
+ c.bold(
2625
+ t.store.heading(
2626
+ n(inventory.totalRecords),
2627
+ formatUsd(priced.total.totalUsd),
2628
+ inventory.span === null ? '' : day(inventory.span.fromMs),
2629
+ inventory.span === null ? '' : day(inventory.span.toMs),
2630
+ ),
2631
+ ),
2632
+ );
2633
+ for (const provider of inventory.providers) {
2634
+ console.log(
2635
+ ` ${t.store.providerRow(
2636
+ provider.provider,
2637
+ n(provider.records),
2638
+ provider.span === null ? '' : `${day(provider.span.fromMs)} → ${day(provider.span.toMs)}`,
2639
+ n(provider.models.length),
2640
+ )}`,
2641
+ );
2642
+ }
2643
+ console.log();
2644
+ console.log(` ${c.dim(wrap(t.store.holds(n(files.length)), 74, ' '))}`);
2645
+ if (inventory.possiblyDouble > 0) {
2646
+ console.log(` ${c.yellow(wrap(t.store.possiblyDouble(n(inventory.possiblyDouble)), 74, ' '))}`);
2647
+ }
2648
+ if (inventory.unknownVersion > 0) {
2649
+ console.log(` ${c.yellow(wrap(t.store.unknownVersion(n(inventory.unknownVersion)), 74, ' '))}`);
2650
+ }
2651
+ for (const bad of unreadable) {
2652
+ console.log(` ${c.yellow(wrap(t.store.unreadable(bad.file, String(bad.line)), 74, ' '))}`);
2653
+ }
2654
+ const keepDays = config.store?.keepDays;
2655
+ console.log(
2656
+ ` ${c.dim(wrap(keepDays === undefined ? t.store.noRetention() : t.store.retention(String(keepDays)), 74, ' '))}`,
2657
+ );
2658
+ }
2659
+
2278
2660
  /**
2279
2661
  * `trazum connect <provider>` — the bill, read from the provider.
2280
2662
  *
@@ -2348,6 +2730,17 @@ async function commandConnect(
2348
2730
  const cache = bucketedCacheEconomics(report);
2349
2731
  const n = (value: number): string => value.toLocaleString(t.numberLocale);
2350
2732
 
2733
+ /**
2734
+ * `--store` keeps what was pulled, so the next run does not download it
2735
+ * again and `history` has a series without anybody curating a folder. Opt
2736
+ * in rather than automatic: a command that starts writing to a hidden
2737
+ * directory on its own is a command nobody trusts twice.
2738
+ */
2739
+ let stored = 0;
2740
+ if (boolFlag(args, 'store')) {
2741
+ stored = await appendRecords(process.cwd(), recordsFromBuckets(pull.provider, pull.buckets, Date.now()));
2742
+ }
2743
+
2351
2744
  const outPath = stringFlag(args, 'out');
2352
2745
  if (outPath !== undefined) {
2353
2746
  await writeFile(outPath, `${JSON.stringify({ ...report, pulledFrom: source.variable }, null, 2)}\n`);
@@ -2438,6 +2831,7 @@ async function commandConnect(
2438
2831
  else console.log(` ${wrap(row, 74, ' ')}`);
2439
2832
  }
2440
2833
  if (outPath !== undefined) console.log(c.dim(t.connect.wrote(outPath)));
2834
+ if (stored > 0) console.log(c.dim(t.store.appended(n(stored), STORE_DIR)));
2441
2835
  }
2442
2836
 
2443
2837
  /**
@@ -2449,40 +2843,103 @@ async function commandConnect(
2449
2843
  * same action planned twice — and no series, however long, becomes a
2450
2844
  * forecast.
2451
2845
  */
2452
- async function commandHistory(args: Args, t: CliMessages): Promise<void> {
2453
- const path = args.positional[0];
2454
- if (path === undefined) throw new Error(t.history.noTarget());
2455
- const target = await stat(path).catch(() => null);
2456
- if (!target?.isDirectory()) throw new Error(t.history.noTarget());
2457
-
2458
- const entries = await readdir(path, { withFileTypes: true });
2459
- const files = entries
2460
- .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
2461
- .map((entry) => join(path, entry.name))
2462
- .sort((a, b) => a.localeCompare(b));
2463
-
2846
+ async function commandHistory(args: Args, pricing: PricingCatalogue, t: CliMessages): Promise<void> {
2847
+ /**
2848
+ * `--store` builds the series from measured spend already on disk.
2849
+ *
2850
+ * Bucketed sources carry no label — a usage API groups by model and
2851
+ * workspace, never by workload — so the label series is *absent and named*
2852
+ * rather than empty and misread, the same discipline the connected report
2853
+ * uses for the findings a sum cannot support. The model-share and
2854
+ * cache-share series are exactly what a series exists for, and both work.
2855
+ */
2464
2856
  const reports: StoredReport[] = [];
2465
2857
  const plans: (PlanDocument & { createdAt?: string })[] = [];
2466
2858
  const unrecognized: string[] = [];
2467
- for (const file of files) {
2468
- let parsed: unknown;
2469
- try {
2470
- parsed = JSON.parse(await readFile(file, 'utf8'));
2471
- } catch {
2472
- unrecognized.push(file);
2473
- continue;
2474
- }
2475
- const report = storedReportFrom(file, parsed);
2476
- if (report !== null) {
2477
- reports.push(report);
2478
- continue;
2859
+ const fromStore = boolFlag(args, 'store');
2860
+
2861
+ if (fromStore) {
2862
+ const { resolved } = await readStore(process.cwd());
2863
+ if (resolved.records.length === 0) throw new Error(t.store.empty(STORE_DIR));
2864
+
2865
+ /**
2866
+ * One period per UTC day of stored measurement, priced exactly as a fresh
2867
+ * pull prices it.
2868
+ *
2869
+ * The label series is deliberately absent: a usage API groups by model
2870
+ * and workspace, never by workload, so there is no label to carry.
2871
+ * Rendering an empty label series would read as "no workload moved",
2872
+ * which is a statement about traffic rather than about the source, and
2873
+ * the footer says which it is.
2874
+ */
2875
+ const byDay = new Map<string, typeof resolved.records>();
2876
+ for (const record of resolved.records) {
2877
+ const key = new Date(record.fromMs).toISOString().slice(0, 10);
2878
+ const list = byDay.get(key) ?? [];
2879
+ list.push(record);
2880
+ byDay.set(key, list);
2881
+ }
2882
+ for (const [dayKey, records] of [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
2883
+ const day = bucketedProfile(
2884
+ {
2885
+ provider: 'store',
2886
+ granularity: 'bucketed',
2887
+ buckets: bucketsFromRecords(records),
2888
+ window: {
2889
+ fromMs: Math.min(...records.map((r) => r.fromMs)),
2890
+ toMs: Math.max(...records.map((r) => r.toMs)),
2891
+ },
2892
+ gaps: [],
2893
+ unavailable: [],
2894
+ },
2895
+ { catalogue: pricing },
2896
+ );
2897
+ const cacheTouched = day.total.cacheReadTokens + day.total.cacheWriteTokens;
2898
+ reports.push({
2899
+ name: dayKey,
2900
+ span: day.span,
2901
+ totalUsd: day.total.totalUsd,
2902
+ calls: day.total.calls,
2903
+ byLabel: new Map(),
2904
+ byModel: new Map(day.byModel.map((slice) => [slice.model, slice.totalUsd])),
2905
+ cacheReadShare:
2906
+ day.total.inputTokens + cacheTouched > 0
2907
+ ? day.total.cacheReadTokens / (day.total.inputTokens + cacheTouched)
2908
+ : null,
2909
+ });
2479
2910
  }
2480
- const maybePlan = parsed as PlanDocument & { createdAt?: string };
2481
- if (maybePlan?.schemaVersion === 1 && Array.isArray(maybePlan.actions)) {
2482
- plans.push(maybePlan);
2483
- continue;
2911
+ } else {
2912
+ const path = args.positional[0];
2913
+ if (path === undefined) throw new Error(t.history.noTarget());
2914
+ const target = await stat(path).catch(() => null);
2915
+ if (!target?.isDirectory()) throw new Error(t.history.noTarget());
2916
+
2917
+ const entries = await readdir(path, { withFileTypes: true });
2918
+ const files = entries
2919
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
2920
+ .map((entry) => join(path, entry.name))
2921
+ .sort((a, b) => a.localeCompare(b));
2922
+
2923
+ for (const file of files) {
2924
+ let parsed: unknown;
2925
+ try {
2926
+ parsed = JSON.parse(await readFile(file, 'utf8'));
2927
+ } catch {
2928
+ unrecognized.push(file);
2929
+ continue;
2930
+ }
2931
+ const report = storedReportFrom(file, parsed);
2932
+ if (report !== null) {
2933
+ reports.push(report);
2934
+ continue;
2935
+ }
2936
+ const maybePlan = parsed as PlanDocument & { createdAt?: string };
2937
+ if (maybePlan?.schemaVersion === 1 && Array.isArray(maybePlan.actions)) {
2938
+ plans.push(maybePlan);
2939
+ continue;
2940
+ }
2941
+ unrecognized.push(file);
2484
2942
  }
2485
- unrecognized.push(file);
2486
2943
  }
2487
2944
 
2488
2945
  const history = buildHistory(reports, plans);
@@ -2516,7 +2973,7 @@ async function commandHistory(args: Args, t: CliMessages): Promise<void> {
2516
2973
  const row = t.history.periodRow(
2517
2974
  period.name,
2518
2975
  formatUsd(period.totalUsd),
2519
- n(period.calls),
2976
+ period.calls === null ? null : n(period.calls),
2520
2977
  ((period.toMs - period.fromMs) / 86_400_000).toFixed(1),
2521
2978
  );
2522
2979
  out.push(md ? `- ${row}` : ` ${row}`);
@@ -2544,6 +3001,11 @@ async function commandHistory(args: Args, t: CliMessages): Promise<void> {
2544
3001
  for (const name of unrecognized) {
2545
3002
  out.push(md ? `- ${t.history.unrecognized(name)}` : ` ${t.history.unrecognized(name)}`);
2546
3003
  }
3004
+ if (fromStore) {
3005
+ out.push('');
3006
+ const note = t.history.storeNoLabels();
3007
+ out.push(md ? `_${note}_` : ` ${note}`);
3008
+ }
2547
3009
  out.push('');
2548
3010
  out.push(md ? `_${t.history.footer()}_` : ` ${t.history.footer()}`);
2549
3011
  return out;
@@ -6615,11 +7077,17 @@ async function main(): Promise<void> {
6615
7077
  await commandVerify(args, pricing, t);
6616
7078
  break;
6617
7079
  case 'history':
6618
- await commandHistory(args, t);
7080
+ await commandHistory(args, pricing, t);
6619
7081
  break;
6620
7082
  case 'connect':
6621
7083
  await commandConnect(args, pricing, t);
6622
7084
  break;
7085
+ case 'store':
7086
+ await commandStore(args, config, pricing, t);
7087
+ break;
7088
+ case 'watch':
7089
+ await commandWatch(args, config, pricing, t);
7090
+ break;
6623
7091
  case 'route':
6624
7092
  await commandRoute(args, pricing, t);
6625
7093
  break;