tokenmaxing 0.1.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.
@@ -0,0 +1,198 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ addModelUsage,
5
+ addMonthlyTokens,
6
+ addSpend,
7
+ addTokens,
8
+ countJsonlEntries,
9
+ createEmptyReport,
10
+ fileExists,
11
+ findDirs,
12
+ normalizeClaudeUsage,
13
+ readJson,
14
+ registerTimestamp,
15
+ toDay,
16
+ toMonth,
17
+ tokenTotal,
18
+ walkFiles,
19
+ } from "../utils.js";
20
+ import { finalizeReport } from "../metrics.js";
21
+
22
+ export async function scanClaude(options = {}) {
23
+ const claudeHome = options.claudeHome;
24
+ const report = createEmptyReport("Claude Code", "claude", claudeHome);
25
+
26
+ if (!fileExists(claudeHome)) {
27
+ report.notes.push("Claude home not found.");
28
+ return finalizeReport(report);
29
+ }
30
+
31
+ report.available = true;
32
+ report.projectScanRoot = options.scanRoot;
33
+ report.projectConfigExamples = findProjectClaudeDirs(options.scanRoot, claudeHome, options.maxScanDepth);
34
+ report.projectConfigDirs = report.projectConfigExamples.length;
35
+ report.historyEntries = countJsonlEntries(path.join(claudeHome, "history.jsonl"));
36
+
37
+ const projectRoot = path.join(claudeHome, "projects");
38
+ const sessionFiles = walkFiles(projectRoot, (_file, name) => name.endsWith(".jsonl"));
39
+ report.sessionFiles = sessionFiles.length;
40
+
41
+ const transcriptStats = scanClaudeTranscripts(sessionFiles);
42
+ report.dailyActivity = transcriptStats.dailyActivity;
43
+ report.hourlyActivity = transcriptStats.hourlyActivity;
44
+ report.totalMessages = transcriptStats.messageCount;
45
+ report.totalSessions = transcriptStats.sessionIds.size || sessionFiles.length;
46
+ for (const timestamp of transcriptStats.seenTimestamps) registerTimestamp(report, timestamp);
47
+
48
+ const stats = readJson(path.join(claudeHome, "stats-cache.json"));
49
+ if (stats) {
50
+ applyClaudeStats(report, stats);
51
+ report.notes.push(`Used stats-cache.json last computed ${stats.lastComputedDate || "unknown date"}.`);
52
+ } else {
53
+ applyTranscriptUsage(report, transcriptStats);
54
+ report.notes.push("stats-cache.json not found; token usage inferred from transcripts.");
55
+ }
56
+
57
+ if (!report.totalMessages && transcriptStats.messageCount) report.totalMessages = transcriptStats.messageCount;
58
+ if (!report.totalSessions && transcriptStats.sessionIds.size) report.totalSessions = transcriptStats.sessionIds.size;
59
+ if (report.projectConfigDirs > 0) {
60
+ report.notes.push(
61
+ `Found ${report.projectConfigDirs} project .claude directories under ${report.projectScanRoot}; these are treated as project config, not usage transcripts.`,
62
+ );
63
+ }
64
+
65
+ return finalizeReport(report);
66
+ }
67
+
68
+ function findProjectClaudeDirs(scanRoot, claudeHome, maxScanDepth) {
69
+ if (!scanRoot || !fileExists(scanRoot)) return [];
70
+ const globalRoot = path.resolve(claudeHome);
71
+ const dirs = findDirs(scanRoot, ".claude", { maxDepth: maxScanDepth });
72
+ return dirs.filter((dir) => path.resolve(dir) !== globalRoot);
73
+ }
74
+
75
+ function scanClaudeTranscripts(files) {
76
+ const result = {
77
+ dailyActivity: {},
78
+ hourlyActivity: Array.from({ length: 24 }, () => 0),
79
+ messageCount: 0,
80
+ sessionIds: new Set(),
81
+ seenTimestamps: [],
82
+ usageEvents: [],
83
+ };
84
+
85
+ for (const file of files) {
86
+ for (const line of readJsonLines(file)) {
87
+ const timestamp = line.timestamp;
88
+ if (timestamp) result.seenTimestamps.push(timestamp);
89
+ if (line.sessionId) result.sessionIds.add(line.sessionId);
90
+ if (line.type === "user" || line.type === "assistant") {
91
+ result.messageCount += 1;
92
+ const date = timestamp ? new Date(timestamp) : null;
93
+ if (date && Number.isFinite(date.getTime())) {
94
+ result.hourlyActivity[date.getHours()] += 1;
95
+ const day = toDay(date);
96
+ if (day) result.dailyActivity[day] = (result.dailyActivity[day] || 0) + 1;
97
+ }
98
+ }
99
+
100
+ const usage = line.message?.usage;
101
+ const model = line.message?.model || line.model;
102
+ if (usage && model) {
103
+ result.usageEvents.push({
104
+ timestamp,
105
+ model,
106
+ tokens: normalizeClaudeUsage(usage),
107
+ });
108
+ }
109
+ }
110
+ }
111
+
112
+ return result;
113
+ }
114
+
115
+ function applyClaudeStats(report, stats) {
116
+ report.totalSessions = Number(stats.totalSessions || report.totalSessions || 0);
117
+ report.totalMessages = Number(stats.totalMessages || report.totalMessages || 0);
118
+ if (stats.firstSessionDate) registerTimestamp(report, stats.firstSessionDate);
119
+
120
+ const modelTotals = {};
121
+ for (const [model, usage] of Object.entries(stats.modelUsage || {})) {
122
+ const tokens = normalizeClaudeUsage(usage);
123
+ const costUSD = Number(usage.costUSD || 0);
124
+ modelTotals[model] = tokenTotal(tokens);
125
+ addTokens(report.tokens, tokens);
126
+ addModelUsage(report, model, tokens, { costUSD });
127
+ }
128
+
129
+ for (const day of stats.dailyModelTokens || []) {
130
+ const month = day.date ? day.date.slice(0, 7) : null;
131
+ if (!month || !day.tokensByModel) continue;
132
+ for (const [model, tokensForDay] of Object.entries(day.tokensByModel)) {
133
+ const totalForModel = modelTotals[model] || 0;
134
+ const modelUsage = stats.modelUsage?.[model] || {};
135
+ const cost = totalForModel > 0 ? (Number(modelUsage.costUSD || 0) * Number(tokensForDay || 0)) / totalForModel : 0;
136
+ addSpend(report, month, cost);
137
+ addMonthlyTokens(report, month, { total: Number(tokensForDay || 0) });
138
+ }
139
+ }
140
+
141
+ if (!Object.keys(report.monthlySpendUSD).length) {
142
+ const totalCost = Object.values(stats.modelUsage || {}).reduce((sum, usage) => sum + Number(usage.costUSD || 0), 0);
143
+ if (totalCost > 0) {
144
+ report.knownSpendUSD += totalCost;
145
+ report.notes.push("Claude spend total found, but daily model tokens were unavailable for monthly allocation.");
146
+ }
147
+ }
148
+
149
+ mergeStatsDailyMessages(report, stats.dailyActivity || []);
150
+ }
151
+
152
+ // Transcripts older than the cleanup window are pruned from disk, but
153
+ // stats-cache.json keeps recorded per-day message counts for the full history.
154
+ // Merge those recorded counts in for days the transcripts no longer cover.
155
+ function mergeStatsDailyMessages(report, statDays) {
156
+ let merged = 0;
157
+ for (const day of statDays) {
158
+ const recorded = Number(day.messageCount);
159
+ if (!day.date || !recorded) continue;
160
+ if (!report.dailyActivity[day.date]) merged += 1;
161
+ report.dailyActivity[day.date] = Math.max(report.dailyActivity[day.date] || 0, recorded);
162
+ }
163
+ if (merged) {
164
+ report.notes.push(
165
+ `Added ${merged} days of message counts recorded in stats-cache (transcripts pruned from disk).`,
166
+ );
167
+ }
168
+ }
169
+
170
+ function applyTranscriptUsage(report, transcriptStats) {
171
+ for (const event of transcriptStats.usageEvents) {
172
+ addTokens(report.tokens, event.tokens);
173
+ addModelUsage(report, event.model, event.tokens);
174
+ addMonthlyTokens(report, toMonth(event.timestamp), event.tokens);
175
+ }
176
+ report.unknownSpend = true;
177
+ }
178
+
179
+ function readJsonLines(file) {
180
+ let text;
181
+ try {
182
+ text = fs.readFileSync(file, "utf8");
183
+ } catch {
184
+ return [];
185
+ }
186
+
187
+ const parsed = [];
188
+ for (const line of text.split("\n")) {
189
+ if (!line.trim()) continue;
190
+ try {
191
+ parsed.push(JSON.parse(line));
192
+ } catch {
193
+ // Ignore corrupt or partial lines.
194
+ }
195
+ }
196
+ return parsed;
197
+ }
198
+
@@ -0,0 +1,103 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ addModelUsage,
5
+ addMonthlyTokens,
6
+ addTokens,
7
+ countJsonlEntries,
8
+ createEmptyReport,
9
+ diffTokens,
10
+ fileExists,
11
+ normalizeCodexUsage,
12
+ registerMessageEvent,
13
+ registerTimestamp,
14
+ toMonth,
15
+ tokenTotal,
16
+ walkFiles,
17
+ } from "../utils.js";
18
+ import { finalizeReport } from "../metrics.js";
19
+
20
+ export async function scanCodex(options = {}) {
21
+ const codexHome = options.codexHome;
22
+ const report = createEmptyReport("Codex", "codex", codexHome);
23
+
24
+ if (!fileExists(codexHome)) {
25
+ report.notes.push("Codex home not found.");
26
+ return finalizeReport(report);
27
+ }
28
+
29
+ report.available = true;
30
+ report.historyEntries = countJsonlEntries(path.join(codexHome, "history.jsonl"));
31
+
32
+ const sessionRoot = path.join(codexHome, "sessions");
33
+ const sessionFiles = walkFiles(sessionRoot, (_file, name) => name.endsWith(".jsonl"));
34
+ report.sessionFiles = sessionFiles.length;
35
+ report.totalSessions = sessionFiles.length;
36
+ report.unknownSpend = true;
37
+
38
+ for (const file of sessionFiles) {
39
+ scanCodexSessionFile(file, report);
40
+ }
41
+
42
+ if (!sessionFiles.length) {
43
+ report.notes.push("No Codex session JSONL files found.");
44
+ } else {
45
+ report.notes.push("Spend is not estimated for Codex because local files do not include dollar prices.");
46
+ }
47
+
48
+ return finalizeReport(report);
49
+ }
50
+
51
+ function scanCodexSessionFile(file, report) {
52
+ let currentModel = "unknown";
53
+ let previousUsage = null;
54
+
55
+ for (const line of readJsonLines(file)) {
56
+ if (line.timestamp) {
57
+ registerTimestamp(report, line.timestamp);
58
+ }
59
+
60
+ const payload = line.payload || {};
61
+ if (payload.model) currentModel = payload.model;
62
+ if (payload.collaboration_mode?.settings?.model) {
63
+ currentModel = payload.collaboration_mode.settings.model;
64
+ }
65
+
66
+ if (payload.type === "user_message" || payload.type === "agent_message" || payload.type === "message") {
67
+ report.totalMessages += 1;
68
+ registerMessageEvent(report, line.timestamp);
69
+ }
70
+
71
+ const totalUsage = payload.info?.total_token_usage;
72
+ if (!totalUsage) continue;
73
+
74
+ const currentUsage = normalizeCodexUsage(totalUsage);
75
+ const increment = previousUsage ? diffTokens(currentUsage, previousUsage) : currentUsage;
76
+ previousUsage = currentUsage;
77
+
78
+ if (tokenTotal(increment) <= 0) continue;
79
+ addTokens(report.tokens, increment);
80
+ addModelUsage(report, currentModel, increment);
81
+ addMonthlyTokens(report, toMonth(line.timestamp), increment);
82
+ }
83
+ }
84
+
85
+ function readJsonLines(file) {
86
+ let text;
87
+ try {
88
+ text = fs.readFileSync(file, "utf8");
89
+ } catch {
90
+ return [];
91
+ }
92
+
93
+ const parsed = [];
94
+ for (const line of text.split("\n")) {
95
+ if (!line.trim()) continue;
96
+ try {
97
+ parsed.push(JSON.parse(line));
98
+ } catch {
99
+ // Ignore corrupt or partial lines.
100
+ }
101
+ }
102
+ return parsed;
103
+ }
package/src/utils.js ADDED
@@ -0,0 +1,281 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export function readJson(file) {
5
+ try {
6
+ return JSON.parse(fs.readFileSync(file, "utf8"));
7
+ } catch {
8
+ return null;
9
+ }
10
+ }
11
+
12
+ export function fileExists(file) {
13
+ try {
14
+ return fs.existsSync(file);
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ export function walkFiles(root, predicate, options = {}) {
21
+ const files = [];
22
+ const maxDepth = options.maxDepth ?? Infinity;
23
+ const skipDirs = options.skipDirs || defaultSkipDirs;
24
+
25
+ function walk(dir, depth) {
26
+ if (depth > maxDepth) return;
27
+ let entries;
28
+ try {
29
+ entries = fs.readdirSync(dir, { withFileTypes: true });
30
+ } catch {
31
+ return;
32
+ }
33
+
34
+ for (const entry of entries) {
35
+ const fullPath = path.join(dir, entry.name);
36
+ if (entry.isDirectory()) {
37
+ if (skipDirs(entry.name, fullPath)) continue;
38
+ walk(fullPath, depth + 1);
39
+ } else if (entry.isFile() && predicate(fullPath, entry.name)) {
40
+ files.push(fullPath);
41
+ }
42
+ }
43
+ }
44
+
45
+ walk(root, 0);
46
+ return files;
47
+ }
48
+
49
+ export function findDirs(root, targetName, options = {}) {
50
+ const dirs = [];
51
+ const maxDepth = options.maxDepth ?? 8;
52
+ const skipDirs = options.skipDirs || defaultProjectSkipDirs;
53
+
54
+ function walk(dir, depth) {
55
+ if (depth > maxDepth) return;
56
+ let entries;
57
+ try {
58
+ entries = fs.readdirSync(dir, { withFileTypes: true });
59
+ } catch {
60
+ return;
61
+ }
62
+
63
+ for (const entry of entries) {
64
+ if (!entry.isDirectory()) continue;
65
+ const fullPath = path.join(dir, entry.name);
66
+ if (entry.name === targetName) {
67
+ dirs.push(fullPath);
68
+ continue;
69
+ }
70
+ if (skipDirs(entry.name, fullPath)) continue;
71
+ walk(fullPath, depth + 1);
72
+ }
73
+ }
74
+
75
+ walk(root, 0);
76
+ return dirs;
77
+ }
78
+
79
+ export function countJsonlEntries(file) {
80
+ try {
81
+ const text = fs.readFileSync(file, "utf8");
82
+ return text.split("\n").filter(Boolean).length;
83
+ } catch {
84
+ return 0;
85
+ }
86
+ }
87
+
88
+ export function createEmptyReport(provider, key, root) {
89
+ return {
90
+ provider,
91
+ key,
92
+ root,
93
+ available: false,
94
+ sessionFiles: 0,
95
+ projectConfigDirs: 0,
96
+ projectConfigExamples: [],
97
+ projectScanRoot: null,
98
+ historyEntries: 0,
99
+ totalSessions: 0,
100
+ totalMessages: 0,
101
+ firstSeen: null,
102
+ lastSeen: null,
103
+ dailyActivity: {},
104
+ monthlySpendUSD: {},
105
+ monthlyTokens: {},
106
+ hourlyActivity: Array.from({ length: 24 }, () => 0),
107
+ tokens: emptyTokens(),
108
+ modelUsage: {},
109
+ knownSpendUSD: 0,
110
+ unknownSpend: false,
111
+ favoriteModel: null,
112
+ notes: [],
113
+ };
114
+ }
115
+
116
+ export function emptyTokens() {
117
+ return {
118
+ input: 0,
119
+ output: 0,
120
+ cacheRead: 0,
121
+ cacheCreation: 0,
122
+ reasoning: 0,
123
+ total: 0,
124
+ };
125
+ }
126
+
127
+ export function addTokens(target, source) {
128
+ for (const key of ["input", "output", "cacheRead", "cacheCreation", "reasoning", "total"]) {
129
+ target[key] = (target[key] || 0) + (Number(source?.[key]) || 0);
130
+ }
131
+ return target;
132
+ }
133
+
134
+ export function diffTokens(current, previous) {
135
+ const diff = emptyTokens();
136
+ for (const key of ["input", "output", "cacheRead", "cacheCreation", "reasoning", "total"]) {
137
+ diff[key] = Math.max(0, (Number(current?.[key]) || 0) - (Number(previous?.[key]) || 0));
138
+ }
139
+ return diff;
140
+ }
141
+
142
+ export function tokenTotal(tokens) {
143
+ return Number(tokens?.total) || sumTokenParts(tokens);
144
+ }
145
+
146
+ function sumTokenParts(tokens) {
147
+ return (
148
+ (Number(tokens?.input) || 0) +
149
+ (Number(tokens?.output) || 0) +
150
+ (Number(tokens?.cacheRead) || 0) +
151
+ (Number(tokens?.cacheCreation) || 0) +
152
+ (Number(tokens?.reasoning) || 0)
153
+ );
154
+ }
155
+
156
+ export function normalizeClaudeUsage(usage = {}) {
157
+ const tokens = emptyTokens();
158
+ tokens.input = Number(usage.input_tokens || usage.inputTokens || 0);
159
+ tokens.output = Number(usage.output_tokens || usage.outputTokens || 0);
160
+ tokens.cacheRead = Number(usage.cache_read_input_tokens || usage.cacheReadInputTokens || 0);
161
+ tokens.cacheCreation = Number(usage.cache_creation_input_tokens || usage.cacheCreationInputTokens || 0);
162
+ tokens.reasoning = Number(usage.reasoning_output_tokens || usage.reasoningOutputTokens || 0);
163
+ tokens.total = sumTokenParts(tokens);
164
+ return tokens;
165
+ }
166
+
167
+ export function normalizeCodexUsage(usage = {}) {
168
+ const tokens = emptyTokens();
169
+ tokens.input = Number(usage.input_tokens || usage.inputTokens || 0);
170
+ tokens.output = Number(usage.output_tokens || usage.outputTokens || 0);
171
+ tokens.cacheRead = Number(usage.cached_input_tokens || usage.cacheReadInputTokens || 0);
172
+ tokens.cacheCreation = Number(usage.cache_creation_input_tokens || usage.cacheCreationInputTokens || 0);
173
+ tokens.reasoning = Number(usage.reasoning_output_tokens || usage.reasoningOutputTokens || 0);
174
+ tokens.total = Number(usage.total_tokens || usage.totalTokens || tokens.input + tokens.output);
175
+ return tokens;
176
+ }
177
+
178
+ export function addMonthlyTokens(report, month, tokens) {
179
+ if (!month) return;
180
+ report.monthlyTokens[month] ||= emptyTokens();
181
+ addTokens(report.monthlyTokens[month], tokens);
182
+ }
183
+
184
+ export function addModelUsage(report, model, tokens, extra = {}) {
185
+ const key = model || "unknown";
186
+ report.modelUsage[key] ||= { ...emptyTokens(), model: key, provider: report.provider, costUSD: 0 };
187
+ addTokens(report.modelUsage[key], tokens);
188
+ if (extra.costUSD) report.modelUsage[key].costUSD += extra.costUSD;
189
+ }
190
+
191
+ export function addSpend(report, month, spend) {
192
+ if (!month || !Number.isFinite(spend) || spend <= 0) return;
193
+ report.monthlySpendUSD[month] = (report.monthlySpendUSD[month] || 0) + spend;
194
+ report.knownSpendUSD += spend;
195
+ }
196
+
197
+ export function registerTimestamp(report, timestamp) {
198
+ if (!timestamp) return;
199
+ const date = new Date(timestamp);
200
+ if (!Number.isFinite(date.getTime())) return;
201
+ const iso = date.toISOString();
202
+ if (!report.firstSeen || iso < report.firstSeen) report.firstSeen = iso;
203
+ if (!report.lastSeen || iso > report.lastSeen) report.lastSeen = iso;
204
+ }
205
+
206
+ export function registerMessageEvent(report, timestamp) {
207
+ if (!timestamp) return;
208
+ const date = new Date(timestamp);
209
+ if (!Number.isFinite(date.getTime())) return;
210
+ report.hourlyActivity[date.getHours()] += 1;
211
+ const day = toDay(date);
212
+ if (day) report.dailyActivity[day] = (report.dailyActivity[day] || 0) + 1;
213
+ }
214
+
215
+ export function toMonth(value) {
216
+ const date = value instanceof Date ? value : new Date(value);
217
+ if (!Number.isFinite(date.getTime())) return null;
218
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
219
+ }
220
+
221
+ export function toDay(value) {
222
+ const date = value instanceof Date ? value : new Date(value);
223
+ if (!Number.isFinite(date.getTime())) return null;
224
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
225
+ }
226
+
227
+ export function makeObjectMap(object) {
228
+ return Object.fromEntries(Object.entries(object).sort(([a], [b]) => a.localeCompare(b)));
229
+ }
230
+
231
+ export function sortedEntries(object) {
232
+ return Object.entries(object || {}).sort(([a], [b]) => a.localeCompare(b));
233
+ }
234
+
235
+ export function compactNumber(value) {
236
+ const number = Number(value) || 0;
237
+ const abs = Math.abs(number);
238
+ if (abs >= 1_000_000_000) return `${(number / 1_000_000_000).toFixed(2)}B`;
239
+ if (abs >= 1_000_000) return `${(number / 1_000_000).toFixed(2)}M`;
240
+ if (abs >= 1_000) return `${(number / 1_000).toFixed(1)}K`;
241
+ return formatInteger(number);
242
+ }
243
+
244
+ export function formatInteger(value) {
245
+ return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(Number(value) || 0);
246
+ }
247
+
248
+ export function dollars(value) {
249
+ return `$${(Number(value) || 0).toFixed(2)}`;
250
+ }
251
+
252
+ export function formatPercent(value) {
253
+ return `${((Number(value) || 0) * 100).toFixed(1)}%`;
254
+ }
255
+
256
+ const defaultSkipDirs = (name) => name === "node_modules" || name === ".git";
257
+
258
+ const skippedProjectDirs = new Set([
259
+ ".cache",
260
+ ".cargo",
261
+ ".git",
262
+ ".gradle",
263
+ ".local",
264
+ ".npm",
265
+ ".pnpm-store",
266
+ ".rustup",
267
+ ".Trash",
268
+ "Applications",
269
+ "Caches",
270
+ "Library",
271
+ "Movies",
272
+ "Music",
273
+ "node_modules",
274
+ "Pictures",
275
+ ]);
276
+
277
+ function defaultProjectSkipDirs(name) {
278
+ if (name === ".claude") return false;
279
+ if (skippedProjectDirs.has(name)) return true;
280
+ return name.startsWith(".") && name !== ".claude";
281
+ }