@vibe-cafe/vibe-usage 0.7.0 → 0.7.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,346 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { readdirSync, existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { aggregateToBuckets, extractSessions } from './index.js';
6
+
7
+
8
+
9
+ /**
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).
14
+ */
15
+
16
+ const SOURCE = 'antigravity';
17
+ const CONVERSATIONS_DIR = join(homedir(), '.gemini', 'antigravity', 'conversations');
18
+
19
+ // User sources → role 'user'; Model source → role 'assistant'; System sources → skip
20
+ const USER_SOURCES = new Set([
21
+ 'CORTEX_STEP_SOURCE_USER_EXPLICIT',
22
+ 'CORTEX_STEP_SOURCE_USER_IMPLICIT',
23
+ ]);
24
+ const ASSISTANT_SOURCES = new Set([
25
+ 'CORTEX_STEP_SOURCE_MODEL',
26
+ ]);
27
+
28
+ // ── Process discovery (single instance) ──────────────────────────────
29
+
30
+ const IS_WIN = process.platform === 'win32';
31
+
32
+ /**
33
+ * Find ONE running language server process with a CSRF token.
34
+ * Returns { pid, csrfToken } or null.
35
+ */
36
+ function findLanguageServer() {
37
+ try {
38
+ return IS_WIN ? findLanguageServerWin() : findLanguageServerUnix();
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function findLanguageServerUnix() {
45
+ const out = execSync("ps aux | grep 'antigravity/bin/language_server_'", { encoding: 'utf-8', timeout: 5000 });
46
+ for (const line of out.split('\n')) {
47
+ if (!line.trim()) continue;
48
+ if (line.includes('grep')) continue;
49
+ const parts = line.trim().split(/\s+/);
50
+ if (parts.length < 2) continue;
51
+ const pid = parts[1];
52
+ const csrfMatch = line.match(/--csrf_token\s+([0-9a-f-]+)/);
53
+ const csrfToken = csrfMatch ? csrfMatch[1] : '';
54
+ if (csrfToken) return { pid, csrfToken };
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function findLanguageServerWin() {
60
+ const out = execSync(
61
+ 'wmic process where "CommandLine like \'%antigravity%language_server%\'" get ProcessId,CommandLine /format:list',
62
+ { encoding: 'utf-8', timeout: 5000, shell: 'cmd.exe' },
63
+ );
64
+ // wmic /format:list outputs lines like "CommandLine=..." and "ProcessId=..."
65
+ let cmdLine = '';
66
+ let pid = '';
67
+ for (const line of out.split('\n')) {
68
+ const trimmed = line.trim();
69
+ if (trimmed.startsWith('CommandLine=')) {
70
+ const val = trimmed.slice('CommandLine='.length);
71
+ if (/WMIC\.exe/i.test(val)) continue; // skip wmic's own process
72
+ cmdLine = val;
73
+ }
74
+ if (trimmed.startsWith('ProcessId=')) pid = trimmed.slice('ProcessId='.length);
75
+ }
76
+ if (!pid || !cmdLine) return null;
77
+ const csrfMatch = cmdLine.match(/--csrf_token\s+([0-9a-f-]+)/);
78
+ const csrfToken = csrfMatch ? csrfMatch[1] : '';
79
+ if (!csrfToken) return null;
80
+ return { pid, csrfToken };
81
+ }
82
+
83
+ function findListeningPorts(pid) {
84
+ try {
85
+ return IS_WIN ? findListeningPortsWin(pid) : findListeningPortsUnix(pid);
86
+ } catch {
87
+ return [];
88
+ }
89
+ }
90
+
91
+ function findListeningPortsUnix(pid) {
92
+ const out = execSync(`lsof -iTCP -sTCP:LISTEN -nP -a -p ${pid}`, {
93
+ encoding: 'utf-8',
94
+ timeout: 5000,
95
+ });
96
+ const ports = [];
97
+ for (const line of out.split('\n')) {
98
+ const match = line.match(/:(\d+)\s+\(LISTEN\)/);
99
+ if (match) ports.push(parseInt(match[1], 10));
100
+ }
101
+ return ports;
102
+ }
103
+
104
+ function findListeningPortsWin(pid) {
105
+ // netstat output: TCP 127.0.0.1:49327 0.0.0.0:0 LISTENING 12345
106
+ const out = execSync('netstat -ano', { encoding: 'utf-8', timeout: 5000 });
107
+ const ports = [];
108
+ for (const line of out.split('\n')) {
109
+ if (!line.includes('LISTENING')) continue;
110
+ const parts = line.trim().split(/\s+/);
111
+ // parts: [TCP, local_addr:port, foreign_addr, LISTENING, pid]
112
+ const linePid = parts[parts.length - 1];
113
+ if (linePid !== String(pid)) continue;
114
+ const addrMatch = parts[1]?.match(/:(\d+)$/);
115
+ if (addrMatch) ports.push(parseInt(addrMatch[1], 10));
116
+ }
117
+ return ports;
118
+ }
119
+
120
+ async function rpcPost(baseUrl, path, body, csrfToken, timeoutMs = 10000) {
121
+ const url = new URL(path, baseUrl);
122
+ const headers = {
123
+ 'Content-Type': 'application/json',
124
+ 'Connect-Protocol-Version': '1',
125
+ };
126
+ if (csrfToken) headers['X-Codeium-Csrf-Token'] = csrfToken;
127
+
128
+ const res = await fetch(url, {
129
+ method: 'POST',
130
+ headers,
131
+ body: JSON.stringify(body),
132
+ signal: AbortSignal.timeout(timeoutMs),
133
+ });
134
+ if (!res.ok) throw new Error(`HTTP ${res.status} from ${path}`);
135
+ return res.json();
136
+ }
137
+
138
+ async function probeHttpPort(ports, csrfToken) {
139
+ for (const port of ports) {
140
+ const baseUrl = `http://127.0.0.1:${port}`;
141
+ try {
142
+ await rpcPost(
143
+ baseUrl,
144
+ '/exa.language_server_pb.LanguageServerService/GetWorkspaceInfos',
145
+ {},
146
+ csrfToken,
147
+ 3000,
148
+ );
149
+ return baseUrl;
150
+ } catch {
151
+ // Not the right port, try next
152
+ }
153
+ }
154
+ return null;
155
+ }
156
+
157
+ // ── Helpers ──────────────────────────────────────────────────────────
158
+
159
+ /**
160
+ * Normalize model names to canonical forms.
161
+ */
162
+ const MODEL_NORMALIZE_MAP = {
163
+ 'claude-opus-4-6-thinking': 'claude-opus-4-6',
164
+ 'claude-sonnet-4-6-thinking': 'claude-sonnet-4-6',
165
+ 'gemini-3-flash-c': 'gemini-3-flash',
166
+ "gemini-3.1-pro-high": "gemini-3.1-pro",
167
+ "gemini-3.1-pro-low": "gemini-3.1-pro",
168
+ "gemini-3-pro-high": "gemini-3-pro",
169
+ "gemini-3-pro-low": "gemini-3-pro",
170
+ };
171
+
172
+ /**
173
+ * Map internal placeholder model IDs to canonical names.
174
+ * Used when responseModel is empty and only chatModel.model is available.
175
+ */
176
+ const PLACEHOLDER_MODEL_MAP = {
177
+ 'MODEL_PLACEHOLDER_M37': 'gemini-3.1-pro',
178
+ 'MODEL_PLACEHOLDER_M36': 'gemini-3.1-pro',
179
+ 'MODEL_PLACEHOLDER_M47': 'gemini-3-flash',
180
+ 'MODEL_PLACEHOLDER_M35': 'claude-sonnet-4-6',
181
+ 'MODEL_PLACEHOLDER_M26': 'claude-opus-4-6',
182
+ 'MODEL_OPENAI_GPT_OSS_120B_MEDIUM': 'gpt-oss-120b',
183
+ };
184
+
185
+ function normalizeModel(raw) {
186
+ return MODEL_NORMALIZE_MAP[raw] || raw;
187
+ }
188
+
189
+ /**
190
+ * Resolve model name: prefer responseModel, fall back to placeholder map.
191
+ */
192
+ function resolveModel(chatModel) {
193
+ if (chatModel.responseModel) return normalizeModel(chatModel.responseModel);
194
+ const placeholder = chatModel.model || '';
195
+ if (PLACEHOLDER_MODEL_MAP[placeholder]) return PLACEHOLDER_MODEL_MAP[placeholder];
196
+ return 'unknown';
197
+ }
198
+
199
+ function toSafeNumber(value) {
200
+ if (value == null) return 0;
201
+ const n = Number(value);
202
+ return Number.isFinite(n) ? n : 0;
203
+ }
204
+
205
+ /**
206
+ * Extract project name from a workspace URI (e.g. "file:///Users/x/myproject" → "myproject").
207
+ */
208
+ function projectFromUri(uri) {
209
+ if (!uri) return null;
210
+ const parts = uri.replace(/\/$/, '').split('/');
211
+ return parts[parts.length - 1] || null;
212
+ }
213
+
214
+ /**
215
+ * List cascade IDs from .pb files in the conversations directory.
216
+ */
217
+ function listCascades() {
218
+ try {
219
+ const files = readdirSync(CONVERSATIONS_DIR);
220
+ const results = [];
221
+ for (const f of files) {
222
+ if (!f.endsWith('.pb')) continue;
223
+ results.push(f.slice(0, -3)); // strip .pb → cascadeId
224
+ }
225
+ return results;
226
+ } catch {
227
+ return [];
228
+ }
229
+ }
230
+
231
+ // ── Main parse ───────────────────────────────────────────────────────
232
+
233
+ export async function parse() {
234
+ // Step 1: List cascade .pb files
235
+ const cascadeIds = listCascades();
236
+ if (cascadeIds.length === 0) return { buckets: [], sessions: [] };
237
+
238
+ // Step 2: Find a running language server to make RPC calls
239
+ const server = findLanguageServer();
240
+ if (!server) return { buckets: [], sessions: [] };
241
+
242
+ const ports = findListeningPorts(server.pid);
243
+ if (ports.length === 0) return { buckets: [], sessions: [] };
244
+
245
+ const baseUrl = await probeHttpPort(ports, server.csrfToken);
246
+ if (!baseUrl) return { buckets: [], sessions: [] };
247
+
248
+ const rpc = (method, body) =>
249
+ rpcPost(
250
+ baseUrl,
251
+ `/exa.language_server_pb.LanguageServerService/${method}`,
252
+ body,
253
+ server.csrfToken,
254
+ );
255
+
256
+ // Step 3: Fetch trajectory for each changed cascade
257
+ const entries = [];
258
+ const sessionEvents = [];
259
+ const seenResponseIds = new Set();
260
+
261
+ for (const cascadeId of cascadeIds) {
262
+ let resp;
263
+ try {
264
+ resp = await rpc('GetCascadeTrajectory', { cascadeId });
265
+ } catch {
266
+ continue; // skip this cascade if RPC fails
267
+ }
268
+
269
+ const trajectory = resp?.trajectory;
270
+ if (!trajectory) continue;
271
+
272
+ const steps = trajectory.steps || [];
273
+ const metadataList = trajectory.generatorMetadata || [];
274
+
275
+
276
+ // Extract project from trajectory metadata workspaces
277
+ let project = 'unknown';
278
+ const workspaces = trajectory.metadata?.workspaces || [];
279
+ if (workspaces.length > 0) {
280
+ project = workspaces[0].repository?.computedName || projectFromUri(workspaces[0].workspaceFolderAbsoluteUri) || 'unknown';
281
+ }
282
+
283
+ // ── Token entries from generatorMetadata ──
284
+ for (const meta of metadataList) {
285
+ const chatModel = meta?.chatModel;
286
+ if (!chatModel) continue;
287
+
288
+ const responseModel = resolveModel(chatModel);
289
+ const createdAt = chatModel?.chatStartMetadata?.createdAt;
290
+ const ts = createdAt ? new Date(createdAt) : null;
291
+ if (!ts || isNaN(ts.getTime())) continue;
292
+
293
+ const retryInfos = chatModel.retryInfos || [];
294
+ for (const retry of retryInfos) {
295
+ const usage = retry.usage;
296
+ if (!usage) continue;
297
+
298
+ const responseId = usage.responseId || '';
299
+ if (responseId && seenResponseIds.has(responseId)) continue;
300
+ if (responseId) seenResponseIds.add(responseId);
301
+
302
+ entries.push({
303
+ source: SOURCE,
304
+ model: responseModel,
305
+ project,
306
+ timestamp: ts,
307
+ inputTokens: toSafeNumber(usage.inputTokens),
308
+ outputTokens: toSafeNumber(usage.outputTokens),
309
+ cachedInputTokens: toSafeNumber(usage.cacheReadTokens),
310
+ reasoningOutputTokens: toSafeNumber(usage.thinkingOutputTokens),
311
+ });
312
+ }
313
+ }
314
+
315
+ // ── Session events from trajectory steps ──
316
+ for (const step of steps) {
317
+ const stepSource = step?.metadata?.source || '';
318
+ let role;
319
+ if (USER_SOURCES.has(stepSource)) {
320
+ role = 'user';
321
+ } else if (ASSISTANT_SOURCES.has(stepSource)) {
322
+ role = 'assistant';
323
+ } else {
324
+ continue; // skip SYSTEM / SYSTEM_SDK / UNSPECIFIED
325
+ }
326
+
327
+ const createdAt = step?.metadata?.createdAt;
328
+ const ts = createdAt ? new Date(createdAt) : null;
329
+ if (!ts || isNaN(ts.getTime())) continue;
330
+
331
+ sessionEvents.push({
332
+ sessionId: cascadeId,
333
+ source: SOURCE,
334
+ project,
335
+ timestamp: ts,
336
+ role,
337
+ });
338
+ }
339
+
340
+ }
341
+
342
+ return {
343
+ buckets: aggregateToBuckets(entries),
344
+ sessions: extractSessions(sessionEvents),
345
+ };
346
+ }
@@ -9,6 +9,7 @@ import { parse as parseQwenCode } from './qwen-code.js';
9
9
  import { parse as parseKimiCode } from './kimi-code.js';
10
10
  import { parse as parseAmp } from './amp.js';
11
11
  import { parse as parseDroid } from './droid.js';
12
+ import { parse as parseAntigravity } from './antigravity.js';
12
13
  import { parse as parsePiCodingAgent } from './pi-coding-agent.js';
13
14
 
14
15
  export const parsers = {
@@ -22,6 +23,7 @@ export const parsers = {
22
23
  'kimi-code': parseKimiCode,
23
24
  'amp': parseAmp,
24
25
  'droid': parseDroid,
26
+ 'antigravity': parseAntigravity,
25
27
  'pi-coding-agent': parsePiCodingAgent,
26
28
  };
27
29
 
package/src/tools.js CHANGED
@@ -3,6 +3,11 @@ import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
 
5
5
  export const TOOLS = [
6
+ {
7
+ name: 'Antigravity',
8
+ id: 'antigravity',
9
+ dataDir: join(homedir(), '.gemini', 'antigravity'),
10
+ },
6
11
  {
7
12
  name: 'Claude Code',
8
13
  id: 'claude-code',