@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.
- package/README.md +7 -4
- package/package.json +1 -1
- package/src/index.js +20 -1
- package/src/parsers/contract.js +5 -2
- package/src/parsers/cursor.js +649 -91
- package/src/state.js +19 -2
- package/src/sync.js +544 -337
package/src/parsers/cursor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
const
|
|
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
|
|
153
|
-
let resp;
|
|
268
|
+
for (const headers of credentialAttempts(token)) {
|
|
154
269
|
const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
|
155
270
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
271
|
-
//
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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
|
}
|