cursor-usage-analyzer 0.2.0 → 0.3.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.
@@ -3,7 +3,11 @@
3
3
  "allow": [
4
4
  "Bash(npm install)",
5
5
  "Bash(sqlite3:*)",
6
- "Bash(python3:*)"
6
+ "Bash(python3:*)",
7
+ "Bash(node -e:*)",
8
+ "Bash(for file in cursor-logs-export/chats/2025-12-17*.txt)",
9
+ "Bash(grep:*)",
10
+ "Bash(node analyze.js:*)"
7
11
  ]
8
12
  }
9
13
  }
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="AgentMigrationStateService">
4
+ <option name="migrationStatus" value="COMPLETED" />
5
+ </component>
6
+ </project>
package/analyze.js CHANGED
@@ -3,10 +3,83 @@
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
5
  import os from 'os';
6
- import Database from 'better-sqlite3';
7
6
  import { generateHTMLReport } from './html-template.js';
8
7
  import { parse } from 'csv-parse/sync';
9
8
 
9
+ // Database abstraction - try better-sqlite3 first, fall back to sql.js
10
+ let dbEngine = null;
11
+
12
+ async function loadDatabaseEngine() {
13
+ // Try better-sqlite3 first (faster, handles large files)
14
+ try {
15
+ const { default: Database } = await import('better-sqlite3');
16
+ // Test that it actually works (native bindings compiled)
17
+ const testDb = new Database(':memory:');
18
+ testDb.close();
19
+ dbEngine = { type: 'better-sqlite3', Database };
20
+ return;
21
+ } catch (e) {
22
+ console.log('Note: better-sqlite3 unavailable, trying sql.js fallback...');
23
+ }
24
+
25
+ // Fall back to sql.js (pure JS, works everywhere, but 2GB limit)
26
+ try {
27
+ const { default: initSqlJs } = await import('sql.js');
28
+ const SQL = await initSqlJs();
29
+ dbEngine = { type: 'sql.js', SQL };
30
+ } catch (e) {
31
+ throw new Error('No SQLite library available. Please run: npm install -g cursor-usage-analyzer');
32
+ }
33
+ }
34
+
35
+ function openDatabase(dbPath) {
36
+ if (dbEngine.type === 'better-sqlite3') {
37
+ return new dbEngine.Database(dbPath, { readonly: true });
38
+ } else {
39
+ const fileSize = fs.statSync(dbPath).size;
40
+ if (fileSize > 2 * 1024 * 1024 * 1024) {
41
+ throw new Error(
42
+ `Database file (${(fileSize / 1024 / 1024 / 1024).toFixed(2)} GB) exceeds sql.js 2GB limit.\n` +
43
+ `To fix this, install globally with native compilation:\n` +
44
+ ` npm install -g cursor-usage-analyzer\n` +
45
+ `Or clone and build locally:\n` +
46
+ ` git clone https://github.com/xaverric/cursor-usage-analyzer.git\n` +
47
+ ` cd cursor-usage-analyzer && npm install && npm rebuild better-sqlite3`
48
+ );
49
+ }
50
+ const dbBuffer = fs.readFileSync(dbPath);
51
+ return new dbEngine.SQL.Database(dbBuffer);
52
+ }
53
+ }
54
+
55
+ function dbQueryAll(db, sql, params = []) {
56
+ if (dbEngine.type === 'better-sqlite3') {
57
+ return db.prepare(sql).all(...params);
58
+ } else {
59
+ const stmt = db.prepare(sql);
60
+ if (params.length > 0) stmt.bind(params);
61
+ const results = [];
62
+ while (stmt.step()) {
63
+ results.push(stmt.getAsObject());
64
+ }
65
+ stmt.free();
66
+ return results;
67
+ }
68
+ }
69
+
70
+ function dbQueryOne(db, sql, params = []) {
71
+ if (dbEngine.type === 'better-sqlite3') {
72
+ return db.prepare(sql).get(...params);
73
+ } else {
74
+ const results = dbQueryAll(db, sql, params);
75
+ return results.length > 0 ? results[0] : null;
76
+ }
77
+ }
78
+
79
+ function dbClose(db) {
80
+ db.close();
81
+ }
82
+
10
83
  // Get Cursor storage paths based on OS
