@trazum/cli 1.40.0 → 1.42.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,13 +2,15 @@
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, buildHistory, buildPlan, 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, 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
9
9
  // bundle cannot reach it. See packages/core/src/node.ts.
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
+ import { fetchProviderUsage } from './connect.js';
13
+ import { STORE_DIR, appendRecords, readStore, rewriteStore } from './store-fs.js';
12
14
  import { detectLocale, getCliMessages } from './i18n/index.js';
13
15
  import { MAX_SUMMARY_CHARS, fitWithin, renderBlameMarkdown, renderCheckMarkdown, renderDiffMarkdown, renderRankMarkdown, renderProfileMarkdown, } from './markdown.js';
14
16
  // --------------------------------------------------------------------------
@@ -27,6 +29,8 @@ const VALUE_FLAGS = new Set([
27
29
  'against',
28
30
  'from-log',
29
31
  'min-usd',
32
+ 'payload',
33
+ 'keep',
30
34
  // `route` takes a path here, and the flag is deliberately not `--prompt`:
31
35
  // everywhere else in this tool `--prompt` names a marked prompt *inside* a
32
36
  // source file, and reusing it for a path would be a trap laid for the reader.
@@ -288,7 +292,9 @@ const COMMAND_FLAGS = {
288
292
  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'],
289
293
  plan: ['json', 'out', 'markdown-out', 'min-usd', 'pricing', 'pricing-live'],
290
294
  verify: ['against', 'gate', 'json', 'markdown-out', 'pricing', 'pricing-live'],
291
- history: ['json', 'markdown-out'],
295
+ history: ['store', 'json', 'markdown-out'],
296
+ connect: ['since', 'until', 'payload', 'store', 'json', 'out', 'markdown-out', 'pricing', 'pricing-live', 'dry-run'],
297
+ store: ['prune', 'keep', 'json', 'pricing', 'pricing-live', 'dry-run'],
292
298
  route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
293
299
  eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
294
300
  prune: ['cases', 'concurrency', 'json', 'yes'],
@@ -1646,6 +1652,271 @@ function isoDate() {
1646
1652
  * metered API calls somebody was actually billed for — the bill exists wherever
1647
1653
  * Trazum happens to be running, so the host has no bearing on it.
1648
1654
  */
1655
+ /**
1656
+ * One end of a time window, from a flag.
1657
+ *
1658
+ * A UTC day (`2026-08-14`), a full ISO 8601 timestamp, a relative window
1659
+ * (`7d`, `24h`) or `now`. A bare day means the whole of it — since its first
1660
+ * instant, until its last — because `--until 2026-08-14` excluding the named
1661
+ * day is a trap sprung on everyone who reads dates the way humans do.
1662
+ *
1663
+ * `relative` comes back so the caller can state the caveat: a relative window
1664
+ * is measured against **the machine's clock, not the data's**, and a log
1665
+ * exported last month answers `--since 7d` with nothing.
1666
+ */
1667
+ function parseWhen(args, flag, endOfDay, t, now) {
1668
+ const value = stringFlag(args, flag);
1669
+ if (value === undefined)
1670
+ return { ms: undefined, relative: false };
1671
+ const relative = /^(\d+)([dh])$/.exec(value);
1672
+ if (relative) {
1673
+ const amount = Number(relative[1]);
1674
+ if (amount > 0) {
1675
+ const span = relative[2] === 'd' ? 86_400_000 : 3_600_000;
1676
+ return { ms: now - amount * span, relative: true };
1677
+ }
1678
+ }
1679
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
1680
+ const midnight = Date.parse(`${value}T00:00:00Z`);
1681
+ if (Number.isFinite(midnight)) {
1682
+ return { ms: endOfDay ? midnight + 86_400_000 : midnight, relative: false };
1683
+ }
1684
+ }
1685
+ if (value === 'now')
1686
+ return { ms: now, relative: false };
1687
+ const exact = Date.parse(value);
1688
+ if (Number.isFinite(exact))
1689
+ return { ms: exact, relative: false };
1690
+ throw new Error(t.profile.badWhen(flag, value));
1691
+ }
1692
+ /**
1693
+ * `trazum store` — what is kept, and what a prune would take.
1694
+ *
1695
+ * The store is the one thing in this product that *deletes* something, so the
1696
+ * errands around it are written to make that visible: the inventory says what
1697
+ * is there and how far back, and `--prune` names what went with the span it
1698
+ * covered. Retention with no policy written down is refused rather than
1699
+ * defaulted — deleting measurements on a guess is not something anybody
1700
+ * should receive by accident.
1701
+ */
1702
+ async function commandStore(args, config, pricing, t) {
1703
+ const root = process.cwd();
1704
+ const { resolved, unreadable, files } = await readStore(root);
1705
+ const inventory = storeInventory(resolved);
1706
+ const n = (value) => value.toLocaleString(t.numberLocale);
1707
+ const day = (msValue) => new Date(msValue).toISOString().slice(0, 10);
1708
+ const priced = bucketedProfile({
1709
+ provider: 'store',
1710
+ granularity: 'bucketed',
1711
+ buckets: bucketsFromRecords(resolved.records),
1712
+ window: inventory.span,
1713
+ gaps: [],
1714
+ unavailable: [],
1715
+ }, { catalogue: pricing });
1716
+ if (boolFlag(args, 'prune')) {
1717
+ const keepFlag = stringFlag(args, 'keep');
1718
+ const keepDays = keepFlag !== undefined
1719
+ ? Number(/^(\d+)d?$/.exec(keepFlag)?.[1] ?? NaN)
1720
+ : config.store?.keepDays;
1721
+ if (keepDays === undefined || !Number.isFinite(keepDays) || keepDays <= 0) {
1722
+ throw new Error(t.store.pruneNeedsPolicy());
1723
+ }
1724
+ const cutoff = Date.now() - keepDays * 86_400_000;
1725
+ const result = pruneRecords(resolved.records, cutoff);
1726
+ const droppedUsd = bucketedProfile({
1727
+ provider: 'store',
1728
+ granularity: 'bucketed',
1729
+ buckets: bucketsFromRecords(result.dropped),
1730
+ window: null,
1731
+ gaps: [],
1732
+ unavailable: [],
1733
+ }, { catalogue: pricing }).total.totalUsd;
1734
+ if (boolFlag(args, 'dry-run')) {
1735
+ console.log(wrap(t.store.pruneDryRun(n(result.dropped.length), String(keepDays), result.droppedSpan === null
1736
+ ? null
1737
+ : `${day(result.droppedSpan.fromMs)} → ${day(result.droppedSpan.toMs)}`, formatUsd(droppedUsd)), 76, ' '));
1738
+ return;
1739
+ }
1740
+ // The prune also collapses the append log to what the store resolves to,
1741
+ // which is the only moment a rewrite is safe: it is what the reader was
1742
+ // already seeing.
1743
+ await rewriteStore(root, result.kept);
1744
+ console.log(wrap(t.store.pruned(n(result.dropped.length), String(keepDays), result.droppedSpan === null
1745
+ ? null
1746
+ : `${day(result.droppedSpan.fromMs)} → ${day(result.droppedSpan.toMs)}`, formatUsd(droppedUsd), n(result.kept.length)), 76, ' '));
1747
+ return;
1748
+ }
1749
+ if (boolFlag(args, 'json')) {
1750
+ console.log(JSON.stringify({ ...inventory, totalUsd: priced.total.totalUsd, unreadable }, null, 2));
1751
+ return;
1752
+ }
1753
+ /**
1754
+ * Empty means *nothing at all* — not "nothing I could resolve".
1755
+ *
1756
+ * Records the store could not tell apart, lines it could not parse and
1757
+ * records from a newer schema are all real measurements sitting on disk.
1758
+ * Reporting an empty store over them would hide exactly what the reader
1759
+ * needs to see, which is the failure this whole module is written against.
1760
+ */
1761
+ const nothingAtAll = inventory.totalRecords === 0 &&
1762
+ inventory.possiblyDouble === 0 &&
1763
+ inventory.unknownVersion === 0 &&
1764
+ unreadable.length === 0;
1765
+ if (nothingAtAll) {
1766
+ console.log(wrap(t.store.empty(STORE_DIR), 76, ' '));
1767
+ return;
1768
+ }
1769
+ 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))));
1770
+ for (const provider of inventory.providers) {
1771
+ 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))}`);
1772
+ }
1773
+ console.log();
1774
+ console.log(` ${c.dim(wrap(t.store.holds(n(files.length)), 74, ' '))}`);
1775
+ if (inventory.possiblyDouble > 0) {
1776
+ console.log(` ${c.yellow(wrap(t.store.possiblyDouble(n(inventory.possiblyDouble)), 74, ' '))}`);
1777
+ }
1778
+ if (inventory.unknownVersion > 0) {
1779
+ console.log(` ${c.yellow(wrap(t.store.unknownVersion(n(inventory.unknownVersion)), 74, ' '))}`);
1780
+ }
1781
+ for (const bad of unreadable) {
1782
+ console.log(` ${c.yellow(wrap(t.store.unreadable(bad.file, String(bad.line)), 74, ' '))}`);
1783
+ }
1784
+ const keepDays = config.store?.keepDays;
1785
+ console.log(` ${c.dim(wrap(keepDays === undefined ? t.store.noRetention() : t.store.retention(String(keepDays)), 74, ' '))}`);
1786
+ }
1787
+ /**
1788
+ * `trazum connect <provider>` — the bill, read from the provider.
1789
+ *
1790
+ * The pull and the pricing live elsewhere; this owns the window, the
1791
+ * rendering and the refusals. The report it prints is deliberately a
1792
+ * *restricted* one: a usage API serves sums, so every per-call finding is
1793
+ * listed as unavailable rather than computed from a zero nobody measured.
1794
+ */
1795
+ async function commandConnect(args, pricing, t) {
1796
+ const id = args.positional[0];
1797
+ if (id === undefined) {
1798
+ throw new Error(t.connect.noTarget(CONNECTORS.map((c) => c.id).join(', ')));
1799
+ }
1800
+ const descriptor = connectorFor(id);
1801
+ if (descriptor === null) {
1802
+ throw new Error(t.connect.unknownProvider(id, CONNECTORS.map((c) => c.id).join(', ')));
1803
+ }
1804
+ const now = Date.now();
1805
+ const since = parseWhen(args, 'since', false, t, now);
1806
+ const until = parseWhen(args, 'until', true, t, now);
1807
+ // A month back by default: long enough to be a bill, short enough that a
1808
+ // first run against a busy organisation does not walk fifty pages.
1809
+ const fromMs = since.ms ?? now - 30 * 86_400_000;
1810
+ const toMs = until.ms ?? now;
1811
+ if (fromMs >= toMs)
1812
+ throw new Error(t.profile.sinceAfterUntil());
1813
+ const day = (msValue) => new Date(msValue).toISOString().slice(0, 10);
1814
+ if (boolFlag(args, 'dry-run')) {
1815
+ console.log(wrap(t.connect.dryRun(descriptor.displayName, day(fromMs), day(toMs), descriptor.credentialEnv.join(' or '), descriptor.keyKind), 76, ' '));
1816
+ return;
1817
+ }
1818
+ /**
1819
+ * A payload somebody already has is priced without a pull.
1820
+ *
1821
+ * People save API responses — from a support thread, from a curl in a
1822
+ * runbook, from a colleague who has the admin key and they do not. Pricing
1823
+ * one needs no credential and no network, and it is the same arithmetic on
1824
+ * the same shape, so refusing it would be ceremony rather than safety.
1825
+ */
1826
+ const payloadPath = stringFlag(args, 'payload');
1827
+ const pulled = payloadPath === undefined
1828
+ ? await fetchProviderUsage({ descriptor, fromMs, toMs, env: process.env })
1829
+ : {
1830
+ pull: (descriptor.id === 'anthropic' ? normalizeAnthropicUsage : normalizeOpenAIUsage)(JSON.parse(await readFile(payloadPath, 'utf8'))),
1831
+ source: { variable: payloadPath },
1832
+ };
1833
+ const { pull, source } = pulled;
1834
+ const report = bucketedProfile(pull, { catalogue: pricing });
1835
+ const cache = bucketedCacheEconomics(report);
1836
+ const n = (value) => value.toLocaleString(t.numberLocale);
1837
+ /**
1838
+ * `--store` keeps what was pulled, so the next run does not download it
1839
+ * again and `history` has a series without anybody curating a folder. Opt
1840
+ * in rather than automatic: a command that starts writing to a hidden
1841
+ * directory on its own is a command nobody trusts twice.
1842
+ */
1843
+ let stored = 0;
1844
+ if (boolFlag(args, 'store')) {
1845
+ stored = await appendRecords(process.cwd(), recordsFromBuckets(pull.provider, pull.buckets, Date.now()));
1846
+ }
1847
+ const outPath = stringFlag(args, 'out');
1848
+ if (outPath !== undefined) {
1849
+ await writeFile(outPath, `${JSON.stringify({ ...report, pulledFrom: source.variable }, null, 2)}\n`);
1850
+ }
1851
+ if (boolFlag(args, 'json')) {
1852
+ console.log(JSON.stringify(report, null, 2));
1853
+ return;
1854
+ }
1855
+ const lines = (md) => {
1856
+ const out = [];
1857
+ const heading = t.connect.heading(descriptor.displayName, report.span === null ? day(fromMs) : day(report.span.fromMs), report.span === null ? day(toMs) : day(report.span.toMs), formatUsd(report.total.totalUsd), report.total.calls === null ? null : n(report.total.calls));
1858
+ out.push(md ? `## ${heading}` : heading);
1859
+ const modelWidth = Math.max(0, ...report.byModel.map((s) => s.model.length));
1860
+ for (const slice of report.byModel) {
1861
+ const share = report.total.totalUsd > 0 ? slice.totalUsd / report.total.totalUsd : 0;
1862
+ const row = t.connect.modelRow(md ? slice.model : slice.model.padEnd(modelWidth), formatUsd(slice.totalUsd).padStart(9), `${(share * 100).toFixed(1)}%`.padStart(6), slice.calls === null ? null : n(slice.calls));
1863
+ out.push(md ? `- ${row}` : row);
1864
+ }
1865
+ if (report.byModel.length === 0) {
1866
+ out.push(md ? `_${t.connect.nothingBilled()}_` : t.connect.nothingBilled());
1867
+ }
1868
+ if (cache.verdict !== 'no-cache') {
1869
+ out.push('');
1870
+ const line = cache.verdict === 'paid-off'
1871
+ ? t.connect.cachePaid(formatUsd(-cache.deltaUsd))
1872
+ : t.connect.cacheLost(formatUsd(cache.deltaUsd));
1873
+ out.push(line);
1874
+ if (cache.worstCaseVerdict !== cache.verdict) {
1875
+ const unsettled = t.connect.cacheUnsettled();
1876
+ out.push(md ? `_${unsettled}_` : unsettled);
1877
+ }
1878
+ }
1879
+ if (report.total.calls === null) {
1880
+ out.push('');
1881
+ const line = t.connect.noCallCount(descriptor.displayName);
1882
+ out.push(md ? `_${line}_` : line);
1883
+ }
1884
+ for (const model of report.unpricedModels) {
1885
+ out.push('');
1886
+ const line = t.connect.unpriced(model.model, n(model.inputTokens + model.outputTokens));
1887
+ out.push(md ? `- ${line}` : `! ${line}`);
1888
+ }
1889
+ if (report.gaps.length > 0)
1890
+ out.push('');
1891
+ for (const gap of report.gaps) {
1892
+ const line = t.connect.gap(gap.detail);
1893
+ out.push(md ? `- ${line}` : `! ${line}`);
1894
+ }
1895
+ out.push('');
1896
+ const unavailable = t.connect.unavailable(report.unavailable.map((u) => u.finding).join(', '));
1897
+ out.push(md ? `_${unavailable}_` : unavailable);
1898
+ out.push('');
1899
+ out.push(md ? `_${t.connect.footer()}_` : t.connect.footer());
1900
+ return out;
1901
+ };
1902
+ await writeMarkdown(args, () => lines(true).join('\n'));
1903
+ const [head, ...rest] = lines(false);
1904
+ console.log(c.bold(head));
1905
+ for (const row of rest) {
1906
+ // Short rows print as written so the columns stay aligned; `wrap` collapses
1907
+ // runs of spaces, which is right for prose and wrong for a table.
1908
+ if (row === '')
1909
+ console.log('');
1910
+ else if (row.length <= 74)
1911
+ console.log(` ${row}`);
1912
+ else
1913
+ console.log(` ${wrap(row, 74, ' ')}`);
1914
+ }
1915
+ if (outPath !== undefined)
1916
+ console.log(c.dim(t.connect.wrote(outPath)));
1917
+ if (stored > 0)
1918
+ console.log(c.dim(t.store.appended(n(stored), STORE_DIR)));
1919
+ }
1649
1920
  /**
1650
1921
  * `trazum history <dir>` — many reports over many periods, as one series.
1651
1922
  *
@@ -1655,41 +1926,100 @@ function isoDate() {
1655
1926
  * same action planned twice — and no series, however long, becomes a
1656
1927
  * forecast.
1657
1928
  */
