Files
wehub-resource-sync f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

595 lines
14 KiB
Go

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)
}