agentlytics 0.2.8 → 0.2.9

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
@@ -14,6 +14,7 @@
14
14
  <a href="#supported-editors"><img src="https://img.shields.io/badge/editors-16-818cf8" alt="editors"></a>
15
15
  <a href="#license"><img src="https://img.shields.io/badge/license-MIT-green" alt="license"></a>
16
16
  <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%E2%89%A520.19%20%7C%20%E2%89%A522.12-brightgreen" alt="node"></a>
17
+ <a href="https://deno.land"><img src="https://img.shields.io/badge/deno-%E2%89%A52.0-000?logo=deno" alt="deno"></a>
17
18
  </p>
18
19
 
19
20
  <p align="center">
@@ -39,10 +40,61 @@ You switch between Cursor, Windsurf, Claude Code, VS Code Copilot, and more —
39
40
 
40
41
  ```bash
41
42
  npx agentlytics
43
+ # or
44
+ pnpm dlx agentlytics
45
+ # or
46
+ yarn dlx agentlytics
47
+ # or
48
+ bunx agentlytics
42
49
  ```
43
50
 
44
51
  Opens at **http://localhost:4637**. Requires Node.js ≥ 20.19 or ≥ 22.12, macOS. No data ever leaves your machine.
45
52
 
53
+ ### Deno (Sandboxed)
54
+
55
+ Run a lightweight, zero-dependency analytics scan with Deno's permission sandbox — directly from a URL, no install needed:
56
+
57
+ ```bash
58
+ deno run --allow-read --allow-env https://raw.githubusercontent.com/f/agentlytics/master/mod.ts
59
+ ```
60
+
61
+ Only `--allow-read` and `--allow-env` are required. No network access, no file writes, no code execution — just reads your local editor data and prints a summary.
62
+
63
+ ```
64
+ (● ●) [● ●] Agentlytics — Deno Sandboxed Edition
65
+ {● ●} <● ●> Lightweight CLI analytics for AI coding agents
66
+
67
+ ✓ Claude Code 8 sessions
68
+ ✓ VS Code 23 sessions
69
+ ✓ VS Code Insiders 66 sessions
70
+ ● Cursor detected
71
+ ✓ Codex CLI 3 sessions
72
+ ...
73
+
74
+ Summary
75
+ Sessions 109
76
+ Messages 459
77
+ Projects 18
78
+ Editors 7 of 15 checked
79
+ Date range 2025-04-02 → 2026-03-09
80
+ ```
81
+
82
+ Add `--json` for machine-readable output:
83
+
84
+ ```bash
85
+ deno run --allow-read --allow-env mod.ts --json
86
+ ```
87
+
88
+ If you've cloned the repo, you can also use Deno tasks for the full dashboard:
89
+
90
+ ```bash
91
+ deno task start # Full dashboard (all permissions)
92
+ deno task scan # Lightweight CLI scan
93
+ deno task scan:json # JSON output
94
+ ```
95
+
96
+ ### Node.js
97
+
46
98
  ```
47
99
  $ npx agentlytics
48
100
 
@@ -68,6 +120,7 @@ To only build the cache without starting the server:
68
120
 
69
121
  ```bash
70
122
  npx agentlytics --collect
123
+ # or: pnpm dlx agentlytics --collect
71
124
  ```
72
125
 
73
126
  ## Features
@@ -112,6 +165,7 @@ Relay enables multi-user context sharing across a team. One person starts a rela
112
165
 
113
166
  ```bash
114
167
  npx agentlytics --relay
168
+ # or: pnpm dlx agentlytics --relay
115
169
  ```
116
170
 
117
171
  Optionally protect with a password:
@@ -138,6 +192,7 @@ This starts a relay server on port `4638` and prints the join command and MCP en
138
192
  ```bash
139
193
  cd /path/to/your-project
140
194
  npx agentlytics --join <host:port>
195
+ # or: pnpm dlx agentlytics --join <host:port>
141
196
  ```
142
197
 
143
198
  If the relay is password-protected:
@@ -186,7 +241,11 @@ Editor files/APIs → editors/*.js → cache.js (SQLite) → server.js (REST)
186
241
  Relay: join clients → POST /relay/sync → relay.db (SQLite) → MCP server → AI clients
187
242
  ```
188
243
 
189
- All data is normalized into a local SQLite cache at `~/.agentlytics/cache.db`. The Express server exposes read-only REST endpoints consumed by the React frontend. Relay data is stored separately in `~/.agentlytics/relay.db`.
244
+ ```
245
+ Deno: Editor files → mod.ts (zero deps) → stdout (CLI/JSON)
246
+ ```
247
+
248
+ All data is normalized into a local SQLite cache at `~/.agentlytics/cache.db`. The Express server exposes read-only REST endpoints consumed by the React frontend. Relay data is stored separately in `~/.agentlytics/relay.db`. The Deno sandboxed edition (`mod.ts`) bypasses SQLite entirely and reads editor files directly for a lightweight, permission-minimal CLI report.
190
249
 
191
250
  ## API
192
251
 
package/deno.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "tasks": {
3
+ "start": "deno run --allow-read --allow-write --allow-net --allow-env --allow-ffi --allow-run index.js",
4
+ "collect": "deno run --allow-read --allow-write --allow-env --allow-ffi index.js --collect",
5
+ "scan": "deno run --allow-read --allow-env mod.ts",
6
+ "scan:json": "deno run --allow-read --allow-env mod.ts --json"
7
+ },
8
+ "nodeModulesDir": "auto"
9
+ }
package/index.js CHANGED
@@ -6,11 +6,44 @@ const path = require('path');
6
6
  const os = require('os');
7
7
  const { execSync } = require('child_process');
8
8
 
9
+ // ── Node.js version check ─────────────────────────────────
10
+ const nodeVersion = process.versions.node;
11
+ const [nodeMajor, nodeMinor] = nodeVersion.split('.').map(Number);
12
+ const isNodeSupported =
13
+ (nodeMajor === 20 && nodeMinor >= 19) ||
14
+ (nodeMajor === 22 && nodeMinor >= 12) ||
15
+ nodeMajor === 23 || nodeMajor === 24 || nodeMajor >= 25;
16
+
17
+ if (!isNodeSupported) {
18
+ console.error('');
19
+ console.error(` \x1b[31m✗ Unsupported Node.js version: v${nodeVersion}\x1b[0m`);
20
+ console.error('');
21
+ console.error(` \x1b[1mAgentlytics requires Node.js 20.19+ or 22.12+\x1b[0m`);
22
+ console.error(` \x1b[2mYour current version: v${nodeVersion}\x1b[0m`);
23
+ console.error('');
24
+ console.error(` \x1b[2mTo upgrade, visit: https://nodejs.org\x1b[0m`);
25
+ console.error(` \x1b[2mOr use a version manager: nvm install 22\x1b[0m`);
26
+ console.error('');
27
+ process.exit(1);
28
+ }
29
+
9
30
  const HOME = os.homedir();
31
+
32
+ // ── Detect package manager ─────────────────────────────────
33
+ function detectPackageManager() {
34
+ const ua = process.env.npm_config_user_agent || '';
35
+ if (ua.startsWith('pnpm/')) return 'pnpm';
36
+ if (ua.startsWith('yarn/')) return 'yarn';
37
+ if (ua.startsWith('bun/')) return 'bun';
38
+ return 'npm';
39
+ }
40
+ const PM = detectPackageManager();
41
+ const PM_RUN = PM === 'npm' ? 'npx' : PM === 'pnpm' ? 'pnpm dlx' : PM === 'yarn' ? 'yarn dlx' : 'bunx';
10
42
  const PORT = process.env.PORT || 4637;
