@vibe-cafe/vibe-usage 0.14.4 → 0.14.5

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 CHANGED
@@ -84,7 +84,7 @@ Quota results are never uploaded or added to incremental sync state. Credential-
84
84
  | Grok | `$GROK_HOME/sessions/<encoded-cwd>/<session-id>/` (default `~/.grok`) plus explicitly added Grok Homes; token usage from `updates.jsonl` `turn_completed.usage` (per-model `modelUsage`, cache reads, reasoning); project from `summary.json` cwd; copied sessions keep the more complete local record |
85
85
  | GitHub Copilot CLI | `~/.copilot/session-state/*/events.jsonl` |
86
86
  | CraftAgent | `~/.craft-agent/workspaces/*/sessions/*/.pi-sessions/*.jsonl`; honors `$CRAFT_AGENT_DIR` / `$CRAFTAGENT_DIR`; cache writes are included in input usage |
87
- | Cursor | `state.vscdb` (SQLite, reads `cursorAuth/accessToken`, fetches CSV from `cursor.com`); cloud data is stamped with a fixed `cursor-cloud` hostname so multi-machine setups don't double-count |
87
+ | Cursor | `state.vscdb` (SQLite; reads only the token Cursor itself already stored locally, never logs in or refreshes it) plus `cursor.com`'s usage CSV, with the dashboard's paginated usage-events API as fallback when the CSV is empty or changes shape; cloud data is stamped with a fixed `cursor-cloud` hostname so multi-machine setups don't double-count; `status` shows the signed-in account and newest event |
88
88
  | DimAgent | `$DIMCODE_HOME/dimcode.sqlite` (default `~/.dimcode/v2/dimcode.sqlite`); exact usage from `usage_ledger`, with forked ledger/history copies deduplicated |
89
89
  | Gemini CLI | `~/.gemini/tmp/<project_hash>/chats/session-*.jsonl` (current line-delimited format) and legacy `session-*.json`; recurses into nested subagent sessions |
90
90
  | OpenCode | `~/.local/share/opencode/opencode.db` (SQLite) — both the legacy `message` table and the 2.x `session_message` projection are read, merged by message id — with `storage/message/` as the legacy alternative; cache writes are counted as 5m cache creation; supports explicitly added data roots |
@@ -144,20 +144,23 @@ To relabel old history, the maintainer needs the old bucket keys, raw per-tool t
144
144
  - Uploads buckets + sessions to your vibecafe.ai dashboard (always gzip-compressed, ~94% smaller)
145
145
  - Incremental upload: every parser emits a complete local snapshot, then only buckets/sessions that are new or changed since the last successful upload are sent — a quiet machine uploads nothing. Upload state remains in `~/.vibe-usage/state.json`; failed or still-indexing parsers retain their prior state, while deleted local logs are pruned. Deleting the state file triggers a one-time full re-upload, and `reset` clears it automatically after deleting cloud data
146
146
  - Incremental Codex parsing: a versioned, disposable cache under `~/.vibe-usage/cache/codex/` stores per-rollout aggregate results and parser continuation state. Unchanged rollouts require no raw-log reads; an ordinary append reads only the new tail; forks, sub-agents, replacements, truncations, and failed safety checks fall back to the full correctness path. Multi-file sessions have a combined cache that rebuilds when any segment changes. A bounded rolling audit occasionally re-reads one historical file or continuation group. Very large first-time indexes checkpoint before the Mac app timeout and resume on the next sync instead of restarting
147
- - Interactive `sync` shows a live status line (`正在读取上传设置` → `正在扫描本地日志 12/35 · 已用时 2m10s`) while it reads backend settings and scans/indexes local logs. A first sync, a cold Codex index rebuild, or a slow `cursor.com` export can take minutes, so that line is what separates a slow sync from a hung one. It is printed only on a TTY — the Mac and Windows apps read the piped stdout as the result text and must keep receiving plain output. Interrupting a sync is safe: finished batches are already committed, and the Codex index checkpoints and resumes on the next run
147
+ - Interactive `sync` shows a live status line (`正在读取上传设置` → `正在扫描本地日志 12/35 · 已用时 2m10s`) while it reads backend settings and scans/indexes local logs. A first sync, a cold Codex index rebuild, or a Cursor account with a long event history can take minutes, so that line is what separates a slow sync from a hung one. It is printed only on a TTY — the Mac and Windows apps read the piped stdout as the result text and must keep receiving plain output. Interrupting a sync is safe: finished batches are already committed, and the Codex index checkpoints and resumes on the next run
148
148
  - The Codex parser cache contains derived aggregates and replay metadata, not raw prompt or response text. It is independent of upload state and can be deleted safely (the next sync rebuilds it). `reset` intentionally keeps it so the required full re-upload does not also require a full disk rescan. Set `VIBE_USAGE_CODEX_CACHE=0` to disable the optimization for diagnosis
149
149
  - SQLite-backed tools are read via Node's built-in `node:sqlite` on Node ≥ 22.5 — no `sqlite3` binary needed (works on Windows out of the box); on older Node the CLI falls back to the system `sqlite3` executable
