tledger 0.1.3 → 0.1.4

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
@@ -1,9 +1,10 @@
1
1
  # Token Ledger
2
2
 
3
3
  Token Ledger is a lightweight, local-only terminal dashboard for Codex token
4
- usage. It ranks projects, shows model and cache mix, and adds reset-cycle
5
- context without sending usage data anywhere. Codex Auto Review is shown
6
- separately with its token total, distinct-turn share, and cached-input share.
4
+ usage. It ranks projects, names every active model—including Daybreak Blue—and
5
+ shows cache and reset-cycle context without sending usage data anywhere. Codex
6
+ Auto Review is shown separately with its token total, distinct-turn share, and
7
+ cached-input share.
7
8
 
8
9
  Token Ledger reads Codex history stored on the computer where it runs. It does
9
10
  not sign in, fetch account-wide usage, or combine activity from other machines.
@@ -29,7 +30,7 @@ Token Ledger has no runtime npm dependencies and runs no installation script.
29
30
 
30
31
  ```bash
31
32
  npm install --global tledger
32
- tledger --help
33
+ tledger --version
33
34
  ```
34
35
 
35
36
  ## Use
@@ -40,8 +41,15 @@ tledger week # same default, stated explicitly
40
41
  tledger day # today
41
42
  tledger day yesterday
42
43
  tledger week 2026-08-05
44
+ tledger month # rolling 30-day window
45
+ tledger 90d # rolling 90-day window
46
+ tledger 90d 2026-08-05 # 90 days ending on a chosen day
47
+ tledger all # every dated event in the local snapshot
43
48
  ```
44
49
 
50
+ `week`, `month`, and custom ranges such as `90d` are inclusive rolling
51
+ calendar-day windows ending today unless an end day is supplied.
52
+
45
53
  The local timezone and top 10 projects are selected automatically. The default
46
54
  view is interactive in a terminal; use arrow keys or `j`/`k` to move and `q` or
47
55
  Escape to exit. If the selected period is empty, Token Ledger reports the most
@@ -58,11 +66,13 @@ Useful options:
58
66
  --codex-home <dir> Read another Codex data directory
59
67
  --no-archived Skip archived sessions during collection
60
68
  --raw-projects Keep singleton project labels separate
69
+ -anon Show Project 1, Project 2, etc. instead of project names
61
70
  --static Print once instead of opening the interactive view
62
71
  --plain Print once without ANSI color
63
72
  --ascii Use ASCII bars
64
73
  --width <40-200> Set the static layout width
65
74
  --date <day> Set today, yesterday, or YYYY-MM-DD
75
+ -v, --version Show the installed version
66
76
  --help Show complete CLI help
67
77
  ```
68
78
 
@@ -70,6 +80,7 @@ Useful options:
70
80
 
71
81
  ```bash
72
82
  npm install --global tledger@latest
83
+ tledger --version
73
84
  npm uninstall --global tledger
74
85
  ```
75
86
 
@@ -87,15 +98,16 @@ workers; unchanged runs read the existing cache. Token Ledger makes no network
87
98
  requests and excludes
88
99
  message bodies, reasoning text, tool payloads, credentials, and full local
89
100
  paths from the cache. Project labels can still reveal local context, so keep
90
- snapshots private unless you have reviewed them. Reset-cycle burn
91
- is an estimate, not official quota or billing data.
101
+ snapshots private unless you have reviewed them. Use `-anon` to hide project
102
+ names in terminal output; it does not rewrite the cached snapshot. Reset-cycle
103
+ burn is an estimate, not official quota or billing data.
92
104
 
93
105
  ## Develop
94
106
 
95
107
  ```bash
96
108
  npm test
97
109
  npm run demo
98
- npm pack --dry-run
110
+ npm run verify:release
99
111
  ```
100
112
 
101
113
  ## License
@@ -1,3 +1,8 @@
1
+ import {
2
+ modelColorKey,
3
+ modelDisplayName,
4
+ } from "../lib/token-ledger-models.mjs";
5
+
1
6
  const RESET = "\u001b[0m";
2
7
  const DIM = [38, 5, 245];
3
8
  const TEAL = [38, 5, 80];
@@ -6,6 +11,7 @@ export const MODEL_COLORS = {
6
11
  luna: [38, 5, 80],
7
12
  terra: [38, 5, 179],
8
13
  gpt: [38, 5, 176],
14
+ daybreakBlue: [38, 5, 69],
9
15
  autoReview: [38, 5, 153],
10
16
  other: [38, 5, 60],
11
17
  };
