tledger 0.2.0 → 0.2.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.
package/README.md CHANGED
@@ -46,6 +46,8 @@ is interactive; press `q` or `esc` to exit.
46
46
  Other common views:
47
47
 
48
48
  ```bash
49
+ tledger 1d
50
+ tledger 1d --static
49
51
  tledger day 2026-08-05
50
52
  tledger week --top 5
51
53
  tledger week --static
@@ -54,6 +56,10 @@ tledger trend 7d --image --image-output artifacts/token-ledger-trend-7d.png
54
56
  tledger report 7d
55
57
  ```
56
58
 
59
+ `tledger 1d` shows the `TOKENS BY PROJECT` breakdown for the rolling 24 hours
60
+ ending when the command starts. It is different from `tledger day today`, which
61
+ covers the current calendar day from local midnight.
62
+
57
63
  `tledger report [7d|14d|30d]` is the one-step report output: it writes the
58
64
  dashboard PNG (identical to `trend --image`) to
59
65
  `token-ledger-report-<period>.png` in the current directory. It accepts the
@@ -107,6 +113,12 @@ The first refresh may scan historical rollout files; later runs use the cache
107
113
  for one hour before checking source freshness again. Use `--refresh` when you
108
114
  need to force an immediate rebuild.
109
115
 
116
+ The `1d` project dashboard shows a compact snapshot-age line such as
117
+ `SNAPSHOT · fresh · 12m old`. `fresh` means the snapshot is within the
118
+ one-hour cache window, `stale` means it is older, and `age unknown` means the
119
+ snapshot has no usable capture-time metadata. The indicator does not print a
120
+ local path or trigger another source scan.
121
+
110
122
  Useful overrides:
111
123
 
112
124
  ```bash
@@ -139,19 +151,29 @@ writes its privacy-reduced cache to:
139
151
 
140
152
  The collector does not export message bodies, reasoning text, tool arguments or
141
153
  results, credentials, file contents, or full local paths. Display titles may
142
- contain user-written text. The CLI makes no network requests.
154
+ contain user-written text. CLI errors and empty-state source labels show only a
155
+ safe filename label, not an absolute input or source path. When a PNG or report
156
+ is written, the explicit output path is reported so you can find the file. The
157
+ CLI makes no network requests.
143
158
 
144
159
  ## Keyboard controls
145
160
 
146
161
  In the interactive dashboard:
147
162
 
148
163
  - `↑` / `↓` or `j` / `k` moves between projects.
149
- - `q` or `esc` exits.
164
+ - `q`, `Q`, `Esc`, or `Ctrl-C` exits.
165
+ - Enter does not inspect a project, and `d` / `w` / `m` do not change the
166
+ range; choose the desired range in the command instead.
150
167
 
151
168
  ## Verify from source
152
169
 
153
170
  ```bash
154
171
  npm test
155
172
  npm run lint
