@vibe-cafe/vibe-usage 0.10.26 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/sync.js +54 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.26",
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": {
package/src/sync.js CHANGED
@@ -15,6 +15,28 @@ 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`;
@@ -321,6 +343,20 @@ export async function runSync({
321
343
  const totalBatches = Math.max(bucketBatches, sessionBatches, 1);
322
344
  const syncClient = createSyncClient({ defaultSurface: surface, hostname: host });
323
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
+
324
360
  try {
325
361
  for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) {
326
362
  const batch = allBucketsToSend.slice(batchIdx * BATCH_SIZE, (batchIdx + 1) * BATCH_SIZE);
@@ -328,13 +364,25 @@ export async function runSync({
328
364
  const batchNum = batchIdx + 1;
329
365
  const prefix = totalBatches > 1 ? ` ${dim(`[${batchNum}/${totalBatches}]`)} 上传中 ` : ' 上传中 ';
330
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;
331
377
  const result = await ingest(apiUrl, config.apiKey, batch, {
332
378
  client: forBatch(syncClient, batchIdx, totalBatches),
333
379
  onProgress(sent, total) {
380
+ batchBytes = total;
334
381
  const pct = Math.round((sent / total) * 100);
335
- 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`);
336
383
  },
337
384
  }, batchSessions.length > 0 ? batchSessions : undefined);
385
+ uploadedBytes += batchBytes;
338
386
  totalIngested += result.ingested ?? batch.length;
339
387
  totalSessionsSynced += result.sessions ?? 0;
340
388
  const batchUnknownSources = new Set(result.dropped?.unknownSources || []);
@@ -379,6 +427,10 @@ export async function runSync({
379
427
  const syncParts = [`${totalIngested} buckets`];
380
428
  if (totalSessionsSynced > 0) syncParts.push(`${totalSessionsSynced} sessions`);
381
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
+ }
382
434
 
383
435
  if (totalDroppedBuckets > 0) {
384
436
  const reasons = [];
@@ -399,13 +451,7 @@ export async function runSync({
399
451
  const totalActive = allSessionsToSend.reduce((s, x) => s + x.activeSeconds, 0);
400
452
  const totalDuration = allSessionsToSend.reduce((s, x) => s + x.durationSeconds, 0);
401
453
  const totalMsgs = allSessionsToSend.reduce((s, x) => s + x.messageCount, 0);
402
- const fmtTime = (secs) => {
403
- if (secs < 60) return `${secs}s`;
404
- const h = Math.floor(secs / 3600);
405
- const m = Math.floor((secs % 3600) / 60);
406
- return h > 0 ? (m > 0 ? `${h}h ${m}m` : `${h}h`) : `${m}m`;
407
- };
408
- console.log(dim(` 活跃 ${fmtTime(totalActive)} / 总时长 ${fmtTime(totalDuration)} · ${totalMsgs} 条消息`));
454
+ console.log(dim(` 活跃 ${formatDuration(totalActive)} / 总时长 ${formatDuration(totalDuration)} · ${totalMsgs} 条消息`));
409
455
  }
410
456
 
411
457
  if (!quiet) {