getaimeter 0.6.4 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getaimeter",
3
- "version": "0.6.4",
3
+ "version": "0.7.1",
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/tray-launcher.vbs CHANGED
@@ -1,2 +1,4 @@
1
1
  Set objShell = CreateObject("WScript.Shell")
2
- objShell.Run "powershell -NoProfile -ExecutionPolicy Bypass -STA -File """ & Replace(WScript.ScriptFullName, "tray-launcher.vbs", "tray.ps1") & """ -IconPath """ & Replace(WScript.ScriptFullName, "tray-launcher.vbs", "icon.ico") & """ -MaskedKey """ & WScript.Arguments(0) & """ -LogPath """ & WScript.Arguments(1) & """ -ParentPid " & WScript.Arguments(2), 0, False
2
+ Dim sVersion
3
+ If WScript.Arguments.Count > 3 Then sVersion = WScript.Arguments(3) Else sVersion = "unknown"
4
+ objShell.Run "powershell -NoProfile -ExecutionPolicy Bypass -STA -File """ & Replace(WScript.ScriptFullName, "tray-launcher.vbs", "tray.ps1") & """ -IconPath """ & Replace(WScript.ScriptFullName, "tray-launcher.vbs", "icon.ico") & """ -MaskedKey """ & WScript.Arguments(0) & """ -LogPath """ & WScript.Arguments(1) & """ -ParentPid " & WScript.Arguments(2) & " -Version """ & sVersion & """", 0, False
package/tray.js CHANGED
@@ -13,6 +13,7 @@ async function startTray(onStop) {
13
13
  const apiKey = getApiKey();
14
14
  const maskedKey = apiKey ? `${apiKey.slice(0, 8)}...${apiKey.slice(-4)}` : 'not set';
15
15
  const logPath = path.join(AIMETER_DIR, 'watcher.log');
16
+ const version = require('./package.json').version;
16
17
 
17
18
  return new Promise((resolve) => {
18
19
  // Use cscript to launch VBS which launches PowerShell hidden with -STA
@@ -22,6 +23,7 @@ async function startTray(onStop) {
22
23
  maskedKey,
23
24
  logPath,
24
25
  String(process.pid),
26
+ version,
25
27
  ], { windowsHide: true }, (err) => {
26
28
  if (err) resolve(null);
27
29
  });
package/tray.ps1 CHANGED
@@ -2,12 +2,23 @@ param(
2
2
  [string]$IconPath,
3
3
  [string]$MaskedKey,
4
4
  [string]$LogPath,
5
- [int]$ParentPid
5
+ [int]$ParentPid,
6
+ [string]$Version = "unknown"
6
7
  )
7
8
 
8
9
  Add-Type -AssemblyName System.Windows.Forms
9
10
  Add-Type -AssemblyName System.Drawing
10
11
 
12
+ # ── Prevent duplicate tray instances ──────────────────────────────────────────
13
+ $mutexName = "Global\AIMeterTray"
14
+ $createdNew = $false
15
+ $mutex = New-Object System.Threading.Mutex($true, $mutexName, [ref]$createdNew)
16
+ if (-not $createdNew) {
17
+ # Another tray is already running — exit silently
18
+ $mutex.Dispose()
19
+ exit 0
20
+ }
21
+
11
22
  # Create tray icon
12
23
  $tray = New-Object System.Windows.Forms.NotifyIcon
13
24
 
@@ -21,13 +32,13 @@ if ($IconPath -and (Test-Path $IconPath)) {
21
32
  $tray.Icon = [System.Drawing.SystemIcons]::Application
22
33
  }
23
34
 
24
- $tray.Text = "AIMeter - Tracking AI usage"
35
+ $tray.Text = "AIMeter v$Version - Tracking AI usage"
25
36
  $tray.Visible = $true
26
37
 
27
38
  # Context menu
28
39
  $menu = New-Object System.Windows.Forms.ContextMenuStrip
29
40
 
30
- $header = $menu.Items.Add("AIMeter Watcher")
41
+ $header = $menu.Items.Add("AIMeter Watcher v$Version")
31
42
  $header.Enabled = $false
32
43
  $header.Font = New-Object System.Drawing.Font($header.Font, [System.Drawing.FontStyle]::Bold)
33
44
 
@@ -44,8 +55,6 @@ $dashboard.Add_Click({ Start-Process "https://getaimeter.com/dashboard" })
44
55
  $logs = $menu.Items.Add("View Logs")
45
56
  $viewerScript = Join-Path (Split-Path $MyInvocation.MyCommand.Path) 'log-viewer.ps1'
46
57
  $logs.Add_Click({
47
- # Launch log viewer as a separate process so it doesn't block the tray
48
- # Launch via wscript to avoid any console window flash
49
58
  $vbsCmd = "CreateObject(""WScript.Shell"").Run ""powershell -NoProfile -ExecutionPolicy Bypass -STA -WindowStyle Hidden -File """"$viewerScript"""" -LogPath """"$LogPath"""""", 0, False"
50
59
  $tempVbs = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'aimeter-logviewer.vbs')
51
60
  [System.IO.File]::WriteAllText($tempVbs, $vbsCmd)
@@ -54,10 +63,78 @@ $logs.Add_Click({
54
63
 
55
64
  $menu.Items.Add("-")
56
65
 
66
+ # ── Help submenu ──────────────────────────────────────────────────────────────
67
+ $helpMenu = New-Object System.Windows.Forms.ToolStripMenuItem("Help")
68
+
69
+ $aboutItem = New-Object System.Windows.Forms.ToolStripMenuItem("About AIMeter")
70
+ $aboutItem.Add_Click({
71
+ [System.Windows.Forms.MessageBox]::Show(
72
+ "AIMeter v$Version`n`nAI coding cost optimizer.`nTrack Claude, Cursor, Codex, and Gemini.`n`nhttps://getaimeter.com",
73
+ "About AIMeter",
74
+ [System.Windows.Forms.MessageBoxButtons]::OK,
75
+ [System.Windows.Forms.MessageBoxIcon]::Information
76
+ )
77
+ })
78
+ $helpMenu.DropDownItems.Add($aboutItem) | Out-Null
79
+
80
+ $checkUpdateItem = New-Object System.Windows.Forms.ToolStripMenuItem("Check for Updates")
81
+ $checkUpdateItem.Add_Click({
82
+ $checkUpdateItem.Text = "Checking..."
83
+ $checkUpdateItem.Enabled = $false
84
+ try {
85
+ $response = Invoke-RestMethod -Uri "https://registry.npmjs.org/getaimeter/latest" -TimeoutSec 5
86
+ $latest = $response.version
87
+ if ($latest -and $latest -ne $Version) {
88
+ $result = [System.Windows.Forms.MessageBox]::Show(
89
+ "Update available: v$Version -> v$latest`n`nRun this command to update:`nnpm install -g getaimeter@latest`n`nThen restart the watcher.`n`nOpen terminal to update now?",
90
+ "Update Available",
91
+ [System.Windows.Forms.MessageBoxButtons]::YesNo,
92
+ [System.Windows.Forms.MessageBoxIcon]::Information
93
+ )
94
+ if ($result -eq [System.Windows.Forms.DialogResult]::Yes) {
95
+ Start-Process "cmd.exe" -ArgumentList "/k npm install -g getaimeter@latest && echo. && echo Update complete. Restart the watcher with: aimeter stop && aimeter start"
96
+ }
97
+ } else {
98
+ [System.Windows.Forms.MessageBox]::Show(
99
+ "You are running the latest version (v$Version).",
100
+ "No Updates",
101
+ [System.Windows.Forms.MessageBoxButtons]::OK,
102
+ [System.Windows.Forms.MessageBoxIcon]::Information
103
+ )
104
+ }
105
+ } catch {
106
+ [System.Windows.Forms.MessageBox]::Show(
107
+ "Could not check for updates. Please check your internet connection.",
108
+ "Update Check Failed",
109
+ [System.Windows.Forms.MessageBoxButtons]::OK,
110
+ [System.Windows.Forms.MessageBoxIcon]::Warning
111
+ )
112
+ }
113
+ $checkUpdateItem.Text = "Check for Updates"
114
+ $checkUpdateItem.Enabled = $true
115
+ })
116
+ $helpMenu.DropDownItems.Add($checkUpdateItem) | Out-Null
117
+
118
+ $helpMenu.DropDownItems.Add("-") | Out-Null
119
+
120
+ $docsItem = New-Object System.Windows.Forms.ToolStripMenuItem("Documentation")
121
+ $docsItem.Add_Click({ Start-Process "https://www.npmjs.com/package/getaimeter" })
122
+ $helpMenu.DropDownItems.Add($docsItem) | Out-Null
123
+
124
+ $websiteItem = New-Object System.Windows.Forms.ToolStripMenuItem("Website")
125
+ $websiteItem.Add_Click({ Start-Process "https://getaimeter.com" })
126
+ $helpMenu.DropDownItems.Add($websiteItem) | Out-Null
127
+
128
+ $menu.Items.Add($helpMenu)
129
+
130
+ $menu.Items.Add("-")
131
+
57
132
  $stop = $menu.Items.Add("Stop Watcher")
58
133
  $stop.Add_Click({
59
134
  $tray.Visible = $false
60
135
  $tray.Dispose()
136
+ $mutex.ReleaseMutex()
137
+ $mutex.Dispose()
61
138
  if ($ParentPid -gt 0) {
62
139
  try { Stop-Process -Id $ParentPid -Force -ErrorAction SilentlyContinue } catch {}
63
140
  }
@@ -77,11 +154,16 @@ $timer.Add_Tick({
77
154
  # Parent died, clean up
78
155
  $tray.Visible = $false
79
156
  $tray.Dispose()
157
+ $mutex.ReleaseMutex()
158
+ $mutex.Dispose()
80
159
  [System.Windows.Forms.Application]::Exit()
81
160
  }
82
161
  }
83
162
  })
84
163
  $timer.Start()
85
164
 
165
+ # Double-click tray icon opens dashboard
166
+ $tray.Add_DoubleClick({ Start-Process "https://getaimeter.com/dashboard" })
167
+
86
168
  # Run message loop
87
169
  [System.Windows.Forms.Application]::Run()
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
  }