@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 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 | `~/.codex/sessions/` and `~/.codex/archived_sessions/` |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.9.11",
3
+ "version": "0.9.13",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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 ~/.codex/sessions and, once a session is
8
- // "completed", moves its rollout file verbatim into ~/.codex/archived_sessions.
9
- // A session can be archived between two syncs, so scanning only the live dir
10
- // loses that session's usage forever. We scan both: the parser is stateless
11
- // and the server dedups on (source, sessionHash/bucket), so re-reading an
12
- // archived file that was already synced from sessions/ is idempotent. Indexing
13
- // both together also keeps fork replay-skip correct when a fork and its parent
14
- // end up split across the two directories.
15
- const SESSIONS_DIRS = [
16
- join(homedir(), '.codex', 'sessions'),
17
- join(homedir(), '.codex', 'archived_sessions'),
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
- * Stream a session file once and extract its index metadata: the session
61
- * id, the forked-from id, the project name, and the total count of
62
- * `event_msg/token_count` records. The token_count total is used to size
63
- * the replayed-history block of a forked session a fork copies the
64
- * original conversation verbatim, so it begins with exactly as many
65
- * token_count records as the source session has in total.
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 tokenCountRecords = 0;
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
- const meta = obj.payload;
79
- sessionId = meta.id || sessionId;
80
- forkedFromId = meta.forked_from_id || null;
81
- sessionProject = extractProject(meta);
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
- tokenCountRecords++;
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 { sessionId, forkedFromId, sessionProject, tokenCountRecords };
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
- if (!SESSIONS_DIRS.some(existsSync)) return { buckets: [], sessions: [] };
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 = SESSIONS_DIRS.flatMap(findJsonlFiles);
314
+ const files = dirs.flatMap(findJsonlFiles);
99
315
  if (files.length === 0) return { buckets: [], sessions: [] };
100
316
 
101
- // Pass 1: index every session by its UUID and count its token_count
102
- // records. A forked session (session_meta.payload.forked_from_id) starts
103
- // with the original conversation replayed verbatim including every
104
- // token_count, all timestamped in a burst at the fork instant. Those
105
- // tokens are already counted from the original session's own file, so
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
- tokenCountById.set(meta.sessionId, meta.tokenCountRecords);
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, skipping each fork's replayed-history token_counts.
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
- const { forkedFromId } = fm;
131
-
132
- // How many leading token_count records are copied history. A fork's file
133
- // begins with the *entire* source file replayed verbatim, so the count
134
- // to skip is the source's total token_count count. This is correct even
135
- // for chained forks: a fork-of-a-fork replays the parent fork's whole
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
- const prevTotal = new Map();
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
- // A fork's replayed-history block is the run from the start of the
163
- // file up to and including the Nth token_count, where N is the source
164
- // session's total token_count count. We are still inside that block
165
- // until we have *passed* the Nth token_count. (token_count is the
166
- // last event of each turn, so the boundary lands cleanly at a turn
167
- // edge the new conversation's events come strictly after it.)
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
- // Skip replayed history events so a forked session's
174
- // duration/active-time/message counts reflect only the new
175
- // conversation, not the copied original. session_meta itself is
176
- // kept: it marks when the fork actually started.
177
- const isReplay = inReplayBlock && obj.type !== 'session_meta';
178
- if (!isReplay) {
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
- const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
207
- if (!timestamp || isNaN(timestamp.getTime())) continue;
208
-
209
- // This is the (tokenCountSeen+1)-th token_count in the file. If it
210
- // falls inside the fork's replay block it's an exact copy of a record
211
- // already counted from the source session's own file skip it (but
212
- // still advance the cumulative-total baseline below so the first real
213
- // post-fork delta is measured correctly).
214
- const isReplayedHistory = tokenCountSeen < replayTokenCountToSkip;
215
- tokenCountSeen++;
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 total as fallback
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 && info.total_token_usage) {
220
- const totalKey = `${info.model || payload.model || turnContextModel || ''}`;
221
- const prev = prevTotal.get(totalKey);
222
- const curr = info.total_token_usage;
223
- if (prev) {
224
- usage = {
225
- input_tokens: (curr.input_tokens || 0) - (prev.input_tokens || 0),
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