getaimeter 0.10.0 → 0.11.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.
Files changed (5) hide show
  1. package/cli.js +657 -657
  2. package/config.js +6 -7
  3. package/package.json +1 -1
  4. package/tray.ps1 +208 -208
  5. package/watcher.js +47 -8
package/config.js CHANGED
@@ -68,13 +68,12 @@ function getWatchPaths() {
68
68
  }
69
69
  if (fs.existsSync(desktopSessions)) paths.push(desktopSessions);
70
70
 
71
- // 3. OpenAI Codex CLI sessions
72
- const codexSessions = path.join(os.homedir(), '.codex', 'sessions');
73
- if (fs.existsSync(codexSessions)) paths.push(codexSessions);
74
-
75
- // Also watch ~/.codex/history.jsonl directly (Codex writes a single file)
76
- const codexHistory = path.join(os.homedir(), '.codex');
77
- if (fs.existsSync(codexHistory) && !paths.includes(codexHistory)) paths.push(codexHistory);
71
+ // 3. OpenAI Codex CLI — watch the root ~/.codex dir so findJsonlFiles recurses
72
+ // into both sessions/ (per-session UUIDs) and history.jsonl (single flat file).
73
+ // Previously both ~/.codex and ~/.codex/sessions were added separately, causing
74
+ // the same files to be discovered twice per poll cycle.
75
+ const codexDir = path.join(os.homedir(), '.codex');
76
+ if (fs.existsSync(codexDir)) paths.push(codexDir);
78
77
 
79
78
  // 4. GitHub Copilot CLI sessions
80
79
  const copilotSessions = path.join(os.homedir(), '.copilot', 'session-state');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getaimeter",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
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
@@ -162,6 +162,17 @@ function getConversationMeta(filePath) {
162
162
  // Conversation ID = file basename without extension
163
163
  let conversationId = path.basename(filePath, '.jsonl');
164
164
 
165
+ // Copilot stores sessions as <session-UUID>/events.jsonl — all files are named
166
+ // "events.jsonl", so the basename is always "events" which collides across sessions.
167
+ // Use the parent directory UUID as the conversation ID instead.
168
+ if (conversationId === 'events') {
169
+ const parentDir = path.basename(path.dirname(filePath));
170
+ // Only override if parent looks like a UUID (avoids false positives)
171
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(parentDir)) {
172
+ conversationId = parentDir;
173
+ }
174
+ }
175
+
165
176
  // For subagent files, use the parent session UUID as conversation ID
166
177
  const subagentMatch = normalized.match(/\/([^/]+)\/subagents\//);
167
178
  if (subagentMatch) {
@@ -332,6 +343,30 @@ function extractNewUsage(filePath) {
332
343
  continue;
333
344
  }
334
345
 
346
+ // ── Copilot VS Code agent: real-time per-turn output tracking ──
347
+ // assistant.message fires after every AI response with data.outputTokens.
348
+ // Input tokens are not available per-turn — they come from session.shutdown.
349
+ if (obj.type === 'assistant.message' && obj.data?.outputTokens > 0) {
350
+ const msgId = obj.data.messageId;
351
+ const hashKey = `${filePath}:copilot-msg:${msgId}`;
352
+ const hash = crypto.createHash('md5').update(hashKey).digest('hex');
353
+ if (isDuplicate(hash)) continue;
354
+
355
+ usageEvents.push({
356
+ provider: 'github',
357
+ model: 'copilot',
358
+ source: detectSource(filePath),
359
+ inputTokens: 0,
360
+ outputTokens: obj.data.outputTokens,
361
+ thinkingTokens: 0,
362
+ cacheReadTokens: 0,
363
+ cacheWriteTokens: 0,
364
+ conversationId: convMeta.conversationId,
365
+ projectPath: convMeta.projectPath,
366
+ });
367
+ continue;
368
+ }
369
+
335
370
  // ── Copilot format (old: token_usage, new: session.shutdown with modelMetrics) ──
336
371
  if (obj.type === 'token_usage' && (obj.input_tokens !== undefined || obj.output_tokens !== undefined)) {
337
372
  const copilotModel = obj.model || 'copilot';
@@ -354,15 +389,19 @@ function extractNewUsage(filePath) {
354
389
  continue;
355
390
  }
356
391
 
357
- // GitHub Copilot agent: session.shutdown has per-model usage in data.modelMetrics
392
+ // GitHub Copilot agent: session.shutdown reports the per-model session totals.
393
+ // Output tokens were already reported turn-by-turn via assistant.message above,
394
+ // so here we only emit input tokens (+ cache) to avoid double-counting output.
358
395
  if (obj.type === 'session.shutdown' && obj.data?.modelMetrics) {
359
396
  for (const [model, metrics] of Object.entries(obj.data.modelMetrics)) {
360
397
  const u = metrics.usage || {};
361
- const inputTokens = u.inputTokens || 0;
362
- const outputTokens = u.outputTokens || 0;
363
- if (inputTokens === 0 && outputTokens === 0) continue;
398
+ const inputTokens = u.inputTokens || 0;
399
+ const cacheReadTokens = u.cacheReadTokens || 0;
400
+ const cacheWriteTokens = u.cacheWriteTokens || 0;
401
+ // Skip if no input data (output was already handled per-turn)
402
+ if (inputTokens === 0 && cacheReadTokens === 0) continue;
364
403
 
365
- const hashKey = `${filePath}:copilot-shutdown:${model}:${inputTokens}:${outputTokens}`;
404
+ const hashKey = `${filePath}:copilot-shutdown-input:${model}:${inputTokens}`;
366
405
  const hash = crypto.createHash('md5').update(hashKey).digest('hex');
367
406
  if (isDuplicate(hash)) continue;
368
407
 
@@ -377,10 +416,10 @@ function extractNewUsage(filePath) {
377
416
  model,
378
417
  source: detectSource(filePath),
379
418
  inputTokens,
380
- outputTokens,
419
+ outputTokens: 0, // already reported per assistant.message turn
381
420
  thinkingTokens: 0,
382
- cacheReadTokens: u.cacheReadTokens || 0,
383
- cacheWriteTokens: u.cacheWriteTokens || 0,
421
+ cacheReadTokens,
422
+ cacheWriteTokens,
384
423
  conversationId: convMeta.conversationId,
385
424
  projectPath: convMeta.projectPath,
386
425
  });