-
Notifications
You must be signed in to change notification settings - Fork 512
Expand file tree
/
Copy pathcursor.go
More file actions
594 lines (534 loc) · 14.5 KB
/
Copy pathcursor.go
File metadata and controls
594 lines (534 loc) · 14.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
package parser
import (
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"path/filepath"
"strings"
"time"
"github.com/tidwall/gjson"
)
// maxCursorTranscriptSize is the maximum transcript file size
// we'll read into memory. Cursor transcripts are typically
// under 500 KB; 10 MB provides generous headroom.
const maxCursorTranscriptSize = 10 << 20
// parseSession parses a Cursor agent transcript file. Transcripts are plain
// text with "user:" and "assistant:" role markers, tool calls, and thinking
// blocks.
func (p *cursorProvider) parseSession(
path, project, machine string,
) (*ParsedSession, []ParsedMessage, error) {
// Open with O_NOFOLLOW (Unix) to reject symlinks at the
// final path component, closing the TOCTOU window between
// discovery validation and file read.
f, err := openNoFollow(path)
if err != nil {
return nil, nil, fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
// Use Fstat on the open fd — this reflects the actual
// opened file, not whatever the path currently points to.
info, err := f.Stat()
if err != nil {
return nil, nil, fmt.Errorf("stat %s: %w", path, err)
}
if !info.Mode().IsRegular() {
return nil, nil, fmt.Errorf(
"skip %s: not a regular file", path,
)
}
// Use LimitReader to enforce the size cap even if the
// file grows after Fstat. Read one extra byte so we can
// detect truncation.
data, err := io.ReadAll(
io.LimitReader(f, maxCursorTranscriptSize+1),
)
if err != nil {
return nil, nil, fmt.Errorf("read %s: %w", path, err)
}
if int64(len(data)) > maxCursorTranscriptSize {
return nil, nil, fmt.Errorf(
"skip %s: file too large (read %d bytes, max %d)",
path, len(data), maxCursorTranscriptSize,
)
}
text := string(data)
var messages []ParsedMessage
if isCursorJSONL(text) {
messages = parseCursorJSONL(text)
} else {
lines := strings.Split(text, "\n")
messages = parseCursorMessages(lines)
}
if len(messages) == 0 {
return nil, nil, nil
}
sessionID := CursorSessionID(path)
var firstMessage string
for _, m := range messages {
if m.Role == RoleUser && m.Content != "" {
firstMessage = truncate(
strings.ReplaceAll(m.Content, "\n", " "), 300,
)
break
}
}
// Compute hash from the already-read data to avoid
// re-opening the file by path (which would be another
// TOCTOU opportunity).
hash := fmt.Sprintf("%x", sha256.Sum256(data))
mtime := info.ModTime()
sess := &ParsedSession{
ID: sessionID,
Project: project,
Machine: machine,
Agent: AgentCursor,
FirstMessage: firstMessage,
StartedAt: mtime,
EndedAt: mtime,
MessageCount: len(messages),
File: FileInfo{
Path: path,
Size: info.Size(),
Mtime: mtime.UnixNano(),
Hash: hash,
},
}
return sess, messages, nil
}
// cursorBlock represents a raw block of lines between role
// markers in a Cursor transcript.
type cursorBlock struct {
role RoleType
lines []string
}
// parseCursorMessages splits transcript lines on role markers
// and converts each block into a ParsedMessage.
func parseCursorMessages(lines []string) []ParsedMessage {
blocks := splitCursorBlocks(lines)
messages := make([]ParsedMessage, 0, len(blocks))
for i, block := range blocks {
content, hasThinking, toolCalls := extractCursorContent(
block.role, block.lines,
)
content = strings.TrimSpace(content)
if content == "" && len(toolCalls) == 0 {
continue
}
messages = append(messages, ParsedMessage{
Ordinal: i,
Role: block.role,
Content: content,
Timestamp: time.Time{},
HasThinking: hasThinking,
HasToolUse: len(toolCalls) > 0,
ContentLength: len(content),
ToolCalls: toolCalls,
})
}
// Re-number ordinals to be contiguous after filtering
for i := range messages {
messages[i].Ordinal = i
}
return messages
}
// splitCursorBlocks splits lines into blocks delimited by
// "user:" or "assistant:" on a line by itself.
func splitCursorBlocks(lines []string) []cursorBlock {
var blocks []cursorBlock
var current *cursorBlock
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "user:" || trimmed == "assistant:" {
if current != nil {
blocks = append(blocks, *current)
}
role := RoleUser
if trimmed == "assistant:" {
role = RoleAssistant
}
current = &cursorBlock{role: role}
continue
}
if current != nil {
current.lines = append(current.lines, line)
}
}
if current != nil {
blocks = append(blocks, *current)
}
return blocks
}
// extractCursorContent processes lines for a single message
// block, returning the visible text content, whether thinking
// was present, and any tool calls found.
func extractCursorContent(
role RoleType, lines []string,
) (string, bool, []ParsedToolCall) {
if role == RoleUser {
content := extractUserQuery(lines)
return content, false, nil
}
return extractAssistantContent(lines)
}
// extractUserQuery extracts text from <user_query> tags.
// Falls back to joining all lines if no tags are found.
func extractUserQuery(lines []string) string {
text := strings.Join(lines, "\n")
start := strings.Index(text, "<user_query>")
end := strings.Index(text, "</user_query>")
if start >= 0 && end > start {
return strings.TrimSpace(
text[start+len("<user_query>") : end],
)
}
return strings.TrimSpace(text)
}
// extractAssistantContent parses assistant message lines for
// visible text, thinking blocks, and tool calls.
func extractAssistantContent(
lines []string,
) (string, bool, []ParsedToolCall) {
var textParts []string
var toolCalls []ParsedToolCall
hasThinking := false
i := 0
for i < len(lines) {
line := lines[i]
trimmed := strings.TrimSpace(line)
// Thinking block
if strings.HasPrefix(trimmed, "[Thinking]") {
hasThinking = true
i++
for i < len(lines) {
if isBlockBodyEnd(lines[i]) {
break
}
i++
}
continue
}
// Tool call
if toolName, ok := strings.CutPrefix(
trimmed, "[Tool call] ",
); ok {
i++
bodyStart := i
for i < len(lines) {
if isBlockBodyEnd(lines[i]) {
break
}
i++
}
inputJSON := cursorToolInputJSON(
toolName,
lines[bodyStart:i],
)
toolCalls = append(toolCalls, ParsedToolCall{
ToolName: toolName,
Category: NormalizeToolCategory(toolName),
InputJSON: inputJSON,
SkillName: inferToolSkillName(
toolName,
inputJSON,
),
})
continue
}
// Tool result — skip the header and body
if strings.HasPrefix(trimmed, "[Tool result]") {
i++
for i < len(lines) {
if isBlockBodyEnd(lines[i]) {
break
}
i++
}
continue
}
// Regular text
textParts = append(textParts, line)
i++
}
content := strings.TrimSpace(strings.Join(textParts, "\n"))
return content, hasThinking, toolCalls
}
func cursorToolInputJSON(toolName string, lines []string) string {
raw := strings.TrimSpace(strings.Join(dedentCursorBlock(lines), "\n"))
if raw == "" {
return ""
}
if gjson.Valid(raw) {
return raw
}
if strings.EqualFold(toolName, "ApplyPatch") &&
(strings.Contains(raw, "*** Begin Patch") ||
strings.HasPrefix(raw, "@@")) {
return marshalCursorToolParams(map[string]string{
"patch": raw,
})
}
params := make(map[string]string)
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
key, value, ok := strings.Cut(trimmed, "=")
key = strings.TrimSpace(key)
if !ok || key == "" {
return ""
}
params[key] = strings.TrimSpace(value)
}
if len(params) == 0 {
return ""
}
return marshalCursorToolParams(params)
}
func dedentCursorBlock(lines []string) []string {
minIndent := -1
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
indent := cursorLineIndent(line)
if minIndent < 0 || indent < minIndent {
minIndent = indent
}
}
if minIndent <= 0 {
return lines
}
dedented := make([]string, len(lines))
for i, line := range lines {
if len(line) < minIndent {
dedented[i] = strings.TrimLeft(line, " \t")
continue
}
dedented[i] = line[minIndent:]
}
return dedented
}
func cursorLineIndent(line string) int {
for i, r := range line {
if r != ' ' && r != '\t' {
return i
}
}
return len(line)
}
func marshalCursorToolParams(params map[string]string) string {
data, err := json.Marshal(params)
if err != nil {
return ""
}
return string(data)
}
// isAssistantMarker returns true if the line is a structural
// marker within an assistant block (thinking, tool call, or
// tool result).
func isAssistantMarker(trimmed string) bool {
return strings.HasPrefix(trimmed, "[Thinking]") ||
strings.HasPrefix(trimmed, "[Tool call] ") ||
strings.HasPrefix(trimmed, "[Tool result]")
}
// isBlockBodyEnd returns true if line signals the end of a
// structured block body (thinking, tool call, or tool result).
// Block bodies consist of indented or empty lines; a non-empty
// line at the left margin is either a new marker or regular
// assistant prose that should not be consumed.
func isBlockBodyEnd(line string) bool {
trimmed := strings.TrimSpace(line)
if isAssistantMarker(trimmed) {
return true
}
// Non-empty line at left margin ends the block.
return trimmed != "" && len(line) > 0 && line[0] != ' ' &&
line[0] != '\t'
}
// CursorSessionID derives a session ID from a transcript file
// path by stripping whatever extension is present.
func CursorSessionID(path string) string {
base := filepath.Base(path)
base = strings.TrimSuffix(base, filepath.Ext(base))
return "cursor:" + base
}
// isCursorJSONL returns true if the data looks like JSONL
// (Anthropic API message format) rather than plain text.
// Scans up to 4 KB to locate the first non-empty line, then
// validates the full line from the original data.
func isCursorJSONL(data string) bool {
const maxScan = 4096
// Find the byte offset of the first non-whitespace
// character within the scan window.
limit := min(len(data), maxScan)
lineStart := -1
for i := range limit {
if data[i] != '\n' && data[i] != '\r' &&
data[i] != ' ' && data[i] != '\t' {
lineStart = i
break
}
}
if lineStart < 0 {
return false
}
// Extract the full first line from the original data
// (not the truncated scan window).
lineEnd := strings.IndexByte(data[lineStart:], '\n')
var line string
if lineEnd < 0 {
line = data[lineStart:]
} else {
line = data[lineStart : lineStart+lineEnd]
}
return gjson.Valid(strings.TrimSpace(line))
}
// parseCursorJSONL parses a Cursor JSONL transcript where
// each line is an Anthropic API message object with "role"
// and "message.content" fields.
func parseCursorJSONL(data string) []ParsedMessage {
lines := strings.Split(data, "\n")
var messages []ParsedMessage
ordinal := 0
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || !gjson.Valid(line) {
continue
}
role := gjson.Get(line, "role").Str
if role != "user" && role != "assistant" {
continue
}
content := gjson.Get(line, "message.content")
if !content.Exists() {
continue
}
var msg ParsedMessage
msg.Ordinal = ordinal
if role == "user" {
msg.Role = RoleUser
msg.Content = extractJSONLUserContent(content)
} else {
msg.Role = RoleAssistant
text, _, hasThinking, hasToolUse,
toolCalls, toolResults :=
ExtractTextContent(content)
msg.Content = text
msg.HasThinking = hasThinking
msg.HasToolUse = hasToolUse
msg.ToolCalls = toolCalls
msg.ToolResults = toolResults
}
msg.Content = strings.TrimSpace(msg.Content)
msg.ContentLength = len(msg.Content)
if msg.Content == "" &&
len(msg.ToolCalls) == 0 &&
len(msg.ToolResults) == 0 {
continue
}
messages = append(messages, msg)
ordinal++
}
return messages
}
// extractJSONLUserContent extracts text from a user message's
// content field. If the content is a string, strips
// <user_query> tags. If it's an array of blocks, collects
// text blocks and strips tags from the combined result.
func extractJSONLUserContent(content gjson.Result) string {
if content.Type == gjson.String {
return extractUserQuery(
strings.Split(content.Str, "\n"),
)
}
if !content.IsArray() {
return ""
}
var parts []string
content.ForEach(func(_, block gjson.Result) bool {
if block.Get("type").Str == "text" {
text := block.Get("text").Str
if text != "" {
parts = append(parts, text)
}
}
return true
})
if len(parts) == 0 {
return ""
}
combined := strings.Join(parts, "\n")
return extractUserQuery(strings.Split(combined, "\n"))
}
// cursorHighMarkers are directory names that are very
// unlikely to appear inside usernames (capitalized or
// plural forms). Checked first during path decoding.
var cursorHighMarkers = map[string]bool{
"Documents": true, "Code": true,
"projects": true, "repos": true,
}
// cursorLowMarkers are directory names that could
// plausibly appear in usernames (short, lowercase).
// Only checked as a fallback after high markers.
var cursorLowMarkers = map[string]bool{
"code": true, "src": true,
"work": true, "dev": true,
}
// DecodeCursorProjectDir extracts a clean project name from
// a Cursor-style hyphenated directory name. Cursor encodes
// absolute paths by replacing / and . with hyphens, e.g.
// "Users-fiona-Documents-mcp-cursor-analytics".
//
// Scans forward from the home-directory root to find the
// first marker, handling multi-token usernames (e.g.
// "Users-john-doe-Documents-project").
func DecodeCursorProjectDir(dirName string) string {
if dirName == "" {
return ""
}
parts := strings.Split(dirName, "-")
// Determine the earliest position a marker could
// appear, based on the platform root prefix:
// macOS/Linux: Users-<name>-<marker> (min idx 2)
// home-<name>-<marker> (min idx 2)
// Windows: C-Users-<name>-<marker> (min idx 3)
minIdx := -1
if len(parts) >= 3 &&
(parts[0] == "Users" || parts[0] == "home") {
minIdx = 2
} else if len(parts) >= 4 &&
len(parts[0]) == 1 && parts[1] == "Users" {
minIdx = 3
}
// Two-pass scan: high-confidence markers first (unlikely
// in usernames), then low-confidence as fallback. This
// handles "Users-john-code-doe-Documents-app" correctly
// (finds Documents, not code).
if minIdx >= 0 {
for _, tier := range []map[string]bool{
cursorHighMarkers, cursorLowMarkers,
} {
for i := minIdx; i < len(parts)-1; i++ {
if !tier[parts[i]] {
continue
}
result := strings.Join(
parts[i+1:], "-",
)
if result != "" {
return NormalizeName(result)
}
}
}
}
// Fallback: last two components
if len(parts) >= 2 {
return NormalizeName(
strings.Join(parts[len(parts)-2:], "-"),
)
}
return NormalizeName(dirName)
}