156
- npm pack --dry-run
173
+ npm run verify:release
174
+ npm pack --dry-run --json
157
175
  ```
176
+
177
+ `npm run verify:release` packs the allowlisted artifact, installs that tarball
178
+ in a clean temporary directory with no network or Codex data access, and runs
179
+ the installed `tledger --help` and synthetic `tledger 1d --static` smoke checks.
@@ -0,0 +1,24 @@
1
+ export const INTERACTIVE_KEY_INPUTS = Object.freeze({
2
+ up: Object.freeze(["\u001b[A", "k"]),
3
+ down: Object.freeze(["\u001b[B", "j"]),
4
+ quit: Object.freeze(["q", "Q", "\u0003", "\u001b"]),
5
+ });
6
+
7
+ export const INTERACTIVE_FOOTER = Object.freeze({
8
+ ascii: "[j/k] select [q/Q/esc/ctrl-c] quit",
9
+ unicode: "[↑↓/j/k] select [q/Q/esc/ctrl-c] quit",
10
+ });
11
+
12
+ export const INTERACTIVE_HELP = "↑/↓ or j/k move • q/Q, esc, or ctrl-c quit";
13
+
14
+ export function actionFor(input) {
15
+ const value = String(input);
16
+ if (value.includes(INTERACTIVE_KEY_INPUTS.up[0]) || value === INTERACTIVE_KEY_INPUTS.up[1]) {
17
+ return "up";
18
+ }
19
+ if (value.includes(INTERACTIVE_KEY_INPUTS.down[0]) || value === INTERACTIVE_KEY_INPUTS.down[1]) {
20
+ return "down";
21
+ }
22
+ if (INTERACTIVE_KEY_INPUTS.quit.includes(value)) return "quit";
23
+ return null;
24
+ }
@@ -2,6 +2,10 @@ import {
2
2
  normalizeQuotaTimeline,
3
3
  weeklyQuotaObservations,
4
4
  } from "./token-ledger-trend.mjs";
5
+ import {
6
+ INTERACTIVE_FOOTER,
7
+ INTERACTIVE_HELP,
8
+ } from "./token-ledger-controls.mjs";
5
9
 
6
10
  const RESET = "\u001b[0m";
7
11
  const PRIMARY_STYLE = [38, 2, 255, 255, 255];
@@ -128,6 +132,7 @@ function displayProject(row) {
128
132
  }
129
133
 
130
134
  function dateLabel(bounds, range = "day") {
135
+ if (range === "rolling24h") return "LAST 24 HOURS";
131
136
  if (range === "week" && bounds.startDateString && bounds.endDateString) {
132
137
  const startParts = bounds.startDateString.split("-").map(Number);
133
138
  const endParts = bounds.endDateString.split("-").map(Number);
@@ -496,10 +501,22 @@ function panel(leftLines, rightLines, leftWidth, rightWidth, enabled, ascii) {
496
501
  return [top, ...body, bottom];
497
502
  }
498
503
 
499
- function headerLines(stats, bounds, frameWidth, options, enabled) {
504
+ function snapshotLine(freshness, enabled) {
505
+ const detail = freshness?.status === "fresh" || freshness?.status === "stale"
506
+ ? `${freshness.status} · ${freshness.ageLabel}`
507
+ : "age unknown";
508
+ return `${colorize("SNAPSHOT", ACCENT_STYLE, enabled)} ${colorize("·", SECONDARY_STYLE, enabled)} ${colorize(detail, SECONDARY_STYLE, enabled)}`;
509
+ }
510
+
511
+ function headerLines(stats, bounds, frameWidth, options, enabled, freshness) {
500
512
  const left = colorize("TOKEN LEDGER", TITLE_STYLE, enabled);
501
513
  const date = colorize(dateLabel(bounds, options.range), TEXT_STYLE, enabled);
502
- const mode = colorize(options.range === "week" ? "7 DAYS" : "DAY", [1, ...ACCENT_STYLE], enabled);
514
+ const modeLabel = options.range === "rolling24h"
515
+ ? "24 HOURS"
516
+ : options.range === "week"
517
+ ? "7 DAYS"
518
+ : "DAY";
519
+ const mode = colorize(modeLabel, [1, ...ACCENT_STYLE], enabled);
503
520
  const metric = (value, label) =>
504
521
  `${colorize(String(value), TITLE_STYLE, enabled)} ${colorize(label, SECONDARY_STYLE, enabled)}`;
505
522
  const separator = colorize("·", SECONDARY_STYLE, enabled);
@@ -515,13 +532,19 @@ function headerLines(stats, bounds, frameWidth, options, enabled) {
515
532
  metric(stats.projectCount.toLocaleString("en-US"), "PROJECTS"),
516
533
  ].join(join);
517
534
  if (visibleLength(fullLine) < frameWidth) {
518
- return [alignHeader(fullLine)];
535
+ const lines = [alignHeader(fullLine)];
536
+ if (options.range === "rolling24h") lines.push(alignHeader(snapshotLine(freshness, enabled)));
537
+ return lines;
519
538
  }
520
539
 
521
540
  const compactDate = dateLabel(bounds, options.range)
522
541
  .replace(/ 20\d{2}$/, "")
523
542
  .replace(" – ", "–");
524
- const compactMode = options.range === "week" ? "7D" : "DAY";
543
+ const compactMode = options.range === "rolling24h"
544
+ ? "24H"
545
+ : options.range === "week"
546
+ ? "7D"
547
+ : "DAY";
525
548
  const compactLine = [
526
549
  left,
527
550
  colorize(compactDate, TEXT_STYLE, enabled),
@@ -532,7 +555,9 @@ function headerLines(stats, bounds, frameWidth, options, enabled) {
532
555
  metric(stats.projectCount.toLocaleString("en-US"), "P"),
533
556
  ].join(join);
534
557
  if (visibleLength(compactLine) < frameWidth) {
535
- return [alignHeader(compactLine)];
558
+ const lines = [alignHeader(compactLine)];
559
+ if (options.range === "rolling24h") lines.push(alignHeader(snapshotLine(freshness, enabled)));
560
+ return lines;
536
561
  }
537
562
 
538
563
  const minimalTitle = colorize(frameWidth >= 45 ? "LEDGER" : "L", TITLE_STYLE, enabled);
@@ -545,10 +570,20 @@ function headerLines(stats, bounds, frameWidth, options, enabled) {
545
570
  compact(stats.threads),
546
571
  compact(stats.projectCount),
547
572
  ].join(" ");
548
- return [alignHeader(minimalLine)];
573
+ const lines = [alignHeader(minimalLine)];
574
+ if (options.range === "rolling24h") lines.push(alignHeader(snapshotLine(freshness, enabled)));
575
+ return lines;
549
576
  }
550
577
 
551
- export function renderTerminal({ options, snapshot, bounds, events, rows, allRows }) {
578
+ export function renderTerminal({
579
+ options,
580
+ snapshot,
581
+ snapshotFreshness,
582
+ bounds,
583
+ events,
584
+ rows,
585
+ allRows,
586
+ }) {
552
587
  const enabled = colorsEnabled(options);
553
588
  const stats = summary(events);
554
589
  const quota = quotaCycleSummary(snapshot, events);
@@ -561,7 +596,7 @@ export function renderTerminal({ options, snapshot, bounds, events, rows, allRow
561
596
  const left = panelLines(rows, allRows, stats.totalTokens, leftWidth, options, enabled);
562
597
  const right = sideBySide ? sidebarLines(stats, sideWidth, enabled, options, quota) : null;
563
598
  const lines = [
564
- ...headerLines(stats, bounds, frameWidth, options, enabled),
599
+ ...headerLines(stats, bounds, frameWidth, options, enabled, snapshotFreshness),
565
600
  ...panel(left, right, leftWidth, sideWidth, enabled, options.ascii),
566
601
  ];
567
602
  if (!sideBySide) {
@@ -571,9 +606,7 @@ export function renderTerminal({ options, snapshot, bounds, events, rows, allRow
571
606
  lines.push("");
572
607
  lines.push(
573
608
  colorize(
574
- options.ascii
575
- ? "[j/k] select [enter] inspect [d/w/m] range [q] quit"
576
- : "[↑↓] select [enter] inspect [d/w/m] range [q] quit",
609
+ options.ascii ? INTERACTIVE_FOOTER.ascii : INTERACTIVE_FOOTER.unicode,
577
610
  SECONDARY_STYLE,
578
611
  enabled,
579
612
  ),
@@ -591,7 +624,17 @@ function paintFullscreenLine(line, width, background, enabled) {
591
624
  return `${background}${restored}${RESET}`;
592
625
  }
593
626
 
594
- export function renderFullscreen({ options, snapshot, bounds, events, rows, allRows, width, height }) {
627
+ export function renderFullscreen({
628
+ options,
629
+ snapshot,
630
+ snapshotFreshness,
631
+ bounds,
632
+ events,
633
+ rows,
634
+ allRows,
635
+ width,
636
+ height,
637
+ }) {
595
638
  const enabled = options.forceColor ?? colorsEnabled(options);
596
639
  const columns = Math.max(40, Number(width) || Number(process.stdout.columns) || 120);
597
640
  const screenHeight = Math.max(1, Number(height) || Number(process.stdout.rows) || 32);
@@ -606,6 +649,7 @@ export function renderFullscreen({ options, snapshot, bounds, events, rows, allR
606
649
  width: frameWidth + 2,
607
650
  },
608
651
  snapshot,
652
+ snapshotFreshness,
609
653
  bounds,
610
654
  events,
611
655
  rows,
@@ -615,7 +659,7 @@ export function renderFullscreen({ options, snapshot, bounds, events, rows, allR
615
659
  staticLines.pop();
616
660
  if (staticLines.at(-1) === "") staticLines.pop();
617
661
  const summaryLine = staticLines.shift() ?? "";
618
- const help = colorize("↑/↓ move • j/k move • q/esc quit", SECONDARY_STYLE, enabled);
662
+ const help = colorize(INTERACTIVE_HELP, SECONDARY_STYLE, enabled);
619
663
  const content = [
620
664
  { line: summaryLine, background: SCREEN_BASE },
621
665
  ...staticLines.map((line) => ({ line, background: PANEL_BASE })),
@@ -1,4 +1,7 @@
1
1
  import { renderFullscreen, SCREEN_BASE } from "./token-ledger-terminal.mjs";
2
+ import { actionFor } from "./token-ledger-controls.mjs";
3
+
4
+ export { actionFor };
2
5
 
3
6
  const ENTER_ALT_SCREEN = "\u001b[?1049h";
4
7
  const EXIT_ALT_SCREEN = "\u001b[?1049l";
@@ -7,17 +10,16 @@ const SHOW_CURSOR = "\u001b[?25h";
7
10
  const CLEAR_SCREEN = "\u001b[2J\u001b[H";
8
11
  const RESET = "\u001b[0m";
9
12
 
10
- function actionFor(input) {
11
- const value = String(input);
12
- if (value.includes("\u001b[A") || value === "k") return "up";
13
- if (value.includes("\u001b[B") || value === "j") return "down";
14
- if (value === "q" || value === "Q" || value === "\u0003" || value === "\u001b") return "quit";
15
- if (value === "\r" || value === "\n") return "inspect";
16
- return null;
17
- }
18
-
19
13
  export function startInteractive(view) {
20
- const { options, snapshot, bounds, events, rows, allRows } = view;
14
+ const {
15
+ options,
16
+ snapshot,
17
+ snapshotFreshness,
18
+ bounds,
19
+ events,
20
+ rows,
21
+ allRows,
22
+ } = view;
21
23
  const stdin = process.stdin;
22
24
  const stdout = process.stdout;
23
25
  if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") {
@@ -34,6 +36,7 @@ export function startInteractive(view) {
34
36
  const screen = renderFullscreen({
35
37
  options: { ...options, forceColor: true, selectedIndex },
36
38
  snapshot,
39
+ snapshotFreshness,
37
40
  bounds,
38
41
  events,
39
42
  rows,
@@ -74,7 +77,6 @@ export function startInteractive(view) {
74
77
  draw();
75
78
  return;
76
79
  }
77
- if (action === "inspect") draw();
78
80
  };
79
81
 
80
82
  stdin.setRawMode(true);
@@ -31,6 +31,7 @@ export const DEFAULT_SNAPSHOT = resolve(
31
31
  const DEFAULT_TOP = 10;
32
32
  const DEFAULT_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
33
33
  export const SNAPSHOT_CACHE_MAX_AGE_MS = 60 * 60 * 1000;
34
+ export const ROLLING_24_HOURS_MS = 24 * 60 * 60 * 1000;
34
35
  const ANSI_RESET = "\u001b[0m";
35
36
  const MODEL_COLORS = {
36
37
  sol: TERMINAL_MODEL_COLORS.sol,
@@ -45,6 +46,7 @@ function usage() {
45
46
  return `Token Ledger terminal usage
