agentlytics 0.1.8 → 0.1.10

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/README.md CHANGED
@@ -5,8 +5,8 @@
5
5
  <h1 align="center">Agentlytics</h1>
6
6
 
7
7
  <p align="center">
8
- <strong>Unified analytics for your AI coding agents</strong><br>
9
- <sub>Cursor · Windsurf · Claude Code · VS Code Copilot · Zed · Antigravity · OpenCode · Codex · Gemini CLI · Copilot CLI · Cursor Agent · Command Code</sub>
8
+ <strong>Your Cursor, Windsurf, Claude Code sessions — analyzed, unified, tracked.</strong><br>
9
+ <sub>One command to turn scattered AI conversations from <b>14 editors</b> into a unified analytics dashboard.<br>Sessions, costs, models, tools finally in one place. 100% local.</sub>
10
10
  </p>
11
11
 
12
12
  <p align="center">
@@ -22,57 +22,84 @@
22
22
 
23
23
  ---
24
24
 
25
- Agentlytics reads local chat history from every major AI coding assistant and presents a unified analytics dashboard in your browser. **No data ever leaves your machine.**
25
+ ## The Problem
26
26
 
27
- ## Quick Start
27
+ You switch between Cursor, Windsurf, Claude Code, VS Code Copilot, and more — each with its own siloed conversation history.
28
+
29
+ - ✗ Sessions scattered across editors, no unified view
30
+ - ✗ No idea how much you're spending on AI tokens
31
+ - ✗ Can't compare which editor is more effective
32
+ - ✗ Can't search across all your AI conversations
33
+ - ✗ No way to share session context with your team
34
+
35
+ ## The Solution
36
+
37
+ **One command. Full picture. All local.**
28
38
 
29
39
  ```bash
30
40
  npx agentlytics
31
41
  ```
32
42
 
33
- Opens at **http://localhost:4637**. Requires Node.js ≥ 20.19 or ≥ 22.12, macOS.
43
+ Opens at **http://localhost:4637**. Requires Node.js ≥ 20.19 or ≥ 22.12, macOS. No data ever leaves your machine.
44
+
45
+ ```
46
+ $ npx agentlytics
47
+
48
+ (● ●) [● ●] Agentlytics
49
+ {● ●} <● ●> Unified analytics for your AI coding agents
50
+
51
+ Looking for AI coding agents...
52
+ ✓ Cursor 498 sessions
53
+ ✓ Windsurf 20 sessions
54
+ ✓ Windsurf Next 56 sessions
55
+ ✓ Claude Code 6 sessions
56
+ ✓ VS Code 23 sessions
57
+ ✓ Zed 1 session
58
+ ✓ Codex 3 sessions
59
+ ✓ Gemini CLI 2 sessions
60
+ ...and 6 more
61
+
62
+ (● ●) [● ●] {● ●} <● ●> ✓ 691 analyzed, 360 cached (27.1s)
63
+ ✓ Dashboard ready at http://localhost:4637
64
+ ```
34
65
 
35
- To only build the cache database without starting the server:
66
+ To only build the cache without starting the server:
36
67
 
37
68
  ```bash
38
69
  npx agentlytics --collect
39
70
  ```
40
71
 
41
- For local development, run `npm run dev` from the repo root. That starts both the backend on `http://localhost:4637` and the Vite frontend on `http://localhost:5173`.
42
-
43
72
  ## Features
44
73
 
45
74
  - **Dashboard** — KPIs, activity heatmap, editor breakdown, coding streaks, token economy, peak hours, top models & tools
