@vibe-cafe/vibe-usage 0.14.4 → 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,
@@ -79,11 +80,105 @@ export function reaggregateHiddenProjectBuckets(buckets) {
79
80
  })));
80
81
  }
81
82
 
83
+ /**
84
+ * Compare local parser diagnostics (Cursor's signed-in account and newest
85
+ * event) with the previous run and warn only on a meaningful change. The
86
+ * values live in state.json under `parserMeta`; they are local diagnostics,
87
+ * never uploaded, and older CLIs ignore them.
88
+ *
89
+ * This is how an account switch stops being silent: the old token keeps
90
+ * returning the old account's history, so the dashboard simply stops growing
91
+ * while every sync still "succeeds". `status` prints the recorded account and
92
+ * newest event for the same reason.
93
+ */
94
+ export function applyParserInfos(state, parserInfos) {
95
+ reportParserInfoChanges(state, parserInfos);
96
+ return recordParserInfos(state, parserInfos);
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;
106
+ for (const { source, info } of parserInfos) {
107
+ if (!usableInfo(info)) continue;
108
+ const previous = state.parserMeta?.[source];
109
+ if (source === 'cursor') {
110
+ const warnings = [];
111
+ if (previous?.account && info.account && previous.account !== info.account) {
112
+ warnings.push(
113
+ `cursor: Cursor 登录账号已从 ${previous.account} 更换为 ${info.account};`
114
+ + '如果这不是你刚切换的账号,请在 Cursor 设置中重新登录。',
115
+ );
116
+ }
117
+ if (previous?.latestEventAt && info.latestEventAt) {
118
+ const before = Date.parse(previous.latestEventAt);
119
+ const after = Date.parse(info.latestEventAt);
120
+ if (Number.isFinite(before) && Number.isFinite(after) && after < before) {
121
+ warnings.push(
122
+ `cursor: Cursor 最新事件从 ${previous.latestEventAt} 回退到 ${info.latestEventAt};`
123
+ + '这通常意味着当前登录的是另一个(更旧的)Cursor 账号。',
124
+ );
125
+ }
126
+ }
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.
130
+ for (const message of warnings) {
131
+ process.stderr.write(`${dim(` ${message}`)}\n`);
132
+ }
133
+ }
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)) {
155
+ state.parserMeta[source] = info;
156
+ changed = true;
157
+ }
158
+ }
159
+ return changed;
160
+ }
161
+
82
162
  // Parser execution is I/O bound (log reads, occasional network calls). Run a
83
163
  // bounded number at once to cut wall-clock sync time without the memory spike
84
164
  // of loading every tool's logs simultaneously.
85
165
  export const PARSER_CONCURRENCY = 4;
86
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
+
87
182
  // Run `fn` over `items` with at most `limit` in flight, preserving order.
88
183
  export async function mapWithConcurrency(items, limit, fn) {
89
184
  const results = new Array(items.length);
@@ -174,386 +269,498 @@ export async function runSync({
174
269
  }
175
270
  }
176
271
 
177
- let allBuckets = [];
178
- const allSessions = [];
179
- const parserResults = [];
180
- const parserProgress = [];
181
- // Sources whose parser ran to completion this sync. pruneState() below is
182
- // scoped to these so a transient parser failure doesn't evict that tool's
183
- // state and force a full re-upload next run.
184
- const okSources = new Set();
272
+ // Loaded before parsing so a stateful parser can use the previous run's
273
+ // diagnostics (Cursor uses the last event time to gate its events fallback
274
+ // and avoid re-uploading already-known history under a different model id).
275
+ const state = loadState(identity);
276
+
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
+ };
185
300
 
186
301
  // Run parsers concurrently (bounded) so one slow parser (Cursor's network
187
302
  // fetch, a cold Codex index) can't stall the rest. Results are collected in
188
303
  // registry order so output and merged arrays stay deterministic.
189
304
  const parserRegistry = Object.entries(parsers);
190
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
+ };
191
322
  status.start(elapsedMs => `正在扫描本地日志 ${parsedSources}/${parserRegistry.length}${phaseElapsed(elapsedMs)}`);
192
- const parserOutcomes = await mapWithConcurrency(
193
- parserRegistry,
194
- PARSER_CONCURRENCY,
195
- 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;
196
385
  try {
197
- const result = await parse({
198
- extraRoots: extraRootList(config.extraRoots?.[source]),
199
- ...(source === 'codex' ? {
200
- codexExtraHome: resolveCodexExtraHome(config.codexExtraHome, codexExtraHome),
201
- } : {}),
202
- });
203
- return { source, result };
386
+ normalized = normalizeParserResult(source, result);
204
387
  } catch (err) {
205
- return { source, error: err };
206
- } finally {
207
- 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 });
208
412
  }
