chekk 0.2.3 → 0.2.5

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/bin/chekk.js CHANGED
@@ -1,20 +1,62 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { execSync, spawn } from 'child_process';
3
4
  import { Command } from 'commander';
4
5
  import { run } from '../src/index.js';
5
6
 
6
- const program = new Command();
7
-
8
- program
9
- .name('chekk')
10
- .description('The engineering capability score. See how you prompt.')
11
- .version('0.2.3')
12
- .option('--offline', 'Skip AI prose generation, show data-driven output')
13
- .option('--verbose', 'Show detailed per-project and per-metric breakdowns')
14
- .option('--json', 'Output raw metrics as JSON')
15
- .option('--no-upload', 'Skip the claim prompt')
16
- .action(async (options) => {
17
- await run(options);
18
- });
19
-
20
- program.parse();
7
+ const LOCAL_VERSION = '0.2.5';
8
+
9
+ // ── Auto-update check ──
10
+ // If running from a cached npx install, check if there's a newer version
11
+ // and re-exec with @latest to ensure users always get the freshest build.
12
+ async function checkForUpdate() {
13
+ try {
14
+ const latest = execSync('npm view chekk version 2>/dev/null', {
15
+ encoding: 'utf-8',
16
+ timeout: 3000,
17
+ }).trim();
18
+
19
+ if (latest && latest !== LOCAL_VERSION && isNewer(latest, LOCAL_VERSION)) {
20
+ // Re-run with the latest version
21
+ const args = process.argv.slice(2);
22
+ const child = spawn('npx', ['--yes', `chekk@${latest}`, ...args], {
23
+ stdio: 'inherit',
24
+ shell: true,
25
+ });
26
+ child.on('exit', (code) => process.exit(code || 0));
27
+ return true; // signal that we're handing off
28
+ }
29
+ } catch {
30
+ // Network down or timeout — just run the local version
31
+ }
32
+ return false;
33
+ }
34
+
35
+ function isNewer(remote, local) {
36
+ const r = remote.split('.').map(Number);
37
+ const l = local.split('.').map(Number);
38
+ for (let i = 0; i < 3; i++) {
39
+ if ((r[i] || 0) > (l[i] || 0)) return true;
40
+ if ((r[i] || 0) < (l[i] || 0)) return false;
41
+ }
42
+ return false;
43
+ }
44
+
45
+ const handedOff = await checkForUpdate();
46
+ if (!handedOff) {
47
+ const program = new Command();
48
+
49
+ program
50
+ .name('chekk')
51
+ .description('The engineering capability score. See how you prompt.')
52
+ .version(LOCAL_VERSION)
53
+ .option('--offline', 'Skip AI prose generation, show data-driven output')
54
+ .option('--verbose', 'Show detailed per-project and per-metric breakdowns')
55
+ .option('--json', 'Output raw metrics as JSON')
56
+ .option('--no-upload', 'Skip the claim prompt')
57
+ .action(async (options) => {
58
+ await run(options);
59
+ });
60
+
61
+ program.parse();
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chekk",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "See how you prompt. Chekk analyzes your AI coding workflow and tells you what kind of engineer you are.",
5
5
  "bin": {
6
6
  "chekk": "./bin/chekk.js"
package/src/detect.js CHANGED
@@ -9,7 +9,7 @@ export function detectTools() {
9
9
  const home = homedir();
10
10
  const results = [];
11
11
 
12
- // Claude Code: ~/.claude/projects/
12
+ // ── Claude Code: ~/.claude/projects/ ──
13
13
  const claudeProjectsDir = join(home, '.claude', 'projects');
14
14
  if (existsSync(claudeProjectsDir)) {
15
15
  const projects = readdirSync(claudeProjectsDir).filter(f => {
@@ -29,11 +29,10 @@ export function detectTools() {
29
29
 
30
30
  if (jsonlFiles.length > 0) {
31
31
  totalSessions += jsonlFiles.length;
32
- // Rough line count for display
33
32
  for (const file of jsonlFiles) {
34
33
  try {
35
34
  const stat = statSync(join(projectDir, file));
36
- totalLines += Math.floor(stat.size / 500); // rough estimate
35
+ totalLines += Math.floor(stat.size / 500);
37
36
  } catch {}
38
37
  }
39
38
  projectDetails.push({
@@ -55,39 +54,92 @@ export function detectTools() {
55
54
  }
56
55
  }
57
56
 
58
- // Cursor: ~/Library/Application Support/Cursor/User/workspaceStorage/
57
+ // ── Cursor: workspaceStorage with state.vscdb files ──
59
58
  const cursorPaths = [
60
59
  join(home, 'Library', 'Application Support', 'Cursor', 'User', 'workspaceStorage'), // macOS
61
60
  join(home, '.config', 'Cursor', 'User', 'workspaceStorage'), // Linux
62
61
  join(home, 'AppData', 'Roaming', 'Cursor', 'User', 'workspaceStorage'), // Windows
63
62
  ];
63
+
64
64
  for (const cursorPath of cursorPaths) {
65
65
  if (existsSync(cursorPath)) {
66
- results.push({
67
- tool: 'Cursor',
68
- sessions: 0,
69
- projects: [],
70
- estimatedPrompts: 0,
71
- basePath: cursorPath,
72
- status: 'detected_not_supported',
73
- message: 'Cursor support coming in V2',
74
- });
66
+ // Count workspaces that have state.vscdb
67
+ let workspaceCount = 0;
68
+ const projectDetails = [];
69
+
70
+ try {
71
+ const dirs = readdirSync(cursorPath).filter(f => {
72
+ try { return statSync(join(cursorPath, f)).isDirectory(); } catch { return false; }
73
+ });
74
+
75
+ for (const dir of dirs) {
76
+ const dbPath = join(cursorPath, dir, 'state.vscdb');
77
+ if (existsSync(dbPath)) {
78
+ workspaceCount++;
79
+ projectDetails.push({
80
+ name: dir.slice(0, 8),
81
+ sessions: 1,
82
+ path: join(cursorPath, dir),
83
+ });
84
+ }
85
+ }
86
+ } catch {}
87
+
88
+ if (workspaceCount > 0) {
89
+ results.push({
90
+ tool: 'Cursor',
91
+ sessions: workspaceCount,
92
+ projects: projectDetails,
93
+ estimatedPrompts: 0,
94
+ basePath: cursorPath,
95
+ });
96
+ }
75
97
  break;
76
98
  }
77
99
  }
78
100
 
79
- // Codex: ~/.codex/
101
+ // ── Codex: ~/.codex/sessions/ ──
80
102
  const codexPath = join(home, '.codex');
81
- if (existsSync(codexPath)) {
82
- results.push({
83
- tool: 'Codex',
84
- sessions: 0,
85
- projects: [],
86
- estimatedPrompts: 0,
87
- basePath: codexPath,
88
- status: 'detected_not_supported',
89
- message: 'Codex support coming in V2',
90
- });
103
+ const codexSessionsPath = join(codexPath, 'sessions');
104
+ if (existsSync(codexSessionsPath)) {
105
+ // Count JSONL session files recursively
106
+ let totalSessions = 0;
107
+ const projectDetails = [];
108
+
109
+ function countFiles(dir) {
110
+ try {
111
+ const entries = readdirSync(dir);
112
+ for (const entry of entries) {
113
+ const full = join(dir, entry);
114
+ try {
115
+ const stat = statSync(full);
116
+ if (stat.isDirectory()) {
117
+ countFiles(full);
118
+ } else if (entry.endsWith('.jsonl')) {
119
+ totalSessions++;
120
+ }
121
+ } catch {}
122
+ }
123
+ } catch {}
124
+ }
125
+
126
+ countFiles(codexSessionsPath);
127
+
128
+ if (totalSessions > 0) {
129
+ projectDetails.push({
130
+ name: 'codex',
131
+ sessions: totalSessions,
132
+ path: codexSessionsPath,
133
+ });
134
+
135
+ results.push({
136
+ tool: 'Codex',
137
+ sessions: totalSessions,
138
+ projects: projectDetails,
139
+ estimatedPrompts: 0,
140
+ basePath: codexPath,
141
+ });
142
+ }
91
143
  }
92
144
 
93
145
  return results;
package/src/display.js CHANGED
@@ -90,7 +90,7 @@ export function displayHeader() {
90
90
  console.log();
91
91
  const lines = [
92
92
  '',
93
- ` ${bold.white('chekk')}${dim(' v0.2.3')}`,
93
+ ` ${bold.white('chekk')}${dim(' v0.2.5')}`,
94
94
  ` ${dim('the engineering capability score')}`,
95
95
  '',
96
96
  ];
@@ -106,17 +106,12 @@ export function displayScan(tools) {
106
106
  console.log(dim(' Scanning local AI tools...\n'));
107
107
 
108
108
  for (const tool of tools) {
109
- if (tool.status === 'detected_not_supported') {
110
- console.log(` ${dim('\u25CB')} ${dim(tool.tool)}${dim(' detected \u2014 V2')}`);
111
- } else {
112
- const sessions = numberFormat(tool.sessions);
113
- const projects = tool.projects.length;
114
- const exchanges = numberFormat(tool.estimatedPrompts);
115
- console.log(
116
- ` ${cyan('\u2726')} ${bold.white(tool.tool)} ` +
117
- dim(`${sessions} sessions \u00B7 ${projects} projects`)
118
- );
119
- }
109
+ const sessions = numberFormat(tool.sessions);
110
+ const projects = tool.projects.length;
111
+ console.log(
112
+ ` ${cyan('\u2726')} ${bold.white(tool.tool)} ` +
113
+ dim(`${sessions} sessions \u00B7 ${projects} projects`)
114
+ );
120
115
  }
121
116
  console.log();
122
117
  }
@@ -208,25 +203,44 @@ export function displayScore(result, prose) {
208
203
  // ══════════════════════════════════════════════
209
204
 
210
205
  export function displayNarratives(metrics, prose) {
206
+ // Track shown snippets globally to avoid duplicates across sections
207
+ const shownSnippets = new Set();
208
+ function showUniqueSnippet(prompt) {
209
+ if (!prompt) return;
210
+ const s = snippet(prompt, 70);
211
+ if (shownSnippets.has(s)) return;
212
+ shownSnippets.add(s);
213
+ displaySnippet(prompt);
214
+ }
215
+
211
216
  if (prose && prose.sections) {
212
- // Use DeepSeek-generated prose
217
+ // Use DeepSeek-generated prose with inline prompt snippets per section
218
+ const sectionSnippetMap = {
219
+ 'thinking': pickExample(metrics.decomposition.examples, 'decomposition'),
220
+ 'debugging': pickExample(metrics.debugCycles.examples, 'specific_report') || pickExample(metrics.debugCycles.examples, 'quick_fix'),
221
+ 'ai leverage': pickExample(metrics.aiLeverage.examples, 'architectural') || pickExample(metrics.aiLeverage.examples, 'planning'),
222
+ 'workflow': pickExample(metrics.sessionStructure.examples, 'context_setting') || pickExample(metrics.sessionStructure.examples, 'refinement'),
223
+ };
224
+
213
225
  for (const section of prose.sections) {
214
226
  console.log(` ${section.emoji} ${bold(section.title.toUpperCase())}`);
215
227
  const lines = section.description.split('\n').filter(l => l.trim());
216
228
  for (const line of lines) {
217
229
  console.log(` ${dim(line.trim())}`);
218
230
  }
231
+ // Show relevant prompt snippet under this section
232
+ const titleLower = section.title.toLowerCase();
233
+ const matchedSnippet = sectionSnippetMap[titleLower];
234
+ if (matchedSnippet) showUniqueSnippet(matchedSnippet);
219
235
  console.log();
220
236
  }
221
- // Show prompt evidence after AI prose
222
- displayPromptEvidence(metrics);
223
237
  } else {
224
238
  // Fallback: data-driven bullet points with inline snippets
225
- displayDataNarratives(metrics);
239
+ displayDataNarrativesWithTracker(metrics, shownSnippets);
226
240
  }
227
241
  }
228
242
 
229
- function displayDataNarratives(metrics) {
243
+ function displayDataNarrativesWithTracker(metrics, shownSnippets) {
230
244
  const d = metrics.decomposition.details;
231
245
  const db = metrics.debugCycles.details;
232
246
  const ai = metrics.aiLeverage.details;
@@ -236,8 +250,6 @@ function displayDataNarratives(metrics) {
236
250
  const aiEx = metrics.aiLeverage.examples || [];
237
251
  const ssEx = metrics.sessionStructure.examples || [];
238
252
 
239
- // Track shown snippets to avoid duplicates
240
- const shownSnippets = new Set();
241
253
  function showUniqueSnippet(prompt) {
242
254
  if (!prompt) return;
243
255
  const s = snippet(prompt, 70);
@@ -282,49 +294,6 @@ function displayDataNarratives(metrics) {
282
294
  console.log();
283
295
  }
284
296
 
285
- // ── Prompt evidence block (shown after AI prose) ──
286
-
287
- function displayPromptEvidence(metrics) {
288
- const allExamples = [
289
- ...(metrics.decomposition.examples || []),
290
- ...(metrics.debugCycles.examples || []),
291
- ...(metrics.aiLeverage.examples || []),
292
- ...(metrics.sessionStructure.examples || []),
293
- ];
294
-
295
- if (allExamples.length === 0) return;
296
-
297
- // Pick up to 3 best examples to show as evidence, deduplicated
298
- const candidates = [];
299
- const arch = pickExample(metrics.aiLeverage.examples, 'architectural');
300
- const decomp = pickExample(metrics.decomposition.examples, 'decomposition');
301
- const debug = pickExample(metrics.debugCycles.examples, 'specific_report');
302
- const ctx = pickExample(metrics.sessionStructure.examples, 'context_setting');
303
-
304
- if (arch) candidates.push({ label: 'Architecture', prompt: arch });
305
- if (decomp) candidates.push({ label: 'Thinking', prompt: decomp });
306
- if (debug) candidates.push({ label: 'Debugging', prompt: debug });
307
- if (ctx) candidates.push({ label: 'Context', prompt: ctx });
308
-
309
- // Deduplicate by snippet text
310
- const picks = [];
311
- const seen = new Set();
312
- for (const c of candidates) {
313
- const s = snippet(c.prompt, 65);
314
- if (!seen.has(s)) {
315
- seen.add(s);
316
- picks.push(c);
317
- }
318
- }
319
-
320
- if (picks.length === 0) return;
321
-
322
- console.log(dim(' YOUR PROMPTS\n'));
323
- for (const pick of picks.slice(0, 3)) {
324
- console.log(` ${dim(pick.label + ':')} ${dim('\u201C')}${dim.italic(snippet(pick.prompt, 65))}${dim('\u201D')}`);
325
- }
326
- console.log();
327
- }
328
297
 
329
298
  // ══════════════════════════════════════════════
330
299
  // EASTER EGGS
@@ -450,7 +419,7 @@ export function displayVerbose(metrics, sessions) {
450
419
 
451
420
  export function displayOffline(result, metrics) {
452
421
  displayScore(result, null);
453
- displayDataNarratives(metrics);
422
+ displayDataNarrativesWithTracker(metrics, new Set());
454
423
  displayEasterEggs(result, metrics);
455
424
  console.log(dim(' Run without --offline for personalized AI-generated insights\n'));
456
425
  displayEnding(result);
package/src/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import chalk from 'chalk';
2
2
  import { detectTools } from './detect.js';
3
3
  import { parseAllProjects } from './parsers/claude-code.js';
4
+ import { parseAllWorkspaces } from './parsers/cursor.js';
5
+ import { parseAllSessions as parseCodexSessions } from './parsers/codex.js';
4
6
  import { computeDecomposition } from './metrics/decomposition.js';
5
7
  import { computeDebugCycles } from './metrics/debug-cycles.js';
6
8
  import { computeAILeverage } from './metrics/ai-leverage.js';
@@ -26,18 +28,7 @@ export async function run(options = {}) {
26
28
 
27
29
  if (tools.length === 0) {
28
30
  console.log(chalk.dim(' No AI coding tools detected.'));
29
- console.log(chalk.dim(' Chekk currently supports Claude Code.'));
30
- console.log(chalk.dim(' Cursor and Codex coming in V2.\n'));
31
- process.exit(1);
32
- }
33
-
34
- const supported = tools.filter(t => t.status !== 'detected_not_supported');
35
- if (supported.length === 0) {
36
- console.log(chalk.dim(' No supported tools found.\n'));
37
- for (const t of tools) {
38
- console.log(chalk.dim(` ${t.tool} \u2014 ${t.message}`));
39
- }
40
- console.log();
31
+ console.log(chalk.dim(' Chekk supports Claude Code, Cursor, and Codex.\n'));
41
32
  process.exit(1);
42
33
  }
43
34
 
@@ -45,9 +36,13 @@ export async function run(options = {}) {
45
36
 
46
37
  // ── Step 2: Parse sessions ──
47
38
  let allSessions = [];
48
- for (const tool of supported) {
39
+ for (const tool of tools) {
49
40
  if (tool.tool === 'Claude Code') {
50
41
  allSessions.push(...parseAllProjects(tool.basePath));
42
+ } else if (tool.tool === 'Cursor') {
43
+ allSessions.push(...parseAllWorkspaces(tool.basePath));
44
+ } else if (tool.tool === 'Codex') {
45
+ allSessions.push(...parseCodexSessions(tool.basePath));
51
46
  }
52
47
  }
53
48
 
@@ -90,7 +85,7 @@ export async function run(options = {}) {
90
85
  totalExchanges,
91
86
  projectCount: projects.length,
92
87
  dateRange: dateRangeFull,
93
- tools: supported.map(t => t.tool),
88
+ tools: tools.map(t => t.tool),
94
89
  };
95
90
 
96
91
  // ── JSON output ──
@@ -0,0 +1,188 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ /**
5
+ * Parse OpenAI Codex CLI session files into normalized format.
6
+ *
7
+ * Codex stores sessions under:
8
+ * ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
9
+ *
10
+ * Each JSONL line follows the OpenAI message format:
11
+ * { "role": "user"|"assistant"|"system", "content": "..." | [...], "timestamp": "..." }
12
+ *
13
+ * Tool calls appear as content blocks with type "function_call" or "tool_use".
14
+ * We normalize into the same session format as Claude Code.
15
+ */
16
+
17
+ function parseJsonlLine(line) {
18
+ try {
19
+ return JSON.parse(line);
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ function extractText(content) {
26
+ if (typeof content === 'string') return content;
27
+ if (!Array.isArray(content)) return '';
28
+ return content
29
+ .filter(block => block.type === 'text' || block.type === 'output_text')
30
+ .map(block => block.text || block.content || '')
31
+ .join('\n');
32
+ }
33
+
34
+ function extractToolCalls(content) {
35
+ if (!Array.isArray(content)) return [];
36
+ return content
37
+ .filter(block =>
38
+ block.type === 'function_call' ||
39
+ block.type === 'tool_use' ||
40
+ block.type === 'tool_call'
41
+ )
42
+ .map(block => ({
43
+ tool: block.name || block.function?.name || 'unknown',
44
+ input: block.arguments || block.input || {},
45
+ }));
46
+ }
47
+
48
+ /**
49
+ * Parse a single Codex rollout JSONL file.
50
+ */
51
+ function parseSessionFile(filePath) {
52
+ let raw;
53
+ try {
54
+ raw = readFileSync(filePath, 'utf-8');
55
+ } catch {
56
+ return [];
57
+ }
58
+
59
+ const lines = raw.split('\n').filter(l => l.trim());
60
+ const exchanges = [];
61
+ let current = null;
62
+
63
+ for (const line of lines) {
64
+ const entry = parseJsonlLine(line);
65
+ if (!entry) continue;
66
+
67
+ const role = entry.role;
68
+ if (!role) continue;
69
+
70
+ // Skip system messages
71
+ if (role === 'system') continue;
72
+
73
+ // Skip tool result messages
74
+ if (role === 'tool' || role === 'function') continue;
75
+
76
+ const text = extractText(entry.content);
77
+ const tools = role === 'assistant' ? extractToolCalls(entry.content) : [];
78
+ const timestamp = entry.timestamp || entry.created_at || null;
79
+
80
+ if (role === 'user') {
81
+ if (!text.trim()) continue;
82
+ if (current) exchanges.push(current);
83
+ current = {
84
+ userPrompt: text,
85
+ userTimestamp: timestamp,
86
+ assistantResponses: [],
87
+ toolCalls: [],
88
+ thinkingContent: [],
89
+ };
90
+ } else if (role === 'assistant' && current) {
91
+ if (text) current.assistantResponses.push(text);
92
+ current.toolCalls.push(...tools);
93
+
94
+ // Codex may include reasoning/thinking
95
+ if (entry.reasoning || entry.thinking) {
96
+ current.thinkingContent.push(entry.reasoning || entry.thinking);
97
+ }
98
+ }
99
+ }
100
+
101
+ if (current) exchanges.push(current);
102
+ return exchanges;
103
+ }
104
+
105
+ /**
106
+ * Recursively find all rollout-*.jsonl files under sessions dir.
107
+ */
108
+ function findSessionFiles(dir) {
109
+ const files = [];
110
+ if (!existsSync(dir)) return files;
111
+
112
+ try {
113
+ const entries = readdirSync(dir);
114
+ for (const entry of entries) {
115
+ const fullPath = join(dir, entry);
116
+ try {
117
+ const stat = statSync(fullPath);
118
+ if (stat.isDirectory()) {
119
+ files.push(...findSessionFiles(fullPath));
120
+ } else if (entry.endsWith('.jsonl')) {
121
+ files.push(fullPath);
122
+ }
123
+ } catch {
124
+ continue;
125
+ }
126
+ }
127
+ } catch {
128
+ // Ignore permission errors
129
+ }
130
+
131
+ return files;
132
+ }
133
+
134
+ /**
135
+ * Extract project name from session file path or content.
136
+ * Codex sessions are in ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
137
+ * Try to get the working directory from the session content.
138
+ */
139
+ function extractProjectName(filePath, exchanges) {
140
+ // Try to extract from the file path date structure
141
+ const parts = filePath.split('/');
142
+ const dateIdx = parts.findIndex(p => p === 'sessions');
143
+ if (dateIdx >= 0 && parts.length > dateIdx + 3) {
144
+ return `codex/${parts[dateIdx + 1]}/${parts[dateIdx + 2]}/${parts[dateIdx + 3]}`;
145
+ }
146
+ return 'codex';
147
+ }
148
+
149
+ /**
150
+ * Parse all Codex sessions from ~/.codex/sessions.
151
+ */
152
+ export function parseAllSessions(basePath) {
153
+ const sessionsDir = join(basePath, 'sessions');
154
+ if (!existsSync(sessionsDir)) return [];
155
+
156
+ const sessionFiles = findSessionFiles(sessionsDir);
157
+ const allSessions = [];
158
+
159
+ for (const filePath of sessionFiles) {
160
+ const exchanges = parseSessionFile(filePath);
161
+ if (exchanges.length === 0) continue;
162
+
163
+ const timestamps = exchanges
164
+ .map(e => e.userTimestamp)
165
+ .filter(Boolean)
166
+ .map(t => typeof t === 'number' ? t * 1000 : new Date(t).getTime())
167
+ .filter(t => !isNaN(t))
168
+ .sort();
169
+
170
+ const fileName = filePath.split('/').pop();
171
+
172
+ allSessions.push({
173
+ id: fileName.replace('.jsonl', ''),
174
+ file: fileName,
175
+ project: extractProjectName(filePath, exchanges),
176
+ exchanges,
177
+ turnCount: exchanges.length * 2,
178
+ exchangeCount: exchanges.length,
179
+ startTime: timestamps[0] ? new Date(timestamps[0]).toISOString() : null,
180
+ endTime: timestamps.length > 0 ? new Date(timestamps[timestamps.length - 1]).toISOString() : null,
181
+ durationMinutes: timestamps.length >= 2
182
+ ? Math.round((timestamps[timestamps.length - 1] - timestamps[0]) / 60000)
183
+ : 0,
184
+ });
185
+ }
186
+
187
+ return allSessions;
188
+ }
@@ -0,0 +1,281 @@
1
+ import { existsSync, readdirSync, statSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { execSync } from 'child_process';
4
+
5
+ /**
6
+ * Parse Cursor chat history from SQLite state.vscdb files.
7
+ *
8
+ * Cursor stores chats in SQLite databases under:
9
+ * ~/Library/Application Support/Cursor/User/workspaceStorage/<hash>/state.vscdb
10
+ *
11
+ * Two storage formats exist:
12
+ * - Legacy: key = 'workbench.panel.aichat.view.aichat.chatdata' → JSON with tabs/bubbles
13
+ * - Current: key like 'composerData:%' in table cursorDiskKV → JSON with conversation data
14
+ * Bubble text stored separately under 'bubbleId:<composerId>:<bubbleId>'
15
+ *
16
+ * We normalize into the same session format as Claude Code.
17
+ */
18
+
19
+ function querySqlite(dbPath, query) {
20
+ try {
21
+ const result = execSync(
22
+ `sqlite3 -json "${dbPath}" "${query.replace(/"/g, '\\"')}"`,
23
+ { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }
24
+ );
25
+ return JSON.parse(result || '[]');
26
+ } catch {
27
+ return [];
28
+ }
29
+ }
30
+
31
+ function querySqliteRaw(dbPath, query) {
32
+ try {
33
+ return execSync(
34
+ `sqlite3 "${dbPath}" "${query.replace(/"/g, '\\"')}"`,
35
+ { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }
36
+ ).trim();
37
+ } catch {
38
+ return '';
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Try to detect which table the DB uses (ItemTable vs cursorDiskKV).
44
+ */
45
+ function detectTable(dbPath) {
46
+ const tables = querySqliteRaw(dbPath, ".tables");
47
+ if (tables.includes('cursorDiskKV')) return 'cursorDiskKV';
48
+ if (tables.includes('ItemTable')) return 'ItemTable';
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * Parse legacy aichat format (tabs with bubbles inline).
54
+ */
55
+ function parseLegacyChat(dbPath, table) {
56
+ const raw = querySqliteRaw(
57
+ dbPath,
58
+ `SELECT value FROM ${table} WHERE key = 'workbench.panel.aichat.view.aichat.chatdata';`
59
+ );
60
+ if (!raw) return [];
61
+
62
+ try {
63
+ const data = JSON.parse(raw);
64
+ if (!data.tabs || !Array.isArray(data.tabs)) return [];
65
+
66
+ const sessions = [];
67
+ for (const tab of data.tabs) {
68
+ if (!tab.bubbles || !Array.isArray(tab.bubbles)) continue;
69
+
70
+ const exchanges = [];
71
+ let current = null;
72
+
73
+ for (const bubble of tab.bubbles) {
74
+ const text = bubble.text || '';
75
+ if (!text.trim()) continue;
76
+
77
+ // type 1 = user, type 2 = assistant
78
+ if (bubble.type === 1) {
79
+ if (current) exchanges.push(current);
80
+ current = {
81
+ userPrompt: text,
82
+ userTimestamp: bubble.createdAt || null,
83
+ assistantResponses: [],
84
+ toolCalls: [],
85
+ thinkingContent: [],
86
+ };
87
+ } else if (bubble.type === 2 && current) {
88
+ current.assistantResponses.push(text);
89
+ // Extract tool info if available
90
+ if (bubble.toolFormerData && Array.isArray(bubble.toolFormerData)) {
91
+ for (const tool of bubble.toolFormerData) {
92
+ current.toolCalls.push({ tool: tool.toolName || 'unknown', input: {} });
93
+ }
94
+ }
95
+ }
96
+ }
97
+ if (current) exchanges.push(current);
98
+ if (exchanges.length === 0) continue;
99
+
100
+ const timestamps = exchanges
101
+ .map(e => e.userTimestamp)
102
+ .filter(Boolean)
103
+ .map(t => new Date(t).getTime())
104
+ .sort();
105
+
106
+ sessions.push({
107
+ id: tab.tabId || `cursor-${sessions.length}`,
108
+ file: 'state.vscdb',
109
+ exchanges,
110
+ turnCount: exchanges.length * 2,
111
+ exchangeCount: exchanges.length,
112
+ startTime: timestamps[0] ? new Date(timestamps[0]).toISOString() : null,
113
+ endTime: timestamps.length > 0 ? new Date(timestamps[timestamps.length - 1]).toISOString() : null,
114
+ durationMinutes: timestamps.length >= 2
115
+ ? Math.round((timestamps[timestamps.length - 1] - timestamps[0]) / 60000)
116
+ : 0,
117
+ });
118
+ }
119
+ return sessions;
120
+ } catch {
121
+ return [];
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Parse composer format (cursorDiskKV with separate bubble keys).
127
+ */
128
+ function parseComposerChat(dbPath) {
129
+ // Get all composer conversation metadata
130
+ const composerRows = querySqlite(
131
+ dbPath,
132
+ `SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%';`
133
+ );
134
+
135
+ if (!composerRows.length) return [];
136
+
137
+ const sessions = [];
138
+
139
+ for (const row of composerRows) {
140
+ try {
141
+ const composerData = JSON.parse(row.value || '{}');
142
+ const composerId = composerData.composerId || row.key.replace('composerData:', '');
143
+ const headers = composerData.fullConversationHeadersOnly || [];
144
+
145
+ if (headers.length === 0) continue;
146
+
147
+ const exchanges = [];
148
+ let current = null;
149
+
150
+ for (const header of headers) {
151
+ const bubbleKey = `bubbleId:${composerId}:${header.bubbleId}`;
152
+ const bubbleRaw = querySqliteRaw(
153
+ dbPath,
154
+ `SELECT value FROM cursorDiskKV WHERE key = '${bubbleKey}';`
155
+ );
156
+
157
+ if (!bubbleRaw) continue;
158
+
159
+ let bubbleData;
160
+ try {
161
+ bubbleData = JSON.parse(bubbleRaw);
162
+ } catch {
163
+ continue;
164
+ }
165
+
166
+ const text = bubbleData.text || '';
167
+ if (!text.trim()) continue;
168
+
169
+ // type 1 = user, type 2 = assistant
170
+ if (header.type === 1) {
171
+ if (current) exchanges.push(current);
172
+ current = {
173
+ userPrompt: text,
174
+ userTimestamp: bubbleData.createdAt || bubbleData.timingInfo?.startedAt || null,
175
+ assistantResponses: [],
176
+ toolCalls: [],
177
+ thinkingContent: [],
178
+ };
179
+ } else if (header.type === 2 && current) {
180
+ current.assistantResponses.push(text);
181
+ if (bubbleData.toolFormerData && Array.isArray(bubbleData.toolFormerData)) {
182
+ for (const tool of bubbleData.toolFormerData) {
183
+ current.toolCalls.push({ tool: tool.toolName || String(tool.tool || 'unknown'), input: {} });
184
+ }
185
+ }
186
+ }
187
+ }
188
+ if (current) exchanges.push(current);
189
+ if (exchanges.length === 0) continue;
190
+
191
+ const timestamps = exchanges
192
+ .map(e => e.userTimestamp)
193
+ .filter(Boolean)
194
+ .map(t => typeof t === 'number' ? t : new Date(t).getTime())
195
+ .sort();
196
+
197
+ sessions.push({
198
+ id: composerId,
199
+ file: 'state.vscdb',
200
+ exchanges,
201
+ turnCount: exchanges.length * 2,
202
+ exchangeCount: exchanges.length,
203
+ startTime: timestamps[0] ? new Date(timestamps[0]).toISOString() : null,
204
+ endTime: timestamps.length > 0 ? new Date(timestamps[timestamps.length - 1]).toISOString() : null,
205
+ durationMinutes: timestamps.length >= 2
206
+ ? Math.round((timestamps[timestamps.length - 1] - timestamps[0]) / 60000)
207
+ : 0,
208
+ });
209
+ } catch {
210
+ continue;
211
+ }
212
+ }
213
+
214
+ return sessions;
215
+ }
216
+
217
+ /**
218
+ * Get workspace name from workspace.json.
219
+ */
220
+ function getWorkspaceName(workspaceDir) {
221
+ const wsFile = join(workspaceDir, 'workspace.json');
222
+ if (!existsSync(wsFile)) return workspaceDir.split('/').pop();
223
+ try {
224
+ const data = JSON.parse(execSync(`cat "${wsFile}"`, { encoding: 'utf-8' }));
225
+ // workspace.json has a "folder" field with the project path
226
+ const folder = data.folder || data.workspace || '';
227
+ // Extract meaningful project name from path
228
+ const decoded = decodeURIComponent(folder.replace('file://', ''));
229
+ const parts = decoded.split('/').filter(Boolean);
230
+ return parts.slice(-2).join('/') || workspaceDir.split('/').pop();
231
+ } catch {
232
+ return workspaceDir.split('/').pop();
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Parse all Cursor workspaces from workspaceStorage.
238
+ */
239
+ export function parseAllWorkspaces(basePath) {
240
+ if (!existsSync(basePath)) return [];
241
+
242
+ const allSessions = [];
243
+ let dirs;
244
+ try {
245
+ dirs = readdirSync(basePath).filter(f => {
246
+ try { return statSync(join(basePath, f)).isDirectory(); } catch { return false; }
247
+ });
248
+ } catch {
249
+ return [];
250
+ }
251
+
252
+ for (const dir of dirs) {
253
+ const workspaceDir = join(basePath, dir);
254
+ const dbPath = join(workspaceDir, 'state.vscdb');
255
+ if (!existsSync(dbPath)) continue;
256
+
257
+ const projectName = getWorkspaceName(workspaceDir);
258
+ const table = detectTable(dbPath);
259
+ if (!table) continue;
260
+
261
+ let sessions = [];
262
+
263
+ // Try composer format first (newer)
264
+ if (table === 'cursorDiskKV') {
265
+ sessions = parseComposerChat(dbPath);
266
+ // Also try legacy key in same table
267
+ if (sessions.length === 0) {
268
+ sessions = parseLegacyChat(dbPath, table);
269
+ }
270
+ } else {
271
+ sessions = parseLegacyChat(dbPath, table);
272
+ }
273
+
274
+ for (const session of sessions) {
275
+ session.project = projectName;
276
+ allSessions.push(session);
277
+ }
278
+ }
279
+
280
+ return allSessions;
281
+ }