@vibe-cafe/vibe-usage 0.6.8 → 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/README.md CHANGED
@@ -19,7 +19,12 @@ This will:
19
19
  npx @vibe-cafe/vibe-usage # Init (first run) or sync (subsequent runs)
20
20
  npx @vibe-cafe/vibe-usage init # Re-run setup
21
21
  npx @vibe-cafe/vibe-usage sync # Manual sync
22
- npx @vibe-cafe/vibe-usage daemon # Continuous sync (every 5 minutes)
22
+ npx @vibe-cafe/vibe-usage daemon # Continuous sync (every 5m, foreground)
23
+ npx @vibe-cafe/vibe-usage daemon install # Install background service (systemd/launchd)
24
+ npx @vibe-cafe/vibe-usage daemon uninstall # Remove background service
25
+ npx @vibe-cafe/vibe-usage daemon status # Show background service status
26
+ npx @vibe-cafe/vibe-usage daemon stop # Stop background service
27
+ npx @vibe-cafe/vibe-usage daemon restart # Restart background service
23
28
  npx @vibe-cafe/vibe-usage reset # Delete all data and re-upload from local logs
24
29
  npx @vibe-cafe/vibe-usage reset --local # Delete this host's data only and re-upload
25
30
  npx @vibe-cafe/vibe-usage skill # Install skill for AI coding assistants
@@ -89,13 +94,34 @@ Config stored at `~/.vibe-usage/config.json` (dev: `config.dev.json`). Contains
89
94
 
90
95
  ## Daemon Mode
91
96
 
97
+ ### Background service (recommended)
98
+
99
+ Install as a system service for automatic background syncing:
100
+
101
+ ```bash
102
+ npx @vibe-cafe/vibe-usage daemon install
103
+ ```
104
+
105
+ This creates a user-level service (systemd on Linux, launchd on macOS) that syncs every 5 minutes and starts automatically on login. Manage with:
106
+
107
+ ```bash
108
+ npx @vibe-cafe/vibe-usage daemon status
109
+ npx @vibe-cafe/vibe-usage daemon stop
110
+ npx @vibe-cafe/vibe-usage daemon restart
111
+ npx @vibe-cafe/vibe-usage daemon uninstall
112
+ ```
113
+
114
+ For reliable operation, install globally first: `npm install -g @vibe-cafe/vibe-usage`
115
+
116
+ ### Foreground mode
117
+
92
118
  Run continuous syncing in the foreground (every 5 minutes):
93
119
 
94
120
  ```bash
95
121
  npx @vibe-cafe/vibe-usage daemon
96
122
  ```
97
123
 
98
- Press Ctrl+C to stop. For background use: `nohup npx @vibe-cafe/vibe-usage daemon &`
124
+ Press Ctrl+C to stop.
99
125
 
100
126
  ## License
