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 +3 -2
- package/editors/cursor-agent.js +195 -0
- package/editors/index.js +2 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/server.js +45 -0
- package/share-image.js +2 -0
- package/ui/src/lib/api.js +1 -1
- package/ui/src/lib/constants.js +2 -0
- package/ui/src/pages/ChatDetail.jsx +13 -3
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-
|
|
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
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
package/ui/src/lib/constants.js
CHANGED
|
@@ -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
|
|