tledger 0.2.1 → 0.3.1

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.
@@ -4,7 +4,6 @@ import { spawnSync } from "node:child_process";
4
4
  import { existsSync, realpathSync } from "node:fs";
5
5
  import {
6
6
  mkdir,
7
- readFile,
8
7
  stat,
9
8
  } from "node:fs/promises";
10
9
  import { homedir } from "node:os";
@@ -20,18 +19,32 @@ import {
20
19
  renderTrendImage,
21
20
  writeTrendPng,
22
21
  } from "./token-ledger-trend-image.mjs";
22
+ import { renderCacheReportImage } from "./token-ledger-cache-image.mjs";
23
23
  import { renderTrendCombo } from "./token-ledger-trend-terminal.mjs";
24
24
  import { startInteractive } from "./token-ledger-tui.mjs";
25
+ import {
26
+ readPrivateSnapshot,
27
+ writePrivateSnapshot,
28
+ } from "../lib/token-ledger-snapshot.mjs";
29
+ import {
30
+ SNAPSHOT_SCHEMA_VERSION,
31
+ usageBuckets,
32
+ usageBucketsInRange,
33
+ usageCallCount,
34
+ usageThreadIds,
35
+ } from "../lib/token-ledger-usage.mjs";
25
36
 
26
37
  export const DEFAULT_SNAPSHOT = resolve(
27
38
  homedir(),
28
39
  ".token-ledger",
29
- "token-ledger-snapshot.json",
40
+ "token-ledger-snapshot-v2.json.gz",
30
41
  );
31
42
  const DEFAULT_TOP = 10;
32
43
  const DEFAULT_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
33
44
  export const SNAPSHOT_CACHE_MAX_AGE_MS = 60 * 60 * 1000;
34
45
  export const ROLLING_24_HOURS_MS = 24 * 60 * 60 * 1000;
46
+ const MAX_ROLLING_DAYS = 3_650;
47
+ const DURATION_ALIAS = /^(\d+)(d|w)$/i;
35
48
  const ANSI_RESET = "\u001b[0m";
