@vibe-cafe/vibe-usage 0.9.12 → 0.9.14

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,8 @@ 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_HOME/sessions/` and `$CODEX_HOME/archived_sessions/` (default `~/.codex`); forked and sub-agent rollouts replay the parent conversation's `token_count` records at spawn time — those are skipped (fork: by source session's record count; sub-agent: everything before its own first `task_started`), and duplicate `token_count` emissions with an unchanged cumulative total count once |
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
+ | Grok | `$GROK_HOME/sessions/<encoded-cwd>/<session-id>/` (default `~/.grok`); token usage from `updates.jsonl` `turn_completed.usage` (per-model `modelUsage`, cache reads, reasoning); project from `summary.json` cwd; honors `GROK_HOME` |
55
56
  | GitHub Copilot CLI | `~/.copilot/session-state/*/events.jsonl` |
56
57
  | 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
58
  | 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.12",
3
+ "version": "0.9.14",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -23,6 +23,7 @@
23
23
  "tokens",
24
24
  "claude",
25
25
  "codex",
26
+ "grok",
26
27
  "gemini"
27
28
  ],
28
29
  "dependencies": {},
@@ -2,17 +2,16 @@ 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
8
  // Codex stores live sessions in $CODEX_HOME/sessions (default ~/.codex) and,
8
9
  // once a session is "completed", moves its rollout file verbatim into
9
10
  // $CODEX_HOME/archived_sessions. A session can be archived between two syncs,
10
11
  // so scanning only the live dir loses that session's usage forever. We scan
11
- // both: the parser is stateless and the server dedups on
12
- // (source, sessionHash/bucket), so re-reading an archived file that was
13
- // already synced from sessions/ is idempotent. Indexing both together also
14
- // keeps fork replay-skip correct when a fork and its parent end up split
15
- // across the two directories.
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.
16
15
  function sessionsDirs() {
17
16
  const codexHome = process.env.CODEX_HOME?.trim() || join(homedir(), '.codex');
18
17
  return [
@@ -74,44 +73,236 @@ function isSubagentMeta(meta) {
74
73
  return meta.parent_thread_id != null;
75
74
  }
76
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
+
77
119
  /**
78
- * Stream a session file once and extract its index metadata: the session
79
- * id, the forked-from id, the project name, whether it is a sub-agent
80
- * rollout (and contains a task_started boundary), and the total count of
81
- * `event_msg/token_count` records. The token_count total is used to size
82
- * the replayed-history block of a forked session a fork copies the
83
- * original conversation verbatim, so it begins with exactly as many
84
- * token_count records as the source session has in total.
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.
85
162
  */
86
163
  async function indexSessionFile(filePath) {
87
164
  let sessionId = null;
88
165
  let forkedFromId = null;
166
+ let parentThreadId = null;
89
167
  let sessionProject = 'unknown';
90
- let tokenCountRecords = 0;
168
+ let sessionStartedAtMs = null;
91
169
  let isSubagent = false;
92
- let hasTaskStarted = 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;
93
180
 
94
181
  for await (const line of readLines(filePath)) {
95
182
  if (!line.trim()) continue;
96
183
  try {
97
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
+
98
197
  if (obj.type === 'session_meta' && obj.payload) {
99
- const meta = obj.payload;
100
- sessionId = meta.id || sessionId;
101
- forkedFromId = meta.forked_from_id || null;
102
- isSubagent = isSubagentMeta(meta);
103
- 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
+ }
104
208
  } else if (obj.type === 'event_msg' && obj.payload?.type === 'token_count') {
105
- tokenCountRecords++;
106
- } else if (obj.type === 'event_msg' && obj.payload?.type === 'task_started') {
107
- hasTaskStarted = true;
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
+ }
108
233
  }
109
234
  } catch {
110
235
  continue;
111
236
  }
112
237
  }
113
238
 
114
- return { sessionId, forkedFromId, sessionProject, tokenCountRecords, isSubagent, hasTaskStarted };
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 };
115
306
  }
