agentlytics 0.2.7 → 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/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();