209
- },
210
- );
211
- status.stop();
212
-
213
- for (const { source, result, error } of parserOutcomes) {
214
- if (error) {
215
- // Parser errors are non-fatal — pass-through in dim gray (no translation).
216
- process.stderr.write(`${dim(` ${source}: ${error.message}`)}\n`);
217
- continue;
218
- }
219
- let normalized;
220
- try {
221
- normalized = normalizeParserResult(source, result);
222
- } catch (err) {
223
- process.stderr.write(`${dim(` ${source}: ${err.message}`)}\n`);
224
- continue;
225
- }
226
- const { buckets, sessions, skipped, warnings, indexing } = normalized;
227
- if (indexing) {
228
- parserProgress.push({ source, ...indexing });
229
- }
230
- // Parser warnings always reach stderr, including quiet (daemon) runs: the
231
- // daemon log is the only trail a background failure leaves. Cursor's fetch
232
- // soft-skip used to be filtered out here to keep that log tidy, which made
233
- // a permanently failing export indistinguishable from a healthy one -- the
234
- // tool still listed as "installed" while it had never uploaded a byte.
235
- for (const message of warnings) {
236
- process.stderr.write(`${dim(` ${message}`)}\n`);
237
413
  }
238
- // A parser may downgrade a transient error (Cursor network timeout) to a
239
- // warning instead of throwing. Its empty result is not proof that its prior
240
- // data disappeared, so it must not be pruned this run.
241
- if (!skipped) okSources.add(source);
242
- for (const bucket of buckets) allBuckets.push(bucket);
243
- for (const session of sessions) allSessions.push(session);
244
- if (buckets.length > 0 || sessions.length > 0) {
245
- 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;
246
434
  }
247
- }
248
435
 
249
- if (allBuckets.length === 0 && allSessions.length === 0) {
250
- // Successful parsers emitted no live items. Prune their old keys even on
251
- // this fast path; otherwise deleting the final local log would leave dead
252
- // state entries forever. Failed-parser sources remain protected.
253
- const state = loadState(identity);
254
- if (state.identityChanged && !quiet) {
255
- 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
+ }
256
443
  }
257
- const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
258
- pruneState(state, new Set(), new Set(), okSources);
259
- const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
260
- if (pruned > 0) saveState(state, identity);
261
444
  if (!quiet && parserProgress.length > 0) {
262
445
  for (const p of parserProgress) {
263
446
  console.log(dim(` ${p.source}: 正在建立本地索引 ${p.completed}/${p.total}(下次同步继续)`));
264
447
  }
265
- } else if (!quiet) {
266
- console.log(dim('暂无新数据。'));
267
448
  }
268
- return 0;
269
- }
270
449
 
271
- if (!quiet && parserResults.length > 0) {
272
- for (const p of parserResults) {
273
- const parts = [];
274
- if (p.buckets > 0) parts.push(`${p.buckets} buckets`);
275
- if (p.sessions > 0) parts.push(`${p.sessions} sessions`);
276
- 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);
277
455
  }
278
- }
279
- if (!quiet && parserProgress.length > 0) {
280
- for (const p of parserProgress) {
281
- 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
+ }
282
468
  }
283
- }
284
-
285
- let host = config.hostname;
286
- if (!host) {
287
- host = osHostname().replace(/\.local$/, '');
288
- config.hostname = host;
289
- saveConfig(config);
290
- }
291
- // Cloud-sourced parsers (e.g. cursor) pre-set their own hostname sentinel so
292
- // the same account data isn't stored as separate rows per machine.
293
- for (const b of allBuckets) if (!b.hostname) b.hostname = host;
294
- for (const s of allSessions) if (!s.hostname) s.hostname = host;
295
-
296
- if (!quiet) {
297
- if (uploadProject) {
298
- console.log(dim(' 项目名: 上传(可在 Web 设置中关闭)'));
299
- } else {
300
- 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);
301
473
  }
302
- }
303
- if (!uploadProject) {
304
- for (const b of allBuckets) b.project = 'unknown';
305
- for (const s of allSessions) s.project = 'unknown';
306
- allBuckets = reaggregateHiddenProjectBuckets(allBuckets);
307
- }
308
474
 
