@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 +2 -1
- package/package.json +2 -1
- package/src/parsers/codex.js +279 -117
- package/src/parsers/grok.js +325 -0
- package/src/parsers/index.js +6 -4
- package/src/skill.js +1 -1
- package/src/tools.js +46 -18
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
|
|
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.
|
|
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": {},
|
package/src/parsers/codex.js
CHANGED
|
@@ -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
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
168
|
+
let sessionStartedAtMs = null;
|
|
91
169
|
let isSubagent = false;
|
|
92
|
-
let
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 {
|
|
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:
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
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)) {
|
|
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
|
|
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
|
|
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 &&
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
+
}
|
package/src/parsers/index.js
CHANGED
|
@@ -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
|
-
'
|
|
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
|
|
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',
|