11
84
  function getCursorPaths() {
12
85
  const platform = os.platform();
@@ -246,7 +319,7 @@ function findWorkspaceFromPath(filePath) {
246
319
  }
247
320
 
248
321
  // Determine workspace name from multiple sources
249
- function resolveWorkspace(composerData, headers, db, composerId) {
322
+ function resolveWorkspace(composerData, headers, composerId) {
250
323
  // Try workspaceId first
251
324
  if (composerData.workspaceId) {
252
325
  const name = readWorkspaceJson(composerData.workspaceId);
@@ -268,11 +341,20 @@ function resolveWorkspace(composerData, headers, db, composerId) {
268
341
  }
269
342
  }
270
343
 
344
+ return 'unknown';
345
+ }
346
+
347
+ // Determine workspace name with database access for messageRequestContext
348
+ function resolveWorkspaceWithDb(composerData, headers, db, composerId) {
349
+ // Try basic resolution first
350
+ const basicResult = resolveWorkspace(composerData, headers, composerId);
351
+ if (basicResult !== 'unknown') return basicResult;
352
+
271
353
  // Last resort: messageRequestContext
272
354
  if (headers?.length > 0) {
273
355
  try {
274
356
  const contextKey = `messageRequestContext:${composerId}:${headers[0].bubbleId}`;
275
- const row = db.prepare("SELECT value FROM cursorDiskKV WHERE key = ?").get(contextKey);
357
+ const row = dbQueryOne(db, "SELECT value FROM cursorDiskKV WHERE key = ?", [contextKey]);
276
358
  if (row) {
277
359
  const context = JSON.parse(row.value);
278
360
  if (context.projectLayouts?.length > 0) {
@@ -288,7 +370,7 @@ function resolveWorkspace(composerData, headers, db, composerId) {
288
370
  }
289
371
 
290
372
  // Extract conversations from database
291
- function extractConversations(startTime, endTime, apiCalls = []) {
373
+ async function extractConversations(startTime, endTime, apiCalls = []) {
292
374
  const dbPath = path.join(CURSOR_GLOBAL_STORAGE, 'state.vscdb');
293
375
 
294
376
  if (!fs.existsSync(dbPath)) {
@@ -297,11 +379,11 @@ function extractConversations(startTime, endTime, apiCalls = []) {
297
379
  }
298
380
 
299
381
  try {
300
- const db = new Database(dbPath, { readonly: true });
382
+ const db = openDatabase(dbPath);
301
383
 
302
384
  // Load all bubbles
303
385
  const bubbleMap = {};
304
- const bubbleRows = db.prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'").all();
386
+ const bubbleRows = dbQueryAll(db, "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'");
305
387
 
306
388
  for (const row of bubbleRows) {
307
389
  try {
@@ -312,9 +394,9 @@ function extractConversations(startTime, endTime, apiCalls = []) {
312
394
  }
313
395
 
314
396
  // Load composers
315
- const composerRows = db.prepare(
397
+ const composerRows = dbQueryAll(db,
316
398
  "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%' AND value LIKE '%fullConversationHeadersOnly%'"
317
- ).all();
399
+ );
318
400
 
319
401
  const conversations = [];
320
402
 
@@ -354,7 +436,7 @@ function extractConversations(startTime, endTime, apiCalls = []) {
354
436
  timestamp,
355
437
  messages,
356
438
  messageCount: messages.length,
357
- workspace: resolveWorkspace(composer, headers, db, composerId),
439
+ workspace: resolveWorkspaceWithDb(composer, headers, db, composerId),
358
440
  model: composer.modelConfig?.modelName || 'unknown',
359
441
  contextTokensUsed: composer.contextTokensUsed || 0,
360
442
  contextTokenLimit: composer.contextTokenLimit || 0,
@@ -363,8 +445,33 @@ function extractConversations(startTime, endTime, apiCalls = []) {
363
445
  filesChangedCount: composer.filesChangedCount || 0
364
446
  };
365
447
 
366
- // Match API calls if available
367
- const matchedCalls = matchAPICallsToConversation(conv, apiCalls);
448
+ conversations.push(conv);
449
+ } catch (e) {
450
+ // Silently skip invalid composers
451
+ }
452
+ }
453
+
454
+ // Match API calls to conversations WITHOUT double-counting
455
+ // Each API call should only be matched to ONE conversation
456
+ if (apiCalls.length > 0) {
457
+ const usedApiCallIndices = new Set();
458
+
459
+ for (const conv of conversations) {
460
+ const availableApiCalls = apiCalls.filter((_, idx) => !usedApiCallIndices.has(idx));
461
+ const matchedCalls = matchAPICallsToConversation(conv, availableApiCalls);
462
+
463
+ // Mark these API calls as used
464
+ matchedCalls.forEach(call => {
465
+ const originalIndex = apiCalls.findIndex(c =>
466
+ c.timestamp === call.timestamp &&
467
+ c.model === call.model &&
468
+ c.totalTokens === call.totalTokens
469
+ );
470
+ if (originalIndex !== -1) {
471
+ usedApiCallIndices.add(originalIndex);
472
+ }
473
+ });
474
+
368
475
  conv.apiCalls = matchedCalls;
369
476
  conv.apiCallCount = matchedCalls.length;
370
477
 
@@ -377,14 +484,24 @@ function extractConversations(startTime, endTime, apiCalls = []) {
377
484
  totalTokens: matchedCalls.reduce((sum, c) => sum + c.totalTokens, 0),
378
485
  cost: matchedCalls.reduce((sum, c) => sum + c.cost, 0)
379
486
  };
380
-
381
- conversations.push(conv);
382
- } catch (e) {
383
- // Silently skip invalid composers
384
487
  }
488
+ } else {
489
+ // No API calls to match
490
+ conversations.forEach(conv => {
491
+ conv.apiCalls = [];
492
+ conv.apiCallCount = 0;
493
+ conv.apiTokens = {
494
+ inputWithCache: 0,
495
+ inputWithoutCache: 0,
496
+ cacheRead: 0,
497
+ outputTokens: 0,
498
+ totalTokens: 0,
499
+ cost: 0
500
+ };
501
+ });
385
502
  }
386
503
 
387
- db.close();
504
+ dbClose(db);
388
505
  return conversations;
389
506
  } catch (error) {
390
507
  console.error('Database error:', error.message);
@@ -560,8 +677,13 @@ async function main() {
560
677
  let apiCalls = [];
561
678
  if (csvPath) {
562
679
  console.log(`CSV file: ${csvPath}`);
563
- apiCalls = parseCSVUsage(csvPath);
564
- console.log(`Parsed ${apiCalls.length} API calls from CSV\n`);
680
+ const allApiCalls = parseCSVUsage(csvPath);
681
+ // Filter API calls to only those within the date range
682
+ apiCalls = allApiCalls.filter(call =>
683
+ call.timestamp >= startOfDay && call.timestamp <= endOfDay
684
+ );
685
+ console.log(`Parsed ${allApiCalls.length} API calls from CSV`);
686
+ console.log(`Filtered to ${apiCalls.length} API calls within date range\n`);
565
687
  } else {
566
688
  console.log('No CSV file provided (use --csv path/to/file.csv to include API token data)\n');
567
689
  }
@@ -572,7 +694,8 @@ async function main() {
572
694
 
573
695
  console.log('Extracting conversations...');
574
696
 
575
- const conversations = extractConversations(startOfDay, endOfDay, apiCalls);
697
+ await loadDatabaseEngine();
698
+ const conversations = await extractConversations(startOfDay, endOfDay, apiCalls);
576
699
 
577
700
  console.log(`Found ${conversations.length} conversations\n`);
578
701
 
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-usage-analyzer",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
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",
@@ -12,13 +12,15 @@
12
12
  "today": "node analyze.js --today",
13
13
  "yesterday": "node analyze.js --yesterday",
14
14
  "this-month": "node analyze.js --this-month",
15
- "last-month": "node analyze.js --last-month"
15
+ "last-month": "node analyze.js --last-month",
16
+ "postinstall": "npm rebuild better-sqlite3 2>/dev/null || echo 'Note: better-sqlite3 native build failed, will use sql.js fallback (2GB limit)'"
16
17
  },
17
18
  "keywords": ["cursor", "ai", "analytics", "usage", "chat", "tokens", "statistics"],
18
19
  "author": "Daniel Jílek",
19
20
  "license": "MIT",
20
21
  "dependencies": {
21
- "better-sqlite3": "^9.2.2",
22
+ "better-sqlite3": "^11.7.0",
23
+ "sql.js": "^1.11.0",
22
24
  "csv-parse": "^5.5.3"
23
25
  },
24
26
  "repository": {