getaimeter 0.7.0 → 0.7.2

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 (4) hide show
  1. package/cli.js +2 -0
  2. package/package.json +1 -1
  3. package/service.js +54 -1
  4. package/watcher.js +37 -34
package/cli.js CHANGED
@@ -129,6 +129,8 @@ async function runSetup() {
129
129
  console.log(` ✓ Installed at: ${result.path}`);
130
130
  if (result.platform === 'windows') {
131
131
  console.log(' ✓ Will auto-start on login (Windows Startup folder)');
132
+ console.log(' ✓ Desktop shortcut created (double-click "AIMeter" to start)');
133
+ console.log(' ✓ Start Menu shortcut created');
132
134
  } else if (result.platform === 'macos') {
133
135
  console.log(' ✓ Will auto-start on login (launchd)');
134
136
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getaimeter",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
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"
package/service.js CHANGED
@@ -86,12 +86,65 @@ WshShell.Run """${nodePath}"" ""${script}"" watch", 0, False
86
86
 
87
87
  fs.mkdirSync(path.dirname(vbsPath), { recursive: true });
88
88
  fs.writeFileSync(vbsPath, vbs, 'utf8');
89
+
90
+ // Create Desktop and Start Menu shortcuts so users can launch without a terminal
91
+ createWindowsShortcuts(nodePath, script);
92
+
89
93
  return vbsPath;
90
94
  }
91
95
 
96
+ /**
97
+ * Create .lnk shortcuts on Desktop and Start Menu via PowerShell COM.
98
+ * These are proper Windows shortcuts with icon and description.
99
+ */
100
+ function createWindowsShortcuts(nodePath, script) {
101
+ const iconPath = path.join(__dirname, 'icon.ico').replace(/\\/g, '\\\\');
102
+ const locations = [
103
+ { name: 'Desktop', dir: path.join(os.homedir(), 'Desktop') },
104
+ { name: 'Start Menu', dir: path.join(os.homedir(), 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs') },
105
+ ];
106
+
107
+ for (const loc of locations) {
108
+ const lnkPath = path.join(loc.dir, 'AIMeter.lnk');
109
+ // PowerShell script to create a .lnk shortcut
110
+ const ps = `
111
+ $ws = New-Object -ComObject WScript.Shell
112
+ $s = $ws.CreateShortcut('${lnkPath.replace(/'/g, "''")}')
113
+ $s.TargetPath = '${nodePath.replace(/\\\\/g, '\\').replace(/'/g, "''")}'
114
+ $s.Arguments = '"${script.replace(/\\\\/g, '\\').replace(/'/g, "''")}" start'
115
+ $s.WorkingDirectory = '${os.homedir().replace(/'/g, "''")}'
116
+ $s.Description = 'AIMeter - AI coding cost optimizer'
117
+ $s.WindowStyle = 7
118
+ $iconFile = '${iconPath.replace(/\\\\/g, '\\').replace(/'/g, "''")}'
119
+ if (Test-Path $iconFile) { $s.IconLocation = $iconFile }
120
+ $s.Save()
121
+ `.trim();
122
+
123
+ try {
124
+ require('child_process').execSync(
125
+ `powershell -NoProfile -ExecutionPolicy Bypass -Command "${ps.replace(/"/g, '\\"')}"`,
126
+ { stdio: 'ignore', timeout: 10000 }
127
+ );
128
+ } catch {
129
+ // Non-critical — shortcuts are convenience, not required
130
+ }
131
+ }
132
+ }
133
+
92
134
  function uninstallWindows() {
93
135
  const vbsPath = getWindowsVbsPath();
94
- try { fs.unlinkSync(vbsPath); return true; } catch { return false; }
136
+ try { fs.unlinkSync(vbsPath); } catch {}
137
+
138
+ // Remove Desktop and Start Menu shortcuts
139
+ const shortcuts = [
140
+ path.join(os.homedir(), 'Desktop', 'AIMeter.lnk'),
141
+ path.join(os.homedir(), 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'AIMeter.lnk'),
142
+ ];
143
+ for (const lnk of shortcuts) {
144
+ try { fs.unlinkSync(lnk); } catch {}
145
+ }
146
+
147
+ return true;
95
148
  }
96
149
 
97
150
  function isInstalledWindows() {
package/watcher.js CHANGED
@@ -310,8 +310,9 @@ function extractNewUsage(filePath) {
310
310
  // Cursor IDE — extract usage from SQLite state.vscdb
311
311
  // ---------------------------------------------------------------------------
312
312
 
313
- // Track last-seen composerData keys to detect new conversations
314
- const _cursorSeenKeys = new Set();
313
+ // Track last-seen bubble counts per conversation to detect new messages
314
+ // Map<composerId, { aiCount, aiTextLen, userTextLen }>
315
+ const _cursorConvState = new Map();
315
316
  let _cursorSqliteAvailable = null; // null = unchecked, true/false after first check
316
317
 
317
318
  // Locate sqlite3 binary — may be in PATH or in the WinGet install location
@@ -413,7 +414,6 @@ function extractCursorUsage(dbPath) {
413
414
  const parts = line.split('|');
414
415
  if (parts.length < 4) continue;
415
416
  const [key, composerId, modelName, createdAt] = parts;
416
- if (_cursorSeenKeys.has(key)) continue;
417
417
  conversations.push({ key, composerId, modelName: modelName || 'default', createdAt: parseInt(createdAt) || 0 });
418
418
  }
419
419
 
@@ -422,7 +422,8 @@ function extractCursorUsage(dbPath) {
422
422
  return [];
423
423
  }
424
424
 
425
- // Step 2: For new conversations, count AI response bubbles and measure text
425
+ // Step 2: For each conversation, check if bubble count increased since last check.
426
+ // Report only the DELTA (new messages since last poll).
426
427
  for (const conv of conversations) {
427
428
  try {
428
429
  // Count AI responses (type=2) and sum their text lengths
@@ -430,19 +431,11 @@ function extractCursorUsage(dbPath) {
430
431
  `SELECT count(*), sum(length(json_extract(value, '$.text'))) FROM cursorDiskKV WHERE key LIKE 'bubbleId:${conv.composerId}:%' AND json_extract(value, '$.type') = 2`
431
432
  );
432
433
 
433
- if (!bubbleRaw || !bubbleRaw.trim()) {
434
- _cursorSeenKeys.add(conv.key);
435
- continue;
436
- }
434
+ if (!bubbleRaw || !bubbleRaw.trim()) continue;
437
435
 
438
436
  const [countStr, textLenStr] = bubbleRaw.trim().split('|');
439
- const responseCount = parseInt(countStr) || 0;
440
- const totalTextLen = parseInt(textLenStr) || 0;
441
-
442
- if (responseCount === 0) {
443
- _cursorSeenKeys.add(conv.key);
444
- continue;
445
- }
437
+ const aiCount = parseInt(countStr) || 0;
438
+ const aiTextLen = parseInt(textLenStr) || 0;
446
439
 
447
440
  // Also count user messages for input estimation
448
441
  const userRaw = runSqlite(dbPath,
@@ -450,21 +443,31 @@ function extractCursorUsage(dbPath) {
450
443
  );
451
444
  const userTextLen = parseInt((userRaw || '').trim()) || 0;
452
445
 
453
- // Estimate tokens: ~4 chars per token
454
- const estimatedInputTokens = Math.ceil(userTextLen / 4);
455
- const estimatedOutputTokens = Math.ceil(totalTextLen / 4);
446
+ // Get previous state for this conversation
447
+ const prev = _cursorConvState.get(conv.composerId) || { aiCount: 0, aiTextLen: 0, userTextLen: 0 };
456
448
 
457
- if (estimatedInputTokens === 0 && estimatedOutputTokens === 0) {
458
- _cursorSeenKeys.add(conv.key);
459
- continue;
449
+ // Check if anything changed
450
+ if (aiCount <= prev.aiCount && aiTextLen <= prev.aiTextLen && userTextLen <= prev.userTextLen) {
451
+ continue; // No new messages
460
452
  }
461
453
 
462
- const hashKey = `cursor:${conv.composerId}:${responseCount}:${totalTextLen}`;
454
+ // Calculate delta (new tokens since last check)
455
+ const deltaAiTextLen = Math.max(0, aiTextLen - prev.aiTextLen);
456
+ const deltaUserTextLen = Math.max(0, userTextLen - prev.userTextLen);
457
+
458
+ // Update stored state
459
+ _cursorConvState.set(conv.composerId, { aiCount, aiTextLen, userTextLen });
460
+
461
+ // Estimate tokens: ~4 chars per token
462
+ const estimatedInputTokens = Math.ceil(deltaUserTextLen / 4);
463
+ const estimatedOutputTokens = Math.ceil(deltaAiTextLen / 4);
464
+
465
+ if (estimatedInputTokens === 0 && estimatedOutputTokens === 0) continue;
466
+
467
+ // Dedup by exact delta values
468
+ const hashKey = `cursor:${conv.composerId}:${aiCount}:${aiTextLen}:${userTextLen}`;
463
469
  const hash = crypto.createHash('md5').update(hashKey).digest('hex');
464
- if (isDuplicate(hash)) {
465
- _cursorSeenKeys.add(conv.key);
466
- continue;
467
- }
470
+ if (isDuplicate(hash)) continue;
468
471
 
469
472
  // Determine provider from model name
470
473
  let provider = 'openai';
@@ -487,8 +490,6 @@ function extractCursorUsage(dbPath) {
487
490
  cacheWriteTokens: 0,
488
491
  });
489
492
  } catch {}
490
-
491
- _cursorSeenKeys.add(conv.key);
492
493
  }
493
494
  } catch (err) {
494
495
  if (!err.message.includes('database is locked')) {
@@ -654,20 +655,22 @@ function startWatching() {
654
655
  const cursorDbs = findCursorDbs(watchPaths);
655
656
  if (cursorDbs.length > 0) {
656
657
  log('Cursor databases found:', cursorDbs.join(', '));
657
- // Always pre-populate _cursorSeenKeys on startup so we don't re-report
658
- // historical conversations. On first run, also set the mtime offset.
658
+ // Pre-populate _cursorConvState on startup so we only report new messages.
659
+ // On first run, set the mtime offset and discard results.
660
+ // On subsequent runs, report any new data as catch-up.
659
661
  for (const dbPath of cursorDbs) {
660
662
  try {
661
663
  if (isFirstRun) {
662
- const mtime = fs.statSync(dbPath).mtimeMs;
664
+ // Set WAL mtime so first poll doesn't re-process
665
+ const walPath = dbPath + '-wal';
666
+ const mtime = fs.existsSync(walPath) ? fs.statSync(walPath).mtimeMs : fs.statSync(dbPath).mtimeMs;
663
667
  setOffset(dbPath + ':mtime', mtime);
664
668
  }
665
- // Dry-run extraction to populate _cursorSeenKeys (discard results)
669
+ // Extract to populate _cursorConvState with current bubble counts
666
670
  const existing = extractCursorUsage(dbPath);
667
671
  if (isFirstRun && existing.length > 0) {
668
- log(`Cursor: marked ${existing.length} existing conversations as read`);
672
+ log(`Cursor: marked ${_cursorConvState.size} conversations as read (${existing.length} events skipped)`);
669
673
  } else if (existing.length > 0) {
670
- // Not first run but has new data since last mtime — report it
671
674
  reportEvents(existing);
672
675
  log(`Cursor: catch-up reported ${existing.length} events`);
673
676
  }