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.
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 +122 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getaimeter",
3
- "version": "0.9.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
@@ -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 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.
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 = u.inputTokens || 0;
284
- const outputTokens = u.outputTokens || 0;
285
- 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;
286
392
 
287
- const hashKey = `${filePath}:copilot-shutdown:${model}:${inputTokens}:${outputTokens}`;
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: u.cacheReadTokens || 0,
305
- cacheWriteTokens: u.cacheWriteTokens || 0,
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
  }