46
- - **Sessions** — Search, filter, full conversation viewer with syntax highlighting and diff views
47
- - **Projects** — Per-project analytics: sessions, messages, tokens, models, editor breakdown
48
- - **Deep Analysis** — Tool frequency, model distribution, token breakdown with drill-down
49
- - **Compare** — Side-by-side editor comparison with efficiency ratios
50
- - **Refetch** — One-click cache rebuild with live progress
51
- - **Relay** — Multi-user context sharing with MCP server for cross-team AI session querying
75
+ - **Sessions** — Search, filter, and read full conversations with syntax highlighting. Open any chat in a slide-over sidebar.
76
+ - **Costs** — Estimate your AI spend broken down by model, editor, project, and month. Spot your most expensive sessions.
77
+ - **Projects** — Per-project analytics: sessions, messages, tokens, models, editor breakdown, and drill-down detail views
78
+ - **Deep Analysis** — Tool frequency heatmaps, model distribution, token breakdown, and filterable drill-down analytics
79
+ - **Compare** — Side-by-side editor comparison with efficiency ratios, token usage, and session patterns
80
+ - **Relay** — Share AI session context across your team via MCP
52
81
 
53
82
  ## Supported Editors
54
83
 
55
- | Editor | ID | Msgs | Tools | Models | Tokens |
56
- |--------|----|:----:|:-----:|:------:|:------:|
57
- | **Cursor** | `cursor` | ✅ | ✅ | ⚠️ | ⚠️ |
58
- | **Windsurf** | `windsurf` | ✅ | ✅ | ✅ | ✅ |
59
- | **Windsurf Next** | `windsurf-next` | ✅ | ✅ | ✅ | ✅ |
60
- | **Antigravity** | `antigravity` | ✅ | ✅ | ✅ | ✅ |
61
- | **Claude Code** | `claude-code` | ✅ | ✅ | ✅ | ✅ |
62
- | **VS Code** | `vscode` | ✅ | ✅ | ✅ | ✅ |
63
- | **VS Code Insiders** | `vscode-insiders` | ✅ | ✅ | ✅ | ✅ |
64
- | **Zed** | `zed` | ✅ | ✅ | ✅ | ❌ |
65
- | **OpenCode** | `opencode` | ✅ | ✅ | ✅ | ✅ |
66
- | **Codex** | `codex` | ✅ | ✅ | ✅ | ✅ |
67
- | **Gemini CLI** | `gemini-cli` | ✅ | ✅ | ✅ | ✅ |
68
- | **Copilot CLI** | `copilot-cli` | ✅ | ✅ | ✅ | ✅ |
69
- | **Cursor Agent** | `cursor-agent` | ✅ | ❌ | ❌ | ❌ |
70
- | **Command Code** | `commandcode` | ✅ | ✅ | ❌ | ❌ |
84
+ | Editor | Msgs | Tools | Models | Tokens |
85
+ |--------|:----:|:-----:|:------:|:------:|
86
+ | **Cursor** | ✅ | ✅ | ⚠️ | ⚠️ |
87
+ | **Windsurf** | ✅ | ✅ | ✅ | ✅ |
88
+ | **Windsurf Next** | ✅ | ✅ | ✅ | ✅ |
89
+ | **Antigravity** | ✅ | ✅ | ✅ | ✅ |
90
+ | **Claude Code** | ✅ | ✅ | ✅ | ✅ |
91
+ | **VS Code** | ✅ | ✅ | ✅ | ✅ |
92
+ | **VS Code Insiders** | ✅ | ✅ | ✅ | ✅ |
93
+ | **Zed** | ✅ | ✅ | ✅ | ❌ |
94
+ | **OpenCode** | ✅ | ✅ | ✅ | ✅ |
95
+ | **Codex** | ✅ | ✅ | ✅ | ✅ |
96
+ | **Gemini CLI** | ✅ | ✅ | ✅ | ✅ |
97
+ | **Copilot CLI** | ✅ | ✅ | ✅ | ✅ |
98
+ | **Cursor Agent** | ✅ | ❌ | ❌ | ❌ |
99
+ | **Command Code** | ✅ | ✅ | ❌ | ❌ |
71
100
 
72
101
  > Windsurf, Windsurf Next, and Antigravity must be running during scan.
73
102
 
74
- Codex sessions are read from `${CODEX_HOME:-~/.codex}/sessions/**/*.jsonl`. Reasoning summaries may appear in transcripts when Codex records them in clear text, but encrypted reasoning content is not readable. Codex Desktop and CLI sessions are aggregated into one `codex` editor in analytics.
75
-
76
103
  ## Relay