36
49
  const MODEL_COLORS = {
37
50
  sol: TERMINAL_MODEL_COLORS.sol,
@@ -42,46 +55,111 @@ const MODEL_COLORS = {
42
55
  other: TERMINAL_MODEL_COLORS.other,
43
56
  };
44
57
 
45
- function usage() {
46
- return `Token Ledger terminal usage
58
+ export function usage() {
59
+ return `Token Ledger
47
60
 
48
61
  Usage:
49
- tledger 1d Rolling 24-hour project breakdown (ends now)
50
- tledger day <YYYY-MM-DD>
51
- tledger week [end-day]
52
- tledger trend [7d|14d|30d]
53
- tledger report [7d|14d|30d]
54
- npm run usage:day -- <YYYY-MM-DD>
55
- npm run usage:week -- [end-day]
56
-
57
- Options:
58
- --date <day> Date as YYYY-MM-DD, today, or yesterday
59
- --period <window> Trend window: 7d, 14d, or 30d
60
- --input <file> Snapshot to read (default: ~/.token-ledger/token-ledger-snapshot.json)
61
- --refresh Rebuild the default snapshot from CODEX_HOME or ~/.codex
62
- --no-refresh Use the cached snapshot without checking local JSONL files
63
- --codex-home <dir> Codex data root used when refreshing
64
- --tz <name> IANA timezone (default: machine timezone)
65
- --top <number> Number of projects to show (default: 10)
66
- --width <number> Terminal layout width in columns
67
- --raw-projects Keep singleton thread labels instead of grouping them
68
- --no-archived Skip archived_sessions when refreshing
69
- --plain Disable terminal colors
70
- --ascii Use ASCII bars instead of Unicode blocks
71
- --static Print once instead of opening the interactive dashboard
72
- --drain Trend columns show observed limit drain percent instead of token volume
73
- --image Write trend view as a PNG image
74
- --image-output <file> PNG output path for trend view
75
- --image-width <px> PNG image width from 900 to 2400 pixels
76
- --youplot Use the legacy single-series YouPlot renderer
77
- --help Show this help
78
-
79
- The report command writes the dashboard PNG (same as trend --image) to
80
- token-ledger-report-<period>.png; use --image-output to choose the path.
81
-
82
- The command reads a privacy-reduced Token Ledger snapshot. It never uploads
83
- the snapshot or prints message bodies, tool payloads, credentials, or local
84
- input/source paths. Explicit PNG output paths are reported after writing.`;
62
+ tledger 1d Last 24 hours in the terminal
63
+ tledger week Last 7 calendar days in the terminal
64
+ tledger 30d Rolling 30 days in the terminal
65
+ tledger report 7d Write the 7-day PNG report
66
+ tledger report 7d --cache-rate Write the cache-only PNG report
67
+
68
+ Common options:
69
+ --static Print once instead of opening the dashboard
70
+ --refresh Rebuild the local usage cache
71
+ --image-output <file> Choose where to save a PNG
72
+ --no-open Do not open a generated PNG
73
+ -h, --help Show this quick guide
74
+ --help-all Show every command and option
75
+
76
+ Token Ledger reads local Codex data only. It does not upload your usage.`;
77
+ }
78
+
79
+ export function advancedUsage() {
80
+ return `Token Ledger command reference
81
+
82
+ Terminal commands:
83
+ tledger 1d Rolling 24-hour project breakdown
84
+ tledger <N>d Rolling N-day project breakdown
85
+ tledger <N>w Rolling N-week project breakdown
86
+ tledger day <YYYY-MM-DD> One local calendar day
87
+ tledger week [end-day] Seven local calendar days
88
+ tledger trend [Nd|Nw] Multi-day terminal trend
89
+
90
+ Report commands:
91
+ tledger report [Nd|Nw] Write the usage dashboard PNG
92
+ tledger report [Nd|Nw] --cache-rate
93
+ Write the cache-only PNG
94
+
95
+ Dates and ranges:
96
+ --date <day> YYYY-MM-DD, today, or yesterday
97
+ --period <window> Trend window such as 7d, 14d, or 2w
98
+ --tz <name> IANA timezone (default: machine timezone)
99
+
100
+ Data and refresh:
101
+ --input <file> Read an explicit snapshot
102
+ --refresh Rebuild the default snapshot from local Codex data
103
+ --no-refresh Use the cached snapshot without checking source files
104
+ --codex-home <dir> Codex data root used when refreshing
105
+ --no-archived Skip archived sessions when refreshing
106
+
107
+ Terminal output:
108
+ --top <number> Projects to show, from 1 to 100 (default: 10)
109
+ --width <number> Layout width, from 40 to 200 columns
110
+ --raw-projects Keep singleton thread labels ungrouped
111
+ --plain Disable terminal colors
112
+ --ascii Use ASCII bars instead of Unicode blocks
113
+ --static Print once instead of opening the dashboard
114
+ --youplot Use the legacy single-series renderer
115
+
116
+ Report output:
117
+ --drain Chart estimated meter drain instead of token volume
118
+ --cache-rate Write the cache-only report (report command only)
119
+ --image Write the trend view as a PNG
120
+ --image-output <file> Choose the PNG output path
121
+ --image-width <px> Set PNG width from 900 to 2400 pixels
122
+ --no-open Do not open the finished PNG
123
+
124
+ Help:
125
+ -h, --help Show the quick guide
126
+ --help-all Show this complete reference
127
+
128
+ The default snapshot is ~/.token-ledger/token-ledger-snapshot-v2.json.gz.
129
+ Token Ledger reads local Codex data only. It does not upload your usage.`;
130
+ }
131
+
132
+ function durationAlias(value) {
133
+ const match = DURATION_ALIAS.exec(String(value ?? ""));
134
+ if (!match) return null;
135
+ const amount = Number(match[1]);
136
+ const unit = match[2].toLowerCase();
137
+ const days = unit === "w" ? amount * 7 : amount;
138
+ if (
139
+ !Number.isSafeInteger(amount) ||
140
+ !Number.isSafeInteger(days) ||
141
+ amount < 1 ||
142
+ days > MAX_ROLLING_DAYS
143
+ ) {
144
+ throw new Error(
145
+ `Duration must be between 1d and ${MAX_ROLLING_DAYS}d (or the equivalent in weeks).`,
146
+ );
147
+ }
148
+ const noun = unit === "w"
149
+ ? amount === 1 ? "week" : "weeks"
150
+ : amount === 1 ? "day" : "days";
151
+ return {
152
+ amount,
153
+ unit,
154
+ days,
155
+ label: `${amount} ${noun}`,
156
+ };
157
+ }
158
+
159
+ function rollingRangeDescription(options) {
160
+ return options.range === "rolling24h"
161
+ ? "the last 24 hours"
162
+ : `the last ${options.rollingLabel}`;
85
163
  }
86
164
 
87
165
  function readOption(argv, index, name) {
@@ -93,9 +171,14 @@ function readOption(argv, index, name) {
93
171
  }
94
172
 
95
173
  export function parseArgs(argv) {
174
+ const helpCommand = argv[0] === "help";
175
+ const alias = durationAlias(argv[0]);
96
176
  const rolling24hCommand = argv[0] === "1d";
177
+ const rollingDurationCommand = Boolean(alias) && !rolling24hCommand;
97
178
  const command = rolling24hCommand
98
179
  ? "rolling24h"
180
+ : rollingDurationCommand
181
+ ? "rolling"
99
182
  : argv[0] === "week"
100
183
  ? "week"
101
184
  : argv[0] === "trend" || argv[0] === "report"
@@ -105,6 +188,11 @@ export function parseArgs(argv) {
105
188
  range: command,
106
189
  view: command === "trend" ? "trend" : "projects",
107
190
  rolling24h: rolling24hCommand,
191
+ rollingDuration: rollingDurationCommand,
192
+ rollingDays: alias?.days ?? (rolling24hCommand ? 1 : null),
193
+ rollingAmount: alias?.amount ?? (rolling24hCommand ? 1 : null),
194
+ rollingUnit: alias?.unit ?? (rolling24hCommand ? "d" : null),
195
+ rollingLabel: alias?.label ?? "1 day",
108
196
  report: argv[0] === "report",
109
197
  trendDays: 7,
110
198
  date: null,
@@ -124,17 +212,23 @@ export function parseArgs(argv) {
124
212
  image: false,
125
213
  imageOutput: null,
126
214
  imageWidth: null,
215
+ openImage: true,
127
216
  drain: false,
217
+ cacheRate: false,
128
218
  legacyPlot: false,
129
- help: false,
219
+ help: argv.length === 0 || helpCommand,
220
+ helpAll: false,
130
221
  };
131
222
 
132
223
  let trendPeriodSeen = false;
133
- let index = ["1d", "day", "week", "trend", "report"].includes(argv[0]) ? 1 : 0;
224
+ let index = alias || ["day", "week", "trend", "report", "help"].includes(argv[0]) ? 1 : 0;
134
225
  for (; index < argv.length; index += 1) {
135
226
  const argument = argv[index];
136
227
  if (argument === "--help" || argument === "-h") {
137
228
  options.help = true;
229
+ } else if (argument === "--help-all") {
230
+ options.help = true;
231
+ options.helpAll = true;
138
232
  } else if (argument === "--date") {
139
233
  options.date = readOption(argv, index, "--date");
140
234
  index += 1;
@@ -146,10 +240,13 @@ export function parseArgs(argv) {
146
240
  throw new Error("Trend period can only be specified once.");
147
241
  }
148
242
  const value = readOption(argv, index, "--period");
149
- if (!["7d", "14d", "30d"].includes(value)) {
150
- throw new Error("Trend period must be 7d, 14d, or 30d.");
243
+ const period = durationAlias(value);
244
+ if (!period) {
245
+ throw new Error(
246
+ "Trend period must use a positive number of days or weeks, such as 7d or 2w.",
247
+ );
151
248
  }
152
- options.trendDays = Number.parseInt(value, 10);
249
+ options.trendDays = period.days;
153
250
  trendPeriodSeen = true;
154
251
  index += 1;
155
252
  } else if (argument === "--input") {
@@ -195,11 +292,21 @@ export function parseArgs(argv) {
195
292
  throw new Error("--drain is only available for the trend view.");
196
293
  }
197
294
  options.drain = true;
295
+ } else if (argument === "--cache-rate") {
296
+ if (!options.report) {
297
+ throw new Error("--cache-rate is only available with the report command.");
298
+ }
299
+ options.cacheRate = true;
198
300
  } else if (argument === "--image") {
199
301
  if (options.view !== "trend") {
200
302
  throw new Error("--image is only available for the trend view.");
201
303
  }
202
304
  options.image = true;
305
+ } else if (argument === "--no-open") {
306
+ if (options.view !== "trend") {
307
+ throw new Error("--no-open is only available for the trend view.");
308
+ }
309
+ options.openImage = false;
203
310
  } else if (argument === "--image-output") {
204
311
  if (options.view !== "trend") {
205
312
  throw new Error("--image-output is only available for the trend view.");
@@ -241,10 +348,13 @@ export function parseArgs(argv) {
241
348
  if (trendPeriodSeen) {
242
349
  throw new Error("Trend period can only be specified once.");
243
350
  }
244
- if (!["7d", "14d", "30d"].includes(argument)) {
245
- throw new Error("Trend period must be 7d, 14d, or 30d.");
351
+ const period = durationAlias(argument);
352
+ if (!period) {
353
+ throw new Error(
354
+ "Trend period must use a positive number of days or weeks, such as 7d or 2w.",
355
+ );
246
356
  }
247
- options.trendDays = Number.parseInt(argument, 10);
357
+ options.trendDays = period.days;
248
358
  trendPeriodSeen = true;
249
359
  } else if (!argument.startsWith("-") && !options.date) {
250
360
  options.date = argument;
@@ -254,13 +364,13 @@ export function parseArgs(argv) {
254
364
  }
255
365
 
256
366
  if (options.report) options.image = true;
257
- if (!options.help && options.rolling24h && options.date) {
258
- throw new Error("1d does not accept --date; its rolling window ends now.");
367
+ if (!options.help && (options.rolling24h || options.rollingDuration) && options.date) {
368
+ throw new Error(`${options.rollingLabel} does not accept --date; its rolling window ends now.`);
259
369
  }
260
370
  if (!options.help && !options.date && (options.range === "week" || options.view === "trend")) {
261
371
  options.date = "today";
262
372
  }
263
- if (!options.help && !options.date && !options.rolling24h) {
373
+ if (!options.help && !options.date && !options.rolling24h && !options.rollingDuration) {
264
374
  throw new Error("A day is required, for example: tledger day 2026-08-01");
265
375
  }
266
376
  if (!options.help && options.refresh && !options.autoRefresh) {
@@ -272,6 +382,9 @@ export function parseArgs(argv) {
272
382
  if (!options.help && options.view === "trend" && options.legacyPlot) {
273
383
  throw new Error("--youplot is only available for the project view.");
274
384
  }
385
+ if (!options.help && options.cacheRate && options.drain) {
386
+ throw new Error("--cache-rate cannot be combined with --drain.");
387
+ }
275
388
  return options;
276
389
  }
277
390
 
@@ -387,20 +500,35 @@ export function weekBounds(value, timeZone) {
387
500
  };
388
501
  }
389
502
 
390
- export function rolling24hBounds(value = new Date(), timeZone = DEFAULT_TIME_ZONE) {
503
+ export function rollingDurationBounds(
504
+ value = new Date(),
505
+ timeZone = DEFAULT_TIME_ZONE,
506
+ rangeDays = 1,
507
+ ) {
391
508
  validateTimeZone(timeZone);
392
509
  const end = value instanceof Date ? new Date(value.getTime()) : new Date(value);
393
510
  if (!Number.isFinite(end.getTime())) {
394
- throw new Error("Rolling 24-hour window requires a valid end time.");
511
+ throw new Error("Rolling window requires a valid end time.");
512
+ }
513
+ const days = Number(rangeDays);
514
+ if (!Number.isSafeInteger(days) || days < 1 || days > MAX_ROLLING_DAYS) {
515
+ throw new Error(
516
+ `Rolling window must be between 1 and ${MAX_ROLLING_DAYS} days.`,
517
+ );
395
518
  }
396
519
  return {
397
- start: new Date(end.getTime() - ROLLING_24_HOURS_MS),
520
+ start: new Date(end.getTime() - days * ROLLING_24_HOURS_MS),
398
521
  end,
399
522
  timeZone,
400
- rangeHours: 24,
523
+ rangeHours: days * 24,
524
+ rangeDays: days,
401
525
  };
402
526
  }
403
527
 
528
+ export function rolling24hBounds(value = new Date(), timeZone = DEFAULT_TIME_ZONE) {
529
+ return rollingDurationBounds(value, timeZone, 1);
530
+ }
531
+
404
532
  export function sanitizeTerminalText(value) {
405
533
  return String(value ?? "")
406
534
  .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "")
@@ -486,7 +614,9 @@ export function oneOffProjects(snapshot) {
486
614
  ids.add(threadId);
487
615
  threadIdsByProject.set(normalizedProject, ids);
488
616
  };
489
- for (const event of snapshot.events ?? []) add(event.project, event.threadId);
617
+ for (const bucket of usageBuckets(snapshot)) {
618
+ for (const threadId of usageThreadIds(bucket)) add(bucket.project, threadId);
619
+ }
490
620
  for (const thread of snapshot.threads ?? []) add(thread.project, thread.id);
491
621
  return new Set(
492
622
  [...threadIdsByProject.entries()]
@@ -509,10 +639,7 @@ function modelLabel(value) {
509
639
  export function filterDayEvents(snapshot, bounds) {
510
640
  const start = bounds.start.getTime();
511
641
  const end = bounds.end.getTime();
512
- return (snapshot.events ?? []).filter((event) => {
513
- const timestamp = new Date(event.timestamp).getTime();
514
- return Number.isFinite(timestamp) && timestamp >= start && timestamp < end;
515
- });
642
+ return usageBucketsInRange(snapshot, start, end);
516
643
  }
517
644
 
518
645
  export function aggregateProjects(snapshot, events, options = {}) {
@@ -543,8 +670,8 @@ export function aggregateProjects(snapshot, events, options = {}) {
543
670
  row.outputTokens += Number(event.outputTokens) || 0;
544
671
  row.reasoningTokens += Number(event.reasoningTokens) || 0;
545
672
  row.toolCalls += Number(event.toolCalls) || 0;
546
- row.events += 1;
547
- if (event.threadId) row.threadIds.add(event.threadId);
673
+ row.events += usageCallCount(event);
674
+ for (const threadId of usageThreadIds(event)) row.threadIds.add(threadId);
548
675
  if (event.rateCardCredits !== null && Number.isFinite(Number(event.rateCardCredits))) {
549
676
  row.rateCardCredits += Number(event.rateCardCredits);
550
677
  row.knownCreditTokens += Number(event.totalTokens) || 0;
@@ -558,7 +685,7 @@ export function aggregateProjects(snapshot, events, options = {}) {
558
685
  rateCardCredits: 0,
559
686
  };
560
687
  modelRow.totalTokens += Number(event.totalTokens) || 0;
561
- modelRow.events += 1;
688
+ modelRow.events += usageCallCount(event);
562
689
  if (event.rateCardCredits !== null && Number.isFinite(Number(event.rateCardCredits))) {
563
690
  modelRow.rateCardCredits += Number(event.rateCardCredits);
564
691
  }
@@ -588,7 +715,10 @@ function totalSummary(events) {
588
715
  summary.totalTokens += Number(event.totalTokens) || 0;
589
716
  summary.outputTokens += Number(event.outputTokens) || 0;
590
717
  summary.toolCalls += Number(event.toolCalls) || 0;
591
- if (event.threadId) summary.threadIds.add(event.threadId);
718
+ summary.calls += usageCallCount(event);
719
+ for (const threadId of usageThreadIds(event)) {
720
+ summary.threadIds.add(threadId);
721
+ }
592
722
  if (event.rateCardCredits !== null && Number.isFinite(Number(event.rateCardCredits))) {
593
723
  summary.rateCardCredits += Number(event.rateCardCredits);
594
724
  summary.knownCreditTokens += Number(event.totalTokens) || 0;
@@ -599,6 +729,7 @@ function totalSummary(events) {
599
729
  totalTokens: 0,
600
730
  outputTokens: 0,
601
731
  toolCalls: 0,
732
+ calls: 0,
602
733
  rateCardCredits: 0,
603
734
  knownCreditTokens: 0,
604
735
  threadIds: new Set(),
@@ -614,12 +745,18 @@ function compact(value, digits = 2) {
614
745
  [1_000_000, "M"],
615
746
  [1_000, "K"],
616
747
  ];
617
- for (const [divisor, suffix] of units) {
618
- if (absolute >= divisor) {
619
- const scaled = value / divisor;
620
- const precision = scaled >= 100 ? 0 : scaled >= 10 ? 1 : digits;
621
- return `${scaled.toFixed(precision)}${suffix}`;
748
+ for (let index = 0; index < units.length; index += 1) {
749
+ const [divisor, suffix] = units[index];
750
+ if (absolute < divisor) continue;
751
+ const scaled = value / divisor;
752
+ const magnitude = Math.abs(scaled);
753
+ const precision = magnitude >= 100 ? 0 : magnitude >= 10 ? 1 : digits;
754
+ // Values that round to 1000 of a unit belong to the next unit up
755
+ // (999,999 → 1.00M, not 1000K).
756
+ if (index > 0 && Number(magnitude.toFixed(precision)) >= 1_000) {
757
+ return compact(Math.sign(value) * divisor * 1_000, digits);
622
758
  }
759
+ return `${scaled.toFixed(precision)}${suffix}`;
623
760
  }
624
761
  return Math.round(value).toLocaleString("en-US");
625
762
  }
@@ -723,7 +860,7 @@ async function readSnapshot(snapshotPath) {
723
860
  const snapshotLabel = safeDisplayLabel(snapshotPath, "snapshot");
724
861
  let parsed;
725
862
  try {
726
- parsed = JSON.parse(await readFile(snapshotPath, "utf8"));
863
+ parsed = await readPrivateSnapshot(snapshotPath);
727
864
  } catch (error) {
728
865
  if (error?.code === "ENOENT") {
729
866
  throw new Error(`Snapshot not found: ${snapshotLabel}`);
@@ -732,8 +869,14 @@ async function readSnapshot(snapshotPath) {
732
869
  `Could not read snapshot ${snapshotLabel}: ${safeErrorMessage(error, [snapshotPath])}`,
733
870
  );
734
871
  }
735
- if (!parsed || !Array.isArray(parsed.events)) {
736
- throw new Error(`Snapshot is missing its events array: ${snapshotLabel}`);
872
+ if (
873
+ !parsed ||
874
+ parsed.schemaVersion !== SNAPSHOT_SCHEMA_VERSION ||
875
+ !Array.isArray(parsed.events)
876
+ ) {
877
+ throw new Error(
878
+ `Snapshot uses an unsupported schema: ${snapshotLabel}. Rebuild it with --refresh.`,
879
+ );
737
880
  }
738
881
  return parsed;
739
882
  }
@@ -746,7 +889,7 @@ async function refreshSnapshot(options) {
746
889
  }
747
890
  let progressStarted = false;
748
891
  try {
749
- const { collectUsage, writePrivateSnapshot } = await import(
892
+ const { collectUsage } = await import(
750
893
  "../lib/token-ledger-importer.mjs"
751
894
  );
752
895
  process.stderr.write("Token Ledger: refreshing local snapshot…\n");
@@ -763,10 +906,25 @@ async function refreshSnapshot(options) {
763
906
  },
764
907
  );
765
908
  process.stderr.write("\n");
766
- await writePrivateSnapshot(options.input, snapshot);
767
- return snapshot;
909
+ const writeResult = await writePrivateSnapshot(options.input, snapshot);
910
+ const storedSnapshot = writeResult.snapshot;
911
+ process.stderr.write(
912
+ `Token Ledger: cached ${(writeResult.bytesWritten / 1_000_000).toFixed(1)} MB ${writeResult.encoding} snapshot (${(writeResult.jsonBytes / 1_000_000).toFixed(1)} MB JSON before encoding; ${storedSnapshot.events.length.toLocaleString()} buckets for ${storedSnapshot.coverage.observedModelCalls.toLocaleString()} calls; ${(writeResult.maxBytes / 1_000_000).toFixed(1)} MB limit).\n`,
913
+ );
914
+ if (writeResult.bytesWritten / writeResult.maxBytes >= 0.7) {
915
+ process.stderr.write(
916
+ "Token Ledger: snapshot is above 70% of its safety limit; older buckets will compact automatically as it grows.\n",
917
+ );
918
+ }
919
+ return storedSnapshot;
768
920
  } catch (error) {
769
921
  if (progressStarted) process.stderr.write("\n");
922
+ if (error?.code === "ERR_SNAPSHOT_SIZE_LIMIT" && existsSync(options.input)) {
923
+ process.stderr.write(
924
+ "Token Ledger: refresh exceeded the safety limit; continuing with the previous cache, which may be stale.\n",
925
+ );
926
+ return readSnapshot(options.input);
927
+ }
770
928
  throw new Error(
771
929
  `Could not refresh local snapshot: ${safeErrorMessage(error, [options.input, options.codexHome])}`,
772
930
  );
@@ -789,6 +947,18 @@ export function snapshotCacheIsFresh(
789
947
  );
790
948
  }
791
949
 
950
+ export function shouldCheckSourceFreshness(
951
+ options = {},
952
+ snapshotMtimeMs,
953
+ nowMs = Date.now(),
954
+ ) {
955
+ // PNG reports are expected to reflect every completed local call. Checking
956
+ // source mtimes is much cheaper than parsing the rollouts again; the full
957
+ // collector only runs when one of those sources actually changed.
958
+ return Boolean(options.view === "trend" && options.image) ||
959
+ !snapshotCacheIsFresh(snapshotMtimeMs, nowMs);
960
+ }
961
+
792
962
  function snapshotAgeLabel(ageMs) {
793
963
  if (ageMs < 60 * 1_000) return "now";
794
964
  const minutes = Math.floor(ageMs / (60 * 1_000));
@@ -798,10 +968,18 @@ function snapshotAgeLabel(ageMs) {
798
968
  return `${Math.floor(hours / 24)}d old`;
799
969
  }
800
970
 
971
+ function primitiveString(value) {
972
+ try {
973
+ const text = String.prototype.valueOf.call(value);
974
+ return text === value ? text : null;
975
+ } catch {
976
+ return null;
977
+ }
978
+ }
979
+
801
980
  export function snapshotFreshness(snapshot = {}, nowMs = Date.now()) {
802
- const generatedAtMs = typeof snapshot.generatedAt === "string"
803
- ? Date.parse(snapshot.generatedAt)
804
- : NaN;
981
+ const generatedAt = primitiveString(snapshot.generatedAt);
982
+ const generatedAtMs = generatedAt === null ? NaN : Date.parse(generatedAt);
805
983
  if (
806
984
  !Number.isFinite(generatedAtMs) ||
807
985
  !Number.isFinite(nowMs) ||
@@ -839,7 +1017,7 @@ async function loadSnapshot(options) {
839
1017
  `Could not inspect snapshot ${safeDisplayLabel(options.input, "snapshot")}: ${safeErrorMessage(error, [options.input])}`,
840
1018
  );
841
1019
  }
842
- if (snapshotCacheIsFresh(snapshotStat.mtimeMs)) {
1020
+ if (!shouldCheckSourceFreshness(options, snapshotStat.mtimeMs)) {
843
1021
  return readSnapshot(options.input);
844
1022
  }
845
1023
 
@@ -861,8 +1039,25 @@ async function loadSnapshot(options) {
861
1039
  return readSnapshot(options.input);
862
1040
  }
863
1041
 
864
- function render(options, snapshot, bounds, events, rows, allRows, freshness) {
1042
+ function render(
1043
+ options,
1044
+ snapshot,
1045
+ bounds,
1046
+ events,
1047
+ rows,
1048
+ allRows,
1049
+ freshness,
1050
+ reportTimeMs,
1051
+ ) {
865
1052
  if (options.view === "trend") {
1053
+ if (options.image && options.cacheRate) {
1054
+ return renderCacheReportImage({
1055
+ snapshot,
1056
+ bounds,
1057
+ days: options.trendDays,
1058
+ options,
1059
+ });
1060
+ }
866
1061
  const trend = buildUsageTrend(snapshot, bounds);
867
1062
  if (options.image) {
868
1063
  return renderTrendImage({
@@ -870,7 +1065,8 @@ function render(options, snapshot, bounds, events, rows, allRows, freshness) {
870
1065
  bounds,
871
1066
  trend,
872
1067
  days: options.trendDays,
873
- options,
1068
+ options: { ...options, reportTimeMs },
1069
+ projectRows: allRows,
874
1070
  });
875
1071
  }
876
1072
  return renderTrendCombo({
@@ -897,7 +1093,9 @@ function render(options, snapshot, bounds, events, rows, allRows, freshness) {
897
1093
  const totalTokens = summary.totalTokens;
898
1094
  const dateLabel = options.range === "rolling24h"
899
1095
  ? "last 24 hours"
900
- : options.range === "week"
1096
+ : options.range === "rolling"
1097
+ ? `last ${options.rollingLabel}`
1098
+ : options.range === "week"
901
1099
  ? `${bounds.startDateString} through ${bounds.endDateString}`
902
1100
  : new Intl.DateTimeFormat("en-US", {
903
1101
  timeZone: bounds.timeZone,
@@ -914,7 +1112,7 @@ function render(options, snapshot, bounds, events, rows, allRows, freshness) {
914
1112
 
915
1113
  const header = [
916
1114
  `Token Ledger · ${dateLabel} · ${bounds.timeZone}`,
917
- `${compact(totalTokens)} tokens · ${summary.threadIds.size.toLocaleString()} threads · ${events.length.toLocaleString()} calls · ${compact(summary.outputTokens)} output`,
1115
+ `${compact(totalTokens)} tokens · ${summary.threadIds.size.toLocaleString()} threads · ${summary.calls.toLocaleString()} calls · ${compact(summary.outputTokens)} output`,
918
1116
  `Source: ${sourceLabel(options.input, snapshot)}`,
919
1117
  "",
920
1118
  chart,
@@ -943,11 +1141,16 @@ function boundsForOptions(options, now = new Date()) {
943
1141
  if (options.range === "rolling24h") {
944
1142
  return rolling24hBounds(now, options.timeZone);
945
1143
  }
1144
+ if (options.range === "rolling") {
1145
+ return rollingDurationBounds(now, options.timeZone, options.rollingDays);
1146
+ }
946
1147
  return dayBounds(options.date, options.timeZone);
947
1148
  }
948
1149
 
949
1150
  function rangeDescription(options, bounds) {
950
- if (options.range === "rolling24h") return "the last 24 hours";
1151
+ if (options.range === "rolling24h" || options.range === "rolling") {
1152
+ return rollingRangeDescription(options);
1153
+ }
951
1154
  if (bounds.startDateString && bounds.endDateString) {
952
1155
  return `${bounds.startDateString} through ${bounds.endDateString}`;
953
1156
  }
@@ -960,26 +1163,37 @@ export async function run(options, { nowMs } = {}) {
960
1163
  const bounds = boundsForOptions(options, now);
961
1164
  const snapshot = await loadSnapshot(options);
962
1165
  const events = filterDayEvents(snapshot, bounds);
963
- if (events.length === 0) {
1166
+ const writingImage = options.view === "trend" && options.image;
1167
+ const writingEmptyCacheReport = writingImage && options.cacheRate;
1168
+ if (events.length === 0 && !writingEmptyCacheReport) {
964
1169
  return [
965
1170
  `No model-call events found for ${rangeDescription(options, bounds)} (${bounds.timeZone}).`,
966
1171
  `Source: ${sourceLabel(options.input, snapshot)}`,
967
1172
  ].join("\n");
968
1173
  }
969
- const allRows = aggregateProjects(snapshot, events, options);
1174
+ const allRows = options.cacheRate
1175
+ ? []
1176
+ : aggregateProjects(snapshot, events, options);
970
1177
  const rows = allRows.slice(0, options.top);
971
- const writingImage = options.view === "trend" && options.image;
972
1178
  const outputPath = writingImage
973
1179
  ? options.imageOutput ??
974
1180
  resolve(
975
1181
  process.cwd(),
976
- `token-ledger-${options.report ? "report" : "trend"}-${options.trendDays}d.png`,
1182
+ `token-ledger-${options.cacheRate ? "cache-report" : options.report ? "report" : "trend"}-${options.trendDays}d.png`,
977
1183
  )
978
1184
  : null;
979
- const imageLabel = options.report ? "report" : "trend image";
1185
+ const imageLabel = options.cacheRate
1186
+ ? "cache report"
1187
+ : options.report
1188
+ ? "report"
1189
+ : "trend image";
980
1190
  if (writingImage) {
981
1191
  process.stderr.write(`Token Ledger: generating ${imageLabel} PNG…\n`);
982
1192
  }
1193
+ const reportTimeMs = hasInjectedNow ? now.getTime() : Date.now();
1194
+ const verifiedSourceTimeMs = options.autoRefresh && !options.inputExplicit
1195
+ ? reportTimeMs
1196
+ : undefined;
983
1197
  const output = render(
984
1198
  options,
985
1199
  snapshot,
@@ -989,22 +1203,45 @@ export async function run(options, { nowMs } = {}) {
989
1203
  allRows,
990
1204
  snapshotFreshness(
991
1205
  snapshot,
992
- hasInjectedNow ? now.getTime() : Date.now(),
1206
+ reportTimeMs,
993
1207
  ),
1208
+ verifiedSourceTimeMs,
994
1209
  );
995
1210
  if (writingImage) {
996
1211
  await mkdir(dirname(outputPath), { recursive: true });
997
1212
  process.stderr.write(`Token Ledger: encoding ${imageLabel} PNG…\n`);
998
1213
  await writeTrendPng(output, outputPath);
999
1214
  process.stderr.write(`Token Ledger: finished ${imageLabel} PNG.\n`);
1000
- return [
1001
- `Wrote ${options.report ? "report" : "trend image"}: ${outputPath}`,
1215
+ const lines = [
1216
+ `Wrote ${imageLabel}: ${outputPath}`,
1002
1217
  `Range: ${bounds.startDateString} through ${bounds.endDateString} (${bounds.timeZone})`,
1003
- ].join("\n");
1218
+ ];
1219
+ // Show the finished report on screen right away instead of leaving it to
1220
+ // be dug out of a file browser. Skipped for piped/scripted runs so
1221
+ // automation and CI never pop windows.
1222
+ if (options.openImage && process.stdout.isTTY) {
1223
+ lines.push(
1224
+ openInViewer(outputPath)
1225
+ ? "Opened the report in your default image viewer."
1226
+ : "Could not open a viewer automatically; open the file above to see the report.",
1227
+ );
1228
+ }
1229
+ return lines.join("\n");
1004
1230
  }
1005
1231
  return output;
1006
1232
  }
1007
1233
 
1234
+ function openInViewer(path) {
1235
+ const platform = process.platform;
1236
+ const [command, args] = platform === "darwin"
1237
+ ? ["open", [path]]
1238
+ : platform === "win32"
1239
+ ? ["cmd", ["/c", "start", "", path]]
1240
+ : ["xdg-open", [path]];
1241
+ const result = spawnSync(command, args, { stdio: "ignore" });
1242
+ return result.status === 0;
1243
+ }
1244
+
1008
1245
  function shouldUseInteractive(options) {
1009
1246
  return Boolean(
1010
1247
  !options.static &&
@@ -1046,7 +1283,7 @@ async function main() {
1046
1283
  try {
1047
1284
  options = parseArgs(process.argv.slice(2));
1048
1285
  if (options.help) {
1049
- process.stdout.write(`${usage()}\n`);
1286
+ process.stdout.write(`${options.helpAll ? advancedUsage() : usage()}\n`);
1050
1287
  return;
1051
1288
  }
1052
1289
  if (shouldUseInteractive(options)) {
@@ -1056,11 +1293,11 @@ async function main() {
1056
1293
  }
1057
1294
  } catch (error) {
1058
1295
  process.stderr.write(
1059
- `Token Ledger CLI failed: ${safeErrorMessage(error, [
1296
+ `Token Ledger: ${safeErrorMessage(error, [
1060
1297
  options?.input,
1061
1298
  options?.codexHome,
1062
1299
  options?.imageOutput,
1063
- ])}\n\n${usage()}\n`,
1300
+ ])}\nRun \`tledger --help\` for examples or \`tledger --help-all\` for every option.\n`,
1064
1301
  );
1065
1302
  process.exitCode = 1;
1066
1303
  }