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 +20 -0
- package/log-viewer.ps1 +63 -0
- package/package.json +2 -1
- package/tray.ps1 +7 -70
- package/watcher.js +110 -6
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
|
+
"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
|
-
#
|
|
47
|
-
#
|
|
48
|
-
$
|
|
49
|
-
$
|
|
50
|
-
$
|
|
51
|
-
$
|
|
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)
|
|
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
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|