150
150
  - Continuous syncing is on by default: the first run installs a background service (see [Background sync](#background-sync)); the [Vibe Usage Mac app](https://github.com/vibe-cafe/vibe-usage-app) is the menu-bar alternative
151
151
 
152
152
  ## Cursor 网络排查
153
153
 
154
- Cursor 用量需要从 `cursor.com` 下载 CSV。`Cursor usage export skipped (network: …)` 表示这次下载失败;CLI 会保留 Cursor 的历史同步状态,下次同步重新尝试。
154
+ Cursor 用量优先读取 `cursor.com` 的 CSV 导出(模型名最准确)。如果 CSV 为空、表头变化或没有 token 计数,会自动改用仪表盘自己的分页用量事件接口(`/api/dashboard/get-filtered-usage-events`);该接口较早事件的模型名可能显示为 `default`,但 token 总量正确。CSV 仍可用但最新事件已超过 24 小时(且不超过 7 天)时,会只向事件接口补“CSV 最新事件之后”的增量,不覆盖或重传历史。`Cursor usage export skipped (network: …)` / `Cursor usage request skipped (network: …)` 表示这次请求失败;CLI 会保留 Cursor 的历史同步状态,下次同步重新尝试。
155
155
 
156
156
  - `ENOTFOUND` / `EAI_AGAIN`:检查 DNS 和终端网络。
157
157
  - `UND_ERR_CONNECT_TIMEOUT` / `ETIMEDOUT` / `ECONNRESET`:检查到 Cursor 的连接及终端代理。浏览器或 Cursor 应用能联网,不代表 Node.js 使用了相同代理。
158
158
  - `CERT_*` / `UNABLE_TO_VERIFY_LEAF_SIGNATURE` 等:检查系统时间和代理或公司网络的 CA 证书配置;自定义 CA 可通过 `NODE_EXTRA_CA_CERTS` 指定。
159
- - `timeout after …ms`:导出超时。`cursor.com` 的导出是按整个账号现算的,**账号用量越大越慢**,所以重度用户会每次都超时、而不是偶尔。默认等待 120 秒;仍不够就调大,例如 `VIBE_USAGE_CURSOR_FETCH_TIMEOUT_MS=300000`,然后重跑一次 `sync` 补传历史。
159
+ - `timeout after …ms`:请求超时(每页默认 120 秒)。可用 `VIBE_USAGE_CURSOR_FETCH_TIMEOUT_MS=300000` 调大,然后重跑一次 `sync` 补传历史。
160
160
  - `Cursor session rejected`:在 Cursor 的 Account 设置中重新登录,再同步。
161
+ - 数据停在某一天、或刚换过 Cursor 账号:运行 `npx @vibe-cafe/vibe-usage status`。它会显示 Cursor 当前登录账号(本地脱敏标签)和最新事件时间;如果最新事件超过 48 小时且你最近用过 Cursor,通常是客户端仍登录旧账号。切换账号后 Cursor 的本地 `state.vscdb` 会更新,重新同步即可。
162
+ - X 订阅(The X Subscription)账号的用量由 Cursor 与 Grok 共享:如果 Cursor 仪表盘本身没有新事件,请到 `grok.com/dashboard` 的 Usage 查看;CLI 只能读取 Cursor 仪表盘返回的 Cursor 事件。
163
+ - 只有 X Premium+ / Grok 订阅、但 Cursor 里显示 `Free`:X Premium+ 不会自动把 Cursor 计划升级,需要在 `cursor.com/unify` 合并到 The X Subscription 后,Cursor 用量才进入共享池。`cursor.com/api/dashboard/get-sand-usage-status` 返回 `includedUsageSuperGrokPlan: "x-premium-plus"` 且 `cursorPlanName: "Free"` 就是这种状态。
161
164
 
162
165
  需要 HTTP/HTTPS 代理时,Node.js **22.21+ 或 24.5+** 可用 `NODE_USE_ENV_PROXY=1` 启用环境变量代理([Node.js 官方说明](https://nodejs.org/en/learn/http/enterprise-network-configuration))。以下为 macOS/Linux 终端示例,代理地址须替换为实际地址:
163
166
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.14.4",
3
+ "version": "0.14.5",
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
@@ -76,7 +76,9 @@ async function showStatus() {
76
76
  // reads exactly like a healthy one, which is how a stale bundled CLI stayed
77
77
  // invisible (issue #100). state.json is the only local record of what was
78
78
  // actually uploaded, so count it per source.
79
- const uploaded = uploadedItemCounts(loadState());
79
+ const state = loadState();
80
+ const uploaded = uploadedItemCounts(state);
81
+ const cursorInfo = state.parserMeta?.cursor;
80
82
  for (const tool of detected) {
81
83
  const buckets = uploaded.buckets.get(tool.id) || 0;
82
84
  const sessions = uploaded.sessions.get(tool.id) || 0;
@@ -86,6 +88,19 @@ async function showStatus() {
86
88
  console.log(` ${tool.name} · 尚未上传过数据`);
87
89
  console.log(dimText(' 本机检测到数据,但本地没有这条工具的上传记录:运行 `npx @vibe-cafe/vibe-usage` 同步'));
88
90
  }
91
+ if (tool.id === 'cursor' && cursorInfo) {
92
+ const detail = [];
93
+ if (cursorInfo.account) detail.push(`账号 ${cursorInfo.account}`);
94
+ if (cursorInfo.latestEventAt) {
95
+ detail.push(`最新事件 ${String(cursorInfo.latestEventAt).slice(0, 16).replace('T', ' ')}Z`);
96
+ }
97
+ if (Number.isFinite(cursorInfo.events)) detail.push(`${cursorInfo.events} 个事件`);
98
+ if (detail.length > 0) console.log(dimText(` ${detail.join(' · ')}`));
99
+ const latest = Date.parse(cursorInfo.latestEventAt);
100
+ if (Number.isFinite(latest) && Date.now() - latest > 48 * 60 * 60 * 1000) {
101
+ console.log(dimText(' 已超过 48 小时没有新的 Cursor 事件;如最近用过 Cursor,请确认 Cursor 登录的是这个账号'));
102
+ }
103
+ }
89
104
  }
90
105
  console.log();
91
106
  }
@@ -2,8 +2,10 @@
2
2
  * Parser result contract.
3
3
  *
4
4
  * Every parser exports an async parse() returning either
5
- * { buckets: object[], sessions: object[], skipped?: boolean, warnings?: string[], indexing?: object }
6
- * or a legacy bare buckets array.
5
+ * { buckets: object[], sessions: object[], skipped?: boolean, warnings?: string[], indexing?: object,
6
+ * info?: object }
7
+ * or a legacy bare buckets array. `info` is optional local diagnostics (for
8
+ * example Cursor's signed-in account and newest event); it is never uploaded.
7
9
  *
8
10
  * buckets entries are the aggregateToBuckets() output shape
9
11
  * ({ source, model, project, hostname?, bucketStart, inputTokens, ... }).
@@ -51,5 +53,6 @@ export function normalizeParserResult(source, result) {
51
53
  skipped: result?.skipped === true,
52
54
  warnings,
53
55
  ...(result?.indexing ? { indexing: result.indexing } : {}),
56
+ ...(result?.info && typeof result.info === 'object' ? { info: result.info } : {}),
54
57
  };
55
58
  }
@@ -7,6 +7,13 @@ import { queryDbJsonSnapshotOnLock, sqliteUnavailableError, isSqliteUnavailableE
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;
10
17
 
11
18
  function getDefaultStateDbPath() {
12
19
  if (process.platform === 'darwin') {
@@ -41,6 +48,48 @@ export function getCursorStateDbPath() {
41
48
  return null;
42
49
  }
43
50
 
51
+ /**
52
+ * Cursor stores this value as TEXT on most builds, but some (notably newer
53
+ * Windows builds) write the ASCII token as a UTF-16LE BLOB. `node:sqlite`
54
+ * returns a Uint8Array then, while the sqlite3 CLI returns a JS string whose
55
+ * characters carry interleaved NULs. Both used to read as "no token", which
56
+ * made Cursor silently stop syncing after a fresh sign-in.
57
+ */
58
+ export function decodeTokenValue(value) {
59
+ if (typeof value === 'string') {
60
+ if (value.includes('\u0000')) {
61
+ const bytes = Buffer.from(value, 'latin1');
62
+ if (bytes.length % 2 === 0) {
63
+ const decoded = bytes.toString('utf16le').replace(/\u0000/g, '').trim();
64
+ if (decoded) return decoded;
65
+ }
66
+ return value.replace(/\u0000/g, '').trim() || null;
67
+ }
68
+ return value.trim() || null;
69
+ }
70
+
71
+ if (value instanceof Uint8Array) {
72
+ const bytes = Buffer.from(value);
73
+ if (bytes.length === 0) return null;
74
+ // ASCII UTF-16LE has a zero high byte for every character. Check that
75
+ // before UTF-8: the byte stream is also valid UTF-8, just with NULs.
76
+ let utf16 = bytes.length % 2 === 0;
77
+ for (let i = 0; utf16 && i < bytes.length; i += 2) {
78
+ if (bytes[i + 1] !== 0) utf16 = false;
79
+ }
80
+ const decoded = utf16
81
+ ? bytes.toString('utf16le').replace(/\u0000/g, '')
82
+ : bytes.toString('utf8');
83
+ return decoded.trim() || null;
84
+ }
85
+
86
+ // Defensive: a wrapper that JSON-encodes SQLite BLOBs.
87
+ if (value && value.type === 'Buffer' && Array.isArray(value.data)) {
88
+ return decodeTokenValue(Uint8Array.from(value.data));
89
+ }
90
+ return null;
91
+ }
92
+
44
93
  function readAccessToken(dbPath) {
45
94
  // Cursor app holds a write lock; queryDbJsonSnapshotOnLock copies the WAL set
46
95
  // to a temp dir and retries on "database is locked".
@@ -49,25 +98,35 @@ function readAccessToken(dbPath) {
49
98
  tempPrefix: 'vibe-usage-cursor-',
50
99
  opts: { maxBuffer: 4 * 1024 * 1024, timeout: 15000 },
51
100
  });
52
- const value = rows[0]?.value;
53
- if (typeof value !== 'string') return null;
54
- const t = value.trim();
55
- return t || null;
101
+ return decodeTokenValue(rows[0]?.value);
56
102
  }
57
103
 
58
- function decodeJwtSub(token) {
104
+ function decodeJwtPayload(token) {
59
105
  const payload = token.split('.')[1];
60
106
  if (!payload) return null;
61
107
  try {
62
108
  const b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
63
109
  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;
110
+ return JSON.parse(Buffer.from(padded, 'base64').toString('utf-8'));
66
111
  } catch {
67
112
  return null;
68
113
  }
69
114
  }
70
115
 
116
+ function decodeJwtSub(token) {
117
+ const json = decodeJwtPayload(token);
118
+ return typeof json?.sub === 'string' ? json.sub.trim() : null;
119
+ }
120
+
121
+ /** Local-only account label for warnings/status. Never uploaded. */
122
+ export function cursorAccountLabel(sub) {
123
+ if (!sub) return null;
124
+ const [provider, id] = sub.includes('|') ? [sub.split('|')[0], sub.split('|').pop()] : [null, sub];
125
+ const trimmed = id || '';
126
+ const masked = trimmed.length > 12 ? `${trimmed.slice(0, 4)}…${trimmed.slice(-4)}` : trimmed;
127
+ return provider ? `${provider}|${masked}` : masked;
128
+ }
129
+
71
130
  // cursor.com's CSV export is computed over the whole account, so it gets
72
131
  // slower the more usage the account has: heavy users time out on every run,
73
132
  // not intermittently. 10s (v0.7.11) and then 30s (#72) were both still short
@@ -123,8 +182,24 @@ function usageNetworkError(error, signal) {
123
182
  return err;
124
183
  }
125
184
 
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`;
185
+ function skipped(message) {
186
+ const err = new Error(message);
187
+ err.skip = true;
188
+ return err;
189
+ }
190
+
191
+ function baseHeaders(accept) {
192
+ return {
193
+ Accept: accept,
194
+ Origin: 'https://cursor.com',
195
+ Referer: 'https://cursor.com/dashboard?tab=usage',
196
+ // Node's default UA is a common target for intermittent WAF blocks on
197
+ // cursor.com; match what the dashboard sends.
198
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
199
+ };
200
+ }
201
+
202
+ function credentialAttempts(token) {
128
203
  const sub = decodeJwtSub(token);
129
204
  // The dashboard API authenticates via the WorkosCursorSessionToken cookie in
130
205
  // `{sub}%3A%3A{jwt}` form (what the browser sends). Bearer and bare-token
@@ -135,48 +210,55 @@ async function fetchUsageCsv(token) {
135
210
  ...(userId ? [`${userId}%3A%3A${token}`] : []),
136
211
  token,
137
212
  ];
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}` }));
213
+ const attempts = cookieValues.map(value => ({ Cookie: `${SESSION_COOKIE}=${value}` }));
149
214
  attempts.push({ Authorization: `Bearer ${token}` });
215
+ return attempts;
216
+ }
150
217
 
218
+ function cursorWebBase() {
219
+ return (process.env.CURSOR_WEB_BASE_URL?.trim() || 'https://cursor.com').replace(/\/+$/, '');
220
+ }
221
+
222
+ function authRejected(status, statusText) {
223
+ const err = new Error(`auth rejected (${status} ${statusText})`);
224
+ err.authRejected = true;
225
+ return err;
226
+ }
227
+
228
+ async function fetchCsv(headers, signal) {
229
+ const url = `${cursorWebBase()}${CSV_PATH}?strategy=tokens`;
230
+ let resp;
231
+ try {
232
+ resp = await fetch(url, { headers: { ...baseHeaders('text/csv,*/*;q=0.8'), ...headers }, signal });
233
+ // The export may time out or disconnect after headers have arrived.
234
+ // Keep body consumption under the same deadline and soft-skip handling.
235
+ if (resp.ok) return await resp.text();
236
+ } catch (err) {
237
+ throw usageNetworkError(err, signal);
238
+ }
239
+ if (resp.status === 401 || resp.status === 403) throw authRejected(resp.status, resp.statusText);
240
+ // 429/5xx are transient server-side states — soft-skip like network errors
241
+ // instead of surfacing them as auth failures every daemon cycle.
242
+ throw skipped(`Cursor usage export skipped (HTTP ${resp.status} ${resp.statusText})`);
243
+ }
244
+
245
+ async function fetchUsageCsv(token) {
151
246
  const failures = [];
152
- for (const headers of attempts) {
153
- let resp;
247
+ for (const headers of credentialAttempts(token)) {
154
248
  const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
155
249
  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;
250
+ return await fetchCsv(headers, signal);
251
+ } catch (err) {
252
+ if (err?.authRejected) {
253
+ failures.push(String(err.message).replace(/^auth rejected /, ''));
254
+ continue;
255
+ }
174
256
  throw err;
175
257
  }
176
258
  }
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.`);
259
+ throw new Error(
260
+ `Cursor session rejected (${failures.join('; ')}). Open Cursor and sign in again (Cursor Settings → Account), then re-run sync.`,
261
+ );
180
262
  }
181
263
 
182
264
  function parseCsv(text) {
@@ -219,41 +301,15 @@ function parseInt0(value) {
219
301
  return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
220
302
  }
221
303
 
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: [] };
304
+ /**
305
+ * Parse the legacy CSV export. Returns `unusable` (with a reason) when Cursor
306
+ * changed the header, returned no rows, or returned rows whose token columns
307
+ * are all empty — the caller then tries the events API instead of uploading an
308
+ * empty snapshot that would prune history.
309
+ */
310
+ function entriesFromCsv(csv) {
311
+ const rows = parseCsv(String(csv).replace(/^\uFEFF/, ''));
312
+ if (rows.length < 2) return { entries: [], events: 0, latestEventAt: null, unusable: '返回了空表' };
257
313
 
258
314
  const header = rows[0].map(h => h.trim());
259
315
  const idx = (name) => header.indexOf(name);
@@ -267,30 +323,31 @@ export async function parse() {
267
323
  // A renamed column used to degrade silently: every row then parsed as zero
268
324
  // tokens, the run uploaded an empty snapshot, and sync.js pruned Cursor's
269
325
  // 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.
326
+ // stopped counting Cursor. Treat a header we do not recognise as unusable
327
+ // and try the events API before giving up.
272
328
  const hasTokenColumn =
273
329
  inputCacheWriteIdx >= 0 || inputNoCacheIdx >= 0 || cacheReadIdx >= 0 || outputIdx >= 0;
274
330
  if (dateIdx < 0 || modelIdx < 0 || !hasTokenColumn) {
275
331
  return {
276
- buckets: [],
277
- sessions: [],
278
- skipped: true,
279
- warnings: [
280
- 'cursor: 导出表头与预期不符(需要 Date、Model 及至少一个 token 列),本次已跳过以保护增量状态;' +
281
- 'Cursor 可能更改了导出接口,请反馈。' +
282
- `当前表头: ${header.slice(0, 8).join(', ')}`,
283
- ],
332
+ entries: [],
333
+ events: 0,
334
+ latestEventAt: null,
335
+ unusable: `导出表头与预期不符(当前表头: ${header.slice(0, 8).join(', ')})`,
284
336
  };
285
337
  }
286
338
 
287
339
  const entries = [];
340
+ let latestEventAt = null;
341
+ let latestTokenAt = null;
342
+ let eventCount = 0;
288
343
  for (let r = 1; r < rows.length; r++) {
289
344
  const row = rows[r];
290
345
  if (row.length === 1 && row[0].trim() === '') continue;
291
346
  const timestamp = parseDate(row[dateIdx]);
292
347
  const model = row[modelIdx]?.trim();
293
348
  if (!timestamp || !model) continue;
349
+ eventCount++;
350
+ if (!latestEventAt || timestamp > latestEventAt) latestEventAt = timestamp;
294
351
 
295
352
  const inputCacheWrite = inputCacheWriteIdx >= 0 ? parseInt0(row[inputCacheWriteIdx]) : 0;
296
353
  const inputNoCache = inputNoCacheIdx >= 0 ? parseInt0(row[inputNoCacheIdx]) : 0;
@@ -298,6 +355,7 @@ export async function parse() {
298
355
  const output = outputIdx >= 0 ? parseInt0(row[outputIdx]) : 0;
299
356
 
300
357
  if (inputCacheWrite + inputNoCache + cacheRead + output === 0) continue;
358
+ if (!latestTokenAt || timestamp > latestTokenAt) latestTokenAt = timestamp;
301
359
 
302
360
  entries.push({
303
361
  source: 'cursor',
@@ -315,5 +373,385 @@ export async function parse() {
315
373
  });
316
374
  }
317
375
 
318
- return { buckets: aggregateToBuckets(entries), sessions: [] };
376
+ if (entries.length === 0) {
377
+ return {
378
+ entries,
379
+ events: eventCount,
380
+ latestEventAt,
381
+ latestTokenAt,
382
+ unusable: eventCount > 0 ? `返回了 ${eventCount} 行,但没有任何 token 计数` : '没有返回任何用量行',
383
+ };
384
+ }
385
+ return { entries, events: eventCount, latestEventAt, latestTokenAt };
386
+ }
387
+
388
+ function tokenCount(value) {
389
+ if (value == null) return 0;
390
+ const n = Number(String(value).replace(/,/g, '').trim());
391
+ return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
392
+ }
393
+
394
+ function eventTimestamp(value) {
395
+ if (value == null) return null;
396
+ const raw = typeof value === 'string' ? value.trim() : value;
397
+ if (raw === '') return null;
398
+ const numeric = Number(raw);
399
+ if (Number.isFinite(numeric) && numeric > 0) {
400
+ // The dashboard serializes Unix milliseconds as a string.
401
+ const date = new Date(numeric);
402
+ return Number.isNaN(date.getTime()) ? null : date;
403
+ }
404
+ const date = new Date(raw);
405
+ return Number.isNaN(date.getTime()) ? null : date;
406
+ }
407
+
408
+ function eventDedupKey(event) {
409
+ if (typeof event?.usageEventId === 'string' && event.usageEventId) return event.usageEventId;
410
+ return JSON.stringify([event?.timestamp, event?.model, event?.tokenUsage]);
411
+ }
412
+
413
+ async function fetchAllEventPages(headers, signal, { startDate = '0' } = {}) {
414
+ const url = `${cursorWebBase()}${EVENTS_PATH}`;
415
+ const endDate = String(Date.now());
416
+ const events = [];
417
+ const seen = new Set();
418
+ let total = null;
419
+ let rawCount = 0;
420
+
421
+ for (let page = 1; page <= MAX_PAGES; page++) {
422
+ let resp;
423
+ try {
424
+ resp = await fetch(url, {
425
+ method: 'POST',
426
+ headers: { ...baseHeaders('application/json'), 'Content-Type': 'application/json', ...headers },
427
+ body: JSON.stringify({ page, pageSize: PAGE_SIZE, startDate, endDate }),
428
+ signal,
429
+ });
430
+ } catch (err) {
431
+ throw usageNetworkError(err, signal);
432
+ }
433
+
434
+ if (resp.status === 401 || resp.status === 403) throw authRejected(resp.status, resp.statusText);
435
+ if (!resp.ok) {
436
+ throw skipped(`Cursor usage request skipped (HTTP ${resp.status} ${resp.statusText})`);
437
+ }
438
+
439
+ let json;
440
+ try {
441
+ json = await resp.json();
442
+ } catch (err) {
443
+ if (err instanceof SyntaxError) {
444
+ throw skipped('Cursor usage request skipped (dashboard returned invalid JSON; the API may have changed).');
445
+ }
446
+ throw usageNetworkError(err, signal);
447
+ }
448
+
449
+ // An empty query window returns `{}` with no fields at all.
450
+ const pageEvents = json && typeof json === 'object' ? json.usageEventsDisplay : undefined;
451
+ if (!Array.isArray(pageEvents)) {
452
+ if (json && typeof json === 'object' && Object.keys(json).length === 0) {
453
+ total = 0;
454
+ break;
455
+ }
456
+ const reported = Number(json?.totalUsageEventsCount);
457
+ if (Number.isFinite(reported) && reported === 0) {
458
+ total = 0;
459
+ break;
460
+ }
461
+ throw skipped(
462
+ 'Cursor usage request skipped (dashboard response has no usageEventsDisplay; the API may have changed). '
463
+ + `Keys: ${json && typeof json === 'object' ? Object.keys(json).slice(0, 8).join(', ') : typeof json}`,
464
+ );
465
+ }
466
+
467
+ const reported = Number(json.totalUsageEventsCount);
468
+ if (Number.isFinite(reported) && reported >= 0) total = reported;
469
+
470
+ rawCount += pageEvents.length;
471
+ for (const event of pageEvents) {
472
+ const key = eventDedupKey(event);
473
+ if (seen.has(key)) continue;
474
+ seen.add(key);
475
+ events.push(event);
476
+ }
477
+
478
+ if (pageEvents.length < PAGE_SIZE) break;
479
+ if (total != null && rawCount >= total) break;
480
+ if (page === MAX_PAGES) {
481
+ throw skipped(`Cursor usage request skipped (more than ${MAX_PAGES * PAGE_SIZE} events; history left unchanged).`);
482
+ }
483
+ }
484
+
485
+ if (total != null && rawCount < total) {
486
+ throw skipped(
487
+ `Cursor usage request skipped (received ${rawCount} of ${total} events; history left unchanged).`,
488
+ );
489
+ }
490
+ return { events, total: total ?? events.length };
491
+ }
492
+
493
+ async function fetchUsageEvents(token, options = {}) {
494
+ const failures = [];
495
+ for (const headers of credentialAttempts(token)) {
496
+ const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
497
+ try {
498
+ return await fetchAllEventPages(headers, signal, options);
499
+ } catch (err) {
500
+ if (err?.authRejected) {
501
+ failures.push(String(err.message).replace(/^auth rejected /, ''));
502
+ continue;
503
+ }
504
+ throw err;
505
+ }
506
+ }
507
+ throw new Error(
508
+ `Cursor session rejected (${failures.join('; ')}). Open Cursor and sign in again (Cursor Settings → Account), then re-run sync.`,
509
+ );
510
+ }
511
+
512
+ /**
513
+ * Fallback for accounts whose CSV export is empty or uses an unknown format
514
+ * (observed on some X Subscription / unified accounts, 2026-10). The events
515
+ * endpoint is the dashboard's own paginated source. Its `model` field is less
516
+ * accurate for older rows (`default` instead of `auto`/Composer), so it is only
517
+ * used when the CSV cannot provide usable data.
518
+ */
519
+ function entriesFromEvents(events) {
520
+ const entries = [];
521
+ let latestEventAt = null;
522
+ let latestTokenAt = null;
523
+ let tokenEventCount = 0;
524
+
525
+ for (const event of events) {
526
+ const timestamp = eventTimestamp(event?.timestamp);
527
+ if (timestamp && (!latestEventAt || timestamp > latestEventAt)) latestEventAt = timestamp;
528
+
529
+ const model = typeof event?.model === 'string' ? event.model.trim() : '';
530
+ if (!timestamp || !model) continue;
531
+
532
+ const usage = event?.tokenUsage;
533
+ if (!usage || typeof usage !== 'object') continue;
534
+
535
+ const inputTokens = tokenCount(usage.inputTokens) + tokenCount(usage.cacheWriteTokens);
536
+ const outputTokens = tokenCount(usage.outputTokens);
537
+ const cachedInputTokens = tokenCount(usage.cacheReadTokens);
538
+ if (inputTokens + outputTokens + cachedInputTokens === 0) continue;
539
+ if (!latestTokenAt || timestamp > latestTokenAt) latestTokenAt = timestamp;
540
+
541
+ tokenEventCount++;
542
+ entries.push({
543
+ source: 'cursor',
544
+ model,
545
+ project: 'unknown',
546
+ hostname: 'cursor-cloud',
547
+ timestamp,
548
+ inputTokens,
549
+ outputTokens,
550
+ cachedInputTokens,
551
+ reasoningOutputTokens: 0,
552
+ });
553
+ }
554
+
555
+ return { entries, latestEventAt, latestTokenAt, tokenEventCount };
556
+ }
557
+
558
+ function laterDate(a, b) {
559
+ if (!a) return b || null;
560
+ if (!b) return a;
561
+ return a > b ? a : b;
562
+ }
563
+
564
+ function resultInfo({ account, latestEventAt, latestTokenAt, events, tokenEvents, fallbackLatestAt }) {
565
+ return {
566
+ ...(account ? { account } : {}),
567
+ ...(latestEventAt ? { latestEventAt: new Date(latestEventAt).toISOString() } : {}),
568
+ ...(latestTokenAt ? { latestTokenAt: new Date(latestTokenAt).toISOString() } : {}),
569
+ events,
570
+ tokenEvents,
571
+ ...(fallbackLatestAt ? { fallbackLatestAt } : {}),
572
+ };
573
+ }
574
+
575
+ export async function parse({ previousInfo } = {}) {
576
+ const dbPath = getCursorStateDbPath();
577
+ let token = null;
578
+ let sqliteUnavailable = false;
579
+
580
+ if (dbPath) {
581
+ try {
582
+ token = readAccessToken(dbPath);
583
+ } catch (err) {
584
+ if (!isSqliteUnavailableError(err)) throw err;
585
+ sqliteUnavailable = true;
586
+ }
587
+ }
588
+ if (!token) {
589
+ if (sqliteUnavailable) throw sqliteUnavailableError('Cursor');
590
+ return { buckets: [], sessions: [] };
591
+ }
592
+
593
+ const sub = decodeJwtSub(token);
594
+ const account = cursorAccountLabel(sub);
595
+
596
+ let csvResult;
597
+ try {
598
+ csvResult = entriesFromCsv(await fetchUsageCsv(token));
599
+ } 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
+ }
612
+ throw err;
613
+ }
614
+
615
+ 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
+ };
680
+ }
681
+
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
+ };
713
+ }
714
+ throw err;
715
+ }
716
+
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
+ }
747
+
748
+ return {
749
+ buckets: aggregateToBuckets(fallback.entries),
750
+ sessions: [],
751
+ warnings: [
752
+ `cursor: CSV 导出不可用(${csvResult.unusable}),已从仪表盘事件接口补回${windowLabel}的 `
753
+ + `${fallback.tokenEventCount} 个 token 事件;这些事件的模型名可能显示为 default(不影响 token 总量)。`,
754
+ ],
755
+ info,
756
+ };
319
757
  }
package/src/state.js CHANGED
@@ -52,24 +52,37 @@ function sameIdentity(a, b) {
52
52
  // `identity` is optional: callers that only read the recorded counts (e.g.
53
53
  // `status`) pass nothing and keep the pre-0.11.1 behaviour of taking the file
54
54
  // at face value.
55
+ function parserMetaFrom(parsed) {
56
+ const meta = parsed?.parserMeta;
57
+ return meta && typeof meta === 'object' && !Array.isArray(meta) ? meta : {};
58
+ }
59
+
55
60
  export function loadState(identity) {
56
61
  if (!existsSync(STATE_FILE)) return { buckets: {}, sessions: {} };
57
62
  try {
58
63
  const parsed = JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
59
64
  const buckets = parsed.buckets ?? {};
60
65
  const sessions = parsed.sessions ?? {};
66
+ // Local parser diagnostics (currently Cursor's account/newest event).
67
+ // Purely additive; older CLIs ignore it and it never enters an upload.
68
+ const parserMeta = parserMetaFrom(parsed);
69
+ const withMeta = (value) => (Object.keys(parserMeta).length > 0
70
+ ? { ...value, parserMeta }
71
+ : value);
61
72
  if (!isIdentity(identity) || !isIdentity(parsed.identity)) {
62
73
  // No identity recorded — written by a CLI older than 0.11.1. Adopt the
63
74
  // entries as-is rather than forcing a re-upload: on upgrade day that
64
75
  // would make every installed client re-send its whole history at once.
65
76
  // The next saveState() stamps the current identity, so any later re-bind
66
77
  // is caught.
67
- return { buckets, sessions };
78
+ return withMeta({ buckets, sessions });
68
79
  }
69
- if (sameIdentity(parsed.identity, identity)) return { buckets, sessions };
80
+ if (sameIdentity(parsed.identity, identity)) return withMeta({ buckets, sessions });
70
81
  // Bound to a different account or server: nothing recorded here was ever
71
82
  // uploaded to the current target, so start empty and re-send local history.
72
83
  // `identityChanged` is a runtime signal for the caller, never persisted.
84
+ // Parser diagnostics describe the old target's local source, so they are
85
+ // dropped with the old hashes.
73
86
  return { buckets: {}, sessions: {}, identityChanged: true };
74
87
  } catch {
75
88
  // Corrupt/unreadable state must not lose data — treat as empty, which
@@ -91,6 +104,10 @@ export function saveState(state, identity) {
91
104
  buckets: state.buckets ?? {},
92
105
  sessions: state.sessions ?? {},
93
106
  };
107
+ if (state.parserMeta && typeof state.parserMeta === 'object'
108
+ && Object.keys(state.parserMeta).length > 0) {
109
+ payload.parserMeta = state.parserMeta;
110
+ }
94
111
  if (isIdentity(identity)) {
95
112
  payload.identity = {
96
113
  apiUrl: identity.apiUrl,
package/src/sync.js CHANGED
@@ -79,6 +79,67 @@ export function reaggregateHiddenProjectBuckets(buckets) {
79
79
  })));
80
80
  }
81
81
 
82
+ /**
83
+ * Compare local parser diagnostics (Cursor's signed-in account and newest
84
+ * event) with the previous run and warn only on a meaningful change. The
85
+ * values live in state.json under `parserMeta`; they are local diagnostics,
86
+ * never uploaded, and older CLIs ignore them.
87
+ *
88
+ * This is how an account switch stops being silent: the old token keeps
89
+ * returning the old account's history, so the dashboard simply stops growing
90
+ * while every sync still "succeeds". `status` prints the recorded account and
91
+ * newest event for the same reason.
92
+ */
93
+ export function applyParserInfos(state, parserInfos) {
94
+ if (!Array.isArray(parserInfos) || parserInfos.length === 0) return false;
95
+ if (!state.parserMeta || typeof state.parserMeta !== 'object') state.parserMeta = {};
96
+ let changed = false;
97
+
98
+ for (const { source, info } of parserInfos) {
99
+ if (!info || typeof info !== 'object' || Array.isArray(info)) continue;
100
+ const previous = state.parserMeta[source];
101
+ if (source === 'cursor') {
102
+ const warnings = [];
103
+ if (previous?.account && info.account && previous.account !== info.account) {
104
+ warnings.push(
105
+ `cursor: Cursor 登录账号已从 ${previous.account} 更换为 ${info.account};`
106
+ + '如果这不是你刚切换的账号,请在 Cursor 设置中重新登录。',
107
+ );
108
+ }
109
+ if (previous?.latestEventAt && info.latestEventAt) {
110
+ const before = Date.parse(previous.latestEventAt);
111
+ const after = Date.parse(info.latestEventAt);
112
+ if (Number.isFinite(before) && Number.isFinite(after) && after < before) {
113
+ warnings.push(
114
+ `cursor: Cursor 最新事件从 ${previous.latestEventAt} 回退到 ${info.latestEventAt};`
115
+ + '这通常意味着当前登录的是另一个(更旧的)Cursor 账号。',
116
+ );
117
+ }
118
+ }
119
+ if (previous?.tokenEvents > 0 && info.tokenEvents === 0) {
120
+ warnings.push(
121
+ `cursor: Cursor 本次没有读到可上传的 token 用量(账号 ${info.account || '未知'},`
122
+ + `事件 ${info.events ?? 0})。如果最近用过 Cursor,请运行 status 确认登录账号;`
123
+ + 'X 订阅的用量也可能并入 grok.com/dashboard。',
124
+ );
125
+ } else if (previous?.events > 0 && info.events === 0) {
126
+ warnings.push(
127
+ `cursor: Cursor 本次没有返回任何用量事件(账号 ${info.account || '未知'});`
128
+ + '如果最近用过 Cursor,请确认 Cursor 仍登录该账号。',
129
+ );
130
+ }
131
+ for (const message of warnings) {
132
+ process.stderr.write(`${dim(` ${message}`)}\n`);
133
+ }
134
+ }
135
+ if (JSON.stringify(previous ?? null) !== JSON.stringify(info)) {
136
+ state.parserMeta[source] = info;
137
+ changed = true;
138
+ }
139
+ }
140
+ return changed;
141
+ }
142
+
82
143
  // Parser execution is I/O bound (log reads, occasional network calls). Run a
83
144
  // bounded number at once to cut wall-clock sync time without the memory spike
84
145
  // of loading every tool's logs simultaneously.
@@ -174,10 +235,16 @@ export async function runSync({
174
235
  }
175
236
  }
176
237
 
238
+ // Loaded before parsing so a stateful parser can use the previous run's
239
+ // diagnostics (Cursor uses the last event time to gate its events fallback
240
+ // and avoid re-uploading already-known history under a different model id).
241
+ const state = loadState(identity);
242
+
177
243
  let allBuckets = [];
178
244
  const allSessions = [];
179
245
  const parserResults = [];
180
246
  const parserProgress = [];
247
+ const parserInfos = [];
181
248
  // Sources whose parser ran to completion this sync. pruneState() below is
182
249
  // scoped to these so a transient parser failure doesn't evict that tool's
183
250
  // state and force a full re-upload next run.
@@ -196,6 +263,7 @@ export async function runSync({
196
263
  try {
197
264
  const result = await parse({
198
265
  extraRoots: extraRootList(config.extraRoots?.[source]),
266
+ previousInfo: state.parserMeta?.[source],
199
267
  ...(source === 'codex' ? {
200
268
  codexExtraHome: resolveCodexExtraHome(config.codexExtraHome, codexExtraHome),
201
269
  } : {}),
@@ -223,10 +291,11 @@ export async function runSync({
223
291
  process.stderr.write(`${dim(` ${source}: ${err.message}`)}\n`);
224
292
  continue;
225
293
  }
226
- const { buckets, sessions, skipped, warnings, indexing } = normalized;
294
+ const { buckets, sessions, skipped, warnings, indexing, info } = normalized;
227
295
  if (indexing) {
228
296
  parserProgress.push({ source, ...indexing });
229
297
  }
298
+ if (info) parserInfos.push({ source, info });
230
299
  // Parser warnings always reach stderr, including quiet (daemon) runs: the
231
300
  // daemon log is the only trail a background failure leaves. Cursor's fetch
232
301
  // soft-skip used to be filtered out here to keep that log tidy, which made
@@ -250,14 +319,14 @@ export async function runSync({
250
319
  // Successful parsers emitted no live items. Prune their old keys even on
251
320
  // this fast path; otherwise deleting the final local log would leave dead
252
321
  // state entries forever. Failed-parser sources remain protected.
253
- const state = loadState(identity);
254
322
  if (state.identityChanged && !quiet) {
255
323
  console.log(dim('检测到上传账号已更换,本次全量重传本地历史'));
256
324
  }
257
325
  const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
258
326
  pruneState(state, new Set(), new Set(), okSources);
259
327
  const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
260
- if (pruned > 0) saveState(state, identity);
328
+ const metaChanged = applyParserInfos(state, parserInfos);
329
+ if (pruned > 0 || metaChanged) saveState(state, identity);
261
330
  if (!quiet && parserProgress.length > 0) {
262
331
  for (const p of parserProgress) {
263
332
  console.log(dim(` ${p.source}: 正在建立本地索引 ${p.completed}/${p.total}(下次同步继续)`));
@@ -313,10 +382,10 @@ export async function runSync({
313
382
  // an active one sends just the current 30-min bucket.
314
383
  // Missing/corrupt state.json => empty maps => one-time full upload, then
315
384
  // incremental forever after.
316
- const state = loadState(identity);
317
385
  if (state.identityChanged && !quiet) {
318
386
  console.log(dim('检测到上传账号已更换,本次全量重传本地历史'));
319
387
  }
388
+ const metaChanged = applyParserInfos(state, parserInfos);
320
389
  let migration;
321
390
  try {
322
391
  migration = planKikiMigration(allBuckets, allSessions, state, config.kikiStartAt);
@@ -399,7 +468,7 @@ export async function runSync({
399
468
  const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
400
469
  pruneState(state, liveBucketKeys, liveSessionKeys, okSources);
401
470
  const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
402
- if (pruned > 0) saveState(state, identity);
471
+ if (pruned > 0 || metaChanged) saveState(state, identity);
403
472
 
404
473
  if (changedBuckets.length === 0 && changedSessions.length === 0) {
405
474
  if (!quiet) console.log(dim('无新增数据。'));