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.
- package/.idea/cursor-usage-analyzer.iml +12 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/README.md +266 -0
- package/analyze.js +426 -0
- package/cursor-usage-analyzer-0.1.0.tgz +0 -0
- package/html-template.js +452 -0
- package/package.json +28 -0
|
@@ -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
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
|
package/html-template.js
ADDED
|
@@ -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
|
+
|