101
127
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.6.8",
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,272 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { writeFileSync, unlinkSync, mkdirSync, existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { homedir, platform } from 'node:os';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const SERVICE_NAME = 'vibe-usage';
8
+ const LAUNCHD_LABEL = 'ai.vibecafe.vibe-usage';
9
+
10
+ function detectPlatform() {
11
+ const os = platform();
12
+ if (os === 'linux') {
13
+ if (existsSync('/run/systemd/system')) return 'systemd';
14
+ return null;
15
+ }
16
+ if (os === 'darwin') {
17
+ return 'launchd';
18
+ }
19
+ return null;
20
+ }
21
+
22
+ function resolvePaths() {
23
+ const nodePath = process.execPath;
24
+ const thisFile = fileURLToPath(import.meta.url);
25
+ const binPath = join(thisFile, '..', '..', 'bin', 'vibe-usage.js');
26
+
27
+ // npx cache paths are unstable — service will break when cache is cleared
28
+ const isNpxCache = binPath.includes('.npm/_npx');
29
+
30
+ return { nodePath, binPath, isNpxCache };
31
+ }
32
+
33
+ function getServicePaths(plat) {
34
+ if (plat === 'systemd') {
35
+ const dir = join(homedir(), '.config', 'systemd', 'user');
36
+ return { dir, file: join(dir, `${SERVICE_NAME}.service`) };
37
+ }
38
+ if (plat === 'launchd') {
39
+ const dir = join(homedir(), 'Library', 'LaunchAgents');
40
+ return { dir, file: join(dir, `${LAUNCHD_LABEL}.plist`) };
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function generateSystemdUnit(nodePath, binPath) {
46
+ return `[Unit]
47
+ Description=VibeCafe Usage Tracker
48
+ After=network.target
49
+
50
+ [Service]
51
+ Type=simple
52
+ ExecStart=${nodePath} ${binPath} daemon
53
+ Restart=on-failure
54
+ RestartSec=10
55
+ Environment=NODE_ENV=production
56
+ WorkingDirectory=${homedir()}
57
+
58
+ [Install]
59
+ WantedBy=default.target
60
+ `;
61
+ }
62
+
63
+ function generateLaunchdPlist(nodePath, binPath) {
64
+ const logDir = join(homedir(), '.vibe-usage');
65
+ return `<?xml version="1.0" encoding="UTF-8"?>
66
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
67
+ <plist version="1.0">
68
+ <dict>
69
+ <key>Label</key>
70
+ <string>${LAUNCHD_LABEL}</string>
71
+ <key>ProgramArguments</key>
72
+ <array>
73
+ <string>${nodePath}</string>
74
+ <string>${binPath}</string>
75
+ <string>daemon</string>
76
+ </array>
77
+ <key>RunAtLoad</key>
78
+ <true/>
79
+ <key>KeepAlive</key>
80
+ <true/>
81
+ <key>WorkingDirectory</key>
82
+ <string>${homedir()}</string>
83
+ <key>StandardOutPath</key>
84
+ <string>${join(logDir, 'daemon.log')}</string>
85
+ <key>StandardErrorPath</key>
86
+ <string>${join(logDir, 'daemon.err')}</string>
87
+ <key>EnvironmentVariables</key>
88
+ <dict>
89
+ <key>NODE_ENV</key>
90
+ <string>production</string>
91
+ </dict>
92
+ </dict>
93
+ </plist>
94
+ `;
95
+ }
96
+
97
+ function run(cmd, args) {
98
+ try {
99
+ const output = execFileSync(cmd, args, {
100
+ encoding: 'utf-8',
101
+ stdio: ['pipe', 'pipe', 'pipe'],
102
+ });
103
+ return { ok: true, output: output.trim() };
104
+ } catch (err) {
105
+ return { ok: false, output: (err.stderr || err.stdout || err.message || '').trim() };
106
+ }
107
+ }
108
+
109
+ function install() {
110
+ const plat = detectPlatform();
111
+ if (!plat) {
112
+ console.log('Daemon install is not supported on this platform.');
113
+ console.log('Supported: Linux (systemd), macOS (launchd).');
114
+ return;
115
+ }
116
+
117
+ const { nodePath, binPath, isNpxCache } = resolvePaths();
118
+
119
+ if (isNpxCache) {
120
+ console.log('Warning: vibe-usage appears to be running from the npx cache.');
121
+ console.log('The daemon may break when the cache is cleared.');
122
+ console.log('For reliable operation, install globally first:');
123
+ console.log(' npm install -g @vibe-cafe/vibe-usage\n');
124
+ }
125
+
126
+ const paths = getServicePaths(plat);
127
+
128
+ if (existsSync(paths.file)) {
129
+ console.log('Service is already installed. Run `vibe-usage daemon restart` or `daemon uninstall` first.');
130
+ return;
131
+ }
132
+
133
+ mkdirSync(paths.dir, { recursive: true });
134
+
135
+ if (plat === 'systemd') {
136
+ writeFileSync(paths.file, generateSystemdUnit(nodePath, binPath), 'utf-8');
137
+ console.log(`Created ${paths.file}`);
138
+
139
+ run('systemctl', ['--user', 'daemon-reload']);
140
+ const result = run('systemctl', ['--user', 'enable', '--now', `${SERVICE_NAME}.service`]);
141
+ if (!result.ok) {
142
+ console.error(`Failed to start service: ${result.output}`);
143
+ return;
144
+ }
145
+ console.log('Service enabled and started.');
146
+ }
147
+
148
+ if (plat === 'launchd') {
149
+ mkdirSync(join(homedir(), '.vibe-usage'), { recursive: true });
150
+ writeFileSync(paths.file, generateLaunchdPlist(nodePath, binPath), 'utf-8');
151
+ console.log(`Created ${paths.file}`);
152
+
153
+ const result = run('launchctl', ['load', paths.file]);
154
+ if (!result.ok) {
155
+ console.error(`Failed to load service: ${result.output}`);
156
+ return;
157
+ }
158
+ console.log('Service loaded and started.');
159
+ }
160
+
161
+ console.log('\nDaemon installed. Usage data will sync automatically every 5 minutes.');
162
+ console.log('Run `vibe-usage daemon status` to check.');
163
+ }
164
+
165
+ function uninstall() {
166
+ const plat = detectPlatform();
167
+ if (!plat) {
168
+ console.log('No supported service platform detected.');
169
+ return;
170
+ }
171
+
172
+ const paths = getServicePaths(plat);
173
+
174
+ if (!existsSync(paths.file)) {
175
+ console.log('No daemon service is installed.');
176
+ return;
177
+ }
178
+
179
+ if (plat === 'systemd') {
180
+ run('systemctl', ['--user', 'stop', `${SERVICE_NAME}.service`]);
181
+ run('systemctl', ['--user', 'disable', `${SERVICE_NAME}.service`]);
182
+ unlinkSync(paths.file);
183
+ run('systemctl', ['--user', 'daemon-reload']);
184
+ console.log('Service stopped, disabled, and removed.');
185
+ }
186
+
187
+ if (plat === 'launchd') {
188
+ run('launchctl', ['unload', paths.file]);
189
+ unlinkSync(paths.file);
190
+ console.log('Service unloaded and removed.');
191
+ }
192
+ }
193
+
194
+ function status() {
195
+ const plat = detectPlatform();
196
+ if (!plat) {
197
+ console.log('No supported service platform detected.');
198
+ return;
199
+ }
200
+
201
+ const paths = getServicePaths(plat);
202
+
203
+ if (!existsSync(paths.file)) {
204
+ console.log('No daemon service is installed.');
205
+ console.log('Run `vibe-usage daemon install` to set up.');
206
+ return;
207
+ }
208
+
209
+ if (plat === 'systemd') {
210
+ const result = run('systemctl', ['--user', 'status', `${SERVICE_NAME}.service`]);
211
+ console.log(result.output);
212
+ }
213
+
214
+ if (plat === 'launchd') {
215
+ const result = run('launchctl', ['list', LAUNCHD_LABEL]);
216
+ if (result.ok) {
217
+ console.log(`Service: ${LAUNCHD_LABEL}`);
218
+ console.log(result.output);
219
+ } else {
220
+ console.log('Service is installed but not currently running.');
221
+ }
222
+ }
223
+ }
224
+
225
+ function stop() {
226
+ const plat = detectPlatform();
227
+ if (!plat) {
228
+ console.log('No supported service platform detected.');
229
+ return;
230
+ }
231
+
232
+ if (plat === 'systemd') {
233
+ const result = run('systemctl', ['--user', 'stop', `${SERVICE_NAME}.service`]);
234
+ console.log(result.ok ? 'Service stopped.' : `Failed: ${result.output}`);
235
+ }
236
+
237
+ if (plat === 'launchd') {
238
+ const result = run('launchctl', ['stop', LAUNCHD_LABEL]);
239
+ console.log(result.ok ? 'Service stopped.' : `Failed: ${result.output}`);
240
+ }
241
+ }
242
+
243
+ function restart() {
244
+ const plat = detectPlatform();
245
+ if (!plat) {
246
+ console.log('No supported service platform detected.');
247
+ return;
248
+ }
249
+
250
+ if (plat === 'systemd') {
251
+ const result = run('systemctl', ['--user', 'restart', `${SERVICE_NAME}.service`]);
252
+ console.log(result.ok ? 'Service restarted.' : `Failed: ${result.output}`);
253
+ }
254
+
255
+ if (plat === 'launchd') {
256
+ run('launchctl', ['stop', LAUNCHD_LABEL]);
257
+ const result = run('launchctl', ['start', LAUNCHD_LABEL]);
258
+ console.log(result.ok ? 'Service restarted.' : `Failed: ${result.output}`);
259
+ }
260
+ }
261
+
262
+ const SUBCOMMANDS = { install, uninstall, status, stop, restart };
263
+
264
+ export async function manageDaemon(subcommand) {
265
+ const fn = SUBCOMMANDS[subcommand];
266
+ if (!fn) {
267
+ console.error(`Unknown daemon subcommand: ${subcommand}`);
268
+ console.error('Usage: vibe-usage daemon <install|uninstall|status|stop|restart>');
269
+ process.exit(1);
270
+ }
271
+ fn();
272
+ }
package/src/index.js CHANGED
@@ -109,8 +109,14 @@ export async function run(args) {
109
109
  }
110
110
  case 'daemon':
111
111
  case '--daemon': {
112
- const { runDaemon } = await import('./daemon.js');
113
- await runDaemon();
112
+ const sub = args[1];
113
+ if (sub && ['install', 'uninstall', 'status', 'stop', 'restart'].includes(sub)) {
114
+ const { manageDaemon } = await import('./daemon-service.js');
115
+ await manageDaemon(sub);
116
+ } else {
117
+ const { runDaemon } = await import('./daemon.js');
118
+ await runDaemon();
119
+ }
114
120
  break;
115
121
  }
116
122
  case 'skill': {
@@ -136,7 +142,12 @@ export async function run(args) {
136
142
  npx @vibe-cafe/vibe-usage Init (first run) or sync
137
143
  npx @vibe-cafe/vibe-usage init Set up API key
138
144
  npx @vibe-cafe/vibe-usage sync Manually sync usage data
139
- npx @vibe-cafe/vibe-usage daemon Continuous sync (every 5m)
145
+ npx @vibe-cafe/vibe-usage daemon Continuous sync (every 5m, foreground)
146
+ npx @vibe-cafe/vibe-usage daemon install Install background service (systemd/launchd)
147
+ npx @vibe-cafe/vibe-usage daemon uninstall Remove background service
148
+ npx @vibe-cafe/vibe-usage daemon status Show background service status
149
+ npx @vibe-cafe/vibe-usage daemon stop Stop background service
150
+ npx @vibe-cafe/vibe-usage daemon restart Restart background service
140
151
  npx @vibe-cafe/vibe-usage reset Delete all data and re-upload
141
152
  npx @vibe-cafe/vibe-usage reset --local Delete data for this host only and re-upload
142
153
  npx @vibe-cafe/vibe-usage skill Install skill for AI coding tools
package/src/init.js CHANGED
@@ -75,4 +75,8 @@ export async function runInit() {
75
75
  await runSync();
76
76
 
77
77
  console.log(`\nSetup complete! View your dashboard at: ${apiUrl}/usage`);
78
+
79
+ if (process.platform === 'linux' || process.platform === 'darwin') {
80
+ console.log('\nTip: Run `npx @vibe-cafe/vibe-usage daemon install` to sync automatically in the background.');
81
+ }
78
82
  }
@@ -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',