cursor-usage-analyzer 0.1.0 → 0.2.1

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/analyze.js CHANGED
@@ -5,10 +5,40 @@ import path from 'path';
5
5
  import os from 'os';
6
6
  import Database from 'better-sqlite3';
7
7
  import { generateHTMLReport } from './html-template.js';
8
+ import { parse } from 'csv-parse/sync';
9
+
10
+ // Get Cursor storage paths based on OS
11
+ function getCursorPaths() {
12
+ const platform = os.platform();
13
+ const home = os.homedir();
14
+
15
+ if (platform === 'darwin') {
16
+ // macOS
17
+ return {
18
+ global: path.join(home, 'Library/Application Support/Cursor/User/globalStorage'),
19
+ workspace: path.join(home, 'Library/Application Support/Cursor/User/workspaceStorage')
20
+ };
21
+ } else if (platform === 'win32') {
22
+ // Windows
23
+ const appData = process.env.APPDATA || path.join(home, 'AppData/Roaming');
24
+ return {
25
+ global: path.join(appData, 'Cursor/User/globalStorage'),
26
+ workspace: path.join(appData, 'Cursor/User/workspaceStorage')
27
+ };
28
+ } else {
29
+ // Linux
30
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
31
+ return {
32
+ global: path.join(configHome, 'Cursor/User/globalStorage'),
33
+ workspace: path.join(configHome, 'Cursor/User/workspaceStorage')
34
+ };
35
+ }
36
+ }
8
37
 
9
38
  // 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');
39
+ const CURSOR_PATHS = getCursorPaths();
40
+ const CURSOR_GLOBAL_STORAGE = CURSOR_PATHS.global;
41
+ const CURSOR_WORKSPACE_STORAGE = CURSOR_PATHS.workspace;
12
42
  const OUTPUT_DIR = path.join(process.cwd(), 'cursor-logs-export');
13
43
  const CHATS_DIR = path.join(OUTPUT_DIR, 'chats');
14
44
  const REPORT_PATH = path.join(OUTPUT_DIR, 'report.html');
