@vibe-cafe/vibe-usage 0.9.10 → 0.9.12

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 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 |
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 |
@@ -66,7 +66,8 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
66
66
  | Kiro | Kiro CLI native event streams `~/.kiro/sessions/cli/*.jsonl` (estimated tokens from message text: input = prompt + tool results, output = reply + tool calls, reasoning = thinking, cacheRead = re-sent context; thinking-block signatures excluded). Falls back to `~/Library/Application Support/kiro-cli/data.sqlite3` / `~/.local/share/kiro-cli/data.sqlite3` + optional `~/.kiro_sessions/*.json` archives, then IDE `q-client.log` whole-credit deltas as `kiro-credits` (floored cumulative diff — the server stores token counts as bigint); legacy IDE `dev_data/devdata.sqlite` token telemetry is opt-in with `VIBE_USAGE_KIRO_LEGACY_TOKENS=1` |
67
67
  | Cline | `<host>/User/globalStorage/saoudrizwan.claude-dev/{state/taskHistory.json,tasks/<id>/ui_messages.json}` (walks all VSCode-fork hosts: Code, Cursor, Windsurf, VSCodium, Trae, ...) |
68
68
  | Roo Code | `<host>/User/globalStorage/rooveterinaryinc.roo-cline/{tasks/_index.json,tasks/<id>/{history_item,ui_messages}.json}` (walks all VSCode-fork hosts) |
69
- | Antigravity | `~/.gemini/antigravity/conversations/*.pb` / `*.db` to discover cascades, then reads token usage + sessions from the running language server via Connect RPC (process discovered with `ps`/`lsof` on macOS/Linux, PowerShell CIM with a `wmic` fallback on Windows) |
69
+ | Trae CLI | macOS: `~/Library/Caches/trae-cli/sessions/`; Windows: `%LOCALAPPDATA%/trae-cli/cache/sessions/`; Linux: `~/.cache/trae-cli/sessions/` (CLI telemetry only; Trae IDE/Trae Work chats are not supported) |
70
+ | Antigravity | App 2.0 `~/.gemini/antigravity/conversations/*.db` and `agy` CLI `~/.gemini/antigravity-cli/conversations/*.db` are parsed offline (tokens, real model display name, project, sessions); legacy App `.pb` history falls back to Connect RPC while the language server is running |
70
71
  | ZCode | `~/.zcode/cli/db/db.sqlite` (SQLite; reads the `message` table for per-message tokens, model, and project `cwd`/`root`, joined to `session.directory`) |
71
72
 
