tledger 0.2.1 → 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,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
  );
@@ -798,10 +956,18 @@ function snapshotAgeLabel(ageMs) {
798
956
  return `${Math.floor(hours / 24)}d old`;
799
957
  }
800
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
+
801
968
  export function snapshotFreshness(snapshot = {}, nowMs = Date.now()) {
802
- const generatedAtMs = typeof snapshot.generatedAt === "string"
803
- ? Date.parse(snapshot.generatedAt)
804
- : NaN;
969
+ const generatedAt = primitiveString(snapshot.generatedAt);
970
+ const generatedAtMs = generatedAt === null ? NaN : Date.parse(generatedAt);
805
971
  if (
806
972
  !Number.isFinite(generatedAtMs) ||
807
973
  !Number.isFinite(nowMs) ||
@@ -863,6 +1029,14 @@ async function loadSnapshot(options) {
863
1029
 
864
1030
  function render(options, snapshot, bounds, events, rows, allRows, freshness) {
865
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
+ }
866
1040
  const trend = buildUsageTrend(snapshot, bounds);
867
1041
  if (options.image) {
868
1042
  return renderTrendImage({
@@ -871,6 +1045,7 @@ function render(options, snapshot, bounds, events, rows, allRows, freshness) {
871
1045
  trend,
872
1046
  days: options.trendDays,
873
1047
  options,
1048
+ projectRows: allRows,
874
1049
  });
875
1050
  }
876
1051
  return renderTrendCombo({
@@ -897,7 +1072,9 @@ function render(options, snapshot, bounds, events, rows, allRows, freshness) {
897
1072
  const totalTokens = summary.totalTokens;
898
1073
  const dateLabel = options.range === "rolling24h"
899
1074
  ? "last 24 hours"
900
- : options.range === "week"
1075
+ : options.range === "rolling"
1076
+ ? `last ${options.rollingLabel}`
1077
+ : options.range === "week"
901
1078
  ? `${bounds.startDateString} through ${bounds.endDateString}`
902
1079
  : new Intl.DateTimeFormat("en-US", {
903
1080
  timeZone: bounds.timeZone,
@@ -914,7 +1091,7 @@ function render(options, snapshot, bounds, events, rows, allRows, freshness) {
914
1091
 
915
1092
  const header = [
916
1093
  `Token Ledger · ${dateLabel} · ${bounds.timeZone}`,
917
- `${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`,
918
1095
  `Source: ${sourceLabel(options.input, snapshot)}`,
919
1096
  "",
920
1097
  chart,
@@ -943,11 +1120,16 @@ function boundsForOptions(options, now = new Date()) {
943
1120
  if (options.range === "rolling24h") {
944
1121
  return rolling24hBounds(now, options.timeZone);
945
1122
  }
1123
+ if (options.range === "rolling") {
1124
+ return rollingDurationBounds(now, options.timeZone, options.rollingDays);
1125
+ }
946
1126
  return dayBounds(options.date, options.timeZone);
947
1127
  }
948
1128
 
949
1129
  function rangeDescription(options, bounds) {
950
- if (options.range === "rolling24h") return "the last 24 hours";
1130
+ if (options.range === "rolling24h" || options.range === "rolling") {
1131
+ return rollingRangeDescription(options);
1132
+ }
951
1133
  if (bounds.startDateString && bounds.endDateString) {
952
1134
  return `${bounds.startDateString} through ${bounds.endDateString}`;
953
1135
  }
@@ -960,23 +1142,30 @@ export async function run(options, { nowMs } = {}) {
960
1142
  const bounds = boundsForOptions(options, now);
961
1143
  const snapshot = await loadSnapshot(options);
962
1144
  const events = filterDayEvents(snapshot, bounds);
963
- if (events.length === 0) {
1145
+ const writingImage = options.view === "trend" && options.image;
1146
+ const writingEmptyCacheReport = writingImage && options.cacheRate;
1147
+ if (events.length === 0 && !writingEmptyCacheReport) {
964
1148
  return [
965
1149
  `No model-call events found for ${rangeDescription(options, bounds)} (${bounds.timeZone}).`,
966
1150
  `Source: ${sourceLabel(options.input, snapshot)}`,
967
1151
  ].join("\n");
968
1152
  }
969
- const allRows = aggregateProjects(snapshot, events, options);
1153
+ const allRows = options.cacheRate
1154
+ ? []
1155
+ : aggregateProjects(snapshot, events, options);
970
1156
  const rows = allRows.slice(0, options.top);
971
- const writingImage = options.view === "trend" && options.image;
972
1157
  const outputPath = writingImage
973
1158
  ? options.imageOutput ??
974
1159
  resolve(
975
1160
  process.cwd(),
976
- `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`,
977
1162
  )
978
1163
  : null;
979
- const imageLabel = options.report ? "report" : "trend image";
1164
+ const imageLabel = options.cacheRate
1165
+ ? "cache report"
1166
+ : options.report
1167
+ ? "report"
1168
+ : "trend image";
980
1169
  if (writingImage) {
981
1170
  process.stderr.write(`Token Ledger: generating ${imageLabel} PNG…\n`);
982
1171
  }
@@ -997,14 +1186,36 @@ export async function run(options, { nowMs } = {}) {
997
1186
  process.stderr.write(`Token Ledger: encoding ${imageLabel} PNG…\n`);
998
1187
  await writeTrendPng(output, outputPath);
999
1188
  process.stderr.write(`Token Ledger: finished ${imageLabel} PNG.\n`);
1000
- return [
1001
- `Wrote ${options.report ? "report" : "trend image"}: ${outputPath}`,
1189
+ const lines = [
1190
+ `Wrote ${imageLabel}: ${outputPath}`,
1002
1191
  `Range: ${bounds.startDateString} through ${bounds.endDateString} (${bounds.timeZone})`,
1003
- ].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");
1004
1204
  }
1005
1205
  return output;
1006
1206
  }
1007
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
+
1008
1219
  function shouldUseInteractive(options) {
1009
1220
  return Boolean(
1010
1221
  !options.static &&
@@ -1046,7 +1257,7 @@ async function main() {
1046
1257
  try {
1047
1258
  options = parseArgs(process.argv.slice(2));
1048
1259
  if (options.help) {
1049
- process.stdout.write(`${usage()}\n`);
1260
+ process.stdout.write(`${options.helpAll ? advancedUsage() : usage()}\n`);
1050
1261
  return;
1051
1262
  }
1052
1263
  if (shouldUseInteractive(options)) {
@@ -1056,11 +1267,11 @@ async function main() {
1056
1267
  }
1057
1268
  } catch (error) {
1058
1269
  process.stderr.write(
1059
- `Token Ledger CLI failed: ${safeErrorMessage(error, [
1270
+ `Token Ledger: ${safeErrorMessage(error, [
1060
1271
  options?.input,
1061
1272
  options?.codexHome,
1062
1273
  options?.imageOutput,
1063
- ])}\n\n${usage()}\n`,
1274
+ ])}\nRun \`tledger --help\` for examples or \`tledger --help-all\` for every option.\n`,
1064
1275
  );
1065
1276
  process.exitCode = 1;
1066
1277
  }