@vibe-cafe/vibe-usage 0.14.3 → 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 +7 -3
- package/package.json +1 -1
- package/src/index.js +16 -1
- package/src/output.js +59 -0
- package/src/parsers/contract.js +5 -2
- package/src/parsers/cursor.js +527 -89
- package/src/state.js +19 -2
- package/src/sync.js +95 -7
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
|
|
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,19 +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 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
|
|
147
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
|
|
148
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
|
|
149
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
|
|
150
151
|
|
|
151
152
|
## Cursor 网络排查
|
|
152
153
|
|
|
153
|
-
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 的历史同步状态,下次同步重新尝试。
|
|
154
155
|
|
|
155
156
|
- `ENOTFOUND` / `EAI_AGAIN`:检查 DNS 和终端网络。
|
|
156
157
|
- `UND_ERR_CONNECT_TIMEOUT` / `ETIMEDOUT` / `ECONNRESET`:检查到 Cursor 的连接及终端代理。浏览器或 Cursor 应用能联网,不代表 Node.js 使用了相同代理。
|
|
157
158
|
- `CERT_*` / `UNABLE_TO_VERIFY_LEAF_SIGNATURE` 等:检查系统时间和代理或公司网络的 CA 证书配置;自定义 CA 可通过 `NODE_EXTRA_CA_CERTS` 指定。
|
|
158
|
-
- `timeout after …ms
|
|
159
|
+
- `timeout after …ms`:请求超时(每页默认 120 秒)。可用 `VIBE_USAGE_CURSOR_FETCH_TIMEOUT_MS=300000` 调大,然后重跑一次 `sync` 补传历史。
|
|
159
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"` 就是这种状态。
|
|
160
164
|
|
|
161
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 终端示例,代理地址须替换为实际地址:
|
|
162
166
|
|
package/package.json
CHANGED
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
|
|
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
|
}
|
package/src/output.js
CHANGED
|
@@ -52,6 +52,65 @@ export function hint(msg) {
|
|
|
52
52
|
console.log(dim(`提示: ${msg}`));
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* One redrawn status line for a phase that would otherwise leave the terminal
|
|
57
|
+
* blank for minutes: the settings round-trip (a timing-out endpoint re-trying),
|
|
58
|
+
* the local log scan, and a cold Codex index build. Without it the sync header
|
|
59
|
+
* is the last thing on screen, and "still working" is indistinguishable from
|
|
60
|
+
* "hung" — exactly how a slow first sync reads.
|
|
61
|
+
*
|
|
62
|
+
* TTY-only for the same reason as hint(): the Mac (`SyncEngine.swift`) and
|
|
63
|
+
* Windows (`sync_engine.rs`) apps run `sync` through a pipe and treat stdout as
|
|
64
|
+
* the result text, so a pipe must never receive these lines.
|
|
65
|
+
* `VIBE_USAGE_FORCE_PROGRESS=1` forces them on for tests, and
|
|
66
|
+
* `VIBE_USAGE_PROGRESS_INTERVAL_MS` overrides the redraw interval.
|
|
67
|
+
*
|
|
68
|
+
* `label` is a function so the caller owns the wording and its elapsed-time
|
|
69
|
+
* text; it is re-read on every redraw. The interval is unref'ed so a phase that
|
|
70
|
+
* never reaches `stop()` cannot hold the process open.
|
|
71
|
+
*/
|
|
72
|
+
export function liveStatus({ enabled = true, intervalMs } = {}) {
|
|
73
|
+
const configuredInterval = Number(process.env.VIBE_USAGE_PROGRESS_INTERVAL_MS);
|
|
74
|
+
const tickMs = intervalMs ?? (configuredInterval > 0 ? configuredInterval : 5000);
|
|
75
|
+
const active = enabled
|
|
76
|
+
&& (!!process.stdout.isTTY || process.env.VIBE_USAGE_FORCE_PROGRESS === '1');
|
|
77
|
+
let label = null;
|
|
78
|
+
let timer = null;
|
|
79
|
+
let startedAt = 0;
|
|
80
|
+
|
|
81
|
+
function draw() {
|
|
82
|
+
if (label === null) return;
|
|
83
|
+
process.stdout.write(`\r${dim(` ${label(Date.now() - startedAt)}`)}\x1b[K`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
/** Show the line, or retarget it to the next phase. */
|
|
88
|
+
start(nextLabel) {
|
|
89
|
+
if (!active) return;
|
|
90
|
+
label = nextLabel;
|
|
91
|
+
startedAt = Date.now();
|
|
92
|
+
draw();
|
|
93
|
+
if (timer === null) {
|
|
94
|
+
timer = setInterval(draw, tickMs);
|
|
95
|
+
unrefTimer(timer);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
/** Erase the line before regular output continues on it. */
|
|
99
|
+
stop() {
|
|
100
|
+
if (!active || label === null) return;
|
|
101
|
+
clearInterval(timer);
|
|
102
|
+
timer = null;
|
|
103
|
+
label = null;
|
|
104
|
+
process.stdout.write('\r\x1b[K');
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Bun's timer objects and Node's both expose unref; tests run on either. */
|
|
110
|
+
function unrefTimer(timer) {
|
|
111
|
+
if (typeof timer?.unref === 'function') timer.unref();
|
|
112
|
+
}
|
|
113
|
+
|
|
55
114
|
/** Print a blank line. */
|
|
56
115
|
export const nl = () => console.log();
|
|
57
116
|
|
package/src/parsers/contract.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
}
|
package/src/parsers/cursor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
const
|
|
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
|
|
153
|
-
let resp;
|
|
247
|
+
for (const headers of credentialAttempts(token)) {
|
|
154
248
|
const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
|
155
249
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (resp.ok) return await resp.text();
|
|
163
|
-
} catch (e) {
|
|
164
|
-
// Changing credentials cannot repair a network failure.
|
|
165
|
-
throw usageNetworkError(e, signal);
|
|
166
|
-
}
|
|
167
|
-
failures.push(`${resp.status} ${resp.statusText}`);
|
|
168
|
-
// Only auth rejections are worth retrying with different credentials.
|
|
169
|
-
// 429/5xx are transient server-side states — soft-skip like network errors
|
|
170
|
-
// instead of surfacing them as auth failures every daemon cycle.
|
|
171
|
-
if (resp.status !== 401 && resp.status !== 403) {
|
|
172
|
-
const err = new Error(`Cursor usage export skipped (HTTP ${resp.status} ${resp.statusText})`);
|
|
173
|
-
err.skip = true;
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
throw sqliteUnavailableError('Cursor');
|
|
232
|
-
}
|
|
233
|
-
throw err;
|
|
234
|
-
}
|
|
235
|
-
if (!token) return { buckets: [], sessions: [] };
|
|
236
|
-
|
|
237
|
-
let csv;
|
|
238
|
-
try {
|
|
239
|
-
csv = await fetchUsageCsv(token);
|
|
240
|
-
} catch (err) {
|
|
241
|
-
// Network/timeout → skip with diagnostics (quiet sync suppresses these).
|
|
242
|
-
// Auth failure → bubble up so user sees they need to re-login in Cursor.
|
|
243
|
-
// Tell sync.js this was not a successful empty snapshot so it preserves
|
|
244
|
-
// Cursor's incremental state instead of pruning it as dead history.
|
|
245
|
-
if (err && err.skip) {
|
|
246
|
-
return {
|
|
247
|
-
buckets: [],
|
|
248
|
-
sessions: [],
|
|
249
|
-
skipped: true,
|
|
250
|
-
warnings: [`cursor: ${err.message}`],
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
throw err;
|
|
254
|
-
}
|
|
255
|
-
const rows = parseCsv(csv);
|
|
256
|
-
if (rows.length < 2) return { buckets: [], sessions: [] };
|
|
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
|
|
271
|
-
//
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
@@ -11,7 +11,7 @@ import { aggregateToBuckets } from './parsers/aggregate.js';
|
|
|
11
11
|
import { normalizeParserResult } from './parsers/contract.js';
|
|
12
12
|
import { extraRootList } from './extra-roots.js';
|
|
13
13
|
import { planKikiMigration } from './kiki-migration.js';
|
|
14
|
-
import { success, failure, warn, arrow, link, dim } from './output.js';
|
|
14
|
+
import { success, failure, warn, arrow, link, dim, liveStatus } from './output.js';
|
|
15
15
|
|
|
16
16
|
const BATCH_SIZE = 100;
|
|
17
17
|
const SESSION_BATCH_SIZE = 500;
|
|
@@ -44,6 +44,11 @@ function formatBytes(bytes) {
|
|
|
44
44
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** Elapsed-time suffix for the live status line (`formatDuration` rounds 0 to `0s`). */
|
|
48
|
+
function phaseElapsed(elapsedMs) {
|
|
49
|
+
return ` · 已用时 ${formatDuration(elapsedMs / 1000)}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
47
52
|
export function resolveUploadProjectSetting(settings) {
|
|
48
53
|
if (typeof settings?.uploadProject !== 'boolean') {
|
|
49
54
|
const error = new Error('SETTINGS_UNAVAILABLE');
|
|
@@ -74,6 +79,67 @@ export function reaggregateHiddenProjectBuckets(buckets) {
|
|
|
74
79
|
})));
|
|
75
80
|
}
|
|
76
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
|
+
|
|
77
143
|
// Parser execution is I/O bound (log reads, occasional network calls). Run a
|
|
78
144
|
// bounded number at once to cut wall-clock sync time without the memory spike
|
|
79
145
|
// of loading every tool's logs simultaneously.
|
|
@@ -127,6 +193,13 @@ export async function runSync({
|
|
|
127
193
|
// Built from the same `apiUrl` the ingest calls below use, so the recorded
|
|
128
194
|
// target and the actual target can never drift apart.
|
|
129
195
|
const identity = stateIdentity({ apiUrl, apiKey: config.apiKey });
|
|
196
|
+
// Both the settings round-trip and the log scan below can run for minutes on
|
|
197
|
+
// a cold or proxied machine (a timing-out endpoint retrying, a cold Codex
|
|
198
|
+
// index, a 120s Cursor export). A live status line keeps that work
|
|
199
|
+
// distinguishable from a hang; it never reaches a pipe, so the desktop apps
|
|
200
|
+
// keep parsing bare result text from stdout.
|
|
201
|
+
const status = liveStatus({ enabled: !quiet });
|
|
202
|
+
status.start(elapsedMs => `正在读取上传设置${phaseElapsed(elapsedMs)}`);
|
|
130
203
|
let uploadProject;
|
|
131
204
|
try {
|
|
132
205
|
const settings = await fetchSettings(apiUrl, config.apiKey);
|
|
@@ -143,6 +216,7 @@ export async function runSync({
|
|
|
143
216
|
saveConfig(config);
|
|
144
217
|
}
|
|
145
218
|
} catch (err) {
|
|
219
|
+
status.stop();
|
|
146
220
|
if (err.message === 'UNAUTHORIZED') {
|
|
147
221
|
console.error(failure('API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。'));
|
|
148
222
|
if (throws) throw err;
|
|
@@ -161,10 +235,16 @@ export async function runSync({
|
|
|
161
235
|
}
|
|
162
236
|
}
|
|
163
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
|
+
|
|
164
243
|
let allBuckets = [];
|
|
165
244
|
const allSessions = [];
|
|
166
245
|
const parserResults = [];
|
|
167
246
|
const parserProgress = [];
|
|
247
|
+
const parserInfos = [];
|
|
168
248
|
// Sources whose parser ran to completion this sync. pruneState() below is
|
|
169
249
|
// scoped to these so a transient parser failure doesn't evict that tool's
|
|
170
250
|
// state and force a full re-upload next run.
|
|
@@ -173,13 +253,17 @@ export async function runSync({
|
|
|
173
253
|
// Run parsers concurrently (bounded) so one slow parser (Cursor's network
|
|
174
254
|
// fetch, a cold Codex index) can't stall the rest. Results are collected in
|
|
175
255
|
// registry order so output and merged arrays stay deterministic.
|
|
256
|
+
const parserRegistry = Object.entries(parsers);
|
|
257
|
+
let parsedSources = 0;
|
|
258
|
+
status.start(elapsedMs => `正在扫描本地日志 ${parsedSources}/${parserRegistry.length}${phaseElapsed(elapsedMs)}`);
|
|
176
259
|
const parserOutcomes = await mapWithConcurrency(
|
|
177
|
-
|
|
260
|
+
parserRegistry,
|
|
178
261
|
PARSER_CONCURRENCY,
|
|
179
262
|
async ([source, parse]) => {
|
|
180
263
|
try {
|
|
181
264
|
const result = await parse({
|
|
182
265
|
extraRoots: extraRootList(config.extraRoots?.[source]),
|
|
266
|
+
previousInfo: state.parserMeta?.[source],
|
|
183
267
|
...(source === 'codex' ? {
|
|
184
268
|
codexExtraHome: resolveCodexExtraHome(config.codexExtraHome, codexExtraHome),
|
|
185
269
|
} : {}),
|
|
@@ -187,9 +271,12 @@ export async function runSync({
|
|
|
187
271
|
return { source, result };
|
|
188
272
|
} catch (err) {
|
|
189
273
|
return { source, error: err };
|
|
274
|
+
} finally {
|
|
275
|
+
parsedSources++;
|
|
190
276
|
}
|
|
191
277
|
},
|
|
192
278
|
);
|
|
279
|
+
status.stop();
|
|
193
280
|
|
|
194
281
|
for (const { source, result, error } of parserOutcomes) {
|
|
195
282
|
if (error) {
|
|
@@ -204,10 +291,11 @@ export async function runSync({
|
|
|
204
291
|
process.stderr.write(`${dim(` ${source}: ${err.message}`)}\n`);
|
|
205
292
|
continue;
|
|
206
293
|
}
|
|
207
|
-
const { buckets, sessions, skipped, warnings, indexing } = normalized;
|
|
294
|
+
const { buckets, sessions, skipped, warnings, indexing, info } = normalized;
|
|
208
295
|
if (indexing) {
|
|
209
296
|
parserProgress.push({ source, ...indexing });
|
|
210
297
|
}
|
|
298
|
+
if (info) parserInfos.push({ source, info });
|
|
211
299
|
// Parser warnings always reach stderr, including quiet (daemon) runs: the
|
|
212
300
|
// daemon log is the only trail a background failure leaves. Cursor's fetch
|
|
213
301
|
// soft-skip used to be filtered out here to keep that log tidy, which made
|
|
@@ -231,14 +319,14 @@ export async function runSync({
|
|
|
231
319
|
// Successful parsers emitted no live items. Prune their old keys even on
|
|
232
320
|
// this fast path; otherwise deleting the final local log would leave dead
|
|
233
321
|
// state entries forever. Failed-parser sources remain protected.
|
|
234
|
-
const state = loadState(identity);
|
|
235
322
|
if (state.identityChanged && !quiet) {
|
|
236
323
|
console.log(dim('检测到上传账号已更换,本次全量重传本地历史'));
|
|
237
324
|
}
|
|
238
325
|
const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
|
|
239
326
|
pruneState(state, new Set(), new Set(), okSources);
|
|
240
327
|
const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
|
|
241
|
-
|
|
328
|
+
const metaChanged = applyParserInfos(state, parserInfos);
|
|
329
|
+
if (pruned > 0 || metaChanged) saveState(state, identity);
|
|
242
330
|
if (!quiet && parserProgress.length > 0) {
|
|
243
331
|
for (const p of parserProgress) {
|
|
244
332
|
console.log(dim(` ${p.source}: 正在建立本地索引 ${p.completed}/${p.total}(下次同步继续)`));
|
|
@@ -294,10 +382,10 @@ export async function runSync({
|
|
|
294
382
|
// an active one sends just the current 30-min bucket.
|
|
295
383
|
// Missing/corrupt state.json => empty maps => one-time full upload, then
|
|
296
384
|
// incremental forever after.
|
|
297
|
-
const state = loadState(identity);
|
|
298
385
|
if (state.identityChanged && !quiet) {
|
|
299
386
|
console.log(dim('检测到上传账号已更换,本次全量重传本地历史'));
|
|
300
387
|
}
|
|
388
|
+
const metaChanged = applyParserInfos(state, parserInfos);
|
|
301
389
|
let migration;
|
|
302
390
|
try {
|
|
303
391
|
migration = planKikiMigration(allBuckets, allSessions, state, config.kikiStartAt);
|
|
@@ -380,7 +468,7 @@ export async function runSync({
|
|
|
380
468
|
const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
|
|
381
469
|
pruneState(state, liveBucketKeys, liveSessionKeys, okSources);
|
|
382
470
|
const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
|
|
383
|
-
if (pruned > 0) saveState(state, identity);
|
|
471
|
+
if (pruned > 0 || metaChanged) saveState(state, identity);
|
|
384
472
|
|
|
385
473
|
if (changedBuckets.length === 0 && changedSessions.length === 0) {
|
|
386
474
|
if (!quiet) console.log(dim('无新增数据。'));
|