72
73
  ## How It Works
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -0,0 +1,307 @@
1
+ import { copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { queryDbJson } from './sqlite.js';
5
+
6
+ /**
7
+ * Offline reader for Antigravity SQLite conversation stores.
8
+ *
9
+ * Antigravity 2.0 (standalone app) and the `agy` CLI both persist each cascade
10
+ * as a per-conversation SQLite `.db` file whose `gen_metadata` table holds one
11
+ * protobuf-encoded GeneratorMetadata per row. Unlike the legacy `.pb` files
12
+ * (which are encrypted/opaque and only decodable by a running language server),
13
+ * these blobs are plain protobuf, so we can extract token usage directly from
14
+ * disk — no running process, no RPC.
15
+ *
16
+ * The wire-format tag numbers below were cross-verified against the language
17
+ * server's GetCascadeTrajectory JSON for the same responseId:
18
+ *
19
+ * chatModel = field 1
20
+ * usage = field 4
21
+ * inputTokens = 4.2
22
+ * outputTokens = 4.3
23
+ * cacheReadTokens = 4.5
24
+ * thinkingOutputTokens = 4.9
25
+ * responseId = 4.11
26
+ * chatStartMetadata = field 9
27
+ * createdAt (Timestamp) = 9.4 → seconds = 9.4.1
28
+ * responseModel = field 19 ("gemini-3-flash-a" / "gemini-default")
29
+ * modelDisplayName = field 21 ("Gemini 3.5 Flash (High/Medium/Low)")
30
+ */
31
+
32
+ // ── Minimal protobuf wire-format decoder (no dependency) ──────────────
33
+
34
+ /** Read a base-128 varint. Returns [value: number, newPos]. */
35
+ function readVarint(buf, pos) {
36
+ let result = 0n;
37
+ let shift = 0n;
38
+ while (pos < buf.length) {
39
+ const byte = buf[pos++];
40
+ result |= BigInt(byte & 0x7f) << shift;
41
+ if ((byte & 0x80) === 0) break;
42
+ shift += 7n;
43
+ }
44
+ return [Number(result), pos];
45
+ }
46
+
47
+ /**
48
+ * Decode one protobuf message into Map<fieldNumber, Array<{wireType, value}>>.
49
+ * Length-delimited values are kept as raw Buffers (caller decides string vs
50
+ * sub-message). Unknown wire types abort parsing of the rest of the message.
51
+ */
52
+ function decodeMessage(buf) {
53
+ const fields = new Map();
54
+ let pos = 0;
55
+ while (pos < buf.length) {
56
+ let tag;
57
+ [tag, pos] = readVarint(buf, pos);
58
+ const fieldNum = tag >> 3;
59
+ const wireType = tag & 0x7;
60
+ let value;
61
+ if (wireType === 0) {
62
+ [value, pos] = readVarint(buf, pos);
63
+ } else if (wireType === 2) {
64
+ let len;
65
+ [len, pos] = readVarint(buf, pos);
66
+ value = buf.subarray(pos, pos + len);
67
+ pos += len;
68
+ } else if (wireType === 5) {
69
+ value = buf.subarray(pos, pos + 4);
70
+ pos += 4;
71
+ } else if (wireType === 1) {
72
+ value = buf.subarray(pos, pos + 8);
73
+ pos += 8;
74
+ } else {
75
+ break; // group/unknown — stop
76
+ }
77
+ if (!fields.has(fieldNum)) fields.set(fieldNum, []);
78
+ fields.get(fieldNum).push({ wireType, value });
79
+ }
80
+ return fields;
81
+ }
82
+
83
+ function firstVarint(fields, num) {
84
+ const arr = fields.get(num);
85
+ const e = arr && arr.find((x) => x.wireType === 0);
86
+ return e ? e.value : undefined;
87
+ }
88
+
89
+ function firstBytes(fields, num) {
90
+ const arr = fields.get(num);
91
+ const e = arr && arr.find((x) => x.wireType === 2);
92
+ return e ? e.value : undefined;
93
+ }
94
+
95
+ function firstString(fields, num) {
96
+ const b = firstBytes(fields, num);
97
+ return b ? Buffer.from(b).toString('utf-8') : undefined;
98
+ }
99
+
100
+ function firstMessage(fields, num) {
101
+ const b = firstBytes(fields, num);
102
+ return b ? decodeMessage(b) : undefined;
103
+ }
104
+
105
+ // ── GeneratorMetadata parsing ─────────────────────────────────────────
106
+
107
+ /**
108
+ * Parse one gen_metadata blob into a normalized usage record, or null if it
109
+ * carries no token usage (error/planning placeholders have none).
110
+ *
111
+ * @param {Buffer} buf raw protobuf bytes of a GeneratorMetadata row
112
+ * @returns {{inputTokens, outputTokens, cacheReadTokens, thinkingOutputTokens,
113
+ * responseId, timestamp: Date|null, displayName, responseModel}|null}
114
+ */
115
+ export function parseGenMetadataBlob(buf) {
116
+ const chatModel = firstMessage(decodeMessage(buf), 1);
117
+ if (!chatModel) return null;
118
+
119
+ const usage = firstMessage(chatModel, 4);
120
+ if (!usage) return null;
121
+
122
+ const inputTokens = firstVarint(usage, 2) || 0;
123
+ const outputTokens = firstVarint(usage, 3) || 0;
124
+ const cacheReadTokens = firstVarint(usage, 5) || 0;
125
+ const thinkingOutputTokens = firstVarint(usage, 9) || 0;
126
+ const responseId = firstString(usage, 11) || '';
127
+
128
+ // Skip rows with no real usage (errors, planning-only steps).
129
+ if (!inputTokens && !outputTokens && !cacheReadTokens && !thinkingOutputTokens) {
130
+ return null;
131
+ }
132
+
133
+ const chatStartMetadata = firstMessage(chatModel, 9);
134
+ const createdAt = chatStartMetadata ? firstMessage(chatStartMetadata, 4) : undefined;
135
+ const seconds = createdAt ? firstVarint(createdAt, 1) : undefined;
136
+ const timestamp = seconds ? new Date(seconds * 1000) : null;
137
+
138
+ return {
139
+ inputTokens,
140
+ outputTokens,
141
+ cacheReadTokens,
142
+ thinkingOutputTokens,
143
+ responseId,
144
+ timestamp,
145
+ displayName: firstString(chatModel, 21) || '',
146
+ responseModel: firstString(chatModel, 19) || '',
147
+ };
148
+ }
149
+
150
+ // ── SQLite store reading ──────────────────────────────────────────────
151
+
152
+ function isLockError(err) {
153
+ return err && typeof err.message === 'string' && /database is locked/i.test(err.message);
154
+ }
155
+
156
+ function isSqliteUnavailableError(err) {
157
+ return err && typeof err.message === 'string' && /ENOENT|sqlite3.*not found/i.test(err.message);
158
+ }
159
+
160
+ function queryCascadeDb(conversationsDir, cascadeId, sql) {
161
+ const dbPath = join(conversationsDir, `${cascadeId}.db`);
162
+ try {
163
+ return queryDbJson(dbPath, sql);
164
+ } catch (err) {
165
+ if (isSqliteUnavailableError(err)) {
166
+ throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync Antigravity data.');
167
+ }
168
+ if (!isLockError(err)) throw err;
169
+
170
+ // The App can hold the live DB open. Query a WAL-consistent snapshot so
171
+ // one active cascade does not make the whole Antigravity parser go empty.
172
+ const snapshotDir = mkdtempSync(join(tmpdir(), 'vibe-usage-antigravity-'));
173
+ const snapshotPath = join(snapshotDir, `${cascadeId}.db`);
174
+ try {
175
+ copyFileSync(dbPath, snapshotPath);
176
+ for (const suffix of ['-shm', '-wal']) {
177
+ const companion = `${dbPath}${suffix}`;
178
+ if (existsSync(companion)) copyFileSync(companion, `${snapshotPath}${suffix}`);
179
+ }
180
+ return queryDbJson(snapshotPath, sql);
181
+ } finally {
182
+ rmSync(snapshotDir, { recursive: true, force: true });
183
+ }
184
+ }
185
+ }
186
+
187
+ /** List cascade IDs backed by a `.db` file in a conversations directory. */
188
+ export function listDbCascades(conversationsDir) {
189
+ try {
190
+ const out = [];
191
+ for (const f of readdirSync(conversationsDir)) {
192
+ if (f.endsWith('.db') && f !== 'db.sqlite') out.push(f.slice(0, -3));
193
+ }
194
+ return out;
195
+ } catch {
196
+ return [];
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Read all gen_metadata blobs from one cascade `.db` and return parsed usage
202
+ * records. blob is fetched as hex text so it round-trips through both the
203
+ * node:sqlite and sqlite3-CLI backends uniformly.
204
+ */
205
+ export function readDbUsageRecords(conversationsDir, cascadeId) {
206
+ let rows;
207
+ try {
208
+ rows = queryCascadeDb(conversationsDir, cascadeId, 'SELECT hex(data) AS h FROM gen_metadata ORDER BY idx');
209
+ } catch (err) {
210
+ if (isSqliteUnavailableError(err)) throw err;
211
+ return [];
212
+ }
213
+ const records = [];
214
+ for (const row of rows) {
215
+ if (!row.h) continue;
216
+ let rec;
217
+ try {
218
+ rec = parseGenMetadataBlob(Buffer.from(row.h, 'hex'));
219
+ } catch {
220
+ continue; // one malformed blob must not kill the rest
221
+ }
222
+ if (rec) records.push(rec);
223
+ }
224
+ return records;
225
+ }
226
+
227
+ /**
228
+ * Read the workspace URI for a cascade from trajectory_metadata_blob.
229
+ * Structure: field 1 = workspaces[0], 1.1 = workspaceFolderAbsoluteUri.
230
+ * Returns the raw file:// URI, or null.
231
+ */
232
+ export function readDbWorkspaceUri(conversationsDir, cascadeId) {
233
+ let rows;
234
+ try {
235
+ rows = queryCascadeDb(conversationsDir, cascadeId, 'SELECT hex(data) AS h FROM trajectory_metadata_blob LIMIT 1');
236
+ } catch (err) {
237
+ if (isSqliteUnavailableError(err)) throw err;
238
+ return null;
239
+ }
240
+ if (!rows.length || !rows[0].h) return null;
241
+ try {
242
+ const meta = decodeMessage(Buffer.from(rows[0].h, 'hex'));
243
+ const ws0 = firstMessage(meta, 1);
244
+ return (ws0 && firstString(ws0, 1)) || null;
245
+ } catch {
246
+ return null;
247
+ }
248
+ }
249
+
250
+ // Step source enum in steps.metadata field 3 (behavior-verified against payload
251
+ // contents, since it mirrors the RPC's CORTEX_STEP_SOURCE_*): 4 = user turn,
252
+ // 2 = model turn. Everything else (system, tool, unspecified) is skipped.
253
+ const STEP_SOURCE_USER = 4;
254
+ const STEP_SOURCE_MODEL = 2;
255
+
256
+ /**
257
+ * Parse one steps.metadata blob into a session-timing event, or null if the
258
+ * step is neither a user nor a model turn (system/tool/unspecified). createdAt
259
+ * is a Timestamp at field 1 (seconds at 1.1); source enum is field 3.
260
+ *
261
+ * @param {Buffer} buf
262
+ * @returns {{role:'user'|'assistant', timestamp: Date}|null}
263
+ */
264
+ export function parseStepMetadata(buf) {
265
+ const meta = decodeMessage(buf);
266
+ const source = firstVarint(meta, 3);
267
+ let role;
268
+ if (source === STEP_SOURCE_USER) role = 'user';
269
+ else if (source === STEP_SOURCE_MODEL) role = 'assistant';
270
+ else return null;
271
+
272
+ const createdAt = firstMessage(meta, 1);
273
+ const seconds = createdAt ? firstVarint(createdAt, 1) : undefined;
274
+ if (!seconds) return null;
275
+
276
+ return { role, timestamp: new Date(seconds * 1000) };
277
+ }
278
+
279
+ /**
280
+ * Read session timing events (user/assistant turns) for a cascade from the
281
+ * steps table, chronological by idx.
282
+ */
283
+ export function readDbSessionEvents(conversationsDir, cascadeId) {
284
+ let rows;
285
+ try {
286
+ rows = queryCascadeDb(
287
+ conversationsDir,
288
+ cascadeId,
289
+ 'SELECT hex(metadata) AS h FROM steps WHERE metadata IS NOT NULL ORDER BY idx',
290
+ );
291
+ } catch (err) {
292
+ if (isSqliteUnavailableError(err)) throw err;
293
+ return [];
294
+ }
295
+ const events = [];
296
+ for (const row of rows) {
297
+ if (!row.h) continue;
298
+ let ev;
299
+ try {
300
+ ev = parseStepMetadata(Buffer.from(row.h, 'hex'));
301
+ } catch {
302
+ continue;
303
+ }
304
+ if (ev) events.push(ev);
305
+ }
306
+ return events;
307
+ }
@@ -3,18 +3,25 @@ import { readdirSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { aggregateToBuckets, extractSessions } from './index.js';
6
+ import { listDbCascades, readDbUsageRecords, readDbWorkspaceUri, readDbSessionEvents } from './antigravity-db.js';
6
7
 
7
8
 
8
9
 
9
10
  /**
10
- * Antigravity parser (file-based).
11
- * Scans .pb/.db files in ~/.gemini/antigravity/conversations/ to discover cascade IDs.
12
- * Calls GetCascadeTrajectory via a running language server to extract token usage
13
- * (from generatorMetadata) and session events (from trajectory steps).
11
+ * Antigravity parser.
12
+ *
13
+ * Two conversation stores, two read paths:
14
+ * - `.db` cascades (App 2.0 + `agy` CLI): plain-protobuf SQLite, parsed offline
15
+ * from disk — no running process required (see antigravity-db.js).
16
+ * - `.pb` cascades (legacy App history): encrypted/opaque, only decodable via a
17
+ * running language server's GetCascadeTrajectory RPC (fallback below).
18
+ * A cascade backed by a `.db` never uses RPC, so the two paths never double-count.
14
19
  */
15
20
 
16
21
  const SOURCE = 'antigravity';
17
22
  const CONVERSATIONS_DIR = join(homedir(), '.gemini', 'antigravity', 'conversations');
23
+ // `agy` CLI stores conversations in a separate, App-independent directory.
24
+ const CLI_CONVERSATIONS_DIR = join(homedir(), '.gemini', 'antigravity-cli', 'conversations');
18
25
 
19
26
  // User sources → role 'user'; Model source → role 'assistant'; System sources → skip
20
27
  const USER_SOURCES = new Set([
@@ -221,12 +228,14 @@ async function probeHttpPort(ports, csrfToken) {
221
228
  /**
222
229
  * Normalize model names to canonical forms.
223
230
  */
231
+ // Normalize model names to canonical forms. NOTE: only legacy .pb data (which
232
+ // exposes bare slugs via responseModel) reaches this; .db data uses the
233
+ // human-readable modelDisplayName verbatim (e.g. "Gemini 3.5 Flash (High)"),
234
+ // which is never normalized. Flash reasoning tiers (-a/-b/-c) are intentionally
235
+ // NOT merged: each tier is a distinct choice and left as-is ("as it is").
224
236
  const MODEL_NORMALIZE_MAP = {
225
237
  'claude-opus-4-6-thinking': 'claude-opus-4-6',
226
238
  'claude-sonnet-4-6-thinking': 'claude-sonnet-4-6',
227
- 'gemini-3-flash-a': 'gemini-3-flash',
228
- 'gemini-3-flash-b': 'gemini-3-flash',
229
- 'gemini-3-flash-c': 'gemini-3-flash',
230
239
  "gemini-3.1-pro-high": "gemini-3.1-pro",
231
240
  "gemini-3.1-pro-low": "gemini-3.1-pro",
232
241
  "gemini-3-pro-high": "gemini-3-pro",
@@ -244,10 +253,6 @@ const PLACEHOLDER_MODEL_MAP = {
244
253
  'MODEL_PLACEHOLDER_M35': 'claude-sonnet-4-6',
245
254
  'MODEL_PLACEHOLDER_M26': 'claude-opus-4-6',
246
255
  'MODEL_OPENAI_GPT_OSS_120B_MEDIUM': 'gpt-oss-120b',
247
- // Antigravity 2.0+ standalone app placeholders (verified via responseModel)
248
- 'MODEL_PLACEHOLDER_M132': 'gemini-3-flash',
249
- 'MODEL_PLACEHOLDER_M133': 'gemini-3-flash',
250
- 'MODEL_PLACEHOLDER_M20': 'gemini-3-flash',
251
256
  };
252
257
 
253
258
  function normalizeModel(raw) {
@@ -255,9 +260,16 @@ function normalizeModel(raw) {
255
260
  }
256
261
 
257
262
  /**
258
- * Resolve model name: prefer responseModel, fall back to placeholder map.
263
+ * Resolve a display model name from a chatModel-like object.
264
+ * Priority: modelDisplayName (real name, e.g. "Gemini 3.5 Flash (High)") →
265
+ * responseModel slug (normalized) → placeholder map → "unknown".
266
+ *
267
+ * modelDisplayName is present on .db data (App 2.0 + CLI) and is authoritative;
268
+ * it is used verbatim (carries the reasoning tier). Legacy .pb data has no
269
+ * display name, so it falls back to the responseModel slug.
259
270
  */
260
271
  function resolveModel(chatModel) {
272
+ if (chatModel.modelDisplayName) return chatModel.modelDisplayName;
261
273
  if (chatModel.responseModel) return normalizeModel(chatModel.responseModel);
262
274
  const placeholder = chatModel.model || '';
263
275
  if (PLACEHOLDER_MODEL_MAP[placeholder]) return PLACEHOLDER_MODEL_MAP[placeholder];
@@ -280,21 +292,16 @@ function projectFromUri(uri) {
280
292
  }
281
293
 
282
294
  /**
283
- * List cascade IDs from .pb and .db files in the conversations directory.
284
- * Antigravity 2.0+ stores new conversations as SQLite .db files instead of .pb.
295
+ * List cascade IDs backed by a legacy `.pb` file (App history). `.db` cascades
296
+ * are handled separately via offline parsing.
285
297
  */
286
- function listCascades() {
298
+ function listPbCascades() {
287
299
  try {
288
- const files = readdirSync(CONVERSATIONS_DIR);
289
- const seen = new Set();
290
- for (const f of files) {
291
- // SQLite auxiliaries (foo.db-wal / foo.db-shm) don't end with .db, so no
292
- // extra filtering is needed here.
293
- if (f.endsWith('.pb') || f.endsWith('.db')) {
294
- seen.add(f.slice(0, -3));
295
- }
300
+ const out = [];
301
+ for (const f of readdirSync(CONVERSATIONS_DIR)) {
302
+ if (f.endsWith('.pb')) out.push(f.slice(0, -3));
296
303
  }
297
- return [...seen];
304
+ return out;
298
305
  } catch {
299
306
  return [];
300
307
  }
@@ -302,113 +309,133 @@ function listCascades() {
302
309
 
303
310
  // ── Main parse ───────────────────────────────────────────────────────
304
311
 
305
- export async function parse() {
306
- // Step 1: List cascade conversation files (.pb / .db)
307
- const cascadeIds = listCascades();
308
- if (cascadeIds.length === 0) return { buckets: [], sessions: [] };
309
-
310
- // Step 2: Find a running language server to make RPC calls
311
- const server = findLanguageServer();
312
- if (!server) return { buckets: [], sessions: [] };
313
-
314
- const ports = findListeningPorts(server.pid);
315
- if (ports.length === 0) return { buckets: [], sessions: [] };
316
-
317
- const baseUrl = await probeHttpPort(ports, server.csrfToken);
318
- if (!baseUrl) return { buckets: [], sessions: [] };
319
-
320
- const rpc = (method, body) =>
321
- rpcPost(
322
- baseUrl,
323
- `/exa.language_server_pb.LanguageServerService/${method}`,
324
- body,
325
- server.csrfToken,
326
- );
312
+ /** Model name for an offline .db record: real display name → slug → unknown. */
313
+ function modelFromRecord(rec) {
314
+ if (rec.displayName) return rec.displayName;
315
+ if (rec.responseModel) return normalizeModel(rec.responseModel);
316
+ return 'unknown';
317
+ }
327
318
 
328
- // Step 3: Fetch trajectory for each changed cascade
319
+ export async function parse() {
329
320
  const entries = [];
330
321
  const sessionEvents = [];
331
322
  const seenResponseIds = new Set();
332
323
 
333
- for (const cascadeId of cascadeIds) {
334
- let resp;
335
- try {
336
- resp = await rpc('GetCascadeTrajectory', { cascadeId });
337
- } catch {
338
- continue; // skip this cascade if RPC fails
339
- }
340
-
341
- const trajectory = resp?.trajectory;
342
- if (!trajectory) continue;
343
-
344
- const steps = trajectory.steps || [];
345
- const metadataList = trajectory.generatorMetadata || [];
346
-
347
-
348
- // Extract project from trajectory metadata workspaces
349
- let project = 'unknown';
350
- const workspaces = trajectory.metadata?.workspaces || [];
351
- if (workspaces.length > 0) {
352
- project = workspaces[0].repository?.computedName || projectFromUri(workspaces[0].workspaceFolderAbsoluteUri) || 'unknown';
353
- }
354
-
355
- // ── Token entries from generatorMetadata ──
356
- for (const meta of metadataList) {
357
- const chatModel = meta?.chatModel;
358
- if (!chatModel) continue;
359
-
360
- const responseModel = resolveModel(chatModel);
361
- const createdAt = chatModel?.chatStartMetadata?.createdAt;
362
- const ts = createdAt ? new Date(createdAt) : null;
363
- if (!ts || isNaN(ts.getTime())) continue;
364
-
365
- const retryInfos = chatModel.retryInfos || [];
366
- for (const retry of retryInfos) {
367
- const usage = retry.usage;
368
- if (!usage) continue;
369
-
370
- const responseId = usage.responseId || '';
371
- if (responseId && seenResponseIds.has(responseId)) continue;
372
- if (responseId) seenResponseIds.add(responseId);
324
+ // ── Path 1: offline .db parsing (App 2.0 + agy CLI, no process needed) ──
325
+ const dbHandled = new Set();
326
+ for (const dir of [CONVERSATIONS_DIR, CLI_CONVERSATIONS_DIR]) {
327
+ for (const cascadeId of listDbCascades(dir)) {
328
+ const records = readDbUsageRecords(dir, cascadeId);
329
+ const project = projectFromUri(readDbWorkspaceUri(dir, cascadeId)) || 'unknown';
330
+
331
+ if (records.length > 0) {
332
+ dbHandled.add(cascadeId);
333
+ for (const rec of records) {
334
+ if (rec.responseId && seenResponseIds.has(rec.responseId)) continue;
335
+ if (rec.responseId) seenResponseIds.add(rec.responseId);
336
+ if (!rec.timestamp || isNaN(rec.timestamp.getTime())) continue;
337
+ entries.push({
338
+ source: SOURCE,
339
+ model: modelFromRecord(rec),
340
+ project,
341
+ timestamp: rec.timestamp,
342
+ inputTokens: toSafeNumber(rec.inputTokens),
343
+ outputTokens: toSafeNumber(rec.outputTokens),
344
+ cachedInputTokens: toSafeNumber(rec.cacheReadTokens),
345
+ reasoningOutputTokens: toSafeNumber(rec.thinkingOutputTokens),
346
+ });
347
+ }
348
+ }
373
349
 
374
- entries.push({
350
+ // Session timing from steps (independent of token usage presence).
351
+ for (const ev of readDbSessionEvents(dir, cascadeId)) {
352
+ sessionEvents.push({
353
+ sessionId: cascadeId,
375
354
  source: SOURCE,
376
- model: responseModel,
377
355
  project,
378
- timestamp: ts,
379
- inputTokens: toSafeNumber(usage.inputTokens),
380
- outputTokens: toSafeNumber(usage.outputTokens),
381
- cachedInputTokens: toSafeNumber(usage.cacheReadTokens),
382
- reasoningOutputTokens: toSafeNumber(usage.thinkingOutputTokens),
356
+ timestamp: ev.timestamp,
357
+ role: ev.role,
383
358
  });
384
359
  }
385
360
  }
361
+ }
386
362
 
387
- // ── Session events from trajectory steps ──
388
- for (const step of steps) {
389
- const stepSource = step?.metadata?.source || '';
390
- let role;
391
- if (USER_SOURCES.has(stepSource)) {
392
- role = 'user';
393
- } else if (ASSISTANT_SOURCES.has(stepSource)) {
394
- role = 'assistant';
395
- } else {
396
- continue; // skip SYSTEM / SYSTEM_SDK / UNSPECIFIED
363
+ // ── Path 2: RPC fallback, only for legacy .pb cascades not already parsed ──
364
+ const pbCascades = listPbCascades().filter((id) => !dbHandled.has(id));
365
+ if (pbCascades.length > 0) {
366
+ const server = findLanguageServer();
367
+ const ports = server ? findListeningPorts(server.pid) : [];
368
+ const baseUrl = ports.length > 0 ? await probeHttpPort(ports, server.csrfToken) : null;
369
+ if (baseUrl) {
370
+ const rpc = (method, body) =>
371
+ rpcPost(
372
+ baseUrl,
373
+ `/exa.language_server_pb.LanguageServerService/${method}`,
374
+ body,
375
+ server.csrfToken,
376
+ );
377
+
378
+ for (const cascadeId of pbCascades) {
379
+ let resp;
380
+ try {
381
+ resp = await rpc('GetCascadeTrajectory', { cascadeId });
382
+ } catch {
383
+ continue;
384
+ }
385
+ const trajectory = resp?.trajectory;
386
+ if (!trajectory) continue;
387
+
388
+ const steps = trajectory.steps || [];
389
+ const metadataList = trajectory.generatorMetadata || [];
390
+
391
+ let project = 'unknown';
392
+ const workspaces = trajectory.metadata?.workspaces || [];
393
+ if (workspaces.length > 0) {
394
+ project = workspaces[0].repository?.computedName
395
+ || projectFromUri(workspaces[0].workspaceFolderAbsoluteUri)
396
+ || 'unknown';
397
+ }
398
+
399
+ for (const meta of metadataList) {
400
+ const chatModel = meta?.chatModel;
401
+ if (!chatModel) continue;
402
+ const model = resolveModel(chatModel);
403
+ const createdAt = chatModel?.chatStartMetadata?.createdAt;
404
+ const ts = createdAt ? new Date(createdAt) : null;
405
+ if (!ts || isNaN(ts.getTime())) continue;
406
+
407
+ for (const retry of (chatModel.retryInfos || [])) {
408
+ const usage = retry.usage;
409
+ if (!usage) continue;
410
+ const responseId = usage.responseId || '';
411
+ if (responseId && seenResponseIds.has(responseId)) continue;
412
+ if (responseId) seenResponseIds.add(responseId);
413
+ entries.push({
414
+ source: SOURCE,
415
+ model,
416
+ project,
417
+ timestamp: ts,
418
+ inputTokens: toSafeNumber(usage.inputTokens),
419
+ outputTokens: toSafeNumber(usage.outputTokens),
420
+ cachedInputTokens: toSafeNumber(usage.cacheReadTokens),
421
+ reasoningOutputTokens: toSafeNumber(usage.thinkingOutputTokens),
422
+ });
423
+ }
424
+ }
425
+
426
+ for (const step of steps) {
427
+ const stepSource = step?.metadata?.source || '';
428
+ let role;
429
+ if (USER_SOURCES.has(stepSource)) role = 'user';
430
+ else if (ASSISTANT_SOURCES.has(stepSource)) role = 'assistant';
431
+ else continue;
432
+ const createdAt = step?.metadata?.createdAt;
433
+ const ts = createdAt ? new Date(createdAt) : null;
434
+ if (!ts || isNaN(ts.getTime())) continue;
435
+ sessionEvents.push({ sessionId: cascadeId, source: SOURCE, project, timestamp: ts, role });
436
+ }
397
437
  }
398
-
399
- const createdAt = step?.metadata?.createdAt;
400
- const ts = createdAt ? new Date(createdAt) : null;
401
- if (!ts || isNaN(ts.getTime())) continue;
402
-
403
- sessionEvents.push({
404
- sessionId: cascadeId,
405
- source: SOURCE,
406
- project,
407
- timestamp: ts,
408
- role,
409
- });
410
438
  }
411
-
412
439
  }
413
440
 
414
441
  return {
@@ -4,18 +4,22 @@ import { homedir } from 'node:os';
4
4
  import { createInterface } from 'node:readline';
5
5
  import { aggregateToBuckets, extractSessions } from './index.js';
6
6
 
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
- ];
7
+ // Codex stores live sessions in $CODEX_HOME/sessions (default ~/.codex) and,
8
+ // once a session is "completed", moves its rollout file verbatim into
9
+ // $CODEX_HOME/archived_sessions. A session can be archived between two syncs,
10
+ // 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.
16
+ function sessionsDirs() {
17
+ const codexHome = process.env.CODEX_HOME?.trim() || join(homedir(), '.codex');
18
+ return [
19
+ join(codexHome, 'sessions'),
20
+ join(codexHome, 'archived_sessions'),
21
+ ];
22
+ }
19
23
 
20
24
  /**
21
25
  * Recursively find all .jsonl files under a directory.
@@ -56,9 +60,24 @@ function extractProject(meta) {
56
60
  return 'unknown';
57
61
  }
58
62
 
63
+ /**
64
+ * A sub-agent rollout (spawned thread / guardian / collab agent). Depending
65
+ * on the Codex version the marker is `thread_source: "subagent"`, a
66
+ * `source: { subagent: {...} }` object, or just a `parent_thread_id` — check
67
+ * all three so no version's sub-agents slip through as normal sessions.
68
+ */
69
+ function isSubagentMeta(meta) {
70
+ if (meta.thread_source === 'subagent') return true;
71
+ const src = meta.source;
72
+ if (src === 'subagent') return true;
73
+ if (src && typeof src === 'object' && 'subagent' in src) return true;
74
+ return meta.parent_thread_id != null;
75
+ }
76
+
59
77
  /**
60
78
  * 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
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
62
81
  * `event_msg/token_count` records. The token_count total is used to size
63
82
  * the replayed-history block of a forked session — a fork copies the
64
83
  * original conversation verbatim, so it begins with exactly as many
@@ -69,6 +88,8 @@ async function indexSessionFile(filePath) {
69
88
  let forkedFromId = null;
70
89
  let sessionProject = 'unknown';
71
90
  let tokenCountRecords = 0;
91
+ let isSubagent = false;
92
+ let hasTaskStarted = false;
72
93
 
73
94
  for await (const line of readLines(filePath)) {
74
95
  if (!line.trim()) continue;
@@ -78,24 +99,28 @@ async function indexSessionFile(filePath) {
78
99
  const meta = obj.payload;
79
100
  sessionId = meta.id || sessionId;
80
101
  forkedFromId = meta.forked_from_id || null;
102
+ isSubagent = isSubagentMeta(meta);
81
103
  sessionProject = extractProject(meta);
82
104
  } else if (obj.type === 'event_msg' && obj.payload?.type === 'token_count') {
83
105
  tokenCountRecords++;
106
+ } else if (obj.type === 'event_msg' && obj.payload?.type === 'task_started') {
107
+ hasTaskStarted = true;
84
108
  }
85
109
  } catch {
86
110
  continue;
87
111
  }
88
112
  }
89
113
 
90
- return { sessionId, forkedFromId, sessionProject, tokenCountRecords };
114
+ return { sessionId, forkedFromId, sessionProject, tokenCountRecords, isSubagent, hasTaskStarted };
91
115
  }
92
116
 
93
117
  export async function parse() {
94
- if (!SESSIONS_DIRS.some(existsSync)) return { buckets: [], sessions: [] };
118
+ const dirs = sessionsDirs();
119
+ if (!dirs.some(existsSync)) return { buckets: [], sessions: [] };
95
120
 
96
121
  const entries = [];
97
122
  const sessionEvents = [];
98
- const files = SESSIONS_DIRS.flatMap(findJsonlFiles);
123
+ const files = dirs.flatMap(findJsonlFiles);
99
124
  if (files.length === 0) return { buckets: [], sessions: [] };
100
125
 
101
126
  // Pass 1: index every session by its UUID and count its token_count
@@ -144,6 +169,19 @@ export async function parse() {
144
169
  }
145
170
  let tokenCountSeen = 0;
146
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;
184
+
147
185
  const sessionProject = fm.sessionProject;
148
186
  // Group timing events by the real Codex session id, not the file path: the
149
187
  // same session can briefly exist in both sessions/ and archived_sessions/
@@ -154,6 +192,7 @@ export async function parse() {
154
192
 
155
193
  let turnContextModel = 'unknown';
156
194
  const prevTotal = new Map();
195
+ let prevCumulativeTotal = null;
157
196
  for await (const line of readLines(filePath)) {
158
197
  if (!line.trim()) continue;
159
198
  try {
@@ -167,14 +206,23 @@ export async function parse() {
167
206
  // edge — the new conversation's events come strictly after it.)
168
207
  const inReplayBlock = tokenCountSeen < replayTokenCountToSkip;
169
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;
217
+
170
218
  if (obj.timestamp) {
171
219
  const evTs = new Date(obj.timestamp);
172
220
  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';
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';
178
226
  if (!isReplay) {
179
227
  const isUserTurn = obj.type === 'turn_context' || obj.type === 'session_meta';
180
228
  sessionEvents.push({
@@ -207,13 +255,27 @@ export async function parse() {
207
255
  if (!timestamp || isNaN(timestamp.getTime())) continue;
208
256
 
209
257
  // 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;
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;
215
264
  tokenCountSeen++;
216
265
 
266
+ // Codex sometimes writes the same token_count twice back-to-back:
267
+ // identical last_token_usage with an unchanged cumulative total. A
268
+ // real API call always advances the cumulative counter (its input
269
+ // tokens alone are non-zero), so an unchanged positive total marks a
270
+ // duplicate emission — or a zero-usage bookkeeping event such as
271
+ // compaction — and must count as zero, not a second copy of
272
+ // last_token_usage. Guarded to positive totals so builds that leave
273
+ // total_token_usage all-zero can't suppress real usage.
274
+ const cumulativeTotal = info.total_token_usage?.total_tokens;
275
+ const isDuplicateEmission = typeof cumulativeTotal === 'number'
276
+ && cumulativeTotal > 0 && cumulativeTotal === prevCumulativeTotal;
277
+ if (typeof cumulativeTotal === 'number') prevCumulativeTotal = cumulativeTotal;
278
+
217
279
  // Prefer incremental per-request usage; compute delta from cumulative total as fallback
218
280
  let usage = info.last_token_usage;
219
281
  if (!usage && info.total_token_usage) {
@@ -237,7 +299,7 @@ export async function parse() {
237
299
  prevTotal.set(totalKey, { ...curr });
238
300
  }
239
301
  if (!usage) continue;
240
- if (isReplayedHistory) continue;
302
+ if (isReplayedHistory || isDuplicateEmission) continue;
241
303
 
242
304
  const model = info.model || payload.model || turnContextModel || 'unknown';
243
305
 
@@ -17,6 +17,7 @@ import { parse as parseHermes } from './hermes.js';
17
17
  import { parse as parseKiro } from './kiro.js';
18
18
  import { parse as parsePiCodingAgent } from './pi-coding-agent.js';
19
19
  import { parse as parseZcode } from './zcode.js';
20
+ import { parse as parseTraeCli } from './trae-cli.js';
20
21
 
21
22
  export const parsers = {
22
23
  'claude-code': parseClaudeCode,
@@ -37,6 +38,7 @@ export const parsers = {
37
38
  'kiro': parseKiro,
38
39
  'pi-coding-agent': parsePiCodingAgent,
39
40
  'zcode': parseZcode,
41
+ 'trae-cli': parseTraeCli,
40
42
  };
41
43
 
42
44
 
@@ -0,0 +1,155 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { findTraeCliDataDirs } from '../tools.js';
4
+ import { aggregateToBuckets, extractSessions } from './index.js';
5
+
6
+ function readJsonSafe(path) {
7
+ try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
8
+ }
9
+
10
+ function projectFromPath(absPath) {
11
+ if (!absPath || typeof absPath !== 'string') return 'unknown';
12
+ const trimmed = absPath.replace(/[\\/]+$/, '');
13
+ const name = basename(trimmed);
14
+ return name || 'unknown';
15
+ }
16
+
17
+ function parseJsonlSafe(path) {
18
+ try {
19
+ const content = readFileSync(path, 'utf-8');
20
+ return content
21
+ .split('\n')
22
+ .map(line => line.trim())
23
+ .filter(line => line.length > 0)
24
+ .map(line => {
25
+ try { return JSON.parse(line); } catch { return null; }
26
+ })
27
+ .filter(Boolean);
28
+ } catch {
29
+ return [];
30
+ }
31
+ }
32
+
33
+ export async function parse() {
34
+ const cacheDirs = findTraeCliDataDirs();
35
+ if (cacheDirs.length === 0) return { buckets: [], sessions: [] };
36
+
37
+ const entries = [];
38
+ const events = [];
39
+
40
+ for (const cacheDir of cacheDirs) {
41
+ let sessionDirs = [];
42
+ try {
43
+ sessionDirs = readdirSync(cacheDir, { withFileTypes: true })
44
+ .filter(entry => entry.isDirectory())
45
+ .map(entry => entry.name);
46
+ } catch {
47
+ continue;
48
+ }
49
+
50
+ for (const sessionId of sessionDirs) {
51
+ const sessionPath = join(cacheDir, sessionId);
52
+ const sessionJson = readJsonSafe(join(sessionPath, 'session.json')) || {};
53
+ const project = projectFromPath(sessionJson.metadata?.cwd);
54
+ const fallbackModel = sessionJson.metadata?.model_name || 'trae-unknown';
55
+
56
+ // 1. Parse traces.jsonl for token usage
57
+ const traceLines = parseJsonlSafe(join(sessionPath, 'traces.jsonl'));
58
+ const tracesMap = new Map();
59
+
60
+ for (const line of traceLines) {
61
+ if (!line.traceID) continue;
62
+ const tags = Array.isArray(line.tags) ? line.tags : [];
63
+ const tagMap = {};
64
+ for (const t of tags) {
65
+ if (t && typeof t === 'object' && t.key) {
66
+ tagMap[t.key] = t.value;
67
+ }
68
+ }
69
+
70
+ const model = tagMap['model.name'] || tagMap['semantic.name'] || null;
71
+ const inputTokens = Math.max(0, Number(tagMap['usage.input_tokens']) || 0);
72
+ const outputTokens = Math.max(0, Number(tagMap['usage.output_tokens']) || 0);
73
+ const cacheReadTokens = Math.max(0, Number(tagMap['usage.cache_read_tokens']) || 0);
74
+ const reasoningTokens = Math.max(0, Number(tagMap['usage.reasoning_tokens']) || 0);
75
+
76
+ if (inputTokens + outputTokens + cacheReadTokens + reasoningTokens === 0) {
77
+ continue;
78
+ }
79
+
80
+ if (!tracesMap.has(line.traceID)) {
81
+ tracesMap.set(line.traceID, {
82
+ model,
83
+ inputTokens,
84
+ outputTokens,
85
+ cacheReadTokens,
86
+ reasoningTokens,
87
+ startTime: Number(line.startTime) || 0,
88
+ });
89
+ } else {
90
+ // Merge spans under the same traceID by selecting the max values
91
+ const existing = tracesMap.get(line.traceID);
92
+ if (model) {
93
+ existing.model = model;
94
+ }
95
+ existing.inputTokens = Math.max(existing.inputTokens, inputTokens);
96
+ existing.outputTokens = Math.max(existing.outputTokens, outputTokens);
97
+ existing.cacheReadTokens = Math.max(existing.cacheReadTokens, cacheReadTokens);
98
+ existing.reasoningTokens = Math.max(existing.reasoningTokens, reasoningTokens);
99
+ if (line.startTime) {
100
+ existing.startTime = existing.startTime ? Math.min(existing.startTime, Number(line.startTime)) : Number(line.startTime);
101
+ }
102
+ }
103
+ }
104
+
105
+ // Convert trace map to vibe-usage entries
106
+ for (const trace of tracesMap.values()) {
107
+ // Convert microsecond startTime to milliseconds for Date constructor
108
+ const startTime = Number(trace.startTime);
109
+ if (!Number.isFinite(startTime) || startTime <= 0) continue;
110
+ const timestamp = new Date(startTime / 1000);
111
+ entries.push({
112
+ source: 'trae-cli',
113
+ model: trace.model || fallbackModel,
114
+ project,
115
+ timestamp,
116
+ inputTokens: trace.inputTokens,
117
+ outputTokens: trace.outputTokens,
118
+ cachedInputTokens: trace.cacheReadTokens,
119
+ reasoningOutputTokens: trace.reasoningTokens,
120
+ });
121
+ }
122
+
123
+ // 2. Parse events.jsonl for user and assistant timings
124
+ const eventLines = parseJsonlSafe(join(sessionPath, 'events.jsonl'));
125
+ for (const line of eventLines) {
126
+ if (!line.created_at) continue;
127
+ const timestamp = new Date(line.created_at);
128
+ if (Number.isNaN(timestamp.getTime())) continue;
129
+
130
+ if (line.agent_start) {
131
+ events.push({
132
+ sessionId,
133
+ source: 'trae-cli',
134
+ project,
135
+ timestamp,
136
+ role: 'user',
137
+ });
138
+ } else if (line.agent_end || line.tool_call || (line.message && line.message.message?.role === 'assistant')) {
139
+ events.push({
140
+ sessionId,
141
+ source: 'trae-cli',
142
+ project,
143
+ timestamp,
144
+ role: 'assistant',
145
+ });
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ return {
152
+ buckets: aggregateToBuckets(entries),
153
+ sessions: extractSessions(events),
154
+ };
155
+ }
package/src/tools.js CHANGED
@@ -113,11 +113,41 @@ function findKimiCodeDataDirs() {
113
113
  ].filter(existsSync);
114
114
  }
115
115
 
116
+ function findAntigravityDataDirs() {
117
+ return [
118
+ join(homedir(), '.gemini', 'antigravity'),
119
+ join(homedir(), '.gemini', 'antigravity-cli'),
120
+ ].filter(existsSync);
121
+ }
122
+
123
+ export function findTraeCliDataDirs() {
124
+ const envDir = process.env.VIBE_USAGE_TRAE_CLI_SESSIONS?.trim();
125
+ if (envDir) {
126
+ return [envDir].filter(existsSync);
127
+ }
128
+ if (process.platform === 'darwin') {
129
+ return [join(homedir(), 'Library', 'Caches', 'trae-cli', 'sessions')].filter(existsSync);
130
+ }
131
+ if (process.platform === 'win32') {
132
+ const localAppData = process.env.LOCALAPPDATA?.trim() || join(homedir(), 'AppData', 'Local');
133
+ return [join(localAppData, 'trae-cli', 'cache', 'sessions')].filter(existsSync);
134
+ }
135
+ const xdgCacheHome = process.env.XDG_CACHE_HOME?.trim() || join(homedir(), '.cache');
136
+ return [join(xdgCacheHome, 'trae-cli', 'sessions')].filter(existsSync);
137
+ }
138
+
116
139
  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
+ },
117
146
  {
118
147
  name: 'Antigravity',
119
148
  id: 'antigravity',
120
149
  dataDir: join(homedir(), '.gemini', 'antigravity'),
150
+ detectDataDirs: findAntigravityDataDirs,
121
151
  },
122
152
  {
123
153
  name: 'Claude Code',