@@ -95,23 +101,11 @@ function plural(value, singular, pluralForm = `${singular}s`) {
95
101
 
96
102
  function modelLabel(value) {
97
103
  const model = sanitizeText(value) || "Unknown model";
98
- const lower = model.toLowerCase().replaceAll("_", "-");
99
- if (lower === "codex-auto-review" || lower.startsWith("codex-auto-review-")) {
100
- return "Auto Review";
101
- }
102
- if (lower.includes("sol")) return "Sol";
103
- if (lower.includes("luna")) return "Luna";
104
- if (lower.includes("terra")) return "Terra";
105
- if (lower.includes("gpt-5.5") || lower.includes("gpt-5.4")) return "GPT";
106
- return "Other";
104
+ return modelDisplayName(model);
107
105
  }
108
106
 
109
107
  function modelColor(model) {
110
- const key = String(model || "")
111
- .toLowerCase()
112
- .replace(/[\s_-]+/g, "-");
113
- if (key === "auto-review") return MODEL_COLORS.autoReview;
114
- return MODEL_COLORS[key] ?? MODEL_COLORS.other;
108
+ return MODEL_COLORS[modelColorKey(model)] ?? MODEL_COLORS.other;
115
109
  }
116
110
 
117
111
  function usageTypeLabel(value) {
@@ -134,7 +128,7 @@ function displayProject(row) {
134
128
  }
135
129
 
136
130
  function dateLabel(bounds, range = "day") {
137
- if (range === "week" && bounds.startDateString && bounds.endDateString) {
131
+ if (range !== "day" && bounds.rangeDays !== 1 && bounds.startDateString && bounds.endDateString) {
138
132
  const startParts = bounds.startDateString.split("-").map(Number);
139
133
  const endParts = bounds.endDateString.split("-").map(Number);
140
134
  const monthNames = [
@@ -143,7 +137,12 @@ function dateLabel(bounds, range = "day") {
143
137
  ];
144
138
  const start = `${monthNames[startParts[1] - 1]} ${String(startParts[2]).padStart(2, "0")}`;
145
139
  const end = `${monthNames[endParts[1] - 1]} ${String(endParts[2]).padStart(2, "0")}`;
146
- return `${start} ${end} ${endParts[0]}`;
140
+ if (bounds.startDateString === bounds.endDateString) {
141
+ return `${end} ${endParts[0]}`;
142
+ }
143
+ return startParts[0] === endParts[0]
144
+ ? `${start} – ${end} ${endParts[0]}`
145
+ : `${start} ${startParts[0]} – ${end} ${endParts[0]}`;
147
146
  }
148
147
  const parts = new Intl.DateTimeFormat("en-US", {
149
148
  timeZone: bounds.timeZone,
@@ -160,6 +159,13 @@ function dateLabel(bounds, range = "day") {
160
159
  return `${values.weekday} ${values.day} ${values.month} ${values.year}`.toUpperCase();
161
160
  }
162
161
 
162
+ function rangeModeLabel(bounds, range = "day", compact = false) {
163
+ if (range === "all") return "ALL";
164
+ if (range === "day") return "DAY";
165
+ const days = bounds.rangeDays ?? 1;
166
+ return compact ? `${days}D` : `${days} ${days === 1 ? "DAY" : "DAYS"}`;
167
+ }
168
+
163
169
  function modelTotals(events) {
164
170
  const totals = new Map();
165
171
  for (const event of events) {
@@ -340,21 +346,11 @@ function summary(events) {
340
346
  }
341
347
 
342
348
  function modelLegendItems(models, totalTokens) {
343
- const known = new Map();
344
- for (const model of models) {
345
- const key = ["Sol", "Luna", "Terra", "GPT", "Auto Review"].includes(model.model)
346
- ? model.model
347
- : "Other";
348
- known.set(key, (known.get(key) ?? 0) + model.totalTokens);
349
- }
350
- const order = ["Luna", "Sol", "Terra", "GPT"];
351
- if ((known.get("Auto Review") ?? 0) > 0) order.push("Auto Review");
352
- order.push("Other");
353
- return order
349
+ return models
350
+ .filter((model) => model.totalTokens > 0)
354
351
  .map((model) => ({
355
- model,
356
- totalTokens: known.get(model) ?? 0,
357
- share: totalTokens > 0 ? ((known.get(model) ?? 0) / totalTokens) * 100 : 0,
352
+ ...model,
353
+ share: totalTokens > 0 ? (model.totalTokens / totalTokens) * 100 : 0,
358
354
  }));
359
355
  }
360
356
 
@@ -430,6 +426,7 @@ function panelLines(rows, allRows, totalTokens, panelWidth, options, enabled) {
430
426
  panelWidth - labelWidth - shareWidth - totalWidth - 1 - rightPadding,
431
427
  );
432
428
  const maxTokens = allRows[0]?.totalTokens ?? 0;
429
+ const rowOffset = Number.isInteger(options.rowOffset) ? options.rowOffset : 0;
433
430
  const lines = [];
434
431
  const heading = colorize("TOKENS BY PROJECT", TEAL, enabled);
435
432
  const barHeaderWidth = barWidth;
@@ -444,13 +441,14 @@ function panelLines(rows, allRows, totalTokens, panelWidth, options, enabled) {
444
441
  lines.push(colorize("─".repeat(panelWidth), DIM, enabled));
445
442
 
446
443
  for (const [index, row] of rows.entries()) {
444
+ const rankValue = rowOffset + index + 1;
447
445
  const share = totalTokens > 0 ? (row.totalTokens / totalTokens) * 100 : 0;
448
446
  const selected = !options.static && index === (options.selectedIndex ?? 0);
449
447
  const caretValue = selected ? (options.ascii ? ">" : "▶") : " ";
450
- const prefix = `${caretValue} ${index + 1}. `;
448
+ const prefix = `${caretValue} ${rankValue}. `;
451
449
  const projectText = truncateText(displayProject(row), labelWidth - prefix.length);
452
450
  const caret = selected ? colorize(caretValue, [1, ...TEAL], enabled) : caretValue;
453
- const rank = colorize(`${index + 1}.`, TITLE_STYLE, enabled);
451
+ const rank = colorize(`${rankValue}.`, TITLE_STYLE, enabled);
454
452
  const title = `${caret} ${rank} ${colorize(projectText, TITLE_STYLE, enabled)}`;
455
453
  const label = fit(title, labelWidth);
456
454
  const metrics = `${fit(compact(row.totalTokens), totalWidth, "right")}${fit(percent(share), shareWidth, "right")}${" ".repeat(rightPadding)}`;
@@ -483,15 +481,26 @@ function sidebarLines(stats, panelWidth, enabled, options = {}, quota = null) {
483
481
  const heading = (value) => colorize(value, TEAL, enabled);
484
482
  push(heading("MODEL MIX"));
485
483
  if (!compactSidebar) push();
486
- for (const item of modelLegendItems(stats.models, stats.totalTokens)) {
484
+ const modelShareWidth = 6;
485
+ const modelNameWidth = Math.max(1, contentWidth - modelShareWidth - 2);
486
+ const modelItems = modelLegendItems(stats.models, stats.totalTokens);
487
+ const modelLimit = Number.isInteger(options.modelLimit)
488
+ ? Math.max(0, options.modelLimit)
489
+ : modelItems.length;
490
+ const visibleModelItems = modelItems.slice(0, modelLimit);
491
+ for (const item of visibleModelItems) {
487
492
  const swatch = colorize("■", modelColor(item.model), enabled);
488
- push(`${swatch} ${fit(item.model, Math.max(1, contentWidth - 10))}${fit(percent(item.share), 8, "right")}`);
493
+ push(`${swatch} ${fit(item.model, modelNameWidth)}${fit(percent(item.share), modelShareWidth, "right")}`);
489
494
  if (item.model === "Auto Review" && stats.autoReview.present) {
490
495
  const turnLabel = stats.autoReview.turns === 1 ? "turn" : "turns";
491
496
  push(` ${compact(stats.autoReview.turns)} ${turnLabel} · ${percent(stats.autoReview.turnShare)}`);
492
497
  push(` ${compact(stats.autoReview.totalTokens)} · ${percent(stats.autoReview.cachedInputShare)} cached`);
493
498
  }
494
499
  }
500
+ const hiddenModelCount = modelItems.length - visibleModelItems.length;
501
+ if (hiddenModelCount > 0) {
502
+ push(colorize(`… ${plural(hiddenModelCount, "more model")}`, DIM, enabled));
503
+ }
495
504
  if (!compactSidebar) push();
496
505
  divider();
497
506
  push(heading("USAGE TYPE · TOKENS"));
@@ -554,7 +563,7 @@ function panel(leftLines, rightLines, leftWidth, rightWidth, enabled, ascii) {
554
563
  function headerLines(stats, bounds, frameWidth, options, enabled) {
555
564
  const left = colorize("TOKEN LEDGER", TITLE_STYLE, enabled);
556
565
  const date = colorize(dateLabel(bounds, options.range), TEXT_STYLE, enabled);
557
- const mode = colorize(options.range === "week" ? "7 DAYS" : "DAY", [1, ...TEAL], enabled);
566
+ const mode = colorize(rangeModeLabel(bounds, options.range), [1, ...TEAL], enabled);
558
567
  const metric = (value, label) =>
559
568
  `${colorize(String(value), TITLE_STYLE, enabled)} ${colorize(label, DIM, enabled)}`;
560
569
  const separator = colorize("·", DIM, enabled);
@@ -573,10 +582,14 @@ function headerLines(stats, bounds, frameWidth, options, enabled) {
573
582
  return [alignHeader(fullLine)];
574
583
  }
575
584
 
576
- const compactDate = dateLabel(bounds, options.range)
577
- .replace(/ 20\d{2}$/, "")
578
- .replace(" ", "–");
579
- const compactMode = options.range === "week" ? "7D" : "DAY";
585
+ const crossesCalendarYear =
586
+ bounds.startDateString?.slice(0, 4) !== bounds.endDateString?.slice(0, 4);
587
+ const compactDate = (
588
+ crossesCalendarYear
589
+ ? dateLabel(bounds, options.range)
590
+ : dateLabel(bounds, options.range).replace(/ 20\d{2}$/, "")
591
+ ).replace(" – ", "–");
592
+ const compactMode = rangeModeLabel(bounds, options.range, true);
580
593
  const compactLine = [
581
594
  left,
582
595
  colorize(compactDate, TEXT_STYLE, enabled),
@@ -653,30 +666,84 @@ export function renderFullscreen({ options, snapshot, bounds, events, rows, allR
653
666
  const columns = Math.max(40, Number(width) || Number(process.stdout.columns) || 120);
654
667
  const screenHeight = Math.max(1, Number(height) || Number(process.stdout.rows) || 32);
655
668
  const frameWidth = Math.max(38, Math.min(158, columns - 4));
669
+ const forceSideBySide = frameWidth >= 84;
670
+ const staticBudget = Math.max(0, screenHeight - 1);
671
+ let visibleRows = rows;
672
+ let fullscreenOptions = {
673
+ ...options,
674
+ forceColor: enabled,
675
+ forceSideBySide,
676
+ highlight: false,
677
+ compactSidebar: true,
678
+ hideHelp: true,
679
+ width: frameWidth + 2,
680
+ };
681
+
682
+ if (forceSideBySide && staticBudget >= 3) {
683
+ const stats = summary(events);
684
+ const quota = quotaCycleSummary(snapshot, events);
685
+ const panelContentBudget = Math.max(1, staticBudget - 3);
686
+ const visibleRowCount = Math.min(
687
+ rows.length,
688
+ Math.max(1, Math.floor((panelContentBudget - 2) / 2)),
689
+ );
690
+ const selectedIndex = Math.max(
691
+ 0,
692
+ Math.min(rows.length - 1, Number(options.selectedIndex) || 0),
693
+ );
694
+ const rowOffset = Math.min(
695
+ Math.max(0, selectedIndex - visibleRowCount + 1),
696
+ Math.max(0, rows.length - visibleRowCount),
697
+ );
698
+ visibleRows = rows.slice(rowOffset, rowOffset + visibleRowCount);
699
+
700
+ const sideWidth = stats.autoReview.present ? 28 : 26;
701
+ let modelLimit = modelLegendItems(stats.models, stats.totalTokens).length;
702
+ while (
703
+ modelLimit > 0 &&
704
+ sidebarLines(
705
+ stats,
706
+ sideWidth,
707
+ enabled,
708
+ { ...fullscreenOptions, modelLimit },
709
+ quota,
710
+ ).length > panelContentBudget
711
+ ) {
712
+ modelLimit -= 1;
713
+ }
714
+
715
+ fullscreenOptions = {
716
+ ...fullscreenOptions,
717
+ modelLimit,
718
+ rowOffset,
719
+ selectedIndex: selectedIndex - rowOffset,
720
+ };
721
+ }
722
+
656
723
  const staticOutput = renderTerminal({
657
- options: {
658
- ...options,
659
- forceColor: enabled,
660
- forceSideBySide: frameWidth >= 84,
661
- highlight: false,
662
- compactSidebar: true,
663
- hideHelp: true,
664
- width: frameWidth + 2,
665
- },
724
+ options: fullscreenOptions,
666
725
  snapshot,
667
726
  bounds,
668
727
  events,
669
- rows,
728
+ rows: visibleRows,
670
729
  allRows,
671
730
  });
672
- const staticLines = staticOutput.split("\n");
731
+ let staticLines = staticOutput.split("\n");
673
732
  if (staticLines.at(-1) === "") staticLines.pop();
674
- const summaryLine = staticLines.shift() ?? "";
675
733
  const help = colorize("↑/↓ move • j/k move • q/esc quit", DIM, enabled);
734
+ const includeSpacer = staticLines.length + 2 <= screenHeight;
735
+ const availableStaticLines = Math.max(
736
+ 0,
737
+ screenHeight - 1 - (includeSpacer ? 1 : 0),
738
+ );
739
+ staticLines = staticLines.slice(0, availableStaticLines);
740
+ const summaryLine = staticLines.shift();
676
741
  const content = [
677
- { line: summaryLine, background: SCREEN_BASE },
742
+ ...(summaryLine === undefined
743
+ ? []
744
+ : [{ line: summaryLine, background: SCREEN_BASE }]),
678
745
  ...staticLines.map((line) => ({ line, background: PANEL_BASE })),
679
- { line: "", background: SCREEN_BASE },
746
+ ...(includeSpacer ? [{ line: "", background: SCREEN_BASE }] : []),
680
747
  { line: help, background: SCREEN_BASE },
681
748
  ];
682
749
  const topPadding = Math.max(0, Math.floor((screenHeight - content.length) / 2));
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { existsSync, realpathSync } from "node:fs";
4
+ import { createRequire } from "node:module";
4
5
  import {
5
6
  readFile,
6
7
  stat,
@@ -11,6 +12,10 @@ import { fileURLToPath } from "node:url";
11
12
 
12
13
  import { renderTerminal } from "./token-ledger-terminal.mjs";
13
14
  import { startInteractive } from "./token-ledger-tui.mjs";
15
+ import { modelDisplayName } from "../lib/token-ledger-models.mjs";
16
+
17
+ const require = createRequire(import.meta.url);
18
+ export const VERSION = require("../package.json").version;
14
19
 
15
20
  export const DEFAULT_SNAPSHOT = resolve(
16
21
  homedir(),
@@ -18,6 +23,7 @@ export const DEFAULT_SNAPSHOT = resolve(
18
23
  "token-ledger-snapshot.json",
19
24
  );
20
25
  const DEFAULT_TOP = 10;
26
+ const MAX_RANGE_DAYS = 100_000;
21
27
  const DEFAULT_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
22
28
 
23
29
  function usage() {
@@ -27,6 +33,16 @@ Usage:
27
33
  tledger
28
34
  tledger week [end-day]
29
35
  tledger day [day]
36
+ tledger month [end-day]
37
+ tledger <number>d [end-day]
38
+ tledger all
39
+
40
+ Ranges:
41
+ day One calendar day
42
+ week 7 days ending on end-day (default: today)
43
+ month 30 days ending on end-day (default: today)
44
+ <number>d That many days ending on end-day, for example 90d
45
+ all Every dated event in the snapshot
30
46
 
31
47
  Options:
32
48
  --date <day> Date as YYYY-MM-DD, today, or yesterday
@@ -38,10 +54,12 @@ Options:
38
54
  --top <number> Number of projects to show (default: 10)
39
55
  --width <number> Terminal layout width in columns
40
56
  --raw-projects Keep singleton thread labels instead of grouping them
57
+ -anon Replace project names with Project 1, Project 2, and so on
41
58
  --no-archived Skip archived_sessions when refreshing
42
59
  --plain Disable terminal colors
43
60
  --ascii Use ASCII bars instead of Unicode blocks
44
61
  --static Print once instead of opening the interactive dashboard
62
+ -v, --version Show the installed version
45
63
  --help Show this help
46
64
 
47
65
  The default view is the seven-day window ending today. Token Ledger never
@@ -56,14 +74,38 @@ function readOption(argv, index, name) {
56
74
  return value;
57
75
  }
58
76
 
77
+ function rangeSpec(value) {
78
+ if (value === "day") return { range: "day", rangeDays: 1 };
79
+ if (value === "week") return { range: "week", rangeDays: 7 };
80
+ if (value === "month") return { range: "month", rangeDays: 30 };
81
+ if (value === "all") return { range: "all", rangeDays: null };
82
+ const custom = /^(\d+)d$/.exec(value ?? "");
83
+ if (!custom) return null;
84
+ const rangeDays = Number(custom[1]);
85
+ if (
86
+ !Number.isSafeInteger(rangeDays) ||
87
+ rangeDays < 1 ||
88
+ rangeDays > MAX_RANGE_DAYS
89
+ ) {
90
+ throw new Error(
91
+ `Day range must be an integer from 1 to ${MAX_RANGE_DAYS.toLocaleString("en-US")}, for example 90d.`,
92
+ );
93
+ }
94
+ return { range: `${rangeDays}d`, rangeDays };
95
+ }
96
+
59
97
  export function parseArgs(argv) {
60
- const commandExplicit = argv[0] === "day" || argv[0] === "week";
98
+ const requestedRange = rangeSpec(argv[0]);
99
+ const commandExplicit = Boolean(requestedRange);
61
100
  if (argv[0] && !argv[0].startsWith("-") && !commandExplicit) {
62
- throw new Error(`Unknown command: ${argv[0]}. Use day or week.`);
101
+ throw new Error(
102
+ `Unknown command: ${argv[0]}. Use day, week, month, all, or a duration like 90d.`,
103
+ );
63
104
  }
64
- const command = commandExplicit ? argv[0] : "week";
105
+ const command = requestedRange ?? { range: "week", rangeDays: 7 };
65
106
  const options = {
66
- range: command,
107
+ range: command.range,
108
+ rangeDays: command.rangeDays,
67
109
  date: null,
68
110
  input: DEFAULT_SNAPSHOT,
69
111
  inputExplicit: false,
@@ -75,9 +117,11 @@ export function parseArgs(argv) {
75
117
  top: DEFAULT_TOP,
76
118
  width: null,
77
119
  rawProjects: false,
120
+ anonymizeProjects: false,
78
121
  plain: false,
79
122
  ascii: false,
80
123
  static: false,
124
+ version: false,
81
125
  help: false,
82
126
  };
83
127
 
@@ -86,6 +130,8 @@ export function parseArgs(argv) {
86
130
  const argument = argv[index];
87
131
  if (argument === "--help" || argument === "-h") {
88
132
  options.help = true;
133
+ } else if (argument === "--version" || argument === "-v") {
134
+ options.version = true;
89
135
  } else if (argument === "--date") {
90
136
  options.date = readOption(argv, index, "--date");
91
137
  index += 1;
@@ -119,6 +165,8 @@ export function parseArgs(argv) {
119
165
  index += 1;
120
166
  } else if (argument === "--raw-projects") {
121
167
  options.rawProjects = true;
168
+ } else if (argument === "-anon") {
169
+ options.anonymizeProjects = true;
122
170
  } else if (argument === "--no-archived") {
123
171
  options.includeArchived = false;
124
172
  } else if (argument === "--plain") {
@@ -134,13 +182,16 @@ export function parseArgs(argv) {
134
182
  }
135
183
  }
136
184
 
137
- if (!options.help && !options.date) {
185
+ if (!options.help && !options.version && options.range === "all" && options.date) {
186
+ throw new Error("The all range does not accept an end day.");
187
+ }
188
+ if (!options.help && !options.version && !options.date && options.range !== "all") {
138
189
  options.date = "today";
139
190
  }
140
- if (!options.help && options.refresh && !options.autoRefresh) {
191
+ if (!options.help && !options.version && options.refresh && !options.autoRefresh) {
141
192
  throw new Error("--refresh cannot be combined with --no-refresh.");
142
193
  }
143
- if (!options.help && options.refresh && options.inputExplicit) {
194
+ if (!options.help && !options.version && options.refresh && options.inputExplicit) {
144
195
  throw new Error("--refresh cannot be combined with --input.");
145
196
  }
146
197
  return options;
@@ -243,21 +294,99 @@ export function dayBounds(value, timeZone) {
243
294
  const nextDateString = shiftCalendarDate(dateString, 1);
244
295
  const start = zonedMidnight(dateString, timeZone);
245
296
  const end = zonedMidnight(nextDateString, timeZone);
246
- return { dateString, start, end, timeZone };
297
+ return {
298
+ dateString,
299
+ startDateString: dateString,
300
+ endDateString: dateString,
301
+ start,
302
+ end,
303
+ timeZone,
304
+ rangeDays: 1,
305
+ };
247
306
  }
248
307
 
249
- export function weekBounds(value, timeZone) {
308
+ export function rollingBounds(value, timeZone, rangeDays) {
309
+ if (
310
+ !Number.isSafeInteger(rangeDays) ||
311
+ rangeDays < 1 ||
312
+ rangeDays > MAX_RANGE_DAYS
313
+ ) {
314
+ throw new Error(
315
+ `Range days must be an integer from 1 to ${MAX_RANGE_DAYS.toLocaleString("en-US")}.`,
316
+ );
317
+ }
250
318
  const endDay = dayBounds(value, timeZone);
251
- const startDateString = shiftCalendarDate(endDay.dateString, -6);
319
+ const startDateString = shiftCalendarDate(endDay.dateString, -(rangeDays - 1));
252
320
  return {
253
321
  ...endDay,
254
322
  startDateString,
255
323
  endDateString: endDay.dateString,
256
324
  start: zonedMidnight(startDateString, timeZone),
257
- rangeDays: 7,
325
+ rangeDays,
258
326
  };
259
327
  }
260
328
 
329
+ export function weekBounds(value, timeZone) {
330
+ return rollingBounds(value, timeZone, 7);
331
+ }
332
+
333
+ export function monthBounds(value, timeZone) {
334
+ return rollingBounds(value, timeZone, 30);
335
+ }
336
+
337
+ function eventTimestamp(event) {
338
+ if (typeof event?.timestamp !== "string" || !event.timestamp.trim()) {
339
+ return Number.NaN;
340
+ }
341
+ return new Date(event.timestamp).getTime();
342
+ }
343
+
344
+ export function allBounds(snapshot, timeZone) {
345
+ validateTimeZone(timeZone);
346
+ let earliest = Number.POSITIVE_INFINITY;
347
+ let latest = Number.NEGATIVE_INFINITY;
348
+ for (const event of snapshot.events ?? []) {
349
+ const timestamp = eventTimestamp(event);
350
+ if (!Number.isFinite(timestamp)) continue;
351
+ earliest = Math.min(earliest, timestamp);
352
+ latest = Math.max(latest, timestamp);
353
+ }
354
+ if (!Number.isFinite(earliest)) {
355
+ return {
356
+ ...dayBounds("today", timeZone),
357
+ rangeDays: null,
358
+ allTime: true,
359
+ };
360
+ }
361
+ const dateString = (timestamp) => dateStringFromParts(numericDateParts({
362
+ value: new Date(timestamp),
363
+ timeZone,
364
+ }));
365
+ const startDateString = dateString(earliest);
366
+ const endDateString = dateString(latest);
367
+ return {
368
+ dateString: endDateString,
369
+ startDateString,
370
+ endDateString,
371
+ start: zonedMidnight(startDateString, timeZone),
372
+ end: zonedMidnight(shiftCalendarDate(endDateString, 1), timeZone),
373
+ timeZone,
374
+ rangeDays: null,
375
+ allTime: true,
376
+ };
377
+ }
378
+
379
+ function boundsForOptions(options, snapshot) {
380
+ if (options.range === "all") return allBounds(snapshot, options.timeZone);
381
+ return rollingBounds(options.date, options.timeZone, options.rangeDays);
382
+ }
383
+
384
+ function describeRange(options, bounds) {
385
+ if (options.range === "all") return "all time";
386
+ if (bounds.rangeDays === 1) return bounds.dateString;
387
+ return `${bounds.startDateString} through ${bounds.endDateString}`;
388
+ }
389
+
261
390
  export function sanitizeTerminalText(value) {
262
391
  return String(value ?? "")
263
392
  .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "")
@@ -298,20 +427,14 @@ export function oneOffProjects(snapshot) {
298
427
 
299
428
  function modelLabel(value) {
300
429
  const model = cleanLabel(value, "Unknown model");
301
- const lower = model.toLowerCase();
302
- if (lower.includes("sol")) return "Sol";
303
- if (lower.includes("luna")) return "Luna";
304
- if (lower.includes("terra")) return "Terra";
305
- if (lower === "gpt-5.5") return "GPT-5.5";
306
- if (lower === "gpt-5.4") return "GPT-5.4";
307
- return model;
430
+ return modelDisplayName(model);
308
431
  }
309
432
 
310
433
  export function filterDayEvents(snapshot, bounds) {
311
434
  const start = bounds.start.getTime();
312
435
  const end = bounds.end.getTime();
313
436
  return (snapshot.events ?? []).filter((event) => {
314
- const timestamp = new Date(event.timestamp).getTime();
437
+ const timestamp = eventTimestamp(event);
315
438
  return Number.isFinite(timestamp) && timestamp >= start && timestamp < end;
316
439
  });
317
440
  }
@@ -369,7 +492,13 @@ export function aggregateProjects(snapshot, events, options = {}) {
369
492
  return right.totalTokens - left.totalTokens;
370
493
  }
371
494
  return left.project.localeCompare(right.project);
372
- });
495
+ })
496
+ .map((row, index) => ({
497
+ ...row,
498
+ displayProject: options.anonymizeProjects
499
+ ? `Project ${index + 1}`
500
+ : row.displayProject,
501
+ }));
373
502
  }
374
503
 
375
504
  function sourceLabel(snapshotPath, snapshot) {
@@ -385,7 +514,7 @@ function sourceLabel(snapshotPath, snapshot) {
385
514
  function latestActivityDateString(snapshot, timeZone) {
386
515
  let latestTimestamp = Number.NEGATIVE_INFINITY;
387
516
  for (const event of snapshot.events ?? []) {
388
- const timestamp = new Date(event.timestamp).getTime();
517
+ const timestamp = eventTimestamp(event);
389
518
  if (Number.isFinite(timestamp) && timestamp > latestTimestamp) {
390
519
  latestTimestamp = timestamp;
391
520
  }
@@ -408,11 +537,8 @@ function displayCalendarDate(dateString) {
408
537
  }
409
538
 
410
539
  function emptyState(options, snapshot, bounds) {
411
- const rangeDescription = options.range === "week"
412
- ? `${bounds.startDateString} through ${bounds.endDateString}`
413
- : bounds.dateString;
414
540
  const lines = [
415
- `No model-call events found for ${rangeDescription} (${bounds.timeZone}).`,
541
+ `No model-call events found for ${describeRange(options, bounds)} (${bounds.timeZone}).`,
416
542
  "Token Ledger reads only Codex history stored on this computer.",
417
543
  ];
418
544
  const latestDate = latestActivityDateString(snapshot, bounds.timeZone);
@@ -525,10 +651,9 @@ function render(options, snapshot, bounds, events, rows, allRows) {
525
651
  }
526
652
 
527
653
  export async function run(options) {
528
- const bounds = options.range === "week"
529
- ? weekBounds(options.date, options.timeZone)
530
- : dayBounds(options.date, options.timeZone);
654
+ if (options.range !== "all") boundsForOptions(options);
531
655
  const snapshot = await loadSnapshot(options);
656
+ const bounds = boundsForOptions(options, snapshot);
532
657
  const events = filterDayEvents(snapshot, bounds);
533
658
  if (events.length === 0) {
534
659
  return emptyState(options, snapshot, bounds);
@@ -549,10 +674,9 @@ function shouldUseInteractive(options) {
549
674
  }
550
675
 
551
676
  async function runInteractive(options) {
552
- const bounds = options.range === "week"
553
- ? weekBounds(options.date, options.timeZone)
554
- : dayBounds(options.date, options.timeZone);
677
+ if (options.range !== "all") boundsForOptions(options);
555
678
  const snapshot = await loadSnapshot(options);
679
+ const bounds = boundsForOptions(options, snapshot);
556
680
  const events = filterDayEvents(snapshot, bounds);
557
681
  if (events.length === 0) {
558
682
  process.stdout.write(`${emptyState(options, snapshot, bounds)}\n`);
@@ -577,6 +701,10 @@ async function main() {
577
701
  process.stdout.write(`${usage()}\n`);
578
702
  return;
579
703
  }
704
+ if (options.version) {
705
+ process.stdout.write(`${VERSION}\n`);
706
+ return;
707
+ }
580
708
  if (shouldUseInteractive(options)) {
581
709
  await runInteractive(options);
582
710
  } else {
@@ -39,6 +39,8 @@ import {
39
39
  workerData,
40
40
  } from "node:worker_threads";
41
41
 
42
+ import { normalizeModelIdentifier } from "./token-ledger-models.mjs";
43
+
42
44
  const SCHEMA_VERSION = 1;
43
45
  const WEEK_MINUTES = 10_080;
44
46
  const DEFAULT_MAX_SCAN_WORKERS = 4;
@@ -230,19 +232,7 @@ function hasDetailedBreakdown(usage) {
230
232
  }
231
233
 
232
234
  function normalizeModel(model) {
233
- const value = sanitizeLabel(model, "unknown")
234
- .toLowerCase()
235
- .replaceAll("_", "-");
236
- if (value.startsWith("gpt-5.6-sol")) return "gpt-5.6-sol";
237
- if (value.startsWith("gpt-5.6-terra")) return "gpt-5.6-terra";
238
- if (value.startsWith("gpt-5.6-luna")) return "gpt-5.6-luna";
239
- if (value.startsWith("gpt-5.5-cyber")) return "gpt-5.5-cyber";
240
- if (value.startsWith("gpt-5.5")) return "gpt-5.5";
241
- if (value.startsWith("gpt-5.4-mini")) return "gpt-5.4-mini";
242
- if (value.startsWith("gpt-5.4")) return "gpt-5.4";
243
- if (value.startsWith("gpt-5.3-codex")) return "gpt-5.3-codex";
244
- if (value.startsWith("gpt-5.2")) return "gpt-5.2";
245
- return value || "unknown";
235
+ return normalizeModelIdentifier(sanitizeLabel(model, "unknown"));
246
236
  }
247
237
 
248
238
  function parseStructuredSource(value) {
@@ -0,0 +1,113 @@
1
+ const MODEL_PRESENTATIONS = [
2
+ {
3
+ canonical: "codex-auto-review",
4
+ families: ["auto-review", "codex-auto-review"],
5
+ label: "Auto Review",
6
+ colorKey: "autoReview",
7
+ },
8
+ {
9
+ canonical: "gpt-daybreak-blue",
10
+ families: ["daybreak-blue", "gpt-daybreak-blue"],
11
+ label: "Daybreak Blue",
12
+ colorKey: "daybreakBlue",
13
+ },
14
+ {
15
+ canonical: "gpt-5.6-sol",
16
+ families: ["sol", "gpt-5.6-sol"],
17
+ label: "Sol",
18
+ colorKey: "sol",
19
+ },
20
+ {
21
+ canonical: "gpt-5.6-luna",
22
+ families: ["luna", "gpt-5.6-luna"],
23
+ label: "Luna",
24
+ colorKey: "luna",
25
+ },
26
+ {
27
+ canonical: "gpt-5.6-terra",
28
+ families: ["terra", "gpt-5.6-terra"],
29
+ label: "Terra",
30
+ colorKey: "terra",
31
+ },
32
+ {
33
+ canonical: "gpt-5.5-cyber",
34
+ families: ["gpt-5.5-cyber"],
35
+ label: "GPT-5.5 Cyber",
36
+ colorKey: "gpt",
37
+ },
38
+ {
39
+ canonical: "gpt-5.5",
40
+ families: ["gpt-5.5"],
41
+ label: "GPT-5.5",
42
+ colorKey: "gpt",
43
+ },
44
+ {
45
+ canonical: "gpt-5.4-mini",
46
+ families: ["gpt-5.4-mini"],
47
+ label: "GPT-5.4 Mini",
48
+ colorKey: "gpt",
49
+ },
50
+ {
51
+ canonical: "gpt-5.4",
52
+ families: ["gpt-5.4"],
53
+ label: "GPT-5.4",
54
+ colorKey: "gpt",
55
+ },
56
+ {
57
+ canonical: "gpt-5.3-codex",
58
+ families: ["gpt-5.3-codex"],
59
+ label: "GPT-5.3 Codex",
60
+ colorKey: "gpt",
61
+ },
62
+ {
63
+ canonical: "gpt-5.2",
64
+ families: ["gpt-5.2"],
65
+ label: "GPT-5.2",
66
+ colorKey: "gpt",
67
+ },
68
+ {
69
+ canonical: "gpt",
70
+ families: ["gpt"],
71
+ label: "GPT",
72
+ colorKey: "gpt",
73
+ exact: true,
74
+ },
75
+ {
76
+ canonical: "unknown",
77
+ families: ["unknown", "unknown-model"],
78
+ label: "Unknown model",
79
+ colorKey: "other",
80
+ exact: true,
81
+ },
82
+ ];
83
+
84
+ function normalizedModel(value) {
85
+ return String(value || "")
86
+ .trim()
87
+ .toLowerCase()
88
+ .replaceAll("_", "-")
89
+ .replace(/\s+/g, "-");
90
+ }
91
+
92
+ function presentationFor(value) {
93
+ const normalized = normalizedModel(value);
94
+ return MODEL_PRESENTATIONS.find((presentation) => presentation.families.some(
95
+ (family) => normalized === family || (
96
+ presentation.exact !== true && normalized.startsWith(`${family}-`)
97
+ ),
98
+ ));
99
+ }
100
+
101
+ export function normalizeModelIdentifier(value) {
102
+ const normalized = normalizedModel(value) || "unknown";
103
+ return presentationFor(normalized)?.canonical || normalized;
104
+ }
105
+
106
+ export function modelDisplayName(value) {
107
+ const model = String(value || "").trim();
108
+ return presentationFor(model)?.label || model || "Unknown model";
109
+ }
110
+
111
+ export function modelColorKey(value) {
112
+ return presentationFor(value)?.colorKey || "other";
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tledger",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "A local-only terminal dashboard for Codex token usage",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -36,7 +36,10 @@
36
36
  "test:cli": "node --test tests/token-ledger-cli.test.mjs",
37
37
  "test:collector": "node --test tests/token-ledger-collector.test.mjs",
38
38
  "demo": "node scripts/generate-demo-screenshot.mjs",
39
- "prepublishOnly": "npm test"
39
+ "demo:check": "node scripts/generate-demo-screenshot.mjs --check",
40
+ "verify:packed": "node scripts/verify-release.mjs",
41
+ "verify:release": "npm test && npm run verify:packed",
42
+ "prepublishOnly": "npm run verify:release"
40
43
  },
41
44
  "publishConfig": {
42
45
  "access": "public"