@vibe-cafe/vibe-usage 0.14.1 → 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/src/parsers/codex-cache.js +1 -1
- package/src/parsers/codex-segments.js +3 -1
- package/src/parsers/codex-usage-record.js +29 -0
- package/src/parsers/codex.js +33 -6
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
|
}
|
|
@@ -14,7 +14,7 @@ import { join } from 'node:path';
|
|
|
14
14
|
// separate from ~/.vibe-usage/state.json, whose hashes are the authoritative
|
|
15
15
|
// record of successful uploads and must remain backward-compatible.
|
|
16
16
|
export const CODEX_CACHE_SCHEMA_VERSION = 1;
|
|
17
|
-
export const CODEX_PARSER_ALGORITHM_VERSION =
|
|
17
|
+
export const CODEX_PARSER_ALGORITHM_VERSION = 5;
|
|
18
18
|
|
|
19
19
|
function hash(value, length = 24) {
|
|
20
20
|
return createHash('sha256').update(value).digest('hex').slice(0, length);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { normalizeUsageRecord } from './codex-usage-record.js';
|
|
2
3
|
|
|
3
4
|
const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('base64url');
|
|
4
5
|
function pick(value, keys) {
|
|
@@ -10,6 +11,7 @@ const usageKeys = ['input_tokens', 'output_tokens', 'cached_input_tokens', 'cach
|
|
|
10
11
|
// Retain only fields consumed by the existing token/timing parser. Full JSON is
|
|
11
12
|
// hashed transiently to identify exact copies; chat/tool text is never retained.
|
|
12
13
|
function accountingRecord(obj, context) {
|
|
14
|
+
obj = normalizeUsageRecord(obj);
|
|
13
15
|
const next = { type: obj.type, timestamp: obj.timestamp };
|
|
14
16
|
const p = obj.payload;
|
|
15
17
|
if (obj.type === 'session_meta' && p) {
|
|
@@ -22,7 +24,7 @@ function accountingRecord(obj, context) {
|
|
|
22
24
|
} else if (obj.type === 'turn_context') {
|
|
23
25
|
next.payload = pick(p, ['model', 'service_tier']);
|
|
24
26
|
} else if (obj.type === 'event_msg' && p) {
|
|
25
|
-
next.payload = pick(p, ['type', 'started_at', 'model']);
|
|
27
|
+
next.payload = pick(p, ['type', 'started_at', 'model', 'usage_record']);
|
|
26
28
|
if (p.type === 'token_count') {
|
|
27
29
|
next._tokenFingerprint = hash(p).slice(0, 16);
|
|
28
30
|
next._segmentContext = { ...context };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Codex writes usage twice for the same request: once as the UI-facing
|
|
2
|
+
// `event_msg` / `token_count`, and once as a durable `token_usage_record`.
|
|
3
|
+
// The UI event can be missing entirely — an interrupted or crashed call never
|
|
4
|
+
// reaches the point where it is emitted — while the durable record still lands,
|
|
5
|
+
// so counting only the UI form under-reports real usage.
|
|
6
|
+
//
|
|
7
|
+
// Rewrite the durable form into the shape the parser already understands, and
|
|
8
|
+
// keep a `usage_record` marker so the UI event that mirrors the same request is
|
|
9
|
+
// not counted a second time when its cumulative total lags.
|
|
10
|
+
export function normalizeUsageRecord(obj) {
|
|
11
|
+
if (obj?.type !== 'token_usage_record') return obj;
|
|
12
|
+
const p = obj.payload;
|
|
13
|
+
const usage = p?.usage;
|
|
14
|
+
const total = p?.thread_token_usage;
|
|
15
|
+
if (!usage || !total || !Number.isFinite(total.total_tokens) || total.total_tokens <= 0) return obj;
|
|
16
|
+
for (const value of [usage.input_tokens, usage.output_tokens, usage.cached_input_tokens ?? 0, usage.reasoning_output_tokens ?? 0]) {
|
|
17
|
+
if (!Number.isFinite(value) || value < 0) return obj;
|
|
18
|
+
}
|
|
19
|
+
return { ...obj, type: 'event_msg', payload: {
|
|
20
|
+
type: 'token_count', usage_record: true,
|
|
21
|
+
info: { last_token_usage: pickUsage(usage), total_token_usage: pickUsage(total) },
|
|
22
|
+
}};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pickUsage(value) {
|
|
26
|
+
const keys = ['input_tokens', 'output_tokens', 'cached_input_tokens', 'cache_read_input_tokens',
|
|
27
|
+
'cache_write_input_tokens', 'reasoning_output_tokens', 'total_tokens'];
|
|
28
|
+
return Object.fromEntries(keys.filter(key => Number.isFinite(value[key])).map(key => [key, value[key]]));
|
|
29
|
+
}
|
package/src/parsers/codex.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeUsageRecord } from './codex-usage-record.js';
|
|
1
2
|
import {
|
|
2
3
|
closeSync,
|
|
3
4
|
createReadStream,
|
|
@@ -139,7 +140,7 @@ async function readSessionHeader(filePath, snapshotSize) {
|
|
|
139
140
|
for await (const line of readLines(filePath, snapshotSize)) {
|
|
140
141
|
if (!line.trim()) continue;
|
|
141
142
|
try {
|
|
142
|
-
const obj = JSON.parse(line);
|
|
143
|
+
const obj = normalizeUsageRecord(JSON.parse(line));
|
|
143
144
|
if (obj.type !== 'session_meta' || !obj.payload) continue;
|
|
144
145
|
const meta = obj.payload;
|
|
145
146
|
return {
|
|
@@ -338,7 +339,7 @@ async function indexSessionFile(filePath, snapshotSize, lines = null) {
|
|
|
338
339
|
for await (const line of (lines ?? readLines(filePath, snapshotSize))) {
|
|
339
340
|
if (!line.trim()) continue;
|
|
340
341
|
try {
|
|
341
|
-
const obj = JSON.parse(line);
|
|
342
|
+
const obj = normalizeUsageRecord(JSON.parse(line));
|
|
342
343
|
parsedRecordCount++;
|
|
343
344
|
|
|
344
345
|
const recordTimestamp = timestampMs(obj.timestamp);
|
|
@@ -635,12 +636,20 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
|
|
|
635
636
|
let serviceTier = previousTail?.serviceTier || null;
|
|
636
637
|
let prevTotal = previousTail?.prevTotal || null;
|
|
637
638
|
let prevCumulativeTotal = previousTail?.prevCumulativeTotal ?? null;
|
|
639
|
+
// Durable records and UI counters are separate series whose cumulative totals
|
|
640
|
+
// can diverge after an interrupted call, so each keeps its own baseline.
|
|
641
|
+
let prevRecordTotal = previousTail?.prevRecordTotal ?? null;
|
|
642
|
+
let pendingUsageMirror = previousTail?.pendingUsageMirror ?? null;
|
|
638
643
|
const start = previousTail?.parsedBytes || 0;
|
|
639
644
|
for await (const line of (lines ?? readLines(filePath, snapshotSize, start))) {
|
|
640
645
|
if (!line.trim()) continue;
|
|
641
646
|
try {
|
|
642
|
-
const obj = JSON.parse(line);
|
|
647
|
+
const obj = normalizeUsageRecord(JSON.parse(line));
|
|
643
648
|
parsedRecordIndex++;
|
|
649
|
+
// A resumed or new turn cannot be the delayed mirror of the preceding
|
|
650
|
+
// request, so the marker must not survive a turn boundary.
|
|
651
|
+
if (obj.type === 'session_meta' || obj.type === 'turn_context' || obj.type === 'token_usage_record'
|
|
652
|
+
|| (obj.type === 'event_msg' && isTaskStarted(obj.payload))) pendingUsageMirror = null;
|
|
644
653
|
|
|
645
654
|
// A direct child task boundary covers every copied record, including
|
|
646
655
|
// timing/meta events. The raw-token ordinal covers full-history and
|
|
@@ -717,11 +726,21 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
|
|
|
717
726
|
// compaction — and must count as zero, not a second copy of
|
|
718
727
|
// last_token_usage. Guarded to positive totals so builds that leave
|
|
719
728
|
// total_token_usage all-zero can't suppress real usage.
|
|
729
|
+
const isUsageRecord = payload.usage_record === true;
|
|
730
|
+
// The durable record and the UI event describe the same request, but their
|
|
731
|
+
// cumulative counters can diverge after an interrupted call, so the match
|
|
732
|
+
// is on per-request usage. The marker survives appends because the mirror
|
|
733
|
+
// can arrive in a later read.
|
|
734
|
+
const isMirror = !isUsageRecord && pendingUsageMirror && sameRequestUsage(pendingUsageMirror, info.last_token_usage);
|
|
735
|
+
pendingUsageMirror = isUsageRecord ? info.last_token_usage : null;
|
|
720
736
|
const cumulativeTotal = info.total_token_usage?.total_tokens;
|
|
721
737
|
const isDuplicateEmission = typeof cumulativeTotal === 'number'
|
|
722
738
|
&& cumulativeTotal > 0
|
|
723
|
-
&& cumulativeTotal === prevCumulativeTotal;
|
|
724
|
-
if (typeof cumulativeTotal === 'number')
|
|
739
|
+
&& cumulativeTotal === (isUsageRecord ? prevRecordTotal : prevCumulativeTotal);
|
|
740
|
+
if (typeof cumulativeTotal === 'number') {
|
|
741
|
+
if (isUsageRecord) prevRecordTotal = cumulativeTotal;
|
|
742
|
+
else prevCumulativeTotal = cumulativeTotal;
|
|
743
|
+
}
|
|
725
744
|
|
|
726
745
|
// Prefer incremental per-request usage; compute delta from cumulative
|
|
727
746
|
// totals as fallback. Always advance the cumulative baseline, even
|
|
@@ -749,7 +768,7 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
|
|
|
749
768
|
// avoids counting the full cumulative total again after a model switch.
|
|
750
769
|
if (curr) prevTotal = { ...curr };
|
|
751
770
|
if (!usage) continue;
|
|
752
|
-
if (isReplayedHistory || isDuplicateEmission) continue;
|
|
771
|
+
if (isReplayedHistory || isDuplicateEmission || isMirror) continue;
|
|
753
772
|
|
|
754
773
|
const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
|
|
755
774
|
if (!timestamp || isNaN(timestamp.getTime())) continue;
|
|
@@ -816,6 +835,8 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
|
|
|
816
835
|
serviceTier,
|
|
817
836
|
prevTotal,
|
|
818
837
|
prevCumulativeTotal,
|
|
838
|
+
prevRecordTotal,
|
|
839
|
+
pendingUsageMirror,
|
|
819
840
|
buckets,
|
|
820
841
|
sessionAccumulator,
|
|
821
842
|
guardHash: guard.hash,
|
|
@@ -1171,6 +1192,12 @@ async function parseNativeCodex({ codexExtraHome, extraRoots = [] } = {}) {
|
|
|
1171
1192
|
return { ...mergeFileResults(results), cache: cacheStats };
|
|
1172
1193
|
}
|
|
1173
1194
|
|
|
1195
|
+
function sameRequestUsage(left, right) {
|
|
1196
|
+
if (!left || !right) return false;
|
|
1197
|
+
return ['input_tokens', 'cached_input_tokens', 'output_tokens', 'reasoning_output_tokens']
|
|
1198
|
+
.every(key => (left[key] || 0) === (right[key] || 0));
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1174
1201
|
export async function parse(options = {}) {
|
|
1175
1202
|
const nativeResult = await parseNativeCodex(options);
|
|
1176
1203
|
return mergeCindyHarnessUsage(nativeResult, readCindyHarnessUsage('codex'));
|