@vibe-cafe/vibe-usage 0.14.2 → 0.14.4
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 +2 -1
- package/package.json +1 -1
- package/src/output.js +59 -0
- package/src/parsers/antigravity-db.js +163 -93
- package/src/parsers/antigravity.js +167 -14
- package/src/sync.js +21 -2
package/README.md
CHANGED
|
@@ -104,7 +104,7 @@ Quota results are never uploaded or added to incremental sync state. Credential-
|
|
|
104
104
|
| Cline (CLI / SDK / Desktop app) | All variants write the same `~/.cline/data/sessions/*/*.messages.json` per-call metrics (the desktop app's manifest says `source: "desktop"`; its Electron userData holds only WebView caches), plus legacy `~/.cline/{,data/}state/taskHistory.json` and editor extension stores. Honors `CLINE_DIR`, `CLINE_DATA_DIR`, and `CLINE_SESSION_DATA_DIR`; copied history is deduplicated |
|
|
105
105
|
| Roo Code | `<host>/User/globalStorage/rooveterinaryinc.roo-cline/{tasks/_index.json,tasks/<id>/{history_item,ui_messages}.json}` (walks all VSCode-fork hosts) |
|
|
106
106
|
| Trae CLI | macOS: `~/Library/Caches/trae-cli/sessions/`; Windows: `%LOCALAPPDATA%/trae-cli/cache/sessions/`; Linux: `~/.cache/trae-cli/sessions/` (CLI telemetry only; Trae IDE/Trae Work chats are not supported). Token usage is summed per unique LLM call (`model.stream.eino`, plus `model.generate` failovers); nested duplicate spans that share a session `traceID` are not max-merged. `traces.jsonl` / `events.jsonl` are streamed line-by-line so a multi-hundred-MB events file cannot hit Node's string-length limit. |
|
|
107
|
-
| Antigravity | Scans App 2.0 `~/.gemini/antigravity/conversations/`, `agy` CLI `~/.gemini/antigravity-cli/conversations/`, and standalone IDE `~/.gemini/antigravity-ide/conversations/`. `.db` stores, including the same paths below explicitly added alternate Homes, are parsed offline (tokens, model, project, sessions). When Gemini blobs omit `chatStartMetadata.createdAt` or `modelDisplayName`, timestamps fall back to `steps.
|
|
107
|
+
| Antigravity | Scans App 2.0 `~/.gemini/antigravity/conversations/`, `agy` CLI `~/.gemini/antigravity-cli/conversations/`, and standalone IDE `~/.gemini/antigravity-ide/conversations/`. `.db` stores, including the same paths below explicitly added alternate Homes, are parsed offline (tokens, model, project, sessions). Usage is read from `gen_metadata` and from `steps.metadata` (newer builds keep the live usage on the step instead); the two are merged by response/message identity, keeping the richest payload so a call both tables saw is counted once. Output is the store's total (thinking is billed once as reasoning, not twice), and cache writes land in the 5m column. When Gemini blobs omit `chatStartMetadata.createdAt` or `modelDisplayName`, timestamps fall back to the step's own clock or the `steps.idx` join and model names to `responseModel`/step model info. `.pb` history in the default stores requires the corresponding App/IDE language server to be running; when several servers are open, the parser tries the others for unreadable conversations. Unavailable legacy history produces a warning and preserves prior sync state. |
|
|
108
108
|
| WorkBuddy | Current releases: `~/.workbuddy-ai/projects/**/*.jsonl`; legacy releases: `~/.workbuddy/projects/**/*.jsonl` (fixture/relocation override: `VIBE_USAGE_WORKBUDDY_DIRS`). Reads usage-bearing completed assistant and `function_call` records, using the routed model identifier exposed as `providerData.requestModelId`. Splits cache reads and reasoning from inclusive input/output totals, deduplicates copied record IDs, and extracts local session timing without uploading message content. |
|
|
109
109
|
| CodeBuddy | Tencent's CodeBuddy Code CLI (`@tencent-ai/codebuddy-code`). Home is `$CODEBUDDY_CONFIG_DIR` or `~/.codebuddy` (fixture/relocation override: `VIBE_USAGE_CODEBUDDY_DIRS`); transcripts at `projects/<compressed-cwd>/<sessionId>.jsonl`, including nested subagent directories. Only the API-message records' `message.usage` is read: `input_tokens` + `cache_creation_input_tokens` fold into input (the store writes no per-TTL breakdown), `cache_read_input_tokens` stays separate, `output_tokens` is the full completion. Retries and copies dedupe onto one logical call using `message.id`, `providerData.messageId`, or the record id (the CLI leaves `message.id` empty on some builds; `conversationRequestId` is a turn id, never a dedup key). The model comes from `message.model` and falls back to `providerData.requestModelId`/`model`, because successful calls can write `message.model: null`; routing-tier labels are namespaced (`codebuddy-auto`) so they can never match another vendor's price. Prompt text, thinking signatures, and tool payloads are never read. |
|
|
110
110
|
| ZCode | `~/.zcode/cli/db/db.sqlite` (SQLite; reads the `message` table for per-message tokens, model, and project `cwd`/`root`, joined to `session.directory`; fixture/relocation override: `VIBE_USAGE_ZCODE_DB`) |
|
|
@@ -144,6 +144,7 @@ To relabel old history, the maintainer needs the old bucket keys, raw per-tool t
|
|
|
144
144
|
- Uploads buckets + sessions to your vibecafe.ai dashboard (always gzip-compressed, ~94% smaller)
|
|
145
145
|
- Incremental upload: every parser emits a complete local snapshot, then only buckets/sessions that are new or changed since the last successful upload are sent — a quiet machine uploads nothing. Upload state remains in `~/.vibe-usage/state.json`; failed or still-indexing parsers retain their prior state, while deleted local logs are pruned. Deleting the state file triggers a one-time full re-upload, and `reset` clears it automatically after deleting cloud data
|
|
146
146
|
- Incremental Codex parsing: a versioned, disposable cache under `~/.vibe-usage/cache/codex/` stores per-rollout aggregate results and parser continuation state. Unchanged rollouts require no raw-log reads; an ordinary append reads only the new tail; forks, sub-agents, replacements, truncations, and failed safety checks fall back to the full correctness path. Multi-file sessions have a combined cache that rebuilds when any segment changes. A bounded rolling audit occasionally re-reads one historical file or continuation group. Very large first-time indexes checkpoint before the Mac app timeout and resume on the next sync instead of restarting
|
|
147
|
+
- Interactive `sync` shows a live status line (`正在读取上传设置` → `正在扫描本地日志 12/35 · 已用时 2m10s`) while it reads backend settings and scans/indexes local logs. A first sync, a cold Codex index rebuild, or a slow `cursor.com` export can take minutes, so that line is what separates a slow sync from a hung one. It is printed only on a TTY — the Mac and Windows apps read the piped stdout as the result text and must keep receiving plain output. Interrupting a sync is safe: finished batches are already committed, and the Codex index checkpoints and resumes on the next run
|
|
147
148
|
- The Codex parser cache contains derived aggregates and replay metadata, not raw prompt or response text. It is independent of upload state and can be deleted safely (the next sync rebuilds it). `reset` intentionally keeps it so the required full re-upload does not also require a full disk rescan. Set `VIBE_USAGE_CODEX_CACHE=0` to disable the optimization for diagnosis
|
|
148
149
|
- SQLite-backed tools are read via Node's built-in `node:sqlite` on Node ≥ 22.5 — no `sqlite3` binary needed (works on Windows out of the box); on older Node the CLI falls back to the system `sqlite3` executable
|
|
149
150
|
- Continuous syncing is on by default: the first run installs a background service (see [Background sync](#background-sync)); the [Vibe Usage Mac app](https://github.com/vibe-cafe/vibe-usage-app) is the menu-bar alternative
|
package/package.json
CHANGED
package/src/output.js
CHANGED
|
@@ -52,6 +52,65 @@ export function hint(msg) {
|
|
|
52
52
|
console.log(dim(`提示: ${msg}`));
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* One redrawn status line for a phase that would otherwise leave the terminal
|
|
57
|
+
* blank for minutes: the settings round-trip (a timing-out endpoint re-trying),
|
|
58
|
+
* the local log scan, and a cold Codex index build. Without it the sync header
|
|
59
|
+
* is the last thing on screen, and "still working" is indistinguishable from
|
|
60
|
+
* "hung" — exactly how a slow first sync reads.
|
|
61
|
+
*
|
|
62
|
+
* TTY-only for the same reason as hint(): the Mac (`SyncEngine.swift`) and
|
|
63
|
+
* Windows (`sync_engine.rs`) apps run `sync` through a pipe and treat stdout as
|
|
64
|
+
* the result text, so a pipe must never receive these lines.
|
|
65
|
+
* `VIBE_USAGE_FORCE_PROGRESS=1` forces them on for tests, and
|
|
66
|
+
* `VIBE_USAGE_PROGRESS_INTERVAL_MS` overrides the redraw interval.
|
|
67
|
+
*
|
|
68
|
+
* `label` is a function so the caller owns the wording and its elapsed-time
|
|
69
|
+
* text; it is re-read on every redraw. The interval is unref'ed so a phase that
|
|
70
|
+
* never reaches `stop()` cannot hold the process open.
|
|
71
|
+
*/
|
|
72
|
+
export function liveStatus({ enabled = true, intervalMs } = {}) {
|
|
73
|
+
const configuredInterval = Number(process.env.VIBE_USAGE_PROGRESS_INTERVAL_MS);
|
|
74
|
+
const tickMs = intervalMs ?? (configuredInterval > 0 ? configuredInterval : 5000);
|
|
75
|
+
const active = enabled
|
|
76
|
+
&& (!!process.stdout.isTTY || process.env.VIBE_USAGE_FORCE_PROGRESS === '1');
|
|
77
|
+
let label = null;
|
|
78
|
+
let timer = null;
|
|
79
|
+
let startedAt = 0;
|
|
80
|
+
|
|
81
|
+
function draw() {
|
|
82
|
+
if (label === null) return;
|
|
83
|
+
process.stdout.write(`\r${dim(` ${label(Date.now() - startedAt)}`)}\x1b[K`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
/** Show the line, or retarget it to the next phase. */
|
|
88
|
+
start(nextLabel) {
|
|
89
|
+
if (!active) return;
|
|
90
|
+
label = nextLabel;
|
|
91
|
+
startedAt = Date.now();
|
|
92
|
+
draw();
|
|
93
|
+
if (timer === null) {
|
|
94
|
+
timer = setInterval(draw, tickMs);
|
|
95
|
+
unrefTimer(timer);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
/** Erase the line before regular output continues on it. */
|
|
99
|
+
stop() {
|
|
100
|
+
if (!active || label === null) return;
|
|
101
|
+
clearInterval(timer);
|
|
102
|
+
timer = null;
|
|
103
|
+
label = null;
|
|
104
|
+
process.stdout.write('\r\x1b[K');
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Bun's timer objects and Node's both expose unref; tests run on either. */
|
|
110
|
+
function unrefTimer(timer) {
|
|
111
|
+
if (typeof timer?.unref === 'function') timer.unref();
|
|
112
|
+
}
|
|
113
|
+
|
|
55
114
|
/** Print a blank line. */
|
|
56
115
|
export const nl = () => console.log();
|
|
57
116
|
|
|
@@ -105,7 +105,73 @@ function firstMessage(fields, num) {
|
|
|
105
105
|
return b ? decodeMessage(b) : undefined;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
// ──
|
|
108
|
+
// ── Usage message decoding ────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Decode one ModelUsage message. The identical wire shape appears in three
|
|
112
|
+
* carriers — `gen_metadata` chatModel.usage (1.4), that message's per-attempt
|
|
113
|
+
* retry entries (1.17.2), and `steps.metadata` usage (9) with its retries
|
|
114
|
+
* (28.2) — so every carrier shares this decoder:
|
|
115
|
+
*
|
|
116
|
+
* modelId = 1
|
|
117
|
+
* inputTokens = 2
|
|
118
|
+
* totalOutputTokens = 3 (visible + thinking)
|
|
119
|
+
* cacheCreation = 4
|
|
120
|
+
* cacheReadTokens = 5
|
|
121
|
+
* provider = 6
|
|
122
|
+
* messageId = 7
|
|
123
|
+
* thinkingOutput = 9
|
|
124
|
+
* visibleOutput = 10
|
|
125
|
+
* responseId = 11
|
|
126
|
+
* providerMessageId = 12
|
|
127
|
+
*
|
|
128
|
+
* Field 3 is the *total* output: verified on a live store where 3 = 9 + 10 on
|
|
129
|
+
* every usage row. The bucket schema bills output + reasoning at the output
|
|
130
|
+
* rate, so thinking must leave `outputTokens` here — leaving it inside counts
|
|
131
|
+
* every thinking token a second time in 总 Token and once more in cost.
|
|
132
|
+
*/
|
|
133
|
+
function parseUsageMessage(usage) {
|
|
134
|
+
const inputTokens = firstVarint(usage, 2) || 0;
|
|
135
|
+
const totalOutputTokens = firstVarint(usage, 3) || 0;
|
|
136
|
+
const cacheCreationTokens = firstVarint(usage, 4) || 0;
|
|
137
|
+
const cacheReadTokens = firstVarint(usage, 5) || 0;
|
|
138
|
+
const thinkingOutputTokens = firstVarint(usage, 9) || 0;
|
|
139
|
+
const visibleOutputTokens = firstVarint(usage, 10);
|
|
140
|
+
const outputTokens = visibleOutputTokens === undefined
|
|
141
|
+
? Math.max(0, totalOutputTokens - thinkingOutputTokens)
|
|
142
|
+
: Math.min(visibleOutputTokens, totalOutputTokens);
|
|
143
|
+
return {
|
|
144
|
+
modelId: firstVarint(usage, 1) || 0,
|
|
145
|
+
inputTokens,
|
|
146
|
+
outputTokens,
|
|
147
|
+
cacheCreationTokens,
|
|
148
|
+
cacheReadTokens,
|
|
149
|
+
// Older blobs omit the visible/reasoning split; the remainder after the
|
|
150
|
+
// visible part is thinking, and vice versa, so the two always sum to 3.
|
|
151
|
+
thinkingOutputTokens: Math.max(thinkingOutputTokens, totalOutputTokens - outputTokens),
|
|
152
|
+
messageId: firstString(usage, 7) || '',
|
|
153
|
+
responseId: firstString(usage, 11) || '',
|
|
154
|
+
providerMessageId: firstString(usage, 12) || '',
|
|
155
|
+
totalOutputTokens,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** True when a decoded usage message carries any real token count. */
|
|
160
|
+
export function hasTokenUsage(usage) {
|
|
161
|
+
return Boolean(usage && (usage.inputTokens || usage.totalOutputTokens ||
|
|
162
|
+
usage.cacheCreationTokens || usage.cacheReadTokens || usage.thinkingOutputTokens));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Decode the usage of every retry entry carried at `fieldNumber` (2 = usage). */
|
|
166
|
+
function parseRetryUsages(fields, fieldNumber) {
|
|
167
|
+
const out = [];
|
|
168
|
+
for (const entry of fields.get(fieldNumber) || []) {
|
|
169
|
+
if (entry.wireType !== 2) continue;
|
|
170
|
+
const usage = firstMessage(decodeMessage(entry.value), 2);
|
|
171
|
+
if (usage) out.push(parseUsageMessage(usage));
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
109
175
|
|
|
110
176
|
/**
|
|
111
177
|
* Parse one gen_metadata blob into a normalized usage record, or null if it
|
|
@@ -113,7 +179,8 @@ function firstMessage(fields, num) {
|
|
|
113
179
|
*
|
|
114
180
|
* @param {Buffer} buf raw protobuf bytes of a GeneratorMetadata row
|
|
115
181
|
* @returns {{inputTokens, outputTokens, cacheReadTokens, thinkingOutputTokens,
|
|
116
|
-
* responseId, timestamp: Date|null, displayName, responseModel
|
|
182
|
+
* responseId, timestamp: Date|null, displayName, responseModel,
|
|
183
|
+
* retryUsages}|null}
|
|
117
184
|
*/
|
|
118
185
|
export function parseGenMetadataBlob(buf) {
|
|
119
186
|
const chatModel = firstMessage(decodeMessage(buf), 1);
|
|
@@ -122,51 +189,75 @@ export function parseGenMetadataBlob(buf) {
|
|
|
122
189
|
const usage = firstMessage(chatModel, 4);
|
|
123
190
|
if (!usage) return null;
|
|
124
191
|
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
const cacheReadTokens = firstVarint(usage, 5) || 0;
|
|
128
|
-
const thinkingOutputTokens = firstVarint(usage, 9) || 0;
|
|
129
|
-
const responseId = firstString(usage, 11) || '';
|
|
130
|
-
|
|
131
|
-
// Skip rows with no real usage (errors, planning-only steps).
|
|
132
|
-
if (!inputTokens && !outputTokens && !cacheReadTokens && !thinkingOutputTokens) {
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
192
|
+
const parsed = parseUsageMessage(usage);
|
|
193
|
+
if (!hasTokenUsage(parsed)) return null;
|
|
135
194
|
|
|
136
195
|
const chatStartMetadata = firstMessage(chatModel, 9);
|
|
137
196
|
const createdAt = chatStartMetadata ? firstMessage(chatStartMetadata, 4) : undefined;
|
|
138
197
|
const seconds = createdAt ? firstVarint(createdAt, 1) : undefined;
|
|
139
|
-
const timestamp = seconds ? new Date(seconds * 1000) : null;
|
|
140
198
|
|
|
141
199
|
return {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
cacheReadTokens,
|
|
145
|
-
thinkingOutputTokens,
|
|
146
|
-
responseId,
|
|
147
|
-
timestamp,
|
|
200
|
+
...parsed,
|
|
201
|
+
timestamp: seconds ? new Date(seconds * 1000) : null,
|
|
148
202
|
displayName: firstString(chatModel, 21) || '',
|
|
149
203
|
responseModel: firstString(chatModel, 19) || '',
|
|
204
|
+
retryUsages: parseRetryUsages(chatModel, 17),
|
|
150
205
|
};
|
|
151
206
|
}
|
|
152
207
|
|
|
153
208
|
/**
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
209
|
+
* Decode the usage a `steps.metadata` blob carries itself. Newer Antigravity
|
|
210
|
+
* builds stop writing usage into `gen_metadata` while the step keeps it (field
|
|
211
|
+
* 9), so a store can have a live step stream with no generator usage at all —
|
|
212
|
+
* the exact shape where the parser used to go silently empty.
|
|
157
213
|
*
|
|
158
|
-
* @
|
|
159
|
-
* @returns {Date|null}
|
|
214
|
+
* @returns {{usage, retryUsages, modelName, modelId}} usage/entries may be null
|
|
160
215
|
*/
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
|
|
216
|
+
function decodeStepUsage(meta) {
|
|
217
|
+
const usage = firstMessage(meta, 9);
|
|
218
|
+
const modelInfo = firstMessage(meta, 24);
|
|
219
|
+
return {
|
|
220
|
+
usage: usage ? parseUsageMessage(usage) : null,
|
|
221
|
+
retryUsages: parseRetryUsages(meta, 28),
|
|
222
|
+
modelName: modelInfo ? (firstString(modelInfo, 12) || firstString(modelInfo, 8) || '') : '',
|
|
223
|
+
modelId: modelInfo ? (firstVarint(modelInfo, 1) || 0) : 0,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Parse one steps.metadata blob's own usage record (field 9 + retries). */
|
|
228
|
+
export function parseStepUsageBlob(buf) {
|
|
229
|
+
return decodeStepUsage(decodeMessage(buf));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Extract a step's own clock, regardless of step source. Field 1.1 is the
|
|
234
|
+
* createdAt this parser has always used (Gemini 3.7 CLI gen_metadata blobs no
|
|
235
|
+
* longer carry chatStartMetadata, so the step is the remaining clock); field 8
|
|
236
|
+
* carries a completion timestamp on some builds and only fills in when 1.1 is
|
|
237
|
+
* absent, so every timestamp that resolved before keeps its exact second.
|
|
238
|
+
*/
|
|
239
|
+
function stepTimestamp(meta) {
|
|
240
|
+
let seconds;
|
|
241
|
+
for (const fieldNumber of [1, 8]) {
|
|
242
|
+
const message = firstMessage(meta, fieldNumber);
|
|
243
|
+
seconds = message ? firstVarint(message, 1) : undefined;
|
|
244
|
+
if (seconds) break;
|
|
245
|
+
}
|
|
165
246
|
if (!seconds) return null;
|
|
166
247
|
const timestamp = new Date(seconds * 1000);
|
|
167
248
|
return Number.isNaN(timestamp.getTime()) ? null : timestamp;
|
|
168
249
|
}
|
|
169
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Extract a steps.metadata blob's own timestamp (see {@link stepTimestamp}).
|
|
253
|
+
*
|
|
254
|
+
* @param {Buffer} buf
|
|
255
|
+
* @returns {Date|null}
|
|
256
|
+
*/
|
|
257
|
+
export function parseStepTimestamp(buf) {
|
|
258
|
+
return stepTimestamp(decodeMessage(buf));
|
|
259
|
+
}
|
|
260
|
+
|
|
170
261
|
/**
|
|
171
262
|
* Prefer the blob's own createdAt; if Gemini 3.7 omitted it, use the step
|
|
172
263
|
* with the same idx. idx-join is exact on current CLI stores (every
|
|
@@ -244,11 +335,24 @@ export function readDbUsageRecords(conversationsDir, cascadeId, { strict = false
|
|
|
244
335
|
}
|
|
245
336
|
|
|
246
337
|
/**
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
338
|
+
* Read every `steps` row in one pass and derive everything the parser needs
|
|
339
|
+
* from that same scan:
|
|
340
|
+
*
|
|
341
|
+
* - `usages` — the usage each step carries itself (field 9, plus the field 28
|
|
342
|
+
* retry entries). Newer Antigravity builds keep the live usage here instead
|
|
343
|
+
* of `gen_metadata`, so a store whose generator rows carry no usage still
|
|
344
|
+
* has its tokens on the step.
|
|
345
|
+
* - `timestamps` — idx → clock for every step that has one, including the
|
|
346
|
+
* system/tool steps that produce no event. Used to timestamp gen_metadata
|
|
347
|
+
* rows that no longer embed chatStartMetadata.
|
|
348
|
+
* - `events` — user/assistant turns, chronological by idx, for session timing.
|
|
349
|
+
*
|
|
350
|
+
* Step source enum in steps.metadata field 3 (behavior-verified against payload
|
|
351
|
+
* contents, since it mirrors the RPC's CORTEX_STEP_SOURCE_*): 4 = user turn,
|
|
352
|
+
* 2 = model turn. Everything else (system, tool, unspecified) contributes a
|
|
353
|
+
* timestamp and nothing else.
|
|
250
354
|
*/
|
|
251
|
-
export function
|
|
355
|
+
export function readDbStepRows(conversationsDir, cascadeId, { strict = false } = {}) {
|
|
252
356
|
let rows;
|
|
253
357
|
try {
|
|
254
358
|
rows = queryCascadeDb(
|
|
@@ -258,22 +362,41 @@ export function readDbStepTimestamps(conversationsDir, cascadeId, { strict = fal
|
|
|
258
362
|
);
|
|
259
363
|
} catch (err) {
|
|
260
364
|
if (isSqliteUnavailableError(err) || strict) throw err;
|
|
261
|
-
return new Map();
|
|
365
|
+
return { usages: [], timestamps: new Map(), events: [] };
|
|
262
366
|
}
|
|
263
|
-
const
|
|
367
|
+
const usages = [];
|
|
368
|
+
const timestamps = new Map();
|
|
369
|
+
const events = [];
|
|
264
370
|
for (const row of rows) {
|
|
265
371
|
if (!row.h) continue;
|
|
266
|
-
let
|
|
372
|
+
let meta;
|
|
267
373
|
try {
|
|
268
|
-
|
|
374
|
+
meta = decodeMessage(Buffer.from(row.h, 'hex'));
|
|
269
375
|
} catch {
|
|
270
|
-
continue;
|
|
376
|
+
continue; // one malformed blob must not kill the rest
|
|
271
377
|
}
|
|
272
|
-
if (!ts) continue;
|
|
273
378
|
const idx = Number(row.idx);
|
|
274
|
-
|
|
379
|
+
const timestamp = stepTimestamp(meta);
|
|
380
|
+
if (timestamp && Number.isFinite(idx)) timestamps.set(idx, timestamp);
|
|
381
|
+
|
|
382
|
+
const source = firstVarint(meta, 3);
|
|
383
|
+
const role = source === STEP_SOURCE_USER
|
|
384
|
+
? 'user'
|
|
385
|
+
: source === STEP_SOURCE_MODEL ? 'assistant' : null;
|
|
386
|
+
if (role && timestamp) events.push({ role, timestamp });
|
|
387
|
+
|
|
388
|
+
const stepUsage = decodeStepUsage(meta);
|
|
389
|
+
if (!stepUsage.usage && stepUsage.retryUsages.length === 0) continue;
|
|
390
|
+
usages.push({
|
|
391
|
+
idx: Number.isFinite(idx) ? idx : null,
|
|
392
|
+
timestamp,
|
|
393
|
+
modelName: stepUsage.modelName,
|
|
394
|
+
modelId: stepUsage.modelId,
|
|
395
|
+
usage: stepUsage.usage,
|
|
396
|
+
retryUsages: stepUsage.retryUsages,
|
|
397
|
+
});
|
|
275
398
|
}
|
|
276
|
-
return
|
|
399
|
+
return { usages, timestamps, events };
|
|
277
400
|
}
|
|
278
401
|
|
|
279
402
|
/**
|
|
@@ -304,56 +427,3 @@ export function readDbWorkspaceUri(conversationsDir, cascadeId) {
|
|
|
304
427
|
// 2 = model turn. Everything else (system, tool, unspecified) is skipped.
|
|
305
428
|
const STEP_SOURCE_USER = 4;
|
|
306
429
|
const STEP_SOURCE_MODEL = 2;
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Parse one steps.metadata blob into a session-timing event, or null if the
|
|
310
|
-
* step is neither a user nor a model turn (system/tool/unspecified). createdAt
|
|
311
|
-
* is a Timestamp at field 1 (seconds at 1.1); source enum is field 3.
|
|
312
|
-
*
|
|
313
|
-
* @param {Buffer} buf
|
|
314
|
-
* @returns {{role:'user'|'assistant', timestamp: Date}|null}
|
|
315
|
-
*/
|
|
316
|
-
export function parseStepMetadata(buf) {
|
|
317
|
-
const meta = decodeMessage(buf);
|
|
318
|
-
const source = firstVarint(meta, 3);
|
|
319
|
-
let role;
|
|
320
|
-
if (source === STEP_SOURCE_USER) role = 'user';
|
|
321
|
-
else if (source === STEP_SOURCE_MODEL) role = 'assistant';
|
|
322
|
-
else return null;
|
|
323
|
-
|
|
324
|
-
const createdAt = firstMessage(meta, 1);
|
|
325
|
-
const seconds = createdAt ? firstVarint(createdAt, 1) : undefined;
|
|
326
|
-
if (!seconds) return null;
|
|
327
|
-
|
|
328
|
-
return { role, timestamp: new Date(seconds * 1000) };
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Read session timing events (user/assistant turns) for a cascade from the
|
|
333
|
-
* steps table, chronological by idx.
|
|
334
|
-
*/
|
|
335
|
-
export function readDbSessionEvents(conversationsDir, cascadeId, { strict = false } = {}) {
|
|
336
|
-
let rows;
|
|
337
|
-
try {
|
|
338
|
-
rows = queryCascadeDb(
|
|
339
|
-
conversationsDir,
|
|
340
|
-
cascadeId,
|
|
341
|
-
'SELECT hex(metadata) AS h FROM steps WHERE metadata IS NOT NULL ORDER BY idx',
|
|
342
|
-
);
|
|
343
|
-
} catch (err) {
|
|
344
|
-
if (isSqliteUnavailableError(err) || strict) throw err;
|
|
345
|
-
return [];
|
|
346
|
-
}
|
|
347
|
-
const events = [];
|
|
348
|
-
for (const row of rows) {
|
|
349
|
-
if (!row.h) continue;
|
|
350
|
-
let ev;
|
|
351
|
-
try {
|
|
352
|
-
ev = parseStepMetadata(Buffer.from(row.h, 'hex'));
|
|
353
|
-
} catch {
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
if (ev) events.push(ev);
|
|
357
|
-
}
|
|
358
|
-
return events;
|
|
359
|
-
}
|
|
@@ -4,7 +4,7 @@ import { delimiter, join } from 'node:path';
|
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { antigravityConversationDirs, normalizeExtraRoot } from '../extra-roots.js';
|
|
6
6
|
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
7
|
-
import { listDbCascades, readDbUsageRecords, readDbWorkspaceUri,
|
|
7
|
+
import { hasTokenUsage, listDbCascades, readDbUsageRecords, readDbWorkspaceUri, readDbStepRows, resolveUsageTimestamp } from './antigravity-db.js';
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
|
|
@@ -310,13 +310,152 @@ function listPbCascades(conversationsDir) {
|
|
|
310
310
|
|
|
311
311
|
// ── Main parse ───────────────────────────────────────────────────────
|
|
312
312
|
|
|
313
|
-
|
|
313
|
+
// Model ids (`GeneratorMetadata` chatModel field 3 / `ModelUsage` field 1) for
|
|
314
|
+
// the models current builds serve. Cross-verified against the ccusage
|
|
315
|
+
// Antigravity adapter's id table, which decodes the same wire format; an id
|
|
316
|
+
// outside this table falls through to the cascade's last named model rather
|
|
317
|
+
// than inventing a placeholder nobody can price.
|
|
318
|
+
const MODEL_ID_NAMES = {
|
|
319
|
+
246: 'gemini-2.5-pro',
|
|
320
|
+
312: 'gemini-2.5-flash',
|
|
321
|
+
313: 'gemini-2.5-flash-thinking',
|
|
322
|
+
329: 'gemini-2.5-flash-thinking',
|
|
323
|
+
330: 'gemini-2.5-flash-lite',
|
|
324
|
+
281: 'claude-4-sonnet',
|
|
325
|
+
282: 'claude-4-sonnet',
|
|
326
|
+
290: 'claude-4-opus',
|
|
327
|
+
291: 'claude-4-opus',
|
|
328
|
+
333: 'claude-4.5-sonnet',
|
|
329
|
+
334: 'claude-4.5-sonnet',
|
|
330
|
+
340: 'claude-4.5-haiku',
|
|
331
|
+
341: 'claude-4.5-haiku',
|
|
332
|
+
342: 'gpt-oss-120b-medium',
|
|
333
|
+
1071: 'gemini-3.6-flash-high',
|
|
334
|
+
1072: 'gemini-3.6-flash-medium',
|
|
335
|
+
1073: 'gemini-3.6-flash-low',
|
|
336
|
+
1298: 'gemini-3.7-flash-high',
|
|
337
|
+
1299: 'gemini-3.7-flash-medium',
|
|
338
|
+
1300: 'gemini-3.7-flash-low',
|
|
339
|
+
1318: 'gemini-3.8-flash-high',
|
|
340
|
+
1319: 'gemini-3.8-flash-medium',
|
|
341
|
+
1320: 'gemini-3.8-flash-low',
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/** Model name for a .db usage record: display name → slug → step model → id. */
|
|
314
345
|
function modelFromRecord(rec) {
|
|
315
346
|
if (rec.displayName) return rec.displayName;
|
|
316
347
|
if (rec.responseModel) return normalizeModel(rec.responseModel);
|
|
348
|
+
if (rec.modelName) return normalizeModel(rec.modelName);
|
|
349
|
+
if (rec.modelId && MODEL_ID_NAMES[rec.modelId]) return MODEL_ID_NAMES[rec.modelId];
|
|
317
350
|
return 'unknown';
|
|
318
351
|
}
|
|
319
352
|
|
|
353
|
+
/** Identity keys naming one provider call; any shared key links two records. */
|
|
354
|
+
function usageIdentityKeys(rec) {
|
|
355
|
+
const keys = [];
|
|
356
|
+
if (rec.responseId) keys.push(`response:${rec.responseId}`);
|
|
357
|
+
if (rec.providerMessageId) keys.push(`provider:${rec.providerMessageId}`);
|
|
358
|
+
if (rec.messageId) keys.push(`message:${rec.messageId}`);
|
|
359
|
+
return keys;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Ranking used to keep the richest copy of one call. */
|
|
363
|
+
function usageScore(rec) {
|
|
364
|
+
return (rec.inputTokens || 0) + (rec.totalOutputTokens || 0) +
|
|
365
|
+
(rec.cacheReadTokens || 0) + (rec.cacheCreationTokens || 0);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Keep the richest payload, borrowing the loser's clock and model name. */
|
|
369
|
+
function richerUsageRecord(a, b) {
|
|
370
|
+
const [rich, poor] = usageScore(b) > usageScore(a) ? [b, a] : [a, b];
|
|
371
|
+
return {
|
|
372
|
+
...rich,
|
|
373
|
+
timestamp: rich.timestamp ?? poor.timestamp ?? null,
|
|
374
|
+
displayName: rich.displayName || poor.displayName || '',
|
|
375
|
+
responseModel: rich.responseModel || poor.responseModel || '',
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Merge one cascade's usage records from the two tables that can carry them.
|
|
381
|
+
*
|
|
382
|
+
* Older stores write every call into `gen_metadata`; newer builds keep the live
|
|
383
|
+
* usage on the *step* instead, so a cascade can have a moving step stream and no
|
|
384
|
+
* generator usage at all — the shape where this parser used to go silently
|
|
385
|
+
* empty while its session timing kept advancing. Both carriers name a call by
|
|
386
|
+
* the provider's response id and, on some builds, by message ids only, so two
|
|
387
|
+
* records are linked by *any* shared identity and the richest payload wins;
|
|
388
|
+
* counting both would double-bill every turn that both tables saw.
|
|
389
|
+
*
|
|
390
|
+
* A step record with no identity at all can only be proven new when the cascade
|
|
391
|
+
* has no generator usage for it to duplicate; next to generator usage it is
|
|
392
|
+
* dropped rather than risk the same turn twice.
|
|
393
|
+
*/
|
|
394
|
+
function mergeCascadeUsage(genRecords, stepRows) {
|
|
395
|
+
const groups = new Set();
|
|
396
|
+
const byIdentity = new Map();
|
|
397
|
+
|
|
398
|
+
const merge = (record) => {
|
|
399
|
+
const keys = usageIdentityKeys(record);
|
|
400
|
+
const hits = new Set();
|
|
401
|
+
for (const key of keys) {
|
|
402
|
+
const group = byIdentity.get(key);
|
|
403
|
+
if (group) hits.add(group);
|
|
404
|
+
}
|
|
405
|
+
let target;
|
|
406
|
+
if (hits.size === 0) {
|
|
407
|
+
target = { rec: record, keys: new Set() };
|
|
408
|
+
groups.add(target);
|
|
409
|
+
} else {
|
|
410
|
+
target = hits.values().next().value;
|
|
411
|
+
for (const other of hits) {
|
|
412
|
+
if (other === target) continue;
|
|
413
|
+
target.rec = richerUsageRecord(target.rec, other.rec);
|
|
414
|
+
for (const key of other.keys) {
|
|
415
|
+
byIdentity.set(key, target);
|
|
416
|
+
target.keys.add(key);
|
|
417
|
+
}
|
|
418
|
+
groups.delete(other);
|
|
419
|
+
}
|
|
420
|
+
target.rec = richerUsageRecord(target.rec, record);
|
|
421
|
+
}
|
|
422
|
+
for (const key of keys) {
|
|
423
|
+
byIdentity.set(key, target);
|
|
424
|
+
target.keys.add(key);
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
for (const rec of genRecords) {
|
|
429
|
+
merge(rec);
|
|
430
|
+
// Retry entries repeat the call they belong to (same response id), so they
|
|
431
|
+
// collapse into it; a genuine extra attempt keeps its own id and counts.
|
|
432
|
+
for (const retry of rec.retryUsages || []) merge({ ...retry, timestamp: rec.timestamp });
|
|
433
|
+
}
|
|
434
|
+
const stepUsageStandsAlone = genRecords.length === 0;
|
|
435
|
+
for (const row of stepRows.usages) {
|
|
436
|
+
for (const usage of [row.usage, ...row.retryUsages]) {
|
|
437
|
+
if (!hasTokenUsage(usage)) continue;
|
|
438
|
+
if (!stepUsageStandsAlone && usageIdentityKeys(usage).length === 0) continue;
|
|
439
|
+
merge({
|
|
440
|
+
...usage,
|
|
441
|
+
timestamp: row.timestamp,
|
|
442
|
+
displayName: '',
|
|
443
|
+
responseModel: '',
|
|
444
|
+
modelName: row.modelName,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Records merged in first-seen order; resolve each model, carrying the
|
|
450
|
+
// cascade's last named model onto records that only carry an id.
|
|
451
|
+
let carriedModel = '';
|
|
452
|
+
return [...groups].map((group) => {
|
|
453
|
+
const model = modelFromRecord(group.rec);
|
|
454
|
+
if (model !== 'unknown') carriedModel = model;
|
|
455
|
+
return { ...group.rec, model: model === 'unknown' ? carriedModel || 'unknown' : model };
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
320
459
|
export async function parse({ extraRoots = [] } = {}) {
|
|
321
460
|
const entries = [];
|
|
322
461
|
const sessionEvents = [];
|
|
@@ -396,11 +535,13 @@ export async function parse({ extraRoots = [] } = {}) {
|
|
|
396
535
|
if (selected && selected.dir !== dir) continue;
|
|
397
536
|
try {
|
|
398
537
|
const options = { strict };
|
|
399
|
-
|
|
538
|
+
// One steps scan feeds all of it: the usage newer builds keep on the step
|
|
539
|
+
// (which `gen_metadata` no longer carries), the idx → clock fallback for
|
|
540
|
+
// generator rows that lost their own timestamp, and session events.
|
|
541
|
+
const stepRows = readDbStepRows(dir, cascadeId, options);
|
|
542
|
+
const genRecords = readDbUsageRecords(dir, cascadeId, options);
|
|
400
543
|
const project = projectFromUri(readDbWorkspaceUri(dir, cascadeId)) || 'unknown';
|
|
401
|
-
const
|
|
402
|
-
? readDbStepTimestamps(dir, cascadeId, options)
|
|
403
|
-
: new Map();
|
|
544
|
+
const records = mergeCascadeUsage(genRecords, stepRows);
|
|
404
545
|
|
|
405
546
|
if (records.length > 0) {
|
|
406
547
|
dbHandled.add(cascadeId);
|
|
@@ -408,25 +549,30 @@ export async function parse({ extraRoots = [] } = {}) {
|
|
|
408
549
|
if (rec.responseId && seenResponseIds.has(rec.responseId)) continue;
|
|
409
550
|
if (rec.responseId) seenResponseIds.add(rec.responseId);
|
|
410
551
|
// Gemini 3.7 CLI blobs dropped chatStartMetadata.createdAt (9.4.1)
|
|
411
|
-
// and modelDisplayName (21)
|
|
412
|
-
//
|
|
413
|
-
|
|
552
|
+
// and modelDisplayName (21); newer stores drop generator usage
|
|
553
|
+
// altogether and keep it on the step. Either way the clock comes from
|
|
554
|
+
// the record itself or from steps.metadata at the same idx.
|
|
555
|
+
const timestamp = resolveUsageTimestamp(rec, stepRows.timestamps);
|
|
414
556
|
if (!timestamp || isNaN(timestamp.getTime())) continue;
|
|
415
557
|
entries.push({
|
|
416
558
|
source: SOURCE,
|
|
417
|
-
model:
|
|
559
|
+
model: rec.model,
|
|
418
560
|
project,
|
|
419
561
|
timestamp,
|
|
420
562
|
inputTokens: toSafeNumber(rec.inputTokens),
|
|
421
563
|
outputTokens: toSafeNumber(rec.outputTokens),
|
|
422
564
|
cachedInputTokens: toSafeNumber(rec.cacheReadTokens),
|
|
565
|
+
// The store carries one untyped cache-write count; an unexplained
|
|
566
|
+
// total belongs in the cheaper 5m tier (see the cache-write rule in
|
|
567
|
+
// AGENTS.md).
|
|
568
|
+
cacheCreation5mTokens: toSafeNumber(rec.cacheCreationTokens),
|
|
423
569
|
reasoningOutputTokens: toSafeNumber(rec.thinkingOutputTokens),
|
|
424
570
|
});
|
|
425
571
|
}
|
|
426
572
|
}
|
|
427
573
|
|
|
428
|
-
// Session timing from steps (independent of token usage
|
|
429
|
-
for (const ev of
|
|
574
|
+
// Session timing from the same steps scan (independent of token usage).
|
|
575
|
+
for (const ev of stepRows.events) {
|
|
430
576
|
sessionEvents.push({
|
|
431
577
|
sessionId: cascadeId,
|
|
432
578
|
source: SOURCE,
|
|
@@ -497,15 +643,22 @@ export async function parse({ extraRoots = [] } = {}) {
|
|
|
497
643
|
const responseId = usage.responseId || '';
|
|
498
644
|
if (responseId && seenResponseIds.has(responseId)) continue;
|
|
499
645
|
if (responseId) seenResponseIds.add(responseId);
|
|
646
|
+
// The language server serializes the same GeneratorMetadata
|
|
647
|
+
// protobuf the .db rows carry, where `outputTokens` (field 3) is
|
|
648
|
+
// the total and thinking is one part of it — billed at the same
|
|
649
|
+
// output rate, but only once.
|
|
650
|
+
const totalOutputTokens = toSafeNumber(usage.outputTokens);
|
|
651
|
+
const thinkingOutputTokens = toSafeNumber(usage.thinkingOutputTokens);
|
|
652
|
+
const outputTokens = Math.max(0, totalOutputTokens - thinkingOutputTokens);
|
|
500
653
|
entries.push({
|
|
501
654
|
source: SOURCE,
|
|
502
655
|
model,
|
|
503
656
|
project,
|
|
504
657
|
timestamp: ts,
|
|
505
658
|
inputTokens: toSafeNumber(usage.inputTokens),
|
|
506
|
-
outputTokens
|
|
659
|
+
outputTokens,
|
|
507
660
|
cachedInputTokens: toSafeNumber(usage.cacheReadTokens),
|
|
508
|
-
reasoningOutputTokens:
|
|
661
|
+
reasoningOutputTokens: Math.max(thinkingOutputTokens, totalOutputTokens - outputTokens),
|
|
509
662
|
});
|
|
510
663
|
}
|
|
511
664
|
}
|
package/src/sync.js
CHANGED
|
@@ -11,7 +11,7 @@ import { aggregateToBuckets } from './parsers/aggregate.js';
|
|
|
11
11
|
import { normalizeParserResult } from './parsers/contract.js';
|
|
12
12
|
import { extraRootList } from './extra-roots.js';
|
|
13
13
|
import { planKikiMigration } from './kiki-migration.js';
|
|
14
|
-
import { success, failure, warn, arrow, link, dim } from './output.js';
|
|
14
|
+
import { success, failure, warn, arrow, link, dim, liveStatus } from './output.js';
|
|
15
15
|
|
|
16
16
|
const BATCH_SIZE = 100;
|
|
17
17
|
const SESSION_BATCH_SIZE = 500;
|
|
@@ -44,6 +44,11 @@ function formatBytes(bytes) {
|
|
|
44
44
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** Elapsed-time suffix for the live status line (`formatDuration` rounds 0 to `0s`). */
|
|
48
|
+
function phaseElapsed(elapsedMs) {
|
|
49
|
+
return ` · 已用时 ${formatDuration(elapsedMs / 1000)}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
47
52
|
export function resolveUploadProjectSetting(settings) {
|
|
48
53
|
if (typeof settings?.uploadProject !== 'boolean') {
|
|
49
54
|
const error = new Error('SETTINGS_UNAVAILABLE');
|
|
@@ -127,6 +132,13 @@ export async function runSync({
|
|
|
127
132
|
// Built from the same `apiUrl` the ingest calls below use, so the recorded
|
|
128
133
|
// target and the actual target can never drift apart.
|
|
129
134
|
const identity = stateIdentity({ apiUrl, apiKey: config.apiKey });
|
|
135
|
+
// Both the settings round-trip and the log scan below can run for minutes on
|
|
136
|
+
// a cold or proxied machine (a timing-out endpoint retrying, a cold Codex
|
|
137
|
+
// index, a 120s Cursor export). A live status line keeps that work
|
|
138
|
+
// distinguishable from a hang; it never reaches a pipe, so the desktop apps
|
|
139
|
+
// keep parsing bare result text from stdout.
|
|
140
|
+
const status = liveStatus({ enabled: !quiet });
|
|
141
|
+
status.start(elapsedMs => `正在读取上传设置${phaseElapsed(elapsedMs)}`);
|
|
130
142
|
let uploadProject;
|
|
131
143
|
try {
|
|
132
144
|
const settings = await fetchSettings(apiUrl, config.apiKey);
|
|
@@ -143,6 +155,7 @@ export async function runSync({
|
|
|
143
155
|
saveConfig(config);
|
|
144
156
|
}
|
|
145
157
|
} catch (err) {
|
|
158
|
+
status.stop();
|
|
146
159
|
if (err.message === 'UNAUTHORIZED') {
|
|
147
160
|
console.error(failure('API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。'));
|
|
148
161
|
if (throws) throw err;
|
|
@@ -173,8 +186,11 @@ export async function runSync({
|
|
|
173
186
|
// Run parsers concurrently (bounded) so one slow parser (Cursor's network
|
|
174
187
|
// fetch, a cold Codex index) can't stall the rest. Results are collected in
|
|
175
188
|
// registry order so output and merged arrays stay deterministic.
|
|
189
|
+
const parserRegistry = Object.entries(parsers);
|
|
190
|
+
let parsedSources = 0;
|
|
191
|
+
status.start(elapsedMs => `正在扫描本地日志 ${parsedSources}/${parserRegistry.length}${phaseElapsed(elapsedMs)}`);
|
|
176
192
|
const parserOutcomes = await mapWithConcurrency(
|
|
177
|
-
|
|
193
|
+
parserRegistry,
|
|
178
194
|
PARSER_CONCURRENCY,
|
|
179
195
|
async ([source, parse]) => {
|
|
180
196
|
try {
|
|
@@ -187,9 +203,12 @@ export async function runSync({
|
|
|
187
203
|
return { source, result };
|
|
188
204
|
} catch (err) {
|
|
189
205
|
return { source, error: err };
|
|
206
|
+
} finally {
|
|
207
|
+
parsedSources++;
|
|
190
208
|
}
|
|
191
209
|
},
|
|
192
210
|
);
|
|
211
|
+
status.stop();
|
|
193
212
|
|
|
194
213
|
for (const { source, result, error } of parserOutcomes) {
|
|
195
214
|
if (error) {
|