getaimeter 0.3.5 → 0.5.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/config.js CHANGED
@@ -68,6 +68,26 @@ 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);
78
+
79
+ // 4. GitHub Copilot CLI sessions
80
+ const copilotSessions = path.join(os.homedir(), '.copilot', 'session-state');
81
+ if (fs.existsSync(copilotSessions)) paths.push(copilotSessions);
82
+
83
+ // 5. Gemini CLI sessions
84
+ const geminiDir = path.join(os.homedir(), '.gemini', 'tmp');
85
+ if (fs.existsSync(geminiDir)) {
86
+ // Gemini stores sessions under ~/.gemini/tmp/<project_hash>/chats/
87
+ // Watch the entire tmp dir and findJsonlFiles will recurse into it
88
+ paths.push(geminiDir);
89
+ }
90
+
71
91
  return paths;
72
92
  }
73
93
 
package/log-viewer.ps1 ADDED
@@ -0,0 +1,63 @@
1
+ param(
2
+ [string]$LogPath
3
+ )
4
+
5
+ Add-Type -AssemblyName System.Windows.Forms
6
+ Add-Type -AssemblyName System.Drawing
7
+
8
+ $form = New-Object System.Windows.Forms.Form
9
+ $form.Text = 'AIMeter — Live Logs'
10
+ $form.Size = New-Object System.Drawing.Size(860, 520)
11
+ $form.BackColor = [System.Drawing.Color]::FromArgb(15, 15, 15)
12
+ $form.StartPosition = 'CenterScreen'
13
+ $form.ShowInTaskbar = $true
14
+ $form.Add_Shown({ $form.Activate() })
15
+
16
+ $rtb = New-Object System.Windows.Forms.RichTextBox
17
+ $rtb.Dock = 'Fill'
18
+ $rtb.BackColor = [System.Drawing.Color]::FromArgb(15, 15, 15)
19
+ $rtb.ForeColor = [System.Drawing.Color]::FromArgb(200, 200, 200)
20
+ $rtb.Font = New-Object System.Drawing.Font('Consolas', 9)
21
+ $rtb.ReadOnly = $true
22
+ $rtb.ScrollBars = 'Vertical'
23
+ $rtb.WordWrap = $false
24
+ $form.Controls.Add($rtb)
25
+
26
+ # Seed with last 200 lines
27
+ $script:fileSize = 0
28
+ if (Test-Path $LogPath) {
29
+ $all = Get-Content $LogPath -Raw -ErrorAction SilentlyContinue
30
+ if ($all) {
31
+ $lines = $all -split "`n"
32
+ $seed = ($lines | Select-Object -Last 200) -join "`n"
33
+ $rtb.Text = $seed.TrimEnd() + "`n"
34
+ $rtb.SelectionStart = $rtb.Text.Length
35
+ $rtb.ScrollToCaret()
36
+ }
37
+ $script:fileSize = (Get-Item $LogPath).Length
38
+ }
39
+
40
+ # Poll for new bytes every 500 ms
41
+ $timer = New-Object System.Windows.Forms.Timer
42
+ $timer.Interval = 500
43
+ $timer.Add_Tick({
44
+ if (-not (Test-Path $LogPath)) { return }
45
+ $newSize = (Get-Item $LogPath).Length
46
+ if ($newSize -le $script:fileSize) { return }
47
+ try {
48
+ $fs = [System.IO.File]::Open($LogPath, 'Open', 'Read', 'ReadWrite')
49
+ $null = $fs.Seek($script:fileSize, 'Begin')
50
+ $buf = New-Object byte[] ($newSize - $script:fileSize)
51
+ $null = $fs.Read($buf, 0, $buf.Length)
52
+ $fs.Close()
53
+ $chunk = [System.Text.Encoding]::UTF8.GetString($buf)
54
+ $rtb.AppendText($chunk)
55
+ $rtb.SelectionStart = $rtb.Text.Length
56
+ $rtb.ScrollToCaret()
57
+ $script:fileSize = $newSize
58
+ } catch {}
59
+ })
60
+ $timer.Start()
61
+
62
+ $form.Add_FormClosed({ $timer.Stop(); $timer.Dispose() })
63
+ [System.Windows.Forms.Application]::Run($form)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getaimeter",
3
- "version": "0.3.5",
3
+ "version": "0.5.0",
4
4
  "description": "Track your Claude AI usage across CLI, VS Code, and Desktop App. One command to start.",
