tledger 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,21 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { spawnSync } from "node:child_process";
3
4
  import { existsSync, realpathSync } from "node:fs";
4
- import { createRequire } from "node:module";
5
5
  import {
6
+ mkdir,
6
7
  readFile,
7
8
  stat,
8
9
  } from "node:fs/promises";
9
10
  import { homedir } from "node:os";
10
- import { basename, resolve } from "node:path";
11
+ import { basename, dirname, resolve } from "node:path";
11
12
  import { fileURLToPath } from "node:url";
12
13
 
13
- import { renderTerminal } from "./token-ledger-terminal.mjs";
14
+ import {
15
+ MODEL_COLORS as TERMINAL_MODEL_COLORS,
16
+ renderTerminal,
17
+ } from "./token-ledger-terminal.mjs";
18
+ import { buildUsageTrend, multiDayBounds } from "./token-ledger-trend.mjs";
19
+ import {
20
+ renderTrendImage,
21
+ writeTrendPng,
22
+ } from "./token-ledger-trend-image.mjs";
23
+ import { renderTrendCombo } from "./token-ledger-trend-terminal.mjs";
14
24
  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;
19
25
 
20
26
  export const DEFAULT_SNAPSHOT = resolve(
21
27
  homedir(),
@@ -23,29 +29,32 @@ export const DEFAULT_SNAPSHOT = resolve(
23
29
  "token-ledger-snapshot.json",
24
30
  );
25
31
  const DEFAULT_TOP = 10;
26
- const MAX_RANGE_DAYS = 100_000;
27
32
  const DEFAULT_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
33
+ export const SNAPSHOT_CACHE_MAX_AGE_MS = 60 * 60 * 1000;
34
+ const ANSI_RESET = "\u001b[0m";
35
+ const MODEL_COLORS = {
36
+ sol: TERMINAL_MODEL_COLORS.sol,
37
+ luna: TERMINAL_MODEL_COLORS.luna,
38
+ terra: TERMINAL_MODEL_COLORS.terra,
39
+ "gpt-5.5": TERMINAL_MODEL_COLORS.gpt,
40
+ "gpt-5.4": TERMINAL_MODEL_COLORS.gpt,
41
+ other: TERMINAL_MODEL_COLORS.other,
42
+ };
28
43
 
29
44
  function usage() {
30
45
  return `Token Ledger terminal usage
31
46
 
32
47
  Usage:
33
- tledger
48
+ tledger day <YYYY-MM-DD>
34
49
  tledger week [end-day]
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
50
+ tledger trend [7d|14d|30d]
51
+ tledger report [7d|14d|30d]
52
+ npm run usage:day -- <YYYY-MM-DD>
53
+ npm run usage:week -- [end-day]
46
54
 
47
55
  Options:
48
56
  --date <day> Date as YYYY-MM-DD, today, or yesterday
57
+ --period <window> Trend window: 7d, 14d, or 30d
49
58
  --input <file> Snapshot to read (default: ~/.token-ledger/token-ledger-snapshot.json)
50
59
  --refresh Rebuild the default snapshot from CODEX_HOME or ~/.codex
51
60
  --no-refresh Use the cached snapshot without checking local JSONL files
@@ -54,16 +63,22 @@ Options:
54
63
  --top <number> Number of projects to show (default: 10)
55
64
  --width <number> Terminal layout width in columns
56
65
  --raw-projects Keep singleton thread labels instead of grouping them
57
- -anon Replace project names with Project 1, Project 2, and so on
58
66
  --no-archived Skip archived_sessions when refreshing
59
67
  --plain Disable terminal colors
60
68
  --ascii Use ASCII bars instead of Unicode blocks
61
69
  --static Print once instead of opening the interactive dashboard
62
- -v, --version Show the installed version
70
+ --drain Trend columns show observed limit drain percent instead of token volume
71
+ --image Write trend view as a PNG image
72
+ --image-output <file> PNG output path for trend view
73
+ --image-width <px> PNG image width from 900 to 2400 pixels
74
+ --youplot Use the legacy single-series YouPlot renderer
63
75
  --help Show this help
64
76
 
65
- The default view is the seven-day window ending today. Token Ledger never
66
- uploads data or renders message bodies, tool payloads, or credentials.`;
77
+ The report command writes the dashboard PNG (same as trend --image) to
78
+ token-ledger-report-<period>.png; use --image-output to choose the path.
79
+
80
+ The command reads a privacy-reduced Token Ledger snapshot. It never uploads
81
+ the snapshot or prints message bodies, tool payloads, credentials, or paths.`;
67
82
  }
68
83
 
69
84
  function readOption(argv, index, name) {
@@ -74,38 +89,17 @@ function readOption(argv, index, name) {
74
89
  return value;
75
90
  }
76
91
 
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
-
97
92
  export function parseArgs(argv) {
98
- const requestedRange = rangeSpec(argv[0]);
99
- const commandExplicit = Boolean(requestedRange);
100
- if (argv[0] && !argv[0].startsWith("-") && !commandExplicit) {
101
- throw new Error(
102
- `Unknown command: ${argv[0]}. Use day, week, month, all, or a duration like 90d.`,
103
- );
104
- }
105
- const command = requestedRange ?? { range: "week", rangeDays: 7 };
93
+ const command = argv[0] === "week"
94
+ ? "week"
95
+ : argv[0] === "trend" || argv[0] === "report"
96
+ ? "trend"
97
+ : "day";
106
98
  const options = {
107
- range: command.range,
108
- rangeDays: command.rangeDays,
99
+ range: command,
100
+ view: command === "trend" ? "trend" : "projects",
101
+ report: argv[0] === "report",
102
+ trendDays: 7,
109
103
  date: null,
110
104
  input: DEFAULT_SNAPSHOT,
111
105
  inputExplicit: false,
@@ -117,24 +111,40 @@ export function parseArgs(argv) {
117
111
  top: DEFAULT_TOP,
118
112
  width: null,
119
113
  rawProjects: false,
120
- anonymizeProjects: false,
121
114
  plain: false,
122
115
  ascii: false,
123
116
  static: false,
124
- version: false,
117
+ image: false,
118
+ imageOutput: null,
119
+ imageWidth: null,
120
+ drain: false,
121
+ legacyPlot: false,
125
122
  help: false,
126
123
  };
127
124
 
128
- let index = commandExplicit ? 1 : 0;
125
+ let trendPeriodSeen = false;
126
+ let index = ["day", "week", "trend", "report"].includes(argv[0]) ? 1 : 0;
129
127
  for (; index < argv.length; index += 1) {
130
128
  const argument = argv[index];
131
129
  if (argument === "--help" || argument === "-h") {
132
130
  options.help = true;
133
- } else if (argument === "--version" || argument === "-v") {
134
- options.version = true;
135
131
  } else if (argument === "--date") {
136
132
  options.date = readOption(argv, index, "--date");
137
133
  index += 1;
134
+ } else if (argument === "--period") {
135
+ if (options.view !== "trend") {
136
+ throw new Error("--period is only available for the trend view.");
137
+ }
138
+ if (trendPeriodSeen) {
139
+ throw new Error("Trend period can only be specified once.");
140
+ }
141
+ const value = readOption(argv, index, "--period");
142
+ if (!["7d", "14d", "30d"].includes(value)) {
143
+ throw new Error("Trend period must be 7d, 14d, or 30d.");
144
+ }
145
+ options.trendDays = Number.parseInt(value, 10);
146
+ trendPeriodSeen = true;
147
+ index += 1;
138
148
  } else if (argument === "--input") {
139
149
  options.input = resolve(readOption(argv, index, "--input"));
140
150
  options.inputExplicit = true;
@@ -165,8 +175,6 @@ export function parseArgs(argv) {
165
175
  index += 1;
166
176
  } else if (argument === "--raw-projects") {
167
177
  options.rawProjects = true;
168
- } else if (argument === "-anon") {
169
- options.anonymizeProjects = true;
170
178
  } else if (argument === "--no-archived") {
171
179
  options.includeArchived = false;
172
180
  } else if (argument === "--plain") {
@@ -175,25 +183,72 @@ export function parseArgs(argv) {
175
183
  options.ascii = true;
176
184
  } else if (argument === "--static") {
177
185
  options.static = true;
178
- } else if (commandExplicit && !argument.startsWith("-") && !options.date) {
186
+ } else if (argument === "--drain") {
187
+ if (options.view !== "trend") {
188
+ throw new Error("--drain is only available for the trend view.");
189
+ }
190
+ options.drain = true;
191
+ } else if (argument === "--image") {
192
+ if (options.view !== "trend") {
193
+ throw new Error("--image is only available for the trend view.");
194
+ }
195
+ options.image = true;
196
+ } else if (argument === "--image-output") {
197
+ if (options.view !== "trend") {
198
+ throw new Error("--image-output is only available for the trend view.");
199
+ }
200
+ const value = readOption(argv, index, "--image-output");
201
+ if (!value.toLowerCase().endsWith(".png")) {
202
+ throw new Error("--image-output must end in .png.");
203
+ }
204
+ options.image = true;
205
+ options.imageOutput = resolve(value);
206
+ index += 1;
207
+ } else if (argument === "--image-width") {
208
+ if (options.view !== "trend") {
209
+ throw new Error("--image-width is only available for the trend view.");
210
+ }
211
+ const value = Number(readOption(argv, index, "--image-width"));
212
+ if (!Number.isInteger(value) || value < 900 || value > 2400) {
213
+ throw new Error("--image-width must be an integer from 900 to 2400.");
214
+ }
215
+ options.image = true;
216
+ options.imageWidth = value;
217
+ index += 1;
218
+ } else if (argument === "--youplot") {
219
+ options.legacyPlot = true;
220
+ } else if (!argument.startsWith("-") && options.view === "trend") {
221
+ if (trendPeriodSeen) {
222
+ throw new Error("Trend period can only be specified once.");
223
+ }
224
+ if (!["7d", "14d", "30d"].includes(argument)) {
225
+ throw new Error("Trend period must be 7d, 14d, or 30d.");
226
+ }
227
+ options.trendDays = Number.parseInt(argument, 10);
228
+ trendPeriodSeen = true;
229
+ } else if (!argument.startsWith("-") && !options.date) {
179
230
  options.date = argument;
180
231
  } else {
181
232
  throw new Error(`Unknown option: ${argument}`);
182
233
  }
183
234
  }
184
235
 
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") {
236
+ if (options.report) options.image = true;
237
+ if (!options.help && !options.date && (options.range === "week" || options.view === "trend")) {
189
238
  options.date = "today";
190
239
  }
191
- if (!options.help && !options.version && options.refresh && !options.autoRefresh) {
240
+ if (!options.help && !options.date) {
241
+ throw new Error("A day is required, for example: tledger day 2026-08-01");
242
+ }
243
+ if (!options.help && options.refresh && !options.autoRefresh) {
192
244
  throw new Error("--refresh cannot be combined with --no-refresh.");
193
245
  }
194
- if (!options.help && !options.version && options.refresh && options.inputExplicit) {
246
+ if (!options.help && options.refresh && options.inputExplicit) {
195
247
  throw new Error("--refresh cannot be combined with --input.");
196
248
  }
249
+ if (!options.help && options.view === "trend" && options.legacyPlot) {
250
+ throw new Error("--youplot is only available for the project view.");
251
+ }
197
252
  return options;
198
253
  }
199
254
 
@@ -294,99 +349,21 @@ export function dayBounds(value, timeZone) {
294
349
  const nextDateString = shiftCalendarDate(dateString, 1);
295
350
  const start = zonedMidnight(dateString, timeZone);
296
351
  const end = zonedMidnight(nextDateString, timeZone);
297
- return {
298
- dateString,
299
- startDateString: dateString,
300
- endDateString: dateString,
301
- start,
302
- end,
303
- timeZone,
304
- rangeDays: 1,
305
- };
352
+ return { dateString, start, end, timeZone };
306
353
  }
307
354
 
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
- }
355
+ export function weekBounds(value, timeZone) {
318
356
  const endDay = dayBounds(value, timeZone);
319
- const startDateString = shiftCalendarDate(endDay.dateString, -(rangeDays - 1));
357
+ const startDateString = shiftCalendarDate(endDay.dateString, -6);
320
358
  return {
321
359
  ...endDay,
322
360
  startDateString,
323
361
  endDateString: endDay.dateString,
324
362
  start: zonedMidnight(startDateString, timeZone),
325
- rangeDays,
363
+ rangeDays: 7,
326
364
  };
327
365
  }
328
366
 
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
-
390
367
  export function sanitizeTerminalText(value) {
391
368
  return String(value ?? "")
392
369
  .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "")
@@ -427,14 +404,20 @@ export function oneOffProjects(snapshot) {
427
404
 
428
405
  function modelLabel(value) {
429
406
  const model = cleanLabel(value, "Unknown model");
430
- return modelDisplayName(model);
407
+ const lower = model.toLowerCase();
408
+ if (lower.includes("sol")) return "Sol";
409
+ if (lower.includes("luna")) return "Luna";
410
+ if (lower.includes("terra")) return "Terra";
411
+ if (lower === "gpt-5.5") return "GPT-5.5";
412
+ if (lower === "gpt-5.4") return "GPT-5.4";
413
+ return model;
431
414
  }
432
415
 
433
416
  export function filterDayEvents(snapshot, bounds) {
434
417
  const start = bounds.start.getTime();
435
418
  const end = bounds.end.getTime();
436
419
  return (snapshot.events ?? []).filter((event) => {
437
- const timestamp = eventTimestamp(event);
420
+ const timestamp = new Date(event.timestamp).getTime();
438
421
  return Number.isFinite(timestamp) && timestamp >= start && timestamp < end;
439
422
  });
440
423
  }
@@ -459,6 +442,8 @@ export function aggregateProjects(snapshot, events, options = {}) {
459
442
  toolCalls: 0,
460
443
  events: 0,
461
444
  threadIds: new Set(),
445
+ rateCardCredits: 0,
446
+ knownCreditTokens: 0,
462
447
  models: new Map(),
463
448
  };
464
449
  row.totalTokens += Number(event.totalTokens) || 0;
@@ -467,14 +452,23 @@ export function aggregateProjects(snapshot, events, options = {}) {
467
452
  row.toolCalls += Number(event.toolCalls) || 0;
468
453
  row.events += 1;
469
454
  if (event.threadId) row.threadIds.add(event.threadId);
455
+ if (event.rateCardCredits !== null && Number.isFinite(Number(event.rateCardCredits))) {
456
+ row.rateCardCredits += Number(event.rateCardCredits);
457
+ row.knownCreditTokens += Number(event.totalTokens) || 0;
458
+ }
459
+
470
460
  const model = modelLabel(event.model);
471
461
  const modelRow = row.models.get(model) ?? {
472
462
  model,
473
463
  totalTokens: 0,
474
464
  events: 0,
465
+ rateCardCredits: 0,
475
466
  };
476
467
  modelRow.totalTokens += Number(event.totalTokens) || 0;
477
468
  modelRow.events += 1;
469
+ if (event.rateCardCredits !== null && Number.isFinite(Number(event.rateCardCredits))) {
470
+ modelRow.rateCardCredits += Number(event.rateCardCredits);
471
+ }
478
472
  row.models.set(model, modelRow);
479
473
  grouped.set(project, row);
480
474
  }
@@ -492,13 +486,93 @@ export function aggregateProjects(snapshot, events, options = {}) {
492
486
  return right.totalTokens - left.totalTokens;
493
487
  }
494
488
  return left.project.localeCompare(right.project);
489
+ });
490
+ }
491
+
492
+ function totalSummary(events) {
493
+ return events.reduce(
494
+ (summary, event) => {
495
+ summary.totalTokens += Number(event.totalTokens) || 0;
496
+ summary.outputTokens += Number(event.outputTokens) || 0;
497
+ summary.toolCalls += Number(event.toolCalls) || 0;
498
+ if (event.threadId) summary.threadIds.add(event.threadId);
499
+ if (event.rateCardCredits !== null && Number.isFinite(Number(event.rateCardCredits))) {
500
+ summary.rateCardCredits += Number(event.rateCardCredits);
501
+ summary.knownCreditTokens += Number(event.totalTokens) || 0;
502
+ }
503
+ return summary;
504
+ },
505
+ {
506
+ totalTokens: 0,
507
+ outputTokens: 0,
508
+ toolCalls: 0,
509
+ rateCardCredits: 0,
510
+ knownCreditTokens: 0,
511
+ threadIds: new Set(),
512
+ },
513
+ );
514
+ }
515
+
516
+ function compact(value, digits = 2) {
517
+ if (!Number.isFinite(value)) return "—";
518
+ const absolute = Math.abs(value);
519
+ const units = [
520
+ [1_000_000_000, "B"],
521
+ [1_000_000, "M"],
522
+ [1_000, "K"],
523
+ ];
524
+ for (const [divisor, suffix] of units) {
525
+ if (absolute >= divisor) {
526
+ const scaled = value / divisor;
527
+ const precision = scaled >= 100 ? 0 : scaled >= 10 ? 1 : digits;
528
+ return `${scaled.toFixed(precision)}${suffix}`;
529
+ }
530
+ }
531
+ return Math.round(value).toLocaleString("en-US");
532
+ }
533
+
534
+ function percent(value) {
535
+ return `${value.toFixed(value >= 10 ? 1 : 2)}%`;
536
+ }
537
+
538
+ function chartUnit(maximum) {
539
+ if (maximum >= 1_000_000_000) return { divisor: 1_000_000_000, suffix: "B" };
540
+ if (maximum >= 1_000_000) return { divisor: 1_000_000, suffix: "M" };
541
+ if (maximum >= 1_000) return { divisor: 1_000, suffix: "K" };
542
+ return { divisor: 1, suffix: "tokens" };
543
+ }
544
+
545
+ function chartNumber(value, divisor) {
546
+ const scaled = value / divisor;
547
+ if (scaled >= 100) return scaled.toFixed(0);
548
+ if (scaled >= 10) return scaled.toFixed(1);
549
+ return scaled.toFixed(2);
550
+ }
551
+
552
+ function colorize(value, code, enabled) {
553
+ const codes = Array.isArray(code) ? code.join(";") : code;
554
+ return enabled ? `\u001b[${codes}m${value}${ANSI_RESET}` : value;
555
+ }
556
+
557
+ function modelColor(model) {
558
+ const lower = model.toLowerCase();
559
+ if (lower.includes("sol")) return MODEL_COLORS.sol;
560
+ if (lower.includes("luna")) return MODEL_COLORS.luna;
561
+ if (lower.includes("terra")) return MODEL_COLORS.terra;
562
+ if (lower.includes("gpt-5.5") || lower.includes("gpt-5.4")) {
563
+ return MODEL_COLORS["gpt-5.5"];
564
+ }
565
+ return MODEL_COLORS.other;
566
+ }
567
+
568
+ function modelMix(row, enabled) {
569
+ return row.models
570
+ .slice(0, 4)
571
+ .map((model) => {
572
+ const share = row.totalTokens > 0 ? (model.totalTokens / row.totalTokens) * 100 : 0;
573
+ return `${colorize(model.model, modelColor(model.model), enabled)} ${percent(share)}`;
495
574
  })
496
- .map((row, index) => ({
497
- ...row,
498
- displayProject: options.anonymizeProjects
499
- ? `Project ${index + 1}`
500
- : row.displayProject,
501
- }));
575
+ .join(" · ");
502
576
  }
503
577
 
504
578
  function sourceLabel(snapshotPath, snapshot) {
@@ -508,46 +582,48 @@ function sourceLabel(snapshotPath, snapshot) {
508
582
  timeStyle: "short",
509
583
  })
510
584
  : "unknown time";
511
- return `${cleanLabel(basename(snapshotPath), "snapshot")} · captured ${generated}`;
512
- }
585
+ return `${basename(snapshotPath)} · captured ${generated}`;
586
+ }
587
+
588
+ function runYouPlot(rows, options, dateLabel, unit) {
589
+ const chartInput = [
590
+ "project\tvalue",
591
+ ...rows.map(
592
+ (row) => `${row.displayProject.replace(/[\t\r\n]+/g, " ")}\t${chartNumber(row.totalTokens, unit.divisor)}`,
593
+ ),
594
+ ].join("\n");
595
+ const terminalWidth = Number(process.stdout.columns) || 100;
596
+ const width = options.width ?? Math.max(56, Math.min(110, terminalWidth - 4));
597
+ const useColor = !options.plain && !process.env.NO_COLOR && Boolean(process.stdout.isTTY);
598
+ const args = [
599
+ "bar",
600
+ "-H",
601
+ "-o",
602
+ "-",
603
+ "-t",
604
+ `Top ${rows.length} projects · tokens (${unit.suffix}) · ${dateLabel}`,
605
+ "-w",
606
+ String(width),
607
+ "--symbol",
608
+ options.ascii ? "#" : "█",
609
+ ];
610
+ if (useColor) args.push("-C", "-c", "blue");
611
+ else args.push("-M");
513
612
 
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
- }
613
+ const result = spawnSync("uplot", args, {
614
+ input: `${chartInput}\n`,
615
+ encoding: "utf8",
616
+ maxBuffer: 1_000_000,
617
+ });
618
+ if (result.error?.code === "ENOENT") {
619
+ throw new Error(
620
+ "YouPlot is required. Install it with `brew install youplot`, then rerun this command.",
621
+ );
521
622
  }
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}`);
623
+ if (result.status !== 0) {
624
+ throw new Error(result.stderr?.trim() || "YouPlot failed to render the chart.");
548
625
  }
549
- lines.push(`Source: ${sourceLabel(options.input, snapshot)}`);
550
- return lines.join("\n");
626
+ return result.stdout;
551
627
  }
552
628
 
553
629
  async function readSnapshot(snapshotPath) {
@@ -556,28 +632,22 @@ async function readSnapshot(snapshotPath) {
556
632
  parsed = JSON.parse(await readFile(snapshotPath, "utf8"));
557
633
  } catch (error) {
558
634
  if (error?.code === "ENOENT") {
559
- throw new Error(`Snapshot not found: ${sanitizeTerminalText(snapshotPath)}`);
635
+ throw new Error(`Snapshot not found: ${snapshotPath}`);
560
636
  }
561
- throw new Error(
562
- `Could not read snapshot ${sanitizeTerminalText(snapshotPath)}: ${sanitizeTerminalText(error.message)}`,
563
- );
637
+ throw new Error(`Could not read snapshot ${snapshotPath}: ${error.message}`);
564
638
  }
565
639
  if (!parsed || !Array.isArray(parsed.events)) {
566
- throw new Error(
567
- `Snapshot is missing its events array: ${sanitizeTerminalText(snapshotPath)}`,
568
- );
640
+ throw new Error(`Snapshot is missing its events array: ${snapshotPath}`);
569
641
  }
570
642
  return parsed;
571
643
  }
572
644
 
573
645
  async function refreshSnapshot(options) {
574
646
  if (!existsSync(options.codexHome)) {
575
- throw new Error(
576
- `Codex data directory not found: ${sanitizeTerminalText(options.codexHome)}`,
577
- );
647
+ throw new Error(`Codex data directory not found: ${options.codexHome}`);
578
648
  }
579
649
  const { collectUsage, writePrivateSnapshot } = await import(
580
- "../lib/token-ledger-collector.mjs"
650
+ "../lib/token-ledger-importer.mjs"
581
651
  );
582
652
  process.stderr.write("Token Ledger: refreshing local snapshot…\n");
583
653
  const snapshot = await collectUsage(
@@ -596,18 +666,19 @@ async function refreshSnapshot(options) {
596
666
  return snapshot;
597
667
  }
598
668
 
599
- export function snapshotNeedsRefresh(
669
+ export function snapshotNeedsRefresh(snapshotMtimeMs, latestJsonlMtimeMs) {
670
+ return latestJsonlMtimeMs > snapshotMtimeMs;
671
+ }
672
+
673
+ export function snapshotCacheIsFresh(
600
674
  snapshotMtimeMs,
601
- latestSourceMtimeMs,
602
- cachedSourceFingerprint,
603
- expectedSourceFingerprint,
604
- cachedSourceFileCount,
605
- currentSourceFileCount,
675
+ nowMs = Date.now(),
606
676
  ) {
607
677
  return (
608
- cachedSourceFingerprint !== expectedSourceFingerprint ||
609
- cachedSourceFileCount !== currentSourceFileCount ||
610
- latestSourceMtimeMs > snapshotMtimeMs
678
+ Number.isFinite(snapshotMtimeMs) &&
679
+ Number.isFinite(nowMs) &&
680
+ snapshotMtimeMs <= nowMs &&
681
+ nowMs - snapshotMtimeMs < SNAPSHOT_CACHE_MAX_AGE_MS
611
682
  );
612
683
  }
613
684
 
@@ -617,7 +688,7 @@ async function loadSnapshot(options) {
617
688
  }
618
689
  if (!existsSync(options.input)) {
619
690
  if (options.inputExplicit || !options.autoRefresh) {
620
- throw new Error(`Snapshot not found: ${sanitizeTerminalText(options.input)}`);
691
+ throw new Error(`Snapshot not found: ${options.input}`);
621
692
  }
622
693
  return refreshSnapshot(options);
623
694
  }
@@ -625,48 +696,135 @@ async function loadSnapshot(options) {
625
696
  return readSnapshot(options.input);
626
697
  }
627
698
 
628
- const { sourceFingerprint, sourceState } = await import(
629
- "../lib/token-ledger-collector.mjs"
699
+ const snapshotStat = await stat(options.input);
700
+ if (snapshotCacheIsFresh(snapshotStat.mtimeMs)) {
701
+ return readSnapshot(options.input);
702
+ }
703
+
704
+ const { latestSourceModifiedAt } = await import("../lib/token-ledger-importer.mjs");
705
+ const latestSourceMtimeMs = await latestSourceModifiedAt(
706
+ options.codexHome,
707
+ options.includeArchived,
630
708
  );
631
- const [snapshotStat, currentSourceState, snapshot] = await Promise.all([
632
- stat(options.input),
633
- sourceState(options.codexHome, options.includeArchived),
634
- readSnapshot(options.input),
635
- ]);
636
- if (snapshotNeedsRefresh(
637
- snapshotStat.mtimeMs,
638
- currentSourceState.latestMtimeMs,
639
- snapshot.provenance?.sourceFingerprint,
640
- sourceFingerprint(options.codexHome, options.includeArchived),
641
- snapshot.coverage?.sourceFileCount,
642
- currentSourceState.fileCount,
643
- )) {
709
+ if (snapshotNeedsRefresh(snapshotStat.mtimeMs, latestSourceMtimeMs)) {
644
710
  return refreshSnapshot(options);
645
711
  }
646
- return snapshot;
712
+ return readSnapshot(options.input);
647
713
  }
648
714
 
649
715
  function render(options, snapshot, bounds, events, rows, allRows) {
650
- return renderTerminal({ options, snapshot, bounds, events, rows, allRows });
716
+ if (options.view === "trend") {
717
+ const trend = buildUsageTrend(snapshot, bounds);
718
+ if (options.image) {
719
+ return renderTrendImage({
720
+ snapshot,
721
+ bounds,
722
+ trend,
723
+ days: options.trendDays,
724
+ options,
725
+ });
726
+ }
727
+ return renderTrendCombo({
728
+ snapshot,
729
+ bounds,
730
+ trend,
731
+ days: options.trendDays,
732
+ options,
733
+ });
734
+ }
735
+ if (!options.legacyPlot) {
736
+ return renderTerminal({ options, snapshot, bounds, events, rows, allRows });
737
+ }
738
+ const enabled = !options.plain && !process.env.NO_COLOR && Boolean(process.stdout.isTTY);
739
+ const summary = totalSummary(events);
740
+ 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);
750
+ const unit = chartUnit(rows[0]?.totalTokens ?? 0);
751
+ const shares = rows.map((row) =>
752
+ totalTokens > 0 ? (row.totalTokens / totalTokens) * 100 : 0,
753
+ );
754
+ const chart = runYouPlot(rows, options, dateLabel, unit).trimEnd();
755
+
756
+ const header = [
757
+ `Token Ledger · ${dateLabel} · ${bounds.timeZone}`,
758
+ `${compact(totalTokens)} tokens · ${summary.threadIds.size.toLocaleString()} threads · ${events.length.toLocaleString()} calls · ${compact(summary.outputTokens)} output`,
759
+ `Source: ${sourceLabel(options.input, snapshot)}`,
760
+ "",
761
+ chart,
762
+ "",
763
+ `Model mix · colors: ${colorize("Sol", MODEL_COLORS.sol, enabled)} ${colorize("Luna", MODEL_COLORS.luna, enabled)} ${colorize("Terra", MODEL_COLORS.terra, enabled)} ${colorize("GPT", MODEL_COLORS["gpt-5.5"], enabled)} ${colorize("Other", MODEL_COLORS.other, enabled)}`,
764
+ ];
765
+
766
+ const details = rows.map((row, index) => {
767
+ const knownCreditShare =
768
+ summary.rateCardCredits > 0 && row.rateCardCredits > 0
769
+ ? ` · ${percent((row.rateCardCredits / summary.rateCardCredits) * 100)} credits`
770
+ : "";
771
+ return `${String(index + 1).padStart(2, " ")} ${row.displayProject} · ${compact(row.totalTokens)} · ${percent(shares[index])} · ${row.threads.toLocaleString()} threads${knownCreditShare}\n ${modelMix(row, enabled)}`;
772
+ });
773
+
774
+ return `${header.join("\n")}\n\n${details.join("\n")}`;
651
775
  }
652
776
 
653
777
  export async function run(options) {
654
- if (options.range !== "all") boundsForOptions(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);
655
783
  const snapshot = await loadSnapshot(options);
656
- const bounds = boundsForOptions(options, snapshot);
657
784
  const events = filterDayEvents(snapshot, bounds);
658
785
  if (events.length === 0) {
659
- return emptyState(options, snapshot, bounds);
786
+ const rangeDescription = options.range === "week"
787
+ ? `${bounds.startDateString} through ${bounds.endDateString}`
788
+ : bounds.dateString;
789
+ return [
790
+ `No model-call events found for ${rangeDescription} (${bounds.timeZone}).`,
791
+ `Source: ${sourceLabel(options.input, snapshot)}`,
792
+ ].join("\n");
660
793
  }
661
794
  const allRows = aggregateProjects(snapshot, events, options);
662
795
  const rows = allRows.slice(0, options.top);
663
- return render(options, snapshot, bounds, events, rows, allRows);
796
+ const writingImage = options.view === "trend" && options.image;
797
+ const outputPath = writingImage
798
+ ? options.imageOutput ??
799
+ resolve(
800
+ process.cwd(),
801
+ `token-ledger-${options.report ? "report" : "trend"}-${options.trendDays}d.png`,
802
+ )
803
+ : null;
804
+ const imageLabel = options.report ? "report" : "trend image";
805
+ if (writingImage) {
806
+ process.stderr.write(`Token Ledger: generating ${imageLabel} PNG…\n`);
807
+ }
808
+ const output = render(options, snapshot, bounds, events, rows, allRows);
809
+ if (writingImage) {
810
+ await mkdir(dirname(outputPath), { recursive: true });
811
+ process.stderr.write(`Token Ledger: encoding ${imageLabel} PNG…\n`);
812
+ await writeTrendPng(output, outputPath);
813
+ process.stderr.write(`Token Ledger: finished ${imageLabel} PNG.\n`);
814
+ return [
815
+ `Wrote ${options.report ? "report" : "trend image"}: ${outputPath}`,
816
+ `Range: ${bounds.startDateString} through ${bounds.endDateString} (${bounds.timeZone})`,
817
+ ].join("\n");
818
+ }
819
+ return output;
664
820
  }
665
821
 
666
822
  function shouldUseInteractive(options) {
667
823
  return Boolean(
668
824
  !options.static &&
825
+ options.view !== "trend" &&
669
826
  !options.plain &&
827
+ !options.legacyPlot &&
670
828
  !process.env.NO_COLOR &&
671
829
  process.stdin.isTTY &&
672
830
  process.stdout.isTTY,
@@ -674,12 +832,20 @@ function shouldUseInteractive(options) {
674
832
  }
675
833
 
676
834
  async function runInteractive(options) {
677
- if (options.range !== "all") boundsForOptions(options);
835
+ const bounds = options.range === "week"
836
+ ? weekBounds(options.date, options.timeZone)
837
+ : dayBounds(options.date, options.timeZone);
678
838
  const snapshot = await loadSnapshot(options);
679
- const bounds = boundsForOptions(options, snapshot);
680
839
  const events = filterDayEvents(snapshot, bounds);
681
840
  if (events.length === 0) {
682
- process.stdout.write(`${emptyState(options, snapshot, bounds)}\n`);
841
+ const rangeDescription = options.range === "week"
842
+ ? `${bounds.startDateString} through ${bounds.endDateString}`
843
+ : bounds.dateString;
844
+ process.stdout.write([
845
+ `No model-call events found for ${rangeDescription} (${bounds.timeZone}).`,
846
+ `Source: ${sourceLabel(options.input, snapshot)}`,
847
+ "",
848
+ ].join("\n"));
683
849
  return;
684
850
  }
685
851
  const allRows = aggregateProjects(snapshot, events, options);
@@ -701,19 +867,13 @@ async function main() {
701
867
  process.stdout.write(`${usage()}\n`);
702
868
  return;
703
869
  }
704
- if (options.version) {
705
- process.stdout.write(`${VERSION}\n`);
706
- return;
707
- }
708
870
  if (shouldUseInteractive(options)) {
709
871
  await runInteractive(options);
710
872
  } else {
711
- process.stdout.write(`${await run({ ...options, static: true })}\n`);
873
+ process.stdout.write(`${await run(options)}\n`);
712
874
  }
713
875
  } catch (error) {
714
- process.stderr.write(
715
- `Token Ledger CLI failed: ${sanitizeTerminalText(error.message)}\n\n${usage()}\n`,
716
- );
876
+ process.stderr.write(`Token Ledger CLI failed: ${error.message}\n\n${usage()}\n`);
717
877
  process.exitCode = 1;
718
878
  }
719
879
  }