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.
- package/config.js +24 -0
- package/package.json +10 -5
- 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
|
-
"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');
|
|
@@ -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
|
|
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
|
}
|