getaimeter 0.8.1 → 0.10.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/cli.js CHANGED
@@ -10,7 +10,7 @@ const { startTray, stopTray } = require('./tray');
10
10
  const command = process.argv[2] || 'help';
11
11
 
12
12
  // Version check on interactive commands (non-blocking)
13
- if (['setup', 'status', 'help', '--help', '-h'].includes(command)) {
13
+ if (['setup', 'status', 'summary', 'help', '--help', '-h'].includes(command)) {
14
14
  checkForUpdate();
15
15
  }
16
16
 
@@ -24,9 +24,12 @@ switch (command) {
24
24
  case 'status': runStatus(); break;
25
25
  case 'logs': runLogs(); break;
26
26
  case 'key': runKey(); break;
27
+ case 'summary': runSummary(); break;
28
+ case 'blocks': runBlocks(); break;
27
29
  case 'version': case '--version': case '-v':
28
30
  console.log(getCurrentVersion());
29
31
  break;
32
+ case 'mcp': runMcp(); break;
30
33
  case 'help': case '--help': case '-h':
31
34
  printHelp();
32
35
  break;
@@ -37,6 +40,41 @@ switch (command) {
37
40
  break;
38
41
  }
39
42
 
43
+ // ---------------------------------------------------------------------------
44
+ // fetchFromApi — shared helper for API calls (native https, zero deps)
45
+ // ---------------------------------------------------------------------------
46
+
47
+ function fetchFromApi(urlPath, apiKey) {
48
+ return new Promise((resolve, reject) => {
49
+ const https = require('https');
50
+ const options = {
51
+ hostname: 'aimeter-api.fly.dev',
52
+ port: 443,
53
+ path: urlPath,
54
+ method: 'GET',
55
+ headers: {
56
+ 'X-AIMeter-Key': apiKey,
57
+ },
58
+ };
59
+
60
+ const req = https.request(options, (res) => {
61
+ let body = '';
62
+ res.on('data', chunk => body += chunk);
63
+ res.on('end', () => {
64
+ if (res.statusCode >= 200 && res.statusCode < 300) {
65
+ try { resolve(JSON.parse(body)); }
66
+ catch { reject(new Error('Invalid JSON response')); }
67
+ } else {
68
+ reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 200)}`));
69
+ }
70
+ });
71
+ });
72
+ req.on('error', reject);
73
+ req.setTimeout(10000, () => { req.destroy(); reject(new Error('Request timeout')); });
74
+ req.end();
75
+ });
76
+ }
77
+
40
78
  // ---------------------------------------------------------------------------
41
79
  // setup — full onboarding wizard
42
80
  // ---------------------------------------------------------------------------
@@ -390,6 +428,195 @@ function runKey() {
390
428
  }
391
429
  }
392
430
 
431
+ // ---------------------------------------------------------------------------
432
+ // mcp — start MCP server for Claude Code/Desktop
433
+ // ---------------------------------------------------------------------------
434
+
435
+ function runMcp() {
436
+ require('./mcp').startMcpServer();
437
+ }
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // summary — show current usage summary from API
441
+ // ---------------------------------------------------------------------------
442
+
443
+ async function runSummary() {
444
+ const https = require('https');
445
+ const apiKey = getApiKey();
446
+ if (!apiKey) {
447
+ console.log('No API key configured. Run: aimeter setup');
448
+ process.exitCode = 1;
449
+ return;
450
+ }
451
+
452
+ // Check for --json flag
453
+ const jsonMode = process.argv.includes('--json');
454
+
455
+ try {
456
+ // Fetch current usage from API
457
+ const data = await fetchFromApi('/api/usage/current', apiKey);
458
+
459
+ if (jsonMode) {
460
+ console.log(JSON.stringify(data, null, 2));
461
+ return;
462
+ }
463
+
464
+ // Pretty print
465
+ const fmtTokens = (n) => {
466
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
467
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
468
+ return String(n);
469
+ };
470
+ const fmtCost = (c) => {
471
+ if (c >= 1) return `$${c.toFixed(2)}`;
472
+ if (c >= 0.01) return `$${c.toFixed(3)}`;
473
+ return `$${c.toFixed(4)}`;
474
+ };
475
+
476
+ // Calculate total cost from byModel
477
+ const totalCost = (data.byModel || []).reduce((sum, m) => sum + (m.estimatedCost || 0), 0);
478
+
479
+ // Reset countdown
480
+ let resetStr = 'N/A';
481
+ if (data.nextReset) {
482
+ const resetMs = new Date(data.nextReset) - Date.now();
483
+ if (resetMs > 0) {
484
+ const h = Math.floor(resetMs / 3600000);
485
+ const m = Math.floor((resetMs % 3600000) / 60000);
486
+ resetStr = `${h}h ${m}m`;
487
+ } else {
488
+ resetStr = 'now';
489
+ }
490
+ }
491
+
492
+ console.log('');
493
+ console.log(' AIMeter — Current Usage (5-hour window)');
494
+ console.log(' ════════════════════════════════════════');
495
+ console.log('');
496
+ console.log(` Cost: ${fmtCost(totalCost)}`);
497
+ console.log(` Input: ${fmtTokens(data.totalInputTokens || 0)}`);
498
+ console.log(` Output: ${fmtTokens(data.totalOutputTokens || 0)}`);
499
+ console.log(` Thinking: ${fmtTokens(data.totalThinkingTokens || 0)}`);
500
+ console.log(` Cache Read: ${fmtTokens(data.totalCacheReadTokens || 0)}`);
501
+ console.log(` Requests: ${data.windowRequests || 0}`);
502
+ console.log(` Reset in: ${resetStr}`);
503
+
504
+ // By source
505
+ if (data.bySource && data.bySource.length > 0) {
506
+ console.log('');
507
+ console.log(' By Source:');
508
+ const sourceLabels = {
509
+ cli: 'Terminal', vscode: 'VS Code', desktop_app: 'Desktop',
510
+ cursor: 'Cursor', codex_cli: 'Codex CLI', codex_vscode: 'Codex VS',
511
+ gemini_cli: 'Gemini', copilot_cli: 'Copilot', copilot_vscode: 'Copilot VS',
512
+ };
513
+ for (const s of data.bySource) {
514
+ const label = sourceLabels[s.source] || s.source;
515
+ console.log(` ${label.padEnd(14)} ${String(s.requests).padStart(4)} req`);
516
+ }
517
+ }
518
+
519
+ // By model
520
+ if (data.byModel && data.byModel.length > 0) {
521
+ console.log('');
522
+ console.log(' By Model:');
523
+ for (const m of data.byModel) {
524
+ const label = m.model.replace(/-\d{8}$/, '');
525
+ console.log(` ${label.padEnd(22)} ${String(m.requests).padStart(4)} req ${fmtCost(m.estimatedCost || 0)}`);
526
+ }
527
+ }
528
+
529
+ // Previous window comparison
530
+ if (data.previous && data.previous.totalCost > 0) {
531
+ const prevCost = data.previous.totalCost;
532
+ const change = totalCost - prevCost;
533
+ const pct = prevCost > 0 ? ((change / prevCost) * 100).toFixed(0) : '∞';
534
+ const arrow = change >= 0 ? '↑' : '↓';
535
+ console.log('');
536
+ console.log(` vs Previous: ${arrow} ${Math.abs(change).toFixed(2)} (${pct}%)`);
537
+ }
538
+
539
+ console.log('');
540
+ } catch (err) {
541
+ console.error(`Error fetching usage: ${err.message}`);
542
+ process.exitCode = 1;
543
+ }
544
+ }
545
+
546
+ // ---------------------------------------------------------------------------
547
+ // blocks — show 5-hour billing blocks
548
+ // ---------------------------------------------------------------------------
549
+
550
+ async function runBlocks() {
551
+ const apiKey = getApiKey();
552
+ if (!apiKey) {
553
+ console.log('No API key configured. Run: aimeter setup');
554
+ process.exitCode = 1;
555
+ return;
556
+ }
557
+
558
+ const jsonMode = process.argv.includes('--json');
559
+ const days = parseInt(process.argv[3]) || 3;
560
+
561
+ try {
562
+ const data = await fetchFromApi(`/api/usage/blocks?days=${days}`, apiKey);
563
+
564
+ if (jsonMode) {
565
+ console.log(JSON.stringify(data, null, 2));
566
+ return;
567
+ }
568
+
569
+ console.log('');
570
+ console.log(` AIMeter — Billing Blocks (last ${days} days)`);
571
+ console.log(' ════════════════════════════════════════');
572
+ console.log('');
573
+
574
+ if (!data.blocks || data.blocks.length === 0) {
575
+ console.log(' No billing blocks found.');
576
+ console.log('');
577
+ return;
578
+ }
579
+
580
+ for (const block of data.blocks) {
581
+ const start = new Date(block.startTime);
582
+ const fmtTime = (d) => d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
583
+ const active = block.isActive ? ' ◉ ACTIVE' : '';
584
+
585
+ console.log(` ${fmtTime(start)}${active}`);
586
+ console.log(` ────────────────────────────`);
587
+
588
+ const fmtCost = (c) => c >= 1 ? `$${c.toFixed(2)}` : `$${c.toFixed(3)}`;
589
+ const fmtTokens = (n) => {
590
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
591
+ if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
592
+ return String(n);
593
+ };
594
+
595
+ console.log(` Cost: ${fmtCost(block.totalCost)} | Requests: ${block.requestCount} | Tokens: ${fmtTokens(block.totalInputTokens + block.totalOutputTokens)}`);
596
+
597
+ if (block.burnRate) {
598
+ console.log(` Burn: ${fmtTokens(block.burnRate.tokensPerMinute)}/min | ${fmtCost(block.burnRate.costPerHour)}/hr`);
599
+ }
600
+
601
+ if (block.isActive && block.remainingMinutes > 0) {
602
+ const h = Math.floor(block.remainingMinutes / 60);
603
+ const m = Math.round(block.remainingMinutes % 60);
604
+ console.log(` Remaining: ${h}h ${m}m`);
605
+ }
606
+
607
+ console.log(` Sources: ${(block.sources || []).join(', ')}`);
608
+ console.log(` Models: ${(block.models || []).join(', ')}`);
609
+ console.log('');
610
+ }
611
+
612
+ console.log(` Total: ${data.totalBlocks} blocks, $${data.totalCost.toFixed(2)}`);
613
+ console.log('');
614
+ } catch (err) {
615
+ console.error(`Error fetching blocks: ${err.message}`);
616
+ process.exitCode = 1;
617
+ }
618
+ }
619
+
393
620
  // ---------------------------------------------------------------------------
394
621
  // help
395
622
  // ---------------------------------------------------------------------------
@@ -413,11 +640,18 @@ function printHelp() {
413
640
  Manual mode:
414
641
  watch Run watcher in foreground
415
642
 
643
+ Usage insights:
644
+ summary Show current usage summary (--json for JSON)
645
+ blocks [N] Show 5-hour billing blocks (last N days, default 3)
646
+
416
647
  Info:
417
648
  status Show current configuration
418
649
  logs [N] Tail watcher log (last N lines, default 50)
419
650
  key Print current API key
420
651
 
652
+ Integration:
653
+ mcp Start MCP server (for Claude Code/Desktop)
654
+
421
655
  https://getaimeter.com
422
656
  `);
423
657
  }
@@ -0,0 +1,61 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "AIMeter Configuration",
4
+ "description": "Configuration file for AIMeter watcher (~/.aimeter/config.json)",
5
+ "type": "object",
6
+ "properties": {
7
+ "apiKey": {
8
+ "type": "string",
9
+ "description": "Your AIMeter API key (starts with aim_)",
10
+ "pattern": "^aim_[a-zA-Z0-9]+$"
11
+ },
12
+ "apiUrl": {
13
+ "type": "string",
14
+ "description": "API server URL (default: https://aimeter-api.fly.dev)",
15
+ "format": "uri",
16
+ "default": "https://aimeter-api.fly.dev"
17
+ },
18
+ "pollInterval": {
19
+ "type": "integer",
20
+ "description": "File polling interval in milliseconds (default: 5000)",
21
+ "minimum": 1000,
22
+ "maximum": 60000,
23
+ "default": 5000
24
+ },
25
+ "enableCursor": {
26
+ "type": "boolean",
27
+ "description": "Enable Cursor IDE tracking (requires sqlite3)",
28
+ "default": true
29
+ },
30
+ "enableCodex": {
31
+ "type": "boolean",
32
+ "description": "Enable OpenAI Codex tracking",
33
+ "default": true
34
+ },
35
+ "enableCopilot": {
36
+ "type": "boolean",
37
+ "description": "Enable GitHub Copilot tracking",
38
+ "default": true
39
+ },
40
+ "enableGemini": {
41
+ "type": "boolean",
42
+ "description": "Enable Google Gemini tracking",
43
+ "default": true
44
+ },
45
+ "logLevel": {
46
+ "type": "string",
47
+ "description": "Logging verbosity",
48
+ "enum": ["debug", "info", "warn", "error"],
49
+ "default": "info"
50
+ },
51
+ "maxLogSizeMb": {
52
+ "type": "number",
53
+ "description": "Maximum log file size in MB before rotation (default: 10)",
54
+ "minimum": 1,
55
+ "maximum": 100,
56
+ "default": 10
57
+ }
58
+ },
59
+ "required": ["apiKey"],
60
+ "additionalProperties": false
61
+ }
package/mcp.js ADDED
@@ -0,0 +1,772 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * AIMeter MCP Server
6
+ *
7
+ * Model Context Protocol server that exposes AI usage data to Claude Code/Desktop.
8
+ * Uses STDIO transport (JSON-RPC 2.0) with zero npm dependencies.
9
+ *
10
+ * Usage:
11
+ * node mcp.js — start the MCP server (stdin/stdout)
12
+ * aimeter mcp — same, via CLI
13
+ * aimeter-mcp — same, via bin alias
14
+ *
15
+ * Configuration for Claude Code (~/.claude/settings.json):
16
+ * {
17
+ * "mcpServers": {
18
+ * "aimeter": {
19
+ * "command": "aimeter-mcp"
20
+ * }
21
+ * }
22
+ * }
23
+ */
24
+
25
+ const https = require('https');
26
+ const fs = require('fs');
27
+ const os = require('os');
28
+ const path = require('path');
29
+
30
+ // ─── Constants ───────────────────────────────────────────────────────────────
31
+
32
+ const API_HOST = 'aimeter-api.fly.dev';
33
+ const CONFIG_FILE = path.join(os.homedir(), '.aimeter', 'config.json');
34
+ const MCP_PROTOCOL_VERSION = '2024-11-05';
35
+
36
+ const SERVER_INFO = {
37
+ name: 'aimeter',
38
+ version: '0.9.0',
39
+ };
40
+
41
+ // ─── Tool Definitions ────────────────────────────────────────────────────────
42
+
43
+ const TOOLS = [
44
+ {
45
+ name: 'get_usage_summary',
46
+ description:
47
+ 'Get your current AI coding costs across all tools (Claude, Cursor, Codex, Copilot, Gemini) for the active 5-hour billing window. Shows token counts, cost breakdown by model and source, and time until reset.',
48
+ inputSchema: {
49
+ type: 'object',
50
+ properties: {},
51
+ required: [],
52
+ },
53
+ },
54
+ {
55
+ name: 'get_daily_costs',
56
+ description:
57
+ 'Get daily cost breakdown showing spend per day across all AI coding tools. Shows date, cost, and request count for each day.',
58
+ inputSchema: {
59
+ type: 'object',
60
+ properties: {
61
+ days: {
62
+ type: 'number',
63
+ description: 'Number of days to look back (default: 7, max: 90)',
64
+ },
65
+ },
66
+ required: [],
67
+ },
68
+ },
69
+ {
70
+ name: 'get_session_blocks',
71
+ description:
72
+ 'Analyze your usage in 5-hour billing blocks. Shows token consumption and cost for each block to help identify high-usage periods.',
73
+ inputSchema: {
74
+ type: 'object',
75
+ properties: {
76
+ hours: {
77
+ type: 'number',
78
+ description: 'Number of hours to look back (default: 24, max: 120)',
79
+ },
80
+ },
81
+ required: [],
82
+ },
83
+ },
84
+ {
85
+ name: 'get_cost_forecast',
86
+ description:
87
+ 'Get monthly spend projection based on your current usage patterns. Shows spent so far, daily average, and projected monthly total.',
88
+ inputSchema: {
89
+ type: 'object',
90
+ properties: {},
91
+ required: [],
92
+ },
93
+ },
94
+ {
95
+ name: 'get_optimizations',
96
+ description:
97
+ 'Get personalized cost optimization recommendations based on your usage patterns. Analyzes model choices, caching efficiency, and usage timing. Requires Pro plan.',
98
+ inputSchema: {
99
+ type: 'object',
100
+ properties: {
101
+ days: {
102
+ type: 'number',
103
+ description: 'Number of days of data to analyze (default: 30)',
104
+ },
105
+ },
106
+ required: [],
107
+ },
108
+ },
109
+ ];
110
+
111
+ // ─── API Key ─────────────────────────────────────────────────────────────────
112
+
113
+ function getApiKey() {
114
+ // Environment variable takes precedence
115
+ if (process.env.AIMETER_KEY) return process.env.AIMETER_KEY.trim();
116
+
117
+ // Read from config file
118
+ try {
119
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
120
+ if (cfg.apiKey) return cfg.apiKey;
121
+ } catch {
122
+ // config file doesn't exist or is invalid
123
+ }
124
+
125
+ // Legacy: ~/.claude/aimeter-key
126
+ try {
127
+ const legacyPath = path.join(os.homedir(), '.claude', 'aimeter-key');
128
+ const key = fs.readFileSync(legacyPath, 'utf8').trim();
129
+ if (key) return key;
130
+ } catch {}
131
+
132
+ return null;
133
+ }
134
+
135
+ // ─── HTTP Client ─────────────────────────────────────────────────────────────
136
+
137
+ function callApi(apiPath) {
138
+ return new Promise((resolve, reject) => {
139
+ const apiKey = getApiKey();
140
+ if (!apiKey) {
141
+ reject(new Error('No AIMeter API key configured. Run: aimeter setup'));
142
+ return;
143
+ }
144
+
145
+ const options = {
146
+ hostname: API_HOST,
147
+ port: 443,
148
+ path: apiPath,
149
+ method: 'GET',
150
+ headers: {
151
+ 'X-AIMeter-Key': apiKey,
152
+ 'Accept': 'application/json',
153
+ },
154
+ };
155
+
156
+ const req = https.request(options, (res) => {
157
+ let body = '';
158
+ res.on('data', (chunk) => { body += chunk; });
159
+ res.on('end', () => {
160
+ if (res.statusCode >= 200 && res.statusCode < 300) {
161
+ try {
162
+ resolve(JSON.parse(body));
163
+ } catch (e) {
164
+ reject(new Error(`Invalid JSON response from API: ${e.message}`));
165
+ }
166
+ } else if (res.statusCode === 401) {
167
+ reject(new Error('Invalid API key. Run: aimeter setup'));
168
+ } else if (res.statusCode === 403) {
169
+ reject(new Error('Pro plan required for this feature. Upgrade at https://getaimeter.com/dashboard/account'));
170
+ } else if (res.statusCode === 429) {
171
+ reject(new Error('Rate limit exceeded. Please wait a moment and try again.'));
172
+ } else {
173
+ let errorMsg = `API returned ${res.statusCode}`;
174
+ try {
175
+ const parsed = JSON.parse(body);
176
+ if (parsed.error) errorMsg = parsed.error;
177
+ } catch {}
178
+ reject(new Error(errorMsg));
179
+ }
180
+ });
181
+ });
182
+
183
+ req.on('error', (err) => reject(new Error(`Network error: ${err.message}`)));
184
+ req.setTimeout(15000, () => {
185
+ req.destroy();
186
+ reject(new Error('API request timed out (15s)'));
187
+ });
188
+ req.end();
189
+ });
190
+ }
191
+
192
+ // ─── Formatting Helpers ──────────────────────────────────────────────────────
193
+
194
+ function formatCost(cost) {
195
+ if (cost == null || isNaN(cost)) return '$0.00';
196
+ return '$' + Number(cost).toFixed(2);
197
+ }
198
+
199
+ function formatTokens(count) {
200
+ if (count == null || isNaN(count)) return '0';
201
+ count = Number(count);
202
+ if (count >= 1_000_000) return (count / 1_000_000).toFixed(1) + 'M';
203
+ if (count >= 1_000) return (count / 1_000).toFixed(1) + 'k';
204
+ return count.toString();
205
+ }
206
+
207
+ function formatDuration(ms) {
208
+ if (ms == null || isNaN(ms) || ms <= 0) return '0m';
209
+ const totalMinutes = Math.floor(ms / 60000);
210
+ const hours = Math.floor(totalMinutes / 60);
211
+ const minutes = totalMinutes % 60;
212
+ if (hours > 0) return `${hours}h ${minutes}m`;
213
+ return `${minutes}m`;
214
+ }
215
+
216
+ function padRight(str, len) {
217
+ str = String(str);
218
+ while (str.length < len) str += ' ';
219
+ return str;
220
+ }
221
+
222
+ function padLeft(str, len) {
223
+ str = String(str);
224
+ while (str.length < len) str = ' ' + str;
225
+ return str;
226
+ }
227
+
228
+ // ─── Tool Handlers ───────────────────────────────────────────────────────────
229
+
230
+ async function handleGetUsageSummary() {
231
+ const data = await callApi('/api/usage/current');
232
+
233
+ const totalCost = (data.byModel || []).reduce((sum, m) => sum + (m.estimatedCost || 0), 0);
234
+
235
+ // Calculate time until reset
236
+ let resetText = 'N/A';
237
+ if (data.nextReset) {
238
+ const resetTime = new Date(data.nextReset);
239
+ const now = new Date();
240
+ const diffMs = resetTime.getTime() - now.getTime();
241
+ if (diffMs > 0) {
242
+ resetText = formatDuration(diffMs);
243
+ } else {
244
+ resetText = 'Imminent';
245
+ }
246
+ }
247
+
248
+ let output = '';
249
+ output += 'Current Usage (5-hour window)\n';
250
+ output += '='.repeat(40) + '\n';
251
+ output += `Total Cost: ${formatCost(totalCost)}\n`;
252
+ output += `Input Tokens: ${formatTokens(data.totalInputTokens)}\n`;
253
+ output += `Output Tokens: ${formatTokens(data.totalOutputTokens)}\n`;
254
+ output += `Thinking: ${formatTokens(data.totalThinkingTokens)}\n`;
255
+ output += `Cache Read: ${formatTokens(data.totalCacheReadTokens)}\n`;
256
+ output += `Requests: ${data.windowRequests || 0}\n`;
257
+ output += `Reset in: ${resetText}\n`;
258
+
259
+ // By Source
260
+ const bySource = data.bySource || [];
261
+ if (bySource.length > 0) {
262
+ output += '\nBy Source:\n';
263
+ output += '-'.repeat(40) + '\n';
264
+ for (const s of bySource) {
265
+ const srcName = padRight(s.source || 'unknown', 14);
266
+ const reqCount = padLeft(String(s.requests || 0), 4);
267
+ const srcTokens = formatTokens((s.inputTokens || 0) + (s.outputTokens || 0));
268
+ output += ` ${srcName} ${reqCount} requests ${padLeft(srcTokens, 8)} tokens\n`;
269
+ }
270
+ }
271
+
272
+ // By Model
273
+ const byModel = data.byModel || [];
274
+ if (byModel.length > 0) {
275
+ output += '\nBy Model:\n';
276
+ output += '-'.repeat(40) + '\n';
277
+ for (const m of byModel) {
278
+ const modelName = padRight(m.model || 'unknown', 22);
279
+ const reqCount = padLeft(String(m.requests || 0), 4);
280
+ output += ` ${modelName} ${reqCount} req ${padLeft(formatCost(m.estimatedCost), 8)}\n`;
281
+ }
282
+ }
283
+
284
+ // Previous window comparison
285
+ if (data.previous) {
286
+ const prevCost = data.previous.totalCost || 0;
287
+ const prevReqs = data.previous.totalRequests || 0;
288
+ output += '\nPrevious Window:\n';
289
+ output += '-'.repeat(40) + '\n';
290
+ output += ` Cost: ${formatCost(prevCost)} | Requests: ${prevReqs}\n`;
291
+ if (prevCost > 0 && totalCost > 0) {
292
+ const change = ((totalCost - prevCost) / prevCost * 100).toFixed(0);
293
+ const arrow = totalCost > prevCost ? 'UP' : 'DOWN';
294
+ output += ` Change: ${change > 0 ? '+' : ''}${change}% (${arrow})\n`;
295
+ }
296
+ }
297
+
298
+ // Weekly summary
299
+ if (data.weekly) {
300
+ output += '\nWeekly Rolling (7 days):\n';
301
+ output += '-'.repeat(40) + '\n';
302
+ output += ` Total Tokens: ${formatTokens(data.weekly.totalTokens)} | Requests: ${data.weekly.requests || 0}\n`;
303
+ }
304
+
305
+ return output;
306
+ }
307
+
308
+ async function handleGetDailyCosts(args) {
309
+ const days = Math.min(Math.max(parseInt(args.days) || 7, 1), 90);
310
+
311
+ const data = await callApi(`/api/usage/history?days=${days}`);
312
+
313
+ const dailyData = data.days || [];
314
+
315
+ if (dailyData.length === 0) {
316
+ return `No usage data found for the last ${days} days.`;
317
+ }
318
+
319
+ // We need cost data from the analytics endpoint for pro users,
320
+ // but history gives us token counts. Let's present what we have.
321
+ let output = `Daily Usage (last ${days} days)\n`;
322
+ output += '='.repeat(50) + '\n';
323
+ output += `${padRight('Date', 14)} ${padLeft('Input', 10)} ${padLeft('Output', 10)} ${padLeft('Thinking', 10)} ${padLeft('Requests', 10)}\n`;
324
+ output += '-'.repeat(54) + '\n';
325
+
326
+ let totalInput = 0;
327
+ let totalOutput = 0;
328
+ let totalThinking = 0;
329
+ let totalRequests = 0;
330
+
331
+ for (const day of dailyData) {
332
+ const dateStr = day.date ? new Date(day.date).toISOString().slice(0, 10) : 'unknown';
333
+ const input = day.inputTokens || 0;
334
+ const output2 = day.outputTokens || 0;
335
+ const thinking = day.thinkingTokens || 0;
336
+ const reqs = day.requests || 0;
337
+
338
+ totalInput += input;
339
+ totalOutput += output2;
340
+ totalThinking += thinking;
341
+ totalRequests += reqs;
342
+
343
+ output += `${padRight(dateStr, 14)} ${padLeft(formatTokens(input), 10)} ${padLeft(formatTokens(output2), 10)} ${padLeft(formatTokens(thinking), 10)} ${padLeft(String(reqs), 10)}\n`;
344
+ }
345
+
346
+ output += '-'.repeat(54) + '\n';
347
+ output += `${padRight('TOTAL', 14)} ${padLeft(formatTokens(totalInput), 10)} ${padLeft(formatTokens(totalOutput), 10)} ${padLeft(formatTokens(totalThinking), 10)} ${padLeft(String(totalRequests), 10)}\n`;
348
+
349
+ const avgReqs = Math.round(totalRequests / dailyData.length);
350
+ output += `\nAverage: ${avgReqs} requests/day\n`;
351
+
352
+ return output;
353
+ }
354
+
355
+ async function handleGetSessionBlocks(args) {
356
+ const hours = Math.min(Math.max(parseInt(args.hours) || 24, 1), 120);
357
+
358
+ // Fetch recent events to analyze blocks
359
+ const limit = 200;
360
+ const data = await callApi(`/api/usage/events?limit=${limit}&offset=0`);
361
+
362
+ const events = (data.events || []).filter((e) => {
363
+ if (!e.timestamp) return false;
364
+ const eventTime = new Date(e.timestamp);
365
+ const cutoff = new Date(Date.now() - hours * 3600000);
366
+ return eventTime >= cutoff;
367
+ });
368
+
369
+ if (events.length === 0) {
370
+ return `No usage events found in the last ${hours} hours.`;
371
+ }
372
+
373
+ // Group events into 5-hour blocks
374
+ const blockSize = 5 * 3600000; // 5 hours in ms
375
+ const now = Date.now();
376
+ const blocks = new Map();
377
+
378
+ for (const event of events) {
379
+ const eventTime = new Date(event.timestamp).getTime();
380
+ const blockIndex = Math.floor((now - eventTime) / blockSize);
381
+ const blockStart = now - (blockIndex + 1) * blockSize;
382
+ const blockEnd = now - blockIndex * blockSize;
383
+ const key = blockIndex;
384
+
385
+ if (!blocks.has(key)) {
386
+ blocks.set(key, {
387
+ start: new Date(blockStart),
388
+ end: new Date(blockEnd),
389
+ inputTokens: 0,
390
+ outputTokens: 0,
391
+ thinkingTokens: 0,
392
+ cost: 0,
393
+ requests: 0,
394
+ models: new Set(),
395
+ });
396
+ }
397
+
398
+ const block = blocks.get(key);
399
+ block.inputTokens += event.inputTokens || 0;
400
+ block.outputTokens += event.outputTokens || 0;
401
+ block.thinkingTokens += event.thinkingTokens || 0;
402
+ block.cost += event.estimatedCost || 0;
403
+ block.requests += 1;
404
+ if (event.model) block.models.add(event.model);
405
+ }
406
+
407
+ // Sort blocks by time (most recent first)
408
+ const sortedBlocks = Array.from(blocks.entries())
409
+ .sort((a, b) => a[0] - b[0])
410
+ .map((entry) => entry[1]);
411
+
412
+ let output = `5-Hour Billing Blocks (last ${hours}h)\n`;
413
+ output += '='.repeat(55) + '\n';
414
+
415
+ for (const block of sortedBlocks) {
416
+ const startStr = block.start.toISOString().slice(11, 16);
417
+ const endStr = block.end.toISOString().slice(11, 16);
418
+ const dateStr = block.start.toISOString().slice(0, 10);
419
+
420
+ output += `\n[${dateStr} ${startStr} - ${endStr} UTC]\n`;
421
+ output += '-'.repeat(40) + '\n';
422
+ output += ` Cost: ${formatCost(block.cost)}\n`;
423
+ output += ` Requests: ${block.requests}\n`;
424
+ output += ` Input: ${formatTokens(block.inputTokens)}\n`;
425
+ output += ` Output: ${formatTokens(block.outputTokens)}\n`;
426
+ output += ` Thinking: ${formatTokens(block.thinkingTokens)}\n`;
427
+ output += ` Models: ${Array.from(block.models).join(', ') || 'N/A'}\n`;
428
+ }
429
+
430
+ output += '\n' + '='.repeat(55) + '\n';
431
+ output += `Total: ${sortedBlocks.length} block(s), ${events.length} events\n`;
432
+
433
+ return output;
434
+ }
435
+
436
+ async function handleGetCostForecast() {
437
+ const data = await callApi('/api/analytics/forecast');
438
+
439
+ let output = 'Monthly Cost Forecast\n';
440
+ output += '='.repeat(40) + '\n';
441
+ output += `Spent so far: ${formatCost(data.spentSoFar)}\n`;
442
+ output += `Daily average: ${formatCost(data.dailyAverage)}\n`;
443
+ output += `Projected monthly: ${formatCost(data.projectedMonthly)}\n`;
444
+ output += '\n';
445
+ output += `Days elapsed: ${data.daysElapsed}\n`;
446
+ output += `Days remaining: ${data.daysRemaining}\n`;
447
+
448
+ // Add some context
449
+ const projected = Number(data.projectedMonthly) || 0;
450
+ if (projected > 0) {
451
+ output += '\n';
452
+ output += 'Projections at current pace:\n';
453
+ output += '-'.repeat(40) + '\n';
454
+ output += ` This week: ~${formatCost((data.dailyAverage || 0) * 7)}\n`;
455
+ output += ` This month: ~${formatCost(projected)}\n`;
456
+ output += ` This year: ~${formatCost(projected * 12)}\n`;
457
+ }
458
+
459
+ return output;
460
+ }
461
+
462
+ async function handleGetOptimizations(args) {
463
+ const days = Math.min(Math.max(parseInt(args.days) || 30, 1), 365);
464
+
465
+ const data = await callApi(`/api/analytics/optimizations?days=${days}`);
466
+
467
+ let output = `Cost Optimization Recommendations (${days} days)\n`;
468
+ output += '='.repeat(50) + '\n';
469
+
470
+ // The optimization engine returns various recommendations
471
+ // Format them in a readable way
472
+ if (data.score != null) {
473
+ output += `\nEfficiency Score: ${data.score}/100\n`;
474
+ }
475
+
476
+ if (data.totalCost != null) {
477
+ output += `Total Cost Analyzed: ${formatCost(data.totalCost)}\n`;
478
+ }
479
+
480
+ if (data.potentialSavings != null) {
481
+ output += `Potential Savings: ${formatCost(data.potentialSavings)}\n`;
482
+ }
483
+
484
+ const recommendations = data.recommendations || data.tips || [];
485
+ if (recommendations.length > 0) {
486
+ output += '\nRecommendations:\n';
487
+ output += '-'.repeat(50) + '\n';
488
+ for (let i = 0; i < recommendations.length; i++) {
489
+ const rec = recommendations[i];
490
+ if (typeof rec === 'string') {
491
+ output += ` ${i + 1}. ${rec}\n`;
492
+ } else {
493
+ const title = rec.title || rec.name || `Recommendation ${i + 1}`;
494
+ const desc = rec.description || rec.detail || rec.message || '';
495
+ const savings = rec.savings || rec.estimatedSavings;
496
+ output += `\n ${i + 1}. ${title}\n`;
497
+ if (desc) output += ` ${desc}\n`;
498
+ if (savings != null) output += ` Estimated savings: ${formatCost(savings)}/month\n`;
499
+ if (rec.priority) output += ` Priority: ${rec.priority}\n`;
500
+ }
501
+ }
502
+ }
503
+
504
+ // Model breakdown if available
505
+ const models = data.modelBreakdown || data.byModel || [];
506
+ if (models.length > 0) {
507
+ output += '\nCost by Model:\n';
508
+ output += '-'.repeat(50) + '\n';
509
+ for (const m of models) {
510
+ const name = padRight(m.model || m.name || 'unknown', 24);
511
+ output += ` ${name} ${padLeft(formatCost(m.cost || m.totalCost), 10)}\n`;
512
+ }
513
+ }
514
+
515
+ if (recommendations.length === 0 && !data.score) {
516
+ // Raw data fallback - format whatever we got
517
+ output += '\n' + JSON.stringify(data, null, 2) + '\n';
518
+ }
519
+
520
+ return output;
521
+ }
522
+
523
+ // ─── MCP Protocol Handler ────────────────────────────────────────────────────
524
+
525
+ async function handleMessage(message) {
526
+ const { id, method, params } = message;
527
+
528
+ // Notifications (no id) - acknowledge silently
529
+ if (id === undefined || id === null) {
530
+ if (method === 'notifications/initialized') {
531
+ // Client acknowledged initialization - nothing to respond
532
+ return null;
533
+ }
534
+ if (method === 'notifications/cancelled') {
535
+ return null;
536
+ }
537
+ // Unknown notification - ignore
538
+ return null;
539
+ }
540
+
541
+ switch (method) {
542
+ case 'initialize':
543
+ return {
544
+ jsonrpc: '2.0',
545
+ id,
546
+ result: {
547
+ protocolVersion: MCP_PROTOCOL_VERSION,
548
+ capabilities: {
549
+ tools: {},
550
+ },
551
+ serverInfo: SERVER_INFO,
552
+ },
553
+ };
554
+
555
+ case 'ping':
556
+ return {
557
+ jsonrpc: '2.0',
558
+ id,
559
+ result: {},
560
+ };
561
+
562
+ case 'tools/list':
563
+ return {
564
+ jsonrpc: '2.0',
565
+ id,
566
+ result: {
567
+ tools: TOOLS,
568
+ },
569
+ };
570
+
571
+ case 'tools/call': {
572
+ const toolName = params && params.name;
573
+ const toolArgs = (params && params.arguments) || {};
574
+
575
+ try {
576
+ let resultText;
577
+
578
+ switch (toolName) {
579
+ case 'get_usage_summary':
580
+ resultText = await handleGetUsageSummary();
581
+ break;
582
+ case 'get_daily_costs':
583
+ resultText = await handleGetDailyCosts(toolArgs);
584
+ break;
585
+ case 'get_session_blocks':
586
+ resultText = await handleGetSessionBlocks(toolArgs);
587
+ break;
588
+ case 'get_cost_forecast':
589
+ resultText = await handleGetCostForecast();
590
+ break;
591
+ case 'get_optimizations':
592
+ resultText = await handleGetOptimizations(toolArgs);
593
+ break;
594
+ default:
595
+ return {
596
+ jsonrpc: '2.0',
597
+ id,
598
+ result: {
599
+ content: [{ type: 'text', text: `Unknown tool: ${toolName}` }],
600
+ isError: true,
601
+ },
602
+ };
603
+ }
604
+
605
+ return {
606
+ jsonrpc: '2.0',
607
+ id,
608
+ result: {
609
+ content: [{ type: 'text', text: resultText }],
610
+ },
611
+ };
612
+ } catch (err) {
613
+ return {
614
+ jsonrpc: '2.0',
615
+ id,
616
+ result: {
617
+ content: [{ type: 'text', text: `Error: ${err.message}` }],
618
+ isError: true,
619
+ },
620
+ };
621
+ }
622
+ }
623
+
624
+ default:
625
+ return {
626
+ jsonrpc: '2.0',
627
+ id,
628
+ error: {
629
+ code: -32601,
630
+ message: `Method not found: ${method}`,
631
+ },
632
+ };
633
+ }
634
+ }
635
+
636
+ // ─── STDIO Transport ─────────────────────────────────────────────────────────
637
+
638
+ function startMcpServer() {
639
+ // Suppress any accidental console.log from dependencies
640
+ // MCP protocol uses stdout exclusively for JSON-RPC messages
641
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout);
642
+
643
+ function sendResponse(response) {
644
+ if (!response) return;
645
+ const json = JSON.stringify(response);
646
+ originalStdoutWrite(json + '\n');
647
+ }
648
+
649
+ // Read from stdin line by line
650
+ let buffer = '';
651
+
652
+ process.stdin.setEncoding('utf8');
653
+ process.stdin.on('data', (chunk) => {
654
+ buffer += chunk;
655
+
656
+ // Process complete lines
657
+ let newlineIndex;
658
+ while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
659
+ const line = buffer.slice(0, newlineIndex).trim();
660
+ buffer = buffer.slice(newlineIndex + 1);
661
+
662
+ if (!line) continue;
663
+
664
+ let message;
665
+ try {
666
+ message = JSON.parse(line);
667
+ } catch (err) {
668
+ // Try reading as Content-Length framed message
669
+ // Some MCP clients use HTTP-style framing: "Content-Length: N\r\n\r\n{...}"
670
+ // We'll handle this in the content-length path below
671
+ continue;
672
+ }
673
+
674
+ handleMessage(message)
675
+ .then((response) => sendResponse(response))
676
+ .catch((err) => {
677
+ // Internal error
678
+ if (message && message.id != null) {
679
+ sendResponse({
680
+ jsonrpc: '2.0',
681
+ id: message.id,
682
+ error: {
683
+ code: -32603,
684
+ message: `Internal error: ${err.message}`,
685
+ },
686
+ });
687
+ }
688
+ });
689
+ }
690
+
691
+ // Handle Content-Length framed messages (some clients use this format)
692
+ handleContentLengthFrames();
693
+ });
694
+
695
+ // Content-Length based frame parsing
696
+ let contentLengthBuffer = '';
697
+ let expectedLength = -1;
698
+
699
+ function handleContentLengthFrames() {
700
+ // Check if buffer contains Content-Length headers
701
+ if (expectedLength === -1) {
702
+ const headerEnd = buffer.indexOf('\r\n\r\n');
703
+ if (headerEnd === -1) return;
704
+
705
+ const header = buffer.slice(0, headerEnd);
706
+ const match = header.match(/Content-Length:\s*(\d+)/i);
707
+ if (!match) return;
708
+
709
+ expectedLength = parseInt(match[1], 10);
710
+ buffer = buffer.slice(headerEnd + 4);
711
+ }
712
+
713
+ if (expectedLength > 0 && buffer.length >= expectedLength) {
714
+ const body = buffer.slice(0, expectedLength);
715
+ buffer = buffer.slice(expectedLength);
716
+ expectedLength = -1;
717
+
718
+ let message;
719
+ try {
720
+ message = JSON.parse(body);
721
+ } catch {
722
+ return;
723
+ }
724
+
725
+ handleMessage(message)
726
+ .then((response) => sendResponse(response))
727
+ .catch((err) => {
728
+ if (message && message.id != null) {
729
+ sendResponse({
730
+ jsonrpc: '2.0',
731
+ id: message.id,
732
+ error: {
733
+ code: -32603,
734
+ message: `Internal error: ${err.message}`,
735
+ },
736
+ });
737
+ }
738
+ });
739
+
740
+ // Recurse to handle any remaining frames
741
+ handleContentLengthFrames();
742
+ }
743
+ }
744
+
745
+ process.stdin.on('end', () => {
746
+ process.exit(0);
747
+ });
748
+
749
+ process.stdin.on('error', () => {
750
+ process.exit(1);
751
+ });
752
+
753
+ // Handle process signals gracefully
754
+ process.on('SIGINT', () => process.exit(0));
755
+ process.on('SIGTERM', () => process.exit(0));
756
+
757
+ // Suppress unhandled rejections from crashing the server
758
+ process.on('unhandledRejection', () => {});
759
+ process.on('uncaughtException', () => {});
760
+
761
+ // Log to stderr (not stdout — stdout is reserved for MCP protocol)
762
+ process.stderr.write('[aimeter-mcp] Server started on stdio\n');
763
+ }
764
+
765
+ // ─── Entry Point ─────────────────────────────────────────────────────────────
766
+
767
+ // If run directly (not required as module), start the server
768
+ if (require.main === module) {
769
+ startMcpServer();
770
+ }
771
+
772
+ module.exports = { startMcpServer };
package/package.json CHANGED
@@ -1,11 +1,17 @@
1
1
  {
2
2
  "name": "getaimeter",
3
- "version": "0.8.1",
4
- "description": "Track AI coding costs across Claude, Cursor, Codex, and Gemini. Optimization recommendations that cut costs by 30%.",
3
+ "version": "0.10.0",
4
+ "description": "Track AI coding costs across Claude, Cursor, Codex, Copilot, and Gemini. MCP server, billing blocks, optimization recommendations.",
5
5
  "bin": {
6
- "aimeter": "cli.js"
6
+ "aimeter": "cli.js",
7
+ "aimeter-mcp": "mcp.js"
7
8
  },
8
9
  "main": "watcher.js",
10
+ "scripts": {
11
+ "test": "node --test test/parser.test.js",
12
+ "test:e2e": "node --test test/e2e.test.js",
13
+ "test:all": "node --test test/*.test.js"
14
+ },
9
15
  "engines": {
10
16
  "node": ">=18"
11
17
  },
@@ -25,10 +31,13 @@
25
31
  "optimization",
26
32
  "claude-code",
27
33
  "vscode",
34
+ "copilot",
35
+ "mcp",
28
36
  "multi-tool"
29
37
  ],
30
38
  "files": [
31
39
  "cli.js",
40
+ "mcp.js",
32
41
  "watcher.js",
33
42
  "reporter.js",
34
43
  "config.js",
@@ -40,6 +49,7 @@
40
49
  "tray-launcher.vbs",
41
50
  "icon.ico",
42
51
  "update-check.js",
52
+ "config-schema.json",
43
53
  "README.md"
44
54
  ],
45
55
  "repository": {
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,8 @@ function extractNewUsage(filePath) {
252
326
  thinkingTokens: deltaReasoning,
253
327
  cacheReadTokens: cachedTokens,
254
328
  cacheWriteTokens: 0,
329
+ conversationId: convMeta.conversationId,
330
+ projectPath: convMeta.projectPath,
255
331
  });
256
332
  continue;
257
333
  }
@@ -272,6 +348,8 @@ function extractNewUsage(filePath) {
272
348
  thinkingTokens: obj.reasoning_tokens || 0,
273
349
  cacheReadTokens: 0,
274
350
  cacheWriteTokens: 0,
351
+ conversationId: convMeta.conversationId,
352
+ projectPath: convMeta.projectPath,
275
353
  });
276
354
  continue;
277
355
  }
