tledger 0.2.0 → 0.3.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.
@@ -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,17 +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;
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;
34
48
  const ANSI_RESET = "\u001b[0m";
35
49
  const MODEL_COLORS = {
36
50
  sol: TERMINAL_MODEL_COLORS.sol,
@@ -41,44 +55,111 @@ const MODEL_COLORS = {
41
55
  other: TERMINAL_MODEL_COLORS.other,
42
56
  };
43
57
 
44
- function usage() {
45
- return `Token Ledger terminal usage
58
+ export function usage() {
59
+ return `Token Ledger
46
60
 
47
61
  Usage:
48
- tledger day <YYYY-MM-DD>
49
- tledger week [end-day]
50
- tledger trend [7d|14d|30d]
51
- tledger report [7d|14d|30d]
52
- npm run usage:day -- <YYYY-MM-DD>
53
- npm run usage:week -- [end-day]
54
-
55
- Options:
56
- --date <day> Date as YYYY-MM-DD, today, or yesterday
57
- --period <window> Trend window: 7d, 14d, or 30d
58
- --input <file> Snapshot to read (default: ~/.token-ledger/token-ledger-snapshot.json)
59
- --refresh Rebuild the default snapshot from CODEX_HOME or ~/.codex
60
- --no-refresh Use the cached snapshot without checking local JSONL files
61
- --codex-home <dir> Codex data root used when refreshing
62
- --tz <name> IANA timezone (default: machine timezone)
63
- --top <number> Number of projects to show (default: 10)
64
- --width <number> Terminal layout width in columns
65
- --raw-projects Keep singleton thread labels instead of grouping them
66
- --no-archived Skip archived_sessions when refreshing
67
- --plain Disable terminal colors
68
- --ascii Use ASCII bars instead of Unicode blocks
69
- --static Print once instead of opening the interactive dashboard
70
- --drain Trend columns show observed limit drain percent instead of token volume
71
- --image Write trend view as a PNG image
72
- --image-output <file> PNG output path for trend view
73
- --image-width <px> PNG image width from 900 to 2400 pixels
74
- --youplot Use the legacy single-series YouPlot renderer
75
- --help Show this help
76
-
77
- The report command writes the dashboard PNG (same as trend --image) to
78
- token-ledger-report-<period>.png; use --image-output to choose the path.
79
-
80
- The command reads a privacy-reduced Token Ledger snapshot. It never uploads
81
- the snapshot or prints message bodies, tool payloads, credentials, or paths.`;
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}`;
82
163
  }
83
164
 
84
165
  function readOption(argv, index, name) {
@@ -90,14 +171,28 @@ function readOption(argv, index, name) {
90
171
  }
91
172
 
92
173
  export function parseArgs(argv) {
93
- const command = argv[0] === "week"
94
- ? "week"
95
- : argv[0] === "trend" || argv[0] === "report"
96
- ? "trend"
97
- : "day";
174
+ const helpCommand = argv[0] === "help";
175
+ const alias = durationAlias(argv[0]);
176
+ const rolling24hCommand = argv[0] === "1d";
177
+ const rollingDurationCommand = Boolean(alias) && !rolling24hCommand;
178
+ const command = rolling24hCommand
179
+ ? "rolling24h"
180
+ : rollingDurationCommand
181
+ ? "rolling"
182
+ : argv[0] === "week"
183
+ ? "week"
184
+ : argv[0] === "trend" || argv[0] === "report"
185
+ ? "trend"
186
+ : "day";
98
187
  const options = {
99
188
  range: command,
100
189
  view: command === "trend" ? "trend" : "projects",
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",
101
196
  report: argv[0] === "report",
102
197
  trendDays: 7,
103
198
  date: null,
@@ -117,17 +212,23 @@ export function parseArgs(argv) {
117
212
  image: false,
118
213
  imageOutput: null,
119
214
  imageWidth: null,
215
+ openImage: true,
120
216
  drain: false,
217
+ cacheRate: false,
121
218
  legacyPlot: false,
122
- help: false,
219
+ help: argv.length === 0 || helpCommand,
220
+ helpAll: false,
123
221
  };
124
222
 
125
223
  let trendPeriodSeen = false;
126
- let index = ["day", "week", "trend", "report"].includes(argv[0]) ? 1 : 0;
224
+ let index = alias || ["day", "week", "trend", "report", "help"].includes(argv[0]) ? 1 : 0;
127
225
  for (; index < argv.length; index += 1) {
128
226
  const argument = argv[index];
129
227
  if (argument === "--help" || argument === "-h") {
130
228
  options.help = true;
229
+ } else if (argument === "--help-all") {
230
+ options.help = true;
231
+ options.helpAll = true;
131
232
  } else if (argument === "--date") {
132
233
  options.date = readOption(argv, index, "--date");
133
234
  index += 1;
@@ -139,10 +240,13 @@ export function parseArgs(argv) {
139
240
  throw new Error("Trend period can only be specified once.");
140
241
  }
141
242
  const value = readOption(argv, index, "--period");
142
- if (!["7d", "14d", "30d"].includes(value)) {
143
- 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
+ );
144
248
  }
145
- options.trendDays = Number.parseInt(value, 10);
249
+ options.trendDays = period.days;
146
250
  trendPeriodSeen = true;
147
251
  index += 1;
148
252
  } else if (argument === "--input") {
@@ -188,11 +292,21 @@ export function parseArgs(argv) {
188
292
  throw new Error("--drain is only available for the trend view.");
189
293
  }
190
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;
191
300
  } else if (argument === "--image") {
192
301
  if (options.view !== "trend") {
193
302
  throw new Error("--image is only available for the trend view.");
194
303
  }
195
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;
196
310
  } else if (argument === "--image-output") {
197
311
  if (options.view !== "trend") {
198
312
  throw new Error("--image-output is only available for the trend view.");
@@ -217,14 +331,30 @@ export function parseArgs(argv) {
217
331
  index += 1;
218
332
  } else if (argument === "--youplot") {
219
333
  options.legacyPlot = true;
334
+ } else if (
335
+ !argument.startsWith("-") &&
336
+ options.view === "projects" &&
337
+ argument === "1d" &&
338
+ !options.date
339
+ ) {
340
+ if (argv[0] !== "day") {
341
+ throw new Error(
342
+ "The 1d alias is only available as `tledger 1d` or `tledger day 1d`.",
343
+ );
344
+ }
345
+ options.range = "rolling24h";
346
+ options.rolling24h = true;
220
347
  } else if (!argument.startsWith("-") && options.view === "trend") {
221
348
  if (trendPeriodSeen) {
222
349
  throw new Error("Trend period can only be specified once.");
223
350
  }
224
- if (!["7d", "14d", "30d"].includes(argument)) {
225
- 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
+ );
226
356
  }
227
- options.trendDays = Number.parseInt(argument, 10);
357
+ options.trendDays = period.days;
228
358
  trendPeriodSeen = true;
229
359
  } else if (!argument.startsWith("-") && !options.date) {
230
360
  options.date = argument;
@@ -234,10 +364,13 @@ export function parseArgs(argv) {
234
364
  }
235
365
 
236
366
  if (options.report) options.image = true;
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.`);
369
+ }
237
370
  if (!options.help && !options.date && (options.range === "week" || options.view === "trend")) {
238
371
  options.date = "today";
239
372
  }
240
- if (!options.help && !options.date) {
373
+ if (!options.help && !options.date && !options.rolling24h && !options.rollingDuration) {
241
374
  throw new Error("A day is required, for example: tledger day 2026-08-01");
242
375
  }
243
376
  if (!options.help && options.refresh && !options.autoRefresh) {
@@ -249,6 +382,9 @@ export function parseArgs(argv) {
249
382
  if (!options.help && options.view === "trend" && options.legacyPlot) {
250
383
  throw new Error("--youplot is only available for the project view.");
251
384
  }
385
+ if (!options.help && options.cacheRate && options.drain) {
386
+ throw new Error("--cache-rate cannot be combined with --drain.");
387
+ }
252
388
  return options;
253
389
  }
254
390
 
@@ -364,6 +500,35 @@ export function weekBounds(value, timeZone) {
364
500
  };
365
501
  }
366
502
 
503
+ export function rollingDurationBounds(
504
+ value = new Date(),
505
+ timeZone = DEFAULT_TIME_ZONE,
506
+ rangeDays = 1,
507
+ ) {
508
+ validateTimeZone(timeZone);
509
+ const end = value instanceof Date ? new Date(value.getTime()) : new Date(value);
510
+ if (!Number.isFinite(end.getTime())) {
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
+ );
518
+ }
519
+ return {
520
+ start: new Date(end.getTime() - days * ROLLING_24_HOURS_MS),
521
+ end,
522
+ timeZone,
523
+ rangeHours: days * 24,
524
+ rangeDays: days,
525
+ };
526
+ }
527
+
528
+ export function rolling24hBounds(value = new Date(), timeZone = DEFAULT_TIME_ZONE) {
529
+ return rollingDurationBounds(value, timeZone, 1);
530
+ }
531
+
367
532
  export function sanitizeTerminalText(value) {
368
533
  return String(value ?? "")
369
534
  .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "")
@@ -378,6 +543,62 @@ function cleanLabel(value, fallback) {
378
543
  return label || fallback;
379
544
  }
380
545
 
546
+ const QUOTED_ABSOLUTE_PATH =
547
+ /(["'])(\/(?!\/)[^"'\r\n]*|(?:\/\/|\\\\)[^"'\r\n]+|[A-Za-z]:[\\/][^"'\r\n]*)\1/g;
548
+ const UNQUOTED_ABSOLUTE_PATH =
549
+ /(^|[\s([{=])((?:\/(?!\/)|\/\/|\\\\|[A-Za-z]:[\\/])[^\s"'`)\]},;]+)/g;
550
+
551
+ function isAbsoluteLocalPath(path) {
552
+ return (
553
+ path.startsWith("/") ||
554
+ path.startsWith("\\\\") ||
555
+ /^[A-Za-z]:[\\/]/.test(path)
556
+ );
557
+ }
558
+
559
+ export function safeDisplayLabel(value, fallback = "local path") {
560
+ const normalized = String(value ?? "").replaceAll("\\", "/");
561
+ const label = sanitizeTerminalText(basename(normalized))
562
+ .replace(/\s+/g, " ")
563
+ .trim();
564
+ return label && label !== "." && label !== ".." ? label : fallback;
565
+ }
566
+
567
+ export function redactLocalPaths(value, paths = []) {
568
+ let redacted = String(value ?? "");
569
+ const explicitPaths = new Set(
570
+ paths
571
+ .filter(Boolean)
572
+ .map((path) => String(path))
573
+ .filter(isAbsoluteLocalPath),
574
+ );
575
+ const pathsToRedact = [...new Set([
576
+ ...explicitPaths,
577
+ homedir(),
578
+ process.cwd(),
579
+ ])]
580
+ .filter((path) => path && path !== "/")
581
+ .sort((left, right) => right.length - left.length);
582
+
583
+ for (const path of pathsToRedact) {
584
+ redacted = redacted.replaceAll(
585
+ path,
586
+ explicitPaths.has(path) ? safeDisplayLabel(path) : "[local path]",
587
+ );
588
+ }
589
+
590
+ return redacted
591
+ .replace(QUOTED_ABSOLUTE_PATH, (_match, quote) => `${quote}[local path]${quote}`)
592
+ .replace(UNQUOTED_ABSOLUTE_PATH, (_match, prefix) => `${prefix}[local path]`);
593
+ }
594
+
595
+ function safeErrorMessage(error, paths = []) {
596
+ return redactLocalPaths(
597
+ error instanceof Error ? error.message : String(error),
598
+ paths,
599
+ );
600
+ }
601
+
381
602
  function displayLabel(value) {
382
603
  const label = cleanLabel(value, "Unlabelled activity");
383
604
  if (label.length <= 30) return label;
@@ -393,7 +614,9 @@ export function oneOffProjects(snapshot) {
393
614
  ids.add(threadId);
394
615
  threadIdsByProject.set(normalizedProject, ids);
395
616
  };
396
- 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
+ }
397
620
  for (const thread of snapshot.threads ?? []) add(thread.project, thread.id);
398
621
  return new Set(
399
622
  [...threadIdsByProject.entries()]
@@ -416,10 +639,7 @@ function modelLabel(value) {
416
639
  export function filterDayEvents(snapshot, bounds) {
417
640
  const start = bounds.start.getTime();
418
641
  const end = bounds.end.getTime();
419
- return (snapshot.events ?? []).filter((event) => {
420
- const timestamp = new Date(event.timestamp).getTime();
421
- return Number.isFinite(timestamp) && timestamp >= start && timestamp < end;
422
- });
642
+ return usageBucketsInRange(snapshot, start, end);
423
643
  }
424
644
 
425
645
  export function aggregateProjects(snapshot, events, options = {}) {
@@ -450,8 +670,8 @@ export function aggregateProjects(snapshot, events, options = {}) {
450
670
  row.outputTokens += Number(event.outputTokens) || 0;
451
671
  row.reasoningTokens += Number(event.reasoningTokens) || 0;
452
672
  row.toolCalls += Number(event.toolCalls) || 0;
453
- row.events += 1;
454
- if (event.threadId) row.threadIds.add(event.threadId);
673
+ row.events += usageCallCount(event);
674
+ for (const threadId of usageThreadIds(event)) row.threadIds.add(threadId);
455
675
  if (event.rateCardCredits !== null && Number.isFinite(Number(event.rateCardCredits))) {
456
676
  row.rateCardCredits += Number(event.rateCardCredits);
457
677
  row.knownCreditTokens += Number(event.totalTokens) || 0;
@@ -465,7 +685,7 @@ export function aggregateProjects(snapshot, events, options = {}) {
465
685
  rateCardCredits: 0,
466
686
  };
467
687
  modelRow.totalTokens += Number(event.totalTokens) || 0;
468
- modelRow.events += 1;
688
+ modelRow.events += usageCallCount(event);
469
689
  if (event.rateCardCredits !== null && Number.isFinite(Number(event.rateCardCredits))) {
470
690
  modelRow.rateCardCredits += Number(event.rateCardCredits);
471
691
  }
@@ -495,7 +715,10 @@ function totalSummary(events) {
495
715
  summary.totalTokens += Number(event.totalTokens) || 0;
496
716
  summary.outputTokens += Number(event.outputTokens) || 0;
497
717
  summary.toolCalls += Number(event.toolCalls) || 0;
498
- 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
+ }
499
722
  if (event.rateCardCredits !== null && Number.isFinite(Number(event.rateCardCredits))) {
500
723
  summary.rateCardCredits += Number(event.rateCardCredits);
501
724
  summary.knownCreditTokens += Number(event.totalTokens) || 0;
@@ -506,6 +729,7 @@ function totalSummary(events) {
506
729
  totalTokens: 0,
507
730
  outputTokens: 0,
508
731
  toolCalls: 0,
732
+ calls: 0,
509
733
  rateCardCredits: 0,
510
734
  knownCreditTokens: 0,
511
735
  threadIds: new Set(),
@@ -521,12 +745,18 @@ function compact(value, digits = 2) {
521
745
  [1_000_000, "M"],
522
746
  [1_000, "K"],
523
747
  ];
524
- for (const [divisor, suffix] of units) {
525
- if (absolute >= divisor) {
526
- const scaled = value / divisor;
527
- const precision = scaled >= 100 ? 0 : scaled >= 10 ? 1 : digits;
528
- 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);
529
758
  }
759
+ return `${scaled.toFixed(precision)}${suffix}`;
530
760
  }
531
761
  return Math.round(value).toLocaleString("en-US");
532
762
  }
@@ -582,7 +812,7 @@ function sourceLabel(snapshotPath, snapshot) {
582
812
  timeStyle: "short",
583
813
  })
584
814
  : "unknown time";
585
- return `${basename(snapshotPath)} · captured ${generated}`;
815
+ return `${safeDisplayLabel(snapshotPath, "snapshot")} · captured ${generated}`;
586
816
  }
587
817
 
588
818
  function runYouPlot(rows, options, dateLabel, unit) {
@@ -627,43 +857,78 @@ function runYouPlot(rows, options, dateLabel, unit) {
627
857
  }
628
858
 
629
859
  async function readSnapshot(snapshotPath) {
860
+ const snapshotLabel = safeDisplayLabel(snapshotPath, "snapshot");
630
861
  let parsed;
631
862
  try {
632
- parsed = JSON.parse(await readFile(snapshotPath, "utf8"));
863
+ parsed = await readPrivateSnapshot(snapshotPath);
633
864
  } catch (error) {
634
865
  if (error?.code === "ENOENT") {
635
- throw new Error(`Snapshot not found: ${snapshotPath}`);
866
+ throw new Error(`Snapshot not found: ${snapshotLabel}`);
636
867
  }
637
- throw new Error(`Could not read snapshot ${snapshotPath}: ${error.message}`);
868
+ throw new Error(
869
+ `Could not read snapshot ${snapshotLabel}: ${safeErrorMessage(error, [snapshotPath])}`,
870
+ );
638
871
  }
639
- if (!parsed || !Array.isArray(parsed.events)) {
640
- throw new Error(`Snapshot is missing its events array: ${snapshotPath}`);
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
+ );
641
880
  }
642
881
  return parsed;
643
882
  }
644
883
 
645
884
  async function refreshSnapshot(options) {
646
885
  if (!existsSync(options.codexHome)) {
647
- throw new Error(`Codex data directory not found: ${options.codexHome}`);
886
+ throw new Error(
887
+ `Codex data directory not found: ${safeDisplayLabel(options.codexHome, "Codex data directory")}`,
888
+ );
889
+ }
890
+ let progressStarted = false;
891
+ try {
892
+ const { collectUsage } = await import(
893
+ "../lib/token-ledger-importer.mjs"
894
+ );
895
+ process.stderr.write("Token Ledger: refreshing local snapshot…\n");
896
+ progressStarted = true;
897
+ const snapshot = await collectUsage(
898
+ {
899
+ output: options.input,
900
+ codexHome: options.codexHome,
901
+ includeArchived: options.includeArchived,
902
+ since: null,
903
+ },
904
+ ({ current, total }) => {
905
+ process.stderr.write(`\rToken Ledger: scanned ${current}/${total} rollout files`);
906
+ },
907
+ );
908
+ process.stderr.write("\n");
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;
920
+ } catch (error) {
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
+ }
928
+ throw new Error(
929
+ `Could not refresh local snapshot: ${safeErrorMessage(error, [options.input, options.codexHome])}`,
930
+ );
648
931
  }
649
- const { collectUsage, writePrivateSnapshot } = await import(
650
- "../lib/token-ledger-importer.mjs"
651
- );
652
- process.stderr.write("Token Ledger: refreshing local snapshot…\n");
653
- const snapshot = await collectUsage(
654
- {
655
- output: options.input,
656
- codexHome: options.codexHome,
657
- includeArchived: options.includeArchived,
658
- since: null,
659
- },
660
- ({ current, total }) => {
661
- process.stderr.write(`\rToken Ledger: scanned ${current}/${total} rollout files`);
662
- },
663
- );
664
- process.stderr.write("\n");
665
- await writePrivateSnapshot(options.input, snapshot);
666
- return snapshot;
667
932
  }
668
933
 
669
934
  export function snapshotNeedsRefresh(snapshotMtimeMs, latestJsonlMtimeMs) {
@@ -682,13 +947,49 @@ export function snapshotCacheIsFresh(
682
947
  );
683
948
  }
684
949
 
950
+ function snapshotAgeLabel(ageMs) {
951
+ if (ageMs < 60 * 1_000) return "now";
952
+ const minutes = Math.floor(ageMs / (60 * 1_000));
953
+ if (minutes < 60) return `${minutes}m old`;
954
+ const hours = Math.floor(minutes / 60);
955
+ if (hours < 24) return `${hours}h old`;
956
+ return `${Math.floor(hours / 24)}d old`;
957
+ }
958
+
959
+ function primitiveString(value) {
960
+ try {
961
+ const text = String.prototype.valueOf.call(value);
962
+ return text === value ? text : null;
963
+ } catch {
964
+ return null;
965
+ }
966
+ }
967
+
968
+ export function snapshotFreshness(snapshot = {}, nowMs = Date.now()) {
969
+ const generatedAt = primitiveString(snapshot.generatedAt);
970
+ const generatedAtMs = generatedAt === null ? NaN : Date.parse(generatedAt);
971
+ if (
972
+ !Number.isFinite(generatedAtMs) ||
973
+ !Number.isFinite(nowMs) ||
974
+ generatedAtMs > nowMs
975
+ ) {
976
+ return { status: "unknown", ageLabel: "age unknown" };
977
+ }
978
+ return {
979
+ status: snapshotCacheIsFresh(generatedAtMs, nowMs) ? "fresh" : "stale",
980
+ ageLabel: snapshotAgeLabel(nowMs - generatedAtMs),
981
+ };
982
+ }
983
+
685
984
  async function loadSnapshot(options) {
686
985
  if (options.refresh) {
687
986
  return refreshSnapshot(options);
688
987
  }
689
988
  if (!existsSync(options.input)) {
690
989
  if (options.inputExplicit || !options.autoRefresh) {
691
- throw new Error(`Snapshot not found: ${options.input}`);
990
+ throw new Error(
991
+ `Snapshot not found: ${safeDisplayLabel(options.input, "snapshot")}`,
992
+ );
692
993
  }
693
994
  return refreshSnapshot(options);
694
995
  }
@@ -696,24 +997,46 @@ async function loadSnapshot(options) {
696
997
  return readSnapshot(options.input);
697
998
  }
698
999
 
699
- const snapshotStat = await stat(options.input);
1000
+ let snapshotStat;
1001
+ try {
1002
+ snapshotStat = await stat(options.input);
1003
+ } catch (error) {
1004
+ throw new Error(
1005
+ `Could not inspect snapshot ${safeDisplayLabel(options.input, "snapshot")}: ${safeErrorMessage(error, [options.input])}`,
1006
+ );
1007
+ }
700
1008
  if (snapshotCacheIsFresh(snapshotStat.mtimeMs)) {
701
1009
  return readSnapshot(options.input);
702
1010
  }
703
1011
 
704
1012
  const { latestSourceModifiedAt } = await import("../lib/token-ledger-importer.mjs");
705
- const latestSourceMtimeMs = await latestSourceModifiedAt(
706
- options.codexHome,
707
- options.includeArchived,
708
- );
1013
+ let latestSourceMtimeMs;
1014
+ try {
1015
+ latestSourceMtimeMs = await latestSourceModifiedAt(
1016
+ options.codexHome,
1017
+ options.includeArchived,
1018
+ );
1019
+ } catch (error) {
1020
+ throw new Error(
1021
+ `Could not inspect local Codex source: ${safeErrorMessage(error, [options.codexHome])}`,
1022
+ );
1023
+ }
709
1024
  if (snapshotNeedsRefresh(snapshotStat.mtimeMs, latestSourceMtimeMs)) {
710
1025
  return refreshSnapshot(options);
711
1026
  }
712
1027
  return readSnapshot(options.input);
713
1028
  }
714
1029
 
715
- function render(options, snapshot, bounds, events, rows, allRows) {
1030
+ function render(options, snapshot, bounds, events, rows, allRows, freshness) {
716
1031
  if (options.view === "trend") {
1032
+ if (options.image && options.cacheRate) {
1033
+ return renderCacheReportImage({
1034
+ snapshot,
1035
+ bounds,
1036
+ days: options.trendDays,
1037
+ options,
1038
+ });
1039
+ }
717
1040
  const trend = buildUsageTrend(snapshot, bounds);
718
1041
  if (options.image) {
719
1042
  return renderTrendImage({
@@ -722,6 +1045,7 @@ function render(options, snapshot, bounds, events, rows, allRows) {
722
1045
  trend,
723
1046
  days: options.trendDays,
724
1047
  options,
1048
+ projectRows: allRows,
725
1049
  });
726
1050
  }
727
1051
  return renderTrendCombo({
@@ -733,20 +1057,32 @@ function render(options, snapshot, bounds, events, rows, allRows) {
733
1057
  });
734
1058
  }
735
1059
  if (!options.legacyPlot) {
736
- return renderTerminal({ options, snapshot, bounds, events, rows, allRows });
1060
+ return renderTerminal({
1061
+ options,
1062
+ snapshot,
1063
+ snapshotFreshness: freshness,
1064
+ bounds,
1065
+ events,
1066
+ rows,
1067
+ allRows,
1068
+ });
737
1069
  }
738
1070
  const enabled = !options.plain && !process.env.NO_COLOR && Boolean(process.stdout.isTTY);
739
1071
  const summary = totalSummary(events);
740
1072
  const totalTokens = summary.totalTokens;
741
- const dateLabel = options.range === "week"
742
- ? `${bounds.startDateString} through ${bounds.endDateString}`
743
- : new Intl.DateTimeFormat("en-US", {
744
- timeZone: bounds.timeZone,
745
- weekday: "short",
746
- month: "short",
747
- day: "numeric",
748
- year: "numeric",
749
- }).format(bounds.start);
1073
+ const dateLabel = options.range === "rolling24h"
1074
+ ? "last 24 hours"
1075
+ : options.range === "rolling"
1076
+ ? `last ${options.rollingLabel}`
1077
+ : options.range === "week"
1078
+ ? `${bounds.startDateString} through ${bounds.endDateString}`
1079
+ : new Intl.DateTimeFormat("en-US", {
1080
+ timeZone: bounds.timeZone,
1081
+ weekday: "short",
1082
+ month: "short",
1083
+ day: "numeric",
1084
+ year: "numeric",
1085
+ }).format(bounds.start);
750
1086
  const unit = chartUnit(rows[0]?.totalTokens ?? 0);
751
1087
  const shares = rows.map((row) =>
752
1088
  totalTokens > 0 ? (row.totalTokens / totalTokens) * 100 : 0,
@@ -755,7 +1091,7 @@ function render(options, snapshot, bounds, events, rows, allRows) {
755
1091
 
756
1092
  const header = [
757
1093
  `Token Ledger · ${dateLabel} · ${bounds.timeZone}`,
758
- `${compact(totalTokens)} tokens · ${summary.threadIds.size.toLocaleString()} threads · ${events.length.toLocaleString()} calls · ${compact(summary.outputTokens)} output`,
1094
+ `${compact(totalTokens)} tokens · ${summary.threadIds.size.toLocaleString()} threads · ${summary.calls.toLocaleString()} calls · ${compact(summary.outputTokens)} output`,
759
1095
  `Source: ${sourceLabel(options.input, snapshot)}`,
760
1096
  "",
761
1097
  chart,
@@ -774,51 +1110,112 @@ function render(options, snapshot, bounds, events, rows, allRows) {
774
1110
  return `${header.join("\n")}\n\n${details.join("\n")}`;
775
1111
  }
776
1112
 
777
- export async function run(options) {
778
- const bounds = options.view === "trend"
779
- ? multiDayBounds(options.date, options.timeZone, options.trendDays)
780
- : options.range === "week"
781
- ? weekBounds(options.date, options.timeZone)
782
- : dayBounds(options.date, options.timeZone);
1113
+ function boundsForOptions(options, now = new Date()) {
1114
+ if (options.view === "trend") {
1115
+ return multiDayBounds(options.date, options.timeZone, options.trendDays);
1116
+ }
1117
+ if (options.range === "week") {
1118
+ return weekBounds(options.date, options.timeZone);
1119
+ }
1120
+ if (options.range === "rolling24h") {
1121
+ return rolling24hBounds(now, options.timeZone);
1122
+ }
1123
+ if (options.range === "rolling") {
1124
+ return rollingDurationBounds(now, options.timeZone, options.rollingDays);
1125
+ }
1126
+ return dayBounds(options.date, options.timeZone);
1127
+ }
1128
+
1129
+ function rangeDescription(options, bounds) {
1130
+ if (options.range === "rolling24h" || options.range === "rolling") {
1131
+ return rollingRangeDescription(options);
1132
+ }
1133
+ if (bounds.startDateString && bounds.endDateString) {
1134
+ return `${bounds.startDateString} through ${bounds.endDateString}`;
1135
+ }
1136
+ return bounds.dateString;
1137
+ }
1138
+
1139
+ export async function run(options, { nowMs } = {}) {
1140
+ const hasInjectedNow = nowMs !== undefined;
1141
+ const now = new Date(hasInjectedNow ? nowMs : Date.now());
1142
+ const bounds = boundsForOptions(options, now);
783
1143
  const snapshot = await loadSnapshot(options);
784
1144
  const events = filterDayEvents(snapshot, bounds);
785
- if (events.length === 0) {
786
- const rangeDescription = options.range === "week"
787
- ? `${bounds.startDateString} through ${bounds.endDateString}`
788
- : bounds.dateString;
1145
+ const writingImage = options.view === "trend" && options.image;
1146
+ const writingEmptyCacheReport = writingImage && options.cacheRate;
1147
+ if (events.length === 0 && !writingEmptyCacheReport) {
789
1148
  return [
790
- `No model-call events found for ${rangeDescription} (${bounds.timeZone}).`,
1149
+ `No model-call events found for ${rangeDescription(options, bounds)} (${bounds.timeZone}).`,
791
1150
  `Source: ${sourceLabel(options.input, snapshot)}`,
792
1151
  ].join("\n");
793
1152
  }
794
- const allRows = aggregateProjects(snapshot, events, options);
1153
+ const allRows = options.cacheRate
1154
+ ? []
1155
+ : aggregateProjects(snapshot, events, options);
795
1156
  const rows = allRows.slice(0, options.top);
796
- const writingImage = options.view === "trend" && options.image;
797
1157
  const outputPath = writingImage
798
1158
  ? options.imageOutput ??
799
1159
  resolve(
800
1160
  process.cwd(),
801
- `token-ledger-${options.report ? "report" : "trend"}-${options.trendDays}d.png`,
1161
+ `token-ledger-${options.cacheRate ? "cache-report" : options.report ? "report" : "trend"}-${options.trendDays}d.png`,
802
1162
  )
803
1163
  : null;
804
- const imageLabel = options.report ? "report" : "trend image";
1164
+ const imageLabel = options.cacheRate
1165
+ ? "cache report"
1166
+ : options.report
1167
+ ? "report"
1168
+ : "trend image";
805
1169
  if (writingImage) {
806
1170
  process.stderr.write(`Token Ledger: generating ${imageLabel} PNG…\n`);
807
1171
  }
808
- const output = render(options, snapshot, bounds, events, rows, allRows);
1172
+ const output = render(
1173
+ options,
1174
+ snapshot,
1175
+ bounds,
1176
+ events,
1177
+ rows,
1178
+ allRows,
1179
+ snapshotFreshness(
1180
+ snapshot,
1181
+ hasInjectedNow ? now.getTime() : Date.now(),
1182
+ ),
1183
+ );
809
1184
  if (writingImage) {
810
1185
  await mkdir(dirname(outputPath), { recursive: true });
811
1186
  process.stderr.write(`Token Ledger: encoding ${imageLabel} PNG…\n`);
812
1187
  await writeTrendPng(output, outputPath);
813
1188
  process.stderr.write(`Token Ledger: finished ${imageLabel} PNG.\n`);
814
- return [
815
- `Wrote ${options.report ? "report" : "trend image"}: ${outputPath}`,
1189
+ const lines = [
1190
+ `Wrote ${imageLabel}: ${outputPath}`,
816
1191
  `Range: ${bounds.startDateString} through ${bounds.endDateString} (${bounds.timeZone})`,
817
- ].join("\n");
1192
+ ];
1193
+ // Show the finished report on screen right away instead of leaving it to
1194
+ // be dug out of a file browser. Skipped for piped/scripted runs so
1195
+ // automation and CI never pop windows.
1196
+ if (options.openImage && process.stdout.isTTY) {
1197
+ lines.push(
1198
+ openInViewer(outputPath)
1199
+ ? "Opened the report in your default image viewer."
1200
+ : "Could not open a viewer automatically; open the file above to see the report.",
1201
+ );
1202
+ }
1203
+ return lines.join("\n");
818
1204
  }
819
1205
  return output;
820
1206
  }
821
1207
 
1208
+ function openInViewer(path) {
1209
+ const platform = process.platform;
1210
+ const [command, args] = platform === "darwin"
1211
+ ? ["open", [path]]
1212
+ : platform === "win32"
1213
+ ? ["cmd", ["/c", "start", "", path]]
1214
+ : ["xdg-open", [path]];
1215
+ const result = spawnSync(command, args, { stdio: "ignore" });
1216
+ return result.status === 0;
1217
+ }
1218
+
822
1219
  function shouldUseInteractive(options) {
823
1220
  return Boolean(
824
1221
  !options.static &&
@@ -832,17 +1229,12 @@ function shouldUseInteractive(options) {
832
1229
  }
833
1230
 
834
1231
  async function runInteractive(options) {
835
- const bounds = options.range === "week"
836
- ? weekBounds(options.date, options.timeZone)
837
- : dayBounds(options.date, options.timeZone);
1232
+ const bounds = boundsForOptions(options);
838
1233
  const snapshot = await loadSnapshot(options);
839
1234
  const events = filterDayEvents(snapshot, bounds);
840
1235
  if (events.length === 0) {
841
- const rangeDescription = options.range === "week"
842
- ? `${bounds.startDateString} through ${bounds.endDateString}`
843
- : bounds.dateString;
844
1236
  process.stdout.write([
845
- `No model-call events found for ${rangeDescription} (${bounds.timeZone}).`,
1237
+ `No model-call events found for ${rangeDescription(options, bounds)} (${bounds.timeZone}).`,
846
1238
  `Source: ${sourceLabel(options.input, snapshot)}`,
847
1239
  "",
848
1240
  ].join("\n"));
@@ -852,6 +1244,7 @@ async function runInteractive(options) {
852
1244
  await startInteractive({
853
1245
  options,
854
1246
  snapshot,
1247
+ snapshotFreshness: snapshotFreshness(snapshot),
855
1248
  bounds,
856
1249
  events,
857
1250
  rows: allRows.slice(0, options.top),
@@ -864,7 +1257,7 @@ async function main() {
864
1257
  try {
865
1258
  options = parseArgs(process.argv.slice(2));
866
1259
  if (options.help) {
867
- process.stdout.write(`${usage()}\n`);
1260
+ process.stdout.write(`${options.helpAll ? advancedUsage() : usage()}\n`);
868
1261
  return;
869
1262
  }
870
1263
  if (shouldUseInteractive(options)) {
@@ -873,7 +1266,13 @@ async function main() {
873
1266
  process.stdout.write(`${await run(options)}\n`);
874
1267
  }
875
1268
  } catch (error) {
876
- process.stderr.write(`Token Ledger CLI failed: ${error.message}\n\n${usage()}\n`);
1269
+ process.stderr.write(
1270
+ `Token Ledger: ${safeErrorMessage(error, [
1271
+ options?.input,
1272
+ options?.codexHome,
1273
+ options?.imageOutput,
1274
+ ])}\nRun \`tledger --help\` for examples or \`tledger --help-all\` for every option.\n`,
1275
+ );
877
1276
  process.exitCode = 1;
878
1277
  }
879
1278
  }