getaimeter 0.10.0 → 0.11.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 (4) hide show
  1. package/cli.js +657 -657
  2. package/package.json +1 -1
  3. package/tray.ps1 +208 -208
  4. package/watcher.js +36 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getaimeter",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Track AI coding costs across Claude, Cursor, Codex, Copilot, and Gemini. MCP server, billing blocks, optimization recommendations.",
5
5
  "bin": {
6
6
  "aimeter": "cli.js",
package/tray.ps1 CHANGED
@@ -1,208 +1,208 @@
1
- param(
2
- [string]$IconPath,
3
- [string]$MaskedKey,
4
- [string]$LogPath,
5
- [int]$ParentPid,
6
- [string]$Version = "unknown"
7
- )
8
-
9
- Add-Type -AssemblyName System.Windows.Forms
10
- Add-Type -AssemblyName System.Drawing
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
-
22
- # Create tray icon
23
- $tray = New-Object System.Windows.Forms.NotifyIcon
24
-
25
- if ($IconPath -and (Test-Path $IconPath)) {
26
- try {
27
- $tray.Icon = New-Object System.Drawing.Icon($IconPath)
28
- } catch {
29
- $tray.Icon = [System.Drawing.SystemIcons]::Application
30
- }
31
- } else {
32
- $tray.Icon = [System.Drawing.SystemIcons]::Application
33
- }
34
-
35
- $tray.Text = "AIMeter v$Version - Tracking AI usage"
36
- $tray.Visible = $true
37
-
38
- # Context menu
39
- $menu = New-Object System.Windows.Forms.ContextMenuStrip
40
-
41
- $header = $menu.Items.Add("AIMeter Watcher v$Version")
42
- $header.Enabled = $false
43
- $header.Font = New-Object System.Drawing.Font($header.Font, [System.Drawing.FontStyle]::Bold)
44
-
45
- $menu.Items.Add("-")
46
-
47
- $keyItem = $menu.Items.Add("Key: $MaskedKey")
48
- $keyItem.Enabled = $false
49
-
50
- $menu.Items.Add("-")
51
-
52
- $dashboard = $menu.Items.Add("Open Dashboard")
53
- $dashboard.Add_Click({ Start-Process "https://getaimeter.com/dashboard" })
54
-
55
- $logs = $menu.Items.Add("View Logs")
56
- $viewerScript = Join-Path (Split-Path $MyInvocation.MyCommand.Path) 'log-viewer.ps1'
57
- $logs.Add_Click({
58
- # Launch log viewer directly via PowerShell — avoids VBS quoting issues with paths containing spaces
59
- Start-Process "powershell" -ArgumentList @(
60
- "-NoProfile",
61
- "-ExecutionPolicy", "Bypass",
62
- "-STA",
63
- "-WindowStyle", "Hidden",
64
- "-File", $viewerScript,
65
- "-LogPath", $LogPath
66
- ) -WindowStyle Hidden
67
- })
68
-
69
- $menu.Items.Add("-")
70
-
71
- # ── Help submenu ──────────────────────────────────────────────────────────────
72
- $helpMenu = New-Object System.Windows.Forms.ToolStripMenuItem("Help")
73
-
74
- $aboutItem = New-Object System.Windows.Forms.ToolStripMenuItem("About AIMeter")
75
- $aboutItem.Add_Click({
76
- [System.Windows.Forms.MessageBox]::Show(
77
- "AIMeter v$Version`n`nAI coding cost optimizer.`nTrack Claude, Cursor, Codex, and Gemini.`n`nhttps://getaimeter.com",
78
- "About AIMeter",
79
- [System.Windows.Forms.MessageBoxButtons]::OK,
80
- [System.Windows.Forms.MessageBoxIcon]::Information
81
- )
82
- })
83
- $helpMenu.DropDownItems.Add($aboutItem) | Out-Null
84
-
85
- $checkUpdateItem = New-Object System.Windows.Forms.ToolStripMenuItem("Check for Updates")
86
- $script:updateJob = $null
87
- $checkUpdateItem.Add_Click({
88
- $checkUpdateItem.Text = "Checking..."
89
- $checkUpdateItem.Enabled = $false
90
-
91
- # Run HTTP request in a background job to avoid freezing the UI
92
- $script:updateJob = Start-Job -ScriptBlock {
93
- try {
94
- $r = Invoke-RestMethod -Uri "https://registry.npmjs.org/getaimeter/latest" -TimeoutSec 10
95
- return $r.version
96
- } catch {
97
- return "ERROR"
98
- }
99
- }
100
-
101
- # Timer polls for job completion (every 500ms)
102
- $updateTimer = New-Object System.Windows.Forms.Timer
103
- $updateTimer.Interval = 500
104
- $updateTimer.Add_Tick({
105
- if ($script:updateJob.State -eq "Completed") {
106
- $updateTimer.Stop()
107
- $updateTimer.Dispose()
108
- $latest = Receive-Job $script:updateJob
109
- Remove-Job $script:updateJob
110
- $script:updateJob = $null
111
-
112
- $checkUpdateItem.Text = "Check for Updates"
113
- $checkUpdateItem.Enabled = $true
114
-
115
- if ($latest -eq "ERROR") {
116
- [System.Windows.Forms.MessageBox]::Show(
117
- "Could not check for updates. Please check your internet connection.",
118
- "Update Check Failed",
119
- [System.Windows.Forms.MessageBoxButtons]::OK,
120
- [System.Windows.Forms.MessageBoxIcon]::Warning
121
- ) | Out-Null
122
- } elseif ($latest -and $latest -ne $Version) {
123
- $result = [System.Windows.Forms.MessageBox]::Show(
124
- "Update available: v$Version -> v$latest`n`nClick Yes to open a terminal and run the update command.",
125
- "Update Available",
126
- [System.Windows.Forms.MessageBoxButtons]::YesNo,
127
- [System.Windows.Forms.MessageBoxIcon]::Information
128
- )
129
- if ($result -eq [System.Windows.Forms.DialogResult]::Yes) {
130
- # Update and auto-restart: stop watcher, update npm, restart watcher
131
- $nodePath = (Get-Command node -ErrorAction SilentlyContinue).Source
132
- $aimeter = (Get-Command aimeter -ErrorAction SilentlyContinue).Source
133
- Start-Process "cmd.exe" -ArgumentList "/k echo Updating AIMeter... && npm install -g getaimeter@latest && echo. && echo Reinstalling shortcuts... && aimeter install && echo. && echo Updated to latest version! This window can be closed. && echo."
134
-
135
- # Give the cmd window time to stop us, then exit this tray
136
- Start-Sleep -Seconds 3
137
- $tray.Visible = $false
138
- $tray.Dispose()
139
- $mutex.ReleaseMutex()
140
- $mutex.Dispose()
141
- [System.Windows.Forms.Application]::Exit()
142
- }
143
- } else {
144
- [System.Windows.Forms.MessageBox]::Show(
145
- "You are running the latest version (v$Version).",
146
- "No Updates",
147
- [System.Windows.Forms.MessageBoxButtons]::OK,
148
- [System.Windows.Forms.MessageBoxIcon]::Information
149
- ) | Out-Null
150
- }
151
- }
152
- })
153
- $updateTimer.Start()
154
- })
155
- $helpMenu.DropDownItems.Add($checkUpdateItem) | Out-Null
156
-
157
- $helpMenu.DropDownItems.Add("-") | Out-Null
158
-
159
- $docsItem = New-Object System.Windows.Forms.ToolStripMenuItem("Documentation")
160
- $docsItem.Add_Click({ Start-Process "https://www.npmjs.com/package/getaimeter" })
161
- $helpMenu.DropDownItems.Add($docsItem) | Out-Null
162
-
163
- $websiteItem = New-Object System.Windows.Forms.ToolStripMenuItem("Website")
164
- $websiteItem.Add_Click({ Start-Process "https://getaimeter.com" })
165
- $helpMenu.DropDownItems.Add($websiteItem) | Out-Null
166
-
167
- $menu.Items.Add($helpMenu)
168
-
169
- $menu.Items.Add("-")
170
-
171
- $stop = $menu.Items.Add("Stop Watcher")
172
- $stop.Add_Click({
173
- $tray.Visible = $false
174
- $tray.Dispose()
175
- $mutex.ReleaseMutex()
176
- $mutex.Dispose()
177
- if ($ParentPid -gt 0) {
178
- try { Stop-Process -Id $ParentPid -Force -ErrorAction SilentlyContinue } catch {}
179
- }
180
- [System.Windows.Forms.Application]::Exit()
181
- })
182
-
183
- $tray.ContextMenuStrip = $menu
184
-
185
- # Timer to check if parent process is still alive
186
- $timer = New-Object System.Windows.Forms.Timer
187
- $timer.Interval = 5000
188
- $timer.Add_Tick({
189
- if ($ParentPid -gt 0) {
190
- try {
191
- $p = Get-Process -Id $ParentPid -ErrorAction Stop
192
- } catch {
193
- # Parent died, clean up
194
- $tray.Visible = $false
195
- $tray.Dispose()
196
- $mutex.ReleaseMutex()
197
- $mutex.Dispose()
198
- [System.Windows.Forms.Application]::Exit()
199
- }
200
- }
201
- })
202
- $timer.Start()
203
-
204
- # Double-click tray icon opens dashboard
205
- $tray.Add_DoubleClick({ Start-Process "https://getaimeter.com/dashboard" })
206
-
207
- # Run message loop
208
- [System.Windows.Forms.Application]::Run()
1
+ param(
2
+ [string]$IconPath,
3
+ [string]$MaskedKey,
4
+ [string]$LogPath,
5
+ [int]$ParentPid,
6
+ [string]$Version = "unknown"
7
+ )
8
+
9
+ Add-Type -AssemblyName System.Windows.Forms
10
+ Add-Type -AssemblyName System.Drawing
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
+
22
+ # Create tray icon
23
+ $tray = New-Object System.Windows.Forms.NotifyIcon
24
+
25
+ if ($IconPath -and (Test-Path $IconPath)) {
26
+ try {
27
+ $tray.Icon = New-Object System.Drawing.Icon($IconPath)
28
+ } catch {
29
+ $tray.Icon = [System.Drawing.SystemIcons]::Application
30
+ }
31
+ } else {
32
+ $tray.Icon = [System.Drawing.SystemIcons]::Application
33
+ }
34
+
35
+ $tray.Text = "AIMeter v$Version - Tracking AI usage"
36
+ $tray.Visible = $true
37
+
38
+ # Context menu
39
+ $menu = New-Object System.Windows.Forms.ContextMenuStrip
40
+
41
+ $header = $menu.Items.Add("AIMeter Watcher v$Version")
42
+ $header.Enabled = $false
43
+ $header.Font = New-Object System.Drawing.Font($header.Font, [System.Drawing.FontStyle]::Bold)
44
+
45
+ $menu.Items.Add("-")
46
+
47
+ $keyItem = $menu.Items.Add("Key: $MaskedKey")
48
+ $keyItem.Enabled = $false
49
+
50
+ $menu.Items.Add("-")
51
+
52
+ $dashboard = $menu.Items.Add("Open Dashboard")
53
+ $dashboard.Add_Click({ Start-Process "https://getaimeter.com/dashboard" })
54
+
55
+ $logs = $menu.Items.Add("View Logs")
56
+ $viewerScript = Join-Path (Split-Path $MyInvocation.MyCommand.Path) 'log-viewer.ps1'
57
+ $logs.Add_Click({
58
+ # Launch log viewer directly via PowerShell — avoids VBS quoting issues with paths containing spaces
59
+ Start-Process "powershell" -ArgumentList @(
60
+ "-NoProfile",
61
+ "-ExecutionPolicy", "Bypass",
62
+ "-STA",
63
+ "-WindowStyle", "Hidden",
64
+ "-File", $viewerScript,
65
+ "-LogPath", $LogPath
66
+ ) -WindowStyle Hidden
67
+ })
68
+
69
+ $menu.Items.Add("-")
70
+
71
+ # ── Help submenu ──────────────────────────────────────────────────────────────
72
+ $helpMenu = New-Object System.Windows.Forms.ToolStripMenuItem("Help")
73
+
74
+ $aboutItem = New-Object System.Windows.Forms.ToolStripMenuItem("About AIMeter")
75
+ $aboutItem.Add_Click({
76
+ [System.Windows.Forms.MessageBox]::Show(
77
+ "AIMeter v$Version`n`nAI coding cost optimizer.`nTrack Claude, Cursor, Codex, and Gemini.`n`nhttps://getaimeter.com",
78
+ "About AIMeter",
79
+ [System.Windows.Forms.MessageBoxButtons]::OK,
80
+ [System.Windows.Forms.MessageBoxIcon]::Information
81
+ )
82
+ })
83
+ $helpMenu.DropDownItems.Add($aboutItem) | Out-Null
84
+
85
+ $checkUpdateItem = New-Object System.Windows.Forms.ToolStripMenuItem("Check for Updates")
86
+ $script:updateJob = $null
87
+ $checkUpdateItem.Add_Click({
88
+ $checkUpdateItem.Text = "Checking..."
89
+ $checkUpdateItem.Enabled = $false
90
+
91
+ # Run HTTP request in a background job to avoid freezing the UI
92
+ $script:updateJob = Start-Job -ScriptBlock {
93
+ try {
94
+ $r = Invoke-RestMethod -Uri "https://registry.npmjs.org/getaimeter/latest" -TimeoutSec 10
95
+ return $r.version
96
+ } catch {
97
+ return "ERROR"
98
+ }
99
+ }
100
+
101
+ # Timer polls for job completion (every 500ms)
102
+ $updateTimer = New-Object System.Windows.Forms.Timer
103
+ $updateTimer.Interval = 500
104
+ $updateTimer.Add_Tick({
105
+ if ($script:updateJob.State -eq "Completed") {
106
+ $updateTimer.Stop()
107
+ $updateTimer.Dispose()
108
+ $latest = Receive-Job $script:updateJob
109
+ Remove-Job $script:updateJob
110
+ $script:updateJob = $null
111
+
112
+ $checkUpdateItem.Text = "Check for Updates"
113
+ $checkUpdateItem.Enabled = $true
114
+
115
+ if ($latest -eq "ERROR") {
116
+ [System.Windows.Forms.MessageBox]::Show(
117
+ "Could not check for updates. Please check your internet connection.",
118
+ "Update Check Failed",
119
+ [System.Windows.Forms.MessageBoxButtons]::OK,
120
+ [System.Windows.Forms.MessageBoxIcon]::Warning
121
+ ) | Out-Null
122
+ } elseif ($latest -and $latest -ne $Version) {
123
+ $result = [System.Windows.Forms.MessageBox]::Show(
124
+ "Update available: v$Version -> v$latest`n`nClick Yes to open a terminal and run the update command.",
125
+ "Update Available",
126
+ [System.Windows.Forms.MessageBoxButtons]::YesNo,
127
+ [System.Windows.Forms.MessageBoxIcon]::Information
128
+ )
129
+ if ($result -eq [System.Windows.Forms.DialogResult]::Yes) {
130
+ # Update and auto-restart: stop watcher, update npm, restart watcher
131
+ $nodePath = (Get-Command node -ErrorAction SilentlyContinue).Source
132
+ $aimeter = (Get-Command aimeter -ErrorAction SilentlyContinue).Source
133
+ Start-Process "cmd.exe" -ArgumentList "/k echo Updating AIMeter... && npm install -g getaimeter@latest && echo. && echo Reinstalling shortcuts... && aimeter install && echo. && echo Updated to latest version! This window can be closed. && echo."
134
+
135
+ # Give the cmd window time to stop us, then exit this tray
136
+ Start-Sleep -Seconds 3
137
+ $tray.Visible = $false
138
+ $tray.Dispose()
139
+ $mutex.ReleaseMutex()
140
+ $mutex.Dispose()
141
+ [System.Windows.Forms.Application]::Exit()
142
+ }
143
+ } else {
144
+ [System.Windows.Forms.MessageBox]::Show(
145
+ "You are running the latest version (v$Version).",
146
+ "No Updates",
147
+ [System.Windows.Forms.MessageBoxButtons]::OK,
148
+ [System.Windows.Forms.MessageBoxIcon]::Information
149
+ ) | Out-Null
150
+ }
151
+ }
152
+ })
153
+ $updateTimer.Start()
154
+ })
155
+ $helpMenu.DropDownItems.Add($checkUpdateItem) | Out-Null
156
+
157
+ $helpMenu.DropDownItems.Add("-") | Out-Null
158
+
159
+ $docsItem = New-Object System.Windows.Forms.ToolStripMenuItem("Documentation")
160
+ $docsItem.Add_Click({ Start-Process "https://www.npmjs.com/package/getaimeter" })
161
+ $helpMenu.DropDownItems.Add($docsItem) | Out-Null
162
+
163
+ $websiteItem = New-Object System.Windows.Forms.ToolStripMenuItem("Website")
164
+ $websiteItem.Add_Click({ Start-Process "https://getaimeter.com" })
165
+ $helpMenu.DropDownItems.Add($websiteItem) | Out-Null
166
+
167
+ $menu.Items.Add($helpMenu)
168
+
169
+ $menu.Items.Add("-")
170
+
171
+ $stop = $menu.Items.Add("Stop Watcher")
172
+ $stop.Add_Click({
173
+ $tray.Visible = $false
174
+ $tray.Dispose()
175
+ $mutex.ReleaseMutex()
176
+ $mutex.Dispose()
177
+ if ($ParentPid -gt 0) {
178
+ try { Stop-Process -Id $ParentPid -Force -ErrorAction SilentlyContinue } catch {}
179
+ }
180
+ [System.Windows.Forms.Application]::Exit()
181
+ })
182
+
183
+ $tray.ContextMenuStrip = $menu
184
+
185
+ # Timer to check if parent process is still alive
186
+ $timer = New-Object System.Windows.Forms.Timer
187
+ $timer.Interval = 5000
188
+ $timer.Add_Tick({
189
+ if ($ParentPid -gt 0) {
190
+ try {
191
+ $p = Get-Process -Id $ParentPid -ErrorAction Stop
192
+ } catch {
193
+ # Parent died, clean up
194
+ $tray.Visible = $false
195
+ $tray.Dispose()
196
+ $mutex.ReleaseMutex()
197
+ $mutex.Dispose()
198
+ [System.Windows.Forms.Application]::Exit()
199
+ }
200
+ }
201
+ })
202
+ $timer.Start()
203
+
204
+ # Double-click tray icon opens dashboard
205
+ $tray.Add_DoubleClick({ Start-Process "https://getaimeter.com/dashboard" })
206
+
207
+ # Run message loop
208
+ [System.Windows.Forms.Application]::Run()
package/watcher.js CHANGED
@@ -332,6 +332,30 @@ function extractNewUsage(filePath) {
332
332
  continue;
333
333
  }
334
334
 
335
+ // ── Copilot VS Code agent: real-time per-turn output tracking ──
336
+ // assistant.message fires after every AI response with data.outputTokens.
337
+ // Input tokens are not available per-turn — they come from session.shutdown.
338
+ if (obj.type === 'assistant.message' && obj.data?.outputTokens > 0) {
339
+ const msgId = obj.data.messageId;
340
+ const hashKey = `${filePath}:copilot-msg:${msgId}`;
341
+ const hash = crypto.createHash('md5').update(hashKey).digest('hex');
342
+ if (isDuplicate(hash)) continue;
343
+
344
+ usageEvents.push({
345
+ provider: 'github',
346
+ model: 'copilot',
347
+ source: detectSource(filePath),
348
+ inputTokens: 0,
349
+ outputTokens: obj.data.outputTokens,
350
+ thinkingTokens: 0,
351
+ cacheReadTokens: 0,
352
+ cacheWriteTokens: 0,
353
+ conversationId: convMeta.conversationId,
354
+ projectPath: convMeta.projectPath,
355
+ });
356
+ continue;
357
+ }
358
+
335
359
  // ── Copilot format (old: token_usage, new: session.shutdown with modelMetrics) ──
336
360
  if (obj.type === 'token_usage' && (obj.input_tokens !== undefined || obj.output_tokens !== undefined)) {
337
361
  const copilotModel = obj.model || 'copilot';
@@ -354,15 +378,19 @@ function extractNewUsage(filePath) {
354
378
  continue;
355
379
  }
356
380
 
357
- // GitHub Copilot agent: session.shutdown has per-model usage in data.modelMetrics
381
+ // GitHub Copilot agent: session.shutdown reports the per-model session totals.
382
+ // Output tokens were already reported turn-by-turn via assistant.message above,
383
+ // so here we only emit input tokens (+ cache) to avoid double-counting output.
358
384
  if (obj.type === 'session.shutdown' && obj.data?.modelMetrics) {
359
385
  for (const [model, metrics] of Object.entries(obj.data.modelMetrics)) {
360
386
  const u = metrics.usage || {};
361
- const inputTokens = u.inputTokens || 0;
362
- const outputTokens = u.outputTokens || 0;
363
- if (inputTokens === 0 && outputTokens === 0) continue;
387
+ const inputTokens = u.inputTokens || 0;
388
+ const cacheReadTokens = u.cacheReadTokens || 0;
389
+ const cacheWriteTokens = u.cacheWriteTokens || 0;
390
+ // Skip if no input data (output was already handled per-turn)
391
+ if (inputTokens === 0 && cacheReadTokens === 0) continue;
364
392
 
365
- const hashKey = `${filePath}:copilot-shutdown:${model}:${inputTokens}:${outputTokens}`;
393
+ const hashKey = `${filePath}:copilot-shutdown-input:${model}:${inputTokens}`;
366
394
  const hash = crypto.createHash('md5').update(hashKey).digest('hex');
367
395
  if (isDuplicate(hash)) continue;
368
396
 
@@ -377,10 +405,10 @@ function extractNewUsage(filePath) {
377
405
  model,
378
406
  source: detectSource(filePath),
379
407
  inputTokens,
380
- outputTokens,
408
+ outputTokens: 0, // already reported per assistant.message turn
381
409
  thinkingTokens: 0,
382
- cacheReadTokens: u.cacheReadTokens || 0,
383
- cacheWriteTokens: u.cacheWriteTokens || 0,
410
+ cacheReadTokens,
411
+ cacheWriteTokens,
384
412
  conversationId: convMeta.conversationId,
385
413
  projectPath: convMeta.projectPath,
386
414
  });