@tracebench/adapter-cursor 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +17 -0
- package/README.md +55 -0
- package/dist/discover.d.ts +11 -0
- package/dist/discover.d.ts.map +1 -0
- package/dist/discover.js +65 -0
- package/dist/discover.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/normalize.d.ts +26 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/normalize.js +284 -0
- package/dist/normalize.js.map +1 -0
- package/dist/parse.d.ts +7 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +24 -0
- package/dist/parse.js.map +1 -0
- package/dist/paths.d.ts +11 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +42 -0
- package/dist/paths.js.map +1 -0
- package/fixtures/01-simple.jsonl +3 -0
- package/fixtures/02-subagent.jsonl +2 -0
- package/package.json +34 -0
- package/src/discover.test.ts +34 -0
- package/src/discover.ts +76 -0
- package/src/index.ts +17 -0
- package/src/normalize.test.ts +71 -0
- package/src/normalize.ts +331 -0
- package/src/parse.ts +30 -0
- package/src/paths.ts +46 -0
- package/tsconfig.json +10 -0
- package/vitest.config.ts +7 -0
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tracebench/adapter-cursor",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Tracebench adapter for Cursor agent transcripts (~/.cursor/projects/**/agent-transcripts/**/*.jsonl).",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Skandesh",
|
|
7
|
+
"homepage": "https://github.com/Skandesh/tracebench",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Skandesh/tracebench.git",
|
|
11
|
+
"directory": "packages/adapter-cursor"
|
|
12
|
+
},
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "./dist/index.js",
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@tracebench/core": "0.2.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc -p tsconfig.json",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"test:watch": "vitest",
|
|
32
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { discoverSessions } from './discover.js';
|
|
6
|
+
|
|
7
|
+
describe('discoverSessions', () => {
|
|
8
|
+
it('finds nested and subagent jsonl files', () => {
|
|
9
|
+
const root = mkdtempSync(join(tmpdir(), 'tb-cursor-discover-'));
|
|
10
|
+
const project = join(root, 'Users-me-testproj');
|
|
11
|
+
const nested = join(project, 'agent-transcripts', 'sess-main', 'sess-main.jsonl');
|
|
12
|
+
const subagent = join(
|
|
13
|
+
project,
|
|
14
|
+
'agent-transcripts',
|
|
15
|
+
'sess-main',
|
|
16
|
+
'subagents',
|
|
17
|
+
'sess-sub.jsonl',
|
|
18
|
+
);
|
|
19
|
+
mkdirSync(join(project, 'agent-transcripts', 'sess-main', 'subagents'), {
|
|
20
|
+
recursive: true,
|
|
21
|
+
});
|
|
22
|
+
writeFileSync(nested, '{"role":"user","message":{"content":[]}}\n');
|
|
23
|
+
writeFileSync(subagent, '{"role":"user","message":{"content":[]}}\n');
|
|
24
|
+
|
|
25
|
+
const found = discoverSessions(root);
|
|
26
|
+
const ids = found.map((f) => f.session_id).sort();
|
|
27
|
+
expect(ids).toEqual(['sess-main', 'sess-sub']);
|
|
28
|
+
expect(found.every((f) => f.encoded_project_dir === 'Users-me-testproj')).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns empty when root is missing', () => {
|
|
32
|
+
expect(discoverSessions('/nonexistent/tracebench-cursor-root')).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
});
|
package/src/discover.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Discover Cursor agent transcripts on disk.
|
|
2
|
+
//
|
|
3
|
+
// Layout:
|
|
4
|
+
// ~/.cursor/projects/<sanitized-path>/agent-transcripts/
|
|
5
|
+
// <session-uuid>.jsonl (flat)
|
|
6
|
+
// <session-uuid>/<session-uuid>.jsonl (nested)
|
|
7
|
+
// <parent-uuid>/subagents/<subagent-uuid>.jsonl
|
|
8
|
+
|
|
9
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
10
|
+
import { join, basename } from 'node:path';
|
|
11
|
+
import { defaultCursorProjectsRoot } from './paths.js';
|
|
12
|
+
|
|
13
|
+
export interface DiscoveredCursorSession {
|
|
14
|
+
session_id: string;
|
|
15
|
+
file_path: string;
|
|
16
|
+
encoded_project_dir: string;
|
|
17
|
+
size: number;
|
|
18
|
+
mtime_ms: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { defaultCursorProjectsRoot as defaultProjectsRoot };
|
|
22
|
+
|
|
23
|
+
function walkJsonl(
|
|
24
|
+
dir: string,
|
|
25
|
+
encodedProjectDir: string,
|
|
26
|
+
out: DiscoveredCursorSession[],
|
|
27
|
+
): void {
|
|
28
|
+
let entries: import('node:fs').Dirent[];
|
|
29
|
+
try {
|
|
30
|
+
entries = readdirSync(dir, { withFileTypes: true }) as import('node:fs').Dirent[];
|
|
31
|
+
} catch {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
for (const ent of entries) {
|
|
35
|
+
const name = String(ent.name);
|
|
36
|
+
const p = join(dir, name);
|
|
37
|
+
if (ent.isDirectory()) {
|
|
38
|
+
walkJsonl(p, encodedProjectDir, out);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
42
|
+
let st;
|
|
43
|
+
try {
|
|
44
|
+
st = statSync(p);
|
|
45
|
+
} catch {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const sessionId = basename(name, '.jsonl');
|
|
49
|
+
out.push({
|
|
50
|
+
session_id: sessionId,
|
|
51
|
+
file_path: p,
|
|
52
|
+
encoded_project_dir: encodedProjectDir,
|
|
53
|
+
size: st.size,
|
|
54
|
+
mtime_ms: st.mtimeMs,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function discoverSessions(root?: string): DiscoveredCursorSession[] {
|
|
60
|
+
const base = root ?? defaultCursorProjectsRoot();
|
|
61
|
+
const out: DiscoveredCursorSession[] = [];
|
|
62
|
+
let projectDirs: string[];
|
|
63
|
+
try {
|
|
64
|
+
projectDirs = readdirSync(base, { withFileTypes: true })
|
|
65
|
+
.filter((d) => d.isDirectory())
|
|
66
|
+
.map((d) => d.name);
|
|
67
|
+
} catch {
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
for (const dir of projectDirs) {
|
|
71
|
+
const transcriptsDir = join(base, dir, 'agent-transcripts');
|
|
72
|
+
walkJsonl(transcriptsDir, dir, out);
|
|
73
|
+
}
|
|
74
|
+
out.sort((a, b) => b.mtime_ms - a.mtime_ms);
|
|
75
|
+
return out;
|
|
76
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const HARNESS_NAME = 'cursor' as const;
|
|
2
|
+
export const FORMAT_VERSION = '2026-q1';
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
defaultCursorProjectsRoot,
|
|
6
|
+
defaultCursorUserDataDir,
|
|
7
|
+
defaultCursorGlobalDbPath,
|
|
8
|
+
decodeProjectPath,
|
|
9
|
+
} from './paths.js';
|
|
10
|
+
export { defaultProjectsRoot, discoverSessions, type DiscoveredCursorSession } from './discover.js';
|
|
11
|
+
export { parseSession, streamSession, type RawCursorEvent } from './parse.js';
|
|
12
|
+
export {
|
|
13
|
+
loadSession,
|
|
14
|
+
normalizeSession,
|
|
15
|
+
parseTranscriptPath,
|
|
16
|
+
type NormalizeResult,
|
|
17
|
+
} from './normalize.js';
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { loadSession, normalizeSession, parseTranscriptPath } from './normalize.js';
|
|
5
|
+
import { parseSession } from './parse.js';
|
|
6
|
+
import { decodeProjectPath } from './paths.js';
|
|
7
|
+
|
|
8
|
+
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), '..', 'fixtures');
|
|
9
|
+
|
|
10
|
+
describe('fixture 01 — simple agent transcript', () => {
|
|
11
|
+
it('emits a cursor session with decoded project path', async () => {
|
|
12
|
+
const rawPath = join(
|
|
13
|
+
'/Users/me/.cursor/projects/Users-me-code-fixture/agent-transcripts',
|
|
14
|
+
'aaaa-bbbb-cccc-dddd-eeeeeeeeeeee/aaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl',
|
|
15
|
+
);
|
|
16
|
+
const raws = await parseSession(join(FIXTURES, '01-simple.jsonl'));
|
|
17
|
+
const { session, events } = normalizeSession(raws, {
|
|
18
|
+
rawPath,
|
|
19
|
+
sessionId: 'aaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
|
20
|
+
formatVersion: '2026-q1',
|
|
21
|
+
fileMtimeMs: new Date('2026-05-18T12:00:05.000Z').getTime(),
|
|
22
|
+
encodedProjectDir: 'Users-me-code-fixture',
|
|
23
|
+
});
|
|
24
|
+
expect(session.harness).toBe('cursor');
|
|
25
|
+
expect(session.session_id).toBe('aaaa-bbbb-cccc-dddd-eeeeeeeeeeee');
|
|
26
|
+
expect(session.project_path).toBe('/Users/me/code/fixture');
|
|
27
|
+
expect(session.title).toMatch(/Read the README/);
|
|
28
|
+
expect(session.model).toBeNull();
|
|
29
|
+
expect(events.length).toBeGreaterThan(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('splits assistant blocks and emits tool_call without upstream id', async () => {
|
|
33
|
+
const { events } = await loadSession(join(FIXTURES, '01-simple.jsonl'));
|
|
34
|
+
const toolCalls = events.filter((e) => e.event_type === 'tool_call');
|
|
35
|
+
expect(toolCalls.length).toBe(1);
|
|
36
|
+
expect(toolCalls[0]!.tool.name).toBe('Read');
|
|
37
|
+
expect(toolCalls[0]!.event_id).toMatch(/^cursor:/);
|
|
38
|
+
expect(events.some((e) => e.event_type === 'tool_result')).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('strips user_query wrappers from user messages', async () => {
|
|
42
|
+
const { events } = await loadSession(join(FIXTURES, '01-simple.jsonl'));
|
|
43
|
+
const user = events.find((e) => e.role === 'user');
|
|
44
|
+
expect(user!.content).toBe('Read the README and summarize it');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('starts a new turn per user message', async () => {
|
|
48
|
+
const { events } = await loadSession(join(FIXTURES, '01-simple.jsonl'));
|
|
49
|
+
const turnIds = new Set(events.map((e) => e.turn_id));
|
|
50
|
+
expect(turnIds.size).toBe(1);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('parseTranscriptPath', () => {
|
|
55
|
+
it('detects subagent transcripts', () => {
|
|
56
|
+
const p =
|
|
57
|
+
'/Users/me/.cursor/projects/Users-me-proj/agent-transcripts/parent-uuid/subagents/child-uuid.jsonl';
|
|
58
|
+
const info = parseTranscriptPath(p);
|
|
59
|
+
expect(info.subagent).toBe(true);
|
|
60
|
+
expect(info.parent_session_id).toBe('parent-uuid');
|
|
61
|
+
expect(info.encoded_project_dir).toBe('Users-me-proj');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('decodeProjectPath', () => {
|
|
66
|
+
it('decodes macOS-style encoded dirs', () => {
|
|
67
|
+
expect(decodeProjectPath('Users-skndsh-Desktop-projects-tracebench')).toBe(
|
|
68
|
+
'/Users/skndsh/Desktop/projects/tracebench',
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// Convert Cursor agent-transcript JSONL into canonical Tracebench events.
|
|
2
|
+
//
|
|
3
|
+
// Cursor writes supplementary logs under ~/.cursor/projects/.../agent-transcripts/.
|
|
4
|
+
// Format is Anthropic-like (role + message.content blocks) but:
|
|
5
|
+
// - no per-line timestamps (synthetic timestamps from file mtime)
|
|
6
|
+
// - tool_use blocks have no id field
|
|
7
|
+
// - no tool_result lines — tool outputs are not exported separately
|
|
8
|
+
//
|
|
9
|
+
// Turn boundaries: each user text message starts a new turn.
|
|
10
|
+
|
|
11
|
+
import type { CanonicalEvent, EventTool, EventType, Session } from '@tracebench/core';
|
|
12
|
+
import type { RawCursorEvent } from './parse.js';
|
|
13
|
+
import { decodeProjectPath } from './paths.js';
|
|
14
|
+
|
|
15
|
+
interface NormalizeOptions {
|
|
16
|
+
rawPath: string;
|
|
17
|
+
sessionId?: string;
|
|
18
|
+
formatVersion: string;
|
|
19
|
+
/** File mtime (ms) — used for synthetic per-line timestamps. */
|
|
20
|
+
fileMtimeMs?: number;
|
|
21
|
+
encodedProjectDir?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface NormalizeResult {
|
|
25
|
+
session: Session;
|
|
26
|
+
events: CanonicalEvent[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function emptyTool(): EventTool {
|
|
30
|
+
return { name: null, input: null, output: null, status: null, error_message: null };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const emptyTokens = {
|
|
34
|
+
input: null,
|
|
35
|
+
output: null,
|
|
36
|
+
cache_read: null,
|
|
37
|
+
cache_creation: null,
|
|
38
|
+
reasoning: null,
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
function toString(content: unknown): string | null {
|
|
42
|
+
if (typeof content === 'string') return content;
|
|
43
|
+
if (Array.isArray(content)) {
|
|
44
|
+
const parts = content
|
|
45
|
+
.map((b) => {
|
|
46
|
+
if (typeof b === 'string') return b;
|
|
47
|
+
if (b && typeof b === 'object' && 'text' in b && typeof (b as { text: unknown }).text === 'string') {
|
|
48
|
+
return (b as { text: string }).text;
|
|
49
|
+
}
|
|
50
|
+
return '';
|
|
51
|
+
})
|
|
52
|
+
.filter((s) => s.length > 0);
|
|
53
|
+
return parts.length > 0 ? parts.join('\n') : null;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stripUserQuery(text: string | null): string | null {
|
|
59
|
+
if (text == null) return null;
|
|
60
|
+
const stripped = text.replace(/<\/?user_query>/gi, '').trim();
|
|
61
|
+
return stripped.length > 0 ? stripped : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function deriveTitle(content: unknown): string | null {
|
|
65
|
+
const s = stripUserQuery(toString(content));
|
|
66
|
+
if (!s) return null;
|
|
67
|
+
const single = s.replace(/\s+/g, ' ').trim();
|
|
68
|
+
if (!single) return null;
|
|
69
|
+
return single.length > 120 ? single.slice(0, 117) + '...' : single;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function parseTranscriptPath(rawPath: string): {
|
|
73
|
+
encoded_project_dir: string | null;
|
|
74
|
+
subagent: boolean;
|
|
75
|
+
parent_session_id: string | null;
|
|
76
|
+
} {
|
|
77
|
+
const dirMatch = rawPath.match(/\/projects\/([^/]+)\/agent-transcripts\//);
|
|
78
|
+
const encoded = dirMatch ? dirMatch[1]! : null;
|
|
79
|
+
const subMatch = /\/agent-transcripts\/([^/]+)\/subagents\/([^/]+)\.jsonl$/i.exec(rawPath);
|
|
80
|
+
if (subMatch) {
|
|
81
|
+
return {
|
|
82
|
+
encoded_project_dir: encoded,
|
|
83
|
+
subagent: true,
|
|
84
|
+
parent_session_id: subMatch[1]!,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return { encoded_project_dir: encoded, subagent: false, parent_session_id: null };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function syntheticTimestamps(lineCount: number, endMs: number): string[] {
|
|
91
|
+
if (lineCount === 0) return [];
|
|
92
|
+
const span = Math.max(lineCount - 1, 0) * 1000;
|
|
93
|
+
const startMs = endMs - span;
|
|
94
|
+
return Array.from({ length: lineCount }, (_, i) =>
|
|
95
|
+
new Date(startMs + i * 1000).toISOString(),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function normalizeSession(
|
|
100
|
+
raws: RawCursorEvent[],
|
|
101
|
+
opts: NormalizeOptions,
|
|
102
|
+
): NormalizeResult {
|
|
103
|
+
const pathInfo = parseTranscriptPath(opts.rawPath);
|
|
104
|
+
const encodedDir =
|
|
105
|
+
opts.encodedProjectDir ?? pathInfo.encoded_project_dir ?? 'unknown';
|
|
106
|
+
const projectPath = decodeProjectPath(encodedDir);
|
|
107
|
+
|
|
108
|
+
let resolvedSessionId = opts.sessionId ?? null;
|
|
109
|
+
if (!resolvedSessionId) {
|
|
110
|
+
resolvedSessionId =
|
|
111
|
+
opts.rawPath.split('/').pop()?.replace(/\.jsonl$/, '') ?? 'unknown';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const endMs = opts.fileMtimeMs ?? Date.now();
|
|
115
|
+
const timestamps = syntheticTimestamps(raws.length, endMs);
|
|
116
|
+
const firstTs = timestamps[0] ?? new Date(endMs).toISOString();
|
|
117
|
+
const lastTs = timestamps[timestamps.length - 1] ?? firstTs;
|
|
118
|
+
|
|
119
|
+
let firstUser: RawCursorEvent | undefined;
|
|
120
|
+
for (const r of raws) {
|
|
121
|
+
if (r.role === 'user' && !firstUser) firstUser = r;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const titleSource = firstUser
|
|
125
|
+
? ((firstUser.message as { content?: unknown } | undefined)?.content ?? null)
|
|
126
|
+
: null;
|
|
127
|
+
const title = deriveTitle(titleSource);
|
|
128
|
+
|
|
129
|
+
const source = {
|
|
130
|
+
harness: 'cursor' as const,
|
|
131
|
+
format_version: opts.formatVersion,
|
|
132
|
+
raw_path: opts.rawPath,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const sessionMeta: Record<string, unknown> = {
|
|
136
|
+
encoded_project_dir: encodedDir,
|
|
137
|
+
};
|
|
138
|
+
if (pathInfo.subagent) {
|
|
139
|
+
sessionMeta.subagent = true;
|
|
140
|
+
if (pathInfo.parent_session_id) sessionMeta.parent_session_id = pathInfo.parent_session_id;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const events: CanonicalEvent[] = [];
|
|
144
|
+
let turnIndex = 0;
|
|
145
|
+
let currentTurnId = `${resolvedSessionId}::t0`;
|
|
146
|
+
|
|
147
|
+
function newTurn(): string {
|
|
148
|
+
turnIndex += 1;
|
|
149
|
+
currentTurnId = `${resolvedSessionId}::t${turnIndex}`;
|
|
150
|
+
return currentTurnId;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function baseMeta(extra: Record<string, unknown> = {}): Record<string, unknown> {
|
|
154
|
+
return { ...sessionMeta, ...extra };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (let lineIndex = 0; lineIndex < raws.length; lineIndex++) {
|
|
158
|
+
const r = raws[lineIndex]!;
|
|
159
|
+
const ts = timestamps[lineIndex] ?? lastTs;
|
|
160
|
+
const role = typeof r.role === 'string' ? r.role : 'unknown';
|
|
161
|
+
|
|
162
|
+
if (role === 'user') {
|
|
163
|
+
newTurn();
|
|
164
|
+
const content = (r.message as { content?: unknown } | undefined)?.content;
|
|
165
|
+
events.push({
|
|
166
|
+
event_id: `cursor:${resolvedSessionId}:u:${lineIndex}`,
|
|
167
|
+
session_id: resolvedSessionId,
|
|
168
|
+
turn_id: currentTurnId,
|
|
169
|
+
parent_event_id: null,
|
|
170
|
+
timestamp: ts,
|
|
171
|
+
source,
|
|
172
|
+
role: 'user',
|
|
173
|
+
event_type: 'message',
|
|
174
|
+
model: null,
|
|
175
|
+
tokens: { ...emptyTokens },
|
|
176
|
+
cost_usd: null,
|
|
177
|
+
cost_method: null,
|
|
178
|
+
duration_ms: null,
|
|
179
|
+
content: stripUserQuery(toString(content)),
|
|
180
|
+
tool: emptyTool(),
|
|
181
|
+
metadata: baseMeta(),
|
|
182
|
+
raw: r,
|
|
183
|
+
});
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (role === 'assistant') {
|
|
188
|
+
const msg = (r.message as { content?: unknown } | undefined) ?? {};
|
|
189
|
+
const blocks = Array.isArray(msg.content) ? msg.content : [];
|
|
190
|
+
|
|
191
|
+
if (blocks.length === 0) {
|
|
192
|
+
events.push({
|
|
193
|
+
event_id: `cursor:${resolvedSessionId}:a:${lineIndex}:0`,
|
|
194
|
+
session_id: resolvedSessionId,
|
|
195
|
+
turn_id: currentTurnId,
|
|
196
|
+
parent_event_id: null,
|
|
197
|
+
timestamp: ts,
|
|
198
|
+
source,
|
|
199
|
+
role: 'assistant',
|
|
200
|
+
event_type: 'message',
|
|
201
|
+
model: null,
|
|
202
|
+
tokens: { ...emptyTokens },
|
|
203
|
+
cost_usd: null,
|
|
204
|
+
cost_method: null,
|
|
205
|
+
duration_ms: null,
|
|
206
|
+
content: null,
|
|
207
|
+
tool: emptyTool(),
|
|
208
|
+
metadata: baseMeta({ empty: true }),
|
|
209
|
+
raw: r,
|
|
210
|
+
});
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (let bi = 0; bi < blocks.length; bi++) {
|
|
215
|
+
const block = blocks[bi] as Record<string, unknown>;
|
|
216
|
+
const blockType = typeof block.type === 'string' ? block.type : '';
|
|
217
|
+
const eventId = `cursor:${resolvedSessionId}:${lineIndex}:${bi}`;
|
|
218
|
+
|
|
219
|
+
let evtType: EventType = 'message';
|
|
220
|
+
let toolInfo = emptyTool();
|
|
221
|
+
let contentField: CanonicalEvent['content'] = null;
|
|
222
|
+
|
|
223
|
+
if (blockType === 'text') {
|
|
224
|
+
evtType = 'message';
|
|
225
|
+
contentField = typeof block.text === 'string' ? block.text : null;
|
|
226
|
+
} else if (blockType === 'tool_use') {
|
|
227
|
+
evtType = 'tool_call';
|
|
228
|
+
toolInfo = {
|
|
229
|
+
name: typeof block.name === 'string' ? block.name : null,
|
|
230
|
+
input: (block.input as Record<string, unknown> | undefined) ?? null,
|
|
231
|
+
output: null,
|
|
232
|
+
status: null,
|
|
233
|
+
error_message: null,
|
|
234
|
+
};
|
|
235
|
+
} else if (blockType === 'thinking') {
|
|
236
|
+
evtType = 'thinking';
|
|
237
|
+
contentField =
|
|
238
|
+
typeof block.thinking === 'string'
|
|
239
|
+
? block.thinking
|
|
240
|
+
: typeof block.text === 'string'
|
|
241
|
+
? block.text
|
|
242
|
+
: null;
|
|
243
|
+
} else {
|
|
244
|
+
evtType = 'meta';
|
|
245
|
+
contentField = block as unknown as CanonicalEvent['content'];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
events.push({
|
|
249
|
+
event_id: eventId,
|
|
250
|
+
session_id: resolvedSessionId,
|
|
251
|
+
turn_id: currentTurnId,
|
|
252
|
+
parent_event_id: null,
|
|
253
|
+
timestamp: ts,
|
|
254
|
+
source,
|
|
255
|
+
role: 'assistant',
|
|
256
|
+
event_type: evtType,
|
|
257
|
+
model: null,
|
|
258
|
+
tokens: { ...emptyTokens },
|
|
259
|
+
cost_usd: null,
|
|
260
|
+
cost_method: null,
|
|
261
|
+
duration_ms: null,
|
|
262
|
+
content: contentField,
|
|
263
|
+
tool: toolInfo,
|
|
264
|
+
metadata: baseMeta(blockType && blockType !== 'text' ? { block_type: blockType } : {}),
|
|
265
|
+
raw: bi === 0 ? r : { _ref_line: lineIndex },
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
events.push({
|
|
272
|
+
event_id: `cursor:${resolvedSessionId}:meta:${lineIndex}`,
|
|
273
|
+
session_id: resolvedSessionId,
|
|
274
|
+
turn_id: currentTurnId,
|
|
275
|
+
parent_event_id: null,
|
|
276
|
+
timestamp: ts,
|
|
277
|
+
source,
|
|
278
|
+
role: 'system',
|
|
279
|
+
event_type: 'meta',
|
|
280
|
+
model: null,
|
|
281
|
+
tokens: { ...emptyTokens },
|
|
282
|
+
cost_usd: null,
|
|
283
|
+
cost_method: null,
|
|
284
|
+
duration_ms: null,
|
|
285
|
+
content: null,
|
|
286
|
+
tool: emptyTool(),
|
|
287
|
+
metadata: baseMeta({ kind: role }),
|
|
288
|
+
raw: r,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const session: Session = {
|
|
293
|
+
session_id: resolvedSessionId,
|
|
294
|
+
harness: 'cursor',
|
|
295
|
+
project_path: projectPath,
|
|
296
|
+
title,
|
|
297
|
+
started_at: firstTs,
|
|
298
|
+
ended_at: lastTs,
|
|
299
|
+
model: null,
|
|
300
|
+
raw_path: opts.rawPath,
|
|
301
|
+
format_version: opts.formatVersion,
|
|
302
|
+
mtime_ms: 0,
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
return { session, events };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
import { parseSession } from './parse.js';
|
|
309
|
+
import { promises as fs } from 'node:fs';
|
|
310
|
+
|
|
311
|
+
export async function loadSession(
|
|
312
|
+
filePath: string,
|
|
313
|
+
opts: { formatVersion?: string; encodedProjectDir?: string } = {},
|
|
314
|
+
): Promise<NormalizeResult> {
|
|
315
|
+
const raws = await parseSession(filePath);
|
|
316
|
+
let fileMtimeMs: number | undefined;
|
|
317
|
+
try {
|
|
318
|
+
const st = await fs.stat(filePath);
|
|
319
|
+
fileMtimeMs = st.mtimeMs;
|
|
320
|
+
} catch {
|
|
321
|
+
// ignore
|
|
322
|
+
}
|
|
323
|
+
const sessionId = filePath.split('/').pop()?.replace(/\.jsonl$/, '');
|
|
324
|
+
return normalizeSession(raws, {
|
|
325
|
+
rawPath: filePath,
|
|
326
|
+
sessionId,
|
|
327
|
+
formatVersion: opts.formatVersion ?? '2026-q1',
|
|
328
|
+
fileMtimeMs,
|
|
329
|
+
encodedProjectDir: opts.encodedProjectDir,
|
|
330
|
+
});
|
|
331
|
+
}
|
package/src/parse.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Stream a Cursor agent-transcript JSONL file line by line.
|
|
2
|
+
|
|
3
|
+
import { createReadStream } from 'node:fs';
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
5
|
+
|
|
6
|
+
export type RawCursorEvent = Record<string, unknown> & {
|
|
7
|
+
role?: string;
|
|
8
|
+
message?: unknown;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export async function* streamSession(
|
|
12
|
+
filePath: string,
|
|
13
|
+
): AsyncIterable<RawCursorEvent> {
|
|
14
|
+
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
|
15
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
16
|
+
for await (const line of rl) {
|
|
17
|
+
if (!line.trim()) continue;
|
|
18
|
+
try {
|
|
19
|
+
yield JSON.parse(line) as RawCursorEvent;
|
|
20
|
+
} catch {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function parseSession(filePath: string): Promise<RawCursorEvent[]> {
|
|
27
|
+
const out: RawCursorEvent[] = [];
|
|
28
|
+
for await (const ev of streamSession(filePath)) out.push(ev);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Cursor on-disk path helpers (JSONL roots + future Composer SQLite locations).
|
|
2
|
+
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export function defaultCursorProjectsRoot(): string {
|
|
7
|
+
return join(homedir(), '.cursor', 'projects');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** VS Code–style Cursor `User/` data directory (global + workspace DBs). */
|
|
11
|
+
export function defaultCursorUserDataDir(): string {
|
|
12
|
+
const home = homedir();
|
|
13
|
+
switch (process.platform) {
|
|
14
|
+
case 'darwin':
|
|
15
|
+
return join(home, 'Library', 'Application Support', 'Cursor', 'User');
|
|
16
|
+
case 'win32': {
|
|
17
|
+
const appData = process.env.APPDATA ?? join(home, 'AppData', 'Roaming');
|
|
18
|
+
return join(appData, 'Cursor', 'User');
|
|
19
|
+
}
|
|
20
|
+
default:
|
|
21
|
+
return join(home, '.config', 'Cursor', 'User');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Global Composer DB — Phase 2 reads this (not used by JSONL adapter yet). */
|
|
26
|
+
export function defaultCursorGlobalDbPath(): string {
|
|
27
|
+
return join(defaultCursorUserDataDir(), 'globalStorage', 'state.vscdb');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Decode sanitized project dir name back to an absolute path (best-effort).
|
|
32
|
+
* Cursor encodes `/Users/me/proj` → `Users-me-proj` (leading slash stripped).
|
|
33
|
+
*/
|
|
34
|
+
export function decodeProjectPath(encoded: string): string {
|
|
35
|
+
if (encoded.startsWith('Users-')) {
|
|
36
|
+
return '/Users/' + encoded.slice('Users-'.length).replace(/-/g, '/');
|
|
37
|
+
}
|
|
38
|
+
if (encoded.startsWith('home-')) {
|
|
39
|
+
return '/' + encoded.replace(/-/g, '/');
|
|
40
|
+
}
|
|
41
|
+
if (/^[A-Za-z]-/.test(encoded)) {
|
|
42
|
+
const drive = encoded[0]!.toUpperCase();
|
|
43
|
+
return drive + ':\\' + encoded.slice(2).replace(/-/g, '\\');
|
|
44
|
+
}
|
|
45
|
+
return encoded;
|
|
46
|
+
}
|
package/tsconfig.json
ADDED