agentlytics 0.0.6 → 0.0.8

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
@@ -6,12 +6,12 @@
6
6
 
7
7
  <p align="center">
8
8
  <strong>Unified analytics for your AI coding agents</strong><br>
9
- <sub>Cursor · Windsurf · Claude Code · VS Code Copilot · Zed · Antigravity · OpenCode · Gemini CLI · Copilot CLI</sub>
9
+ <sub>Cursor · Windsurf · Claude Code · VS Code Copilot · Zed · Antigravity · OpenCode · Gemini CLI · Copilot CLI · Cursor Agent</sub>
10
10
  </p>
11
11
 
12
12
  <p align="center">
13
13
  <a href="https://www.npmjs.com/package/agentlytics"><img src="https://img.shields.io/npm/v/agentlytics?color=6366f1&label=npm" alt="npm"></a>
14
- <a href="#supported-editors"><img src="https://img.shields.io/badge/editors-11-818cf8" alt="editors"></a>
14
+ <a href="#supported-editors"><img src="https://img.shields.io/badge/editors-12-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%A518-brightgreen" alt="node"></a>
17
17
  </p>
@@ -56,6 +56,7 @@ Opens at **http://localhost:4637**. Requires Node.js ≥ 18, macOS.
56
56
  | **OpenCode** | `opencode` | ✅ | ✅ | ✅ | ✅ |
57
57
  | **Gemini CLI** | `gemini-cli` | ✅ | ✅ | ✅ | ✅ |
58
58
  | **Copilot CLI** | `copilot-cli` | ✅ | ✅ | ✅ | ✅ |
59
+ | **Cursor Agent** | `cursor-agent` | ✅ | ❌ | ❌ | ❌ |
59
60
 
60
61
  > Windsurf, Windsurf Next, and Antigravity must be running during scan.
61
62
 
