getaimeter 0.6.4 → 0.7.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.
- package/package.json +1 -1
- package/tray-launcher.vbs +3 -1
- package/tray.js +2 -0
- package/tray.ps1 +87 -5
- package/watcher.js +37 -34
package/package.json
CHANGED
package/tray-launcher.vbs
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
Set objShell = CreateObject("WScript.Shell")
|
|
2
|
-
|
|
2
|
+
Dim sVersion
|
|
3
|
+
If WScript.Arguments.Count > 3 Then sVersion = WScript.Arguments(3) Else sVersion = "unknown"
|
|
4
|
+
objShell.Run "powershell -NoProfile -ExecutionPolicy Bypass -STA -File """ & Replace(WScript.ScriptFullName, "tray-launcher.vbs", "tray.ps1") & """ -IconPath """ & Replace(WScript.ScriptFullName, "tray-launcher.vbs", "icon.ico") & """ -MaskedKey """ & WScript.Arguments(0) & """ -LogPath """ & WScript.Arguments(1) & """ -ParentPid " & WScript.Arguments(2) & " -Version """ & sVersion & """", 0, False
|
package/tray.js
CHANGED
|
@@ -13,6 +13,7 @@ async function startTray(onStop) {
|
|
|
13
13
|
const apiKey = getApiKey();
|
|
14
14
|
const maskedKey = apiKey ? `${apiKey.slice(0, 8)}...${apiKey.slice(-4)}` : 'not set';
|
|
15
15
|
const logPath = path.join(AIMETER_DIR, 'watcher.log');
|
|
16
|
+
const version = require('./package.json').version;
|
|
16
17
|
|
|
17
18
|
return new Promise((resolve) => {
|
|
18
19
|
// Use cscript to launch VBS which launches PowerShell hidden with -STA
|
|
@@ -22,6 +23,7 @@ async function startTray(onStop) {
|
|
|
22
23
|
maskedKey,
|
|
23
24
|
logPath,
|
|
24
25
|
String(process.pid),
|
|
26
|
+
version,
|
|
25
27
|
], { windowsHide: true }, (err) => {
|
|
26
28
|
if (err) resolve(null);
|
|
27
29
|
});
|
package/tray.ps1
CHANGED
|
@@ -2,12 +2,23 @@ param(
|
|
|
2
2
|
[string]$IconPath,
|
|
3
3
|
[string]$MaskedKey,
|
|
4
4
|
[string]$LogPath,
|
|
5
|
-
[int]$ParentPid
|
|
5
|
+
[int]$ParentPid,
|
|
6
|
+
[string]$Version = "unknown"
|
|
6
7
|
)
|
|
7
8
|
|
|
8
9
|
Add-Type -AssemblyName System.Windows.Forms
|
|
9
10
|
Add-Type -AssemblyName System.Drawing
|
|
10
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
|
+
|
|
11
22
|
# Create tray icon
|
|
12
23
|
$tray = New-Object System.Windows.Forms.NotifyIcon
|
|
13
24
|
|
|
@@ -21,13 +32,13 @@ if ($IconPath -and (Test-Path $IconPath)) {
|
|
|
21
32
|
$tray.Icon = [System.Drawing.SystemIcons]::Application
|
|
22
33
|
}
|
|
23
34
|
|
|
24
|
-
$tray.Text = "AIMeter - Tracking AI usage"
|
|
35
|
+
$tray.Text = "AIMeter v$Version - Tracking AI usage"
|
|
25
36
|
$tray.Visible = $true
|
|
26
37
|
|
|
27
38
|
# Context menu
|
|
28
39
|
$menu = New-Object System.Windows.Forms.ContextMenuStrip
|
|
29
40
|
|
|
30
|
-
$header = $menu.Items.Add("AIMeter Watcher")
|
|
41
|
+
$header = $menu.Items.Add("AIMeter Watcher v$Version")
|
|
31
42
|
$header.Enabled = $false
|
|
32
43
|
$header.Font = New-Object System.Drawing.Font($header.Font, [System.Drawing.FontStyle]::Bold)
|
|
33
44
|
|
|
@@ -44,8 +55,6 @@ $dashboard.Add_Click({ Start-Process "https://getaimeter.com/dashboard" })
|
|
|
44
55
|
$logs = $menu.Items.Add("View Logs")
|
|
45
56
|
$viewerScript = Join-Path (Split-Path $MyInvocation.MyCommand.Path) 'log-viewer.ps1'
|
|
46
57
|
$logs.Add_Click({
|
|
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
58
|
$vbsCmd = "CreateObject(""WScript.Shell"").Run ""powershell -NoProfile -ExecutionPolicy Bypass -STA -WindowStyle Hidden -File """"$viewerScript"""" -LogPath """"$LogPath"""""", 0, False"
|
|
50
59
|
$tempVbs = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'aimeter-logviewer.vbs')
|
|
51
60
|
[System.IO.File]::WriteAllText($tempVbs, $vbsCmd)
|
|
@@ -54,10 +63,78 @@ $logs.Add_Click({
|
|
|
54
63
|
|
|
55
64
|
$menu.Items.Add("-")
|
|
56
65
|
|
|
66
|
+
# ── Help submenu ──────────────────────────────────────────────────────────────
|
|
67
|
+
$helpMenu = New-Object System.Windows.Forms.ToolStripMenuItem("Help")
|
|
68
|
+
|
|
69
|
+
$aboutItem = New-Object System.Windows.Forms.ToolStripMenuItem("About AIMeter")
|
|
70
|
+
$aboutItem.Add_Click({
|
|
71
|
+
[System.Windows.Forms.MessageBox]::Show(
|
|
72
|
+
"AIMeter v$Version`n`nAI coding cost optimizer.`nTrack Claude, Cursor, Codex, and Gemini.`n`nhttps://getaimeter.com",
|
|
73
|
+
"About AIMeter",
|
|
74
|
+
[System.Windows.Forms.MessageBoxButtons]::OK,
|
|
75
|
+
[System.Windows.Forms.MessageBoxIcon]::Information
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
$helpMenu.DropDownItems.Add($aboutItem) | Out-Null
|
|
79
|
+
|
|
80
|
+
$checkUpdateItem = New-Object System.Windows.Forms.ToolStripMenuItem("Check for Updates")
|
|
81
|
+
$checkUpdateItem.Add_Click({
|
|
82
|
+
$checkUpdateItem.Text = "Checking..."
|
|
83
|
+
$checkUpdateItem.Enabled = $false
|
|
84
|
+
try {
|
|
85
|
+
$response = Invoke-RestMethod -Uri "https://registry.npmjs.org/getaimeter/latest" -TimeoutSec 5
|
|
86
|
+
$latest = $response.version
|
|
87
|
+
if ($latest -and $latest -ne $Version) {
|
|
88
|
+
$result = [System.Windows.Forms.MessageBox]::Show(
|
|
89
|
+
"Update available: v$Version -> v$latest`n`nRun this command to update:`nnpm install -g getaimeter@latest`n`nThen restart the watcher.`n`nOpen terminal to update now?",
|
|
90
|
+
"Update Available",
|
|
91
|
+
[System.Windows.Forms.MessageBoxButtons]::YesNo,
|
|
92
|
+
[System.Windows.Forms.MessageBoxIcon]::Information
|
|
93
|
+
)
|
|
94
|
+
if ($result -eq [System.Windows.Forms.DialogResult]::Yes) {
|
|
95
|
+
Start-Process "cmd.exe" -ArgumentList "/k npm install -g getaimeter@latest && echo. && echo Update complete. Restart the watcher with: aimeter stop && aimeter start"
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
[System.Windows.Forms.MessageBox]::Show(
|
|
99
|
+
"You are running the latest version (v$Version).",
|
|
100
|
+
"No Updates",
|
|
101
|
+
[System.Windows.Forms.MessageBoxButtons]::OK,
|
|
102
|
+
[System.Windows.Forms.MessageBoxIcon]::Information
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
[System.Windows.Forms.MessageBox]::Show(
|
|
107
|
+
"Could not check for updates. Please check your internet connection.",
|
|
108
|
+
"Update Check Failed",
|
|
109
|
+
[System.Windows.Forms.MessageBoxButtons]::OK,
|
|
110
|
+
[System.Windows.Forms.MessageBoxIcon]::Warning
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
$checkUpdateItem.Text = "Check for Updates"
|
|
114
|
+
$checkUpdateItem.Enabled = $true
|
|
115
|
+
})
|
|
116
|
+
$helpMenu.DropDownItems.Add($checkUpdateItem) | Out-Null
|
|
117
|
+
|
|
118
|
+
$helpMenu.DropDownItems.Add("-") | Out-Null
|
|
119
|
+
|
|
120
|
+
$docsItem = New-Object System.Windows.Forms.ToolStripMenuItem("Documentation")
|
|
121
|
+
$docsItem.Add_Click({ Start-Process "https://www.npmjs.com/package/getaimeter" })
|
|
122
|
+
$helpMenu.DropDownItems.Add($docsItem) | Out-Null
|
|
123
|
+
|
|
124
|
+
$websiteItem = New-Object System.Windows.Forms.ToolStripMenuItem("Website")
|
|
125
|
+
$websiteItem.Add_Click({ Start-Process "https://getaimeter.com" })
|
|
126
|
+
$helpMenu.DropDownItems.Add($websiteItem) | Out-Null
|
|
127
|
+
|
|
128
|
+
$menu.Items.Add($helpMenu)
|
|
129
|
+
|
|
130
|
+
$menu.Items.Add("-")
|
|
131
|
+
|
|
57
132
|
$stop = $menu.Items.Add("Stop Watcher")
|
|
58
133
|
$stop.Add_Click({
|
|
59
134
|
$tray.Visible = $false
|
|
60
135
|
$tray.Dispose()
|
|
136
|
+
$mutex.ReleaseMutex()
|
|
137
|
+
$mutex.Dispose()
|
|
61
138
|
if ($ParentPid -gt 0) {
|
|
62
139
|
try { Stop-Process -Id $ParentPid -Force -ErrorAction SilentlyContinue } catch {}
|
|
63
140
|
}
|
|
@@ -77,11 +154,16 @@ $timer.Add_Tick({
|
|
|
77
154
|
# Parent died, clean up
|
|
78
155
|
$tray.Visible = $false
|
|
79
156
|
$tray.Dispose()
|
|
157
|
+
$mutex.ReleaseMutex()
|
|
158
|
+
$mutex.Dispose()
|
|
80
159
|
[System.Windows.Forms.Application]::Exit()
|
|
81
160
|
}
|
|
82
161
|
}
|
|
83
162
|
})
|
|
84
163
|
$timer.Start()
|
|
85
164
|
|
|
165
|
+
# Double-click tray icon opens dashboard
|
|
166
|
+
$tray.Add_DoubleClick({ Start-Process "https://getaimeter.com/dashboard" })
|
|
167
|
+
|
|
86
168
|
# Run message loop
|
|
87
169
|
[System.Windows.Forms.Application]::Run()
|
package/watcher.js
CHANGED
|
@@ -310,8 +310,9 @@ function extractNewUsage(filePath) {
|
|
|
310
310
|
// Cursor IDE — extract usage from SQLite state.vscdb
|
|
311
311
|
// ---------------------------------------------------------------------------
|
|
312
312
|
|
|
313
|
-
// Track last-seen
|
|
314
|
-
|
|
313
|
+
// Track last-seen bubble counts per conversation to detect new messages
|
|
314
|
+
// Map<composerId, { aiCount, aiTextLen, userTextLen }>
|
|
315
|
+
const _cursorConvState = new Map();
|
|
315
316
|
let _cursorSqliteAvailable = null; // null = unchecked, true/false after first check
|
|
316
317
|
|
|
317
318
|
// Locate sqlite3 binary — may be in PATH or in the WinGet install location
|
|
@@ -413,7 +414,6 @@ function extractCursorUsage(dbPath) {
|
|
|
413
414
|
const parts = line.split('|');
|
|
414
415
|
if (parts.length < 4) continue;
|
|
415
416
|
const [key, composerId, modelName, createdAt] = parts;
|
|
416
|
-
if (_cursorSeenKeys.has(key)) continue;
|
|
417
417
|
conversations.push({ key, composerId, modelName: modelName || 'default', createdAt: parseInt(createdAt) || 0 });
|
|
418
418
|
}
|
|
419
419
|
|
|
@@ -422,7 +422,8 @@ function extractCursorUsage(dbPath) {
|
|
|
422
422
|
return [];
|
|
423
423
|
}
|
|
424
424
|
|
|
425
|
-
// Step 2: For
|
|
425
|
+
// Step 2: For each conversation, check if bubble count increased since last check.
|
|
426
|
+
// Report only the DELTA (new messages since last poll).
|
|
426
427
|
for (const conv of conversations) {
|
|
427
428
|
try {
|
|
428
429
|
// Count AI responses (type=2) and sum their text lengths
|
|
@@ -430,19 +431,11 @@ function extractCursorUsage(dbPath) {
|
|
|
430
431
|
`SELECT count(*), sum(length(json_extract(value, '$.text'))) FROM cursorDiskKV WHERE key LIKE 'bubbleId:${conv.composerId}:%' AND json_extract(value, '$.type') = 2`
|
|
431
432
|
);
|
|
432
433
|
|
|
433
|
-
if (!bubbleRaw || !bubbleRaw.trim())
|
|
434
|
-
_cursorSeenKeys.add(conv.key);
|
|
435
|
-
continue;
|
|
436
|
-
}
|
|
434
|
+
if (!bubbleRaw || !bubbleRaw.trim()) continue;
|
|
437
435
|
|
|
438
436
|
const [countStr, textLenStr] = bubbleRaw.trim().split('|');
|
|
439
|
-
const
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
if (responseCount === 0) {
|
|
443
|
-
_cursorSeenKeys.add(conv.key);
|
|
444
|
-
continue;
|
|
445
|
-
}
|
|
437
|
+
const aiCount = parseInt(countStr) || 0;
|
|
438
|
+
const aiTextLen = parseInt(textLenStr) || 0;
|
|
446
439
|
|
|
447
440
|
// Also count user messages for input estimation
|
|
448
441
|
const userRaw = runSqlite(dbPath,
|
|
@@ -450,21 +443,31 @@ function extractCursorUsage(dbPath) {
|
|
|
450
443
|
);
|
|
451
444
|
const userTextLen = parseInt((userRaw || '').trim()) || 0;
|
|
452
445
|
|
|
453
|
-
//
|
|
454
|
-
const
|
|
455
|
-
const estimatedOutputTokens = Math.ceil(totalTextLen / 4);
|
|
446
|
+
// Get previous state for this conversation
|
|
447
|
+
const prev = _cursorConvState.get(conv.composerId) || { aiCount: 0, aiTextLen: 0, userTextLen: 0 };
|
|
456
448
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
continue;
|
|
449
|
+
// Check if anything changed
|
|
450
|
+
if (aiCount <= prev.aiCount && aiTextLen <= prev.aiTextLen && userTextLen <= prev.userTextLen) {
|
|
451
|
+
continue; // No new messages
|
|
460
452
|
}
|
|
461
453
|
|
|
462
|
-
|
|
454
|
+
// Calculate delta (new tokens since last check)
|
|
455
|
+
const deltaAiTextLen = Math.max(0, aiTextLen - prev.aiTextLen);
|
|
456
|
+
const deltaUserTextLen = Math.max(0, userTextLen - prev.userTextLen);
|
|
457
|
+
|
|
458
|
+
// Update stored state
|
|
459
|
+
_cursorConvState.set(conv.composerId, { aiCount, aiTextLen, userTextLen });
|
|
460
|
+
|
|
461
|
+
// Estimate tokens: ~4 chars per token
|
|
462
|
+
const estimatedInputTokens = Math.ceil(deltaUserTextLen / 4);
|
|
463
|
+
const estimatedOutputTokens = Math.ceil(deltaAiTextLen / 4);
|
|
464
|
+
|
|
465
|
+
if (estimatedInputTokens === 0 && estimatedOutputTokens === 0) continue;
|
|
466
|
+
|
|
467
|
+
// Dedup by exact delta values
|
|
468
|
+
const hashKey = `cursor:${conv.composerId}:${aiCount}:${aiTextLen}:${userTextLen}`;
|
|
463
469
|
const hash = crypto.createHash('md5').update(hashKey).digest('hex');
|
|
464
|
-
if (isDuplicate(hash))
|
|
465
|
-
_cursorSeenKeys.add(conv.key);
|
|
466
|
-
continue;
|
|
467
|
-
}
|
|
470
|
+
if (isDuplicate(hash)) continue;
|
|
468
471
|
|
|
469
472
|
// Determine provider from model name
|
|
470
473
|
let provider = 'openai';
|
|
@@ -487,8 +490,6 @@ function extractCursorUsage(dbPath) {
|
|
|
487
490
|
cacheWriteTokens: 0,
|
|
488
491
|
});
|
|
489
492
|
} catch {}
|
|
490
|
-
|
|
491
|
-
_cursorSeenKeys.add(conv.key);
|
|
492
493
|
}
|
|
493
494
|
} catch (err) {
|
|
494
495
|
if (!err.message.includes('database is locked')) {
|
|
@@ -654,20 +655,22 @@ function startWatching() {
|
|
|
654
655
|
const cursorDbs = findCursorDbs(watchPaths);
|
|
655
656
|
if (cursorDbs.length > 0) {
|
|
656
657
|
log('Cursor databases found:', cursorDbs.join(', '));
|
|
657
|
-
//
|
|
658
|
-
//
|
|
658
|
+
// Pre-populate _cursorConvState on startup so we only report new messages.
|
|
659
|
+
// On first run, set the mtime offset and discard results.
|
|
660
|
+
// On subsequent runs, report any new data as catch-up.
|
|
659
661
|
for (const dbPath of cursorDbs) {
|
|
660
662
|
try {
|
|
661
663
|
if (isFirstRun) {
|
|
662
|
-
|
|
664
|
+
// Set WAL mtime so first poll doesn't re-process
|
|
665
|
+
const walPath = dbPath + '-wal';
|
|
666
|
+
const mtime = fs.existsSync(walPath) ? fs.statSync(walPath).mtimeMs : fs.statSync(dbPath).mtimeMs;
|
|
663
667
|
setOffset(dbPath + ':mtime', mtime);
|
|
664
668
|
}
|
|
665
|
-
//
|
|
669
|
+
// Extract to populate _cursorConvState with current bubble counts
|
|
666
670
|
const existing = extractCursorUsage(dbPath);
|
|
667
671
|
if (isFirstRun && existing.length > 0) {
|
|
668
|
-
log(`Cursor: marked ${
|
|
672
|
+
log(`Cursor: marked ${_cursorConvState.size} conversations as read (${existing.length} events skipped)`);
|
|
669
673
|
} else if (existing.length > 0) {
|
|
670
|
-
// Not first run but has new data since last mtime — report it
|
|
671
674
|
reportEvents(existing);
|
|
672
675
|
log(`Cursor: catch-up reported ${existing.length} events`);
|
|
673
676
|
}
|