5
5
  "bin": {
6
6
  "aimeter": "cli.js"
@@ -31,6 +31,7 @@
31
31
  "service.js",
32
32
  "tray.js",
33
33
  "tray.ps1",
34
+ "log-viewer.ps1",
34
35
  "tray-launcher.vbs",
35
36
  "icon.ico",
36
37
  "update-check.js",
package/tray.ps1 CHANGED
@@ -42,77 +42,14 @@ $dashboard = $menu.Items.Add("Open Dashboard")
42
42
  $dashboard.Add_Click({ Start-Process "https://getaimeter.com/dashboard" })
43
43
 
44
44
  $logs = $menu.Items.Add("View Logs")
45
+ $viewerScript = Join-Path (Split-Path $MyInvocation.MyCommand.Path) 'log-viewer.ps1'
45
46
  $logs.Add_Click({
46
- # Build a self-contained live-tail window in a separate runspace so it
47
- # doesn't block the tray message loop.
48
- $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
49
- $rs.ApartmentState = 'STA'
50
- $rs.ThreadOptions = 'ReuseThread'
51
- $rs.Open()
52
- $rs.SessionStateProxy.SetVariable('LogPath', $LogPath)
53
-
54
- $ps = [System.Management.Automation.PowerShell]::Create()
55
- $ps.Runspace = $rs
56
- $null = $ps.AddScript({
57
- Add-Type -AssemblyName System.Windows.Forms
58
- Add-Type -AssemblyName System.Drawing
59
-
60
- $form = New-Object System.Windows.Forms.Form
61
- $form.Text = 'AIMeter — Live Logs'
62
- $form.Size = New-Object System.Drawing.Size(860, 520)
63
- $form.BackColor = [System.Drawing.Color]::FromArgb(15, 15, 15)
64
- $form.StartPosition = 'CenterScreen'
65
-
66
- $rtb = New-Object System.Windows.Forms.RichTextBox
67
- $rtb.Dock = 'Fill'
68
- $rtb.BackColor = [System.Drawing.Color]::FromArgb(15, 15, 15)
69
- $rtb.ForeColor = [System.Drawing.Color]::FromArgb(200, 200, 200)
70
- $rtb.Font = New-Object System.Drawing.Font('Consolas', 9)
71
- $rtb.ReadOnly = $true
72
- $rtb.ScrollBars = 'Vertical'
73
- $rtb.WordWrap = $false
74
- $form.Controls.Add($rtb)
75
-
76
- # Seed with last 200 lines
77
- $fileSize = 0
78
- if (Test-Path $LogPath) {
79
- $all = Get-Content $LogPath -Raw -ErrorAction SilentlyContinue
80
- if ($all) {
81
- $lines = $all -split "`n"
82
- $seed = ($lines | Select-Object -Last 200) -join "`n"
83
- $rtb.Text = $seed.TrimEnd() + "`n"
84
- $rtb.SelectionStart = $rtb.Text.Length
85
- $rtb.ScrollToCaret()
86
- }
87
- $fileSize = (Get-Item $LogPath).Length
88
- }
89
-
90
- # Poll for new bytes every 500 ms
91
- $timer = New-Object System.Windows.Forms.Timer
92
- $timer.Interval = 500
93
- $timer.Add_Tick({
94
- if (-not (Test-Path $LogPath)) { return }
95
- $newSize = (Get-Item $LogPath).Length
96
- if ($newSize -le $fileSize) { return }
97
- try {
98
- $fs = [System.IO.File]::Open($LogPath, 'Open', 'Read', 'ReadWrite')
99
- $null = $fs.Seek($fileSize, 'Begin')
100
- $buf = New-Object byte[] ($newSize - $fileSize)
101
- $null = $fs.Read($buf, 0, $buf.Length)
102
- $fs.Close()
103
- $chunk = [System.Text.Encoding]::UTF8.GetString($buf)
104
- $rtb.AppendText($chunk)
105
- $rtb.SelectionStart = $rtb.Text.Length
106
- $rtb.ScrollToCaret()
107
- $fileSize = $newSize
108
- } catch {}
109
- })
110
- $timer.Start()
111
-
112
- $form.Add_FormClosed({ $timer.Stop(); $timer.Dispose() })
113
- [System.Windows.Forms.Application]::Run($form)
114
- })
115
- $null = $ps.BeginInvoke()
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
+ $vbsCmd = "CreateObject(""WScript.Shell"").Run ""powershell -NoProfile -ExecutionPolicy Bypass -STA -WindowStyle Hidden -File """"$viewerScript"""" -LogPath """"$LogPath"""""", 0, False"
50
+ $tempVbs = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'aimeter-logviewer.vbs')
51
+ [System.IO.File]::WriteAllText($tempVbs, $vbsCmd)
52
+ Start-Process 'wscript' -ArgumentList @($tempVbs) -WindowStyle Hidden
116
53
  })
117
54
 
118
55
  $menu.Items.Add("-")
package/watcher.js CHANGED
@@ -34,6 +34,9 @@ function logError(...args) {
34
34
  // Cache detected sources per file to avoid re-reading headers
35
35
  const _sourceCache = new Map();
36
36
 
37
+ // Track cumulative token counts per file for Codex CLI (which reports cumulative, not delta)
38
+ const _codexCumulative = {};
39
+
37
40
  function detectSource(filePath) {
38
41
  if (_sourceCache.has(filePath)) return _sourceCache.get(filePath);
39
42
 
@@ -62,6 +65,19 @@ function detectSource(filePath) {
62
65
  }
63
66
  }
64
67
 
68
+ if (normalized.includes('.copilot/') || normalized.includes('/copilot/')) {
69
+ _sourceCache.set(filePath, 'copilot_cli');
70
+ return 'copilot_cli';
71
+ }
72
+ if (normalized.includes('.gemini/') || normalized.includes('/gemini/')) {
73
+ _sourceCache.set(filePath, 'gemini_cli');
74
+ return 'gemini_cli';
75
+ }
76
+ if (normalized.includes('.codex/') || normalized.includes('/codex/')) {
77
+ _sourceCache.set(filePath, 'codex_cli');
78
+ return 'codex_cli';
79
+ }
80
+
65
81
  // Read first 10KB of the file to find entrypoint or IDE markers
66
82
  let source = 'cli'; // default
67
83
  try {
@@ -137,7 +153,93 @@ function extractNewUsage(filePath) {
137
153
  msgId = msg.id; // progress fires multiple times; dedup by message ID
138
154
  }
139
155
 
140
- if (!msg) continue;
156
+ if (!msg) {
157
+ // ── Codex CLI format ──────────────────────────────────────────
158
+ // Codex events have { type: "event", payload: { type: "token_count", ... } }
159
+ // with cumulative input_tokens, output_tokens, reasoning_tokens, cached_input_tokens
160
+ if (obj.type === 'event' && obj.payload?.type === 'token_count') {
161
+ const p = obj.payload;
162
+ const codexModel = obj.turn_context?.model || 'codex';
163
+ // Token counts are cumulative per session; we store deltas
164
+ // Use a per-file tracker for the previous cumulative values
165
+ const prevKey = filePath;
166
+ const prev = _codexCumulative[prevKey] || { input: 0, output: 0, reasoning: 0, cached: 0 };
167
+ const deltaInput = (p.input_tokens || 0) - prev.input;
168
+ const deltaOutput = (p.output_tokens || 0) - prev.output;
169
+ const deltaReasoning = (p.reasoning_tokens || 0) - prev.reasoning;
170
+
171
+ _codexCumulative[prevKey] = {
172
+ input: p.input_tokens || 0,
173
+ output: p.output_tokens || 0,
174
+ reasoning: p.reasoning_tokens || 0,
175
+ cached: p.cached_input_tokens || 0,
176
+ };
177
+
178
+ // Skip if no new tokens (duplicate or first read)
179
+ if (deltaInput <= 0 && deltaOutput <= 0) continue;
180
+
181
+ const hashKey = `${filePath}:${lineOffset}:codex:${p.input_tokens}:${p.output_tokens}`;
182
+ const hash = crypto.createHash('md5').update(hashKey).digest('hex');
183
+ if (isDuplicate(hash)) continue;
184
+
185
+ usageEvents.push({
186
+ provider: 'openai',
187
+ model: codexModel,
188
+ source: 'codex_cli',
189
+ inputTokens: deltaInput,
190
+ outputTokens: deltaOutput,
191
+ thinkingTokens: deltaReasoning,
192
+ cacheReadTokens: 0,
193
+ cacheWriteTokens: 0,
194
+ });
195
+ continue;
196
+ }
197
+
198
+ // ── Copilot CLI format ────────────────────────────────────────
199
+ // Copilot events: { type: "token_usage", input_tokens, output_tokens, reasoning_tokens, model }
200
+ if (obj.type === 'token_usage' && (obj.input_tokens !== undefined || obj.output_tokens !== undefined)) {
201
+ const copilotModel = obj.model || 'copilot';
202
+ const hashKey = `${filePath}:${lineOffset}:copilot:${obj.input_tokens || 0}:${obj.output_tokens || 0}`;
203
+ const hash = crypto.createHash('md5').update(hashKey).digest('hex');
204
+ if (isDuplicate(hash)) continue;
205
+
206
+ usageEvents.push({
207
+ provider: 'github',
208
+ model: copilotModel,
209
+ source: 'copilot_cli',
210
+ inputTokens: obj.input_tokens || 0,
211
+ outputTokens: obj.output_tokens || 0,
212
+ thinkingTokens: obj.reasoning_tokens || 0,
213
+ cacheReadTokens: 0,
214
+ cacheWriteTokens: 0,
215
+ });
216
+ continue;
217
+ }
218
+
219
+ // ── Gemini CLI format ─────────────────────────────────────────
220
+ // Gemini events may contain usageMetadata: { promptTokenCount, candidatesTokenCount, totalTokenCount }
221
+ if (obj.usageMetadata && (obj.usageMetadata.promptTokenCount || obj.usageMetadata.candidatesTokenCount)) {
222
+ const um = obj.usageMetadata;
223
+ const geminiModel = obj.modelVersion || obj.model || 'gemini';
224
+ const hashKey = `${filePath}:${lineOffset}:gemini:${um.promptTokenCount || 0}:${um.candidatesTokenCount || 0}`;
225
+ const hash = crypto.createHash('md5').update(hashKey).digest('hex');
226
+ if (isDuplicate(hash)) continue;
227
+
228
+ usageEvents.push({
229
+ provider: 'google',
230
+ model: geminiModel,
231
+ source: 'gemini_cli',
232
+ inputTokens: um.promptTokenCount || 0,
233
+ outputTokens: um.candidatesTokenCount || 0,
234
+ thinkingTokens: um.thoughtsTokenCount || 0,
235
+ cacheReadTokens: um.cachedContentTokenCount || 0,
236
+ cacheWriteTokens: 0,
237
+ });
238
+ continue;
239
+ }
240
+
241
+ continue;
242
+ }
141
243
 
142
244
  // Skip synthetic/internal messages
143
245
  if (msg.model === '<synthetic>') continue;
@@ -169,11 +271,13 @@ function extractNewUsage(filePath) {
169
271
  pendingThinkingChars = 0; // Reset for next turn
170
272
 
171
273
  // Build dedup hash.
172
- // For progress entries, use the message ID so the same message fired multiple
173
- // times (streaming chunks) counts only once. For assistant entries, use the
174
- // line offset as before.
175
- const hashKey = msgId
176
- ? `${filePath}:msgid:${msgId}`
274
+ // For entries with a message ID (progress or assistant), use ONLY the message
275
+ // ID so the same call is counted once even if it appears in both the parent
276
+ // session file (as progress) and the subagent file (as assistant).
277
+ // For entries without an ID, fall back to file+offset.
278
+ const effectiveId = msgId || msg.id;
279
+ const hashKey = effectiveId
280
+ ? `msgid:${effectiveId}`
177
281
  : `${filePath}:${lineOffset}:${model}:${u.input_tokens || 0}:${u.output_tokens || 0}`;
178
282
  const hash = crypto.createHash('md5').update(hashKey).digest('hex');
179
283