309
- // Incremental upload diff: parsers above emit a complete view of live local
310
- // data (Codex may assemble that view from its disposable parser cache). Here
311
- // we drop anything whose content matches what we already uploaded, so only
312
- // new/changed items go over the network. A quiet machine sends zero bytes;
313
- // an active one sends just the current 30-min bucket.
314
- // Missing/corrupt state.json => empty maps => one-time full upload, then
315
- // incremental forever after.
316
- const state = loadState(identity);
317
- if (state.identityChanged && !quiet) {
318
- console.log(dim('检测到上传账号已更换,本次全量重传本地历史'));
319
- }
320
- let migration;
321
- try {
322
- migration = planKikiMigration(allBuckets, allSessions, state, config.kikiStartAt);
323
- } catch (err) {
324
- process.stderr.write(`${dim(` kiki: ${err.message}`)}\n`);
325
- migration = planKikiMigration(allBuckets, allSessions, state);
326
- migration.buckets = migration.buckets.filter(b => b.source !== 'kiki');
327
- migration.sessions = migration.sessions.filter(s => s.source !== 'kiki');
328
- // The invalid cut drops every Kiki row regardless of what the fallback
329
- // plan did; report that as withheld so the diagnostic is not silent.
330
- const droppedBuckets = allBuckets.filter(b => b.source === 'kiki');
331
- const droppedSessions = allSessions.filter(s => s.source === 'kiki');
332
- const droppedStarts = droppedBuckets.map(b => Date.parse(b.bucketStart)).filter(Number.isFinite);
333
- migration.withheld = {
334
- buckets: droppedBuckets.length,
335
- sessions: droppedSessions.length,
336
- totalTokens: droppedBuckets.reduce((sum, b) => sum + (Number(b.totalTokens) || 0), 0),
337
- earliest: droppedStarts.length > 0 ? new Date(Math.min(...droppedStarts)).toISOString() : null,
338
- latest: droppedStarts.length > 0 ? new Date(Math.max(...droppedStarts)).toISOString() : null,
339
- };
340
- okSources.delete('kiki');
341
- }
342
- if (migration.blocked) {
343
- okSources.delete('kiki');
344
- process.stderr.write(`${dim(' kiki: 历史与 kimi-code 同步记录重叠,暂停 Kiki 上传以避免双计。请保留 state.json,停用旧兼容采集器并按 README 迁移,显式设置 config set kikiStartAt <UTC半小时切点>。')}\n`);
345
- }
346
- // A cut (or the guard) withholds real Kiki history and freezes legacy
347
- // kimi-code rows; neither is silent, even in quiet/daemon runs. The user
348
- // learns the counts and time range, and both ways out: set a verified cut, or
349
- // clear the guard when they know no compatibility collector ever ran.
350
- const withheld = migration.withheld;
351
- if (withheld.buckets > 0 || withheld.sessions > 0
352
- || migration.frozenBuckets > 0 || migration.frozenSessions > 0) {
353
- const parts = [];
354
- if (withheld.buckets > 0 || withheld.sessions > 0) {
355
- const range = withheld.earliest ? `,时间范围 ${withheld.earliest} 至 ${withheld.latest}` : '';
356
- 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');
357
505
  }
358
- if (migration.frozenBuckets > 0 || migration.frozenSessions > 0) {
359
- 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);
360
553
  }
361
- process.stderr.write(`${dim(` kiki: ${parts.join(';')}。若这段历史应归入 Kiki,请设置切点 config set kikiStartAt <UTC半小时切点>;若确认从未运行过兼容采集器,可用 config set kikiStartAt none 解除保护。`)}\n`);
362
- }
363
- allBuckets = migration.buckets;
364
- const uploadSessions = migration.sessions;
365
- const changedBuckets = [];
366
- const changedSessions = [];
367
- const liveBucketKeys = new Set(migration.preserveBuckets);
368
- const liveSessionKeys = new Set(migration.preserveSessions);
369
- // key -> hash, committed to state only after the owning batch's upload
370
- // succeeds (a failed batch re-sends next sync — no silent gap).
371
- const pendingBucketState = new Map();
372
- const pendingSessionState = new Map();
373
-
374
- for (const b of allBuckets) {
375
- const key = bucketKey(b);
376
- const h = bucketHash(b);
377
- liveBucketKeys.add(key);
378
- if (migration.preserveBuckets.has(key) || state.buckets[key] === h) continue;
379
- changedBuckets.push(b);
380
- pendingBucketState.set(key, h);
381
- }
382
- for (const s of uploadSessions) {
383
- const key = sessionKey(s);
384
- const h = sessionHash(s);
385
- liveSessionKeys.add(key);
386
- if (migration.preserveSessions.has(key) || state.sessions[key] === h) continue;
387
- changedSessions.push(s);
388
- pendingSessionState.set(key, h);
389
- }
390
554
 