46
47
 
47
48
  Usage:
49
+ tledger 1d Rolling 24-hour project breakdown (ends now)
48
50
  tledger day <YYYY-MM-DD>
49
51
  tledger week [end-day]
50
52
  tledger trend [7d|14d|30d]
@@ -78,7 +80,8 @@ The report command writes the dashboard PNG (same as trend --image) to
78
80
  token-ledger-report-<period>.png; use --image-output to choose the path.
79
81
 
80
82
  The command reads a privacy-reduced Token Ledger snapshot. It never uploads
81
- the snapshot or prints message bodies, tool payloads, credentials, or paths.`;
83
+ the snapshot or prints message bodies, tool payloads, credentials, or local
84
+ input/source paths. Explicit PNG output paths are reported after writing.`;
82
85
  }
83
86
 
84
87
  function readOption(argv, index, name) {
@@ -90,14 +93,18 @@ function readOption(argv, index, name) {
90
93
  }
91
94
 
92
95
  export function parseArgs(argv) {
93
- const command = argv[0] === "week"
94
- ? "week"
95
- : argv[0] === "trend" || argv[0] === "report"
96
- ? "trend"
97
- : "day";
96
+ const rolling24hCommand = argv[0] === "1d";
97
+ const command = rolling24hCommand
98
+ ? "rolling24h"
99
+ : argv[0] === "week"
100
+ ? "week"
101
+ : argv[0] === "trend" || argv[0] === "report"
102
+ ? "trend"
103
+ : "day";
98
104
  const options = {
99
105
  range: command,
100
106
  view: command === "trend" ? "trend" : "projects",
107
+ rolling24h: rolling24hCommand,
101
108
  report: argv[0] === "report",
102
109
  trendDays: 7,
103
110
  date: null,
@@ -123,7 +130,7 @@ export function parseArgs(argv) {
123
130
  };
124
131
 
125
132
  let trendPeriodSeen = false;
126
- let index = ["day", "week", "trend", "report"].includes(argv[0]) ? 1 : 0;
133
+ let index = ["1d", "day", "week", "trend", "report"].includes(argv[0]) ? 1 : 0;
127
134
  for (; index < argv.length; index += 1) {
128
135
  const argument = argv[index];
129
136
  if (argument === "--help" || argument === "-h") {
@@ -217,6 +224,19 @@ export function parseArgs(argv) {
217
224
  index += 1;
218
225
  } else if (argument === "--youplot") {
219
226
  options.legacyPlot = true;
227
+ } else if (
228
+ !argument.startsWith("-") &&
229
+ options.view === "projects" &&
230
+ argument === "1d" &&
231
+ !options.date
232
+ ) {
233
+ if (argv[0] !== "day") {
234
+ throw new Error(
235
+ "The 1d alias is only available as `tledger 1d` or `tledger day 1d`.",
236
+ );
237
+ }
238
+ options.range = "rolling24h";
239
+ options.rolling24h = true;
220
240
  } else if (!argument.startsWith("-") && options.view === "trend") {
221
241
  if (trendPeriodSeen) {
222
242
  throw new Error("Trend period can only be specified once.");
@@ -234,10 +254,13 @@ export function parseArgs(argv) {
234
254
  }
235
255
 
236
256
  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.");
259
+ }
237
260
  if (!options.help && !options.date && (options.range === "week" || options.view === "trend")) {
238
261
  options.date = "today";
239
262
  }
240
- if (!options.help && !options.date) {
263
+ if (!options.help && !options.date && !options.rolling24h) {
241
264
  throw new Error("A day is required, for example: tledger day 2026-08-01");
242
265
  }
243
266
  if (!options.help && options.refresh && !options.autoRefresh) {
@@ -364,6 +387,20 @@ export function weekBounds(value, timeZone) {
364
387
  };
365
388
  }
366
389
 
390
+ export function rolling24hBounds(value = new Date(), timeZone = DEFAULT_TIME_ZONE) {
391
+ validateTimeZone(timeZone);
392
+ const end = value instanceof Date ? new Date(value.getTime()) : new Date(value);
393
+ if (!Number.isFinite(end.getTime())) {
394
+ throw new Error("Rolling 24-hour window requires a valid end time.");
395
+ }
396
+ return {
397
+ start: new Date(end.getTime() - ROLLING_24_HOURS_MS),
398
+ end,
399
+ timeZone,
400
+ rangeHours: 24,
401
+ };
402
+ }
403
+
367
404
  export function sanitizeTerminalText(value) {
368
405
  return String(value ?? "")
369
406
  .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "")
@@ -378,6 +415,62 @@ function cleanLabel(value, fallback) {
378
415
  return label || fallback;
379
416
  }
380
417
 
418
+ const QUOTED_ABSOLUTE_PATH =
419
+ /(["'])(\/(?!\/)[^"'\r\n]*|(?:\/\/|\\\\)[^"'\r\n]+|[A-Za-z]:[\\/][^"'\r\n]*)\1/g;
420
+ const UNQUOTED_ABSOLUTE_PATH =
421
+ /(^|[\s([{=])((?:\/(?!\/)|\/\/|\\\\|[A-Za-z]:[\\/])[^\s"'`)\]},;]+)/g;
422
+
423
+ function isAbsoluteLocalPath(path) {
424
+ return (
425
+ path.startsWith("/") ||
426
+ path.startsWith("\\\\") ||
427
+ /^[A-Za-z]:[\\/]/.test(path)
428
+ );
429
+ }
430
+
431
+ export function safeDisplayLabel(value, fallback = "local path") {
432
+ const normalized = String(value ?? "").replaceAll("\\", "/");
433
+ const label = sanitizeTerminalText(basename(normalized))
434
+ .replace(/\s+/g, " ")
435
+ .trim();
436
+ return label && label !== "." && label !== ".." ? label : fallback;
437
+ }
438
+
439
+ export function redactLocalPaths(value, paths = []) {
440
+ let redacted = String(value ?? "");
441
+ const explicitPaths = new Set(
442
+ paths
443
+ .filter(Boolean)
444
+ .map((path) => String(path))
445
+ .filter(isAbsoluteLocalPath),
446
+ );
447
+ const pathsToRedact = [...new Set([
448
+ ...explicitPaths,
449
+ homedir(),
450
+ process.cwd(),
451
+ ])]
452
+ .filter((path) => path && path !== "/")
453
+ .sort((left, right) => right.length - left.length);
454
+
455
+ for (const path of pathsToRedact) {
456
+ redacted = redacted.replaceAll(
457
+ path,
458
+ explicitPaths.has(path) ? safeDisplayLabel(path) : "[local path]",
459
+ );
460
+ }
461
+
462
+ return redacted
463
+ .replace(QUOTED_ABSOLUTE_PATH, (_match, quote) => `${quote}[local path]${quote}`)
464
+ .replace(UNQUOTED_ABSOLUTE_PATH, (_match, prefix) => `${prefix}[local path]`);
465
+ }
466
+
467
+ function safeErrorMessage(error, paths = []) {
468
+ return redactLocalPaths(
469
+ error instanceof Error ? error.message : String(error),
470
+ paths,
471
+ );
472
+ }
473
+
381
474
  function displayLabel(value) {
382
475
  const label = cleanLabel(value, "Unlabelled activity");
383
476
  if (label.length <= 30) return label;
@@ -582,7 +675,7 @@ function sourceLabel(snapshotPath, snapshot) {
582
675
  timeStyle: "short",
583
676
  })
584
677
  : "unknown time";
585
- return `${basename(snapshotPath)} · captured ${generated}`;
678
+ return `${safeDisplayLabel(snapshotPath, "snapshot")} · captured ${generated}`;
586
679
  }
587
680
 
588
681
  function runYouPlot(rows, options, dateLabel, unit) {
@@ -627,43 +720,57 @@ function runYouPlot(rows, options, dateLabel, unit) {
627
720
  }
628
721
 
629
722
  async function readSnapshot(snapshotPath) {
723
+ const snapshotLabel = safeDisplayLabel(snapshotPath, "snapshot");
630
724
  let parsed;
631
725
  try {
632
726
  parsed = JSON.parse(await readFile(snapshotPath, "utf8"));
633
727
  } catch (error) {
634
728
  if (error?.code === "ENOENT") {
635
- throw new Error(`Snapshot not found: ${snapshotPath}`);
729
+ throw new Error(`Snapshot not found: ${snapshotLabel}`);
636
730
  }
637
- throw new Error(`Could not read snapshot ${snapshotPath}: ${error.message}`);
731
+ throw new Error(
732
+ `Could not read snapshot ${snapshotLabel}: ${safeErrorMessage(error, [snapshotPath])}`,
733
+ );
638
734
  }
639
735
  if (!parsed || !Array.isArray(parsed.events)) {
640
- throw new Error(`Snapshot is missing its events array: ${snapshotPath}`);
736
+ throw new Error(`Snapshot is missing its events array: ${snapshotLabel}`);
641
737
  }
642
738
  return parsed;
643
739
  }
644
740
 
645
741
  async function refreshSnapshot(options) {
646
742
  if (!existsSync(options.codexHome)) {
647
- throw new Error(`Codex data directory not found: ${options.codexHome}`);
743
+ throw new Error(
744
+ `Codex data directory not found: ${safeDisplayLabel(options.codexHome, "Codex data directory")}`,
745
+ );
746
+ }
747
+ let progressStarted = false;
748
+ try {
749
+ const { collectUsage, writePrivateSnapshot } = await import(
750
+ "../lib/token-ledger-importer.mjs"
751
+ );
752
+ process.stderr.write("Token Ledger: refreshing local snapshot…\n");
753
+ progressStarted = true;
754
+ const snapshot = await collectUsage(
755
+ {
756
+ output: options.input,
757
+ codexHome: options.codexHome,
758
+ includeArchived: options.includeArchived,
759
+ since: null,
760
+ },
761
+ ({ current, total }) => {
762
+ process.stderr.write(`\rToken Ledger: scanned ${current}/${total} rollout files`);
763
+ },
764
+ );
765
+ process.stderr.write("\n");
766
+ await writePrivateSnapshot(options.input, snapshot);
767
+ return snapshot;
768
+ } catch (error) {
769
+ if (progressStarted) process.stderr.write("\n");
770
+ throw new Error(
771
+ `Could not refresh local snapshot: ${safeErrorMessage(error, [options.input, options.codexHome])}`,
772
+ );
648
773
  }
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
774
  }
668
775
 
669
776
  export function snapshotNeedsRefresh(snapshotMtimeMs, latestJsonlMtimeMs) {
@@ -682,13 +789,41 @@ export function snapshotCacheIsFresh(
682
789
  );
683
790
  }
684
791
 
792
+ function snapshotAgeLabel(ageMs) {
793
+ if (ageMs < 60 * 1_000) return "now";
794
+ const minutes = Math.floor(ageMs / (60 * 1_000));
795
+ if (minutes < 60) return `${minutes}m old`;
796
+ const hours = Math.floor(minutes / 60);
797
+ if (hours < 24) return `${hours}h old`;
798
+ return `${Math.floor(hours / 24)}d old`;
799
+ }
800
+
801
+ export function snapshotFreshness(snapshot = {}, nowMs = Date.now()) {
802
+ const generatedAtMs = typeof snapshot.generatedAt === "string"
803
+ ? Date.parse(snapshot.generatedAt)
804
+ : NaN;
805
+ if (
806
+ !Number.isFinite(generatedAtMs) ||
807
+ !Number.isFinite(nowMs) ||
808
+ generatedAtMs > nowMs
809
+ ) {
810
+ return { status: "unknown", ageLabel: "age unknown" };
811
+ }
812
+ return {
813
+ status: snapshotCacheIsFresh(generatedAtMs, nowMs) ? "fresh" : "stale",
814
+ ageLabel: snapshotAgeLabel(nowMs - generatedAtMs),
815
+ };
816
+ }
817
+
685
818
  async function loadSnapshot(options) {
686
819
  if (options.refresh) {
687
820
  return refreshSnapshot(options);
688
821
  }
689
822
  if (!existsSync(options.input)) {
690
823
  if (options.inputExplicit || !options.autoRefresh) {
691
- throw new Error(`Snapshot not found: ${options.input}`);
824
+ throw new Error(
825
+ `Snapshot not found: ${safeDisplayLabel(options.input, "snapshot")}`,
826
+ );
692
827
  }
693
828
  return refreshSnapshot(options);
694
829
  }
@@ -696,23 +831,37 @@ async function loadSnapshot(options) {
696
831
  return readSnapshot(options.input);
697
832
  }
698
833
 
699
- const snapshotStat = await stat(options.input);
834
+ let snapshotStat;
835
+ try {
836
+ snapshotStat = await stat(options.input);
837
+ } catch (error) {
838
+ throw new Error(
839
+ `Could not inspect snapshot ${safeDisplayLabel(options.input, "snapshot")}: ${safeErrorMessage(error, [options.input])}`,
840
+ );
841
+ }
700
842
  if (snapshotCacheIsFresh(snapshotStat.mtimeMs)) {
701
843
  return readSnapshot(options.input);
702
844
  }
703
845
 
704
846
  const { latestSourceModifiedAt } = await import("../lib/token-ledger-importer.mjs");
705
- const latestSourceMtimeMs = await latestSourceModifiedAt(
706
- options.codexHome,
707
- options.includeArchived,
708
- );
847
+ let latestSourceMtimeMs;
848
+ try {
849
+ latestSourceMtimeMs = await latestSourceModifiedAt(
850
+ options.codexHome,
851
+ options.includeArchived,
852
+ );
853
+ } catch (error) {
854
+ throw new Error(
855
+ `Could not inspect local Codex source: ${safeErrorMessage(error, [options.codexHome])}`,
856
+ );
857
+ }
709
858
  if (snapshotNeedsRefresh(snapshotStat.mtimeMs, latestSourceMtimeMs)) {
710
859
  return refreshSnapshot(options);
711
860
  }
712
861
  return readSnapshot(options.input);
713
862
  }
714
863
 
715
- function render(options, snapshot, bounds, events, rows, allRows) {
864
+ function render(options, snapshot, bounds, events, rows, allRows, freshness) {
716
865
  if (options.view === "trend") {
717
866
  const trend = buildUsageTrend(snapshot, bounds);
718
867
  if (options.image) {
@@ -733,20 +882,30 @@ function render(options, snapshot, bounds, events, rows, allRows) {
733
882
  });
734
883
  }
735
884
  if (!options.legacyPlot) {
736
- return renderTerminal({ options, snapshot, bounds, events, rows, allRows });
885
+ return renderTerminal({
886
+ options,
887
+ snapshot,
888
+ snapshotFreshness: freshness,
889
+ bounds,
890
+ events,
891
+ rows,
892
+ allRows,
893
+ });
737
894
  }
738
895
  const enabled = !options.plain && !process.env.NO_COLOR && Boolean(process.stdout.isTTY);
739
896
  const summary = totalSummary(events);
740
897
  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);
898
+ const dateLabel = options.range === "rolling24h"
899
+ ? "last 24 hours"
900
+ : options.range === "week"
901
+ ? `${bounds.startDateString} through ${bounds.endDateString}`
902
+ : new Intl.DateTimeFormat("en-US", {
903
+ timeZone: bounds.timeZone,
904
+ weekday: "short",
905
+ month: "short",
906
+ day: "numeric",
907
+ year: "numeric",
908
+ }).format(bounds.start);
750
909
  const unit = chartUnit(rows[0]?.totalTokens ?? 0);
751
910
  const shares = rows.map((row) =>
752
911
  totalTokens > 0 ? (row.totalTokens / totalTokens) * 100 : 0,
@@ -774,20 +933,36 @@ function render(options, snapshot, bounds, events, rows, allRows) {
774
933
  return `${header.join("\n")}\n\n${details.join("\n")}`;
775
934
  }
776
935
 
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);
936
+ function boundsForOptions(options, now = new Date()) {
937
+ if (options.view === "trend") {
938
+ return multiDayBounds(options.date, options.timeZone, options.trendDays);
939
+ }
940
+ if (options.range === "week") {
941
+ return weekBounds(options.date, options.timeZone);
942
+ }
943
+ if (options.range === "rolling24h") {
944
+ return rolling24hBounds(now, options.timeZone);
945
+ }
946
+ return dayBounds(options.date, options.timeZone);
947
+ }
948
+
949
+ function rangeDescription(options, bounds) {
950
+ if (options.range === "rolling24h") return "the last 24 hours";
951
+ if (bounds.startDateString && bounds.endDateString) {
952
+ return `${bounds.startDateString} through ${bounds.endDateString}`;
953
+ }
954
+ return bounds.dateString;
955
+ }
956
+
957
+ export async function run(options, { nowMs } = {}) {
958
+ const hasInjectedNow = nowMs !== undefined;
959
+ const now = new Date(hasInjectedNow ? nowMs : Date.now());
960
+ const bounds = boundsForOptions(options, now);
783
961
  const snapshot = await loadSnapshot(options);
784
962
  const events = filterDayEvents(snapshot, bounds);
785
963
  if (events.length === 0) {
786
- const rangeDescription = options.range === "week"
787
- ? `${bounds.startDateString} through ${bounds.endDateString}`
788
- : bounds.dateString;
789
964
  return [
790
- `No model-call events found for ${rangeDescription} (${bounds.timeZone}).`,
965
+ `No model-call events found for ${rangeDescription(options, bounds)} (${bounds.timeZone}).`,
791
966
  `Source: ${sourceLabel(options.input, snapshot)}`,
792
967
  ].join("\n");
793
968
  }
@@ -805,7 +980,18 @@ export async function run(options) {
805
980
  if (writingImage) {
806
981
  process.stderr.write(`Token Ledger: generating ${imageLabel} PNG…\n`);
807
982
  }
808
- const output = render(options, snapshot, bounds, events, rows, allRows);
983
+ const output = render(
984
+ options,
985
+ snapshot,
986
+ bounds,
987
+ events,
988
+ rows,
989
+ allRows,
990
+ snapshotFreshness(
991
+ snapshot,
992
+ hasInjectedNow ? now.getTime() : Date.now(),
993
+ ),
994
+ );
809
995
  if (writingImage) {
810
996
  await mkdir(dirname(outputPath), { recursive: true });
811
997
  process.stderr.write(`Token Ledger: encoding ${imageLabel} PNG…\n`);
@@ -832,17 +1018,12 @@ function shouldUseInteractive(options) {
832
1018
  }
833
1019
 
834
1020
  async function runInteractive(options) {
835
- const bounds = options.range === "week"
836
- ? weekBounds(options.date, options.timeZone)
837
- : dayBounds(options.date, options.timeZone);
1021
+ const bounds = boundsForOptions(options);
838
1022
  const snapshot = await loadSnapshot(options);
839
1023
  const events = filterDayEvents(snapshot, bounds);
840
1024
  if (events.length === 0) {
841
- const rangeDescription = options.range === "week"
842
- ? `${bounds.startDateString} through ${bounds.endDateString}`
843
- : bounds.dateString;
844
1025
  process.stdout.write([
845
- `No model-call events found for ${rangeDescription} (${bounds.timeZone}).`,
1026
+ `No model-call events found for ${rangeDescription(options, bounds)} (${bounds.timeZone}).`,
846
1027
  `Source: ${sourceLabel(options.input, snapshot)}`,
847
1028
  "",
848
1029
  ].join("\n"));
@@ -852,6 +1033,7 @@ async function runInteractive(options) {
852
1033
  await startInteractive({
853
1034
  options,
854
1035
  snapshot,
1036
+ snapshotFreshness: snapshotFreshness(snapshot),
855
1037
  bounds,
856
1038
  events,
857
1039
  rows: allRows.slice(0, options.top),
@@ -873,7 +1055,13 @@ async function main() {
873
1055
  process.stdout.write(`${await run(options)}\n`);
874
1056
  }
875
1057
  } catch (error) {
876
- process.stderr.write(`Token Ledger CLI failed: ${error.message}\n\n${usage()}\n`);
1058
+ process.stderr.write(
1059
+ `Token Ledger CLI failed: ${safeErrorMessage(error, [
1060
+ options?.input,
1061
+ options?.codexHome,
1062
+ options?.imageOutput,
1063
+ ])}\n\n${usage()}\n`,
1064
+ );
877
1065
  process.exitCode = 1;
878
1066
  }
879
1067
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tledger",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "A local-only terminal dashboard for Codex token usage",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -40,7 +40,8 @@
40
40
  "usage:snapshot": "node lib/token-ledger-importer.mjs --output outputs/token-ledger-snapshot.json",
41
41
  "usage:day": "node bin/token-ledger.mjs day",
42
42
  "usage:week": "node bin/token-ledger.mjs week",
43
- "prepublishOnly": "npm test"
43
+ "verify:release": "node tools/verify-release.mjs",
44
+ "prepublishOnly": "npm test && npm run verify:release"
44
45
  },
45
46
  "devDependencies": {
46
47
  "eslint": "9.39.4"