77
104
 
78
105
  Relay enables multi-user context sharing across a team. One person starts a relay server, others join and share selected project sessions. An MCP server is exposed so AI clients can query across everyone's coding history.
package/cache.js CHANGED
@@ -2,13 +2,56 @@ const Database = require('better-sqlite3');
2
2
  const path = require('path');
3
3
  const os = require('os');
4
4
  const fs = require('fs');
5
- const { getAllChats, getMessages, findChat: findChatRaw, resetCaches } = require('./editors');
5
+ const { getAllChats, getMessages, resetCaches } = require('./editors');
6
6
  const { calculateCost, getModelPricing, normalizeModelName } = require('./pricing');
7
7
 
8
8
  const CACHE_DIR = path.join(os.homedir(), '.agentlytics');
9
9
  const CACHE_DB = path.join(CACHE_DIR, 'cache.db');
10
10
  const SCHEMA_VERSION = 5; // bump this when schema changes to auto-revalidate
11
11
 
12
+ /**
13
+ * Normalize a folder path for consistent storage/lookup.
14
+ * - Strips file:// prefix
15
+ * - On Windows: resolves real disk casing via fs.realpathSync.native(),
16
+ * falls back to uppercase drive letter + lowercase rest, trims trailing backslash,
17
+ * and converts backslashes to forward slashes.
18
+ * - On macOS/Linux: resolves symlinks via fs.realpathSync().
19
+ */
20
+ function normalizeFolder(folder) {
21
+ if (!folder) return folder;
22
+ // Strip file:// prefix
23
+ folder = folder.replace(/^file:\/\//, '');
24
+
25
+ if (process.platform === 'win32') {
26
+ try {
27
+ folder = path.resolve(folder);
28
+ try {
29
+ folder = fs.realpathSync.native(folder);
30
+ } catch {
31
+ // realpathSync.native failed — uppercase drive letter, lowercase rest
32
+ if (/^[a-zA-Z]:/.test(folder)) {
33
+ folder = folder[0].toUpperCase() + folder.slice(1);
34
+ }
35
+ }
36
+ // Remove trailing backslash (but keep "C:\")
37
+ folder = folder.replace(/\\$/, '');
38
+ if (/^[A-Z]:$/.test(folder)) folder += '\\';
39
+ // Convert backslashes to forward slashes
40
+ folder = folder.replace(/\\/g, '/');
41
+ } catch {
42
+ // If all else fails, just return as-is with forward slashes
43
+ folder = folder.replace(/\\/g, '/');
44
+ }
45
+ } else {
46
+ try {
47
+ folder = fs.realpathSync(folder);
48
+ } catch {
49
+ // Path doesn't exist, return as-is
50
+ }
51
+ }
52
+ return folder;
53
+ }
54
+
12
55
  let db = null;
13
56
 
14
57
  // ============================================================
@@ -113,6 +156,30 @@ function initDb() {
113
156
 
114
157
  // Store schema version so future runs can detect mismatches
115
158
  db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run('schema_version', SCHEMA_VERSION.toString());
159
+
160
+ // v2 migration: normalize folder paths on Windows
161
+ if (process.platform === 'win32') {
162
+ let normV = 0;
163
+ try {
164
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'folder_norm_v'").get();
165
+ if (row) normV = parseInt(row.value) || 0;
166
+ } catch {}
167
+ if (normV < 2) {
168
+ const chatRows = db.prepare('SELECT id, folder FROM chats WHERE folder IS NOT NULL').all();
169
+ const updChat = db.prepare('UPDATE chats SET folder = ? WHERE id = ?');
170
+ for (const r of chatRows) {
171
+ const norm = normalizeFolder(r.folder);
172
+ if (norm !== r.folder) updChat.run(norm, r.id);
173
+ }
174
+ const tcRows = db.prepare('SELECT id, folder FROM tool_calls WHERE folder IS NOT NULL').all();
175
+ const updTc = db.prepare('UPDATE tool_calls SET folder = ? WHERE id = ?');
176
+ for (const r of tcRows) {
177
+ const norm = normalizeFolder(r.folder);
178
+ if (norm !== r.folder) updTc.run(norm, r.id);
179
+ }
180
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('folder_norm_v', '2')").run();
181
+ }
182
+ }
116
183
  }
117
184
 
118
185
  // ============================================================
@@ -245,6 +312,9 @@ function scanAll(onProgress, opts = {}) {
245
312
  }
246
313
  });
