@vibe-cafe/vibe-usage 0.9.11 → 0.9.13
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/codex.js +321 -97
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
51
51
|
| Tool | Data Location |
|
|
52
52
|
|------|---------------|
|
|
53
53
|
| Claude Code | `~/.claude/projects/` (tokens + sessions), `~/.claude/transcripts/` (sessions only); also scans `$CLAUDE_CONFIG_DIR` when set (deduped), so relocated configs and GUI/CLI env mismatches are both covered |
|
|
54
|
-
| Codex CLI |
|
|
54
|
+
| Codex CLI | `$CODEX_HOME/sessions/` and `$CODEX_HOME/archived_sessions/` (default `~/.codex`); forked and sub-agent rollouts replay parent metadata, tasks, and `token_count` records at spawn time — full or last-N-turn replay blocks are matched as a fingerprinted parent suffix plus the child's own task boundary, duplicate cumulative emissions count once, and live/archive copies of the same session are deduplicated |
|
|
55
55
|
| GitHub Copilot CLI | `~/.copilot/session-state/*/events.jsonl` |
|
|
56
56
|
| Cursor | `state.vscdb` (SQLite, reads `cursorAuth/accessToken`, fetches CSV from `cursor.com`); cloud data is stamped with a fixed `cursor-cloud` hostname so multi-machine setups don't double-count |
|
|
57
57
|
| Gemini CLI | `~/.gemini/tmp/<project_hash>/chats/session-*.jsonl` (current line-delimited format) and legacy `session-*.json`; recurses into nested subagent sessions |
|
package/package.json
CHANGED
package/src/parsers/codex.js
CHANGED
|
@@ -2,20 +2,23 @@ import { createReadStream, readdirSync, existsSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { createInterface } from 'node:readline';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
5
6
|
import { aggregateToBuckets, extractSessions } from './index.js';
|
|
6
7
|
|
|
7
|
-
// Codex stores live sessions in
|
|
8
|
-
// "completed", moves its rollout file verbatim into
|
|
9
|
-
// A session can be archived between two syncs,
|
|
10
|
-
// loses that session's usage forever. We scan
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// both
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
8
|
+
// Codex stores live sessions in $CODEX_HOME/sessions (default ~/.codex) and,
|
|
9
|
+
// once a session is "completed", moves its rollout file verbatim into
|
|
10
|
+
// $CODEX_HOME/archived_sessions. A session can be archived between two syncs,
|
|
11
|
+
// so scanning only the live dir loses that session's usage forever. We scan
|
|
12
|
+
// both, index them together so fork replay-skip works across directories, and
|
|
13
|
+
// select the most complete physical file when the same session briefly exists
|
|
14
|
+
// in both locations during an archive move.
|
|
15
|
+
function sessionsDirs() {
|
|
16
|
+
const codexHome = process.env.CODEX_HOME?.trim() || join(homedir(), '.codex');
|
|
17
|
+
return [
|
|
18
|
+
join(codexHome, 'sessions'),
|
|
19
|
+
join(codexHome, 'archived_sessions'),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* Recursively find all .jsonl files under a directory.
|
|
@@ -57,59 +60,265 @@ function extractProject(meta) {
|
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* `
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
* A sub-agent rollout (spawned thread / guardian / collab agent). Depending
|
|
64
|
+
* on the Codex version the marker is `thread_source: "subagent"`, a
|
|
65
|
+
* `source: { subagent: {...} }` object, or just a `parent_thread_id` — check
|
|
66
|
+
* all three so no version's sub-agents slip through as normal sessions.
|
|
67
|
+
*/
|
|
68
|
+
function isSubagentMeta(meta) {
|
|
69
|
+
if (meta.thread_source === 'subagent') return true;
|
|
70
|
+
const src = meta.source;
|
|
71
|
+
if (src === 'subagent') return true;
|
|
72
|
+
if (src && typeof src === 'object' && 'subagent' in src) return true;
|
|
73
|
+
return meta.parent_thread_id != null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function extractParentThreadId(meta) {
|
|
77
|
+
return meta.parent_thread_id
|
|
78
|
+
|| meta.source?.subagent?.thread_spawn?.parent_thread_id
|
|
79
|
+
|| null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function timestampMs(value) {
|
|
83
|
+
if (value == null || value === '') return null;
|
|
84
|
+
const n = new Date(value).getTime();
|
|
85
|
+
return Number.isFinite(n) ? n : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function epochMs(value) {
|
|
89
|
+
if (typeof value === 'string' && value.trim() !== '') value = Number(value);
|
|
90
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
|
91
|
+
return value < 1e12 ? value * 1000 : value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isTaskStarted(payload) {
|
|
95
|
+
return payload?.type === 'task_started' || payload?.type === 'turn_started';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function upperBound(sorted, target) {
|
|
99
|
+
let lo = 0;
|
|
100
|
+
let hi = sorted.length;
|
|
101
|
+
while (lo < hi) {
|
|
102
|
+
const mid = lo + ((hi - lo) >> 1);
|
|
103
|
+
if (sorted[mid] <= target) lo = mid + 1;
|
|
104
|
+
else hi = mid;
|
|
105
|
+
}
|
|
106
|
+
return lo;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function tokenFingerprint(payload) {
|
|
110
|
+
// Copied rollout items are re-serialized with a fresh outer timestamp, but
|
|
111
|
+
// their token_count payload is unchanged. A compact payload hash therefore
|
|
112
|
+
// identifies replayed records without retaining raw usage objects in memory.
|
|
113
|
+
return createHash('sha256')
|
|
114
|
+
.update(JSON.stringify(payload))
|
|
115
|
+
.digest('base64url')
|
|
116
|
+
.slice(0, 16);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Return the longest prefix of `child` that is also a suffix of `parent`.
|
|
121
|
+
* Codex can fork full history or the last N turns, but the copied block always
|
|
122
|
+
* reaches the source snapshot's end. Requiring the suffix prevents a child's
|
|
123
|
+
* coincidentally repeated payload from matching an unrelated interior turn.
|
|
124
|
+
* KMP keeps this linear even when many token payloads are identical.
|
|
125
|
+
*/
|
|
126
|
+
function longestReplayPrefix(child, parent) {
|
|
127
|
+
if (child.length === 0 || parent.length === 0) return 0;
|
|
128
|
+
|
|
129
|
+
const prefix = new Array(child.length).fill(0);
|
|
130
|
+
for (let i = 1, matched = 0; i < child.length; i++) {
|
|
131
|
+
while (matched > 0 && child[i] !== child[matched]) matched = prefix[matched - 1];
|
|
132
|
+
if (child[i] === child[matched]) matched++;
|
|
133
|
+
prefix[i] = matched;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let matched = 0;
|
|
137
|
+
for (let i = 0; i < parent.length; i++) {
|
|
138
|
+
const fingerprint = parent[i];
|
|
139
|
+
while (matched > 0 && fingerprint !== child[matched]) matched = prefix[matched - 1];
|
|
140
|
+
if (fingerprint === child[matched]) matched++;
|
|
141
|
+
if (matched === child.length && i < parent.length - 1) matched = prefix[matched - 1];
|
|
142
|
+
}
|
|
143
|
+
return matched;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// `task_started.started_at` is stored at one-second precision while the
|
|
147
|
+
// canonical session timestamp has milliseconds. Real Codex Desktop rollouts
|
|
148
|
+
// start the child task within a few seconds of creating the child session.
|
|
149
|
+
const OWN_TASK_START_WINDOW_MS = 5_000;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Stream a rollout once and build a compact replay index. A fork/sub-agent
|
|
153
|
+
* file starts with its own session_meta and can then contain the source
|
|
154
|
+
* session's complete metadata and history. Only the first session_meta is
|
|
155
|
+
* canonical; later ones are replayed records and must never overwrite it.
|
|
156
|
+
*
|
|
157
|
+
* tokenTimes preserves raw token_count ordinals (including malformed usage
|
|
158
|
+
* records) on a monotonic timeline. tokenFingerprints identifies an exact
|
|
159
|
+
* copied sequence even when Codex forks only the last N turns instead of a
|
|
160
|
+
* full prefix. Together they bound matching to source records that existed at
|
|
161
|
+
* spawn without over-skipping child work when the parent later grows.
|
|
66
162
|
*/
|
|
67
163
|
async function indexSessionFile(filePath) {
|
|
68
164
|
let sessionId = null;
|
|
69
165
|
let forkedFromId = null;
|
|
166
|
+
let parentThreadId = null;
|
|
70
167
|
let sessionProject = 'unknown';
|
|
71
|
-
let
|
|
168
|
+
let sessionStartedAtMs = null;
|
|
169
|
+
let isSubagent = false;
|
|
170
|
+
let sessionMetaCount = 0;
|
|
171
|
+
let parsedRecordCount = 0;
|
|
172
|
+
let rawTokenCount = 0;
|
|
173
|
+
let logicalTimestamp = Number.NEGATIVE_INFINITY;
|
|
174
|
+
const tokenTimes = [];
|
|
175
|
+
const tokenFingerprints = [];
|
|
176
|
+
let pendingTokenTimeIndexes = [];
|
|
177
|
+
const taskBoundaries = [];
|
|
178
|
+
let firstTaskBoundary = null;
|
|
179
|
+
let ownTaskBoundary = null;
|
|
72
180
|
|
|
73
181
|
for await (const line of readLines(filePath)) {
|
|
74
182
|
if (!line.trim()) continue;
|
|
75
183
|
try {
|
|
76
184
|
const obj = JSON.parse(line);
|
|
185
|
+
parsedRecordCount++;
|
|
186
|
+
|
|
187
|
+
const recordTimestamp = timestampMs(obj.timestamp);
|
|
188
|
+
if (recordTimestamp != null) {
|
|
189
|
+
logicalTimestamp = Math.max(logicalTimestamp, recordTimestamp);
|
|
190
|
+
// An invalid token_count timestamp is placed at the next valid record
|
|
191
|
+
// time. If there is no next valid record it remains +Infinity, which
|
|
192
|
+
// deliberately biases a parent-at-spawn boundary toward under-skip.
|
|
193
|
+
for (const idx of pendingTokenTimeIndexes) tokenTimes[idx] = logicalTimestamp;
|
|
194
|
+
pendingTokenTimeIndexes = [];
|
|
195
|
+
}
|
|
196
|
+
|
|
77
197
|
if (obj.type === 'session_meta' && obj.payload) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
198
|
+
sessionMetaCount++;
|
|
199
|
+
if (sessionMetaCount === 1) {
|
|
200
|
+
const meta = obj.payload;
|
|
201
|
+
sessionId = meta.id || null;
|
|
202
|
+
forkedFromId = meta.forked_from_id || null;
|
|
203
|
+
parentThreadId = extractParentThreadId(meta);
|
|
204
|
+
isSubagent = isSubagentMeta(meta);
|
|
205
|
+
sessionProject = extractProject(meta);
|
|
206
|
+
sessionStartedAtMs = timestampMs(meta.timestamp) ?? recordTimestamp;
|
|
207
|
+
}
|
|
82
208
|
} else if (obj.type === 'event_msg' && obj.payload?.type === 'token_count') {
|
|
83
|
-
|
|
209
|
+
rawTokenCount++;
|
|
210
|
+
tokenFingerprints.push(tokenFingerprint(obj.payload));
|
|
211
|
+
if (recordTimestamp == null) {
|
|
212
|
+
tokenTimes.push(Number.POSITIVE_INFINITY);
|
|
213
|
+
pendingTokenTimeIndexes.push(tokenTimes.length - 1);
|
|
214
|
+
} else {
|
|
215
|
+
tokenTimes.push(logicalTimestamp);
|
|
216
|
+
}
|
|
217
|
+
} else if (obj.type === 'event_msg' && isTaskStarted(obj.payload)) {
|
|
218
|
+
const boundary = {
|
|
219
|
+
recordIndex: parsedRecordCount,
|
|
220
|
+
rawTokenCount,
|
|
221
|
+
startedAtMs: epochMs(obj.payload.started_at),
|
|
222
|
+
};
|
|
223
|
+
taskBoundaries.push(boundary);
|
|
224
|
+
firstTaskBoundary ??= boundary;
|
|
225
|
+
|
|
226
|
+
const startedAtMs = boundary.startedAtMs;
|
|
227
|
+
if (sessionStartedAtMs != null && startedAtMs != null
|
|
228
|
+
&& Math.abs(startedAtMs - sessionStartedAtMs) <= OWN_TASK_START_WINDOW_MS) {
|
|
229
|
+
// Keep the last match so a copied parent task that happened to start
|
|
230
|
+
// in the same second cannot win over the child's later own boundary.
|
|
231
|
+
ownTaskBoundary = boundary;
|
|
232
|
+
}
|
|
84
233
|
}
|
|
85
234
|
} catch {
|
|
86
235
|
continue;
|
|
87
236
|
}
|
|
88
237
|
}
|
|
89
238
|
|
|
90
|
-
return {
|
|
239
|
+
return {
|
|
240
|
+
filePath,
|
|
241
|
+
sessionId,
|
|
242
|
+
forkedFromId,
|
|
243
|
+
parentThreadId,
|
|
244
|
+
sessionProject,
|
|
245
|
+
sessionStartedAtMs,
|
|
246
|
+
isSubagent,
|
|
247
|
+
sessionMetaCount,
|
|
248
|
+
parsedRecordCount,
|
|
249
|
+
rawTokenCount,
|
|
250
|
+
tokenTimes,
|
|
251
|
+
tokenFingerprints,
|
|
252
|
+
taskBoundaries,
|
|
253
|
+
firstTaskBoundary,
|
|
254
|
+
ownTaskBoundary,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function replayBoundary(meta, sessionById) {
|
|
259
|
+
const parentId = meta.forkedFromId || (meta.isSubagent ? meta.parentThreadId : null);
|
|
260
|
+
const parent = parentId ? sessionById.get(parentId) : null;
|
|
261
|
+
const parentAtSpawn = parent && meta.sessionStartedAtMs != null
|
|
262
|
+
? upperBound(parent.tokenTimes, meta.sessionStartedAtMs)
|
|
263
|
+
: null;
|
|
264
|
+
const replayTokenCount = parentAtSpawn == null
|
|
265
|
+
? 0
|
|
266
|
+
: longestReplayPrefix(
|
|
267
|
+
meta.tokenFingerprints,
|
|
268
|
+
parent.tokenFingerprints.slice(0, parentAtSpawn)
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
if (meta.isSubagent) {
|
|
272
|
+
// Direct evidence inside the child wins. Legacy single-meta rollouts did
|
|
273
|
+
// not replay task_started records, so their first task remains a safe
|
|
274
|
+
// fallback. Double-meta files must not use their copied parent's first
|
|
275
|
+
// task_started as the boundary.
|
|
276
|
+
// Exact token matching also handles LastNTurns forks. When it identifies
|
|
277
|
+
// the copied token suffix, the last task_started at that same raw ordinal
|
|
278
|
+
// is the child's own task boundary (copied history is written first).
|
|
279
|
+
const matchedTaskBoundaries = replayTokenCount > 0
|
|
280
|
+
? meta.taskBoundaries.filter(boundary => (
|
|
281
|
+
boundary.rawTokenCount === replayTokenCount
|
|
282
|
+
&& boundary.startedAtMs != null
|
|
283
|
+
&& meta.sessionStartedAtMs != null
|
|
284
|
+
&& boundary.startedAtMs >= Math.floor(meta.sessionStartedAtMs / 1000) * 1000
|
|
285
|
+
))
|
|
286
|
+
: [];
|
|
287
|
+
const matchedTaskBoundary = matchedTaskBoundaries.at(-1) || null;
|
|
288
|
+
const direct = matchedTaskBoundary
|
|
289
|
+
|| meta.ownTaskBoundary
|
|
290
|
+
|| (meta.sessionMetaCount === 1 && !meta.forkedFromId
|
|
291
|
+
? meta.firstTaskBoundary
|
|
292
|
+
: null);
|
|
293
|
+
if (direct) {
|
|
294
|
+
return {
|
|
295
|
+
rawTokenCount: Math.max(replayTokenCount, direct.rawTokenCount),
|
|
296
|
+
recordIndex: direct.recordIndex,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
return { rawTokenCount: replayTokenCount, recordIndex: null };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (meta.forkedFromId) {
|
|
303
|
+
return { rawTokenCount: replayTokenCount, recordIndex: null };
|
|
304
|
+
}
|
|
305
|
+
return { rawTokenCount: 0, recordIndex: null };
|
|
91
306
|
}
|
|
92
307
|
|
|
93
308
|
export async function parse() {
|
|
94
|
-
|
|
309
|
+
const dirs = sessionsDirs();
|
|
310
|
+
if (!dirs.some(existsSync)) return { buckets: [], sessions: [] };
|
|
95
311
|
|
|
96
312
|
const entries = [];
|
|
97
313
|
const sessionEvents = [];
|
|
98
|
-
const files =
|
|
314
|
+
const files = dirs.flatMap(findJsonlFiles);
|
|
99
315
|
if (files.length === 0) return { buckets: [], sessions: [] };
|
|
100
316
|
|
|
101
|
-
// Pass 1:
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
// re-counting them here double-counts usage and produces a spurious
|
|
107
|
-
// token/cost spike at the fork time. Timestamps cannot distinguish the
|
|
108
|
-
// replay from new activity (the replay burst is stamped at/after the fork
|
|
109
|
-
// instant, within the same 1–3s window), so we instead skip exactly the
|
|
110
|
-
// original session's token_count count from the start of each fork.
|
|
111
|
-
const tokenCountById = new Map(); // sessionId → number of token_count records
|
|
112
|
-
const fileMeta = new Map(); // filePath -> { forkedFromId, sessionProject }
|
|
317
|
+
// Pass 1: build a compact per-file index. Keep the most complete physical
|
|
318
|
+
// copy for each logical session id so a rollout briefly present in both
|
|
319
|
+
// sessions/ and archived_sessions/ cannot double its token buckets.
|
|
320
|
+
const sessionById = new Map();
|
|
321
|
+
const fileMeta = new Map();
|
|
113
322
|
for (const filePath of files) {
|
|
114
323
|
let meta;
|
|
115
324
|
try {
|
|
@@ -119,30 +328,23 @@ export async function parse() {
|
|
|
119
328
|
}
|
|
120
329
|
fileMeta.set(filePath, meta);
|
|
121
330
|
if (meta.sessionId) {
|
|
122
|
-
|
|
331
|
+
const existing = sessionById.get(meta.sessionId);
|
|
332
|
+
if (!existing || meta.parsedRecordCount > existing.parsedRecordCount) {
|
|
333
|
+
sessionById.set(meta.sessionId, meta);
|
|
334
|
+
}
|
|
123
335
|
}
|
|
124
336
|
}
|
|
125
337
|
|
|
126
|
-
// Pass 2: parse usage
|
|
338
|
+
// Pass 2: parse usage while skipping the replay prefix resolved above.
|
|
127
339
|
for (const filePath of files) {
|
|
128
340
|
const fm = fileMeta.get(filePath);
|
|
129
341
|
if (!fm) continue;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// file (which itself already contains the grandparent's replay), so
|
|
137
|
-
// skipping the parent's full count skips exactly the duplicated region.
|
|
138
|
-
// If the source file is missing (rotated/deleted) we cannot locate the
|
|
139
|
-
// boundary; skip nothing so incomplete data over-counts rather than
|
|
140
|
-
// silently dropping real usage.
|
|
141
|
-
let replayTokenCountToSkip = 0;
|
|
142
|
-
if (forkedFromId != null) {
|
|
143
|
-
replayTokenCountToSkip = tokenCountById.get(forkedFromId) ?? 0;
|
|
144
|
-
}
|
|
145
|
-
let tokenCountSeen = 0;
|
|
342
|
+
if (fm.sessionId && sessionById.get(fm.sessionId)?.filePath !== filePath) continue;
|
|
343
|
+
|
|
344
|
+
const boundary = replayBoundary(fm, sessionById);
|
|
345
|
+
let rawTokenSeen = 0;
|
|
346
|
+
let parsedRecordIndex = 0;
|
|
347
|
+
let firstSessionMetaSeen = false;
|
|
146
348
|
|
|
147
349
|
const sessionProject = fm.sessionProject;
|
|
148
350
|
// Group timing events by the real Codex session id, not the file path: the
|
|
@@ -153,29 +355,37 @@ export async function parse() {
|
|
|
153
355
|
const sessionKey = fm.sessionId || filePath;
|
|
154
356
|
|
|
155
357
|
let turnContextModel = 'unknown';
|
|
156
|
-
|
|
358
|
+
let prevTotal = null;
|
|
359
|
+
let prevCumulativeTotal = null;
|
|
157
360
|
for await (const line of readLines(filePath)) {
|
|
158
361
|
if (!line.trim()) continue;
|
|
159
362
|
try {
|
|
160
363
|
const obj = JSON.parse(line);
|
|
364
|
+
parsedRecordIndex++;
|
|
365
|
+
|
|
366
|
+
// A direct child task boundary covers every copied record, including
|
|
367
|
+
// timing/meta events. The raw-token ordinal covers full-history and
|
|
368
|
+
// last-N-turn forks whose exact payload sequence was matched in pass 1.
|
|
369
|
+
const beforeOwnTask = boundary.recordIndex != null
|
|
370
|
+
&& parsedRecordIndex < boundary.recordIndex;
|
|
371
|
+
const inReplayBlock = beforeOwnTask || rawTokenSeen < boundary.rawTokenCount;
|
|
161
372
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const inReplayBlock = tokenCountSeen < replayTokenCountToSkip;
|
|
373
|
+
const isSessionMeta = obj.type === 'session_meta';
|
|
374
|
+
const isCanonicalSessionMeta = isSessionMeta && !firstSessionMetaSeen;
|
|
375
|
+
const isOwnSessionMeta = isSessionMeta
|
|
376
|
+
&& obj.payload?.id != null
|
|
377
|
+
&& obj.payload.id === fm.sessionId;
|
|
378
|
+
if (isSessionMeta) firstSessionMetaSeen = true;
|
|
169
379
|
|
|
170
380
|
if (obj.timestamp) {
|
|
171
381
|
const evTs = new Date(obj.timestamp);
|
|
172
382
|
if (!isNaN(evTs.getTime())) {
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (!
|
|
383
|
+
// Repeated same-id metadata can be appended on resume/config
|
|
384
|
+
// updates and belongs to this logical session. A different-id meta
|
|
385
|
+
// is copied parent history and must not inflate timing stats.
|
|
386
|
+
const keepSessionMeta = isCanonicalSessionMeta
|
|
387
|
+
|| (isOwnSessionMeta && !inReplayBlock);
|
|
388
|
+
if (keepSessionMeta || (!isSessionMeta && !inReplayBlock)) {
|
|
179
389
|
const isUserTurn = obj.type === 'turn_context' || obj.type === 'session_meta';
|
|
180
390
|
sessionEvents.push({
|
|
181
391
|
sessionId: sessionKey,
|
|
@@ -200,44 +410,58 @@ export async function parse() {
|
|
|
200
410
|
|
|
201
411
|
if (payload.type !== 'token_count') continue;
|
|
202
412
|
|
|
413
|
+
// Raw ordinals advance before validating usage/timestamp so pass 1 and
|
|
414
|
+
// pass 2 cannot drift on a malformed copied token_count record.
|
|
415
|
+
const isReplayedHistory = inReplayBlock;
|
|
416
|
+
rawTokenSeen++;
|
|
417
|
+
|
|
203
418
|
const info = payload.info;
|
|
204
419
|
if (!info) continue;
|
|
205
420
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
const
|
|
215
|
-
|
|
421
|
+
// Codex sometimes writes the same token_count twice back-to-back:
|
|
422
|
+
// identical last_token_usage with an unchanged cumulative total. A
|
|
423
|
+
// real API call always advances the cumulative counter (its input
|
|
424
|
+
// tokens alone are non-zero), so an unchanged positive total marks a
|
|
425
|
+
// duplicate emission — or a zero-usage bookkeeping event such as
|
|
426
|
+
// compaction — and must count as zero, not a second copy of
|
|
427
|
+
// last_token_usage. Guarded to positive totals so builds that leave
|
|
428
|
+
// total_token_usage all-zero can't suppress real usage.
|
|
429
|
+
const cumulativeTotal = info.total_token_usage?.total_tokens;
|
|
430
|
+
const isDuplicateEmission = typeof cumulativeTotal === 'number'
|
|
431
|
+
&& cumulativeTotal > 0
|
|
432
|
+
&& cumulativeTotal === prevCumulativeTotal;
|
|
433
|
+
if (typeof cumulativeTotal === 'number') prevCumulativeTotal = cumulativeTotal;
|
|
216
434
|
|
|
217
|
-
// Prefer incremental per-request usage; compute delta from cumulative
|
|
435
|
+
// Prefer incremental per-request usage; compute delta from cumulative
|
|
436
|
+
// totals as fallback. Always advance the cumulative baseline, even
|
|
437
|
+
// when last_token_usage exists or the record belongs to a replay.
|
|
438
|
+
const curr = info.total_token_usage;
|
|
218
439
|
let usage = info.last_token_usage;
|
|
219
|
-
if (!usage &&
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
output_tokens: (curr.output_tokens || 0) - (prev.output_tokens || 0),
|
|
227
|
-
cached_input_tokens: (curr.cached_input_tokens || 0) - (prev.cached_input_tokens || 0),
|
|
228
|
-
reasoning_output_tokens: (curr.reasoning_output_tokens || 0) - (prev.reasoning_output_tokens || 0),
|
|
440
|
+
if (!usage && curr) {
|
|
441
|
+
if (prevTotal) {
|
|
442
|
+
const delta = {
|
|
443
|
+
input_tokens: (curr.input_tokens || 0) - (prevTotal.input_tokens || 0),
|
|
444
|
+
output_tokens: (curr.output_tokens || 0) - (prevTotal.output_tokens || 0),
|
|
445
|
+
cached_input_tokens: (curr.cached_input_tokens || 0) - (prevTotal.cached_input_tokens || 0),
|
|
446
|
+
reasoning_output_tokens: (curr.reasoning_output_tokens || 0) - (prevTotal.reasoning_output_tokens || 0),
|
|
229
447
|
};
|
|
448
|
+
// Cumulative counters can reset after compaction or a new usage
|
|
449
|
+
// window. Treat the first post-reset total as a fresh baseline;
|
|
450
|
+
// allowing a negative delta would cancel legitimate bucket usage.
|
|
451
|
+
usage = Object.values(delta).some(value => value < 0) ? curr : delta;
|
|
230
452
|
} else {
|
|
231
453
|
// First cumulative entry — use as-is (it's the first event's total)
|
|
232
454
|
usage = curr;
|
|
233
455
|
}
|
|
234
|
-
// Always advance the cumulative baseline, even for replayed history,
|
|
235
|
-
// so the first real post-fork delta is measured against the last
|
|
236
|
-
// replayed total instead of being mistaken for a fresh "first entry".
|
|
237
|
-
prevTotal.set(totalKey, { ...curr });
|
|
238
456
|
}
|
|
457
|
+
// total_token_usage is session-wide, not per model. A global baseline
|
|
458
|
+
// avoids counting the full cumulative total again after a model switch.
|
|
459
|
+
if (curr) prevTotal = { ...curr };
|
|
239
460
|
if (!usage) continue;
|
|
240
|
-
if (isReplayedHistory) continue;
|
|
461
|
+
if (isReplayedHistory || isDuplicateEmission) continue;
|
|
462
|
+
|
|
463
|
+
const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
|
|
464
|
+
if (!timestamp || isNaN(timestamp.getTime())) continue;
|
|
241
465
|
|
|
242
466
|
const model = info.model || payload.model || turnContextModel || 'unknown';
|
|
243
467
|
|