@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 +3 -2
- package/package.json +1 -1
- package/src/parsers/antigravity-db.js +307 -0
- package/src/parsers/antigravity.js +144 -117
- package/src/parsers/codex.js +89 -27
- package/src/parsers/index.js +2 -0
- package/src/parsers/trae-cli.js +155 -0
- package/src/tools.js +30 -0
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 |
|
|
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
|
-
|
|
|
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
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* (
|
|
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
|
|
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
|
|
284
|
-
*
|
|
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
|
|
298
|
+
function listPbCascades() {
|
|
287
299
|
try {
|
|
288
|
-
const
|
|
289
|
-
const
|
|
290
|
-
|
|
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
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
319
|
+
export async function parse() {
|
|
329
320
|
const entries = [];
|
|
330
321
|
const sessionEvents = [];
|
|
331
322
|
const seenResponseIds = new Set();
|
|
332
323
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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:
|
|
379
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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 {
|
package/src/parsers/codex.js
CHANGED
|
@@ -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
|
|
8
|
-
// "completed", moves its rollout file verbatim into
|
|
9
|
-
// A session can be archived between two syncs,
|
|
10
|
-
// loses that session's usage forever. We scan
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
join(homedir(), '.codex'
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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
|
|
174
|
-
// duration/active-time/message counts reflect only the
|
|
175
|
-
// conversation, not the copied original. session_meta itself
|
|
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
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
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
|
|
package/src/parsers/index.js
CHANGED
|
@@ -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',
|