@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 +1 -1
- package/package.json +1 -1
- package/src/parsers/dsh.js +97 -61
- package/src/sync.js +54 -8
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
|
|
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
package/src/parsers/dsh.js
CHANGED
|
@@ -7,13 +7,14 @@ import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
|
7
7
|
|
|
8
8
|
const SOURCE = 'dsh';
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
const
|
|
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
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
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
|
|
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 ' +
|
|
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
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
parent.messages[parentIndex].
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
|
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
|
-
*
|
|
455
|
-
*
|
|
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 ->
|
|
489
|
-
//
|
|
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 ||
|
|
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
|
-
|
|
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) {
|