getaimeter 0.4.0 → 0.6.0

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.
Files changed (3) hide show
  1. package/config.js +24 -0
  2. package/package.json +10 -5
  3. package/watcher.js +231 -1
package/config.js CHANGED
@@ -76,6 +76,30 @@ function getWatchPaths() {
76
76
  const codexHistory = path.join(os.homedir(), '.codex');
77
77
  if (fs.existsSync(codexHistory) && !paths.includes(codexHistory)) paths.push(codexHistory);
78
78
 
79
+ // 4. GitHub Copilot CLI sessions
80
+ const copilotSessions = path.join(os.homedir(), '.copilot', 'session-state');
81
+ if (fs.existsSync(copilotSessions)) paths.push(copilotSessions);
82
+
83
+ // 5. Gemini CLI sessions
84
+ const geminiDir = path.join(os.homedir(), '.gemini', 'tmp');
85
+ if (fs.existsSync(geminiDir)) {
86
+ // Gemini stores sessions under ~/.gemini/tmp/<project_hash>/chats/
87
+ // Watch the entire tmp dir and findJsonlFiles will recurse into it
88
+ paths.push(geminiDir);
89
+ }
90
+
91
+ // 6. Cursor IDE — stores conversations in SQLite at globalStorage/state.vscdb
92
+ // We watch the globalStorage dir; the watcher handles .vscdb files separately
93
+ let cursorGlobal;
94
+ if (platform === 'win32') {
95
+ cursorGlobal = path.join(process.env.APPDATA || '', 'Cursor', 'User', 'globalStorage');
96
+ } else if (platform === 'darwin') {
97
+ cursorGlobal = path.join(os.homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage');
98
+ } else {
99
+ cursorGlobal = path.join(os.homedir(), '.config', 'Cursor', 'User', 'globalStorage');
100
+ }
101
+ if (fs.existsSync(cursorGlobal)) paths.push(cursorGlobal);
102
+
79
103
  return paths;
80
104
  }
81
105
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "getaimeter",
3
- "version": "0.4.0",
4
- "description": "Track your Claude AI usage across CLI, VS Code, and Desktop App. One command to start.",
3
+ "version": "0.6.0",
4
+ "description": "Track AI coding costs across Claude, Cursor, Codex, and Gemini. Optimization recommendations that cut costs by 30%.",
5
5
  "bin": {
6
6
  "aimeter": "cli.js"
7
7
  },
@@ -13,14 +13,19 @@
13
13
  "keywords": [
14
14
  "claude",
15
15
  "anthropic",
16
- "usage",
17
- "tracking",
16
+ "cursor",
17
+ "codex",
18
+ "gemini",
18
19
  "ai",
19
20
  "tokens",
20
21
  "cost",
22
+ "usage",
23
+ "tracking",
21
24
  "monitor",
25
+ "optimization",
22
26
  "claude-code",
23
- "vscode"
27
+ "vscode",
28
+ "multi-tool"
24
29
  ],
25
30
  "files": [
26
31
  "cli.js",
package/watcher.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const crypto = require('crypto');
6
+ const { execSync } = require('child_process');
6
7
  const { getApiKey, getWatchPaths } = require('./config');
7
8
  const { getOffset, setOffset, isDuplicate, save: saveState } = require('./state');
8
9
  const { postUsage } = require('./reporter');
@@ -65,6 +66,23 @@ function detectSource(filePath) {
65
66
  }
66
67
  }
67
68
 
69
+ if (normalized.includes('.copilot/') || normalized.includes('/copilot/')) {
70
+ _sourceCache.set(filePath, 'copilot_cli');
71
+ return 'copilot_cli';
72
+ }
73
+ if (normalized.includes('.gemini/') || normalized.includes('/gemini/')) {
74
+ _sourceCache.set(filePath, 'gemini_cli');
75
+ return 'gemini_cli';
76
+ }
77
+ if (normalized.includes('.codex/') || normalized.includes('/codex/')) {
78
+ _sourceCache.set(filePath, 'codex_cli');
79
+ return 'codex_cli';
80
+ }
81
+ if (normalized.includes('/Cursor/') || normalized.includes('/cursor/') || normalized.includes('.cursor/')) {
82
+ _sourceCache.set(filePath, 'cursor');
83
+ return 'cursor';
84
+ }
85
+
68
86
  // Read first 10KB of the file to find entrypoint or IDE markers
69
87
  let source = 'cli'; // default
70
88
  try {
@@ -182,6 +200,49 @@ function extractNewUsage(filePath) {
182
200
  continue;
183
201
  }
184
202
 
203
+ // ── Copilot CLI format ────────────────────────────────────────
204
+ // Copilot events: { type: "token_usage", input_tokens, output_tokens, reasoning_tokens, model }
205
+ if (obj.type === 'token_usage' && (obj.input_tokens !== undefined || obj.output_tokens !== undefined)) {
206
+ const copilotModel = obj.model || 'copilot';
207
+ const hashKey = `${filePath}:${lineOffset}:copilot:${obj.input_tokens || 0}:${obj.output_tokens || 0}`;
208
+ const hash = crypto.createHash('md5').update(hashKey).digest('hex');
209
+ if (isDuplicate(hash)) continue;
210
+
211
+ usageEvents.push({
212
+ provider: 'github',
213
+ model: copilotModel,
214
+ source: 'copilot_cli',
215
+ inputTokens: obj.input_tokens || 0,
216
+ outputTokens: obj.output_tokens || 0,
217
+ thinkingTokens: obj.reasoning_tokens || 0,
218
+ cacheReadTokens: 0,
219
+ cacheWriteTokens: 0,
220
+ });
221
+ continue;
222
+ }
223
+
224
+ // ── Gemini CLI format ─────────────────────────────────────────
225
+ // Gemini events may contain usageMetadata: { promptTokenCount, candidatesTokenCount, totalTokenCount }
226
+ if (obj.usageMetadata && (obj.usageMetadata.promptTokenCount || obj.usageMetadata.candidatesTokenCount)) {
227
+ const um = obj.usageMetadata;
228
+ const geminiModel = obj.modelVersion || obj.model || 'gemini';
229
+ const hashKey = `${filePath}:${lineOffset}:gemini:${um.promptTokenCount || 0}:${um.candidatesTokenCount || 0}`;
230
+ const hash = crypto.createHash('md5').update(hashKey).digest('hex');
231
+ if (isDuplicate(hash)) continue;
232
+
233
+ usageEvents.push({
234
+ provider: 'google',
235
+ model: geminiModel,
236
+ source: 'gemini_cli',
237
+ inputTokens: um.promptTokenCount || 0,
238
+ outputTokens: um.candidatesTokenCount || 0,
239
+ thinkingTokens: um.thoughtsTokenCount || 0,
240
+ cacheReadTokens: um.cachedContentTokenCount || 0,
241
+ cacheWriteTokens: 0,
242
+ });
243
+ continue;
244
+ }
245
+
185
246
  continue;
186
247
  }
187
248
 
@@ -245,6 +306,129 @@ function extractNewUsage(filePath) {
245
306
  return usageEvents;
246
307
  }
247
308
 
309
+ // ---------------------------------------------------------------------------
310
+ // Cursor IDE — extract usage from SQLite state.vscdb
311
+ // ---------------------------------------------------------------------------
312
+
313
+ // Track last-seen composerData keys to detect new conversations
314
+ const _cursorSeenKeys = new Set();
315
+ let _cursorSqliteAvailable = null; // null = unchecked, true/false after first check
316
+
317
+ function isSqliteAvailable() {
318
+ if (_cursorSqliteAvailable !== null) return _cursorSqliteAvailable;
319
+ try {
320
+ execSync('sqlite3 --version', { stdio: 'pipe', timeout: 5000 });
321
+ _cursorSqliteAvailable = true;
322
+ } catch {
323
+ _cursorSqliteAvailable = false;
324
+ log('sqlite3 CLI not found — Cursor tracking disabled. Install sqlite3 to enable.');
325
+ }
326
+ return _cursorSqliteAvailable;
327
+ }
328
+
329
+ function extractCursorUsage(dbPath) {
330
+ if (!isSqliteAvailable()) return [];
331
+ if (!fs.existsSync(dbPath)) return [];
332
+
333
+ // Check if the DB file has been modified since our last check
334
+ let stat;
335
+ try { stat = fs.statSync(dbPath); } catch { return []; }
336
+ const currentMtime = stat.mtimeMs;
337
+ const lastMtime = getOffset(dbPath + ':mtime');
338
+ if (currentMtime <= lastMtime) return [];
339
+
340
+ const usageEvents = [];
341
+
342
+ try {
343
+ // Query composerData entries from Cursor's KV store
344
+ // Each entry has usageData with per-model cost and count
345
+ const raw = execSync(
346
+ `sqlite3 "${dbPath}" "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'"`,
347
+ { timeout: 10000, maxBuffer: 50 * 1024 * 1024, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
348
+ );
349
+
350
+ if (!raw || !raw.trim()) {
351
+ setOffset(dbPath + ':mtime', currentMtime);
352
+ return [];
353
+ }
354
+
355
+ for (const line of raw.split('\n')) {
356
+ if (!line.trim()) continue;
357
+
358
+ // sqlite3 outputs "key|value" with pipe separator
359
+ const sepIdx = line.indexOf('|');
360
+ if (sepIdx < 0) continue;
361
+
362
+ const key = line.substring(0, sepIdx);
363
+ const valueStr = line.substring(sepIdx + 1);
364
+
365
+ // Skip already-seen conversations
366
+ if (_cursorSeenKeys.has(key)) continue;
367
+
368
+ let data;
369
+ try { data = JSON.parse(valueStr); } catch { continue; }
370
+
371
+ // Extract usage from composerData.usageData
372
+ // Format: { "model-name": { costInCents: 0.42, amount: 1 }, ... }
373
+ const usage = data.usageData;
374
+ if (!usage || typeof usage !== 'object') {
375
+ _cursorSeenKeys.add(key);
376
+ continue;
377
+ }
378
+
379
+ const composerId = data.composerId || key;
380
+ const timestamp = data.lastUpdatedAt || data.createdAt;
381
+
382
+ for (const [model, stats] of Object.entries(usage)) {
383
+ if (!stats || typeof stats !== 'object') continue;
384
+
385
+ const costCents = stats.costInCents || 0;
386
+ const amount = stats.amount || 0;
387
+ if (costCents <= 0 && amount <= 0) continue;
388
+
389
+ const hashKey = `cursor:${composerId}:${model}:${costCents}:${amount}`;
390
+ const hash = crypto.createHash('md5').update(hashKey).digest('hex');
391
+ if (isDuplicate(hash)) continue;
392
+
393
+ // Determine provider from model name
394
+ let provider = 'openai'; // default — Cursor uses OpenAI models primarily
395
+ if (model.includes('claude') || model.includes('anthropic')) {
396
+ provider = 'anthropic';
397
+ } else if (model.includes('gemini')) {
398
+ provider = 'google';
399
+ }
400
+
401
+ // Estimate tokens from cost (rough inverse of pricing)
402
+ // costInCents is total cost. We don't have exact input/output split,
403
+ // so we attribute it all as output tokens for a rough estimate.
404
+ // Using mid-range pricing for estimation: ~$10/M tokens average
405
+ const estimatedTokens = Math.round((costCents / 100) / (10 / 1_000_000));
406
+
407
+ usageEvents.push({
408
+ provider,
409
+ model,
410
+ source: 'cursor',
411
+ inputTokens: 0,
412
+ outputTokens: estimatedTokens > 0 ? estimatedTokens : amount * 500, // fallback: ~500 tokens per request
413
+ thinkingTokens: 0,
414
+ cacheReadTokens: 0,
415
+ cacheWriteTokens: 0,
416
+ });
417
+ }
418
+
419
+ _cursorSeenKeys.add(key);
420
+ }
421
+ } catch (err) {
422
+ // SQLite might be locked by Cursor — this is normal, retry next poll
423
+ if (!err.message.includes('database is locked')) {
424
+ logError('Cursor SQLite read error:', err.message);
425
+ }
426
+ }
427
+
428
+ setOffset(dbPath + ':mtime', currentMtime);
429
+ return usageEvents;
430
+ }
431
+
248
432
  // ---------------------------------------------------------------------------
249
433
  // Report usage events to backend
250
434
  // ---------------------------------------------------------------------------
@@ -319,6 +503,20 @@ function findJsonlFiles(dir) {
319
503
  return results;
320
504
  }
321
505
 
506
+ /**
507
+ * Find Cursor state.vscdb files in watch paths.
508
+ */
509
+ function findCursorDbs(dirs) {
510
+ const results = [];
511
+ for (const dir of dirs) {
512
+ const vscdb = path.join(dir, 'state.vscdb');
513
+ if (fs.existsSync(vscdb)) {
514
+ results.push(vscdb);
515
+ }
516
+ }
517
+ return results;
518
+ }
519
+
322
520
  /**
323
521
  * Start watching all configured paths.
324
522
  * Returns a cleanup function.
@@ -327,7 +525,7 @@ function startWatching() {
327
525
  const watchPaths = getWatchPaths();
328
526
 
329
527
  if (watchPaths.length === 0) {
330
- logError('No Claude transcript directories found. Is Claude Code installed?');
528
+ logError('No AI tool directories found. Is Claude Code, Cursor, Codex, or Gemini CLI installed?');
331
529
  process.exit(1);
332
530
  }
333
531
 
@@ -381,10 +579,29 @@ function startWatching() {
381
579
  const POLL_INTERVAL = 5_000;
382
580
  let polling = false;
383
581
 
582
+ // Find Cursor DBs once at startup
583
+ const cursorDbs = findCursorDbs(watchPaths);
584
+ if (cursorDbs.length > 0) {
585
+ log('Cursor databases found:', cursorDbs.join(', '));
586
+ // Mark existing Cursor data as seen on first run
587
+ if (isFirstRun) {
588
+ for (const dbPath of cursorDbs) {
589
+ try {
590
+ const mtime = fs.statSync(dbPath).mtimeMs;
591
+ setOffset(dbPath + ':mtime', mtime);
592
+ // Pre-populate seen keys so we don't report historical data
593
+ extractCursorUsage(dbPath); // populates _cursorSeenKeys but we discard results
594
+ } catch {}
595
+ }
596
+ log('Cursor: marked existing conversations as read');
597
+ }
598
+ }
599
+
384
600
  const pollInterval = setInterval(async () => {
385
601
  if (polling) return; // skip if previous poll still running
386
602
  polling = true;
387
603
  try {
604
+ // Poll JSONL files (Claude, Codex, Gemini, etc.)
388
605
  for (const watchPath of watchPaths) {
389
606
  const files = findJsonlFiles(watchPath);
390
607
  for (const file of files) {
@@ -397,6 +614,19 @@ function startWatching() {
397
614
  } catch {}
398
615
  }
399
616
  }
617
+
618
+ // Poll Cursor SQLite databases
619
+ for (const dbPath of cursorDbs) {
620
+ try {
621
+ const events = extractCursorUsage(dbPath);
622
+ if (events.length > 0) {
623
+ await reportEvents(events);
624
+ saveState();
625
+ }
626
+ } catch (err) {
627
+ logError('Cursor poll error:', err.message);
628
+ }
629
+ }
400
630
  } finally {
401
631
  polling = false;
402
632
  }