Skip to content

Commit c85aa95

Browse files
cbb330claude
andauthored
fix: support Cursor's nested transcript directory layout (#243)
## Summary - Cursor recently changed its transcript storage from a flat layout (`agent-transcripts/<uuid>.jsonl`) to a nested layout (`agent-transcripts/<uuid>/<uuid>.jsonl`). The discovery and source file lookup functions only handled the flat layout, causing sessions from newer Cursor versions to be invisible. - Updates `DiscoverCursorSessions` and `FindCursorSourceFile` to handle both flat and nested layouts. - Extracts dedup logic into `cursorAddSeen` helper to avoid repetition between the two code paths. ## Test plan - [x] New table-driven tests for nested layout discovery (jsonl, txt, dedup, mixed flat+nested) - [x] New tests for `FindCursorSourceFile` with nested layout - [x] Existing flat-layout tests continue to pass - [x] Verified against real Cursor transcript directory on macOS (`~/.cursor/projects/`) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8b8805a commit c85aa95

File tree

2 files changed

+204
-38
lines changed

2 files changed

+204
-38
lines changed

internal/parser/discovery.go

Lines changed: 95 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,22 @@ func confirmGeminiSessionID(
481481
// the Cursor projects dir (<projectsDir>/<project>/agent-transcripts/<uuid>.txt).
482482
// All discovered paths are validated to resolve within the
483483
// canonical projectsDir, preventing symlink escapes.
484+
// cursorAddSeen inserts a transcript path into the seen map,
485+
// preferring .jsonl over .txt when both exist for the same stem.
486+
func cursorAddSeen(
487+
seen map[string]string, name, fullPath string,
488+
) {
489+
stem := strings.TrimSuffix(name, filepath.Ext(name))
490+
if prev, ok := seen[stem]; ok {
491+
if strings.HasSuffix(prev, ".txt") &&
492+
strings.HasSuffix(name, ".jsonl") {
493+
seen[stem] = fullPath
494+
}
495+
return
496+
}
497+
seen[stem] = fullPath
498+
}
499+
484500
func DiscoverCursorSessions(
485501
projectsDir string,
486502
) []DiscoveredFile {
@@ -538,33 +554,64 @@ func DiscoverCursorSessions(
538554
// Collect valid transcripts, deduping by basename
539555
// stem. When both .jsonl and .txt exist for the
540556
// same session, prefer .jsonl.
557+
//
558+
// Cursor uses two layouts:
559+
// flat: agent-transcripts/<uuid>.{txt,jsonl}
560+
// nested: agent-transcripts/<uuid>/<uuid>.{txt,jsonl}
541561
seen := make(map[string]string) // stem -> path
542562
for _, sf := range transcripts {
543-
if sf.IsDir() {
544-
continue
545-
}
546-
name := sf.Name()
547-
if !IsCursorTranscriptExt(name) {
563+
if !sf.IsDir() {
564+
// Flat layout: file directly in
565+
// agent-transcripts/.
566+
name := sf.Name()
567+
if !IsCursorTranscriptExt(name) {
568+
continue
569+
}
570+
fullPath := filepath.Join(
571+
transcriptsDir, name,
572+
)
573+
if !IsRegularFile(fullPath) {
574+
continue
575+
}
576+
cursorAddSeen(seen, name, fullPath)
548577
continue
549578
}
550-
fullPath := filepath.Join(
551-
transcriptsDir, name,
579+
580+
// Nested layout: agent-transcripts/<uuid>/
581+
// containing <uuid>.{txt,jsonl}.
582+
subDir := filepath.Join(
583+
transcriptsDir, sf.Name(),
552584
)
553-
if !IsRegularFile(fullPath) {
585+
subEntries, err := os.ReadDir(subDir)
586+
if err != nil {
554587
continue
555588
}
556-
stem := strings.TrimSuffix(
557-
name, filepath.Ext(name),
558-
)
559-
if prev, ok := seen[stem]; ok {
560-
// .jsonl wins over .txt
561-
if strings.HasSuffix(prev, ".txt") &&
562-
strings.HasSuffix(name, ".jsonl") {
563-
seen[stem] = fullPath
589+
dirName := sf.Name()
590+
for _, sub := range subEntries {
591+
if sub.IsDir() {
592+
continue
564593
}
565-
continue
594+
name := sub.Name()
595+
if !IsCursorTranscriptExt(name) {
596+
continue
597+
}
598+
// Only accept files whose stem matches
599+
// the parent directory name, e.g.
600+
// <uuid>/<uuid>.jsonl.
601+
stem := strings.TrimSuffix(
602+
name, filepath.Ext(name),
603+
)
604+
if stem != dirName {
605+
continue
606+
}
607+
fullPath := filepath.Join(
608+
subDir, name,
609+
)
610+
if !IsRegularFile(fullPath) {
611+
continue
612+
}
613+
cursorAddSeen(seen, name, fullPath)
566614
}
567-
seen[stem] = fullPath
568615
}
569616
for _, path := range seen {
570617
files = append(files, DiscoveredFile{
@@ -606,28 +653,38 @@ func FindCursorSourceFile(
606653
if !entry.IsDir() {
607654
continue
608655
}
609-
candidate := filepath.Join(
610-
projectsDir, entry.Name(),
611-
"agent-transcripts", target,
612-
)
613-
if !IsRegularFile(candidate) {
614-
continue
615-
}
616-
resolved, err := filepath.EvalSymlinks(
617-
candidate,
618-
)
619-
if err != nil {
620-
continue
656+
// Nested layout first (matches discovery
657+
// precedence), then flat layout.
658+
candidates := []string{
659+
filepath.Join(
660+
projectsDir, entry.Name(),
661+
"agent-transcripts", sessionID, target,
662+
),
663+
filepath.Join(
664+
projectsDir, entry.Name(),
665+
"agent-transcripts", target,
666+
),
621667
}
622-
rel, err := filepath.Rel(
623-
resolvedRoot, resolved,
624-
)
625-
sep := string(filepath.Separator)
626-
if err != nil || rel == ".." ||
627-
strings.HasPrefix(rel, ".."+sep) {
628-
continue
668+
for _, candidate := range candidates {
669+
if !IsRegularFile(candidate) {
670+
continue
671+
}
672+
resolved, err := filepath.EvalSymlinks(
673+
candidate,
674+
)
675+
if err != nil {
676+
continue
677+
}
678+
rel, err := filepath.Rel(
679+
resolvedRoot, resolved,
680+
)
681+
sep := string(filepath.Separator)
682+
if err != nil || rel == ".." ||
683+
strings.HasPrefix(rel, ".."+sep) {
684+
continue
685+
}
686+
return candidate
629687
}
630-
return candidate
631688
}
632689
}
633690
return ""

internal/parser/discovery_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1192,6 +1192,89 @@ func TestDiscoverCursorSessions(t *testing.T) {
11921192
}
11931193
}
11941194

1195+
func TestDiscoverCursorSessions_NestedLayout(t *testing.T) {
1196+
cursorTranscripts := filepath.Join(
1197+
"proj-dir", "agent-transcripts",
1198+
)
1199+
1200+
tests := []struct {
1201+
name string
1202+
files map[string]string
1203+
wantCount int
1204+
}{
1205+
{
1206+
name: "NestedJsonl",
1207+
files: map[string]string{
1208+
filepath.Join(cursorTranscripts, "aaa", "aaa.jsonl"): `{"role":"user"}`,
1209+
},
1210+
wantCount: 1,
1211+
},
1212+
{
1213+
name: "NestedTxt",
1214+
files: map[string]string{
1215+
filepath.Join(cursorTranscripts, "bbb", "bbb.txt"): "user:\nhi",
1216+
},
1217+
wantCount: 1,
1218+
},
1219+
{
1220+
name: "NestedWithSubagentsIgnored",
1221+
files: map[string]string{
1222+
filepath.Join(cursorTranscripts, "ccc", "ccc.jsonl"): `{"role":"user"}`,
1223+
filepath.Join(cursorTranscripts, "ccc", "subagents", "sub1.jsonl"): `{"role":"user"}`,
1224+
filepath.Join(cursorTranscripts, "ccc", "subagents", "sub2.jsonl"): `{"role":"user"}`,
1225+
},
1226+
wantCount: 1,
1227+
},
1228+
{
1229+
name: "NestedDedupPrefersJsonl",
1230+
files: map[string]string{
1231+
filepath.Join(cursorTranscripts, "ddd", "ddd.txt"): "user:\nhi",
1232+
filepath.Join(cursorTranscripts, "ddd", "ddd.jsonl"): `{"role":"user"}`,
1233+
},
1234+
wantCount: 1,
1235+
},
1236+
{
1237+
name: "NestedIgnoresAuxiliaryFiles",
1238+
files: map[string]string{
1239+
filepath.Join(cursorTranscripts, "eee", "eee.jsonl"): `{"role":"user"}`,
1240+
filepath.Join(cursorTranscripts, "eee", "other.jsonl"): `{"role":"user"}`,
1241+
filepath.Join(cursorTranscripts, "eee", "notes.txt"): "notes",
1242+
},
1243+
wantCount: 1,
1244+
},
1245+
{
1246+
name: "MixedFlatAndNested",
1247+
files: map[string]string{
1248+
filepath.Join(cursorTranscripts, "flat.txt"): "user:\nhi",
1249+
filepath.Join(cursorTranscripts, "nested", "nested.jsonl"): `{"role":"user"}`,
1250+
},
1251+
wantCount: 2,
1252+
},
1253+
}
1254+
1255+
for _, tt := range tests {
1256+
t.Run(tt.name, func(t *testing.T) {
1257+
dir := t.TempDir()
1258+
setupFileSystem(t, dir, tt.files)
1259+
files := DiscoverCursorSessions(dir)
1260+
if len(files) != tt.wantCount {
1261+
t.Fatalf(
1262+
"got %d files, want %d",
1263+
len(files), tt.wantCount,
1264+
)
1265+
}
1266+
for _, f := range files {
1267+
if f.Agent != AgentCursor {
1268+
t.Errorf(
1269+
"agent = %q, want %q",
1270+
f.Agent, AgentCursor,
1271+
)
1272+
}
1273+
}
1274+
})
1275+
}
1276+
}
1277+
11951278
func TestDiscoverCursorSessions_DedupPrefersJsonl(t *testing.T) {
11961279
dir := t.TempDir()
11971280
transcripts := filepath.Join(
@@ -1257,6 +1340,32 @@ func TestFindCursorSourceFile(t *testing.T) {
12571340
}
12581341
})
12591342

1343+
t.Run("FindsNestedJsonl", func(t *testing.T) {
1344+
dir := t.TempDir()
1345+
setupFileSystem(t, dir, map[string]string{
1346+
filepath.Join(cursorTranscripts, "sess4", "sess4.jsonl"): "{}",
1347+
})
1348+
got := FindCursorSourceFile(dir, "sess4")
1349+
if got == "" {
1350+
t.Fatal("expected to find nested .jsonl file")
1351+
}
1352+
if !strings.HasSuffix(got, filepath.Join("sess4", "sess4.jsonl")) {
1353+
t.Errorf("unexpected path %q", got)
1354+
}
1355+
})
1356+
1357+
t.Run("PrefersJsonlOverNestedTxt", func(t *testing.T) {
1358+
dir := t.TempDir()
1359+
setupFileSystem(t, dir, map[string]string{
1360+
filepath.Join(cursorTranscripts, "sess5", "sess5.txt"): "old",
1361+
filepath.Join(cursorTranscripts, "sess5", "sess5.jsonl"): "new",
1362+
})
1363+
got := FindCursorSourceFile(dir, "sess5")
1364+
if !strings.HasSuffix(got, "sess5.jsonl") {
1365+
t.Errorf("expected .jsonl path, got %q", got)
1366+
}
1367+
})
1368+
12601369
t.Run("NotFound", func(t *testing.T) {
12611370
dir := t.TempDir()
12621371
got := FindCursorSourceFile(dir, "nonexistent")

0 commit comments

Comments
 (0)