@trazum/cli 1.41.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,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, 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,7 @@ 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';
13
14
  import { detectLocale, getCliMessages } from './i18n/index.js';
14
15
  import { MAX_SUMMARY_CHARS, fitWithin, renderBlameMarkdown, renderCheckMarkdown, renderDiffMarkdown, renderRankMarkdown, renderProfileMarkdown, } from './markdown.js';
15
16
  // --------------------------------------------------------------------------
@@ -29,6 +30,7 @@ const VALUE_FLAGS = new Set([
29
30
  'from-log',
30
31
  'min-usd',
31
32
  'payload',
33
+ 'keep',
32
34
  // `route` takes a path here, and the flag is deliberately not `--prompt`:
33
35
  // everywhere else in this tool `--prompt` names a marked prompt *inside* a
34
36
  // source file, and reusing it for a path would be a trap laid for the reader.
@@ -290,8 +292,9 @@ const COMMAND_FLAGS = {
290
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'],
291
293
  plan: ['json', 'out', 'markdown-out', 'min-usd', 'pricing', 'pricing-live'],
292
294
  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'],
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'],
295
298
  route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
296
299
  eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
297
300
  prune: ['cases', 'concurrency', 'json', 'yes'],
@@ -1686,6 +1689,101 @@ function parseWhen(args, flag, endOfDay, t, now) {
1686
1689
  return { ms: exact, relative: false };
1687
1690
  throw new Error(t.profile.badWhen(flag, value));
1688
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
+ }
1689
1787
  /**
1690
1788
  * `trazum connect <provider>` — the bill, read from the provider.
1691
1789
  *
@@ -1736,6 +1834,16 @@ async function commandConnect(args, pricing, t) {
1736
1834
  const report = bucketedProfile(pull, { catalogue: pricing });
1737
1835
  const cache = bucketedCacheEconomics(report);
1738
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
+ }
1739
1847
  const outPath = stringFlag(args, 'out');
1740
1848
  if (outPath !== undefined) {
1741
1849
  await writeFile(outPath, `${JSON.stringify({ ...report, pulledFrom: source.variable }, null, 2)}\n`);
@@ -1806,6 +1914,8 @@ async function commandConnect(args, pricing, t) {
1806
1914
  }
1807
1915
  if (outPath !== undefined)
1808
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)));
1809
1919
  }
1810
1920
  /**
1811
1921
  * `trazum history <dir>` — many reports over many periods, as one series.
@@ -1816,41 +1926,100 @@ async function commandConnect(args, pricing, t) {
1816
1926
  * same action planned twice — and no series, however long, becomes a
1817
1927
  * forecast.
1818
1928
  */
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));
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
+ */
1831
1939
  const reports = [];
1832
1940
  const plans = [];
1833
1941
  const unrecognized = [];
1834
- for (const file of files) {
1835
- let parsed;
1836
- try {
1837
- 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
+ });
1838
1988
  }
1839
- 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
+ }
1840
2021
  unrecognized.push(file);
1841
- continue;
1842
2022
  }
1843
- const report = storedReportFrom(file, parsed);
1844
- if (report !== null) {
1845
- reports.push(report);
1846
- continue;
1847
- }
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
2023
  }
1855
2024
  const history = buildHistory(reports, plans);
1856
2025
  if (history.periods.length < 3) {
@@ -1877,7 +2046,7 @@ async function commandHistory(args, t) {
1877
2046
  const heading = t.history.heading(n(history.periods.length), day(first.fromMs), day(last.toMs));
1878
2047
  out.push(md ? `## ${heading}` : heading);
1879
2048
  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));
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));
1881
2050
  out.push(md ? `- ${row}` : ` ${row}`);
1882
2051
  }
1883
2052
  if (history.runs.length > 0)
@@ -1898,6 +2067,11 @@ async function commandHistory(args, t) {
1898
2067
  for (const name of unrecognized) {
1899
2068
  out.push(md ? `- ${t.history.unrecognized(name)}` : ` ${t.history.unrecognized(name)}`);
1900
2069
  }
2070
+ if (fromStore) {
2071
+ out.push('');
2072
+ const note = t.history.storeNoLabels();
2073
+ out.push(md ? `_${note}_` : ` ${note}`);
2074
+ }
1901
2075
  out.push('');
1902
2076
  out.push(md ? `_${t.history.footer()}_` : ` ${t.history.footer()}`);
1903
2077
  return out;
@@ -5286,11 +5460,14 @@ async function main() {
5286
5460
  await commandVerify(args, pricing, t);
5287
5461
  break;
5288
5462
  case 'history':
5289
- await commandHistory(args, t);
5463
+ await commandHistory(args, pricing, t);
5290
5464
  break;
5291
5465
  case 'connect':
5292
5466
  await commandConnect(args, pricing, t);
5293
5467
  break;
5468
+ case 'store':
5469
+ await commandStore(args, config, pricing, t);
5470
+ break;
5294
5471
  case 'route':
5295
5472
  await commandRoute(args, pricing, t);
5296
5473
  break;