@vibe-cafe/vibe-usage 0.9.10 → 0.9.11

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
@@ -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.11",
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 {
@@ -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',