@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 +1 -1
- package/package.json +1 -1
- package/src/parsers/antigravity-db.js +163 -93
- package/src/parsers/antigravity.js +167 -14
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`) |
|
package/package.json
CHANGED
|
@@ -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
|
}
|