@vibe-cafe/vibe-usage 0.14.5 → 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/package.json +1 -1
- package/src/index.js +5 -1
- package/src/parsers/cursor.js +273 -153
- package/src/sync.js +496 -358
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -94,8 +94,12 @@ async function showStatus() {
|
|
|
94
94
|
if (cursorInfo.latestEventAt) {
|
|
95
95
|
detail.push(`最新事件 ${String(cursorInfo.latestEventAt).slice(0, 16).replace('T', ' ')}Z`);
|
|
96
96
|
}
|
|
97
|
-
if (Number.isFinite(cursorInfo.events)) detail.push(
|
|
97
|
+
if (Number.isFinite(cursorInfo.events)) detail.push(`本次读到 ${cursorInfo.events} 个事件`);
|
|
98
98
|
if (detail.length > 0) console.log(dimText(` ${detail.join(' · ')}`));
|
|
99
|
+
const backfillBefore = cursorInfo.backfill?.before;
|
|
100
|
+
if (typeof backfillBefore === 'string') {
|
|
101
|
+
console.log(dimText(` 历史补到 ${backfillBefore.slice(0, 10)}(每次同步继续往前补)`));
|
|
102
|
+
}
|
|
99
103
|
const latest = Date.parse(cursorInfo.latestEventAt);
|
|
100
104
|
if (Number.isFinite(latest) && Date.now() - latest > 48 * 60 * 60 * 1000) {
|
|
101
105
|
console.log(dimText(' 已超过 48 小时没有新的 Cursor 事件;如最近用过 Cursor,请确认 Cursor 登录的是这个账号'));
|
package/src/parsers/cursor.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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');
|
|
@@ -14,6 +14,24 @@ const PAGE_SIZE = 1000;
|
|
|
14
14
|
// real personal account. Hitting the cap means the fallback is incomplete, so
|
|
15
15
|
// the run is skipped instead of pruning incremental state with a partial view.
|
|
16
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;
|
|
17
35
|
|
|
18
36
|
function getDefaultStateDbPath() {
|
|
19
37
|
if (process.platform === 'darwin') {
|
|
@@ -93,12 +111,15 @@ export function decodeTokenValue(value) {
|
|
|
93
111
|
function readAccessToken(dbPath) {
|
|
94
112
|
// Cursor app holds a write lock; queryDbJsonSnapshotOnLock copies the WAL set
|
|
95
113
|
// to a temp dir and retries on "database is locked".
|
|
96
|
-
|
|
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`;
|
|
97
117
|
const rows = queryDbJsonSnapshotOnLock(dbPath, sql, {
|
|
98
118
|
tempPrefix: 'vibe-usage-cursor-',
|
|
99
119
|
opts: { maxBuffer: 4 * 1024 * 1024, timeout: 15000 },
|
|
100
120
|
});
|
|
101
|
-
|
|
121
|
+
const tokenHex = rows[0]?.tokenHex;
|
|
122
|
+
return typeof tokenHex === 'string' ? decodeTokenValue(Buffer.from(tokenHex, 'hex')) : null;
|
|
102
123
|
}
|
|
103
124
|
|
|
104
125
|
function decodeJwtPayload(token) {
|
|
@@ -225,8 +246,8 @@ function authRejected(status, statusText) {
|
|
|
225
246
|
return err;
|
|
226
247
|
}
|
|
227
248
|
|
|
228
|
-
async function fetchCsv(headers, signal) {
|
|
229
|
-
const url = `${cursorWebBase()}${CSV_PATH}?strategy=tokens`;
|
|
249
|
+
async function fetchCsv(headers, signal, range) {
|
|
250
|
+
const url = `${cursorWebBase()}${CSV_PATH}?startDate=${range.startDate}&endDate=${range.endDate}&strategy=tokens`;
|
|
230
251
|
let resp;
|
|
231
252
|
try {
|
|
232
253
|
resp = await fetch(url, { headers: { ...baseHeaders('text/csv,*/*;q=0.8'), ...headers }, signal });
|
|
@@ -242,12 +263,12 @@ async function fetchCsv(headers, signal) {
|
|
|
242
263
|
throw skipped(`Cursor usage export skipped (HTTP ${resp.status} ${resp.statusText})`);
|
|
243
264
|
}
|
|
244
265
|
|
|
245
|
-
async function fetchUsageCsv(token) {
|
|
266
|
+
async function fetchUsageCsv(token, range) {
|
|
246
267
|
const failures = [];
|
|
247
268
|
for (const headers of credentialAttempts(token)) {
|
|
248
269
|
const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
|
249
270
|
try {
|
|
250
|
-
return await fetchCsv(headers, signal);
|
|
271
|
+
return await fetchCsv(headers, signal, range);
|
|
251
272
|
} catch (err) {
|
|
252
273
|
if (err?.authRejected) {
|
|
253
274
|
failures.push(String(err.message).replace(/^auth rejected /, ''));
|
|
@@ -332,6 +353,7 @@ function entriesFromCsv(csv) {
|
|
|
332
353
|
entries: [],
|
|
333
354
|
events: 0,
|
|
334
355
|
latestEventAt: null,
|
|
356
|
+
headerMismatch: true,
|
|
335
357
|
unusable: `导出表头与预期不符(当前表头: ${header.slice(0, 8).join(', ')})`,
|
|
336
358
|
};
|
|
337
359
|
}
|
|
@@ -561,7 +583,7 @@ function laterDate(a, b) {
|
|
|
561
583
|
return a > b ? a : b;
|
|
562
584
|
}
|
|
563
585
|
|
|
564
|
-
function resultInfo({ account, latestEventAt, latestTokenAt, events, tokenEvents, fallbackLatestAt }) {
|
|
586
|
+
function resultInfo({ account, latestEventAt, latestTokenAt, events, tokenEvents, fallbackLatestAt, syncedAt, backfill }) {
|
|
565
587
|
return {
|
|
566
588
|
...(account ? { account } : {}),
|
|
567
589
|
...(latestEventAt ? { latestEventAt: new Date(latestEventAt).toISOString() } : {}),
|
|
@@ -569,9 +591,170 @@ function resultInfo({ account, latestEventAt, latestTokenAt, events, tokenEvents
|
|
|
569
591
|
events,
|
|
570
592
|
tokenEvents,
|
|
571
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() } } : {}),
|
|
572
596
|
};
|
|
573
597
|
}
|
|
574
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
|
+
|
|
575
758
|
export async function parse({ previousInfo } = {}) {
|
|
576
759
|
const dbPath = getCursorStateDbPath();
|
|
577
760
|
let token = null;
|
|
@@ -592,166 +775,103 @@ export async function parse({ previousInfo } = {}) {
|
|
|
592
775
|
|
|
593
776
|
const sub = decodeJwtSub(token);
|
|
594
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 ?? {});
|
|
595
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.
|
|
596
789
|
let csvResult;
|
|
597
790
|
try {
|
|
598
|
-
csvResult = entriesFromCsv(await fetchUsageCsv(token));
|
|
791
|
+
csvResult = entriesFromCsv(await fetchUsageCsv(token, { startDate: recentStart, endDate: startedAt }));
|
|
599
792
|
} catch (err) {
|
|
600
|
-
//
|
|
601
|
-
|
|
602
|
-
// Tell sync.js this was not a successful empty snapshot so it preserves
|
|
603
|
-
// Cursor's incremental state instead of pruning it as dead history.
|
|
604
|
-
if (err && err.skip) {
|
|
605
|
-
return {
|
|
606
|
-
buckets: [],
|
|
607
|
-
sessions: [],
|
|
608
|
-
skipped: true,
|
|
609
|
-
warnings: [`cursor: ${err.message}`],
|
|
610
|
-
};
|
|
611
|
-
}
|
|
793
|
+
// Auth failure → bubble up so the user sees they need to re-login.
|
|
794
|
+
if (err && err.skip) return exportSkipped(err);
|
|
612
795
|
throw err;
|
|
613
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;
|
|
614
805
|
|
|
615
806
|
if (!csvResult.unusable) {
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
//
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
try {
|
|
634
|
-
const probe = await fetchUsageEvents(token, {
|
|
635
|
-
startDate: String(Math.max(0, boundaryMs - 60 * 60 * 1000)),
|
|
636
|
-
});
|
|
637
|
-
const newer = probe.events.filter((event) => {
|
|
638
|
-
const timestamp = eventTimestamp(event?.timestamp);
|
|
639
|
-
return timestamp && timestamp.getTime() > boundaryMs;
|
|
640
|
-
});
|
|
641
|
-
const extra = entriesFromEvents(newer);
|
|
642
|
-
if (extra.entries.length > 0) {
|
|
643
|
-
entries = entries.concat(extra.entries);
|
|
644
|
-
if (extra.latestEventAt && (!latest || extra.latestEventAt > latest)) {
|
|
645
|
-
latest = extra.latestEventAt;
|
|
646
|
-
}
|
|
647
|
-
if (extra.latestTokenAt && (!latestToken || extra.latestTokenAt > latestToken)) {
|
|
648
|
-
latestToken = extra.latestTokenAt;
|
|
649
|
-
}
|
|
650
|
-
eventCount += 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
|
-
buckets: aggregateToBuckets(entries),
|
|
669
|
-
sessions: [],
|
|
670
|
-
warnings,
|
|
671
|
-
info: resultInfo({
|
|
672
|
-
account,
|
|
673
|
-
latestEventAt: latest,
|
|
674
|
-
latestTokenAt: latestToken,
|
|
675
|
-
events: eventCount,
|
|
676
|
-
tokenEvents: entries.length,
|
|
677
|
-
fallbackLatestAt,
|
|
678
|
-
}),
|
|
679
|
-
};
|
|
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);
|
|
680
824
|
}
|
|
681
825
|
|
|
682
|
-
//
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
info: resultInfo({
|
|
706
|
-
account,
|
|
707
|
-
latestEventAt: csvResult.latestEventAt,
|
|
708
|
-
latestTokenAt: csvResult.latestTokenAt,
|
|
709
|
-
events: csvResult.events,
|
|
710
|
-
tokenEvents: csvResult.entries.length,
|
|
711
|
-
}),
|
|
712
|
-
};
|
|
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;
|
|
713
849
|
}
|
|
714
|
-
|
|
850
|
+
backfill = before > backfill.until ? { before, until: backfill.until } : null;
|
|
715
851
|
}
|
|
716
852
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
})
|
|
722
|
-
: events.events;
|
|
723
|
-
const fallback = entriesFromEvents(candidateEvents);
|
|
724
|
-
const windowLabel = hasPreviousLatest
|
|
725
|
-
? `${new Date(previousLatest).toISOString()} 之后`
|
|
726
|
-
: '最近 7 天';
|
|
727
|
-
const info = resultInfo({
|
|
728
|
-
account,
|
|
729
|
-
latestEventAt: laterDate(fallback.latestEventAt, csvResult.latestEventAt),
|
|
730
|
-
latestTokenAt: laterDate(fallback.latestTokenAt, csvResult.latestTokenAt),
|
|
731
|
-
events: candidateEvents.length,
|
|
732
|
-
tokenEvents: fallback.tokenEventCount,
|
|
733
|
-
});
|
|
734
|
-
|
|
735
|
-
if (fallback.entries.length === 0) {
|
|
736
|
-
return {
|
|
737
|
-
buckets: [],
|
|
738
|
-
sessions: [],
|
|
739
|
-
skipped: true,
|
|
740
|
-
warnings: [
|
|
741
|
-
`cursor: CSV 导出不可用(${csvResult.unusable}),事件接口在${windowLabel}也没有读到 token 用量。`
|
|
742
|
-
+ '如果刚切换到 X 订阅,Cursor 的用量可能并入 grok.com/dashboard;本次已跳过以保留历史。',
|
|
743
|
-
],
|
|
744
|
-
info,
|
|
745
|
-
};
|
|
746
|
-
}
|
|
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))));
|
|
747
857
|
|
|
858
|
+
const carried = value => (Number.isFinite(Date.parse(value)) ? new Date(Date.parse(value)) : null);
|
|
748
859
|
return {
|
|
749
|
-
buckets: aggregateToBuckets(
|
|
860
|
+
buckets: aggregateToBuckets(entries),
|
|
750
861
|
sessions: [],
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
info
|
|
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
|
+
}),
|
|
756
876
|
};
|
|
757
877
|
}
|