391
- // Drop entries the parsers no longer emit (deleted logs) so state.json can't
392
- // grow forever. Done by liveness, never by age — an old bucket's hash never
393
- // changes, so keeping it is exactly what prevents re-uploading it.
394
- //
395
- // Persist the pruned state unconditionally and immediately: removing dead
396
- // keys is independent of whether anything uploads, so it must NOT be coupled
397
- // to upload success. If we deferred this to the batch loop, a first-batch
398
- // failure would throw before any saveState and the prune would be lost.
399
- const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
400
- pruneState(state, liveBucketKeys, liveSessionKeys, okSources);
401
- const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
402
- if (pruned > 0) saveState(state, identity);
403
-
404
- if (changedBuckets.length === 0 && changedSessions.length === 0) {
405
- if (!quiet) console.log(dim('无新增数据。'));
406
- return 0;
407
- }
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
+ };
408
585
 
409
- const allBucketsToSend = changedBuckets;
410
- const allSessionsToSend = changedSessions;
411
-
412
- let totalIngested = 0;
413
- let totalSessionsSynced = 0;
414
- let totalDroppedBuckets = 0;
415
- let totalDroppedUnknownModels = 0;
416
- let totalDroppedImplausible = 0;
417
- let totalProtectedBuckets = 0;
418
- const droppedSources = new Set();
419
- const bucketBatches = Math.ceil(allBucketsToSend.length / BATCH_SIZE);
420
- const sessionBatches = Math.ceil(allSessionsToSend.length / SESSION_BATCH_SIZE);
421
- const totalBatches = Math.max(bucketBatches, sessionBatches, 1);
422
- const syncClient = createSyncClient({ defaultSurface: surface, hostname: host });
423
-
424
- // Say up front how much is about to go up. A first sync (or one that
425
- // backfills after a parser was broken) can be thousands of batches, and with
426
- // only a per-batch progress line the user cannot tell a long upload from a
427
- // hung one -- which is exactly how a silently failing parser stayed hidden.
428
- if (!quiet) {
429
- const pending = [`${allBucketsToSend.length} buckets`];
430
- if (allSessionsToSend.length > 0) pending.push(`${allSessionsToSend.length} sessions`);
431
- const batchNote = totalBatches > 1 ? `,分 ${totalBatches} 批` : '';
432
- console.log(dim(` 待上传 ${pending.join(' · ')}${batchNote}`));
433
- }
434
586
 
435
- let uploadedBytes = 0;
436
- 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);
437
599
 
438
- try {
439
- for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) {
440
- const batch = allBucketsToSend.slice(batchIdx * BATCH_SIZE, (batchIdx + 1) * BATCH_SIZE);
441
- const batchSessions = allSessionsToSend.slice(batchIdx * SESSION_BATCH_SIZE, (batchIdx + 1) * SESSION_BATCH_SIZE);
442
- const batchNum = batchIdx + 1;
443
- const prefix = totalBatches > 1 ? ` ${dim(`[${batchNum}/${totalBatches}]`)} 上传中 ` : ' 上传中 ';
444
-
445
- // Only measured batches feed the estimate, so the first batch shows no
446
- // ETA rather than a made-up one.
447
- const remaining = estimateRemainingSeconds({
448
- elapsedMs: Date.now() - uploadStartedAt,
449
- doneBatches: batchIdx,
450
- totalBatches,
451
- });
452
- const etaNote = remaining === null ? '' : ` · 预计还需 ${formatDuration(remaining)}`;
453
-
454
- let batchBytes = 0;
455
- const result = await ingest(apiUrl, config.apiKey, batch, {
456
- client: forBatch(syncClient, batchIdx, totalBatches),
457
- onProgress(sent, total) {
458
- batchBytes = total;
459
- const pct = Math.round((sent / total) * 100);
460
- process.stdout.write(`\r${prefix}${dim(`${formatBytes(sent)}/${formatBytes(total)} (${pct}%)${etaNote}`)}\x1b[K`);
461
- },
462
- }, batchSessions.length > 0 ? batchSessions : undefined);
463
- uploadedBytes += batchBytes;
464
- totalIngested += result.ingested ?? batch.length;
465
- totalSessionsSynced += result.sessions ?? 0;
466
- const batchUnknownSources = new Set(result.dropped?.unknownSources || []);
467
- if (result.dropped) {
468
- totalDroppedBuckets += Number(result.dropped.buckets) || 0;
469
- totalDroppedUnknownModels += Number(result.dropped.unknownModels) || 0;
470
- totalDroppedImplausible += Number(result.dropped.implausible) || 0;
471
- 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('无新增数据。'));
472
604
  }
