getaimeter 0.5.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.
- package/config.js +12 -0
- package/package.json +10 -5
- package/watcher.js +175 -1
package/config.js
CHANGED
|
@@ -88,6 +88,18 @@ function getWatchPaths() {
|
|
|
88
88
|
paths.push(geminiDir);
|
|
89
89
|
}
|
|
90
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
|
+
|
|
91
103
|
return paths;
|
|
92
104
|
}
|
|
93
105
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "getaimeter",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Track
|
|
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
|
-
"
|
|
17
|
-
"
|
|
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');
|
|
@@ -77,6 +78,10 @@ function detectSource(filePath) {
|
|
|
77
78
|
_sourceCache.set(filePath, 'codex_cli');
|
|
78
79
|
return 'codex_cli';
|
|
79
80
|
}
|
|
81
|
+
if (normalized.includes('/Cursor/') || normalized.includes('/cursor/') || normalized.includes('.cursor/')) {
|
|
82
|
+
_sourceCache.set(filePath, 'cursor');
|
|
83
|
+
return 'cursor';
|
|
84
|
+
}
|
|
80
85
|
|
|
81
86
|
// Read first 10KB of the file to find entrypoint or IDE markers
|
|
82
87
|
let source = 'cli'; // default
|
|
@@ -301,6 +306,129 @@ function extractNewUsage(filePath) {
|
|
|
301
306
|
return usageEvents;
|
|
302
307
|
}
|
|
303
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
|
+
|
|
304
432
|
// ---------------------------------------------------------------------------
|
|
305
433
|
// Report usage events to backend
|
|
306
434
|
// ---------------------------------------------------------------------------
|
|
@@ -375,6 +503,20 @@ function findJsonlFiles(dir) {
|
|
|
375
503
|
return results;
|
|
376
504
|
}
|
|
377
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
|
+
|
|
378
520
|
/**
|
|
379
521
|
* Start watching all configured paths.
|
|
380
522
|
* Returns a cleanup function.
|
|
@@ -383,7 +525,7 @@ function startWatching() {
|
|
|
383
525
|
const watchPaths = getWatchPaths();
|
|
384
526
|
|
|
385
527
|
if (watchPaths.length === 0) {
|
|
386
|
-
logError('No
|
|
528
|
+
logError('No AI tool directories found. Is Claude Code, Cursor, Codex, or Gemini CLI installed?');
|
|
387
529
|
process.exit(1);
|
|
388
530
|
}
|
|
389
531
|
|
|
@@ -437,10 +579,29 @@ function startWatching() {
|
|
|
437
579
|
const POLL_INTERVAL = 5_000;
|
|
438
580
|
let polling = false;
|
|
439
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
|
+
|
|
440
600
|
const pollInterval = setInterval(async () => {
|
|
441
601
|
if (polling) return; // skip if previous poll still running
|
|
442
602
|
polling = true;
|
|
443
603
|
try {
|
|
604
|
+
// Poll JSONL files (Claude, Codex, Gemini, etc.)
|
|
444
605
|
for (const watchPath of watchPaths) {
|
|
445
606
|
const files = findJsonlFiles(watchPath);
|
|
446
607
|
for (const file of files) {
|
|
@@ -453,6 +614,19 @@ function startWatching() {
|
|
|
453
614
|
} catch {}
|
|
454
615
|
}
|
|
455
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
|
+
}
|
|
456
630
|
} finally {
|
|
457
631
|
polling = false;
|
|
458
632
|
}
|