@vibe-cafe/vibe-usage 0.14.4 → 0.14.6

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,12 +1,37 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { aggregateToBuckets } from './aggregate.js';
4
+ import { aggregateToBuckets, roundToHalfHour } from './aggregate.js';
5
5
  import { queryDbJsonSnapshotOnLock, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
6
6
 
7
7
  const STATE_DB_RELATIVE = join('User', 'globalStorage', 'state.vscdb');
8
8
  const ACCESS_TOKEN_KEY = 'cursorAuth/accessToken';
9
9
  const SESSION_COOKIE = 'WorkosCursorSessionToken';
10
+ const CSV_PATH = '/api/dashboard/export-usage-events-csv';
11
+ const EVENTS_PATH = '/api/dashboard/get-filtered-usage-events';
12
+ const PAGE_SIZE = 1000;
13
+ // Cursor's dashboard pages the events endpoint; 200k events is far above any
14
+ // real personal account. Hitting the cap means the fallback is incomplete, so
15
+ // the run is skipped instead of pruning incremental state with a partial view.
16
+ const MAX_PAGES = 200;
17
+ const HOUR_MS = 60 * 60 * 1000;
18
+ const DAY_MS = 24 * HOUR_MS;
19
+
20
+ // Rolling window (issue #131). cursor.com builds the whole-account export in
21
+ // time proportional to its rows (60k rows / 6.5 MB / 99s measured 2026-11-04),
22
+ // so every sync now re-reads only the last 7 days, and history is filled in
23
+ // weekly blocks when it is missing: first run, Cursor account switch, or no
24
+ // successful Cursor sync for more than 6 days. The export filters on
25
+ // `startDate` / `endDate` in **milliseconds**, both ends inclusive (verified
26
+ // row for row against the whole export); seconds silently return nothing.
27
+ const RECENT_MS = 7 * DAY_MS;
28
+ const GAP_LIMIT_MS = 6 * DAY_MS;
29
+ const BACKFILL_BLOCK_MS = 7 * DAY_MS;
30
+ const BACKFILL_FLOOR = Date.parse('2024-01-01T00:00:00Z');
31
+ // No new history block starts once Cursor has spent this long in one sync
32
+ // (uploads not included); a started block finishes. A heavy week measured
33
+ // ~12k rows, roughly 8-19s.
34
+ const BACKFILL_BUDGET_MS = 40_000;
10
35
 
11
36
  function getDefaultStateDbPath() {
12
37
  if (process.platform === 'darwin') {
@@ -41,33 +66,88 @@ export function getCursorStateDbPath() {
41
66
  return null;
42
67
  }
43
68
 
69
+ /**
70
+ * Cursor stores this value as TEXT on most builds, but some (notably newer
71
+ * Windows builds) write the ASCII token as a UTF-16LE BLOB. `node:sqlite`
72
+ * returns a Uint8Array then, while the sqlite3 CLI returns a JS string whose
73
+ * characters carry interleaved NULs. Both used to read as "no token", which
74
+ * made Cursor silently stop syncing after a fresh sign-in.
75
+ */
76
+ export function decodeTokenValue(value) {
77
+ if (typeof value === 'string') {
78
+ if (value.includes('\u0000')) {
79
+ const bytes = Buffer.from(value, 'latin1');
80
+ if (bytes.length % 2 === 0) {
81
+ const decoded = bytes.toString('utf16le').replace(/\u0000/g, '').trim();
82
+ if (decoded) return decoded;
83
+ }
84
+ return value.replace(/\u0000/g, '').trim() || null;
85
+ }
86
+ return value.trim() || null;
87
+ }
88
+
89
+ if (value instanceof Uint8Array) {
90
+ const bytes = Buffer.from(value);
91
+ if (bytes.length === 0) return null;
92
+ // ASCII UTF-16LE has a zero high byte for every character. Check that
93
+ // before UTF-8: the byte stream is also valid UTF-8, just with NULs.
94
+ let utf16 = bytes.length % 2 === 0;
95
+ for (let i = 0; utf16 && i < bytes.length; i += 2) {
96
+ if (bytes[i + 1] !== 0) utf16 = false;
97
+ }
98
+ const decoded = utf16
99
+ ? bytes.toString('utf16le').replace(/\u0000/g, '')
100
+ : bytes.toString('utf8');
101
+ return decoded.trim() || null;
102
+ }
103
+
104
+ // Defensive: a wrapper that JSON-encodes SQLite BLOBs.
105
+ if (value && value.type === 'Buffer' && Array.isArray(value.data)) {
106
+ return decodeTokenValue(Uint8Array.from(value.data));
107
+ }
108
+ return null;
109
+ }
110
+
44
111
  function readAccessToken(dbPath) {
45
112
  // Cursor app holds a write lock; queryDbJsonSnapshotOnLock copies the WAL set
46
113
  // to a temp dir and retries on "database is locked".
47
- const sql = `SELECT value FROM ItemTable WHERE key = '${ACCESS_TOKEN_KEY}' LIMIT 1`;
114
+ // Older sqlite3 CLIs truncate BLOB JSON strings at the first NUL. Hex keeps
115
+ // UTF-16 bytes intact on both the CLI fallback and node:sqlite.
116
+ const sql = `SELECT hex(value) AS tokenHex FROM ItemTable WHERE key = '${ACCESS_TOKEN_KEY}' LIMIT 1`;
48
117
  const rows = queryDbJsonSnapshotOnLock(dbPath, sql, {
49
118
  tempPrefix: 'vibe-usage-cursor-',
50
119
  opts: { maxBuffer: 4 * 1024 * 1024, timeout: 15000 },
51
120
  });
52
- const value = rows[0]?.value;
53
- if (typeof value !== 'string') return null;
54
- const t = value.trim();
55
- return t || null;
121
+ const tokenHex = rows[0]?.tokenHex;
122
+ return typeof tokenHex === 'string' ? decodeTokenValue(Buffer.from(tokenHex, 'hex')) : null;
56
123
  }
57
124
 
58
- function decodeJwtSub(token) {
125
+ function decodeJwtPayload(token) {
59
126
  const payload = token.split('.')[1];
60
127
  if (!payload) return null;
61
128
  try {
62
129
  const b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
63
130
  const padded = b64.padEnd(Math.ceil(b64.length / 4) * 4, '=');
64
- const json = JSON.parse(Buffer.from(padded, 'base64').toString('utf-8'));
65
- return typeof json.sub === 'string' ? json.sub.trim() : null;
131
+ return JSON.parse(Buffer.from(padded, 'base64').toString('utf-8'));
66
132
  } catch {
67
133
  return null;
68
134
  }
69
135
  }
70
136
 
137
+ function decodeJwtSub(token) {
138
+ const json = decodeJwtPayload(token);
139
+ return typeof json?.sub === 'string' ? json.sub.trim() : null;
140
+ }
141
+
142
+ /** Local-only account label for warnings/status. Never uploaded. */
143
+ export function cursorAccountLabel(sub) {
144
+ if (!sub) return null;
145
+ const [provider, id] = sub.includes('|') ? [sub.split('|')[0], sub.split('|').pop()] : [null, sub];
146
+ const trimmed = id || '';
147
+ const masked = trimmed.length > 12 ? `${trimmed.slice(0, 4)}…${trimmed.slice(-4)}` : trimmed;
148
+ return provider ? `${provider}|${masked}` : masked;
149
+ }
150
+
71
151
  // cursor.com's CSV export is computed over the whole account, so it gets
72
152
  // slower the more usage the account has: heavy users time out on every run,
73
153
  // not intermittently. 10s (v0.7.11) and then 30s (#72) were both still short
@@ -123,8 +203,24 @@ function usageNetworkError(error, signal) {
123
203
  return err;
124
204
  }
125
205
 
126
- async function fetchUsageCsv(token) {
127
- const url = `${(process.env.CURSOR_WEB_BASE_URL?.trim() || 'https://cursor.com').replace(/\/+$/, '')}/api/dashboard/export-usage-events-csv?strategy=tokens`;
206
+ function skipped(message) {
207
+ const err = new Error(message);
208
+ err.skip = true;
209
+ return err;
210
+ }
211
+
212
+ function baseHeaders(accept) {
213
+ return {
214
+ Accept: accept,
215
+ Origin: 'https://cursor.com',
216
+ Referer: 'https://cursor.com/dashboard?tab=usage',
217
+ // Node's default UA is a common target for intermittent WAF blocks on
218
+ // cursor.com; match what the dashboard sends.
219
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
220
+ };
221
+ }
222
+
223
+ function credentialAttempts(token) {
128
224
  const sub = decodeJwtSub(token);
129
225
  // The dashboard API authenticates via the WorkosCursorSessionToken cookie in
130
226
  // `{sub}%3A%3A{jwt}` form (what the browser sends). Bearer and bare-token
@@ -135,48 +231,55 @@ async function fetchUsageCsv(token) {
135
231
  ...(userId ? [`${userId}%3A%3A${token}`] : []),
136
232
  token,
137
233
  ];
138
-
139
- // Browser-mimicking headers, matching what the dashboard sends (and what
140
- // cursor-stats / cursor-price-tracking send) — Node's default UA is a
141
- // common target for intermittent WAF blocks on cursor.com.
142
- const baseHeaders = {
143
- Accept: 'text/csv,*/*;q=0.8',
144
- Origin: 'https://cursor.com',
145
- Referer: 'https://cursor.com/dashboard?tab=usage',
146
- 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
147
- };
148
- const attempts = cookieValues.map(cv => ({ Cookie: `${SESSION_COOKIE}=${cv}` }));
234
+ const attempts = cookieValues.map(value => ({ Cookie: `${SESSION_COOKIE}=${value}` }));
149
235
  attempts.push({ Authorization: `Bearer ${token}` });
236
+ return attempts;
237
+ }
238
+
239
+ function cursorWebBase() {
240
+ return (process.env.CURSOR_WEB_BASE_URL?.trim() || 'https://cursor.com').replace(/\/+$/, '');
241
+ }
242
+
243
+ function authRejected(status, statusText) {
244
+ const err = new Error(`auth rejected (${status} ${statusText})`);
245
+ err.authRejected = true;
246
+ return err;
247
+ }
248
+
249
+ async function fetchCsv(headers, signal, range) {
250
+ const url = `${cursorWebBase()}${CSV_PATH}?startDate=${range.startDate}&endDate=${range.endDate}&strategy=tokens`;
251
+ let resp;
252
+ try {
253
+ resp = await fetch(url, { headers: { ...baseHeaders('text/csv,*/*;q=0.8'), ...headers }, signal });
254
+ // The export may time out or disconnect after headers have arrived.
255
+ // Keep body consumption under the same deadline and soft-skip handling.
256
+ if (resp.ok) return await resp.text();
257
+ } catch (err) {
258
+ throw usageNetworkError(err, signal);
259
+ }
260
+ if (resp.status === 401 || resp.status === 403) throw authRejected(resp.status, resp.statusText);
261
+ // 429/5xx are transient server-side states — soft-skip like network errors
262
+ // instead of surfacing them as auth failures every daemon cycle.
263
+ throw skipped(`Cursor usage export skipped (HTTP ${resp.status} ${resp.statusText})`);
264
+ }
150
265
 
266
+ async function fetchUsageCsv(token, range) {
151
267
  const failures = [];
152
- for (const headers of attempts) {
153
- let resp;
268
+ for (const headers of credentialAttempts(token)) {
154
269
  const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
155
270
  try {
156
- resp = await fetch(url, {
157
- headers: { ...baseHeaders, ...headers },
158
- signal,
159
- });
160
- // The export may time out or disconnect after headers have arrived.
161
- // Keep body consumption under the same deadline and soft-skip handling.
162
- if (resp.ok) return await resp.text();
163
- } catch (e) {
164
- // Changing credentials cannot repair a network failure.
165
- throw usageNetworkError(e, signal);
166
- }
167
- failures.push(`${resp.status} ${resp.statusText}`);
168
- // Only auth rejections are worth retrying with different credentials.
169
- // 429/5xx are transient server-side states — soft-skip like network errors
170
- // instead of surfacing them as auth failures every daemon cycle.
171
- if (resp.status !== 401 && resp.status !== 403) {
172
- const err = new Error(`Cursor usage export skipped (HTTP ${resp.status} ${resp.statusText})`);
173
- err.skip = true;
271
+ return await fetchCsv(headers, signal, range);
272
+ } catch (err) {
273
+ if (err?.authRejected) {
274
+ failures.push(String(err.message).replace(/^auth rejected /, ''));
275
+ continue;
276
+ }
174
277
  throw err;
175
278
  }
176
279
  }
177
- // Every auth combo rejected — the stored token no longer works. Surface an
178
- // actionable message: re-signing in inside Cursor rewrites the token.
179
- throw new Error(`Cursor session rejected (${failures.join('; ')}). Open Cursor and sign in again (Cursor Settings → Account), then re-run sync.`);
280
+ throw new Error(
281
+ `Cursor session rejected (${failures.join('; ')}). Open Cursor and sign in again (Cursor Settings → Account), then re-run sync.`,
282
+ );
180
283
  }
181
284
 
182
285
  function parseCsv(text) {
@@ -219,41 +322,15 @@ function parseInt0(value) {
219
322
  return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
220
323
  }
221
324
 
222
- export async function parse() {
223
- const dbPath = getCursorStateDbPath();
224
- if (!dbPath) return { buckets: [], sessions: [] };
225
-
226
- let token;
227
- try {
228
- token = readAccessToken(dbPath);
229
- } catch (err) {
230
- if (isSqliteUnavailableError(err)) {
231
- throw sqliteUnavailableError('Cursor');
232
- }
233
- throw err;
234
- }
235
- if (!token) return { buckets: [], sessions: [] };
236
-
237
- let csv;
238
- try {
239
- csv = await fetchUsageCsv(token);
240
- } catch (err) {
241
- // Network/timeout → skip with diagnostics (quiet sync suppresses these).
242
- // Auth failure → bubble up so user sees they need to re-login in Cursor.
243
- // Tell sync.js this was not a successful empty snapshot so it preserves
244
- // Cursor's incremental state instead of pruning it as dead history.
245
- if (err && err.skip) {
246
- return {
247
- buckets: [],
248
- sessions: [],
249
- skipped: true,
250
- warnings: [`cursor: ${err.message}`],
251
- };
252
- }
253
- throw err;
254
- }
255
- const rows = parseCsv(csv);
256
- if (rows.length < 2) return { buckets: [], sessions: [] };
325
+ /**
326
+ * Parse the legacy CSV export. Returns `unusable` (with a reason) when Cursor
327
+ * changed the header, returned no rows, or returned rows whose token columns
328
+ * are all empty — the caller then tries the events API instead of uploading an
329
+ * empty snapshot that would prune history.
330
+ */
331
+ function entriesFromCsv(csv) {
332
+ const rows = parseCsv(String(csv).replace(/^\uFEFF/, ''));
333
+ if (rows.length < 2) return { entries: [], events: 0, latestEventAt: null, unusable: '返回了空表' };
257
334
 
258
335
  const header = rows[0].map(h => h.trim());
259
336
  const idx = (name) => header.indexOf(name);
@@ -267,30 +344,32 @@ export async function parse() {
267
344
  // A renamed column used to degrade silently: every row then parsed as zero
268
345
  // tokens, the run uploaded an empty snapshot, and sync.js pruned Cursor's
269
346
  // incremental state as if the account had gone quiet — the dashboard just
270
- // stopped counting Cursor. Treat a header we do not recognise as a skipped
271
- // run instead: the state survives and the user gets a warning.
347
+ // stopped counting Cursor. Treat a header we do not recognise as unusable
348
+ // and try the events API before giving up.
272
349
  const hasTokenColumn =
273
350
  inputCacheWriteIdx >= 0 || inputNoCacheIdx >= 0 || cacheReadIdx >= 0 || outputIdx >= 0;
274
351
  if (dateIdx < 0 || modelIdx < 0 || !hasTokenColumn) {
275
352
  return {
276
- buckets: [],
277
- sessions: [],
278
- skipped: true,
279
- warnings: [
280
- 'cursor: 导出表头与预期不符(需要 Date、Model 及至少一个 token 列),本次已跳过以保护增量状态;' +
281
- 'Cursor 可能更改了导出接口,请反馈。' +
282
- `当前表头: ${header.slice(0, 8).join(', ')}`,
283
- ],
353
+ entries: [],
354
+ events: 0,
355
+ latestEventAt: null,
356
+ headerMismatch: true,
357
+ unusable: `导出表头与预期不符(当前表头: ${header.slice(0, 8).join(', ')})`,
284
358
  };
285
359
  }
286
360
 
287
361
  const entries = [];
362
+ let latestEventAt = null;
363
+ let latestTokenAt = null;
364
+ let eventCount = 0;
288
365
  for (let r = 1; r < rows.length; r++) {
289
366
  const row = rows[r];
290
367
  if (row.length === 1 && row[0].trim() === '') continue;
291
368
  const timestamp = parseDate(row[dateIdx]);
292
369
  const model = row[modelIdx]?.trim();
293
370
  if (!timestamp || !model) continue;
371
+ eventCount++;
372
+ if (!latestEventAt || timestamp > latestEventAt) latestEventAt = timestamp;
294
373
 
295
374
  const inputCacheWrite = inputCacheWriteIdx >= 0 ? parseInt0(row[inputCacheWriteIdx]) : 0;
296
375
  const inputNoCache = inputNoCacheIdx >= 0 ? parseInt0(row[inputNoCacheIdx]) : 0;
@@ -298,6 +377,7 @@ export async function parse() {
298
377
  const output = outputIdx >= 0 ? parseInt0(row[outputIdx]) : 0;
299
378
 
300
379
  if (inputCacheWrite + inputNoCache + cacheRead + output === 0) continue;
380
+ if (!latestTokenAt || timestamp > latestTokenAt) latestTokenAt = timestamp;
301
381
 
302
382
  entries.push({
303
383
  source: 'cursor',
@@ -315,5 +395,483 @@ export async function parse() {
315
395
  });
316
396
  }
317
397
 
318
- return { buckets: aggregateToBuckets(entries), sessions: [] };
398
+ if (entries.length === 0) {
399
+ return {
400
+ entries,
401
+ events: eventCount,
402
+ latestEventAt,
403
+ latestTokenAt,
404
+ unusable: eventCount > 0 ? `返回了 ${eventCount} 行,但没有任何 token 计数` : '没有返回任何用量行',
405
+ };
406
+ }
407
+ return { entries, events: eventCount, latestEventAt, latestTokenAt };
408
+ }
409
+
410
+ function tokenCount(value) {
411
+ if (value == null) return 0;
412
+ const n = Number(String(value).replace(/,/g, '').trim());
413
+ return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
414
+ }
415
+
416
+ function eventTimestamp(value) {
417
+ if (value == null) return null;
418
+ const raw = typeof value === 'string' ? value.trim() : value;
419
+ if (raw === '') return null;
420
+ const numeric = Number(raw);
421
+ if (Number.isFinite(numeric) && numeric > 0) {
422
+ // The dashboard serializes Unix milliseconds as a string.
423
+ const date = new Date(numeric);
424
+ return Number.isNaN(date.getTime()) ? null : date;
425
+ }
426
+ const date = new Date(raw);
427
+ return Number.isNaN(date.getTime()) ? null : date;
428
+ }
429
+
430
+ function eventDedupKey(event) {
431
+ if (typeof event?.usageEventId === 'string' && event.usageEventId) return event.usageEventId;
432
+ return JSON.stringify([event?.timestamp, event?.model, event?.tokenUsage]);
433
+ }
434
+
435
+ async function fetchAllEventPages(headers, signal, { startDate = '0' } = {}) {
436
+ const url = `${cursorWebBase()}${EVENTS_PATH}`;
437
+ const endDate = String(Date.now());
438
+ const events = [];
439
+ const seen = new Set();
440
+ let total = null;
441
+ let rawCount = 0;
442
+
443
+ for (let page = 1; page <= MAX_PAGES; page++) {
444
+ let resp;
445
+ try {
446
+ resp = await fetch(url, {
447
+ method: 'POST',
448
+ headers: { ...baseHeaders('application/json'), 'Content-Type': 'application/json', ...headers },
449
+ body: JSON.stringify({ page, pageSize: PAGE_SIZE, startDate, endDate }),
450
+ signal,
451
+ });
452
+ } catch (err) {
453
+ throw usageNetworkError(err, signal);
454
+ }
455
+
456
+ if (resp.status === 401 || resp.status === 403) throw authRejected(resp.status, resp.statusText);
457
+ if (!resp.ok) {
458
+ throw skipped(`Cursor usage request skipped (HTTP ${resp.status} ${resp.statusText})`);
459
+ }
460
+
461
+ let json;
462
+ try {
463
+ json = await resp.json();
464
+ } catch (err) {
465
+ if (err instanceof SyntaxError) {
466
+ throw skipped('Cursor usage request skipped (dashboard returned invalid JSON; the API may have changed).');
467
+ }
468
+ throw usageNetworkError(err, signal);
469
+ }
470
+
471
+ // An empty query window returns `{}` with no fields at all.
472
+ const pageEvents = json && typeof json === 'object' ? json.usageEventsDisplay : undefined;
473
+ if (!Array.isArray(pageEvents)) {
474
+ if (json && typeof json === 'object' && Object.keys(json).length === 0) {
475
+ total = 0;
476
+ break;
477
+ }
478
+ const reported = Number(json?.totalUsageEventsCount);
479
+ if (Number.isFinite(reported) && reported === 0) {
480
+ total = 0;
481
+ break;
482
+ }
483
+ throw skipped(
484
+ 'Cursor usage request skipped (dashboard response has no usageEventsDisplay; the API may have changed). '
485
+ + `Keys: ${json && typeof json === 'object' ? Object.keys(json).slice(0, 8).join(', ') : typeof json}`,
486
+ );
487
+ }
488
+
489
+ const reported = Number(json.totalUsageEventsCount);
490
+ if (Number.isFinite(reported) && reported >= 0) total = reported;
491
+
492
+ rawCount += pageEvents.length;
493
+ for (const event of pageEvents) {
494
+ const key = eventDedupKey(event);
495
+ if (seen.has(key)) continue;
496
+ seen.add(key);
497
+ events.push(event);
498
+ }
499
+
500
+ if (pageEvents.length < PAGE_SIZE) break;
501
+ if (total != null && rawCount >= total) break;
502
+ if (page === MAX_PAGES) {
503
+ throw skipped(`Cursor usage request skipped (more than ${MAX_PAGES * PAGE_SIZE} events; history left unchanged).`);
504
+ }
505
+ }
506
+
507
+ if (total != null && rawCount < total) {
508
+ throw skipped(
509
+ `Cursor usage request skipped (received ${rawCount} of ${total} events; history left unchanged).`,
510
+ );
511
+ }
512
+ return { events, total: total ?? events.length };
513
+ }
514
+
515
+ async function fetchUsageEvents(token, options = {}) {
516
+ const failures = [];
517
+ for (const headers of credentialAttempts(token)) {
518
+ const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
519
+ try {
520
+ return await fetchAllEventPages(headers, signal, options);
521
+ } catch (err) {
522
+ if (err?.authRejected) {
523
+ failures.push(String(err.message).replace(/^auth rejected /, ''));
524
+ continue;
525
+ }
526
+ throw err;
527
+ }
528
+ }
529
+ throw new Error(
530
+ `Cursor session rejected (${failures.join('; ')}). Open Cursor and sign in again (Cursor Settings → Account), then re-run sync.`,
531
+ );
532
+ }
533
+
534
+ /**
535
+ * Fallback for accounts whose CSV export is empty or uses an unknown format
536
+ * (observed on some X Subscription / unified accounts, 2026-10). The events
537
+ * endpoint is the dashboard's own paginated source. Its `model` field is less
538
+ * accurate for older rows (`default` instead of `auto`/Composer), so it is only
539
+ * used when the CSV cannot provide usable data.
540
+ */
541
+ function entriesFromEvents(events) {
542
+ const entries = [];
543
+ let latestEventAt = null;
544
+ let latestTokenAt = null;
545
+ let tokenEventCount = 0;
546
+
547
+ for (const event of events) {
548
+ const timestamp = eventTimestamp(event?.timestamp);
549
+ if (timestamp && (!latestEventAt || timestamp > latestEventAt)) latestEventAt = timestamp;
550
+
551
+ const model = typeof event?.model === 'string' ? event.model.trim() : '';
552
+ if (!timestamp || !model) continue;
553
+
554
+ const usage = event?.tokenUsage;
555
+ if (!usage || typeof usage !== 'object') continue;
556
+
557
+ const inputTokens = tokenCount(usage.inputTokens) + tokenCount(usage.cacheWriteTokens);
558
+ const outputTokens = tokenCount(usage.outputTokens);
559
+ const cachedInputTokens = tokenCount(usage.cacheReadTokens);
560
+ if (inputTokens + outputTokens + cachedInputTokens === 0) continue;
561
+ if (!latestTokenAt || timestamp > latestTokenAt) latestTokenAt = timestamp;
562
+
563
+ tokenEventCount++;
564
+ entries.push({
565
+ source: 'cursor',
566
+ model,
567
+ project: 'unknown',
568
+ hostname: 'cursor-cloud',
569
+ timestamp,
570
+ inputTokens,
571
+ outputTokens,
572
+ cachedInputTokens,
573
+ reasoningOutputTokens: 0,
574
+ });
575
+ }
576
+
577
+ return { entries, latestEventAt, latestTokenAt, tokenEventCount };
578
+ }
579
+
580
+ function laterDate(a, b) {
581
+ if (!a) return b || null;
582
+ if (!b) return a;
583
+ return a > b ? a : b;
584
+ }
585
+
586
+ function resultInfo({ account, latestEventAt, latestTokenAt, events, tokenEvents, fallbackLatestAt, syncedAt, backfill }) {
587
+ return {
588
+ ...(account ? { account } : {}),
589
+ ...(latestEventAt ? { latestEventAt: new Date(latestEventAt).toISOString() } : {}),
590
+ ...(latestTokenAt ? { latestTokenAt: new Date(latestTokenAt).toISOString() } : {}),
591
+ events,
592
+ tokenEvents,
593
+ ...(fallbackLatestAt ? { fallbackLatestAt } : {}),
594
+ ...(syncedAt ? { syncedAt: new Date(syncedAt).toISOString() } : {}),
595
+ ...(backfill ? { backfill: { before: new Date(backfill.before).toISOString(), until: new Date(backfill.until).toISOString() } } : {}),
596
+ };
597
+ }
598
+
599
+ const windowOf = time => roundToHalfHour(new Date(time)).getTime();
600
+
601
+ /**
602
+ * Which history still has to be read, as `{ before, until }` (ms): the weekly
603
+ * blocks of `[until, before)`, newest first. Without a recorded `syncedAt`
604
+ * (first run, reset, re-bind, Cursor account switch) that is everything down
605
+ * to BACKFILL_FLOOR; after more than 6 days without a successful Cursor sync,
606
+ * the gap since then. Otherwise the recorded progress, or nothing.
607
+ */
608
+ export function planBackfill(previousInfo, now, recentStart) {
609
+ const syncedAt = Date.parse(previousInfo?.syncedAt);
610
+ const before = Date.parse(previousInfo?.backfill?.before);
611
+ const until = Date.parse(previousInfo?.backfill?.until);
612
+ const current = Number.isFinite(before) && Number.isFinite(until) ? { before, until } : null;
613
+ if (!Number.isFinite(syncedAt)) return { before: recentStart, until: BACKFILL_FLOOR };
614
+ if (now - syncedAt > GAP_LIMIT_MS) {
615
+ const gapUntil = windowOf(syncedAt - DAY_MS);
616
+ return { before: recentStart, until: current ? Math.min(current.until, gapUntil) : gapUntil };
617
+ }
618
+ return current;
619
+ }
620
+
621
+ /**
622
+ * The recent CSV path: windows starting at or after `recentStart`, plus the
623
+ * existing stale-gap probe. The CSV can be valid but stop growing (observed
624
+ * after account switches/unification): when its newest token row is 24h-7d
625
+ * old, take dashboard events newer than that row -- only newer ones, since the
626
+ * events API labels history `default` and re-adding it would double-count.
627
+ */
628
+ async function recentFromCsv(csvResult, { token, previousInfo, recentStart }) {
629
+ let entries = csvResult.entries;
630
+ let latest = csvResult.latestEventAt;
631
+ let latestToken = csvResult.latestTokenAt ?? csvResult.latestEventAt;
632
+ let events = csvResult.events;
633
+ let fallbackLatestAt = previousInfo?.fallbackLatestAt;
634
+ const warnings = [];
635
+
636
+ const boundaryMs = latestToken ? latestToken.getTime() : NaN;
637
+ const now = Date.now();
638
+ if (Number.isFinite(boundaryMs) && now - boundaryMs > DAY_MS && now - boundaryMs <= 7 * DAY_MS) {
639
+ try {
640
+ const probe = await fetchUsageEvents(token, { startDate: String(Math.max(0, boundaryMs - HOUR_MS)) });
641
+ const newer = probe.events.filter((event) => {
642
+ const timestamp = eventTimestamp(event?.timestamp);
643
+ return timestamp && timestamp.getTime() > boundaryMs;
644
+ });
645
+ const extra = entriesFromEvents(newer);
646
+ if (extra.entries.length > 0) {
647
+ entries = entries.concat(extra.entries);
648
+ if (extra.latestEventAt && (!latest || extra.latestEventAt > latest)) latest = extra.latestEventAt;
649
+ if (extra.latestTokenAt && (!latestToken || extra.latestTokenAt > latestToken)) latestToken = extra.latestTokenAt;
650
+ events += newer.length;
651
+ const probedThrough = Date.parse(fallbackLatestAt);
652
+ if (!Number.isFinite(probedThrough) || probedThrough < latestToken.getTime()) {
653
+ warnings.push(
654
+ `cursor: CSV 最新 token 事件停在 ${new Date(boundaryMs).toISOString()},`
655
+ + `已从仪表盘事件接口补回 ${extra.tokenEventCount} 个更新的 token 事件;`
656
+ + '这些事件的模型名可能不完整(不影响 token 总量)。',
657
+ );
658
+ }
659
+ fallbackLatestAt = latestToken.toISOString();
660
+ }
661
+ } catch {
662
+ // The CSV data is still valid; a failed freshness probe must not
663
+ // discard it or fail the whole sync.
664
+ }
665
+ }
666
+
667
+ return {
668
+ entries: entries.filter(entry => windowOf(entry.timestamp) >= recentStart),
669
+ latestEventAt: latest,
670
+ latestTokenAt: latestToken,
671
+ events,
672
+ tokenEvents: entries.length,
673
+ fallbackLatestAt,
674
+ warnings,
675
+ };
676
+ }
677
+
678
+ /**
679
+ * The events fallback (#140, unchanged rules) for a recent CSV that is empty or
680
+ * unusable. Its output is not limited to the recent window: an account whose
681
+ * CSV is always empty may have events older than it after the collector was
682
+ * off for a while. Only events newer than the previous sync's newest event
683
+ * count as new usage (the events API labels history `default`); on a first
684
+ * run it is bounded to the last seven days. Every half-hour window that holds
685
+ * a new token event is rebuilt from all of its events, so a later upload never
686
+ * replaces 10 + 20 with 20 under the same key.
687
+ *
688
+ * Known cost: on the run where the CSV first comes back empty, a rebuilt
689
+ * window can hold events the CSV already uploaded under another model name
690
+ * (`auto` there, `default` here); that part counts twice. At most one window
691
+ * per switch from a usable to an empty export.
692
+ */
693
+ async function eventsFallback(csvResult, { token, previousInfo }) {
694
+ const previousLatest = Date.parse(previousInfo?.latestTokenAt ?? previousInfo?.latestEventAt);
695
+ const hasPreviousLatest = Number.isFinite(previousLatest);
696
+ // Two hours back holds every event of any window a new event lands in: a
697
+ // window starts at most 90 minutes before its events (roundToHalfHour() on
698
+ // the night clocks fall back), and a new event is after `previousLatest`.
699
+ const fallbackStart = hasPreviousLatest
700
+ ? Math.max(0, previousLatest - 2 * HOUR_MS)
701
+ : Date.now() - 7 * DAY_MS;
702
+ // An empty export with a recognized header is the normal idle week; only a
703
+ // changed header or token-less rows mean the export itself is in doubt.
704
+ const idle = !csvResult.headerMismatch && csvResult.events === 0;
705
+
706
+ let events;
707
+ try {
708
+ events = await fetchUsageEvents(token, { startDate: String(fallbackStart) });
709
+ } catch (err) {
710
+ if (err && err.skip) {
711
+ return {
712
+ entries: [],
713
+ events: 0,
714
+ warnings: [`cursor: CSV 导出不可用(${csvResult.unusable});事件接口也失败:${err.message}`],
715
+ };
716
+ }
717
+ throw err;
718
+ }
719
+
720
+ const eventTime = event => eventTimestamp(event?.timestamp)?.getTime() ?? NaN;
721
+ const candidateEvents = hasPreviousLatest
722
+ ? events.events.filter(event => eventTime(event) > previousLatest)
723
+ : events.events;
724
+ const fallback = entriesFromEvents(candidateEvents);
725
+ const newWindows = new Set(fallback.entries.map(entry => windowOf(entry.timestamp)));
726
+ const windowEntries = hasPreviousLatest
727
+ ? entriesFromEvents(events.events.filter(event => newWindows.has(windowOf(eventTime(event))))).entries
728
+ : fallback.entries;
729
+ const windowLabel = hasPreviousLatest ? `${new Date(previousLatest).toISOString()} 之后` : '最近 7 天';
730
+
731
+ const warnings = [];
732
+ if (fallback.entries.length > 0) {
733
+ warnings.push(
734
+ `cursor: CSV 导出不可用(${csvResult.unusable}),已从仪表盘事件接口补回${windowLabel}的 `
735
+ + `${fallback.tokenEventCount} 个 token 事件;这些事件的模型名可能显示为 default(不影响 token 总量)。`,
736
+ );
737
+ } else if (!idle) {
738
+ warnings.push(
739
+ `cursor: CSV 导出不可用(${csvResult.unusable}),事件接口在${windowLabel}也没有读到 token 用量。`
740
+ + '如果刚切换到 X 订阅,Cursor 的用量可能并入 grok.com/dashboard;本次已跳过以保留历史。',
741
+ );
742
+ }
743
+ return {
744
+ entries: windowEntries,
745
+ events: candidateEvents.length,
746
+ latestEventAt: fallback.latestEventAt,
747
+ latestTokenAt: fallback.latestTokenAt,
748
+ warnings,
749
+ };
750
+ }
751
+
752
+ function exportSkipped(err) {
753
+ // Network/timeout → skip with diagnostics, no info: sync.js keeps Cursor's
754
+ // state and the recorded progress for the next run.
755
+ return { buckets: [], sessions: [], skipped: true, warnings: [`cursor: ${err.message}`] };
756
+ }
757
+
758
+ export async function parse({ previousInfo } = {}) {
759
+ const dbPath = getCursorStateDbPath();
760
+ let token = null;
761
+ let sqliteUnavailable = false;
762
+
763
+ if (dbPath) {
764
+ try {
765
+ token = readAccessToken(dbPath);
766
+ } catch (err) {
767
+ if (!isSqliteUnavailableError(err)) throw err;
768
+ sqliteUnavailable = true;
769
+ }
770
+ }
771
+ if (!token) {
772
+ if (sqliteUnavailable) throw sqliteUnavailableError('Cursor');
773
+ return { buckets: [], sessions: [] };
774
+ }
775
+
776
+ const sub = decodeJwtSub(token);
777
+ const account = cursorAccountLabel(sub);
778
+ // A different Cursor account: forget the old account's times before any
779
+ // path reads them. Otherwise the events fallback filtered the new account's
780
+ // events by the old account's newest time and dropped everything before it.
781
+ const accountChanged = Boolean(previousInfo?.account && account && previousInfo.account !== account);
782
+ const prev = accountChanged ? {} : (previousInfo ?? {});
783
+
784
+ const startedAt = Date.now();
785
+ const recentStart = windowOf(startedAt - RECENT_MS);
786
+ let backfill = planBackfill(prev, startedAt, recentStart);
787
+
788
+ // 1. Recent CSV: windows from `recentStart` on.
789
+ let csvResult;
790
+ try {
791
+ csvResult = entriesFromCsv(await fetchUsageCsv(token, { startDate: recentStart, endDate: startedAt }));
792
+ } catch (err) {
793
+ // Auth failure → bubble up so the user sees they need to re-login.
794
+ if (err && err.skip) return exportSkipped(err);
795
+ throw err;
796
+ }
797
+ const warnings = [];
798
+ let csvEntries = [];
799
+ let fallbackEntries = [];
800
+ let latestEventAt = null;
801
+ let latestTokenAt = null;
802
+ let fallbackLatestAt = prev.fallbackLatestAt;
803
+ let events = 0;
804
+ let tokenEvents = 0;
805
+
806
+ if (!csvResult.unusable) {
807
+ const recent = await recentFromCsv(csvResult, { token, previousInfo: prev, recentStart });
808
+ csvEntries = recent.entries;
809
+ latestEventAt = recent.latestEventAt;
810
+ latestTokenAt = recent.latestTokenAt;
811
+ fallbackLatestAt = recent.fallbackLatestAt;
812
+ events += recent.events;
813
+ tokenEvents += recent.tokenEvents;
814
+ warnings.push(...recent.warnings);
815
+ } else {
816
+ // 2. Events fallback: once per sync, output not limited to the window.
817
+ const fallback = await eventsFallback(csvResult, { token, previousInfo: prev });
818
+ fallbackEntries = fallback.entries;
819
+ latestEventAt = laterDate(fallback.latestEventAt, csvResult.latestEventAt);
820
+ latestTokenAt = laterDate(fallback.latestTokenAt, csvResult.latestTokenAt);
821
+ events += fallback.events;
822
+ tokenEvents += fallback.entries.length;
823
+ warnings.push(...fallback.warnings);
824
+ }
825
+
826
+ // 3. History blocks: `[until, before)`, newest first, within the budget.
827
+ if (backfill) {
828
+ let { before } = backfill;
829
+ while (before > backfill.until && Date.now() - startedAt < BACKFILL_BUDGET_MS) {
830
+ const start = Math.max(backfill.until, before - BACKFILL_BLOCK_MS);
831
+ let block;
832
+ try {
833
+ block = entriesFromCsv(await fetchUsageCsv(token, { startDate: start, endDate: before - 1 }));
834
+ } catch (err) {
835
+ if (!(err && err.skip)) throw err;
836
+ warnings.push(`cursor: 历史补齐本次暂停,下次同步继续(${err.message})`);
837
+ break;
838
+ }
839
+ if (block.headerMismatch) break;
840
+ csvEntries = csvEntries.concat(block.entries.filter((entry) => {
841
+ const window = windowOf(entry.timestamp);
842
+ return window >= start && window < before;
843
+ }));
844
+ latestEventAt = laterDate(latestEventAt, block.latestEventAt);
845
+ latestTokenAt = laterDate(latestTokenAt, block.latestTokenAt);
846
+ events += block.events;
847
+ tokenEvents += block.entries.length;
848
+ before = start;
849
+ }
850
+ backfill = before > backfill.until ? { before, until: backfill.until } : null;
851
+ }
852
+
853
+ // One source per half-hour window: a window with any CSV row is built from
854
+ // the CSV alone; the fallback's events for it are dropped, never added.
855
+ const csvWindows = new Set(csvEntries.map(entry => windowOf(entry.timestamp)));
856
+ const entries = csvEntries.concat(fallbackEntries.filter(entry => !csvWindows.has(windowOf(entry.timestamp))));
857
+
858
+ const carried = value => (Number.isFinite(Date.parse(value)) ? new Date(Date.parse(value)) : null);
859
+ return {
860
+ buckets: aggregateToBuckets(entries),
861
+ sessions: [],
862
+ // Recent windows and history blocks only, never a whole-account view:
863
+ // upload these, keep every older Cursor state key (no pruning).
864
+ skipped: true,
865
+ warnings,
866
+ info: resultInfo({
867
+ account,
868
+ latestEventAt: laterDate(latestEventAt, carried(prev.latestEventAt)),
869
+ latestTokenAt: laterDate(latestTokenAt, carried(prev.latestTokenAt)),
870
+ events,
871
+ tokenEvents,
872
+ fallbackLatestAt,
873
+ syncedAt: startedAt,
874
+ backfill,
875
+ }),
876
+ };
319
877
  }