247
314
 
315
+ // Normalize folder paths
316
+ for (const chat of chats) chat.folder = normalizeFolder(chat.folder);
317
+
248
318
  // Insert all chats in a transaction
249
319
  batchInsert(chats);
250
320
 
@@ -401,7 +471,7 @@ function getCachedOverview(opts = {}) {
401
471
  GROUP BY folder ORDER BY count DESC LIMIT 20
402
472
  `).all(...params);
403
473
  const topProjects = projects.map(p => ({
404
- name: p.folder.split('/').slice(-2).join('/'),
474
+ name: p.folder.split(/[/\\]/).slice(-2).join('/'),
405
475
  fullPath: p.folder,
406
476
  count: p.count,
407
477
  }));
@@ -644,7 +714,7 @@ function getCachedProjects(opts = {}) {
644
714
 
645
715
  result.push({
646
716
  folder: proj.folder,
647
- name: proj.folder.split('/').pop(),
717
+ name: proj.folder.split(/[/\\]/).pop(),
648
718
  totalSessions: proj.totalSessions,
649
719
  editors: proj.editors,
650
720
  firstSeen: proj.firstSeen,
@@ -694,16 +764,6 @@ function safeParseJson(s) {
694
764
  try { return JSON.parse(s); } catch { return {}; }
695
765
  }
696
766
 
697
- function resetAndRescan(onProgress) {
698
- if (db) db.close();
699
- if (fs.existsSync(CACHE_DB)) fs.unlinkSync(CACHE_DB);
700
- for (const suffix of ['-wal', '-shm']) {
701
- if (fs.existsSync(CACHE_DB + suffix)) fs.unlinkSync(CACHE_DB + suffix);
702
- }
703
- initDb();
704
- return scanAll(onProgress);
705
- }
706
-
707
767
  /**
708
768
  * Async version of scanAll that yields the event loop between iterations.
709
769
  * Required for SSE streaming so progress events actually flush to the client.
@@ -720,6 +780,9 @@ async function scanAllAsync(onProgress) {
720
780
  existing[row.id] = row.last_updated_at;
721
781
  }
722
782
 
783
+ // Normalize folder paths
784
+ for (const chat of chats) chat.folder = normalizeFolder(chat.folder);
785
+
723
786
  const ins = insertChat();
724
787
  const batchInsert = db.transaction((chatBatch) => {
725
788
  for (const chat of chatBatch) {
@@ -1316,7 +1379,6 @@ module.exports = {
1316
1379
  getCachedChat,
1317
1380
  getCachedProjects,
1318
1381
  getCachedToolCalls,
1319
- resetAndRescan,
1320
1382
  resetAndRescanAsync,
1321
1383
  getCachedDashboardStats,
1322
1384
  getCostBreakdown,
package/editors/base.js CHANGED
@@ -1,6 +1,5 @@
1
1
  const path = require('path');
2
2
  const os = require('os');
3
- const chalk = require('chalk');
4
3
 
5
4
  const HOME = os.homedir();
6
5
 
@@ -23,112 +22,6 @@ function getAppDataPath(appName) {
23
22
  }
24
23
  }
25
24
 
26
- // --- Formatting utilities shared across all editor adapters ---
27
-
28
- function formatArgs(args, maxLen = 300) {
29
- if (!args || typeof args !== 'object') return '';
30
- const lines = [];
31
- for (const [key, val] of Object.entries(args)) {
32
- let display = typeof val === 'string' ? val : JSON.stringify(val);
33
- if (display && display.length > maxLen) {
34
- display = display.substring(0, maxLen) + chalk.dim(`… (${display.length} chars)`);
35
- }
36
- lines.push(` ${chalk.dim(key + ':')} ${display}`);
37
- }
38
- return lines.join('\n');
39
- }
40
-
41
- function formatToolCall(item) {
42
- const name = item.toolName || 'unknown';
43
- const id = item.toolCallId || '';
44
- const decision = item.userDecision;
45
- const decisionStr = decision === 'accepted' ? chalk.green(' ✓accepted')
46
- : decision === 'rejected' ? chalk.red(' ✗rejected')
47
- : decision ? chalk.yellow(` ${decision}`) : '';
48
- let out = ` ${chalk.magenta('▶')} ${chalk.bold.magenta(name)}${decisionStr} ${chalk.dim(id)}`;
49
- if (item.args && Object.keys(item.args).length > 0) {
50
- out += '\n' + formatArgs(item.args);
51
- }
52
- return out;
53
- }
54
-
55
- function formatToolResult(item) {
56
- const name = item.toolName || 'unknown';
57
- const result = typeof item.result === 'string' ? item.result : JSON.stringify(item.result || '');
58
- const maxPreview = 500;
59
- const preview = result.length > maxPreview
60
- ? result.substring(0, maxPreview) + chalk.dim(`… (${result.length} chars)`)
61
- : result;
62
- const status = result.startsWith('Rejected') ? chalk.red('✗ rejected') : chalk.green('✓ ok');
63
- let out = ` ${chalk.yellow('◀')} ${chalk.bold.yellow(name)} ${status}`;
64
- if (preview.trim()) {
65
- out += '\n ' + chalk.dim(preview.replace(/\n/g, '\n '));
66
- }
67
- return out;
68
- }
69
-
70
- function extractText(content, { richToolDisplay = false } = {}) {
71
- if (typeof content === 'string') return content;
72
- if (!Array.isArray(content)) return '';
73
- return content
74
- .map((item) => {
75
- if (item.type === 'text') return item.text;
76
- if (item.type === 'reasoning') return `[thinking] ${item.text}`;
77
- if (item.type === 'tool-call') {
78
- return richToolDisplay
79
- ? formatToolCall(item)
80
- : `[tool-call: ${item.toolName || 'unknown'}(${Object.keys(item.args || {}).join(', ')})]`;
81
- }
82
- if (item.type === 'tool-result') {
83
- return richToolDisplay
84
- ? formatToolResult(item)
85
- : `[tool-result: ${item.toolName || 'unknown'}] ${(typeof item.result === 'string' ? item.result : '').substring(0, 200)}`;
86
- }
87
- return '';
88
- })
89
- .filter(Boolean)
90
- .join('\n');
91
- }
92
-
93
- function roleColor(role) {
94
- switch (role) {
95
- case 'user': return chalk.green;
96
- case 'assistant': return chalk.cyan;
97
- case 'system': return chalk.gray;
98
- case 'tool': return chalk.yellow;
99
- default: return chalk.white;
100
- }
101
- }
102
-
103
- function roleLabel(role) {
104
- switch (role) {
105
- case 'user': return '👤 User';
106
- case 'assistant': return '🤖 Assistant';
107
- case 'system': return '⚙️ System';
108
- case 'tool': return '🔧 Tool';
109
- default: return role;
110
- }
111
- }
112
-
113
- function formatDate(ts) {
114
- if (!ts) return 'unknown';
115
- return new Date(ts).toLocaleString();
116
- }
117
-
118
- function truncate(str, max = 120) {
119
- if (!str) return '';
120
- const oneLine = str.replace(/\n/g, ' ').trim();
121
- return oneLine.length > max ? oneLine.substring(0, max) + '…' : oneLine;
122
- }
123
-
124
- function shortenPath(p, maxLen = 40) {
125
- if (!p) return '';
126
- if (p.length <= maxLen) return p;
127
- const parts = p.split('/');
128
- if (parts.length <= 3) return p;
129
- return '…/' + parts.slice(-2).join('/');
130
- }
131
-
132
25
  /**
133
26
  * Every editor adapter must implement:
134
27
  *
@@ -141,13 +34,4 @@ function shortenPath(p, maxLen = 40) {
141
34
 
142
35
  module.exports = {
143
36
  getAppDataPath,
144
- formatArgs,
145
- formatToolCall,
146
- formatToolResult,
147
- extractText,
148
- roleColor,
149
- roleLabel,
150
- formatDate,
151
- truncate,
152
- shortenPath,
153
37
  };
package/editors/codex.js CHANGED
@@ -439,15 +439,4 @@ module.exports = {
439
439
  name,
440
440
  getChats,
441
441
  getMessages,
442
- _test: {
443
- getSessionsDir,
444
- readChatMetadata,
445
- parseSessionMessages,
446
- normalizeRawUsage,
447
- subtractRawUsage,
448
- convertToDelta,
449
- extractModel,
450
- isBootstrapMessage,
451
- previewToolOutput,
452
- },
453
442
  };
package/editors/index.js CHANGED
@@ -46,18 +46,10 @@ function getMessages(chat) {
46
46
  return resolvedEditor.getMessages(chat);
47
47
  }
48
48
 
49
- /**
50
- * Find a chat by ID prefix across all editors.
51
- */
52
- function findChat(idPrefix) {
53
- const chats = getAllChats();
54
- return chats.find((c) => c.composerId.startsWith(idPrefix));
55
- }
56
-
57
49
  function resetCaches() {
58
50
  for (const editor of editors) {
59
51
  if (typeof editor.resetCache === 'function') editor.resetCache();
60
52
  }
61
53
  }
62
54
 
63
- module.exports = { getAllChats, getMessages, findChat, editors, resetCaches };
55
+ module.exports = { getAllChats, getMessages, editors, resetCaches };
@@ -14,7 +14,7 @@ function queryDb(sql) {
14
14
  try {
15
15
  const raw = execSync(
16
16
  `sqlite3 -json ${JSON.stringify(DB_PATH)} ${JSON.stringify(sql)}`,
17
- { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }
17
+ { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }
18
18
  );
19
19
  return JSON.parse(raw);
20
20
  } catch { return []; }
@@ -23,7 +23,7 @@ function findLanguageServers() {
23
23
  if (_lsCache) return _lsCache;
24
24
  _lsCache = [];
25
25
  try {
26
- const ps = execSync('ps aux', { encoding: 'utf-8', maxBuffer: 1024 * 1024 });
26
+ const ps = execSync('ps aux', { encoding: 'utf-8', maxBuffer: 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
27
27
  for (const line of ps.split('\n')) {
28
28
  if (!line.includes('language_server_macos') || !line.includes('--csrf_token')) continue;
29
29
  const csrfMatch = line.match(/--csrf_token\s+(\S+)/);
@@ -38,7 +38,7 @@ function findLanguageServers() {
38
38
  if (!pidMatch) continue;
39
39
  const pid = pidMatch[1];
40
40
  try {
41
- const lsof = execSync(`lsof -i TCP -P -n -a -p ${pid} 2>/dev/null`, { encoding: 'utf-8' });
41
+ const lsof = execSync(`lsof -i TCP -P -n -a -p ${pid} 2>/dev/null`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
42
42
  for (const l of lsof.split('\n')) {
43
43
  const portMatch = l.match(/TCP\s+127\.0\.0\.1:(\d+)\s+\(LISTEN\)/);
44
44
  if (portMatch) {
@@ -79,7 +79,7 @@ function callRpc(port, csrf, method, body, useHttps) {
79
79
  `-H "x-codeium-csrf-token: ${csrf}" ` +
80
80
  `-d ${JSON.stringify(data)} ` +
81
81
  `--max-time 10`,
82
- { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 }
82
+ { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }
83
83
  );
84
84
  return JSON.parse(result);
85
85
  } catch { return null; }
package/editors/zed.js CHANGED
@@ -15,7 +15,7 @@ function decompressZstd(buf) {
15
15
  const tmpOut = tmpIn.replace('.zst', '.json');
16
16
  try {
17
17
  fs.writeFileSync(tmpIn, buf);
18
- execSync(`zstd -d -f -q ${JSON.stringify(tmpIn)} -o ${JSON.stringify(tmpOut)}`, { stdio: 'pipe' });
18
+ execSync(`zstd -d -f -q ${JSON.stringify(tmpIn)} -o ${JSON.stringify(tmpOut)}`, { stdio: ['pipe', 'pipe', 'pipe'] });
19
19
  const data = fs.readFileSync(tmpOut, 'utf-8');
20
20
  return data;
21
21
  } finally {
@@ -33,7 +33,7 @@ function queryDb(sql) {
33
33
  try {
34
34
  const raw = execSync(
35
35
  `sqlite3 -json ${JSON.stringify(THREADS_DB)} ${JSON.stringify(sql)}`,
36
- { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }
36
+ { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }
37
37
  );
38
38
  return JSON.parse(raw);
39
39
  } catch { return []; }
@@ -44,7 +44,7 @@ function queryBlobHex(id) {
44
44
  try {
45
45
  const hex = execSync(
46
46
  `sqlite3 ${JSON.stringify(THREADS_DB)} "SELECT hex(data) FROM threads WHERE id = '${id}'"`,
47
- { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 }
47
+ { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }
48
48
  ).trim();
49
49
  if (!hex) return null;
50
50
  return Buffer.from(hex, 'hex');
package/index.js CHANGED
@@ -174,7 +174,8 @@ if (noCache) {
174
174
  }
175
175
  }
176
176
 
177
- // ── Warn about installed-but-not-running Windsurf variants ─
177
+ // ── Warn about installed-but-not-running Windsurf variants (macOS only)
178
+ if (process.platform === 'darwin') {
178
179
  const WINDSURF_VARIANTS = [
179
180
  { name: 'Windsurf', app: '/Applications/Windsurf.app', dataDir: path.join(HOME, '.codeium', 'windsurf'), ide: 'windsurf' },
180
181
  { name: 'Windsurf Next', app: '/Applications/Windsurf Next.app', dataDir: path.join(HOME, '.codeium', 'windsurf-next'), ide: 'windsurf-next' },
@@ -208,6 +209,7 @@ const WINDSURF_VARIANTS = [
208
209
  console.log('');
209
210
  }
210
211
  })();
212
+ }
211
213
 
212
214
  // Initialize cache DB
213
215
  cache.initDb();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlytics",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravity, OpenCode, Command Code",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -14,6 +14,8 @@
14
14
  "relay-server.js",
15
15
  "relay-client.js",
16
16
  "mcp-server.js",
17
+ "pricing.js",
18
+ "pricing.json",
17
19
  "editors/",
18
20
  "ui/src/",
19
21
  "ui/index.html",
@@ -52,9 +54,7 @@
52
54
  "@modelcontextprotocol/sdk": "^1.27.1",
53
55
  "better-sqlite3": "^12.6.2",
54
56
  "chalk": "^4.1.2",
55
- "commander": "^14.0.3",
56
57
  "express": "^4.22.1",
57
- "inquirer": "^13.3.0",
58
58
  "log-update": "^4.0.0",
59
59
  "open": "^8.4.2"
60
60
  }
package/pricing.js ADDED
@@ -0,0 +1,88 @@
1
+ // Load pricing data from JSON – edit pricing.json to add/update models
2
+ const _raw = require('./pricing.json');
3
+ const MODEL_PRICING = Object.fromEntries(
4
+ Object.entries(_raw).filter(([k]) => !k.startsWith('_'))
5
+ );
6
+
7
+ // Normalize a model identifier to match pricing keys
8
+ // Handles versioned names like "claude-sonnet-4-20250514", "gpt-4o-2024-08-06", etc.
9
+ function normalizeModelName(name) {
10
+ if (!name) return null;
11
+ let n = name.toLowerCase().trim();
12
+
13
+ // Strip leading provider prefixes (e.g. "anthropic/claude-..." or "openai/gpt-...")
14
+ const slashIdx = n.lastIndexOf('/');
15
+ if (slashIdx !== -1) n = n.substring(slashIdx + 1);
16
+
17
+ // Strip dot-delimited provider prefixes (e.g. "us.anthropic.claude-sonnet-4-6")
18
+ // Only strip if all prefix segments are simple words (no dashes), to avoid
19
+ // splitting version dots like "claude-4.6-opus"
20
+ const dotParts = n.split('.');
21
+ if (dotParts.length > 1) {
22
+ const prefixes = dotParts.slice(0, -1);
23
+ const last = dotParts[dotParts.length - 1];
24
+ if (last.includes('-') && prefixes.every(p => !p.includes('-'))) n = last;
25
+ }
26
+
27
+ // Handle MODEL_CLAUDE_* / MODEL_GPT_* enum constants
28
+ if (n.startsWith('model_')) {
29
+ n = n.substring(6).replace(/_/g, '-');
30
+ }
31
+
32
+ // Build candidate list: original + dots→dashes + reversed claude names
33
+ const candidates = [n];
34
+ if (n.includes('.')) candidates.push(n.replace(/\./g, '-'));
35
+
36
+ // Rearrange reversed claude names: "claude-4-6-opus-..." → "claude-opus-4-6"
37
+ // Run on all candidates so dots→dashes variant is also checked
38
+ for (const c of [...candidates]) {
39
+ const rev = c.match(/^(claude)-(\d+)-(\d+)-(opus|sonnet|haiku)/);
40
+ if (rev) candidates.push(`${rev[1]}-${rev[4]}-${rev[2]}-${rev[3]}`);
41
+ }
42
+
43
+ // Pass 1: exact and precise matches across ALL candidates first
44
+ for (const c of candidates) {
45
+ if (MODEL_PRICING[c]) return c;
46
+ }
47
+ for (const c of candidates) {
48
+ const withoutDate = c.replace(/-\d{4}-?\d{2}-?\d{2}$/, '');
49
+ if (MODEL_PRICING[withoutDate]) return withoutDate;
50
+ const withoutTag = c.replace(/:(latest|thinking)$/, '');
51
+ if (MODEL_PRICING[withoutTag]) return withoutTag;
52
+ const withoutQual = c.replace(/-(thinking|high|xhigh|preview|latest)(-thinking|-high|-xhigh|-preview)*/g, '');
53
+ if (withoutQual !== c && MODEL_PRICING[withoutQual]) return withoutQual;
54
+ }
55
+
56
+ // Pass 2: fuzzy startsWith (longest key match wins)
57
+ const keys = Object.keys(MODEL_PRICING);
58
+ for (const c of candidates) {
59
+ let best = null;
60
+ for (const key of keys) {
61
+ if (c.startsWith(key) && (!best || key.length > best.length)) best = key;
62
+ }
63
+ if (best) return best;
64
+ }
65
+
66
+ return null;
67
+ }
68
+
69
+ function getModelPricing(modelName) {
70
+ const key = normalizeModelName(modelName);
71
+ return key ? MODEL_PRICING[key] : null;
72
+ }
73
+
74
+ // Calculate cost for a set of token counts and a model
75
+ // Returns cost in USD or null if model is unknown
76
+ function calculateCost(modelName, inputTokens, outputTokens, cacheRead, cacheWrite) {
77
+ const pricing = getModelPricing(modelName);
78
+ if (!pricing) return null;
79
+
80
+ const input = ((inputTokens || 0) / 1_000_000) * pricing.input;
81
+ const output = ((outputTokens || 0) / 1_000_000) * pricing.output;
82
+ const cr = ((cacheRead || 0) / 1_000_000) * pricing.cacheRead;
83
+ const cw = ((cacheWrite || 0) / 1_000_000) * pricing.cacheWrite;
84
+
85
+ return input + output + cr + cw;
86
+ }
87
+
88
+ module.exports = { MODEL_PRICING, normalizeModelName, getModelPricing, calculateCost };