@@ -303,6 +381,8 @@ function extractNewUsage(filePath) {
303
381
  thinkingTokens: 0,
304
382
  cacheReadTokens: u.cacheReadTokens || 0,
305
383
  cacheWriteTokens: u.cacheWriteTokens || 0,
384
+ conversationId: convMeta.conversationId,
385
+ projectPath: convMeta.projectPath,
306
386
  });
307
387
  }
308
388
  continue;
@@ -326,6 +406,8 @@ function extractNewUsage(filePath) {
326
406
  thinkingTokens: um.thoughtsTokenCount || 0,
327
407
  cacheReadTokens: um.cachedContentTokenCount || 0,
328
408
  cacheWriteTokens: 0,
409
+ conversationId: convMeta.conversationId,
410
+ projectPath: convMeta.projectPath,
329
411
  });
330
412
  continue;
331
413
  }
@@ -384,6 +466,8 @@ function extractNewUsage(filePath) {
384
466
  thinkingTokens: estimatedThinkingTokens,
385
467
  cacheReadTokens: u.cache_read_input_tokens || 0,
386
468
  cacheWriteTokens: u.cache_creation_input_tokens || 0,
469
+ conversationId: convMeta.conversationId,
470
+ projectPath: convMeta.projectPath,
387
471
  });
388
472
  }
389
473
 
@@ -575,6 +659,8 @@ function extractCursorUsage(dbPath) {
575
659
  thinkingTokens: 0,
576
660
  cacheReadTokens: 0,
577
661
  cacheWriteTokens: 0,
662
+ conversationId: conv.composerId,
663
+ projectPath: null,
578
664
  });
579
665
  } catch {}
580
666
  }