getaimeter 0.9.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.
- package/cli.js +657 -657
- package/package.json +1 -1
- package/tray.ps1 +208 -208
- package/watcher.js +122 -8
package/package.json
CHANGED
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
|
@@ -35,6 +35,9 @@ function logError(...args) {
|
|
|
35
35
|
// Cache detected sources per file to avoid re-reading headers
|
|
36
36
|
const _sourceCache = new Map();
|
|
37
37
|
|
|
38
|
+
// Cache conversation metadata per file: { conversationId, projectPath }
|
|
39
|
+
const _convMetaCache = new Map();
|
|
40
|
+
|
|
38
41
|
// Track cumulative token counts per file for Codex CLI (which reports cumulative, not delta)
|
|
39
42
|
const _codexCumulative = {};
|
|
40
43
|
|
|
@@ -140,6 +143,76 @@ function detectSource(filePath) {
|
|
|
140
143
|
return source;
|
|
141
144
|
}
|
|
142
145
|
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Conversation metadata extraction
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Extract conversation ID and project path from a file.
|
|
152
|
+
* - conversationId: file basename without extension (unique per session)
|
|
153
|
+
* - projectPath: cwd from the JSONL header (Claude Code stores this in init/system messages)
|
|
154
|
+
*
|
|
155
|
+
* For subagent files, the conversation ID is inherited from the parent session.
|
|
156
|
+
*/
|
|
157
|
+
function getConversationMeta(filePath) {
|
|
158
|
+
if (_convMetaCache.has(filePath)) return _convMetaCache.get(filePath);
|
|
159
|
+
|
|
160
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
161
|
+
|
|
162
|
+
// Conversation ID = file basename without extension
|
|
163
|
+
let conversationId = path.basename(filePath, '.jsonl');
|
|
164
|
+
|
|
165
|
+
// For subagent files, use the parent session UUID as conversation ID
|
|
166
|
+
const subagentMatch = normalized.match(/\/([^/]+)\/subagents\//);
|
|
167
|
+
if (subagentMatch) {
|
|
168
|
+
conversationId = subagentMatch[1]; // parent session UUID
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Extract project path (cwd) from the first few lines of the file
|
|
172
|
+
let projectPath = null;
|
|
173
|
+
try {
|
|
174
|
+
const fd = fs.openSync(filePath, 'r');
|
|
175
|
+
const buf = Buffer.alloc(Math.min(8192, fs.fstatSync(fd).size));
|
|
176
|
+
fs.readSync(fd, buf, 0, buf.length, 0);
|
|
177
|
+
fs.closeSync(fd);
|
|
178
|
+
const header = buf.toString('utf8');
|
|
179
|
+
|
|
180
|
+
for (const line of header.split('\n').slice(0, 10)) {
|
|
181
|
+
if (!line.trim()) continue;
|
|
182
|
+
try {
|
|
183
|
+
const obj = JSON.parse(line.trim());
|
|
184
|
+
// Claude Code: type=system or init messages have cwd
|
|
185
|
+
if (obj.cwd) {
|
|
186
|
+
projectPath = obj.cwd;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
// Some formats nest it in message or data
|
|
190
|
+
if (obj.message?.cwd) {
|
|
191
|
+
projectPath = obj.message.cwd;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
// Codex: session_meta may have cwd
|
|
195
|
+
if (obj.type === 'session_meta' && obj.payload?.cwd) {
|
|
196
|
+
projectPath = obj.payload.cwd;
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
} catch {}
|
|
200
|
+
}
|
|
201
|
+
} catch {}
|
|
202
|
+
|
|
203
|
+
// Shorten project path to just the last directory name for privacy/brevity
|
|
204
|
+
if (projectPath) {
|
|
205
|
+
projectPath = projectPath.replace(/\\/g, '/').replace(/\/$/, '');
|
|
206
|
+
// Keep last 2 path segments: "User/project" or just "project"
|
|
207
|
+
const parts = projectPath.split('/');
|
|
208
|
+
projectPath = parts.length > 1 ? parts.slice(-2).join('/') : parts[parts.length - 1];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const meta = { conversationId, projectPath };
|
|
212
|
+
_convMetaCache.set(filePath, meta);
|
|
213
|
+
return meta;
|
|
214
|
+
}
|
|
215
|
+
|
|
143
216
|
// ---------------------------------------------------------------------------
|
|
144
217
|
// JSONL parsing — extract usage from new bytes in a transcript file
|
|
145
218
|
// ---------------------------------------------------------------------------
|
|
@@ -166,6 +239,7 @@ function extractNewUsage(filePath) {
|
|
|
166
239
|
if (lastOffset > 0 && lines.length > 0) lines.shift();
|
|
167
240
|
|
|
168
241
|
const usageEvents = [];
|
|
242
|
+
const convMeta = getConversationMeta(filePath);
|
|
169
243
|
let lineOffset = lastOffset;
|
|
170
244
|
let pendingThinkingChars = 0; // Track thinking chars from streaming progress messages
|
|
171
245
|
|
|
@@ -252,6 +326,32 @@ function extractNewUsage(filePath) {
|
|
|
252
326
|
thinkingTokens: deltaReasoning,
|
|
253
327
|
cacheReadTokens: cachedTokens,
|
|
254
328
|
cacheWriteTokens: 0,
|
|
329
|
+
conversationId: convMeta.conversationId,
|
|
330
|
+
projectPath: convMeta.projectPath,
|
|
331
|
+
});
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
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,
|
|
255
355
|
});
|
|
256
356
|
continue;
|
|
257
357
|
}
|
|
@@ -272,19 +372,25 @@ function extractNewUsage(filePath) {
|
|
|
272
372
|
thinkingTokens: obj.reasoning_tokens || 0,
|
|
273
373
|
cacheReadTokens: 0,
|
|
274
374
|
cacheWriteTokens: 0,
|
|
375
|
+
conversationId: convMeta.conversationId,
|
|
376
|
+
projectPath: convMeta.projectPath,
|
|
275
377
|
});
|
|
276
378
|
continue;
|
|
277
379
|
}
|
|
278
380
|
|
|
279
|
-
// GitHub Copilot agent: session.shutdown
|
|
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.
|
|
280
384
|
if (obj.type === 'session.shutdown' && obj.data?.modelMetrics) {
|
|
281
385
|
for (const [model, metrics] of Object.entries(obj.data.modelMetrics)) {
|
|
282
386
|
const u = metrics.usage || {};
|
|
283
|
-
const inputTokens
|
|
284
|
-
const
|
|
285
|
-
|
|
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;
|
|
286
392
|
|
|
287
|
-
const hashKey = `${filePath}:copilot-shutdown:${model}:${inputTokens}
|
|
393
|
+
const hashKey = `${filePath}:copilot-shutdown-input:${model}:${inputTokens}`;
|
|
288
394
|
const hash = crypto.createHash('md5').update(hashKey).digest('hex');
|
|
289
395
|
if (isDuplicate(hash)) continue;
|
|
290
396
|
|
|
@@ -299,10 +405,12 @@ function extractNewUsage(filePath) {
|
|
|
299
405
|
model,
|
|
300
406
|
source: detectSource(filePath),
|
|
301
407
|
inputTokens,
|
|
302
|
-
outputTokens,
|
|
408
|
+
outputTokens: 0, // already reported per assistant.message turn
|
|
303
409
|
thinkingTokens: 0,
|
|
304
|
-
cacheReadTokens
|
|
305
|
-
cacheWriteTokens
|
|
410
|
+
cacheReadTokens,
|
|
411
|
+
cacheWriteTokens,
|
|
412
|
+
conversationId: convMeta.conversationId,
|
|
413
|
+
projectPath: convMeta.projectPath,
|
|
306
414
|
});
|
|
307
415
|
}
|
|
308
416
|
continue;
|
|
@@ -326,6 +434,8 @@ function extractNewUsage(filePath) {
|
|
|
326
434
|
thinkingTokens: um.thoughtsTokenCount || 0,
|
|
327
435
|
cacheReadTokens: um.cachedContentTokenCount || 0,
|
|
328
436
|
cacheWriteTokens: 0,
|
|
437
|
+
conversationId: convMeta.conversationId,
|
|
438
|
+
projectPath: convMeta.projectPath,
|
|
329
439
|
});
|
|
330
440
|
continue;
|
|
331
441
|
}
|
|
@@ -384,6 +494,8 @@ function extractNewUsage(filePath) {
|
|
|
384
494
|
thinkingTokens: estimatedThinkingTokens,
|
|
385
495
|
cacheReadTokens: u.cache_read_input_tokens || 0,
|
|
386
496
|
cacheWriteTokens: u.cache_creation_input_tokens || 0,
|
|
497
|
+
conversationId: convMeta.conversationId,
|
|
498
|
+
projectPath: convMeta.projectPath,
|
|
387
499
|
});
|
|
388
500
|
}
|
|
389
501
|
|
|
@@ -575,6 +687,8 @@ function extractCursorUsage(dbPath) {
|
|
|
575
687
|
thinkingTokens: 0,
|
|
576
688
|
cacheReadTokens: 0,
|
|
577
689
|
cacheWriteTokens: 0,
|
|
690
|
+
conversationId: conv.composerId,
|
|
691
|
+
projectPath: null,
|
|
578
692
|
});
|
|
579
693
|
} catch {}
|
|
580
694
|
}
|