473
- totalProtectedBuckets += Number(result.protected?.buckets) || 0;
474
-
475
- // Commit only hashes from this successful batch. Persist before starting
476
- // the next batch: if a later upload fails or the process exits abruptly,
477
- // the next sync retries only the uncommitted suffix.
478
- let batchStateChanged = false;
479
- for (const b of batch) {
480
- // A source unknown to an older backend may become valid after deploy.
481
- // Leave those hashes uncommitted so the next sync retries them instead
482
- // of turning a temporary release-order mismatch into permanent loss.
483
- if (batchUnknownSources.has(b.source)) continue;
484
- const key = bucketKey(b);
485
- const entry = pendingBucketState.get(key);
486
- if (entry) {
487
- state.buckets[key] = entry;
488
- 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);
489
674
  }
490
- }
491
- for (const s of batchSessions) {
492
- // Same uncommitted-on-drop rule as buckets: a session the backend
493
- // rejected for an unknown source must be retried on the next sync
494
- // rather than permanently lost.
495
- if (batchUnknownSources.has(s.source)) continue;
496
- const key = sessionKey(s);
497
- const entry = pendingSessionState.get(key);
498
- if (entry) {
499
- state.sessions[key] = entry;
500
- 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
+ }
501
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
+ }
706
+ }
707
+ if (batchStateChanged) saveState(state, identity);
502
708
  }
503
- if (batchStateChanged) saveState(state, identity);
504
- }
505
709
 
506
- if (totalBatches > 1 || allBucketsToSend.length > 0) {
507
- process.stdout.write('\r\x1b[K');
508
- }
509
- const syncParts = [`${totalIngested} buckets`];
510
- if (totalSessionsSynced > 0) syncParts.push(`${totalSessionsSynced} sessions`);
511
- console.log(success(`已同步 ${syncParts.join(' · ')}`));
512
- if (!quiet && uploadedBytes > 0) {
513
- const elapsed = (Date.now() - uploadStartedAt) / 1000;
514
- console.log(dim(` 上传 ${formatBytes(uploadedBytes)}(已压缩),用时 ${formatDuration(elapsed)}`));
515
- }
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
+ }
516
720
 
517
- if (totalDroppedBuckets > 0) {
518
- const reasons = [];
519
- if (droppedSources.size > 0) {
520
- 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(';')})`));
521
730
  }
522
- if (totalDroppedUnknownModels > 0) reasons.push(`模型未知: ${totalDroppedUnknownModels}`);
523
- if (totalDroppedImplausible > 0) reasons.push(`超出合理范围: ${totalDroppedImplausible}`);
524
- if (reasons.length === 0) reasons.push('服务端拒绝');
525
- console.log(dim(` ${totalDroppedBuckets} buckets dropped (${reasons.join(';')})`));
526
- }
527
731
 
528
- if (totalProtectedBuckets > 0) {
529
- console.log(dim(` 服务端保留了 ${totalProtectedBuckets} 个更大的已有 bucket(本次较小快照未覆盖)`));
530
- }
732
+ if (totalProtectedBuckets > 0) {
733
+ console.log(dim(` 服务端保留了 ${totalProtectedBuckets} 个更大的已有 bucket(本次较小快照未覆盖)`));
734
+ }
531
735
 
532
- if (!quiet && totalSessionsSynced > 0) {
533
- const totalActive = allSessionsToSend.reduce((s, x) => s + x.activeSeconds, 0);
534
- const totalDuration = allSessionsToSend.reduce((s, x) => s + x.durationSeconds, 0);
535
- const totalMsgs = allSessionsToSend.reduce((s, x) => s + x.messageCount, 0);
536
- console.log(dim(` 活跃 ${formatDuration(totalActive)} / 总时长 ${formatDuration(totalDuration)} · ${totalMsgs} 条消息`));
537
- }
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
+ }
538
742
 
539
- if (!quiet) {
540
- console.log();
541
- console.log(`${arrow('前往 Dashboard 查看详情')} ${link(`${apiUrl}/usage`)}`);
542
- }
743
+ if (!quiet && final) printDashboardLink();
543
744
 
544
- return totalIngested;
545
- } catch (err) {
546
- if (err.message === 'UNAUTHORIZED') {
547
- 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
+ }
548
762
  if (throws) throw err;
549
763
  process.exit(1);
550
764
  }
551
- if (totalIngested > 0) {
552
- console.error(failure(`部分完成(已上传 ${totalIngested} buckets): ${err.message}`));
553
- } else {
554
- console.error(failure(`同步失败: ${err.message}`));
555
- }
556
- if (throws) throw err;
557
- process.exit(1);
558
765
  }
559
766
  }