11
43
  const RELAY_PORT = process.env.RELAY_PORT || 4638;
12
44
  const noCache = process.argv.includes('--no-cache');
13
45
  const collectOnly = process.argv.includes('--collect');
46
+ const isUiDev = process.argv.includes('--ui-dev');
14
47
  const isRelay = process.argv.includes('--relay');
15
48
  const joinIndex = process.argv.indexOf('--join');
16
49
  const isJoin = joinIndex !== -1;
@@ -47,7 +80,7 @@ if (isRelay) {
47
80
  console.log('');
48
81
  console.log(chalk.bold(' Share this command with your team:'));
49
82
  console.log('');
50
- console.log(chalk.cyan(` npx agentlytics --join ${localIp}:${RELAY_PORT} --username <name>`));
83
+ console.log(chalk.cyan(` ${PM_RUN} agentlytics --join ${localIp}:${RELAY_PORT} --username <name>`));
51
84
  console.log('');
52
85
  console.log(chalk.bold(' MCP server endpoint (add to your AI client):'));
53
86
  console.log('');
@@ -76,7 +109,7 @@ if (isJoin) {
76
109
  let username = usernameIndex !== -1 ? process.argv[usernameIndex + 1] : null;
77
110
 
78
111
  if (!relayAddress) {
79
- console.error(chalk.red('\n ✗ Missing relay address. Usage: npx agentlytics --join <host:port> --username <name>\n'));
112
+ console.error(chalk.red(`\n ✗ Missing relay address. Usage: ${PM_RUN} agentlytics --join <host:port> --username <name>\n`));
80
113
  process.exit(1);
81
114
  }
82
115
 
@@ -135,15 +168,20 @@ console.log('');
135
168
  const publicIndex = path.join(__dirname, 'public', 'index.html');
136
169
  const uiDir = path.join(__dirname, 'ui');
137
170
 
138
- if (!collectOnly && !fs.existsSync(publicIndex) && fs.existsSync(uiDir)) {
171
+ if (!collectOnly && !isUiDev && !fs.existsSync(publicIndex) && fs.existsSync(uiDir)) {
139
172
  console.log(chalk.cyan(' ⟳ Building dashboard UI (first run)...'));
140
173
  try {
141
174
  const uiModules = path.join(uiDir, 'node_modules');
142
175
  if (fs.existsSync(uiModules)) fs.rmSync(uiModules, { recursive: true, force: true });
143
176
  console.log(chalk.dim(' Installing UI dependencies...'));
144
- execSync('npm install --no-audit --no-fund', { cwd: uiDir, stdio: 'pipe' });
177
+ const installCmd = PM === 'npm' ? 'npm install --no-audit --no-fund'
178
+ : PM === 'pnpm' ? 'pnpm install --no-frozen-lockfile'
179
+ : PM === 'yarn' ? 'yarn install'
180
+ : 'bun install';
181
+ const buildCmd = `${PM} run build`;
182
+ execSync(installCmd, { cwd: uiDir, stdio: 'pipe' });
145
183
  console.log(chalk.dim(' Compiling frontend...'));
146
- execSync('npm run build', { cwd: uiDir, stdio: 'pipe' });
184
+ execSync(buildCmd, { cwd: uiDir, stdio: 'pipe' });
147
185
  console.log(chalk.green(' ✓ UI built successfully'));
148
186
  } catch (err) {
149
187
  console.error(chalk.red(' ✗ UI build failed:'), err.message);
@@ -152,9 +190,9 @@ if (!collectOnly && !fs.existsSync(publicIndex) && fs.existsSync(uiDir)) {
152
190
  console.log('');
153
191
  }
154
192
 
155
- if (!collectOnly && !fs.existsSync(publicIndex)) {
193
+ if (!collectOnly && !isUiDev && !fs.existsSync(publicIndex)) {
156
194
  console.error(chalk.red(' ✗ No built UI found at public/index.html'));
157
- console.error(chalk.dim(' Run: cd ui && npm install && npm run build'));
195
+ console.error(chalk.dim(` Run: cd ui && ${PM} install && ${PM} run build`));
158
196
  process.exit(1);
159
197
  }
160
198
 
@@ -365,12 +403,12 @@ const BOT_STYLES = [
365
403
  }
366
404
 
367
405
  app.listen(port, '0.0.0.0', () => {
406
+ if (isUiDev) return;
368
407
  const url = `http://localhost:${port}`;
369
408
  console.log(chalk.green(` ✓ Dashboard ready at ${chalk.bold.white(url)}`));
370
409
  console.log('');
371
410
  console.log(chalk.dim(' Press Ctrl+C to stop\n'));
372
411
 
373
- // Auto-open browser
374
412
  const open = require('open');
375
413
  open(url).catch(() => {});
376
414
  });
package/mod.ts ADDED
@@ -0,0 +1,1020 @@
1
+ #!/usr/bin/env -S deno run --allow-read --allow-env
2
+ // ============================================================
3
+ // Agentlytics — Deno Sandboxed Edition
4
+ // Lightweight CLI analytics for AI coding agents
5
+ //
6
+ // Usage:
7
+ // deno run --allow-read --allow-env https://raw.githubusercontent.com/f/agentlytics/master/mod.ts
8
+ // deno run --allow-read --allow-env mod.ts
9
+ // deno run --allow-read --allow-env mod.ts --json
10
+ // ============================================================
11
+
12
+ // ── ANSI helpers (zero dependencies) ─────────────────────────
13
+
14
+ const noColor = Deno.env.get("NO_COLOR") !== undefined;
15
+ const bold = (s: string) => noColor ? s : `\x1b[1m${s}\x1b[0m`;
16
+ const dim = (s: string) => noColor ? s : `\x1b[2m${s}\x1b[0m`;
17
+ const green = (s: string) => noColor ? s : `\x1b[32m${s}\x1b[0m`;
18
+ const yellow = (s: string) => noColor ? s : `\x1b[33m${s}\x1b[0m`;
19
+ const cyan = (s: string) => noColor ? s : `\x1b[36m${s}\x1b[0m`;
20
+ const red = (s: string) => noColor ? s : `\x1b[31m${s}\x1b[0m`;
21
+ const hex = (color: string) => {
22
+ if (noColor) return (s: string) => s;
23
+ const r = parseInt(color.slice(1, 3), 16);
24
+ const g = parseInt(color.slice(3, 5), 16);
25
+ const b = parseInt(color.slice(5, 7), 16);
26
+ return (s: string) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[0m`;
27
+ };
28
+
29
+ // ── Platform detection ───────────────────────────────────────
30
+
31
+ const HOME = Deno.env.get("HOME") || Deno.env.get("USERPROFILE") || "";
32
+ const PLATFORM = Deno.build.os; // "darwin", "windows", "linux"
33
+
34
+ function join(...parts: string[]): string {
35
+ const sep = PLATFORM === "windows" ? "\\" : "/";
36
+ return parts.join(sep).replace(/[/\\]+/g, sep);
37
+ }
38
+
39
+ function basename(p: string): string {
40
+ const parts = p.replace(/\\/g, "/").split("/");
41
+ return parts[parts.length - 1] || "";
42
+ }
43
+
44
+ // ── File system helpers ──────────────────────────────────────
45
+
46
+ function existsSync(path: string): boolean {
47
+ try { Deno.statSync(path); return true; } catch { return false; }
48
+ }
49
+
50
+ function readTextSync(path: string): string {
51
+ return Deno.readTextFileSync(path);
52
+ }
53
+
54
+ function readDirNames(path: string): string[] {
55
+ try {
56
+ return [...Deno.readDirSync(path)].map((e) => e.name);
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
62
+ function readDirEntries(path: string): Deno.DirEntry[] {
63
+ try {
64
+ return [...Deno.readDirSync(path)];
65
+ } catch {
66
+ return [];
67
+ }
68
+ }
69
+
70
+ function fileMtime(path: string): number | null {
71
+ try {
72
+ const info = Deno.statSync(path);
73
+ return info.mtime ? info.mtime.getTime() : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function fileBirthtime(path: string): number | null {
80
+ try {
81
+ const info = Deno.statSync(path);
82
+ return info.birthtime ? info.birthtime.getTime() : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function isDirectory(path: string): boolean {
89
+ try {
90
+ return Deno.statSync(path).isDirectory;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ function getAppDataPath(appName: string): string {
97
+ switch (PLATFORM) {
98
+ case "darwin":
99
+ return join(HOME, "Library", "Application Support", appName);
100
+ case "windows":
101
+ return join(HOME, "AppData", "Roaming", appName);
102
+ default:
103
+ return join(HOME, ".config", appName);
104
+ }
105
+ }
106
+
107
+ // ── Types ────────────────────────────────────────────────────
108
+
109
+ interface Chat {
110
+ source: string;
111
+ composerId: string;
112
+ name: string | null;
113
+ createdAt: number | null;
114
+ lastUpdatedAt: number | null;
115
+ mode: string;
116
+ folder: string | null;
117
+ bubbleCount: number;
118
+ messageCount?: number;
119
+ }
120
+
121
+ interface EditorResult {
122
+ name: string;
123
+ label: string;
124
+ detected: boolean;
125
+ sessions: Chat[];
126
+ note?: string;
127
+ }
128
+
129
+ // ── Editor: Claude Code ──────────────────────────────────────
130
+
131
+ function scanClaude(): EditorResult {
132
+ const claudeDir = join(HOME, ".claude");
133
+ const projectsDir = join(claudeDir, "projects");
134
+ const result: EditorResult = { name: "claude-code", label: "Claude Code", detected: false, sessions: [] };
135
+
136
+ if (!existsSync(projectsDir)) return result;
137
+ result.detected = true;
138
+
139
+ for (const projDir of readDirNames(projectsDir)) {
140
+ const dir = join(projectsDir, projDir);
141
+ if (!isDirectory(dir)) continue;
142
+
143
+ const decodedFolder = projDir.replace(/-/g, "/");
144
+
145
+ // Read sessions-index.json for metadata
146
+ const indexPath = join(dir, "sessions-index.json");
147
+ const indexed = new Map<string, Record<string, unknown>>();
148
+ try {
149
+ const index = JSON.parse(readTextSync(indexPath));
150
+ for (const entry of index.entries || []) {
151
+ indexed.set(entry.sessionId, entry);
152
+ }
153
+ } catch { /* no index */ }
154
+
155
+ const files = readDirNames(dir).filter((f) => f.endsWith(".jsonl"));
156
+ for (const file of files) {
157
+ const sessionId = file.replace(".jsonl", "");
158
+ const fullPath = join(dir, file);
159
+ const entry = indexed.get(sessionId);
160
+
161
+ let msgCount = 0;
162
+ try {
163
+ const content = readTextSync(fullPath);
164
+ const lines = content.split("\n").filter(Boolean);
165
+ for (const line of lines) {
166
+ try {
167
+ const obj = JSON.parse(line);
168
+ if (obj.type === "user" || obj.type === "assistant") msgCount++;
169
+ } catch { /* skip */ }
170
+ }
171
+ } catch { /* skip */ }
172
+
173
+ if (entry) {
174
+ const e = entry as Record<string, unknown>;
175
+ result.sessions.push({
176
+ source: "claude-code",
177
+ composerId: sessionId,
178
+ name: cleanPrompt(e.firstPrompt as string),
179
+ createdAt: e.created ? new Date(e.created as string).getTime() : null,
180
+ lastUpdatedAt: e.modified ? new Date(e.modified as string).getTime() : fileMtime(fullPath),
181
+ mode: "claude",
182
+ folder: (e.projectPath as string) || decodedFolder,
183
+ bubbleCount: (e.messageCount as number) || msgCount,
184
+ messageCount: msgCount,
185
+ });
186
+ } else {
187
+ const meta = peekClaudeMeta(fullPath);
188
+ result.sessions.push({
189
+ source: "claude-code",
190
+ composerId: sessionId,
191
+ name: meta.firstPrompt ? cleanPrompt(meta.firstPrompt) : null,
192
+ createdAt: meta.timestamp || fileBirthtime(fullPath),
193
+ lastUpdatedAt: fileMtime(fullPath),
194
+ mode: "claude",
195
+ folder: meta.cwd || decodedFolder,
196
+ bubbleCount: msgCount,
197
+ messageCount: msgCount,
198
+ });
199
+ }
200
+ }
201
+ }
202
+ return result;
203
+ }
204
+
205
+ function peekClaudeMeta(filePath: string): { firstPrompt: string | null; cwd: string | null; timestamp: number | null } {
206
+ const meta = { firstPrompt: null as string | null, cwd: null as string | null, timestamp: null as number | null };
207
+ try {
208
+ const buf = readTextSync(filePath);
209
+ for (const line of buf.split("\n")) {
210
+ if (!line) continue;
211
+ const obj = JSON.parse(line);
212
+ if (!meta.cwd && obj.cwd) meta.cwd = obj.cwd;
213
+ if (!meta.timestamp && obj.timestamp) {
214
+ meta.timestamp = typeof obj.timestamp === "string" ? new Date(obj.timestamp).getTime() : obj.timestamp;
215
+ }
216
+ if (!meta.firstPrompt && obj.type === "user" && obj.message?.content) {
217
+ const text = typeof obj.message.content === "string"
218
+ ? obj.message.content
219
+ : obj.message.content.filter((c: Record<string, unknown>) => c.type === "text").map((c: Record<string, unknown>) => c.text).join(" ");
220
+ meta.firstPrompt = text.substring(0, 200);
221
+ }
222
+ if (meta.cwd && meta.firstPrompt) break;
223
+ }
224
+ } catch { /* skip */ }
225
+ return meta;
226
+ }
227
+
228
+ function cleanPrompt(prompt: string | null | undefined): string | null {
229
+ if (!prompt || prompt === "No prompt") return null;
230
+ const clean = prompt
231
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "")
232
+ .replace(/<[^>]+>/g, "")
233
+ .replace(/\s+/g, " ")
234
+ .trim()
235
+ .substring(0, 120);
236
+ return clean || null;
237
+ }
238
+
239
+ // ── Editor: VS Code / Copilot Chat ──────────────────────────
240
+
241
+ function scanVSCode(): EditorResult[] {
242
+ const variants = [
243
+ { id: "vscode", label: "VS Code", appSupport: getAppDataPath("Code") },
244
+ { id: "vscode-insiders", label: "VS Code Insiders", appSupport: getAppDataPath("Code - Insiders") },
245
+ ];
246
+
247
+ const results: EditorResult[] = [];
248
+
249
+ for (const variant of variants) {
250
+ const result: EditorResult = { name: variant.id, label: variant.label, detected: false, sessions: [] };
251
+ if (!existsSync(variant.appSupport)) { results.push(result); continue; }
252
+ result.detected = true;
253
+
254
+ // Global (empty window) chat sessions
255
+ const globalDir = join(variant.appSupport, "User", "globalStorage", "emptyWindowChatSessions");
256
+ if (existsSync(globalDir)) {
257
+ collectVSCodeSessions(globalDir, null, variant.id, result.sessions);
258
+ }
259
+
260
+ // Workspace chat sessions
261
+ const wsRoot = join(variant.appSupport, "User", "workspaceStorage");
262
+ if (existsSync(wsRoot)) {
263
+ for (const wsHash of readDirNames(wsRoot)) {
264
+ const wsDir = join(wsRoot, wsHash);
265
+ if (!isDirectory(wsDir)) continue;
266
+ const chatDir = join(wsDir, "chatSessions");
267
+ if (!existsSync(chatDir)) continue;
268
+ const folder = getVSCodeWorkspaceFolder(wsDir);
269
+ collectVSCodeSessions(chatDir, folder, variant.id, result.sessions);
270
+ }
271
+ }
272
+
273
+ results.push(result);
274
+ }
275
+
276
+ return results;
277
+ }
278
+
279
+ function getVSCodeWorkspaceFolder(wsDir: string): string | null {
280
+ const wsJson = join(wsDir, "workspace.json");
281
+ if (!existsSync(wsJson)) return null;
282
+ try {
283
+ const data = JSON.parse(readTextSync(wsJson));
284
+ const uri = data.folder || data.workspace;
285
+ if (uri) return decodeURIComponent(uri.replace("file://", ""));
286
+ } catch { /* skip */ }
287
+ return null;
288
+ }
289
+
290
+ function collectVSCodeSessions(dir: string, folder: string | null, source: string, chats: Chat[]) {
291
+ const files = readDirNames(dir).filter((f) => f.endsWith(".jsonl") || f.endsWith(".json"));
292
+ for (const file of files) {
293
+ const filePath = join(dir, file);
294
+ try {
295
+ const meta = peekVSCodeMeta(filePath);
296
+ chats.push({
297
+ source,
298
+ composerId: meta.sessionId || file.replace(/\.(jsonl|json)$/, ""),
299
+ name: meta.title || meta.firstUserText || null,
300
+ createdAt: meta.createdAt || fileBirthtime(filePath),
301
+ lastUpdatedAt: fileMtime(filePath),
302
+ mode: "copilot",
303
+ folder,
304
+ bubbleCount: meta.requestCount || 0,
305
+ });
306
+ } catch { /* skip */ }
307
+ }
308
+ }
309
+
310
+ function peekVSCodeMeta(filePath: string): {
311
+ sessionId: string | null;
312
+ title: string | null;
313
+ createdAt: number | null;
314
+ requestCount: number;
315
+ firstUserText: string | null;
316
+ } {
317
+ if (filePath.endsWith(".json")) {
318
+ try {
319
+ const data = JSON.parse(readTextSync(filePath));
320
+ return {
321
+ sessionId: data.sessionId || null,
322
+ title: data.customTitle || null,
323
+ createdAt: data.creationDate || data.lastMessageDate || null,
324
+ requestCount: data.requests?.length || 0,
325
+ firstUserText: (data.requests?.[0]?.message?.text || "").substring(0, 120) || null,
326
+ };
327
+ } catch { return { sessionId: null, title: null, createdAt: null, requestCount: 0, firstUserText: null }; }
328
+ }
329
+ // JSONL
330
+ try {
331
+ const content = readTextSync(filePath);
332
+ const firstNewline = content.indexOf("\n");
333
+ const firstLine = firstNewline > 0 ? content.substring(0, firstNewline) : content;
334
+ const init = JSON.parse(firstLine);
335
+ const state = init.v || {};
336
+ let title = state.customTitle || null;
337
+ if (!title) {
338
+ const titleIdx = content.indexOf('"customTitle"');
339
+ if (titleIdx !== -1) {
340
+ const lineStart = content.lastIndexOf("\n", titleIdx) + 1;
341
+ const lineEnd = content.indexOf("\n", titleIdx);
342
+ const patchLine = content.substring(lineStart, lineEnd > 0 ? lineEnd : undefined);
343
+ try {
344
+ const patch = JSON.parse(patchLine);
345
+ if (patch.kind === 1 && patch.k[0] === "customTitle") title = patch.v;
346
+ } catch { /* skip */ }
347
+ }
348
+ }
349
+ return {
350
+ sessionId: state.sessionId || null,
351
+ title,
352
+ createdAt: state.creationDate || null,
353
+ requestCount: state.requests?.length || 0,
354
+ firstUserText: null,
355
+ };
356
+ } catch { return { sessionId: null, title: null, createdAt: null, requestCount: 0, firstUserText: null }; }
357
+ }
358
+
359
+ // ── Editor: Cursor (file-based chats only, no SQLite) ────────
360
+
361
+ function scanCursor(): EditorResult {
362
+ const cursorChatsDir = join(HOME, ".cursor", "chats");
363
+ const result: EditorResult = { name: "cursor", label: "Cursor", detected: false, sessions: [] };
364
+
365
+ // Check if Cursor app data exists
366
+ const cursorApp = getAppDataPath("Cursor");
367
+ if (!existsSync(cursorChatsDir) && !existsSync(cursorApp)) return result;
368
+ result.detected = true;
369
+
370
+ // Scan ~/.cursor/chats/<workspace>/<chatId>/ for agent-mode sessions
371
+ if (existsSync(cursorChatsDir)) {
372
+ for (const workspace of readDirNames(cursorChatsDir)) {
373
+ const wsDir = join(cursorChatsDir, workspace);
374
+ if (!isDirectory(wsDir)) continue;
375
+ for (const chatId of readDirNames(wsDir)) {
376
+ const chatDir = join(wsDir, chatId);
377
+ if (!isDirectory(chatDir)) continue;
378
+
379
+ // Try to read composer_data or similar JSON
380
+ const files = readDirNames(chatDir);
381
+ const jsonFile = files.find((f) => f.endsWith(".json"));
382
+ if (jsonFile) {
383
+ const filePath = join(chatDir, jsonFile);
384
+ try {
385
+ const data = JSON.parse(readTextSync(filePath));
386
+ result.sessions.push({
387
+ source: "cursor",
388
+ composerId: chatId,
389
+ name: data.name || data.title || null,
390
+ createdAt: data.createdAt || fileBirthtime(filePath),
391
+ lastUpdatedAt: fileMtime(filePath),
392
+ mode: "agent",
393
+ folder: data.folder || data.workspacePath || null,
394
+ bubbleCount: data.bubbleCount || 0,
395
+ });
396
+ } catch { /* skip */ }
397
+ }
398
+ }
399
+ }
400
+ }
401
+
402
+ // Note: state.vscdb (SQLite) sessions require --allow-ffi
403
+ if (existsSync(join(cursorApp, "User", "globalStorage", "state.vscdb"))) {
404
+ result.note = "SQLite sessions available (run full version for complete data)";
405
+ }
406
+
407
+ return result;
408
+ }
409
+
410
+ // ── Editor: Codex CLI ────────────────────────────────────────
411
+
412
+ function scanCodex(): EditorResult {
413
+ const codexHome = Deno.env.get("CODEX_HOME") || join(HOME, ".codex");
414
+ const result: EditorResult = { name: "codex", label: "Codex CLI", detected: false, sessions: [] };
415
+
416
+ const sessionDirs = [join(codexHome, "sessions"), join(codexHome, "archived_sessions")];
417
+ let found = false;
418
+ for (const dir of sessionDirs) {
419
+ if (!existsSync(dir)) continue;
420
+ found = true;
421
+ walkJsonlFiles(dir, (filePath) => {
422
+ try {
423
+ const content = readTextSync(filePath);
424
+ const lines = content.split("\n").filter(Boolean);
425
+ if (lines.length === 0) return;
426
+
427
+ const first = JSON.parse(lines[0]);
428
+ let msgCount = 0;
429
+ let model: string | null = null;
430
+ for (const line of lines) {
431
+ try {
432
+ const obj = JSON.parse(line);
433
+ if (obj.type === "message" || obj.role) msgCount++;
434
+ if (!model && obj.model) model = obj.model;
435
+ } catch { /* skip */ }
436
+ }
437
+
438
+ result.sessions.push({
439
+ source: "codex",
440
+ composerId: first.id || basename(filePath).replace(".jsonl", ""),
441
+ name: first.instructions?.substring(0, 120) || null,
442
+ createdAt: first.created_at ? first.created_at * 1000 : fileBirthtime(filePath),
443
+ lastUpdatedAt: fileMtime(filePath),
444
+ mode: "codex",
445
+ folder: first.cwd || null,
446
+ bubbleCount: msgCount,
447
+ messageCount: msgCount,
448
+ });
449
+ } catch { /* skip */ }
450
+ });
451
+ }
452
+
453
+ result.detected = found;
454
+ return result;
455
+ }
456
+
457
+ function walkJsonlFiles(dir: string, cb: (path: string) => void) {
458
+ for (const entry of readDirEntries(dir)) {
459
+ const fullPath = join(dir, entry.name);
460
+ if (entry.isDirectory) {
461
+ walkJsonlFiles(fullPath, cb);
462
+ } else if (entry.isFile && entry.name.endsWith(".jsonl")) {
463
+ cb(fullPath);
464
+ }
465
+ }
466
+ }
467
+
468
+ // ── Editor: Gemini CLI ───────────────────────────────────────
469
+
470
+ function scanGemini(): EditorResult {
471
+ const geminiDir = join(HOME, ".gemini");
472
+ const tmpDir = join(geminiDir, "tmp");
473
+ const result: EditorResult = { name: "gemini-cli", label: "Gemini CLI", detected: false, sessions: [] };
474
+
475
+ if (!existsSync(tmpDir)) return result;
476
+ result.detected = true;
477
+
478
+ // Load project map
479
+ const projectMap = new Map<string, string>();
480
+ try {
481
+ const data = JSON.parse(readTextSync(join(geminiDir, "projects.json")));
482
+ if (data.projects) {
483
+ for (const [folderPath, projName] of Object.entries(data.projects)) {
484
+ projectMap.set(projName as string, folderPath);
485
+ }
486
+ }
487
+ } catch { /* skip */ }
488
+
489
+ for (const projName of readDirNames(tmpDir)) {
490
+ const projDir = join(tmpDir, projName);
491
+ if (!isDirectory(projDir)) continue;
492
+ const folder = projectMap.get(projName) || null;
493
+
494
+ for (const file of readDirNames(projDir).filter((f) => f.endsWith(".jsonl"))) {
495
+ const filePath = join(projDir, file);
496
+ try {
497
+ const content = readTextSync(filePath);
498
+ const lines = content.split("\n").filter(Boolean);
499
+ let msgCount = 0;
500
+ let firstUserText: string | null = null;
501
+ for (const line of lines) {
502
+ try {
503
+ const obj = JSON.parse(line);
504
+ if (obj.role === "user" || obj.role === "model") msgCount++;
505
+ if (!firstUserText && obj.role === "user") {
506
+ const text = obj.parts?.[0]?.text || "";
507
+ firstUserText = text.substring(0, 120) || null;
508
+ }
509
+ } catch { /* skip */ }
510
+ }
511
+
512
+ result.sessions.push({
513
+ source: "gemini-cli",
514
+ composerId: file.replace(".jsonl", ""),
515
+ name: firstUserText,
516
+ createdAt: fileBirthtime(filePath),
517
+ lastUpdatedAt: fileMtime(filePath),
518
+ mode: "gemini",
519
+ folder,
520
+ bubbleCount: msgCount,
521
+ messageCount: msgCount,
522
+ });
523
+ } catch { /* skip */ }
524
+ }
525
+ }
526
+
527
+ return result;
528
+ }
529
+
530
+ // ── Editor: Command Code ─────────────────────────────────────
531
+
532
+ function scanCommandCode(): EditorResult {
533
+ const projectsDir = join(HOME, ".commandcode", "projects");
534
+ const result: EditorResult = { name: "commandcode", label: "Command Code", detected: false, sessions: [] };
535
+
536
+ if (!existsSync(projectsDir)) return result;
537
+ result.detected = true;
538
+
539
+ for (const projDir of readDirNames(projectsDir)) {
540
+ const dir = join(projectsDir, projDir);
541
+ if (!isDirectory(dir)) continue;
542
+ const decodedFolder = "/" + projDir.replace(/-/g, "/");
543
+
544
+ const files = readDirNames(dir).filter((f) => f.endsWith(".jsonl") && !f.includes(".checkpoints."));
545
+ for (const file of files) {
546
+ const sessionId = file.replace(".jsonl", "");
547
+ const fullPath = join(dir, file);
548
+ const metaPath = join(dir, `${sessionId}.meta.json`);
549
+
550
+ let title: string | null = null;
551
+ try {
552
+ const meta = JSON.parse(readTextSync(metaPath));
553
+ title = meta.title || null;
554
+ } catch { /* skip */ }
555
+
556
+ let msgCount = 0;
557
+ try {
558
+ const content = readTextSync(fullPath);
559
+ for (const line of content.split("\n")) {
560
+ if (!line) continue;
561
+ try {
562
+ const obj = JSON.parse(line);
563
+ if (obj.type === "user" || obj.type === "assistant") msgCount++;
564
+ } catch { /* skip */ }
565
+ }
566
+ } catch { /* skip */ }
567
+
568
+ result.sessions.push({
569
+ source: "commandcode",
570
+ composerId: sessionId,
571
+ name: title,
572
+ createdAt: fileBirthtime(fullPath),
573
+ lastUpdatedAt: fileMtime(fullPath),
574
+ mode: "commandcode",
575
+ folder: decodedFolder,
576
+ bubbleCount: msgCount,
577
+ messageCount: msgCount,
578
+ });
579
+ }
580
+ }
581
+
582
+ return result;
583
+ }
584
+
585
+ // ── Editor: Copilot CLI ──────────────────────────────────────
586
+
587
+ function scanCopilot(): EditorResult {
588
+ const sessionStateDir = join(HOME, ".copilot", "session-state");
589
+ const result: EditorResult = { name: "copilot-cli", label: "Copilot CLI", detected: false, sessions: [] };
590
+
591
+ if (!existsSync(sessionStateDir)) return result;
592
+ result.detected = true;
593
+
594
+ for (const sessionDir of readDirNames(sessionStateDir)) {
595
+ const dir = join(sessionStateDir, sessionDir);
596
+ if (!isDirectory(dir)) continue;
597
+
598
+ // Parse workspace.yaml
599
+ const yamlPath = join(dir, "workspace.yaml");
600
+ let folder: string | null = null;
601
+ let summary: string | null = null;
602
+ let createdAt: string | null = null;
603
+ let updatedAt: string | null = null;
604
+ if (existsSync(yamlPath)) {
605
+ try {
606
+ const raw = readTextSync(yamlPath);
607
+ for (const line of raw.split("\n")) {
608
+ const match = line.match(/^(\w+):\s*(.*)$/);
609
+ if (!match) continue;
610
+ if (match[1] === "cwd" || match[1] === "git_root") folder = folder || match[2].trim();
611
+ if (match[1] === "summary") summary = match[2].trim();
612
+ if (match[1] === "created_at") createdAt = match[2].trim();
613
+ if (match[1] === "updated_at") updatedAt = match[2].trim();
614
+ }
615
+ } catch { /* skip */ }
616
+ }
617
+
618
+ // Count events
619
+ const eventsPath = join(dir, "events.jsonl");
620
+ let msgCount = 0;
621
+ if (existsSync(eventsPath)) {
622
+ try {
623
+ const content = readTextSync(eventsPath);
624
+ for (const line of content.split("\n")) {
625
+ if (!line) continue;
626
+ try {
627
+ const obj = JSON.parse(line);
628
+ if (obj.type === "user.message" || obj.type === "assistant.message") msgCount++;
629
+ } catch { /* skip */ }
630
+ }
631
+ } catch { /* skip */ }
632
+ }
633
+
634
+ result.sessions.push({
635
+ source: "copilot-cli",
636
+ composerId: sessionDir,
637
+ name: summary,
638
+ createdAt: createdAt ? new Date(createdAt).getTime() : fileBirthtime(dir),
639
+ lastUpdatedAt: updatedAt ? new Date(updatedAt).getTime() : fileMtime(dir),
640
+ mode: "copilot",
641
+ folder,
642
+ bubbleCount: msgCount,
643
+ messageCount: msgCount,
644
+ });
645
+ }
646
+
647
+ return result;
648
+ }
649
+
650
+ // ── Editor: Kiro ─────────────────────────────────────────────
651
+
652
+ function scanKiro(): EditorResult {
653
+ const kiroAgentDir = join(getAppDataPath("Kiro"), "User", "globalStorage", "kiro.kiroagent");
654
+ const wsSessionsDir = join(kiroAgentDir, "workspace-sessions");
655
+ const result: EditorResult = { name: "kiro", label: "Kiro", detected: false, sessions: [] };
656
+
657
+ if (!existsSync(kiroAgentDir)) return result;
658
+ result.detected = true;
659
+
660
+ if (existsSync(wsSessionsDir)) {
661
+ for (const folder of readDirNames(wsSessionsDir)) {
662
+ const wsDir = join(wsSessionsDir, folder);
663
+ if (!isDirectory(wsDir)) continue;
664
+
665
+ // Decode base64 folder name
666
+ let workspacePath: string | null = null;
667
+ try { workspacePath = atob(folder); } catch { /* skip */ }
668
+
669
+ const indexPath = join(wsDir, "sessions.json");
670
+ let sessions: Record<string, unknown>[] = [];
671
+ try { sessions = JSON.parse(readTextSync(indexPath)); } catch { continue; }
672
+
673
+ for (const session of sessions) {
674
+ const sessionFile = join(wsDir, `${session.sessionId}.json`);
675
+ const exists = existsSync(sessionFile);
676
+
677
+ result.sessions.push({
678
+ source: "kiro",
679
+ composerId: session.sessionId as string,
680
+ name: (session.title as string) || null,
681
+ createdAt: parseInt(session.dateCreated as string) || null,
682
+ lastUpdatedAt: exists ? fileMtime(sessionFile) : parseInt(session.dateCreated as string) || null,
683
+ mode: "kiro",
684
+ folder: workspacePath,
685
+ bubbleCount: (session.messageCount as number) || 0,
686
+ });
687
+ }
688
+ }
689
+ }
690
+
691
+ return result;
692
+ }
693
+
694
+ // ── Editor: Goose (file-based sessions) ──────────────────────
695
+
696
+ function scanGoose(): EditorResult {
697
+ const gooseDir = join(HOME, ".local", "share", "goose", "sessions");
698
+ const result: EditorResult = { name: "goose", label: "Goose", detected: false, sessions: [] };
699
+
700
+ if (!existsSync(gooseDir)) return result;
701
+ result.detected = true;
702
+
703
+ // Scan JSONL session files
704
+ for (const file of readDirNames(gooseDir).filter((f) => f.endsWith(".jsonl"))) {
705
+ const filePath = join(gooseDir, file);
706
+ try {
707
+ const content = readTextSync(filePath);
708
+ const lines = content.split("\n").filter(Boolean);
709
+ let msgCount = 0;
710
+ let firstUserText: string | null = null;
711
+
712
+ for (const line of lines) {
713
+ try {
714
+ const obj = JSON.parse(line);
715
+ if (obj.role === "user" || obj.role === "assistant") msgCount++;
716
+ if (!firstUserText && obj.role === "user") {
717
+ const text = typeof obj.content === "string" ? obj.content : "";
718
+ firstUserText = text.substring(0, 120) || null;
719
+ }
720
+ } catch { /* skip */ }
721
+ }
722
+
723
+ result.sessions.push({
724
+ source: "goose",
725
+ composerId: file.replace(".jsonl", ""),
726
+ name: firstUserText,
727
+ createdAt: fileBirthtime(filePath),
728
+ lastUpdatedAt: fileMtime(filePath),
729
+ mode: "goose",
730
+ folder: null,
731
+ bubbleCount: msgCount,
732
+ messageCount: msgCount,
733
+ });
734
+ } catch { /* skip */ }
735
+ }
736
+
737
+ // Note about SQLite sessions
738
+ const dbPath = join(gooseDir, "sessions.db");
739
+ if (existsSync(dbPath)) {
740
+ result.note = "SQLite sessions available (run full version for complete data)";
741
+ }
742
+
743
+ return result;
744
+ }
745
+
746
+ // ── Editor: OpenCode (file-based sessions) ───────────────────
747
+
748
+ function scanOpenCode(): EditorResult {
749
+ const storageDir = join(HOME, ".local", "share", "opencode", "storage");
750
+ const sessionDir = join(storageDir, "session");
751
+ const result: EditorResult = { name: "opencode", label: "OpenCode", detected: false, sessions: [] };
752
+
753
+ if (!existsSync(sessionDir)) return result;
754
+ result.detected = true;
755
+
756
+ for (const file of readDirNames(sessionDir).filter((f) => f.endsWith(".json"))) {
757
+ const filePath = join(sessionDir, file);
758
+ try {
759
+ const data = JSON.parse(readTextSync(filePath));
760
+ result.sessions.push({
761
+ source: "opencode",
762
+ composerId: data.id || file.replace(".json", ""),
763
+ name: data.title || null,
764
+ createdAt: data.time_created ? new Date(data.time_created).getTime() : fileBirthtime(filePath),
765
+ lastUpdatedAt: data.time_updated ? new Date(data.time_updated).getTime() : fileMtime(filePath),
766
+ mode: "opencode",
767
+ folder: data.directory || null,
768
+ bubbleCount: 0,
769
+ });
770
+ } catch { /* skip */ }
771
+ }
772
+
773
+ return result;
774
+ }
775
+
776
+ // ── Editor: Windsurf / Antigravity (detection only) ──────────
777
+
778
+ function scanWindsurf(): EditorResult[] {
779
+ const variants = [
780
+ { id: "windsurf", label: "Windsurf", dataDir: join(HOME, ".codeium", "windsurf") },
781
+ { id: "windsurf-next", label: "Windsurf Next", dataDir: join(HOME, ".codeium", "windsurf-next") },
782
+ { id: "antigravity", label: "Antigravity", dataDir: join(HOME, ".codeium", "antigravity") },
783
+ ];
784
+
785
+ return variants.map((v) => {
786
+ const detected = existsSync(v.dataDir);
787
+ return {
788
+ name: v.id,
789
+ label: v.label,
790
+ detected,
791
+ sessions: [],
792
+ note: detected ? "Requires running editor + full version for session data" : undefined,
793
+ };
794
+ });
795
+ }
796
+
797
+ // ── Editor: Zed (detection only) ─────────────────────────────
798
+
799
+ function scanZed(): EditorResult {
800
+ let zedPath: string;
801
+ switch (PLATFORM) {
802
+ case "darwin":
803
+ zedPath = join(HOME, "Library", "Application Support", "Zed");
804
+ break;
805
+ case "windows":
806
+ zedPath = join(HOME, "AppData", "Local", "Zed");
807
+ break;
808
+ default:
809
+ zedPath = join(HOME, ".config", "Zed");
810
+ }
811
+
812
+ const threadsDb = join(zedPath, "threads", "threads.db");
813
+ const detected = existsSync(zedPath);
814
+
815
+ return {
816
+ name: "zed",
817
+ label: "Zed",
818
+ detected,
819
+ sessions: [],
820
+ note: detected && existsSync(threadsDb) ? "SQLite sessions available (run full version for complete data)" : undefined,
821
+ };
822
+ }
823
+
824
+ // ── Aggregation & Display ────────────────────────────────────
825
+
826
+ function formatDate(ts: number | null): string {
827
+ if (!ts) return "unknown";
828
+ const d = new Date(ts);
829
+ return d.toISOString().split("T")[0];
830
+ }
831
+
832
+ function formatRelative(ts: number | null): string {
833
+ if (!ts) return "";
834
+ const diff = Date.now() - ts;
835
+ const mins = Math.floor(diff / 60000);
836
+ if (mins < 60) return `${mins}m ago`;
837
+ const hours = Math.floor(mins / 60);
838
+ if (hours < 24) return `${hours}h ago`;
839
+ const days = Math.floor(hours / 24);
840
+ if (days < 30) return `${days}d ago`;
841
+ const months = Math.floor(days / 30);
842
+ return `${months}mo ago`;
843
+ }
844
+
845
+ function main() {
846
+ const args = Deno.args;
847
+ const jsonOutput = args.includes("--json");
848
+ const showHelp = args.includes("--help") || args.includes("-h");
849
+
850
+ if (showHelp) {
851
+ console.log(`
852
+ ${bold("Agentlytics")} — Deno Sandboxed Edition
853
+ Lightweight CLI analytics for your AI coding agents.
854
+
855
+ ${bold("Usage:")}
856
+ deno run --allow-read --allow-env mod.ts [options]
857
+ deno run --allow-read --allow-env https://raw.githubusercontent.com/f/agentlytics/master/mod.ts
858
+
859
+ ${bold("Options:")}
860
+ --json Output results as JSON
861
+ --help, -h Show this help message
862
+
863
+ ${bold("Permissions:")}
864
+ --allow-read Read local editor data files (required)
865
+ --allow-env Access HOME directory path (required)
866
+
867
+ ${bold("Full version:")}
868
+ For the complete dashboard with SQLite support, cost analytics,
869
+ and web UI, install the full version:
870
+ npx agentlytics
871
+ deno task start ${dim("(from cloned repo)")}
872
+ `);
873
+ Deno.exit(0);
874
+ }
875
+
876
+ // Collect from all editors
877
+ const allResults: EditorResult[] = [];
878
+
879
+ allResults.push(scanClaude());
880
+ allResults.push(...scanVSCode());
881
+ allResults.push(scanCursor());
882
+ allResults.push(scanCodex());
883
+ allResults.push(scanGemini());
884
+ allResults.push(scanCopilot());
885
+ allResults.push(scanCommandCode());
886
+ allResults.push(scanKiro());
887
+ allResults.push(scanGoose());
888
+ allResults.push(scanOpenCode());
889
+ allResults.push(...scanWindsurf());
890
+ allResults.push(scanZed());
891
+
892
+ // Gather all sessions
893
+ const allSessions = allResults.flatMap((r) => r.sessions);
894
+ allSessions.sort((a, b) => (b.lastUpdatedAt || b.createdAt || 0) - (a.lastUpdatedAt || a.createdAt || 0));
895
+
896
+ // Unique projects
897
+ const projects = new Set(allSessions.map((s) => s.folder).filter(Boolean));
898
+
899
+ // Date range
900
+ const timestamps = allSessions.map((s) => s.createdAt || s.lastUpdatedAt || 0).filter((t) => t > 0);
901
+ const oldest = timestamps.length > 0 ? Math.min(...timestamps) : null;
902
+ const newest = timestamps.length > 0 ? Math.max(...timestamps) : null;
903
+
904
+ // Total messages
905
+ const totalMessages = allSessions.reduce((sum, s) => sum + (s.messageCount || s.bubbleCount || 0), 0);
906
+
907
+ if (jsonOutput) {
908
+ const output = {
909
+ timestamp: new Date().toISOString(),
910
+ platform: PLATFORM,
911
+ editors: allResults.map((r) => ({
912
+ name: r.name,
913
+ label: r.label,
914
+ detected: r.detected,
915
+ sessionCount: r.sessions.length,
916
+ note: r.note || null,
917
+ })),
918
+ summary: {
919
+ totalSessions: allSessions.length,
920
+ totalMessages,
921
+ totalProjects: projects.size,
922
+ dateRange: {
923
+ oldest: oldest ? new Date(oldest).toISOString() : null,
924
+ newest: newest ? new Date(newest).toISOString() : null,
925
+ },
926
+ },
927
+ sessions: allSessions,
928
+ };
929
+ console.log(JSON.stringify(output, null, 2));
930
+ return;
931
+ }
932
+
933
+ // ── Pretty CLI output ────────────────────────────────────
934
+
935
+ const c1 = hex("#818cf8"), c2 = hex("#f472b6"), c3 = hex("#34d399"), c4 = hex("#fbbf24");
936
+
937
+ console.log("");
938
+ console.log(` ${c1("(● ●)")} ${c2("[● ●]")} ${bold("Agentlytics")} ${dim("— Deno Sandboxed Edition")}`);
939
+ console.log(` ${c3("{● ●}")} ${c4("<● ●>")} ${dim("Lightweight CLI analytics for AI coding agents")}`);
940
+ console.log("");
941
+
942
+ // Editor detection table
943
+ const detected = allResults.filter((r) => r.detected);
944
+ const notDetected = allResults.filter((r) => !r.detected);
945
+
946
+ for (const r of allResults) {
947
+ if (r.detected && r.sessions.length > 0) {
948
+ const count = r.sessions.length;
949
+ console.log(` ${green("✓")} ${bold(r.label.padEnd(22))} ${dim(`${count} session${count === 1 ? "" : "s"}`)}`);
950
+ } else if (r.detected) {
951
+ const note = r.note ? dim(` (${r.note})`) : "";
952
+ console.log(` ${yellow("●")} ${bold(r.label.padEnd(22))} ${dim("detected")}${note}`);
953
+ } else {
954
+ console.log(` ${dim("–")} ${dim(r.label.padEnd(22) + "–")}`);
955
+ }
956
+ }
957
+
958
+ console.log("");
959
+
960
+ if (allSessions.length === 0) {
961
+ console.log(dim(" No sessions found. Make sure your editors have been used."));
962
+ console.log("");
963
+ return;
964
+ }
965
+
966
+ // Summary stats
967
+ console.log(` ${bold("Summary")}`);
968
+ console.log(` ${"Sessions".padEnd(18)} ${bold(String(allSessions.length))}`);
969
+ if (totalMessages > 0) {
970
+ console.log(` ${"Messages".padEnd(18)} ${bold(String(totalMessages))}`);
971
+ }
972
+ console.log(` ${"Projects".padEnd(18)} ${bold(String(projects.size))}`);
973
+ console.log(` ${"Editors".padEnd(18)} ${bold(String(detected.filter((r) => r.sessions.length > 0).length))} ${dim(`of ${allResults.length} checked`)}`);
974
+ if (oldest && newest) {
975
+ console.log(` ${"Date range".padEnd(18)} ${dim(`${formatDate(oldest)} → ${formatDate(newest)}`)}`);
976
+ }
977
+ console.log("");
978
+
979
+ // Top 5 recent sessions
980
+ const recent = allSessions.slice(0, 5);
981
+ if (recent.length > 0) {
982
+ console.log(` ${bold("Recent Sessions")}`);
983
+ for (const s of recent) {
984
+ const name = (s.name || "Untitled").substring(0, 50);
985
+ const editor = allResults.find((r) => r.name === s.source)?.label || s.source;
986
+ const time = formatRelative(s.lastUpdatedAt || s.createdAt);
987
+ console.log(` ${dim("•")} ${name}`);
988
+ console.log(` ${dim(`${editor} · ${time}${s.folder ? ` · ${basename(s.folder)}` : ""}`)}`);
989
+ }
990
+ console.log("");
991
+ }
992
+
993
+ // Top projects
994
+ if (projects.size > 0) {
995
+ const projectCounts = new Map<string, number>();
996
+ for (const s of allSessions) {
997
+ if (s.folder) {
998
+ projectCounts.set(s.folder, (projectCounts.get(s.folder) || 0) + 1);
999
+ }
1000
+ }
1001
+ const topProjects = [...projectCounts.entries()]
1002
+ .sort((a, b) => b[1] - a[1])
1003
+ .slice(0, 5);
1004
+
1005
+ if (topProjects.length > 0) {
1006
+ console.log(` ${bold("Top Projects")}`);
1007
+ for (const [folder, count] of topProjects) {
1008
+ console.log(` ${dim("•")} ${basename(folder).padEnd(30)} ${dim(`${count} session${count === 1 ? "" : "s"}`)}`);
1009
+ }
1010
+ console.log("");
1011
+ }
1012
+ }
1013
+
1014
+ // Footer
1015
+ console.log(dim(" For the full dashboard with cost analytics and web UI:"));
1016
+ console.log(cyan(" npx agentlytics"));
1017
+ console.log("");
1018
+ }
1019
+
1020
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlytics",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
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": {
@@ -23,7 +23,9 @@
23
23
  "ui/package-lock.json",
24
24
  "ui/vite.config.js",
25
25
  "ui/eslint.config.js",
26
- "README.md"
26
+ "README.md",
27
+ "mod.ts",
28
+ "deno.json"
27
29
  ],
28
30
  "scripts": {
29
31
  "start": "node index.js",
@@ -46,6 +48,9 @@
46
48
  ],
47
49
  "author": "fkadev",
48
50
  "license": "ISC",
51
+ "engines": {
52
+ "node": ">=20.19.0"
53
+ },
49
54
  "repository": {
50
55
  "type": "git",
51
56
  "url": "https://github.com/f/agentlytics"
package/relay-server.js CHANGED
@@ -542,7 +542,7 @@ function createRelayApp() {
542
542
  if (fs.existsSync(index)) {
543
543
  res.sendFile(index);
544
544
  } else {
545
- res.status(404).send('UI not built. Run: cd ui && npm install && npm run build');
545
+ res.status(404).send('UI not built. Run: cd ui && npm install && npm run build (or use your preferred package manager)');
546
546
  }
547
547
  });
548
548
 
package/ui/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "version": "0.0.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "vite",
7
+ "dev": "node ../index.js --ui-dev & vite",
8
8
  "build": "vite build",
9
9
  "lint": "eslint .",
10
10
  "preview": "vite preview"
package/ui/src/App.jsx CHANGED
@@ -3,6 +3,7 @@ import { Routes, Route, NavLink, useLocation } from 'react-router-dom'
3
3
  import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, DollarSign, CreditCard, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Plug, Copy, Check, Settings as SettingsIcon, Package, ChevronDown } from 'lucide-react'
4
4
  import { fetchOverview, refetchAgents, fetchMode, fetchRelayConfig, getAuthToken, setOnAuthFailure } from './lib/api'
5
5
  import { useTheme } from './lib/theme'
6
+ import { useLive } from './hooks/useLive'
6
7
  import AnimatedLogo from './components/AnimatedLogo'
7
8
  import AnimatedLoader from './components/AnimatedLoader'
8
9
  import LoginScreen from './components/LoginScreen'
@@ -70,7 +71,7 @@ function NavDropdown({ icon: Icon, label, items }) {
70
71
  export default function App() {
71
72
  const [overview, setOverview] = useState(null)
72
73
  const [refetchState, setRefetchState] = useState(null) // null | { scanned, total }
73
- const [live, setLive] = useState(false)
74
+ const { live, toggle: toggleLive } = useLive()
74
75
  const [mode, setMode] = useState(null) // 'local' | 'relay'
75
76
  const [needsAuth, setNeedsAuth] = useState(false)
76
77
  const [authed, setAuthed] = useState(!!getAuthToken())
@@ -105,25 +106,29 @@ export default function App() {
105
106
  if (mode === 'local') refreshOverview()
106
107
  }, [mode])
107
108
 
108
- // Live mode: refetch overview every 60s
109
+ const rescanAndRefresh = useCallback(async (onProgress) => {
110
+ await refetchAgents(onProgress)
111
+ const data = await fetchOverview()
112
+ setOverview(data)
113
+ }, [])
114
+
115
+ // Live mode: rescan & refresh every 60s
109
116
  useEffect(() => {
110
117
  if (live && mode === 'local') {
111
118
  liveRef.current = setInterval(() => {
112
- refreshOverview()
119
+ rescanAndRefresh().catch(() => {})
113
120
  }, 60000)
114
121
  } else {
115
122
  if (liveRef.current) clearInterval(liveRef.current)
116
123
  liveRef.current = null
117
124
  }
118
125
  return () => { if (liveRef.current) clearInterval(liveRef.current) }
119
- }, [live, refreshOverview])
126
+ }, [live, rescanAndRefresh])
120
127
 
121
128
  const handleRefetch = async () => {
122
129
  setRefetchState({ scanned: 0, total: 0 })
123
130
  try {
124
- await refetchAgents((p) => setRefetchState({ scanned: p.scanned, total: p.total }))
125
- const data = await fetchOverview()
126
- setOverview(data)
131
+ await rescanAndRefresh((p) => setRefetchState({ scanned: p.scanned, total: p.total }))
127
132
  } catch (e) { console.error(e) }
128
133
  setRefetchState(null)
129
134
  }
@@ -187,7 +192,7 @@ export default function App() {
187
192
  {!isRelay && (
188
193
  <>
189
194
  <button
190
- onClick={() => setLive(!live)}
195
+ onClick={toggleLive}
191
196
  className="flex items-center gap-1.5 px-2 py-0.5 text-[11px] transition"
192
197
  style={{
193
198
  color: live ? '#22c55e' : 'var(--c-text3)',
@@ -0,0 +1,15 @@
1
+ import { useState, useEffect, useCallback } from 'react'
2
+
3
+ const KEY = 'agentlytics-live'
4
+
5
+ export function useLive() {
6
+ const [live, setLive] = useState(() => localStorage.getItem(KEY) === 'true')
7
+
8
+ useEffect(() => {
9
+ localStorage.setItem(KEY, live)
10
+ }, [live])
11
+
12
+ const toggle = useCallback(() => setLive(l => !l), [])
13
+
14
+ return { live, toggle }
15
+ }