cursor-usage-analyzer 0.1.0

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.
@@ -0,0 +1,12 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <module type="WEB_MODULE" version="4">
3
+ <component name="NewModuleRootManager">
4
+ <content url="file://$MODULE_DIR$">
5
+ <excludeFolder url="file://$MODULE_DIR$/.tmp" />
6
+ <excludeFolder url="file://$MODULE_DIR$/temp" />
7
+ <excludeFolder url="file://$MODULE_DIR$/tmp" />
8
+ </content>
9
+ <orderEntry type="inheritedJdk" />
10
+ <orderEntry type="sourceFolder" forTests="false" />
11
+ </component>
12
+ </module>
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectModuleManager">
4
+ <modules>
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/cursor-usage-analyzer.iml" filepath="$PROJECT_DIR$/.idea/cursor-usage-analyzer.iml" />
6
+ </modules>
7
+ </component>
8
+ </project>
package/.idea/vcs.xml ADDED
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="VcsDirectoryMappings">
4
+ <mapping directory="" vcs="Git" />
5
+ </component>
6
+ </project>
package/README.md ADDED
@@ -0,0 +1,266 @@
1
+ # Cursor Usage Analyzer
2
+
3
+ A powerful Node.js tool to analyze and visualize your Cursor AI editor usage. Extract conversation histories, track token usage, and generate beautiful HTML reports with interactive charts.
4
+
5
+ ## Features
6
+
7
+ - πŸ“Š **Comprehensive Analytics**: Track conversations, messages, tokens, code changes, and more
8
+ - πŸ“ˆ **Interactive Charts**: Visualize usage patterns with Chart.js-powered graphs
9
+ - πŸ” **Smart Filtering**: Filter and sort conversations by project, model, date, and name
10
+ - πŸ“ **Full Export**: Export complete conversation histories as readable text files
11
+ - 🎯 **Project Detection**: Automatically resolves workspace names from Cursor's database
12
+ - πŸ“… **Flexible Date Ranges**: Analyze single days, months, or custom periods
13
+
14
+ ## Installation
15
+
16
+ ### Option 1: Use with npx (Recommended)
17
+
18
+ No installation needed! Run directly from npm:
19
+
20
+ ```bash
21
+ npx cursor-usage-analyzer
22
+ ```
23
+
24
+ **First run**: npx will download and cache the package automatically. Subsequent runs use the cached version.
25
+
26
+ ### Option 2: Clone Locally
27
+
28
+ ```bash
29
+ # Clone the repository
30
+ git clone https://github.com/xaverric/cursor-usage-analyzer.git
31
+ cd cursor-usage-analyzer
32
+
33
+ # Install dependencies
34
+ npm install
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ ### Using npx
40
+
41
+ ```bash
42
+ # Analyze today's usage
43
+ npx cursor-usage-analyzer
44
+
45
+ # Analyze yesterday
46
+ npx cursor-usage-analyzer --yesterday
47
+
48
+ # Analyze last month
49
+ npx cursor-usage-analyzer --last-month
50
+
51
+ # Analyze this month
52
+ npx cursor-usage-analyzer --this-month
53
+ ```
54
+
55
+ ### Using locally installed version
56
+
57
+ ```bash
58
+ # Using npm scripts
59
+ npm run analyze
60
+ npm run yesterday
61
+ npm run last-month
62
+ npm run this-month
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ ### Command Line Arguments
68
+
69
+ All these work with both `npx cursor-usage-analyzer` and local `node analyze.js`:
70
+
71
+ ```bash
72
+ # Today (default)
73
+ npx cursor-usage-analyzer
74
+
75
+ # Yesterday
76
+ npx cursor-usage-analyzer --yesterday
77
+
78
+ # Specific date
79
+ npx cursor-usage-analyzer --date 2025-12-01
80
+
81
+ # Date range
82
+ npx cursor-usage-analyzer --from 2025-11-01 --to 2025-11-30
83
+
84
+ # Last month
85
+ npx cursor-usage-analyzer --last-month
86
+
87
+ # This month
88
+ npx cursor-usage-analyzer --this-month
89
+ ```
90
+
91
+ ### NPM Scripts (Local Installation Only)
92
+
93
+ If you cloned the repo locally, you can use these convenient shortcuts:
94
+
95
+ ```bash
96
+ npm run analyze # Today
97
+ npm run yesterday # Yesterday
98
+ npm run last-month # Previous month
99
+ npm run this-month # Current month
100
+ ```
101
+
102
+ ## Output
103
+
104
+ The tool generates the following in `cursor-logs-export/`:
105
+
106
+ ### 1. Text Files (`chats/`)
107
+ Individual conversation exports with full message history:
108
+ ```
109
+ CONVERSATION #1
110
+ Name: Feature implementation
111
+ Workspace: my-project
112
+ Time: 12/8/2025, 2:30:45 PM
113
+ Model: claude-4.5-sonnet-thinking
114
+ Tokens: 15,234 / 200,000 (7.6%)
115
+ Changes: +245 -12 lines in 8 files
116
+ Messages: 23
117
+ ```
118
+
119
+ ### 2. HTML Report (`report.html`)
120
+
121
+ Interactive dashboard featuring:
122
+
123
+ **Summary Statistics**
124
+ - Total conversations, messages, and tokens
125
+ - Lines added/removed and files changed
126
+ - Averages per conversation
127
+
128
+ **Interactive Charts**
129
+ - Tokens over time (bar chart)
130
+ - Messages over time (line chart)
131
+ - Activity distribution (hourly/daily)
132
+ - Model usage (doughnut chart)
133
+ - Project distribution (pie chart)
134
+
135
+ **Filterable Table**
136
+ - Sort by any column (date, name, project, model, etc.)
137
+ - Filter by project, model, name, or date range
138
+ - View complete conversation metadata
139
+
140
+ ## Data Source
141
+
142
+ The analyzer reads from Cursor's local SQLite database:
143
+ ```
144
+ ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb
145
+ ```
146
+
147
+ It extracts:
148
+ - **Conversation metadata**: Names, timestamps, models
149
+ - **Message content**: Full chat history (user prompts & AI responses)
150
+ - **Code changes**: Lines added/removed, files modified
151
+ - **Token usage**: Context tokens used and limits
152
+ - **Project information**: Workspace paths and names
153
+
154
+ ## Requirements
155
+
156
+ - **Node.js**: v14 or higher
157
+ - **Cursor Editor**: Must have conversations stored locally
158
+ - **OS**: macOS (paths are macOS-specific)
159
+
160
+ ## Project Structure
161
+
162
+ ```
163
+ cursor-usage-analyzer/
164
+ β”œβ”€β”€ analyze.js # Main extraction and analysis logic
165
+ β”œβ”€β”€ html-template.js # HTML report generation
166
+ β”œβ”€β”€ package.json # Dependencies and scripts
167
+ └── cursor-logs-export/ # Generated output (gitignored)
168
+ β”œβ”€β”€ chats/ # Individual conversation text files
169
+ └── report.html # Interactive HTML dashboard
170
+ ```
171
+
172
+ ## Tips
173
+
174
+ ### Analyze Specific Periods
175
+
176
+ ```bash
177
+ # Q4 2024
178
+ npx cursor-usage-analyzer --from 2024-10-01 --to 2024-12-31
179
+
180
+ # Specific week
181
+ npx cursor-usage-analyzer --from 2025-12-01 --to 2025-12-07
182
+
183
+ # Single day
184
+ npx cursor-usage-analyzer --date 2025-11-15
185
+ ```
186
+
187
+ ### Understanding the Report
188
+
189
+ - **Context Tokens**: Tokens used in the conversation context (what the AI "sees")
190
+ - **Messages**: Individual chat messages (both user and assistant)
191
+ - **Changes**: Code modifications tracked by Cursor's composer
192
+ - **Unknown Projects**: Conversations where workspace couldn't be determined
193
+
194
+ ### Performance
195
+
196
+ - Large date ranges (e.g., entire year) may take longer to process
197
+ - The tool processes thousands of messages efficiently
198
+ - HTML reports remain performant even with 100+ conversations
199
+
200
+ ## Troubleshooting
201
+
202
+ ### "Database not found"
203
+ Ensure Cursor has been used and conversations exist. Database path:
204
+ ```
205
+ ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb
206
+ ```
207
+
208
+ ### "No conversations found"
209
+ - Check the date range is correct
210
+ - Verify you have conversations in Cursor from that period
211
+ - Ensure Cursor isn't currently running (may lock database)
212
+
213
+ ### Project shows as "unknown"
214
+ This happens when the analyzer can't determine the workspace from file paths. The conversation is still exported with all content intact.
215
+
216
+ ## Privacy & Security
217
+
218
+ - All processing is **100% local** - no data leaves your machine
219
+ - The tool only reads Cursor's database (read-only access)
220
+ - Generated reports can be freely shared or kept private
221
+ - Sensitive conversation content is exported as-is (review before sharing)
222
+
223
+ ## License
224
+
225
+ MIT
226
+
227
+ ## Development
228
+
229
+ ### Testing npx Locally
230
+
231
+ Before publishing to npm, you can test the npx functionality:
232
+
233
+ ```bash
234
+ # In the project directory
235
+ npm link
236
+
237
+ # Now you can run from anywhere
238
+ cursor-usage-analyzer --yesterday
239
+ cursor-usage-analyzer --last-month
240
+
241
+ # Unlink when done testing
242
+ npm unlink -g cursor-usage-analyzer
243
+ ```
244
+
245
+ ### Publishing to npm
246
+
247
+ Once ready to publish (requires npm account):
248
+
249
+ ```bash
250
+ # Login to npm
251
+ npm login
252
+
253
+ # Publish the package
254
+ npm publish
255
+
256
+ # Now anyone can use it
257
+ npx cursor-usage-analyzer
258
+ ```
259
+
260
+ ## Contributing
261
+
262
+ Feel free to open issues or submit pull requests for improvements!
263
+
264
+ ---
265
+
266
+ **Made with ❀️ for Cursor users who love data**
package/analyze.js ADDED
@@ -0,0 +1,426 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ import Database from 'better-sqlite3';
7
+ import { generateHTMLReport } from './html-template.js';
8
+
9
+ // Constants
10
+ const CURSOR_GLOBAL_STORAGE = path.join(os.homedir(), 'Library/Application Support/Cursor/User/globalStorage');
11
+ const CURSOR_WORKSPACE_STORAGE = path.join(os.homedir(), 'Library/Application Support/Cursor/User/workspaceStorage');
12
+ const OUTPUT_DIR = path.join(process.cwd(), 'cursor-logs-export');
13
+ const CHATS_DIR = path.join(OUTPUT_DIR, 'chats');
14
+ const REPORT_PATH = path.join(OUTPUT_DIR, 'report.html');
15
+
16
+ // Helpers
17
+ const formatDate = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
18
+
19
+ const setDayBounds = (date, start = true) => {
20
+ const d = new Date(date);
21
+ d.setHours(start ? 0 : 23, start ? 0 : 59, start ? 0 : 59, start ? 0 : 999);
22
+ return d;
23
+ };
24
+
25
+ // Parse command line arguments
26
+ function parseArgs() {
27
+ const args = process.argv.slice(2);
28
+ let startDate, endDate, dateStr;
29
+
30
+ if (args.includes('--last-month')) {
31
+ const now = new Date();
32
+ startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
33
+ endDate = new Date(now.getFullYear(), now.getMonth(), 0);
34
+ dateStr = `${formatDate(startDate)}_${formatDate(endDate)}`;
35
+ } else if (args.includes('--this-month')) {
36
+ const now = new Date();
37
+ startDate = new Date(now.getFullYear(), now.getMonth(), 1);
38
+ endDate = new Date();
39
+ dateStr = `${formatDate(startDate)}_${formatDate(endDate)}`;
40
+ } else if (args.includes('--from') && args.includes('--to')) {
41
+ const fromIdx = args.indexOf('--from');
42
+ const toIdx = args.indexOf('--to');
43
+ startDate = new Date(args[fromIdx + 1]);
44
+ endDate = new Date(args[toIdx + 1]);
45
+ dateStr = `${args[fromIdx + 1]}_${args[toIdx + 1]}`;
46
+ } else if (args.includes('--yesterday')) {
47
+ startDate = new Date();
48
+ startDate.setDate(startDate.getDate() - 1);
49
+ endDate = new Date(startDate);
50
+ dateStr = formatDate(startDate);
51
+ } else if (args.includes('--date')) {
52
+ const dateIdx = args.indexOf('--date');
53
+ startDate = new Date(args[dateIdx + 1]);
54
+ endDate = new Date(startDate);
55
+ dateStr = formatDate(startDate);
56
+ } else {
57
+ startDate = endDate = new Date();
58
+ dateStr = formatDate(startDate);
59
+ }
60
+
61
+ return {
62
+ startOfDay: setDayBounds(startDate, true).getTime(),
63
+ endOfDay: setDayBounds(endDate, false).getTime(),
64
+ dateStr
65
+ };
66
+ }
67
+
68
+ // Extract text from various bubble formats
69
+ function extractTextFromBubble(bubble) {
70
+ if (bubble.text?.trim()) return bubble.text;
71
+
72
+ if (bubble.richText) {
73
+ try {
74
+ const richData = JSON.parse(bubble.richText);
75
+ if (richData.root?.children) {
76
+ return extractRichText(richData.root.children);
77
+ }
78
+ } catch (e) {}
79
+ }
80
+
81
+ let text = '';
82
+ if (bubble.codeBlocks?.length) {
83
+ text = bubble.codeBlocks
84
+ .filter(cb => cb.content)
85
+ .map(cb => `\n\`\`\`${cb.language || ''}\n${cb.content}\n\`\`\``)
86
+ .join('');
87
+ }
88
+
89
+ return text;
90
+ }
91
+
92
+ function extractRichText(children) {
93
+ return children.map(child => {
94
+ if (child.type === 'text' && child.text) return child.text;
95
+ if (child.type === 'code' && child.children) return '\n```\n' + extractRichText(child.children) + '\n```\n';
96
+ if (child.children?.length) return extractRichText(child.children);
97
+ return '';
98
+ }).join('');
99
+ }
100
+
101
+ // Read workspace name from workspace.json
102
+ function readWorkspaceJson(workspaceId) {
103
+ try {
104
+ const jsonPath = path.join(CURSOR_WORKSPACE_STORAGE, workspaceId, 'workspace.json');
105
+ if (fs.existsSync(jsonPath)) {
106
+ const data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
107
+ if (data.folder) {
108
+ return path.basename(data.folder.replace('file://', ''));
109
+ }
110
+ }
111
+ } catch (e) {}
112
+ return null;
113
+ }
114
+
115
+ // Find workspace name from file path
116
+ function findWorkspaceFromPath(filePath) {
117
+ try {
118
+ const entries = fs.readdirSync(CURSOR_WORKSPACE_STORAGE);
119
+ let bestMatch = null;
120
+ let bestLen = 0;
121
+
122
+ for (const entry of entries) {
123
+ const name = readWorkspaceJson(entry);
124
+ if (!name) continue;
125
+
126
+ try {
127
+ const jsonPath = path.join(CURSOR_WORKSPACE_STORAGE, entry, 'workspace.json');
128
+ const data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
129
+ const wsPath = data.folder?.replace('file://', '');
130
+
131
+ if (wsPath && filePath.startsWith(wsPath) && wsPath.length > bestLen) {
132
+ bestMatch = name;
133
+ bestLen = wsPath.length;
134
+ }
135
+ } catch (e) {}
136
+ }
137
+
138
+ if (bestMatch) return bestMatch;
139
+ } catch (e) {}
140
+
141
+ const parts = filePath.split('/');
142
+ return parts[parts.length - 2] || 'unknown';
143
+ }
144
+
145
+ // Determine workspace name from multiple sources
146
+ function resolveWorkspace(composerData, headers, db, composerId) {
147
+ // Try workspaceId first
148
+ if (composerData.workspaceId) {
149
+ const name = readWorkspaceJson(composerData.workspaceId);
150
+ if (name) return name;
151
+ }
152
+
153
+ // Try file paths from various sources
154
+ const fileSources = [
155
+ composerData.newlyCreatedFiles?.[0]?.uri?.path,
156
+ Object.keys(composerData.codeBlockData || {})[0]?.replace('file://', ''),
157
+ composerData.addedFiles?.[0]?.replace('file://', ''),
158
+ composerData.allAttachedFileCodeChunksUris?.[0]?.replace('file://', '')
159
+ ];
160
+
161
+ for (const filePath of fileSources) {
162
+ if (filePath) {
163
+ const name = findWorkspaceFromPath(filePath);
164
+ if (name !== 'unknown') return name;
165
+ }
166
+ }
167
+
168
+ // Last resort: messageRequestContext
169
+ if (headers?.length > 0) {
170
+ try {
171
+ const contextKey = `messageRequestContext:${composerId}:${headers[0].bubbleId}`;
172
+ const row = db.prepare("SELECT value FROM cursorDiskKV WHERE key = ?").get(contextKey);
173
+ if (row) {
174
+ const context = JSON.parse(row.value);
175
+ if (context.projectLayouts?.length > 0) {
176
+ const layout = JSON.parse(context.projectLayouts[0]);
177
+ const absPath = layout.listDirV2Result?.directoryTreeRoot?.absPath;
178
+ if (absPath) return findWorkspaceFromPath(absPath);
179
+ }
180
+ }
181
+ } catch (e) {}
182
+ }
183
+
184
+ return 'unknown';
185
+ }
186
+
187
+ // Extract conversations from database
188
+ function extractConversations(startTime, endTime) {
189
+ const dbPath = path.join(CURSOR_GLOBAL_STORAGE, 'state.vscdb');
190
+
191
+ if (!fs.existsSync(dbPath)) {
192
+ console.log('Database not found');
193
+ return [];
194
+ }
195
+
196
+ try {
197
+ const db = new Database(dbPath, { readonly: true });
198
+
199
+ // Load all bubbles
200
+ const bubbleMap = {};
201
+ const bubbleRows = db.prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'").all();
202
+
203
+ for (const row of bubbleRows) {
204
+ try {
205
+ const bubbleId = row.key.split(':')[2];
206
+ const bubble = JSON.parse(row.value);
207
+ if (bubble) bubbleMap[bubbleId] = bubble;
208
+ } catch (e) {}
209
+ }
210
+
211
+ // Load composers
212
+ const composerRows = db.prepare(
213
+ "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%' AND value LIKE '%fullConversationHeadersOnly%'"
214
+ ).all();
215
+
216
+ const conversations = [];
217
+
218
+ for (const row of composerRows) {
219
+ try {
220
+ const composerId = row.key.split(':')[1];
221
+ const composer = JSON.parse(row.value);
222
+ const timestamp = composer.lastUpdatedAt || composer.createdAt || Date.now();
223
+
224
+ if (timestamp < startTime || timestamp > endTime) continue;
225
+
226
+ const headers = composer.fullConversationHeadersOnly || [];
227
+ if (headers.length === 0) continue;
228
+
229
+ // Extract messages
230
+ const messages = headers
231
+ .map(h => {
232
+ const bubble = bubbleMap[h.bubbleId];
233
+ if (!bubble) return null;
234
+
235
+ const text = extractTextFromBubble(bubble);
236
+ if (!text?.trim()) return null;
237
+
238
+ return {
239
+ role: h.type === 1 ? 'user' : 'assistant',
240
+ text: text.trim(),
241
+ timestamp: bubble.timestamp || Date.now()
242
+ };
243
+ })
244
+ .filter(Boolean);
245
+
246
+ if (messages.length === 0) continue;
247
+
248
+ conversations.push({
249
+ composerId,
250
+ name: composer.name || 'Untitled Chat',
251
+ timestamp,
252
+ messages,
253
+ messageCount: messages.length,
254
+ workspace: resolveWorkspace(composer, headers, db, composerId),
255
+ model: composer.modelConfig?.modelName || 'unknown',
256
+ contextTokensUsed: composer.contextTokensUsed || 0,
257
+ contextTokenLimit: composer.contextTokenLimit || 0,
258
+ totalLinesAdded: composer.totalLinesAdded || 0,
259
+ totalLinesRemoved: composer.totalLinesRemoved || 0,
260
+ filesChangedCount: composer.filesChangedCount || 0
261
+ });
262
+ } catch (e) {
263
+ // Silently skip invalid composers
264
+ }
265
+ }
266
+
267
+ db.close();
268
+ return conversations;
269
+ } catch (error) {
270
+ console.error('Database error:', error.message);
271
+ return [];
272
+ }
273
+ }
274
+
275
+ // Export conversation to text file
276
+ function exportConversation(conv, index, dateStr) {
277
+ const timestamp = new Date(conv.timestamp);
278
+ const timeStr = timestamp.toTimeString().split(' ')[0].replace(/:/g, '-');
279
+ const workspaceShort = conv.workspace.substring(0, 15).replace(/[^a-zA-Z0-9]/g, '_');
280
+ const filename = `${dateStr}_${timeStr}_${workspaceShort}_conv${index}.txt`;
281
+
282
+ const percentUsed = (conv.contextTokensUsed / conv.contextTokenLimit * 100).toFixed(1);
283
+
284
+ const lines = [
285
+ '='.repeat(80),
286
+ `CONVERSATION #${index}`,
287
+ `Name: ${conv.name}`,
288
+ `Workspace: ${conv.workspace}`,
289
+ `Time: ${timestamp.toLocaleString('en-US')}`,
290
+ `Model: ${conv.model}`,
291
+ `Tokens: ${conv.contextTokensUsed.toLocaleString()} / ${conv.contextTokenLimit.toLocaleString()} (${percentUsed}%)`,
292
+ `Changes: +${conv.totalLinesAdded} -${conv.totalLinesRemoved} lines in ${conv.filesChangedCount} files`,
293
+ `Messages: ${conv.messageCount}`,
294
+ `Composer ID: ${conv.composerId}`,
295
+ '='.repeat(80),
296
+ ''
297
+ ];
298
+
299
+ for (const msg of conv.messages) {
300
+ const msgTime = new Date(msg.timestamp).toLocaleTimeString('en-US');
301
+ const role = msg.role.toUpperCase();
302
+
303
+ lines.push(
304
+ '',
305
+ '-'.repeat(80),
306
+ `[${role}] ${msgTime}`,
307
+ '-'.repeat(80),
308
+ msg.text
309
+ );
310
+ }
311
+
312
+ lines.push('', '='.repeat(80), 'End of conversation', '='.repeat(80));
313
+
314
+ fs.writeFileSync(path.join(CHATS_DIR, filename), lines.join('\n'), 'utf-8');
315
+ return filename;
316
+ }
317
+
318
+ // Generate statistics
319
+ function generateStats(conversations, startOfDay, endOfDay) {
320
+ const daysDiff = Math.ceil((endOfDay - startOfDay) / (1000 * 60 * 60 * 24));
321
+
322
+ const stats = {
323
+ totalConversations: conversations.length,
324
+ totalMessages: 0,
325
+ totalTokens: 0,
326
+ totalLinesAdded: 0,
327
+ totalLinesRemoved: 0,
328
+ totalFilesChanged: 0,
329
+ modelUsage: {},
330
+ workspaceUsage: {},
331
+ hourlyDistribution: Array(24).fill(0),
332
+ dailyDistribution: {},
333
+ isMultiDay: daysDiff > 1,
334
+ conversations: [],
335
+ generatedAt: new Date().toLocaleString('en-US')
336
+ };
337
+
338
+ for (const conv of conversations) {
339
+ const ts = new Date(conv.timestamp);
340
+
341
+ stats.totalMessages += conv.messageCount;
342
+ stats.totalTokens += conv.contextTokensUsed;
343
+ stats.totalLinesAdded += conv.totalLinesAdded;
344
+ stats.totalLinesRemoved += conv.totalLinesRemoved;
345
+ stats.totalFilesChanged += conv.filesChangedCount;
346
+
347
+ stats.modelUsage[conv.model] = (stats.modelUsage[conv.model] || 0) + 1;
348
+ stats.workspaceUsage[conv.workspace] = (stats.workspaceUsage[conv.workspace] || 0) + 1;
349
+ stats.hourlyDistribution[ts.getHours()]++;
350
+
351
+ const dateKey = ts.toLocaleDateString('en-US');
352
+ stats.dailyDistribution[dateKey] = (stats.dailyDistribution[dateKey] || 0) + 1;
353
+
354
+ const firstUserMsg = conv.messages.find(m => m.role === 'user');
355
+ const preview = firstUserMsg?.text.substring(0, 100) + (firstUserMsg?.text.length > 100 ? '...' : '') || '(no user message)';
356
+
357
+ stats.conversations.push({
358
+ timestamp: conv.timestamp,
359
+ time: ts.toLocaleTimeString('en-US'),
360
+ date: ts.toLocaleDateString('en-US'),
361
+ datetime: ts.toLocaleString('en-US'),
362
+ name: conv.name,
363
+ workspace: conv.workspace,
364
+ model: conv.model,
365
+ messages: conv.messageCount,
366
+ tokens: conv.contextTokensUsed,
367
+ contextLimit: conv.contextTokenLimit,
368
+ linesChanged: `+${conv.totalLinesAdded}/-${conv.totalLinesRemoved}`,
369
+ files: conv.filesChangedCount,
370
+ preview
371
+ });
372
+ }
373
+
374
+ stats.conversations.sort((a, b) => a.timestamp - b.timestamp);
375
+
376
+ return stats;
377
+ }
378
+
379
+ // Main
380
+ async function main() {
381
+ console.log('Cursor Usage Analyzer v2\n');
382
+
383
+ const { startOfDay, endOfDay, dateStr } = parseArgs();
384
+
385
+ console.log(`Analyzing: ${dateStr}`);
386
+ console.log(`Period: ${new Date(startOfDay).toLocaleString('en-US')} - ${new Date(endOfDay).toLocaleString('en-US')}\n`);
387
+
388
+ // Prepare output folders
389
+ if (fs.existsSync(OUTPUT_DIR)) fs.rmSync(OUTPUT_DIR, { recursive: true });
390
+ fs.mkdirSync(CHATS_DIR, { recursive: true });
391
+
392
+ console.log('Extracting conversations...');
393
+
394
+ const conversations = extractConversations(startOfDay, endOfDay);
395
+
396
+ console.log(`Found ${conversations.length} conversations\n`);
397
+
398
+ if (conversations.length === 0) {
399
+ console.log('No conversations found in specified period');
400
+ return;
401
+ }
402
+
403
+ console.log('Exporting conversations...');
404
+ conversations.forEach((conv, i) => {
405
+ exportConversation(conv, i + 1, dateStr);
406
+ });
407
+
408
+ console.log('Generating statistics...');
409
+ const stats = generateStats(conversations, startOfDay, endOfDay);
410
+
411
+ console.log('Generating HTML report...');
412
+ try {
413
+ generateHTMLReport(stats, dateStr, REPORT_PATH);
414
+ } catch (e) {
415
+ console.error('Error generating report:', e.message);
416
+ console.error(e.stack);
417
+ return;
418
+ }
419
+
420
+ console.log('\nDone!\n');
421
+ console.log(`Export folder: ${OUTPUT_DIR}`);
422
+ console.log(`Conversations: ${conversations.length}`);
423
+ console.log(`Report: ${REPORT_PATH}`);
424
+ }
425
+
426
+ main().catch(console.error);
Binary file
@@ -0,0 +1,452 @@
1
+ import fs from 'fs';
2
+
3
+ export function generateHTMLReport(stats, dateStr, reportPath) {
4
+ const avg = (total, count) => count > 0 ? Math.round(total / count) : 0;
5
+
6
+ const avgTokens = avg(stats.totalTokens, stats.totalConversations);
7
+ const avgMessages = avg(stats.totalMessages, stats.totalConversations);
8
+ const avgLines = avg(stats.totalLinesAdded + stats.totalLinesRemoved, stats.totalConversations);
9
+
10
+ // Prepare chart data
11
+ const isMultiDay = stats.isMultiDay;
12
+ const timeLabels = stats.conversations.map(c =>
13
+ isMultiDay ? `${c.date} ${c.time}` : `${c.time} ${c.name.substring(0, 20)}`
14
+ );
15
+ const tokenData = stats.conversations.map(c => c.tokens);
16
+ const messageData = stats.conversations.map(c => c.messages);
17
+
18
+ const activityTitle = isMultiDay ? 'Activity Over Period' : 'Activity During Day';
19
+ const activityLabels = isMultiDay
20
+ ? Object.keys(stats.dailyDistribution).sort()
21
+ : Array.from({length: 24}, (_, i) => i + ':00');
22
+ const activityData = isMultiDay
23
+ ? activityLabels.map(k => stats.dailyDistribution[k])
24
+ : stats.hourlyDistribution;
25
+
26
+ const html = `<!DOCTYPE html>
27
+ <html lang="en">
28
+ <head>
29
+ <meta charset="UTF-8">
30
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
31
+ <title>Cursor Usage Report - ${dateStr}</title>
32
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
33
+ <style>
34
+ * { box-sizing: border-box; margin: 0; padding: 0; }
35
+ body {
36
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
37
+ padding: 20px;
38
+ background: #f9fafb;
39
+ color: #111827;
40
+ }
41
+ .container { max-width: 1400px; margin: 0 auto; }
42
+ h1 { color: #2563eb; margin-bottom: 8px; }
43
+ h2 {
44
+ color: #111827;
45
+ margin: 40px 0 20px;
46
+ font-size: 20px;
47
+ font-weight: 600;
48
+ border-bottom: 2px solid #e5e7eb;
49
+ padding-bottom: 8px;
50
+ }
51
+ .date { color: #6b7280; margin-bottom: 32px; font-size: 14px; }
52
+
53
+ .stats-grid, .charts-grid {
54
+ display: grid;
55
+ gap: 16px;
56
+ margin-bottom: 40px;
57
+ }
58
+ .stats-grid { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
59
+ .charts-grid { grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 20px; }
60
+
61
+ .stat, .chart-container, .filters, table {
62
+ background: white;
63
+ border-radius: 8px;
64
+ border: 1px solid #e5e7eb;
65
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
66
+ }
67
+
68
+ .stat { padding: 20px; }
69
+ .stat-value { font-size: 32px; font-weight: 700; color: #2563eb; margin-bottom: 4px; }
70
+ .stat-label { color: #6b7280; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; }
71
+ .stat-sublabel { color: #9ca3af; font-size: 12px; margin-top: 4px; }
72
+
73
+ .chart-container { padding: 24px; }
74
+ .chart-title { font-size: 16px; font-weight: 600; color: #111827; margin-bottom: 16px; }
75
+
76
+ .filters {
77
+ display: grid;
78
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
79
+ gap: 12px;
80
+ padding: 16px;
81
+ margin-bottom: 20px;
82
+ }
83
+ .filter-group { display: flex; flex-direction: column; gap: 4px; }
84
+ .filter-group label {
85
+ font-size: 11px;
86
+ color: #6b7280;
87
+ text-transform: uppercase;
88
+ letter-spacing: 0.5px;
89
+ font-weight: 600;
90
+ }
91
+ .filter-group input, .filter-group select {
92
+ padding: 8px 12px;
93
+ border: 1px solid #e5e7eb;
94
+ border-radius: 4px;
95
+ font-size: 13px;
96
+ background: white;
97
+ }
98
+ .filter-group input:focus, .filter-group select:focus {
99
+ outline: none;
100
+ border-color: #2563eb;
101
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
102
+ }
103
+
104
+ table { width: 100%; border-collapse: collapse; overflow: hidden; }
105
+ th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #e5e7eb; }
106
+ th {
107
+ background: #f9fafb;
108
+ font-weight: 600;
109
+ font-size: 13px;
110
+ color: #6b7280;
111
+ text-transform: uppercase;
112
+ letter-spacing: 0.5px;
113
+ cursor: pointer;
114
+ user-select: none;
115
+ position: relative;
116
+ padding-right: 28px;
117
+ }
118
+ th:hover { background: #f3f4f6; }
119
+ th.sortable::after { content: 'β‡…'; position: absolute; right: 8px; opacity: 0.3; font-size: 14px; }
120
+ th.sortable.asc::after { content: '↑'; opacity: 1; }
121
+ th.sortable.desc::after { content: '↓'; opacity: 1; }
122
+ td { font-size: 14px; }
123
+ tr:last-child td { border-bottom: none; }
124
+ tr:hover { background: #f9fafb; }
125
+ small { font-size: 12px; color: #6b7280; }
126
+
127
+ @media (max-width: 768px) {
128
+ .charts-grid { grid-template-columns: 1fr; }
129
+ .stats-grid { grid-template-columns: repeat(2, 1fr); }
130
+ }
131
+ </style>
132
+ </head>
133
+ <body>
134
+ <div class="container">
135
+ <h1>Cursor Usage Report</h1>
136
+ <div class="date">${dateStr}</div>
137
+ <div class="date" style="font-size: 12px; margin-top: 4px;">Generated: ${stats.generatedAt}</div>
138
+
139
+ <div class="stats-grid">
140
+ <div class="stat">
141
+ <div class="stat-value">${stats.totalConversations}</div>
142
+ <div class="stat-label">Conversations</div>
143
+ </div>
144
+ <div class="stat">
145
+ <div class="stat-value">${stats.totalMessages.toLocaleString()}</div>
146
+ <div class="stat-label">Messages</div>
147
+ <div class="stat-sublabel">Ø ${avgMessages} per conversation</div>
148
+ </div>
149
+ <div class="stat">
150
+ <div class="stat-value">${stats.totalTokens.toLocaleString()}</div>
151
+ <div class="stat-label">Context Tokens</div>
152
+ <div class="stat-sublabel">Ø ${avgTokens.toLocaleString()} per conversation</div>
153
+ </div>
154
+ <div class="stat">
155
+ <div class="stat-value">+${stats.totalLinesAdded.toLocaleString()}</div>
156
+ <div class="stat-label">Lines Added</div>
157
+ </div>
158
+ <div class="stat">
159
+ <div class="stat-value">-${stats.totalLinesRemoved.toLocaleString()}</div>
160
+ <div class="stat-label">Lines Removed</div>
161
+ </div>
162
+ <div class="stat">
163
+ <div class="stat-value">${stats.totalFilesChanged.toLocaleString()}</div>
164
+ <div class="stat-label">Files Changed</div>
165
+ <div class="stat-sublabel">Ø ${avgLines} lines per conversation</div>
166
+ </div>
167
+ </div>
168
+
169
+ <h2>Usage Charts</h2>
170
+
171
+ <div class="charts-grid">
172
+ <div class="chart-container">
173
+ <div class="chart-title">Tokens Over Time</div>
174
+ <canvas id="tokensChart"></canvas>
175
+ </div>
176
+ <div class="chart-container">
177
+ <div class="chart-title">Messages Over Time</div>
178
+ <canvas id="messagesChart"></canvas>
179
+ </div>
180
+ <div class="chart-container">
181
+ <div class="chart-title">${activityTitle}</div>
182
+ <canvas id="activityChart"></canvas>
183
+ </div>
184
+ <div class="chart-container">
185
+ <div class="chart-title">Distribution by Model</div>
186
+ <canvas id="modelsChart"></canvas>
187
+ </div>
188
+ <div class="chart-container">
189
+ <div class="chart-title">Distribution by Project</div>
190
+ <canvas id="workspacesChart"></canvas>
191
+ </div>
192
+ </div>
193
+
194
+ <h2>Conversation Details</h2>
195
+
196
+ <div class="filters">
197
+ <div class="filter-group">
198
+ <label>Name</label>
199
+ <input type="text" id="filterName" placeholder="Filter...">
200
+ </div>
201
+ <div class="filter-group">
202
+ <label>Project</label>
203
+ <select id="filterWorkspace">
204
+ <option value="">All projects</option>
205
+ ${[...new Set(stats.conversations.map(c => c.workspace))].sort().map(w =>
206
+ `<option value="${w}">${w}</option>`
207
+ ).join('')}
208
+ </select>
209
+ </div>
210
+ <div class="filter-group">
211
+ <label>Model</label>
212
+ <select id="filterModel">
213
+ <option value="">All models</option>
214
+ ${[...new Set(stats.conversations.map(c => c.model))].sort().map(m =>
215
+ `<option value="${m}">${m}</option>`
216
+ ).join('')}
217
+ </select>
218
+ </div>
219
+ <div class="filter-group">
220
+ <label>Date From</label>
221
+ <input type="date" id="filterDateFrom">
222
+ </div>
223
+ <div class="filter-group">
224
+ <label>Date To</label>
225
+ <input type="date" id="filterDateTo">
226
+ </div>
227
+ </div>
228
+
229
+ <table id="conversationsTable">
230
+ <thead>
231
+ <tr>
232
+ <th class="sortable" data-column="datetime" data-type="date">Date & Time</th>
233
+ <th class="sortable" data-column="name" data-type="string">Name</th>
234
+ <th class="sortable" data-column="workspace" data-type="string">Project</th>
235
+ <th class="sortable" data-column="model" data-type="string">Model</th>
236
+ <th class="sortable" data-column="messages" data-type="number">Messages</th>
237
+ <th class="sortable" data-column="tokens" data-type="number">Context Tokens</th>
238
+ <th class="sortable" data-column="linesChanged" data-type="string">Changes</th>
239
+ <th class="sortable" data-column="files" data-type="number">Files</th>
240
+ </tr>
241
+ </thead>
242
+ <tbody id="conversationsBody">
243
+ ${stats.conversations.map((c, idx) => `
244
+ <tr data-index="${idx}">
245
+ <td data-value="${c.timestamp}"><small>${c.datetime}</small></td>
246
+ <td data-value="${c.name}">${c.name}</td>
247
+ <td data-value="${c.workspace}">${c.workspace}</td>
248
+ <td data-value="${c.model}"><small>${c.model}</small></td>
249
+ <td data-value="${c.messages}">${c.messages}</td>
250
+ <td data-value="${c.tokens}"><small>${c.tokens.toLocaleString()} / ${c.contextLimit.toLocaleString()}</small></td>
251
+ <td data-value="${c.linesChanged}">${c.linesChanged}</td>
252
+ <td data-value="${c.files}">${c.files}</td>
253
+ </tr>
254
+ `).join('')}
255
+ </tbody>
256
+ </table>
257
+ </div>
258
+
259
+ <script>
260
+ const data = ${JSON.stringify({
261
+ conversations: stats.conversations,
262
+ timeLabels,
263
+ tokenData,
264
+ messageData,
265
+ activityLabels,
266
+ activityData,
267
+ modelLabels: Object.keys(stats.modelUsage),
268
+ modelCounts: Object.values(stats.modelUsage),
269
+ workspaceLabels: Object.keys(stats.workspaceUsage),
270
+ workspaceCounts: Object.values(stats.workspaceUsage),
271
+ isMultiDay
272
+ })};
273
+
274
+ // Chart defaults
275
+ const baseOptions = {
276
+ responsive: true,
277
+ maintainAspectRatio: true,
278
+ plugins: { legend: { display: false } },
279
+ scales: {
280
+ y: { beginAtZero: true, grid: { color: '#f3f4f6' } },
281
+ x: { grid: { display: false }, ticks: { maxRotation: 45, minRotation: 45, font: { size: 10 } } }
282
+ }
283
+ };
284
+
285
+ // Create chart helper
286
+ const createChart = (id, type, labels, dataset, options = {}) => {
287
+ new Chart(document.getElementById(id), {
288
+ type,
289
+ data: { labels, datasets: [dataset] },
290
+ options: { ...baseOptions, ...options }
291
+ });
292
+ };
293
+
294
+ // Create charts
295
+ createChart('tokensChart', 'bar', data.timeLabels, {
296
+ label: 'Tokens',
297
+ data: data.tokenData,
298
+ backgroundColor: 'rgba(37, 99, 235, 0.8)',
299
+ borderColor: 'rgb(37, 99, 235)',
300
+ borderWidth: 1
301
+ }, {
302
+ scales: {
303
+ ...baseOptions.scales,
304
+ y: {
305
+ ...baseOptions.scales.y,
306
+ ticks: { callback: v => v.toLocaleString() }
307
+ }
308
+ }
309
+ });
310
+
311
+ createChart('messagesChart', 'line', data.timeLabels, {
312
+ label: 'Messages',
313
+ data: data.messageData,
314
+ backgroundColor: 'rgba(59, 130, 246, 0.1)',
315
+ borderColor: 'rgb(59, 130, 246)',
316
+ borderWidth: 2,
317
+ fill: true,
318
+ tension: 0.4
319
+ });
320
+
321
+ createChart('activityChart', 'bar', data.activityLabels, {
322
+ label: 'Conversations',
323
+ data: data.activityData,
324
+ backgroundColor: 'rgba(34, 197, 94, 0.8)',
325
+ borderColor: 'rgb(34, 197, 94)',
326
+ borderWidth: 1
327
+ }, {
328
+ scales: {
329
+ ...baseOptions.scales,
330
+ x: {
331
+ ...baseOptions.scales.x,
332
+ ticks: {
333
+ maxRotation: data.isMultiDay ? 90 : 0,
334
+ minRotation: data.isMultiDay ? 45 : 0,
335
+ font: { size: 10 }
336
+ }
337
+ }
338
+ }
339
+ });
340
+
341
+ const pieColors = [
342
+ 'rgba(37, 99, 235, 0.8)', 'rgba(59, 130, 246, 0.8)',
343
+ 'rgba(96, 165, 250, 0.8)', 'rgba(147, 197, 253, 0.8)',
344
+ 'rgba(34, 197, 94, 0.8)', 'rgba(251, 146, 60, 0.8)',
345
+ 'rgba(168, 85, 247, 0.8)', 'rgba(236, 72, 153, 0.8)'
346
+ ];
347
+
348
+ new Chart(document.getElementById('modelsChart'), {
349
+ type: 'doughnut',
350
+ data: {
351
+ labels: data.modelLabels,
352
+ datasets: [{
353
+ data: data.modelCounts,
354
+ backgroundColor: pieColors,
355
+ borderWidth: 1
356
+ }]
357
+ },
358
+ options: {
359
+ responsive: true,
360
+ maintainAspectRatio: true,
361
+ plugins: { legend: { display: true, position: 'bottom' } }
362
+ }
363
+ });
364
+
365
+ new Chart(document.getElementById('workspacesChart'), {
366
+ type: 'pie',
367
+ data: {
368
+ labels: data.workspaceLabels,
369
+ datasets: [{
370
+ data: data.workspaceCounts,
371
+ backgroundColor: pieColors,
372
+ borderWidth: 1
373
+ }]
374
+ },
375
+ options: {
376
+ responsive: true,
377
+ maintainAspectRatio: true,
378
+ plugins: { legend: { display: true, position: 'bottom' } }
379
+ }
380
+ });
381
+
382
+ // Table sorting
383
+ let sortState = { column: null, dir: 'asc' };
384
+
385
+ document.querySelectorAll('th.sortable').forEach(th => {
386
+ th.addEventListener('click', () => {
387
+ const col = th.dataset.column;
388
+ const type = th.dataset.type;
389
+ const colIdx = Array.from(th.parentElement.children).indexOf(th);
390
+
391
+ sortState.dir = sortState.column === col ? (sortState.dir === 'asc' ? 'desc' : 'asc') : 'asc';
392
+ sortState.column = col;
393
+
394
+ document.querySelectorAll('th.sortable').forEach(h => h.classList.remove('asc', 'desc'));
395
+ th.classList.add(sortState.dir);
396
+
397
+ const tbody = document.getElementById('conversationsBody');
398
+ const rows = Array.from(tbody.querySelectorAll('tr'));
399
+
400
+ rows.sort((a, b) => {
401
+ let aVal = a.children[colIdx].dataset.value;
402
+ let bVal = b.children[colIdx].dataset.value;
403
+
404
+ if (type === 'number' || type === 'date') {
405
+ aVal = parseFloat(aVal) || 0;
406
+ bVal = parseFloat(bVal) || 0;
407
+ }
408
+
409
+ const result = aVal < bVal ? -1 : (aVal > bVal ? 1 : 0);
410
+ return sortState.dir === 'asc' ? result : -result;
411
+ });
412
+
413
+ rows.forEach(row => tbody.appendChild(row));
414
+ });
415
+ });
416
+
417
+ // Table filtering
418
+ const applyFilters = () => {
419
+ const filters = {
420
+ name: document.getElementById('filterName').value.toLowerCase(),
421
+ workspace: document.getElementById('filterWorkspace').value,
422
+ model: document.getElementById('filterModel').value,
423
+ dateFrom: document.getElementById('filterDateFrom').value,
424
+ dateTo: document.getElementById('filterDateTo').value
425
+ };
426
+
427
+ const fromTs = filters.dateFrom ? new Date(filters.dateFrom).getTime() : 0;
428
+ const toTs = filters.dateTo ? new Date(filters.dateTo + 'T23:59:59').getTime() : Infinity;
429
+
430
+ document.querySelectorAll('#conversationsBody tr').forEach(row => {
431
+ const idx = parseInt(row.dataset.index);
432
+ const conv = data.conversations[idx];
433
+
434
+ const show = (!filters.name || conv.name.toLowerCase().includes(filters.name)) &&
435
+ (!filters.workspace || conv.workspace === filters.workspace) &&
436
+ (!filters.model || conv.model === filters.model) &&
437
+ (conv.timestamp >= fromTs && conv.timestamp <= toTs);
438
+
439
+ row.style.display = show ? '' : 'none';
440
+ });
441
+ };
442
+
443
+ ['filterName', 'filterWorkspace', 'filterModel', 'filterDateFrom', 'filterDateTo'].forEach(id => {
444
+ const el = document.getElementById(id);
445
+ el.addEventListener(el.tagName === 'SELECT' ? 'change' : 'input', applyFilters);
446
+ });
447
+ </script>
448
+ </body>
449
+ </html>`;
450
+
451
+ fs.writeFileSync(reportPath, html, 'utf-8');
452
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "cursor-usage-analyzer",
3
+ "version": "0.1.0",
4
+ "description": "Analyze and visualize your Cursor AI editor usage with interactive reports",
5
+ "main": "analyze.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "cursor-usage-analyzer": "./analyze.js"
9
+ },
10
+ "scripts": {
11
+ "analyze": "node analyze.js",
12
+ "today": "node analyze.js --today",
13
+ "yesterday": "node analyze.js --yesterday",
14
+ "this-month": "node analyze.js --this-month",
15
+ "last-month": "node analyze.js --last-month"
16
+ },
17
+ "keywords": ["cursor", "ai", "analytics", "usage", "chat", "tokens", "statistics"],
18
+ "author": "Daniel JΓ­lek",
19
+ "license": "MIT",
20
+ "dependencies": {
21
+ "better-sqlite3": "^9.2.2"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/xaverric/cursor-usage-analyzer.git"
26
+ }
27
+ }
28
+