tledger 0.2.1 → 0.3.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.
- package/README.md +150 -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 +1527 -584
- package/bin/token-ledger-trend-terminal.mjs +73 -28
- package/bin/token-ledger-trend.mjs +25 -11
- package/bin/token-ledger-tui.mjs +7 -3
- package/bin/token-ledger.mjs +303 -92
- 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 +589 -279
- 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
|
);
|
|
@@ -798,10 +956,18 @@ function snapshotAgeLabel(ageMs) {
|
|
|
798
956
|
return `${Math.floor(hours / 24)}d old`;
|
|
799
957
|
}
|
|
800
958
|
|
|
959
|
+
function primitiveString(value) {
|
|
960
|
+
try {
|
|
961
|
+
const text = String.prototype.valueOf.call(value);
|
|
962
|
+
return text === value ? text : null;
|
|
963
|
+
} catch {
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
801
968
|
export function snapshotFreshness(snapshot = {}, nowMs = Date.now()) {
|
|
802
|
-
const
|
|
803
|
-
|
|
804
|
-
: NaN;
|
|
969
|
+
const generatedAt = primitiveString(snapshot.generatedAt);
|
|
970
|
+
const generatedAtMs = generatedAt === null ? NaN : Date.parse(generatedAt);
|
|
805
971
|
if (
|
|
806
972
|
!Number.isFinite(generatedAtMs) ||
|
|
807
973
|
!Number.isFinite(nowMs) ||
|
|
@@ -863,6 +1029,14 @@ async function loadSnapshot(options) {
|
|
|
863
1029
|
|
|
864
1030
|
function render(options, snapshot, bounds, events, rows, allRows, freshness) {
|
|
865
1031
|
if (options.view === "trend") {
|
|
1032
|
+
if (options.image && options.cacheRate) {
|
|
1033
|
+
return renderCacheReportImage({
|
|
1034
|
+
snapshot,
|
|
1035
|
+
bounds,
|
|
1036
|
+
days: options.trendDays,
|
|
1037
|
+
options,
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
866
1040
|
const trend = buildUsageTrend(snapshot, bounds);
|
|
867
1041
|
if (options.image) {
|
|
868
1042
|
return renderTrendImage({
|
|
@@ -871,6 +1045,7 @@ function render(options, snapshot, bounds, events, rows, allRows, freshness) {
|
|
|
871
1045
|
trend,
|
|
872
1046
|
days: options.trendDays,
|
|
873
1047
|
options,
|
|
1048
|
+
projectRows: allRows,
|
|
874
1049
|
});
|
|
875
1050
|
}
|
|
876
1051
|
return renderTrendCombo({
|
|
@@ -897,7 +1072,9 @@ function render(options, snapshot, bounds, events, rows, allRows, freshness) {
|
|
|
897
1072
|
const totalTokens = summary.totalTokens;
|
|
898
1073
|
const dateLabel = options.range === "rolling24h"
|
|
899
1074
|
? "last 24 hours"
|
|
900
|
-
: options.range === "
|
|
1075
|
+
: options.range === "rolling"
|
|
1076
|
+
? `last ${options.rollingLabel}`
|
|
1077
|
+
: options.range === "week"
|
|
901
1078
|
? `${bounds.startDateString} through ${bounds.endDateString}`
|
|
902
1079
|
: new Intl.DateTimeFormat("en-US", {
|
|
903
1080
|
timeZone: bounds.timeZone,
|
|
@@ -914,7 +1091,7 @@ function render(options, snapshot, bounds, events, rows, allRows, freshness) {
|
|
|
914
1091
|
|
|
915
1092
|
const header = [
|
|
916
1093
|
`Token Ledger · ${dateLabel} · ${bounds.timeZone}`,
|
|
917
|
-
`${compact(totalTokens)} tokens · ${summary.threadIds.size.toLocaleString()} threads · ${
|
|
1094
|
+
`${compact(totalTokens)} tokens · ${summary.threadIds.size.toLocaleString()} threads · ${summary.calls.toLocaleString()} calls · ${compact(summary.outputTokens)} output`,
|
|
918
1095
|
`Source: ${sourceLabel(options.input, snapshot)}`,
|
|
919
1096
|
"",
|
|
920
1097
|
chart,
|
|
@@ -943,11 +1120,16 @@ function boundsForOptions(options, now = new Date()) {
|
|
|
943
1120
|
if (options.range === "rolling24h") {
|
|
944
1121
|
return rolling24hBounds(now, options.timeZone);
|
|
945
1122
|
}
|
|
1123
|
+
if (options.range === "rolling") {
|
|
1124
|
+
return rollingDurationBounds(now, options.timeZone, options.rollingDays);
|
|
1125
|
+
}
|
|
946
1126
|
return dayBounds(options.date, options.timeZone);
|
|
947
1127
|
}
|
|
948
1128
|
|
|
949
1129
|
function rangeDescription(options, bounds) {
|
|
950
|
-
if (options.range === "rolling24h"
|
|
1130
|
+
if (options.range === "rolling24h" || options.range === "rolling") {
|
|
1131
|
+
return rollingRangeDescription(options);
|
|
1132
|
+
}
|
|
951
1133
|
if (bounds.startDateString && bounds.endDateString) {
|
|
952
1134
|
return `${bounds.startDateString} through ${bounds.endDateString}`;
|
|
953
1135
|
}
|
|
@@ -960,23 +1142,30 @@ export async function run(options, { nowMs } = {}) {
|
|
|
960
1142
|
const bounds = boundsForOptions(options, now);
|
|
961
1143
|
const snapshot = await loadSnapshot(options);
|
|
962
1144
|
const events = filterDayEvents(snapshot, bounds);
|
|
963
|
-
|
|
1145
|
+
const writingImage = options.view === "trend" && options.image;
|
|
1146
|
+
const writingEmptyCacheReport = writingImage && options.cacheRate;
|
|
1147
|
+
if (events.length === 0 && !writingEmptyCacheReport) {
|
|
964
1148
|
return [
|
|
965
1149
|
`No model-call events found for ${rangeDescription(options, bounds)} (${bounds.timeZone}).`,
|
|
966
1150
|
`Source: ${sourceLabel(options.input, snapshot)}`,
|
|
967
1151
|
].join("\n");
|
|
968
1152
|
}
|
|
969
|
-
const allRows =
|
|
1153
|
+
const allRows = options.cacheRate
|
|
1154
|
+
? []
|
|
1155
|
+
: aggregateProjects(snapshot, events, options);
|
|
970
1156
|
const rows = allRows.slice(0, options.top);
|
|
971
|
-
const writingImage = options.view === "trend" && options.image;
|
|
972
1157
|
const outputPath = writingImage
|
|
973
1158
|
? options.imageOutput ??
|
|
974
1159
|
resolve(
|
|
975
1160
|
process.cwd(),
|
|
976
|
-
`token-ledger-${options.report ? "report" : "trend"}-${options.trendDays}d.png`,
|
|
1161
|
+
`token-ledger-${options.cacheRate ? "cache-report" : options.report ? "report" : "trend"}-${options.trendDays}d.png`,
|
|
977
1162
|
)
|
|
978
1163
|
: null;
|
|
979
|
-
const imageLabel = options.
|
|
1164
|
+
const imageLabel = options.cacheRate
|
|
1165
|
+
? "cache report"
|
|
1166
|
+
: options.report
|
|
1167
|
+
? "report"
|
|
1168
|
+
: "trend image";
|
|
980
1169
|
if (writingImage) {
|
|
981
1170
|
process.stderr.write(`Token Ledger: generating ${imageLabel} PNG…\n`);
|
|
982
1171
|
}
|
|
@@ -997,14 +1186,36 @@ export async function run(options, { nowMs } = {}) {
|
|
|
997
1186
|
process.stderr.write(`Token Ledger: encoding ${imageLabel} PNG…\n`);
|
|
998
1187
|
await writeTrendPng(output, outputPath);
|
|
999
1188
|
process.stderr.write(`Token Ledger: finished ${imageLabel} PNG.\n`);
|
|
1000
|
-
|
|
1001
|
-
`Wrote ${
|
|
1189
|
+
const lines = [
|
|
1190
|
+
`Wrote ${imageLabel}: ${outputPath}`,
|
|
1002
1191
|
`Range: ${bounds.startDateString} through ${bounds.endDateString} (${bounds.timeZone})`,
|
|
1003
|
-
]
|
|
1192
|
+
];
|
|
1193
|
+
// Show the finished report on screen right away instead of leaving it to
|
|
1194
|
+
// be dug out of a file browser. Skipped for piped/scripted runs so
|
|
1195
|
+
// automation and CI never pop windows.
|
|
1196
|
+
if (options.openImage && process.stdout.isTTY) {
|
|
1197
|
+
lines.push(
|
|
1198
|
+
openInViewer(outputPath)
|
|
1199
|
+
? "Opened the report in your default image viewer."
|
|
1200
|
+
: "Could not open a viewer automatically; open the file above to see the report.",
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
return lines.join("\n");
|
|
1004
1204
|
}
|
|
1005
1205
|
return output;
|
|
1006
1206
|
}
|
|
1007
1207
|
|
|
1208
|
+
function openInViewer(path) {
|
|
1209
|
+
const platform = process.platform;
|
|
1210
|
+
const [command, args] = platform === "darwin"
|
|
1211
|
+
? ["open", [path]]
|
|
1212
|
+
: platform === "win32"
|
|
1213
|
+
? ["cmd", ["/c", "start", "", path]]
|
|
1214
|
+
: ["xdg-open", [path]];
|
|
1215
|
+
const result = spawnSync(command, args, { stdio: "ignore" });
|
|
1216
|
+
return result.status === 0;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1008
1219
|
function shouldUseInteractive(options) {
|
|
1009
1220
|
return Boolean(
|
|
1010
1221
|
!options.static &&
|
|
@@ -1046,7 +1257,7 @@ async function main() {
|
|
|
1046
1257
|
try {
|
|
1047
1258
|
options = parseArgs(process.argv.slice(2));
|
|
1048
1259
|
if (options.help) {
|
|
1049
|
-
process.stdout.write(`${usage()}\n`);
|
|
1260
|
+
process.stdout.write(`${options.helpAll ? advancedUsage() : usage()}\n`);
|
|
1050
1261
|
return;
|
|
1051
1262
|
}
|
|
1052
1263
|
if (shouldUseInteractive(options)) {
|
|
@@ -1056,11 +1267,11 @@ async function main() {
|
|
|
1056
1267
|
}
|
|
1057
1268
|
} catch (error) {
|
|
1058
1269
|
process.stderr.write(
|
|
1059
|
-
`Token Ledger
|
|
1270
|
+
`Token Ledger: ${safeErrorMessage(error, [
|
|
1060
1271
|
options?.input,
|
|
1061
1272
|
options?.codexHome,
|
|
1062
1273
|
options?.imageOutput,
|
|
1063
|
-
])}\n
|
|
1274
|
+
])}\nRun \`tledger --help\` for examples or \`tledger --help-all\` for every option.\n`,
|
|
1064
1275
|
);
|
|
1065
1276
|
process.exitCode = 1;
|
|
1066
1277
|
}
|