tokentracker-cli 0.2.27 → 0.3.3
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 +61 -28
- package/dashboard/dist/assets/DashboardPage-DwwjNQP7.js +12 -0
- package/dashboard/dist/assets/{LandingExtras-BUwK_c-9.js → LandingExtras-BsrCd4pK.js} +1 -1
- package/dashboard/dist/assets/LeaderboardPage-ZIRnfiXX.js +1 -0
- package/dashboard/dist/assets/LeaderboardProfilePage-CoLDY-lR.js +1 -0
- package/dashboard/dist/assets/{MatrixRain-D7Pm88QF.js → MatrixRain-B2DIJ0ID.js} +1 -1
- package/dashboard/dist/assets/MatrixShell-Byc-iwIr.js +1 -0
- package/dashboard/dist/assets/{main-oIbJf8pA.js → main-C3QpHBOP.js} +10 -8
- package/dashboard/dist/assets/main-DJvWJCv5.css +1 -0
- package/dashboard/dist/assets/vibeusage-api-EQ444MvB.js +1 -0
- package/dashboard/dist/index.html +2 -2
- package/dashboard/dist/share.html +2 -2
- package/package.json +2 -2
- package/src/commands/init.js +22 -1
- package/src/commands/sync.js +78 -2
- package/src/lib/cursor-config.js +328 -0
- package/src/lib/local-api.js +108 -12
- package/src/lib/rollout.js +316 -2
- package/dashboard/dist/assets/AsciiBox-BZ2xYyXa.js +0 -1
- package/dashboard/dist/assets/DashboardPage-ynq92wZf.js +0 -12
- package/dashboard/dist/assets/LeaderboardPage-IzN8LibU.js +0 -1
- package/dashboard/dist/assets/LeaderboardProfilePage-3JjGYRaz.js +0 -1
- package/dashboard/dist/assets/MatrixShell-BtjokX5f.js +0 -1
- package/dashboard/dist/assets/main-hwTpulbk.css +0 -1
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
const os = require("node:os");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const cp = require("node:child_process");
|
|
5
|
+
const https = require("node:https");
|
|
6
|
+
|
|
7
|
+
const { readJson } = require("./fs");
|
|
8
|
+
|
|
9
|
+
// ── Path resolution ──
|
|
10
|
+
|
|
11
|
+
function resolveCursorPaths({ home } = {}) {
|
|
12
|
+
const h = home || os.homedir();
|
|
13
|
+
const appSupport = path.join(h, "Library", "Application Support", "Cursor");
|
|
14
|
+
return {
|
|
15
|
+
appDir: appSupport,
|
|
16
|
+
stateDbPath: path.join(appSupport, "User", "globalStorage", "state.vscdb"),
|
|
17
|
+
cliConfigPath: path.join(h, ".cursor", "cli-config.json"),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isCursorInstalled({ home } = {}) {
|
|
22
|
+
const { appDir } = resolveCursorPaths({ home });
|
|
23
|
+
try {
|
|
24
|
+
return fs.statSync(appDir).isDirectory();
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Auth token extraction ──
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extract Cursor session cookie from local SQLite + cli-config.json.
|
|
34
|
+
* Returns { cookie, userId } or null on failure.
|
|
35
|
+
*
|
|
36
|
+
* Cookie format: WorkosCursorSessionToken=user_XXXXX%3A%3A<jwt>
|
|
37
|
+
* - JWT from state.vscdb → ItemTable → cursorAuth/accessToken
|
|
38
|
+
* - userId from cli-config.json → authInfo.authId → "auth0|user_XXXXX"
|
|
39
|
+
*/
|
|
40
|
+
function extractCursorSessionToken({ home } = {}) {
|
|
41
|
+
const { stateDbPath, cliConfigPath } = resolveCursorPaths({ home });
|
|
42
|
+
|
|
43
|
+
// 1. Extract JWT from SQLite
|
|
44
|
+
let jwt;
|
|
45
|
+
try {
|
|
46
|
+
jwt = cp
|
|
47
|
+
.execSync(
|
|
48
|
+
`sqlite3 -readonly "${stateDbPath}" "SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken';"`,
|
|
49
|
+
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
|
50
|
+
)
|
|
51
|
+
.trim();
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (!jwt || jwt.length < 10) return null;
|
|
56
|
+
|
|
57
|
+
// 2. Extract userId — try cli-config.json first, fall back to JWT decode
|
|
58
|
+
let userId = extractUserIdFromCliConfig(cliConfigPath);
|
|
59
|
+
if (!userId) {
|
|
60
|
+
userId = extractUserIdFromJwt(jwt);
|
|
61
|
+
}
|
|
62
|
+
if (!userId) return null;
|
|
63
|
+
|
|
64
|
+
// 3. Build cookie
|
|
65
|
+
const cookie = `WorkosCursorSessionToken=${userId}%3A%3A${jwt}`;
|
|
66
|
+
return { cookie, userId };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function extractUserIdFromCliConfig(configPath) {
|
|
70
|
+
try {
|
|
71
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
72
|
+
const authId = config?.authInfo?.authId || "";
|
|
73
|
+
const match = authId.match(/\|(user_[A-Za-z0-9_]+)/);
|
|
74
|
+
return match ? match[1] : null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function extractUserIdFromJwt(jwt) {
|
|
81
|
+
try {
|
|
82
|
+
const parts = jwt.split(".");
|
|
83
|
+
if (parts.length !== 3) return null;
|
|
84
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
|
|
85
|
+
const sub = payload.sub || "";
|
|
86
|
+
const match = sub.match(/(user_[A-Za-z0-9_]+)/);
|
|
87
|
+
return match ? match[1] : null;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── API client ──
|
|
94
|
+
|
|
95
|
+
const CURSOR_CSV_URL = "https://cursor.com/api/dashboard/export-usage-events-csv?strategy=tokens";
|
|
96
|
+
const CURSOR_SUMMARY_URL = "https://cursor.com/api/usage-summary";
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fetch full usage CSV from Cursor API.
|
|
100
|
+
* Returns raw CSV string or throws on error.
|
|
101
|
+
*/
|
|
102
|
+
function fetchCursorUsageCsv({ cookie, timeoutMs = 30000 }) {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const url = new URL(CURSOR_CSV_URL);
|
|
105
|
+
const req = https.request(
|
|
106
|
+
{
|
|
107
|
+
hostname: url.hostname,
|
|
108
|
+
path: url.pathname + url.search,
|
|
109
|
+
method: "GET",
|
|
110
|
+
headers: {
|
|
111
|
+
Accept: "*/*",
|
|
112
|
+
Cookie: cookie,
|
|
113
|
+
Referer: "https://www.cursor.com/settings",
|
|
114
|
+
"User-Agent":
|
|
115
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
116
|
+
},
|
|
117
|
+
timeout: timeoutMs,
|
|
118
|
+
},
|
|
119
|
+
(res) => {
|
|
120
|
+
if (res.statusCode === 401 || res.statusCode === 403) {
|
|
121
|
+
res.resume();
|
|
122
|
+
return reject(new Error("Cursor session expired — re-login in Cursor to refresh"));
|
|
123
|
+
}
|
|
124
|
+
if (res.statusCode === 308 || res.statusCode === 301 || res.statusCode === 302) {
|
|
125
|
+
// Follow redirect once
|
|
126
|
+
const location = res.headers.location;
|
|
127
|
+
res.resume();
|
|
128
|
+
if (!location) return reject(new Error(`Cursor API redirect without Location header`));
|
|
129
|
+
return fetchUrlRaw({ urlStr: location, cookie, timeoutMs }).then(resolve, reject);
|
|
130
|
+
}
|
|
131
|
+
if (res.statusCode !== 200) {
|
|
132
|
+
res.resume();
|
|
133
|
+
return reject(new Error(`Cursor API returned ${res.statusCode}`));
|
|
134
|
+
}
|
|
135
|
+
let data = "";
|
|
136
|
+
res.on("data", (chunk) => {
|
|
137
|
+
data += chunk;
|
|
138
|
+
});
|
|
139
|
+
res.on("end", () => resolve(data));
|
|
140
|
+
res.on("error", reject);
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
req.on("error", reject);
|
|
144
|
+
req.on("timeout", () => {
|
|
145
|
+
req.destroy();
|
|
146
|
+
reject(new Error("Cursor API request timed out"));
|
|
147
|
+
});
|
|
148
|
+
req.end();
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function fetchUrlRaw({ urlStr, cookie, timeoutMs }) {
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
const url = new URL(urlStr);
|
|
155
|
+
const req = https.request(
|
|
156
|
+
{
|
|
157
|
+
hostname: url.hostname,
|
|
158
|
+
path: url.pathname + url.search,
|
|
159
|
+
method: "GET",
|
|
160
|
+
headers: {
|
|
161
|
+
Accept: "*/*",
|
|
162
|
+
Cookie: cookie,
|
|
163
|
+
Referer: "https://www.cursor.com/settings",
|
|
164
|
+
"User-Agent":
|
|
165
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
166
|
+
},
|
|
167
|
+
timeout: timeoutMs,
|
|
168
|
+
},
|
|
169
|
+
(res) => {
|
|
170
|
+
if (res.statusCode !== 200) {
|
|
171
|
+
res.resume();
|
|
172
|
+
return reject(new Error(`Cursor API returned ${res.statusCode} from ${urlStr}`));
|
|
173
|
+
}
|
|
174
|
+
let data = "";
|
|
175
|
+
res.on("data", (chunk) => {
|
|
176
|
+
data += chunk;
|
|
177
|
+
});
|
|
178
|
+
res.on("end", () => resolve(data));
|
|
179
|
+
res.on("error", reject);
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
req.on("error", reject);
|
|
183
|
+
req.on("timeout", () => {
|
|
184
|
+
req.destroy();
|
|
185
|
+
reject(new Error("Cursor API request timed out"));
|
|
186
|
+
});
|
|
187
|
+
req.end();
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── CSV parsing ──
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Parse Cursor usage CSV into structured records.
|
|
195
|
+
*
|
|
196
|
+
* New format columns:
|
|
197
|
+
* Date, Kind, Model, Max Mode, Input (w/ Cache Write), Input (w/o Cache Write),
|
|
198
|
+
* Cache Read, Output Tokens, Total Tokens, Cost
|
|
199
|
+
*
|
|
200
|
+
* Old format columns:
|
|
201
|
+
* Date, Model, Input (w/ Cache Write), Input (w/o Cache Write),
|
|
202
|
+
* Cache Read, Output Tokens, Total Tokens, Cost, Cost to you
|
|
203
|
+
*/
|
|
204
|
+
function parseCursorCsv(csvText) {
|
|
205
|
+
const lines = csvText.split("\n").filter((l) => l.trim().length > 0);
|
|
206
|
+
if (lines.length < 2) return [];
|
|
207
|
+
|
|
208
|
+
const header = lines[0];
|
|
209
|
+
const isNewFormat = header.includes("Kind");
|
|
210
|
+
|
|
211
|
+
const records = [];
|
|
212
|
+
for (let i = 1; i < lines.length; i++) {
|
|
213
|
+
const fields = parseCsvLine(lines[i]);
|
|
214
|
+
if (!fields || fields.length < 8) continue;
|
|
215
|
+
|
|
216
|
+
let record;
|
|
217
|
+
if (isNewFormat) {
|
|
218
|
+
// Date,Kind,Model,Max Mode,Input (w/ Cache Write),Input (w/o Cache Write),Cache Read,Output Tokens,Total Tokens,Cost
|
|
219
|
+
const inputWithCache = toNum(fields[4]);
|
|
220
|
+
const inputWithoutCache = toNum(fields[5]);
|
|
221
|
+
record = {
|
|
222
|
+
date: stripQuotes(fields[0]),
|
|
223
|
+
kind: stripQuotes(fields[1]),
|
|
224
|
+
model: stripQuotes(fields[2]),
|
|
225
|
+
maxMode: stripQuotes(fields[3]),
|
|
226
|
+
inputTokens: inputWithoutCache,
|
|
227
|
+
cacheWriteTokens: Math.max(0, inputWithCache - inputWithoutCache),
|
|
228
|
+
cacheReadTokens: toNum(fields[6]),
|
|
229
|
+
outputTokens: toNum(fields[7]),
|
|
230
|
+
totalTokens: toNum(fields[8]),
|
|
231
|
+
cost: toFloat(fields[9]),
|
|
232
|
+
};
|
|
233
|
+
} else {
|
|
234
|
+
// Date,Model,Input (w/ Cache Write),Input (w/o Cache Write),Cache Read,Output Tokens,Total Tokens,Cost,Cost to you
|
|
235
|
+
const inputWithCache = toNum(fields[2]);
|
|
236
|
+
const inputWithoutCache = toNum(fields[3]);
|
|
237
|
+
record = {
|
|
238
|
+
date: stripQuotes(fields[0]),
|
|
239
|
+
kind: "unknown",
|
|
240
|
+
model: stripQuotes(fields[1]),
|
|
241
|
+
maxMode: "No",
|
|
242
|
+
inputTokens: inputWithoutCache,
|
|
243
|
+
cacheWriteTokens: Math.max(0, inputWithCache - inputWithoutCache),
|
|
244
|
+
cacheReadTokens: toNum(fields[4]),
|
|
245
|
+
outputTokens: toNum(fields[5]),
|
|
246
|
+
totalTokens: toNum(fields[6]),
|
|
247
|
+
cost: toFloat(fields[7]),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Skip records with no tokens
|
|
252
|
+
if (record.totalTokens <= 0 && record.inputTokens <= 0 && record.outputTokens <= 0) continue;
|
|
253
|
+
|
|
254
|
+
records.push(record);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return records;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Normalize a Cursor CSV record to TokenTracker's standard token format.
|
|
262
|
+
*/
|
|
263
|
+
function normalizeCursorUsage(record) {
|
|
264
|
+
const inputTokens = Math.max(0, Math.floor(record.inputTokens || 0));
|
|
265
|
+
const cacheWrite = Math.max(0, Math.floor(record.cacheWriteTokens || 0));
|
|
266
|
+
const cacheRead = Math.max(0, Math.floor(record.cacheReadTokens || 0));
|
|
267
|
+
const outputTokens = Math.max(0, Math.floor(record.outputTokens || 0));
|
|
268
|
+
const totalTokens = inputTokens + outputTokens + cacheWrite + cacheRead;
|
|
269
|
+
return {
|
|
270
|
+
input_tokens: inputTokens,
|
|
271
|
+
cached_input_tokens: cacheRead,
|
|
272
|
+
cache_creation_input_tokens: cacheWrite,
|
|
273
|
+
output_tokens: outputTokens,
|
|
274
|
+
reasoning_output_tokens: 0,
|
|
275
|
+
total_tokens: totalTokens,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── CSV helpers ──
|
|
280
|
+
|
|
281
|
+
function parseCsvLine(line) {
|
|
282
|
+
const fields = [];
|
|
283
|
+
let current = "";
|
|
284
|
+
let inQuotes = false;
|
|
285
|
+
for (let i = 0; i < line.length; i++) {
|
|
286
|
+
const ch = line[i];
|
|
287
|
+
if (ch === '"') {
|
|
288
|
+
inQuotes = !inQuotes;
|
|
289
|
+
current += ch;
|
|
290
|
+
} else if (ch === "," && !inQuotes) {
|
|
291
|
+
fields.push(current.trim());
|
|
292
|
+
current = "";
|
|
293
|
+
} else {
|
|
294
|
+
current += ch;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
fields.push(current.trim());
|
|
298
|
+
return fields;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function stripQuotes(s) {
|
|
302
|
+
if (!s) return "";
|
|
303
|
+
const trimmed = s.trim();
|
|
304
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
305
|
+
return trimmed.slice(1, -1);
|
|
306
|
+
}
|
|
307
|
+
return trimmed;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function toNum(s) {
|
|
311
|
+
const n = Number(stripQuotes(s));
|
|
312
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function toFloat(s) {
|
|
316
|
+
const cleaned = stripQuotes(s).replace(/[$,]/g, "");
|
|
317
|
+
const n = Number(cleaned);
|
|
318
|
+
return Number.isFinite(n) ? n : 0;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
module.exports = {
|
|
322
|
+
resolveCursorPaths,
|
|
323
|
+
isCursorInstalled,
|
|
324
|
+
extractCursorSessionToken,
|
|
325
|
+
fetchCursorUsageCsv,
|
|
326
|
+
parseCursorCsv,
|
|
327
|
+
normalizeCursorUsage,
|
|
328
|
+
};
|
package/src/lib/local-api.js
CHANGED
|
@@ -57,6 +57,111 @@ function aggregateByDay(rows) {
|
|
|
57
57
|
return Array.from(byDay.values()).sort((a, b) => a.day.localeCompare(b.day));
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
function getTimeZoneContext(url) {
|
|
61
|
+
const tz = String(url.searchParams.get("tz") || "").trim();
|
|
62
|
+
const rawOffset = Number(url.searchParams.get("tz_offset_minutes"));
|
|
63
|
+
return {
|
|
64
|
+
timeZone: tz || null,
|
|
65
|
+
offsetMinutes: Number.isFinite(rawOffset) ? Math.trunc(rawOffset) : null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getZonedParts(date, { timeZone, offsetMinutes } = {}) {
|
|
70
|
+
const dt = date instanceof Date ? date : new Date(date);
|
|
71
|
+
if (!Number.isFinite(dt.getTime())) return null;
|
|
72
|
+
|
|
73
|
+
if (timeZone && typeof Intl !== "undefined" && Intl.DateTimeFormat) {
|
|
74
|
+
try {
|
|
75
|
+
const formatter = new Intl.DateTimeFormat("en-CA", {
|
|
76
|
+
timeZone,
|
|
77
|
+
year: "numeric",
|
|
78
|
+
month: "2-digit",
|
|
79
|
+
day: "2-digit",
|
|
80
|
+
hour: "2-digit",
|
|
81
|
+
minute: "2-digit",
|
|
82
|
+
second: "2-digit",
|
|
83
|
+
hourCycle: "h23",
|
|
84
|
+
});
|
|
85
|
+
const parts = formatter.formatToParts(dt);
|
|
86
|
+
const values = parts.reduce((acc, part) => {
|
|
87
|
+
if (part.type && part.value) acc[part.type] = part.value;
|
|
88
|
+
return acc;
|
|
89
|
+
}, {});
|
|
90
|
+
const year = Number(values.year);
|
|
91
|
+
const month = Number(values.month);
|
|
92
|
+
const day = Number(values.day);
|
|
93
|
+
const hour = Number(values.hour);
|
|
94
|
+
const minute = Number(values.minute);
|
|
95
|
+
const second = Number(values.second);
|
|
96
|
+
if ([year, month, day, hour, minute, second].every(Number.isFinite)) {
|
|
97
|
+
return { year, month, day, hour, minute, second };
|
|
98
|
+
}
|
|
99
|
+
} catch (_e) {
|
|
100
|
+
// fall through
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (Number.isFinite(offsetMinutes)) {
|
|
105
|
+
const shifted = new Date(dt.getTime() + offsetMinutes * 60 * 1000);
|
|
106
|
+
return {
|
|
107
|
+
year: shifted.getUTCFullYear(),
|
|
108
|
+
month: shifted.getUTCMonth() + 1,
|
|
109
|
+
day: shifted.getUTCDate(),
|
|
110
|
+
hour: shifted.getUTCHours(),
|
|
111
|
+
minute: shifted.getUTCMinutes(),
|
|
112
|
+
second: shifted.getUTCSeconds(),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
year: dt.getFullYear(),
|
|
118
|
+
month: dt.getMonth() + 1,
|
|
119
|
+
day: dt.getDate(),
|
|
120
|
+
hour: dt.getHours(),
|
|
121
|
+
minute: dt.getMinutes(),
|
|
122
|
+
second: dt.getSeconds(),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function formatPartsDayKey(parts) {
|
|
127
|
+
if (!parts) return "";
|
|
128
|
+
return `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function aggregateHourlyByDay(rows, dayKey, timeZoneContext) {
|
|
132
|
+
const byHour = new Map();
|
|
133
|
+
for (const row of rows) {
|
|
134
|
+
if (!row.hour_start) continue;
|
|
135
|
+
const parts = getZonedParts(new Date(row.hour_start), timeZoneContext);
|
|
136
|
+
if (!parts) continue;
|
|
137
|
+
if (formatPartsDayKey(parts) !== dayKey) continue;
|
|
138
|
+
const hourKey = `${dayKey}T${String(parts.hour).padStart(2, "0")}:00:00`;
|
|
139
|
+
if (!byHour.has(hourKey)) {
|
|
140
|
+
byHour.set(hourKey, {
|
|
141
|
+
hour: hourKey,
|
|
142
|
+
total_tokens: 0,
|
|
143
|
+
billable_total_tokens: 0,
|
|
144
|
+
input_tokens: 0,
|
|
145
|
+
output_tokens: 0,
|
|
146
|
+
cached_input_tokens: 0,
|
|
147
|
+
cache_creation_input_tokens: 0,
|
|
148
|
+
reasoning_output_tokens: 0,
|
|
149
|
+
conversation_count: 0,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
const bucket = byHour.get(hourKey);
|
|
153
|
+
bucket.total_tokens += row.total_tokens || 0;
|
|
154
|
+
bucket.billable_total_tokens += row.total_tokens || 0;
|
|
155
|
+
bucket.input_tokens += row.input_tokens || 0;
|
|
156
|
+
bucket.output_tokens += row.output_tokens || 0;
|
|
157
|
+
bucket.cached_input_tokens += row.cached_input_tokens || 0;
|
|
158
|
+
bucket.cache_creation_input_tokens += row.cache_creation_input_tokens || 0;
|
|
159
|
+
bucket.reasoning_output_tokens += row.reasoning_output_tokens || 0;
|
|
160
|
+
bucket.conversation_count += row.conversation_count || 0;
|
|
161
|
+
}
|
|
162
|
+
return Array.from(byHour.values()).sort((a, b) => a.hour.localeCompare(b.hour));
|
|
163
|
+
}
|
|
164
|
+
|
|
60
165
|
// ---------------------------------------------------------------------------
|
|
61
166
|
// Sync helper
|
|
62
167
|
// ---------------------------------------------------------------------------
|
|
@@ -461,18 +566,9 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
461
566
|
// --- usage-hourly (stub for day-view) ---
|
|
462
567
|
if (p === "/functions/vibeusage-usage-hourly") {
|
|
463
568
|
const day = url.searchParams.get("day") || new Date().toISOString().slice(0, 10);
|
|
464
|
-
const
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
total_tokens: r.total_tokens || 0,
|
|
468
|
-
billable_total_tokens: r.total_tokens || 0,
|
|
469
|
-
input_tokens: r.input_tokens || 0,
|
|
470
|
-
output_tokens: r.output_tokens || 0,
|
|
471
|
-
cached_input_tokens: r.cached_input_tokens || 0,
|
|
472
|
-
cache_creation_input_tokens: r.cache_creation_input_tokens || 0,
|
|
473
|
-
reasoning_output_tokens: r.reasoning_output_tokens || 0,
|
|
474
|
-
conversation_count: r.conversation_count || 0,
|
|
475
|
-
}));
|
|
569
|
+
const timeZoneContext = getTimeZoneContext(url);
|
|
570
|
+
const rows = readQueueData(qp);
|
|
571
|
+
const data = aggregateHourlyByDay(rows, day, timeZoneContext);
|
|
476
572
|
json(res, { day, data });
|
|
477
573
|
return true;
|
|
478
574
|
}
|