@vibe-cafe/vibe-usage 0.10.26 → 0.10.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -80,7 +80,7 @@ npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short
80
80
  | MiMoCode | `$MIMOCODE_HOME/data/mimocode.db`, `$XDG_DATA_HOME/mimocode/mimocode.db`, or `~/.local/share/mimocode/mimocode.db` (SQLite; exact input, output, reasoning, and cache-read tokens from assistant messages; honors `MIMOCODE_DB`; cache-write tokens are included in input usage) |
81
81
  | Amp | `~/.local/share/amp/threads/`; cache-creation tokens are included in input usage |
82
82
  | Droid | `~/.factory/sessions/` |
83
- | DeepSeek Harness | `$DSH_HOME/sessions/` (default `~/.dsh`, fixture/relocation override: `VIBE_USAGE_DSH_SESSIONS`). Reads multi-frame Zstandard `session.jsonl.zstd` logs (built-in `node:zlib` zstd on Node ≥ 22.15, `zstd` CLI fallback) and plain `session.jsonl` logs. Usage comes from `assistant/message`: cache writes join uncached input, cache reads remain separate, and reasoning is split out of inclusive output. Fork/subagent history is de-duplicated from the immutable header: `parentSession` identifies the source and `seedLength` gives the exact leading event boundary. Inherited messages are skipped only when matching source seqs remain in the parent file; missing parents fail open. `session/end-seed` positions are not used because resumes can append the marker after real history. |
83
+ | DeepSeek Harness | `$DSH_HOME/sessions/` (default `~/.dsh`, fixture/relocation override: `VIBE_USAGE_DSH_SESSIONS`). Reads V0–V3 logs, including `session.v3.jsonl.zstd` from DSH `0.1.5-alpha.2`, with multi-frame Zstandard support (Node ≥ 22.15 built-in, `zstd` CLI fallback) and plain JSONL support. Each session uses its highest `session[.vN].jsonl[.zstd]` generation once, so frozen pre-migration logs are not double-counted. Usage comes from `assistant/message`: cache writes join uncached input, cache reads remain separate, and reasoning is split out of inclusive output. Fork history uses V0/V1 `seedLength` or V2/V3's last `session/end-seed` tagged `inherited: true`, and is skipped only when the parent copy confirms it; missing parents retain the sole local history. Unknown versions warn and protect sync state. |
84
84
  | Hermes | `$HERMES_HOME/state.db` (default `~/.hermes/state.db`) + `profiles/<name>/state.db` (SQLite, multi-profile). Cache writes join input; reasoning is separated from inclusive output. Usage is currently a cumulative session total attributed to session start: a session spanning several days does **not** yet provide an accurate daily breakdown. |
85
85
  | Kiro | Kiro CLI native event streams `~/.kiro/sessions/cli/*.jsonl` (estimated tokens from message text: input = prompt + tool results, output = reply + tool calls, reasoning = thinking, cacheRead = re-sent context; thinking-block signatures excluded). Falls back to `~/Library/Application Support/kiro-cli/data.sqlite3` / `~/.local/share/kiro-cli/data.sqlite3` + optional `~/.kiro_sessions/*.json` archives, then IDE `q-client.log` whole-credit deltas as `kiro-credits` (floored cumulative diff — the server stores token counts as bigint); legacy IDE `dev_data/devdata.sqlite` token telemetry is opt-in with `VIBE_USAGE_KIRO_LEGACY_TOKENS=1` |
86
86
  | Cline | Standalone `~/.cline/` plus `<host>/User/globalStorage/saoudrizwan.claude-dev/` across VSCode-fork hosts; migrated copies are deduplicated and empty leftover extension stores no longer count as installed |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.26",
3
+ "version": "0.10.28",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -7,13 +7,14 @@ import { aggregateToBuckets, extractSessions } from './aggregate.js';
7
7
 
8
8
  const SOURCE = 'dsh';
9
9
 