@@ -0,0 +1,195 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+
5
+ const CURSOR_PROJECTS_DIR = path.join(os.homedir(), '.cursor', 'projects');
6
+
7
+ // ============================================================
8
+ // Adapter interface
9
+ // ============================================================
10
+
11
+ const name = 'cursor-agent';
12
+
13
+ /**
14
+ * Decode project directory name back to folder path.
15
+ * e.g. "Users-fka-Code-Wapple" → "/Users/fka/Code/Wapple"
16
+ */
17
+ function decodeProjectDir(dirName) {
18
+ // The encoding replaces "/" with "-". We reconstruct by prepending "/"
19
+ // and replacing "-" back to "/". Handle ambiguity by checking if path exists.
20
+ const candidate = '/' + dirName.replace(/-/g, '/');
21
+ if (fs.existsSync(candidate)) return candidate;
22
+ // Fallback: try common patterns (the first segment is usually "Users")
23
+ const parts = dirName.split('-');
24
+ for (let i = 2; i < parts.length; i++) {
25
+ const prefix = '/' + parts.slice(0, i).join('/');
26
+ const suffix = parts.slice(i).join('-');
27
+ const full = path.join(prefix, suffix);
28
+ if (fs.existsSync(full)) return full;
29
+ }
30
+ return candidate;
31
+ }
32
+
33
+ /**
34
+ * Find all agent transcript JSONL files across all projects.
35
+ * Two patterns:
36
+ * - <project>/agent-transcripts/<id>.jsonl (flat)
37
+ * - <project>/agent-transcripts/<id>/<id>.jsonl (nested)
38
+ */
39
+ function findTranscripts() {
40
+ const results = [];
41
+ if (!fs.existsSync(CURSOR_PROJECTS_DIR)) return results;
42
+
43
+ let projectDirs;
44
+ try { projectDirs = fs.readdirSync(CURSOR_PROJECTS_DIR); } catch { return results; }
45
+
46
+ for (const projDir of projectDirs) {
47
+ const transcriptsDir = path.join(CURSOR_PROJECTS_DIR, projDir, 'agent-transcripts');
48
+ if (!fs.existsSync(transcriptsDir)) continue;
49
+
50
+ let entries;
51
+ try { entries = fs.readdirSync(transcriptsDir); } catch { continue; }
52
+
53
+ const folder = decodeProjectDir(projDir);
54
+
55
+ for (const entry of entries) {
56
+ const entryPath = path.join(transcriptsDir, entry);
57
+
58
+ // Flat pattern: <id>.jsonl
59
+ if (entry.endsWith('.jsonl')) {
60
+ const sessionId = entry.replace('.jsonl', '');
61
+ results.push({ sessionId, jsonlPath: entryPath, folder });
62
+ continue;
63
+ }
64
+
65
+ // Nested pattern: <id>/<id>.jsonl
66
+ try {
67
+ if (fs.statSync(entryPath).isDirectory()) {
68
+ const nestedJsonl = path.join(entryPath, entry + '.jsonl');
69
+ if (fs.existsSync(nestedJsonl)) {
70
+ results.push({ sessionId: entry, jsonlPath: nestedJsonl, folder });
71
+ }
72
+ }
73
+ } catch { /* skip */ }
74
+ }
75
+ }
76
+ return results;
77
+ }
78
+
79
+ /**
80
+ * Parse a JSONL transcript file into an array of raw message objects.
81
+ * Each line: {"role":"user"|"assistant", "message":{"content":[{"type":"text","text":"..."}]}}
82
+ */
83
+ function parseJsonl(jsonlPath) {
84
+ try {
85
+ const raw = fs.readFileSync(jsonlPath, 'utf-8');
86
+ return raw.split('\n').filter(Boolean).map(line => {
87
+ try { return JSON.parse(line); } catch { return null; }
88
+ }).filter(Boolean);
89
+ } catch { return []; }
90
+ }
91
+
92
+ /**
93
+ * Extract the first user query text from a parsed transcript (for chat name).
94
+ */
95
+ function extractFirstUserText(entries) {
96
+ for (const e of entries) {
97
+ if (e.role !== 'user') continue;
98
+ const parts = e.message?.content;
99
+ if (!Array.isArray(parts)) continue;
100
+ for (const p of parts) {
101
+ if (p.type === 'text' && p.text) {
102
+ // Strip <user_query> wrapper if present
103
+ let text = p.text.replace(/<\/?user_query>/g, '').trim();
104
+ // Strip <attached_files> blocks
105
+ text = text.replace(/<attached_files>[\s\S]*?<\/attached_files>/g, '').trim();
106
+ if (text) return text.substring(0, 120);
107
+ }
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+
113
+ function getChats() {
114
+ const chats = [];
115
+ const transcripts = findTranscripts();
116
+
117
+ for (const { sessionId, jsonlPath, folder } of transcripts) {
118
+ try {
119
+ const stat = fs.statSync(jsonlPath);
120
+ const entries = parseJsonl(jsonlPath);
121
+ if (entries.length === 0) continue;
122
+
123
+ // Count user/assistant messages
124
+ const userCount = entries.filter(e => e.role === 'user').length;
125
+ const assistantCount = entries.filter(e => e.role === 'assistant').length;
126
+ const bubbleCount = userCount + assistantCount;
127
+ if (bubbleCount === 0) continue;
128
+
129
+ chats.push({
130
+ source: 'cursor-agent',
131
+ composerId: sessionId,
132
+ name: extractFirstUserText(entries),
133
+ createdAt: stat.birthtimeMs || stat.mtimeMs,
134
+ lastUpdatedAt: stat.mtimeMs,
135
+ mode: 'agent',
136
+ folder,
137
+ bubbleCount,
138
+ _jsonlPath: jsonlPath,
139
+ });
140
+ } catch { /* skip */ }
141
+ }
142
+
143
+ return chats;
144
+ }
145
+
146
+ function getMessages(chat) {
147
+ const jsonlPath = chat._jsonlPath;
148
+ if (!jsonlPath || !fs.existsSync(jsonlPath)) return [];
149
+
150
+ const entries = parseJsonl(jsonlPath);
151
+ const result = [];
152
+
153
+ for (const entry of entries) {
154
+ const parts = entry.message?.content;
155
+ if (!Array.isArray(parts)) continue;
156
+
157
+ const textParts = [];
158
+ for (const p of parts) {
159
+ if (p.type === 'text' && p.text) {
160
+ let text = p.text;
161
+ // Clean up user_query wrappers
162
+ text = text.replace(/<\/?user_query>/g, '').trim();
163
+ // Clean attached_files blocks but note file references
164
+ const fileRefs = [];
165
+ text = text.replace(/<attached_files>([\s\S]*?)<\/attached_files>/g, (_, inner) => {
166
+ const paths = inner.match(/path="([^"]+)"/g);
167
+ if (paths) {
168
+ for (const pm of paths) {
169
+ const fp = pm.match(/path="([^"]+)"/);
170
+ if (fp) fileRefs.push(fp[1]);
171
+ }
172
+ }
173
+ return '';
174
+ }).trim();
175
+ // Clean image_files blocks
176
+ text = text.replace(/<image_files>[\s\S]*?<\/image_files>/g, '[image]').trim();
177
+ if (text) textParts.push(text);
178
+ if (fileRefs.length > 0) {
179
+ textParts.push(fileRefs.map(f => `[file: ${f}]`).join('\n'));
180
+ }
181
+ }
182
+ }
183
+
184
+ if (textParts.length > 0) {
185
+ result.push({
186
+ role: entry.role === 'user' ? 'user' : 'assistant',
187
+ content: textParts.join('\n'),
188
+ });
189
+ }
190
+ }
191
+
192
+ return result;
193
+ }
194
+
195
+ module.exports = { name, getChats, getMessages };
package/editors/index.js CHANGED
@@ -6,8 +6,9 @@ const zed = require('./zed');
6
6
  const opencode = require('./opencode');
7
7
  const gemini = require('./gemini');
8
8
  const copilot = require('./copilot');
9
+ const cursorAgent = require('./cursor-agent');
9
10
 
10
- const editors = [cursor, windsurf, claude, vscode, zed, opencode, gemini, copilot];
11
+ const editors = [cursor, windsurf, claude, vscode, zed, opencode, gemini, copilot, cursorAgent];
11
12
 
12
13
  /**
13
14
  * Get all chats from all editor adapters, sorted by most recent first.
package/index.js CHANGED
@@ -94,7 +94,7 @@ console.log(chalk.dim(' Initializing cache database...'));
94
94
  cache.initDb();
95
95
 
96
96
  // Scan all editors and populate cache
97
- console.log(chalk.dim(' Scanning editors: Cursor, Windsurf, Claude Code, VS Code, Zed, Antigravity, OpenCode, Gemini CLI, Copilot CLI'));
97
+ console.log(chalk.dim(' Scanning editors: Cursor, Windsurf, Claude Code, VS Code, Zed, Antigravity, OpenCode, Gemini CLI, Copilot CLI, Cursor Agent'));
98
98
  const startTime = Date.now();
99
99
  const result = cache.scanAll((progress) => {
100
100
  process.stdout.write(chalk.dim(`\r Scanning: ${progress.scanned}/${progress.total} chats (${progress.analyzed} analyzed, ${progress.skipped} cached)`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlytics",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravity, OpenCode",
5
5
  "main": "index.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -69,6 +69,51 @@ app.get('/api/chats/:id', (req, res) => {
69
69
  }
70
70
  });
71
71
 
72
+ app.get('/api/chats/:id/markdown', (req, res) => {
73
+ try {
74
+ const result = cache.getCachedChat(req.params.id);
75
+ if (!result) return res.status(404).json({ error: 'Chat not found' });
76
+
77
+ const lines = [];
78
+ const title = result.name || 'Untitled Session';
79
+ lines.push(`# ${title}\n`);
80
+
81
+ // Metadata
82
+ const meta = [];
83
+ if (result.source) meta.push(`**Editor:** ${result.source}`);
84
+ if (result.mode) meta.push(`**Mode:** ${result.mode}`);
85
+ if (result.folder) meta.push(`**Project:** ${result.folder}`);
86
+ if (result.createdAt) meta.push(`**Created:** ${new Date(result.createdAt).toISOString()}`);
87
+ if (result.lastUpdatedAt) meta.push(`**Updated:** ${new Date(result.lastUpdatedAt).toISOString()}`);
88
+ if (result.stats) {
89
+ meta.push(`**Messages:** ${result.stats.totalMessages}`);
90
+ if (result.stats.totalInputTokens) meta.push(`**Input Tokens:** ${result.stats.totalInputTokens}`);
91
+ if (result.stats.totalOutputTokens) meta.push(`**Output Tokens:** ${result.stats.totalOutputTokens}`);
92
+ const models = [...new Set(result.stats.models || [])];
93
+ if (models.length > 0) meta.push(`**Models:** ${models.join(', ')}`);
94
+ }
95
+ if (meta.length > 0) lines.push(meta.join(' \n') + '\n');
96
+
97
+ lines.push('---\n');
98
+
99
+ // Messages
100
+ for (const msg of result.messages) {
101
+ const label = msg.role === 'user' ? '## User' : msg.role === 'assistant' ? '## Assistant' : `## ${msg.role.charAt(0).toUpperCase() + msg.role.slice(1)}`;
102
+ const modelTag = msg.model ? ` *(${msg.model})*` : '';
103
+ lines.push(`${label}${modelTag}\n`);
104
+ lines.push(msg.content + '\n');
105
+ }
106
+
107
+ const md = lines.join('\n');
108
+ const filename = title.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 80) + '.md';
109
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
110
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
111
+ res.send(md);
112
+ } catch (err) {
113
+ res.status(500).json({ error: err.message });
114
+ }
115
+ });
116
+
72
117
  app.get('/api/projects', (req, res) => {
73
118
  try {
74
119
  res.json(cache.getCachedProjects());
package/share-image.js CHANGED
@@ -22,6 +22,7 @@ const EDITOR_COLORS = {
22
22
  'opencode': '#ec4899',
23
23
  'gemini-cli': '#4285f4',
24
24
  'copilot-cli': '#8957e5',
25
+ 'cursor-agent': '#f59e0b',
25
26
  };
26
27
 
27
28
  const EDITOR_LABELS = {
@@ -37,6 +38,7 @@ const EDITOR_LABELS = {
37
38
  'opencode': 'OpenCode',
38
39
  'gemini-cli': 'Gemini CLI',
39
40
  'copilot-cli': 'Copilot CLI',
41
+ 'cursor-agent': 'Cursor Agent',
40
42
  };
41
43
 
42
44
  function generateShareSvg(overview, stats) {
package/ui/src/lib/api.js CHANGED
@@ -1,4 +1,4 @@
1
- const BASE = '';
1
+ export const BASE = '';
2
2
 
3
3
  export async function fetchOverview(params = {}) {
4
4
  const q = new URLSearchParams();
@@ -11,6 +11,7 @@ export const EDITOR_COLORS = {
11
11
  'opencode': '#ec4899',
12
12
  'gemini-cli': '#4285f4',
13
13
  'copilot-cli': '#8957e5',
14
+ 'cursor-agent': '#f59e0b',
14
15
  };
15
16
 
16
17
  export const EDITOR_LABELS = {
@@ -26,6 +27,7 @@ export const EDITOR_LABELS = {
26
27
  'opencode': 'OpenCode',
27
28
  'gemini-cli': 'Gemini CLI',
28
29
  'copilot-cli': 'Copilot CLI',
30
+ 'cursor-agent': 'Cursor Agent',
29
31
  };
30
32
 
31
33
  export function editorColor(src) {
@@ -1,9 +1,9 @@
1
1
  import { useState, useEffect } from 'react'
2
2
  import { useParams, useNavigate } from 'react-router-dom'
3
- import { ArrowLeft, User, Bot, Wrench, Settings, Play, CheckCircle, ChevronRight, ChevronDown } from 'lucide-react'
3
+ import { ArrowLeft, User, Bot, Wrench, Settings, Play, CheckCircle, ChevronRight, ChevronDown, Download } from 'lucide-react'
4
4
  import ReactMarkdown from 'react-markdown'
5
5
  import remarkGfm from 'remark-gfm'
6
- import { fetchChat } from '../lib/api'
6
+ import { fetchChat, BASE } from '../lib/api'
7
7
  import { editorColor, editorLabel, formatDateTime, formatNumber } from '../lib/constants'
8
8
  import KpiCard from '../components/KpiCard'
9
9
 
@@ -184,7 +184,7 @@ export default function ChatDetail() {
184
184
  {/* Header */}
185
185
  <div className="card p-5 mb-4">
186
186
  <div className="flex items-start justify-between">
187
- <div>
187
+ <div className="flex-1 min-w-0">
188
188
  <h2 className="text-xl font-semibold mb-1" style={{ color: 'var(--c-white)' }}>{chat.name || '(untitled)'}</h2>
189
189
  <div className="flex items-center gap-3 text-xs" style={{ color: 'var(--c-text2)' }}>
190
190
  <span className="inline-flex items-center gap-1.5">
@@ -200,6 +200,16 @@ export default function ChatDetail() {
200
200
  <span className="ml-3 font-mono" style={{ color: 'var(--c-text3)' }}>{chat.id}</span>
201
201
  </div>
202
202
  </div>
203
+ <a
204
+ href={`${BASE}/api/chats/${chat.id}/markdown`}
205
+ download
206
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition shrink-0"
207
+ style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
208
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg2)'}
209
+ onMouseLeave={e => e.currentTarget.style.background = 'var(--c-bg3)'}
210
+ >
211
+ <Download size={13} /> Export .md
212
+ </a>
203
213
  </div>
204
214
  </div>
205
215