@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/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { readdir, readFile, stat, writeFile } from 'node:fs/promises';
3
3
  import { join, resolve as resolvePath } from 'node:path';
4
4
  import { gunzipSync } from 'node:zlib';
5
- import { applyRewrites, BASELINE_FILENAME, BASELINE_VERSION, breaches, cacheableMinimum, analyzeCachePrefix, billLevers, bucketedCacheEconomics, bucketedProfile, buildHistory, buildPlan, connectorFor, CONNECTORS, normalizeAnthropicUsage, normalizeOpenAIUsage, storedReportFrom, verifyPlan, cacheEconomics, cacheHitRate, contextPressure, comparePrompts, compareToBaseline, computeSavings, countTokensAnthropic, DEFAULT_USAGE, detectFromSource, coverageDrift, driversBetween, explainGateFailure, assignSources, fleetRollup, labelCoverage, measuredUsage, gateMargin, GATE_MARGIN_TIGHT, estimateTokens, evaluate, extractPrompts, findExamples, formatBaseline, formatSignedUsd, formatUsd, getMessages, getModel, hasMarker, LOCALES, MAX_BASELINE_BYTES, moneyIsComparable, mostSpecificMatch, nearestName, optimize, parseBaseline, PHRASE_LANGUAGES, plannedCalls, profilePrompt, profileToCsv, profileUsage, promptId, providerFromEnv, pruneExamples, refineWithLlm, rejectionText, reorderForCache, repriceProfile, reviewAgeDays, reviewExamples, RULES, sharedPrefixes, sharesOf, SOURCE_EXTENSIONS, suggestRewrites, toOtlpMetrics, toPromptfoo, TTL_1H_MS, UNLABELLED, withExactTokenCounts, } from '@trazum/core';
5
+ import { applyRewrites, BASELINE_FILENAME, BASELINE_VERSION, breaches, cacheableMinimum, analyzeCachePrefix, billLevers, bucketedCacheEconomics, bucketedProfile, buildHistory, buildPlan, connectorFor, CONNECTORS, normalizeAnthropicUsage, normalizeOpenAIUsage, bucketsFromRecords, evaluateWatch, firedKey, pruneRecords, recordsFromBuckets, storeInventory, storedReportFrom, verifyPlan, cacheEconomics, cacheHitRate, contextPressure, comparePrompts, compareToBaseline, computeSavings, countTokensAnthropic, DEFAULT_USAGE, detectFromSource, coverageDrift, driversBetween, explainGateFailure, assignSources, fleetRollup, labelCoverage, measuredUsage, gateMargin, GATE_MARGIN_TIGHT, estimateTokens, evaluate, extractPrompts, findExamples, formatBaseline, formatSignedUsd, formatUsd, getMessages, getModel, hasMarker, LOCALES, MAX_BASELINE_BYTES, moneyIsComparable, mostSpecificMatch, nearestName, optimize, parseBaseline, PHRASE_LANGUAGES, plannedCalls, profilePrompt, profileToCsv, profileUsage, promptId, providerFromEnv, pruneExamples, refineWithLlm, rejectionText, reorderForCache, repriceProfile, reviewAgeDays, reviewExamples, RULES, sharedPrefixes, sharesOf, SOURCE_EXTENSIONS, suggestRewrites, toOtlpMetrics, toPromptfoo, TTL_1H_MS, UNLABELLED, withExactTokenCounts, } from '@trazum/core';
6
6
  import { cacheDir, cacheStats, cachingProvider, clearCache } from './suggest-cache.js';
7
7
  import { dayOf, formatGap, median, spanDays } from './time.js';
8
8
  // Everything that reads the filesystem, on its own entry point so the web
@@ -10,6 +10,8 @@ import { dayOf, formatGap, median, spanDays } from './time.js';
10
10
  import { CONFIG_FILENAME, DEFAULT_EXTENSIONS, budgetFor, BUNDLED_CATALOGUE, SAFE_FETCH_INIT, applyPricingOverlay, catalogueFromOverlay, checkedEndpoint, openrouterOverlay, detectHost, loadConfig, walkPrompts, } from '@trazum/core/node';
11
11
  import { contentAt, gitAvailable, namesByRevision, pathInRepository, repositoryRoot, revisionsFor, } from './git.js';
12
12
  import { fetchProviderUsage } from './connect.js';
