@vibe-cafe/vibe-usage 0.10.25 → 0.10.27

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
@@ -111,7 +111,7 @@ Cursor 用量需要从 `cursor.com` 下载 CSV。`Cursor usage export skipped (n
111
111
  - `ENOTFOUND` / `EAI_AGAIN`:检查 DNS 和终端网络。
112
112
  - `UND_ERR_CONNECT_TIMEOUT` / `ETIMEDOUT` / `ECONNRESET`:检查到 Cursor 的连接及终端代理。浏览器或 Cursor 应用能联网,不代表 Node.js 使用了相同代理。
113
113
  - `CERT_*` / `UNABLE_TO_VERIFY_LEAF_SIGNATURE` 等:检查系统时间和代理或公司网络的 CA 证书配置;自定义 CA 可通过 `NODE_EXTRA_CA_CERTS` 指定。
114
- - `timeout after …ms`:导出或下载超时,稍后重试;网络较慢时可设置 `VIBE_USAGE_CURSOR_FETCH_TIMEOUT_MS=60000`。
114
+ - `timeout after …ms`:导出超时。`cursor.com` 的导出是按整个账号现算的,**账号用量越大越慢**,所以重度用户会每次都超时、而不是偶尔。默认等待 120 秒;仍不够就调大,例如 `VIBE_USAGE_CURSOR_FETCH_TIMEOUT_MS=300000`,然后重跑一次 `sync` 补传历史。
115
115
  - `Cursor session rejected`:在 Cursor 的 Account 设置中重新登录,再同步。
116
116
 
117
117
  需要 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 终端示例,代理地址须替换为实际地址:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.25",
3
+ "version": "0.10.27",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -68,9 +68,15 @@ function decodeJwtSub(token) {
68
68
  }
69
69
  }
70
70
 
71
- // Under full sync many parsers hammer disk concurrently; cursor.com's CSV
72
- // export can still succeed but take >10s. A short timeout caused silent skips.
73
- const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
71
+ // cursor.com's CSV export is computed over the whole account, so it gets
72
+ // slower the more usage the account has: heavy users time out on every run,
73
+ // not intermittently. 10s (v0.7.11) and then 30s (#72) were both still short
74
+ // enough to lock those accounts out permanently -- a user with ~13B tokens/30d
75
+ // needed 120s to complete a single export, and every default-timeout run of
76
+ // his silently uploaded nothing. Budget for the slow accounts; a healthy
77
+ // export returns in a second or two, so this ceiling only costs wall-clock
78
+ // time on the runs that were failing anyway.
79
+ const DEFAULT_FETCH_TIMEOUT_MS = 120_000;
74
80
  const MAX_FETCH_TIMEOUT_MS = 2_147_483_647;
75
81
 
76
82
  export function resolveCursorFetchTimeout(value) {
package/src/sync.js CHANGED
@@ -15,18 +15,34 @@ import { success, failure, warn, arrow, link, dim } from './output.js';
15
15
  const BATCH_SIZE = 100;
16
16
  const SESSION_BATCH_SIZE = 500;
17
17
 
18
+ /** Coarse human duration: "45s" / "2m10s" / "1h 20m". */
19
+ export function formatDuration(seconds) {
20
+ const secs = Math.max(0, Math.round(seconds));
21
+ if (secs < 60) return `${secs}s`;
22
+ const h = Math.floor(secs / 3600);
23
+ const m = Math.floor((secs % 3600) / 60);
24
+ const s = secs % 60;
25
+ if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
26
+ return s > 0 ? `${m}m${s}s` : `${m}m`;
27
+ }
28
+
29
+ /**
30
+ * Remaining upload time from batches already finished. Measured, never guessed:
31
+ * returns null until at least one batch has completed, because a first-sync
32
+ * backlog and a steady-state trickle differ by three orders of magnitude and
33
+ * any a-priori rate would be wrong for one of them.
34
+ */
35
+ export function estimateRemainingSeconds({ elapsedMs, doneBatches, totalBatches }) {
36
+ if (!(elapsedMs > 0) || doneBatches < 1 || totalBatches <= doneBatches) return null;
37
+ return ((elapsedMs / doneBatches) * (totalBatches - doneBatches)) / 1000;
38
+ }
39
+
18
40
  function formatBytes(bytes) {
19
41
  if (bytes < 1024) return `${bytes}B`;
20
42
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
21
43
  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
22
44
  }
23
45
 
24
- /** Hide only Cursor's intentional transient fetch soft-skip in quiet (daemon) syncs. */
25
- export function shouldSuppressParserWarning(source, message, quiet) {
26
- if (!quiet) return false;
27
- return source === 'cursor' && message.startsWith('cursor: Cursor usage export skipped (');
28
- }
29
-
30
46
  export function resolveUploadProjectSetting(settings) {
31
47
  if (typeof settings?.uploadProject !== 'boolean') {
32
48
  const error = new Error('SETTINGS_UNAVAILABLE');
@@ -184,13 +200,17 @@ export async function runSync({
184
200
  if (indexing) {
185
201
  parserProgress.push({ source, ...indexing });
186
202
  }
203
+ // Parser warnings always reach stderr, including quiet (daemon) runs: the
204
+ // daemon log is the only trail a background failure leaves. Cursor's fetch
205
+ // soft-skip used to be filtered out here to keep that log tidy, which made
206
+ // a permanently failing export indistinguishable from a healthy one -- the
207
+ // tool still listed as "installed" while it had never uploaded a byte.
187
208
  for (const message of warnings) {
188
- if (shouldSuppressParserWarning(source, message, quiet)) continue;
189
209
  process.stderr.write(`${dim(` ${message}`)}\n`);
190
210
  }
191
- // A parser may deliberately suppress a transient error (Cursor network
192
- // timeout) to keep daemon logs quiet. Its empty result is not proof that
193
- // its prior data disappeared, so it must not be pruned this run.
211
+ // A parser may downgrade a transient error (Cursor network timeout) to a
212
+ // warning instead of throwing. Its empty result is not proof that its prior
213
+ // data disappeared, so it must not be pruned this run.
194
214
  if (!skipped) okSources.add(source);
195
215
  for (const bucket of buckets) allBuckets.push(bucket);
196
216
  for (const session of sessions) allSessions.push(session);
@@ -323,6 +343,20 @@ export async function runSync({
323
343
  const totalBatches = Math.max(bucketBatches, sessionBatches, 1);
324
344
  const syncClient = createSyncClient({ defaultSurface: surface, hostname: host });
325
345
 
346
+ // Say up front how much is about to go up. A first sync (or one that
347
+ // backfills after a parser was broken) can be thousands of batches, and with
348
+ // only a per-batch progress line the user cannot tell a long upload from a
349
+ // hung one -- which is exactly how a silently failing parser stayed hidden.
350
+ if (!quiet) {
351
+ const pending = [`${allBucketsToSend.length} buckets`];
352
+ if (allSessionsToSend.length > 0) pending.push(`${allSessionsToSend.length} sessions`);
353
+ const batchNote = totalBatches > 1 ? `,分 ${totalBatches} 批` : '';
354
+ console.log(dim(` 待上传 ${pending.join(' · ')}${batchNote}`));
355
+ }
356
+
357
+ let uploadedBytes = 0;
358
+ const uploadStartedAt = Date.now();
359
+
326
360
  try {
327
361
  for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) {
328
362
  const batch = allBucketsToSend.slice(batchIdx * BATCH_SIZE, (batchIdx + 1) * BATCH_SIZE);
@@ -330,13 +364,25 @@ export async function runSync({
330
364
  const batchNum = batchIdx + 1;
331
365
  const prefix = totalBatches > 1 ? ` ${dim(`[${batchNum}/${totalBatches}]`)} 上传中 ` : ' 上传中 ';
332
366
 
367
+ // Only measured batches feed the estimate, so the first batch shows no
368
+ // ETA rather than a made-up one.
369
+ const remaining = estimateRemainingSeconds({
370
+ elapsedMs: Date.now() - uploadStartedAt,
371
+ doneBatches: batchIdx,
372
+ totalBatches,
373
+ });
374
+ const etaNote = remaining === null ? '' : ` · 预计还需 ${formatDuration(remaining)}`;
375
+
376
+ let batchBytes = 0;
333
377
  const result = await ingest(apiUrl, config.apiKey, batch, {
334
378
  client: forBatch(syncClient, batchIdx, totalBatches),
335
379
  onProgress(sent, total) {
380
+ batchBytes = total;
336
381
  const pct = Math.round((sent / total) * 100);
337
- process.stdout.write(`\r${prefix}${dim(`${formatBytes(sent)}/${formatBytes(total)} (${pct}%)`)}\x1b[K`);
382
+ process.stdout.write(`\r${prefix}${dim(`${formatBytes(sent)}/${formatBytes(total)} (${pct}%)${etaNote}`)}\x1b[K`);
338
383
  },
339
384
  }, batchSessions.length > 0 ? batchSessions : undefined);
385
+ uploadedBytes += batchBytes;
340
386
  totalIngested += result.ingested ?? batch.length;
341
387
  totalSessionsSynced += result.sessions ?? 0;
342
388
  const batchUnknownSources = new Set(result.dropped?.unknownSources || []);
@@ -381,6 +427,10 @@ export async function runSync({
381
427
  const syncParts = [`${totalIngested} buckets`];
382
428
  if (totalSessionsSynced > 0) syncParts.push(`${totalSessionsSynced} sessions`);
383
429
  console.log(success(`已同步 ${syncParts.join(' · ')}`));
430
+ if (!quiet && uploadedBytes > 0) {
431
+ const elapsed = (Date.now() - uploadStartedAt) / 1000;
432
+ console.log(dim(` 上传 ${formatBytes(uploadedBytes)}(已压缩),用时 ${formatDuration(elapsed)}`));
433
+ }
384
434
 
385
435
  if (totalDroppedBuckets > 0) {
386
436
  const reasons = [];
@@ -401,13 +451,7 @@ export async function runSync({
401
451
  const totalActive = allSessionsToSend.reduce((s, x) => s + x.activeSeconds, 0);
402
452
  const totalDuration = allSessionsToSend.reduce((s, x) => s + x.durationSeconds, 0);
403
453
  const totalMsgs = allSessionsToSend.reduce((s, x) => s + x.messageCount, 0);
404
- const fmtTime = (secs) => {
405
- if (secs < 60) return `${secs}s`;
406
- const h = Math.floor(secs / 3600);
407
- const m = Math.floor((secs % 3600) / 60);
408
- return h > 0 ? (m > 0 ? `${h}h ${m}m` : `${h}h`) : `${m}m`;
409
- };
410
- console.log(dim(` 活跃 ${fmtTime(totalActive)} / 总时长 ${fmtTime(totalDuration)} · ${totalMsgs} 条消息`));
454
+ console.log(dim(` 活跃 ${formatDuration(totalActive)} / 总时长 ${formatDuration(totalDuration)} · ${totalMsgs} 条消息`));
411
455
  }
412
456
 
413
457
  if (!quiet) {