tledger 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +152 -136
- package/bin/token-ledger-cache-image.mjs +1150 -0
- package/bin/token-ledger-rates.mjs +4 -1
- package/bin/token-ledger-terminal.mjs +89 -29
- package/bin/token-ledger-trend-image.mjs +1669 -587
- package/bin/token-ledger-trend-terminal.mjs +73 -28
- package/bin/token-ledger-trend.mjs +86 -26
- package/bin/token-ledger-tui.mjs +7 -3
- package/bin/token-ledger.mjs +333 -96
- package/docs/token-ledger-cli-week.png +0 -0
- package/docs/token-ledger-report-7-day.png +0 -0
- package/lib/token-ledger-importer.mjs +605 -282
- package/lib/token-ledger-snapshot.mjs +267 -0
- package/lib/token-ledger-usage.mjs +524 -0
- package/package.json +10 -5
package/bin/token-ledger.mjs
CHANGED
|
@@ -4,7 +4,6 @@ import { spawnSync } from "node:child_process";
|
|
|
4
4
|
import { existsSync, realpathSync } from "node:fs";
|
|
5
5
|
import {
|
|
6
6
|
mkdir,
|
|
7
|
-
readFile,
|
|
8
7
|
stat,
|
|
9
8
|
} from "node:fs/promises";
|
|
10
9
|
import { homedir } from "node:os";
|
|
@@ -20,18 +19,32 @@ import {
|
|
|
20
19
|
renderTrendImage,
|
|
21
20
|
writeTrendPng,
|
|
22
21
|
} from "./token-ledger-trend-image.mjs";
|
|
22
|
+
import { renderCacheReportImage } from "./token-ledger-cache-image.mjs";
|
|
23
23
|
import { renderTrendCombo } from "./token-ledger-trend-terminal.mjs";
|
|
24
24
|
import { startInteractive } from "./token-ledger-tui.mjs";
|
|
25
|
+
import {
|
|
26
|
+
readPrivateSnapshot,
|
|
27
|
+
writePrivateSnapshot,
|
|
28
|
+
} from "../lib/token-ledger-snapshot.mjs";
|
|
29
|
+
import {
|
|
30
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
31
|
+
usageBuckets,
|
|
32
|
+
usageBucketsInRange,
|
|
33
|
+
usageCallCount,
|
|
34
|
+
usageThreadIds,
|
|
35
|
+
} from "../lib/token-ledger-usage.mjs";
|
|
25
36
|
|
|
26
37
|
export const DEFAULT_SNAPSHOT = resolve(
|
|
27
38
|
homedir(),
|
|
28
39
|
".token-ledger",
|
|
29
|
-
"token-ledger-snapshot.json",
|
|
40
|
+
"token-ledger-snapshot-v2.json.gz",
|
|
30
41
|
);
|
|
31
42
|
const DEFAULT_TOP = 10;
|
|
32
43
|
const DEFAULT_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
33
44
|
export const SNAPSHOT_CACHE_MAX_AGE_MS = 60 * 60 * 1000;
|
|
34
45
|
export const ROLLING_24_HOURS_MS = 24 * 60 * 60 * 1000;
|
|
46
|
+
const MAX_ROLLING_DAYS = 3_650;
|
|
47
|
+
const DURATION_ALIAS = /^(\d+)(d|w)$/i;
|
|
35
48
|
const ANSI_RESET = "\u001b[0m";
|
|
36
49
|
const MODEL_COLORS = {
|
|
37
50
|
sol: TERMINAL_MODEL_COLORS.sol,
|
|
@@ -42,46 +55,111 @@ const MODEL_COLORS = {
|
|
|
42
55
|
other: TERMINAL_MODEL_COLORS.other,
|
|
43
56
|
};
|
|
44
57
|
|
|
45
|
-
function usage() {
|
|
46
|
-
return `Token Ledger
|
|
58
|
+
export function usage() {
|
|
59
|
+
return `Token Ledger
|
|
47
60
|
|
|
48
61
|
Usage:
|
|
49
|
-
tledger 1d
|
|
50
|
-
tledger
|
|
51
|
-
tledger
|
|
52
|
-
tledger
|
|
53
|
-
tledger report
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
--
|
|
59
|
-
--
|
|
60
|
-
--
|
|
61
|
-
--
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
62
|
+
tledger 1d Last 24 hours in the terminal
|
|
63
|
+
tledger week Last 7 calendar days in the terminal
|
|
64
|
+
tledger 30d Rolling 30 days in the terminal
|
|
65
|
+
tledger report 7d Write the 7-day PNG report
|
|
66
|
+
tledger report 7d --cache-rate Write the cache-only PNG report
|
|
67
|
+
|
|
68
|
+
Common options:
|
|
69
|
+
--static Print once instead of opening the dashboard
|
|
70
|
+
--refresh Rebuild the local usage cache
|
|
71
|
+
--image-output <file> Choose where to save a PNG
|
|
72
|
+
--no-open Do not open a generated PNG
|
|
73
|
+
-h, --help Show this quick guide
|
|
74
|
+
--help-all Show every command and option
|
|
75
|
+
|
|
76
|
+
Token Ledger reads local Codex data only. It does not upload your usage.`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function advancedUsage() {
|
|
80
|
+
return `Token Ledger command reference
|
|
81
|
+
|
|
82
|
+
Terminal commands:
|
|
83
|
+
tledger 1d Rolling 24-hour project breakdown
|
|
84
|
+
tledger <N>d Rolling N-day project breakdown
|
|
85
|
+
tledger <N>w Rolling N-week project breakdown
|
|
86
|
+
tledger day <YYYY-MM-DD> One local calendar day
|
|
87
|
+
tledger week [end-day] Seven local calendar days
|
|
88
|
+
tledger trend [Nd|Nw] Multi-day terminal trend
|
|
89
|
+
|
|
90
|
+
Report commands:
|
|
91
|
+
tledger report [Nd|Nw] Write the usage dashboard PNG
|
|
92
|
+
tledger report [Nd|Nw] --cache-rate
|
|
93
|
+
Write the cache-only PNG
|
|
94
|
+
|
|
95
|
+
Dates and ranges:
|
|
96
|
+
--date <day> YYYY-MM-DD, today, or yesterday
|
|
97
|
+
--period <window> Trend window such as 7d, 14d, or 2w
|
|
98
|
+
--tz <name> IANA timezone (default: machine timezone)
|
|
99
|
+
|
|
100
|
+
Data and refresh:
|
|
101
|
+
--input <file> Read an explicit snapshot
|
|
102
|
+
--refresh Rebuild the default snapshot from local Codex data
|
|
103
|
+
--no-refresh Use the cached snapshot without checking source files
|
|
104
|
+
--codex-home <dir> Codex data root used when refreshing
|
|
105
|
+
--no-archived Skip archived sessions when refreshing
|
|
106
|
+
|
|
107
|
+
Terminal output:
|
|
108
|
+
--top <number> Projects to show, from 1 to 100 (default: 10)
|
|
109
|
+
--width <number> Layout width, from 40 to 200 columns
|
|
110
|
+
--raw-projects Keep singleton thread labels ungrouped
|
|
111
|
+
--plain Disable terminal colors
|
|
112
|
+
--ascii Use ASCII bars instead of Unicode blocks
|
|
113
|
+
--static Print once instead of opening the dashboard
|
|
114
|
+
--youplot Use the legacy single-series renderer
|
|
115
|
+
|
|
116
|
+
Report output:
|
|
117
|
+
--drain Chart estimated meter drain instead of token volume
|
|
118
|
+
--cache-rate Write the cache-only report (report command only)
|
|
119
|
+
--image Write the trend view as a PNG
|
|
120
|
+
--image-output <file> Choose the PNG output path
|
|
121
|
+
--image-width <px> Set PNG width from 900 to 2400 pixels
|
|
122
|
+
--no-open Do not open the finished PNG
|
|
123
|
+
|
|
124
|
+
Help:
|
|
125
|
+
-h, --help Show the quick guide
|
|
126
|
+
--help-all Show this complete reference
|
|
127
|
+
|
|
128
|
+
The default snapshot is ~/.token-ledger/token-ledger-snapshot-v2.json.gz.
|
|
129
|
+
Token Ledger reads local Codex data only. It does not upload your usage.`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function durationAlias(value) {
|
|
133
|
+
const match = DURATION_ALIAS.exec(String(value ?? ""));
|
|
134
|
+
if (!match) return null;
|
|
135
|
+
const amount = Number(match[1]);
|
|
136
|
+
const unit = match[2].toLowerCase();
|
|
137
|
+
const days = unit === "w" ? amount * 7 : amount;
|
|
138
|
+
if (
|
|
139
|
+
!Number.isSafeInteger(amount) ||
|
|
140
|
+
!Number.isSafeInteger(days) ||
|
|
141
|
+
amount < 1 ||
|
|
142
|
+
days > MAX_ROLLING_DAYS
|
|
143
|
+
) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Duration must be between 1d and ${MAX_ROLLING_DAYS}d (or the equivalent in weeks).`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const noun = unit === "w"
|
|
149
|
+
? amount === 1 ? "week" : "weeks"
|
|
150
|
+
: amount === 1 ? "day" : "days";
|
|
151
|
+
return {
|
|
152
|
+
amount,
|
|
153
|
+
unit,
|
|
154
|
+
days,
|
|
155
|
+
label: `${amount} ${noun}`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function rollingRangeDescription(options) {
|
|
160
|
+
return options.range === "rolling24h"
|
|
161
|
+
? "the last 24 hours"
|
|
162
|
+
: `the last ${options.rollingLabel}`;
|
|
85
163
|
}
|
|
86
164
|
|
|
87
165
|
function readOption(argv, index, name) {
|
|
@@ -93,9 +171,14 @@ function readOption(argv, index, name) {
|
|
|
93
171
|
}
|
|
94
172
|
|
|
95
173
|
export function parseArgs(argv) {
|
|
174
|
+
const helpCommand = argv[0] === "help";
|
|
175
|
+
const alias = durationAlias(argv[0]);
|
|
96
176
|
const rolling24hCommand = argv[0] === "1d";
|
|
177
|
+
const rollingDurationCommand = Boolean(alias) && !rolling24hCommand;
|
|
97
178
|
const command = rolling24hCommand
|
|
98
179
|
? "rolling24h"
|
|
180
|
+
: rollingDurationCommand
|
|
181
|
+
? "rolling"
|
|
99
182
|
: argv[0] === "week"
|
|
100
183
|
? "week"
|
|
101
184
|
: argv[0] === "trend" || argv[0] === "report"
|
|
@@ -105,6 +188,11 @@ export function parseArgs(argv) {
|
|
|
105
188
|
range: command,
|
|
106
189
|
view: command === "trend" ? "trend" : "projects",
|
|
107
190
|
rolling24h: rolling24hCommand,
|
|
191
|
+
rollingDuration: rollingDurationCommand,
|
|
192
|
+
rollingDays: alias?.days ?? (rolling24hCommand ? 1 : null),
|
|
193
|
+
rollingAmount: alias?.amount ?? (rolling24hCommand ? 1 : null),
|
|
194
|
+
rollingUnit: alias?.unit ?? (rolling24hCommand ? "d" : null),
|
|
195
|
+
rollingLabel: alias?.label ?? "1 day",
|
|
108
196
|
report: argv[0] === "report",
|
|
109
197
|
trendDays: 7,
|
|
110
198
|
date: null,
|
|
@@ -124,17 +212,23 @@ export function parseArgs(argv) {
|
|
|
124
212
|
image: false,
|
|
125
213
|
imageOutput: null,
|
|
126
214
|
imageWidth: null,
|
|
215
|
+
openImage: true,
|
|
127
216
|
drain: false,
|
|
217
|
+
cacheRate: false,
|
|
128
218
|
legacyPlot: false,
|
|
129
|
-
help:
|
|
219
|
+
help: argv.length === 0 || helpCommand,
|
|
220
|
+
helpAll: false,
|
|
130
221
|
};
|
|
131
222
|
|
|
132
223
|
let trendPeriodSeen = false;
|
|
133
|
-
let index = ["
|
|
224
|
+
let index = alias || ["day", "week", "trend", "report", "help"].includes(argv[0]) ? 1 : 0;
|
|
134
225
|
for (; index < argv.length; index += 1) {
|
|
135
226
|
const argument = argv[index];
|
|
136
227
|
if (argument === "--help" || argument === "-h") {
|
|
137
228
|
options.help = true;
|
|
229
|
+
} else if (argument === "--help-all") {
|
|
230
|
+
options.help = true;
|
|
231
|
+
options.helpAll = true;
|
|
138
232
|
} else if (argument === "--date") {
|
|
139
233
|
options.date = readOption(argv, index, "--date");
|
|
140
234
|
index += 1;
|
|
@@ -146,10 +240,13 @@ export function parseArgs(argv) {
|
|
|
146
240
|
throw new Error("Trend period can only be specified once.");
|
|
147
241
|
}
|
|
148
242
|
const value = readOption(argv, index, "--period");
|
|
149
|
-
|
|
150
|
-
|
|
243
|
+
const period = durationAlias(value);
|
|
244
|
+
if (!period) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
"Trend period must use a positive number of days or weeks, such as 7d or 2w.",
|
|
247
|
+
);
|
|
151
248
|
}
|
|
152
|
-
options.trendDays =
|
|
249
|
+
options.trendDays = period.days;
|
|
153
250
|
trendPeriodSeen = true;
|
|
154
251
|
index += 1;
|
|
155
252
|
} else if (argument === "--input") {
|
|
@@ -195,11 +292,21 @@ export function parseArgs(argv) {
|
|
|
195
292
|
throw new Error("--drain is only available for the trend view.");
|
|
196
293
|
}
|
|
197
294
|
options.drain = true;
|
|
295
|
+
} else if (argument === "--cache-rate") {
|
|
296
|
+
if (!options.report) {
|
|
297
|
+
throw new Error("--cache-rate is only available with the report command.");
|
|
298
|
+
}
|
|
299
|
+
options.cacheRate = true;
|
|
198
300
|
} else if (argument === "--image") {
|
|
199
301
|
if (options.view !== "trend") {
|
|
200
302
|
throw new Error("--image is only available for the trend view.");
|
|
201
303
|
}
|
|
202
304
|
options.image = true;
|
|
305
|
+
} else if (argument === "--no-open") {
|
|
306
|
+
if (options.view !== "trend") {
|
|
307
|
+
throw new Error("--no-open is only available for the trend view.");
|
|
308
|
+
}
|
|
309
|
+
options.openImage = false;
|
|
203
310
|
} else if (argument === "--image-output") {
|
|
204
311
|
if (options.view !== "trend") {
|
|
205
312
|
throw new Error("--image-output is only available for the trend view.");
|
|
@@ -241,10 +348,13 @@ export function parseArgs(argv) {
|
|
|
241
348
|
if (trendPeriodSeen) {
|
|
242
349
|
throw new Error("Trend period can only be specified once.");
|
|
243
350
|
}
|
|
244
|
-
|
|
245
|
-
|
|
351
|
+
const period = durationAlias(argument);
|
|
352
|
+
if (!period) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
"Trend period must use a positive number of days or weeks, such as 7d or 2w.",
|
|
355
|
+
);
|
|
246
356
|
}
|
|
247
|
-
options.trendDays =
|
|
357
|
+
options.trendDays = period.days;
|
|
248
358
|
trendPeriodSeen = true;
|
|
249
359
|
} else if (!argument.startsWith("-") && !options.date) {
|
|
250
360
|
options.date = argument;
|
|
@@ -254,13 +364,13 @@ export function parseArgs(argv) {
|
|
|
254
364
|
}
|
|
255
365
|
|
|
256
366
|
if (options.report) options.image = true;
|
|
257
|
-
if (!options.help && options.rolling24h && options.date) {
|
|
258
|
-
throw new Error(
|
|
367
|
+
if (!options.help && (options.rolling24h || options.rollingDuration) && options.date) {
|
|
368
|
+
throw new Error(`${options.rollingLabel} does not accept --date; its rolling window ends now.`);
|
|
259
369
|
}
|
|
260
370
|
if (!options.help && !options.date && (options.range === "week" || options.view === "trend")) {
|
|
261
371
|
options.date = "today";
|
|
262
372
|
}
|
|
263
|
-
if (!options.help && !options.date && !options.rolling24h) {
|
|
373
|
+
if (!options.help && !options.date && !options.rolling24h && !options.rollingDuration) {
|
|
264
374
|
throw new Error("A day is required, for example: tledger day 2026-08-01");
|
|
265
375
|
}
|
|
266
376
|
if (!options.help && options.refresh && !options.autoRefresh) {
|
|
@@ -272,6 +382,9 @@ export function parseArgs(argv) {
|
|
|
272
382
|
if (!options.help && options.view === "trend" && options.legacyPlot) {
|
|
273
383
|
throw new Error("--youplot is only available for the project view.");
|
|
274
384
|
}
|
|
385
|
+
if (!options.help && options.cacheRate && options.drain) {
|
|
386
|
+
throw new Error("--cache-rate cannot be combined with --drain.");
|
|
387
|
+
}
|
|
275
388
|
return options;
|
|
276
389
|
}
|
|
277
390
|
|
|
@@ -387,20 +500,35 @@ export function weekBounds(value, timeZone) {
|
|
|
387
500
|
};
|
|
388
501
|
}
|
|
389
502
|
|
|
390
|
-
export function
|
|
503
|
+
export function rollingDurationBounds(
|
|
504
|
+
value = new Date(),
|
|
505
|
+
timeZone = DEFAULT_TIME_ZONE,
|
|
506
|
+
rangeDays = 1,
|
|
507
|
+
) {
|
|
391
508
|
validateTimeZone(timeZone);
|
|
392
509
|
const end = value instanceof Date ? new Date(value.getTime()) : new Date(value);
|
|
393
510
|
if (!Number.isFinite(end.getTime())) {
|
|
394
|
-
throw new Error("Rolling
|
|
511
|
+
throw new Error("Rolling window requires a valid end time.");
|
|
512
|
+
}
|
|
513
|
+
const days = Number(rangeDays);
|
|
514
|
+
if (!Number.isSafeInteger(days) || days < 1 || days > MAX_ROLLING_DAYS) {
|
|
515
|
+
throw new Error(
|
|
516
|
+
`Rolling window must be between 1 and ${MAX_ROLLING_DAYS} days.`,
|
|
517
|
+
);
|
|
395
518
|
}
|
|
396
519
|
return {
|
|
397
|
-
start: new Date(end.getTime() - ROLLING_24_HOURS_MS),
|
|
520
|
+
start: new Date(end.getTime() - days * ROLLING_24_HOURS_MS),
|
|
398
521
|
end,
|
|
399
522
|
timeZone,
|
|
400
|
-
rangeHours: 24,
|
|
523
|
+
rangeHours: days * 24,
|
|
524
|
+
rangeDays: days,
|
|
401
525
|
};
|
|
402
526
|
}
|
|
403
527
|
|
|
528
|
+
export function rolling24hBounds(value = new Date(), timeZone = DEFAULT_TIME_ZONE) {
|
|
529
|
+
return rollingDurationBounds(value, timeZone, 1);
|
|
530
|
+
}
|
|
531
|
+
|
|
404
532
|
export function sanitizeTerminalText(value) {
|
|
405
533
|
return String(value ?? "")
|
|
406
534
|
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "")
|
|
@@ -486,7 +614,9 @@ export function oneOffProjects(snapshot) {
|
|
|
486
614
|
ids.add(threadId);
|
|
487
615
|
threadIdsByProject.set(normalizedProject, ids);
|
|
488
616
|
};
|
|
489
|
-
for (const
|
|
617
|
+
for (const bucket of usageBuckets(snapshot)) {
|
|
618
|
+
for (const threadId of usageThreadIds(bucket)) add(bucket.project, threadId);
|
|
619
|
+
}
|
|
490
620
|
for (const thread of snapshot.threads ?? []) add(thread.project, thread.id);
|
|
491
621
|
return new Set(
|
|
492
622
|
[...threadIdsByProject.entries()]
|
|
@@ -509,10 +639,7 @@ function modelLabel(value) {
|
|
|
509
639
|
export function filterDayEvents(snapshot, bounds) {
|
|
510
640
|
const start = bounds.start.getTime();
|
|
511
641
|
const end = bounds.end.getTime();
|
|
512
|
-
return (snapshot
|
|
513
|
-
const timestamp = new Date(event.timestamp).getTime();
|
|
514
|
-
return Number.isFinite(timestamp) && timestamp >= start && timestamp < end;
|
|
515
|
-
});
|
|
642
|
+
return usageBucketsInRange(snapshot, start, end);
|
|
516
643
|
}
|
|
517
644
|
|
|
518
645
|
export function aggregateProjects(snapshot, events, options = {}) {
|
|
@@ -543,8 +670,8 @@ export function aggregateProjects(snapshot, events, options = {}) {
|
|
|
543
670
|
row.outputTokens += Number(event.outputTokens) || 0;
|
|
544
671
|
row.reasoningTokens += Number(event.reasoningTokens) || 0;
|
|
545
672
|
row.toolCalls += Number(event.toolCalls) || 0;
|
|
546
|
-
row.events +=
|
|
547
|
-
|
|
673
|
+
row.events += usageCallCount(event);
|
|
674
|
+
for (const threadId of usageThreadIds(event)) row.threadIds.add(threadId);
|
|
548
675
|
if (event.rateCardCredits !== null && Number.isFinite(Number(event.rateCardCredits))) {
|
|
549
676
|
row.rateCardCredits += Number(event.rateCardCredits);
|
|
550
677
|
row.knownCreditTokens += Number(event.totalTokens) || 0;
|
|
@@ -558,7 +685,7 @@ export function aggregateProjects(snapshot, events, options = {}) {
|
|
|
558
685
|
rateCardCredits: 0,
|
|
559
686
|
};
|
|
560
687
|
modelRow.totalTokens += Number(event.totalTokens) || 0;
|
|
561
|
-
modelRow.events +=
|
|
688
|
+
modelRow.events += usageCallCount(event);
|
|
562
689
|
if (event.rateCardCredits !== null && Number.isFinite(Number(event.rateCardCredits))) {
|
|
563
690
|
modelRow.rateCardCredits += Number(event.rateCardCredits);
|
|
564
691
|
}
|
|
@@ -588,7 +715,10 @@ function totalSummary(events) {
|
|
|
588
715
|
summary.totalTokens += Number(event.totalTokens) || 0;
|
|
589
716
|
summary.outputTokens += Number(event.outputTokens) || 0;
|
|
590
717
|
summary.toolCalls += Number(event.toolCalls) || 0;
|
|
591
|
-
|
|
718
|
+
summary.calls += usageCallCount(event);
|
|
719
|
+
for (const threadId of usageThreadIds(event)) {
|
|
720
|
+
summary.threadIds.add(threadId);
|
|
721
|
+
}
|
|
592
722
|
if (event.rateCardCredits !== null && Number.isFinite(Number(event.rateCardCredits))) {
|
|
593
723
|
summary.rateCardCredits += Number(event.rateCardCredits);
|
|
594
724
|
summary.knownCreditTokens += Number(event.totalTokens) || 0;
|
|
@@ -599,6 +729,7 @@ function totalSummary(events) {
|
|
|
599
729
|
totalTokens: 0,
|
|
600
730
|
outputTokens: 0,
|
|
601
731
|
toolCalls: 0,
|
|
732
|
+
calls: 0,
|
|
602
733
|
rateCardCredits: 0,
|
|
603
734
|
knownCreditTokens: 0,
|
|
604
735
|
threadIds: new Set(),
|
|
@@ -614,12 +745,18 @@ function compact(value, digits = 2) {
|
|
|
614
745
|
[1_000_000, "M"],
|
|
615
746
|
[1_000, "K"],
|
|
616
747
|
];
|
|
617
|
-
for (
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
748
|
+
for (let index = 0; index < units.length; index += 1) {
|
|
749
|
+
const [divisor, suffix] = units[index];
|
|
750
|
+
if (absolute < divisor) continue;
|
|
751
|
+
const scaled = value / divisor;
|
|
752
|
+
const magnitude = Math.abs(scaled);
|
|
753
|
+
const precision = magnitude >= 100 ? 0 : magnitude >= 10 ? 1 : digits;
|
|
754
|
+
// Values that round to 1000 of a unit belong to the next unit up
|
|
755
|
+
// (999,999 → 1.00M, not 1000K).
|
|
756
|
+
if (index > 0 && Number(magnitude.toFixed(precision)) >= 1_000) {
|
|
757
|
+
return compact(Math.sign(value) * divisor * 1_000, digits);
|
|
622
758
|
}
|
|
759
|
+
return `${scaled.toFixed(precision)}${suffix}`;
|
|
623
760
|
}
|
|
624
761
|
return Math.round(value).toLocaleString("en-US");
|
|
625
762
|
}
|
|
@@ -723,7 +860,7 @@ async function readSnapshot(snapshotPath) {
|
|
|
723
860
|
const snapshotLabel = safeDisplayLabel(snapshotPath, "snapshot");
|
|
724
861
|
let parsed;
|
|
725
862
|
try {
|
|
726
|
-
parsed =
|
|
863
|
+
parsed = await readPrivateSnapshot(snapshotPath);
|
|
727
864
|
} catch (error) {
|
|
728
865
|
if (error?.code === "ENOENT") {
|
|
729
866
|
throw new Error(`Snapshot not found: ${snapshotLabel}`);
|
|
@@ -732,8 +869,14 @@ async function readSnapshot(snapshotPath) {
|
|
|
732
869
|
`Could not read snapshot ${snapshotLabel}: ${safeErrorMessage(error, [snapshotPath])}`,
|
|
733
870
|
);
|
|
734
871
|
}
|
|
735
|
-
if (
|
|
736
|
-
|
|
872
|
+
if (
|
|
873
|
+
!parsed ||
|
|
874
|
+
parsed.schemaVersion !== SNAPSHOT_SCHEMA_VERSION ||
|
|
875
|
+
!Array.isArray(parsed.events)
|
|
876
|
+
) {
|
|
877
|
+
throw new Error(
|
|
878
|
+
`Snapshot uses an unsupported schema: ${snapshotLabel}. Rebuild it with --refresh.`,
|
|
879
|
+
);
|
|
737
880
|
}
|
|
738
881
|
return parsed;
|
|
739
882
|
}
|
|
@@ -746,7 +889,7 @@ async function refreshSnapshot(options) {
|
|
|
746
889
|
}
|
|
747
890
|
let progressStarted = false;
|
|
748
891
|
try {
|
|
749
|
-
const { collectUsage
|
|
892
|
+
const { collectUsage } = await import(
|
|
750
893
|
"../lib/token-ledger-importer.mjs"
|
|
751
894
|
);
|
|
752
895
|
process.stderr.write("Token Ledger: refreshing local snapshot…\n");
|
|
@@ -763,10 +906,25 @@ async function refreshSnapshot(options) {
|
|
|
763
906
|
},
|
|
764
907
|
);
|
|
765
908
|
process.stderr.write("\n");
|
|
766
|
-
await writePrivateSnapshot(options.input, snapshot);
|
|
767
|
-
|
|
909
|
+
const writeResult = await writePrivateSnapshot(options.input, snapshot);
|
|
910
|
+
const storedSnapshot = writeResult.snapshot;
|
|
911
|
+
process.stderr.write(
|
|
912
|
+
`Token Ledger: cached ${(writeResult.bytesWritten / 1_000_000).toFixed(1)} MB ${writeResult.encoding} snapshot (${(writeResult.jsonBytes / 1_000_000).toFixed(1)} MB JSON before encoding; ${storedSnapshot.events.length.toLocaleString()} buckets for ${storedSnapshot.coverage.observedModelCalls.toLocaleString()} calls; ${(writeResult.maxBytes / 1_000_000).toFixed(1)} MB limit).\n`,
|
|
913
|
+
);
|
|
914
|
+
if (writeResult.bytesWritten / writeResult.maxBytes >= 0.7) {
|
|
915
|
+
process.stderr.write(
|
|
916
|
+
"Token Ledger: snapshot is above 70% of its safety limit; older buckets will compact automatically as it grows.\n",
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
return storedSnapshot;
|
|
768
920
|
} catch (error) {
|
|
769
921
|
if (progressStarted) process.stderr.write("\n");
|
|
922
|
+
if (error?.code === "ERR_SNAPSHOT_SIZE_LIMIT" && existsSync(options.input)) {
|
|
923
|
+
process.stderr.write(
|
|
924
|
+
"Token Ledger: refresh exceeded the safety limit; continuing with the previous cache, which may be stale.\n",
|
|
925
|
+
);
|
|
926
|
+
return readSnapshot(options.input);
|
|
927
|
+
}
|
|
770
928
|
throw new Error(
|
|
771
929
|
`Could not refresh local snapshot: ${safeErrorMessage(error, [options.input, options.codexHome])}`,
|
|
772
930
|
);
|
|
@@ -789,6 +947,18 @@ export function snapshotCacheIsFresh(
|
|
|
789
947
|
);
|
|
790
948
|
}
|
|
791
949
|
|
|
950
|
+
export function shouldCheckSourceFreshness(
|
|
951
|
+
options = {},
|
|
952
|
+
snapshotMtimeMs,
|
|
953
|
+
nowMs = Date.now(),
|
|
954
|
+
) {
|
|
955
|
+
// PNG reports are expected to reflect every completed local call. Checking
|
|
956
|
+
// source mtimes is much cheaper than parsing the rollouts again; the full
|
|
957
|
+
// collector only runs when one of those sources actually changed.
|
|
958
|
+
return Boolean(options.view === "trend" && options.image) ||
|
|
959
|
+
!snapshotCacheIsFresh(snapshotMtimeMs, nowMs);
|
|
960
|
+
}
|
|
961
|
+
|
|
792
962
|
function snapshotAgeLabel(ageMs) {
|
|
793
963
|
if (ageMs < 60 * 1_000) return "now";
|
|
794
964
|
const minutes = Math.floor(ageMs / (60 * 1_000));
|
|
@@ -798,10 +968,18 @@ function snapshotAgeLabel(ageMs) {
|
|
|
798
968
|
return `${Math.floor(hours / 24)}d old`;
|
|
799
969
|
}
|
|
800
970
|
|
|
971
|
+
function primitiveString(value) {
|
|
972
|
+
try {
|
|
973
|
+
const text = String.prototype.valueOf.call(value);
|
|
974
|
+
return text === value ? text : null;
|
|
975
|
+
} catch {
|
|
976
|
+
return null;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
801
980
|
export function snapshotFreshness(snapshot = {}, nowMs = Date.now()) {
|
|
802
|
-
const
|
|
803
|
-
|
|
804
|
-
: NaN;
|
|
981
|
+
const generatedAt = primitiveString(snapshot.generatedAt);
|
|
982
|
+
const generatedAtMs = generatedAt === null ? NaN : Date.parse(generatedAt);
|
|
805
983
|
if (
|
|
806
984
|
!Number.isFinite(generatedAtMs) ||
|
|
807
985
|
!Number.isFinite(nowMs) ||
|
|
@@ -839,7 +1017,7 @@ async function loadSnapshot(options) {
|
|
|
839
1017
|
`Could not inspect snapshot ${safeDisplayLabel(options.input, "snapshot")}: ${safeErrorMessage(error, [options.input])}`,
|
|
840
1018
|
);
|
|
841
1019
|
}
|
|
842
|
-
if (
|
|
1020
|
+
if (!shouldCheckSourceFreshness(options, snapshotStat.mtimeMs)) {
|
|
843
1021
|
return readSnapshot(options.input);
|
|
844
1022
|
}
|
|
845
1023
|
|
|
@@ -861,8 +1039,25 @@ async function loadSnapshot(options) {
|
|
|
861
1039
|
return readSnapshot(options.input);
|
|
862
1040
|
}
|
|
863
1041
|
|
|
864
|
-
function render(
|
|
1042
|
+
function render(
|
|
1043
|
+
options,
|
|
1044
|
+
snapshot,
|
|
1045
|
+
bounds,
|
|
1046
|
+
events,
|
|
1047
|
+
rows,
|
|
1048
|
+
allRows,
|
|
1049
|
+
freshness,
|
|
1050
|
+
reportTimeMs,
|
|
1051
|
+
) {
|
|
865
1052
|
if (options.view === "trend") {
|
|
1053
|
+
if (options.image && options.cacheRate) {
|
|
1054
|
+
return renderCacheReportImage({
|
|
1055
|
+
snapshot,
|
|
1056
|
+
bounds,
|
|
1057
|
+
days: options.trendDays,
|
|
1058
|
+
options,
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
866
1061
|
const trend = buildUsageTrend(snapshot, bounds);
|
|
867
1062
|
if (options.image) {
|
|
868
1063
|
return renderTrendImage({
|
|
@@ -870,7 +1065,8 @@ function render(options, snapshot, bounds, events, rows, allRows, freshness) {
|
|
|
870
1065
|
bounds,
|
|
871
1066
|
trend,
|
|
872
1067
|
days: options.trendDays,
|
|
873
|
-
options,
|
|
1068
|
+
options: { ...options, reportTimeMs },
|
|
1069
|
+
projectRows: allRows,
|
|
874
1070
|
});
|
|
875
1071
|
}
|
|
876
1072
|
return renderTrendCombo({
|
|
@@ -897,7 +1093,9 @@ function render(options, snapshot, bounds, events, rows, allRows, freshness) {
|
|
|
897
1093
|
const totalTokens = summary.totalTokens;
|
|
898
1094
|
const dateLabel = options.range === "rolling24h"
|
|
899
1095
|
? "last 24 hours"
|
|
900
|
-
: options.range === "
|
|
1096
|
+
: options.range === "rolling"
|
|
1097
|
+
? `last ${options.rollingLabel}`
|
|
1098
|
+
: options.range === "week"
|
|
901
1099
|
? `${bounds.startDateString} through ${bounds.endDateString}`
|
|
902
1100
|
: new Intl.DateTimeFormat("en-US", {
|
|
903
1101
|
timeZone: bounds.timeZone,
|
|
@@ -914,7 +1112,7 @@ function render(options, snapshot, bounds, events, rows, allRows, freshness) {
|
|
|
914
1112
|
|
|
915
1113
|
const header = [
|
|
916
1114
|
`Token Ledger · ${dateLabel} · ${bounds.timeZone}`,
|
|
917
|
-
`${compact(totalTokens)} tokens · ${summary.threadIds.size.toLocaleString()} threads · ${
|
|
1115
|
+
`${compact(totalTokens)} tokens · ${summary.threadIds.size.toLocaleString()} threads · ${summary.calls.toLocaleString()} calls · ${compact(summary.outputTokens)} output`,
|
|
918
1116
|
`Source: ${sourceLabel(options.input, snapshot)}`,
|
|
919
1117
|
"",
|
|
920
1118
|
chart,
|
|
@@ -943,11 +1141,16 @@ function boundsForOptions(options, now = new Date()) {
|
|
|
943
1141
|
if (options.range === "rolling24h") {
|
|
944
1142
|
return rolling24hBounds(now, options.timeZone);
|
|
945
1143
|
}
|
|
1144
|
+
if (options.range === "rolling") {
|
|
1145
|
+
return rollingDurationBounds(now, options.timeZone, options.rollingDays);
|
|
1146
|
+
}
|
|
946
1147
|
return dayBounds(options.date, options.timeZone);
|
|
947
1148
|
}
|
|
948
1149
|
|
|
949
1150
|
function rangeDescription(options, bounds) {
|
|
950
|
-
if (options.range === "rolling24h"
|
|
1151
|
+
if (options.range === "rolling24h" || options.range === "rolling") {
|
|
1152
|
+
return rollingRangeDescription(options);
|
|
1153
|
+
}
|
|
951
1154
|
if (bounds.startDateString && bounds.endDateString) {
|
|
952
1155
|
return `${bounds.startDateString} through ${bounds.endDateString}`;
|
|
953
1156
|
}
|
|
@@ -960,26 +1163,37 @@ export async function run(options, { nowMs } = {}) {
|
|
|
960
1163
|
const bounds = boundsForOptions(options, now);
|
|
961
1164
|
const snapshot = await loadSnapshot(options);
|
|
962
1165
|
const events = filterDayEvents(snapshot, bounds);
|
|
963
|
-
|
|
1166
|
+
const writingImage = options.view === "trend" && options.image;
|
|
1167
|
+
const writingEmptyCacheReport = writingImage && options.cacheRate;
|
|
1168
|
+
if (events.length === 0 && !writingEmptyCacheReport) {
|
|
964
1169
|
return [
|
|
965
1170
|
`No model-call events found for ${rangeDescription(options, bounds)} (${bounds.timeZone}).`,
|
|
966
1171
|
`Source: ${sourceLabel(options.input, snapshot)}`,
|
|
967
1172
|
].join("\n");
|
|
968
1173
|
}
|
|
969
|
-
const allRows =
|
|
1174
|
+
const allRows = options.cacheRate
|
|
1175
|
+
? []
|
|
1176
|
+
: aggregateProjects(snapshot, events, options);
|
|
970
1177
|
const rows = allRows.slice(0, options.top);
|
|
971
|
-
const writingImage = options.view === "trend" && options.image;
|
|
972
1178
|
const outputPath = writingImage
|
|
973
1179
|
? options.imageOutput ??
|
|
974
1180
|
resolve(
|
|
975
1181
|
process.cwd(),
|
|
976
|
-
`token-ledger-${options.report ? "report" : "trend"}-${options.trendDays}d.png`,
|
|
1182
|
+
`token-ledger-${options.cacheRate ? "cache-report" : options.report ? "report" : "trend"}-${options.trendDays}d.png`,
|
|
977
1183
|
)
|
|
978
1184
|
: null;
|
|
979
|
-
const imageLabel = options.
|
|
1185
|
+
const imageLabel = options.cacheRate
|
|
1186
|
+
? "cache report"
|
|
1187
|
+
: options.report
|
|
1188
|
+
? "report"
|
|
1189
|
+
: "trend image";
|
|
980
1190
|
if (writingImage) {
|
|
981
1191
|
process.stderr.write(`Token Ledger: generating ${imageLabel} PNG…\n`);
|
|
982
1192
|
}
|
|
1193
|
+
const reportTimeMs = hasInjectedNow ? now.getTime() : Date.now();
|
|
1194
|
+
const verifiedSourceTimeMs = options.autoRefresh && !options.inputExplicit
|
|
1195
|
+
? reportTimeMs
|
|
1196
|
+
: undefined;
|
|
983
1197
|
const output = render(
|
|
984
1198
|
options,
|
|
985
1199
|
snapshot,
|
|
@@ -989,22 +1203,45 @@ export async function run(options, { nowMs } = {}) {
|
|
|
989
1203
|
allRows,
|
|
990
1204
|
snapshotFreshness(
|
|
991
1205
|
snapshot,
|
|
992
|
-
|
|
1206
|
+
reportTimeMs,
|
|
993
1207
|
),
|
|
1208
|
+
verifiedSourceTimeMs,
|
|
994
1209
|
);
|
|
995
1210
|
if (writingImage) {
|
|
996
1211
|
await mkdir(dirname(outputPath), { recursive: true });
|
|
997
1212
|
process.stderr.write(`Token Ledger: encoding ${imageLabel} PNG…\n`);
|
|
998
1213
|
await writeTrendPng(output, outputPath);
|
|
999
1214
|
process.stderr.write(`Token Ledger: finished ${imageLabel} PNG.\n`);
|
|
1000
|
-
|
|
1001
|
-
`Wrote ${
|
|
1215
|
+
const lines = [
|
|
1216
|
+
`Wrote ${imageLabel}: ${outputPath}`,
|
|
1002
1217
|
`Range: ${bounds.startDateString} through ${bounds.endDateString} (${bounds.timeZone})`,
|
|
1003
|
-
]
|
|
1218
|
+
];
|
|
1219
|
+
// Show the finished report on screen right away instead of leaving it to
|
|
1220
|
+
// be dug out of a file browser. Skipped for piped/scripted runs so
|
|
1221
|
+
// automation and CI never pop windows.
|
|
1222
|
+
if (options.openImage && process.stdout.isTTY) {
|
|
1223
|
+
lines.push(
|
|
1224
|
+
openInViewer(outputPath)
|
|
1225
|
+
? "Opened the report in your default image viewer."
|
|
1226
|
+
: "Could not open a viewer automatically; open the file above to see the report.",
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
return lines.join("\n");
|
|
1004
1230
|
}
|
|
1005
1231
|
return output;
|
|
1006
1232
|
}
|
|
1007
1233
|
|
|
1234
|
+
function openInViewer(path) {
|
|
1235
|
+
const platform = process.platform;
|
|
1236
|
+
const [command, args] = platform === "darwin"
|
|
1237
|
+
? ["open", [path]]
|
|
1238
|
+
: platform === "win32"
|
|
1239
|
+
? ["cmd", ["/c", "start", "", path]]
|
|
1240
|
+
: ["xdg-open", [path]];
|
|
1241
|
+
const result = spawnSync(command, args, { stdio: "ignore" });
|
|
1242
|
+
return result.status === 0;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1008
1245
|
function shouldUseInteractive(options) {
|
|
1009
1246
|
return Boolean(
|
|
1010
1247
|
!options.static &&
|
|
@@ -1046,7 +1283,7 @@ async function main() {
|
|
|
1046
1283
|
try {
|
|
1047
1284
|
options = parseArgs(process.argv.slice(2));
|
|
1048
1285
|
if (options.help) {
|
|
1049
|
-
process.stdout.write(`${usage()}\n`);
|
|
1286
|
+
process.stdout.write(`${options.helpAll ? advancedUsage() : usage()}\n`);
|
|
1050
1287
|
return;
|
|
1051
1288
|
}
|
|
1052
1289
|
if (shouldUseInteractive(options)) {
|
|
@@ -1056,11 +1293,11 @@ async function main() {
|
|
|
1056
1293
|
}
|
|
1057
1294
|
} catch (error) {
|
|
1058
1295
|
process.stderr.write(
|
|
1059
|
-
`Token Ledger
|
|
1296
|
+
`Token Ledger: ${safeErrorMessage(error, [
|
|
1060
1297
|
options?.input,
|
|
1061
1298
|
options?.codexHome,
|
|
1062
1299
|
options?.imageOutput,
|
|
1063
|
-
])}\n
|
|
1300
|
+
])}\nRun \`tledger --help\` for examples or \`tledger --help-all\` for every option.\n`,
|
|
1064
1301
|
);
|
|
1065
1302
|
process.exitCode = 1;
|
|
1066
1303
|
}
|