@vibe-cafe/vibe-usage 0.14.2 → 0.14.3

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
@@ -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.metadata` and model names to `responseModel`. `.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. |
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`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.14.2",
3
+ "version": "0.14.3",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -105,7 +105,73 @@ function firstMessage(fields, num) {
105
105
  return b ? decodeMessage(b) : undefined;
106
106
  }
107
107
 
108
- // ── GeneratorMetadata parsing ─────────────────────────────────────────
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}|null}
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 inputTokens = firstVarint(usage, 2) || 0;
126
- const outputTokens = firstVarint(usage, 3) || 0;
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
- inputTokens,
143
- outputTokens,
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
- * Extract createdAt seconds from a steps.metadata blob, regardless of step
155
- * source. Gemini 3.7 CLI gen_metadata blobs no longer carry chatStartMetadata
156
- * (field 9.4); the step timestamp at field 1.1 is the remaining clock.
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
- * @param {Buffer} buf
159
- * @returns {Date|null}
214
+ * @returns {{usage, retryUsages, modelName, modelId}} usage/entries may be null
160
215
  */
161
- export function parseStepTimestamp(buf) {
162
- const meta = decodeMessage(buf);
163
- const createdAt = firstMessage(meta, 1);
164
- const seconds = createdAt ? firstVarint(createdAt, 1) : undefined;
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
- * Map steps.idx → createdAt for every step that has a timestamp, including
248
- * system/tool steps that parseStepMetadata skips. Used to timestamp 3.7
249
- * gen_metadata rows that no longer embed chatStartMetadata.
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 readDbStepTimestamps(conversationsDir, cascadeId, { strict = false } = {}) {
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 byIdx = new Map();
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 ts;
372
+ let meta;
267
373
  try {
268
- ts = parseStepTimestamp(Buffer.from(row.h, 'hex'));
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
- if (Number.isFinite(idx)) byIdx.set(idx, ts);
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 byIdx;
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, readDbSessionEvents, readDbStepTimestamps, resolveUsageTimestamp } from './antigravity-db.js';
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
- /** Model name for an offline .db record: real display name → slug → unknown. */
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
- const records = readDbUsageRecords(dir, cascadeId, options);
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 stepTimestampsByIdx = records.some((rec) => !rec.timestamp || isNaN(rec.timestamp.getTime()))
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). Usage is still in field 4; clock is
412
- // recovered from steps.metadata at the same idx.
413
- const timestamp = resolveUsageTimestamp(rec, stepTimestampsByIdx);
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: modelFromRecord(rec),
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 presence).
429
- for (const ev of readDbSessionEvents(dir, cascadeId, options)) {
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: toSafeNumber(usage.outputTokens),
659
+ outputTokens,
507
660
  cachedInputTokens: toSafeNumber(usage.cacheReadTokens),
508
- reasoningOutputTokens: toSafeNumber(usage.thinkingOutputTokens),
661
+ reasoningOutputTokens: Math.max(thinkingOutputTokens, totalOutputTokens - outputTokens),
509
662
  });
510
663
  }
511
664
  }