claude-usage-dashboard 1.5.2 → 1.5.4

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.
@@ -1,274 +1,325 @@
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
+ * Normalize a cycle's resets_at to hour precision for use as a dedup key.
12
+ * The API returns varying sub-second precision across calls and machines,
13
+ * so raw strings cannot be used for grouping.
14
+ */
15
+ function cyclePeriodKey(cycle) {
16
+ const d = new Date(cycle.resets_at);
17
+ d.setMinutes(0, 0, 0);
18
+ return d.toISOString();
19
+ }
20
+
21
+ /**
22
+ * Deduplicate history entries that represent the same quota cycle period.
23
+ * Keeps the entry with the latest lastUpdated for each period.
24
+ */
25
+ function deduplicateHistory(history) {
26
+ const byKey = new Map();
27
+ for (const entry of history) {
28
+ const key = cyclePeriodKey(entry);
29
+ const existing = byKey.get(key);
30
+ if (!existing || new Date(entry.lastUpdated) > new Date(existing.lastUpdated)) {
31
+ byKey.set(key, entry);
32
+ }
33
+ }
34
+ const result = Array.from(byKey.values());
35
+ result.sort((a, b) => new Date(b.resets_at) - new Date(a.resets_at));
36
+ return result;
37
+ }
38
+
39
+ /**
40
+ * Pure computation: given records (already filtered to a cycle) and quota data,
41
+ * compute actual tokens/cost and project at 100% utilization.
42
+ */
43
+ export function computeCycleData(records, quotaData) {
44
+ const overallUtil = quotaData.seven_day?.utilization || 0;
45
+ const opusUtil = quotaData.seven_day_opus?.utilization || 0;
46
+ const sonnetUtil = quotaData.seven_day_sonnet?.utilization || 0;
47
+
48
+ // Per-type accumulators
49
+ let inTok = 0, outTok = 0, crTok = 0, cwTok = 0, totalCost = 0;
50
+ let opusIn = 0, opusOut = 0, opusCR = 0, opusCW = 0, opusCost = 0;
51
+ let sonIn = 0, sonOut = 0, sonCR = 0, sonCW = 0, sonnetCost = 0;
52
+
53
+ for (const r of records) {
54
+ const cost = calculateRecordCost(r);
55
+ inTok += r.input_tokens; outTok += r.output_tokens;
56
+ crTok += r.cache_read_tokens; cwTok += r.cache_creation_tokens;
57
+ totalCost += cost;
58
+
59
+ if (r.model?.includes('opus')) {
60
+ opusIn += r.input_tokens; opusOut += r.output_tokens;
61
+ opusCR += r.cache_read_tokens; opusCW += r.cache_creation_tokens;
62
+ opusCost += cost;
63
+ } else if (r.model?.includes('sonnet')) {
64
+ sonIn += r.input_tokens; sonOut += r.output_tokens;
65
+ sonCR += r.cache_read_tokens; sonCW += r.cache_creation_tokens;
66
+ sonnetCost += cost;
67
+ }
68
+ }
69
+
70
+ totalCost = Math.round(totalCost * 100) / 100;
71
+ opusCost = Math.round(opusCost * 100) / 100;
72
+ sonnetCost = Math.round(sonnetCost * 100) / 100;
73
+
74
+ function buildTokens(inp, out, cr, cw) {
75
+ return { input: inp, output: out, cacheRead: cr, cacheCreation: cw };
76
+ }
77
+
78
+ function project(actual, utilization) {
79
+ if (utilization <= 0) return null;
80
+ return Math.round(actual / (utilization / 100));
81
+ }
82
+
83
+ function projectCost(actual, utilization) {
84
+ if (utilization <= 0) return null;
85
+ return Math.round((actual / (utilization / 100)) * 100) / 100;
86
+ }
87
+
88
+ // actualTokens = total excluding cache reads (in + out + cw) — used for projections
89
+ const totalExclCR = inTok + outTok + cwTok;
90
+ const opusExclCR = opusIn + opusOut + opusCW;
91
+ const sonExclCR = sonIn + sonOut + sonCW;
92
+
93
+ return {
94
+ overall: {
95
+ utilization: overallUtil,
96
+ tokens: buildTokens(inTok, outTok, crTok, cwTok),
97
+ actualTokens: totalExclCR,
98
+ projectedTokensAt100: project(totalExclCR, overallUtil),
99
+ actualCost: totalCost,
100
+ projectedCostAt100: projectCost(totalCost, overallUtil),
101
+ },
102
+ models: {
103
+ opus: {
104
+ utilization: opusUtil,
105
+ tokens: buildTokens(opusIn, opusOut, opusCR, opusCW),
106
+ actualTokens: opusExclCR,
107
+ projectedTokensAt100: project(opusExclCR, opusUtil),
108
+ actualCost: opusCost,
109
+ projectedCostAt100: projectCost(opusCost, opusUtil),
110
+ },
111
+ sonnet: {
112
+ utilization: sonnetUtil,
113
+ tokens: buildTokens(sonIn, sonOut, sonCR, sonCW),
114
+ actualTokens: sonExclCR,
115
+ projectedTokensAt100: project(sonExclCR, sonnetUtil),
116
+ actualCost: sonnetCost,
117
+ projectedCostAt100: projectCost(sonnetCost, sonnetUtil),
118
+ },
119
+ },
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Update the quota cycle snapshot file for this machine.
125
+ * Called after each successful quota API fetch.
126
+ *
127
+ * @param {object} quotaData - Quota API response (must have available === true)
128
+ * @param {string} logBaseDir - This machine's log directory (~/.claude/projects/)
129
+ * @param {string} machineName - Identifier for this machine
130
+ * @param {string} [snapshotDir] - Directory for snapshot files (defaults to syncDir or ~/.claude/)
131
+ * @param {string} [syncDir] - Shared sync directory; used as fallback when snapshotDir is not set
132
+ */
133
+ export function updateQuotaCycleSnapshot(quotaData, logBaseDir, machineName, snapshotDir, syncDir) {
134
+ if (!quotaData?.available || !quotaData.seven_day?.resets_at) return;
135
+
136
+ const dir = snapshotDir || syncDir || path.join(os.homedir(), '.claude');
137
+ const filePath = path.join(dir, `quota-cycles-${machineName}.json`);
138
+
139
+ // Normalize resets_at to second precision — the API returns varying microseconds
140
+ // on each call (e.g. .905316 vs .581788) which would cause false cycle switches
141
+ const rawResetsAt = new Date(quotaData.seven_day.resets_at);
142
+ rawResetsAt.setMilliseconds(0);
143
+ const resetsAt = rawResetsAt.toISOString();
144
+ const start = new Date(rawResetsAt.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
145
+
146
+ let snapshot;
147
+ try {
148
+ snapshot = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
149
+ } catch {
150
+ snapshot = { schemaVersion: 1, machineName, currentCycle: null, history: [] };
151
+ }
152
+
153
+ // Compare normalized period keys to detect actual cycle boundary changes.
154
+ // Uses hour-precision keys to tolerate varying sub-second timestamps from the API.
155
+ const storedKey = snapshot.currentCycle ? cyclePeriodKey(snapshot.currentCycle) : null;
156
+ const newKey = cyclePeriodKey({ resets_at: resetsAt });
157
+ if (snapshot.currentCycle && storedKey !== newKey) {
158
+ snapshot.history.unshift(snapshot.currentCycle);
159
+ snapshot.history = deduplicateHistory(snapshot.history);
160
+ if (snapshot.history.length > MAX_HISTORY) {
161
+ snapshot.history = snapshot.history.slice(0, MAX_HISTORY);
162
+ }
163
+ snapshot.currentCycle = null;
164
+ }
165
+
166
+ const allRecords = parseLogDirectory(logBaseDir);
167
+ const cycleRecords = filterByDateRange(allRecords, start, resetsAt);
168
+ const cycleData = computeCycleData(cycleRecords, quotaData);
169
+
170
+ snapshot.currentCycle = {
171
+ resets_at: resetsAt,
172
+ start,
173
+ lastUpdated: new Date().toISOString(),
174
+ ...cycleData,
175
+ };
176
+
177
+ fs.writeFileSync(filePath, JSON.stringify(snapshot, null, 2));
178
+ }
179
+
180
+ /**
181
+ * Load and merge quota cycle data from all machines.
182
+ *
183
+ * @param {string} machineName - This machine's name
184
+ * @param {string|null} syncDir - Shared sync directory (null if single-machine)
185
+ * @param {string} [snapshotDir] - Directory for snapshot files (defaults to ~/.claude/)
186
+ * @returns {{ currentCycle: object|null, history: object[], machines: string[] }}
187
+ */
188
+ export function loadQuotaCycles(machineName, syncDir, snapshotDir) {
189
+ const dir = snapshotDir || syncDir || path.join(os.homedir(), '.claude');
190
+ const empty = { currentCycle: null, history: [], machines: [] };
191
+
192
+ let files;
193
+ try {
194
+ files = fs.readdirSync(dir).filter(f => f.startsWith('quota-cycles-') && f.endsWith('.json'));
195
+ } catch {
196
+ return empty;
197
+ }
198
+
199
+ if (files.length === 0) return empty;
200
+
201
+ const snapshots = [];
202
+ for (const file of files) {
203
+ try {
204
+ const data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf-8'));
205
+ if (data.schemaVersion === 1) snapshots.push(data);
206
+ } catch { /* skip corrupt files */ }
207
+ }
208
+
209
+ if (snapshots.length === 0) return empty;
210
+
211
+ const machines = snapshots.map(s => s.machineName);
212
+
213
+ if (snapshots.length === 1) {
214
+ // Single machine: duplicates are time-series snapshots of the same data,
215
+ // so dedup (keep most recent) and filter out current-cycle overlap.
216
+ const s = snapshots[0];
217
+ let history = deduplicateHistory(s.history);
218
+ if (s.currentCycle) {
219
+ const currentKey = cyclePeriodKey(s.currentCycle);
220
+ history = history.filter(h => cyclePeriodKey(h) !== currentKey);
221
+ }
222
+ return { currentCycle: s.currentCycle, history, machines };
223
+ }
224
+
225
+ // Multi-machine: dedup within each machine first (removes false-switch
226
+ // duplicates), then merge across machines (sums different machines' data).
227
+ const dedupedHistories = snapshots.map(s => deduplicateHistory(s.history));
228
+ let currentCycle = mergeCycles(snapshots.map(s => s.currentCycle).filter(Boolean));
229
+ let history = mergeHistories(dedupedHistories);
230
+
231
+ // If history contains entries for the current cycle's period (e.g. from an
232
+ // offline machine whose cycle was archived), merge them INTO the current
233
+ // cycle instead of dropping them.
234
+ if (currentCycle) {
235
+ const currentKey = cyclePeriodKey(currentCycle);
236
+ const overlapping = history.filter(h => cyclePeriodKey(h) === currentKey);
237
+ history = history.filter(h => cyclePeriodKey(h) !== currentKey);
238
+ if (overlapping.length > 0) {
239
+ currentCycle = mergeSamePeriodCycles([currentCycle, ...overlapping]);
240
+ }
241
+ }
242
+
243
+ return { currentCycle, history, machines };
244
+ }
245
+
246
+ function mergeCycles(cycles) {
247
+ if (cycles.length === 0) return null;
248
+ if (cycles.length === 1) return cycles[0];
249
+
250
+ const byPeriod = new Map();
251
+ for (const c of cycles) {
252
+ const key = cyclePeriodKey(c);
253
+ if (!byPeriod.has(key)) byPeriod.set(key, []);
254
+ byPeriod.get(key).push(c);
255
+ }
256
+
257
+ let bestKey = null, bestCount = 0;
258
+ for (const [key, arr] of byPeriod) {
259
+ if (arr.length > bestCount || (arr.length === bestCount && key > bestKey)) { bestKey = key; bestCount = arr.length; }
260
+ }
261
+
262
+ const sameCycle = byPeriod.get(bestKey);
263
+ return mergeSamePeriodCycles(sameCycle);
264
+ }
265
+
266
+ function mergeSamePeriodCycles(cycles) {
267
+ const mostRecent = cycles.reduce((a, b) =>
268
+ new Date(a.lastUpdated) > new Date(b.lastUpdated) ? a : b
269
+ );
270
+
271
+ return {
272
+ resets_at: mostRecent.resets_at,
273
+ start: mostRecent.start,
274
+ lastUpdated: mostRecent.lastUpdated,
275
+ overall: mergeMetrics(cycles.map(c => c.overall), mostRecent.overall.utilization),
276
+ models: {
277
+ opus: mergeMetrics(cycles.map(c => c.models.opus), mostRecent.models?.opus?.utilization || 0),
278
+ sonnet: mergeMetrics(cycles.map(c => c.models.sonnet), mostRecent.models?.sonnet?.utilization || 0),
279
+ },
280
+ };
281
+ }
282
+
283
+ function mergeMetrics(metricsArray, utilization) {
284
+ const totalTokens = metricsArray.reduce((sum, m) => sum + (m?.actualTokens || 0), 0);
285
+ const totalCost = Math.round(metricsArray.reduce((sum, m) => sum + (m?.actualCost || 0), 0) * 100) / 100;
286
+
287
+ // Merge per-type token breakdown
288
+ const tokens = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
289
+ for (const m of metricsArray) {
290
+ if (m?.tokens) {
291
+ tokens.input += m.tokens.input || 0;
292
+ tokens.output += m.tokens.output || 0;
293
+ tokens.cacheRead += m.tokens.cacheRead || 0;
294
+ tokens.cacheCreation += m.tokens.cacheCreation || 0;
295
+ }
296
+ }
297
+
298
+ return {
299
+ utilization,
300
+ tokens,
301
+ actualTokens: totalTokens,
302
+ projectedTokensAt100: utilization > 0 ? Math.round(totalTokens / (utilization / 100)) : null,
303
+ actualCost: totalCost,
304
+ projectedCostAt100: utilization > 0 ? Math.round((totalCost / (utilization / 100)) * 100) / 100 : null,
305
+ };
306
+ }
307
+
308
+ function mergeHistories(historyArrays) {
309
+ const byPeriod = new Map();
310
+ for (const history of historyArrays) {
311
+ for (const entry of history) {
312
+ const key = cyclePeriodKey(entry);
313
+ if (!byPeriod.has(key)) byPeriod.set(key, []);
314
+ byPeriod.get(key).push(entry);
315
+ }
316
+ }
317
+
318
+ const merged = [];
319
+ for (const [, entries] of byPeriod) {
320
+ merged.push(mergeSamePeriodCycles(entries));
321
+ }
322
+
323
+ merged.sort((a, b) => new Date(b.resets_at) - new Date(a.resets_at));
324
+ return merged.slice(0, MAX_HISTORY);
325
+ }