diff --git a/internal/source/cursor/source.go b/internal/source/cursor/source.go index f5ef8d3..e714874 100644 --- a/internal/source/cursor/source.go +++ b/internal/source/cursor/source.go @@ -1,126 +1,145 @@ +// Package cursor extracts Cursor coding-assistant conversations from Cursor's +// on-disk stores. Cursor keeps the same conversation in two places: +// +// - agent-transcripts JSONL: ~/.cursor/projects//agent-transcripts//.jsonl +// (also flat .jsonl). The name decodes to the workspace path. +// Transcripts carry assistant prose and tool-call inputs but no reasoning, tool +// results, tokens, model, or timestamps. Sub-agent conversations live alongside +// under /subagents/.jsonl. +// - global state: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb +// table cursorDiskKV, keys composerData: (header order + model/title/createdAt, +// plus subagentInfo on sub-agent sessions) and bubbleId:: (per-turn text, +// thinking, toolFormerData with results, and tokenCount). This store is the richer +// of the two. +// +// Where a session exists in both stores (the common case), the records are merged +// into one: the global-state bubbles are the content/metrics backbone (they alone +// carry reasoning, tool results, tokens, model, and timestamps) and the transcript +// supplies the authoritative workspace path. Sessions present in only one store are +// read from whichever store has them. A session is never emitted twice. Sub-agent +// conversations are emitted as their own records, tagged via Provenance.SourceOrigin +// ("cursor-subagent") with a link back to the parent session. package cursor import ( "context" "database/sql" - "encoding/json" + "errors" "fmt" + "io/fs" "os" "path/filepath" "runtime" + "sort" "strings" + "sync" "github.com/AletheiaResearch/mnemosyne/internal/schema" "github.com/AletheiaResearch/mnemosyne/internal/source" ) type Source struct { - dbPath string + dbPath string // state.vscdb (global state) + projectsRoot string // ~/.cursor/projects (agent-transcripts) + + mu sync.Mutex + cache map[string]*sessionEntry // memoized session index for one extraction pass } func New(dbPath string) *Source { if dbPath == "" { - dbPath = defaultPath() + dbPath = defaultDBPath() + } + return &Source{ + dbPath: dbPath, + projectsRoot: defaultProjectsRoot(), } - return &Source{dbPath: dbPath} } func (s *Source) Name() string { return "cursor" } -func (s *Source) Discover(context.Context) ([]source.Grouping, error) { - db, err := source.OpenSQLite(s.dbPath) - if err != nil { - return nil, nil - } - defer db.Close() +// sessionEntry is one logical Cursor conversation, unified across both stores. +type sessionEntry struct { + id string + workspace string // resolved filesystem path, or "" when unattributed + transcriptPath string // set when an agent-transcripts JSONL exists + hasComposer bool // set when a non-empty composerData: exists + isSubagent bool // set when the session is a Cursor sub-agent conversation + parentID string // parent session id for a sub-agent, when known +} - rows, err := db.Query(`select key, value from cursorDiskKV where key like 'composerData:%'`) - if err != nil { - return nil, err - } - defer rows.Close() +func (e *sessionEntry) hasContent() bool { + return e.hasComposer || e.transcriptPath != "" +} + +func (s *Source) Discover(context.Context) ([]source.Grouping, error) { + index := s.index() type aggregate struct { records int + bytes int64 + label string } grouped := make(map[string]*aggregate) - total := 0 - for rows.Next() { - var key string - var value []byte - if err := rows.Scan(&key, &value); err != nil { + for _, entry := range index { + if !entry.hasContent() { continue } - composerID := strings.TrimPrefix(key, "composerData:") - workspace := s.workspaceForComposer(db, composerID, value) - if workspace == "" { - workspace = source.EstimateUnknownLabel(s.Name()) + gid := groupingID(entry.workspace) + agg := grouped[gid] + if agg == nil { + agg = &aggregate{label: displayLabel(entry.workspace)} + grouped[gid] = agg } - if grouped[workspace] == nil { - grouped[workspace] = &aggregate{} + agg.records++ + if entry.transcriptPath != "" { + if info, err := os.Stat(entry.transcriptPath); err == nil { + agg.bytes += info.Size() + } } - grouped[workspace].records++ - total++ - } - - var fileSize int64 - if info, err := os.Stat(s.dbPath); err == nil { - fileSize = info.Size() } groupings := make([]source.Grouping, 0, len(grouped)) - for id, agg := range grouped { - label := id - if id != source.EstimateUnknownLabel(s.Name()) { - label = filepath.Base(id) - } - size := int64(0) - if total > 0 { - size = fileSize / int64(total) * int64(agg.records) - } + for gid, agg := range grouped { groupings = append(groupings, source.Grouping{ - ID: id, - DisplayLabel: "cursor:" + label, + ID: gid, + DisplayLabel: agg.label, Origin: s.Name(), EstimatedRecords: agg.records, - EstimatedBytes: size, + EstimatedBytes: agg.bytes, }) } + sort.Slice(groupings, func(i, j int) bool { return groupings[i].ID < groupings[j].ID }) return groupings, nil } -func (s *Source) Extract(ctx context.Context, grouping source.Grouping, _ source.ExtractionContext, emit func(schema.Record) error) error { - db, err := source.OpenSQLite(s.dbPath) - if err != nil { - return nil - } - defer db.Close() +func (s *Source) Extract(ctx context.Context, grouping source.Grouping, extractCtx source.ExtractionContext, emit func(schema.Record) error) error { + index := s.index() - rows, err := db.Query(`select key, value from cursorDiskKV where key like 'composerData:%'`) - if err != nil { - return err + db, dbErr := source.OpenSQLite(s.dbPath) + if dbErr == nil { + defer db.Close() } - defer rows.Close() - for rows.Next() { - var key string - var value []byte - if err := rows.Scan(&key, &value); err != nil { + for _, id := range sortedIDs(index) { + entry := index[id] + if !entry.hasContent() || groupingID(entry.workspace) != grouping.ID { continue } - composerID := strings.TrimPrefix(key, "composerData:") - workspace := s.workspaceForComposer(db, composerID, value) - if workspace == "" { - workspace = source.EstimateUnknownLabel(s.Name()) - } - if workspace != grouping.ID { + record, ok := s.buildRecord(db, entry) + if !ok { + source.ReportWarning(extractCtx, "cursor: session %s in %s yielded no turns", id, grouping.DisplayLabel) continue } - record, err := s.extractComposer(db, composerID, value, grouping.DisplayLabel, workspace) - if err != nil || len(record.Turns) == 0 { - continue + if entry.workspace == "" { + source.ReportWarning(extractCtx, "cursor: session %s emitted with no resolved workspace", id) + } + // buildRecord already set Grouping to the same DisplayLabel (groupingID + // matched above), so no re-assignment is needed here. + if extractCtx.SuppressReasoning { + suppressReasoning(&record) } if err := emit(record); err != nil { return err @@ -130,113 +149,334 @@ func (s *Source) Extract(ctx context.Context, grouping source.Grouping, _ source } func (s *Source) LookupSession(_ context.Context, sessionID string) (schema.Record, bool, error) { + entry := s.index()[sessionID] + if entry == nil || !entry.hasContent() { + return schema.Record{}, false, nil + } db, err := source.OpenSQLite(s.dbPath) - if err != nil { + if err != nil && !errors.Is(err, os.ErrNotExist) { + return schema.Record{}, false, err + } + if err == nil { + defer db.Close() + } + record, ok := s.buildRecord(db, entry) + if !ok { return schema.Record{}, false, nil } - defer db.Close() + return record, true, nil +} - var value []byte - if err := db.QueryRow(`select value from cursorDiskKV where key = ?`, "composerData:"+sessionID).Scan(&value); err != nil { - if err == sql.ErrNoRows { - return schema.Record{}, false, nil +// index memoizes the unified session index for the lifetime of this Source so +// that Discover, the per-grouping Extract calls, and repeated LookupSession +// calls share one scan rather than re-walking the projects tree and re-decoding +// every composerData blob each time. mnemosyne runs one extraction pass per +// process, so a snapshot taken on first use is the desired semantics. +func (s *Source) index() map[string]*sessionEntry { + s.mu.Lock() + defer s.mu.Unlock() + if s.cache == nil { + s.cache = s.buildIndex() + } + return s.cache +} + +// buildIndex unifies every session across the transcript and global-state stores +// keyed by session id. Transcripts win on workspace attribution because their +// project-dir name decodes to a real filesystem path; bubbles only attribute a +// workspace indirectly. It is best-effort: a missing store contributes nothing. +func (s *Source) buildIndex() map[string]*sessionEntry { + entries := make(map[string]*sessionEntry) + + for id, info := range s.scanTranscripts() { + entries[id] = &sessionEntry{ + id: id, + workspace: info.workspace, + transcriptPath: info.path, + isSubagent: info.isSubagent, + parentID: info.parentID, } - return schema.Record{}, false, err } - workspace := s.workspaceForComposer(db, sessionID, value) - record, err := s.extractComposer(db, sessionID, value, "", workspace) + + db, err := source.OpenSQLite(s.dbPath) if err != nil { - return schema.Record{}, false, err + return entries } - return record, len(record.Turns) > 0, nil -} + defer db.Close() -func (s *Source) workspaceForComposer(db *sql.DB, composerID string, value []byte) string { - headers := composerHeaders(value) - limit := min(5, len(headers)) - for _, bubbleID := range headers[:limit] { - payload, err := s.loadBubble(db, composerID, bubbleID) - if err != nil { + rows, err := db.Query(`select key, value from cursorDiskKV where key like 'composerData:%'`) + if err != nil { + return entries + } + defer rows.Close() + for rows.Next() { + var key string + var value []byte + if err := rows.Scan(&key, &value); err != nil { continue } - uris := source.ExtractSlice(payload, "workspaceUris") - if len(uris) == 0 { + composer, err := source.DecodeJSONObject(value) + if err != nil { continue } - if uri, ok := uris[0].(string); ok && uri != "" { - return strings.TrimPrefix(uri, "file://") + if len(composerHeaderIDs(composer)) == 0 { + continue // empty draft / sub-agent shell — no real conversation + } + id := strings.TrimPrefix(key, "composerData:") + entry := entries[id] + if entry == nil { + entry = &sessionEntry{id: id} + entries[id] = entry + } + entry.hasComposer = true + // subagentInfo is the authoritative sub-agent marker; it also covers the + // rare id collision where a sub-agent id equals an unrelated top-level + // transcript basename, and carries the parent composer id for sub-agents + // that have no transcript to derive it from. + if subagentInfo := source.ExtractMap(composer, "subagentInfo"); subagentInfo != nil { + entry.isSubagent = true + if entry.parentID == "" { + entry.parentID = source.ExtractString(subagentInfo, "parentComposerId") + } + } + if entry.workspace == "" { + entry.workspace = s.composerWorkspace(db, id, composer) } } - return "" + return entries +} + +// buildRecord assembles the merged record for one session. The global-state +// bubbles are the backbone whenever present; the transcript is the fallback, +// including when a composer's bubble rows have been garbage-collected and only +// its header list survives. +func (s *Source) buildRecord(db *sql.DB, entry *sessionEntry) (schema.Record, bool) { + var record schema.Record + usedTranscriptFallback := false + switch { + case entry.hasComposer && db != nil: + record = s.recordFromComposer(db, entry.id) + if len(record.Turns) == 0 && entry.transcriptPath != "" { + // Bubble rows were garbage-collected but the composer header (and the + // title/model/createdAt it carries) survived. Recover the turns from + // the transcript while keeping the composer-derived metadata rather + // than discarding the whole record. + transcript := s.recordFromTranscript(entry.id, entry.transcriptPath) + record.Turns = transcript.Turns + record.EndedAt = source.LatestTimestamp(record.EndedAt, transcript.EndedAt) + if record.StartedAt == "" { + record.StartedAt = transcript.EndedAt + } + finalizeUsage(&record) + usedTranscriptFallback = true + } + case entry.transcriptPath != "": + record = s.recordFromTranscript(entry.id, entry.transcriptPath) + default: + return schema.Record{}, false + } + if len(record.Turns) == 0 { + return schema.Record{}, false + } + + record.WorkingDir = entry.workspace + record.Grouping = displayLabel(entry.workspace) + // The global-state store is only actually read when the composer branch ran + // (hasComposer AND the DB opened). A composer whose DB is unavailable at + // extract time falls through to the transcript, so it must not be described + // as global-state sourced. + globalStateUsed := entry.hasComposer && db != nil + // SourcePath points at the store the turns actually came from; on a fallback + // the turns are the transcript's even though the composer supplied metadata. + sourcePath := s.dbPath + if !globalStateUsed || usedTranscriptFallback { + sourcePath = entry.transcriptPath + } + record.Provenance = &schema.Provenance{ + SourceID: entry.id, + SourceOrigin: s.Name(), + SourcePath: sourcePath, + } + + extensions := make(map[string]any) + if globalStateUsed && entry.transcriptPath != "" { + extensions["stores"] = []string{"global-state", "transcript"} + } + if entry.isSubagent { + record.Provenance.SourceOrigin = "cursor-subagent" + subagent := map[string]any{"is_subagent": true} + if entry.parentID != "" { + subagent["parent_session_id"] = entry.parentID + } + extensions["subagent"] = subagent + } + if len(extensions) > 0 { + record.Provenance.Extensions = extensions + } + return record, true } -func (s *Source) extractComposer(db *sql.DB, composerID string, value []byte, grouping string, workspace string) (schema.Record, error) { - headers := composerHeaders(value) +// recordFromComposer assembles a record from composerData + bubbleId rows. +func (s *Source) recordFromComposer(db *sql.DB, composerID string) schema.Record { + var value []byte + if err := db.QueryRow(`select value from cursorDiskKV where key = ?`, "composerData:"+composerID).Scan(&value); err != nil { + return schema.Record{} + } + composer, err := source.DecodeJSONObject(value) + if err != nil { + return schema.Record{} + } + record := schema.Record{ - RecordID: composerID, - Origin: s.Name(), - Grouping: grouping, - Model: "cursor/unknown", - WorkingDir: workspace, - Turns: make([]schema.Turn, 0), + RecordID: composerID, + Origin: s.Name(), + Model: "cursor/unknown", + Title: source.ExtractString(composer, "name"), + Turns: make([]schema.Turn, 0), + } + if modelConfig := source.ExtractMap(composer, "modelConfig"); modelConfig != nil { + record.Model = source.FirstNonEmpty( + source.ExtractString(modelConfig, "modelName"), + selectedModelID(modelConfig), + record.Model, + ) } + record.StartedAt = source.NormalizeTimestamp(composer["createdAt"]) - for _, bubbleID := range headers { - payload, err := s.loadBubble(db, composerID, bubbleID) + for _, bubbleID := range composerHeaderIDs(composer) { + bubble, err := s.loadBubble(db, composerID, bubbleID) if err != nil { continue } - turnType := source.IntNumber(payload["type"]) - turn := schema.Turn{ - Timestamp: source.NormalizeTimestamp(payload["createdAt"]), - Text: source.ExtractString(payload, "text"), + // Token snapshots accrue on every bubble, including ones whose turn is + // dropped for having no renderable content. + if tokenCount := source.ExtractMap(bubble, "tokenCount"); tokenCount != nil { + record.Usage.InputTokens += source.IntNumber(tokenCount["inputTokens"]) + record.Usage.OutputTokens += source.IntNumber(tokenCount["outputTokens"]) } - if thinking := source.ExtractMap(payload, "thinking"); thinking != nil { - turn.Reasoning = source.ExtractString(thinking, "text") + turn, ok := bubbleToTurn(bubble) + if !ok { + continue } - if modelInfo := source.ExtractMap(payload, "modelInfo"); modelInfo != nil { - record.Model = source.FirstNonEmpty(source.ExtractString(modelInfo, "modelName"), record.Model) + record.Turns = append(record.Turns, turn) + record.StartedAt = source.EarliestTimestamp(record.StartedAt, turn.Timestamp) + record.EndedAt = source.LatestTimestamp(record.EndedAt, turn.Timestamp) + } + record.EndedAt = source.LatestTimestamp( + record.EndedAt, + source.NormalizeTimestamp(composer["lastUpdatedAt"]), + source.NormalizeTimestamp(composer["conversationCheckpointLastUpdatedAt"]), + ) + // A session should never advertise an end without a start. + if record.StartedAt == "" { + record.StartedAt = record.EndedAt + } + + finalizeUsage(&record) + return record +} + +// recordFromTranscript assembles a record from an agent-transcripts JSONL file. +// Transcripts have no per-line timestamps; the file mtime is used as a coarse +// EndedAt only (StartedAt is left unknown rather than fabricating a +// zero-duration session). +func (s *Source) recordFromTranscript(sessionID, path string) schema.Record { + record := schema.Record{ + RecordID: sessionID, + Origin: s.Name(), + Model: "cursor/unknown", + Turns: make([]schema.Turn, 0), + } + _ = source.ReadJSONLines(path, func(_ int, raw []byte) error { + line, err := source.DecodeJSONObject(raw) + if err != nil { + return nil } - if tokenCount := source.ExtractMap(payload, "tokenCount"); tokenCount != nil { - record.Usage.InputTokens += source.IntNumber(tokenCount["inputTokens"]) - record.Usage.OutputTokens += source.IntNumber(tokenCount["outputTokens"]) + role := normalizeTranscriptRole(source.ExtractString(line, "role")) + if role == "" { + return nil } - if tool := source.ExtractMap(payload, "toolFormerData"); tool != nil { - params := normalizeCursorPayload(tool["params"]) - if nested, ok := params.(map[string]any); ok { - params = unwrapCursorToolParameters(nested) - } - result := normalizeCursorPayload(tool["result"]) - call := schema.ToolCall{ - Tool: normalizeCursorToolName(source.ExtractString(tool, "name")), - Input: params, - Status: normalizeCursorStatus(tool["status"]), + message := source.ExtractMap(line, "message") + turn := schema.Turn{Role: role} + for _, item := range source.ExtractSlice(message, "content") { + block, ok := item.(map[string]any) + if !ok { + continue } - if result != nil { - call.Output = &schema.ToolOutput{Raw: result} - if text, ok := result.(string); ok { - call.Output.Text = text + switch source.ExtractString(block, "type") { + case "text": + if text := source.ExtractString(block, "text"); text != "" { + turn.Text = appendText(turn.Text, text) + } + case "thinking", "reasoning": + thinking := source.FirstNonEmpty(source.ExtractString(block, "thinking"), source.ExtractString(block, "text")) + if thinking != "" { + turn.Reasoning = appendText(turn.Reasoning, thinking) } + case "tool_use": + turn.ToolCalls = append(turn.ToolCalls, schema.ToolCall{ + Tool: normalizeCursorToolName(source.ExtractString(block, "name")), + Input: block["input"], + }) } - turn.ToolCalls = append(turn.ToolCalls, call) - } - switch turnType { - case 1: - turn.Role = "user" - case 2: - turn.Role = "assistant" - default: - continue + } + if turn.Text == "" && turn.Reasoning == "" && len(turn.ToolCalls) == 0 { + return nil } record.Turns = append(record.Turns, turn) - record.StartedAt = source.EarliestTimestamp(record.StartedAt, turn.Timestamp) - record.EndedAt = source.LatestTimestamp(record.EndedAt, turn.Timestamp) + return nil + }) + + record.EndedAt = fileTimestamp(path) + finalizeUsage(&record) + return record +} + +// bubbleToTurn converts a single bubbleId payload into a turn. Cursor splits one +// logical assistant message across several bubbles (one per thinking block, prose +// chunk, or tool call), so each bubble maps to at most one turn. +func bubbleToTurn(bubble map[string]any) (schema.Turn, bool) { + role := bubbleRole(source.IntNumber(bubble["type"])) + if role == "" { + return schema.Turn{}, false } + turn := schema.Turn{ + Role: role, + Timestamp: source.NormalizeTimestamp(bubble["createdAt"]), + Text: source.ExtractString(bubble, "text"), + } + if role == "assistant" { + if thinking := source.ExtractMap(bubble, "thinking"); thinking != nil { + turn.Reasoning = source.ExtractString(thinking, "text") + } + if tool := source.ExtractMap(bubble, "toolFormerData"); tool != nil { + turn.ToolCalls = append(turn.ToolCalls, toolCallFromFormer(tool)) + } + } + if turn.Text == "" && turn.Reasoning == "" && len(turn.ToolCalls) == 0 { + return schema.Turn{}, false + } + return turn, true +} - record.Usage.UserTurns = source.CountTurns(record.Turns, "user") - record.Usage.AssistantTurns = source.CountTurns(record.Turns, "assistant") - record.Usage.ToolCalls = source.CountToolCalls(record.Turns) - return record, nil +func toolCallFromFormer(tool map[string]any) schema.ToolCall { + params := normalizeCursorPayload(tool["params"]) + if nested, ok := params.(map[string]any); ok { + params = unwrapCursorToolParameters(nested) + } + result := normalizeCursorPayload(tool["result"]) + call := schema.ToolCall{ + Tool: normalizeCursorToolName(source.ExtractString(tool, "name")), + Input: params, + Status: normalizeCursorStatus(tool["status"]), + } + if result != nil { + call.Output = &schema.ToolOutput{Raw: result} + if text, ok := result.(string); ok { + call.Output.Text = text + } + } + return call } func (s *Source) loadBubble(db *sql.DB, composerID, bubbleID string) (map[string]any, error) { @@ -248,13 +488,229 @@ func (s *Source) loadBubble(db *sql.DB, composerID, bubbleID string) (map[string return source.DecodeJSONObject(raw) } -func composerHeaders(value []byte) []string { - var payload map[string]any - if err := json.Unmarshal(value, &payload); err != nil { - return nil +// composerWorkspace attributes a workspace to a composer that has no transcript. +// It prefers the composer's own workspaceIdentifier, then falls back to the +// workspaceUris stamped on its bubbles. Unlike the previous implementation it +// scans every header bubble rather than capping at the first five. +func (s *Source) composerWorkspace(db *sql.DB, composerID string, composer map[string]any) string { + if wi := source.ExtractMap(composer, "workspaceIdentifier"); wi != nil { + if path := workspaceURIToPath(wi["uri"]); path != "" { + return path + } + } + for _, bubbleID := range composerHeaderIDs(composer) { + bubble, err := s.loadBubble(db, composerID, bubbleID) + if err != nil { + continue + } + for _, item := range source.ExtractSlice(bubble, "workspaceUris") { + if path := workspaceURIToPath(item); path != "" { + return path + } + } + } + return "" +} + +// workspaceURIToPath extracts a filesystem path from a Cursor workspace URI. +// On real builds these are serialized VS Code URI objects +// ({scheme, path, fsPath, ...}); older/remote shapes use a plain "file://" +// string. Cursor does not percent-encode these, so no URL unescaping is needed. +func workspaceURIToPath(value any) string { + switch uri := value.(type) { + case string: + return uriToPath(uri) + case map[string]any: + return source.FirstNonEmpty( + source.ExtractString(uri, "fsPath"), + source.ExtractString(uri, "path"), + ) + default: + return "" } +} + +// --------------------------------------------------------------------------- +// Transcript discovery + project-dir decoding +// --------------------------------------------------------------------------- + +type transcriptInfo struct { + path string + workspace string + isSubagent bool + parentID string +} + +func (s *Source) scanTranscripts() map[string]transcriptInfo { + out := make(map[string]transcriptInfo) + if !source.DirExists(s.projectsRoot) { + return out + } + projectDirs, err := os.ReadDir(s.projectsRoot) + if err != nil { + return out + } + for _, projectDir := range projectDirs { + if !projectDir.IsDir() { + continue + } + transcriptsDir := filepath.Join(s.projectsRoot, projectDir.Name(), "agent-transcripts") + if !source.DirExists(transcriptsDir) { + continue + } + workspace := decodeProjectDir(projectDir.Name()) + for id, raw := range collectTranscripts(transcriptsDir) { + // Same non-sub-agent preference as collectTranscripts, applied across + // project dirs for the rare cross-project basename collision. + if existing, ok := out[id]; ok && !(existing.isSubagent && !raw.isSubagent) { + continue + } + out[id] = transcriptInfo{ + path: raw.path, + workspace: workspace, + isSubagent: raw.isSubagent, + parentID: raw.parentID, + } + } + } + return out +} + +type rawTranscript struct { + path string + isSubagent bool + parentID string +} + +// collectTranscripts returns id->transcript for every transcript JSONL under an +// agent-transcripts directory, keyed by the file's own basename (the id that +// joins composerData). Cursor uses several layouts — flat (.jsonl), nested +// (/.jsonl), and sub-agent conversations at +// /subagents/.jsonl — so the tree is walked recursively and each +// file is classified by whether a "subagents" segment appears in its path. +func collectTranscripts(transcriptsDir string) map[string]rawTranscript { + out := make(map[string]rawTranscript) + files, err := source.CollectFiles(transcriptsDir, func(path string, _ fs.DirEntry) bool { + return filepath.Ext(path) == ".jsonl" + }) + if err != nil { + return out + } + for _, path := range files { + id := strings.TrimSuffix(filepath.Base(path), ".jsonl") + parentID, isSubagent := classifySubagentTranscript(transcriptsDir, path) + // On a basename collision prefer a top-level transcript over a sub-agent + // one so a real top-level session is never mis-tagged as a sub-agent. + if existing, ok := out[id]; ok && !(existing.isSubagent && !isSubagent) { + continue + } + out[id] = rawTranscript{path: path, isSubagent: isSubagent, parentID: parentID} + } + return out +} + +// classifySubagentTranscript reports whether a transcript path is a sub-agent +// conversation (lives under a "subagents" directory) and, if so, the parent +// session id (the path segment immediately above "subagents"). +func classifySubagentTranscript(transcriptsDir, path string) (string, bool) { + rel, err := filepath.Rel(transcriptsDir, path) + if err != nil { + return "", false + } + parts := strings.Split(rel, string(filepath.Separator)) + for i := 1; i < len(parts); i++ { + if parts[i] == "subagents" { + return parts[i-1], true + } + } + return "", false +} + +// decodeProjectDir turns Cursor's dash-encoded project-dir name back into a +// filesystem path. Directory names can legitimately contain dashes (e.g. +// "vibe-replay"), so the segments are resolved greedily against the real +// filesystem; when no on-disk match exists every dash becomes a separator. +// Non-path window sentinels (e.g. "empty-window", numeric window ids) decode to +// the empty string so they bucket under cursor:unknown instead of a bogus path. +func decodeProjectDir(encoded string) string { + if encoded == "" { + return "" + } + parts := strings.Split(encoded, "-") + start := 0 + if parts[0] == "" { + start = 1 + } + if resolved := resolveEncodedParts(parts, start, string(filepath.Separator)); resolved != "" { + return resolved + } + // On-disk resolution failed. Non-path window sentinels (empty-window, numeric + // window ids) have no real directory, so decode to "" — they bucket under + // cursor:unknown instead of a fabricated path. The check runs only after + // resolution so a real directory whose name happens to be all-numeric still + // decodes correctly. + if isSentinelProjectDir(encoded) { + return "" + } + return string(filepath.Separator) + strings.ReplaceAll(strings.TrimPrefix(encoded, "-"), "-", string(filepath.Separator)) +} + +// isSentinelProjectDir matches Cursor project-dir names that are not paths: the +// "empty-window" no-folder sentinel and purely numeric window ids. +func isSentinelProjectDir(name string) bool { + if name == "empty-window" { + return true + } + for _, r := range name { + if r < '0' || r > '9' { + return false + } + } + return name != "" +} + +// resolveEncodedParts walks the dash-separated segments greedily, trying the +// shortest segment boundary first. Cursor's encoding (every path separator +// becomes a dash) is inherently lossy — "/a/b-c" and "/a/b/c" encode +// identically — so when both exist on disk the shortest-boundary match wins. +// This matches the reference (vibe-replay) decoder; with no information left in +// the encoded name to disambiguate, on-disk resolution is best-effort. +func resolveEncodedParts(parts []string, idx int, current string) string { + if idx >= len(parts) { + if source.DirExists(current) { + return current + } + return "" + } + entries, err := os.ReadDir(current) + if err != nil || len(entries) == 0 { + return "" + } + dirNames := make(map[string]bool, len(entries)) + for _, entry := range entries { + if entry.IsDir() || entry.Type()&fs.ModeSymlink != 0 { + dirNames[entry.Name()] = true + } + } + for end := idx + 1; end <= len(parts); end++ { + candidate := strings.Join(parts[idx:end], "-") + if !dirNames[candidate] { + continue + } + if resolved := resolveEncodedParts(parts, end, filepath.Join(current, candidate)); resolved != "" { + return resolved + } + } + return "" +} + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- + +func composerHeaderIDs(composer map[string]any) []string { for _, key := range []string{"fullConversationHeadersOnly", "conversation"} { - items := source.ExtractSlice(payload, key) + items := source.ExtractSlice(composer, key) if len(items) == 0 { continue } @@ -264,8 +720,7 @@ func composerHeaders(value []byte) []string { if !ok { continue } - bubbleID := source.ExtractString(header, "bubbleId") - if bubbleID != "" { + if bubbleID := source.ExtractString(header, "bubbleId"); bubbleID != "" { out = append(out, bubbleID) } } @@ -274,6 +729,108 @@ func composerHeaders(value []byte) []string { return nil } +func selectedModelID(modelConfig map[string]any) string { + for _, item := range source.ExtractSlice(modelConfig, "selectedModels") { + if model, ok := item.(map[string]any); ok { + if id := source.ExtractString(model, "modelId"); id != "" { + return id + } + } + } + return "" +} + +func bubbleRole(bubbleType int) string { + switch bubbleType { + case 1: + return "user" + case 2: + return "assistant" + default: + return "" + } +} + +func normalizeTranscriptRole(role string) string { + switch role { + case "user": + return "user" + case "assistant": + return "assistant" + default: + return "" + } +} + +func appendText(existing, addition string) string { + if existing == "" { + return addition + } + return existing + "\n" + addition +} + +func finalizeUsage(record *schema.Record) { + record.Usage.UserTurns = source.CountTurns(record.Turns, "user") + record.Usage.AssistantTurns = source.CountTurns(record.Turns, "assistant") + record.Usage.ToolCalls = source.CountToolCalls(record.Turns) +} + +// suppressReasoning strips assistant chain-of-thought. A reasoning-only turn +// (a standalone thinking bubble) would otherwise become a content-less shell, so +// such turns are dropped and the usage counts recomputed. +func suppressReasoning(record *schema.Record) { + kept := record.Turns[:0] + for i := range record.Turns { + record.Turns[i].Reasoning = "" + turn := record.Turns[i] + if turn.Text != "" || len(turn.ToolCalls) > 0 || len(turn.Attachments) > 0 { + kept = append(kept, turn) + } + } + record.Turns = kept + finalizeUsage(record) +} + +func sortedIDs(index map[string]*sessionEntry) []string { + ids := make([]string, 0, len(index)) + for id := range index { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} + +func uriToPath(uri string) string { + return strings.TrimPrefix(uri, "file://") +} + +func fileTimestamp(path string) string { + info, err := os.Stat(path) + if err != nil { + return "" + } + return source.NormalizeTimestamp(float64(info.ModTime().UnixMilli())) +} + +// groupingID is the stable Grouping.ID for a workspace — the raw path, or the +// shared "cursor:unknown" bucket when a workspace could not be resolved. +func groupingID(workspace string) string { + if workspace == "" { + return source.EstimateUnknownLabel("cursor") + } + return workspace +} + +// displayLabel is the human-readable Grouping.DisplayLabel. It applies the +// "cursor:" prefix exactly once (the previous implementation double-prefixed +// unknown groupings as "cursor:cursor:unknown"). +func displayLabel(workspace string) string { + if workspace == "" { + return source.EstimateUnknownLabel("cursor") + } + return "cursor:" + source.DisplayLabelFromPath(workspace) +} + func normalizeCursorPayload(value any) any { switch typed := value.(type) { case string: @@ -323,7 +880,11 @@ func normalizeCursorStatus(value any) string { } } -func defaultPath() string { +func defaultProjectsRoot() string { + return source.Expand("~/.cursor/projects") +} + +func defaultDBPath() string { home := source.HomeDir() switch runtime.GOOS { case "darwin": diff --git a/internal/source/cursor/source_test.go b/internal/source/cursor/source_test.go index f4edf20..a8a7696 100644 --- a/internal/source/cursor/source_test.go +++ b/internal/source/cursor/source_test.go @@ -3,6 +3,7 @@ package cursor import ( "database/sql" "encoding/json" + "os" "path/filepath" "testing" @@ -11,46 +12,84 @@ import ( _ "modernc.org/sqlite" ) +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + type bubbleSpec struct { ID string Payload map[string]any } type composerSpec struct { - ID string - Headers []string - Workspace string - Bubbles []bubbleSpec + ID string + Name string // composer.name -> Title + ModelName string // composer.modelConfig.modelName -> Model + SelectedModelID string // composer.modelConfig.selectedModels[0].modelId (when ModelName unset) + CreatedAt int64 // composer.createdAt (epoch ms) -> StartedAt + Headers []string + Workspace string // adds workspaceUris to every bubble when set + WorkspaceURI string // composer.workspaceIdentifier.uri as a "file://" string + WorkspaceURIPath string // composer.workspaceIdentifier.uri as a VS Code URI object + Bubbles []bubbleSpec + Empty bool // emit composerData with no headers (a draft/subagent) + Subagent bool // emit composer.subagentInfo (marks a sub-agent session) + ParentComposerID string // composer.subagentInfo.parentComposerId when set } func buildCursorDB(t *testing.T, composers []composerSpec) string { t.Helper() - dbPath := filepath.Join(t.TempDir(), "state.vscdb") db, err := sql.Open("sqlite", dbPath) if err != nil { t.Fatal(err) } defer db.Close() - if _, err := db.Exec(`create table cursorDiskKV (key text primary key, value blob)`); err != nil { t.Fatal(err) } for _, comp := range composers { - headers := make([]map[string]any, 0, len(comp.Headers)) - for _, id := range comp.Headers { - headers = append(headers, map[string]any{"bubbleId": id}) + composerData := map[string]any{"composerId": comp.ID} + if !comp.Empty { + headers := make([]map[string]any, 0, len(comp.Headers)) + for _, id := range comp.Headers { + headers = append(headers, map[string]any{"bubbleId": id}) + } + composerData["fullConversationHeadersOnly"] = headers + } + if comp.Name != "" { + composerData["name"] = comp.Name } - composerData := map[string]any{ - "fullConversationHeadersOnly": headers, + if comp.ModelName != "" { + composerData["modelConfig"] = map[string]any{"modelName": comp.ModelName} + } else if comp.SelectedModelID != "" { + composerData["modelConfig"] = map[string]any{ + "selectedModels": []map[string]any{{"modelId": comp.SelectedModelID}}, + } + } + if comp.CreatedAt != 0 { + composerData["createdAt"] = comp.CreatedAt + } + if comp.WorkspaceURI != "" { + composerData["workspaceIdentifier"] = map[string]any{"uri": comp.WorkspaceURI} + } else if comp.WorkspaceURIPath != "" { + composerData["workspaceIdentifier"] = map[string]any{"uri": map[string]any{ + "scheme": "file", "path": comp.WorkspaceURIPath, "fsPath": comp.WorkspaceURIPath, + }} + } + if comp.Subagent { + info := map[string]any{"subagentTypeName": "search"} + if comp.ParentComposerID != "" { + info["parentComposerId"] = comp.ParentComposerID + } + composerData["subagentInfo"] = info } raw, err := json.Marshal(composerData) if err != nil { t.Fatal(err) } - if _, err := db.Exec(`insert into cursorDiskKV (key, value) values (?, ?)`, - "composerData:"+comp.ID, raw); err != nil { + if _, err := db.Exec(`insert into cursorDiskKV (key, value) values (?, ?)`, "composerData:"+comp.ID, raw); err != nil { t.Fatal(err) } @@ -77,201 +116,905 @@ func buildCursorDB(t *testing.T, composers []composerSpec) string { return dbPath } -func TestDiscoverResolvesWorkspaceFromBubbles(t *testing.T) { +func tLine(role string, blocks ...map[string]any) map[string]any { + content := make([]any, 0, len(blocks)) + for _, b := range blocks { + content = append(content, b) + } + return map[string]any{"role": role, "message": map[string]any{"content": content}} +} + +func tText(s string) map[string]any { return map[string]any{"type": "text", "text": s} } + +func tToolUse(name string, input map[string]any) map[string]any { + return map[string]any{"type": "tool_use", "name": name, "input": input} +} + +// writeTranscript writes a transcript JSONL under +// //agent-transcripts/[/].jsonl and returns the +// file path. nested toggles the agent-transcripts//.jsonl layout. +func writeTranscript(t *testing.T, root, projectDir, sid string, nested bool, lines []map[string]any) string { + t.Helper() + dir := filepath.Join(root, projectDir, "agent-transcripts") + if nested { + dir = filepath.Join(dir, sid) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, sid+".jsonl") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + enc := json.NewEncoder(f) + for _, line := range lines { + if err := enc.Encode(line); err != nil { + t.Fatal(err) + } + } + return path +} + +func newTestSource(dbPath, projectsRoot string) *Source { + return &Source{ + dbPath: dbPath, + projectsRoot: projectsRoot, + } +} + +// writeSubagentTranscript writes a sub-agent transcript under +// //agent-transcripts//subagents/.jsonl. +func writeSubagentTranscript(t *testing.T, root, projectDir, parentID, subID string, lines []map[string]any) string { + t.Helper() + dir := filepath.Join(root, projectDir, "agent-transcripts", parentID, "subagents") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, subID+".jsonl") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + enc := json.NewEncoder(f) + for _, line := range lines { + if err := enc.Encode(line); err != nil { + t.Fatal(err) + } + } + return path +} + +func extractAll(t *testing.T, src *Source) []schema.Record { + t.Helper() + groupings, err := src.Discover(t.Context()) + if err != nil { + t.Fatal(err) + } + var records []schema.Record + for _, g := range groupings { + if err := src.Extract(t.Context(), g, source.ExtractionContext{}, func(r schema.Record) error { + records = append(records, r) + return nil + }); err != nil { + t.Fatal(err) + } + } + return records +} + +func recordByID(records []schema.Record, id string) (schema.Record, bool) { + for _, r := range records { + if r.RecordID == id { + return r, true + } + } + return schema.Record{}, false +} + +// --------------------------------------------------------------------------- +// Transcript reader (primary for transcript-only sessions) +// --------------------------------------------------------------------------- + +func TestExtractTranscriptOnlySession(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeTranscript(t, root, "home-user-projectx", "sess-1", false, []map[string]any{ + tLine("user", tText("please read main.go")), + tLine("assistant", tText("on it"), tToolUse("read_file", map[string]any{"path": "main.go"})), + }) + src := newTestSource(filepath.Join(t.TempDir(), "absent.vscdb"), root) + + records := extractAll(t, src) + rec, ok := recordByID(records, "sess-1") + if !ok { + t.Fatalf("expected record sess-1, got %+v", records) + } + if rec.Origin != "cursor" { + t.Fatalf("origin = %q", rec.Origin) + } + if rec.WorkingDir != "/home/user/projectx" { + t.Fatalf("working_dir = %q", rec.WorkingDir) + } + if rec.Model != "cursor/unknown" { + t.Fatalf("model = %q", rec.Model) + } + if rec.Usage.UserTurns != 1 || rec.Usage.AssistantTurns != 1 { + t.Fatalf("turn counts = %+v", rec.Usage) + } + if rec.Usage.ToolCalls != 1 { + t.Fatalf("tool calls = %d", rec.Usage.ToolCalls) + } + asst := rec.Turns[1] + if asst.Text != "on it" { + t.Fatalf("assistant text = %q", asst.Text) + } + if len(asst.ToolCalls) != 1 || asst.ToolCalls[0].Tool != "read_file" { + t.Fatalf("tool calls = %+v", asst.ToolCalls) + } + if in, ok := asst.ToolCalls[0].Input.(map[string]any); !ok || in["path"] != "main.go" { + t.Fatalf("tool input = %+v", asst.ToolCalls[0].Input) + } +} + +func TestExtractTranscriptNestedLayout(t *testing.T) { t.Parallel() + root := t.TempDir() + writeTranscript(t, root, "home-user-projectx", "nested-1", true, []map[string]any{ + tLine("user", tText("hi")), + tLine("assistant", tText("hello")), + }) + src := newTestSource(filepath.Join(t.TempDir(), "absent.vscdb"), root) + records := extractAll(t, src) + if _, ok := recordByID(records, "nested-1"); !ok { + t.Fatalf("expected nested transcript record, got %+v", records) + } +} +// --------------------------------------------------------------------------- +// Global-state reader (composerData + bubbleId) +// --------------------------------------------------------------------------- + +func TestExtractGlobalStateSessionFromBubbles(t *testing.T) { + t.Parallel() dbPath := buildCursorDB(t, []composerSpec{ { - ID: "comp-1", - Workspace: "/tmp/cursor-project", - Headers: []string{"b1", "b2"}, + ID: "comp-1", + Name: "Fix the parser", + ModelName: "claude-4.5-sonnet", + CreatedAt: 1_700_000_000_000, + WorkspaceURI: "file:///home/user/repo", + Headers: []string{"b1", "b2", "b3"}, Bubbles: []bubbleSpec{ - {ID: "b1", Payload: map[string]any{"type": float64(1), "text": "hi", "createdAt": "2026-04-17T10:00:00Z"}}, - {ID: "b2", Payload: map[string]any{"type": float64(2), "text": "hello", "createdAt": "2026-04-17T10:00:05Z"}}, + {ID: "b1", Payload: map[string]any{"type": float64(1), "text": "fix it", "createdAt": "2026-04-17T10:00:00Z"}}, + {ID: "b2", Payload: map[string]any{ + "type": float64(2), + "text": "here is the fix", + "createdAt": "2026-04-17T10:00:05Z", + "thinking": map[string]any{"text": "let me think"}, + "tokenCount": map[string]any{"inputTokens": float64(200), "outputTokens": float64(80)}, + }}, + {ID: "b3", Payload: map[string]any{ + "type": float64(2), + "createdAt": "2026-04-17T10:00:10Z", + "toolFormerData": map[string]any{ + "name": "read_file", + "status": "completed", + "params": `{"path":"main.go"}`, + "result": `{"content":"code"}`, + }, + "tokenCount": map[string]any{"inputTokens": float64(10), "outputTokens": float64(0)}, + }}, }, }, + }) + src := newTestSource(dbPath, filepath.Join(t.TempDir(), "noprojects")) + + records := extractAll(t, src) + rec, ok := recordByID(records, "comp-1") + if !ok { + t.Fatalf("expected comp-1, got %+v", records) + } + if rec.Title != "Fix the parser" { + t.Fatalf("title = %q", rec.Title) + } + if rec.Model != "claude-4.5-sonnet" { + t.Fatalf("model = %q", rec.Model) + } + if rec.WorkingDir != "/home/user/repo" { + t.Fatalf("working_dir = %q", rec.WorkingDir) + } + if rec.Usage.InputTokens != 210 || rec.Usage.OutputTokens != 80 { + t.Fatalf("tokens = %+v", rec.Usage) + } + if rec.Usage.UserTurns != 1 || rec.Usage.AssistantTurns != 2 { + t.Fatalf("turn counts = %+v", rec.Usage) + } + if rec.Turns[1].Reasoning != "let me think" { + t.Fatalf("reasoning = %q", rec.Turns[1].Reasoning) + } + tool := rec.Turns[2] + if len(tool.ToolCalls) != 1 { + t.Fatalf("expected 1 tool call, got %+v", tool.ToolCalls) + } + if tool.ToolCalls[0].Tool != "read_file" || tool.ToolCalls[0].Status != "completed" { + t.Fatalf("tool call = %+v", tool.ToolCalls[0]) + } + if tool.ToolCalls[0].Output == nil { + t.Fatalf("expected tool output, got nil") + } +} + +func TestExtractSkipsEmptyComposers(t *testing.T) { + t.Parallel() + dbPath := buildCursorDB(t, []composerSpec{ + {ID: "draft", Empty: true}, + { + ID: "real", + WorkspaceURI: "file:///home/user/repo", + Headers: []string{"b1"}, + Bubbles: []bubbleSpec{{ID: "b1", Payload: map[string]any{"type": float64(1), "text": "hi"}}}, + }, + }) + src := newTestSource(dbPath, filepath.Join(t.TempDir(), "noprojects")) + records := extractAll(t, src) + if _, ok := recordByID(records, "draft"); ok { + t.Fatalf("empty composer should be skipped") + } + if _, ok := recordByID(records, "real"); !ok { + t.Fatalf("real composer missing: %+v", records) + } +} + +// --------------------------------------------------------------------------- +// Merge / dedup across both stores +// --------------------------------------------------------------------------- + +func TestExtractMergesOverlappingSessionPreferringBubbles(t *testing.T) { + t.Parallel() + root := t.TempDir() + // Same session id in both stores. The transcript carries prose + a tool + // call but no reasoning/results; the bubbles carry reasoning + tool output. + writeTranscript(t, root, "home-user-repo", "shared", false, []map[string]any{ + tLine("user", tText("do the thing")), + tLine("assistant", tText("doing the thing"), tToolUse("read_file", map[string]any{"path": "x.go"})), + }) + dbPath := buildCursorDB(t, []composerSpec{ { - ID: "comp-unknown", - Headers: []string{"b3"}, + ID: "shared", + ModelName: "gpt-5", + Headers: []string{"b1", "b2"}, Bubbles: []bubbleSpec{ - {ID: "b3", Payload: map[string]any{"type": float64(1), "text": "orphan", "createdAt": "2026-04-17T10:00:00Z"}}, + {ID: "b1", Payload: map[string]any{"type": float64(1), "text": "do the thing"}}, + {ID: "b2", Payload: map[string]any{ + "type": float64(2), + "text": "doing the thing", + "thinking": map[string]any{"text": "deep thought"}, + "toolFormerData": map[string]any{ + "name": "read_file", + "status": "completed", + "params": `{"path":"x.go"}`, + "result": "file body", + }, + }}, }, }, }) + src := newTestSource(dbPath, root) - src := New(dbPath) + records := extractAll(t, src) + // Exactly one record for the shared session — never two. + count := 0 + for _, r := range records { + if r.RecordID == "shared" { + count++ + } + } + if count != 1 { + t.Fatalf("expected exactly 1 merged record for shared session, got %d: %+v", count, records) + } + rec, _ := recordByID(records, "shared") + // Bubble backbone: reasoning + tool output present (transcripts lack both). + foundReasoning := false + foundOutput := false + for _, turn := range rec.Turns { + if turn.Reasoning != "" { + foundReasoning = true + } + for _, call := range turn.ToolCalls { + if call.Output != nil { + foundOutput = true + } + } + } + if !foundReasoning { + t.Fatalf("merged record dropped bubble reasoning: %+v", rec.Turns) + } + if !foundOutput { + t.Fatalf("merged record dropped bubble tool output: %+v", rec.Turns) + } + // Workspace comes from the transcript project-dir (authoritative). + if rec.WorkingDir != "/home/user/repo" { + t.Fatalf("working_dir = %q", rec.WorkingDir) + } +} + +// --------------------------------------------------------------------------- +// Discover +// --------------------------------------------------------------------------- + +func TestDiscoverGroupsByWorkspaceWithoutDoublePrefix(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeTranscript(t, root, "home-user-projectx", "t1", false, []map[string]any{ + tLine("user", tText("hi")), + tLine("assistant", tText("hello")), + }) + src := newTestSource(filepath.Join(t.TempDir(), "absent.vscdb"), root) groupings, err := src.Discover(t.Context()) if err != nil { t.Fatal(err) } - if len(groupings) != 2 { - t.Fatalf("expected 2 groupings, got %d: %+v", len(groupings), groupings) + if len(groupings) != 1 { + t.Fatalf("expected 1 grouping, got %+v", groupings) } - byID := make(map[string]source.Grouping) - for _, g := range groupings { - byID[g.ID] = g + if got := groupings[0].DisplayLabel; got != "cursor:home/user/projectx" { + t.Fatalf("display label = %q (double-prefix regression?)", got) } - if g, ok := byID["/tmp/cursor-project"]; !ok { - t.Fatalf("missing resolved workspace grouping: %+v", byID) - } else if g.DisplayLabel != "cursor:cursor-project" { - t.Fatalf("display label = %q", g.DisplayLabel) + if groupings[0].Origin != "cursor" { + t.Fatalf("origin = %q", groupings[0].Origin) } - if _, ok := byID[source.EstimateUnknownLabel("cursor")]; !ok { - t.Fatalf("missing unknown grouping: %+v", byID) +} + +func TestDiscoverUnknownWorkspaceNotDoublePrefixed(t *testing.T) { + t.Parallel() + // A composer with no workspace signal at all -> "cursor:unknown", never + // "cursor:cursor:unknown". + dbPath := buildCursorDB(t, []composerSpec{ + { + ID: "comp-x", + Headers: []string{"b1"}, + Bubbles: []bubbleSpec{{ID: "b1", Payload: map[string]any{"type": float64(1), "text": "orphan"}}}, + }, + }) + src := newTestSource(dbPath, filepath.Join(t.TempDir(), "noprojects")) + groupings, err := src.Discover(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(groupings) != 1 { + t.Fatalf("expected 1 grouping, got %+v", groupings) + } + if got := groupings[0].DisplayLabel; got != "cursor:unknown" { + t.Fatalf("display label = %q, want cursor:unknown", got) } } -func TestExtractBuildsRecordFromBubbleChain(t *testing.T) { +func TestDiscoverReturnsNilWhenStoresMissing(t *testing.T) { t.Parallel() + src := newTestSource(filepath.Join(t.TempDir(), "missing.vscdb"), filepath.Join(t.TempDir(), "missing-projects")) + groupings, err := src.Discover(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(groupings) != 0 { + t.Fatalf("expected no groupings, got %+v", groupings) + } +} +// --------------------------------------------------------------------------- +// LookupSession (orchestrator dependency) +// --------------------------------------------------------------------------- + +func TestLookupSessionByComposerID(t *testing.T) { + t.Parallel() dbPath := buildCursorDB(t, []composerSpec{ { - ID: "comp-1", - Workspace: "/tmp/cursor-project", - Headers: []string{"b1", "b2", "b3"}, - Bubbles: []bubbleSpec{ - { - ID: "b1", - Payload: map[string]any{ - "type": float64(1), - "text": "hello", - "createdAt": "2026-04-17T10:00:00Z", - }, - }, - { - ID: "b2", - Payload: map[string]any{ - "type": float64(2), - "text": "hi there", - "createdAt": "2026-04-17T10:00:05Z", - "thinking": map[string]any{"text": "reasoning"}, - "modelInfo": map[string]any{"modelName": "cursor-sonnet"}, - "tokenCount": map[string]any{ - "inputTokens": float64(200), - "outputTokens": float64(80), - }, - }, - }, - { - ID: "b3", - Payload: map[string]any{ - "type": float64(2), - "text": "", - "createdAt": "2026-04-17T10:00:10Z", - "toolFormerData": map[string]any{ - "name": "mcp-filesystem-read_file", - "status": map[string]any{"status": "completed"}, - "params": `{"tools":[{"parameters":{"path":"main.go"}}]}`, - "result": `{"content":"code"}`, - }, - }, - }, - }, + ID: "target", + WorkspaceURI: "file:///home/user/repo", + Headers: []string{"b1"}, + Bubbles: []bubbleSpec{{ID: "b1", Payload: map[string]any{"type": float64(1), "text": "hi"}}}, }, }) + src := newTestSource(dbPath, filepath.Join(t.TempDir(), "noprojects")) + rec, found, err := src.LookupSession(t.Context(), "target") + if err != nil { + t.Fatal(err) + } + if !found { + t.Fatalf("expected found") + } + if rec.RecordID != "target" || rec.WorkingDir != "/home/user/repo" { + t.Fatalf("record = %+v", rec) + } - src := New(dbPath) - var records []schema.Record - err := src.Extract(t.Context(), source.Grouping{ - ID: "/tmp/cursor-project", - DisplayLabel: "cursor:cursor-project", - Origin: src.Name(), - }, source.ExtractionContext{}, func(r schema.Record) error { - records = append(records, r) - return nil + _, found, err = src.LookupSession(t.Context(), "missing") + if err != nil { + t.Fatal(err) + } + if found { + t.Fatalf("expected not found for missing id") + } +} + +func TestLookupSessionByTranscriptID(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeTranscript(t, root, "home-user-projectx", "tsess", false, []map[string]any{ + tLine("user", tText("hi")), + tLine("assistant", tText("hello there")), }) + src := newTestSource(filepath.Join(t.TempDir(), "absent.vscdb"), root) + rec, found, err := src.LookupSession(t.Context(), "tsess") if err != nil { t.Fatal(err) } - if len(records) != 1 { - t.Fatalf("expected 1 record, got %d: %+v", len(records), records) + if !found { + t.Fatalf("expected found for transcript id") } - record := records[0] - if record.Model != "cursor-sonnet" { - t.Fatalf("model = %q", record.Model) + if rec.RecordID != "tsess" { + t.Fatalf("record id = %q", rec.RecordID) } - if record.WorkingDir != "/tmp/cursor-project" { - t.Fatalf("working_dir = %q", record.WorkingDir) +} + +// --------------------------------------------------------------------------- +// Sub-agents +// --------------------------------------------------------------------------- + +func TestSubagentEmittedAsTaggedRecord(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeTranscript(t, root, "home-user-repo", "parent-1", true, []map[string]any{ + tLine("user", tText("spawn a subagent")), + tLine("assistant", tText("spawning"), tToolUse("Task", map[string]any{"prompt": "go"})), + }) + writeSubagentTranscript(t, root, "home-user-repo", "parent-1", "sub-1", []map[string]any{ + tLine("user", tText("subtask")), + tLine("assistant", tText("subtask done")), + }) + src := newTestSource(filepath.Join(t.TempDir(), "absent.vscdb"), root) + + records := extractAll(t, src) + parent, ok := recordByID(records, "parent-1") + if !ok { + t.Fatalf("parent record missing: %+v", records) } - if record.Usage.InputTokens != 200 || record.Usage.OutputTokens != 80 { - t.Fatalf("tokens = %+v", record.Usage) + if parent.Provenance == nil || parent.Provenance.SourceOrigin != "cursor" { + t.Fatalf("parent should be a plain cursor record: %+v", parent.Provenance) } - if record.Usage.UserTurns != 1 || record.Usage.AssistantTurns != 2 { - t.Fatalf("turn counts = %+v", record.Usage) + sub, ok := recordByID(records, "sub-1") + if !ok { + t.Fatalf("subagent record missing: %+v", records) } - if len(record.Turns) != 3 { - t.Fatalf("expected 3 turns, got %d", len(record.Turns)) + if sub.Provenance == nil || sub.Provenance.SourceOrigin != "cursor-subagent" { + t.Fatalf("subagent SourceOrigin = %+v, want cursor-subagent", sub.Provenance) } - - assistant := record.Turns[1] - if assistant.Reasoning != "reasoning" { - t.Fatalf("reasoning = %q", assistant.Reasoning) + meta, _ := sub.Provenance.Extensions["subagent"].(map[string]any) + if meta == nil || meta["parent_session_id"] != "parent-1" { + t.Fatalf("subagent parent link missing: %+v", sub.Provenance.Extensions) } + // Both share the parent's workspace (sub-agent transcripts inherit the project dir). + if sub.WorkingDir != "/home/user/repo" { + t.Fatalf("subagent working_dir = %q", sub.WorkingDir) + } +} - toolTurn := record.Turns[2] - if len(toolTurn.ToolCalls) != 1 { - t.Fatalf("expected 1 tool call, got %d: %+v", len(toolTurn.ToolCalls), toolTurn.ToolCalls) +func TestSubagentComposerDetectedViaSubagentInfo(t *testing.T) { + t.Parallel() + dbPath := buildCursorDB(t, []composerSpec{ + { + ID: "sub-comp", + WorkspaceURI: "file:///home/user/repo", + Subagent: true, + Headers: []string{"b1"}, + Bubbles: []bubbleSpec{{ID: "b1", Payload: map[string]any{"type": float64(2), "text": "did the subtask"}}}, + }, + }) + src := newTestSource(dbPath, filepath.Join(t.TempDir(), "noprojects")) + records := extractAll(t, src) + rec, ok := recordByID(records, "sub-comp") + if !ok { + t.Fatalf("expected sub-comp record, got %+v", records) } - call := toolTurn.ToolCalls[0] - if call.Tool != "read_file" { // normalized from mcp-filesystem-read_file - t.Fatalf("expected normalized tool name, got %q", call.Tool) + if rec.Provenance == nil || rec.Provenance.SourceOrigin != "cursor-subagent" { + t.Fatalf("composer subagent not tagged: %+v", rec.Provenance) } - if call.Status != "completed" { - t.Fatalf("status = %q", call.Status) +} + +// subagentInfo is authoritative: a composer flagged as a sub-agent is tagged +// even when its only transcript is a top-level-named file (the observed id +// collision where a sub-agent id equals an unrelated top-level basename). +func TestSubagentInfoOverridesTopLevelTranscriptClassification(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeTranscript(t, root, "home-user-repo", "collide", true, []map[string]any{ + tLine("user", tText("hi")), + tLine("assistant", tText("hello")), + }) + dbPath := buildCursorDB(t, []composerSpec{ + { + ID: "collide", + Subagent: true, + Headers: []string{"b1"}, + Bubbles: []bubbleSpec{{ID: "b1", Payload: map[string]any{"type": float64(2), "text": "sub work"}}}, + }, + }) + src := newTestSource(dbPath, root) + records := extractAll(t, src) + rec, ok := recordByID(records, "collide") + if !ok { + t.Fatalf("expected collide record, got %+v", records) } - params, ok := call.Input.(map[string]any) + if rec.Provenance == nil || rec.Provenance.SourceOrigin != "cursor-subagent" { + t.Fatalf("subagentInfo should win classification: %+v", rec.Provenance) + } +} + +// A sub-agent composer whose bubble rows have been GC'd must still fall back to +// its sub-agent transcript AND remain tagged as a sub-agent. +func TestSubagentComposerFallbackStaysTagged(t *testing.T) { + t.Parallel() + root := t.TempDir() + dbPath := buildCursorDB(t, []composerSpec{ + { + ID: "sub-gc", + Subagent: true, + Headers: []string{"gone1"}, + Bubbles: nil, // bubble rows GC'd + }, + }) + writeSubagentTranscript(t, root, "home-user-repo", "parent-z", "sub-gc", []map[string]any{ + tLine("user", tText("subtask")), + tLine("assistant", tText("recovered subtask output")), + }) + src := newTestSource(dbPath, root) + records := extractAll(t, src) + rec, ok := recordByID(records, "sub-gc") if !ok { - t.Fatalf("expected unwrapped parameters map, got %T: %+v", call.Input, call.Input) + t.Fatalf("subagent dropped instead of transcript fallback: %+v", records) + } + if rec.Usage.AssistantTurns == 0 { + t.Fatalf("fallback produced no turns: %+v", rec.Usage) } - if params["path"] != "main.go" { - t.Fatalf("expected unwrapped path parameter, got %+v", params) + if rec.Provenance == nil || rec.Provenance.SourceOrigin != "cursor-subagent" { + t.Fatalf("subagent tag lost on transcript fallback: %+v", rec.Provenance) + } + if sub, _ := rec.Provenance.Extensions["subagent"].(map[string]any); sub == nil || sub["parent_session_id"] != "parent-z" { + t.Fatalf("parent link lost on fallback: %+v", rec.Provenance.Extensions) } } -func TestLookupSessionReturnsComposerAsRecord(t *testing.T) { +// --------------------------------------------------------------------------- +// Fallback / robustness +// --------------------------------------------------------------------------- + +func TestComposerFallsBackToTranscriptWhenBubblesMissing(t *testing.T) { t.Parallel() + root := t.TempDir() + // Composer headers (and name/model/createdAt) survive but the referenced + // bubble rows are gone (GC'd). + dbPath := buildCursorDB(t, []composerSpec{ + { + ID: "shared", + Name: "Real Title", + ModelName: "claude-4.5-sonnet", + CreatedAt: 1_700_000_000_000, + WorkspaceURI: "file:///home/user/repo", + Headers: []string{"gone1", "gone2"}, + Bubbles: nil, // no bubble rows inserted + }, + }) + writeTranscript(t, root, "home-user-repo", "shared", true, []map[string]any{ + tLine("user", tText("still here")), + tLine("assistant", tText("recovered from transcript")), + }) + src := newTestSource(dbPath, root) + records := extractAll(t, src) + rec, ok := recordByID(records, "shared") + if !ok { + t.Fatalf("session dropped instead of falling back to transcript: %+v", records) + } + if rec.Usage.AssistantTurns == 0 { + t.Fatalf("transcript fallback produced no assistant turns: %+v", rec.Usage) + } + // Composer-derived metadata must survive the fallback (it lives in + // composerData, not the GC'd bubble rows). + if rec.Title != "Real Title" { + t.Fatalf("fallback lost title: %q", rec.Title) + } + if rec.Model != "claude-4.5-sonnet" { + t.Fatalf("fallback lost model: %q", rec.Model) + } + if rec.StartedAt == "" { + t.Fatalf("fallback lost StartedAt") + } + // Provenance reflects that the turns came from the transcript. + if rec.Provenance == nil || rec.Provenance.SourcePath == "" || filepath.Ext(rec.Provenance.SourcePath) != ".jsonl" { + t.Fatalf("fallback provenance should point at the transcript: %+v", rec.Provenance) + } +} + +func TestSubagentComposerOnlyGetsParentFromSubagentInfo(t *testing.T) { + t.Parallel() + // A sub-agent that lives only in global state (no transcript) still gets its + // parent link from subagentInfo.parentComposerId. dbPath := buildCursorDB(t, []composerSpec{ { - ID: "target", - Workspace: "/tmp/x", - Headers: []string{"b1"}, - Bubbles: []bubbleSpec{ - {ID: "b1", Payload: map[string]any{"type": float64(1), "text": "hi", "createdAt": "2026-04-17T10:00:00Z"}}, - }, + ID: "sub-only", + Subagent: true, + ParentComposerID: "the-parent", + WorkspaceURI: "file:///home/user/repo", + Headers: []string{"b1"}, + Bubbles: []bubbleSpec{{ID: "b1", Payload: map[string]any{"type": float64(2), "text": "subtask output"}}}, }, }) + src := newTestSource(dbPath, filepath.Join(t.TempDir(), "noprojects")) + records := extractAll(t, src) + rec, ok := recordByID(records, "sub-only") + if !ok { + t.Fatalf("expected sub-only record, got %+v", records) + } + if rec.Provenance == nil || rec.Provenance.SourceOrigin != "cursor-subagent" { + t.Fatalf("not tagged subagent: %+v", rec.Provenance) + } + sub, _ := rec.Provenance.Extensions["subagent"].(map[string]any) + if sub == nil || sub["parent_session_id"] != "the-parent" { + t.Fatalf("parent link from subagentInfo missing: %+v", rec.Provenance.Extensions) + } +} - src := New(dbPath) +func TestDecodeProjectSentinelBucketsUnknown(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeTranscript(t, root, "empty-window", "win-1", false, []map[string]any{ + tLine("user", tText("hi")), + tLine("assistant", tText("hello")), + }) + src := newTestSource(filepath.Join(t.TempDir(), "absent.vscdb"), root) - record, found, err := src.LookupSession(t.Context(), "target") + groupings, err := src.Discover(t.Context()) if err != nil { t.Fatal(err) } - if !found { - t.Fatalf("expected found") + if len(groupings) != 1 || groupings[0].DisplayLabel != "cursor:unknown" { + t.Fatalf("empty-window sentinel should bucket as cursor:unknown, got %+v", groupings) } - if record.RecordID != "target" { - t.Fatalf("record id = %q", record.RecordID) + records := extractAll(t, src) + rec, ok := recordByID(records, "win-1") + if !ok { + t.Fatalf("record missing: %+v", records) } - if record.WorkingDir != "/tmp/x" { - t.Fatalf("working_dir = %q", record.WorkingDir) + if rec.WorkingDir != "" { + t.Fatalf("sentinel working_dir should be empty, got %q", rec.WorkingDir) } +} - _, found, err = src.LookupSession(t.Context(), "missing") +// --------------------------------------------------------------------------- +// Reasoning suppression, provenance, decoding edge paths +// --------------------------------------------------------------------------- + +func TestExtractSuppressReasoningDropsReasoningOnlyTurns(t *testing.T) { + t.Parallel() + dbPath := buildCursorDB(t, []composerSpec{ + { + ID: "c1", + WorkspaceURI: "file:///home/user/repo", + Headers: []string{"b1", "b2", "b3"}, + Bubbles: []bubbleSpec{ + {ID: "b1", Payload: map[string]any{"type": float64(1), "text": "hi"}}, + // reasoning-only assistant bubble -> becomes an empty shell once suppressed + {ID: "b2", Payload: map[string]any{"type": float64(2), "thinking": map[string]any{"text": "only reasoning"}}}, + {ID: "b3", Payload: map[string]any{"type": float64(2), "text": "answer", "thinking": map[string]any{"text": "some reasoning"}}}, + }, + }, + }) + src := newTestSource(dbPath, filepath.Join(t.TempDir(), "noprojects")) + groupings, err := src.Discover(t.Context()) if err != nil { t.Fatal(err) } - if found { - t.Fatalf("expected not found") + var rec schema.Record + for _, g := range groupings { + if err := src.Extract(t.Context(), g, source.ExtractionContext{SuppressReasoning: true}, func(r schema.Record) error { + rec = r + return nil + }); err != nil { + t.Fatal(err) + } + } + for _, turn := range rec.Turns { + if turn.Reasoning != "" { + t.Fatalf("reasoning not suppressed: %+v", turn) + } + if turn.Text == "" && len(turn.ToolCalls) == 0 { + t.Fatalf("content-less shell turn survived suppression: %+v", turn) + } + } + // b2 (reasoning-only) is dropped; usage recomputed to 1 user + 1 assistant. + if rec.Usage.UserTurns != 1 || rec.Usage.AssistantTurns != 1 { + t.Fatalf("usage after suppression = %+v", rec.Usage) + } +} + +func TestNewUsesDefaultRootsWhenDBPathEmpty(t *testing.T) { + t.Parallel() + src := New("") + if src.dbPath == "" || src.projectsRoot == "" { + t.Fatalf("defaults not populated: dbPath=%q projectsRoot=%q", src.dbPath, src.projectsRoot) + } + if custom := New("/tmp/custom.vscdb"); custom.dbPath != "/tmp/custom.vscdb" { + t.Fatalf("explicit dbPath = %q", custom.dbPath) + } +} + +// Greptile P2: when a composer exists but the DB is unavailable at extract time, +// the turns come entirely from the transcript, so provenance must point at the +// transcript and not claim global-state was read. +func TestBuildRecordComposerWithoutDBUsesTranscriptProvenance(t *testing.T) { + t.Parallel() + root := t.TempDir() + path := writeTranscript(t, root, "home-user-repo", "s1", true, []map[string]any{ + tLine("user", tText("hi")), + tLine("assistant", tText("hello")), + }) + src := newTestSource(filepath.Join(t.TempDir(), "none.vscdb"), root) + entry := &sessionEntry{id: "s1", hasComposer: true, transcriptPath: path, workspace: "/home/user/repo"} + + rec, ok := src.buildRecord(nil, entry) + if !ok { + t.Fatalf("expected a record from the transcript") + } + if rec.Provenance.SourcePath != path { + t.Fatalf("SourcePath should be the transcript when the DB is nil, got %q", rec.Provenance.SourcePath) + } + if _, hasStores := rec.Provenance.Extensions["stores"]; hasStores { + t.Fatalf("stores must not claim global-state when the DB was not read: %+v", rec.Provenance.Extensions) + } +} + +func TestGlobalStateUnwrapsToolParamsSelectedModelAndBubbleWorkspace(t *testing.T) { + t.Parallel() + dbPath := buildCursorDB(t, []composerSpec{ + { + ID: "c1", + SelectedModelID: "gpt-5-codex", // modelConfig.selectedModels (no modelName) + Workspace: "/home/user/repo", // stamps workspaceUris on bubbles -> composerWorkspace fallback + Headers: []string{"b1", "b2", "b3"}, + Bubbles: []bubbleSpec{ + {ID: "b1", Payload: map[string]any{"type": float64(1), "text": "go"}}, + {ID: "b2", Payload: map[string]any{"type": float64(2), "toolFormerData": map[string]any{ + "name": "mcp-filesystem-read_file", + "status": "completed", + "params": `{"tools":[{"parameters":{"path":"main.go"}}]}`, + "result": `{"content":"x"}`, + }}}, + // An unknown bubble type (neither user nor assistant) is dropped. + {ID: "b3", Payload: map[string]any{"type": float64(3), "text": "ignored"}}, + }, + }, + }) + src := newTestSource(dbPath, filepath.Join(t.TempDir(), "noprojects")) + rec, ok := recordByID(extractAll(t, src), "c1") + if !ok { + t.Fatalf("missing c1") + } + if rec.Model != "gpt-5-codex" { + t.Fatalf("model from selectedModels = %q", rec.Model) + } + if rec.WorkingDir != "/home/user/repo" { + t.Fatalf("workspace from bubble workspaceUris = %q", rec.WorkingDir) + } + var call *schema.ToolCall + for i := range rec.Turns { + for j := range rec.Turns[i].ToolCalls { + call = &rec.Turns[i].ToolCalls[j] + } + } + if call == nil { + t.Fatalf("no tool call found: %+v", rec.Turns) + } + if call.Tool != "read_file" { + t.Fatalf("tool name (mcp_ prefix) = %q", call.Tool) + } + params, ok := call.Input.(map[string]any) + if !ok || params["path"] != "main.go" { + t.Fatalf("unwrapped tool params = %+v", call.Input) + } +} + +// On real builds workspaceIdentifier.uri is a serialized VS Code URI object, +// not a "file://" string; the composer-only workspace fallback must read it. +func TestComposerWorkspaceFromURIObject(t *testing.T) { + t.Parallel() + dbPath := buildCursorDB(t, []composerSpec{ + { + ID: "obj", + WorkspaceURIPath: "/home/user/repo", + Headers: []string{"b1"}, + Bubbles: []bubbleSpec{{ID: "b1", Payload: map[string]any{"type": float64(1), "text": "hi"}}}, + }, + }) + src := newTestSource(dbPath, filepath.Join(t.TempDir(), "noprojects")) + rec, ok := recordByID(extractAll(t, src), "obj") + if !ok { + t.Fatalf("missing obj record") + } + if rec.WorkingDir != "/home/user/repo" { + t.Fatalf("workspace from URI object = %q", rec.WorkingDir) + } +} + +func TestTranscriptThinkingBlockBecomesReasoning(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeTranscript(t, root, "home-user-repo", "t1", true, []map[string]any{ + tLine("user", tText("hi")), + tLine("assistant", map[string]any{"type": "thinking", "thinking": "pondering"}, tText("answer")), + }) + src := newTestSource(filepath.Join(t.TempDir(), "none.vscdb"), root) + rec, ok := recordByID(extractAll(t, src), "t1") + if !ok { + t.Fatalf("missing t1") + } + asst := rec.Turns[1] + if asst.Reasoning != "pondering" { + t.Fatalf("reasoning = %q", asst.Reasoning) + } + if asst.Text != "answer" { + t.Fatalf("text = %q", asst.Text) + } +} + +// --------------------------------------------------------------------------- +// Helper unit tests (retained from the original suite) +// --------------------------------------------------------------------------- + +func TestHelperEdgeCases(t *testing.T) { + t.Parallel() + if bubbleRole(3) != "" { + t.Fatalf("unknown bubble type should map to no role") + } + if normalizeTranscriptRole("system") != "" { + t.Fatalf("non user/assistant role should map to empty") + } + if got := appendText("a", "b"); got != "a\nb" { + t.Fatalf("appendText join = %q", got) + } + if got := normalizeCursorPayload(float64(42)); got != float64(42) { + t.Fatalf("non-string payload should pass through, got %v", got) + } + if got := unwrapCursorToolParameters(map[string]any{"x": float64(1)}); got.(map[string]any)["x"] != float64(1) { + t.Fatalf("payload without tools should pass through, got %+v", got) + } + if _, ok := unwrapCursorToolParameters(map[string]any{"tools": []any{map[string]any{}}}).(map[string]any); !ok { + t.Fatalf("tools without parameters should return the payload") + } + if decodeProjectDir("") != "" { + t.Fatalf("empty encoded name should decode to empty") + } + if !isSentinelProjectDir("1778089727984") { + t.Fatalf("all-numeric window id should be a sentinel") + } + if isSentinelProjectDir("Users-quantumly-repo") { + t.Fatalf("a real path-like name must not be a sentinel") + } + if got := workspaceURIToPath("file:///x"); got != "/x" { + t.Fatalf("string URI -> %q", got) + } + if got := workspaceURIToPath(map[string]any{"path": "/p", "fsPath": "/fs"}); got != "/fs" { + t.Fatalf("URI object should prefer fsPath, got %q", got) + } + if got := workspaceURIToPath(float64(3)); got != "" { + t.Fatalf("non-string/non-object URI -> %q", got) } } func TestNormalizeCursorToolNameHandlesMcpPrefixes(t *testing.T) { t.Parallel() - cases := []struct { - in, want string - }{ + cases := []struct{ in, want string }{ {"mcp_filesystem_readFile", "readFile"}, {"mcp-filesystem-read_file", "read_file"}, {"read_file", "read_file"}, @@ -300,15 +1043,34 @@ func TestNormalizeCursorStatusHandlesMapAndString(t *testing.T) { } } -func TestDiscoverReturnsNilWhenDatabaseMissing(t *testing.T) { +func TestDecodeProjectDirResolvesHyphenatedNames(t *testing.T) { t.Parallel() - - src := New(filepath.Join(t.TempDir(), "missing.vscdb")) - groupings, err := src.Discover(t.Context()) - if err != nil { + base := t.TempDir() + // A real workspace whose final path segment legitimately contains hyphens. + ws := filepath.Join(base, "vibe-replay") + if err := os.MkdirAll(ws, 0o755); err != nil { t.Fatal(err) } - if len(groupings) != 0 { - t.Fatalf("expected no groupings, got %+v", groupings) + encoded := encodeWorkspacePath(ws) + if got := decodeProjectDir(encoded); got != ws { + t.Fatalf("decodeProjectDir(%q) = %q, want %q", encoded, got, ws) + } +} + +// encodeWorkspacePath mirrors Cursor's project-dir encoding: strip the leading +// separator and replace each separator with a dash. +func encodeWorkspacePath(path string) string { + trimmed := path + if len(trimmed) > 0 && trimmed[0] == filepath.Separator { + trimmed = trimmed[1:] + } + out := make([]rune, 0, len(trimmed)) + for _, r := range trimmed { + if r == filepath.Separator { + out = append(out, '-') + } else { + out = append(out, r) + } } + return string(out) }