tledger 0.1.2 → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jskoiz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,21 +1,36 @@
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.
8
+
9
+ Token Ledger reads Codex history stored on the computer where it runs. It does
10
+ not sign in, fetch account-wide usage, or combine activity from other machines.
7
11
 
8
12
  ![Token Ledger running with synthetic demo data](https://raw.githubusercontent.com/jskoiz/token-ledger/main/docs/token-ledger-demo.svg)
9
13
 
10
14
  _The screenshot is generated from an intentionally synthetic fixture._
11
15
 
16
+ ## Requirements
17
+
18
+ Token Ledger requires Node.js 22.13 or newer and npm. Install a supported LTS
19
+ release from [nodejs.org](https://nodejs.org/en/download) if either command is
20
+ missing, then confirm the versions in a new terminal:
21
+
22
+ ```bash
23
+ node --version
24
+ npm --version
25
+ ```
26
+
12
27
  ## Install
13
28
 
14
- Token Ledger requires Node.js 22.13 or newer and has no runtime npm
15
- dependencies.
29
+ Token Ledger has no runtime npm dependencies and runs no installation script.
16
30
 
17
31
  ```bash
18
32
  npm install --global tledger
33
+ tledger --version
19
34
  ```
20
35
 
21
36
  ## Use
@@ -26,11 +41,19 @@ tledger week # same default, stated explicitly
26
41
  tledger day # today
27
42
  tledger day yesterday
28
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
29
48
  ```
30
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
+
31
53
  The local timezone and top 10 projects are selected automatically. The default
32
54
  view is interactive in a terminal; use arrow keys or `j`/`k` to move and `q` or
33
- Escape to exit.
55
+ Escape to exit. If the selected period is empty, Token Ledger reports the most
56
+ recent local activity date and prints the exact command for opening it.
34
57
 
35
58
  Useful options:
36
59
 
@@ -43,14 +66,31 @@ Useful options:
43
66
  --codex-home <dir> Read another Codex data directory
44
67
  --no-archived Skip archived sessions during collection
45
68
  --raw-projects Keep singleton project labels separate
69
+ -anon Show Project 1, Project 2, etc. instead of project names
46
70
  --static Print once instead of opening the interactive view
47
71
  --plain Print once without ANSI color
48
72
  --ascii Use ASCII bars
49
73
  --width <40-200> Set the static layout width
50
74
  --date <day> Set today, yesterday, or YYYY-MM-DD
75
+ -v, --version Show the installed version
51
76
  --help Show complete CLI help
52
77
  ```
53
78
 
79
+ ## Update or uninstall
80
+
81
+ ```bash
82
+ npm install --global tledger@latest
83
+ tledger --version
84
+ npm uninstall --global tledger
85
+ ```
86
+
87
+ Uninstalling the npm package leaves the privacy-reduced cache at
88
+ `~/.token-ledger/token-ledger-snapshot.json`. Remove that directory separately
89
+ only when you also want the next installation to perform a completely fresh
90
+ scan.
91
+
92
+ ## Local data and privacy
93
+
54
94
  By default, Token Ledger reads `CODEX_HOME` or `~/.codex` and keeps a
55
95
  privacy-reduced cache at `~/.token-ledger/token-ledger-snapshot.json`. It checks
56
96
  source freshness automatically. Fresh scans use a bounded pool of up to four
@@ -58,13 +98,18 @@ workers; unchanged runs read the existing cache. Token Ledger makes no network
58
98
  requests and excludes
59
99
  message bodies, reasoning text, tool payloads, credentials, and full local
60
100
  paths from the cache. Project labels can still reveal local context, so keep
61
- snapshots private unless you have reviewed them. Reset-cycle burn
62
- 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.
63
104
 
64
105
  ## Develop
65
106
 
66
107
  ```bash
67
108
  npm test
68
109
  npm run demo
69
- npm pack --dry-run
110
+ npm run verify:release
70
111
  ```
112
+
113
+ ## License
114
+
115
+ [MIT](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) {
@@ -382,6 +511,45 @@ function sourceLabel(snapshotPath, snapshot) {
382
511
  return `${cleanLabel(basename(snapshotPath), "snapshot")} · captured ${generated}`;
383
512
  }
384
513
 
514
+ function latestActivityDateString(snapshot, timeZone) {
515
+ let latestTimestamp = Number.NEGATIVE_INFINITY;
516
+ for (const event of snapshot.events ?? []) {
517
+ const timestamp = eventTimestamp(event);
518
+ if (Number.isFinite(timestamp) && timestamp > latestTimestamp) {
519
+ latestTimestamp = timestamp;
520
+ }
521
+ }
522
+ if (!Number.isFinite(latestTimestamp)) return null;
523
+ return dateStringFromParts(numericDateParts({
524
+ value: new Date(latestTimestamp),
525
+ timeZone,
526
+ }));
527
+ }
528
+
529
+ function displayCalendarDate(dateString) {
530
+ const [year, month, day] = dateString.split("-").map(Number);
531
+ return new Intl.DateTimeFormat("en-US", {
532
+ month: "long",
533
+ day: "numeric",
534
+ year: "numeric",
535
+ timeZone: "UTC",
536
+ }).format(new Date(Date.UTC(year, month - 1, day)));
537
+ }
538
+
539
+ function emptyState(options, snapshot, bounds) {
540
+ const lines = [
541
+ `No model-call events found for ${describeRange(options, bounds)} (${bounds.timeZone}).`,
542
+ "Token Ledger reads only Codex history stored on this computer.",
543
+ ];
544
+ const latestDate = latestActivityDateString(snapshot, bounds.timeZone);
545
+ if (latestDate) {
546
+ lines.push(`Latest local activity: ${displayCalendarDate(latestDate)}.`);
547
+ lines.push(`Try: tledger ${options.range} ${latestDate}`);
548
+ }
549
+ lines.push(`Source: ${sourceLabel(options.input, snapshot)}`);
550
+ return lines.join("\n");
551
+ }
552
+
385
553
  async function readSnapshot(snapshotPath) {
386
554
  let parsed;
387
555
  try {
@@ -483,19 +651,12 @@ function render(options, snapshot, bounds, events, rows, allRows) {
483
651
  }
484
652
 
485
653
  export async function run(options) {
486
- const bounds = options.range === "week"
487
- ? weekBounds(options.date, options.timeZone)
488
- : dayBounds(options.date, options.timeZone);
654
+ if (options.range !== "all") boundsForOptions(options);
489
655
  const snapshot = await loadSnapshot(options);
656
+ const bounds = boundsForOptions(options, snapshot);
490
657
  const events = filterDayEvents(snapshot, bounds);
491
658
  if (events.length === 0) {
492
- const rangeDescription = options.range === "week"
493
- ? `${bounds.startDateString} through ${bounds.endDateString}`
494
- : bounds.dateString;
495
- return [
496
- `No model-call events found for ${rangeDescription} (${bounds.timeZone}).`,
497
- `Source: ${sourceLabel(options.input, snapshot)}`,
498
- ].join("\n");
659
+ return emptyState(options, snapshot, bounds);
499
660
  }
500
661
  const allRows = aggregateProjects(snapshot, events, options);
501
662
  const rows = allRows.slice(0, options.top);
@@ -513,20 +674,12 @@ function shouldUseInteractive(options) {
513
674
  }
514
675
 
515
676
  async function runInteractive(options) {
516
- const bounds = options.range === "week"
517
- ? weekBounds(options.date, options.timeZone)
518
- : dayBounds(options.date, options.timeZone);
677
+ if (options.range !== "all") boundsForOptions(options);
519
678
  const snapshot = await loadSnapshot(options);
679
+ const bounds = boundsForOptions(options, snapshot);
520
680
  const events = filterDayEvents(snapshot, bounds);
521
681
  if (events.length === 0) {
522
- const rangeDescription = options.range === "week"
523
- ? `${bounds.startDateString} through ${bounds.endDateString}`
524
- : bounds.dateString;
525
- process.stdout.write([
526
- `No model-call events found for ${rangeDescription} (${bounds.timeZone}).`,
527
- `Source: ${sourceLabel(options.input, snapshot)}`,
528
- "",
529
- ].join("\n"));
682
+ process.stdout.write(`${emptyState(options, snapshot, bounds)}\n`);
530
683
  return;
531
684
  }
532
685
  const allRows = aggregateProjects(snapshot, events, options);
@@ -548,6 +701,10 @@ async function main() {
548
701
  process.stdout.write(`${usage()}\n`);
549
702
  return;
550
703
  }
704
+ if (options.version) {
705
+ process.stdout.write(`${VERSION}\n`);
706
+ return;
707
+ }
551
708
  if (shouldUseInteractive(options)) {
552
709
  await runInteractive(options);
553
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,7 +1,8 @@
1
1
  {
2
2
  "name": "tledger",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "A local-only terminal dashboard for Codex token usage",
5
+ "license": "MIT",
5
6
  "keywords": [
6
7
  "codex",
7
8
  "cli",
@@ -24,6 +25,7 @@
24
25
  "files": [
25
26
  "bin/",
26
27
  "lib/",
28
+ "LICENSE",
27
29
  "README.md"
28
30
  ],
29
31
  "bin": {
@@ -34,7 +36,10 @@
34
36
  "test:cli": "node --test tests/token-ledger-cli.test.mjs",
35
37
  "test:collector": "node --test tests/token-ledger-collector.test.mjs",
36
38
  "demo": "node scripts/generate-demo-screenshot.mjs",
37
- "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"
38
43
  },
39
44
  "publishConfig": {
40
45
  "access": "public"