tledger 0.1.2
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 +70 -0
- package/bin/token-ledger-terminal.mjs +693 -0
- package/bin/token-ledger-tui.mjs +95 -0
- package/bin/token-ledger.mjs +568 -0
- package/lib/token-ledger-collector.mjs +1248 -0
- package/package.json +43 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { renderFullscreen, SCREEN_BASE } from "./token-ledger-terminal.mjs";
|
|
2
|
+
|
|
3
|
+
const ENTER_ALT_SCREEN = "\u001b[?1049h";
|
|
4
|
+
const EXIT_ALT_SCREEN = "\u001b[?1049l";
|
|
5
|
+
const HIDE_CURSOR = "\u001b[?25l";
|
|
6
|
+
const SHOW_CURSOR = "\u001b[?25h";
|
|
7
|
+
const CLEAR_SCREEN = "\u001b[2J\u001b[H";
|
|
8
|
+
const RESET = "\u001b[0m";
|
|
9
|
+
|
|
10
|
+
function actionFor(input) {
|
|
11
|
+
const value = String(input);
|
|
12
|
+
if (value.includes("\u001b[A") || value === "k") return "up";
|
|
13
|
+
if (value.includes("\u001b[B") || value === "j") return "down";
|
|
14
|
+
if (value === "q" || value === "Q" || value === "\u0003" || value === "\u001b") return "quit";
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function startInteractive(view) {
|
|
19
|
+
const { options, snapshot, bounds, events, rows, allRows } = view;
|
|
20
|
+
const stdin = process.stdin;
|
|
21
|
+
const stdout = process.stdout;
|
|
22
|
+
if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") {
|
|
23
|
+
throw new Error("Interactive mode requires a terminal. Use --static when redirecting output.");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
let selectedIndex = 0;
|
|
28
|
+
let closed = false;
|
|
29
|
+
|
|
30
|
+
const draw = () => {
|
|
31
|
+
try {
|
|
32
|
+
const width = Math.max(40, options.width || stdout.columns || 120);
|
|
33
|
+
const height = Math.max(12, stdout.rows || 32);
|
|
34
|
+
const screen = renderFullscreen({
|
|
35
|
+
options: { ...options, forceColor: true, selectedIndex },
|
|
36
|
+
snapshot,
|
|
37
|
+
bounds,
|
|
38
|
+
events,
|
|
39
|
+
rows,
|
|
40
|
+
allRows,
|
|
41
|
+
width,
|
|
42
|
+
height,
|
|
43
|
+
});
|
|
44
|
+
stdout.write(`${SCREEN_BASE}${CLEAR_SCREEN}${screen}`);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
finish(error);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const finish = (error = null) => {
|
|
51
|
+
if (closed) return;
|
|
52
|
+
closed = true;
|
|
53
|
+
stdin.off("data", onData);
|
|
54
|
+
stdout.off("resize", draw);
|
|
55
|
+
process.off("SIGINT", onSignal);
|
|
56
|
+
process.off("SIGTERM", onSignal);
|
|
57
|
+
process.off("SIGHUP", onSignal);
|
|
58
|
+
if (stdin.isTTY) stdin.setRawMode(false);
|
|
59
|
+
stdin.pause();
|
|
60
|
+
stdout.write(`${RESET}${SHOW_CURSOR}${EXIT_ALT_SCREEN}`);
|
|
61
|
+
if (error) reject(error);
|
|
62
|
+
else resolve();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const onSignal = () => finish();
|
|
66
|
+
const onData = (input) => {
|
|
67
|
+
const action = actionFor(input);
|
|
68
|
+
if (action === "quit") {
|
|
69
|
+
finish();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (action === "up") {
|
|
73
|
+
selectedIndex = Math.max(0, selectedIndex - 1);
|
|
74
|
+
draw();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (action === "down") {
|
|
78
|
+
selectedIndex = Math.min(Math.max(0, rows.length - 1), selectedIndex + 1);
|
|
79
|
+
draw();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
stdin.setRawMode(true);
|
|
85
|
+
stdin.setEncoding("utf8");
|
|
86
|
+
stdin.resume();
|
|
87
|
+
stdin.on("data", onData);
|
|
88
|
+
stdout.on("resize", draw);
|
|
89
|
+
process.once("SIGINT", onSignal);
|
|
90
|
+
process.once("SIGTERM", onSignal);
|
|
91
|
+
process.once("SIGHUP", onSignal);
|
|
92
|
+
stdout.write(`${ENTER_ALT_SCREEN}${SCREEN_BASE}${HIDE_CURSOR}${CLEAR_SCREEN}`);
|
|
93
|
+
draw();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
readFile,
|
|
6
|
+
stat,
|
|
7
|
+
} from "node:fs/promises";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { basename, resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
import { renderTerminal } from "./token-ledger-terminal.mjs";
|
|
13
|
+
import { startInteractive } from "./token-ledger-tui.mjs";
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_SNAPSHOT = resolve(
|
|
16
|
+
homedir(),
|
|
17
|
+
".token-ledger",
|
|
18
|
+
"token-ledger-snapshot.json",
|
|
19
|
+
);
|
|
20
|
+
const DEFAULT_TOP = 10;
|
|
21
|
+
const DEFAULT_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
22
|
+
|
|
23
|
+
function usage() {
|
|
24
|
+
return `Token Ledger terminal usage
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
tledger
|
|
28
|
+
tledger week [end-day]
|
|
29
|
+
tledger day [day]
|
|
30
|
+
|
|
31
|
+
Options:
|
|
32
|
+
--date <day> Date as YYYY-MM-DD, today, or yesterday
|
|
33
|
+
--input <file> Snapshot to read (default: ~/.token-ledger/token-ledger-snapshot.json)
|
|
34
|
+
--refresh Rebuild the default snapshot from CODEX_HOME or ~/.codex
|
|
35
|
+
--no-refresh Use the cached snapshot without checking local JSONL files
|
|
36
|
+
--codex-home <dir> Codex data root used when refreshing
|
|
37
|
+
--tz <name> IANA timezone (default: machine timezone)
|
|
38
|
+
--top <number> Number of projects to show (default: 10)
|
|
39
|
+
--width <number> Terminal layout width in columns
|
|
40
|
+
--raw-projects Keep singleton thread labels instead of grouping them
|
|
41
|
+
--no-archived Skip archived_sessions when refreshing
|
|
42
|
+
--plain Disable terminal colors
|
|
43
|
+
--ascii Use ASCII bars instead of Unicode blocks
|
|
44
|
+
--static Print once instead of opening the interactive dashboard
|
|
45
|
+
--help Show this help
|
|
46
|
+
|
|
47
|
+
The default view is the seven-day window ending today. Token Ledger never
|
|
48
|
+
uploads data or renders message bodies, tool payloads, or credentials.`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readOption(argv, index, name) {
|
|
52
|
+
const value = argv[index + 1];
|
|
53
|
+
if (!value || value.startsWith("--")) {
|
|
54
|
+
throw new Error(`${name} requires a value.`);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseArgs(argv) {
|
|
60
|
+
const commandExplicit = argv[0] === "day" || argv[0] === "week";
|
|
61
|
+
if (argv[0] && !argv[0].startsWith("-") && !commandExplicit) {
|
|
62
|
+
throw new Error(`Unknown command: ${argv[0]}. Use day or week.`);
|
|
63
|
+
}
|
|
64
|
+
const command = commandExplicit ? argv[0] : "week";
|
|
65
|
+
const options = {
|
|
66
|
+
range: command,
|
|
67
|
+
date: null,
|
|
68
|
+
input: DEFAULT_SNAPSHOT,
|
|
69
|
+
inputExplicit: false,
|
|
70
|
+
refresh: false,
|
|
71
|
+
autoRefresh: true,
|
|
72
|
+
codexHome: resolve(process.env.CODEX_HOME || `${homedir()}/.codex`),
|
|
73
|
+
includeArchived: true,
|
|
74
|
+
timeZone: DEFAULT_TIME_ZONE,
|
|
75
|
+
top: DEFAULT_TOP,
|
|
76
|
+
width: null,
|
|
77
|
+
rawProjects: false,
|
|
78
|
+
plain: false,
|
|
79
|
+
ascii: false,
|
|
80
|
+
static: false,
|
|
81
|
+
help: false,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
let index = commandExplicit ? 1 : 0;
|
|
85
|
+
for (; index < argv.length; index += 1) {
|
|
86
|
+
const argument = argv[index];
|
|
87
|
+
if (argument === "--help" || argument === "-h") {
|
|
88
|
+
options.help = true;
|
|
89
|
+
} else if (argument === "--date") {
|
|
90
|
+
options.date = readOption(argv, index, "--date");
|
|
91
|
+
index += 1;
|
|
92
|
+
} else if (argument === "--input") {
|
|
93
|
+
options.input = resolve(readOption(argv, index, "--input"));
|
|
94
|
+
options.inputExplicit = true;
|
|
95
|
+
index += 1;
|
|
96
|
+
} else if (argument === "--refresh") {
|
|
97
|
+
options.refresh = true;
|
|
98
|
+
} else if (argument === "--no-refresh") {
|
|
99
|
+
options.autoRefresh = false;
|
|
100
|
+
} else if (argument === "--codex-home") {
|
|
101
|
+
options.codexHome = resolve(readOption(argv, index, "--codex-home"));
|
|
102
|
+
index += 1;
|
|
103
|
+
} else if (argument === "--tz") {
|
|
104
|
+
options.timeZone = readOption(argv, index, "--tz");
|
|
105
|
+
index += 1;
|
|
106
|
+
} else if (argument === "--top") {
|
|
107
|
+
const value = Number(readOption(argv, index, "--top"));
|
|
108
|
+
if (!Number.isInteger(value) || value < 1 || value > 100) {
|
|
109
|
+
throw new Error("--top must be an integer from 1 to 100.");
|
|
110
|
+
}
|
|
111
|
+
options.top = value;
|
|
112
|
+
index += 1;
|
|
113
|
+
} else if (argument === "--width") {
|
|
114
|
+
const value = Number(readOption(argv, index, "--width"));
|
|
115
|
+
if (!Number.isInteger(value) || value < 40 || value > 200) {
|
|
116
|
+
throw new Error("--width must be an integer from 40 to 200.");
|
|
117
|
+
}
|
|
118
|
+
options.width = value;
|
|
119
|
+
index += 1;
|
|
120
|
+
} else if (argument === "--raw-projects") {
|
|
121
|
+
options.rawProjects = true;
|
|
122
|
+
} else if (argument === "--no-archived") {
|
|
123
|
+
options.includeArchived = false;
|
|
124
|
+
} else if (argument === "--plain") {
|
|
125
|
+
options.plain = true;
|
|
126
|
+
} else if (argument === "--ascii") {
|
|
127
|
+
options.ascii = true;
|
|
128
|
+
} else if (argument === "--static") {
|
|
129
|
+
options.static = true;
|
|
130
|
+
} else if (commandExplicit && !argument.startsWith("-") && !options.date) {
|
|
131
|
+
options.date = argument;
|
|
132
|
+
} else {
|
|
133
|
+
throw new Error(`Unknown option: ${argument}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!options.help && !options.date) {
|
|
138
|
+
options.date = "today";
|
|
139
|
+
}
|
|
140
|
+
if (!options.help && options.refresh && !options.autoRefresh) {
|
|
141
|
+
throw new Error("--refresh cannot be combined with --no-refresh.");
|
|
142
|
+
}
|
|
143
|
+
if (!options.help && options.refresh && options.inputExplicit) {
|
|
144
|
+
throw new Error("--refresh cannot be combined with --input.");
|
|
145
|
+
}
|
|
146
|
+
return options;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function numericDateParts(date) {
|
|
150
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
151
|
+
timeZone: date.timeZone,
|
|
152
|
+
year: "numeric",
|
|
153
|
+
month: "2-digit",
|
|
154
|
+
day: "2-digit",
|
|
155
|
+
}).formatToParts(date.value);
|
|
156
|
+
const values = Object.fromEntries(
|
|
157
|
+
parts
|
|
158
|
+
.filter((part) => part.type !== "literal")
|
|
159
|
+
.map((part) => [part.type, Number(part.value)]),
|
|
160
|
+
);
|
|
161
|
+
return values;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function dateStringFromParts(parts) {
|
|
165
|
+
return [parts.year, parts.month, parts.day]
|
|
166
|
+
.map((value, index) => (index === 0 ? String(value) : String(value).padStart(2, "0")))
|
|
167
|
+
.join("-");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function shiftCalendarDate(value, amount) {
|
|
171
|
+
const date = new Date(`${value}T00:00:00.000Z`);
|
|
172
|
+
date.setUTCDate(date.getUTCDate() + amount);
|
|
173
|
+
return date.toISOString().slice(0, 10);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function validateTimeZone(timeZone) {
|
|
177
|
+
try {
|
|
178
|
+
new Intl.DateTimeFormat("en-US", { timeZone }).format();
|
|
179
|
+
} catch {
|
|
180
|
+
throw new Error(`Unknown IANA timezone: ${timeZone}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function offsetAt(instant, timeZone) {
|
|
185
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
186
|
+
timeZone,
|
|
187
|
+
year: "numeric",
|
|
188
|
+
month: "2-digit",
|
|
189
|
+
day: "2-digit",
|
|
190
|
+
hour: "2-digit",
|
|
191
|
+
minute: "2-digit",
|
|
192
|
+
second: "2-digit",
|
|
193
|
+
hourCycle: "h23",
|
|
194
|
+
}).formatToParts(instant);
|
|
195
|
+
const values = Object.fromEntries(
|
|
196
|
+
parts
|
|
197
|
+
.filter((part) => part.type !== "literal")
|
|
198
|
+
.map((part) => [part.type, Number(part.value)]),
|
|
199
|
+
);
|
|
200
|
+
return (
|
|
201
|
+
Date.UTC(
|
|
202
|
+
values.year,
|
|
203
|
+
values.month - 1,
|
|
204
|
+
values.day,
|
|
205
|
+
values.hour,
|
|
206
|
+
values.minute,
|
|
207
|
+
values.second,
|
|
208
|
+
) - instant.getTime()
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function zonedMidnight(dateString, timeZone) {
|
|
213
|
+
const [year, month, day] = dateString.split("-").map(Number);
|
|
214
|
+
const utcGuess = Date.UTC(year, month - 1, day);
|
|
215
|
+
let instant = new Date(utcGuess - offsetAt(new Date(utcGuess), timeZone));
|
|
216
|
+
const refinedOffset = offsetAt(instant, timeZone);
|
|
217
|
+
instant = new Date(utcGuess - refinedOffset);
|
|
218
|
+
return instant;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function dayBounds(value, timeZone) {
|
|
222
|
+
validateTimeZone(timeZone);
|
|
223
|
+
let dateString = value;
|
|
224
|
+
if (value === "today" || value === "yesterday") {
|
|
225
|
+
const today = dateStringFromParts(numericDateParts({
|
|
226
|
+
value: new Date(),
|
|
227
|
+
timeZone,
|
|
228
|
+
}));
|
|
229
|
+
dateString = value === "today" ? today : shiftCalendarDate(today, -1);
|
|
230
|
+
}
|
|
231
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
|
232
|
+
throw new Error("Day must be YYYY-MM-DD, today, or yesterday.");
|
|
233
|
+
}
|
|
234
|
+
const [year, month, day] = dateString.split("-").map(Number);
|
|
235
|
+
const check = new Date(Date.UTC(year, month - 1, day));
|
|
236
|
+
if (
|
|
237
|
+
check.getUTCFullYear() !== year ||
|
|
238
|
+
check.getUTCMonth() + 1 !== month ||
|
|
239
|
+
check.getUTCDate() !== day
|
|
240
|
+
) {
|
|
241
|
+
throw new Error(`Invalid calendar day: ${dateString}`);
|
|
242
|
+
}
|
|
243
|
+
const nextDateString = shiftCalendarDate(dateString, 1);
|
|
244
|
+
const start = zonedMidnight(dateString, timeZone);
|
|
245
|
+
const end = zonedMidnight(nextDateString, timeZone);
|
|
246
|
+
return { dateString, start, end, timeZone };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function weekBounds(value, timeZone) {
|
|
250
|
+
const endDay = dayBounds(value, timeZone);
|
|
251
|
+
const startDateString = shiftCalendarDate(endDay.dateString, -6);
|
|
252
|
+
return {
|
|
253
|
+
...endDay,
|
|
254
|
+
startDateString,
|
|
255
|
+
endDateString: endDay.dateString,
|
|
256
|
+
start: zonedMidnight(startDateString, timeZone),
|
|
257
|
+
rangeDays: 7,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function sanitizeTerminalText(value) {
|
|
262
|
+
return String(value ?? "")
|
|
263
|
+
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "")
|
|
264
|
+
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
265
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function cleanLabel(value, fallback) {
|
|
269
|
+
const label = sanitizeTerminalText(value)
|
|
270
|
+
.replace(/\s+/g, " ")
|
|
271
|
+
.trim();
|
|
272
|
+
return label || fallback;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function displayLabel(value) {
|
|
276
|
+
const label = cleanLabel(value, "Unlabelled activity");
|
|
277
|
+
if (label.length <= 30) return label;
|
|
278
|
+
return `${label.slice(0, 14)}…${label.slice(-13)}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function oneOffProjects(snapshot) {
|
|
282
|
+
const threadIdsByProject = new Map();
|
|
283
|
+
const add = (project, threadId) => {
|
|
284
|
+
if (!project || !threadId) return;
|
|
285
|
+
const normalizedProject = cleanLabel(project, "Unlabelled activity");
|
|
286
|
+
const ids = threadIdsByProject.get(normalizedProject) ?? new Set();
|
|
287
|
+
ids.add(threadId);
|
|
288
|
+
threadIdsByProject.set(normalizedProject, ids);
|
|
289
|
+
};
|
|
290
|
+
for (const event of snapshot.events ?? []) add(event.project, event.threadId);
|
|
291
|
+
for (const thread of snapshot.threads ?? []) add(thread.project, thread.id);
|
|
292
|
+
return new Set(
|
|
293
|
+
[...threadIdsByProject.entries()]
|
|
294
|
+
.filter(([, threadIds]) => threadIds.size === 1)
|
|
295
|
+
.map(([project]) => project),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function modelLabel(value) {
|
|
300
|
+
const model = cleanLabel(value, "Unknown model");
|
|
301
|
+
const lower = model.toLowerCase();
|
|
302
|
+
if (lower.includes("sol")) return "Sol";
|
|
303
|
+
if (lower.includes("luna")) return "Luna";
|
|
304
|
+
if (lower.includes("terra")) return "Terra";
|
|
305
|
+
if (lower === "gpt-5.5") return "GPT-5.5";
|
|
306
|
+
if (lower === "gpt-5.4") return "GPT-5.4";
|
|
307
|
+
return model;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function filterDayEvents(snapshot, bounds) {
|
|
311
|
+
const start = bounds.start.getTime();
|
|
312
|
+
const end = bounds.end.getTime();
|
|
313
|
+
return (snapshot.events ?? []).filter((event) => {
|
|
314
|
+
const timestamp = new Date(event.timestamp).getTime();
|
|
315
|
+
return Number.isFinite(timestamp) && timestamp >= start && timestamp < end;
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function aggregateProjects(snapshot, events, options = {}) {
|
|
320
|
+
const singletonProjects = options.rawProjects ? new Set() : oneOffProjects(snapshot);
|
|
321
|
+
const grouped = new Map();
|
|
322
|
+
|
|
323
|
+
for (const event of events) {
|
|
324
|
+
const rawProject = cleanLabel(event.project, "Unlabelled activity");
|
|
325
|
+
const project =
|
|
326
|
+
!options.rawProjects && singletonProjects.has(rawProject)
|
|
327
|
+
? "Other activity"
|
|
328
|
+
: rawProject;
|
|
329
|
+
const row =
|
|
330
|
+
grouped.get(project) ?? {
|
|
331
|
+
project,
|
|
332
|
+
displayProject: displayLabel(project),
|
|
333
|
+
totalTokens: 0,
|
|
334
|
+
outputTokens: 0,
|
|
335
|
+
reasoningTokens: 0,
|
|
336
|
+
toolCalls: 0,
|
|
337
|
+
events: 0,
|
|
338
|
+
threadIds: new Set(),
|
|
339
|
+
models: new Map(),
|
|
340
|
+
};
|
|
341
|
+
row.totalTokens += Number(event.totalTokens) || 0;
|
|
342
|
+
row.outputTokens += Number(event.outputTokens) || 0;
|
|
343
|
+
row.reasoningTokens += Number(event.reasoningTokens) || 0;
|
|
344
|
+
row.toolCalls += Number(event.toolCalls) || 0;
|
|
345
|
+
row.events += 1;
|
|
346
|
+
if (event.threadId) row.threadIds.add(event.threadId);
|
|
347
|
+
const model = modelLabel(event.model);
|
|
348
|
+
const modelRow = row.models.get(model) ?? {
|
|
349
|
+
model,
|
|
350
|
+
totalTokens: 0,
|
|
351
|
+
events: 0,
|
|
352
|
+
};
|
|
353
|
+
modelRow.totalTokens += Number(event.totalTokens) || 0;
|
|
354
|
+
modelRow.events += 1;
|
|
355
|
+
row.models.set(model, modelRow);
|
|
356
|
+
grouped.set(project, row);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return [...grouped.values()]
|
|
360
|
+
.map((row) => ({
|
|
361
|
+
...row,
|
|
362
|
+
threads: row.threadIds.size,
|
|
363
|
+
models: [...row.models.values()].sort(
|
|
364
|
+
(left, right) => right.totalTokens - left.totalTokens,
|
|
365
|
+
),
|
|
366
|
+
}))
|
|
367
|
+
.sort((left, right) => {
|
|
368
|
+
if (right.totalTokens !== left.totalTokens) {
|
|
369
|
+
return right.totalTokens - left.totalTokens;
|
|
370
|
+
}
|
|
371
|
+
return left.project.localeCompare(right.project);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function sourceLabel(snapshotPath, snapshot) {
|
|
376
|
+
const generated = snapshot.generatedAt
|
|
377
|
+
? new Date(snapshot.generatedAt).toLocaleString("en-US", {
|
|
378
|
+
dateStyle: "medium",
|
|
379
|
+
timeStyle: "short",
|
|
380
|
+
})
|
|
381
|
+
: "unknown time";
|
|
382
|
+
return `${cleanLabel(basename(snapshotPath), "snapshot")} · captured ${generated}`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function readSnapshot(snapshotPath) {
|
|
386
|
+
let parsed;
|
|
387
|
+
try {
|
|
388
|
+
parsed = JSON.parse(await readFile(snapshotPath, "utf8"));
|
|
389
|
+
} catch (error) {
|
|
390
|
+
if (error?.code === "ENOENT") {
|
|
391
|
+
throw new Error(`Snapshot not found: ${sanitizeTerminalText(snapshotPath)}`);
|
|
392
|
+
}
|
|
393
|
+
throw new Error(
|
|
394
|
+
`Could not read snapshot ${sanitizeTerminalText(snapshotPath)}: ${sanitizeTerminalText(error.message)}`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
if (!parsed || !Array.isArray(parsed.events)) {
|
|
398
|
+
throw new Error(
|
|
399
|
+
`Snapshot is missing its events array: ${sanitizeTerminalText(snapshotPath)}`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
return parsed;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function refreshSnapshot(options) {
|
|
406
|
+
if (!existsSync(options.codexHome)) {
|
|
407
|
+
throw new Error(
|
|
408
|
+
`Codex data directory not found: ${sanitizeTerminalText(options.codexHome)}`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
const { collectUsage, writePrivateSnapshot } = await import(
|
|
412
|
+
"../lib/token-ledger-collector.mjs"
|
|
413
|
+
);
|
|
414
|
+
process.stderr.write("Token Ledger: refreshing local snapshot…\n");
|
|
415
|
+
const snapshot = await collectUsage(
|
|
416
|
+
{
|
|
417
|
+
output: options.input,
|
|
418
|
+
codexHome: options.codexHome,
|
|
419
|
+
includeArchived: options.includeArchived,
|
|
420
|
+
since: null,
|
|
421
|
+
},
|
|
422
|
+
({ current, total }) => {
|
|
423
|
+
process.stderr.write(`\rToken Ledger: scanned ${current}/${total} rollout files`);
|
|
424
|
+
},
|
|
425
|
+
);
|
|
426
|
+
process.stderr.write("\n");
|
|
427
|
+
await writePrivateSnapshot(options.input, snapshot);
|
|
428
|
+
return snapshot;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export function snapshotNeedsRefresh(
|
|
432
|
+
snapshotMtimeMs,
|
|
433
|
+
latestSourceMtimeMs,
|
|
434
|
+
cachedSourceFingerprint,
|
|
435
|
+
expectedSourceFingerprint,
|
|
436
|
+
cachedSourceFileCount,
|
|
437
|
+
currentSourceFileCount,
|
|
438
|
+
) {
|
|
439
|
+
return (
|
|
440
|
+
cachedSourceFingerprint !== expectedSourceFingerprint ||
|
|
441
|
+
cachedSourceFileCount !== currentSourceFileCount ||
|
|
442
|
+
latestSourceMtimeMs > snapshotMtimeMs
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function loadSnapshot(options) {
|
|
447
|
+
if (options.refresh) {
|
|
448
|
+
return refreshSnapshot(options);
|
|
449
|
+
}
|
|
450
|
+
if (!existsSync(options.input)) {
|
|
451
|
+
if (options.inputExplicit || !options.autoRefresh) {
|
|
452
|
+
throw new Error(`Snapshot not found: ${sanitizeTerminalText(options.input)}`);
|
|
453
|
+
}
|
|
454
|
+
return refreshSnapshot(options);
|
|
455
|
+
}
|
|
456
|
+
if (!options.autoRefresh || options.inputExplicit) {
|
|
457
|
+
return readSnapshot(options.input);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const { sourceFingerprint, sourceState } = await import(
|
|
461
|
+
"../lib/token-ledger-collector.mjs"
|
|
462
|
+
);
|
|
463
|
+
const [snapshotStat, currentSourceState, snapshot] = await Promise.all([
|
|
464
|
+
stat(options.input),
|
|
465
|
+
sourceState(options.codexHome, options.includeArchived),
|
|
466
|
+
readSnapshot(options.input),
|
|
467
|
+
]);
|
|
468
|
+
if (snapshotNeedsRefresh(
|
|
469
|
+
snapshotStat.mtimeMs,
|
|
470
|
+
currentSourceState.latestMtimeMs,
|
|
471
|
+
snapshot.provenance?.sourceFingerprint,
|
|
472
|
+
sourceFingerprint(options.codexHome, options.includeArchived),
|
|
473
|
+
snapshot.coverage?.sourceFileCount,
|
|
474
|
+
currentSourceState.fileCount,
|
|
475
|
+
)) {
|
|
476
|
+
return refreshSnapshot(options);
|
|
477
|
+
}
|
|
478
|
+
return snapshot;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function render(options, snapshot, bounds, events, rows, allRows) {
|
|
482
|
+
return renderTerminal({ options, snapshot, bounds, events, rows, allRows });
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export async function run(options) {
|
|
486
|
+
const bounds = options.range === "week"
|
|
487
|
+
? weekBounds(options.date, options.timeZone)
|
|
488
|
+
: dayBounds(options.date, options.timeZone);
|
|
489
|
+
const snapshot = await loadSnapshot(options);
|
|
490
|
+
const events = filterDayEvents(snapshot, bounds);
|
|
491
|
+
if (events.length === 0) {
|
|
492
|
+
const rangeDescription = options.range === "week"
|
|
493
|
+
? `${bounds.startDateString} through ${bounds.endDateString}`
|
|
494
|
+
: bounds.dateString;
|
|
495
|
+
return [
|
|
496
|
+
`No model-call events found for ${rangeDescription} (${bounds.timeZone}).`,
|
|
497
|
+
`Source: ${sourceLabel(options.input, snapshot)}`,
|
|
498
|
+
].join("\n");
|
|
499
|
+
}
|
|
500
|
+
const allRows = aggregateProjects(snapshot, events, options);
|
|
501
|
+
const rows = allRows.slice(0, options.top);
|
|
502
|
+
return render(options, snapshot, bounds, events, rows, allRows);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function shouldUseInteractive(options) {
|
|
506
|
+
return Boolean(
|
|
507
|
+
!options.static &&
|
|
508
|
+
!options.plain &&
|
|
509
|
+
!process.env.NO_COLOR &&
|
|
510
|
+
process.stdin.isTTY &&
|
|
511
|
+
process.stdout.isTTY,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function runInteractive(options) {
|
|
516
|
+
const bounds = options.range === "week"
|
|
517
|
+
? weekBounds(options.date, options.timeZone)
|
|
518
|
+
: dayBounds(options.date, options.timeZone);
|
|
519
|
+
const snapshot = await loadSnapshot(options);
|
|
520
|
+
const events = filterDayEvents(snapshot, bounds);
|
|
521
|
+
if (events.length === 0) {
|
|
522
|
+
const rangeDescription = options.range === "week"
|
|
523
|
+
? `${bounds.startDateString} through ${bounds.endDateString}`
|
|
524
|
+
: bounds.dateString;
|
|
525
|
+
process.stdout.write([
|
|
526
|
+
`No model-call events found for ${rangeDescription} (${bounds.timeZone}).`,
|
|
527
|
+
`Source: ${sourceLabel(options.input, snapshot)}`,
|
|
528
|
+
"",
|
|
529
|
+
].join("\n"));
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const allRows = aggregateProjects(snapshot, events, options);
|
|
533
|
+
await startInteractive({
|
|
534
|
+
options,
|
|
535
|
+
snapshot,
|
|
536
|
+
bounds,
|
|
537
|
+
events,
|
|
538
|
+
rows: allRows.slice(0, options.top),
|
|
539
|
+
allRows,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function main() {
|
|
544
|
+
let options;
|
|
545
|
+
try {
|
|
546
|
+
options = parseArgs(process.argv.slice(2));
|
|
547
|
+
if (options.help) {
|
|
548
|
+
process.stdout.write(`${usage()}\n`);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (shouldUseInteractive(options)) {
|
|
552
|
+
await runInteractive(options);
|
|
553
|
+
} else {
|
|
554
|
+
process.stdout.write(`${await run({ ...options, static: true })}\n`);
|
|
555
|
+
}
|
|
556
|
+
} catch (error) {
|
|
557
|
+
process.stderr.write(
|
|
558
|
+
`Token Ledger CLI failed: ${sanitizeTerminalText(error.message)}\n\n${usage()}\n`,
|
|
559
|
+
);
|
|
560
|
+
process.exitCode = 1;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const invokedPath = process.argv[1] ? realpathSync(resolve(process.argv[1])) : "";
|
|
565
|
+
const modulePath = realpathSync(fileURLToPath(import.meta.url));
|
|
566
|
+
if (invokedPath === modulePath) {
|
|
567
|
+
main();
|
|
568
|
+
}
|