13
+ import { STORE_DIR, appendRecords, readStore, rewriteStore } from './store-fs.js';
14
+ import { WATCH_STATE_VERSION, checkWebhook, postWebhook, readWatchState, writeWatchState, } from './watch-run.js';
13
15
  import { detectLocale, getCliMessages } from './i18n/index.js';
14
16
  import { MAX_SUMMARY_CHARS, fitWithin, renderBlameMarkdown, renderCheckMarkdown, renderDiffMarkdown, renderRankMarkdown, renderProfileMarkdown, } from './markdown.js';
15
17
  // --------------------------------------------------------------------------
@@ -29,6 +31,9 @@ const VALUE_FLAGS = new Set([
29
31
  'from-log',
30
32
  'min-usd',
31
33
  'payload',
34
+ 'keep',
35
+ 'interval',
36
+ 'webhook',
32
37
  // `route` takes a path here, and the flag is deliberately not `--prompt`:
33
38
  // everywhere else in this tool `--prompt` names a marked prompt *inside* a
34
39
  // source file, and reusing it for a path would be a trap laid for the reader.
@@ -290,8 +295,10 @@ const COMMAND_FLAGS = {
290
295
  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'],
291
296
  plan: ['json', 'out', 'markdown-out', 'min-usd', 'pricing', 'pricing-live'],
292
297
  verify: ['against', 'gate', 'json', 'markdown-out', 'pricing', 'pricing-live'],
293
- history: ['json', 'markdown-out'],
294
- connect: ['since', 'until', 'payload', 'json', 'out', 'markdown-out', 'pricing', 'pricing-live', 'dry-run'],
298
+ history: ['store', 'json', 'markdown-out'],
299
+ connect: ['since', 'until', 'payload', 'store', 'json', 'out', 'markdown-out', 'pricing', 'pricing-live', 'dry-run'],
300
+ store: ['prune', 'keep', 'json', 'pricing', 'pricing-live', 'dry-run'],
301
+ watch: ['once', 'interval', 'since', 'payload', 'webhook', 'json', 'pricing', 'pricing-live'],
295
302
  route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
296
303
  eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
297
304
  prune: ['cases', 'concurrency', 'json', 'yes'],
@@ -1686,6 +1693,255 @@ function parseWhen(args, flag, endOfDay, t, now) {
1686
1693
  return { ms: exact, relative: false };
1687
1694
  throw new Error(t.profile.badWhen(flag, value));
1688
1695
  }
1696
+ /**
1697
+ * `trazum watch` — the afternoon it happened, said that afternoon.
1698
+ *
1699
+ * One cycle is the primitive: measure, keep, evaluate, emit, remember. The
1700
+ * loop is that cycle in a timer, so a cron entry and a foreground watcher run
1701
+ * exactly the same code and the tests exercise the thing that ships.
1702
+ *
1703
+ * Three transports, all boring on purpose: a non-zero exit code so cron mails
1704
+ * it, a JSON event on stdout so any pipeline can read it, and a webhook for
1705
+ * the operator who already has somewhere for alerts to go. No hosted service
1706
+ * and no account.
1707
+ */
1708
+ async function commandWatch(args, config, pricing, t) {
1709
+ const root = process.cwd();
1710
+ const asJson = boolFlag(args, 'json');
1711
+ const n = (value) => value.toLocaleString(t.numberLocale);
1712
+ const day = (msValue) => new Date(msValue).toISOString().slice(0, 10);
1713
+ const thresholds = {
1714
+ maxUsd: config.spend?.maxUsd,
1715
+ maxDayUsd: config.spend?.maxDayUsd,
1716
+ maxCacheLossUsd: config.spend?.maxCacheLossUsd,
1717
+ };
1718
+ if (thresholds.maxUsd === undefined &&
1719
+ thresholds.maxDayUsd === undefined &&
1720
+ thresholds.maxCacheLossUsd === undefined) {
1721
+ throw new Error(t.watch.noThresholds());
1722
+ }
1723
+ /**
1724
+ * A webhook is a new outbound surface, so it is checked before anything is
1725
+ * sent: credentials in a URL end up in logs and shell history, and an alert
1726
+ * carrying spend figures over plain http across a network is a leak the
1727
+ * operator did not ask for. Loopback is the exception, because pointing a
1728
+ * watcher at your own alerting daemon is the ordinary case.
1729
+ */
1730
+ const webhookRaw = stringFlag(args, 'webhook');
1731
+ let webhook = null;
1732
+ if (webhookRaw !== undefined) {
1733
+ const checked = checkWebhook(webhookRaw);
1734
+ if (!checked.ok)
1735
+ throw new Error(t.watch.badWebhook(checked.reason));
1736
+ webhook = checked.url;
1737
+ }
1738
+ const intervalRaw = stringFlag(args, 'interval');
1739
+ const once = boolFlag(args, 'once') || intervalRaw === undefined;
1740
+ let intervalMs = 0;
1741
+ if (!once) {
1742
+ const match = /^(\d+)(m|h)$/.exec(intervalRaw);
1743
+ const amount = match === null ? NaN : Number(match[1]);
1744
+ intervalMs = match?.[2] === 'h' ? amount * 3_600_000 : amount * 60_000;
1745
+ // Usage APIs are rate limited, and a tight loop is a way to get somebody's
1746
+ // key throttled by a tool that was supposed to save them money.
1747
+ if (!Number.isFinite(intervalMs) || intervalMs < 5 * 60_000) {
1748
+ throw new Error(t.watch.intervalTooTight());
1749
+ }
1750
+ }
1751
+ const cycle = async () => {
1752
+ const state = await readWatchState(root);
1753
+ const nowMs = Date.now();
1754
+ /**
1755
+ * Where the measurements come from: a saved payload when one is named
1756
+ * (which is how this is tested and how an air-gapped run works), and the
1757
+ * store otherwise. A cycle that found nothing to measure says so — a
1758
+ * watcher over nothing is a green light nobody earned.
1759
+ */
1760
+ const payloadPath = stringFlag(args, 'payload');
1761
+ let pull;
1762
+ if (payloadPath !== undefined) {
1763
+ pull = normalizeAnthropicUsage(JSON.parse(await readFile(payloadPath, 'utf8')));
1764
+ }
1765
+ else {
1766
+ const { resolved } = await readStore(root);
1767
+ if (resolved.records.length === 0)
1768
+ throw new Error(t.watch.nothingToWatch(STORE_DIR));
1769
+ pull = {
1770
+ provider: 'store',
1771
+ granularity: 'bucketed',
1772
+ buckets: bucketsFromRecords(resolved.records),
1773
+ window: null,
1774
+ gaps: [],
1775
+ unavailable: [],
1776
+ };
1777
+ }
1778
+ const report = bucketedProfile(pull, { catalogue: pricing });
1779
+ const cache = bucketedCacheEconomics(report);
1780
+ const result = evaluateWatch({
1781
+ report,
1782
+ thresholds,
1783
+ cacheDeltaUsd: cache.verdict === 'no-cache' ? undefined : cache.deltaUsd,
1784
+ nowMs,
1785
+ lastCoveredToMs: state?.lastCoveredToMs ?? undefined,
1786
+ alreadyFired: new Set(Object.keys(state?.fired ?? {})),
1787
+ });
1788
+ if (asJson) {
1789
+ console.log(JSON.stringify({ schemaVersion: 1, firedAtMs: nowMs, ...result }, null, 2));
1790
+ }
1791
+ else {
1792
+ if (result.gap !== null) {
1793
+ console.log(c.yellow(wrap(t.watch.gap(day(result.gap.fromMs), day(result.gap.toMs)), 76, ' ')));
1794
+ }
1795
+ for (const crossing of result.crossings) {
1796
+ console.log(c.red(wrap(t.watch.crossed(crossing.gate, formatUsd(crossing.measuredUsd), formatUsd(crossing.limitUsd), crossing.day), 76, ' ')));
1797
+ }
1798
+ for (const abstention of result.abstentions) {
1799
+ console.log(c.dim(wrap(t.watch.notJudgeable(abstention.gate, abstention.reason, abstention.detail === null
1800
+ ? null
1801
+ : `${Math.round((abstention.detail.coveredMs / abstention.detail.neededMs) * 100)}%`), 76, ' ')));
1802
+ }
1803
+ for (const still of result.suppressed) {
1804
+ console.log(c.yellow(wrap(t.watch.stillOver(still.gate, formatUsd(still.measuredUsd), formatUsd(still.limitUsd), still.day), 76, ' ')));
1805
+ }
1806
+ if (result.crossings.length === 0 &&
1807
+ result.suppressed.length === 0 &&
1808
+ result.abstentions.length === 0) {
1809
+ console.log(c.green(wrap(t.watch.allWithin(n(Object.keys(thresholds).filter((k) => thresholds[k] !== undefined).length)), 76, ' ')));
1810
+ }
1811
+ }
1812
+ if (webhook !== null && result.crossings.length > 0) {
1813
+ const sent = await postWebhook(webhook, {
1814
+ schemaVersion: 1,
1815
+ firedAtMs: nowMs,
1816
+ crossings: result.crossings,
1817
+ });
1818
+ if (!sent.ok) {
1819
+ // Reported and swallowed: the exit code and the event already carried
1820
+ // the crossing, and losing those because a receiver is down would make
1821
+ // the quietest failure the loudest one.
1822
+ console.error(c.yellow(t.watch.webhookFailed(sent.status === null ? sent.error ?? '' : String(sent.status))));
1823
+ }
1824
+ }
1825
+ const fired = { ...(state?.fired ?? {}) };
1826
+ for (const crossing of result.crossings)
1827
+ fired[firedKey(crossing.gate, crossing.day)] = nowMs;
1828
+ await writeWatchState(root, {
1829
+ v: WATCH_STATE_VERSION,
1830
+ lastCycleMs: nowMs,
1831
+ lastCoveredToMs: report.span?.toMs ?? state?.lastCoveredToMs ?? null,
1832
+ fired,
1833
+ });
1834
+ return result.crossings.length + result.suppressed.length;
1835
+ };
1836
+ const crossed = await cycle();
1837
+ // Still over is still a failure: only the alert was already sent.
1838
+ if (crossed > 0)
1839
+ process.exitCode = 1;
1840
+ if (once)
1841
+ return;
1842
+ console.log(c.dim(t.watch.watching(String(Math.round(intervalMs / 60_000)))));
1843
+ // The loop is the cycle in a timer and nothing more, so the primitive above
1844
+ // is the only thing that ever needs testing.
1845
+ for (;;) {
1846
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
1847
+ await cycle();
1848
+ }
1849
+ }
1850
+ /**
1851
+ * `trazum store` — what is kept, and what a prune would take.
1852
+ *
1853
+ * The store is the one thing in this product that *deletes* something, so the
1854
+ * errands around it are written to make that visible: the inventory says what
1855
+ * is there and how far back, and `--prune` names what went with the span it
1856
+ * covered. Retention with no policy written down is refused rather than
1857
+ * defaulted — deleting measurements on a guess is not something anybody
1858
+ * should receive by accident.
1859
+ */
1860
+ async function commandStore(args, config, pricing, t) {
1861
+ const root = process.cwd();
1862
+ const { resolved, unreadable, files } = await readStore(root);
1863
+ const inventory = storeInventory(resolved);
1864
+ const n = (value) => value.toLocaleString(t.numberLocale);
1865
+ const day = (msValue) => new Date(msValue).toISOString().slice(0, 10);
1866
+ const priced = bucketedProfile({
1867
+ provider: 'store',
1868
+ granularity: 'bucketed',
1869
+ buckets: bucketsFromRecords(resolved.records),
1870
+ window: inventory.span,
1871
+ gaps: [],
1872
+ unavailable: [],
1873
+ }, { catalogue: pricing });
1874
+ if (boolFlag(args, 'prune')) {
1875
+ const keepFlag = stringFlag(args, 'keep');
1876
+ const keepDays = keepFlag !== undefined
1877
+ ? Number(/^(\d+)d?$/.exec(keepFlag)?.[1] ?? NaN)
1878
+ : config.store?.keepDays;
1879
+ if (keepDays === undefined || !Number.isFinite(keepDays) || keepDays <= 0) {
1880
+ throw new Error(t.store.pruneNeedsPolicy());
1881
+ }
1882
+ const cutoff = Date.now() - keepDays * 86_400_000;
1883
+ const result = pruneRecords(resolved.records, cutoff);
1884
+ const droppedUsd = bucketedProfile({
1885
+ provider: 'store',
1886
+ granularity: 'bucketed',
1887
+ buckets: bucketsFromRecords(result.dropped),
1888
+ window: null,
1889
+ gaps: [],
1890
+ unavailable: [],
1891
+ }, { catalogue: pricing }).total.totalUsd;
1892
+ if (boolFlag(args, 'dry-run')) {
1893
+ console.log(wrap(t.store.pruneDryRun(n(result.dropped.length), String(keepDays), result.droppedSpan === null
1894
+ ? null
1895
+ : `${day(result.droppedSpan.fromMs)} → ${day(result.droppedSpan.toMs)}`, formatUsd(droppedUsd)), 76, ' '));
1896
+ return;
1897
+ }
1898
+ // The prune also collapses the append log to what the store resolves to,
1899
+ // which is the only moment a rewrite is safe: it is what the reader was
1900
+ // already seeing.
1901
+ await rewriteStore(root, result.kept);
1902
+ console.log(wrap(t.store.pruned(n(result.dropped.length), String(keepDays), result.droppedSpan === null
1903
+ ? null
1904
+ : `${day(result.droppedSpan.fromMs)} → ${day(result.droppedSpan.toMs)}`, formatUsd(droppedUsd), n(result.kept.length)), 76, ' '));
1905
+ return;
1906
+ }
1907
+ if (boolFlag(args, 'json')) {
1908
+ console.log(JSON.stringify({ ...inventory, totalUsd: priced.total.totalUsd, unreadable }, null, 2));
1909
+ return;
1910
+ }
1911
+ /**
1912
+ * Empty means *nothing at all* — not "nothing I could resolve".
1913
+ *
1914
+ * Records the store could not tell apart, lines it could not parse and
1915
+ * records from a newer schema are all real measurements sitting on disk.
1916
+ * Reporting an empty store over them would hide exactly what the reader
1917
+ * needs to see, which is the failure this whole module is written against.
1918
+ */
1919
+ const nothingAtAll = inventory.totalRecords === 0 &&
1920
+ inventory.possiblyDouble === 0 &&
1921
+ inventory.unknownVersion === 0 &&
1922
+ unreadable.length === 0;
1923
+ if (nothingAtAll) {
1924
+ console.log(wrap(t.store.empty(STORE_DIR), 76, ' '));
1925
+ return;
1926
+ }
1927
+ console.log(c.bold(t.store.heading(n(inventory.totalRecords), formatUsd(priced.total.totalUsd), inventory.span === null ? '' : day(inventory.span.fromMs), inventory.span === null ? '' : day(inventory.span.toMs))));
1928
+ for (const provider of inventory.providers) {
1929
+ console.log(` ${t.store.providerRow(provider.provider, n(provider.records), provider.span === null ? '' : `${day(provider.span.fromMs)} → ${day(provider.span.toMs)}`, n(provider.models.length))}`);
1930
+ }
1931
+ console.log();
1932
+ console.log(` ${c.dim(wrap(t.store.holds(n(files.length)), 74, ' '))}`);
1933
+ if (inventory.possiblyDouble > 0) {
1934
+ console.log(` ${c.yellow(wrap(t.store.possiblyDouble(n(inventory.possiblyDouble)), 74, ' '))}`);
1935
+ }
1936
+ if (inventory.unknownVersion > 0) {
1937
+ console.log(` ${c.yellow(wrap(t.store.unknownVersion(n(inventory.unknownVersion)), 74, ' '))}`);
1938
+ }
1939
+ for (const bad of unreadable) {
1940
+ console.log(` ${c.yellow(wrap(t.store.unreadable(bad.file, String(bad.line)), 74, ' '))}`);
1941
+ }
1942
+ const keepDays = config.store?.keepDays;
1943
+ console.log(` ${c.dim(wrap(keepDays === undefined ? t.store.noRetention() : t.store.retention(String(keepDays)), 74, ' '))}`);
1944
+ }
1689
1945
  /**
1690
1946
  * `trazum connect <provider>` — the bill, read from the provider.
1691
1947
  *
@@ -1736,6 +1992,16 @@ async function commandConnect(args, pricing, t) {
1736
1992
  const report = bucketedProfile(pull, { catalogue: pricing });
1737
1993
  const cache = bucketedCacheEconomics(report);
1738
1994
  const n = (value) => value.toLocaleString(t.numberLocale);
1995
+ /**
1996
+ * `--store` keeps what was pulled, so the next run does not download it
1997
+ * again and `history` has a series without anybody curating a folder. Opt
1998
+ * in rather than automatic: a command that starts writing to a hidden
1999
+ * directory on its own is a command nobody trusts twice.
2000
+ */
2001
+ let stored = 0;
2002
+ if (boolFlag(args, 'store')) {
2003
+ stored = await appendRecords(process.cwd(), recordsFromBuckets(pull.provider, pull.buckets, Date.now()));
2004
+ }
1739
2005
  const outPath = stringFlag(args, 'out');
1740
2006
  if (outPath !== undefined) {
1741
2007
  await writeFile(outPath, `${JSON.stringify({ ...report, pulledFrom: source.variable }, null, 2)}\n`);
@@ -1806,6 +2072,8 @@ async function commandConnect(args, pricing, t) {
1806
2072
  }
1807
2073
  if (outPath !== undefined)
1808
2074
  console.log(c.dim(t.connect.wrote(outPath)));
2075
+ if (stored > 0)
2076
+ console.log(c.dim(t.store.appended(n(stored), STORE_DIR)));
1809
2077
  }
1810
2078
  /**
1811
2079
  * `trazum history <dir>` — many reports over many periods, as one series.
@@ -1816,41 +2084,100 @@ async function commandConnect(args, pricing, t) {
1816
2084
  * same action planned twice — and no series, however long, becomes a
1817
2085
  * forecast.
1818
2086
  */
1819
- async function commandHistory(args, t) {
1820
- const path = args.positional[0];
1821
- if (path === undefined)
1822
- throw new Error(t.history.noTarget());
1823
- const target = await stat(path).catch(() => null);
1824
- if (!target?.isDirectory())
1825
- throw new Error(t.history.noTarget());
1826
- const entries = await readdir(path, { withFileTypes: true });
1827
- const files = entries
1828
- .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
1829
- .map((entry) => join(path, entry.name))
1830
- .sort((a, b) => a.localeCompare(b));
2087
+ async function commandHistory(args, pricing, t) {
2088
+ /**
2089
+ * `--store` builds the series from measured spend already on disk.
2090
+ *
2091
+ * Bucketed sources carry no label — a usage API groups by model and
2092
+ * workspace, never by workload — so the label series is *absent and named*
2093
+ * rather than empty and misread, the same discipline the connected report
2094
+ * uses for the findings a sum cannot support. The model-share and
2095
+ * cache-share series are exactly what a series exists for, and both work.
2096
+ */
1831
2097
  const reports = [];
1832
2098
  const plans = [];
1833
2099
  const unrecognized = [];
1834
- for (const file of files) {
1835
- let parsed;
1836
- try {
1837
- parsed = JSON.parse(await readFile(file, 'utf8'));
2100
+ const fromStore = boolFlag(args, 'store');
2101
+ if (fromStore) {
2102
+ const { resolved } = await readStore(process.cwd());
2103
+ if (resolved.records.length === 0)
2104
+ throw new Error(t.store.empty(STORE_DIR));
2105
+ /**
2106
+ * One period per UTC day of stored measurement, priced exactly as a fresh
2107
+ * pull prices it.
2108
+ *
2109
+ * The label series is deliberately absent: a usage API groups by model
2110
+ * and workspace, never by workload, so there is no label to carry.
2111
+ * Rendering an empty label series would read as "no workload moved",
2112
+ * which is a statement about traffic rather than about the source, and
2113
+ * the footer says which it is.
2114
+ */
2115
+ const byDay = new Map();
2116
+ for (const record of resolved.records) {
2117
+ const key = new Date(record.fromMs).toISOString().slice(0, 10);
2118
+ const list = byDay.get(key) ?? [];
2119
+ list.push(record);
2120
+ byDay.set(key, list);
2121
+ }
2122
+ for (const [dayKey, records] of [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
2123
+ const day = bucketedProfile({
2124
+ provider: 'store',
2125
+ granularity: 'bucketed',
2126
+ buckets: bucketsFromRecords(records),
2127
+ window: {
2128
+ fromMs: Math.min(...records.map((r) => r.fromMs)),
2129
+ toMs: Math.max(...records.map((r) => r.toMs)),
2130
+ },
2131
+ gaps: [],
2132
+ unavailable: [],
2133
+ }, { catalogue: pricing });
2134
+ const cacheTouched = day.total.cacheReadTokens + day.total.cacheWriteTokens;
2135
+ reports.push({
2136
+ name: dayKey,
2137
+ span: day.span,
2138
+ totalUsd: day.total.totalUsd,
2139
+ calls: day.total.calls,
2140
+ byLabel: new Map(),
2141
+ byModel: new Map(day.byModel.map((slice) => [slice.model, slice.totalUsd])),
2142
+ cacheReadShare: day.total.inputTokens + cacheTouched > 0
2143
+ ? day.total.cacheReadTokens / (day.total.inputTokens + cacheTouched)
2144
+ : null,
2145
+ });
1838
2146
  }
1839
- catch {
2147
+ }
2148
+ else {
2149
+ const path = args.positional[0];
2150
+ if (path === undefined)
2151
+ throw new Error(t.history.noTarget());
2152
+ const target = await stat(path).catch(() => null);
2153
+ if (!target?.isDirectory())
2154
+ throw new Error(t.history.noTarget());
2155
+ const entries = await readdir(path, { withFileTypes: true });
2156
+ const files = entries
2157
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
2158
+ .map((entry) => join(path, entry.name))
2159
+ .sort((a, b) => a.localeCompare(b));
2160
+ for (const file of files) {
2161
+ let parsed;
2162
+ try {
2163
+ parsed = JSON.parse(await readFile(file, 'utf8'));
2164
+ }
2165
+ catch {
2166
+ unrecognized.push(file);
2167
+ continue;
2168
+ }
2169
+ const report = storedReportFrom(file, parsed);
2170
+ if (report !== null) {
2171
+ reports.push(report);
2172
+ continue;
2173
+ }
2174
+ const maybePlan = parsed;
2175
+ if (maybePlan?.schemaVersion === 1 && Array.isArray(maybePlan.actions)) {
2176
+ plans.push(maybePlan);
2177
+ continue;
2178
+ }
1840
2179
  unrecognized.push(file);
1841
- continue;
1842
- }
1843
- const report = storedReportFrom(file, parsed);
1844
- if (report !== null) {
1845
- reports.push(report);
1846
- continue;
1847
2180
  }
1848
- const maybePlan = parsed;
1849
- if (maybePlan?.schemaVersion === 1 && Array.isArray(maybePlan.actions)) {
1850
- plans.push(maybePlan);
1851
- continue;
1852
- }
1853
- unrecognized.push(file);
1854
2181
  }
1855
2182
  const history = buildHistory(reports, plans);
1856
2183
  if (history.periods.length < 3) {
@@ -1877,7 +2204,7 @@ async function commandHistory(args, t) {
1877
2204
  const heading = t.history.heading(n(history.periods.length), day(first.fromMs), day(last.toMs));
1878
2205
  out.push(md ? `## ${heading}` : heading);
1879
2206
  for (const period of history.periods) {
1880
- const row = t.history.periodRow(period.name, formatUsd(period.totalUsd), n(period.calls), ((period.toMs - period.fromMs) / 86_400_000).toFixed(1));
2207
+ const row = t.history.periodRow(period.name, formatUsd(period.totalUsd), period.calls === null ? null : n(period.calls), ((period.toMs - period.fromMs) / 86_400_000).toFixed(1));
1881
2208
  out.push(md ? `- ${row}` : ` ${row}`);
1882
2209
  }
1883
2210
  if (history.runs.length > 0)
@@ -1898,6 +2225,11 @@ async function commandHistory(args, t) {
1898
2225
  for (const name of unrecognized) {
1899
2226
  out.push(md ? `- ${t.history.unrecognized(name)}` : ` ${t.history.unrecognized(name)}`);
1900
2227
  }
2228
+ if (fromStore) {
2229
+ out.push('');
2230
+ const note = t.history.storeNoLabels();
2231
+ out.push(md ? `_${note}_` : ` ${note}`);
2232
+ }
1901
2233
  out.push('');
1902
2234
  out.push(md ? `_${t.history.footer()}_` : ` ${t.history.footer()}`);
1903
2235
  return out;
@@ -5286,11 +5618,17 @@ async function main() {
5286
5618
  await commandVerify(args, pricing, t);
5287
5619
  break;
5288
5620
  case 'history':
5289
- await commandHistory(args, t);
5621
+ await commandHistory(args, pricing, t);
5290
5622
  break;
5291
5623
  case 'connect':
5292
5624
  await commandConnect(args, pricing, t);
5293
5625
  break;
5626
+ case 'store':
5627
+ await commandStore(args, config, pricing, t);
5628
+ break;
5629
+ case 'watch':
5630
+ await commandWatch(args, config, pricing, t);
5631
+ break;
5294
5632
  case 'route':
5295
5633
  await commandRoute(args, pricing, t);
5296
5634
  break;