10
- // DeepSeek Harness session-log format version this parser understands. DeepSeek
11
- // Harness is currently in developer preview and is iterating rapidly — THERE
12
- // WILL BE COMPATIBILITY-BREAKING CHANGES. When the CLI bumps the header
13
- // `version` field, bump this constant (and the record-shape mapping below)
14
- // after re-checking the on-disk format instead of guessing against stale
15
- // assumptions.
16
- const SESSION_FORMAT_VERSION = 0;
10
+ // Verified against deepseek-ai/deepseek-harness tag dsh-v0.1.5-alpha.2:
11
+ // packages/session/session-format/src/filename.ts, session-format-v0-to-v1,
12
+ // session-format-v1-to-v2, session-format-v2-to-v3, and core/session/src/types.ts.
13
+ // V0/V1 use header.seedLength; V2/V3 use a tagged inherited end-seed marker.
14
+ // Re-check the format before accepting another version; never silently read a
15
+ // frozen predecessor when a newer generation is present.
16
+ const MAX_SESSION_FORMAT_VERSION = 3;
17
+ const SESSION_FILENAME = /^session(?:\.v([1-9][0-9]*))?\.jsonl(\.zstd)?$/;
17
18
 
18
19
  // Safety cap for a single session log. DSH stores many small zstd frames per
19
20
  // file; anything beyond this is either a runaway log or not a session file.
