claude-usage-dashboard 1.5.0 → 1.5.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/server/pricing.js CHANGED
@@ -1,52 +1,52 @@
1
- export const MODEL_PRICING = {
2
- 'claude-opus-4-6': {
3
- input_price_per_mtok: 5,
4
- output_price_per_mtok: 25,
5
- cache_read_price_per_mtok: 0.50,
6
- cache_creation_price_per_mtok: 6.25,
7
- },
8
- 'claude-sonnet-4-6': {
9
- input_price_per_mtok: 3,
10
- output_price_per_mtok: 15,
11
- cache_read_price_per_mtok: 0.30,
12
- cache_creation_price_per_mtok: 3.75,
13
- },
14
- 'claude-haiku-4-5': {
15
- input_price_per_mtok: 1,
16
- output_price_per_mtok: 5,
17
- cache_read_price_per_mtok: 0.10,
18
- cache_creation_price_per_mtok: 1.25,
19
- },
20
- };
21
-
22
- export const PLAN_DEFAULTS = {
23
- pro: 20,
24
- max5x: 100,
25
- max20x: 200,
26
- };
27
-
28
- export function getModelPricing(modelId) {
29
- return MODEL_PRICING[modelId] || null;
30
- }
31
-
32
- /**
33
- * Calculate the API cost for a single usage record.
34
- * Returns 0 for unknown models.
35
- *
36
- * In Claude Code logs, input_tokens is the non-cached input.
37
- * cache_read_tokens and cache_creation_tokens are separate, additive fields.
38
- * cost = input * input_rate + cache_read * read_rate + cache_creation * write_rate + output * output_rate
39
- */
40
- export function calculateRecordCost(record) {
41
- const pricing = MODEL_PRICING[record.model];
42
- if (!pricing) return 0;
43
-
44
- const M = 1_000_000;
45
-
46
- return (
47
- (record.input_tokens / M) * pricing.input_price_per_mtok +
48
- (record.cache_read_tokens / M) * pricing.cache_read_price_per_mtok +
49
- (record.cache_creation_tokens / M) * pricing.cache_creation_price_per_mtok +
50
- (record.output_tokens / M) * pricing.output_price_per_mtok
51
- );
52
- }
1
+ export const MODEL_PRICING = {
2
+ 'claude-opus-4-6': {
3
+ input_price_per_mtok: 5,
4
+ output_price_per_mtok: 25,
5
+ cache_read_price_per_mtok: 0.50,
6
+ cache_creation_price_per_mtok: 6.25,
7
+ },
8
+ 'claude-sonnet-4-6': {
9
+ input_price_per_mtok: 3,
10
+ output_price_per_mtok: 15,
11
+ cache_read_price_per_mtok: 0.30,
12
+ cache_creation_price_per_mtok: 3.75,
13
+ },
14
+ 'claude-haiku-4-5': {
15
+ input_price_per_mtok: 1,
16
+ output_price_per_mtok: 5,
17
+ cache_read_price_per_mtok: 0.10,
18
+ cache_creation_price_per_mtok: 1.25,
19
+ },
20
+ };
21
+
22
+ export const PLAN_DEFAULTS = {
23
+ pro: 20,
24
+ max5x: 100,
25
+ max20x: 200,
26
+ };
27
+
28
+ export function getModelPricing(modelId) {
29
+ return MODEL_PRICING[modelId] || null;
30
+ }
31
+
32
+ /**
33
+ * Calculate the API cost for a single usage record.
34
+ * Returns 0 for unknown models.
35
+ *
36
+ * In Claude Code logs, input_tokens is the non-cached input.
37
+ * cache_read_tokens and cache_creation_tokens are separate, additive fields.
38
+ * cost = input * input_rate + cache_read * read_rate + cache_creation * write_rate + output * output_rate
39
+ */
40
+ export function calculateRecordCost(record) {
41
+ const pricing = MODEL_PRICING[record.model];
42
+ if (!pricing) return 0;
43
+
44
+ const M = 1_000_000;
45
+
46
+ return (
47
+ (record.input_tokens / M) * pricing.input_price_per_mtok +
48
+ (record.cache_read_tokens / M) * pricing.cache_read_price_per_mtok +
49
+ (record.cache_creation_tokens / M) * pricing.cache_creation_price_per_mtok +
50
+ (record.output_tokens / M) * pricing.output_price_per_mtok
51
+ );
52
+ }
@@ -1,274 +1,274 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import os from 'os';
4
- import { parseLogDirectory } from './parser.js';
5
- import { filterByDateRange } from './aggregator.js';
6
- import { calculateRecordCost } from './pricing.js';
7
-
8
- const MAX_HISTORY = 52;
9
-
10
- /**
11
- * Pure computation: given records (already filtered to a cycle) and quota data,
12
- * compute actual tokens/cost and project at 100% utilization.
13
- */
14
- export function computeCycleData(records, quotaData) {
15
- const overallUtil = quotaData.seven_day?.utilization || 0;
16
- const opusUtil = quotaData.seven_day_opus?.utilization || 0;
17
- const sonnetUtil = quotaData.seven_day_sonnet?.utilization || 0;
18
-
19
- // Per-type accumulators
20
- let inTok = 0, outTok = 0, crTok = 0, cwTok = 0, totalCost = 0;
21
- let opusIn = 0, opusOut = 0, opusCR = 0, opusCW = 0, opusCost = 0;
22
- let sonIn = 0, sonOut = 0, sonCR = 0, sonCW = 0, sonnetCost = 0;
23
-
24
- for (const r of records) {
25
- const cost = calculateRecordCost(r);
26
- inTok += r.input_tokens; outTok += r.output_tokens;
27
- crTok += r.cache_read_tokens; cwTok += r.cache_creation_tokens;
28
- totalCost += cost;
29
-
30
- if (r.model?.includes('opus')) {
31
- opusIn += r.input_tokens; opusOut += r.output_tokens;
32
- opusCR += r.cache_read_tokens; opusCW += r.cache_creation_tokens;
33
- opusCost += cost;
34
- } else if (r.model?.includes('sonnet')) {
35
- sonIn += r.input_tokens; sonOut += r.output_tokens;
36
- sonCR += r.cache_read_tokens; sonCW += r.cache_creation_tokens;
37
- sonnetCost += cost;
38
- }
39
- }
40
-
41
- totalCost = Math.round(totalCost * 100) / 100;
42
- opusCost = Math.round(opusCost * 100) / 100;
43
- sonnetCost = Math.round(sonnetCost * 100) / 100;
44
-
45
- function buildTokens(inp, out, cr, cw) {
46
- return { input: inp, output: out, cacheRead: cr, cacheCreation: cw };
47
- }
48
-
49
- function project(actual, utilization) {
50
- if (utilization <= 0) return null;
51
- return Math.round(actual / (utilization / 100));
52
- }
53
-
54
- function projectCost(actual, utilization) {
55
- if (utilization <= 0) return null;
56
- return Math.round((actual / (utilization / 100)) * 100) / 100;
57
- }
58
-
59
- // actualTokens = total excluding cache reads (in + out + cw) — used for projections
60
- const totalExclCR = inTok + outTok + cwTok;
61
- const opusExclCR = opusIn + opusOut + opusCW;
62
- const sonExclCR = sonIn + sonOut + sonCW;
63
-
64
- return {
65
- overall: {
66
- utilization: overallUtil,
67
- tokens: buildTokens(inTok, outTok, crTok, cwTok),
68
- actualTokens: totalExclCR,
69
- projectedTokensAt100: project(totalExclCR, overallUtil),
70
- actualCost: totalCost,
71
- projectedCostAt100: projectCost(totalCost, overallUtil),
72
- },
73
- models: {
74
- opus: {
75
- utilization: opusUtil,
76
- tokens: buildTokens(opusIn, opusOut, opusCR, opusCW),
77
- actualTokens: opusExclCR,
78
- projectedTokensAt100: project(opusExclCR, opusUtil),
79
- actualCost: opusCost,
80
- projectedCostAt100: projectCost(opusCost, opusUtil),
81
- },
82
- sonnet: {
83
- utilization: sonnetUtil,
84
- tokens: buildTokens(sonIn, sonOut, sonCR, sonCW),
85
- actualTokens: sonExclCR,
86
- projectedTokensAt100: project(sonExclCR, sonnetUtil),
87
- actualCost: sonnetCost,
88
- projectedCostAt100: projectCost(sonnetCost, sonnetUtil),
89
- },
90
- },
91
- };
92
- }
93
-
94
- /**
95
- * Update the quota cycle snapshot file for this machine.
96
- * Called after each successful quota API fetch.
97
- *
98
- * @param {object} quotaData - Quota API response (must have available === true)
99
- * @param {string} logBaseDir - This machine's log directory (~/.claude/projects/)
100
- * @param {string} machineName - Identifier for this machine
101
- * @param {string} [snapshotDir] - Directory for snapshot files (defaults to syncDir or ~/.claude/)
102
- * @param {string} [syncDir] - Shared sync directory; used as fallback when snapshotDir is not set
103
- */
104
- export function updateQuotaCycleSnapshot(quotaData, logBaseDir, machineName, snapshotDir, syncDir) {
105
- if (!quotaData?.available || !quotaData.seven_day?.resets_at) return;
106
-
107
- const dir = snapshotDir || syncDir || path.join(os.homedir(), '.claude');
108
- const filePath = path.join(dir, `quota-cycles-${machineName}.json`);
109
-
110
- // Normalize resets_at to second precision — the API returns varying microseconds
111
- // on each call (e.g. .905316 vs .581788) which would cause false cycle switches
112
- const rawResetsAt = new Date(quotaData.seven_day.resets_at);
113
- rawResetsAt.setMilliseconds(0);
114
- const resetsAt = rawResetsAt.toISOString();
115
- const start = new Date(rawResetsAt.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
116
-
117
- let snapshot;
118
- try {
119
- snapshot = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
120
- } catch {
121
- snapshot = { schemaVersion: 1, machineName, currentCycle: null, history: [] };
122
- }
123
-
124
- // Compare normalized timestamps to detect actual cycle boundary changes
125
- const storedResetNorm = snapshot.currentCycle
126
- ? new Date(snapshot.currentCycle.resets_at).setMilliseconds(0)
127
- : null;
128
- if (snapshot.currentCycle && storedResetNorm !== rawResetsAt.getTime()) {
129
- snapshot.history.unshift(snapshot.currentCycle);
130
- if (snapshot.history.length > MAX_HISTORY) {
131
- snapshot.history = snapshot.history.slice(0, MAX_HISTORY);
132
- }
133
- snapshot.currentCycle = null;
134
- }
135
-
136
- const allRecords = parseLogDirectory(logBaseDir);
137
- const cycleRecords = filterByDateRange(allRecords, start, resetsAt);
138
- const cycleData = computeCycleData(cycleRecords, quotaData);
139
-
140
- snapshot.currentCycle = {
141
- resets_at: resetsAt,
142
- start,
143
- lastUpdated: new Date().toISOString(),
144
- ...cycleData,
145
- };
146
-
147
- fs.writeFileSync(filePath, JSON.stringify(snapshot, null, 2));
148
- }
149
-
150
- /**
151
- * Load and merge quota cycle data from all machines.
152
- *
153
- * @param {string} machineName - This machine's name
154
- * @param {string|null} syncDir - Shared sync directory (null if single-machine)
155
- * @param {string} [snapshotDir] - Directory for snapshot files (defaults to ~/.claude/)
156
- * @returns {{ currentCycle: object|null, history: object[], machines: string[] }}
157
- */
158
- export function loadQuotaCycles(machineName, syncDir, snapshotDir) {
159
- const dir = snapshotDir || syncDir || path.join(os.homedir(), '.claude');
160
- const empty = { currentCycle: null, history: [], machines: [] };
161
-
162
- let files;
163
- try {
164
- files = fs.readdirSync(dir).filter(f => f.startsWith('quota-cycles-') && f.endsWith('.json'));
165
- } catch {
166
- return empty;
167
- }
168
-
169
- if (files.length === 0) return empty;
170
-
171
- const snapshots = [];
172
- for (const file of files) {
173
- try {
174
- const data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf-8'));
175
- if (data.schemaVersion === 1) snapshots.push(data);
176
- } catch { /* skip corrupt files */ }
177
- }
178
-
179
- if (snapshots.length === 0) return empty;
180
-
181
- const machines = snapshots.map(s => s.machineName);
182
-
183
- if (snapshots.length === 1) {
184
- const s = snapshots[0];
185
- return { currentCycle: s.currentCycle, history: s.history, machines };
186
- }
187
-
188
- return {
189
- currentCycle: mergeCycles(snapshots.map(s => s.currentCycle).filter(Boolean)),
190
- history: mergeHistories(snapshots.map(s => s.history)),
191
- machines,
192
- };
193
- }
194
-
195
- function mergeCycles(cycles) {
196
- if (cycles.length === 0) return null;
197
- if (cycles.length === 1) return cycles[0];
198
-
199
- const byReset = new Map();
200
- for (const c of cycles) {
201
- const key = c.resets_at;
202
- if (!byReset.has(key)) byReset.set(key, []);
203
- byReset.get(key).push(c);
204
- }
205
-
206
- let bestKey = null, bestCount = 0;
207
- for (const [key, arr] of byReset) {
208
- if (arr.length > bestCount || (arr.length === bestCount && key > bestKey)) { bestKey = key; bestCount = arr.length; }
209
- }
210
-
211
- const sameCycle = byReset.get(bestKey);
212
- return mergeSamePeriodCycles(sameCycle);
213
- }
214
-
215
- function mergeSamePeriodCycles(cycles) {
216
- const mostRecent = cycles.reduce((a, b) =>
217
- new Date(a.lastUpdated) > new Date(b.lastUpdated) ? a : b
218
- );
219
-
220
- return {
221
- resets_at: mostRecent.resets_at,
222
- start: mostRecent.start,
223
- lastUpdated: mostRecent.lastUpdated,
224
- overall: mergeMetrics(cycles.map(c => c.overall), mostRecent.overall.utilization),
225
- models: {
226
- opus: mergeMetrics(cycles.map(c => c.models.opus), mostRecent.models?.opus?.utilization || 0),
227
- sonnet: mergeMetrics(cycles.map(c => c.models.sonnet), mostRecent.models?.sonnet?.utilization || 0),
228
- },
229
- };
230
- }
231
-
232
- function mergeMetrics(metricsArray, utilization) {
233
- const totalTokens = metricsArray.reduce((sum, m) => sum + (m?.actualTokens || 0), 0);
234
- const totalCost = Math.round(metricsArray.reduce((sum, m) => sum + (m?.actualCost || 0), 0) * 100) / 100;
235
-
236
- // Merge per-type token breakdown
237
- const tokens = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
238
- for (const m of metricsArray) {
239
- if (m?.tokens) {
240
- tokens.input += m.tokens.input || 0;
241
- tokens.output += m.tokens.output || 0;
242
- tokens.cacheRead += m.tokens.cacheRead || 0;
243
- tokens.cacheCreation += m.tokens.cacheCreation || 0;
244
- }
245
- }
246
-
247
- return {
248
- utilization,
249
- tokens,
250
- actualTokens: totalTokens,
251
- projectedTokensAt100: utilization > 0 ? Math.round(totalTokens / (utilization / 100)) : null,
252
- actualCost: totalCost,
253
- projectedCostAt100: utilization > 0 ? Math.round((totalCost / (utilization / 100)) * 100) / 100 : null,
254
- };
255
- }
256
-
257
- function mergeHistories(historyArrays) {
258
- const byReset = new Map();
259
- for (const history of historyArrays) {
260
- for (const entry of history) {
261
- const key = entry.resets_at;
262
- if (!byReset.has(key)) byReset.set(key, []);
263
- byReset.get(key).push(entry);
264
- }
265
- }
266
-
267
- const merged = [];
268
- for (const [, entries] of byReset) {
269
- merged.push(mergeSamePeriodCycles(entries));
270
- }
271
-
272
- merged.sort((a, b) => new Date(b.resets_at) - new Date(a.resets_at));
273
- return merged.slice(0, MAX_HISTORY);
274
- }
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { parseLogDirectory } from './parser.js';
5
+ import { filterByDateRange } from './aggregator.js';
6
+ import { calculateRecordCost } from './pricing.js';
7
+
8
+ const MAX_HISTORY = 52;
9
+
10
+ /**
11
+ * Pure computation: given records (already filtered to a cycle) and quota data,
12
+ * compute actual tokens/cost and project at 100% utilization.
13
+ */
14
+ export function computeCycleData(records, quotaData) {
15
+ const overallUtil = quotaData.seven_day?.utilization || 0;
16
+ const opusUtil = quotaData.seven_day_opus?.utilization || 0;
17
+ const sonnetUtil = quotaData.seven_day_sonnet?.utilization || 0;
18
+
19
+ // Per-type accumulators
20
+ let inTok = 0, outTok = 0, crTok = 0, cwTok = 0, totalCost = 0;
21
+ let opusIn = 0, opusOut = 0, opusCR = 0, opusCW = 0, opusCost = 0;
22
+ let sonIn = 0, sonOut = 0, sonCR = 0, sonCW = 0, sonnetCost = 0;
23
+
24
+ for (const r of records) {
25
+ const cost = calculateRecordCost(r);
26
+ inTok += r.input_tokens; outTok += r.output_tokens;
27
+ crTok += r.cache_read_tokens; cwTok += r.cache_creation_tokens;
28
+ totalCost += cost;
29
+
30
+ if (r.model?.includes('opus')) {
31
+ opusIn += r.input_tokens; opusOut += r.output_tokens;
32
+ opusCR += r.cache_read_tokens; opusCW += r.cache_creation_tokens;
33
+ opusCost += cost;
34
+ } else if (r.model?.includes('sonnet')) {
35
+ sonIn += r.input_tokens; sonOut += r.output_tokens;
36
+ sonCR += r.cache_read_tokens; sonCW += r.cache_creation_tokens;
37
+ sonnetCost += cost;
38
+ }
39
+ }
40
+
41
+ totalCost = Math.round(totalCost * 100) / 100;
42
+ opusCost = Math.round(opusCost * 100) / 100;
43
+ sonnetCost = Math.round(sonnetCost * 100) / 100;
44
+
45
+ function buildTokens(inp, out, cr, cw) {
46
+ return { input: inp, output: out, cacheRead: cr, cacheCreation: cw };
47
+ }
48
+
49
+ function project(actual, utilization) {
50
+ if (utilization <= 0) return null;
51
+ return Math.round(actual / (utilization / 100));
52
+ }
53
+
54
+ function projectCost(actual, utilization) {
55
+ if (utilization <= 0) return null;
56
+ return Math.round((actual / (utilization / 100)) * 100) / 100;
57
+ }
58
+
59
+ // actualTokens = total excluding cache reads (in + out + cw) — used for projections
60
+ const totalExclCR = inTok + outTok + cwTok;
61
+ const opusExclCR = opusIn + opusOut + opusCW;
62
+ const sonExclCR = sonIn + sonOut + sonCW;
63
+
64
+ return {
65
+ overall: {
66
+ utilization: overallUtil,
67
+ tokens: buildTokens(inTok, outTok, crTok, cwTok),
68
+ actualTokens: totalExclCR,
69
+ projectedTokensAt100: project(totalExclCR, overallUtil),
70
+ actualCost: totalCost,
71
+ projectedCostAt100: projectCost(totalCost, overallUtil),
72
+ },
73
+ models: {
74
+ opus: {
75
+ utilization: opusUtil,
76
+ tokens: buildTokens(opusIn, opusOut, opusCR, opusCW),
77
+ actualTokens: opusExclCR,
78
+ projectedTokensAt100: project(opusExclCR, opusUtil),
79
+ actualCost: opusCost,
80
+ projectedCostAt100: projectCost(opusCost, opusUtil),
81
+ },
82
+ sonnet: {
83
+ utilization: sonnetUtil,
84
+ tokens: buildTokens(sonIn, sonOut, sonCR, sonCW),
85
+ actualTokens: sonExclCR,
86
+ projectedTokensAt100: project(sonExclCR, sonnetUtil),
87
+ actualCost: sonnetCost,
88
+ projectedCostAt100: projectCost(sonnetCost, sonnetUtil),
89
+ },
90
+ },
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Update the quota cycle snapshot file for this machine.
96
+ * Called after each successful quota API fetch.
97
+ *
98
+ * @param {object} quotaData - Quota API response (must have available === true)
99
+ * @param {string} logBaseDir - This machine's log directory (~/.claude/projects/)
100
+ * @param {string} machineName - Identifier for this machine
101
+ * @param {string} [snapshotDir] - Directory for snapshot files (defaults to syncDir or ~/.claude/)
102
+ * @param {string} [syncDir] - Shared sync directory; used as fallback when snapshotDir is not set
103
+ */
104
+ export function updateQuotaCycleSnapshot(quotaData, logBaseDir, machineName, snapshotDir, syncDir) {
105
+ if (!quotaData?.available || !quotaData.seven_day?.resets_at) return;
106
+
107
+ const dir = snapshotDir || syncDir || path.join(os.homedir(), '.claude');
108
+ const filePath = path.join(dir, `quota-cycles-${machineName}.json`);
109
+
110
+ // Normalize resets_at to second precision — the API returns varying microseconds
111
+ // on each call (e.g. .905316 vs .581788) which would cause false cycle switches
112
+ const rawResetsAt = new Date(quotaData.seven_day.resets_at);
113
+ rawResetsAt.setMilliseconds(0);
114
+ const resetsAt = rawResetsAt.toISOString();
115
+ const start = new Date(rawResetsAt.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
116
+
117
+ let snapshot;
118
+ try {
119
+ snapshot = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
120
+ } catch {
121
+ snapshot = { schemaVersion: 1, machineName, currentCycle: null, history: [] };
122
+ }
123
+
124
+ // Compare normalized timestamps to detect actual cycle boundary changes
125
+ const storedResetNorm = snapshot.currentCycle
126
+ ? new Date(snapshot.currentCycle.resets_at).setMilliseconds(0)
127
+ : null;
128
+ if (snapshot.currentCycle && storedResetNorm !== rawResetsAt.getTime()) {
129
+ snapshot.history.unshift(snapshot.currentCycle);
130
+ if (snapshot.history.length > MAX_HISTORY) {
131
+ snapshot.history = snapshot.history.slice(0, MAX_HISTORY);
132
+ }
133
+ snapshot.currentCycle = null;
134
+ }
135
+
136
+ const allRecords = parseLogDirectory(logBaseDir);
137
+ const cycleRecords = filterByDateRange(allRecords, start, resetsAt);
138
+ const cycleData = computeCycleData(cycleRecords, quotaData);
139
+
140
+ snapshot.currentCycle = {
141
+ resets_at: resetsAt,
142
+ start,
143
+ lastUpdated: new Date().toISOString(),
144
+ ...cycleData,
145
+ };
146
+
147
+ fs.writeFileSync(filePath, JSON.stringify(snapshot, null, 2));
148
+ }
149
+
150
+ /**
151
+ * Load and merge quota cycle data from all machines.
152
+ *
153
+ * @param {string} machineName - This machine's name
154
+ * @param {string|null} syncDir - Shared sync directory (null if single-machine)
155
+ * @param {string} [snapshotDir] - Directory for snapshot files (defaults to ~/.claude/)
156
+ * @returns {{ currentCycle: object|null, history: object[], machines: string[] }}
157
+ */
158
+ export function loadQuotaCycles(machineName, syncDir, snapshotDir) {
159
+ const dir = snapshotDir || syncDir || path.join(os.homedir(), '.claude');
160
+ const empty = { currentCycle: null, history: [], machines: [] };
161
+
162
+ let files;
163
+ try {
164
+ files = fs.readdirSync(dir).filter(f => f.startsWith('quota-cycles-') && f.endsWith('.json'));
165
+ } catch {
166
+ return empty;
167
+ }
168
+
169
+ if (files.length === 0) return empty;
170
+
171
+ const snapshots = [];
172
+ for (const file of files) {
173
+ try {
174
+ const data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf-8'));
175
+ if (data.schemaVersion === 1) snapshots.push(data);
176
+ } catch { /* skip corrupt files */ }
177
+ }
178
+
179
+ if (snapshots.length === 0) return empty;
180
+
181
+ const machines = snapshots.map(s => s.machineName);
182
+
183
+ if (snapshots.length === 1) {
184
+ const s = snapshots[0];
185
+ return { currentCycle: s.currentCycle, history: s.history, machines };
186
+ }
187
+
188
+ return {
189
+ currentCycle: mergeCycles(snapshots.map(s => s.currentCycle).filter(Boolean)),
190
+ history: mergeHistories(snapshots.map(s => s.history)),
191
+ machines,
192
+ };
193
+ }
194
+
195
+ function mergeCycles(cycles) {
196
+ if (cycles.length === 0) return null;
197
+ if (cycles.length === 1) return cycles[0];
198
+
199
+ const byReset = new Map();
200
+ for (const c of cycles) {
201
+ const key = c.resets_at;
202
+ if (!byReset.has(key)) byReset.set(key, []);
203
+ byReset.get(key).push(c);
204
+ }
205
+
206
+ let bestKey = null, bestCount = 0;
207
+ for (const [key, arr] of byReset) {
208
+ if (arr.length > bestCount || (arr.length === bestCount && key > bestKey)) { bestKey = key; bestCount = arr.length; }
209
+ }
210
+
211
+ const sameCycle = byReset.get(bestKey);
212
+ return mergeSamePeriodCycles(sameCycle);
213
+ }
214
+
215
+ function mergeSamePeriodCycles(cycles) {
216
+ const mostRecent = cycles.reduce((a, b) =>
217
+ new Date(a.lastUpdated) > new Date(b.lastUpdated) ? a : b
218
+ );
219
+
220
+ return {
221
+ resets_at: mostRecent.resets_at,
222
+ start: mostRecent.start,
223
+ lastUpdated: mostRecent.lastUpdated,
224
+ overall: mergeMetrics(cycles.map(c => c.overall), mostRecent.overall.utilization),
225
+ models: {
226
+ opus: mergeMetrics(cycles.map(c => c.models.opus), mostRecent.models?.opus?.utilization || 0),
227
+ sonnet: mergeMetrics(cycles.map(c => c.models.sonnet), mostRecent.models?.sonnet?.utilization || 0),
228
+ },
229
+ };
230
+ }
231
+
232
+ function mergeMetrics(metricsArray, utilization) {
233
+ const totalTokens = metricsArray.reduce((sum, m) => sum + (m?.actualTokens || 0), 0);
234
+ const totalCost = Math.round(metricsArray.reduce((sum, m) => sum + (m?.actualCost || 0), 0) * 100) / 100;
235
+
236
+ // Merge per-type token breakdown
237
+ const tokens = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
238
+ for (const m of metricsArray) {
239
+ if (m?.tokens) {
240
+ tokens.input += m.tokens.input || 0;
241
+ tokens.output += m.tokens.output || 0;
242
+ tokens.cacheRead += m.tokens.cacheRead || 0;
243
+ tokens.cacheCreation += m.tokens.cacheCreation || 0;
244
+ }
245
+ }
246
+
247
+ return {
248
+ utilization,
249
+ tokens,
250
+ actualTokens: totalTokens,
251
+ projectedTokensAt100: utilization > 0 ? Math.round(totalTokens / (utilization / 100)) : null,
252
+ actualCost: totalCost,
253
+ projectedCostAt100: utilization > 0 ? Math.round((totalCost / (utilization / 100)) * 100) / 100 : null,
254
+ };
255
+ }
256
+
257
+ function mergeHistories(historyArrays) {
258
+ const byReset = new Map();
259
+ for (const history of historyArrays) {
260
+ for (const entry of history) {
261
+ const key = entry.resets_at;
262
+ if (!byReset.has(key)) byReset.set(key, []);
263
+ byReset.get(key).push(entry);
264
+ }
265
+ }
266
+
267
+ const merged = [];
268
+ for (const [, entries] of byReset) {
269
+ merged.push(mergeSamePeriodCycles(entries));
270
+ }
271
+
272
+ merged.sort((a, b) => new Date(b.resets_at) - new Date(a.resets_at));
273
+ return merged.slice(0, MAX_HISTORY);
274
+ }