1658
- async function commandHistory(args, t) {
1659
- const path = args.positional[0];
1660
- if (path === undefined)
1661
- throw new Error(t.history.noTarget());
1662
- const target = await stat(path).catch(() => null);
1663
- if (!target?.isDirectory())
1664
- throw new Error(t.history.noTarget());
1665
- const entries = await readdir(path, { withFileTypes: true });
1666
- const files = entries
1667
- .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
1668
- .map((entry) => join(path, entry.name))
1669
- .sort((a, b) => a.localeCompare(b));
1929
+ async function commandHistory(args, pricing, t) {
1930
+ /**
1931
+ * `--store` builds the series from measured spend already on disk.
1932
+ *
1933
+ * Bucketed sources carry no label — a usage API groups by model and
1934
+ * workspace, never by workload — so the label series is *absent and named*
1935
+ * rather than empty and misread, the same discipline the connected report
1936
+ * uses for the findings a sum cannot support. The model-share and
1937
+ * cache-share series are exactly what a series exists for, and both work.
1938
+ */
1670
1939
  const reports = [];
1671
1940
  const plans = [];
1672
1941
  const unrecognized = [];
1673
- for (const file of files) {
1674
- let parsed;
1675
- try {
1676
- parsed = JSON.parse(await readFile(file, 'utf8'));
1942
+ const fromStore = boolFlag(args, 'store');
1943
+ if (fromStore) {
1944
+ const { resolved } = await readStore(process.cwd());
1945
+ if (resolved.records.length === 0)
1946
+ throw new Error(t.store.empty(STORE_DIR));
1947
+ /**
1948
+ * One period per UTC day of stored measurement, priced exactly as a fresh
1949
+ * pull prices it.
1950
+ *
1951
+ * The label series is deliberately absent: a usage API groups by model
1952
+ * and workspace, never by workload, so there is no label to carry.
1953
+ * Rendering an empty label series would read as "no workload moved",
1954
+ * which is a statement about traffic rather than about the source, and
1955
+ * the footer says which it is.
1956
+ */
1957
+ const byDay = new Map();
1958
+ for (const record of resolved.records) {
1959
+ const key = new Date(record.fromMs).toISOString().slice(0, 10);
1960
+ const list = byDay.get(key) ?? [];
1961
+ list.push(record);
1962
+ byDay.set(key, list);
1963
+ }
1964
+ for (const [dayKey, records] of [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
1965
+ const day = bucketedProfile({
1966
+ provider: 'store',
1967
+ granularity: 'bucketed',
1968
+ buckets: bucketsFromRecords(records),
1969
+ window: {
1970
+ fromMs: Math.min(...records.map((r) => r.fromMs)),
1971
+ toMs: Math.max(...records.map((r) => r.toMs)),
1972
+ },
1973
+ gaps: [],
1974
+ unavailable: [],
1975
+ }, { catalogue: pricing });
1976
+ const cacheTouched = day.total.cacheReadTokens + day.total.cacheWriteTokens;
1977
+ reports.push({
1978
+ name: dayKey,
1979
+ span: day.span,
1980
+ totalUsd: day.total.totalUsd,
1981
+ calls: day.total.calls,
1982
+ byLabel: new Map(),
1983
+ byModel: new Map(day.byModel.map((slice) => [slice.model, slice.totalUsd])),
1984
+ cacheReadShare: day.total.inputTokens + cacheTouched > 0
1985
+ ? day.total.cacheReadTokens / (day.total.inputTokens + cacheTouched)
1986
+ : null,
1987
+ });
1677
1988
  }
1678
- catch {
1989
+ }
1990
+ else {
1991
+ const path = args.positional[0];
1992
+ if (path === undefined)
1993
+ throw new Error(t.history.noTarget());
1994
+ const target = await stat(path).catch(() => null);
1995
+ if (!target?.isDirectory())
1996
+ throw new Error(t.history.noTarget());
1997
+ const entries = await readdir(path, { withFileTypes: true });
1998
+ const files = entries
1999
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
2000
+ .map((entry) => join(path, entry.name))
2001
+ .sort((a, b) => a.localeCompare(b));
2002
+ for (const file of files) {
2003
+ let parsed;
2004
+ try {
2005
+ parsed = JSON.parse(await readFile(file, 'utf8'));
2006
+ }
2007
+ catch {
2008
+ unrecognized.push(file);
2009
+ continue;
2010
+ }
2011
+ const report = storedReportFrom(file, parsed);
2012
+ if (report !== null) {
2013
+ reports.push(report);
2014
+ continue;
2015
+ }
2016
+ const maybePlan = parsed;
2017
+ if (maybePlan?.schemaVersion === 1 && Array.isArray(maybePlan.actions)) {
2018
+ plans.push(maybePlan);
2019
+ continue;
2020
+ }
1679
2021
  unrecognized.push(file);
1680
- continue;
1681
- }
1682
- const report = storedReportFrom(file, parsed);
1683
- if (report !== null) {
1684
- reports.push(report);
1685
- continue;
1686
- }
1687
- const maybePlan = parsed;
1688
- if (maybePlan?.schemaVersion === 1 && Array.isArray(maybePlan.actions)) {
1689
- plans.push(maybePlan);
1690
- continue;
1691
2022
  }
1692
- unrecognized.push(file);
1693
2023
  }
1694
2024
  const history = buildHistory(reports, plans);
1695
2025
  if (history.periods.length < 3) {
@@ -1716,7 +2046,7 @@ async function commandHistory(args, t) {
1716
2046
  const heading = t.history.heading(n(history.periods.length), day(first.fromMs), day(last.toMs));
1717
2047
  out.push(md ? `## ${heading}` : heading);
1718
2048
  for (const period of history.periods) {
1719
- const row = t.history.periodRow(period.name, formatUsd(period.totalUsd), n(period.calls), ((period.toMs - period.fromMs) / 86_400_000).toFixed(1));
2049
+ 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));
1720
2050
  out.push(md ? `- ${row}` : ` ${row}`);
1721
2051
  }
1722
2052
  if (history.runs.length > 0)
@@ -1737,6 +2067,11 @@ async function commandHistory(args, t) {
1737
2067
  for (const name of unrecognized) {
1738
2068
  out.push(md ? `- ${t.history.unrecognized(name)}` : ` ${t.history.unrecognized(name)}`);
1739
2069
  }
2070
+ if (fromStore) {
2071
+ out.push('');
2072
+ const note = t.history.storeNoLabels();
2073
+ out.push(md ? `_${note}_` : ` ${note}`);
2074
+ }
1740
2075
  out.push('');
1741
2076
  out.push(md ? `_${t.history.footer()}_` : ` ${t.history.footer()}`);
1742
2077
  return out;
@@ -2074,45 +2409,11 @@ async function commandProfile(args, config, pricing, t) {
2074
2409
  * no record.
2075
2410
  */
2076
2411
  const now = Date.now();
2077
- let relativeWindow = false;
2078
- const parseWhen = (flag, endOfDay) => {
2079
- const value = stringFlag(args, flag);
2080
- if (value === undefined)
2081
- return undefined;
2082
- /**
2083
- * A relative window — `7d`, `24h` — because "the last week" is what a
2084
- * nightly job actually wants, and computing a date in a shell to say it
2085
- * is the step that gets skipped.
2086
- *
2087
- * Relative to **the machine's clock, not the log's**, which is a real
2088
- * difference: a log exported last month answers `--since 7d` with
2089
- * nothing, and the report says so rather than reporting $0. That caveat
2090
- * is stated beside the window line, because a period the reader did not
2091
- * name is a period they will misread.
2092
- */
2093
- const relative = /^(\d+)([dh])$/.exec(value);
2094
- if (relative) {
2095
- const amount = Number(relative[1]);
2096
- if (amount > 0) {
2097
- relativeWindow = true;
2098
- const span = relative[2] === 'd' ? 86_400_000 : 3_600_000;
2099
- return now - amount * span;
2100
- }
2101
- }
2102
- if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
2103
- const midnight = Date.parse(`${value}T00:00:00Z`);
2104
- if (Number.isFinite(midnight))
2105
- return endOfDay ? midnight + 86_400_000 : midnight;
2106
- }
2107
- if (value === 'now')
2108
- return now;
2109
- const exact = Date.parse(value);
2110
- if (Number.isFinite(exact))
2111
- return exact;
2112
- throw new Error(t.profile.badWhen(flag, value));
2113
- };
2114
- const sinceMs = parseWhen('since', false);
2115
- const untilMs = parseWhen('until', true);
2412
+ const sinceWhen = parseWhen(args, 'since', false, t, now);
2413
+ const untilWhen = parseWhen(args, 'until', true, t, now);
2414
+ const relativeWindow = sinceWhen.relative || untilWhen.relative;
2415
+ const sinceMs = sinceWhen.ms;
2416
+ const untilMs = untilWhen.ms;
2116
2417
  if (sinceMs !== undefined && untilMs !== undefined && sinceMs >= untilMs) {
2117
2418
  throw new Error(t.profile.sinceAfterUntil());
2118
2419
  }
@@ -5159,7 +5460,13 @@ async function main() {
5159
5460
  await commandVerify(args, pricing, t);
5160
5461
  break;
5161
5462
  case 'history':
5162
- await commandHistory(args, t);
5463
+ await commandHistory(args, pricing, t);
5464
+ break;
5465
+ case 'connect':
5466
+ await commandConnect(args, pricing, t);
5467
+ break;
5468
+ case 'store':
5469
+ await commandStore(args, config, pricing, t);
5163
5470
  break;
5164
5471
  case 'route':
5165
5472
  await commandRoute(args, pricing, t);