@@ -198,18 +199,10 @@ function isUserMessageRecord(rec) {
198
199
  * ... possibly a resumed/forked seed replay, then ...
199
200
  * {"type":"user/message"|"assistant/message","time":...,"data":{...}}
200
201
  *
201
- * DSH (developer preview) writes session/end-seed records in three situations:
202
- * right after session creation (empty seed), at each resume boundary, and
203
- * appended at the END of a file when that session becomes the seed for a
204
- * further resume. The marker's position is therefore NOT a replay boundary
205
- * a trailing marker would make "skip everything before the last marker"
206
- * discard the session's entire real history.
207
- *
208
- * Fork/subagent lineage is encoded separately in the immutable header.
209
- * `parentSession` identifies the source and `seedLength` is the exact number
210
- * of leading event seqs inherited from it. Only those seqs are skipped, and
211
- * only while the parent file is also present, so a missing/corrupt source
212
- * fails open instead of dropping the sole local copy of its usage.
202
+ * V0/V1 use header.seedLength. Their untagged end-seed markers can appear
203
+ * after real history and must never be treated as a replay boundary. V2/V3
204
+ * instead require isSeeded and use the LAST end-seed with data.inherited=true.
205
+ * Only a proven inherited prefix also present in the parent is skipped.
213
206
  *
214
207
  * Only user/message (source.kind === 'user') and assistant/message records
215
208
  * are kept in the model — they are the only records that produce usage
@@ -220,7 +213,7 @@ function isUserMessageRecord(rec) {
220
213
  * session_projcache totals DSH itself maintains), so reasoning is split out of
221
214
  * output before aggregation, like the Pi-family parsers.
222
215
  */
223
- function buildSessionModel(text) {
216
+ function buildSessionModel(text, fileVersion) {
224
217
  const lines = text.split('\n');
225
218
 
226
219
  let header = null;
@@ -240,16 +233,23 @@ function buildSessionModel(text) {
240
233
  if (!header || typeof header.id !== 'string' || header.id.length === 0) {
241
234
  throw new Error('missing session header record');
242
235
  }
243
- if (header.version !== SESSION_FORMAT_VERSION) {
236
+ if (!Number.isInteger(header.version) || header.version < 0 || header.version > MAX_SESSION_FORMAT_VERSION) {
244
237
  const error = new Error(
245
238
  'session ' + header.id + ' uses format version ' + header.version +
246
- ' (parser supports ' + SESSION_FORMAT_VERSION + ')',
239
+ ' (parser supports 0–' + MAX_SESSION_FORMAT_VERSION + ')',
247
240
  );
248
241
  error.code = 'UNSUPPORTED_FORMAT_VERSION';
249
242
  throw error;
250
243
  }
244
+ if (header.version !== fileVersion) {
245
+ throw new Error('session header format version ' + header.version + ' disagrees with filename version ' + fileVersion);
246
+ }
247
+ if (header.version >= 2 && typeof header.isSeeded !== 'boolean') {
248
+ throw new Error('format v' + header.version + ' session header lacks isSeeded');
249
+ }
251
250
 
252
251
  const messages = [];
252
+ let inheritedSeq = null;
253
253
  for (let i = 0; i < lines.length; i++) {
254
254
  if (lines[i].length === 0) continue;
255
255
  let rec;
@@ -263,8 +263,13 @@ function buildSessionModel(text) {
263
263
  if (timeMs == null) continue;
264
264
  const seq = Number.isSafeInteger(rec.seq) && rec.seq >= 0 ? rec.seq : null;
265
265
 
266
+ if (header.version >= 2 && rec.type === 'session/end-seed' && rec.data?.inherited === true) {
267
+ if (seq == null) throw new Error('inherited end-seed marker lacks a valid seq');
268
+ inheritedSeq = seq;
269
+ }
270
+
266
271
  if (isUserMessageRecord(rec)) {
267
- messages.push({ seq, role: 'user', timeMs, usage: null, model: null });
272
+ messages.push({ seq, messageId: messageId(rec.data.id), role: 'user', timeMs, usage: null, model: null });
268
273
  continue;
269
274
  }
270
275
  if (!isUsageRecord(rec)) continue;
@@ -273,6 +278,7 @@ function buildSessionModel(text) {
273
278
  // usage block is missing; the model keeps it so timing survives.
274
279
  messages.push({
275
280
  seq,
281
+ messageId: messageId(rec.data.message?.id),
276
282
  role: 'assistant',
277
283
  timeMs,
278
284
  usage: parseUsage(rec.data.usage),
@@ -283,13 +289,18 @@ function buildSessionModel(text) {
283
289
  });
284
290
  }
285
291
 
292
+ if (header.version >= 2 && header.isSeeded !== (inheritedSeq !== null)) {
293
+ throw new Error('isSeeded disagrees with the inherited end-seed marker');
294
+ }
295
+
286
296
  return {
297
+ formatVersion: header.version,
287
298
  sessionId: header.id,
288
299
  parentSessionId:
289
300
  typeof header.parentSession === 'string' && header.parentSession
290
301
  ? header.parentSession
291
302
  : null,
292
- seedLength:
303
+ seedLength: header.version >= 2 ? (inheritedSeq ?? 0) :
293
304
  Number.isSafeInteger(header.seedLength) && header.seedLength > 0
294
305
  ? header.seedLength
295
306
  : 0,
@@ -298,6 +309,10 @@ function buildSessionModel(text) {
298
309
  };
299
310
  }
300
311
 
312
+ function messageId(value) {
313
+ return typeof value === 'string' && value.length > 0 ? value : null;
314
+ }
315
+
301
316
  /** Record wall-clock time in epoch ms; null when absent/invalid. */
302
317
  function recordTimeMs(rec) {
303
318
  const t = rec.time;
@@ -337,9 +352,10 @@ function sameUsage(left, right) {
337
352
  /**
338
353
  * Number of leading child messages inherited from a parent seed.
339
354
  *
340
- * `header.seedLength` is DSH's durable fork-lineage boundary: event seqs below
341
- * it came from the parent, while later seqs belong to the child. Each skipped
342
- * message must still exist at the same seq in the selected parent copy.
355
+ * seedLength is normalized from the version's durable inheritance boundary.
356
+ * Each skipped message must still exist in the selected parent copy. Format
357
+ * migrations renumber events, but preserve existing message ids; mixed-format
358
+ * parents therefore need matching ids in order, never a token-count heuristic.
343
359
  * Missing, invalid, or divergent records fail open so usage is not lost.
344
360
  */
345
361
  function replaySkipCount(child, parent) {
@@ -347,21 +363,30 @@ function replaySkipCount(child, parent) {
347
363
  let parentIndex = 0;
348
364
  let previousSeq = -1;
349
365
  let count = 0;
366
+ const mixedVersions = child.formatVersion !== parent.formatVersion;
350
367
  for (const message of child.messages) {
351
368
  if (message.seq == null || message.seq <= previousSeq) return 0;
352
369
  previousSeq = message.seq;
353
370
  if (message.seq >= child.seedLength) break;
354
371
 
355
- while (
356
- parentIndex < parent.messages.length &&
357
- parent.messages[parentIndex].seq != null &&
358
- parent.messages[parentIndex].seq < message.seq
359
- ) {
360
- parentIndex++;
372
+ if (mixedVersions) {
373
+ if (!message.messageId) return 0;
374
+ while (parentIndex < parent.messages.length && parent.messages[parentIndex].messageId !== message.messageId) {
375
+ parentIndex++;
376
+ }
377
+ } else {
378
+ while (
379
+ parentIndex < parent.messages.length &&
380
+ parent.messages[parentIndex].seq != null &&
381
+ parent.messages[parentIndex].seq < message.seq
382
+ ) {
383
+ parentIndex++;
384
+ }
361
385
  }
362
386
  const source = parent.messages[parentIndex];
363
387
  if (
364
- source?.seq !== message.seq ||
388
+ !source ||
389
+ (!mixedVersions && source.seq !== message.seq) ||
365
390
  source.role !== message.role ||
366
391
  source.model !== message.model ||
367
392
  !sameUsage(source.usage, message.usage)
@@ -397,7 +422,27 @@ function modelToResult(model, skipCount) {
397
422
  return { entries, events };
398
423
  }
399
424
 
400
- /** List session log files under a DSH sessions root (session.jsonl[.zstd]). */
425
+ /** Pick the highest canonical generation per session, like DSH persistence. */
426
+ function selectSessionFile(sessionPath) {
427
+ let selected = null;
428
+ for (const entry of readdirSync(sessionPath, { withFileTypes: true })) {
429
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
430
+ const match = SESSION_FILENAME.exec(entry.name);
431
+ if (!match) continue;
432
+ const version = Number(match[1] || 0);
433
+ if (!Number.isSafeInteger(version)) throw new Error('invalid format version in ' + entry.name);
434
+ const compressed = Boolean(match[2]);
435
+ if (!selected || version > selected.version || (version === selected.version && compressed)) {
436
+ selected = { file: join(sessionPath, entry.name), version, compressed };
437
+ }
438
+ }
439
+ if (selected && selected.version > MAX_SESSION_FORMAT_VERSION) {
440
+ throw new Error('format version ' + selected.version + ' is not supported; update Vibe Usage');
441
+ }
442
+ return selected;
443
+ }
444
+
445
+ /** List session.jsonl[.zstd] (V0) and session.vN.jsonl[.zstd] (V1+). */
401
446
  function listSessionFiles(sessionsDir, onFailure) {
402
447
  const files = [];
403
448
  const projectKeys = readdirSync(sessionsDir, { withFileTypes: true })
@@ -419,22 +464,14 @@ function listSessionFiles(sessionsDir, onFailure) {
419
464
  for (const sessionDir of sessionDirs) {
420
465
  if (!sessionDir.isDirectory()) continue;
421
466
  const sessionPath = join(projectDir, sessionDir.name);
422
- for (const name of ['session.jsonl.zstd', 'session.jsonl']) {
423
- const file = join(sessionPath, name);
424
- try {
425
- if (statSync(file).isFile()) {
426
- files.push({ file, compressed: name.endsWith('.zstd') });
427
- break;
428
- }
429
- } catch (error) {
430
- if (error?.code !== 'ENOENT') {
431
- onFailure(
432
- 'dsh: cannot inspect ' + relative(sessionsDir, file) +
433
- ' (' + (error?.code || error?.message || 'stat failed') + ')',
434
- );
435
- break;
436
- }
437
- }
467
+ try {
468
+ const selected = selectSessionFile(sessionPath);
469
+ if (selected) files.push(selected);
470
+ } catch (error) {
471
+ onFailure(
472
+ 'dsh: cannot read session directory ' + relative(sessionsDir, sessionPath) +
473
+ ' (' + (error?.message || error?.code || 'read failed') + ')',
474
+ );
438
475
  }
439
476
  }
440
477
  }
@@ -444,17 +481,15 @@ function listSessionFiles(sessionsDir, onFailure) {
444
481
  /**
445
482
  * DeepSeek Harness (dsh) parser.
446
483
  *
447
- * Reads $DSH_HOME/sessions/<project-key>/session-<id>/session.jsonl.zstd
484
+ * Reads $DSH_HOME/sessions/<project-key>/<id>/session[.vN].jsonl[.zstd]
448
485
  * (default ~/.dsh, fixture/relocation override VIBE_USAGE_DSH_SESSIONS).
449
486
  * Zstandard session logs are multi-frame; node:zlib zstd (Node >= 22.15)
450
487
  * decodes one frame per call, so the buffer is walked frame-by-frame, with a
451
488
  * `zstd` CLI fallback for older Node.
452
489
  *
453
490
  * Replay handling: `header.parentSession` identifies a fork/subagent source,
454
- * and `header.seedLength` is the exact count of leading event seqs inherited
455
- * from it. Those records are skipped only when the parent file is also
456
- * present. Files without either field, and children whose parent is missing,
457
- * are counted in full. `session/end-seed` positions are never used.
491
+ * with a version-specific inheritance boundary (see buildSessionModel).
492
+ * Children whose parent is missing are counted in full.
458
493
  */
459
494
  export async function parse() {
460
495
  const sessionsDir = getDshSessionsDir();
@@ -485,10 +520,10 @@ export async function parse() {
485
520
  return result;
486
521
  }
487
522
 
488
- // sessionId -> most complete model (largest decompressed log wins, so a
489
- // session copied between project dirs is counted once).
523
+ // sessionId -> newest generation, then largest copy within that generation.
524
+ // Migrations can shrink a log by embedding chunks, so size alone is unsafe.
490
525
  const perSession = new Map();
491
- for (const { file, compressed } of files) {
526
+ for (const { file, compressed, version } of files) {
492
527
  let text;
493
528
  try {
494
529
  const stat = statSync(file);
@@ -510,7 +545,7 @@ export async function parse() {
510
545
 
511
546
  let model;
512
547
  try {
513
- model = buildSessionModel(text);
548
+ model = buildSessionModel(text, version);
514
549
  } catch (error) {
515
550
  recordFailure(
516
551
  'dsh: skipping ' + relative(process.cwd(), file) + ' (' + error.message + ')',
@@ -520,7 +555,8 @@ export async function parse() {
520
555
 
521
556
  const weight = text.length;
522
557
  const previous = perSession.get(model.sessionId);
523
- if (!previous || weight > previous.weight) {
558
+ if (!previous || model.formatVersion > previous.model.formatVersion ||
559
+ (model.formatVersion === previous.model.formatVersion && weight > previous.weight)) {
524
560
  perSession.set(model.sessionId, { model, weight });
525
561
  }
526
562
  }
package/src/sync.js CHANGED
@@ -15,6 +15,28 @@ import { success, failure, warn, arrow, link, dim } from './output.js';
15
15
  const BATCH_SIZE = 100;
16
16
  const SESSION_BATCH_SIZE = 500;
17
17
 
18
+ /** Coarse human duration: "45s" / "2m10s" / "1h 20m". */
19
+ export function formatDuration(seconds) {
20
+ const secs = Math.max(0, Math.round(seconds));
21
+ if (secs < 60) return `${secs}s`;
22
+ const h = Math.floor(secs / 3600);
23
+ const m = Math.floor((secs % 3600) / 60);
24
+ const s = secs % 60;
25
+ if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
26
+ return s > 0 ? `${m}m${s}s` : `${m}m`;
27
+ }
28
+
29
+ /**
30
+ * Remaining upload time from batches already finished. Measured, never guessed:
31
+ * returns null until at least one batch has completed, because a first-sync
32
+ * backlog and a steady-state trickle differ by three orders of magnitude and
33
+ * any a-priori rate would be wrong for one of them.
34
+ */
35
+ export function estimateRemainingSeconds({ elapsedMs, doneBatches, totalBatches }) {
36
+ if (!(elapsedMs > 0) || doneBatches < 1 || totalBatches <= doneBatches) return null;
37
+ return ((elapsedMs / doneBatches) * (totalBatches - doneBatches)) / 1000;
38
+ }
39
+
18
40
  function formatBytes(bytes) {
19
41
  if (bytes < 1024) return `${bytes}B`;
20
42
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
@@ -321,6 +343,20 @@ export async function runSync({
321
343
  const totalBatches = Math.max(bucketBatches, sessionBatches, 1);
322
344
  const syncClient = createSyncClient({ defaultSurface: surface, hostname: host });
323
345
 
346
+ // Say up front how much is about to go up. A first sync (or one that
347
+ // backfills after a parser was broken) can be thousands of batches, and with
348
+ // only a per-batch progress line the user cannot tell a long upload from a
349
+ // hung one -- which is exactly how a silently failing parser stayed hidden.
350
+ if (!quiet) {
351
+ const pending = [`${allBucketsToSend.length} buckets`];
352
+ if (allSessionsToSend.length > 0) pending.push(`${allSessionsToSend.length} sessions`);
353
+ const batchNote = totalBatches > 1 ? `,分 ${totalBatches} 批` : '';
354
+ console.log(dim(` 待上传 ${pending.join(' · ')}${batchNote}`));
355
+ }
356
+
357
+ let uploadedBytes = 0;
358
+ const uploadStartedAt = Date.now();
359
+
324
360
  try {
325
361
  for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) {
326
362
  const batch = allBucketsToSend.slice(batchIdx * BATCH_SIZE, (batchIdx + 1) * BATCH_SIZE);
@@ -328,13 +364,25 @@ export async function runSync({
328
364
  const batchNum = batchIdx + 1;
329
365
  const prefix = totalBatches > 1 ? ` ${dim(`[${batchNum}/${totalBatches}]`)} 上传中 ` : ' 上传中 ';
330
366
 
367
+ // Only measured batches feed the estimate, so the first batch shows no
368
+ // ETA rather than a made-up one.
369
+ const remaining = estimateRemainingSeconds({
370
+ elapsedMs: Date.now() - uploadStartedAt,
371
+ doneBatches: batchIdx,
372
+ totalBatches,
373
+ });
374
+ const etaNote = remaining === null ? '' : ` · 预计还需 ${formatDuration(remaining)}`;
375
+
376
+ let batchBytes = 0;
331
377
  const result = await ingest(apiUrl, config.apiKey, batch, {
332
378
  client: forBatch(syncClient, batchIdx, totalBatches),
333
379
  onProgress(sent, total) {
380
+ batchBytes = total;
334
381
  const pct = Math.round((sent / total) * 100);
335
- process.stdout.write(`\r${prefix}${dim(`${formatBytes(sent)}/${formatBytes(total)} (${pct}%)`)}\x1b[K`);
382
+ process.stdout.write(`\r${prefix}${dim(`${formatBytes(sent)}/${formatBytes(total)} (${pct}%)${etaNote}`)}\x1b[K`);
336
383
  },
337
384
  }, batchSessions.length > 0 ? batchSessions : undefined);
385
+ uploadedBytes += batchBytes;
338
386
  totalIngested += result.ingested ?? batch.length;
339
387
  totalSessionsSynced += result.sessions ?? 0;
340
388
  const batchUnknownSources = new Set(result.dropped?.unknownSources || []);
@@ -379,6 +427,10 @@ export async function runSync({
379
427
  const syncParts = [`${totalIngested} buckets`];
380
428
  if (totalSessionsSynced > 0) syncParts.push(`${totalSessionsSynced} sessions`);
381
429
  console.log(success(`已同步 ${syncParts.join(' · ')}`));
430
+ if (!quiet && uploadedBytes > 0) {
431
+ const elapsed = (Date.now() - uploadStartedAt) / 1000;
432
+ console.log(dim(` 上传 ${formatBytes(uploadedBytes)}(已压缩),用时 ${formatDuration(elapsed)}`));
433
+ }
382
434
 
383
435
  if (totalDroppedBuckets > 0) {
384
436
  const reasons = [];
@@ -399,13 +451,7 @@ export async function runSync({
399
451
  const totalActive = allSessionsToSend.reduce((s, x) => s + x.activeSeconds, 0);
400
452
  const totalDuration = allSessionsToSend.reduce((s, x) => s + x.durationSeconds, 0);
401
453
  const totalMsgs = allSessionsToSend.reduce((s, x) => s + x.messageCount, 0);
402
- const fmtTime = (secs) => {
403
- if (secs < 60) return `${secs}s`;
404
- const h = Math.floor(secs / 3600);
405
- const m = Math.floor((secs % 3600) / 60);
406
- return h > 0 ? (m > 0 ? `${h}h ${m}m` : `${h}h`) : `${m}m`;
407
- };
408
- console.log(dim(` 活跃 ${fmtTime(totalActive)} / 总时长 ${fmtTime(totalDuration)} · ${totalMsgs} 条消息`));
454
+ console.log(dim(` 活跃 ${formatDuration(totalActive)} / 总时长 ${formatDuration(totalDuration)} · ${totalMsgs} 条消息`));
409
455
  }
410
456
 
411
457
  if (!quiet) {