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 +57 -15
- package/package.json +1 -1
- package/src/detect.js +76 -24
- package/src/display.js +32 -63
- package/src/index.js +9 -14
- package/src/parsers/codex.js +188 -0
- package/src/parsers/cursor.js +281 -0
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
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);
|
|
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:
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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.
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
239
|
+
displayDataNarrativesWithTracker(metrics, shownSnippets);
|
|
226
240
|
}
|
|
227
241
|
}
|
|
228
242
|
|
|
229
|
-
function
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
+
}
|