116
307
 
117
308
  export async function parse() {
@@ -123,18 +314,11 @@ export async function parse() {
123
314
  const files = dirs.flatMap(findJsonlFiles);
124
315
  if (files.length === 0) return { buckets: [], sessions: [] };
125
316
 
126
- // Pass 1: index every session by its UUID and count its token_count
127
- // records. A forked session (session_meta.payload.forked_from_id) starts
128
- // with the original conversation replayed verbatim including every
129
- // token_count, all timestamped in a burst at the fork instant. Those
130
- // tokens are already counted from the original session's own file, so
131
- // re-counting them here double-counts usage and produces a spurious
132
- // token/cost spike at the fork time. Timestamps cannot distinguish the
133
- // replay from new activity (the replay burst is stamped at/after the fork
134
- // instant, within the same 1–3s window), so we instead skip exactly the
135
- // original session's token_count count from the start of each fork.
136
- const tokenCountById = new Map(); // sessionId → number of token_count records
137
- 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();
138
322
  for (const filePath of files) {
139
323
  let meta;
140
324
  try {
@@ -144,43 +328,23 @@ export async function parse() {
144
328
  }
145
329
  fileMeta.set(filePath, meta);
146
330
  if (meta.sessionId) {
147
- 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
+ }
148
335
  }
149
336
  }
150
337
 
151
- // Pass 2: parse usage, skipping each fork's replayed-history token_counts.
338
+ // Pass 2: parse usage while skipping the replay prefix resolved above.
152
339
  for (const filePath of files) {
153
340
  const fm = fileMeta.get(filePath);
154
341
  if (!fm) continue;
155
- const { forkedFromId } = fm;
156
-
157
- // How many leading token_count records are copied history. A fork's file
158
- // begins with the *entire* source file replayed verbatim, so the count
159
- // to skip is the source's total token_count count. This is correct even
160
- // for chained forks: a fork-of-a-fork replays the parent fork's whole
161
- // file (which itself already contains the grandparent's replay), so
162
- // skipping the parent's full count skips exactly the duplicated region.
163
- // If the source file is missing (rotated/deleted) we cannot locate the
164
- // boundary; skip nothing so incomplete data over-counts rather than
165
- // silently dropping real usage.
166
- let replayTokenCountToSkip = 0;
167
- if (forkedFromId != null) {
168
- replayTokenCountToSkip = tokenCountById.get(forkedFromId) ?? 0;
169
- }
170
- let tokenCountSeen = 0;
171
-
172
- // Sub-agent rollouts can begin with the parent session's history copied
173
- // in — including its token_count records — all re-stamped at the spawn
174
- // instant. Those tokens are already counted from the parent's own file,
175
- // so re-counting them here double-counts usage and produces a huge
176
- // spurious spike at spawn time (observed 20×+). Unlike forks there is no
177
- // forked_from_id/count to size the block by, but the copied history ends
178
- // at the sub-agent's own first task_started event, so skip everything
179
- // before it. If the file has no task_started at all (unknown/legacy
180
- // format) we can't locate the boundary; skip nothing so incomplete data
181
- // over-counts rather than silently dropping real usage.
182
- const skipUntilTaskStarted = fm.isSubagent && fm.hasTaskStarted;
183
- let taskStartedSeen = false;
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;
184
348
 
185
349
  const sessionProject = fm.sessionProject;
186
350
  // Group timing events by the real Codex session id, not the file path: the
@@ -191,39 +355,37 @@ export async function parse() {
191
355
  const sessionKey = fm.sessionId || filePath;
192
356
 
193
357
  let turnContextModel = 'unknown';
194
- const prevTotal = new Map();
358
+ let prevTotal = null;
195
359
  let prevCumulativeTotal = null;
196
360
  for await (const line of readLines(filePath)) {
197
361
  if (!line.trim()) continue;
198
362
  try {
199
363
  const obj = JSON.parse(line);
364
+ parsedRecordIndex++;
200
365
 
201
- // A fork's replayed-history block is the run from the start of the
202
- // file up to and including the Nth token_count, where N is the source
203
- // session's total token_count count. We are still inside that block
204
- // until we have *passed* the Nth token_count. (token_count is the
205
- // last event of each turn, so the boundary lands cleanly at a turn
206
- // edge the new conversation's events come strictly after it.)
207
- const inReplayBlock = tokenCountSeen < replayTokenCountToSkip;
208
-
209
- // The task_started event itself belongs to the sub-agent — it marks
210
- // the start of its own first turn — so flip the flag before deciding
211
- // whether this record is inherited.
212
- if (skipUntilTaskStarted && !taskStartedSeen
213
- && obj.type === 'event_msg' && obj.payload?.type === 'task_started') {
214
- taskStartedSeen = true;
215
- }
216
- const inInheritedBlock = skipUntilTaskStarted && !taskStartedSeen;
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;
372
+
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;
217
379
 
218
380
  if (obj.timestamp) {
219
381
  const evTs = new Date(obj.timestamp);
220
382
  if (!isNaN(evTs.getTime())) {
221
- // Skip replayed/inherited history events so a forked or sub-agent
222
- // session's duration/active-time/message counts reflect only the
223
- // new conversation, not the copied original. session_meta itself
224
- // is kept: it marks when the fork/sub-agent actually started.
225
- const isReplay = (inReplayBlock || inInheritedBlock) && obj.type !== 'session_meta';
226
- 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)) {
227
389
  const isUserTurn = obj.type === 'turn_context' || obj.type === 'session_meta';
228
390
  sessionEvents.push({
229
391
  sessionId: sessionKey,
@@ -248,21 +410,14 @@ export async function parse() {
248
410
 
249
411
  if (payload.type !== 'token_count') continue;
250
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
+
251
418
  const info = payload.info;
252
419
  if (!info) continue;
253
420
 
254
- const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
255
- if (!timestamp || isNaN(timestamp.getTime())) continue;
256
-
257
- // This is the (tokenCountSeen+1)-th token_count in the file. If it
258
- // falls inside the fork's replay block or a sub-agent's inherited
259
- // block it's an exact copy of a record already counted from the
260
- // source session's own file — skip it (but still advance the
261
- // cumulative-total baselines below so the first real delta after the
262
- // copied history is measured correctly).
263
- const isReplayedHistory = tokenCountSeen < replayTokenCountToSkip || inInheritedBlock;
264
- tokenCountSeen++;
265
-
266
421
  // Codex sometimes writes the same token_count twice back-to-back:
267
422
  // identical last_token_usage with an unchanged cumulative total. A
268
423
  // real API call always advances the cumulative counter (its input
@@ -273,34 +428,41 @@ export async function parse() {
273
428
  // total_token_usage all-zero can't suppress real usage.
274
429
  const cumulativeTotal = info.total_token_usage?.total_tokens;
275
430
  const isDuplicateEmission = typeof cumulativeTotal === 'number'
276
- && cumulativeTotal > 0 && cumulativeTotal === prevCumulativeTotal;
431
+ && cumulativeTotal > 0
432
+ && cumulativeTotal === prevCumulativeTotal;
277
433
  if (typeof cumulativeTotal === 'number') prevCumulativeTotal = cumulativeTotal;
278
434
 
279
- // 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;
280
439
  let usage = info.last_token_usage;
281
- if (!usage && info.total_token_usage) {
282
- const totalKey = `${info.model || payload.model || turnContextModel || ''}`;
283
- const prev = prevTotal.get(totalKey);
284
- const curr = info.total_token_usage;
285
- if (prev) {
286
- usage = {
287
- input_tokens: (curr.input_tokens || 0) - (prev.input_tokens || 0),
288
- output_tokens: (curr.output_tokens || 0) - (prev.output_tokens || 0),
289
- cached_input_tokens: (curr.cached_input_tokens || 0) - (prev.cached_input_tokens || 0),
290
- 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),
291
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;
292
452
  } else {
293
453
  // First cumulative entry — use as-is (it's the first event's total)
294
454
  usage = curr;
295
455
  }
296
- // Always advance the cumulative baseline, even for replayed history,
297
- // so the first real post-fork delta is measured against the last
298
- // replayed total instead of being mistaken for a fresh "first entry".
299
- prevTotal.set(totalKey, { ...curr });
300
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 };
301
460
  if (!usage) continue;
302
461
  if (isReplayedHistory || isDuplicateEmission) continue;
303
462
 
463
+ const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
464
+ if (!timestamp || isNaN(timestamp.getTime())) continue;
465
+
304
466
  const model = info.model || payload.model || turnContextModel || 'unknown';
305
467
 
306
468
  // OpenAI API: input_tokens INCLUDES cached, output_tokens INCLUDES reasoning.
@@ -0,0 +1,325 @@
1
+ import { createReadStream, existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { createInterface } from 'node:readline';
3
+ import { basename, join } from 'node:path';
4
+ import { findGrokDataDirs, getGrokSessionsDir } from '../tools.js';
5
+ import { aggregateToBuckets, extractSessions } from './index.js';
6
+
7
+ const SOURCE = 'grok';
8
+
9
+ /**
10
+ * Grok (Grok Build TUI / CLI) parser.
11
+ *
12
+ * Layout (see ~/.grok/docs/user-guide/17-sessions.md):
13
+ * $GROK_HOME/sessions/<url-encoded-cwd>/<session-id>/
14
+ * summary.json — cwd, model, timestamps
15
+ * updates.jsonl — ACP session updates; turn_completed carries exact usage
16
+ * events.jsonl — turn_started / turn_ended timing
17
+ *
18
+ * GROK_HOME defaults to ~/.grok. Override with GROK_HOME or
19
+ * VIBE_USAGE_GROK_SESSIONS (tests / relocated session trees).
20
+ *
21
+ * Token usage comes from updates.jsonl `turn_completed.usage` (and per-model
22
+ * `modelUsage` when present). inputTokens is non-cached prompt (total − cache
23
+ * reads), matching Codex/Copilot so totalTokens does not double-count cache.
24
+ */
25
+
26
+ function readJsonSafe(path) {
27
+ try {
28
+ return JSON.parse(readFileSync(path, 'utf-8'));
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function projectFromPath(absPath) {
35
+ if (!absPath || typeof absPath !== 'string') return 'unknown';
36
+ const trimmed = absPath.replace(/[\\/]+$/, '');
37
+ const name = basename(trimmed);
38
+ return name || 'unknown';
39
+ }
40
+
41
+ /** Decode a sessions group dirname; fall back to basename after decode. */
42
+ function projectFromGroupDir(groupName, groupPath) {
43
+ const cwdFile = join(groupPath, '.cwd');
44
+ if (existsSync(cwdFile)) {
45
+ try {
46
+ const raw = readFileSync(cwdFile, 'utf-8').trim();
47
+ if (raw) return projectFromPath(raw);
48
+ } catch {
49
+ // ignore
50
+ }
51
+ }
52
+ try {
53
+ const decoded = decodeURIComponent(groupName);
54
+ if (decoded.includes('/') || decoded.includes('\\')) {
55
+ return projectFromPath(decoded);
56
+ }
57
+ } catch {
58
+ // not URI-encoded
59
+ }
60
+ return groupName || 'unknown';
61
+ }
62
+
63
+ function toDate(value) {
64
+ if (value == null) return null;
65
+ if (value instanceof Date) {
66
+ return Number.isNaN(value.getTime()) ? null : value;
67
+ }
68
+ if (typeof value === 'number' && Number.isFinite(value)) {
69
+ // Unix seconds (Grok updates.jsonl) vs milliseconds
70
+ const ms = value < 1e12 ? value * 1000 : value;
71
+ const d = new Date(ms);
72
+ return Number.isNaN(d.getTime()) ? null : d;
73
+ }
74
+ if (typeof value === 'string' && value.trim()) {
75
+ const d = new Date(value);
76
+ return Number.isNaN(d.getTime()) ? null : d;
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function pushUsageEntry(entries, { model, project, timestamp, usage }) {
82
+ if (!usage || typeof usage !== 'object') return;
83
+ if (!timestamp) return;
84
+
85
+ const totalInput = Math.max(0, Number(usage.inputTokens) || 0);
86
+ const cached = Math.max(0, Number(usage.cachedReadTokens) || 0);
87
+ const output = Math.max(0, Number(usage.outputTokens) || 0);
88
+ const reasoning = Math.max(0, Number(usage.reasoningTokens) || 0);
89
+
90
+ // Prefer exclusive fields when both are present (Codex-style).
91
+ const inputTokens = Math.max(0, totalInput - cached);
92
+ const outputTokens = Math.max(0, output - reasoning);
93
+
94
+ if (inputTokens + outputTokens + cached + reasoning === 0) return;
95
+
96
+ entries.push({
97
+ source: SOURCE,
98
+ model: model || 'unknown',
99
+ project,
100
+ timestamp,
101
+ inputTokens,
102
+ outputTokens,
103
+ cachedInputTokens: cached,
104
+ reasoningOutputTokens: reasoning,
105
+ });
106
+ }
107
+
108
+ function emitTurnUsage(entries, { usage, project, timestamp, fallbackModel }) {
109
+ if (!usage || typeof usage !== 'object') return;
110
+
111
+ const modelUsage = usage.modelUsage;
112
+ if (modelUsage && typeof modelUsage === 'object' && Object.keys(modelUsage).length > 0) {
113
+ for (const [model, mUsage] of Object.entries(modelUsage)) {
114
+ pushUsageEntry(entries, {
115
+ model,
116
+ project,
117
+ timestamp,
118
+ usage: mUsage && typeof mUsage === 'object' ? mUsage : usage,
119
+ });
120
+ }
121
+ return;
122
+ }
123
+
124
+ pushUsageEntry(entries, {
125
+ model: fallbackModel,
126
+ project,
127
+ timestamp,
128
+ usage,
129
+ });
130
+ }
131
+
132
+ async function forEachJsonlLine(filePath, onLine) {
133
+ if (!existsSync(filePath)) return;
134
+ let stream;
135
+ try {
136
+ stream = createReadStream(filePath, { encoding: 'utf-8' });
137
+ } catch {
138
+ return;
139
+ }
140
+
141
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
142
+ try {
143
+ for await (const line of rl) {
144
+ const trimmed = line.trim();
145
+ if (!trimmed) continue;
146
+ let obj;
147
+ try {
148
+ obj = JSON.parse(trimmed);
149
+ } catch {
150
+ continue;
151
+ }
152
+ onLine(obj);
153
+ }
154
+ } catch {
155
+ // unreadable / truncated mid-write — keep what we have
156
+ } finally {
157
+ rl.close();
158
+ stream.destroy();
159
+ }
160
+ }
161
+
162
+ function listSessionDirs(sessionsDir) {
163
+ const results = [];
164
+ if (!existsSync(sessionsDir)) return results;
165
+
166
+ let groups;
167
+ try {
168
+ groups = readdirSync(sessionsDir, { withFileTypes: true });
169
+ } catch {
170
+ return results;
171
+ }
172
+
173
+ for (const group of groups) {
174
+ if (!group.isDirectory()) continue;
175
+ // Skip non-project group dirs (e.g. future index folders).
176
+ const groupPath = join(sessionsDir, group.name);
177
+ let children;
178
+ try {
179
+ children = readdirSync(groupPath, { withFileTypes: true });
180
+ } catch {
181
+ continue;
182
+ }
183
+
184
+ const projectFallback = projectFromGroupDir(group.name, groupPath);
185
+
186
+ for (const child of children) {
187
+ if (!child.isDirectory()) continue;
188
+ const sessionPath = join(groupPath, child.name);
189
+ // A real session always has summary.json (or at least updates/chat history).
190
+ if (
191
+ !existsSync(join(sessionPath, 'summary.json')) &&
192
+ !existsSync(join(sessionPath, 'updates.jsonl'))
193
+ ) {
194
+ continue;
195
+ }
196
+ results.push({
197
+ sessionId: child.name,
198
+ sessionPath,
199
+ projectFallback,
200
+ });
201
+ }
202
+ }
203
+
204
+ return results;
205
+ }
206
+
207
+ /**
208
+ * Parse all Grok sessions under the configured sessions root(s).
209
+ * @returns {Promise<{ buckets: object[], sessions: object[] }>}
210
+ */
211
+ export async function parse() {
212
+ const sessionRoots = findGrokDataDirs();
213
+ // findGrokDataDirs returns sessions dirs; also allow empty → try default once
214
+ const roots = sessionRoots.length > 0 ? sessionRoots : [getGrokSessionsDir()].filter(existsSync);
215
+ if (roots.length === 0) return { buckets: [], sessions: [] };
216
+
217
+ const entries = [];
218
+ const sessionEvents = [];
219
+
220
+ for (const sessionsDir of roots) {
221
+ for (const { sessionId, sessionPath, projectFallback } of listSessionDirs(sessionsDir)) {
222
+ const summary = readJsonSafe(join(sessionPath, 'summary.json')) || {};
223
+ const cwd = summary.info?.cwd || summary.git_root_dir || null;
224
+ const project = cwd ? projectFromPath(cwd) : projectFallback;
225
+ const fallbackModel = summary.current_model_id || 'unknown';
226
+
227
+ // Prefer updates.jsonl turn_completed for exact usage + message timings.
228
+ let sawUserOrAssistant = false;
229
+ await forEachJsonlLine(join(sessionPath, 'updates.jsonl'), (obj) => {
230
+ const update = obj?.params?.update;
231
+ if (!update || typeof update !== 'object') return;
232
+
233
+ const kind = update.sessionUpdate;
234
+ const timestamp = toDate(obj.timestamp);
235
+
236
+ if (kind === 'turn_completed' && timestamp) {
237
+ emitTurnUsage(entries, {
238
+ usage: update.usage,
239
+ project,
240
+ timestamp,
241
+ fallbackModel,
242
+ });
243
+ }
244
+
245
+ if (!timestamp) return;
246
+
247
+ if (kind === 'user_message_chunk') {
248
+ sawUserOrAssistant = true;
249
+ sessionEvents.push({
250
+ sessionId,
251
+ source: SOURCE,
252
+ project,
253
+ timestamp,
254
+ role: 'user',
255
+ });
256
+ } else if (kind === 'agent_message_chunk' || kind === 'turn_completed') {
257
+ sawUserOrAssistant = true;
258
+ sessionEvents.push({
259
+ sessionId,
260
+ source: SOURCE,
261
+ project,
262
+ timestamp,
263
+ role: 'assistant',
264
+ });
265
+ }
266
+ });
267
+
268
+ // Fallback timing from events.jsonl when updates lack message chunks
269
+ // (short/aborted sessions, older builds).
270
+ if (!sawUserOrAssistant) {
271
+ await forEachJsonlLine(join(sessionPath, 'events.jsonl'), (obj) => {
272
+ const timestamp = toDate(obj.ts || obj.timestamp);
273
+ if (!timestamp) return;
274
+ if (obj.type === 'turn_started') {
275
+ sessionEvents.push({
276
+ sessionId,
277
+ source: SOURCE,
278
+ project,
279
+ timestamp,
280
+ role: 'user',
281
+ });
282
+ } else if (obj.type === 'turn_ended' || obj.type === 'first_token') {
283
+ sessionEvents.push({
284
+ sessionId,
285
+ source: SOURCE,
286
+ project,
287
+ timestamp,
288
+ role: 'assistant',
289
+ });
290
+ }
291
+ });
292
+ }
293
+
294
+ // Last-resort session envelope from summary timestamps so a session with
295
+ // no parseable turns still appears once usage lands later.
296
+ if (sessionEvents.every((e) => e.sessionId !== sessionId)) {
297
+ const created = toDate(summary.created_at || summary.info?.created_at);
298
+ const updated = toDate(summary.updated_at || summary.last_active_at);
299
+ if (created) {
300
+ sessionEvents.push({
301
+ sessionId,
302
+ source: SOURCE,
303
+ project,
304
+ timestamp: created,
305
+ role: 'user',
306
+ });
307
+ }
308
+ if (updated && (!created || updated.getTime() !== created.getTime())) {
309
+ sessionEvents.push({
310
+ sessionId,
311
+ source: SOURCE,
312
+ project,
313
+ timestamp: updated,
314
+ role: 'assistant',
315
+ });
316
+ }
317
+ }
318
+ }
319
+ }
320
+
321
+ return {
322
+ buckets: aggregateToBuckets(entries),
323
+ sessions: extractSessions(sessionEvents),
324
+ };
325
+ }
@@ -6,6 +6,7 @@ import { parse as parseCopilotCli } from './copilot-cli.js';
6
6
  import { parse as parseCursor } from './cursor.js';
7
7
  import { parse as parseRooCode } from './roo-code.js';
8
8
  import { parse as parseGeminiCli } from './gemini-cli.js';
9
+ import { parse as parseGrok } from './grok.js';
9
10
  import { parse as parseOpencode } from './opencode.js';
10
11
  import { parse as parseOpenclaw } from './openclaw.js';
11
12
  import { parse as parseQwenCode } from './qwen-code.js';
@@ -21,24 +22,25 @@ import { parse as parseTraeCli } from './trae-cli.js';
21
22
 
22
23
  export const parsers = {
23
24
  'claude-code': parseClaudeCode,
24
- 'cline': parseCline,
25
25
  'codex': parseCodex,
26
+ 'grok': parseGrok,
26
27
  'copilot-cli': parseCopilotCli,
27
28
  'cursor': parseCursor,
28
- 'roo-code': parseRooCode,
29
29
  'gemini-cli': parseGeminiCli,
30
30
  'opencode': parseOpencode,
31
31
  'openclaw': parseOpenclaw,
32
+ 'pi-coding-agent': parsePiCodingAgent,
32
33
  'qwen-code': parseQwenCode,
33
34
  'kimi-code': parseKimiCode,
34
35
  'amp': parseAmp,
35
36
  'droid': parseDroid,
36
37
  'antigravity': parseAntigravity,
38
+ 'trae-cli': parseTraeCli,
37
39
  'hermes': parseHermes,
38
40
  'kiro': parseKiro,
39
- 'pi-coding-agent': parsePiCodingAgent,
41
+ 'cline': parseCline,
42
+ 'roo-code': parseRooCode,
40
43
  'zcode': parseZcode,
41
- 'trae-cli': parseTraeCli,
42
44
  };
43
45
 
44
46
 
package/src/skill.js CHANGED
@@ -68,7 +68,7 @@ Track and answer questions about the user's AI coding token spend via [Vibe Usag
68
68
  - \`summary\` 读 \`~/.vibe-usage/config.json\` 里已有的 API key,不需要用户额外输入
69
69
  - summary 输出已是 markdown,**原样展示,不要复述**
70
70
  - 用户没装过 vibe-usage?提示运行 \`npx @vibe-cafe/vibe-usage\` 先用浏览器登录链接账号
71
- - 支持的工具:Claude Code, Codex CLI, Copilot CLI, Gemini CLI, OpenCode, OpenClaw, Qwen Code, Kimi Code, Amp, Droid
71
+ - 支持的工具:Claude Code, Codex, Grok
72
72
  `;
73
73
 
74
74
  export async function runSkill(args = []) {
package/src/tools.js CHANGED
@@ -136,37 +136,47 @@ export function findTraeCliDataDirs() {
136
136
  return [join(xdgCacheHome, 'trae-cli', 'sessions')].filter(existsSync);
137
137
  }
138
138
 
139
+ /** Grok home: GROK_HOME env (same as the Grok CLI) or ~/.grok. */
140
+ export function getGrokHome() {
141
+ const envHome = process.env.GROK_HOME?.trim();
142
+ if (envHome) {
143
+ return envHome.startsWith('~') ? join(homedir(), envHome.slice(1)) : envHome;
144
+ }
145
+ return join(homedir(), '.grok');
146
+ }
147
+
148
+ export function getGrokSessionsDir() {
149
+ const testDir = process.env.VIBE_USAGE_GROK_SESSIONS?.trim();
150
+ if (testDir) return testDir;
151
+ return join(getGrokHome(), 'sessions');
152
+ }
153
+
154
+ // Detect Grok when sessions/ exists under GROK_HOME (or the test override).
155
+ export function findGrokDataDirs() {
156
+ const testDir = process.env.VIBE_USAGE_GROK_SESSIONS?.trim();
157
+ if (testDir) return [testDir].filter(existsSync);
158
+ return [join(getGrokHome(), 'sessions')].filter(existsSync);
159
+ }
160
+
139
161
  export const TOOLS = [
140
- {
141
- name: 'Trae CLI',
142
- id: 'trae-cli',
143
- dataDir: join(homedir(), 'Library', 'Caches', 'trae-cli', 'sessions'),
144
- detectDataDirs: findTraeCliDataDirs,
145
- },
146
- {
147
- name: 'Antigravity',
148
- id: 'antigravity',
149
- dataDir: join(homedir(), '.gemini', 'antigravity'),
150
- detectDataDirs: findAntigravityDataDirs,
151
- },
152
162
  {
153
163
  name: 'Claude Code',
154
164
  id: 'claude-code',
155
165
  dataDir: join(homedir(), '.claude', 'projects'),
156
166
  detectDataDirs: findClaudeCodeDataDirs,
157
167
  },
158
- {
159
- name: 'Cline',
160
- id: 'cline',
161
- dataDir: join(homedir(), 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev'),
162
- detectDataDirs: findClineDataDirs,
163
- },
164
168
  {
165
169
  name: 'Codex CLI',
166
170
  id: 'codex',
167
171
  dataDir: join(homedir(), '.codex', 'sessions'),
168
172
  detectDataDirs: findCodexDataDirs,
169
173
  },
174
+ {
175
+ name: 'Grok',
176
+ id: 'grok',
177
+ dataDir: join(homedir(), '.grok', 'sessions'),
178
+ detectDataDirs: findGrokDataDirs,
179
+ },
170
180
  {
171
181
  name: 'GitHub Copilot CLI',
172
182
  id: 'copilot-cli',
@@ -221,6 +231,18 @@ export const TOOLS = [
221
231
  id: 'droid',
222
232
  dataDir: join(homedir(), '.factory', 'sessions'),
223
233
  },
234
+ {
235
+ name: 'Antigravity',
236
+ id: 'antigravity',
237
+ dataDir: join(homedir(), '.gemini', 'antigravity'),
238
+ detectDataDirs: findAntigravityDataDirs,
239
+ },
240
+ {
241
+ name: 'Trae CLI',
242
+ id: 'trae-cli',
243
+ dataDir: join(homedir(), 'Library', 'Caches', 'trae-cli', 'sessions'),
244
+ detectDataDirs: findTraeCliDataDirs,
245
+ },
224
246
  {
225
247
  name: 'Hermes',
226
248
  id: 'hermes',
@@ -231,6 +253,12 @@ export const TOOLS = [
231
253
  id: 'kiro',
232
254
  dataDir: getKiroAgentPath(),
233
255
  },
256
+ {
257
+ name: 'Cline',
258
+ id: 'cline',
259
+ dataDir: join(homedir(), 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev'),
260
+ detectDataDirs: findClineDataDirs,
261
+ },
234
262
  {
235
263
  name: 'Roo Code',
236
264
  id: 'roo-code',