@vibe-cafe/vibe-usage 0.14.5 → 0.14.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sync.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { hostname as osHostname } from 'node:os';
2
+ import { setTimeout as sleep } from 'node:timers/promises';
2
3
  import { loadConfig, saveConfig } from './config.js';
3
4
  import {
4
5
  loadState, saveState, pruneState, stateIdentity,
@@ -91,13 +92,20 @@ export function reaggregateHiddenProjectBuckets(buckets) {
91
92
  * newest event for the same reason.
92
93
  */
93
94
  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;
95
+ reportParserInfoChanges(state, parserInfos);
96
+ return recordParserInfos(state, parserInfos);
97
+ }
97
98
 
99
+ function usableInfo(info) {
100
+ return !!info && typeof info === 'object' && !Array.isArray(info);
101
+ }
102
+
103
+ /** Warn about meaningful changes against the recorded info; mutates nothing. */
104
+ export function reportParserInfoChanges(state, parserInfos) {
105
+ if (!Array.isArray(parserInfos)) return;
98
106
  for (const { source, info } of parserInfos) {
99
- if (!info || typeof info !== 'object' || Array.isArray(info)) continue;
100
- const previous = state.parserMeta[source];
107
+ if (!usableInfo(info)) continue;
108
+ const previous = state.parserMeta?.[source];
101
109
  if (source === 'cursor') {
102
110
  const warnings = [];
103
111
  if (previous?.account && info.account && previous.account !== info.account) {
@@ -116,23 +124,34 @@ export function applyParserInfos(state, parserInfos) {
116
124
  );
117
125
  }
118
126
  }
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
- }
127
+ // No warning when the counts drop to zero: since #131 they are what one
128
+ // run read from a rolling 7-day window, and the last event sliding out
129
+ // of that window is ordinary idleness, not a lost export.
131
130
  for (const message of warnings) {
132
131
  process.stderr.write(`${dim(` ${message}`)}\n`);
133
132
  }
134
133
  }
135
- if (JSON.stringify(previous ?? null) !== JSON.stringify(info)) {
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Write parser info into state.parserMeta; returns whether anything changed.
139
+ *
140
+ * Parser info is not only diagnostics: Cursor's events fallback asks only for
141
+ * events newer than the recorded `latestTokenAt`. runSync therefore calls this
142
+ * for a source only once every item that source changed in the run has been
143
+ * committed, in the same saveState() as the last of those hashes. Recording it
144
+ * earlier let any save -- the prune save before the upload, or another
145
+ * source's batch -- persist the new value, so a failed or killed upload made
146
+ * the retry skip exactly the events the server never received.
147
+ */
148
+ export function recordParserInfos(state, parserInfos) {
149
+ if (!Array.isArray(parserInfos) || parserInfos.length === 0) return false;
150
+ if (!state.parserMeta || typeof state.parserMeta !== 'object') state.parserMeta = {};
151
+ let changed = false;
152
+ for (const { source, info } of parserInfos) {
153
+ if (!usableInfo(info)) continue;
154
+ if (JSON.stringify(state.parserMeta[source] ?? null) !== JSON.stringify(info)) {
136
155
  state.parserMeta[source] = info;
137
156
  changed = true;
138
157
  }
@@ -145,6 +164,21 @@ export function applyParserInfos(state, parserInfos) {
145
164
  // of loading every tool's logs simultaneously.
146
165
  export const PARSER_CONCURRENCY = 4;
147
166
 
167
+ // Parsers whose run time follows the size of a remote account rather than this
168
+ // machine. Cursor downloads cursor.com's usage export; a whole-account export
169
+ // for a heavy account took 137s (issue #131), and every other tool's upload
170
+ // used to wait for it. If one is still running once the local parsers are done
171
+ // (plus a short grace), runSync uploads the local results first.
172
+ export const DEFERRABLE_SOURCES = new Set(['cursor']);
173
+ const DEFAULT_DEFERRED_GRACE_MS = 5_000;
174
+
175
+ export function resolveDeferredGraceMs(value) {
176
+ const ms = Number(value);
177
+ return value !== undefined && value !== '' && Number.isInteger(ms) && ms >= 0 && ms <= 600_000
178
+ ? ms
179
+ : DEFAULT_DEFERRED_GRACE_MS;
180
+ }
181
+
148
182
  // Run `fn` over `items` with at most `limit` in flight, preserving order.
149
183
  export async function mapWithConcurrency(items, limit, fn) {
150
184
  const results = new Array(items.length);
@@ -240,389 +274,493 @@ export async function runSync({
240
274
  // and avoid re-uploading already-known history under a different model id).
241
275
  const state = loadState(identity);
242
276
 
243
- let allBuckets = [];
244
- const allSessions = [];
245
- const parserResults = [];
246
- const parserProgress = [];
247
- const parserInfos = [];
248
- // Sources whose parser ran to completion this sync. pruneState() below is
249
- // scoped to these so a transient parser failure doesn't evict that tool's
250
- // state and force a full re-upload next run.
251
- const okSources = new Set();
277
+ // A sync may run as two passes (see below); these lines print once, and
278
+ // the upload client (one syncId per runSync) is created once.
279
+ let syncClient = null;
280
+ // What the server accepted so far in this runSync, across both passes. A
281
+ // pass that uploaded only sessions (e.g. Qoder CLI) counts as an upload too.
282
+ const uploadedSoFar = { buckets: 0, sessions: 0 };
283
+ const uploadedAnything = () => uploadedSoFar.buckets > 0 || uploadedSoFar.sessions > 0;
284
+ const partialLabel = () => {
285
+ const parts = [`${uploadedSoFar.buckets} buckets`];
286
+ if (uploadedSoFar.sessions > 0) parts.push(`${uploadedSoFar.sessions} sessions`);
287
+ return `部分完成(已上传 ${parts.join(' · ')})`;
288
+ };
289
+ let identityNoticeShown = false;
290
+ let projectNoticeShown = false;
291
+ const announceIdentityChange = () => {
292
+ if (!state.identityChanged || quiet || identityNoticeShown) return;
293
+ identityNoticeShown = true;
294
+ console.log(dim('检测到上传账号已更换,本次全量重传本地历史'));
295
+ };
296
+ const printDashboardLink = () => {
297
+ console.log();
298
+ console.log(`${arrow('前往 Dashboard 查看详情')} ${link(`${apiUrl}/usage`)}`);
299
+ };
252
300
 
253
301
  // Run parsers concurrently (bounded) so one slow parser (Cursor's network
254
302
  // fetch, a cold Codex index) can't stall the rest. Results are collected in
255
303
  // registry order so output and merged arrays stay deterministic.
256
304
  const parserRegistry = Object.entries(parsers);
257
305
  let parsedSources = 0;
306
+ const runParser = async ([source, parse]) => {
307
+ try {
308
+ const result = await parse({
309
+ extraRoots: extraRootList(config.extraRoots?.[source]),
310
+ previousInfo: state.parserMeta?.[source],
311
+ ...(source === 'codex' ? {
312
+ codexExtraHome: resolveCodexExtraHome(config.codexExtraHome, codexExtraHome),
313
+ } : {}),
314
+ });
315
+ return { source, result };
316
+ } catch (err) {
317
+ return { source, error: err };
318
+ } finally {
319
+ parsedSources++;
320
+ }
321
+ };
258
322
  status.start(elapsedMs => `正在扫描本地日志 ${parsedSources}/${parserRegistry.length}${phaseElapsed(elapsedMs)}`);
259
- const parserOutcomes = await mapWithConcurrency(
260
- parserRegistry,
261
- PARSER_CONCURRENCY,
262
- async ([source, parse]) => {
323
+ // Deferrable (network-bound) parsers start first, outside the pool: they
324
+ // wait on a remote account, not on this machine's disk or CPU.
325
+ const deferredEntries = parserRegistry.filter(([source]) => DEFERRABLE_SOURCES.has(source));
326
+ const localEntries = parserRegistry.filter(([source]) => !DEFERRABLE_SOURCES.has(source));
327
+ let deferredSettled = deferredEntries.length === 0;
328
+ const deferredOutcomes = Promise.all(deferredEntries.map(runParser)).then((outcomes) => {
329
+ deferredSettled = true;
330
+ return outcomes;
331
+ });
332
+ const localOutcomes = await mapWithConcurrency(localEntries, PARSER_CONCURRENCY, runParser);
333
+ if (!deferredSettled) {
334
+ // A short grace keeps the ordinary case -- a Cursor export that finishes
335
+ // just after the local logs -- in one pass and one summary. `ref: false`:
336
+ // an already-finished sync must not idle on it.
337
+ await Promise.race([
338
+ deferredOutcomes,
339
+ sleep(resolveDeferredGraceMs(process.env.VIBE_USAGE_DEFERRED_GRACE_MS), undefined, { ref: false }),
340
+ ]);
341
+ }
342
+
343
+ if (deferredSettled) {
344
+ const bySource = new Map([...localOutcomes, ...await deferredOutcomes].map(o => [o.source, o]));
345
+ status.stop();
346
+ return syncOutcomes(parserRegistry.map(([source]) => bySource.get(source)));
347
+ }
348
+
349
+ // Still downloading (a first whole-account Cursor export can take minutes,
350
+ // issue #131): upload every finished tool now, then the slow source. Each
351
+ // pass diffs, prunes (scoped to its own sources) and commits state per batch
352
+ // exactly like a single pass, so the uploaded keys and values are the same;
353
+ // only their order changes. A desktop app that kills the process at its own
354
+ // timeout now loses at most the slow source, not every tool's upload.
355
+ status.stop();
356
+ const pendingSources = deferredEntries.map(([source]) => source).join(', ');
357
+ if (!quiet) console.log(dim(` ${pendingSources}: 仍在读取用量数据,先上传其他工具`));
358
+ const firstPassIngested = await syncOutcomes(localOutcomes, { final: false });
359
+ status.start(elapsedMs => `正在等待 ${pendingSources}${phaseElapsed(elapsedMs)}`);
360
+ const lateOutcomes = await deferredOutcomes;
361
+ status.stop();
362
+ return firstPassIngested + await syncOutcomes(lateOutcomes);
363
+
364
+ // One diff-and-upload pass over a set of parser outcomes. `final` is false
365
+ // only for the first of two passes (see above): that pass stays quiet about
366
+ // "no new data" and leaves the dashboard link to the last pass.
367
+ async function syncOutcomes(parserOutcomes, { final = true } = {}) {
368
+ let allBuckets = [];
369
+ const allSessions = [];
370
+ const parserResults = [];
371
+ const parserProgress = [];
372
+ const parserInfos = [];
373
+ // Sources whose parser ran to completion this sync. pruneState() below is
374
+ // scoped to these so a transient parser failure doesn't evict that tool's
375
+ // state and force a full re-upload next run.
376
+ const okSources = new Set();
377
+
378
+ for (const { source, result, error } of parserOutcomes) {
379
+ if (error) {
380
+ // Parser errors are non-fatal — pass-through in dim gray (no translation).
381
+ process.stderr.write(`${dim(` ${source}: ${error.message}`)}\n`);
382
+ continue;
383
+ }
384
+ let normalized;
263
385
  try {
264
- const result = await parse({
265
- extraRoots: extraRootList(config.extraRoots?.[source]),
266
- previousInfo: state.parserMeta?.[source],
267
- ...(source === 'codex' ? {
268
- codexExtraHome: resolveCodexExtraHome(config.codexExtraHome, codexExtraHome),
269
- } : {}),
270
- });
271
- return { source, result };
386
+ normalized = normalizeParserResult(source, result);
272
387
  } catch (err) {
273
- return { source, error: err };
274
- } finally {
275
- parsedSources++;
388
+ process.stderr.write(`${dim(` ${source}: ${err.message}`)}\n`);
389
+ continue;
390
+ }
391
+ const { buckets, sessions, skipped, warnings, indexing, info } = normalized;
392
+ if (indexing) {
393
+ parserProgress.push({ source, ...indexing });
394
+ }
395
+ if (info) parserInfos.push({ source, info });
396
+ // Parser warnings always reach stderr, including quiet (daemon) runs: the
397
+ // daemon log is the only trail a background failure leaves. Cursor's fetch
398
+ // soft-skip used to be filtered out here to keep that log tidy, which made
399
+ // a permanently failing export indistinguishable from a healthy one -- the
400
+ // tool still listed as "installed" while it had never uploaded a byte.
401
+ for (const message of warnings) {
402
+ process.stderr.write(`${dim(` ${message}`)}\n`);
403
+ }
404
+ // A parser may downgrade a transient error (Cursor network timeout) to a
405
+ // warning instead of throwing. Its empty result is not proof that its prior
406
+ // data disappeared, so it must not be pruned this run.
407
+ if (!skipped) okSources.add(source);
408
+ for (const bucket of buckets) allBuckets.push(bucket);
409
+ for (const session of sessions) allSessions.push(session);
410
+ if (buckets.length > 0 || sessions.length > 0) {
411
+ parserResults.push({ source, buckets: buckets.length, sessions: sessions.length });
276
412
  }
277
- },
278
- );
279
- status.stop();
280
-
281
- for (const { source, result, error } of parserOutcomes) {
282
- if (error) {
283
- // Parser errors are non-fatal — pass-through in dim gray (no translation).
284
- process.stderr.write(`${dim(` ${source}: ${error.message}`)}\n`);
285
- continue;
286
- }
287
- let normalized;
288
- try {
289
- normalized = normalizeParserResult(source, result);
290
- } catch (err) {
291
- process.stderr.write(`${dim(` ${source}: ${err.message}`)}\n`);
292
- continue;
293
- }
294
- const { buckets, sessions, skipped, warnings, indexing, info } = normalized;
295
- if (indexing) {
296
- parserProgress.push({ source, ...indexing });
297
- }
298
- if (info) parserInfos.push({ source, info });
299
- // Parser warnings always reach stderr, including quiet (daemon) runs: the
300
- // daemon log is the only trail a background failure leaves. Cursor's fetch
301
- // soft-skip used to be filtered out here to keep that log tidy, which made
302
- // a permanently failing export indistinguishable from a healthy one -- the
303
- // tool still listed as "installed" while it had never uploaded a byte.
304
- for (const message of warnings) {
305
- process.stderr.write(`${dim(` ${message}`)}\n`);
306
413
  }
307
- // A parser may downgrade a transient error (Cursor network timeout) to a
308
- // warning instead of throwing. Its empty result is not proof that its prior
309
- // data disappeared, so it must not be pruned this run.
310
- if (!skipped) okSources.add(source);
311
- for (const bucket of buckets) allBuckets.push(bucket);
312
- for (const session of sessions) allSessions.push(session);
313
- if (buckets.length > 0 || sessions.length > 0) {
314
- parserResults.push({ source, buckets: buckets.length, sessions: sessions.length });
414
+
415
+ if (allBuckets.length === 0 && allSessions.length === 0) {
416
+ // Successful parsers emitted no live items. Prune their old keys even on
417
+ // this fast path; otherwise deleting the final local log would leave dead
418
+ // state entries forever. Failed-parser sources remain protected.
419
+ announceIdentityChange();
420
+ const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
421
+ pruneState(state, new Set(), new Set(), okSources);
422
+ const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
423
+ const metaChanged = applyParserInfos(state, parserInfos);
424
+ if (pruned > 0 || metaChanged) saveState(state, identity);
425
+ if (!quiet && parserProgress.length > 0) {
426
+ for (const p of parserProgress) {
427
+ console.log(dim(` ${p.source}: 正在建立本地索引 ${p.completed}/${p.total}(下次同步继续)`));
428
+ }
429
+ } else if (!quiet && final) {
430
+ if (uploadedAnything()) printDashboardLink();
431
+ else console.log(dim('暂无新数据。'));
432
+ }
433
+ return 0;
315
434
  }
316
- }
317
435
 
318
- if (allBuckets.length === 0 && allSessions.length === 0) {
319
- // Successful parsers emitted no live items. Prune their old keys even on
320
- // this fast path; otherwise deleting the final local log would leave dead
321
- // state entries forever. Failed-parser sources remain protected.
322
- if (state.identityChanged && !quiet) {
323
- console.log(dim('检测到上传账号已更换,本次全量重传本地历史'));
436
+ if (!quiet && parserResults.length > 0) {
437
+ for (const p of parserResults) {
438
+ const parts = [];
439
+ if (p.buckets > 0) parts.push(`${p.buckets} buckets`);
440
+ if (p.sessions > 0) parts.push(`${p.sessions} sessions`);
441
+ console.log(` ${dim(p.source.padEnd(14))}${parts.join(' · ')}`);
442
+ }
324
443
  }
325
- const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
326
- pruneState(state, new Set(), new Set(), okSources);
327
- const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
328
- const metaChanged = applyParserInfos(state, parserInfos);
329
- if (pruned > 0 || metaChanged) saveState(state, identity);
330
444
  if (!quiet && parserProgress.length > 0) {
331
445
  for (const p of parserProgress) {
332
446
  console.log(dim(` ${p.source}: 正在建立本地索引 ${p.completed}/${p.total}(下次同步继续)`));
333
447
  }
334
- } else if (!quiet) {
335
- console.log(dim('暂无新数据。'));
336
448
  }
337
- return 0;
338
- }
339
449
 
340
- if (!quiet && parserResults.length > 0) {
341
- for (const p of parserResults) {
342
- const parts = [];
343
- if (p.buckets > 0) parts.push(`${p.buckets} buckets`);
344
- if (p.sessions > 0) parts.push(`${p.sessions} sessions`);
345
- console.log(` ${dim(p.source.padEnd(14))}${parts.join(' · ')}`);
450
+ let host = config.hostname;
451
+ if (!host) {
452
+ host = osHostname().replace(/\.local$/, '');
453
+ config.hostname = host;
454
+ saveConfig(config);
346
455
  }
347
- }
348
- if (!quiet && parserProgress.length > 0) {
349
- for (const p of parserProgress) {
350
- console.log(dim(` ${p.source}: 正在建立本地索引 ${p.completed}/${p.total}(下次同步继续)`));
456
+ // Cloud-sourced parsers (e.g. cursor) pre-set their own hostname sentinel so
457
+ // the same account data isn't stored as separate rows per machine.
458
+ for (const b of allBuckets) if (!b.hostname) b.hostname = host;
459
+ for (const s of allSessions) if (!s.hostname) s.hostname = host;
460
+
461
+ if (!quiet && !projectNoticeShown) {
462
+ projectNoticeShown = true;
463
+ if (uploadProject) {
464
+ console.log(dim(' 项目名: 上传(可在 Web 设置中关闭)'));
465
+ } else {
466
+ console.log(dim(' 项目名: 已隐藏'));
467
+ }
351
468
  }
352
- }
353
-
354
- let host = config.hostname;
355
- if (!host) {
356
- host = osHostname().replace(/\.local$/, '');
357
- config.hostname = host;
358
- saveConfig(config);
359
- }
360
- // Cloud-sourced parsers (e.g. cursor) pre-set their own hostname sentinel so
361
- // the same account data isn't stored as separate rows per machine.
362
- for (const b of allBuckets) if (!b.hostname) b.hostname = host;
363
- for (const s of allSessions) if (!s.hostname) s.hostname = host;
364
-
365
- if (!quiet) {
366
- if (uploadProject) {
367
- console.log(dim(' 项目名: 上传(可在 Web 设置中关闭)'));
368
- } else {
369
- console.log(dim(' 项目名: 已隐藏'));
469
+ if (!uploadProject) {
470
+ for (const b of allBuckets) b.project = 'unknown';
471
+ for (const s of allSessions) s.project = 'unknown';
472
+ allBuckets = reaggregateHiddenProjectBuckets(allBuckets);
370
473
  }
371
- }
372
- if (!uploadProject) {
373
- for (const b of allBuckets) b.project = 'unknown';
374
- for (const s of allSessions) s.project = 'unknown';
375
- allBuckets = reaggregateHiddenProjectBuckets(allBuckets);
376
- }
377
474
 
378
- // Incremental upload diff: parsers above emit a complete view of live local
379
- // data (Codex may assemble that view from its disposable parser cache). Here
380
- // we drop anything whose content matches what we already uploaded, so only
381
- // new/changed items go over the network. A quiet machine sends zero bytes;
382
- // an active one sends just the current 30-min bucket.
383
- // Missing/corrupt state.json => empty maps => one-time full upload, then
384
- // incremental forever after.
385
- if (state.identityChanged && !quiet) {
386
- console.log(dim('检测到上传账号已更换,本次全量重传本地历史'));
387
- }
388
- const metaChanged = applyParserInfos(state, parserInfos);
389
- let migration;
390
- try {
391
- migration = planKikiMigration(allBuckets, allSessions, state, config.kikiStartAt);
392
- } catch (err) {
393
- process.stderr.write(`${dim(` kiki: ${err.message}`)}\n`);
394
- migration = planKikiMigration(allBuckets, allSessions, state);
395
- migration.buckets = migration.buckets.filter(b => b.source !== 'kiki');
396
- migration.sessions = migration.sessions.filter(s => s.source !== 'kiki');
397
- // The invalid cut drops every Kiki row regardless of what the fallback
398
- // plan did; report that as withheld so the diagnostic is not silent.
399
- const droppedBuckets = allBuckets.filter(b => b.source === 'kiki');
400
- const droppedSessions = allSessions.filter(s => s.source === 'kiki');
401
- const droppedStarts = droppedBuckets.map(b => Date.parse(b.bucketStart)).filter(Number.isFinite);
402
- migration.withheld = {
403
- buckets: droppedBuckets.length,
404
- sessions: droppedSessions.length,
405
- totalTokens: droppedBuckets.reduce((sum, b) => sum + (Number(b.totalTokens) || 0), 0),
406
- earliest: droppedStarts.length > 0 ? new Date(Math.min(...droppedStarts)).toISOString() : null,
407
- latest: droppedStarts.length > 0 ? new Date(Math.max(...droppedStarts)).toISOString() : null,
408
- };
409
- okSources.delete('kiki');
410
- }
411
- if (migration.blocked) {
412
- okSources.delete('kiki');
413
- process.stderr.write(`${dim(' kiki: 历史与 kimi-code 同步记录重叠,暂停 Kiki 上传以避免双计。请保留 state.json,停用旧兼容采集器并按 README 迁移,显式设置 config set kikiStartAt <UTC半小时切点>。')}\n`);
414
- }
415
- // A cut (or the guard) withholds real Kiki history and freezes legacy
416
- // kimi-code rows; neither is silent, even in quiet/daemon runs. The user
417
- // learns the counts and time range, and both ways out: set a verified cut, or
418
- // clear the guard when they know no compatibility collector ever ran.
419
- const withheld = migration.withheld;
420
- if (withheld.buckets > 0 || withheld.sessions > 0
421
- || migration.frozenBuckets > 0 || migration.frozenSessions > 0) {
422
- const parts = [];
423
- if (withheld.buckets > 0 || withheld.sessions > 0) {
424
- const range = withheld.earliest ? `,时间范围 ${withheld.earliest} 至 ${withheld.latest}` : '';
425
- parts.push(`本次未上传 ${withheld.buckets} 个桶 / ${withheld.sessions} 个会话(${withheld.totalTokens} tokens${range})`);
475
+ // Incremental upload diff: parsers above emit a complete view of live local
476
+ // data (Codex may assemble that view from its disposable parser cache). Here
477
+ // we drop anything whose content matches what we already uploaded, so only
478
+ // new/changed items go over the network. A quiet machine sends zero bytes;
479
+ // an active one sends just the current 30-min bucket.
480
+ // Missing/corrupt state.json => empty maps => one-time full upload, then
481
+ // incremental forever after.
482
+ announceIdentityChange();
483
+ reportParserInfoChanges(state, parserInfos);
484
+ let migration;
485
+ try {
486
+ migration = planKikiMigration(allBuckets, allSessions, state, config.kikiStartAt);
487
+ } catch (err) {
488
+ process.stderr.write(`${dim(` kiki: ${err.message}`)}\n`);
489
+ migration = planKikiMigration(allBuckets, allSessions, state);
490
+ migration.buckets = migration.buckets.filter(b => b.source !== 'kiki');
491
+ migration.sessions = migration.sessions.filter(s => s.source !== 'kiki');
492
+ // The invalid cut drops every Kiki row regardless of what the fallback
493
+ // plan did; report that as withheld so the diagnostic is not silent.
494
+ const droppedBuckets = allBuckets.filter(b => b.source === 'kiki');
495
+ const droppedSessions = allSessions.filter(s => s.source === 'kiki');
496
+ const droppedStarts = droppedBuckets.map(b => Date.parse(b.bucketStart)).filter(Number.isFinite);
497
+ migration.withheld = {
498
+ buckets: droppedBuckets.length,
499
+ sessions: droppedSessions.length,
500
+ totalTokens: droppedBuckets.reduce((sum, b) => sum + (Number(b.totalTokens) || 0), 0),
501
+ earliest: droppedStarts.length > 0 ? new Date(Math.min(...droppedStarts)).toISOString() : null,
502
+ latest: droppedStarts.length > 0 ? new Date(Math.max(...droppedStarts)).toISOString() : null,
503
+ };
504
+ okSources.delete('kiki');
426
505
  }
427
- if (migration.frozenBuckets > 0 || migration.frozenSessions > 0) {
428
- parts.push(`已冻结 ${migration.frozenBuckets} 个旧 kimi-code 桶 / ${migration.frozenSessions} 个会话,其增长不再上传(服务端记录会停留在旧值)`);
506
+ if (migration.blocked) {
507
+ okSources.delete('kiki');
508
+ process.stderr.write(`${dim(' kiki: 历史与 kimi-code 同步记录重叠,暂停 Kiki 上传以避免双计。请保留 state.json,停用旧兼容采集器并按 README 迁移,显式设置 config set kikiStartAt <UTC半小时切点>。')}\n`);
509
+ }
510
+ // A cut (or the guard) withholds real Kiki history and freezes legacy
511
+ // kimi-code rows; neither is silent, even in quiet/daemon runs. The user
512
+ // learns the counts and time range, and both ways out: set a verified cut, or
513
+ // clear the guard when they know no compatibility collector ever ran.
514
+ const withheld = migration.withheld;
515
+ if (withheld.buckets > 0 || withheld.sessions > 0
516
+ || migration.frozenBuckets > 0 || migration.frozenSessions > 0) {
517
+ const parts = [];
518
+ if (withheld.buckets > 0 || withheld.sessions > 0) {
519
+ const range = withheld.earliest ? `,时间范围 ${withheld.earliest} 至 ${withheld.latest}` : '';
520
+ parts.push(`本次未上传 ${withheld.buckets} 个桶 / ${withheld.sessions} 个会话(${withheld.totalTokens} tokens${range})`);
521
+ }
522
+ if (migration.frozenBuckets > 0 || migration.frozenSessions > 0) {
523
+ parts.push(`已冻结 ${migration.frozenBuckets} 个旧 kimi-code 桶 / ${migration.frozenSessions} 个会话,其增长不再上传(服务端记录会停留在旧值)`);
524
+ }
525
+ process.stderr.write(`${dim(` kiki: ${parts.join(';')}。若这段历史应归入 Kiki,请设置切点 config set kikiStartAt <UTC半小时切点>;若确认从未运行过兼容采集器,可用 config set kikiStartAt none 解除保护。`)}\n`);
526
+ }
527
+ allBuckets = migration.buckets;
528
+ const uploadSessions = migration.sessions;
529
+ const changedBuckets = [];
530
+ const changedSessions = [];
531
+ const liveBucketKeys = new Set(migration.preserveBuckets);
532
+ const liveSessionKeys = new Set(migration.preserveSessions);
533
+ // key -> hash, committed to state only after the owning batch's upload
534
+ // succeeds (a failed batch re-sends next sync — no silent gap).
535
+ const pendingBucketState = new Map();
536
+ const pendingSessionState = new Map();
537
+
538
+ for (const b of allBuckets) {
539
+ const key = bucketKey(b);
540
+ const h = bucketHash(b);
541
+ liveBucketKeys.add(key);
542
+ if (migration.preserveBuckets.has(key) || state.buckets[key] === h) continue;
543
+ changedBuckets.push(b);
544
+ pendingBucketState.set(key, h);
545
+ }
546
+ for (const s of uploadSessions) {
547
+ const key = sessionKey(s);
548
+ const h = sessionHash(s);
549
+ liveSessionKeys.add(key);
550
+ if (migration.preserveSessions.has(key) || state.sessions[key] === h) continue;
551
+ changedSessions.push(s);
552
+ pendingSessionState.set(key, h);
429
553
  }
430
- process.stderr.write(`${dim(` kiki: ${parts.join(';')}。若这段历史应归入 Kiki,请设置切点 config set kikiStartAt <UTC半小时切点>;若确认从未运行过兼容采集器,可用 config set kikiStartAt none 解除保护。`)}\n`);
431
- }
432
- allBuckets = migration.buckets;
433
- const uploadSessions = migration.sessions;
434
- const changedBuckets = [];
435
- const changedSessions = [];
436
- const liveBucketKeys = new Set(migration.preserveBuckets);
437
- const liveSessionKeys = new Set(migration.preserveSessions);
438
- // key -> hash, committed to state only after the owning batch's upload
439
- // succeeds (a failed batch re-sends next sync — no silent gap).
440
- const pendingBucketState = new Map();
441
- const pendingSessionState = new Map();
442
-
443
- for (const b of allBuckets) {
444
- const key = bucketKey(b);
445
- const h = bucketHash(b);
446
- liveBucketKeys.add(key);
447
- if (migration.preserveBuckets.has(key) || state.buckets[key] === h) continue;
448
- changedBuckets.push(b);
449
- pendingBucketState.set(key, h);
450
- }
451
- for (const s of uploadSessions) {
452
- const key = sessionKey(s);
453
- const h = sessionHash(s);
454
- liveSessionKeys.add(key);
455
- if (migration.preserveSessions.has(key) || state.sessions[key] === h) continue;
456
- changedSessions.push(s);
457
- pendingSessionState.set(key, h);
458
- }
459
554
 
460
- // Drop entries the parsers no longer emit (deleted logs) so state.json can't
461
- // grow forever. Done by liveness, never by age — an old bucket's hash never
462
- // changes, so keeping it is exactly what prevents re-uploading it.
463
- //
464
- // Persist the pruned state unconditionally and immediately: removing dead
465
- // keys is independent of whether anything uploads, so it must NOT be coupled
466
- // to upload success. If we deferred this to the batch loop, a first-batch
467
- // failure would throw before any saveState and the prune would be lost.
468
- const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
469
- pruneState(state, liveBucketKeys, liveSessionKeys, okSources);
470
- const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
471
- if (pruned > 0 || metaChanged) saveState(state, identity);
472
-
473
- if (changedBuckets.length === 0 && changedSessions.length === 0) {
474
- if (!quiet) console.log(dim('无新增数据。'));
475
- return 0;
476
- }
555
+ // Parser info is written into state.parserMeta only once every item its
556
+ // source changed this run is committed (see recordParserInfos), and in the
557
+ // same save as the last of those hashes. "Committed" follows the hash rule
558
+ // below exactly: a 200 whose `dropped.unknownSources` names the source
559
+ // leaves its hashes -- and so its info -- uncommitted for a retry; items
560
+ // dropped for other reasons (unknown model, implausible) are committed
561
+ // like any accepted item, since resending cannot change the answer.
562
+ // Sources with nothing to send record their info right away.
563
+ const unsentBySource = new Map();
564
+ const noteUnsent = (id) => {
565
+ const source = id.slice(2, id.indexOf('|'));
566
+ if (!unsentBySource.has(source)) unsentBySource.set(source, new Set());
567
+ unsentBySource.get(source).add(id);
568
+ };
569
+ for (const key of pendingBucketState.keys()) noteUnsent(`b:${key}`);
570
+ for (const key of pendingSessionState.keys()) noteUnsent(`s:${key}`);
571
+ const heldInfos = new Map(parserInfos
572
+ .filter(({ source }) => unsentBySource.has(source))
573
+ .map(item => [item.source, item]));
574
+ const metaChanged = recordParserInfos(
575
+ state,
576
+ parserInfos.filter(({ source }) => !unsentBySource.has(source)),
577
+ );
578
+ const markCommitted = (id) => {
579
+ const source = id.slice(2, id.indexOf('|'));
580
+ const unsent = unsentBySource.get(source);
581
+ if (!unsent || !unsent.delete(id) || unsent.size > 0) return;
582
+ const held = heldInfos.get(source);
583
+ if (held) recordParserInfos(state, [held]);
584
+ };
477
585
 
478
- const allBucketsToSend = changedBuckets;
479
- const allSessionsToSend = changedSessions;
480
-
481
- let totalIngested = 0;
482
- let totalSessionsSynced = 0;
483
- let totalDroppedBuckets = 0;
484
- let totalDroppedUnknownModels = 0;
485
- let totalDroppedImplausible = 0;
486
- let totalProtectedBuckets = 0;
487
- const droppedSources = new Set();
488
- const bucketBatches = Math.ceil(allBucketsToSend.length / BATCH_SIZE);
489
- const sessionBatches = Math.ceil(allSessionsToSend.length / SESSION_BATCH_SIZE);
490
- const totalBatches = Math.max(bucketBatches, sessionBatches, 1);
491
- const syncClient = createSyncClient({ defaultSurface: surface, hostname: host });
492
-
493
- // Say up front how much is about to go up. A first sync (or one that
494
- // backfills after a parser was broken) can be thousands of batches, and with
495
- // only a per-batch progress line the user cannot tell a long upload from a
496
- // hung one -- which is exactly how a silently failing parser stayed hidden.
497
- if (!quiet) {
498
- const pending = [`${allBucketsToSend.length} buckets`];
499
- if (allSessionsToSend.length > 0) pending.push(`${allSessionsToSend.length} sessions`);
500
- const batchNote = totalBatches > 1 ? `,分 ${totalBatches} 批` : '';
501
- console.log(dim(` 待上传 ${pending.join(' · ')}${batchNote}`));
502
- }
503
586
 
504
- let uploadedBytes = 0;
505
- const uploadStartedAt = Date.now();
587
+ // Drop entries the parsers no longer emit (deleted logs) so state.json can't
588
+ // grow forever. Done by liveness, never by age — an old bucket's hash never
589
+ // changes, so keeping it is exactly what prevents re-uploading it.
590
+ //
591
+ // Persist the pruned state unconditionally and immediately: removing dead
592
+ // keys is independent of whether anything uploads, so it must NOT be coupled
593
+ // to upload success. If we deferred this to the batch loop, a first-batch
594
+ // failure would throw before any saveState and the prune would be lost.
595
+ const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
596
+ pruneState(state, liveBucketKeys, liveSessionKeys, okSources);
597
+ const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
598
+ if (pruned > 0 || metaChanged) saveState(state, identity);
506
599
 
507
- try {
508
- for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) {
509
- const batch = allBucketsToSend.slice(batchIdx * BATCH_SIZE, (batchIdx + 1) * BATCH_SIZE);
510
- const batchSessions = allSessionsToSend.slice(batchIdx * SESSION_BATCH_SIZE, (batchIdx + 1) * SESSION_BATCH_SIZE);
511
- const batchNum = batchIdx + 1;
512
- const prefix = totalBatches > 1 ? ` ${dim(`[${batchNum}/${totalBatches}]`)} 上传中 ` : ' 上传中 ';
513
-
514
- // Only measured batches feed the estimate, so the first batch shows no
515
- // ETA rather than a made-up one.
516
- const remaining = estimateRemainingSeconds({
517
- elapsedMs: Date.now() - uploadStartedAt,
518
- doneBatches: batchIdx,
519
- totalBatches,
520
- });
521
- const etaNote = remaining === null ? '' : ` · 预计还需 ${formatDuration(remaining)}`;
522
-
523
- let batchBytes = 0;
524
- const result = await ingest(apiUrl, config.apiKey, batch, {
525
- client: forBatch(syncClient, batchIdx, totalBatches),
526
- onProgress(sent, total) {
527
- batchBytes = total;
528
- const pct = Math.round((sent / total) * 100);
529
- process.stdout.write(`\r${prefix}${dim(`${formatBytes(sent)}/${formatBytes(total)} (${pct}%)${etaNote}`)}\x1b[K`);
530
- },
531
- }, batchSessions.length > 0 ? batchSessions : undefined);
532
- uploadedBytes += batchBytes;
533
- totalIngested += result.ingested ?? batch.length;
534
- totalSessionsSynced += result.sessions ?? 0;
535
- const batchUnknownSources = new Set(result.dropped?.unknownSources || []);
536
- if (result.dropped) {
537
- totalDroppedBuckets += Number(result.dropped.buckets) || 0;
538
- totalDroppedUnknownModels += Number(result.dropped.unknownModels) || 0;
539
- totalDroppedImplausible += Number(result.dropped.implausible) || 0;
540
- for (const s of result.dropped.unknownSources || []) droppedSources.add(s);
600
+ if (changedBuckets.length === 0 && changedSessions.length === 0) {
601
+ if (!quiet && final) {
602
+ if (uploadedAnything()) printDashboardLink();
603
+ else console.log(dim('无新增数据。'));
541
604
  }
542
- totalProtectedBuckets += Number(result.protected?.buckets) || 0;
543
-
544
- // Commit only hashes from this successful batch. Persist before starting
545
- // the next batch: if a later upload fails or the process exits abruptly,
546
- // the next sync retries only the uncommitted suffix.
547
- let batchStateChanged = false;
548
- for (const b of batch) {
549
- // A source unknown to an older backend may become valid after deploy.
550
- // Leave those hashes uncommitted so the next sync retries them instead
551
- // of turning a temporary release-order mismatch into permanent loss.
552
- if (batchUnknownSources.has(b.source)) continue;
553
- const key = bucketKey(b);
554
- const entry = pendingBucketState.get(key);
555
- if (entry) {
556
- state.buckets[key] = entry;
557
- batchStateChanged = true;
605
+ return 0;
606
+ }
607
+
608
+ const allBucketsToSend = changedBuckets;
609
+ const allSessionsToSend = changedSessions;
610
+
611
+ let totalIngested = 0;
612
+ let totalSessionsSynced = 0;
613
+ let totalDroppedBuckets = 0;
614
+ let totalDroppedUnknownModels = 0;
615
+ let totalDroppedImplausible = 0;
616
+ let totalProtectedBuckets = 0;
617
+ const droppedSources = new Set();
618
+ const bucketBatches = Math.ceil(allBucketsToSend.length / BATCH_SIZE);
619
+ const sessionBatches = Math.ceil(allSessionsToSend.length / SESSION_BATCH_SIZE);
620
+ const totalBatches = Math.max(bucketBatches, sessionBatches, 1);
621
+ // One syncId per runSync, shared by both passes.
622
+ syncClient ??= createSyncClient({ defaultSurface: surface, hostname: host });
623
+
624
+ // Say up front how much is about to go up. A first sync (or one that
625
+ // backfills after a parser was broken) can be thousands of batches, and with
626
+ // only a per-batch progress line the user cannot tell a long upload from a
627
+ // hung one -- which is exactly how a silently failing parser stayed hidden.
628
+ if (!quiet) {
629
+ const pending = [`${allBucketsToSend.length} buckets`];
630
+ if (allSessionsToSend.length > 0) pending.push(`${allSessionsToSend.length} sessions`);
631
+ const batchNote = totalBatches > 1 ? `,分 ${totalBatches} 批` : '';
632
+ console.log(dim(` 待上传 ${pending.join(' · ')}${batchNote}`));
633
+ }
634
+
635
+ let uploadedBytes = 0;
636
+ const uploadStartedAt = Date.now();
637
+
638
+ try {
639
+ for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) {
640
+ const batch = allBucketsToSend.slice(batchIdx * BATCH_SIZE, (batchIdx + 1) * BATCH_SIZE);
641
+ const batchSessions = allSessionsToSend.slice(batchIdx * SESSION_BATCH_SIZE, (batchIdx + 1) * SESSION_BATCH_SIZE);
642
+ const batchNum = batchIdx + 1;
643
+ const prefix = totalBatches > 1 ? ` ${dim(`[${batchNum}/${totalBatches}]`)} 上传中 ` : ' 上传中 ';
644
+
645
+ // Only measured batches feed the estimate, so the first batch shows no
646
+ // ETA rather than a made-up one.
647
+ const remaining = estimateRemainingSeconds({
648
+ elapsedMs: Date.now() - uploadStartedAt,
649
+ doneBatches: batchIdx,
650
+ totalBatches,
651
+ });
652
+ const etaNote = remaining === null ? '' : ` · 预计还需 ${formatDuration(remaining)}`;
653
+
654
+ let batchBytes = 0;
655
+ const result = await ingest(apiUrl, config.apiKey, batch, {
656
+ client: forBatch(syncClient, batchIdx, totalBatches),
657
+ onProgress(sent, total) {
658
+ batchBytes = total;
659
+ const pct = Math.round((sent / total) * 100);
660
+ process.stdout.write(`\r${prefix}${dim(`${formatBytes(sent)}/${formatBytes(total)} (${pct}%)${etaNote}`)}\x1b[K`);
661
+ },
662
+ }, batchSessions.length > 0 ? batchSessions : undefined);
663
+ uploadedBytes += batchBytes;
664
+ totalIngested += result.ingested ?? batch.length;
665
+ totalSessionsSynced += result.sessions ?? 0;
666
+ uploadedSoFar.buckets += result.ingested ?? batch.length;
667
+ uploadedSoFar.sessions += result.sessions ?? 0;
668
+ const batchUnknownSources = new Set(result.dropped?.unknownSources || []);
669
+ if (result.dropped) {
670
+ totalDroppedBuckets += Number(result.dropped.buckets) || 0;
671
+ totalDroppedUnknownModels += Number(result.dropped.unknownModels) || 0;
672
+ totalDroppedImplausible += Number(result.dropped.implausible) || 0;
673
+ for (const s of result.dropped.unknownSources || []) droppedSources.add(s);
558
674
  }
559
- }
560
- for (const s of batchSessions) {
561
- // Same uncommitted-on-drop rule as buckets: a session the backend
562
- // rejected for an unknown source must be retried on the next sync
563
- // rather than permanently lost.
564
- if (batchUnknownSources.has(s.source)) continue;
565
- const key = sessionKey(s);
566
- const entry = pendingSessionState.get(key);
567
- if (entry) {
568
- state.sessions[key] = entry;
569
- batchStateChanged = true;
675
+ totalProtectedBuckets += Number(result.protected?.buckets) || 0;
676
+
677
+ // Commit only hashes from this successful batch. Persist before starting
678
+ // the next batch: if a later upload fails or the process exits abruptly,
679
+ // the next sync retries only the uncommitted suffix.
680
+ let batchStateChanged = false;
681
+ for (const b of batch) {
682
+ // A source unknown to an older backend may become valid after deploy.
683
+ // Leave those hashes uncommitted so the next sync retries them instead
684
+ // of turning a temporary release-order mismatch into permanent loss.
685
+ if (batchUnknownSources.has(b.source)) continue;
686
+ const key = bucketKey(b);
687
+ const entry = pendingBucketState.get(key);
688
+ if (entry) {
689
+ state.buckets[key] = entry;
690
+ batchStateChanged = true;
691
+ markCommitted(`b:${key}`);
692
+ }
693
+ }
694
+ for (const s of batchSessions) {
695
+ // Same uncommitted-on-drop rule as buckets: a session the backend
696
+ // rejected for an unknown source must be retried on the next sync
697
+ // rather than permanently lost.
698
+ if (batchUnknownSources.has(s.source)) continue;
699
+ const key = sessionKey(s);
700
+ const entry = pendingSessionState.get(key);
701
+ if (entry) {
702
+ state.sessions[key] = entry;
703
+ batchStateChanged = true;
704
+ markCommitted(`s:${key}`);
705
+ }
570
706
  }
707
+ if (batchStateChanged) saveState(state, identity);
571
708
  }
572
- if (batchStateChanged) saveState(state, identity);
573
- }
574
709
 
575
- if (totalBatches > 1 || allBucketsToSend.length > 0) {
576
- process.stdout.write('\r\x1b[K');
577
- }
578
- const syncParts = [`${totalIngested} buckets`];
579
- if (totalSessionsSynced > 0) syncParts.push(`${totalSessionsSynced} sessions`);
580
- console.log(success(`已同步 ${syncParts.join(' · ')}`));
581
- if (!quiet && uploadedBytes > 0) {
582
- const elapsed = (Date.now() - uploadStartedAt) / 1000;
583
- console.log(dim(` 上传 ${formatBytes(uploadedBytes)}(已压缩),用时 ${formatDuration(elapsed)}`));
584
- }
710
+ if (totalBatches > 1 || allBucketsToSend.length > 0) {
711
+ process.stdout.write('\r\x1b[K');
712
+ }
713
+ const syncParts = [`${totalIngested} buckets`];
714
+ if (totalSessionsSynced > 0) syncParts.push(`${totalSessionsSynced} sessions`);
715
+ console.log(success(`已同步 ${syncParts.join(' · ')}`));
716
+ if (!quiet && uploadedBytes > 0) {
717
+ const elapsed = (Date.now() - uploadStartedAt) / 1000;
718
+ console.log(dim(` 上传 ${formatBytes(uploadedBytes)}(已压缩),用时 ${formatDuration(elapsed)}`));
719
+ }
585
720
 
586
- if (totalDroppedBuckets > 0) {
587
- const reasons = [];
588
- if (droppedSources.size > 0) {
589
- reasons.push(`服务端未收录的 source: ${Array.from(droppedSources).sort().join(', ')}`);
721
+ if (totalDroppedBuckets > 0) {
722
+ const reasons = [];
723
+ if (droppedSources.size > 0) {
724
+ reasons.push(`服务端未收录的 source: ${Array.from(droppedSources).sort().join(', ')}`);
725
+ }
726
+ if (totalDroppedUnknownModels > 0) reasons.push(`模型未知: ${totalDroppedUnknownModels}`);
727
+ if (totalDroppedImplausible > 0) reasons.push(`超出合理范围: ${totalDroppedImplausible}`);
728
+ if (reasons.length === 0) reasons.push('服务端拒绝');
729
+ console.log(dim(` ${totalDroppedBuckets} buckets dropped (${reasons.join(';')})`));
590
730
  }
591
- if (totalDroppedUnknownModels > 0) reasons.push(`模型未知: ${totalDroppedUnknownModels}`);
592
- if (totalDroppedImplausible > 0) reasons.push(`超出合理范围: ${totalDroppedImplausible}`);
593
- if (reasons.length === 0) reasons.push('服务端拒绝');
594
- console.log(dim(` ${totalDroppedBuckets} buckets dropped (${reasons.join(';')})`));
595
- }
596
731
 
597
- if (totalProtectedBuckets > 0) {
598
- console.log(dim(` 服务端保留了 ${totalProtectedBuckets} 个更大的已有 bucket(本次较小快照未覆盖)`));
599
- }
732
+ if (totalProtectedBuckets > 0) {
733
+ console.log(dim(` 服务端保留了 ${totalProtectedBuckets} 个更大的已有 bucket(本次较小快照未覆盖)`));
734
+ }
600
735
 
601
- if (!quiet && totalSessionsSynced > 0) {
602
- const totalActive = allSessionsToSend.reduce((s, x) => s + x.activeSeconds, 0);
603
- const totalDuration = allSessionsToSend.reduce((s, x) => s + x.durationSeconds, 0);
604
- const totalMsgs = allSessionsToSend.reduce((s, x) => s + x.messageCount, 0);
605
- console.log(dim(` 活跃 ${formatDuration(totalActive)} / 总时长 ${formatDuration(totalDuration)} · ${totalMsgs} 条消息`));
606
- }
736
+ if (!quiet && totalSessionsSynced > 0) {
737
+ const totalActive = allSessionsToSend.reduce((s, x) => s + x.activeSeconds, 0);
738
+ const totalDuration = allSessionsToSend.reduce((s, x) => s + x.durationSeconds, 0);
739
+ const totalMsgs = allSessionsToSend.reduce((s, x) => s + x.messageCount, 0);
740
+ console.log(dim(` 活跃 ${formatDuration(totalActive)} / 总时长 ${formatDuration(totalDuration)} · ${totalMsgs} 条消息`));
741
+ }
607
742
 
608
- if (!quiet) {
609
- console.log();
610
- console.log(`${arrow('前往 Dashboard 查看详情')} ${link(`${apiUrl}/usage`)}`);
611
- }
743
+ if (!quiet && final) printDashboardLink();
612
744
 
613
- return totalIngested;
614
- } catch (err) {
615
- if (err.message === 'UNAUTHORIZED') {
616
- console.error(failure('API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。'));
745
+ return totalIngested;
746
+ } catch (err) {
747
+ // Every failure line says what this runSync already delivered (both
748
+ // passes, buckets and sessions): when only the slow source's pass fails,
749
+ // the other tools did reach the server. It goes to stderr on one line,
750
+ // because the Windows app shows the last error line of stderr.
751
+ if (err.message === 'UNAUTHORIZED') {
752
+ const keyHint = 'API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。';
753
+ console.error(failure(uploadedAnything() ? `${partialLabel()}: ${keyHint}` : keyHint));
754
+ if (throws) throw err;
755
+ process.exit(1);
756
+ }
757
+ if (uploadedAnything()) {
758
+ console.error(failure(`${partialLabel()}: ${err.message}`));
759
+ } else {
760
+ console.error(failure(`同步失败: ${err.message}`));
761
+ }
617
762
  if (throws) throw err;
618
763
  process.exit(1);
619
764
  }
620
- if (totalIngested > 0) {
621
- console.error(failure(`部分完成(已上传 ${totalIngested} buckets): ${err.message}`));
622
- } else {
623
- console.error(failure(`同步失败: ${err.message}`));
624
- }
625
- if (throws) throw err;
626
- process.exit(1);
627
765
  }
628
766
  }