@vibe-cafe/vibe-usage 0.9.9 → 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` 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.9",
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 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([
@@ -42,7 +49,7 @@ function findLanguageServer() {
42
49
  }
43
50
 
44
51
  function findLanguageServerUnix() {
45
- const out = execSync("ps aux | grep 'antigravity/bin/language_server_'", { encoding: 'utf-8', timeout: 5000 });
52
+ const out = execSync("ps aux | grep -i 'antigravity.*language_server'", { encoding: 'utf-8', timeout: 5000 });
46
53
  for (const line of out.split('\n')) {
47
54
  if (!line.trim()) continue;
48
55
  if (line.includes('grep')) continue;
@@ -221,10 +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-c': 'gemini-3-flash',
228
239
  "gemini-3.1-pro-high": "gemini-3.1-pro",
229
240
  "gemini-3.1-pro-low": "gemini-3.1-pro",
230
241
  "gemini-3-pro-high": "gemini-3-pro",
@@ -249,9 +260,16 @@ function normalizeModel(raw) {
249
260
  }
250
261
 
251
262
  /**
252
- * 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.
253
270
  */
254
271
  function resolveModel(chatModel) {
272
+ if (chatModel.modelDisplayName) return chatModel.modelDisplayName;
255
273
  if (chatModel.responseModel) return normalizeModel(chatModel.responseModel);
256
274
  const placeholder = chatModel.model || '';
257
275
  if (PLACEHOLDER_MODEL_MAP[placeholder]) return PLACEHOLDER_MODEL_MAP[placeholder];
@@ -274,17 +292,16 @@ function projectFromUri(uri) {
274
292
  }
275
293
 
276
294
  /**
277
- * List cascade IDs from .pb files in the conversations directory.
295
+ * List cascade IDs backed by a legacy `.pb` file (App history). `.db` cascades
296
+ * are handled separately via offline parsing.
278
297
  */
279
- function listCascades() {
298
+ function listPbCascades() {
280
299
  try {
281
- const files = readdirSync(CONVERSATIONS_DIR);
282
- const results = [];
283
- for (const f of files) {
284
- if (!f.endsWith('.pb')) continue;
285
- results.push(f.slice(0, -3)); // strip .pb → cascadeId
300
+ const out = [];
301
+ for (const f of readdirSync(CONVERSATIONS_DIR)) {
302
+ if (f.endsWith('.pb')) out.push(f.slice(0, -3));
286
303
  }
287
- return results;
304
+ return out;
288
305
  } catch {
289
306
  return [];
290
307
  }
@@ -292,113 +309,133 @@ function listCascades() {
292
309
 
293
310
  // ── Main parse ───────────────────────────────────────────────────────
294
311
 
295
- export async function parse() {
296
- // Step 1: List cascade .pb files
297
- const cascadeIds = listCascades();
298
- if (cascadeIds.length === 0) return { buckets: [], sessions: [] };
299
-
300
- // Step 2: Find a running language server to make RPC calls
301
- const server = findLanguageServer();
302
- if (!server) return { buckets: [], sessions: [] };
303
-
304
- const ports = findListeningPorts(server.pid);
305
- if (ports.length === 0) return { buckets: [], sessions: [] };
306
-
307
- const baseUrl = await probeHttpPort(ports, server.csrfToken);
308
- if (!baseUrl) return { buckets: [], sessions: [] };
309
-
310
- const rpc = (method, body) =>
311
- rpcPost(
312
- baseUrl,
313
- `/exa.language_server_pb.LanguageServerService/${method}`,
314
- body,
315
- server.csrfToken,
316
- );
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
+ }
317
318
 
318
- // Step 3: Fetch trajectory for each changed cascade
319
+ export async function parse() {
319
320
  const entries = [];
320
321
  const sessionEvents = [];
321
322
  const seenResponseIds = new Set();
322
323
 
323
- for (const cascadeId of cascadeIds) {
324
- let resp;
325
- try {
326
- resp = await rpc('GetCascadeTrajectory', { cascadeId });
327
- } catch {
328
- continue; // skip this cascade if RPC fails
329
- }
330
-
331
- const trajectory = resp?.trajectory;
332
- if (!trajectory) continue;
333
-
334
- const steps = trajectory.steps || [];
335
- const metadataList = trajectory.generatorMetadata || [];
336
-
337
-
338
- // Extract project from trajectory metadata workspaces
339
- let project = 'unknown';
340
- const workspaces = trajectory.metadata?.workspaces || [];
341
- if (workspaces.length > 0) {
342
- project = workspaces[0].repository?.computedName || projectFromUri(workspaces[0].workspaceFolderAbsoluteUri) || 'unknown';
343
- }
344
-
345
- // ── Token entries from generatorMetadata ──
346
- for (const meta of metadataList) {
347
- const chatModel = meta?.chatModel;
348
- if (!chatModel) continue;
349
-
350
- const responseModel = resolveModel(chatModel);
351
- const createdAt = chatModel?.chatStartMetadata?.createdAt;
352
- const ts = createdAt ? new Date(createdAt) : null;
353
- if (!ts || isNaN(ts.getTime())) continue;
354
-
355
- const retryInfos = chatModel.retryInfos || [];
356
- for (const retry of retryInfos) {
357
- const usage = retry.usage;
358
- if (!usage) continue;
359
-
360
- const responseId = usage.responseId || '';
361
- if (responseId && seenResponseIds.has(responseId)) continue;
362
- 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
+ }
363
349
 
364
- 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,
365
354
  source: SOURCE,
366
- model: responseModel,
367
355
  project,
368
- timestamp: ts,
369
- inputTokens: toSafeNumber(usage.inputTokens),
370
- outputTokens: toSafeNumber(usage.outputTokens),
371
- cachedInputTokens: toSafeNumber(usage.cacheReadTokens),
372
- reasoningOutputTokens: toSafeNumber(usage.thinkingOutputTokens),
356
+ timestamp: ev.timestamp,
357
+ role: ev.role,
373
358
  });
374
359
  }
375
360
  }
361
+ }
376
362
 
377
- // ── Session events from trajectory steps ──
378
- for (const step of steps) {
379
- const stepSource = step?.metadata?.source || '';
380
- let role;
381
- if (USER_SOURCES.has(stepSource)) {
382
- role = 'user';
383
- } else if (ASSISTANT_SOURCES.has(stepSource)) {
384
- role = 'assistant';
385
- } else {
386
- 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
+ }
387
437
  }
388
-
389
- const createdAt = step?.metadata?.createdAt;
390
- const ts = createdAt ? new Date(createdAt) : null;
391
- if (!ts || isNaN(ts.getTime())) continue;
392
-
393
- sessionEvents.push({
394
- sessionId: cascadeId,
395
- source: SOURCE,
396
- project,
397
- timestamp: ts,
398
- role,
399
- });
400
438
  }
401
-
402
439
  }
403
440
 
404
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',