@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.14.5",
3
+ "version": "0.14.6",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
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(`${cursorInfo.events} 个事件`);
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 登录的是这个账号'));
@@ -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
- 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`;
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
- return decodeTokenValue(rows[0]?.value);
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
- // Network/timeout → skip with diagnostics (quiet sync suppresses these).
601
- // Auth failure → bubble up so user sees they need to re-login in Cursor.
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
- let entries = csvResult.entries;
617
- let latest = csvResult.latestEventAt;
618
- let latestToken = csvResult.latestTokenAt ?? csvResult.latestEventAt;
619
- let eventCount = csvResult.events;
620
- let fallbackLatestAt = previousInfo?.fallbackLatestAt;
621
- const warnings = [];
622
-
623
- // The CSV can be valid but stop growing (observed after account
624
- // switches/unification). Probe the dashboard events endpoint for the gap,
625
- // but only take events after the CSV's newest *token* row: historical rows
626
- // there are labelled `default`, and re-adding them would double-count
627
- // history. Bounded to a recent gap so an idle account does not probe
628
- // forever.
629
- const boundaryMs = latestToken ? latestToken.getTime() : NaN;
630
- const now = Date.now();
631
- if (Number.isFinite(boundaryMs) && now - boundaryMs > 24 * 60 * 60 * 1000
632
- && now - boundaryMs <= 7 * 24 * 60 * 60 * 1000) {
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
- // The CSV parsed but contained no usable usage events. Try the dashboard's
683
- // events API before treating the account as empty. Only look at events newer
684
- // than the previous sync's newest event: the events API labels many older
685
- // rows as model `default`, and re-adding known history under that name would
686
- // double-count it as a second set of buckets. On a first run (no previous
687
- // info) the fallback is bounded to the last seven days for the same reason.
688
- const previousLatest = Date.parse(previousInfo?.latestTokenAt ?? previousInfo?.latestEventAt);
689
- const hasPreviousLatest = Number.isFinite(previousLatest);
690
- const fallbackStart = hasPreviousLatest
691
- ? Math.max(0, previousLatest - 60 * 60 * 1000)
692
- : Date.now() - 7 * 24 * 60 * 60 * 1000;
693
- let events;
694
- try {
695
- events = await fetchUsageEvents(token, { startDate: String(fallbackStart) });
696
- } catch (err) {
697
- if (err && err.skip) {
698
- return {
699
- buckets: [],
700
- sessions: [],
701
- skipped: true,
702
- warnings: [
703
- `cursor: CSV 导出不可用(${csvResult.unusable});事件接口也失败:${err.message}`,
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
- throw err;
850
+ backfill = before > backfill.until ? { before, until: backfill.until } : null;
715
851
  }
716
852
 
717
- const candidateEvents = hasPreviousLatest
718
- ? events.events.filter((event) => {
719
- const timestamp = eventTimestamp(event?.timestamp);
720
- return timestamp && timestamp.getTime() > previousLatest;
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(fallback.entries),
860
+ buckets: aggregateToBuckets(entries),
750
861
  sessions: [],
751
- warnings: [
752
- `cursor: CSV 导出不可用(${csvResult.unusable}),已从仪表盘事件接口补回${windowLabel}的 `
753
- + `${fallback.tokenEventCount} 个 token 事件;这些事件的模型名可能显示为 default(不影响 token 总量)。`,
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
  }