@@ -25,8 +55,14 @@ const setDayBounds = (date, start = true) => {
25
55
  // Parse command line arguments
26
56
  function parseArgs() {
27
57
  const args = process.argv.slice(2);
28
- let startDate, endDate, dateStr;
29
-
58
+ let startDate, endDate, dateStr, csvPath;
59
+
60
+ // Check for CSV path
61
+ const csvIdx = args.indexOf('--csv');
62
+ if (csvIdx !== -1 && args[csvIdx + 1]) {
63
+ csvPath = args[csvIdx + 1];
64
+ }
65
+
30
66
  if (args.includes('--last-month')) {
31
67
  const now = new Date();
32
68
  startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
@@ -57,14 +93,81 @@ function parseArgs() {
57
93
  startDate = endDate = new Date();
58
94
  dateStr = formatDate(startDate);
59
95
  }
60
-
96
+
61
97
  return {
62
98
  startOfDay: setDayBounds(startDate, true).getTime(),
63
99
  endOfDay: setDayBounds(endDate, false).getTime(),
64
- dateStr
100
+ dateStr,
101
+ csvPath
65
102
  };
66
103
  }
67
104
 
105
+ // Parse CSV usage data from Cursor dashboard export
106
+ function parseCSVUsage(csvPath) {
107
+ if (!csvPath || !fs.existsSync(csvPath)) {
108
+ return [];
109
+ }
110
+
111
+ try {
112
+ const content = fs.readFileSync(csvPath, 'utf-8');
113
+ const records = parse(content, {
114
+ columns: true,
115
+ skip_empty_lines: true
116
+ });
117
+
118
+ return records.map(r => ({
119
+ timestamp: new Date(r.Date).getTime(),
120
+ user: r.User,
121
+ kind: r.Kind,
122
+ model: r.Model,
123
+ maxMode: r['Max Mode'],
124
+ inputWithCache: parseInt(r['Input (w/ Cache Write)']) || 0,
125
+ inputWithoutCache: parseInt(r['Input (w/o Cache Write)']) || 0,
126
+ cacheRead: parseInt(r['Cache Read']) || 0,
127
+ outputTokens: parseInt(r['Output Tokens']) || 0,
128
+ totalTokens: parseInt(r['Total Tokens']) || 0,
129
+ cost: parseFloat(r.Cost) || 0
130
+ }));
131
+ } catch (e) {
132
+ console.error('Error parsing CSV:', e.message);
133
+ return [];
134
+ }
135
+ }
136
+
137
+ // Match API calls to a conversation based on timestamp and model
138
+ function matchAPICallsToConversation(conv, apiCalls, timeWindow = 5 * 60 * 1000) {
139
+ if (!apiCalls || apiCalls.length === 0) return [];
140
+
141
+ const convStart = conv.timestamp;
142
+ const convEnd = conv.messages.length > 0
143
+ ? Math.max(...conv.messages.map(m => m.timestamp))
144
+ : convStart;
145
+
146
+ const normalizeModel = (model) => {
147
+ if (!model) return 'unknown';
148
+ const m = model.toLowerCase();
149
+ if (m.includes('sonnet')) return 'sonnet';
150
+ if (m.includes('opus')) return 'opus';
151
+ if (m.includes('composer')) return 'composer';
152
+ if (m === 'auto' || m === 'default' || m === 'unknown') return 'any';
153
+ return model;
154
+ };
155
+
156
+ const convModel = normalizeModel(conv.model);
157
+
158
+ return apiCalls.filter(call => {
159
+ const callModel = normalizeModel(call.model);
160
+ const timeMatch = call.timestamp >= (convStart - timeWindow) &&
161
+ call.timestamp <= (convEnd + timeWindow);
162
+
163
+ const modelMatch = convModel === 'any' ||
164
+ callModel === 'any' ||
165
+ convModel === callModel;
166
+
167
+ return timeMatch && modelMatch;
168
+ });
169
+ }
170
+
68
171
  // Extract text from various bubble formats
69
172
  function extractTextFromBubble(bubble) {
70
173
  if (bubble.text?.trim()) return bubble.text;
@@ -185,21 +288,21 @@ function resolveWorkspace(composerData, headers, db, composerId) {
185
288
  }
186
289
 
187
290
  // Extract conversations from database
188
- function extractConversations(startTime, endTime) {
291
+ function extractConversations(startTime, endTime, apiCalls = []) {
189
292
  const dbPath = path.join(CURSOR_GLOBAL_STORAGE, 'state.vscdb');
190
-
293
+
191
294
  if (!fs.existsSync(dbPath)) {
192
295
  console.log('Database not found');
193
296
  return [];
194
297
  }
195
-
298
+
196
299
  try {
197
300
  const db = new Database(dbPath, { readonly: true });
198
-
301
+
199
302
  // Load all bubbles
200
303
  const bubbleMap = {};
201
304
  const bubbleRows = db.prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'").all();
202
-
305
+
203
306
  for (const row of bubbleRows) {
204
307
  try {
205
308
  const bubbleId = row.key.split(':')[2];
@@ -207,34 +310,34 @@ function extractConversations(startTime, endTime) {
207
310
  if (bubble) bubbleMap[bubbleId] = bubble;
208
311
  } catch (e) {}
209
312
  }
210
-
313
+
211
314
  // Load composers
212
315
  const composerRows = db.prepare(
213
316
  "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%' AND value LIKE '%fullConversationHeadersOnly%'"
214
317
  ).all();
215
-
318
+
216
319
  const conversations = [];
217
-
320
+
218
321
  for (const row of composerRows) {
219
322
  try {
220
323
  const composerId = row.key.split(':')[1];
221
324
  const composer = JSON.parse(row.value);
222
325
  const timestamp = composer.lastUpdatedAt || composer.createdAt || Date.now();
223
-
326
+
224
327
  if (timestamp < startTime || timestamp > endTime) continue;
225
-
328
+
226
329
  const headers = composer.fullConversationHeadersOnly || [];
227
330
  if (headers.length === 0) continue;
228
-
331
+
229
332
  // Extract messages
230
333
  const messages = headers
231
334
  .map(h => {
232
335
  const bubble = bubbleMap[h.bubbleId];
233
336
  if (!bubble) return null;
234
-
337
+
235
338
  const text = extractTextFromBubble(bubble);
236
339
  if (!text?.trim()) return null;
237
-
340
+
238
341
  return {
239
342
  role: h.type === 1 ? 'user' : 'assistant',
240
343
  text: text.trim(),
@@ -242,10 +345,10 @@ function extractConversations(startTime, endTime) {
242
345
  };
243
346
  })
244
347
  .filter(Boolean);
245
-
348
+
246
349
  if (messages.length === 0) continue;
247
-
248
- conversations.push({
350
+
351
+ const conv = {
249
352
  composerId,
250
353
  name: composer.name || 'Untitled Chat',
251
354
  timestamp,
@@ -258,12 +361,64 @@ function extractConversations(startTime, endTime) {
258
361
  totalLinesAdded: composer.totalLinesAdded || 0,
259
362
  totalLinesRemoved: composer.totalLinesRemoved || 0,
260
363
  filesChangedCount: composer.filesChangedCount || 0
261
- });
364
+ };
365
+
366
+ conversations.push(conv);
262
367
  } catch (e) {
263
368
  // Silently skip invalid composers
264
369
  }
265
370
  }
266
-
371
+
372
+ // Match API calls to conversations WITHOUT double-counting
373
+ // Each API call should only be matched to ONE conversation
374
+ if (apiCalls.length > 0) {
375
+ const usedApiCallIndices = new Set();
376
+
377
+ for (const conv of conversations) {
378
+ const availableApiCalls = apiCalls.filter((_, idx) => !usedApiCallIndices.has(idx));
379
+ const matchedCalls = matchAPICallsToConversation(conv, availableApiCalls);
380
+
381
+ // Mark these API calls as used
382
+ matchedCalls.forEach(call => {
383
+ const originalIndex = apiCalls.findIndex(c =>
384
+ c.timestamp === call.timestamp &&
385
+ c.model === call.model &&
386
+ c.totalTokens === call.totalTokens
387
+ );
388
+ if (originalIndex !== -1) {
389
+ usedApiCallIndices.add(originalIndex);
390
+ }
391
+ });
392
+
393
+ conv.apiCalls = matchedCalls;
394
+ conv.apiCallCount = matchedCalls.length;
395
+
396
+ // Sum up API tokens
397
+ conv.apiTokens = {
398
+ inputWithCache: matchedCalls.reduce((sum, c) => sum + c.inputWithCache, 0),
399
+ inputWithoutCache: matchedCalls.reduce((sum, c) => sum + c.inputWithoutCache, 0),
400
+ cacheRead: matchedCalls.reduce((sum, c) => sum + c.cacheRead, 0),
401
+ outputTokens: matchedCalls.reduce((sum, c) => sum + c.outputTokens, 0),
402
+ totalTokens: matchedCalls.reduce((sum, c) => sum + c.totalTokens, 0),
403
+ cost: matchedCalls.reduce((sum, c) => sum + c.cost, 0)
404
+ };
405
+ }
406
+ } else {
407
+ // No API calls to match
408
+ conversations.forEach(conv => {
409
+ conv.apiCalls = [];
410
+ conv.apiCallCount = 0;
411
+ conv.apiTokens = {
412
+ inputWithCache: 0,
413
+ inputWithoutCache: 0,
414
+ cacheRead: 0,
415
+ outputTokens: 0,
416
+ totalTokens: 0,
417
+ cost: 0
418
+ };
419
+ });
420
+ }
421
+
267
422
  db.close();
268
423
  return conversations;
269
424
  } catch (error) {
@@ -278,9 +433,9 @@ function exportConversation(conv, index, dateStr) {
278
433
  const timeStr = timestamp.toTimeString().split(' ')[0].replace(/:/g, '-');
279
434
  const workspaceShort = conv.workspace.substring(0, 15).replace(/[^a-zA-Z0-9]/g, '_');
280
435
  const filename = `${dateStr}_${timeStr}_${workspaceShort}_conv${index}.txt`;
281
-
436
+
282
437
  const percentUsed = (conv.contextTokensUsed / conv.contextTokenLimit * 100).toFixed(1);
283
-
438
+
284
439
  const lines = [
285
440
  '='.repeat(80),
286
441
  `CONVERSATION #${index}`,
@@ -288,18 +443,34 @@ function exportConversation(conv, index, dateStr) {
288
443
  `Workspace: ${conv.workspace}`,
289
444
  `Time: ${timestamp.toLocaleString('en-US')}`,
290
445
  `Model: ${conv.model}`,
291
- `Tokens: ${conv.contextTokensUsed.toLocaleString()} / ${conv.contextTokenLimit.toLocaleString()} (${percentUsed}%)`,
446
+ `Context Tokens: ${conv.contextTokensUsed.toLocaleString()} / ${conv.contextTokenLimit.toLocaleString()} (${percentUsed}%)`,
292
447
  `Changes: +${conv.totalLinesAdded} -${conv.totalLinesRemoved} lines in ${conv.filesChangedCount} files`,
293
448
  `Messages: ${conv.messageCount}`,
294
449
  `Composer ID: ${conv.composerId}`,
295
- '='.repeat(80),
296
450
  ''
297
451
  ];
298
-
452
+
453
+ // Add API token information if available
454
+ if (conv.apiCallCount > 0) {
455
+ lines.push(
456
+ 'API TOKEN USAGE (from dashboard export):',
457
+ ` API Calls: ${conv.apiCallCount}`,
458
+ ` Input (w/ Cache Write): ${conv.apiTokens.inputWithCache.toLocaleString()}`,
459
+ ` Input (w/o Cache Write): ${conv.apiTokens.inputWithoutCache.toLocaleString()}`,
460
+ ` Cache Read: ${conv.apiTokens.cacheRead.toLocaleString()}`,
461
+ ` Output Tokens: ${conv.apiTokens.outputTokens.toLocaleString()}`,
462
+ ` Total API Tokens: ${conv.apiTokens.totalTokens.toLocaleString()}`,
463
+ ` Cost: $${conv.apiTokens.cost.toFixed(2)}`,
464
+ ''
465
+ );
466
+ }
467
+
468
+ lines.push('='.repeat(80), '');
469
+
299
470
  for (const msg of conv.messages) {
300
471
  const msgTime = new Date(msg.timestamp).toLocaleTimeString('en-US');
301
472
  const role = msg.role.toUpperCase();
302
-
473
+
303
474
  lines.push(
304
475
  '',
305
476
  '-'.repeat(80),
@@ -308,9 +479,9 @@ function exportConversation(conv, index, dateStr) {
308
479
  msg.text
309
480
  );
310
481
  }
311
-
482
+
312
483
  lines.push('', '='.repeat(80), 'End of conversation', '='.repeat(80));
313
-
484
+
314
485
  fs.writeFileSync(path.join(CHATS_DIR, filename), lines.join('\n'), 'utf-8');
315
486
  return filename;
316
487
  }
@@ -318,7 +489,7 @@ function exportConversation(conv, index, dateStr) {
318
489
  // Generate statistics
319
490
  function generateStats(conversations, startOfDay, endOfDay) {
320
491
  const daysDiff = Math.ceil((endOfDay - startOfDay) / (1000 * 60 * 60 * 24));
321
-
492
+
322
493
  const stats = {
323
494
  totalConversations: conversations.length,
324
495
  totalMessages: 0,
@@ -326,6 +497,15 @@ function generateStats(conversations, startOfDay, endOfDay) {
326
497
  totalLinesAdded: 0,
327
498
  totalLinesRemoved: 0,
328
499
  totalFilesChanged: 0,
500
+ totalApiCalls: 0,
501
+ totalApiTokens: {
502
+ inputWithCache: 0,
503
+ inputWithoutCache: 0,
504
+ cacheRead: 0,
505
+ outputTokens: 0,
506
+ totalTokens: 0,
507
+ cost: 0
508
+ },
329
509
  modelUsage: {},
330
510
  workspaceUsage: {},
331
511
  hourlyDistribution: Array(24).fill(0),
@@ -334,26 +514,37 @@ function generateStats(conversations, startOfDay, endOfDay) {
334
514
  conversations: [],
335
515
  generatedAt: new Date().toLocaleString('en-US')
336
516
  };
337
-
517
+
338
518
  for (const conv of conversations) {
339
519
  const ts = new Date(conv.timestamp);
340
-
520
+
341
521
  stats.totalMessages += conv.messageCount;
342
522
  stats.totalTokens += conv.contextTokensUsed;
343
523
  stats.totalLinesAdded += conv.totalLinesAdded;
344
524
  stats.totalLinesRemoved += conv.totalLinesRemoved;
345
525
  stats.totalFilesChanged += conv.filesChangedCount;
346
-
526
+
527
+ // Add API token stats
528
+ if (conv.apiCallCount > 0) {
529
+ stats.totalApiCalls += conv.apiCallCount;
530
+ stats.totalApiTokens.inputWithCache += conv.apiTokens.inputWithCache;
531
+ stats.totalApiTokens.inputWithoutCache += conv.apiTokens.inputWithoutCache;
532
+ stats.totalApiTokens.cacheRead += conv.apiTokens.cacheRead;
533
+ stats.totalApiTokens.outputTokens += conv.apiTokens.outputTokens;
534
+ stats.totalApiTokens.totalTokens += conv.apiTokens.totalTokens;
535
+ stats.totalApiTokens.cost += conv.apiTokens.cost;
536
+ }
537
+
347
538
  stats.modelUsage[conv.model] = (stats.modelUsage[conv.model] || 0) + 1;
348
539
  stats.workspaceUsage[conv.workspace] = (stats.workspaceUsage[conv.workspace] || 0) + 1;
349
540
  stats.hourlyDistribution[ts.getHours()]++;
350
-
541
+
351
542
  const dateKey = ts.toLocaleDateString('en-US');
352
543
  stats.dailyDistribution[dateKey] = (stats.dailyDistribution[dateKey] || 0) + 1;
353
-
544
+
354
545
  const firstUserMsg = conv.messages.find(m => m.role === 'user');
355
546
  const preview = firstUserMsg?.text.substring(0, 100) + (firstUserMsg?.text.length > 100 ? '...' : '') || '(no user message)';
356
-
547
+
357
548
  stats.conversations.push({
358
549
  timestamp: conv.timestamp,
359
550
  time: ts.toLocaleTimeString('en-US'),
@@ -365,49 +556,79 @@ function generateStats(conversations, startOfDay, endOfDay) {
365
556
  messages: conv.messageCount,
366
557
  tokens: conv.contextTokensUsed,
367
558
  contextLimit: conv.contextTokenLimit,
559
+ apiCallCount: conv.apiCallCount || 0,
560
+ apiTokens: conv.apiTokens,
368
561
  linesChanged: `+${conv.totalLinesAdded}/-${conv.totalLinesRemoved}`,
369
562
  files: conv.filesChangedCount,
370
563
  preview
371
564
  });
372
565
  }
373
-
566
+
374
567
  stats.conversations.sort((a, b) => a.timestamp - b.timestamp);
375
-
568
+
376
569
  return stats;
377
570
  }
378
571
 
379
572
  // Main
380
573
  async function main() {
381
574
  console.log('Cursor Usage Analyzer v2\n');
382
-
383
- const { startOfDay, endOfDay, dateStr } = parseArgs();
384
-
575
+
576
+ // Check if Cursor storage exists
577
+ if (!fs.existsSync(CURSOR_GLOBAL_STORAGE)) {
578
+ console.error('Error: Cursor storage directory not found!');
579
+ console.error(`Expected location: ${CURSOR_GLOBAL_STORAGE}`);
580
+ console.error('\nPossible reasons:');
581
+ console.error(' 1. Cursor is not installed');
582
+ console.error(' 2. Cursor has never been run');
583
+ console.error(' 3. Different installation path\n');
584
+ console.error(`Detected OS: ${os.platform()}`);
585
+ process.exit(1);
586
+ }
587
+
588
+ const { startOfDay, endOfDay, dateStr, csvPath } = parseArgs();
589
+
385
590
  console.log(`Analyzing: ${dateStr}`);
386
- console.log(`Period: ${new Date(startOfDay).toLocaleString('en-US')} - ${new Date(endOfDay).toLocaleString('en-US')}\n`);
387
-
591
+ console.log(`Period: ${new Date(startOfDay).toLocaleString('en-US')} - ${new Date(endOfDay).toLocaleString('en-US')}`);
592
+ console.log(`Platform: ${os.platform()}`);
593
+
594
+ // Parse CSV if provided
595
+ let apiCalls = [];
596
+ if (csvPath) {
597
+ console.log(`CSV file: ${csvPath}`);
598
+ const allApiCalls = parseCSVUsage(csvPath);
599
+ // Filter API calls to only those within the date range
600
+ apiCalls = allApiCalls.filter(call =>
601
+ call.timestamp >= startOfDay && call.timestamp <= endOfDay
602
+ );
603
+ console.log(`Parsed ${allApiCalls.length} API calls from CSV`);
604
+ console.log(`Filtered to ${apiCalls.length} API calls within date range\n`);
605
+ } else {
606
+ console.log('No CSV file provided (use --csv path/to/file.csv to include API token data)\n');
607
+ }
608
+
388
609
  // Prepare output folders
389
610
  if (fs.existsSync(OUTPUT_DIR)) fs.rmSync(OUTPUT_DIR, { recursive: true });
390
611
  fs.mkdirSync(CHATS_DIR, { recursive: true });
391
-
612
+
392
613
  console.log('Extracting conversations...');
393
-
394
- const conversations = extractConversations(startOfDay, endOfDay);
395
-
614
+
615
+ const conversations = extractConversations(startOfDay, endOfDay, apiCalls);
616
+
396
617
  console.log(`Found ${conversations.length} conversations\n`);
397
-
618
+
398
619
  if (conversations.length === 0) {
399
620
  console.log('No conversations found in specified period');
400
621
  return;
401
622
  }
402
-
623
+
403
624
  console.log('Exporting conversations...');
404
625
  conversations.forEach((conv, i) => {
405
626
  exportConversation(conv, i + 1, dateStr);
406
627
  });
407
-
628
+
408
629
  console.log('Generating statistics...');
409
630
  const stats = generateStats(conversations, startOfDay, endOfDay);
410
-
631
+
411
632
  console.log('Generating HTML report...');
412
633
  try {
413
634
  generateHTMLReport(stats, dateStr, REPORT_PATH);
@@ -416,11 +637,20 @@ async function main() {
416
637
  console.error(e.stack);
417
638
  return;
418
639
  }
419
-
640
+
420
641
  console.log('\nDone!\n');
421
642
  console.log(`Export folder: ${OUTPUT_DIR}`);
422
643
  console.log(`Conversations: ${conversations.length}`);
644
+ if (apiCalls.length > 0) {
645
+ console.log(`API Calls matched: ${stats.totalApiCalls}`);
646
+ console.log(`Total API tokens: ${stats.totalApiTokens.totalTokens.toLocaleString()}`);
647
+ console.log(`Total cost: $${stats.totalApiTokens.cost.toFixed(2)}`);
648
+ }
423
649
  console.log(`Report: ${REPORT_PATH}`);
650
+
651
+ // Platform-specific open command hint
652
+ const openCmd = os.platform() === 'win32' ? 'start' : os.platform() === 'darwin' ? 'open' : 'xdg-open';
653
+ console.log(`\nOpen report: ${openCmd} ${REPORT_PATH}`);
424
654
  }
425
655
 
426
656
  main().catch(console.error);
Binary file
Binary file
package/html-template.js CHANGED
@@ -151,6 +151,29 @@ export function generateHTMLReport(stats, dateStr, reportPath) {
151
151
  <div class="stat-label">Context Tokens</div>
152
152
  <div class="stat-sublabel">Ø ${avgTokens.toLocaleString()} per conversation</div>
153
153
  </div>
154
+ ${stats.totalApiCalls > 0 ? `
155
+ <div class="stat">
156
+ <div class="stat-value">${stats.totalApiTokens.totalTokens.toLocaleString()}</div>
157
+ <div class="stat-label">API Tokens (Total)</div>
158
+ <div class="stat-sublabel">$${stats.totalApiTokens.cost.toFixed(2)} total cost</div>
159
+ </div>
160
+ <div class="stat">
161
+ <div class="stat-value">${stats.totalApiTokens.inputWithCache.toLocaleString()}</div>
162
+ <div class="stat-label">Input (w/ Cache Write)</div>
163
+ </div>
164
+ <div class="stat">
165
+ <div class="stat-value">${stats.totalApiTokens.cacheRead.toLocaleString()}</div>
166
+ <div class="stat-label">Cache Read</div>
167
+ </div>
168
+ <div class="stat">
169
+ <div class="stat-value">${stats.totalApiTokens.outputTokens.toLocaleString()}</div>
170
+ <div class="stat-label">Output Tokens</div>
171
+ </div>
172
+ <div class="stat">
173
+ <div class="stat-value">${stats.totalApiCalls}</div>
174
+ <div class="stat-label">API Calls</div>
175
+ </div>
176
+ ` : ''}
154
177
  <div class="stat">
155
178
  <div class="stat-value">+${stats.totalLinesAdded.toLocaleString()}</div>
156
179
  <div class="stat-label">Lines Added</div>
@@ -235,6 +258,11 @@ export function generateHTMLReport(stats, dateStr, reportPath) {
235
258
  <th class="sortable" data-column="model" data-type="string">Model</th>
236
259
  <th class="sortable" data-column="messages" data-type="number">Messages</th>
237
260
  <th class="sortable" data-column="tokens" data-type="number">Context Tokens</th>
261
+ ${stats.totalApiCalls > 0 ? `
262
+ <th class="sortable" data-column="apiTokens" data-type="number">API Tokens</th>
263
+ <th class="sortable" data-column="apiCost" data-type="number">Cost</th>
264
+ <th class="sortable" data-column="apiCalls" data-type="number">API Calls</th>
265
+ ` : ''}
238
266
  <th class="sortable" data-column="linesChanged" data-type="string">Changes</th>
239
267
  <th class="sortable" data-column="files" data-type="number">Files</th>
240
268
  </tr>
@@ -248,6 +276,11 @@ export function generateHTMLReport(stats, dateStr, reportPath) {
248
276
  <td data-value="${c.model}"><small>${c.model}</small></td>
249
277
  <td data-value="${c.messages}">${c.messages}</td>
250
278
  <td data-value="${c.tokens}"><small>${c.tokens.toLocaleString()} / ${c.contextLimit.toLocaleString()}</small></td>
279
+ ${stats.totalApiCalls > 0 ? `
280
+ <td data-value="${c.apiTokens?.totalTokens || 0}"><small>${(c.apiTokens?.totalTokens || 0).toLocaleString()}</small></td>
281
+ <td data-value="${c.apiTokens?.cost || 0}"><small>$${(c.apiTokens?.cost || 0).toFixed(2)}</small></td>
282
+ <td data-value="${c.apiCallCount || 0}">${c.apiCallCount || 0}</td>
283
+ ` : ''}
251
284
  <td data-value="${c.linesChanged}">${c.linesChanged}</td>
252
285
  <td data-value="${c.files}">${c.files}</td>
253
286
  </tr>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cursor-usage-analyzer",
3
- "version": "0.1.0",
4
- "description": "Analyze and visualize your Cursor AI editor usage with interactive reports",
3
+ "version": "0.2.1",
4
+ "description": "Analyze and visualize your Cursor AI editor usage with interactive reports including API token tracking and costs",
5
5
  "main": "analyze.js",
6
6
  "type": "module",
7
7
  "bin": {
@@ -18,7 +18,8 @@
18
18
  "author": "Daniel Jílek",
19
19
  "license": "MIT",
20
20
  "dependencies": {
21
- "better-sqlite3": "^9.2.2"
21
+ "better-sqlite3": "^9.2.2",
22
+ "csv-parse": "^5.5.3"
22
23
  },
23
24
  "repository": {
24
25
  "type": "git",