diff --git a/specstory-cli/.claude/commands/merge-conflict.md b/specstory-cli/.claude/commands/merge-conflict.md new file mode 100644 index 00000000..8ff780bb --- /dev/null +++ b/specstory-cli/.claude/commands/merge-conflict.md @@ -0,0 +1,19 @@ +--- +allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(grep:*), Bash(awk:*), Bash(sed:*) +description: Resolve a merge conflict +--- + +## Context + +- Current git status: !`git status` +- Current git diff: !`git diff` + +## Your task + +Examine the current merge conflict using git commands. + +Review each issue conflict by conflict and explain what caused each conflict. Explain what each side of the conflict was attempting to accomplish with their change. + +Importantly, make a suggestion for the best resolution for each conflict, but only resolve them in discussion with me. + +Use file names and line numbers to reference the changes, but also sequentially number every observation so we can easily reference them. diff --git a/specstory-cli/.specstory/history/2026-03-18_10-13-59Z-command-message-merge-conflict.md b/specstory-cli/.specstory/history/2026-03-18_10-13-59Z-command-message-merge-conflict.md new file mode 100644 index 00000000..30c19867 --- /dev/null +++ b/specstory-cli/.specstory/history/2026-03-18_10-13-59Z-command-message-merge-conflict.md @@ -0,0 +1,1245 @@ + + +# 2026-03-18 10:13:59Z + + + +_**User (2026-03-18 10:13:59Z)**_ + +merge-conflict +/merge-conflict + +_**User (2026-03-18 10:13:59Z)**_ + +## Context + +- Current git status: On branch cursor-ide +Your branch is up to date with 'origin/cursor-ide'. + +You have unmerged paths. + (fix conflicts and run "git commit") + (use "git merge --abort" to abort the merge) + +Changes to be committed: + new file: .claude/commands/merge-conflict.md + new file: .specstory/history/2026-03-06_15-54-14Z-command-message-pr-review.md + modified: changelog.md + modified: pkg/spi/path_utils_test.go + +Unmerged paths: + (use "git add ..." to mark resolution) + both modified: pkg/providers/claudecode/provider.go + both modified: pkg/spi/filename_test.go + both modified: pkg/spi/path_utils.go +- Current git diff: diff --cc specstory-cli/pkg/providers/claudecode/provider.go +index 45c2e82,65930de..0000000 +--- a/specstory-cli/pkg/providers/claudecode/provider.go ++++ b/specstory-cli/pkg/providers/claudecode/provider.go +@@@ -493,21 -493,34 +493,52 @@@ func isSyntheticMessage(content string + return false + } + +++<<<<<<< HEAD + +// cleanSyntheticPrefixes strips known boilerplate prefixes that Claude Code + +// prepends to user messages (e.g. plan mode). The real user content follows + +// the prefix, so we strip rather than skip the message entirely. + +func cleanSyntheticPrefixes(content string) string { + + prefixes := []string{ + + "Implement the following plan:", + + } + + trimmed := strings.TrimSpace(content) + + lower := strings.ToLower(trimmed) + + for _, prefix := range prefixes { + + if strings.HasPrefix(lower, strings.ToLower(prefix)) { + + return strings.TrimSpace(trimmed[len(prefix):]) + + } + + } + + return content +++======= ++ // extractContentText extracts plain text from a message content field. ++ // The content field may be either a plain string or a JSON array of typed content ++ // blocks (e.g. [{type:"text",text:"..."},{type:"tool_use",...}]). ++ // When it's an array, all "text"-type blocks are joined with newlines. ++ // This handles the Claude Code JSONL format where IDE context tags (e.g. ++ // ) are injected as separate text blocks before the real question. ++ func extractContentText(content interface{}) string { ++ switch v := content.(type) { ++ case string: ++ return v ++ case []interface{}: ++ var parts []string ++ for _, block := range v { ++ if m, ok := block.(map[string]interface{}); ok { ++ if t, _ := m["type"].(string); t == "text" { ++ if text, ok := m["text"].(string); ok && text != "" { ++ parts = append(parts, text) ++ } ++ } ++ } ++ } ++ slog.Debug("extractContentText: extracted text from content block array", ++ "blockCount", len(v), ++ "textParts", len(parts)) ++ return strings.Join(parts, "\n") ++ default: ++ return "" ++ } +++>>>>>>> dev + } + + // findFirstUserMessage finds the first user message in a session for slug generation +@@@ -672,9 -688,11 +706,11 @@@ func extractSessionMetadata(filePath st + isMeta, _ := record["isMeta"].(bool) + if !isMeta { + if message, ok := record["message"].(map[string]interface{}); ok { +- if content, ok := message["content"].(string); ok && content != "" { ++ content := extractContentText(message["content"]) ++ if content != "" { ++ // Skip synthetic messages (warmup, title generation prompts, etc.) + if !isSyntheticMessage(content) { + - firstUserMessage = content + + firstUserMessage = cleanSyntheticPrefixes(content) + } + } + } +diff --cc specstory-cli/pkg/spi/filename_test.go +index e64abbe,a0fcdc2..0000000 +--- a/specstory-cli/pkg/spi/filename_test.go ++++ b/specstory-cli/pkg/spi/filename_test.go +@@@ -239,9 -231,29 +249,35 @@@ func TestGenerateFilenameFromUserMessag + expected: "fix-the-bug-in", + }, + { +++<<<<<<< HEAD + + name: "Markdown heading produces clean slug", + + message: "# Plan: Replace Gemini Watcher Polling", + + expected: "plan-replace-gemini-watcher", +++======= ++ name: "Uppercase message produces lowercase filename", ++ message: "Create A New Feature", ++ expected: "create-a-new-feature", ++ }, ++ { ++ name: "All caps message produces lowercase filename", ++ message: "IMPLEMENT THE LOGIN HANDLER NOW", ++ expected: "implement-the-login-handler", ++ }, ++ { ++ name: "IDE tag prefix before real message", ++ message: "The user opened the file /Users/foo/bar.go in the IDE.\n\nThe @specstory-cli/ is a go CLI that reads the AI chats", ++ expected: "the-at-specstory-cli", ++ }, ++ { ++ name: "Only IDE tag with no real content", ++ message: "The user opened the file /Users/foo/bar.go in the IDE.", ++ expected: "", ++ }, ++ { ++ name: "Multiple IDE tags before real message", ++ message: "foo.go\nsome code\n\nFix the bug in the login handler", ++ expected: "fix-the-bug-in", +++>>>>>>> dev + }, + } + +diff --cc specstory-cli/pkg/spi/path_utils.go +index e96c4cf,1d8cf27..0000000 +--- a/specstory-cli/pkg/spi/path_utils.go ++++ b/specstory-cli/pkg/spi/path_utils.go +@@@ -16,15 -16,33 +16,45 @@@ import + "golang.org/x/text/unicode/norm" + ) + +++<<<<<<< HEAD + +// stripMarkdownHeading removes leading markdown heading markers (e.g. "# Title" → "Title"). + +// These are structural formatting, not meaningful slug content. + +func stripMarkdownHeading(message string) string { + + trimmed := strings.TrimSpace(message) + + if len(trimmed) > 0 && trimmed[0] == '#' { + + trimmed = strings.TrimLeft(trimmed, "#") + + trimmed = strings.TrimSpace(trimmed) + + } + + return trimmed +++======= ++ // xmlTagsRe matches XML-style tag blocks including their content, such as ++ // ... or self-closing . ++ // These are injected by IDE tools/system prompts and should be stripped before ++ // processing user message text for filename or display purposes. ++ // ++ // The regex has two alternations: ++ // - content — matched with (?s) so . spans newlines; uses .*? (lazy) ++ // to consume the shortest content between matching open/close tags. ++ // - — self-closing tags. ++ // ++ // Known limitation: nested tags with the same name (e.g. x) will ++ // only match up to the first closing tag, leaving a dangling . This is ++ // acceptable because IDE-injected tags are not nested this way. ++ var xmlTagsRe = regexp.MustCompile(`(?s)<[a-zA-Z_][a-zA-Z0-9_-]*(?:\s[^>]*)?>.*?|<[a-zA-Z_][a-zA-Z0-9_-]*(?:\s[^>]*)?/>`) ++ ++ // whitespaceRe collapses any run of Unicode whitespace into a single space. ++ var whitespaceRe = regexp.MustCompile(`\s+`) ++ ++ // stripXMLTags removes XML-style tag blocks (and their content) from s and ++ // returns the trimmed result with normalized whitespace. This cleans up injected ++ // IDE/system context that precedes the real user message text while preserving ++ // word boundaries where tags appeared between tokens. ++ func stripXMLTags(s string) string { ++ cleaned := xmlTagsRe.ReplaceAllString(s, " ") ++ cleaned = strings.TrimSpace(cleaned) ++ cleaned = whitespaceRe.ReplaceAllString(cleaned, " ") ++ return cleaned +++>>>>>>> dev + } + + // extractWordsFromMessage extracts up to maxWords from the message, handling various edge cases + +## Your task + +Examine the current merge conflict using git commands. + +Review each issue conflict by conflict and explain what caused each conflict. Explain what each side of the conflict was attempting to accomplish with their change. + +Importantly, make a suggestion for the best resolution for each conflict, but only resolve them in discussion with me. + +Use file names and line numbers to reference the changes, but also sequentially number every observation so we can easily reference them. + + +--- + +_**Agent (claude-sonnet-4-6 2026-03-18 10:14:02Z)**_ + +_**Agent (claude-sonnet-4-6 2026-03-18 10:14:03Z)**_ + +
+Tool use: **Read** + `./pkg/providers/claudecode/provider.go` + +```text + 480→} + 481→ + 482→// isSyntheticMessage checks if a message is synthetic/internal and should be skipped + 483→// when looking for the first real user message. + 484→// Currently filters warmup messages and Claude Code session title generation prompts. + 485→func isSyntheticMessage(content string) bool { + 486→ if strings.Contains(strings.ToLower(content), "warmup") { + 487→ return true + 488→ } + 489→ // Claude Code wraps the real user prompt in tags for title generation + 490→ if strings.Contains(content, "") { + 491→ return true + 492→ } + 493→ return false + 494→} + 495→ + 496→<<<<<<< HEAD + 497→// cleanSyntheticPrefixes strips known boilerplate prefixes that Claude Code + 498→// prepends to user messages (e.g. plan mode). The real user content follows + 499→// the prefix, so we strip rather than skip the message entirely. + 500→func cleanSyntheticPrefixes(content string) string { + 501→ prefixes := []string{ + 502→ "Implement the following plan:", + 503→ } + 504→ trimmed := strings.TrimSpace(content) + 505→ lower := strings.ToLower(trimmed) + 506→ for _, prefix := range prefixes { + 507→ if strings.HasPrefix(lower, strings.ToLower(prefix)) { + 508→ return strings.TrimSpace(trimmed[len(prefix):]) + 509→ } + 510→ } + 511→ return content + 512→======= + 513→// extractContentText extracts plain text from a message content field. + 514→// The content field may be either a plain string or a JSON array of typed content + 515→// blocks (e.g. [{type:"text",text:"..."},{type:"tool_use",...}]). + 516→// When it's an array, all "text"-type blocks are joined with newlines. + 517→// This handles the Claude Code JSONL format where IDE context tags (e.g. + 518→// ) are injected as separate text blocks before the real question. + 519→func extractContentText(content interface{}) string { + 520→ switch v := content.(type) { + 521→ case string: + 522→ return v + 523→ case []interface{}: + 524→ var parts []string + 525→ for _, block := range v { + 526→ if m, ok := block.(map[string]interface{}); ok { + 527→ if t, _ := m["type"].(string); t == "text" { + 528→ if text, ok := m["text"].(string); ok && text != "" { + 529→ parts = append(parts, text) + 530→ } + 531→ } + 532→ } + 533→ } + 534→ slog.Debug("extractContentText: extracted text from content block array", + 535→ "blockCount", len(v), + 536→ "textParts", len(parts)) + 537→ return strings.Join(parts, "\n") + 538→ default: + 539→ return "" + 540→ } + 541→>>>>>>> dev + 542→} + 543→ + 544→// findFirstUserMessage finds the first user message in a session for slug generation + 545→// Returns empty string if no suitable user message is found + 546→func findFirstUserMessage(session Session) string { + 547→ for _, record := range session.Records { + 548→ // Check if this is a user message + 549→ if recordType, ok := record.Data["type"].(string); !ok || recordType != "user" { + 550→ continue + 551→ } + 552→ + 553→ // Skip meta user messages + 554→ if isMeta, ok := record.Data["isMeta"].(bool); ok && isMeta { + 555→ continue + 556→ } + 557→ + 558→ // Extract content from message - content may be a string or a list of blocks + 559→ if message, ok := record.Data["message"].(map[string]interface{}); ok { +``` +
+ +_**Agent (claude-sonnet-4-6 2026-03-18 10:14:04Z)**_ + +
+Tool use: **Read** + `./pkg/spi/path_utils.go` + +```text + 1→package spi + 2→ + 3→import ( + 4→ "encoding/json" + 5→ "fmt" + 6→ "log/slog" + 7→ "os" + 8→ "path/filepath" + 9→ "regexp" + 10→ "strings" + 11→ "unicode" + 12→ + 13→ "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi/schema" + 14→ "golang.org/x/text/runes" + 15→ "golang.org/x/text/transform" + 16→ "golang.org/x/text/unicode/norm" + 17→) + 18→ + 19→<<<<<<< HEAD + 20→// stripMarkdownHeading removes leading markdown heading markers (e.g. "# Title" → "Title"). + 21→// These are structural formatting, not meaningful slug content. + 22→func stripMarkdownHeading(message string) string { + 23→ trimmed := strings.TrimSpace(message) + 24→ if len(trimmed) > 0 && trimmed[0] == '#' { + 25→ trimmed = strings.TrimLeft(trimmed, "#") + 26→ trimmed = strings.TrimSpace(trimmed) + 27→ } + 28→ return trimmed + 29→======= + 30→// xmlTagsRe matches XML-style tag blocks including their content, such as + 31→// ... or self-closing . + 32→// These are injected by IDE tools/system prompts and should be stripped before + 33→// processing user message text for filename or display purposes. + 34→// + 35→// The regex has two alternations: + 36→// - content — matched with (?s) so . spans newlines; uses .*? (lazy) + 37→// to consume the shortest content between matching open/close tags. + 38→// - — self-closing tags. + 39→// + 40→// Known limitation: nested tags with the same name (e.g. x) will + 41→// only match up to the first closing tag, leaving a dangling . This is + 42→// acceptable because IDE-injected tags are not nested this way. + 43→var xmlTagsRe = regexp.MustCompile(`(?s)<[a-zA-Z_][a-zA-Z0-9_-]*(?:\s[^>]*)?>.*?|<[a-zA-Z_][a-zA-Z0-9_-]*(?:\s[^>]*)?/>`) + 44→ + 45→// whitespaceRe collapses any run of Unicode whitespace into a single space. + 46→var whitespaceRe = regexp.MustCompile(`\s+`) + 47→ + 48→// stripXMLTags removes XML-style tag blocks (and their content) from s and + 49→// returns the trimmed result with normalized whitespace. This cleans up injected + 50→// IDE/system context that precedes the real user message text while preserving + 51→// word boundaries where tags appeared between tokens. + 52→func stripXMLTags(s string) string { + 53→ cleaned := xmlTagsRe.ReplaceAllString(s, " ") + 54→ cleaned = strings.TrimSpace(cleaned) + 55→ cleaned = whitespaceRe.ReplaceAllString(cleaned, " ") + 56→ return cleaned + 57→>>>>>>> dev + 58→} + 59→ + 60→// extractWordsFromMessage extracts up to maxWords from the message, handling various edge cases + 61→func extractWordsFromMessage(message string, maxWords int) []string { + 62→ if message == "" { + 63→ return []string{} + 64→ } + 65→ + 66→ // Strip leading markdown heading markers before they get converted to "hash" + 67→ message = stripMarkdownHeading(message) + 68→ + 69→ // Normalize unicode and remove accents + 70→ t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) + 71→ normalized, _, _ := transform.String(t, message) + 72→ + 73→ // Convert to lowercase + 74→ normalized = strings.ToLower(normalized) + 75→ + 76→ // Replace special characters with their word equivalents + 77→ normalized = strings.ReplaceAll(normalized, "@", " at ") + 78→ normalized = strings.ReplaceAll(normalized, "&", " and ") + 79→ normalized = strings.ReplaceAll(normalized, "#", " hash ") + 80→ + 81→ // Remove all punctuation and special characters, replace with spaces + 82→ // This regex keeps letters, numbers, and spaces + 83→ re := regexp.MustCompile(`[^a-z0-9\s]+`) + 84→ normalized = re.ReplaceAllString(normalized, " ") + 85→ + 86→ // Split into words and filter out empty strings + 87→ words := strings.Fields(normalized) + 88→ + 89→ // Take up to maxWords + 90→ if len(words) > maxWords { + 91→ words = words[:maxWords] + 92→ } + 93→ + 94→ return words + 95→} + 96→ + 97→// generateFilenameFromWords creates a filename from words, handling edge cases. + 98→// Enforces lowercase to guarantee filesystem-safe, consistent filenames regardless + 99→// of how the words were prepared by the caller. + 100→func generateFilenameFromWords(words []string) string { + 101→ if len(words) == 0 { + 102→ return "" + 103→ } + 104→ + 105→ // Join words with hyphens and enforce lowercase as a safety guarantee + 106→ filename := strings.ToLower(strings.Join(words, "-")) + 107→ + 108→ // Ensure no double hyphens + 109→ for strings.Contains(filename, "--") { + 110→ filename = strings.ReplaceAll(filename, "--", "-") + 111→ } + 112→ + 113→ // Remove leading/trailing hyphens + 114→ filename = strings.Trim(filename, "-") + 115→ + 116→ return filename + 117→} + 118→ + 119→// GenerateFilenameFromUserMessage generates a filename based on the first user message content + 120→func GenerateFilenameFromUserMessage(message string) string { + 121→ words := extractWordsFromMessage(stripXMLTags(message), 4) + 122→ return generateFilenameFromWords(words) + 123→} + 124→ + 125→// GenerateReadableName creates a human-readable session name from a user message + 126→// The name is truncated to 100 characters at a word boundary and cleaned of excess whitespace + 127→func GenerateReadableName(message string) string { + 128→ if message == "" { + 129→ return "" + 130→ } + 131→ + 132→ message = stripXMLTags(message) + 133→ if message == "" { + 134→ return "" + 135→ } + 136→ + 137→ // Normalize whitespace: replace newlines and multiple spaces with single space + 138→ name := strings.Join(strings.Fields(message), " ") + 139→ + 140→ // Truncate to reasonable length (100 chars) at word boundary + 141→ maxLength := 100 + 142→ if len(name) <= maxLength { + 143→ return name + 144→ } + 145→ + 146→ // Find last space before maxLength to avoid breaking words + 147→ truncated := name[:maxLength] + 148→ lastSpace := strings.LastIndex(truncated, " ") + 149→ if lastSpace > 0 { + 150→ truncated = truncated[:lastSpace] + 151→ } + 152→ + 153→ // Add ellipsis to indicate truncation + 154→ return truncated + "..." + 155→} + 156→ + 157→// appendRemainingParts appends the remaining path components from parts[startIdx:] to the + 158→// result path, skipping any empty components. This is used when we can't canonicalize the + 159→// rest of the path (e.g., directory doesn't exist or can't be read). + 160→func appendRemainingParts(result string, parts []string, startIdx int) string { + 161→ for j := startIdx; j < len(parts); j++ { + 162→ if parts[j] != "" { + 163→ result = filepath.Join(result, parts[j]) + 164→ } + 165→ } + 166→ return result + 167→} + 168→ + 169→// GetCanonicalPath resolves a path to its canonical form by resolving symlinks + 170→// and correcting case. This is important on case-insensitive filesystems like macOS + 171→// where "/Users/Foo" and "/users/foo" refer to the same directory but would produce + 172→// different hash values. + 173→// + 174→// The algorithm works by walking the path component by component, starting from the root. + 175→// For each component, it opens the parent directory and reads its actual entries, + 176→// performing case-insensitive matching to find the correct casing on disk. + 177→// This ensures that the returned path matches the real filesystem casing, which is + 178→// important for consistent hashing and avoiding subtle bugs on case-insensitive filesystems. + 179→// + 180→// Note: This can be slow for long paths or directories with many entries, since it + 181→// requires reading each directory's contents. + 182→// + 183→// If a component doesn't exist on disk, the remaining path components are + 184→// appended as-is, making this function safe to use with paths that don't + 185→// fully exist yet. + 186→func GetCanonicalPath(p string) (string, error) { + 187→ // Ensure we have an absolute, clean path + 188→ p = filepath.Clean(p) + 189→ if !filepath.IsAbs(p) { + 190→ var err error + 191→ p, err = filepath.Abs(p) + 192→ if err != nil { + 193→ return "", err + 194→ } + 195→ } + 196→ + 197→ // Resolve any symlinks in the path to get the real physical path + 198→ // This is critical for ensuring consistent path hashing across different + 199→ // ways of accessing the same directory (e.g., through a symlink vs directly) + 200→ realPath, err := filepath.EvalSymlinks(p) + 201→ if err == nil && realPath != p { + 202→ // Successfully resolved symlinks - update p to the resolved path for case normalization + 203→ p = realPath + 204→ } else if err != nil { + 205→ // Symlink resolution failed (e.g., path doesn't exist yet), + 206→ // continue with the original path - we'll still normalize the case below + 207→ slog.Warn("GetCanonicalPath: symlink resolution failed, using original path", + 208→ "path", p, "error", err) + 209→ } + 210→ + 211→ // Split into components + 212→ parts := strings.Split(p, string(os.PathSeparator)) + 213→ + 214→ // Start with root + 215→ result := string(os.PathSeparator) + 216→ + 217→ // Skip empty first element (before leading /) that results from splitting "/foo/bar" + 218→ firstPartIndex := 1 + 219→ + 220→ // Build path component by component + 221→ for i := firstPartIndex; i < len(parts); i++ { + 222→ if parts[i] == "" { + 223→ continue + 224→ } + 225→ + 226→ // Open current result directory to read its contents + 227→ f, err := os.Open(result) + 228→ if err != nil { + 229→ // Can't read directory - might not exist yet + 230→ // Append remaining path as-is + 231→ return appendRemainingParts(result, parts, i), nil + 232→ } + 233→ + 234→ names, err := f.Readdirnames(-1) + 235→ _ = f.Close() // Best effort close, error doesn't affect result + 236→ if err != nil { + 237→ return "", err + 238→ } + 239→ + 240→ // Find the correct case + 241→ found := false + 242→ for _, name := range names { + 243→ if strings.EqualFold(name, parts[i]) { + 244→ result = filepath.Join(result, name) + 245→ found = true + 246→ break + 247→ } + 248→ } + 249→ + 250→ if !found { + 251→ // Component doesn't exist, append remaining as-is + 252→ result = appendRemainingParts(result, parts, i) + 253→ break + 254→ } + 255→ } + 256→ + 257→ return result, nil + 258→} + 259→ + 260→// debugBaseDirOverride allows overriding the base directory for debug output. + 261→// When set, debug files are written to this directory instead of the default + 262→// .specstory/debug/ location. This is set via the --debug-dir CLI flag or + 263→// the debug_dir config option. + 264→var debugBaseDirOverride string + 265→ + 266→// SetDebugBaseDir sets the override base directory for debug output. + 267→// When set to a non-empty value, GetDebugDir will use this as the base + 268→// instead of the default .specstory/debug/ path. + 269→func SetDebugBaseDir(dir string) { + 270→ debugBaseDirOverride = dir + 271→} + 272→ + 273→// GetDebugDir returns the debug directory path for a given session ID or UUID. + 274→// + 275→// The debug directory is used to store detailed JSON records and debugging + 276→// information for SpecStory CLI sessions. This is shared across all providers + 277→// (Claude Code, Codex CLI, Cursor CLI) to maintain a consistent debug structure. + 278→// + 279→// When debugBaseDirOverride is set (via SetDebugBaseDir), the directory structure + 280→// is: // + 281→// Otherwise: .specstory/debug// + 282→// + 283→// Example: + 284→// + 285→// debugDir := spi.GetDebugDir("abc123-def456") + 286→// // Returns: ".specstory/debug/abc123-def456" + 287→func GetDebugDir(sessionID string) string { + 288→ if debugBaseDirOverride != "" { + 289→ return filepath.Join(debugBaseDirOverride, sessionID) + 290→ } + 291→ return filepath.Join(".specstory", "debug", sessionID) + 292→} + 293→ + 294→// WriteDebugSessionData writes the SessionData as formatted JSON to the debug directory. + 295→// This provides a standardized, provider-agnostic debug output alongside provider-specific raw data. + 296→// + 297→// The file is written to: .specstory/debug//session-data.json + 298→// + 299→// Returns an error if the debug directory cannot be created or the file cannot be written. + 300→func WriteDebugSessionData(sessionID string, sessionData *schema.SessionData) error { + 301→ if sessionData == nil { + 302→ return fmt.Errorf("sessionData is nil") + 303→ } + 304→ + 305→ // Get debug directory + 306→ debugDir := GetDebugDir(sessionID) + 307→ + 308→ // Create debug directory if it doesn't exist + 309→ if err := os.MkdirAll(debugDir, 0755); err != nil { + 310→ return fmt.Errorf("failed to create debug directory: %w", err) + 311→ } + 312→ + 313→ // Marshal SessionData to pretty-printed JSON + 314→ jsonData, err := json.MarshalIndent(sessionData, "", " ") + 315→ if err != nil { + 316→ return fmt.Errorf("failed to marshal SessionData: %w", err) + 317→ } + 318→ + 319→ // Write to session-data.json + 320→ filePath := filepath.Join(debugDir, "session-data.json") + 321→ if err := os.WriteFile(filePath, jsonData, 0644); err != nil { + 322→ return fmt.Errorf("failed to write session-data.json: %w", err) + 323→ } + 324→ + 325→ // Log absolute path for debugging + 326→ absPath, _ := filepath.Abs(filePath) + 327→ slog.Debug("Wrote debug session data", "sessionId", sessionID, "path", absPath) + 328→ return nil + 329→} + 330→ +``` +
+ +_**Agent (claude-sonnet-4-6 2026-03-18 10:14:04Z)**_ + +
+Tool use: **Read** + `./pkg/spi/filename_test.go` + +```text + 1→package spi + 2→ + 3→import ( + 4→ "testing" + 5→) + 6→ + 7→func TestExtractWordsFromMessage(t *testing.T) { + 8→ tests := []struct { + 9→ name string + 10→ message string + 11→ maxWords int + 12→ expected []string + 13→ }{ + 14→ { + 15→ name: "Basic sentence", + 16→ message: "Create a CLI battleship game", + 17→ maxWords: 4, + 18→ expected: []string{"create", "a", "cli", "battleship"}, + 19→ }, + 20→ { + 21→ name: "Less than max words", + 22→ message: "Hello world", + 23→ maxWords: 4, + 24→ expected: []string{"hello", "world"}, + 25→ }, + 26→ { + 27→ name: "More than max words", + 28→ message: "Create a complex web application with React and TypeScript", + 29→ maxWords: 4, + 30→ expected: []string{"create", "a", "complex", "web"}, + 31→ }, + 32→ { + 33→ name: "With punctuation", + 34→ message: "Hello, world! How are you?", + 35→ maxWords: 4, + 36→ expected: []string{"hello", "world", "how", "are"}, + 37→ }, + 38→ { + 39→ name: "With special characters", + 40→ message: "Send email to user@example.com & notify #team", + 41→ maxWords: 6, + 42→ expected: []string{"send", "email", "to", "user", "at", "example"}, + 43→ }, + 44→ { + 45→ name: "Numbers count as words", + 46→ message: "Create 3 unit tests", + 47→ maxWords: 4, + 48→ expected: []string{"create", "3", "unit", "tests"}, + 49→ }, + 50→ { + 51→ name: "With contractions", + 52→ message: "Don't forget to test it's functionality", + 53→ maxWords: 4, + 54→ expected: []string{"don", "t", "forget", "to"}, + 55→ }, + 56→ { + 57→ name: "With accents", + 58→ message: "Créer une café résumé", + 59→ maxWords: 4, + 60→ expected: []string{"creer", "une", "cafe", "resume"}, + 61→ }, + 62→ { + 63→ name: "Empty message", + 64→ message: "", + 65→ maxWords: 4, + 66→ expected: []string{}, + 67→ }, + 68→ { + 69→ name: "Only special characters", + 70→ message: "@#$%^&*()", + 71→ maxWords: 4, + 72→ expected: []string{"at", "hash", "and"}, + 73→ }, + 74→ { + 75→ name: "Multiple spaces", + 76→ message: "Hello world test", + 77→ maxWords: 4, + 78→ expected: []string{"hello", "world", "test"}, + 79→ }, + 80→ { + 81→ name: "With emojis", + 82→ message: "Hello 😊 world 🌍", + 83→ maxWords: 4, + 84→ expected: []string{"hello", "world"}, + 85→ }, + 86→ { + 87→ name: "Mixed case", + 88→ message: "HeLLo WoRLD TeST", + 89→ maxWords: 4, + 90→ expected: []string{"hello", "world", "test"}, + 91→ }, + 92→ { + 93→ name: "File paths", + 94→ message: "Read file /usr/local/bin/test.txt", + 95→ maxWords: 4, + 96→ expected: []string{"read", "file", "usr", "local"}, + 97→ }, + 98→ { + 99→ name: "URLs", + 100→ message: "Visit https://example.com/page", + 101→ maxWords: 4, + 102→ expected: []string{"visit", "https", "example", "com"}, + 103→ }, + 104→ { + 105→ name: "Markdown heading stripped", + 106→ message: "# Plan: Replace Gemini Watcher", + 107→ maxWords: 4, + 108→ expected: []string{"plan", "replace", "gemini", "watcher"}, + 109→ }, + 110→ { + 111→ name: "Multi-level markdown heading stripped", + 112→ message: "## Fix: Config Template Issue", + 113→ maxWords: 4, + 114→ expected: []string{"fix", "config", "template", "issue"}, + 115→ }, + 116→ { + 117→ name: "Non-heading hash preserved", + 118→ message: "Fix bug in #login component", + 119→ maxWords: 4, + 120→ expected: []string{"fix", "bug", "in", "hash"}, + 121→ }, + 122→ } + 123→ + 124→ for _, tt := range tests { + 125→ t.Run(tt.name, func(t *testing.T) { + 126→ result := extractWordsFromMessage(tt.message, tt.maxWords) + 127→ if len(result) != len(tt.expected) { + 128→ t.Errorf("extractWordsFromMessage() returned %d words, expected %d", len(result), len(tt.expected)) + 129→ t.Errorf("Got: %v", result) + 130→ t.Errorf("Expected: %v", tt.expected) + 131→ return + 132→ } + 133→ for i := range result { + 134→ if result[i] != tt.expected[i] { + 135→ t.Errorf("extractWordsFromMessage() word[%d] = %s, expected %s", i, result[i], tt.expected[i]) + 136→ } + 137→ } + 138→ }) + 139→ } + 140→} + 141→ + 142→func TestGenerateFilenameFromWords(t *testing.T) { + 143→ tests := []struct { + 144→ name string + 145→ words []string + 146→ expected string + 147→ }{ + 148→ { + 149→ name: "Normal words", + 150→ words: []string{"create", "a", "cli", "battleship"}, + 151→ expected: "create-a-cli-battleship", + 152→ }, + 153→ { + 154→ name: "Single word", + 155→ words: []string{"hello"}, + 156→ expected: "hello", + 157→ }, + 158→ { + 159→ name: "Empty words", + 160→ words: []string{}, + 161→ expected: "", + 162→ }, + 163→ { + 164→ name: "Words with hyphens", + 165→ words: []string{"test", "-", "case"}, + 166→ expected: "test-case", + 167→ }, + 168→ { + 169→ name: "Multiple consecutive hyphens", + 170→ words: []string{"test", "", "", "case"}, + 171→ expected: "test-case", + 172→ }, + 173→ { + 174→ name: "Uppercase words are lowercased", + 175→ words: []string{"Hello", "World"}, + 176→ expected: "hello-world", + 177→ }, + 178→ { + 179→ name: "Mixed case words are lowercased", + 180→ words: []string{"Create", "A", "CLI", "Battleship"}, + 181→ expected: "create-a-cli-battleship", + 182→ }, + 183→ } + 184→ + 185→ for _, tt := range tests { + 186→ t.Run(tt.name, func(t *testing.T) { + 187→ result := generateFilenameFromWords(tt.words) + 188→ if result != tt.expected { + 189→ t.Errorf("generateFilenameFromWords() = %s, expected %s", result, tt.expected) + 190→ } + 191→ }) + 192→ } + 193→} + 194→ + 195→func TestGenerateFilenameFromUserMessage(t *testing.T) { + 196→ tests := []struct { + 197→ name string + 198→ message string + 199→ expected string + 200→ }{ + 201→ { + 202→ name: "Example 1: Tell me about my", + 203→ message: "Tell me about my @battleship.py game.", + 204→ expected: "tell-me-about-my", + 205→ }, + 206→ { + 207→ name: "Example 2: Create a CLI battleship", + 208→ message: "Create a CLI battleship game for me in Python.", + 209→ expected: "create-a-cli-battleship", + 210→ }, + 211→ { + 212→ name: "Short message", + 213→ message: "Hello", + 214→ expected: "hello", + 215→ }, + 216→ { + 217→ name: "Empty message", + 218→ message: "", + 219→ expected: "", + 220→ }, + 221→ { + 222→ name: "Only punctuation", + 223→ message: "???!!!", + 224→ expected: "", + 225→ }, + 226→ { + 227→ name: "With special replacements", + 228→ message: "Email me @ user@test.com & CC #dev", + 229→ expected: "email-me-at-user", + 230→ }, + 231→ { + 232→ name: "Trailing spaces and punctuation", + 233→ message: " Hello world!!! ", + 234→ expected: "hello-world", + 235→ }, + 236→ { + 237→ name: "Complex punctuation", + 238→ message: "What's the best way? Let's find out!", + 239→ expected: "what-s-the-best", + 240→ }, + 241→ { + 242→ name: "File reference", + 243→ message: "Update the @index.html file", + 244→ expected: "update-the-at-index", + 245→ }, + 246→ { + 247→ name: "Code related", + 248→ message: "Fix the bug in foo.bar() method", + 249→ expected: "fix-the-bug-in", + 250→ }, + 251→ { + 252→<<<<<<< HEAD + 253→ name: "Markdown heading produces clean slug", + 254→ message: "# Plan: Replace Gemini Watcher Polling", + 255→ expected: "plan-replace-gemini-watcher", + 256→======= + 257→ name: "Uppercase message produces lowercase filename", + 258→ message: "Create A New Feature", + 259→ expected: "create-a-new-feature", + 260→ }, + 261→ { + 262→ name: "All caps message produces lowercase filename", + 263→ message: "IMPLEMENT THE LOGIN HANDLER NOW", + 264→ expected: "implement-the-login-handler", + 265→ }, + 266→ { + 267→ name: "IDE tag prefix before real message", + 268→ message: "The user opened the file /Users/foo/bar.go in the IDE.\n\nThe @specstory-cli/ is a go CLI that reads the AI chats", + 269→ expected: "the-at-specstory-cli", + 270→ }, + 271→ { + 272→ name: "Only IDE tag with no real content", + 273→ message: "The user opened the file /Users/foo/bar.go in the IDE.", + 274→ expected: "", + 275→ }, + 276→ { + 277→ name: "Multiple IDE tags before real message", + 278→ message: "foo.go\nsome code\n\nFix the bug in the login handler", + 279→ expected: "fix-the-bug-in", + 280→>>>>>>> dev + 281→ }, + 282→ } + 283→ + 284→ for _, tt := range tests { + 285→ t.Run(tt.name, func(t *testing.T) { + 286→ result := GenerateFilenameFromUserMessage(tt.message) + 287→ if result != tt.expected { + 288→ t.Errorf("GenerateFilenameFromUserMessage(%q) = %q, expected %q", tt.message, result, tt.expected) + 289→ } + 290→ }) + 291→ } + 292→} + 293→ +``` +
+ +_**Agent (claude-sonnet-4-6 2026-03-18 10:14:08Z)**_ + +_**Agent (claude-sonnet-4-6 2026-03-18 10:14:09Z)**_ + +Now let me look at the usage site in `provider.go` around line 706 to see the full context of both functions being called: + +_**Agent (claude-sonnet-4-6 2026-03-18 10:14:10Z)**_ + +
+Tool use: **Read** + `./pkg/providers/claudecode/provider.go` + +```text + 555→ continue + 556→ } + 557→ + 558→ // Extract content from message - content may be a string or a list of blocks + 559→ if message, ok := record.Data["message"].(map[string]interface{}); ok { + 560→ content := extractContentText(message["content"]) + 561→ if content != "" { + 562→ // Skip synthetic messages (warmup, title generation prompts, etc.) + 563→ if isSyntheticMessage(content) { + 564→ continue + 565→ } + 566→ return cleanSyntheticPrefixes(content) + 567→ } + 568→ } + 569→ } + 570→ return "" + 571→} + 572→ + 573→// FileSlugFromRootRecord generates the slug part of the filename from the session + 574→// Returns the human-readable slug derived from the first user message, or empty string if none + 575→func FileSlugFromRootRecord(session Session) string { + 576→ // Find the first user message and generate slug from it + 577→ firstUserMessage := findFirstUserMessage(session) + 578→ slog.Debug("FileSlugFromRootRecord: First user message", + 579→ "sessionId", session.SessionUuid, + 580→ "message", firstUserMessage) + 581→ + 582→ slug := spi.GenerateFilenameFromUserMessage(firstUserMessage) + 583→ slog.Debug("FileSlugFromRootRecord: Generated slug", + 584→ "sessionId", session.SessionUuid, + 585→ "slug", slug) + 586→ + 587→ return slug + 588→} + 589→ + 590→// ListAgentChatSessions retrieves lightweight session metadata without full parsing + 591→// This is much faster than GetAgentChatSessions as it only reads minimal data from each session + 592→func (p *Provider) ListAgentChatSessions(projectPath string) ([]spi.SessionMetadata, error) { + 593→ // Get the Claude Code project directory + 594→ claudeProjectDir, err := GetClaudeCodeProjectDir(projectPath) + 595→ if err != nil { + 596→ return nil, err + 597→ } + 598→ + 599→ // Check if project directory exists + 600→ if _, err := os.Stat(claudeProjectDir); os.IsNotExist(err) { + 601→ return []spi.SessionMetadata{}, nil // No sessions if no project + 602→ } + 603→ + 604→ // Collect all JSONL files in the project directory + 605→ var sessionFiles []string + 606→ err = filepath.Walk(claudeProjectDir, func(path string, info os.FileInfo, err error) error { + 607→ if err != nil { + 608→ return err + 609→ } + 610→ if info.IsDir() || !strings.HasSuffix(path, ".jsonl") { + 611→ return nil + 612→ } + 613→ sessionFiles = append(sessionFiles, path) + 614→ return nil + 615→ }) + 616→ if err != nil { + 617→ return nil, fmt.Errorf("failed to scan project directory: %w", err) + 618→ } + 619→ + 620→ // Extract metadata from each session file + 621→ sessions := make([]spi.SessionMetadata, 0, len(sessionFiles)) + 622→ for _, filePath := range sessionFiles { + 623→ metadata, err := extractSessionMetadata(filePath) + 624→ if err != nil { + 625→ slog.Warn("Failed to extract session metadata", + 626→ "file", filePath, + 627→ "error", err) + 628→ continue + 629→ } + 630→ + 631→ // Skip warmup-only sessions (no metadata means warmup-only) + 632→ if metadata == nil { + 633→ slog.Debug("Skipping warmup-only session", "file", filePath) + 634→ continue +``` +
+ +_**Agent (claude-sonnet-4-6 2026-03-18 10:14:12Z)**_ + +_**Agent (claude-sonnet-4-6 2026-03-18 10:14:13Z)**_ + +Now let me check `extractSessionMetadata` for context: + +_**Agent (claude-sonnet-4-6 2026-03-18 10:14:13Z)**_ + +
+Tool use: **Read** + `./pkg/providers/claudecode/provider.go` + +```text + 680→ "error", jsonErr) + 681→ } else { + 682→ // Extract session ID (from any record) + 683→ if sessionID == "" { + 684→ if sid, ok := record["sessionId"].(string); ok { + 685→ sessionID = sid + 686→ } + 687→ } + 688→ + 689→ // Only process non-sidechain, non-system records for message extraction + 690→ isSidechain, _ := record["isSidechain"].(bool) + 691→ recordType, hasType := record["type"].(string) + 692→ isSystemRecord := hasType && (recordType == "file-history-snapshot" || recordType == "file-change") + 693→ + 694→ if !isSidechain && !isSystemRecord { + 695→ // This is the first real message record - extract timestamp + 696→ if !foundRealMessage { + 697→ foundRealMessage = true + 698→ if ts, ok := record["timestamp"].(string); ok { + 699→ timestamp = ts + 700→ } + 701→ } + 702→ + 703→ // Extract first user message for slug (if this is a user message) + 704→ // Content may be a string or a list of typed blocks (see extractContentText) + 705→ if firstUserMessage == "" && hasType && recordType == "user" { + 706→ isMeta, _ := record["isMeta"].(bool) + 707→ if !isMeta { + 708→ if message, ok := record["message"].(map[string]interface{}); ok { + 709→ content := extractContentText(message["content"]) + 710→ if content != "" { + 711→ // Skip synthetic messages (warmup, title generation prompts, etc.) + 712→ if !isSyntheticMessage(content) { + 713→ firstUserMessage = cleanSyntheticPrefixes(content) + 714→ } + 715→ } + 716→ } + 717→ } + 718→ } + 719→ } +``` +
+ +_**Agent (claude-sonnet-4-6 2026-03-18 10:14:32Z)**_ + +_**Agent (claude-sonnet-4-6 2026-03-18 10:14:46Z)**_ + +I now have a complete picture. Here is my analysis of all three conflicts: + +--- + +## Conflict Analysis + +### Conflict 1 — `pkg/providers/claudecode/provider.go` (lines 496–542) + +These two branches added **different new functions** to the same insertion point just after `isSyntheticMessage`. + +**HEAD (cursor-ide)** adds `cleanSyntheticPrefixes` — a function that strips boilerplate prefixes Claude Code prepends to user messages in Plan Mode (e.g. `"Implement the following plan:"`). Rather than skipping those messages entirely, it strips the prefix and returns the real user content. + +**dev** adds `extractContentText` — a function that handles the Claude Code JSONL format where the `content` field can be either a plain `string` *or* a JSON array of typed blocks (`[{type:"text",...}]`). Cursor IDE injects context via extra text blocks, so this extracts and concatenates only the `"text"` type blocks. + +**Key finding:** Both functions are *already used* in the code **outside the conflict region**: +- `extractContentText(...)` is called at lines 560 and 709 +- `cleanSyntheticPrefixes(...)` is called at lines 566 and 713 + +**Recommendation:** Keep both functions. This is a purely additive conflict — there is no logical contradiction between them. + +--- + +### Conflict 2 — `pkg/spi/path_utils.go` (lines 19–58) + +Again, two different additions at the same insertion point. + +**HEAD (cursor-ide)** adds `stripMarkdownHeading` — a small function that strips leading `#` characters from messages (e.g. `"# Plan: Replace Gemini Watcher"` → `"Plan: Replace Gemini Watcher"`). Without this, the `#` becomes the word `"hash"` in the generated slug. + +**dev** adds `xmlTagsRe`, `whitespaceRe`, and `stripXMLTags` — regex-based XML tag stripping to remove IDE-injected context blocks like `...` from message text before it's used for filenames. + +**Key finding:** Both are also *already used* downstream in the same file outside the conflict region: +- `stripMarkdownHeading(message)` is called at line 67 inside `extractWordsFromMessage` +- `stripXMLTags(message)` is called at lines 121 and 132 inside `GenerateFilenameFromUserMessage` and `GenerateReadableName` + +**Recommendation:** Keep both. Again purely additive. The `regexp` import is already present. + +--- + +### Conflict 3 — `pkg/spi/filename_test.go` (lines 252–281) + +Two sets of new test cases for `TestGenerateFilenameFromUserMessage` added at the same position. + +**HEAD (cursor-ide)** adds one case: +- `"Markdown heading produces clean slug"` — verifies `# Plan: Replace Gemini Watcher Polling` → `plan-replace-gemini-watcher` + +**dev** adds five cases: +- `"Uppercase message produces lowercase filename"` +- `"All caps message produces lowercase filename"` +- `"IDE tag prefix before real message"` — tests that XML tags are stripped before generating the slug +- `"Only IDE tag with no real content"` — expects empty result +- `"Multiple IDE tags before real message"` + +**Key finding:** All six test cases test distinct, non-overlapping behaviours. They are all compatible and valid given the combined resolution of conflicts 1 and 2. + +**Recommendation:** Keep all six. The markdown heading test belongs first (matching HEAD), followed by the five dev cases — though the order is arbitrary. + +--- + +## Summary + +All three conflicts are **purely additive** — two parallel workstreams each added new functions and test cases at the same location. The correct resolution for all three is to include **both sides**. There is no code that needs to be discarded, and no logical contradiction to resolve. + +Shall I proceed with applying all three resolutions? + +--- + +_**User (2026-03-18 10:15:00Z)**_ + +yes + +--- + +_**Agent (claude-sonnet-4-6 2026-03-18 10:15:04Z)**_ + +_**Agent (claude-sonnet-4-6 2026-03-18 10:15:04Z)**_ + +Resolving all three conflicts now. + +_**Agent (claude-sonnet-4-6 2026-03-18 10:19:33Z)**_ + +
+Tool use: **Edit** + +```text +The file /Users/sean/Source/SpecStory/getspecstory/specstory-cli/pkg/providers/claudecode/provider.go has been updated successfully. +``` +
+ +_**Agent (claude-sonnet-4-6 2026-03-18 10:19:51Z)**_ + +
+Tool use: **Edit** + +```text +The file /Users/sean/Source/SpecStory/getspecstory/specstory-cli/pkg/spi/path_utils.go has been updated successfully. +``` +
+ +_**Agent (claude-sonnet-4-6 2026-03-18 10:20:02Z)**_ + +
+Tool use: **Edit** + +```text +The file /Users/sean/Source/SpecStory/getspecstory/specstory-cli/pkg/spi/filename_test.go has been updated successfully. +``` +
+ +_**Agent (claude-sonnet-4-6 2026-03-18 10:20:05Z)**_ + +All three files are resolved. Let me now run the tests to verify everything compiles and passes: + +_**Agent (claude-sonnet-4-6 2026-03-18 10:20:14Z)**_ + +
+Tool use: **Bash** +Run all tests + +`go test ./... 2>&1` + +```text +ok github.com/specstoryai/getspecstory/specstory-cli 0.036s +? github.com/specstoryai/getspecstory/specstory-cli/pkg/analytics [no test files] +ok github.com/specstoryai/getspecstory/specstory-cli/pkg/cloud 0.509s +ok github.com/specstoryai/getspecstory/specstory-cli/pkg/cmd 0.035s +ok github.com/specstoryai/getspecstory/specstory-cli/pkg/config 0.121s +? github.com/specstoryai/getspecstory/specstory-cli/pkg/log [no test files] +ok github.com/specstoryai/getspecstory/specstory-cli/pkg/provenance 1.023s +ok github.com/specstoryai/getspecstory/specstory-cli/pkg/providers/claudecode 5.697s +ok github.com/specstoryai/getspecstory/specstory-cli/pkg/providers/codexcli 0.117s +ok github.com/specstoryai/getspecstory/specstory-cli/pkg/providers/cursorcli 0.100s +? github.com/specstoryai/getspecstory/specstory-cli/pkg/providers/cursoride [no test files] +ok github.com/specstoryai/getspecstory/specstory-cli/pkg/providers/droidcli 0.066s +ok github.com/specstoryai/getspecstory/specstory-cli/pkg/providers/geminicli 0.047s +ok github.com/specstoryai/getspecstory/specstory-cli/pkg/session 0.043s +ok github.com/specstoryai/getspecstory/specstory-cli/pkg/spi 0.065s +? github.com/specstoryai/getspecstory/specstory-cli/pkg/spi/factory [no test files] +ok github.com/specstoryai/getspecstory/specstory-cli/pkg/spi/schema 0.012s +ok github.com/specstoryai/getspecstory/specstory-cli/pkg/telemetry 0.018s +ok github.com/specstoryai/getspecstory/specstory-cli/pkg/utils 0.051s +``` +
+ +_**Agent (claude-sonnet-4-6 2026-03-18 10:20:17Z)**_ + +All tests pass. The three files are ready to be staged and committed. diff --git a/specstory-cli/docs/COPILOTIDE-PROVIDER.md b/specstory-cli/docs/COPILOTIDE-PROVIDER.md new file mode 100644 index 00000000..0758a219 --- /dev/null +++ b/specstory-cli/docs/COPILOTIDE-PROVIDER.md @@ -0,0 +1,1112 @@ +# Implementation Plan: Add VS Code Copilot IDE Provider (copilotide) + +## Overview + +Add a new provider called `copilotide` that reads VS Code Copilot's JSON session files directly (same as the extension does) to export conversations to the `.specstory` folder. This is phase 1 - focusing on JSON file reading, conversation extraction, workspace filtering, and debug output. Tool export formatting differences will be addressed in later phases. + +## Quick Summary + +**Goal:** Port extension's VS Code Copilot file reading functionality to CLI as a new provider + +**Key Features:** +- Read from workspace-specific chat sessions (`~/.vscode/User/workspaceStorage/[workspace-id]/chatSessions/`) +- Filter by workspace (matches Claude Code's project-scoped behavior) +- Export conversations to markdown in `.specstory/history/` +- Debug mode for raw data inspection +- Load optional editing state from `chatEditingSessions/` + +**Technology:** +- JSON file reading (no SQLite needed - VS Code uses JSON files) +- Workspace filtering via workspace metadata matching +- Standard SPI provider interface +- SQLite only for workspace matching (reading `state.vscdb` for workspace identification) + +**Complexity:** Medium (simpler than Cursor - JSON files vs global SQLite database) + +## Background + +The existing extension (in `ts-extension/`) reads from VS Code's workspace storage at `~/.vscode/User/workspaceStorage/[workspace-id]/chatSessions/`. Each chat session is stored as a separate JSON file. We're porting this functionality to the CLI as a new provider. + +**Key Architectural Difference from Cursor:** +- **Cursor**: Single global SQLite database + workspace filtering +- **VS Code**: Individual JSON files per session in workspace-specific directories + +## Type Strategy (Important) + +**Direct conversion, no intermediate types:** +1. **Raw types:** Define Go structs matching VS Code's actual JSON structure (`VSCodeComposer`, `VSCodeRequestBlock`, etc.) +2. **CLI types:** Convert directly to `schema.SessionData` (the CLI's unified format) +3. **No intermediate types:** Don't port the TS extension's unified conversation types - that's unnecessary complexity + +**Why this is simpler than Cursor:** +- JSON files are self-contained (no SQL queries needed) +- Workspace filtering is inherent (files live in workspace directories) +- No database locking concerns (just file reads) +- Start with minimal type definitions, expand as needed based on debug output + +**Development approach:** +1. Implement basic version with minimal types +2. Get debug-raw mode working +3. Export actual JSON and inspect +4. Refine types incrementally based on real data + +## Critical Files + +### New Files to Create +1. `pkg/providers/copilotide/provider.go` - Main provider implementation +2. `pkg/providers/copilotide/workspace.go` - Workspace matching and path resolution +3. `pkg/providers/copilotide/parser.go` - Parse VS Code native format to internal representation +4. `pkg/providers/copilotide/agent_session.go` - Convert to unified SessionData format +5. `pkg/providers/copilotide/types.go` - Go structs for VS Code data structures +6. `pkg/providers/copilotide/path_utils.go` - VS Code-specific path resolution + +### Files to Modify +1. `pkg/spi/factory/registry.go` - Register the new provider +2. `go.mod` - Verify SQLite dependency exists (already added for cursoride) + +## Implementation Steps + +### Step 1: Verify Dependencies + +- `modernc.org/sqlite` already added for `cursoride` provider +- Only needed for workspace matching (reading `state.vscdb`) +- No new dependencies required + +### Step 2: Create Provider Structure + +Create `pkg/providers/copilotide/provider.go`: +- Define `Provider` struct (empty struct, stateless) +- Implement `NewProvider()` constructor +- Implement `Name()` → return "VS Code Copilot" +- Stub out all 6 required interface methods initially + +### Step 3: Path Resolution + +Create `pkg/providers/copilotide/path_utils.go`: +- `GetWorkspaceStoragePath()` → Find `~/.vscode/User/workspaceStorage/` + - macOS: `~/Library/Application Support/Code/User/workspaceStorage/` + - Linux: `~/.config/Code/User/workspaceStorage/` +- `FindMatchingWorkspace(projectPath)` → Match projectPath to workspace directory +- `GetChatSessionsPath(workspaceDir)` → Return `[workspaceDir]/chatSessions/` +- `GetChatEditingSessionsPath(workspaceDir)` → Return `[workspaceDir]/chatEditingSessions/` +- Handle case where directories don't exist +- Handle multiple matching workspaces (use newest based on `state.vscdb` mtime) + +### Step 4: Define Data Structures + +Create `pkg/providers/copilotide/types.go` - **Raw VS Code JSON types only** (not unified types): + +**Important:** These types match VS Code's actual JSON structure. We'll convert directly from these to `schema.SessionData` (the CLI's unified format). Don't create intermediate unified types - that's unnecessary complexity. + +```go +// VSCodeComposer is the root structure stored in chatSessions/[sessionId].json +type VSCodeComposer struct { + Host string `json:"host"` // Always "vscode" + SessionID string `json:"sessionId"` + Name string `json:"name,omitempty"` + CustomTitle string `json:"customTitle,omitempty"` + Version int `json:"version"` + RequesterUsername string `json:"requesterUsername"` + ResponderUsername string `json:"responderUsername"` + CreationDate int64 `json:"creationDate"` + LastMessageDate int64 `json:"lastMessageDate"` + InitialLocation string `json:"initialLocation"` + IsImported bool `json:"isImported"` + Requests []VSCodeRequestBlock `json:"requests"` +} + +// VSCodeRequestBlock represents one conversational turn (user message + agent responses) +type VSCodeRequestBlock struct { + RequestID string `json:"requestId"` + Timestamp int64 `json:"timestamp"` + Message VSCodeMessage `json:"message"` + Response []json.RawMessage `json:"response"` // Polymorphic array - parse by "kind" + Result VSCodeResult `json:"result"` + ModelID string `json:"modelId,omitempty"` +} + +// VSCodeMessage is the user's input message +type VSCodeMessage struct { + Text string `json:"text"` + Parts []VSCodeMessagePart `json:"parts,omitempty"` +} + +// VSCodeMessagePart (optional - only parse if needed for tool references) +type VSCodeMessagePart struct { + Kind string `json:"kind"` + // Add other fields as needed during implementation +} + +// Response types - each has a "kind" discriminator +// Parse json.RawMessage into these based on kind field + +type VSCodeToolInvocationResponse struct { + Kind string `json:"kind"` // "toolInvocationSerialized" + ID string `json:"id,omitempty"` + ToolID string `json:"toolId,omitempty"` + ToolCallID string `json:"toolCallId,omitempty"` + Presentation string `json:"presentation,omitempty"` // "hidden" or empty + // Add invocationMessage, pastTenseMessage if needed for tool parameters +} + +type VSCodeTextEditGroupResponse struct { + Kind string `json:"kind"` // "textEditGroup" + Uri VSCodeUri `json:"uri"` + Edits [][]VSCodeEdit `json:"edits"` // Nested array + Done bool `json:"done,omitempty"` +} + +type VSCodeCodeblockUriResponse struct { + Kind string `json:"kind"` // "codeblockUri" + Uri VSCodeUri `json:"uri"` + IsEdit bool `json:"isEdit,omitempty"` +} + +type VSCodeConfirmationResponse struct { + Kind string `json:"kind"` // "confirmation" + Title string `json:"title"` + Message string `json:"message"` + IsUsed bool `json:"isUsed"` +} + +type VSCodeInlineReferenceResponse struct { + Kind string `json:"kind"` // "inlineReference" + InlineReference VSCodeUri `json:"inlineReference"` +} + +// Result metadata - contains tool call rounds and results +type VSCodeResult struct { + Metadata VSCodeResultMetadata `json:"metadata"` + Timings VSCodeTimings `json:"timings"` +} + +type VSCodeResultMetadata struct { + ToolCallRounds []VSCodeToolCallRound `json:"toolCallRounds,omitempty"` + ToolCallResults map[string]VSCodeToolCallResult `json:"toolCallResults,omitempty"` + Messages []VSCodeMetadataMessage `json:"messages,omitempty"` +} + +type VSCodeToolCallRound struct { + Response string `json:"response"` // May contain thinking + ToolCalls []VSCodeToolCallInfo `json:"toolCalls"` +} + +type VSCodeToolCallInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Arguments string `json:"arguments"` // JSON string - parse if needed +} + +type VSCodeToolCallResult struct { + Content []struct { + Value string `json:"value,omitempty"` + } `json:"content,omitempty"` +} + +type VSCodeMetadataMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type VSCodeTimings struct { + FirstProgress int64 `json:"firstProgress"` + TotalElapsed int64 `json:"totalElapsed"` +} + +// Common types +type VSCodeUri struct { + Scheme string `json:"scheme,omitempty"` // "file", "vscode-chat-code-block", etc. + Path string `json:"path,omitempty"` + FSPath string `json:"fsPath,omitempty"` +} + +type VSCodeEdit struct { + Text string `json:"text"` + Range VSCodeRange `json:"range"` +} + +type VSCodeRange struct { + StartLineNumber int `json:"startLineNumber"` + StartColumn int `json:"startColumn"` + EndLineNumber int `json:"endLineNumber"` + EndColumn int `json:"endColumn"` +} + +// Optional state file types (for chatEditingSessions/[sessionId]/state.json) +// Only define these if needed - can defer to phase 2 +type VSCodeStateFile struct { + Version int `json:"version"` + SessionID string `json:"sessionId"` + LinearHistory []VSCodeLinearHistory `json:"linearHistory,omitempty"` + // Add other fields as needed +} + +type VSCodeLinearHistory struct { + RequestID string `json:"requestId"` + Stops []VSCodeStop `json:"stops,omitempty"` +} + +type VSCodeStop struct { + // Define as needed - can defer to phase 2 +} +``` + +**Key Points:** +- These match VS Code's actual JSON structure +- Use `json.RawMessage` for polymorphic response array +- Parse "kind" field first, then unmarshal into specific type +- Add fields incrementally as needed - start minimal +- State file types can be deferred to phase 2 + +**Fields we can safely omit** (from TS extension types): +- `requesterAvatarIconUri`, `responderAvatarIconUri` - UI only +- `agent.*` - Agent metadata (extensionId, publisher, etc.) +- `variableData` - Variable references (not needed for basic export) +- `followups`, `contentReferences`, `codeCitations` - Not needed for markdown export +- `isCanceled` - Session state (not relevant for export) +- Most message part fields - only parse if needed for tool references + +**Start with these essentials:** +- Session metadata (id, dates, title) +- Request blocks (user message + responses) +- Tool call metadata (rounds, results) +- Response types: toolInvocationSerialized, codeblockUri, textEditGroup +- Skip everything else until debug output shows we need it + +### Step 5: Workspace Matching + +Create `pkg/providers/copilotide/workspace.go`: +- `FindWorkspaceForProject(projectPath string)` → Find matching workspace directory +- `GetWorkspaceStoragePath()` → Get macOS/Linux workspace storage location + - macOS: `~/Library/Application Support/Code/User/workspaceStorage/` + - Linux: `~/.config/Code/User/workspaceStorage/` +- `MatchWorkspaceURI(projectPath, workspaceURI string)` → Compare paths accounting for symlinks +- `LoadWorkspaceMetadata(workspaceDir)` → Read `workspace.json` to get workspace URI +- `LoadSessionIndex(workspaceDir)` → Read `chat.ChatSessionStore.index` from workspace `state.vscdb` +- `GetNewestWorkspace(matches []string)` → Select workspace with newest `state.vscdb` mtime +- Handle errors gracefully: + - Workspace storage doesn't exist + - No matching workspace found + - Multiple matches (use newest) + - No chat sessions in workspace + +**Implementation Notes:** +```go +// Workspace matching process: +// 1. Scan all directories in ~/.vscode/User/workspaceStorage/ +// 2. For each directory, read workspace.json +// 3. Extract "workspace" or "folder" field +// 4. Compare with projectPath (canonical paths) +// 5. If multiple matches, use newest based on state.vscdb mtime + +func FindWorkspaceForProject(projectPath string) (string, error) { + storageBase := GetWorkspaceStoragePath() + dirs, err := os.ReadDir(storageBase) + // ... iterate and match workspace.json +} + +// Example workspace.json structure: +// {"folder": "file:///Users/bago/code/project"} +// or +// {"workspace": "file:///Users/bago/project.code-workspace"} +``` + +### Step 6: Load Chat Sessions + +Create `pkg/providers/copilotide/loader.go`: +- `LoadAllSessionFiles(workspaceDir string)` → List all JSON files in `chatSessions/` directory +- `LoadSessionFile(sessionPath string)` → Read and parse single session JSON file +- `LoadStateFile(sessionID, workspaceDir string)` → Load optional state file from `chatEditingSessions/[sessionID]/state.json` +- `LoadSessionByID(workspaceDir, sessionID string)` → Load specific session +- `LoadAllSessions(workspaceDir string)` → Load all sessions for workspace +- Error handling: + - Missing chatSessions directory + - Malformed JSON files + - Missing state files (non-fatal) + - Empty sessions (filter out) + +**Implementation Notes:** +```go +func LoadAllSessionFiles(workspaceDir string) ([]string, error) { + chatSessionsPath := filepath.Join(workspaceDir, "chatSessions") + files, err := os.ReadDir(chatSessionsPath) + if err != nil { + return nil, fmt.Errorf("failed to read chat sessions: %w", err) + } + + var sessionFiles []string + for _, file := range files { + if !file.IsDir() && strings.HasSuffix(file.Name(), ".json") { + sessionFiles = append(sessionFiles, filepath.Join(chatSessionsPath, file.Name())) + } + } + return sessionFiles, nil +} + +func LoadSessionFile(sessionPath string) (*VSCodeComposer, error) { + data, err := os.ReadFile(sessionPath) + if err != nil { + return nil, fmt.Errorf("failed to read session file: %w", err) + } + + var composer VSCodeComposer + if err := json.Unmarshal(data, &composer); err != nil { + return nil, fmt.Errorf("failed to parse session JSON: %w", err) + } + + return &composer, nil +} + +// Load optional state file (for editing sessions) +func LoadStateFile(sessionID, workspaceDir string) (*VSCodeStateFile, error) { + statePath := filepath.Join(workspaceDir, "chatEditingSessions", sessionID, "state.json") + + // State file is optional + if _, err := os.Stat(statePath); os.IsNotExist(err) { + return nil, nil // Not an error - state files are optional + } + + data, err := os.ReadFile(statePath) + if err != nil { + return nil, fmt.Errorf("failed to read state file: %w", err) + } + + var state VSCodeStateFile + if err := json.Unmarshal(data, &state); err != nil { + return nil, fmt.Errorf("failed to parse state JSON: %w", err) + } + + return &state, nil +} +``` + +### Step 7: Parse Response Array (Simple) + +Create `pkg/providers/copilotide/parser.go`: + +**Goal:** Simple response parsing - just identify tool calls and extract basic info. No complex intermediate structures. + +```go +// ParseResponseKind identifies the response type without fully parsing +func ParseResponseKind(rawResponse json.RawMessage) (string, error) { + var kindOnly struct { + Kind string `json:"kind"` + } + if err := json.Unmarshal(rawResponse, &kindOnly); err != nil { + return "", fmt.Errorf("failed to parse response kind: %w", err) + } + return kindOnly.Kind, nil +} + +// IsHiddenTool checks if a tool invocation response is marked as hidden +func IsHiddenTool(rawResponse json.RawMessage) bool { + var invocation VSCodeToolInvocationResponse + if err := json.Unmarshal(rawResponse, &invocation); err != nil { + return false + } + return invocation.Presentation == "hidden" +} + +// BuildToolCallMap creates a lookup map from toolCallId to tool call info +func BuildToolCallMap(metadata VSCodeResultMetadata) map[string]VSCodeToolCallInfo { + toolCalls := make(map[string]VSCodeToolCallInfo) + + for _, round := range metadata.ToolCallRounds { + for _, call := range round.ToolCalls { + toolCalls[call.ID] = call + } + } + + return toolCalls +} + +// ExtractThinkingFromMetadata extracts thinking text from tool call rounds +func ExtractThinkingFromMetadata(metadata VSCodeResultMetadata) string { + var thinking strings.Builder + + for _, round := range metadata.ToolCallRounds { + if round.Response != "" { + // Tool call round responses often contain thinking + thinking.WriteString(round.Response) + thinking.WriteString("\n\n") + } + } + + return strings.TrimSpace(thinking.String()) +} + +// ExtractFinalAgentMessage gets the final text response from metadata +func ExtractFinalAgentMessage(metadata VSCodeResultMetadata) string { + // VS Code stores final messages in metadata.messages + for _, msg := range metadata.Messages { + if msg.Role == "assistant" { + return msg.Content + } + } + return "" +} + +// GenerateSlug creates a URL-safe slug from composer name or first message +func GenerateSlug(composer VSCodeComposer) string { + // Use custom title if available + if composer.CustomTitle != "" { + return slugify(composer.CustomTitle) + } + if composer.Name != "" { + return slugify(composer.Name) + } + + // Fall back to first request message + if len(composer.Requests) > 0 { + firstMsg := composer.Requests[0].Message.Text + // Take first 50 chars + if len(firstMsg) > 50 { + firstMsg = firstMsg[:50] + } + return slugify(firstMsg) + } + + return "untitled" +} + +// FormatTimestamp converts Unix timestamp (ms) to ISO 8601 +func FormatTimestamp(unixMs int64) string { + t := time.Unix(0, unixMs*int64(time.Millisecond)) + return t.Format(time.RFC3339) +} + +// slugify converts text to URL-safe slug +func slugify(text string) string { + // Basic implementation - lowercase, replace spaces/special chars with hyphens + text = strings.ToLower(text) + text = strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + return r + } + return '-' + }, text) + // Remove consecutive hyphens + text = strings.Join(strings.FieldsFunc(text, func(r rune) bool { + return r == '-' + }), "-") + return strings.Trim(text, "-") +} +``` + +**Key Points:** +- Simple helper functions, no complex state tracking +- Parse response "kind" field only when needed +- Build tool call map from metadata for quick lookup +- Extract thinking from `toolCallRounds[].response` +- Skip hidden tools (presentation="hidden") +- Let the conversion logic in `agent_session.go` orchestrate everything + +### Step 8: Convert to CLI's SessionData Format + +Create `pkg/providers/copilotide/agent_session.go`: + +**Goal:** Convert `VSCodeComposer` (raw JSON) directly to `schema.SessionData` (CLI format). No intermediate types. + +```go +// ConvertToSessionData converts VS Code raw format to CLI's unified schema +func ConvertToSessionData(composer VSCodeComposer, projectPath string) spi.AgentChatSession { + sessionData := &schema.SessionData{ + SchemaVersion: "1.0", + Provider: schema.ProviderInfo{ + ID: "copilotide", + Name: "VS Code Copilot", + Version: "1.0", + }, + SessionID: composer.SessionID, + CreatedAt: FormatTimestamp(composer.CreationDate), + UpdatedAt: FormatTimestamp(composer.LastMessageDate), + Slug: GenerateSlug(composer), + WorkspaceRoot: projectPath, + Exchanges: ConvertRequestsToExchanges(composer.Requests), + } + + return spi.AgentChatSession{ + SessionID: composer.SessionID, + CreatedAt: sessionData.CreatedAt, + Slug: sessionData.Slug, + SessionData: sessionData, + // RawData will be set by caller with raw JSON + } +} + +// ConvertRequestsToExchanges converts VS Code request blocks to exchanges +func ConvertRequestsToExchanges(requests []VSCodeRequestBlock) []schema.Exchange { + var exchanges []schema.Exchange + + for _, req := range requests { + exchange := schema.Exchange{ + ExchangeID: req.RequestID, + StartTime: FormatTimestamp(req.Timestamp), + Messages: ConvertRequestToMessages(req), + } + exchanges = append(exchanges, exchange) + } + + return exchanges +} + +// ConvertRequestToMessages converts one request block to message array +// Returns: [user message, agent message(s), tool message(s)] +func ConvertRequestToMessages(req VSCodeRequestBlock) []schema.Message { + var messages []schema.Message + + // 1. User message + userMsg := schema.Message{ + Role: schema.RoleUser, + Content: []schema.ContentPart{ + {Type: schema.ContentTypeText, Text: req.Message.Text}, + }, + } + messages = append(messages, userMsg) + + // 2. Extract thinking from tool call rounds (if present) + thinking := ExtractThinkingFromMetadata(req.Result.Metadata) + if thinking != "" { + thinkingMsg := schema.Message{ + Role: schema.RoleAgent, + Content: []schema.ContentPart{ + {Type: schema.ContentTypeThinking, Text: thinking}, + }, + } + messages = append(messages, thinkingMsg) + } + + // 3. Parse responses and extract tool calls + toolMessages := ParseResponsesForTools(req.Response, req.Result.Metadata) + messages = append(messages, toolMessages...) + + // 4. Final agent text message (from metadata.messages if present) + finalText := ExtractFinalAgentMessage(req.Result.Metadata) + if finalText != "" { + agentMsg := schema.Message{ + Role: schema.RoleAgent, + Content: []schema.ContentPart{ + {Type: schema.ContentTypeText, Text: finalText}, + }, + Model: req.ModelID, + } + messages = append(messages, agentMsg) + } + + return messages +} + +// ParseResponsesForTools extracts tool invocations from response array +func ParseResponsesForTools(responses []json.RawMessage, metadata VSCodeResultMetadata) []schema.Message { + var toolMessages []schema.Message + + // Build tool call map from metadata + toolCalls := BuildToolCallMap(metadata) + + // Process each response + for _, rawResp := range responses { + // Parse "kind" field first + var kindOnly struct { + Kind string `json:"kind"` + } + if err := json.Unmarshal(rawResp, &kindOnly); err != nil { + continue + } + + switch kindOnly.Kind { + case "toolInvocationSerialized": + var invocation VSCodeToolInvocationResponse + if err := json.Unmarshal(rawResp, &invocation); err != nil { + continue + } + + // Skip hidden tools + if invocation.Presentation == "hidden" { + continue + } + + // Find matching tool call and result + toolInfo := BuildToolInfoFromInvocation(invocation, toolCalls) + if toolInfo != nil { + toolMsg := schema.Message{ + Role: schema.RoleAgent, + Tool: toolInfo, + } + toolMessages = append(toolMessages, toolMsg) + } + + // Handle other response types (textEditGroup, codeblockUri, etc.) + // Can defer detailed handling to phase 2 + } + } + + return toolMessages +} + +// BuildToolInfoFromInvocation creates ToolInfo from VS Code invocation + metadata +func BuildToolInfoFromInvocation(invocation VSCodeToolInvocationResponse, toolCalls map[string]VSCodeToolCallInfo) *schema.ToolInfo { + // Find tool call details from metadata + toolCall, ok := toolCalls[invocation.ToolCallID] + if !ok { + return nil + } + + toolInfo := &schema.ToolInfo{ + Name: toolCall.Name, + Type: MapToolType(toolCall.Name), + UseID: invocation.ToolCallID, + } + + // Parse arguments if present + if toolCall.Arguments != "" { + var args map[string]interface{} + if err := json.Unmarshal([]byte(toolCall.Arguments), &args); err == nil { + toolInfo.Input = args + } + } + + // Add output from results map + // Can enhance this in phase 2 + + return toolInfo +} + +// MapToolType maps VS Code tool names to schema.ToolType constants +func MapToolType(toolName string) string { + mapping := map[string]string{ + "bash": schema.ToolTypeShell, + "search_files": schema.ToolTypeSearch, + "read_file": schema.ToolTypeRead, + "write_to_file": schema.ToolTypeWrite, + "str_replace_editor": schema.ToolTypeWrite, + "list_files": schema.ToolTypeSearch, + // Add more as needed + } + + if toolType, ok := mapping[toolName]; ok { + return toolType + } + return schema.ToolTypeUnknown +} +``` + +**Key Points:** +- Convert directly from `VSCodeComposer` to `schema.SessionData` +- No intermediate unified types +- Start with basic tool detection, refine in phase 2 +- Thinking extracted from `toolCallRounds[].response` if present +- Tool results from `toolCallResults` map +- Use schema constants: `schema.RoleUser`, `schema.ToolTypeShell`, etc. + +### Step 9: Implement Core Provider Methods + +In `pkg/providers/copilotide/provider.go`: + +1. **Check(customCommand):** + - Verify workspace storage directory exists + - Check for at least one workspace directory + - Return version info (could check VS Code version from paths) + - Return CheckResult with success/error + +2. **DetectAgent(projectPath, helpOutput):** + - Check if workspace storage directory exists + - Try to match projectPath to a workspace + - Check if matched workspace has chatSessions directory + - Check if any session files exist + - Return true if sessions found + - If helpOutput=true, print guidance about: + - Workspace storage location + - Expected workspace path + - chatSessions directory + +3. **GetAgentChatSession(projectPath, sessionID, debugRaw):** + - Find matching workspace + - Load specific session by ID from `chatSessions/[sessionID].json` + - Load optional state file + - Parse conversation + - Convert to AgentChatSession + - If debugRaw=true, write raw session JSON to `.specstory/debug//raw-session.json` + - Return single session + +4. **GetAgentChatSessions(projectPath, debugRaw):** + - Find workspace storage path + - Match projectPath to workspace (scan workspace.json files) + - If multiple matches, use newest workspace + - Load all session files from `chatSessions/` directory + - For each session, load optional state file + - Filter out empty conversations + - Convert each to AgentChatSession + - If debugRaw=true, write debug files for each + - Return array of sessions (workspace-filtered) + +5. **ExecAgentAndWatch(...):** + - Return error: "VS Code Copilot does not support execution via CLI" + - This provider is read-only (IDE-based, not CLI-based) + +6. **WatchAgent(ctx, projectPath, debugRaw, callback):** + - Find matching workspace directory + - Use fsnotify to watch `chatSessions/` directory + - On new/modified files, parse and invoke callback + - Support graceful shutdown via context + - Debounce rapid changes (multiple events for same file) + - Watch pattern: `*.json` files in chatSessions directory + +**Implementation Notes:** +```go +func (p *Provider) GetAgentChatSessions(projectPath string, debugRaw bool) ([]spi.AgentChatSession, error) { + // Find matching workspace + workspaceDir, err := FindWorkspaceForProject(projectPath) + if err != nil { + return nil, fmt.Errorf("failed to find workspace: %w", err) + } + + // Load all session files + sessionFiles, err := LoadAllSessionFiles(workspaceDir) + if err != nil { + return nil, fmt.Errorf("failed to load session files: %w", err) + } + + var sessions []spi.AgentChatSession + for _, sessionFile := range sessionFiles { + composer, err := LoadSessionFile(sessionFile) + if err != nil { + slog.Warn("Failed to load session", "file", sessionFile, "error", err) + continue + } + + // Load optional state file + state, err := LoadStateFile(composer.SessionID, workspaceDir) + if err != nil { + slog.Warn("Failed to load state file", "sessionId", composer.SessionID, "error", err) + // Continue without state - it's optional + } + composer.State = state + + // Filter empty conversations + if len(composer.Requests) == 0 { + continue + } + + // Convert to unified format + session := ConvertToSessionData(composer) + sessions = append(sessions, session) + + // Debug output + if debugRaw { + writeDebugFiles(composer, sessionFile) + } + } + + return sessions, nil +} + +func (p *Provider) WatchAgent(ctx context.Context, projectPath string, debugRaw bool, callback spi.WatchCallback) error { + workspaceDir, err := FindWorkspaceForProject(projectPath) + if err != nil { + return fmt.Errorf("failed to find workspace: %w", err) + } + + chatSessionsPath := filepath.Join(workspaceDir, "chatSessions") + + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("failed to create watcher: %w", err) + } + defer watcher.Close() + + if err := watcher.Add(chatSessionsPath); err != nil { + return fmt.Errorf("failed to watch directory: %w", err) + } + + // Debouncing map + debounce := make(map[string]time.Time) + const debounceWindow = 500 * time.Millisecond + + for { + select { + case <-ctx.Done(): + return nil + + case event := <-watcher.Events: + // Only process JSON files + if !strings.HasSuffix(event.Name, ".json") { + continue + } + + // Debounce rapid events + now := time.Now() + if lastEvent, ok := debounce[event.Name]; ok && now.Sub(lastEvent) < debounceWindow { + continue + } + debounce[event.Name] = now + + // Process create/write events + if event.Op&(fsnotify.Create|fsnotify.Write) != 0 { + composer, err := LoadSessionFile(event.Name) + if err != nil { + slog.Warn("Failed to load session", "file", event.Name, "error", err) + continue + } + + session := ConvertToSessionData(composer) + callback(session) + } + + case err := <-watcher.Errors: + slog.Warn("Watcher error", "error", err) + } + } +} +``` + +### Step 10: Register Provider + +Modify `pkg/spi/factory/registry.go`: +```go +import ( + // ... existing imports ... + "github.com/specstoryai/SpecStoryCLI/pkg/providers/copilotide" +) + +func (r *Registry) registerAll() { + // ... existing registrations ... + + copilotideProvider := copilotide.NewProvider() + r.providers["copilotide"] = copilotideProvider + slog.Debug("Registered provider", "id", "copilotide", "name", copilotideProvider.Name()) + + r.initialized = true +} +``` + +### Step 11: Debug Output Support + +When `--debug-raw` flag is passed: +- Write to `.specstory/debug//` +- Files to write: + - `raw-session.json` - Raw composer/session data from JSON file + - `state.json` - Optional state file (if exists) + - `session-data.json` - Unified SessionData format (handled by central CLI) +- Use `spi.WriteDebugSessionData()` utility + +## Usage Examples + +```zsh +# Check if VS Code Copilot sessions exist +./specstory check copilotide + +# Sync all VS Code Copilot conversations +./specstory sync copilotide + +# Sync specific conversation +./specstory sync copilotide -u + +# Debug mode - see raw data +./specstory sync copilotide --debug-raw + +# Watch mode - monitor for new conversations +./specstory watch copilotide +``` + +## Phase 1 Scope (This Plan) + +**In Scope:** +- ✅ JSON file reading from workspace chatSessions directory +- ✅ Workspace matching and filtering +- ✅ Conversation parsing (requests + responses) +- ✅ Tool invocation detection (from response array) +- ✅ Conversion to unified SessionData +- ✅ Debug output support +- ✅ Provider registration +- ✅ Optional state file loading + +**Out of Scope (Future Phases):** +- ❌ Detailed tool output formatting differences +- ❌ Code block diff rendering +- ❌ MessagePart line range visualization +- ❌ Tool-specific markdown formatting +- ❌ Linear history visualization +- ❌ Edit snapshot handling +- ❌ InlineReference rendering + +## Important Implementation Notes + +### File System Access Patterns +1. **No SQLite for Sessions:** VS Code stores sessions as individual JSON files (simpler than Cursor's SQLite approach) +2. **SQLite Only for Workspace Matching:** Use SQLite to read workspace `state.vscdb` for identifying workspace +3. **Two Directory Types:** + - `chatSessions/` - Main conversation JSON files + - `chatEditingSessions/[sessionId]/state.json` - Optional editing state files +4. **File Watching:** Watch `chatSessions/` directory for new/modified `*.json` files + +### Workspace Matching Challenges +1. **Symlinks:** Must use canonical paths for matching (resolve symlinks before comparison) +2. **Multiple Workspaces:** User may have same project in multiple workspace IDs - use newest +3. **Workspace Types:** Can be single folder or multi-root workspace file +4. **OS-Specific Paths:** + - macOS: `~/Library/Application Support/Code/User/workspaceStorage/` + - Linux: `~/.config/Code/User/workspaceStorage/` + +### Response Polymorphism +VS Code uses polymorphic response arrays with "kind" discriminator: +```json +{ + "response": [ + {"kind": "toolInvocationSerialized", ...}, + {"kind": "codeblockUri", ...}, + {"kind": "textEditGroup", ...} + ] +} +``` +- Must parse each response element to check "kind" field first +- Then unmarshal into specific type +- Handle unknown kinds gracefully (log warning, skip) + +### Tool Call Sequence Tracking +VS Code spreads tool invocation data across multiple locations: +1. **Invocation**: `response[].toolInvocationSerialized` with parameters +2. **Rounds**: `result.metadata.toolCallRounds[]` with LLM calls +3. **Results**: `result.metadata.toolCallResults[callId]` with outputs +4. **Code**: `response[].codeblockUri` or `response[].textEditGroup` + +Must correlate these using IDs: +- `toolInvocationId` links invocation to sequence +- `toolCallId` links to specific LLM call and result + +### Error Handling +1. **Missing Directories:** Graceful failure if workspace storage doesn't exist +2. **No Workspace Match:** Clear error message explaining workspace detection +3. **Empty Conversations:** Filter out sessions with no requests +4. **Malformed JSON:** Skip corrupted files, log warning, continue processing +5. **Missing State Files:** Non-fatal - state files are optional + +## Testing Strategy + +Manual testing approach (no unit tests initially per project conventions): +1. Create test workspace with sample chat sessions +2. Test `check` command - verify workspace detection +3. Test `sync` command - verify conversation export +4. Test `--debug-raw` - verify debug files are written +5. Verify markdown files are created in `.specstory/history/` +6. Test with empty workspace +7. Test with missing workspace storage +8. Test with optional state files + +## Verification Checklist + +After implementation: +- [ ] `./specstory check copilotide` successfully finds workspace +- [ ] `./specstory sync copilotide` exports conversations to `.specstory/history/` +- [ ] `./specstory sync copilotide --debug-raw` creates debug files +- [ ] Markdown files have correct format (timestamps, speakers, messages) +- [ ] Tool invocations are detected (basic level) +- [ ] Empty conversations are filtered out +- [ ] Provider appears in help text for all commands +- [ ] No errors with missing workspace storage (graceful failure) +- [ ] Watch mode detects new sessions in real-time + +## Provider Comparison: cursoride vs copilotide + +### Overview +Both providers read from IDE-based editors but use different storage mechanisms: +- **cursoride** = Cursor IDE (SQLite database) +- **copilotide** = VS Code Copilot (JSON files) + +### Detailed Comparison + +| Aspect | cursoride (Cursor IDE) | copilotide (VS Code Copilot) | +|--------|------------------------|------------------------------| +| **Product** | Cursor IDE (VS Code fork) | VS Code with GitHub Copilot extension | +| **Data Location** | Global: `~/.cursor/extensions/.../state.vscdb`
Workspace: `~/Library/.../workspaceStorage/` | `~/Library/Application Support/Code/User/workspaceStorage/[id]/chatSessions/` | +| **Storage Format** | SQLite database (single global DB) | Individual JSON files per session | +| **Database Schema** | Global DB: `cursorDiskKV` (key, value)
Workspace DB: `ItemTable` (key, value)
Keys: `composerData:*`, `bubbleId:*` | No database for sessions
JSON files: `[sessionId].json` | +| **Conversation Structure** | Flat `conversation` array on composer | Nested `requests` array with `response[]` arrays | +| **Tool Invocations** | `toolFormerData`, `capabilityType=15` | `response[].toolInvocationSerialized` with kind discriminator | +| **Code Blocks** | Part of conversation with `codeBlocks` array | Separate `codeblockUri` and `textEditGroup` response types | +| **Database Access** | SQL queries with WAL mode | Direct file reads with `os.ReadFile()` | +| **Session Metadata** | Indexed in `composer.composerData` | Stored in `chat.ChatSessionStore.index` in workspace `state.vscdb` | +| **Editing State** | Embedded in composer object | Separate `chatEditingSessions/[id]/state.json` files | +| **Project Matching** | Workspace URI matching via `workspace.json` | Same - workspace URI matching | +| **SQLite Library** | `modernc.org/sqlite` (pure Go) | `modernc.org/sqlite` (only for workspace matching) | +| **Execution** | ❌ Cannot execute (IDE-based) | ❌ Cannot execute (IDE-based) | +| **Watch Mode** | Watches global database + WAL file | Watches `chatSessions/` directory for `*.json` files | +| **Message Storage** | Separate bubble records in database | Self-contained in session JSON file | +| **Complexity** | High (global DB + workspace filtering + SQL) | Medium (JSON files + workspace matching) | + +### Key Architectural Differences + +**cursoride (SQLite):** +``` +~/.cursor/extensions/cursor-context-manager-*/ + └── globalStorage/cursor-context-manager/ + └── state.vscdb (ALL composer data in SQLite) + - cursorDiskKV table: + - composerData:uuid → JSON + - bubbleId:uuid:bubbleId → JSON +``` +- **Complex:** Global database + workspace filtering with SQL queries +- **WAL Mode:** Required for non-blocking reads +- **Query Optimization:** IN clauses and LIKE patterns + +**copilotide (JSON):** +``` +~/Library/Application Support/Code/User/workspaceStorage/ + └── [workspace-id]/ + ├── workspace.json (workspace metadata) + ├── chatSessions/ + │ ├── [sessionId].json (complete session data) + │ └── [sessionId].json + └── chatEditingSessions/ + └── [sessionId]/ + └── state.json (optional editing state) +``` +- **Simpler:** Individual JSON files, no SQL needed +- **Self-Contained:** Each session file is complete +- **No Database Locking:** Just file reads + +### Why Simpler Than Cursor + +1. **No Global Database:** Each session is a separate file +2. **No SQL Queries:** Direct JSON parsing +3. **No WAL Mode:** No database locking concerns +4. **No Key Filtering:** No need to skip checkpoint/context keys +5. **Inherently Project-Scoped:** Sessions live in workspace directories + +## Decisions + +1. **SQLite Usage:** Minimal - only for workspace matching + - Read workspace `state.vscdb` to find matching workspace + - No SQLite needed for session data (JSON files) + - Reuse existing `modernc.org/sqlite` dependency from cursoride + +2. **Workspace Filtering:** Same approach as cursoride + - Match projectPath to workspace by comparing URIs + - Select newest workspace if multiple matches + - Load sessions only from matched workspace + +3. **Session ID Mapping:** Use sessionID directly from JSON + - Maintains compatibility with extension + - Simple mapping, no translation needed + +4. **State File Loading:** Optional + - Load if exists, continue if missing + - Only needed for editing session visualization + - Not required for basic conversation export + +## Dependencies + +- `modernc.org/sqlite` - Already added for cursoride (only for workspace matching) +- Existing SPI infrastructure +- fsnotify (already in use for claudecode) +- Standard library: encoding/json, path/filepath, os + +## Success Criteria + +- New `copilotide` provider is registered and available +- Can read VS Code Copilot JSON session files +- Can parse conversations from JSON files +- Can export conversations to markdown files +- Debug mode shows raw data structures +- No crashes or panics with missing/invalid data +- Graceful error messages for common issues +- Watch mode detects new sessions in real-time diff --git a/specstory-cli/docs/COPILOTIDE-TOOLS-PLAN.md b/specstory-cli/docs/COPILOTIDE-TOOLS-PLAN.md new file mode 100644 index 00000000..b244d2a2 --- /dev/null +++ b/specstory-cli/docs/COPILOTIDE-TOOLS-PLAN.md @@ -0,0 +1,313 @@ +# VS Code Copilot IDE - Tool Implementation Plan + +## Current Status + +### ✅ What's Working +- All sessions load successfully +- Basic conversation parsing (user messages, agent responses) +- Thinking blocks correctly identified +- Empty session filtering +- Debug output for raw JSON + +### ❌ What's Not Working +- Tool calls don't appear in markdown output +- **Root Cause:** ID mismatch between two systems: + - `toolCallRounds` metadata uses OpenAI-style IDs: `call_mlWUQhVaoFaj26CRtR7sqD1j__vscode-1763048081513` + - `toolInvocationSerialized` responses use VS Code IDs: `52142529-aec6-4fb4-94ac-ca0deb467986` + +## Tool Usage Analysis + +Based on analysis of all VS Code Copilot sessions in workspace storage: + +### Most Common Tools (by frequency) +1. **apply_patch** - 14 calls (Write: apply code changes) +2. **grep_search** - 6 calls (Search: find text in files) +3. **read_file** - 6 calls (Read: read file contents) +4. **mcp_*** - 3 calls (MCP server tools) +5. **create_file** - 1 call (Write: create new files) +6. **file_search** - 1 call (Search: find files by name) +7. **list_dir** - 1 call (Generic: list directory) +8. **get_errors** - 1 call (Generic: get errors) +9. **manage_todo_list** - 1 call (Task: manage TODOs) +10. **semantic_search** - 1 call (Search: semantic search) +11. **insert_edit_into_file** - 1 call (Write: insert text) + +### Tool Name Mapping + +**OpenAI API Names → VS Code Internal IDs:** + +| OpenAI Tool Name | VS Code Tool ID | Type | Priority | +|------------------|-----------------|------|----------| +| `grep_search` | `copilot_findTextInFiles` | search | HIGH | +| `apply_patch` | `copilot_applyPatch` | write | HIGH | +| `read_file` | `copilot_readFile` | read | HIGH | +| `insert_edit_into_file` | `copilot_insertEdit` | write | MEDIUM | +| `create_file` | `copilot_createFile` | write | MEDIUM | +| `file_search` | `copilot_searchFiles` | search | MEDIUM | +| `list_dir` | `copilot_listDirectory` | generic | LOW | +| `semantic_search` | `copilot_searchCodebase` | search | MEDIUM | +| `manage_todo_list` | `copilot_manageTodoList` | task | LOW | +| `get_errors` | `copilot_getErrors` | generic | LOW | +| `mcp_*` | `mcp_*` | mcp | MEDIUM | + +## Implementation Strategy + +### Phase 1: Fix Tool Correlation (ID Matching) + +**Problem:** Two different ID systems don't match up. + +**Solution Options:** + +#### Option A: Match by Sequence/Order ⭐ RECOMMENDED +- Tool invocations in `response` array appear in sequence +- Tool calls in `toolCallRounds` also appear in sequence +- Match them by order within each round +- Ignore the mismatched IDs entirely + +**Pros:** +- Simpler implementation +- Doesn't rely on ID matching +- Works even if ID formats change + +**Cons:** +- Assumes ordering is consistent (need to verify) + +#### Option B: Build Cross-Reference Map +- Parse both ID systems from raw JSON +- Look for correlation patterns (timestamps, sequence numbers, etc.) +- Build mapping table + +**Pros:** +- More precise matching +- Handles out-of-order responses + +**Cons:** +- Complex, fragile +- Relies on reverse-engineering VS Code's internal logic + +#### Option C: Use invocationMessage Text Parsing +- Extract tool info from `invocationMessage.value` strings +- Example: "Searching text for `syncServiceUrl` (`**/apps/vscode-extension/src/**`)" +- Parse these messages to extract parameters + +**Pros:** +- User-facing messages are stable +- Already formatted for display + +**Cons:** +- Fragile (relies on message format) +- Loses structured data + +### Phase 2: Implement Priority Tools + +**Implementation Order:** + +1. **HIGH Priority** (Most used, essential for understanding sessions) + - ✅ `grep_search` / `copilot_findTextInFiles` - 6 calls + - ✅ `read_file` / `copilot_readFile` - 6 calls + - ✅ `apply_patch` / `copilot_applyPatch` - 14 calls + +2. **MEDIUM Priority** (Less common but important) + - `insert_edit_into_file` / `copilot_insertEdit` - 1 call + - `create_file` / `copilot_createFile` - 1 call + - `semantic_search` / `copilot_searchCodebase` - 1 call + - `file_search` / `copilot_searchFiles` - 1 call + - `mcp_*` - MCP server tools - 3 calls + +3. **LOW Priority** (Rare, nice-to-have) + - `list_dir` / `copilot_listDirectory` + - `get_errors` / `copilot_getErrors` + - `manage_todo_list` / `copilot_manageTodoList` + +### Phase 3: Tool Handler Implementation + +For each tool, implement: + +1. **Parameter Extraction** + - Parse from `toolCallRounds[].toolCalls[].arguments` (JSON string) + - Map to schema.ToolInfo.Input + +2. **Result Extraction** + - Parse from `toolCallResults[toolCallId].content[].value` + - Handle complex objects (use `valueToString` helper) + - Map to schema.ToolInfo.Output + +3. **Summary Generation** (optional) + - Create human-readable summary + - Store in schema.ToolInfo.Summary + +4. **Formatted Markdown** (optional) + - Generate provider-specific markdown + - Store in schema.ToolInfo.FormattedMarkdown + +## Detailed Implementation Plan + +### Step 1: Fix Tool Matching (Use Sequence-Based Matching) + +**File:** `pkg/providers/copilotide/agent_session.go` + +**Current logic:** +```go +// Tries to match by toolCallId (doesn't work) +toolCall, ok := toolCalls[invocation.ToolCallID] +``` + +**New logic:** +```go +// Match by tracking sequence across rounds and response array +// Build a sequence-based correlation +``` + +**Implementation:** +1. Track position in response array for each `toolInvocationSerialized` +2. Track position in `toolCallRounds` for each tool call +3. Match visible (non-hidden) tool invocations to tool calls by sequence +4. Handle hidden tools (skip them in correlation) + +### Step 2: Implement High-Priority Tools + +#### 2.1: grep_search / copilot_findTextInFiles + +**Input Parameters:** +```json +{ + "query": "syncServiceUrl", + "isRegexp": false, + "includePattern": "apps/vscode-extension/src/**" +} +``` + +**Output:** +- Complex object with search results +- Extract: file paths, match count, preview text + +**Display:** +```markdown + +
+Search for "syncServiceUrl" in apps/vscode-extension/src/** + +Found 20 matches across 8 files: +- apps/vscode-extension/src/services/AuthService.ts (5 matches) +- apps/vscode-extension/src/config.ts (2 matches) +... +
+
+``` + +#### 2.2: read_file / copilot_readFile + +**Input Parameters:** +```json +{ + "path": "apps/vscode-extension/src/services/AuthService.ts" +} +``` + +**Output:** +- File content (may be truncated) +- Line count + +**Display:** +```markdown + +
+Read file: apps/vscode-extension/src/services/AuthService.ts + +```typescript +[file content] +``` + +
+
+``` + +#### 2.3: apply_patch / copilot_applyPatch + +**Input Parameters:** +```json +{ + "path": "apps/vscode-extension/src/services/AuthService.ts", + "patch": "... unified diff format ..." +} +``` + +**Output:** +- Success/failure status +- Applied changes summary + +**Display:** +```markdown + +
+Applied patch to apps/vscode-extension/src/services/AuthService.ts + +```diff +- syncServiceUrl: string; ++ syncUrl: string; +``` + +
+
+``` + +### Step 3: Generic Tool Handler + +For tools we don't have specific handlers for: + +```go +func BuildGenericToolInfo(invocation, toolCall, result) *schema.ToolInfo { + return &schema.ToolInfo{ + Name: toolCall.Name, + Type: schema.ToolTypeGeneric, + UseID: invocation.ToolCallID, + Input: parseArguments(toolCall.Arguments), + Output: extractResult(result), + Summary: createGenericSummary(toolCall.Name, input, output), + } +} +``` + +## Testing Strategy + +### Test Cases + +1. **Session with grep_search** + - Verify: Parameters extracted correctly + - Verify: Results shown with file paths + - Verify: Match count displayed + +2. **Session with apply_patch** + - Verify: Patch content displayed + - Verify: File path shown + - Verify: Diff formatting + +3. **Session with read_file** + - Verify: File path displayed + - Verify: Content shown (or truncated notice) + +4. **Session with multiple tools** + - Verify: Tools appear in correct order + - Verify: Each tool has correct parameters + - Verify: No tool invocations are lost + +5. **Session with hidden tools** + - Verify: Hidden tools don't appear in output + +## Success Criteria + +- ✅ Tool calls appear in markdown output +- ✅ At least 3 high-priority tools render correctly +- ✅ Tool parameters and results are extracted +- ✅ Tool sequence matches conversation flow +- ✅ Hidden tools are properly filtered out +- ✅ Generic tools (unknown types) render with basic info + +## Future Enhancements (Phase 3+) + +- Implement all medium-priority tools +- Add tool-specific markdown formatting +- Handle tool errors gracefully +- Support MCP tool variations +- Add tool summary generation +- Implement tool result streaming (for long outputs) diff --git a/specstory-cli/docs/CURSORIDE-PROVIDER.md b/specstory-cli/docs/CURSORIDE-PROVIDER.md new file mode 100644 index 00000000..a6b1850d --- /dev/null +++ b/specstory-cli/docs/CURSORIDE-PROVIDER.md @@ -0,0 +1,446 @@ +# Implementation Plan: Add Cursor IDE Provider (cursoride) + +## Overview + +Add a new provider called `cursoride` that reads Cursor's SQLite database directly (same as the extension does) to export conversations to the `.specstory` folder. This is phase 1 - focusing on database reading, conversation extraction, workspace filtering, and debug output. Tool export formatting differences will be addressed in later phases. + +## Quick Summary + +**Goal:** Port extension's Cursor database reading functionality to CLI as a new provider + +**Key Features:** +- Read from global Cursor database (`~/.cursor/extensions/.../state.vscdb`) +- Filter by workspace (matches Claude Code's project-scoped behavior) +- Export conversations to markdown in `.specstory/history/` +- Debug mode for raw data inspection + +**Technology:** +- `modernc.org/sqlite` (pure Go, no CGO) +- Workspace filtering via workspace metadata matching +- Standard SPI provider interface + +**Complexity:** Medium-High (workspace filtering adds complexity but matches Claude Code behavior) + +## Background + +The existing extension (in `ts-extension/`) reads from Cursor's SQLite database at `~/.cursor/extensions/cursor-context-manager-*/globalStorage/cursor-context-manager/state.vscdb`. We're porting this functionality to the CLI as a new provider. + +## Critical Files + +### New Files to Create +1. `pkg/providers/cursoride/provider.go` - Main provider implementation +2. `pkg/providers/cursoride/database.go` - SQLite database reading +3. `pkg/providers/cursoride/parser.go` - Parse Cursor native format to internal representation +4. `pkg/providers/cursoride/agent_session.go` - Convert to unified SessionData format +5. `pkg/providers/cursoride/types.go` - Go structs for Cursor data structures +6. `pkg/providers/cursoride/path_utils.go` - Cursor-specific path resolution + +### Files to Modify +1. `pkg/spi/factory/registry.go` - Register the new provider +2. `go.mod` - Add SQLite dependency (e.g., `modernc.org/sqlite` or `github.com/mattn/go-sqlite3`) + +## Implementation Steps + +### Step 1: Add SQLite Dependency +- **Decision needed:** Choose between CGO-free (`modernc.org/sqlite`) vs CGO-based (`github.com/mattn/go-sqlite3`) + - `modernc.org/sqlite`: Pure Go, easier cross-compilation, no CGO needed + - `github.com/mattn/go-sqlite3`: More mature, faster, requires CGO +- Add to `go.mod` + +### Step 2: Create Provider Structure +Create `pkg/providers/cursoride/provider.go`: +- Define `Provider` struct (empty struct, stateless) +- Implement `NewProvider()` constructor +- Implement `Name()` → return "Cursor IDE" +- Stub out all 6 required interface methods initially + +### Step 3: Path Resolution +Create `pkg/providers/cursoride/path_utils.go`: +- `GetGlobalDatabasePath()` → Find `~/.cursor/extensions/cursor-context-manager-*/globalStorage/cursor-context-manager/state.vscdb` +- `GetWorkspaceStoragePath()` → Find `~/Library/Application Support/Cursor/User/workspaceStorage/` (macOS) or equivalent +- `FindMatchingWorkspace(projectPath)` → Match projectPath to workspace directory (if Option B chosen) +- Use glob pattern to find versioned extension directory +- Handle case where database doesn't exist +- Check for WAL files (`.vscdb-wal`) + +### Step 4: Define Data Structures +Create `pkg/providers/cursoride/types.go` - Port TypeScript types to Go: +```go +type ComposerData struct { + ComposerID string `json:"composerId"` + Name string `json:"name,omitempty"` + Conversation []ComposerConversation `json:"conversation,omitempty"` + FullConversationHeadersOnly []ComposerConversationHeader `json:"fullConversationHeadersOnly"` + Capabilities []Capability `json:"capabilities,omitempty"` + CodeBlockData map[string][]CodeBlockData `json:"codeBlockData,omitempty"` + CreatedAt int64 `json:"createdAt"` + LastUpdatedAt int64 `json:"lastUpdatedAt,omitempty"` +} + +type ComposerConversation struct { + BubbleID string `json:"bubbleId"` + Type int `json:"type"` // 1=user, 2=assistant + Text string `json:"text"` + Thinking *ThinkingData `json:"thinking,omitempty"` + CapabilityType int `json:"capabilityType,omitempty"` // 15=tool + TimingInfo *TimingInfo `json:"timingInfo,omitempty"` + CodeBlocks []CodeBlockReference `json:"codeBlocks,omitempty"` + ModelInfo *ModelInfo `json:"modelInfo,omitempty"` + UnifiedMode int `json:"unifiedMode,omitempty"` // 1=Ask, 2=Agent, 5=Plan + ToolFormerData *ToolInvocationData `json:"toolFormerData,omitempty"` +} + +// ... other supporting types +``` + +### Step 5: Database Reading +Create `pkg/providers/cursoride/database.go`: +- `OpenDatabase(path string)` → Open SQLite in read-only mode +- `EnableWALMode(db)` → Enable WAL mode for non-blocking reads + - **Use cursorcli's simpler approach:** Just execute `PRAGMA journal_mode=WAL` + - Log warning if it fails but continue (non-fatal) + - Simpler than extension's reopen-in-readwrite approach + - Works because database is likely already in WAL mode +- `LoadComposerData(composerID string)` → Query for specific composer from global database +- `LoadAllComposerIDs()` → Query for all `composerData:*` keys from global database +- `LoadComposerDataBatch(composerIDs []string)` → Load multiple composers efficiently (used with workspace filtering) +- `LoadWorkspaceComposerRefs(workspaceDbPath)` → Load `composer.composerData.allComposers` from workspace database +- SQL queries (matching extension's implementation): + ```sql + -- Load single composer and its bubbles + SELECT key, value FROM cursorDiskKV + WHERE value IS NOT NULL AND ( + key = 'composerData:' || ? + OR key LIKE 'bubbleId:' || ? || ':%' + ) + + -- Load multiple composers (for workspace filtering) + -- Uses IN clause for composerData (indexed) and LIKE for bubbleId (prefix match, indexed) + SELECT key, value FROM cursorDiskKV + WHERE value IS NOT NULL AND ( + key IN ('composerData:id1', 'composerData:id2', ...) + OR (key LIKE 'bubbleId:id1:%' OR key LIKE 'bubbleId:id2:%' ...) + ) + + -- Load all composer IDs (for detection/listing) + SELECT key FROM cursorDiskKV + WHERE key LIKE 'composerData:%' + + -- Load workspace composer refs + SELECT key, value FROM ItemTable + WHERE key = 'composer.composerData' + ``` +- Parse JSON values into Go structs +- **Important:** Skip checkpoint, messageRequestContext, codeBlockDiff keys (not needed for rendering, per extension) + +### Step 6: Workspace Matching +Create `pkg/providers/cursoride/workspace.go`: +- `FindWorkspaceForProject(projectPath string)` → Find matching workspace directory +- `GetWorkspaceStoragePath()` → Get macOS/Linux workspace storage location + - macOS: `~/Library/Application Support/Cursor/User/workspaceStorage/` + - Linux: `~/.config/Cursor/User/workspaceStorage/` +- `MatchWorkspaceURI(projectPath, workspaceURI string)` → Compare paths accounting for symlinks +- `LoadWorkspaceComposerIDs(workspaceDbPath)` → Read `composer.composerData.allComposers[]` from workspace DB +- Handle errors gracefully: + - Workspace storage doesn't exist + - No matching workspace found + - Workspace database missing or corrupted + - No composer data in workspace + +### Step 7: Parse Conversations +Create `pkg/providers/cursoride/parser.go`: +- `ParseComposerData(jsonStr string)` → Unmarshal JSON to ComposerData +- `LoadFullConversation(composerID string, db *sql.DB)` → Load composer + all bubbles +- `AssembleConversation(composer ComposerData, bubbles map[string]ComposerConversation)` → Build complete conversation +- Handle both conversation array and bubble loading patterns +- Skip empty conversations (no messages) + +### Step 8: Convert to Unified Format +Create `pkg/providers/cursoride/agent_session.go`: +- `ConvertToSessionData(composer ComposerData, bubbles map[string]ComposerConversation)` → Build `spi.SessionData` +- Map Cursor message types to unified format: + - type 1 → role "user" + - type 2 → role "agent" +- Extract tool invocations from capabilityType=15 and toolFormerData +- Build exchanges array (user message + assistant response pairs) +- For now, use simple tool classification (defer detailed tool mapping to phase 2) +- Generate slug from conversation name or first user message +- Format timestamps as ISO 8601 + +### Step 9: Implement Core Provider Methods + +In `pkg/providers/cursoride/provider.go`: + +1. **Check(customCommand):** + - Verify database file exists at expected path + - Try to open database in read-only mode + - Return version info (could query database schema version or Cursor version) + - Return CheckResult with success/error + +2. **DetectAgent(projectPath, helpOutput):** + - Check if global database exists + - Check if workspace storage directory exists + - Try to match projectPath to a workspace (use workspace matching logic) + - If match found, check if that workspace has any composer IDs + - Return true if workspace found with composer data + - If helpOutput=true, print guidance about: + - Global database location + - Workspace storage location + - Expected workspace path + +3. **GetAgentChatSession(projectPath, sessionID, debugRaw):** + - Open database + - Load specific composer by ID (sessionID = composerID) + - Parse conversation and bubbles + - Convert to AgentChatSession + - If debugRaw=true, write raw composer JSON to `.specstory/debug//raw-composer.json` + - Return single session + +4. **GetAgentChatSessions(projectPath, debugRaw):** + - Find workspace storage path (`~/Library/Application Support/Cursor/User/workspaceStorage/`) + - Match projectPath to workspace: + - Read each workspace directory's `workspace.json` + - Compare workspace URI with projectPath + - Find matching workspace directory + - Open workspace database (`/state.vscdb`) + - Load composer IDs from `composer.composerData.allComposers` + - Open global composer database + - For each composer ID, load full conversation from global database + - Filter out empty conversations + - Convert each to AgentChatSession + - If debugRaw=true, write debug files for each + - Return array of sessions (workspace-filtered) + +5. **ExecAgentAndWatch(...):** + - Return error: "Cursor IDE does not support execution via CLI" + - This provider is read-only (IDE-based, not CLI-based) + +6. **WatchAgent(ctx, projectPath, debugRaw, callback):** + - Use fsnotify to watch database file and WAL file + - On file changes, reload all sessions + - Invoke callback for new/updated sessions + - Support graceful shutdown via context + - Debounce rapid changes (database writes can trigger multiple events) + +### Step 10: Register Provider +Modify `pkg/spi/factory/registry.go`: +```go +import ( + // ... existing imports ... + "github.com/specstoryai/SpecStoryCLI/pkg/providers/cursoride" +) + +func (r *Registry) registerAll() { + // ... existing registrations ... + + cursorideProvider := cursoride.NewProvider() + r.providers["cursoride"] = cursorideProvider + slog.Debug("Registered provider", "id", "cursoride", "name", cursorideProvider.Name()) + + r.initialized = true +} +``` + +### Step 11: Debug Output Support + +When `--debug-raw` flag is passed: +- Write to `.specstory/debug//` +- Files to write: + - `raw-composer.json` - Raw composer data from database + - `bubbles.json` - All bubble data + - `session-data.json` - Unified SessionData format (handled by central CLI) +- Use `spi.WriteDebugSessionData()` utility + +## Usage Examples + +```zsh +# Check if Cursor IDE database exists +./specstory check cursoride + +# Sync all Cursor IDE conversations +./specstory sync cursoride + +# Sync specific conversation +./specstory sync cursoride -u + +# Debug mode - see raw data +./specstory sync cursoride --debug-raw + +# Watch mode - monitor for new conversations +./specstory watch cursoride +``` + +## Phase 1 Scope (This Plan) + +**In Scope:** +- ✅ Database reading from SQLite (global + workspace) +- ✅ Workspace matching and filtering +- ✅ Conversation parsing (composer + bubbles) +- ✅ Basic tool invocation detection (capabilityType=15) +- ✅ Conversion to unified SessionData +- ✅ Debug output support +- ✅ Provider registration +- ✅ WAL mode handling + +**Out of Scope (Future Phases):** +- ❌ Detailed tool output formatting differences +- ❌ Code block diff rendering +- ❌ Capability data parsing +- ❌ Tool-specific markdown formatting +- ❌ Multi-version ComposerData support (V1 vs V3) +- ❌ Checkpoint data handling +- ❌ messageRequestContext parsing +- ❌ codeBlockDiff data + +## Important Implementation Notes + +### Database Access Patterns +1. **WAL Mode:** Both extension and cursorcli enable WAL mode for non-blocking reads + - **Use cursorcli's approach:** Simply execute `PRAGMA journal_mode=WAL` and treat failure as non-fatal + - Extension uses more complex reopen-in-readwrite approach, but cursorcli's simpler method works fine + - Purpose: Prevents readers from blocking Cursor IDE's writers to the database +2. **Two Database Types:** Global database uses `cursorDiskKV` table, workspace databases use `ItemTable` +3. **Key Filtering:** Extension explicitly skips `checkpoint*`, `messageRequestContext*`, `codeBlockDiff*` keys - we should too +4. **Query Optimization:** Use IN clause for exact matches, LIKE with trailing % for prefix matches (both use index) + +### Workspace Matching Challenges +1. **Symlinks:** Must use canonical paths for matching (like cursorcli does with MD5) +2. **Multiple Workspaces:** User may have same project opened in multiple workspace IDs - use newest +3. **Workspace Types:** Can be single folder or multi-root workspace file +4. **OS-Specific Paths:** + - macOS: `~/Library/Application Support/Cursor/User/workspaceStorage/` + - Linux: `~/.config/Cursor/User/workspaceStorage/` + +### Error Handling +1. **Missing Databases:** Graceful failure if global DB or workspace storage doesn't exist +2. **No Workspace Match:** Clear error message explaining workspace detection +3. **Empty Conversations:** Filter out composers with no conversation messages +4. **Corrupted Data:** Skip malformed JSON, log warning, continue processing other composers +5. **Large Databases:** Extension has retry logic with delays - consider similar approach + +## Testing Strategy + +Manual testing approach (no unit tests initially per project conventions): +1. Create test database with sample conversations +2. Test `check` command - verify database detection +3. Test `sync` command - verify conversation export +4. Test `--debug-raw` - verify debug files are written +5. Verify markdown files are created in `.specstory/history/` +6. Test with empty database +7. Test with missing database + +## Verification Checklist + +After implementation: +- [X] `./specstory check cursoride` successfully finds database +- [X] `./specstory sync cursoride` exports conversations to `.specstory/history/` +- [X] `./specstory sync cursoride --debug-raw` creates debug files +- [X] Markdown files have correct format (timestamps, speakers, messages) +- [ ] Tool invocations are detected (basic level) +- [ ] Empty conversations are filtered out +- [ ] Provider appears in help text for all commands +- [ ] No errors with missing database (graceful failure) + +## Provider Comparison: cursorcli vs cursoride + +### Overview +**Important:** `cursorcli` and `cursoride` are **completely different systems** for different Cursor products: +- **cursorcli** = Cursor CLI (command-line agent tool) +- **cursoride** = Cursor IDE (VS Code-based editor with Composer) + +### Detailed Comparison + +| Aspect | cursorcli (Cursor CLI) | cursoride (Cursor IDE) | +|--------|------------------------|------------------------| +| **Product** | Cursor CLI agent (`cursor-agent` command) | Cursor IDE (VS Code fork) | +| **Data Location** | `~/.cursor/chats///store.db` | Global: `~/.cursor/extensions/.../state.vscdb`
Workspace: `~/Library/.../workspaceStorage/` | +| **Database Structure** | One SQLite DB per session | Single global SQLite DB for all composers | +| **Database Schema** | `blobs` table (id, data)
`meta` table (key, value) | Global DB: `cursorDiskKV` (key, value)
Workspace DB: `ItemTable` (key, value)
Keys: `composerData:*`, `bubbleId:*` | +| **Project Matching** | MD5 hash of canonical project path | Workspace URI matching via `workspace.json` | +| **Project Scoping** | Directory-based (one hash dir per project) | Workspace filtering (IDs in workspace DB) | +| **SQLite Library** | `modernc.org/sqlite` (pure Go) | `modernc.org/sqlite` (pure Go) - **Same!** | +| **Execution** | ✅ Can execute `cursor-agent` CLI | ❌ Cannot execute (IDE-based) | +| **Watch Mode** | Watches `store.db` files in session dirs | Watches global database + WAL file | +| **Session Format** | Blob records with DAG structure | Composer data with conversation arrays | +| **Message Storage** | Blobs with JSON data field | Separate bubble records for messages | +| **Tool Invocations** | Embedded in blob data | `toolFormerData`, `capabilityType=15` | +| **CLI Already Exists** | ✅ Yes (provider: `cursorcli`) | ❌ No (what we're building) | + +### Key Architectural Differences + +**cursorcli (Existing):** +``` +~/.cursor/chats/ + └── a1b2c3d4.../ (MD5 hash of /Users/bago/code/project) + ├── session-uuid-1/ + │ └── store.db (SQLite: blobs, meta tables) + └── session-uuid-2/ + └── store.db +``` +- **Project matching:** Calculate MD5(canonical_project_path), look for that directory +- **Simple:** One DB per session, inherently project-scoped +- **Execution:** Can launch `cursor-agent` command + +**cursoride (What We're Building):** +``` +~/.cursor/extensions/cursor-context-manager-*/ + └── globalStorage/cursor-context-manager/ + └── state.vscdb (ALL composer data) + +~/Library/Application Support/Cursor/User/workspaceStorage/ + ├── workspace-id-1/ + │ ├── workspace.json (contains workspace URI) + │ └── state.vscdb (composer.composerData.allComposers: [...]) + └── workspace-id-2/ + ├── workspace.json + └── state.vscdb +``` +- **Project matching:** Find workspace where URI matches projectPath, load composer IDs from workspace DB, query global DB +- **Complex:** Global DB + workspace filtering required +- **Execution:** Cannot launch (IDE-based, not CLI) + +### Why We Need Both Providers + +These are fundamentally different systems: +1. **Different use cases:** CLI tool vs IDE +2. **Different data formats:** Blob-based vs Composer-based +3. **Different storage:** Per-session files vs global database +4. **Different tools:** Can execute vs read-only + +Users may use both Cursor CLI and Cursor IDE on the same project, so SpecStory CLI needs to support both! + +## Decisions + +1. **SQLite Library:** `modernc.org/sqlite` + - Pure Go implementation (no CGO) + - Easier cross-compilation for macOS/Linux + - Better for distribution and CI/CD + - **Already used by `cursorcli` provider** - proven to work well + +2. **Workspace Filtering:** Implement workspace-scoped filtering (matches Claude Code behavior) + - Read workspace metadata from `~/Library/Application Support/Cursor/User/workspaceStorage/` + - Match projectPath to workspace by comparing URIs in `workspace.json` files + - Load composer IDs from that workspace's `state.vscdb` + - Filter global database queries to only those composer IDs + - Properly project-scoped like Claude Code + +3. **Session ID Mapping:** Use composerID directly as sessionID + - Maintains compatibility with extension + - Simple mapping, no translation needed + +## Dependencies + +- `modernc.org/sqlite` - Pure Go SQLite library (no CGO) +- Existing SPI infrastructure +- fsnotify (already in use for claudecode) +- Standard library: encoding/json, path/filepath, os + +## Success Criteria + +- New `cursoride` provider is registered and available +- Can read Cursor SQLite database +- Can parse conversations from database +- Can export conversations to markdown files +- Debug mode shows raw data structures +- No crashes or panics with missing/invalid data +- Graceful error messages for common issues diff --git a/specstory-cli/pkg/providers/claudecode/provider.go b/specstory-cli/pkg/providers/claudecode/provider.go index 65930ded..1af34a90 100644 --- a/specstory-cli/pkg/providers/claudecode/provider.go +++ b/specstory-cli/pkg/providers/claudecode/provider.go @@ -493,6 +493,23 @@ func isSyntheticMessage(content string) bool { return false } +// cleanSyntheticPrefixes strips known boilerplate prefixes that Claude Code +// prepends to user messages (e.g. plan mode). The real user content follows +// the prefix, so we strip rather than skip the message entirely. +func cleanSyntheticPrefixes(content string) string { + prefixes := []string{ + "Implement the following plan:", + } + trimmed := strings.TrimSpace(content) + lower := strings.ToLower(trimmed) + for _, prefix := range prefixes { + if strings.HasPrefix(lower, strings.ToLower(prefix)) { + return strings.TrimSpace(trimmed[len(prefix):]) + } + } + return content +} + // extractContentText extracts plain text from a message content field. // The content field may be either a plain string or a JSON array of typed content // blocks (e.g. [{type:"text",text:"..."},{type:"tool_use",...}]). @@ -545,7 +562,7 @@ func findFirstUserMessage(session Session) string { if isSyntheticMessage(content) { continue } - return content + return cleanSyntheticPrefixes(content) } } } @@ -692,7 +709,7 @@ func extractSessionMetadata(filePath string) (*spi.SessionMetadata, error) { if content != "" { // Skip synthetic messages (warmup, title generation prompts, etc.) if !isSyntheticMessage(content) { - firstUserMessage = content + firstUserMessage = cleanSyntheticPrefixes(content) } } } diff --git a/specstory-cli/pkg/providers/claudecode/provider_test.go b/specstory-cli/pkg/providers/claudecode/provider_test.go index 7ea7c7d7..5f943e24 100644 --- a/specstory-cli/pkg/providers/claudecode/provider_test.go +++ b/specstory-cli/pkg/providers/claudecode/provider_test.go @@ -228,6 +228,22 @@ func TestFindFirstUserMessage(t *testing.T) { session: Session{Records: []JSONLRecord{}}, expected: "", }, + { + name: "Strips 'Implement the following plan:' prefix", + session: Session{ + Records: []JSONLRecord{ + { + Data: map[string]interface{}{ + "type": "user", + "message": map[string]interface{}{ + "content": "Implement the following plan:\n\n# Plan: Replace Gemini Watcher Polling with fsnotify", + }, + }, + }, + }, + }, + expected: "# Plan: Replace Gemini Watcher Polling with fsnotify", + }, } for _, tt := range tests { @@ -240,6 +256,50 @@ func TestFindFirstUserMessage(t *testing.T) { } } +// TestCleanSyntheticPrefixes tests stripping of boilerplate prefixes +func TestCleanSyntheticPrefixes(t *testing.T) { + tests := []struct { + name string + content string + expected string + }{ + { + name: "Strips plan mode prefix", + content: "Implement the following plan:\n\n# Plan: Replace Gemini Watcher", + expected: "# Plan: Replace Gemini Watcher", + }, + { + name: "Case insensitive matching", + content: "implement the following plan:\n\nDo the thing", + expected: "Do the thing", + }, + { + name: "Leaves normal messages unchanged", + content: "Fix the login bug on the dashboard", + expected: "Fix the login bug on the dashboard", + }, + { + name: "Handles prefix with leading whitespace", + content: " Implement the following plan:\n\nSome plan", + expected: "Some plan", + }, + { + name: "Returns empty string if only prefix", + content: "Implement the following plan:", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := cleanSyntheticPrefixes(tt.content) + if result != tt.expected { + t.Errorf("cleanSyntheticPrefixes(%q) = %q, want %q", tt.content, result, tt.expected) + } + }) + } +} + // TestConvertToAgentChatSession tests the conversion of Session to AgentChatSession // with warmup filtering and debugRaw handling func TestConvertToAgentChatSession(t *testing.T) { diff --git a/specstory-cli/pkg/providers/copilotide/agent_session.go b/specstory-cli/pkg/providers/copilotide/agent_session.go new file mode 100644 index 00000000..60c411dc --- /dev/null +++ b/specstory-cli/pkg/providers/copilotide/agent_session.go @@ -0,0 +1,553 @@ +package copilotide + +import ( + "encoding/json" + "fmt" + "log/slog" + "strings" + + "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi" + "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi/schema" +) + +// ConvertToSessionData converts VS Code raw format to CLI's unified schema +func ConvertToSessionData(composer VSCodeComposer, projectPath string, state *VSCodeStateFile) spi.AgentChatSession { + // Format timestamps + createdAt := FormatTimestamp(composer.CreationDate) + updatedAt := FormatTimestamp(composer.LastMessageDate) + + // Generate slug + slug := GenerateSlug(composer) + + // Handle editing-only sessions (no chat requests but has file operations) + requests := composer.Requests + if len(requests) == 0 && state != nil { + syntheticRequests := createSyntheticRequestsFromEditingState(composer, state) + if len(syntheticRequests) > 0 { + requests = syntheticRequests + } + } + + // Build SessionData + sessionData := &schema.SessionData{ + SchemaVersion: "1.0", + Provider: schema.ProviderInfo{ + ID: "copilotide", + Name: "VS Code Copilot IDE", + Version: "1.0", + }, + SessionID: composer.SessionID, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + Slug: slug, + WorkspaceRoot: projectPath, + Exchanges: ConvertRequestsToExchanges(requests), + } + + // Marshal to JSON for raw data + rawDataJSON, err := json.Marshal(composer) + if err != nil { + slog.Warn("Failed to marshal raw data", "sessionId", composer.SessionID, "error", err) + rawDataJSON = []byte("{}") + } + + return spi.AgentChatSession{ + SessionID: composer.SessionID, + CreatedAt: createdAt, + Slug: slug, + SessionData: sessionData, + RawData: string(rawDataJSON), + } +} + +// ConvertRequestsToExchanges converts VS Code request blocks to exchanges +func ConvertRequestsToExchanges(requests []VSCodeRequestBlock) []schema.Exchange { + var exchanges []schema.Exchange + + for _, req := range requests { + exchange := schema.Exchange{ + ExchangeID: req.RequestID, + StartTime: FormatTimestamp(req.Timestamp), + Messages: ConvertRequestToMessages(req), + } + exchanges = append(exchanges, exchange) + } + + return exchanges +} + +// ConvertRequestToMessages converts one request block to message array +// Returns: [user message, thinking message (if present), tool message(s), agent message] +func ConvertRequestToMessages(req VSCodeRequestBlock) []schema.Message { + var messages []schema.Message + + // 1. User message with timestamp + userMsg := schema.Message{ + Role: schema.RoleUser, + Timestamp: FormatTimestamp(req.Timestamp), + Content: []schema.ContentPart{ + {Type: schema.ContentTypeText, Text: req.Message.Text}, + }, + } + messages = append(messages, userMsg) + + // Check if there are tool calls + hasToolCalls := HasToolCalls(req.Result.Metadata) + + // 2. Extract thinking from tool call rounds (only if there are actual tool calls) + if hasToolCalls { + thinking := ExtractThinkingFromMetadata(req.Result.Metadata) + if thinking != "" { + thinkingMsg := schema.Message{ + Role: schema.RoleAgent, + Model: req.ModelID, + Content: []schema.ContentPart{ + {Type: schema.ContentTypeThinking, Text: thinking}, + }, + } + messages = append(messages, thinkingMsg) + } + } + + // 3. Parse responses and extract tool calls + toolMessages := ParseResponsesForTools(req.Response, req.Result.Metadata, req.ModelID) + messages = append(messages, toolMessages...) + + // 4. Final agent text message + // Try multiple sources in order of preference + var finalText string + + // First try: metadata.messages (if present) + finalText = ExtractFinalAgentMessage(req.Result.Metadata) + + // Second try: toolCallRounds response when no tool calls (this is the actual response) + if finalText == "" && !hasToolCalls { + finalText = ExtractResponseFromToolCallRounds(req.Result.Metadata) + } + + // Third try: response array value field + if finalText == "" { + finalText = ExtractTextFromResponseArray(req.Response) + } + + if finalText != "" { + agentMsg := schema.Message{ + Role: schema.RoleAgent, + Content: []schema.ContentPart{ + {Type: schema.ContentTypeText, Text: finalText}, + }, + Model: req.ModelID, + } + messages = append(messages, agentMsg) + } + + return messages +} + +// ParseResponsesForTools extracts tool invocations from response array +func ParseResponsesForTools(responses []json.RawMessage, metadata VSCodeResultMetadata, modelID string) []schema.Message { + var toolMessages []schema.Message + + // Build ordered sequence of tool calls from metadata + toolCallSequence := BuildToolCallSequence(metadata) + + // Track sequence index for matching + sequenceIndex := 0 + + // Process each response + for _, rawResp := range responses { + // Parse "kind" field first + kind, err := ParseResponseKind(rawResp) + if err != nil { + slog.Debug("Failed to parse response kind", "error", err) + continue + } + + switch kind { + case "toolInvocationSerialized": + var invocation VSCodeToolInvocationResponse + if err := json.Unmarshal(rawResp, &invocation); err != nil { + slog.Debug("Failed to parse tool invocation", "error", err) + continue + } + + // Skip hidden tools (don't increment sequence index for hidden tools) + if invocation.Presentation == "hidden" { + continue + } + + // Match by sequence: get the next tool call from the ordered list + if sequenceIndex >= len(toolCallSequence) { + slog.Debug("Tool invocation has no matching tool call in sequence", + "sequenceIndex", sequenceIndex, + "totalToolCalls", len(toolCallSequence), + "toolCallId", invocation.ToolCallID) + continue + } + + toolCall := toolCallSequence[sequenceIndex] + sequenceIndex++ + + slog.Debug("Matched tool by sequence", + "sequenceIndex", sequenceIndex-1, + "toolName", toolCall.Name, + "invocationId", invocation.ToolCallID, + "metadataId", toolCall.ID) + + // Build tool info using the matched tool call + toolInfo := BuildToolInfoFromInvocation(invocation, toolCall, metadata.ToolCallResults) + if toolInfo != nil { + toolMsg := schema.Message{ + Role: schema.RoleAgent, + Model: modelID, + Tool: toolInfo, + } + toolMessages = append(toolMessages, toolMsg) + } + + // Handle other response types (textEditGroup, codeblockUri, etc.) + // Can defer detailed handling to phase 2 + case "textEditGroup": + slog.Debug("Skipping textEditGroup response (phase 2)", "kind", kind) + case "codeblockUri": + slog.Debug("Skipping codeblockUri response (phase 2)", "kind", kind) + case "confirmation": + slog.Debug("Skipping confirmation response (phase 2)", "kind", kind) + case "inlineReference": + slog.Debug("Skipping inlineReference response (phase 2)", "kind", kind) + default: + slog.Debug("Unknown response kind", "kind", kind) + } + } + + return toolMessages +} + +// BuildToolInfoFromInvocation creates ToolInfo from VS Code invocation + tool call +// Uses sequence-based matching: toolCall is passed directly instead of looked up by ID +func BuildToolInfoFromInvocation( + invocation VSCodeToolInvocationResponse, + toolCall VSCodeToolCallInfo, + toolResults map[string]VSCodeToolCallResult, +) *schema.ToolInfo { + toolInfo := &schema.ToolInfo{ + Name: toolCall.Name, + Type: MapToolType(toolCall.Name), + UseID: invocation.ToolCallID, + } + + // Parse arguments if present + if toolCall.Arguments != "" { + var args map[string]any + if err := json.Unmarshal([]byte(toolCall.Arguments), &args); err == nil { + toolInfo.Input = args + } + } + + // Add output from results map + // Note: We still look up results by invocation.ToolCallID since that's the VS Code ID + if result, ok := toolResults[invocation.ToolCallID]; ok { + output := make(map[string]any) + if len(result.Content) > 0 { + var contentParts []string + for _, content := range result.Content { + // Value can be string or object - convert to string + valueStr := valueToString(content.Value) + if valueStr != "" { + contentParts = append(contentParts, valueStr) + } + } + if len(contentParts) > 0 { + output["result"] = strings.Join(contentParts, "\n") + } + } + if len(output) > 0 { + toolInfo.Output = output + } + } + + return toolInfo +} + +// valueToString converts a tool result value (which can be string or object) to string +func valueToString(value any) string { + if value == nil { + return "" + } + + // Try string first + if str, ok := value.(string); ok { + return str + } + + // If it's an object, marshal to JSON + jsonBytes, err := json.Marshal(value) + if err != nil { + return "" + } + return string(jsonBytes) +} + +// MapToolType maps VS Code Copilot tool names to schema.ToolType constants +func MapToolType(toolName string) string { + // Handle MCP tools (any tool starting with "mcp_") + // Note: MCP tools use generic type until schema.ToolTypeMCP is added + if strings.HasPrefix(toolName, "mcp_") { + return schema.ToolTypeGeneric + } + + mapping := map[string]string{ + // VS Code Copilot tools (OpenAI API names) + "grep_search": schema.ToolTypeSearch, + "apply_patch": schema.ToolTypeWrite, + "read_file": schema.ToolTypeRead, + "insert_edit_into_file": schema.ToolTypeWrite, + "create_file": schema.ToolTypeWrite, + "file_search": schema.ToolTypeSearch, + "semantic_search": schema.ToolTypeSearch, + "list_dir": schema.ToolTypeGeneric, + "manage_todo_list": schema.ToolTypeTask, + "get_errors": schema.ToolTypeGeneric, + + // Legacy tool names (kept for compatibility) + "bash": schema.ToolTypeShell, + "search_files": schema.ToolTypeSearch, + "write_to_file": schema.ToolTypeWrite, + "str_replace_editor": schema.ToolTypeWrite, + "list_files": schema.ToolTypeSearch, + "grep": schema.ToolTypeSearch, + "find": schema.ToolTypeSearch, + } + + if toolType, ok := mapping[toolName]; ok { + return toolType + } + + slog.Debug("Unknown tool type, mapping to generic", "toolName", toolName) + return schema.ToolTypeGeneric +} + +// createSyntheticRequestsFromEditingState creates synthetic request blocks from editing operations +// when there are no chat requests but file operations exist +func createSyntheticRequestsFromEditingState(composer VSCodeComposer, state *VSCodeStateFile) []VSCodeRequestBlock { + if state == nil { + return nil + } + + // Detect state version + version := state.Version + if version == 0 { + version = 1 // Default to version 1 + } + + slog.Debug("Processing editing state", "version", version, "sessionId", composer.SessionID) + + var fileOperationSummaries []string + + if version >= 2 { + // Version 2: Extract from timeline.operations + fileOperationSummaries = extractOperationsFromV2State(state) + slog.Debug("Extracted operations from v2 state", "count", len(fileOperationSummaries)) + } else { + // Version 1: Extract from recentSnapshot/pendingSnapshot + fileOperationSummaries = extractOperationsFromV1State(state) + slog.Debug("Extracted operations from v1 state", "count", len(fileOperationSummaries)) + } + + // Fallback: If we couldn't extract operations but state exists, show generic message + if len(fileOperationSummaries) == 0 { + // Check if there's any indication of editing activity + hasRecentSnapshot := state.RecentSnapshot != nil + hasPendingSnapshot := state.PendingSnapshot != nil + hasTimeline := state.Timeline != nil + + if !hasRecentSnapshot && !hasPendingSnapshot && !hasTimeline { + return nil // No editing activity detected + } + + fileOperationSummaries = []string{"File editing session (details not available)"} + } + + // Get user input text if available (from customTitle) + userText := composer.CustomTitle + if userText == "" { + userText = "File editing session" + } + + // Build synthetic text for assistant message + var assistantText string + if len(fileOperationSummaries) > 0 { + plural := "" + if len(fileOperationSummaries) > 1 { + plural = "s" + } + assistantText = fmt.Sprintf("Performed %d file operation%s:\n\n%s", + len(fileOperationSummaries), + plural, + strings.Join(fileOperationSummaries, "\n")) + } else { + assistantText = "Performed file editing operations" + } + + // Create synthetic request block + syntheticRequest := VSCodeRequestBlock{ + RequestID: composer.SessionID + "-synthetic", + Timestamp: composer.CreationDate, + Message: VSCodeMessage{ + Text: userText, + }, + Response: []json.RawMessage{}, + Result: VSCodeResult{ + Metadata: VSCodeResultMetadata{ + Messages: []VSCodeMetadataMessage{ + { + Role: "assistant", + Content: assistantText, + }, + }, + }, + }, + ModelID: composer.ResponderUsername, + } + + return []VSCodeRequestBlock{syntheticRequest} +} + +// extractOperationsFromV2State extracts file operations from version 2 state format (timeline.operations) +func extractOperationsFromV2State(state *VSCodeStateFile) []string { + if state.Timeline == nil || len(state.Timeline.Operations) == 0 { + return nil + } + + var summaries []string + for _, op := range state.Timeline.Operations { + var fileName string + if op.URI != nil { + // Extract file name from URI + path := op.URI.FSPath + if path == "" { + path = op.URI.Path + } + if path != "" { + parts := strings.Split(path, "/") + fileName = parts[len(parts)-1] + } else { + fileName = "unknown file" + } + } else { + fileName = "unknown file" + } + + switch op.Type { + case "create": + summaries = append(summaries, fmt.Sprintf("Created file: `%s`", fileName)) + case "textEdit": + editCount := len(op.Edits) + if editCount == 0 { + editCount = 1 + } + plural := "" + if editCount > 1 { + plural = "s" + } + summaries = append(summaries, fmt.Sprintf("Edited `%s` (%d edit%s)", fileName, editCount, plural)) + case "delete": + summaries = append(summaries, fmt.Sprintf("Deleted file: `%s`", fileName)) + default: + summaries = append(summaries, fmt.Sprintf("%s: `%s`", op.Type, fileName)) + } + } + + return summaries +} + +// extractOperationsFromV1State extracts file operations from version 1 state format (recentSnapshot/pendingSnapshot) +func extractOperationsFromV1State(state *VSCodeStateFile) []string { + filesSummary := make(map[string]bool) + + // Try recentSnapshot (can be array or object) + if state.RecentSnapshot != nil { + entries := extractEntriesFromSnapshot(state.RecentSnapshot) + for _, entry := range entries { + if fileName := extractFileNameFromEntry(entry); fileName != "" { + filesSummary[fmt.Sprintf("Modified `%s`", fileName)] = true + } + } + } + + // Try pendingSnapshot + if state.PendingSnapshot != nil { + entries := extractEntriesFromSnapshot(state.PendingSnapshot) + for _, entry := range entries { + if fileName := extractFileNameFromEntry(entry); fileName != "" { + filesSummary[fmt.Sprintf("Modified `%s`", fileName)] = true + } + } + } + + // Convert map to slice + var summaries []string + for summary := range filesSummary { + summaries = append(summaries, summary) + } + + return summaries +} + +// extractEntriesFromSnapshot extracts entries from a snapshot (handles both array and object formats) +func extractEntriesFromSnapshot(snapshot any) []VSCodeStopEntry { + if snapshot == nil { + return nil + } + + var entries []VSCodeStopEntry + + // Try to unmarshal as VSCodeStop object + if stopMap, ok := snapshot.(map[string]any); ok { + if entriesData, ok := stopMap["entries"].([]any); ok { + for _, entryData := range entriesData { + if entryMap, ok := entryData.(map[string]any); ok { + if resource, ok := entryMap["resource"].(string); ok { + entries = append(entries, VSCodeStopEntry{Resource: resource}) + } + } + } + return entries + } + } + + // Try to unmarshal as array of VSCodeStop objects + if stopsArray, ok := snapshot.([]any); ok { + for _, stopData := range stopsArray { + if stopMap, ok := stopData.(map[string]any); ok { + if entriesData, ok := stopMap["entries"].([]any); ok { + for _, entryData := range entriesData { + if entryMap, ok := entryData.(map[string]any); ok { + if resource, ok := entryMap["resource"].(string); ok { + entries = append(entries, VSCodeStopEntry{Resource: resource}) + } + } + } + } + } + } + } + + return entries +} + +// extractFileNameFromEntry extracts the file name from an entry object +func extractFileNameFromEntry(entry VSCodeStopEntry) string { + if entry.Resource == "" { + return "" + } + + // Handle both URI strings and file paths + resource := entry.Resource + parts := strings.Split(resource, "/") + if len(parts) > 0 { + return parts[len(parts)-1] + } + + return resource +} diff --git a/specstory-cli/pkg/providers/copilotide/loader.go b/specstory-cli/pkg/providers/copilotide/loader.go new file mode 100644 index 00000000..26e2e63a --- /dev/null +++ b/specstory-cli/pkg/providers/copilotide/loader.go @@ -0,0 +1,321 @@ +package copilotide + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" +) + +// LoadAllSessionFiles returns paths to all session JSON files in the workspace +func LoadAllSessionFiles(workspaceDir string) ([]string, error) { + chatSessionsPath := GetChatSessionsPath(workspaceDir) + + // Check if directory exists + if _, err := os.Stat(chatSessionsPath); os.IsNotExist(err) { + return nil, fmt.Errorf("chatSessions directory not found: %s", chatSessionsPath) + } + + files, err := os.ReadDir(chatSessionsPath) + if err != nil { + return nil, fmt.Errorf("failed to read chat sessions directory: %w", err) + } + + var sessionFiles []string + for _, file := range files { + if file.IsDir() { + continue + } + + // Include both .json and .jsonl files + if !strings.HasSuffix(file.Name(), ".json") && !strings.HasSuffix(file.Name(), ".jsonl") { + continue + } + + sessionPath := filepath.Join(chatSessionsPath, file.Name()) + sessionFiles = append(sessionFiles, sessionPath) + } + + slog.Debug("Found session files", "count", len(sessionFiles), "workspace", workspaceDir) + return sessionFiles, nil +} + +// LoadSessionFile reads and parses a single session JSON or JSONL file +func LoadSessionFile(sessionPath string) (*VSCodeComposer, error) { + data, err := os.ReadFile(sessionPath) + if err != nil { + return nil, fmt.Errorf("failed to read session file: %w", err) + } + + // Determine format based on file extension + isJSONL := strings.HasSuffix(sessionPath, ".jsonl") + + var composer VSCodeComposer + + if isJSONL { + // JSONL format: each line is a separate JSON object + composer, err = parseJSONL(data) + if err != nil { + return nil, fmt.Errorf("failed to parse JSONL: %w", err) + } + } else { + // JSON format: single JSON object + if err := json.Unmarshal(data, &composer); err != nil { + return nil, fmt.Errorf("failed to parse session JSON: %w", err) + } + } + + slog.Debug("Loaded session file", + "sessionId", composer.SessionID, + "requestCount", len(composer.Requests), + "isJSONL", isJSONL) + + return &composer, nil +} + +// LoadSessionByID loads a specific session by ID from the workspace +func LoadSessionByID(workspaceDir, sessionID string) (*VSCodeComposer, error) { + chatSessionsPath := GetChatSessionsPath(workspaceDir) + + // Try .jsonl first (newer format), then fall back to .json (older format) + jsonlPath := filepath.Join(chatSessionsPath, sessionID+".jsonl") + jsonPath := filepath.Join(chatSessionsPath, sessionID+".json") + + var sessionPath string + if _, err := os.Stat(jsonlPath); err == nil { + sessionPath = jsonlPath + } else if _, err := os.Stat(jsonPath); err == nil { + sessionPath = jsonPath + } else { + return nil, fmt.Errorf("session not found: %s", sessionID) + } + + return LoadSessionFile(sessionPath) +} + +// LoadStateFile loads the optional state file for a session +// Returns nil if the state file doesn't exist (not an error) +func LoadStateFile(workspaceDir, sessionID string) (*VSCodeStateFile, error) { + statePath := GetStateFilePath(workspaceDir, sessionID) + + // State file is optional + if _, err := os.Stat(statePath); os.IsNotExist(err) { + slog.Debug("No state file found (optional)", "sessionId", sessionID, "path", statePath) + return nil, nil + } + + data, err := os.ReadFile(statePath) + if err != nil { + slog.Warn("Failed to read state file, continuing without state", + "sessionId", sessionID, + "path", statePath, + "error", err) + return nil, nil // Return nil instead of error, state is optional + } + + var state VSCodeStateFile + if err := json.Unmarshal(data, &state); err != nil { + slog.Warn("Failed to parse state JSON, continuing without state", + "sessionId", sessionID, + "path", statePath, + "error", err) + return nil, nil // Return nil instead of error, state is optional + } + + slog.Debug("Loaded state file", "sessionId", sessionID, "version", state.Version) + return &state, nil +} + +// WriteDebugFiles writes raw session data to debug directory +func WriteDebugFiles(composer *VSCodeComposer, sessionID string) error { + debugDir, err := EnsureDebugDirectory(sessionID) + if err != nil { + return fmt.Errorf("failed to create debug directory: %w", err) + } + + // Write raw session JSON (pretty-printed) + rawSessionPath := filepath.Join(debugDir, "raw-session.json") + rawData, err := json.MarshalIndent(composer, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal session data: %w", err) + } + + if err := os.WriteFile(rawSessionPath, rawData, 0644); err != nil { + return fmt.Errorf("failed to write raw session file: %w", err) + } + + slog.Debug("Wrote debug files", "sessionId", sessionID, "debugDir", debugDir) + return nil +} + +// parseJSONL parses JSONL format with incremental updates +// First line (kind: 0) contains initial state in "v" field +// Subsequent lines (kind: 1) contain updates with key path in "k" and value in "v" +func parseJSONL(data []byte) (VSCodeComposer, error) { + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) == 0 { + return VSCodeComposer{}, fmt.Errorf("empty JSONL file") + } + + // Parse first line - should be kind:0 with initial state + var firstLine struct { + Kind int `json:"kind"` + V VSCodeComposer `json:"v"` + } + + if err := json.Unmarshal([]byte(lines[0]), &firstLine); err != nil { + return VSCodeComposer{}, fmt.Errorf("failed to parse first JSONL line: %w", err) + } + + if firstLine.Kind != 0 { + return VSCodeComposer{}, fmt.Errorf("expected kind:0 in first line, got kind:%d", firstLine.Kind) + } + + composer := firstLine.V + + // Apply subsequent updates. + // kind:1 = incremental key-path update (small field changes, e.g. customTitle) + // kind:2 = bulk key-path replacement (e.g. full requests array written after initial snapshot) + // VS Code Copilot introduced kind:2 to split large payloads from the initial kind:0 snapshot. + for i := 1; i < len(lines); i++ { + var update struct { + Kind int `json:"kind"` + K []string `json:"k"` // Key path + V any `json:"v"` // Value + } + + if err := json.Unmarshal([]byte(lines[i]), &update); err != nil { + slog.Warn("Failed to parse JSONL update line", "line", i+1, "error", err) + continue + } + + switch update.Kind { + case 1: + if len(update.K) > 0 { + // Replace the value at the key path + if err := applyUpdate(&composer, update.K, update.V); err != nil { + slog.Warn("Failed to apply JSONL update", "line", i+1, "kind", update.Kind, "keyPath", update.K, "error", err) + } + } + case 2: + if len(update.K) > 0 { + // Append items to the array at the key path. + // VS Code writes one kind:2 per user turn, each containing only the new + // request(s) for that turn — not the full history — so we must accumulate. + if err := appendUpdate(&composer, update.K, update.V); err != nil { + slog.Warn("Failed to apply JSONL append", "line", i+1, "kind", update.Kind, "keyPath", update.K, "error", err) + } + } + default: + // Log unknown kinds so we detect future format changes early + slog.Warn("Unknown JSONL kind, skipping line", "line", i+1, "kind", update.Kind) + } + } + + return composer, nil +} + +// appendUpdate appends items to an array at a specific key path in the composer. +// Used for kind:2 lines where VS Code writes only the newly added items (e.g. a single +// new request per turn) rather than the full array, so each write must be accumulated. +// Falls back to replace semantics when the target or incoming value is not an array. +func appendUpdate(composer *VSCodeComposer, keyPath []string, value any) error { + newItems, isArray := value.([]any) + if !isArray { + // Not an array delta — treat as a plain replace + return applyUpdate(composer, keyPath, value) + } + + // Convert composer to map so we can read and update the existing slice dynamically + composerData, err := json.Marshal(composer) + if err != nil { + return fmt.Errorf("failed to marshal composer: %w", err) + } + + var composerMap map[string]any + if err := json.Unmarshal(composerData, &composerMap); err != nil { + return fmt.Errorf("failed to unmarshal composer to map: %w", err) + } + + // Navigate to the parent of the target key + current := composerMap + for i := 0; i < len(keyPath)-1; i++ { + key := keyPath[i] + if _, exists := current[key]; !exists { + current[key] = make(map[string]any) + } + if nextMap, ok := current[key].(map[string]any); ok { + current = nextMap + } else { + return fmt.Errorf("cannot navigate through non-map value at key: %s", key) + } + } + + // Append the new items to whatever is already there; fall back to replace if needed + lastKey := keyPath[len(keyPath)-1] + if existing, ok := current[lastKey].([]any); ok { + current[lastKey] = append(existing, newItems...) + } else { + current[lastKey] = newItems + } + + // Convert back to VSCodeComposer + updatedData, err := json.Marshal(composerMap) + if err != nil { + return fmt.Errorf("failed to marshal updated map: %w", err) + } + + if err := json.Unmarshal(updatedData, composer); err != nil { + return fmt.Errorf("failed to unmarshal updated composer: %w", err) + } + + return nil +} + +// applyUpdate applies an update to a composer at a specific key path +// keyPath is an array of keys representing the path (e.g., ["inputState", "inputText"]) +func applyUpdate(composer *VSCodeComposer, keyPath []string, value any) error { + // Convert composer to map for dynamic updates + composerData, err := json.Marshal(composer) + if err != nil { + return fmt.Errorf("failed to marshal composer: %w", err) + } + + var composerMap map[string]any + if err := json.Unmarshal(composerData, &composerMap); err != nil { + return fmt.Errorf("failed to unmarshal composer to map: %w", err) + } + + // Navigate to the parent of the target key + current := composerMap + for i := 0; i < len(keyPath)-1; i++ { + key := keyPath[i] + if _, exists := current[key]; !exists { + // Create intermediate maps as needed + current[key] = make(map[string]any) + } + if nextMap, ok := current[key].(map[string]any); ok { + current = nextMap + } else { + return fmt.Errorf("cannot navigate through non-map value at key: %s", key) + } + } + + // Set the value at the final key + lastKey := keyPath[len(keyPath)-1] + current[lastKey] = value + + // Convert back to VSCodeComposer + updatedData, err := json.Marshal(composerMap) + if err != nil { + return fmt.Errorf("failed to marshal updated map: %w", err) + } + + if err := json.Unmarshal(updatedData, composer); err != nil { + return fmt.Errorf("failed to unmarshal updated composer: %w", err) + } + + return nil +} diff --git a/specstory-cli/pkg/providers/copilotide/parser.go b/specstory-cli/pkg/providers/copilotide/parser.go new file mode 100644 index 00000000..8df593b0 --- /dev/null +++ b/specstory-cli/pkg/providers/copilotide/parser.go @@ -0,0 +1,185 @@ +package copilotide + +import ( + "encoding/json" + "fmt" + "strings" + "time" + "unicode" +) + +// ParseResponseKind identifies the response type without fully parsing +func ParseResponseKind(rawResponse json.RawMessage) (string, error) { + var kindOnly struct { + Kind string `json:"kind"` + } + if err := json.Unmarshal(rawResponse, &kindOnly); err != nil { + return "", fmt.Errorf("failed to parse response kind: %w", err) + } + return kindOnly.Kind, nil +} + +// IsHiddenTool checks if a tool invocation response is marked as hidden +func IsHiddenTool(rawResponse json.RawMessage) bool { + var invocation VSCodeToolInvocationResponse + if err := json.Unmarshal(rawResponse, &invocation); err != nil { + return false + } + return invocation.Presentation == "hidden" +} + +// BuildToolCallMap creates a lookup map from toolCallId to tool call info +// NOTE: This is kept for backward compatibility but sequence-based matching is preferred +func BuildToolCallMap(metadata VSCodeResultMetadata) map[string]VSCodeToolCallInfo { + toolCalls := make(map[string]VSCodeToolCallInfo) + + for _, round := range metadata.ToolCallRounds { + for _, call := range round.ToolCalls { + toolCalls[call.ID] = call + } + } + + return toolCalls +} + +// BuildToolCallSequence creates an ordered list of tool calls from metadata +// This is used for sequence-based matching since VS Code IDs don't match OpenAI IDs +func BuildToolCallSequence(metadata VSCodeResultMetadata) []VSCodeToolCallInfo { + var sequence []VSCodeToolCallInfo + + for _, round := range metadata.ToolCallRounds { + sequence = append(sequence, round.ToolCalls...) + } + + return sequence +} + +// HasToolCalls checks if there are any tool calls in the metadata +func HasToolCalls(metadata VSCodeResultMetadata) bool { + for _, round := range metadata.ToolCallRounds { + if len(round.ToolCalls) > 0 { + return true + } + } + return false +} + +// ExtractThinkingFromMetadata extracts thinking text from tool call rounds +// Should only be called when there are actual tool calls +func ExtractThinkingFromMetadata(metadata VSCodeResultMetadata) string { + var thinking strings.Builder + + for _, round := range metadata.ToolCallRounds { + // Only include response if there are tool calls in this round + if len(round.ToolCalls) > 0 && round.Response != "" { + thinking.WriteString(round.Response) + thinking.WriteString("\n\n") + } + } + + result := strings.TrimSpace(thinking.String()) + if result == "" { + return "" + } + + return result +} + +// ExtractResponseFromToolCallRounds gets the response from tool call rounds when no tool calls present +// This is used when toolCallRounds exists but toolCalls is empty - the response is the final output +func ExtractResponseFromToolCallRounds(metadata VSCodeResultMetadata) string { + for _, round := range metadata.ToolCallRounds { + if round.Response != "" { + return round.Response + } + } + return "" +} + +// ExtractTextFromResponseArray extracts text from the response array +// Handles response items that have a "value" field but no "kind" field +func ExtractTextFromResponseArray(responses []json.RawMessage) string { + for _, rawResp := range responses { + // Try to parse as a value response (no kind field) + var valueResp struct { + Value string `json:"value"` + } + if err := json.Unmarshal(rawResp, &valueResp); err == nil && valueResp.Value != "" { + return valueResp.Value + } + } + return "" +} + +// ExtractFinalAgentMessage gets the final text response from metadata +func ExtractFinalAgentMessage(metadata VSCodeResultMetadata) string { + // VS Code stores final messages in metadata.messages + for _, msg := range metadata.Messages { + if msg.Role == "assistant" { + return msg.Content + } + } + return "" +} + +// GenerateSlug creates a URL-safe slug from composer name or first message +func GenerateSlug(composer VSCodeComposer) string { + // Use custom title if available + if composer.CustomTitle != "" { + return slugify(composer.CustomTitle) + } + if composer.Name != "" { + return slugify(composer.Name) + } + + // Fall back to first request message + if len(composer.Requests) > 0 { + firstMsg := composer.Requests[0].Message.Text + // Take first 50 chars + if len(firstMsg) > 50 { + firstMsg = firstMsg[:50] + } + return slugify(firstMsg) + } + + return "untitled" +} + +// FormatTimestamp converts Unix timestamp (ms) to ISO 8601 +func FormatTimestamp(unixMs int64) string { + t := time.Unix(0, unixMs*int64(time.Millisecond)) + return t.Format(time.RFC3339) +} + +// slugify converts text to URL-safe slug +func slugify(text string) string { + // Convert to lowercase + text = strings.ToLower(text) + + // Replace non-alphanumeric characters with hyphens + var builder strings.Builder + for _, r := range text { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + builder.WriteRune(r) + } else if unicode.IsSpace(r) { + builder.WriteRune('-') + } + } + + slug := builder.String() + + // Remove consecutive hyphens + for strings.Contains(slug, "--") { + slug = strings.ReplaceAll(slug, "--", "-") + } + + // Trim hyphens from start and end + slug = strings.Trim(slug, "-") + + // Ensure we have something + if slug == "" { + return "untitled" + } + + return slug +} diff --git a/specstory-cli/pkg/providers/copilotide/path_utils.go b/specstory-cli/pkg/providers/copilotide/path_utils.go new file mode 100644 index 00000000..3ae6d15e --- /dev/null +++ b/specstory-cli/pkg/providers/copilotide/path_utils.go @@ -0,0 +1,70 @@ +package copilotide + +import ( + "fmt" + "os" + "path/filepath" + "runtime" +) + +// GetWorkspaceStoragePath returns the VS Code workspace storage directory path +// Returns empty string if the directory doesn't exist +func GetWorkspaceStoragePath() string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + + var storagePath string + switch runtime.GOOS { + case "darwin": + // macOS: ~/Library/Application Support/Code/User/workspaceStorage/ + storagePath = filepath.Join(homeDir, "Library", "Application Support", "Code", "User", "workspaceStorage") + case "linux": + // Linux: ~/.config/Code/User/workspaceStorage/ + storagePath = filepath.Join(homeDir, ".config", "Code", "User", "workspaceStorage") + default: + return "" + } + + // Check if directory exists + if _, err := os.Stat(storagePath); os.IsNotExist(err) { + return "" + } + + return storagePath +} + +// GetChatSessionsPath returns the chatSessions directory for a workspace +func GetChatSessionsPath(workspaceDir string) string { + return filepath.Join(workspaceDir, "chatSessions") +} + +// GetChatEditingSessionsPath returns the chatEditingSessions directory for a workspace +func GetChatEditingSessionsPath(workspaceDir string) string { + return filepath.Join(workspaceDir, "chatEditingSessions") +} + +// GetWorkspaceStateDBPath returns the path to the workspace state database +func GetWorkspaceStateDBPath(workspaceDir string) string { + return filepath.Join(workspaceDir, "state.vscdb") +} + +// GetWorkspaceMetadataPath returns the path to workspace.json +func GetWorkspaceMetadataPath(workspaceDir string) string { + return filepath.Join(workspaceDir, "workspace.json") +} + +// GetStateFilePath returns the path to a session's state file (if it exists) +func GetStateFilePath(workspaceDir, sessionID string) string { + return filepath.Join(GetChatEditingSessionsPath(workspaceDir), sessionID, "state.json") +} + +// EnsureDebugDirectory creates the debug directory for a session +func EnsureDebugDirectory(sessionID string) (string, error) { + debugDir := filepath.Join(".specstory", "debug", sessionID) + if err := os.MkdirAll(debugDir, 0755); err != nil { + return "", fmt.Errorf("failed to create debug directory: %w", err) + } + return debugDir, nil +} diff --git a/specstory-cli/pkg/providers/copilotide/provider.go b/specstory-cli/pkg/providers/copilotide/provider.go new file mode 100644 index 00000000..742f9184 --- /dev/null +++ b/specstory-cli/pkg/providers/copilotide/provider.go @@ -0,0 +1,343 @@ +package copilotide + +import ( + "context" + "fmt" + "log/slog" + + "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi" +) + +// Provider implements the SPI Provider interface for VS Code Copilot IDE +type Provider struct{} + +// NewProvider creates a new VS Code Copilot IDE provider instance +func NewProvider() *Provider { + return &Provider{} +} + +// Name returns the human-readable name of this provider +func (p *Provider) Name() string { + return "VS Code Copilot IDE" +} + +// Check verifies VS Code workspace storage exists and returns info +func (p *Provider) Check(customCommand string) spi.CheckResult { + slog.Debug("Check: Checking VS Code Copilot installation") + + // Check for workspace storage directory + storagePath := GetWorkspaceStoragePath() + if storagePath == "" { + return spi.CheckResult{ + Success: false, + Version: "", + Location: "", + ErrorMessage: "VS Code workspace storage directory not found", + } + } + + slog.Debug("VS Code Copilot check successful", "storagePath", storagePath) + + return spi.CheckResult{ + Success: true, + Version: "VS Code Copilot", + Location: storagePath, + ErrorMessage: "", + } +} + +// DetectAgent checks if VS Code Copilot has been used in the given project path +func (p *Provider) DetectAgent(projectPath string, helpOutput bool) bool { + slog.Debug("DetectAgent: Checking for VS Code Copilot activity", "projectPath", projectPath) + + // Try to find workspace for project + workspace, err := FindWorkspaceForProject(projectPath) + if err != nil { + slog.Debug("No workspace found for project", "projectPath", projectPath, "error", err) + if helpOutput { + fmt.Println("\n❌ No VS Code Copilot workspace found for this project") + fmt.Printf(" • Project path: %s\n", projectPath) + fmt.Printf(" • Workspace storage: %s\n", GetWorkspaceStoragePath()) + fmt.Println(" • VS Code needs to be opened in this directory at least once") + fmt.Println() + } + return false + } + + // Check if workspace has any chat sessions + sessionFiles, err := LoadAllSessionFiles(workspace.Dir) + if err != nil || len(sessionFiles) == 0 { + slog.Debug("No chat sessions found", "workspace", workspace.Dir, "error", err) + if helpOutput { + fmt.Println("\n❌ No VS Code Copilot chat sessions found") + fmt.Printf(" • Workspace: %s\n", workspace.Dir) + fmt.Println(" • Create at least one chat session in VS Code Copilot") + fmt.Println() + } + return false + } + + slog.Debug("VS Code Copilot activity detected", "sessionCount", len(sessionFiles)) + return true +} + +// GetAgentChatSession retrieves a single chat session by ID +func (p *Provider) GetAgentChatSession(projectPath string, sessionID string, debugRaw bool) (*spi.AgentChatSession, error) { + slog.Debug("GetAgentChatSession", "projectPath", projectPath, "sessionID", sessionID, "debugRaw", debugRaw) + + // Find workspace for project + workspace, err := FindWorkspaceForProject(projectPath) + if err != nil { + return nil, fmt.Errorf("failed to find workspace: %w", err) + } + + // Load specific session + session, err := LoadSessionByID(workspace.Dir, sessionID) + if err != nil { + return nil, fmt.Errorf("failed to load session: %w", err) + } + + // Load state file (optional) + state, err := LoadStateFile(workspace.Dir, sessionID) + if err != nil { + slog.Warn("Failed to load state file", "sessionId", sessionID, "error", err) + } + + // Convert to AgentChatSession + agentSession := ConvertToSessionData(*session, projectPath, state) + + // Write debug files if requested + if debugRaw { + if err := WriteDebugFiles(session, sessionID); err != nil { + slog.Warn("Failed to write debug files", "error", err) + } + } + + return &agentSession, nil +} + +// GetAgentChatSessions retrieves all chat sessions for the given project path +func (p *Provider) GetAgentChatSessions(projectPath string, debugRaw bool, progress spi.ProgressCallback) ([]spi.AgentChatSession, error) { + slog.Debug("GetAgentChatSessions", "projectPath", projectPath, "debugRaw", debugRaw) + + // Find workspace for project + workspace, err := FindWorkspaceForProject(projectPath) + if err != nil { + return nil, fmt.Errorf("failed to find workspace: %w", err) + } + + // Load all session files + sessionFiles, err := LoadAllSessionFiles(workspace.Dir) + if err != nil { + return nil, fmt.Errorf("failed to load session files: %w", err) + } + + var sessions []spi.AgentChatSession + processedCount := 0 + totalCount := len(sessionFiles) + + for _, sessionFile := range sessionFiles { + composer, err := LoadSessionFile(sessionFile) + if err != nil { + slog.Warn("Failed to load session", "file", sessionFile, "error", err) + continue + } + + // Load state file (optional) + state, err := LoadStateFile(workspace.Dir, composer.SessionID) + if err != nil { + slog.Warn("Failed to load state file", "sessionId", composer.SessionID, "error", err) + } + + // Check if session has content (either chat messages or editing operations) + hasConversations := len(composer.Requests) > 0 + hasEditingOperations := hasEditingActivity(state) + + if !hasConversations && !hasEditingOperations { + slog.Debug("Skipping empty session (no chat or editing activity)", "sessionId", composer.SessionID) + continue + } + + // Convert to AgentChatSession + session := ConvertToSessionData(*composer, projectPath, state) + sessions = append(sessions, session) + + // Write debug files if requested + if debugRaw { + if err := WriteDebugFiles(composer, composer.SessionID); err != nil { + slog.Warn("Failed to write debug files", "sessionId", composer.SessionID, "error", err) + } + } + + // Report progress + processedCount++ + if progress != nil { + progress(processedCount, totalCount) + } + } + + slog.Debug("Loaded sessions", "count", len(sessions)) + return sessions, nil +} + +// ListAgentChatSessions retrieves lightweight metadata for all sessions +func (p *Provider) ListAgentChatSessions(projectPath string) ([]spi.SessionMetadata, error) { + slog.Debug("ListAgentChatSessions: Loading VS Code Copilot session list", + "projectPath", projectPath) + + // Step 1: Find workspace for project + workspace, err := FindWorkspaceForProject(projectPath) + if err != nil { + slog.Debug("No workspace found for project", "error", err) + return []spi.SessionMetadata{}, nil // Return empty list if no workspace + } + + slog.Debug("Found workspace for project", "workspaceDir", workspace.Dir) + + // Step 2: Load all session files + sessionFiles, err := LoadAllSessionFiles(workspace.Dir) + if err != nil { + return nil, fmt.Errorf("failed to load session files: %w", err) + } + + slog.Debug("Loaded session files", "count", len(sessionFiles)) + + if len(sessionFiles) == 0 { + slog.Debug("No session files found") + return []spi.SessionMetadata{}, nil + } + + // Step 3: Extract metadata for each session + metadataList := make([]spi.SessionMetadata, 0, len(sessionFiles)) + for _, sessionFile := range sessionFiles { + composer, err := LoadSessionFile(sessionFile) + if err != nil { + slog.Warn("Failed to load session file", "file", sessionFile, "error", err) + continue + } + + // Load state file to check for editing operations + state, err := LoadStateFile(workspace.Dir, composer.SessionID) + if err != nil { + slog.Warn("Failed to load state file", "sessionId", composer.SessionID, "error", err) + } + + // Check if session has content (either chat messages or editing operations) + hasConversations := len(composer.Requests) > 0 + hasEditingOperations := hasEditingActivity(state) + + if !hasConversations && !hasEditingOperations { + slog.Debug("Skipping empty session (no chat or editing activity)", "sessionId", composer.SessionID) + continue + } + + metadata := extractCopilotIDESessionMetadata(composer) + metadataList = append(metadataList, metadata) + } + + slog.Info("Listed VS Code Copilot sessions", + "totalFiles", len(sessionFiles), + "sessionCount", len(metadataList)) + + return metadataList, nil +} + +// ExecAgentAndWatch is not supported for VS Code Copilot (IDE-based, not CLI) +func (p *Provider) ExecAgentAndWatch(projectPath string, customCommand string, resumeSessionID string, debugRaw bool, sessionCallback func(*spi.AgentChatSession)) error { + return fmt.Errorf("VS Code Copilot does not support execution via CLI (it is an IDE-based tool)") +} + +// WatchAgent watches for new/updated chat sessions +func (p *Provider) WatchAgent(ctx context.Context, projectPath string, debugRaw bool, sessionCallback func(*spi.AgentChatSession)) error { + slog.Debug("WatchAgent", "projectPath", projectPath, "debugRaw", debugRaw) + + // Find workspace for project + workspace, err := FindWorkspaceForProject(projectPath) + if err != nil { + return fmt.Errorf("failed to find workspace: %w", err) + } + + // Start watching the chatSessions directory + return WatchChatSessions(ctx, workspace.Dir, projectPath, debugRaw, sessionCallback) +} + +// extractCopilotIDESessionMetadata extracts lightweight session metadata from a VSCodeComposer +// without fully parsing the conversation +func extractCopilotIDESessionMetadata(composer *VSCodeComposer) spi.SessionMetadata { + // Use session ID + sessionID := composer.SessionID + + // Convert timestamp (milliseconds to ISO 8601) + createdAt := FormatTimestamp(composer.CreationDate) + + // Generate slug using existing GenerateSlug function + slug := GenerateSlug(*composer) + + // Generate human-readable name + name := generateCopilotIDESessionName(composer) + + return spi.SessionMetadata{ + SessionID: sessionID, + CreatedAt: createdAt, + Slug: slug, + Name: name, + } +} + +// generateCopilotIDESessionName creates a human-readable session name from composer data +func generateCopilotIDESessionName(composer *VSCodeComposer) string { + // Prefer custom title if available (it's already human-readable) + if composer.CustomTitle != "" { + return spi.GenerateReadableName(composer.CustomTitle) + } + + // Use name if available + if composer.Name != "" { + return spi.GenerateReadableName(composer.Name) + } + + // Otherwise, use first request message + if len(composer.Requests) > 0 { + firstMsg := composer.Requests[0].Message.Text + if firstMsg != "" { + return spi.GenerateReadableName(firstMsg) + } + } + + // Fallback to empty string (shouldn't happen with non-empty conversations) + return "" +} + +// hasEditingActivity checks if a state file contains editing operations +func hasEditingActivity(state *VSCodeStateFile) bool { + if state == nil { + return false + } + + // Version 2 format: check timeline.operations + if state.Timeline != nil && len(state.Timeline.Operations) > 0 { + return true + } + + // Version 1 format: check recentSnapshot for entries + if state.RecentSnapshot != nil { + // Handle array format + if stopsArray, ok := state.RecentSnapshot.([]any); ok { + for _, stopData := range stopsArray { + if stopMap, ok := stopData.(map[string]any); ok { + if entriesData, ok := stopMap["entries"].([]any); ok && len(entriesData) > 0 { + return true + } + } + } + } + // Handle object format + if stopMap, ok := state.RecentSnapshot.(map[string]any); ok { + if entriesData, ok := stopMap["entries"].([]any); ok && len(entriesData) > 0 { + return true + } + } + } + + return false +} diff --git a/specstory-cli/pkg/providers/copilotide/types.go b/specstory-cli/pkg/providers/copilotide/types.go new file mode 100644 index 00000000..2f30930e --- /dev/null +++ b/specstory-cli/pkg/providers/copilotide/types.go @@ -0,0 +1,216 @@ +package copilotide + +import "encoding/json" + +// VSCodeComposer is the root structure stored in chatSessions/[sessionId].json +type VSCodeComposer struct { + Host string `json:"host"` // Always "vscode" + SessionID string `json:"sessionId"` + Name string `json:"name,omitempty"` + CustomTitle string `json:"customTitle,omitempty"` + Version int `json:"version"` + RequesterUsername string `json:"requesterUsername"` + ResponderUsername string `json:"responderUsername"` + CreationDate int64 `json:"creationDate"` + LastMessageDate int64 `json:"lastMessageDate"` + InitialLocation string `json:"initialLocation"` + IsImported bool `json:"isImported"` + Requests []VSCodeRequestBlock `json:"requests"` +} + +// VSCodeRequestBlock represents one conversational turn (user message + agent responses) +type VSCodeRequestBlock struct { + RequestID string `json:"requestId"` + Timestamp int64 `json:"timestamp"` + Message VSCodeMessage `json:"message"` + Response []json.RawMessage `json:"response"` // Polymorphic array - parse by "kind" + Result VSCodeResult `json:"result"` + ModelID string `json:"modelId,omitempty"` +} + +// VSCodeMessage is the user's input message +type VSCodeMessage struct { + Text string `json:"text"` + Parts []VSCodeMessagePart `json:"parts,omitempty"` +} + +// VSCodeMessagePart (optional - only parse if needed for tool references) +type VSCodeMessagePart struct { + Kind string `json:"kind"` + // Add other fields as needed during implementation +} + +// Response types - each has a "kind" discriminator +// Parse json.RawMessage into these based on kind field + +// VSCodeToolInvocationResponse represents a tool invocation +type VSCodeToolInvocationResponse struct { + Kind string `json:"kind"` // "toolInvocationSerialized" + ID string `json:"id,omitempty"` + ToolID string `json:"toolId,omitempty"` + ToolCallID string `json:"toolCallId,omitempty"` + Presentation string `json:"presentation,omitempty"` // "hidden" or empty + // Add invocationMessage, pastTenseMessage if needed for tool parameters +} + +// VSCodeTextEditGroupResponse represents file edits +type VSCodeTextEditGroupResponse struct { + Kind string `json:"kind"` // "textEditGroup" + Uri VSCodeUri `json:"uri"` + Edits [][]VSCodeEdit `json:"edits"` // Nested array + Done bool `json:"done,omitempty"` +} + +// VSCodeCodeblockUriResponse represents a code block +type VSCodeCodeblockUriResponse struct { + Kind string `json:"kind"` // "codeblockUri" + Uri VSCodeUri `json:"uri"` + IsEdit bool `json:"isEdit,omitempty"` +} + +// VSCodeConfirmationResponse represents a user confirmation request +type VSCodeConfirmationResponse struct { + Kind string `json:"kind"` // "confirmation" + Title string `json:"title"` + Message any `json:"message"` // Can be string or object with value field + IsUsed bool `json:"isUsed"` +} + +// GetMessageText extracts the text from the message field (handles both string and object formats) +func (c *VSCodeConfirmationResponse) GetMessageText() string { + if c.Message == nil { + return "" + } + + // Try string first + if str, ok := c.Message.(string); ok { + return str + } + + // If it's an object, try to get the "value" field + if msgMap, ok := c.Message.(map[string]any); ok { + if value, ok := msgMap["value"].(string); ok { + return value + } + } + + return "" +} + +// VSCodeInlineReferenceResponse represents a reference to code/files +type VSCodeInlineReferenceResponse struct { + Kind string `json:"kind"` // "inlineReference" + InlineReference VSCodeUri `json:"inlineReference"` +} + +// Result metadata - contains tool call rounds and results +type VSCodeResult struct { + Metadata VSCodeResultMetadata `json:"metadata"` + Timings VSCodeTimings `json:"timings"` +} + +// VSCodeResultMetadata contains tool call information and final messages +type VSCodeResultMetadata struct { + ToolCallRounds []VSCodeToolCallRound `json:"toolCallRounds,omitempty"` + ToolCallResults map[string]VSCodeToolCallResult `json:"toolCallResults,omitempty"` + Messages []VSCodeMetadataMessage `json:"messages,omitempty"` +} + +// VSCodeToolCallRound represents one round of tool calls from the LLM +type VSCodeToolCallRound struct { + Response string `json:"response"` // May contain thinking + ToolCalls []VSCodeToolCallInfo `json:"toolCalls"` +} + +// VSCodeToolCallInfo contains the tool call details +type VSCodeToolCallInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Arguments string `json:"arguments"` // JSON string - parse if needed +} + +// VSCodeToolCallResult contains the result of a tool call +type VSCodeToolCallResult struct { + Content []VSCodeToolCallContent `json:"content,omitempty"` +} + +// VSCodeToolCallContent contains the content of a tool call result +type VSCodeToolCallContent struct { + Value any `json:"value,omitempty"` // Can be string or complex object +} + +// VSCodeMetadataMessage contains messages from the result metadata +type VSCodeMetadataMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// VSCodeTimings contains timing information +type VSCodeTimings struct { + FirstProgress int64 `json:"firstProgress"` + TotalElapsed int64 `json:"totalElapsed"` +} + +// Common types + +// VSCodeUri represents a file URI or code block URI +type VSCodeUri struct { + Scheme string `json:"scheme,omitempty"` // "file", "vscode-chat-code-block", etc. + Path string `json:"path,omitempty"` + FSPath string `json:"fsPath,omitempty"` +} + +// VSCodeEdit represents a text edit +type VSCodeEdit struct { + Text string `json:"text"` + Range VSCodeRange `json:"range"` +} + +// VSCodeRange represents a range in a file +type VSCodeRange struct { + StartLineNumber int `json:"startLineNumber"` + StartColumn int `json:"startColumn"` + EndLineNumber int `json:"endLineNumber"` + EndColumn int `json:"endColumn"` +} + +// Optional state file types (for chatEditingSessions/[sessionId]/state.json) +// Only define these if needed - can defer to phase 2 + +// VSCodeStateFile represents the state file for editing sessions +type VSCodeStateFile struct { + Version int `json:"version"` + SessionID string `json:"sessionId"` + LinearHistory []VSCodeLinearHistory `json:"linearHistory,omitempty"` + RecentSnapshot any `json:"recentSnapshot,omitempty"` // Can be array or object + PendingSnapshot any `json:"pendingSnapshot,omitempty"` // Can be array or object + Timeline *VSCodeTimeline `json:"timeline,omitempty"` // Version 2 format +} + +// VSCodeTimeline represents the timeline in version 2 state format +type VSCodeTimeline struct { + Operations []VSCodeOperation `json:"operations,omitempty"` +} + +// VSCodeOperation represents a file operation in version 2 state format +type VSCodeOperation struct { + Type string `json:"type"` // "create", "textEdit", "delete" + URI *VSCodeUri `json:"uri,omitempty"` // File URI + Edits []any `json:"edits,omitempty"` // Edit details +} + +// VSCodeLinearHistory represents the linear history of edits +type VSCodeLinearHistory struct { + RequestID string `json:"requestId"` + Stops []VSCodeStop `json:"stops,omitempty"` +} + +// VSCodeStop represents a stop in the edit history +type VSCodeStop struct { + Entries []VSCodeStopEntry `json:"entries,omitempty"` +} + +// VSCodeStopEntry represents an entry in a stop +type VSCodeStopEntry struct { + Resource string `json:"resource,omitempty"` // File path or URI +} diff --git a/specstory-cli/pkg/providers/copilotide/watcher.go b/specstory-cli/pkg/providers/copilotide/watcher.go new file mode 100644 index 00000000..b95c4f06 --- /dev/null +++ b/specstory-cli/pkg/providers/copilotide/watcher.go @@ -0,0 +1,167 @@ +package copilotide + +import ( + "context" + "fmt" + "log/slog" + "path/filepath" + "strings" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi" +) + +// WatchChatSessions watches the chatSessions directory for new/modified session files +func WatchChatSessions( + ctx context.Context, + workspaceDir string, + projectPath string, + debugRaw bool, + sessionCallback func(*spi.AgentChatSession), +) error { + chatSessionsPath := GetChatSessionsPath(workspaceDir) + + slog.Info("Starting VS Code Copilot watcher", + "workspaceDir", workspaceDir, + "chatSessionsPath", chatSessionsPath) + + // Create fsnotify watcher + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("failed to create watcher: %w", err) + } + defer func() { + if err := watcher.Close(); err != nil { + slog.Warn("Failed to close watcher", "error", err) + } + }() + + // Watch the chatSessions directory + if err := watcher.Add(chatSessionsPath); err != nil { + return fmt.Errorf("failed to watch directory: %w", err) + } + + slog.Info("Watching chatSessions directory", "path", chatSessionsPath) + + // Track known sessions and their modification times + knownSessions := make(map[string]int64) + + // Debouncing map - track last processed time for each file + lastProcessed := make(map[string]time.Time) + debounceWindow := 500 * time.Millisecond + + // Process existing sessions first + existingSessions, err := LoadAllSessionFiles(workspaceDir) + if err != nil { + slog.Warn("Failed to load existing sessions", "error", err) + } else { + for _, sessionPath := range existingSessions { + composer, err := LoadSessionFile(sessionPath) + if err != nil { + slog.Warn("Failed to load session", "path", sessionPath, "error", err) + continue + } + + // Track as known + knownSessions[composer.SessionID] = composer.LastMessageDate + + slog.Debug("Tracked existing session", "sessionId", composer.SessionID) + } + } + + // Event loop + for { + select { + case <-ctx.Done(): + slog.Info("Watcher context canceled, stopping") + return nil + + case event, ok := <-watcher.Events: + if !ok { + return fmt.Errorf("watcher events channel closed") + } + + // Only process JSON and JSONL files + if !strings.HasSuffix(event.Name, ".json") && !strings.HasSuffix(event.Name, ".jsonl") { + continue + } + + // Only process Write and Create events + if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) { + continue + } + + // Debounce rapid events for the same file + now := time.Now() + if lastTime, ok := lastProcessed[event.Name]; ok && now.Sub(lastTime) < debounceWindow { + slog.Debug("Debouncing rapid event", "path", event.Name) + continue + } + lastProcessed[event.Name] = now + + slog.Debug("File event detected", "path", event.Name, "op", event.Op) + + // Load the session file + composer, err := LoadSessionFile(event.Name) + if err != nil { + slog.Warn("Failed to load session after event", "path", event.Name, "error", err) + continue + } + + // Check if this is new or updated + sessionID := composer.SessionID + lastKnownTime, exists := knownSessions[sessionID] + + isNew := !exists + isUpdated := exists && composer.LastMessageDate > lastKnownTime + + if isNew || isUpdated { + // Update known sessions + knownSessions[sessionID] = composer.LastMessageDate + + if isNew { + slog.Info("New session detected", "sessionId", sessionID, "name", composer.Name) + } else { + slog.Info("Session updated", "sessionId", sessionID, "name", composer.Name) + } + + // Load state file (optional) + state, err := LoadStateFile(workspaceDir, sessionID) + if err != nil { + slog.Warn("Failed to load state file", "sessionId", sessionID, "error", err) + } + + // Convert to AgentChatSession + session := ConvertToSessionData(*composer, projectPath, state) + + // Write debug files if requested + if debugRaw { + if err := WriteDebugFiles(composer, sessionID); err != nil { + slog.Warn("Failed to write debug files", "sessionId", sessionID, "error", err) + } + } + + // Invoke callback + slog.Info("Invoking callback for session", "sessionId", sessionID, "slug", session.Slug) + sessionCallback(&session) + } + + case err, ok := <-watcher.Errors: + if !ok { + return fmt.Errorf("watcher errors channel closed") + } + slog.Warn("Watcher error", "error", err) + } + } +} + +// GetSessionIDFromPath extracts session ID from a file path +func GetSessionIDFromPath(path string) string { + filename := filepath.Base(path) + // Remove .json or .jsonl extension + if strings.HasSuffix(filename, ".jsonl") { + return strings.TrimSuffix(filename, ".jsonl") + } + return strings.TrimSuffix(filename, ".json") +} diff --git a/specstory-cli/pkg/providers/copilotide/workspace.go b/specstory-cli/pkg/providers/copilotide/workspace.go new file mode 100644 index 00000000..288934e1 --- /dev/null +++ b/specstory-cli/pkg/providers/copilotide/workspace.go @@ -0,0 +1,317 @@ +package copilotide + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi" +) + +// WorkspaceMatch represents a matched workspace directory +type WorkspaceMatch struct { + ID string // Workspace directory name + Dir string // Full path to workspace directory + URI string // Original workspace URI + Path string // Resolved workspace path +} + +// WorkspaceJSON represents the structure of workspace.json +type WorkspaceJSON struct { + Workspace string `json:"workspace,omitempty"` // For multi-root workspaces + Folder string `json:"folder,omitempty"` // For single folder workspaces +} + +// FindWorkspaceForProject finds the workspace directory that matches the given project path +// Returns the workspace match or an error if not found +func FindWorkspaceForProject(projectPath string) (*WorkspaceMatch, error) { + // Get canonical project path (resolve symlinks, normalize case) + absProjectPath, err := filepath.Abs(projectPath) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %w", err) + } + + canonicalProjectPath, err := spi.GetCanonicalPath(absProjectPath) + if err != nil { + slog.Warn("Failed to get canonical path, using absolute path", + "projectPath", projectPath, + "error", err) + canonicalProjectPath = absProjectPath + } + + // If the project path is itself a .code-workspace file, pre-collect its folders. + // This lets us also match workspace entries opened directly from those folders + // (Method 4 below), so both usage patterns are discoverable via the workspace file path. + var workspaceFileFolders []string + if strings.HasSuffix(canonicalProjectPath, ".code-workspace") { + workspaceFileFolders = collectCodeWorkspaceFolders(canonicalProjectPath) + } + + slog.Debug("Searching for workspace matching project", + "projectPath", projectPath, + "canonicalPath", canonicalProjectPath) + + // Get workspace storage directory + workspaceStoragePath := GetWorkspaceStoragePath() + if workspaceStoragePath == "" { + return nil, fmt.Errorf("workspace storage directory not found") + } + + // Read all workspace directories + entries, err := os.ReadDir(workspaceStoragePath) + if err != nil { + return nil, fmt.Errorf("failed to read workspace storage directory: %w", err) + } + + // Track all workspace directories for potential matches + var matches []WorkspaceMatch + + // Check each workspace directory + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + workspaceID := entry.Name() + workspaceDir := filepath.Join(workspaceStoragePath, workspaceID) + + // Read workspace.json + workspaceJSONPath := GetWorkspaceMetadataPath(workspaceDir) + workspaceJSON, err := readWorkspaceJSON(workspaceJSONPath) + if err != nil { + slog.Debug("Skipping workspace directory (no valid workspace.json)", + "workspaceID", workspaceID, + "error", err) + continue + } + + // Get the workspace URI (prefer workspace over folder) + workspaceURI := workspaceJSON.Workspace + if workspaceURI == "" { + workspaceURI = workspaceJSON.Folder + } + + if workspaceURI == "" { + slog.Debug("Skipping workspace directory (no workspace or folder URI)", + "workspaceID", workspaceID) + continue + } + + // Convert URI to file path + workspaceFilePath, err := uriToPath(workspaceURI) + if err != nil { + slog.Debug("Skipping workspace directory (invalid URI)", + "workspaceID", workspaceID, + "uri", workspaceURI, + "error", err) + continue + } + + // Get canonical workspace path + canonicalWorkspacePath, err := spi.GetCanonicalPath(workspaceFilePath) + if err != nil { + slog.Debug("Failed to get canonical workspace path", + "workspacePath", workspaceFilePath, + "error", err) + canonicalWorkspacePath = workspaceFilePath + } + + // Direct path match (folder opened directly). + isMatch := canonicalProjectPath == canonicalWorkspacePath + + // Method 3: Code workspace file matching. + // When VS Code is opened via a .code-workspace file, workspace.json stores + // the workspace file URI rather than the folder URI. Check whether that + // workspace file lists our target folder as one of its folders. + if !isMatch && strings.HasSuffix(canonicalWorkspacePath, ".code-workspace") { + if codeWorkspaceContainsFolder(canonicalWorkspacePath, canonicalProjectPath) { + isMatch = true + slog.Debug("Matched workspace by .code-workspace folder reference", + "workspaceID", workspaceID, + "workspaceFile", canonicalWorkspacePath, + "targetFolder", canonicalProjectPath) + } + } + + // Method 4: project path is a .code-workspace file — also match workspace entries + // opened directly from a folder listed in that workspace file. + if !isMatch { + for _, folderPath := range workspaceFileFolders { + if canonicalWorkspacePath == folderPath { + isMatch = true + slog.Debug("Matched workspace entry by folder listed in .code-workspace", + "workspaceID", workspaceID, + "folder", folderPath, + "workspaceFile", canonicalProjectPath) + break + } + } + } + + if isMatch { + // Check if chatSessions directory exists + chatSessionsPath := GetChatSessionsPath(workspaceDir) + if _, err := os.Stat(chatSessionsPath); err != nil { + slog.Debug("Workspace match found but chatSessions directory missing", + "workspaceID", workspaceID, + "chatSessionsPath", chatSessionsPath) + continue + } + + matches = append(matches, WorkspaceMatch{ + ID: workspaceID, + Dir: workspaceDir, + URI: workspaceURI, + Path: workspaceFilePath, + }) + + slog.Info("Found matching workspace", + "workspaceID", workspaceID, + "projectPath", canonicalProjectPath, + "workspaceURI", workspaceURI) + } + } + + if len(matches) == 0 { + return nil, fmt.Errorf("no workspace found for project path: %s", projectPath) + } + + // If multiple matches, return the newest one (based on state.vscdb modification time) + if len(matches) > 1 { + slog.Warn("Multiple workspaces match project path, selecting newest", + "projectPath", projectPath, + "matchCount", len(matches)) + return selectNewestWorkspace(matches) + } + + return &matches[0], nil +} + +// selectNewestWorkspace returns the workspace with the newest state.vscdb modification time +func selectNewestWorkspace(matches []WorkspaceMatch) (*WorkspaceMatch, error) { + var newest *WorkspaceMatch + var newestTime int64 + + for i := range matches { + match := &matches[i] + stateDBPath := GetWorkspaceStateDBPath(match.Dir) + + info, err := os.Stat(stateDBPath) + if err != nil { + slog.Debug("Failed to stat state.vscdb", "path", stateDBPath, "error", err) + continue + } + + modTime := info.ModTime().Unix() + if newest == nil || modTime > newestTime { + newest = match + newestTime = modTime + } + } + + if newest == nil { + return &matches[0], nil // Fall back to first match + } + + slog.Debug("Selected newest workspace", "workspaceID", newest.ID, "modTime", newestTime) + return newest, nil +} + +// collectCodeWorkspaceFolders reads a .code-workspace JSON file and returns the +// canonical paths of all listed folders. Relative paths are resolved against the +// workspace file's directory. +func collectCodeWorkspaceFolders(workspaceFilePath string) []string { + data, err := os.ReadFile(workspaceFilePath) + if err != nil { + slog.Debug("collectCodeWorkspaceFolders: failed to read workspace file", + "path", workspaceFilePath, "error", err) + return nil + } + + var workspace struct { + Folders []struct { + Path string `json:"path"` + } `json:"folders"` + } + if err := json.Unmarshal(data, &workspace); err != nil { + slog.Debug("collectCodeWorkspaceFolders: failed to parse workspace file", + "path", workspaceFilePath, "error", err) + return nil + } + + workspaceDir := filepath.Dir(workspaceFilePath) + var folders []string + for _, folder := range workspace.Folders { + if folder.Path == "" { + continue + } + + // Resolve relative paths against the workspace file's directory. + var resolved string + if filepath.IsAbs(folder.Path) { + resolved = folder.Path + } else { + resolved = filepath.Join(workspaceDir, folder.Path) + } + + canonical, err := spi.GetCanonicalPath(resolved) + if err != nil { + canonical = filepath.Clean(resolved) + } + folders = append(folders, canonical) + } + + return folders +} + +// codeWorkspaceContainsFolder reports whether canonicalFolder is listed in the +// .code-workspace file at workspaceFilePath. +func codeWorkspaceContainsFolder(workspaceFilePath, canonicalFolder string) bool { + for _, f := range collectCodeWorkspaceFolders(workspaceFilePath) { + if f == canonicalFolder { + return true + } + } + return false +} + +// readWorkspaceJSON reads and parses a workspace.json file +func readWorkspaceJSON(path string) (*WorkspaceJSON, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read workspace.json: %w", err) + } + + var workspace WorkspaceJSON + if err := json.Unmarshal(data, &workspace); err != nil { + return nil, fmt.Errorf("failed to parse workspace.json: %w", err) + } + + return &workspace, nil +} + +// uriToPath converts a file:// URI to a local file path +func uriToPath(uri string) (string, error) { + // Handle file:// URIs + if !strings.HasPrefix(uri, "file://") { + return "", fmt.Errorf("URI must start with file://: %s", uri) + } + + // Parse the URI + parsedURI, err := url.Parse(uri) + if err != nil { + return "", fmt.Errorf("failed to parse URI: %w", err) + } + + // Get the path from the URI + path := parsedURI.Path + + // On Windows, URL paths have an extra leading slash (e.g., /C:/Users) + // but we don't support Windows, so we can just use the path as-is + + return path, nil +} diff --git a/specstory-cli/pkg/providers/cursorcli/provider.go b/specstory-cli/pkg/providers/cursorcli/provider.go index 5fcf9710..74634c24 100644 --- a/specstory-cli/pkg/providers/cursorcli/provider.go +++ b/specstory-cli/pkg/providers/cursorcli/provider.go @@ -203,7 +203,6 @@ func (p *Provider) DetectAgent(projectPath string, helpOutput bool) bool { if _, err := os.Stat(chatsDir); err != nil { slog.Debug("DetectAgent: Cursor chats directory doesn't exist", "path", chatsDir) } else { - // Get the project hash directory hashDir, err := GetProjectHashDir(projectPath) if err != nil { slog.Debug("DetectAgent: Failed to get project hash directory", "error", err) @@ -252,7 +251,6 @@ func (p *Provider) DetectAgent(projectPath string, helpOutput bool) bool { // GetAgentChatSessions retrieves all chat sessions for the given project path func (p *Provider) GetAgentChatSessions(projectPath string, debugRaw bool, progress spi.ProgressCallback) ([]spi.AgentChatSession, error) { - // Get the project hash directory hashDir, err := GetProjectHashDir(projectPath) if err != nil { return nil, fmt.Errorf("failed to get project hash directory: %w", err) @@ -274,24 +272,17 @@ func (p *Provider) GetAgentChatSessions(projectPath string, debugRaw bool, progr } totalSessions := len(validSessionIDs) - // Collect sessions with progress reporting + // Collect sessions with progress reporting. Use the already-resolved hashDir to + // avoid a redundant hash computation for every individual session. var sessions []spi.AgentChatSession for i, sessionID := range validSessionIDs { - // Get the session data - session, err := p.GetAgentChatSession(projectPath, sessionID, debugRaw) + session, err := p.readAgentChatSession(hashDir, projectPath, sessionID, debugRaw) if err != nil { slog.Debug("Failed to get session", "sessionID", sessionID, "error", err) - // Still report progress even for failed sessions - if progress != nil { - progress(i+1, totalSessions) - } - continue // Skip sessions we can't read - } - if session != nil { + } else if session != nil { sessions = append(sessions, *session) } - // Report progress after each session if progress != nil { progress(i+1, totalSessions) } @@ -302,18 +293,23 @@ func (p *Provider) GetAgentChatSessions(projectPath string, debugRaw bool, progr // GetAgentChatSession retrieves a single chat session by ID for the given project path func (p *Provider) GetAgentChatSession(projectPath string, sessionID string, debugRaw bool) (*spi.AgentChatSession, error) { - // Get the project hash directory hashDir, err := GetProjectHashDir(projectPath) if err != nil { return nil, fmt.Errorf("failed to get project hash directory: %w", err) } + return p.readAgentChatSession(hashDir, projectPath, sessionID, debugRaw) +} +// readAgentChatSession reads a single session from a known hash directory. It is the +// shared implementation used by both GetAgentChatSession and GetAgentChatSessions so +// that the potentially-expensive workspace scan in FindProjectHashDir is only done once +// per bulk operation rather than once per session. +func (p *Provider) readAgentChatSession(hashDir, projectPath, sessionID string, debugRaw bool) (*spi.AgentChatSession, error) { // Check if the session exists and has store.db if !HasStoreDB(hashDir, sessionID) { return nil, nil // Session not found or no store.db } - // Build the session path sessionPath := filepath.Join(hashDir, sessionID) slog.Debug("Reading Cursor CLI session", @@ -339,11 +335,9 @@ func (p *Provider) GetAgentChatSession(projectPath string, sessionID string, deb } rawData := string(rawDataJSON) - // Write provider-specific debug output if requested if debugRaw { if err := writeDebugOutput(sessionID, rawData, orphanRecords); err != nil { slog.Debug("Failed to write debug output", "sessionID", sessionID, "error", err) - // Don't fail the operation if debug output fails } } @@ -579,7 +573,6 @@ func writeDebugOutput(sessionID string, rawData string, orphanRecords []BlobReco // ListAgentChatSessions retrieves lightweight session metadata without full parsing func (p *Provider) ListAgentChatSessions(projectPath string) ([]spi.SessionMetadata, error) { - // Get the project hash directory hashDir, err := GetProjectHashDir(projectPath) if err != nil { return nil, fmt.Errorf("failed to get project hash directory: %w", err) diff --git a/specstory-cli/pkg/providers/cursoride/agent_session.go b/specstory-cli/pkg/providers/cursoride/agent_session.go new file mode 100644 index 00000000..4de11163 --- /dev/null +++ b/specstory-cli/pkg/providers/cursoride/agent_session.go @@ -0,0 +1,405 @@ +package cursoride + +import ( + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi" + "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi/schema" +) + +// ConvertToAgentChatSession converts Cursor composer data to AgentChatSession format +// This is a minimal implementation - markdown output will be improved later +func ConvertToAgentChatSession(composer *ComposerData) (*spi.AgentChatSession, error) { + // Use composer ID as session ID + sessionID := composer.ComposerID + + // Convert timestamp (milliseconds to ISO 8601) + var createdAt string + if composer.CreatedAt > 0 { + t := time.Unix(composer.CreatedAt/1000, (composer.CreatedAt%1000)*1000000) + createdAt = t.Format(time.RFC3339) + } else { + createdAt = time.Now().Format(time.RFC3339) + } + + // Generate slug from composer name or first user message + slug := generateSlug(composer) + + // Build SessionData with provider info + sessionData := &schema.SessionData{ + SchemaVersion: "1.0", + Provider: schema.ProviderInfo{ + ID: "cursoride", + Name: "Cursor IDE", + Version: "unknown", + }, + SessionID: sessionID, + CreatedAt: createdAt, + Slug: slug, + Exchanges: []schema.Exchange{}, + } + + // Get composer-level model name as fallback + composerModelName := "" + if composer.ModelConfig != nil && composer.ModelConfig.ModelName != "" { + composerModelName = composer.ModelConfig.ModelName + } + + // Parse capabilities to extract tool data (V1 format) + capabilitiesMap := parseCapabilities(composer) + + // Create tool registry for formatting tool invocations + toolRegistry := NewToolRegistry() + + // Convert conversation messages to exchanges + // Each exchange groups one user turn with the agent response(s) that follow it. + var currentMessages []schema.Message + var currentExchangeID string // bubble ID of the user message that started the current exchange + for i, bubble := range composer.Conversation { + // Skip empty bubbles that have no content to display + // BUT: Don't skip tool invocations (capabilityType=15) - they generate content from tool data + if bubble.Text == "" && (bubble.Thinking == nil || bubble.Thinking.Text == "") && bubble.CapabilityType != 0 && bubble.CapabilityType != 15 { + slog.Debug("Skipping empty capability bubble", + "bubbleId", bubble.BubbleID, + "capabilityType", bubble.CapabilityType) + continue + } + // Build the message text, including thinking blocks and tool invocations if present + messageText := buildMessageText(&bubble, capabilitiesMap, toolRegistry, composer.Version) + + // Skip if we still have no content after processing (shouldn't happen, but safety check) + if messageText == "" { + slog.Debug("Skipping bubble with no content after processing", + "bubbleId", bubble.BubbleID, + "capabilityType", bubble.CapabilityType) + continue + } + + // Calculate timestamp from timing info + timestamp := calculateTimestamp(bubble.TimingInfo) + + // If this is a user message without a timestamp, use the timestamp from the next assistant message + // This matches the extension's behavior (see ts-extension/core/utils/conversation.ts:55-72) + if bubble.Type == 1 && timestamp == "" { + // Find next assistant message with timestamp + for j := i + 1; j < len(composer.Conversation); j++ { + nextBubble := composer.Conversation[j] + if nextBubble.Type == 2 { // assistant message + nextTimestamp := calculateTimestamp(nextBubble.TimingInfo) + if nextTimestamp != "" { + timestamp = nextTimestamp + break + } + } + } + } + + // Get model name for agent messages only — user messages must not carry a model. + modelName := "" + if bubble.Type == 2 { + if bubble.ModelInfo != nil && bubble.ModelInfo.ModelName != "" { + modelName = bubble.ModelInfo.ModelName + } else if composerModelName != "" { + modelName = composerModelName + } + } + + // Build message with model and mode info + message := schema.Message{ + Role: getRoleFromType(bubble.Type), + Timestamp: timestamp, + Model: modelName, + Content: []schema.ContentPart{ + { + Type: schema.ContentTypeText, + Text: messageText, + }, + }, + } + + // Populate message.Tool for tool bubbles so telemetry stats can count them. + // The formatted markdown is already in Content; Tool carries structured metadata. + if bubble.CapabilityType == 15 { + if toolData := resolveToolData(&bubble, capabilitiesMap, composer.Version); toolData != nil && toolData.Name != "" { + toolType := schema.ToolTypeUnknown + if handler := toolRegistry.GetHandler(toolData.Name); handler != nil { + toolType = toSchemaToolType(handler.GetToolType()) + } + message.Tool = &schema.ToolInfo{ + Name: toolData.Name, + Type: toolType, + } + } + } + + // Add mode to metadata for agent messages + if bubble.Type == 2 && bubble.UnifiedMode != 0 { + message.Metadata = map[string]interface{}{ + "mode": unifiedModeToString(bubble.UnifiedMode), + } + } + + // If this is a user message and we have pending messages, flush the current exchange + // and start a new one keyed by this user bubble. + if bubble.Type == 1 && len(currentMessages) > 0 { + exchange := schema.Exchange{ + ExchangeID: currentExchangeID, + Messages: currentMessages, + } + sessionData.Exchanges = append(sessionData.Exchanges, exchange) + currentMessages = nil + currentExchangeID = bubble.BubbleID + } + + // Record the exchange ID from the first user bubble of each exchange. + if bubble.Type == 1 && currentExchangeID == "" { + currentExchangeID = bubble.BubbleID + } + + currentMessages = append(currentMessages, message) + } + + // Create final exchange if there are remaining messages + if len(currentMessages) > 0 { + exchange := schema.Exchange{ + ExchangeID: currentExchangeID, + Messages: currentMessages, + } + sessionData.Exchanges = append(sessionData.Exchanges, exchange) + } + + // Marshal to JSON for raw data + rawDataJSON, err := json.Marshal(composer) + if err != nil { + return nil, fmt.Errorf("failed to marshal composer to JSON: %w", err) + } + + slog.Debug("Converted composer to AgentChatSession", + "composerID", sessionID, + "slug", slug, + "exchanges", len(sessionData.Exchanges)) + + return &spi.AgentChatSession{ + SessionID: sessionID, + CreatedAt: createdAt, + Slug: slug, + SessionData: sessionData, + RawData: string(rawDataJSON), + }, nil +} + +// generateSlug creates a slug from the composer name or first user message +func generateSlug(composer *ComposerData) string { + // Use composer name if available + if composer.Name != "" { + return slugify(composer.Name) + } + + // Otherwise, use first user message + for _, bubble := range composer.Conversation { + if bubble.Type == 1 && bubble.Text != "" { + // Take first 4 words + return slugifyText(bubble.Text, 4) + } + } + + // Fallback to composer ID + return composer.ComposerID +} + +// slugify converts a string to a slug while preserving casing +// The casing is preserved for use in titles, and will be lowercased for filenames by the caller +func slugify(s string) string { + // Replace spaces with hyphens, keep original casing + s = strings.ReplaceAll(s, " ", "-") + return s +} + +// slugifyText converts text to a slug using the first N words +func slugifyText(text string, wordCount int) string { + words := strings.Fields(text) + if len(words) > wordCount { + words = words[:wordCount] + } + return slugify(strings.Join(words, " ")) +} + +// getRoleFromType converts Cursor's message type to schema role +func getRoleFromType(messageType int) string { + if messageType == 1 { + return schema.RoleUser + } + return schema.RoleAgent +} + +// buildMessageText constructs the full message text including thinking blocks and tool invocations +func buildMessageText(bubble *ComposerConversation, capabilitiesMap map[int]*CapabilityData, toolRegistry *ToolRegistry, composerVersion int) string { + var parts []string + + // Add thinking block if present + if bubble.Thinking != nil && bubble.Thinking.Text != "" { + thinkingBlock := fmt.Sprintf("
Thought Process\n%s
", bubble.Thinking.Text) + parts = append(parts, thinkingBlock) + } + + // Process tool invocations if this is a tool capability (capabilityType = 15) + if bubble.CapabilityType == 15 { + toolText := processToolInvocation(bubble, capabilitiesMap, toolRegistry, composerVersion) + if toolText != "" { + parts = append(parts, toolText) + } + } else if bubble.Text != "" { + // Add main text if present (non-tool bubbles) + parts = append(parts, bubble.Text) + } + + return strings.Join(parts, "\n\n---\n\n") +} + +// resolveToolData finds the BubbleConversation for a tool bubble. +// V3+ stores it in bubble.ToolFormerData; V1 stores it in the capabilities map. +// Returns nil if the tool data cannot be found. +func resolveToolData(bubble *ComposerConversation, capabilitiesMap map[int]*CapabilityData, composerVersion int) *BubbleConversation { + if composerVersion >= 3 && bubble.ToolFormerData != nil { + return convertToolInvocationData(bubble.ToolFormerData) + } + if capData, ok := capabilitiesMap[bubble.CapabilityType]; ok { + if capData.ParsedBubbleMap != nil { + if bubbleData, found := capData.ParsedBubbleMap[bubble.BubbleID]; found { + return bubbleData + } + } + } + return nil +} + +// processToolInvocation processes a tool invocation bubble and returns formatted markdown +func processToolInvocation(bubble *ComposerConversation, capabilitiesMap map[int]*CapabilityData, toolRegistry *ToolRegistry, composerVersion int) string { + toolData := resolveToolData(bubble, capabilitiesMap, composerVersion) + if toolData == nil { + slog.Warn("Tool invocation data not found", + "bubbleId", bubble.BubbleID, + "capabilityType", bubble.CapabilityType) + return bubble.Text // Fallback to original text + } + + // Format the tool invocation using the registry + return FormatToolInvocation(toolData, toolRegistry) +} + +// toSchemaToolType converts a cursoride ToolType to the schema tool type string. +// The only mapping difference is "mcp" which has no schema equivalent and maps to "generic". +func toSchemaToolType(t ToolType) string { + if t == ToolTypeMCP { + return schema.ToolTypeGeneric + } + return string(t) +} + +// convertToolInvocationData converts ToolInvocationData to BubbleConversation +func convertToolInvocationData(data *ToolInvocationData) *BubbleConversation { + return &BubbleConversation{ + Tool: data.Tool, + Name: data.Name, + RawArgs: data.RawArgs, + Params: data.Params, + Result: data.Result, + Status: data.Status, + Error: data.Error, + AdditionalData: data.AdditionalData, + UserDecision: data.UserDecision, + } +} + +// parseCapabilities parses the capabilities array and extracts tool data +// Returns a map of capabilityType -> CapabilityData +func parseCapabilities(composer *ComposerData) map[int]*CapabilityData { + capabilitiesMap := make(map[int]*CapabilityData) + + for _, capability := range composer.Capabilities { + capData := capability.Data + + // Parse bubbleDataMap if it's a string (JSON) + if capData.BubbleDataMap != nil { + if bubbleMapStr, ok := capData.BubbleDataMap.(string); ok { + // Parse the JSON string into a map + var bubbleMap map[string]*BubbleConversation + if err := json.Unmarshal([]byte(bubbleMapStr), &bubbleMap); err != nil { + slog.Warn("Failed to parse bubbleDataMap", + "capabilityType", capability.Type, + "error", err) + } else { + capData.ParsedBubbleMap = bubbleMap + } + } + } + + capabilitiesMap[capability.Type] = &capData + } + + return capabilitiesMap +} + +// calculateTimestamp calculates the absolute timestamp from timing info +// Based on the TypeScript extension logic: +// Sometimes clientStartTime is relative (< 946684800000 = Jan 1, 2000) +// In that case, calculate absolute time as: clientEndTime - clientStartTime +func calculateTimestamp(timingInfo *TimingInfo) string { + if timingInfo == nil { + return "" + } + + clientStartTime := timingInfo.ClientStartTime + if clientStartTime == 0 { + return "" + } + + // Check if clientStartTime is relative (< 946684800000, which is year 2000) + // e.g., full epoch: 1754311716540, relative: 1234.435 + if clientStartTime < 946684800000 { + // Get clientEndTime (try fields in priority order) + var clientEndTime float64 + if timingInfo.ClientRpcSendTime != 0 { + clientEndTime = timingInfo.ClientRpcSendTime + } else if timingInfo.ClientSettleTime != 0 { + clientEndTime = timingInfo.ClientSettleTime + } else if timingInfo.ClientEndTime != 0 { + clientEndTime = timingInfo.ClientEndTime + } + + // Calculate absolute time + if clientEndTime != 0 { + clientStartTime = clientEndTime - clientStartTime + } + } + + // Convert milliseconds to ISO 8601 + // Split into seconds and fractional part for precise conversion + seconds := int64(clientStartTime / 1000) + nanos := int64((clientStartTime - float64(seconds)*1000) * 1000000) + t := time.Unix(seconds, nanos) + return t.Format(time.RFC3339) +} + +// unifiedModeToString converts Cursor's unifiedMode number to a readable string +// Based on the TypeScript extension logic (ts-extension/core/utils/composer.ts:118-132) +// 1 = Ask, 2 = Agent, 5 = Plan, other = Custom +func unifiedModeToString(unifiedMode int) string { + switch unifiedMode { + case 1: + return "Ask" + case 2: + return "Agent" + case 5: + return "Plan" + default: + if unifiedMode != 0 { + return "Custom" + } + return "" + } +} diff --git a/specstory-cli/pkg/providers/cursoride/database.go b/specstory-cli/pkg/providers/cursoride/database.go new file mode 100644 index 00000000..9910b14c --- /dev/null +++ b/specstory-cli/pkg/providers/cursoride/database.go @@ -0,0 +1,315 @@ +package cursoride + +import ( + "database/sql" + "encoding/json" + "fmt" + "log/slog" + "strings" + + _ "modernc.org/sqlite" // Pure Go SQLite driver +) + +// OpenDatabase opens a SQLite database in read-only mode +func OpenDatabase(dbPath string) (*sql.DB, error) { + // Open in read-only mode + db, err := sql.Open("sqlite", dbPath+"?mode=ro") + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + slog.Debug("Successfully opened database", "path", dbPath) + + // Enable WAL mode for non-blocking reads (using cursorcli's simpler approach) + // This prevents readers from blocking Cursor IDE's writers + if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { + // Log warning but continue - not fatal if WAL fails + slog.Warn("Failed to enable WAL mode", "error", err) + } else { + slog.Debug("Enabled WAL mode for non-blocking reads") + } + + return db, nil +} + +// LoadWorkspaceComposerIDs loads the composer IDs from a workspace database. +// Uses two complementary sources to handle both Cursor 2 and Cursor 3: +// +// 1. "composer.composerData" in ItemTable — Cursor 2 stores all IDs here (allComposers); +// Cursor 3 stores only currently-open tabs (selectedComposerIds). +// +// 2. "workbench.panel.composerChatViewPane.*" in ItemTable — each entry's JSON value +// contains keys of the form "workbench.panel.aichat.view.{UUID}" where the UUID is +// the actual composer ID. This is the complete source in Cursor 3 and also present +// in Cursor 2, so it works across both versions. +func LoadWorkspaceComposerIDs(workspaceDbPath string) ([]string, error) { + db, err := OpenDatabase(workspaceDbPath) + if err != nil { + return nil, fmt.Errorf("failed to open workspace database: %w", err) + } + defer func() { + if closeErr := db.Close(); closeErr != nil { + slog.Warn("Failed to close workspace database", "error", closeErr) + } + }() + + seen := make(map[string]bool) + var composerIDs []string + + // Method 1: composer.composerData key (Cursor 2: allComposers, Cursor 3: selectedComposerIds) + var valueJSON string + err = db.QueryRow("SELECT value FROM ItemTable WHERE key = ?", "composer.composerData").Scan(&valueJSON) + if err != nil && err != sql.ErrNoRows { + slog.Warn("Failed to query workspace composer data", "error", err) + } else if err == nil { + var composerRefs WorkspaceComposerRefs + if jsonErr := json.Unmarshal([]byte(valueJSON), &composerRefs); jsonErr != nil { + slog.Warn("Failed to parse workspace composer refs", "error", jsonErr) + } else { + for _, ref := range composerRefs.AllComposers { + if !seen[ref.ComposerID] { + seen[ref.ComposerID] = true + composerIDs = append(composerIDs, ref.ComposerID) + } + } + for _, id := range composerRefs.SelectedComposerIds { + if !seen[id] { + seen[id] = true + composerIDs = append(composerIDs, id) + } + } + } + } + + // Method 2: workbench.panel.composerChatViewPane entries + // In Cursor 3 these are the authoritative source for all conversation IDs. + // Each JSON value is a map whose keys follow the pattern + // "workbench.panel.aichat.view.{composerUUID}". + // Two key formats exist: + // - "workbench.panel.composerChatViewPane" (no suffix) — the main panel shared by all tabs + // - "workbench.panel.composerChatViewPane.{paneUUID}" — one entry per open tab/pane + rows, queryErr := db.Query( + "SELECT value FROM ItemTable WHERE (key = 'workbench.panel.composerChatViewPane' OR (key LIKE 'workbench.panel.composerChatViewPane.%' AND key NOT LIKE '%.hidden'))", + ) + if queryErr != nil { + slog.Warn("Failed to query workspace panel entries", "error", queryErr) + } else { + defer func() { + if closeErr := rows.Close(); closeErr != nil { + slog.Warn("Failed to close panel query rows", "error", closeErr) + } + }() + + const viewPrefix = "workbench.panel.aichat.view." + for rows.Next() { + var panelJSON string + if scanErr := rows.Scan(&panelJSON); scanErr != nil { + slog.Warn("Failed to scan panel row", "error", scanErr) + continue + } + var panelData map[string]interface{} + if jsonErr := json.Unmarshal([]byte(panelJSON), &panelData); jsonErr != nil { + slog.Warn("Failed to parse panel JSON", "error", jsonErr) + continue + } + for key := range panelData { + if strings.HasPrefix(key, viewPrefix) { + composerID := strings.TrimPrefix(key, viewPrefix) + if !seen[composerID] { + seen[composerID] = true + composerIDs = append(composerIDs, composerID) + } + } + } + } + if rowsErr := rows.Err(); rowsErr != nil { + slog.Warn("Error iterating panel rows", "error", rowsErr) + } + } + + slog.Debug("Loaded composer IDs from workspace", + "count", len(composerIDs), + "composerIDs", composerIDs) + + return composerIDs, nil +} + +// LoadComposerDataBatch loads multiple composers and their bubbles from the global database +// This is the main function for workspace-filtered loading +func LoadComposerDataBatch(globalDbPath string, composerIDs []string) (map[string]*ComposerData, error) { + if len(composerIDs) == 0 { + return map[string]*ComposerData{}, nil + } + + db, err := OpenDatabase(globalDbPath) + if err != nil { + return nil, fmt.Errorf("failed to open global database: %w", err) + } + defer func() { + if closeErr := db.Close(); closeErr != nil { + slog.Warn("Failed to close global database", "error", closeErr) + } + }() + + // Build SQL query with placeholders for composer IDs + // We need to query for both composerData:* keys and bubbleId:* keys + + // Part 1: Build IN clause for composerData keys + composerDataKeys := make([]string, len(composerIDs)) + composerDataPlaceholders := make([]string, len(composerIDs)) + for i, id := range composerIDs { + composerDataKeys[i] = "composerData:" + id + composerDataPlaceholders[i] = "?" + } + + // Part 2: Build LIKE conditions for bubbleId keys + bubbleLikeConditions := make([]string, len(composerIDs)) + for i := range composerIDs { + bubbleLikeConditions[i] = "key LIKE ?" + } + + // Build the complete query + query := fmt.Sprintf(`SELECT key, value FROM cursorDiskKV + WHERE value IS NOT NULL AND ( + key IN (%s) + OR (%s) + )`, + strings.Join(composerDataPlaceholders, ","), + strings.Join(bubbleLikeConditions, " OR ")) + + // Build params array: first all composerData keys, then all bubbleId patterns + params := make([]interface{}, 0, len(composerIDs)*2) + for _, key := range composerDataKeys { + params = append(params, key) + } + for _, id := range composerIDs { + params = append(params, "bubbleId:"+id+":%") + } + + slog.Debug("Executing batch query", + "composerCount", len(composerIDs), + "query", query) + + // Execute the query + rows, err := db.Query(query, params...) + if err != nil { + return nil, fmt.Errorf("failed to query composer data: %w", err) + } + defer func() { + if closeErr := rows.Close(); closeErr != nil { + slog.Warn("Failed to close query rows", "error", closeErr) + } + }() + + // Parse results + composers := make(map[string]*ComposerData) + bubbles := make(map[string]map[string]*ComposerConversation) // composerID -> bubbleID -> bubble + + for rows.Next() { + var key, valueJSON string + if err := rows.Scan(&key, &valueJSON); err != nil { + slog.Warn("Failed to scan row", "error", err) + continue + } + + // Determine key type and parse accordingly + if strings.HasPrefix(key, "composerData:") { + // This is a composer record + composerID := strings.TrimPrefix(key, "composerData:") + + var composer ComposerData + if err := json.Unmarshal([]byte(valueJSON), &composer); err != nil { + slog.Warn("Failed to parse composer data", + "composerID", composerID, + "error", err) + continue + } + + composers[composerID] = &composer + slog.Debug("Loaded composer", + "composerID", composerID, + "name", composer.Name) + + } else if strings.HasPrefix(key, "bubbleId:") { + // This is a bubble record: bubbleId:{composerID}:{bubbleID} + parts := strings.SplitN(key, ":", 3) + if len(parts) != 3 { + slog.Warn("Invalid bubble key format", "key", key) + continue + } + + composerID := parts[1] + bubbleID := parts[2] + + var bubble ComposerConversation + if err := json.Unmarshal([]byte(valueJSON), &bubble); err != nil { + slog.Warn("Failed to parse bubble data", + "composerID", composerID, + "bubbleID", bubbleID, + "error", err) + continue + } + + // Initialize bubble map for this composer if needed + if bubbles[composerID] == nil { + bubbles[composerID] = make(map[string]*ComposerConversation) + } + bubbles[composerID][bubbleID] = &bubble + + slog.Debug("Loaded bubble", + "composerID", composerID, + "bubbleID", bubbleID, + "type", bubble.Type) + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating rows: %w", err) + } + + // Assemble conversations: merge bubbles into composers + for composerID, composer := range composers { + if composerBubbles, exists := bubbles[composerID]; exists { + // If composer has fullConversationHeadersOnly, load those bubbles into conversation array + if len(composer.FullConversationHeadersOnly) > 0 { + composer.Conversation = make([]ComposerConversation, 0, len(composer.FullConversationHeadersOnly)) + for _, header := range composer.FullConversationHeadersOnly { + if bubble, found := composerBubbles[header.BubbleID]; found { + composer.Conversation = append(composer.Conversation, *bubble) + } + } + } else if len(composer.Conversation) > 0 { + // Composer has a conversation array - merge individual bubble data into it + // The composerData record may have basic bubble info, but individual bubble + // records have the complete data (including thinking blocks) + for i := range composer.Conversation { + bubbleID := composer.Conversation[i].BubbleID + if fullBubble, found := composerBubbles[bubbleID]; found { + // Merge the full bubble data into the conversation array + // Keep the original if fields are not set in the full bubble + if fullBubble.Thinking != nil { + composer.Conversation[i].Thinking = fullBubble.Thinking + } + if fullBubble.Text != "" { + composer.Conversation[i].Text = fullBubble.Text + } + if fullBubble.TimingInfo != nil { + composer.Conversation[i].TimingInfo = fullBubble.TimingInfo + } + if fullBubble.ModelInfo != nil { + composer.Conversation[i].ModelInfo = fullBubble.ModelInfo + } + if fullBubble.ToolFormerData != nil { + composer.Conversation[i].ToolFormerData = fullBubble.ToolFormerData + } + } + } + } + } + } + + slog.Info("Loaded composers from global database", + "composerCount", len(composers)) + + return composers, nil +} diff --git a/specstory-cli/pkg/providers/cursoride/path_utils.go b/specstory-cli/pkg/providers/cursoride/path_utils.go new file mode 100644 index 00000000..44f5eb3b --- /dev/null +++ b/specstory-cli/pkg/providers/cursoride/path_utils.go @@ -0,0 +1,95 @@ +package cursoride + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "runtime" + "strings" +) + +// GetGlobalDatabasePath finds the Cursor IDE global database +// Returns the path to state.vscdb in Cursor's globalStorage +func GetGlobalDatabasePath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + + // Try multiple possible locations for the global database + var possiblePaths []string + + switch runtime.GOOS { + case "darwin": + // macOS: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb (primary location) + possiblePaths = append(possiblePaths, + filepath.Join(homeDir, "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb")) + // Also try extension location (legacy/fallback) + possiblePaths = append(possiblePaths, + filepath.Join(homeDir, ".cursor", "extensions", "cursor-context-manager-*", "globalStorage", "cursor-context-manager", "state.vscdb")) + case "linux": + // Linux: ~/.config/Cursor/User/globalStorage/state.vscdb (primary location) + possiblePaths = append(possiblePaths, + filepath.Join(homeDir, ".config", "Cursor", "User", "globalStorage", "state.vscdb")) + // Also try extension location (legacy/fallback) + possiblePaths = append(possiblePaths, + filepath.Join(homeDir, ".cursor", "extensions", "cursor-context-manager-*", "globalStorage", "cursor-context-manager", "state.vscdb")) + default: + return "", fmt.Errorf("unsupported operating system: %s (only macOS and Linux are supported)", runtime.GOOS) + } + + // Try each possible path + for _, path := range possiblePaths { + // If path contains glob pattern, expand it + if strings.Contains(path, "*") { + matches, err := filepath.Glob(path) + if err == nil && len(matches) > 0 { + // Use first match + if _, err := os.Stat(matches[0]); err == nil { + slog.Debug("Found Cursor IDE global database", "path", matches[0]) + return matches[0], nil + } + } + } else { + // Direct path, check if it exists + if _, err := os.Stat(path); err == nil { + slog.Debug("Found Cursor IDE global database", "path", path) + return path, nil + } + } + } + + return "", fmt.Errorf("global database not found in any of the expected locations") +} + +// GetWorkspaceStoragePath returns the OS-specific workspace storage directory +func GetWorkspaceStoragePath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + + var workspaceStoragePath string + switch runtime.GOOS { + case "darwin": + // macOS: ~/Library/Application Support/Cursor/User/workspaceStorage/ + workspaceStoragePath = filepath.Join(homeDir, "Library", "Application Support", "Cursor", "User", "workspaceStorage") + case "linux": + // Linux: ~/.config/Cursor/User/workspaceStorage/ + workspaceStoragePath = filepath.Join(homeDir, ".config", "Cursor", "User", "workspaceStorage") + default: + return "", fmt.Errorf("unsupported operating system: %s (only macOS and Linux are supported)", runtime.GOOS) + } + + // Check if the workspace storage directory exists + if _, err := os.Stat(workspaceStoragePath); err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("workspace storage directory not found at %s (has Cursor IDE been used?)", workspaceStoragePath) + } + return "", fmt.Errorf("failed to access workspace storage directory: %w", err) + } + + slog.Debug("Found Cursor IDE workspace storage", "path", workspaceStoragePath) + return workspaceStoragePath, nil +} diff --git a/specstory-cli/pkg/providers/cursoride/provider.go b/specstory-cli/pkg/providers/cursoride/provider.go new file mode 100644 index 00000000..0523de08 --- /dev/null +++ b/specstory-cli/pkg/providers/cursoride/provider.go @@ -0,0 +1,465 @@ +package cursoride + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "time" + + "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi" +) + +// Provider implements the SPI Provider interface for Cursor IDE +type Provider struct{} + +// NewProvider creates a new Cursor IDE provider instance +func NewProvider() *Provider { + return &Provider{} +} + +// Name returns the human-readable name of this provider +func (p *Provider) Name() string { + return "Cursor IDE" +} + +// Check verifies Cursor IDE database exists and returns info +func (p *Provider) Check(customCommand string) spi.CheckResult { + slog.Debug("Check: Checking Cursor IDE installation") + + // Check for global database + globalDbPath, err := GetGlobalDatabasePath() + if err != nil { + return spi.CheckResult{ + Success: false, + Version: "", + Location: "", + ErrorMessage: fmt.Sprintf("Cursor IDE global database not found: %v", err), + } + } + + // Try to open the database + db, err := OpenDatabase(globalDbPath) + if err != nil { + return spi.CheckResult{ + Success: false, + Version: "", + Location: globalDbPath, + ErrorMessage: fmt.Sprintf("Failed to open global database: %v", err), + } + } + defer func() { + if closeErr := db.Close(); closeErr != nil { + slog.Warn("Failed to close database during check", "error", closeErr) + } + }() + + slog.Debug("Cursor IDE check successful", "dbPath", globalDbPath) + + return spi.CheckResult{ + Success: true, + Version: "Cursor IDE", + Location: globalDbPath, + ErrorMessage: "", + } +} + +// DetectAgent checks if Cursor IDE has been used in the given project path +func (p *Provider) DetectAgent(projectPath string, helpOutput bool) bool { + slog.Debug("DetectAgent: Checking for Cursor IDE activity", "projectPath", projectPath) + + // Check if global database exists + globalDbPath, err := GetGlobalDatabasePath() + if err != nil { + slog.Debug("Global database not found", "error", err) + return false + } + + // Try to find workspace for project + workspace, err := FindWorkspaceForProject(projectPath) + if err != nil { + slog.Debug("No workspace found for project", "projectPath", projectPath, "error", err) + if helpOutput { + fmt.Println("\n❌ No Cursor IDE workspace found for this project") + fmt.Printf(" • Project path: %s\n", projectPath) + fmt.Printf(" • Global database: %s\n", globalDbPath) + fmt.Println(" • Cursor IDE needs to be opened in this directory at least once") + fmt.Println() + } + return false + } + + // Check if workspace has any composers + composerIDs, err := LoadWorkspaceComposerIDs(workspace.DBPath) + if err != nil { + slog.Debug("Failed to load composer IDs", "error", err) + return false + } + + if len(composerIDs) == 0 { + slog.Debug("No composers found in workspace") + if helpOutput { + fmt.Println("\n⚠️ Cursor IDE workspace found but no conversations yet") + fmt.Printf(" • Workspace ID: %s\n", workspace.ID) + fmt.Printf(" • Use Cursor IDE's Composer feature to create conversations\n") + fmt.Println() + } + return false + } + + slog.Debug("Cursor IDE activity detected", + "workspaceID", workspace.ID, + "composerCount", len(composerIDs)) + return true +} + +// GetAgentChatSessions retrieves all chat sessions for the given project path +func (p *Provider) GetAgentChatSessions(projectPath string, debugRaw bool, progress spi.ProgressCallback) ([]spi.AgentChatSession, error) { + slog.Info("GetAgentChatSessions: Loading Cursor IDE sessions", + "projectPath", projectPath, + "debugRaw", debugRaw) + + // Step 1: Find workspace for project path + workspace, err := FindWorkspaceForProject(projectPath) + if err != nil { + return nil, fmt.Errorf("failed to find workspace for project: %w", err) + } + + slog.Info("Found workspace for project", + "workspaceID", workspace.ID, + "projectPath", projectPath) + + // Step 2: Load composer IDs from workspace database + composerIDs, err := LoadWorkspaceComposerIDs(workspace.DBPath) + if err != nil { + return nil, fmt.Errorf("failed to load composer IDs from workspace: %w", err) + } + + slog.Info("Loaded composer IDs from workspace", + "count", len(composerIDs)) + + if len(composerIDs) == 0 { + slog.Info("No composers found in workspace") + return []spi.AgentChatSession{}, nil + } + + // Step 3: Get global database path + globalDbPath, err := GetGlobalDatabasePath() + if err != nil { + return nil, fmt.Errorf("failed to get global database path: %w", err) + } + + // Step 4: Load composer data from global database + composers, err := LoadComposerDataBatch(globalDbPath, composerIDs) + if err != nil { + return nil, fmt.Errorf("failed to load composer data: %w", err) + } + + slog.Info("Loaded composers from global database", + "count", len(composers)) + + // Step 5: Convert to AgentChatSessions + sessions := make([]spi.AgentChatSession, 0, len(composers)) + processedCount := 0 + totalCount := len(composers) + + for composerID, composer := range composers { + // Skip empty conversations + if len(composer.Conversation) == 0 { + slog.Debug("Skipping composer with no conversation", + "composerID", composerID) + continue + } + + session, err := ConvertToAgentChatSession(composer) + if err != nil { + slog.Warn("Failed to convert composer to session", + "composerID", composerID, + "error", err) + continue + } + + // Write debug output if requested + if debugRaw { + if err := writeDebugOutput(session); err != nil { + slog.Warn("Failed to write debug output", + "sessionID", session.SessionID, + "error", err) + // Don't fail the operation if debug output fails + } + } + + sessions = append(sessions, *session) + + // Report progress + processedCount++ + if progress != nil { + progress(processedCount, totalCount) + } + } + + slog.Info("Converted sessions", + "totalComposers", len(composers), + "sessionCount", len(sessions)) + + return sessions, nil +} + +// GetAgentChatSession retrieves a single chat session by ID for the given project path +func (p *Provider) GetAgentChatSession(projectPath string, sessionID string, debugRaw bool) (*spi.AgentChatSession, error) { + slog.Debug("GetAgentChatSession: Loading single session", + "projectPath", projectPath, + "sessionID", sessionID, + "debugRaw", debugRaw) + + // Step 1: Find workspace for project path + workspace, err := FindWorkspaceForProject(projectPath) + if err != nil { + slog.Debug("No workspace found for project", "error", err) + return nil, nil // Return nil (not error) if workspace not found + } + + slog.Debug("Found workspace for project", + "workspaceID", workspace.ID, + "projectPath", projectPath) + + // Step 2: Load composer IDs from workspace database + composerIDs, err := LoadWorkspaceComposerIDs(workspace.DBPath) + if err != nil { + return nil, fmt.Errorf("failed to load composer IDs from workspace: %w", err) + } + + slog.Debug("Loaded composer IDs from workspace", "count", len(composerIDs)) + + // Step 3: Check if the requested session ID exists in this workspace + found := false + for _, id := range composerIDs { + if id == sessionID { + found = true + break + } + } + + if !found { + slog.Debug("Session ID not found in workspace", + "sessionID", sessionID, + "workspaceComposerCount", len(composerIDs)) + return nil, nil // Return nil (not error) if session not in this workspace + } + + // Step 4: Get global database path + globalDbPath, err := GetGlobalDatabasePath() + if err != nil { + return nil, fmt.Errorf("failed to get global database path: %w", err) + } + + // Step 5: Load only the requested composer from global database + composers, err := LoadComposerDataBatch(globalDbPath, []string{sessionID}) + if err != nil { + return nil, fmt.Errorf("failed to load composer data: %w", err) + } + + // Step 6: Check if we got the composer + composer, exists := composers[sessionID] + if !exists { + slog.Warn("Composer not found in global database despite being in workspace", + "sessionID", sessionID) + return nil, nil // Return nil (not error) if not found in global DB + } + + // Skip if conversation is empty + if len(composer.Conversation) == 0 { + slog.Debug("Skipping composer with no conversation", "sessionID", sessionID) + return nil, nil + } + + // Step 7: Convert to AgentChatSession + session, err := ConvertToAgentChatSession(composer) + if err != nil { + return nil, fmt.Errorf("failed to convert composer to session: %w", err) + } + + // Step 8: Write debug output if requested + if debugRaw { + if err := writeDebugOutput(session); err != nil { + slog.Warn("Failed to write debug output", + "sessionID", session.SessionID, + "error", err) + // Don't fail the operation if debug output fails + } + } + + slog.Info("Successfully loaded single session", + "sessionID", sessionID, + "slug", session.Slug) + + return session, nil +} + +// ListAgentChatSessions retrieves lightweight metadata for all sessions +func (p *Provider) ListAgentChatSessions(projectPath string) ([]spi.SessionMetadata, error) { + slog.Debug("ListAgentChatSessions: Loading Cursor IDE session list", + "projectPath", projectPath) + + // Step 1: Find workspace for project path + workspace, err := FindWorkspaceForProject(projectPath) + if err != nil { + slog.Debug("No workspace found for project", "error", err) + return []spi.SessionMetadata{}, nil // Return empty list if no workspace + } + + slog.Debug("Found workspace for project", + "workspaceID", workspace.ID, + "projectPath", projectPath) + + // Step 2: Load composer IDs from workspace database + composerIDs, err := LoadWorkspaceComposerIDs(workspace.DBPath) + if err != nil { + return nil, fmt.Errorf("failed to load composer IDs from workspace: %w", err) + } + + slog.Debug("Loaded composer IDs from workspace", "count", len(composerIDs)) + + if len(composerIDs) == 0 { + slog.Debug("No composers found in workspace") + return []spi.SessionMetadata{}, nil + } + + // Step 3: Get global database path + globalDbPath, err := GetGlobalDatabasePath() + if err != nil { + return nil, fmt.Errorf("failed to get global database path: %w", err) + } + + // Step 4: Load composer data from global database + composers, err := LoadComposerDataBatch(globalDbPath, composerIDs) + if err != nil { + return nil, fmt.Errorf("failed to load composer data: %w", err) + } + + slog.Debug("Loaded composers from global database", "count", len(composers)) + + // Step 5: Extract metadata for each composer + metadataList := make([]spi.SessionMetadata, 0, len(composers)) + for composerID, composer := range composers { + // Skip empty conversations + if len(composer.Conversation) == 0 { + slog.Debug("Skipping composer with no conversation", "composerID", composerID) + continue + } + + metadata := extractCursorIDESessionMetadata(composer) + metadataList = append(metadataList, metadata) + } + + slog.Info("Listed Cursor IDE sessions", + "totalComposers", len(composers), + "sessionCount", len(metadataList)) + + return metadataList, nil +} + +// ExecAgentAndWatch is not supported for Cursor IDE (IDE-based, not CLI) +func (p *Provider) ExecAgentAndWatch(projectPath string, customCommand string, resumeSessionID string, debugRaw bool, sessionCallback func(*spi.AgentChatSession)) error { + return fmt.Errorf("cursor IDE does not support execution via CLI (IDE-based, not CLI-based)") +} + +// WatchAgent watches for Cursor IDE activity and calls the callback with AgentChatSession +func (p *Provider) WatchAgent(ctx context.Context, projectPath string, debugRaw bool, sessionCallback func(*spi.AgentChatSession)) error { + slog.Info("WatchAgent: Starting Cursor IDE activity monitoring", + "projectPath", projectPath, + "debugRaw", debugRaw) + + // Check interval for polling the database + checkInterval := 2 * time.Minute + + // Create and start watcher + watcher, err := NewCursorIDEWatcher(projectPath, debugRaw, sessionCallback, checkInterval) + if err != nil { + return fmt.Errorf("failed to create watcher: %w", err) + } + + if err := watcher.Start(); err != nil { + return fmt.Errorf("failed to start watcher: %w", err) + } + + // Wait for context cancellation + <-ctx.Done() + + // Stop watcher gracefully + watcher.Stop() + + slog.Info("WatchAgent: Stopped Cursor IDE activity monitoring") + return nil +} + +// writeDebugOutput writes debug JSON files for a Cursor IDE session +func writeDebugOutput(session *spi.AgentChatSession) error { + // Get the debug directory path + debugDir := spi.GetDebugDir(session.SessionID) + + // Create the debug directory + if err := os.MkdirAll(debugDir, 0755); err != nil { + return fmt.Errorf("failed to create debug directory: %w", err) + } + + // Write raw composer data + rawComposerPath := filepath.Join(debugDir, "raw-composer.json") + if err := os.WriteFile(rawComposerPath, []byte(session.RawData), 0644); err != nil { + return fmt.Errorf("failed to write raw composer data: %w", err) + } + + slog.Debug("Wrote debug output", + "sessionID", session.SessionID, + "path", debugDir) + + return nil +} + +// extractCursorIDESessionMetadata extracts lightweight session metadata from a ComposerData +// without fully parsing the conversation +func extractCursorIDESessionMetadata(composer *ComposerData) spi.SessionMetadata { + // Use composer ID as session ID + sessionID := composer.ComposerID + + // Convert timestamp (milliseconds to ISO 8601) + var createdAt string + if composer.CreatedAt > 0 { + t := time.Unix(composer.CreatedAt/1000, (composer.CreatedAt%1000)*1000000) + createdAt = t.Format(time.RFC3339) + } else { + createdAt = time.Now().Format(time.RFC3339) + } + + // Generate slug from composer name or first user message (using existing logic) + slug := generateSlug(composer) + + // Generate human-readable name + name := generateCursorIDESessionName(composer) + + return spi.SessionMetadata{ + SessionID: sessionID, + CreatedAt: createdAt, + Slug: slug, + Name: name, + } +} + +// generateCursorIDESessionName creates a human-readable session name from composer data +func generateCursorIDESessionName(composer *ComposerData) string { + // Prefer composer name if available (it's already human-readable) + if composer.Name != "" { + return spi.GenerateReadableName(composer.Name) + } + + // Otherwise, use first user message + for _, bubble := range composer.Conversation { + if bubble.Type == 1 && bubble.Text != "" { + return spi.GenerateReadableName(bubble.Text) + } + } + + // Fallback to empty string (shouldn't happen with non-empty conversations) + return "" +} diff --git a/specstory-cli/pkg/providers/cursoride/tool_file_search.go b/specstory-cli/pkg/providers/cursoride/tool_file_search.go new file mode 100644 index 00000000..eb3e467d --- /dev/null +++ b/specstory-cli/pkg/providers/cursoride/tool_file_search.go @@ -0,0 +1,80 @@ +package cursoride + +import ( + "encoding/json" + "fmt" +) + +// FileSearchHandler handles file_search tool invocations +type FileSearchHandler struct{} + +// FileSearchRawArgs represents the raw arguments for file_search tool +type FileSearchRawArgs struct { + Query string `json:"query"` + Explanation string `json:"explanation,omitempty"` +} + +// FileSearchResult represents the result of file_search tool +type FileSearchResult struct { + Files []FileSearchFile `json:"files"` + LimitHit bool `json:"limitHit,omitempty"` + NumResults int `json:"numResults,omitempty"` +} + +// FileSearchFile represents a file found by file_search +type FileSearchFile struct { + URI string `json:"uri,omitempty"` + Name string `json:"name,omitempty"` +} + +// AdaptMessage formats the file_search tool invocation as markdown +func (h *FileSearchHandler) AdaptMessage(bubble *BubbleConversation) (string, error) { + // Parse raw args to get the query + var rawArgs FileSearchRawArgs + if bubble.RawArgs != "" { + if err := json.Unmarshal([]byte(bubble.RawArgs), &rawArgs); err != nil { + return "", fmt.Errorf("failed to parse file_search rawArgs: %w", err) + } + } + + // Parse result to get the file list + var result FileSearchResult + if bubble.Result != "" { + if err := json.Unmarshal([]byte(bubble.Result), &result); err != nil { + return "", fmt.Errorf("failed to parse file_search result: %w", err) + } + } + + resultsLength := len(result.Files) + + // Build the markdown message + message := fmt.Sprintf(`
+Tool use: **%s** • Searched codebase "%s" • **%d** results +`, bubble.Name, rawArgs.Query, resultsLength) + + if resultsLength == 0 { + message += "\nNo results found" + } else { + // Add table header + message += "\n| File |\n|------|\n" + + // Add table rows + for _, file := range result.Files { + // Use name if available, otherwise use URI + displayName := file.Name + if displayName == "" { + displayName = file.URI + } + message += fmt.Sprintf("| `%s` |\n", displayName) + } + } + + message += "\n
" + + return message, nil +} + +// GetToolType returns the tool type category +func (h *FileSearchHandler) GetToolType() ToolType { + return ToolTypeSearch +} diff --git a/specstory-cli/pkg/providers/cursoride/tool_glob_file_search.go b/specstory-cli/pkg/providers/cursoride/tool_glob_file_search.go new file mode 100644 index 00000000..a599a31d --- /dev/null +++ b/specstory-cli/pkg/providers/cursoride/tool_glob_file_search.go @@ -0,0 +1,119 @@ +package cursoride + +import ( + "encoding/json" + "fmt" +) + +// GlobFileSearchHandler handles glob_file_search tool invocations +type GlobFileSearchHandler struct{} + +// GlobFileSearchRawArgs represents the raw arguments for glob_file_search tool +type GlobFileSearchRawArgs struct { + GlobPattern string `json:"glob_pattern"` +} + +// GlobFileSearchResult represents the result of glob_file_search tool +type GlobFileSearchResult struct { + Directories []GlobSearchDirectory `json:"directories"` +} + +// GlobSearchDirectory represents a directory with matching files +type GlobSearchDirectory struct { + AbsPath string `json:"absPath"` + Files []GlobSearchFile `json:"files"` + TotalFiles int `json:"totalFiles"` +} + +// GlobSearchFile represents a file found by glob search +type GlobSearchFile struct { + RelPath string `json:"relPath,omitempty"` + Name string `json:"name,omitempty"` + URI string `json:"uri,omitempty"` +} + +// AdaptMessage formats the glob_file_search tool invocation as markdown +func (h *GlobFileSearchHandler) AdaptMessage(bubble *BubbleConversation) (string, error) { + // Parse raw args to get the glob pattern + var rawArgs GlobFileSearchRawArgs + if bubble.RawArgs != "" { + if err := json.Unmarshal([]byte(bubble.RawArgs), &rawArgs); err != nil { + return "", fmt.Errorf("failed to parse glob_file_search rawArgs: %w", err) + } + } + + // Parse result to get the directories and files + var result GlobFileSearchResult + if bubble.Result != "" { + if err := json.Unmarshal([]byte(bubble.Result), &result); err != nil { + return "", fmt.Errorf("failed to parse glob_file_search result: %w", err) + } + } + + // Calculate total results and directories + resultsLength := 0 + for _, directory := range result.Directories { + resultsLength += directory.TotalFiles + } + directoriesCount := len(result.Directories) + + // Build the details message + var messageDetails string + if directoriesCount == 0 { + messageDetails += "\nNo results found" + } else { + for _, directory := range result.Directories { + filesCount := directory.TotalFiles + pluralSuffix := "" + if filesCount != 1 { + pluralSuffix = "s" + } + + // Add directory name + messageDetails += fmt.Sprintf("\nDirectory: **%s** (%d file%s)\n", directory.AbsPath, filesCount, pluralSuffix) + + if len(directory.Files) > 0 { + // Add table header + messageDetails += "\n| File |\n|------|\n" + + // Add table rows + for _, file := range directory.Files { + // Use relPath first, then name, then uri, then fallback + displayName := file.RelPath + if displayName == "" { + displayName = file.Name + } + if displayName == "" { + displayName = file.URI + } + if displayName == "" { + displayName = "Unknown file" + } + messageDetails += fmt.Sprintf("| `%s` |\n", displayName) + } + } + } + } + + // Build the summary line + resultsPluralSuffix := "" + if resultsLength != 1 { + resultsPluralSuffix = "s" + } + directoriesPluralSuffix := "directory" + if directoriesCount != 1 { + directoriesPluralSuffix = "directories" + } + + message := fmt.Sprintf(`
+Tool use: **%s** • Searched codebase "%s" • **%d** result%s in **%d** %s +%s +
`, bubble.Name, rawArgs.GlobPattern, resultsLength, resultsPluralSuffix, directoriesCount, directoriesPluralSuffix, messageDetails) + + return message, nil +} + +// GetToolType returns the tool type category +func (h *GlobFileSearchHandler) GetToolType() ToolType { + return ToolTypeSearch +} diff --git a/specstory-cli/pkg/providers/cursoride/tool_grep.go b/specstory-cli/pkg/providers/cursoride/tool_grep.go new file mode 100644 index 00000000..95cac061 --- /dev/null +++ b/specstory-cli/pkg/providers/cursoride/tool_grep.go @@ -0,0 +1,245 @@ +package cursoride + +import ( + "encoding/json" + "fmt" + "strings" +) + +// GrepHandler handles grep and ripgrep tool invocations +type GrepHandler struct{} + +// GrepParams represents parameters for grep tools +type GrepParams struct { + Pattern string `json:"pattern"` + Path string `json:"path,omitempty"` + OutputMode string `json:"outputMode,omitempty"` + CaseInsensitive bool `json:"caseInsensitive,omitempty"` +} + +// GrepResult represents the result of a grep operation +type GrepResult struct { + Success *GrepSuccess `json:"success,omitempty"` +} + +// GrepSuccess contains the successful grep results +type GrepSuccess struct { + Pattern string `json:"pattern"` + Path string `json:"path,omitempty"` + OutputMode string `json:"outputMode,omitempty"` + WorkspaceResults map[string]*GrepWorkspaceResult `json:"workspaceResults,omitempty"` +} + +// GrepWorkspaceResult contains results for a specific workspace +type GrepWorkspaceResult struct { + Content *GrepContentResult `json:"content,omitempty"` + Files *GrepFilesResult `json:"files,omitempty"` +} + +// GrepContentResult contains content matches with line numbers +type GrepContentResult struct { + Matches []GrepFileMatch `json:"matches,omitempty"` + TotalLines int `json:"totalLines,omitempty"` + TotalMatchedLines int `json:"totalMatchedLines,omitempty"` +} + +// GrepFileMatch represents matches in a single file +type GrepFileMatch struct { + File string `json:"file,omitempty"` + Matches []GrepMatch `json:"matches,omitempty"` +} + +// GrepMatch represents a single match with line number +type GrepMatch struct { + LineNumber int `json:"lineNumber"` + Content string `json:"content,omitempty"` + IsContextLine bool `json:"isContextLine,omitempty"` +} + +// GrepFilesResult contains just file names (for files_with_matches mode) +type GrepFilesResult struct { + Files []string `json:"files,omitempty"` + TotalFiles int `json:"totalFiles,omitempty"` +} + +// AdaptMessage formats grep tool invocations as markdown +func (h *GrepHandler) AdaptMessage(bubble *BubbleConversation) (string, error) { + var params GrepParams + if bubble.Params != "" { + if err := json.Unmarshal([]byte(bubble.Params), ¶ms); err != nil { + return "", fmt.Errorf("failed to parse grep params: %w", err) + } + } + + var result GrepResult + if bubble.Result != "" { + if err := json.Unmarshal([]byte(bubble.Result), &result); err != nil { + return "", fmt.Errorf("failed to parse grep result: %w", err) + } + } + + // Extract results + var resultsLength int + var messageDetails string + + if result.Success != nil && result.Success.WorkspaceResults != nil { + // Get the first (and typically only) workspace result + for _, workspaceResult := range result.Success.WorkspaceResults { + if workspaceResult.Content != nil { + // Content mode: show matches in a table + resultsLength = workspaceResult.Content.TotalMatchedLines + if resultsLength == 0 { + messageDetails = "\n_No matches found_" + } else { + messageDetails = h.formatContentResults(workspaceResult.Content) + } + } else if workspaceResult.Files != nil { + // Files mode: show file names in a table + resultsLength = workspaceResult.Files.TotalFiles + if resultsLength == 0 { + messageDetails = "\n_No matches found_" + } else { + messageDetails = h.formatFilesResults(workspaceResult.Files) + } + } + break // Only process the first workspace + } + } else { + messageDetails = "\n_No matches found_" + } + + // Build the message + var message strings.Builder + message.WriteString("
\n") + + // Build summary line + inString := "" + if params.Path != "" { + inString = fmt.Sprintf(` in "%s"`, params.Path) + } + matchWord := "match" + if resultsLength != 1 { + matchWord = "matches" + } + fmt.Fprintf(&message, "Tool use: **%s** • Grep for \"%s\"%s • %d %s\n\n", bubble.Name, params.Pattern, inString, resultsLength, matchWord) + + // Add output mode + outputMode := params.OutputMode + if outputMode == "" { + outputMode = "content" + } + fmt.Fprintf(&message, "Output mode: %s\n", outputMode) + + // Add details + fmt.Fprintf(&message, "\n%s\n", messageDetails) + + message.WriteString("\n
") + return message.String(), nil +} + +// formatContentResults formats content matches as a markdown table +func (h *GrepHandler) formatContentResults(content *GrepContentResult) string { + if len(content.Matches) == 0 { + return "\n_No matches found_" + } + + // Check if we have file names in the results + hasFiles := false + for _, fileMatch := range content.Matches { + if fileMatch.File != "" { + hasFiles = true + break + } + } + + var result strings.Builder + + // Build table header + if hasFiles { + result.WriteString("\n| File | Content | Line |\n|------|------|------|\n") + } else { + result.WriteString("\n| Content | Line |\n|------|------|\n") + } + + // Build table rows + for _, fileMatch := range content.Matches { + for _, match := range fileMatch.Matches { + // Skip context lines + if match.IsContextLine { + continue + } + // Skip matches without content (empty lines) + if match.Content == "" { + continue + } + + // Add file column if needed + if hasFiles { + fmt.Fprintf(&result, "| `%s` ", escapeTableCellValue(fileMatch.File)) + } + + // Add content and line number + fmt.Fprintf(&result, "| `%s` | L%d |\n", + escapeTableCellValue(match.Content), + match.LineNumber) + } + } + + return result.String() +} + +// formatFilesResults formats file matches as a markdown table +func (h *GrepHandler) formatFilesResults(files *GrepFilesResult) string { + if len(files.Files) == 0 { + return "\n_No matches found_" + } + + var result strings.Builder + result.WriteString("\n| File |\n|------|\n") + + for _, file := range files.Files { + fmt.Fprintf(&result, "| `%s` |\n", file) + } + + return result.String() +} + +// GetToolType returns the tool type category +func (h *GrepHandler) GetToolType() ToolType { + return ToolTypeSearch +} + +// escapeTableCellValue escapes special characters for markdown table cells +// Matches the TypeScript escapeTableCellValue function +func escapeTableCellValue(value string) string { + // Escape pipes + value = strings.ReplaceAll(value, "|", "\\|") + // Convert newlines to HTML breaks + value = strings.ReplaceAll(value, "\n", "
") + // Escape braces (for some markdown parsers) + value = escapeBraces(value) + // Normalize tabs to spaces + value = strings.ReplaceAll(value, "\t", " ") + // Remove carriage returns + value = strings.ReplaceAll(value, "\r", "") + // Trim whitespace + value = strings.TrimSpace(value) + + return value +} + +// escapeBraces escapes curly braces in markdown +func escapeBraces(s string) string { + var result strings.Builder + prevChar := rune(0) + + for _, char := range s { + if (char == '{' || char == '}') && prevChar != '\\' { + result.WriteRune('\\') + } + result.WriteRune(char) + prevChar = char + } + + return result.String() +} diff --git a/specstory-cli/pkg/providers/cursoride/tool_grep_search.go b/specstory-cli/pkg/providers/cursoride/tool_grep_search.go new file mode 100644 index 00000000..7ceedcd7 --- /dev/null +++ b/specstory-cli/pkg/providers/cursoride/tool_grep_search.go @@ -0,0 +1,119 @@ +package cursoride + +import ( + "encoding/json" + "fmt" + "strings" +) + +// GrepSearchHandler handles grep_search tool invocations +// This is different from regular grep - it uses a different data structure +type GrepSearchHandler struct{} + +// GrepSearchRawArgs represents raw arguments for grep_search +type GrepSearchRawArgs struct { + Query string `json:"query"` +} + +// GrepSearchResult represents the result of a grep_search operation +type GrepSearchResult struct { + Internal *GrepSearchInternal `json:"internal,omitempty"` +} + +// GrepSearchInternal contains the internal grep_search results +type GrepSearchInternal struct { + Results []GrepSearchFileResult `json:"results,omitempty"` +} + +// GrepSearchFileResult represents results for a single file +type GrepSearchFileResult struct { + Resource string `json:"resource"` // File path + Results []GrepSearchMatchEntry `json:"results,omitempty"` +} + +// GrepSearchMatchEntry represents a single match entry +type GrepSearchMatchEntry struct { + Match GrepSearchMatch `json:"match"` +} + +// GrepSearchMatch contains the match details +type GrepSearchMatch struct { + RangeLocations []GrepSearchRangeLocation `json:"rangeLocations,omitempty"` + PreviewText string `json:"previewText"` +} + +// GrepSearchRangeLocation contains the location of the match +type GrepSearchRangeLocation struct { + Source GrepSearchSource `json:"source"` +} + +// GrepSearchSource contains the line number +type GrepSearchSource struct { + StartLineNumber int `json:"startLineNumber"` +} + +// AdaptMessage formats grep_search tool invocations as markdown +func (h *GrepSearchHandler) AdaptMessage(bubble *BubbleConversation) (string, error) { + var rawArgs GrepSearchRawArgs + if bubble.RawArgs != "" { + if err := json.Unmarshal([]byte(bubble.RawArgs), &rawArgs); err != nil { + return "", fmt.Errorf("failed to parse grep_search rawArgs: %w", err) + } + } + + var result GrepSearchResult + if bubble.Result != "" { + if err := json.Unmarshal([]byte(bubble.Result), &result); err != nil { + return "", fmt.Errorf("failed to parse grep_search result: %w", err) + } + } + + // Count results (number of files with matches) + resultsLength := 0 + if result.Internal != nil { + resultsLength = len(result.Internal.Results) + } + + var message strings.Builder + message.WriteString(`
+ Tool use: **`) + message.WriteString(bubble.Name) + message.WriteString(`** • Grep search for "`) + message.WriteString(rawArgs.Query) + message.WriteString(`" • **`) + fmt.Fprintf(&message, "%d", resultsLength) + message.WriteString(`** files + `) + + if resultsLength == 0 { + message.WriteString("\nNo results found") + } else { + // Add table header + message.WriteString("\n| File | Line | Match |\n|------|------|-------|\n") + + // Add table rows + for _, fileResult := range result.Internal.Results { + for _, matchEntry := range fileResult.Results { + // Get line number (add 1 since it's 0-based) + lineNumber := 0 + if len(matchEntry.Match.RangeLocations) > 0 { + lineNumber = matchEntry.Match.RangeLocations[0].Source.StartLineNumber + 1 + } + + // Add row + fmt.Fprintf(&message, "| `%s` | L%d | `%s` |\n", + fileResult.Resource, + lineNumber, + escapeTableCellValue(matchEntry.Match.PreviewText)) + } + } + } + + message.WriteString("\n
") + return message.String(), nil +} + +// GetToolType returns the tool type category +func (h *GrepSearchHandler) GetToolType() ToolType { + return ToolTypeSearch +} diff --git a/specstory-cli/pkg/providers/cursoride/tool_handlers.go b/specstory-cli/pkg/providers/cursoride/tool_handlers.go new file mode 100644 index 00000000..245d8912 --- /dev/null +++ b/specstory-cli/pkg/providers/cursoride/tool_handlers.go @@ -0,0 +1,265 @@ +package cursoride + +import ( + "encoding/json" + "fmt" + "log/slog" + "strings" +) + +// ToolType represents the category of a tool (read, write, search, etc.) +type ToolType string + +const ( + ToolTypeRead ToolType = "read" + ToolTypeWrite ToolType = "write" + ToolTypeSearch ToolType = "search" + ToolTypeShell ToolType = "shell" + ToolTypeTask ToolType = "task" + ToolTypeMCP ToolType = "mcp" + ToolTypeGeneric ToolType = "generic" + ToolTypeUnknown ToolType = "unknown" +) + +// ToolHandler is the interface for all tool handlers +// Each handler knows how to format the markdown output for a specific tool +type ToolHandler interface { + // AdaptMessage formats the tool invocation as markdown + // Returns the formatted markdown text + AdaptMessage(bubble *BubbleConversation) (string, error) + + // GetToolType returns the tool type category + GetToolType() ToolType +} + +// ToolRegistry maps tool names to their handlers +// Multiple tool names can map to the same handler (e.g., read_file and read_file_v2) +type ToolRegistry struct { + handlers map[string]ToolHandler +} + +// NewToolRegistry creates a new tool registry with all handlers registered +func NewToolRegistry() *ToolRegistry { + registry := &ToolRegistry{ + handlers: make(map[string]ToolHandler), + } + + // Register read file handlers + readFileHandler := &ReadFileHandler{} + registry.Register("read_file", readFileHandler) + registry.Register("read_file_v2", readFileHandler) + + // Register code edit handlers + codeEditHandler := &CodeEditHandler{} + registry.Register("edit_file", codeEditHandler) + registry.Register("MultiEdit", codeEditHandler) + registry.Register("edit_notebook", codeEditHandler) + registry.Register("reapply", codeEditHandler) + registry.Register("search_replace", codeEditHandler) + registry.Register("write", codeEditHandler) + registry.Register("edit_file_v2", codeEditHandler) + + // Register delete file handler + deleteFileHandler := &DeleteFileHandler{} + registry.Register("delete_file", deleteFileHandler) + + // Register apply patch handler + applyPatchHandler := &ApplyPatchHandler{} + registry.Register("apply_patch", applyPatchHandler) + + // Register copilot handlers + copilotApplyPatchHandler := &CopilotApplyPatchHandler{} + registry.Register("copilot_applyPatch", copilotApplyPatchHandler) + registry.Register("copilot_insertEdit", copilotApplyPatchHandler) + + // Register shell/terminal command handlers + shellCommandHandler := &ShellCommandHandler{} + registry.Register("run_terminal_cmd", shellCommandHandler) + registry.Register("run_terminal_command", shellCommandHandler) + registry.Register("run_terminal_command_v2", shellCommandHandler) + + // Register grep/search handlers + grepHandler := &GrepHandler{} + registry.Register("grep", grepHandler) + registry.Register("ripgrep", grepHandler) + + // Register grep_search handler (different data structure) + grepSearchHandler := &GrepSearchHandler{} + registry.Register("grep_search", grepSearchHandler) + + // Register list directory handler + listDirectoryHandler := &ListDirectoryHandler{} + registry.Register("list_directory", listDirectoryHandler) + + // Register file search handler + fileSearchHandler := &FileSearchHandler{} + registry.Register("file_search", fileSearchHandler) + + // Register glob file search handler + globFileSearchHandler := &GlobFileSearchHandler{} + registry.Register("glob_file_search", globFileSearchHandler) + + return registry +} + +// Register adds a handler for a specific tool name +func (r *ToolRegistry) Register(toolName string, handler ToolHandler) { + r.handlers[toolName] = handler +} + +// GetHandler returns the handler for a tool name, or nil if not found +func (r *ToolRegistry) GetHandler(toolName string) ToolHandler { + return r.handlers[toolName] +} + +// FormatToolInvocation formats a tool invocation as markdown +// This is the main entry point for processing tool invocations +func FormatToolInvocation(bubble *BubbleConversation, registry *ToolRegistry) string { + // Handle invalid tool (tool = 0) + if bubble.Tool == 0 { + return formatToolError(bubble) + } + + // Handle error status + if bubble.Status == "error" { + return formatToolError(bubble) + } + + // Handle cancelled status + if bubble.Status == "cancelled" { + return "Cancelled" + } + + // Get the handler for this tool name + handler := registry.GetHandler(bubble.Name) + var toolType ToolType + var content string + + if handler != nil { + // Use the registered handler + toolType = handler.GetToolType() + var err error + content, err = handler.AdaptMessage(bubble) + if err != nil { + slog.Warn("Error adapting tool message, using fallback", + "toolName", bubble.Name, + "error", err) + // Fallback to catch-all handler + toolType = ToolTypeUnknown + content = formatCatchAll(bubble) + } + } else { + // Unknown tool - use catch-all handler + slog.Debug("Unknown tool, using catch-all handler", + "toolName", bubble.Name) + toolType = ToolTypeUnknown + content = formatCatchAll(bubble) + } + + // Wrap in tool-use HTML tag + // Format: + return fmt.Sprintf(` +%s +`, toolType, bubble.Name, content) +} + +// formatToolError formats a tool error message +func formatToolError(bubble *BubbleConversation) string { + if bubble.Error != "" { + // Parse the error JSON + var errorData struct { + ClientVisibleErrorMessage string `json:"clientVisibleErrorMessage"` + } + if err := json.Unmarshal([]byte(bubble.Error), &errorData); err == nil { + return errorData.ClientVisibleErrorMessage + } + // If parsing fails, return the raw error + return bubble.Error + } + return "An unknown error occurred" +} + +// formatCatchAll is the fallback formatter for unknown tools +// Matches the TypeScript CatchAllBubbleHandler format +func formatCatchAll(bubble *BubbleConversation) string { + var message strings.Builder + + // Start with summary line (just tool name, no params) + fmt.Fprintf(&message, "
\nTool use: **%s**\n\n", bubble.Name) + + // Parse params + var params map[string]interface{} + if bubble.Params != "" { + if err := json.Unmarshal([]byte(bubble.Params), ¶ms); err != nil { + slog.Warn("Failed to parse tool params", + "toolName", bubble.Name, + "error", err) + } + } + + // Add parameters section (outside summary, inside details) + if len(params) > 0 { + message.WriteString("\nParameters:\n\n") + message.WriteString(formatEscapeJSONBlock(params)) + } + + // Add additional data section + if len(bubble.AdditionalData) > 0 { + message.WriteString("Additional data:\n\n") + message.WriteString(formatEscapeJSONBlock(bubble.AdditionalData)) + } + + // Parse and add result section + var result map[string]interface{} + if bubble.Result != "" { + if err := json.Unmarshal([]byte(bubble.Result), &result); err != nil { + slog.Warn("Failed to parse tool result", + "toolName", bubble.Name, + "error", err) + } + if len(result) > 0 { + message.WriteString("Result:\n\n") + message.WriteString(formatEscapeJSONBlock(result)) + } + } + + // Add user decision + if bubble.UserDecision != "" { + fmt.Fprintf(&message, "User decision: **%s**\n\n", bubble.UserDecision) + } + + // Add status + if bubble.Status != "" { + fmt.Fprintf(&message, "Status: **%s**\n\n", bubble.Status) + } + + // Add error section + if bubble.Error != "" { + message.WriteString("Error:\n\n") + errorData := map[string]interface{}{"error": bubble.Error} + message.WriteString(formatEscapeJSONBlock(errorData)) + } + + message.WriteString("\n
") + + return message.String() +} + +// formatEscapeJSONBlock formats JSON with escaped special characters +// Matches the TypeScript formatEscapeJsonBlock function +func formatEscapeJSONBlock(data map[string]interface{}) string { + // Marshal JSON with indentation + jsonBytes, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Sprintf("```json\n%v\n```\n", data) + } + + // Escape special characters (backticks, <, >, &) + jsonStr := string(jsonBytes) + jsonStr = strings.ReplaceAll(jsonStr, "&", "&") + jsonStr = strings.ReplaceAll(jsonStr, "`", "`") + jsonStr = strings.ReplaceAll(jsonStr, "<", "<") + jsonStr = strings.ReplaceAll(jsonStr, ">", ">") + + return fmt.Sprintf("```json\n%s\n```\n", jsonStr) +} diff --git a/specstory-cli/pkg/providers/cursoride/tool_list_directory.go b/specstory-cli/pkg/providers/cursoride/tool_list_directory.go new file mode 100644 index 00000000..28ab3a89 --- /dev/null +++ b/specstory-cli/pkg/providers/cursoride/tool_list_directory.go @@ -0,0 +1,88 @@ +package cursoride + +import ( + "encoding/json" + "fmt" +) + +// ListDirectoryHandler handles list_directory tool invocations +type ListDirectoryHandler struct{} + +// ListDirectoryRawArgs represents the raw arguments for list_directory tool +type ListDirectoryRawArgs struct { + RelativeWorkspacePath string `json:"relative_workspace_path,omitempty"` +} + +// ListDirectoryResult represents the result of list_directory tool +type ListDirectoryResult struct { + Files []DirectoryFile `json:"files"` +} + +// DirectoryFile represents a file or directory entry +type DirectoryFile struct { + Name string `json:"name"` + IsDirectory bool `json:"isDirectory"` +} + +// AdaptMessage formats the list_directory tool invocation as markdown +func (h *ListDirectoryHandler) AdaptMessage(bubble *BubbleConversation) (string, error) { + // Parse raw args to get the directory path + var rawArgs ListDirectoryRawArgs + if bubble.RawArgs != "" { + if err := json.Unmarshal([]byte(bubble.RawArgs), &rawArgs); err != nil { + return "", fmt.Errorf("failed to parse list_directory rawArgs: %w", err) + } + } + + // Parse result to get the file list + var result ListDirectoryResult + if bubble.Result != "" { + if err := json.Unmarshal([]byte(bubble.Result), &result); err != nil { + return "", fmt.Errorf("failed to parse list_directory result: %w", err) + } + } + + filesLength := len(result.Files) + + // Format the workspace path display + relativeWorkspacePath := rawArgs.RelativeWorkspacePath + workspaceDisplay := "current directory" + if relativeWorkspacePath != "" && relativeWorkspacePath != "." { + workspaceDisplay = fmt.Sprintf("directory %s", relativeWorkspacePath) + } + + // Build the markdown message + pluralSuffix := "" + if filesLength != 1 { + pluralSuffix = "s" + } + + message := fmt.Sprintf(`
+Tool use: **%s** • Listed %s, %d result%s +`, bubble.Name, workspaceDisplay, filesLength, pluralSuffix) + + if filesLength == 0 { + message += "\nNo results found" + } else { + // Add table header + message += "\n| Name |\n|-------|\n" + + // Add table rows + for _, file := range result.Files { + icon := "📄" + if file.IsDirectory { + icon = "📁" + } + message += fmt.Sprintf("| %s `%s` |\n", icon, file.Name) + } + } + + message += "\n
" + + return message, nil +} + +// GetToolType returns the tool type category +func (h *ListDirectoryHandler) GetToolType() ToolType { + return ToolTypeSearch +} diff --git a/specstory-cli/pkg/providers/cursoride/tool_read_file.go b/specstory-cli/pkg/providers/cursoride/tool_read_file.go new file mode 100644 index 00000000..7ba4dd47 --- /dev/null +++ b/specstory-cli/pkg/providers/cursoride/tool_read_file.go @@ -0,0 +1,44 @@ +package cursoride + +import ( + "encoding/json" + "fmt" +) + +// ReadFileHandler handles read_file and read_file_v2 tool invocations +type ReadFileHandler struct{} + +// ReadFileParams represents the parameters for read_file tool +type ReadFileParams struct { + RelativeWorkspacePath string `json:"relativeWorkspacePath,omitempty"` + TargetFile string `json:"targetFile,omitempty"` +} + +// AdaptMessage formats the read_file tool invocation as markdown +func (h *ReadFileHandler) AdaptMessage(bubble *BubbleConversation) (string, error) { + // Parse params to get the file path + var params ReadFileParams + if bubble.Params != "" { + if err := json.Unmarshal([]byte(bubble.Params), ¶ms); err != nil { + return "", fmt.Errorf("failed to parse read_file params: %w", err) + } + } + + // Get the file path (prefer relativeWorkspacePath, fallback to targetFile) + filePath := params.RelativeWorkspacePath + if filePath == "" { + filePath = params.TargetFile + } + + // Format as a collapsed details block + // This matches the TypeScript implementation: + // `
Tool use: **${toolName}** • Read file: ${path}\n\n
` + return fmt.Sprintf(`
Tool use: **%s** • Read file: %s + +
`, bubble.Name, filePath), nil +} + +// GetToolType returns the tool type category +func (h *ReadFileHandler) GetToolType() ToolType { + return ToolTypeRead +} diff --git a/specstory-cli/pkg/providers/cursoride/tool_shell.go b/specstory-cli/pkg/providers/cursoride/tool_shell.go new file mode 100644 index 00000000..51e27e70 --- /dev/null +++ b/specstory-cli/pkg/providers/cursoride/tool_shell.go @@ -0,0 +1,76 @@ +package cursoride + +import ( + "encoding/json" + "fmt" + "strings" +) + +// ShellCommandHandler handles run_terminal_cmd, run_terminal_command, and run_terminal_command_v2 tool invocations +type ShellCommandHandler struct{} + +// ShellCommandRawArgs represents raw arguments for terminal command tools +type ShellCommandRawArgs struct { + Command string `json:"command,omitempty"` +} + +// ShellCommandParams represents parameters for terminal command tools +type ShellCommandParams struct { + Command string `json:"command,omitempty"` +} + +// ShellCommandResult represents the result of a terminal command execution +type ShellCommandResult struct { + Output string `json:"output,omitempty"` +} + +// AdaptMessage formats terminal command tool invocations as markdown +func (h *ShellCommandHandler) AdaptMessage(bubble *BubbleConversation) (string, error) { + var rawArgs ShellCommandRawArgs + if bubble.RawArgs != "" { + // Parse rawArgs, but ignore errors (non-fatal) + _ = json.Unmarshal([]byte(bubble.RawArgs), &rawArgs) + } + + var params ShellCommandParams + if bubble.Params != "" { + // Parse params, but ignore errors (non-fatal) + _ = json.Unmarshal([]byte(bubble.Params), ¶ms) + } + + var result ShellCommandResult + if bubble.Result != "" { + // Parse result, but ignore errors (non-fatal) + _ = json.Unmarshal([]byte(bubble.Result), &result) + } + + var message strings.Builder + fmt.Fprintf(&message, "
Tool use: **%s**", bubble.Name) + + // Get command from params or rawArgs (prefer params) + command := params.Command + if command == "" { + command = rawArgs.Command + } + + // If we have a command, show it in the summary and as a bash block + if command != "" { + fmt.Fprintf(&message, " • Run command: %s\n\n", command) + fmt.Fprintf(&message, "```bash\n%s\n```", command) + } else { + message.WriteString("\n") + } + + // If we have output, show it in a code block + if result.Output != "" { + fmt.Fprintf(&message, "\n\n```\n%s\n```", escapeCodeBlock(result.Output)) + } + + message.WriteString("\n
") + return message.String(), nil +} + +// GetToolType returns the tool type category +func (h *ShellCommandHandler) GetToolType() ToolType { + return ToolTypeShell +} diff --git a/specstory-cli/pkg/providers/cursoride/tool_write.go b/specstory-cli/pkg/providers/cursoride/tool_write.go new file mode 100644 index 00000000..77f539cb --- /dev/null +++ b/specstory-cli/pkg/providers/cursoride/tool_write.go @@ -0,0 +1,349 @@ +package cursoride + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" +) + +// CodeEditHandler is a handler for code editing tools +// Handles: edit_file, MultiEdit, edit_notebook, reapply, search_replace, write, edit_file_v2 +type CodeEditHandler struct{} + +// CodeEditParams represents parameters for code edit tools +type CodeEditParams struct { + RelativeWorkspacePath string `json:"relativeWorkspacePath,omitempty"` + Instructions string `json:"instructions,omitempty"` +} + +// CodeEditResult represents the result of a code edit operation +type CodeEditResult struct { + ApplyFailed bool `json:"applyFailed,omitempty"` + Diff *struct { + Chunks []struct { + DiffString string `json:"diffString"` + OldStart int `json:"oldStart"` + NewStart int `json:"newStart"` + OldLines int `json:"oldLines"` + NewLines int `json:"newLines"` + LinesAdded int `json:"linesAdded"` + LinesRemoved int `json:"linesRemoved"` + } `json:"chunks"` + } `json:"diff,omitempty"` +} + +// AdaptMessage formats code edit tool invocations as markdown +func (h *CodeEditHandler) AdaptMessage(bubble *BubbleConversation) (string, error) { + var params CodeEditParams + if bubble.Params != "" { + if err := json.Unmarshal([]byte(bubble.Params), ¶ms); err != nil { + return "", fmt.Errorf("failed to parse code edit params: %w", err) + } + } + + var result CodeEditResult + if bubble.Result != "" { + // Parse result, but ignore errors (non-fatal) + _ = json.Unmarshal([]byte(bubble.Result), &result) + } + + var message strings.Builder + message.WriteString("\n") + + // Build summary line + if params.RelativeWorkspacePath != "" { + fmt.Fprintf(&message, "
Tool use: **%s** • Edit file: %s\n\n", bubble.Name, params.RelativeWorkspacePath) + } else { + fmt.Fprintf(&message, "
Tool use: **%s**\n\n", bubble.Name) + } + + // Add instructions if present + if params.Instructions != "" { + fmt.Fprintf(&message, "%s\n\n", params.Instructions) + } + + // Add status if not completed + if bubble.Status != "" && bubble.Status != "completed" { + fmt.Fprintf(&message, "Status: **%s**\n\n", bubble.Status) + } + + // Add apply failed message + if result.ApplyFailed { + message.WriteString("**Apply failed**\n\n") + } + + // Add diff chunks if present + if result.Diff != nil && len(result.Diff.Chunks) > 0 { + for i, chunk := range result.Diff.Chunks { + fmt.Fprintf(&message, "**Chunk %d**\n", i+1) + fmt.Fprintf(&message, "Lines added: %d, lines removed: %d\n\n", chunk.LinesAdded, chunk.LinesRemoved) + message.WriteString("```diff\n") + fmt.Fprintf(&message, "@@ -%d,%d +%d,%d @@\n", chunk.OldStart, chunk.OldLines, chunk.NewStart, chunk.NewLines) + message.WriteString(escapeCodeBlock(chunk.DiffString)) + message.WriteString("\n```\n\n") + } + } else if len(bubble.AdditionalData) > 0 { + // Check for codeblock in additionalData (edit_file_v2 format) + if codeblockData, ok := bubble.AdditionalData["codeblock"].(map[string]interface{}); ok { + if content, hasContent := codeblockData["content"].(string); hasContent && content != "" { + lang := "" + if languageId, hasLang := codeblockData["languageId"].(string); hasLang { + lang = languageId + } + fmt.Fprintf(&message, "```%s\n%s\n```\n\n", lang, content) + } + } + } + + message.WriteString("
\n") + return message.String(), nil +} + +// GetToolType returns the tool type category +func (h *CodeEditHandler) GetToolType() ToolType { + return ToolTypeWrite +} + +// DeleteFileHandler handles delete_file tool invocations +type DeleteFileHandler struct{} + +// DeleteFileRawArgs represents raw arguments for delete_file +type DeleteFileRawArgs struct { + Explanation string `json:"explanation"` +} + +// AdaptMessage formats delete_file tool invocations as markdown +func (h *DeleteFileHandler) AdaptMessage(bubble *BubbleConversation) (string, error) { + var rawArgs DeleteFileRawArgs + if bubble.RawArgs != "" { + if err := json.Unmarshal([]byte(bubble.RawArgs), &rawArgs); err != nil { + return "", fmt.Errorf("failed to parse delete_file rawArgs: %w", err) + } + } + + var message strings.Builder + fmt.Fprintf(&message, "
Tool use: **%s**\n\n", bubble.Name) + + if rawArgs.Explanation != "" { + fmt.Fprintf(&message, "Explanation: %s\n\n", rawArgs.Explanation) + } + + message.WriteString("\n
") + return message.String(), nil +} + +// GetToolType returns the tool type category +func (h *DeleteFileHandler) GetToolType() ToolType { + return ToolTypeWrite +} + +// ApplyPatchHandler handles apply_patch tool invocations +type ApplyPatchHandler struct{} + +// ApplyPatchRawArgs represents raw arguments for apply_patch +type ApplyPatchRawArgs struct { + FilePath string `json:"file_path"` + Patch string `json:"patch"` +} + +// AdaptMessage formats apply_patch tool invocations as markdown +func (h *ApplyPatchHandler) AdaptMessage(bubble *BubbleConversation) (string, error) { + var rawArgs ApplyPatchRawArgs + if bubble.RawArgs != "" { + if err := json.Unmarshal([]byte(bubble.RawArgs), &rawArgs); err != nil { + return "", fmt.Errorf("failed to parse apply_patch rawArgs: %w", err) + } + } + + var message strings.Builder + fmt.Fprintf(&message, "
\n Tool use: **%s** • Apply patch for %s\n ", bubble.Name, rawArgs.FilePath) + + if rawArgs.Patch != "" { + fmt.Fprintf(&message, "\n\n```diff\n%s\n```\n", escapeCodeBlock(rawArgs.Patch)) + } + + message.WriteString("\n
") + return message.String(), nil +} + +// GetToolType returns the tool type category +func (h *ApplyPatchHandler) GetToolType() ToolType { + return ToolTypeWrite +} + +// CopilotApplyPatchHandler handles copilot_applyPatch and copilot_insertEdit tool invocations +type CopilotApplyPatchHandler struct{} + +// CopilotApplyPatchRawArgs represents raw arguments for copilot apply patch tools +type CopilotApplyPatchRawArgs struct { + ToolID string `json:"toolId,omitempty"` + VscodeToolID string `json:"vscodeToolId,omitempty"` +} + +// CopilotApplyPatchParams represents parameters for copilot apply patch tools +type CopilotApplyPatchParams struct { + RelativeWorkspacePath string `json:"relativeWorkspacePath,omitempty"` + FilePath string `json:"file_path,omitempty"` +} + +// CopilotApplyPatchResult represents the result of a copilot apply patch operation +type CopilotApplyPatchResult struct { + InvocationMessage string `json:"invocationMessage,omitempty"` + Content string `json:"content,omitempty"` + TextEditContent string `json:"textEditContent,omitempty"` + PastTenseMessage string `json:"pastTenseMessage,omitempty"` +} + +// AdaptMessage formats copilot apply patch tool invocations as markdown +func (h *CopilotApplyPatchHandler) AdaptMessage(bubble *BubbleConversation) (string, error) { + var rawArgs CopilotApplyPatchRawArgs + if bubble.RawArgs != "" { + // Parse rawArgs, but ignore errors (non-fatal, use defaults) + _ = json.Unmarshal([]byte(bubble.RawArgs), &rawArgs) + } + + var params CopilotApplyPatchParams + if bubble.Params != "" { + // Parse params, but ignore errors (non-fatal) + _ = json.Unmarshal([]byte(bubble.Params), ¶ms) + } + + var result CopilotApplyPatchResult + if bubble.Result != "" { + // Parse result, but ignore errors (non-fatal) + _ = json.Unmarshal([]byte(bubble.Result), &result) + } + + // Determine tool ID + toolID := rawArgs.ToolID + if toolID == "" { + toolID = rawArgs.VscodeToolID + } + if toolID == "" { + toolID = "copilot_applyPatch" + } + + // Extract invocation message + invocationMsg := result.InvocationMessage + if invocationMsg == "" { + invocationMsg = "Using \"Apply Patch\"" + } + + // For insertEdit, include file path in summary if available + filePath := params.RelativeWorkspacePath + if filePath == "" { + filePath = params.FilePath + } + if filePath != "" && toolID == "copilot_insertEdit" { + invocationMsg = fmt.Sprintf("Edit file: %s", filePath) + } + + var message strings.Builder + fmt.Fprintf(&message, "
\nTool use: **%s** • %s\n\n", bubble.Name, invocationMsg) + + // Check if operation failed + if bubble.Status == "error" && bubble.Error != "" { + var errorData struct { + Message string `json:"message"` + } + if err := json.Unmarshal([]byte(bubble.Error), &errorData); err == nil { + message.WriteString("**❌ Patch Failed**\n\n") + fmt.Fprintf(&message, "%s\n", errorData.Message) + } + } else { + // Add the collected content + if result.Content != "" && strings.TrimSpace(result.Content) != "" { + message.WriteString(result.Content) + } else if result.TextEditContent != "" && strings.TrimSpace(result.TextEditContent) != "" { + // For insertEdit, use the direct textEditGroup content + extension := "" + if filePath != "" { + extension = filepath.Ext(filePath) + if len(extension) > 0 { + extension = extension[1:] // Remove leading dot + } + } + language := extensionToLanguage(extension) + fmt.Fprintf(&message, "```%s\n%s\n```\n\n", language, result.TextEditContent) + } else { + message.WriteString("_No content to show_\n") + } + + // Add past tense message if available + if result.PastTenseMessage != "" { + fmt.Fprintf(&message, "\n**Status:** %s\n", result.PastTenseMessage) + } + } + + message.WriteString("\n
") + return message.String(), nil +} + +// GetToolType returns the tool type category +func (h *CopilotApplyPatchHandler) GetToolType() ToolType { + return ToolTypeWrite +} + +// extensionToLanguage maps file extensions to language identifiers +// This is a simplified version - the full mapping is in ts-extension/core/utils/extension2language.ts +func extensionToLanguage(extension string) string { + // Common mappings + langMap := map[string]string{ + "js": "javascript", + "jsx": "javascript", + "ts": "typescript", + "tsx": "typescript", + "py": "python", + "go": "go", + "rs": "rust", + "java": "java", + "c": "c", + "cpp": "cpp", + "cc": "cpp", + "cxx": "cpp", + "h": "c", + "hpp": "cpp", + "cs": "csharp", + "php": "php", + "rb": "ruby", + "swift": "swift", + "kt": "kotlin", + "scala": "scala", + "sh": "bash", + "zsh": "bash", + "bash": "bash", + "md": "markdown", + "json": "json", + "xml": "xml", + "yaml": "yaml", + "yml": "yaml", + "toml": "toml", + "html": "html", + "css": "css", + "scss": "scss", + "sass": "sass", + "less": "less", + "sql": "sql", + } + + if lang, ok := langMap[extension]; ok { + return lang + } + + // Default to the extension itself + if extension != "" { + return extension + } + return "text" +} + +// escapeCodeBlock escapes special characters for markdown code blocks +// Matches the TypeScript escapeCodeBlock function +func escapeCodeBlock(code string) string { + code = strings.ReplaceAll(code, "&", "&") + code = strings.ReplaceAll(code, "`", "`") + code = strings.ReplaceAll(code, "<", "<") + code = strings.ReplaceAll(code, ">", ">") + return code +} diff --git a/specstory-cli/pkg/providers/cursoride/types.go b/specstory-cli/pkg/providers/cursoride/types.go new file mode 100644 index 00000000..fecea74f --- /dev/null +++ b/specstory-cli/pkg/providers/cursoride/types.go @@ -0,0 +1,120 @@ +package cursoride + +// ComposerData represents the main composer data structure from Cursor IDE database +// This is stored in the global database with key "composerData:{composerId}" +type ComposerData struct { + ComposerID string `json:"composerId"` + Name string `json:"name,omitempty"` + Version int `json:"_v,omitempty"` + Conversation []ComposerConversation `json:"conversation,omitempty"` + FullConversationHeadersOnly []ComposerConversationHeader `json:"fullConversationHeadersOnly"` + Capabilities []Capability `json:"capabilities,omitempty"` + ModelConfig *ModelConfig `json:"modelConfig,omitempty"` + CreatedAt int64 `json:"createdAt"` + LastUpdatedAt int64 `json:"lastUpdatedAt,omitempty"` +} + +// ComposerConversationHeader represents a conversation header (used when full conversation isn't loaded) +type ComposerConversationHeader struct { + BubbleID string `json:"bubbleId"` +} + +// ComposerConversation represents a single message/bubble in a conversation +// These are stored separately with key "bubbleId:{composerId}:{bubbleId}" +type ComposerConversation struct { + BubbleID string `json:"bubbleId"` + Type int `json:"type"` // 1=user, 2=assistant + Text string `json:"text"` + Thinking *ThinkingData `json:"thinking,omitempty"` // Extended thinking for models with thinking capability + CapabilityType int `json:"capabilityType,omitempty"` // 15=tool + UnifiedMode int `json:"unifiedMode,omitempty"` // 1=Ask, 2=Agent, 5=Plan + TimingInfo *TimingInfo `json:"timingInfo,omitempty"` + ToolFormerData *ToolInvocationData `json:"toolFormerData,omitempty"` + ModelInfo *ModelInfo `json:"modelInfo,omitempty"` +} + +// ThinkingData contains the thinking/reasoning text for models with thinking capability +type ThinkingData struct { + Text string `json:"text"` +} + +// ModelInfo contains model information for a bubble +type ModelInfo struct { + ModelName string `json:"modelName,omitempty"` +} + +// ModelConfig contains the model configuration for the composer (V3+) +type ModelConfig struct { + ModelName string `json:"modelName,omitempty"` +} + +// TimingInfo contains timing information for a conversation bubble +// Note: Cursor stores timing values as floats (with fractional milliseconds) +type TimingInfo struct { + ClientStartTime float64 `json:"clientStartTime,omitempty"` + ClientRpcSendTime float64 `json:"clientRpcSendTime,omitempty"` + ClientSettleTime float64 `json:"clientSettleTime,omitempty"` + ClientEndTime float64 `json:"clientEndTime,omitempty"` +} + +// Capability represents a capability in the composer (tools, custom instructions, etc.) +// Capability type 15 = tool invocations +type Capability struct { + Type int `json:"type"` + Data CapabilityData `json:"data"` +} + +// CapabilityData contains the actual capability data +type CapabilityData struct { + CustomInstructions string `json:"customInstructions,omitempty"` + BubbleDataMap interface{} `json:"bubbleDataMap,omitempty"` // Can be string (JSON) or map + Result string `json:"result,omitempty"` + ParsedBubbleMap map[string]*BubbleConversation `json:"-"` // Parsed bubble data map +} + +// BubbleConversation represents tool invocation data (stored in capabilities.bubbleDataMap) +// This is called BubbleConversationData in the TypeScript code +type BubbleConversation struct { + Tool int `json:"tool"` + Name string `json:"name"` + RawArgs string `json:"rawArgs,omitempty"` + Params string `json:"params,omitempty"` + Result string `json:"result,omitempty"` + Status string `json:"status,omitempty"` + Error string `json:"error,omitempty"` + AdditionalData map[string]interface{} `json:"additionalData,omitempty"` + UserDecision string `json:"userDecision,omitempty"` +} + +// ToolInvocationData represents tool invocation information (V3+ format, stored directly in bubble) +// This is the same structure as BubbleConversation but embedded in the message +type ToolInvocationData struct { + Tool int `json:"tool"` + Name string `json:"name"` + RawArgs string `json:"rawArgs,omitempty"` + Params string `json:"params,omitempty"` + Result string `json:"result,omitempty"` + Status string `json:"status,omitempty"` + Error string `json:"error,omitempty"` + AdditionalData map[string]interface{} `json:"additionalData,omitempty"` + UserDecision string `json:"userDecision,omitempty"` +} + +// WorkspaceComposerRefs represents the workspace-specific composer references +// Stored in workspace database with key "composer.composerData". +// Cursor 2 used allComposers; Cursor 3 uses selectedComposerIds (currently-open tabs only). +type WorkspaceComposerRefs struct { + AllComposers []ComposerRef `json:"allComposers"` // Cursor 2 format + SelectedComposerIds []string `json:"selectedComposerIds"` // Cursor 3 format +} + +// ComposerRef is a reference to a composer ID in the workspace +type ComposerRef struct { + ComposerID string `json:"composerId"` +} + +// WorkspaceJSON represents the structure of workspace.json files +type WorkspaceJSON struct { + Workspace string `json:"workspace,omitempty"` // Multi-root workspace file URI + Folder string `json:"folder,omitempty"` // Single folder URI +} diff --git a/specstory-cli/pkg/providers/cursoride/watcher.go b/specstory-cli/pkg/providers/cursoride/watcher.go new file mode 100644 index 00000000..d228cb8d --- /dev/null +++ b/specstory-cli/pkg/providers/cursoride/watcher.go @@ -0,0 +1,443 @@ +package cursoride + +import ( + "context" + "fmt" + "log/slog" + "os" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi" +) + +// CursorIDEWatcher monitors Cursor IDE databases for changes and notifies when sessions are updated +type CursorIDEWatcher struct { + projectPath string + workspace *WorkspaceMatch + globalDbPath string + debugRaw bool + sessionCallback func(*spi.AgentChatSession) + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + mu sync.RWMutex + lastCheck time.Time + knownComposers map[string]int64 // composerID -> lastUpdatedAt + checkInterval time.Duration // How often to check DB if no file events (default: 2 minutes) + throttleDuration time.Duration // Minimum time between checks (default: 10 seconds) + lastThrottledCall time.Time // Track last throttled call + pendingCheck bool // Whether a check is pending after throttle + fsWatcher *fsnotify.Watcher + // pendingIncomplete tracks sessions whose last bubble is a user message with no agent + // response yet. Cursor writes the user bubble and updates lastUpdatedAt atomically, + // so the header count matches the loaded bubble count (the existing bubble-count guard + // passes), but the agent has not responded yet. We skip these sessions and retry on + // the next check. If a session stays incomplete beyond incompleteTimeout (e.g. the + // user abandoned the chat mid-flight and the agent never replied), we process it + // anyway so the markdown is not lost permanently. + pendingIncomplete map[string]time.Time // composerID -> time first seen as incomplete + incompleteTimeout time.Duration // How long to wait before processing an incomplete session anyway +} + +// NewCursorIDEWatcher creates a new watcher for Cursor IDE databases +func NewCursorIDEWatcher( + projectPath string, + debugRaw bool, + sessionCallback func(*spi.AgentChatSession), + checkInterval time.Duration, +) (*CursorIDEWatcher, error) { + // Find workspace for project + workspace, err := FindWorkspaceForProject(projectPath) + if err != nil { + return nil, fmt.Errorf("failed to find workspace for project: %w", err) + } + + // Get global database path + globalDbPath, err := GetGlobalDatabasePath() + if err != nil { + return nil, fmt.Errorf("failed to get global database path: %w", err) + } + + // Create fsnotify watcher + fsWatcher, err := fsnotify.NewWatcher() + if err != nil { + return nil, fmt.Errorf("failed to create file watcher: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + + // Set default check interval if not provided + if checkInterval == 0 { + checkInterval = 2 * time.Minute + } + + return &CursorIDEWatcher{ + projectPath: projectPath, + workspace: workspace, + globalDbPath: globalDbPath, + debugRaw: debugRaw, + sessionCallback: sessionCallback, + ctx: ctx, + cancel: cancel, + knownComposers: make(map[string]int64), + checkInterval: checkInterval, + throttleDuration: 10 * time.Second, + lastThrottledCall: time.Now(), + pendingCheck: false, + fsWatcher: fsWatcher, + pendingIncomplete: make(map[string]time.Time), + incompleteTimeout: 60 * time.Second, + }, nil +} + +// Start begins monitoring the Cursor IDE databases +func (w *CursorIDEWatcher) Start() error { + slog.Info("Starting Cursor IDE watcher", + "projectPath", w.projectPath, + "workspaceID", w.workspace.ID, + "workspaceDbPath", w.workspace.DBPath, + "globalDbPath", w.globalDbPath, + "checkInterval", w.checkInterval, + "throttleDuration", w.throttleDuration) + + // Set up file watching on workspace database + if err := w.watchDatabaseFiles(w.workspace.DBPath); err != nil { + slog.Warn("Failed to watch workspace database files", "error", err) + } + + // Set up file watching on global database + if err := w.watchDatabaseFiles(w.globalDbPath); err != nil { + slog.Warn("Failed to watch global database files", "error", err) + } + + // Start the file event handler goroutine + w.wg.Add(1) + go w.handleFileEvents() + + // Start the safety net polling goroutine + w.wg.Add(1) + go w.safetyNetPoller() + + // Perform initial check after a short delay + time.AfterFunc(5*time.Second, func() { + w.triggerCheck("initial") + }) + + slog.Info("Cursor IDE watcher started successfully") + return nil +} + +// Stop gracefully stops the watcher +func (w *CursorIDEWatcher) Stop() { + slog.Info("Stopping Cursor IDE watcher") + + // Perform one final check before stopping + slog.Info("Performing final check for changes before stopping") + w.checkForChanges("shutdown") + + // Close fsnotify watcher + if w.fsWatcher != nil { + if err := w.fsWatcher.Close(); err != nil { + slog.Warn("Error closing fsnotify watcher", "error", err) + } + } + + // Cancel context and wait for goroutines + w.cancel() + w.wg.Wait() + + slog.Info("Cursor IDE watcher stopped") +} + +// watchDatabaseFiles sets up file watching for a database and its WAL file +func (w *CursorIDEWatcher) watchDatabaseFiles(dbPath string) error { + // Watch the database file itself + if err := w.fsWatcher.Add(dbPath); err != nil { + // Log but don't fail - we'll rely on polling + slog.Warn("Failed to watch database file", "path", dbPath, "error", err) + return err + } + slog.Debug("Watching database file", "path", dbPath) + + // Watch the WAL file if it exists + walPath := dbPath + "-wal" + if _, err := os.Stat(walPath); err == nil { + if err := w.fsWatcher.Add(walPath); err != nil { + slog.Warn("Failed to watch WAL file", "path", walPath, "error", err) + // Don't return error - WAL is optional + } else { + slog.Debug("Watching WAL file", "path", walPath) + } + } + + return nil +} + +// handleFileEvents processes file system events from fsnotify +func (w *CursorIDEWatcher) handleFileEvents() { + defer w.wg.Done() + + for { + select { + case <-w.ctx.Done(): + return + + case event, ok := <-w.fsWatcher.Events: + if !ok { + return + } + + // Only care about Write and Create events + if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) { + slog.Debug("Database file changed", "path", event.Name, "op", event.Op) + w.triggerCheck("file-change") + } + + case err, ok := <-w.fsWatcher.Errors: + if !ok { + return + } + slog.Warn("File watcher error", "error", err) + } + } +} + +// safetyNetPoller ensures we check the database periodically even if file events are missed +func (w *CursorIDEWatcher) safetyNetPoller() { + defer w.wg.Done() + + ticker := time.NewTicker(w.checkInterval) + defer ticker.Stop() + + for { + select { + case <-w.ctx.Done(): + return + case <-ticker.C: + slog.Debug("Safety net poll triggered") + w.triggerCheck("poll") + } + } +} + +// triggerCheck requests a check, respecting throttle limits +func (w *CursorIDEWatcher) triggerCheck(trigger string) { + w.mu.Lock() + defer w.mu.Unlock() + + now := time.Now() + timeSinceLastCall := now.Sub(w.lastThrottledCall) + + if timeSinceLastCall < w.throttleDuration { + // Within throttle window - mark as pending + if !w.pendingCheck { + w.pendingCheck = true + // Schedule a check after throttle duration expires + delay := w.throttleDuration - timeSinceLastCall + slog.Debug("Check throttled, will retry after delay", + "trigger", trigger, + "delay", delay) + + go func() { + time.Sleep(delay) + w.executePendingCheck() + }() + } + return + } + + // Execute immediately + w.lastThrottledCall = now + w.pendingCheck = false + go w.checkForChanges(trigger) +} + +// executePendingCheck executes a pending check if one was scheduled +func (w *CursorIDEWatcher) executePendingCheck() { + w.mu.Lock() + defer w.mu.Unlock() + + if !w.pendingCheck { + return + } + + now := time.Now() + timeSinceLastCall := now.Sub(w.lastThrottledCall) + + if timeSinceLastCall >= w.throttleDuration { + w.lastThrottledCall = now + w.pendingCheck = false + go w.checkForChanges("throttled") + } +} + +// checkForChanges queries the databases and processes any new or updated sessions +func (w *CursorIDEWatcher) checkForChanges(trigger string) { + slog.Debug("Checking for changes", "trigger", trigger) + + w.mu.Lock() + lastCheck := w.lastCheck + w.lastCheck = time.Now() + w.mu.Unlock() + + // Load composer IDs from workspace database + composerIDs, err := LoadWorkspaceComposerIDs(w.workspace.DBPath) + if err != nil { + slog.Error("Failed to load workspace composer IDs", "error", err) + return + } + + if len(composerIDs) == 0 { + slog.Debug("No composers found in workspace") + return + } + + // Load composer data from global database + composers, err := LoadComposerDataBatch(w.globalDbPath, composerIDs) + if err != nil { + slog.Error("Failed to load composer data", "error", err) + return + } + + // Track new/updated sessions + newCount := 0 + updatedCount := 0 + + for composerID, composer := range composers { + // Skip if conversation is empty + if len(composer.Conversation) == 0 { + continue + } + + // Check if this is a new or updated session + w.mu.RLock() + knownTimestamp, exists := w.knownComposers[composerID] + w.mu.RUnlock() + + currentTimestamp := composer.LastUpdatedAt + if currentTimestamp == 0 { + currentTimestamp = composer.CreatedAt + } + + // Determine if we should process this session + shouldProcess := false + if !exists { + // New session + shouldProcess = true + newCount++ + slog.Info("Found new session", "composerID", composerID, "name", composer.Name) + } else if currentTimestamp > knownTimestamp { + // Updated session + shouldProcess = true + updatedCount++ + slog.Info("Found updated session", + "composerID", composerID, + "name", composer.Name, + "oldTimestamp", knownTimestamp, + "newTimestamp", currentTimestamp) + } + + if shouldProcess { + // Guard against a race condition: Cursor may commit the composerData record + // (updating LastUpdatedAt and FullConversationHeadersOnly) before the + // corresponding bubble records are committed to the database. If the header + // list references more bubbles than were actually loaded, the session data is + // incomplete. By not advancing knownComposers, the next check cycle will + // re-process this session once all bubbles are available. + if len(composer.FullConversationHeadersOnly) > 0 && + len(composer.Conversation) < len(composer.FullConversationHeadersOnly) { + slog.Warn("Incomplete composer data (bubble records not yet committed), will retry on next check", + "composerID", composerID, + "expectedBubbles", len(composer.FullConversationHeadersOnly), + "loadedBubbles", len(composer.Conversation)) + continue + } + + // Guard against a second race condition: the bubble count may match the header + // count (so the guard above passes), but the last committed bubble is a user + // message with no agent response yet. This happens because Cursor writes the + // user bubble and updates lastUpdatedAt in one step, before the agent has + // produced a reply. Processing now would generate markdown that is missing the + // agent response entirely. + // + // We skip the session and let knownComposers stay at the old timestamp so it + // is re-evaluated on the next check cycle (when the agent reply arrives). + // The pendingIncomplete map records when we first saw the session in this + // state. If the agent never replies (e.g. the user abandoned the chat), + // we process the session anyway after incompleteTimeout so the markdown is + // not lost permanently. + if len(composer.Conversation) > 0 { + lastBubble := &composer.Conversation[len(composer.Conversation)-1] + if lastBubble.Type == 1 { + w.mu.Lock() + firstSeen, alreadyPending := w.pendingIncomplete[composerID] + if !alreadyPending { + w.pendingIncomplete[composerID] = time.Now() + w.mu.Unlock() + slog.Warn("Last bubble is a user message (agent not yet responded), will retry on next check", + "composerID", composerID, + "lastBubbleID", lastBubble.BubbleID) + continue + } + if time.Since(firstSeen) < w.incompleteTimeout { + w.mu.Unlock() + slog.Warn("Still waiting for agent response, will retry on next check", + "composerID", composerID, + "waited", time.Since(firstSeen)) + continue + } + // Timed out — the agent likely never replied; process anyway. + delete(w.pendingIncomplete, composerID) + w.mu.Unlock() + slog.Warn("Timed out waiting for agent response, processing incomplete session", + "composerID", composerID, + "waited", time.Since(firstSeen)) + } else { + // Last bubble is an agent message — clear any stale pending state. + w.mu.Lock() + delete(w.pendingIncomplete, composerID) + w.mu.Unlock() + } + } + + // Update known timestamp + w.mu.Lock() + w.knownComposers[composerID] = currentTimestamp + w.mu.Unlock() + + // Convert to AgentChatSession + session, err := ConvertToAgentChatSession(composer) + if err != nil { + slog.Warn("Failed to convert composer to session", + "composerID", composerID, + "error", err) + continue + } + + // Write debug output if requested + if w.debugRaw { + if err := writeDebugOutput(session); err != nil { + slog.Warn("Failed to write debug output", + "sessionID", session.SessionID, + "error", err) + } + } + + // Invoke callback + slog.Info("Invoking callback for session", + "sessionID", session.SessionID, + "slug", session.Slug) + w.sessionCallback(session) + } + } + + elapsed := time.Since(lastCheck) + slog.Info("Completed check for changes", + "trigger", trigger, + "totalComposers", len(composers), + "newSessions", newCount, + "updatedSessions", updatedCount, + "elapsedSinceLastCheck", elapsed) +} diff --git a/specstory-cli/pkg/providers/cursoride/workspace.go b/specstory-cli/pkg/providers/cursoride/workspace.go new file mode 100644 index 00000000..dd4b1a08 --- /dev/null +++ b/specstory-cli/pkg/providers/cursoride/workspace.go @@ -0,0 +1,631 @@ +package cursoride + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi" +) + +// WorkspaceMatch represents a matched workspace directory +type WorkspaceMatch struct { + ID string // Workspace directory name + Path string // Full path to workspace directory + DBPath string // Path to workspace database + URI string // Original workspace URI +} + +// isUnixStylePathOnWindows detects if a path represents a remote (WSL/SSH) filesystem +// location while running on Windows. VS Code's fsPath returns these paths in two forms: +// - "/home/user/project" — forward-slash form (the raw fsPath value) +// - "\home\user\project" — backslash form (Windows filepath.Clean applied to the above) +// +// Both forms have no drive/volume name and must not be passed through filepath.Abs, +// which would corrupt them by prepending the current drive letter (e.g. C:\). +func isUnixStylePathOnWindows(path string) bool { + if runtime.GOOS != "windows" || filepath.VolumeName(path) != "" { + return false + } + // Forward-slash prefix: /home/user/project + if strings.HasPrefix(path, "/") { + return true + } + // Backslash prefix without UNC double-backslash: \home\user\project + // (VS Code converts the forward-slash form to this on Windows) + if strings.HasPrefix(path, `\`) && !strings.HasPrefix(path, `\\`) { + return true + } + return false +} + +// isWindowsWSLUNCPath reports whether path is a Windows UNC path pointing into WSL, +// i.e. \\wsl.localhost\\... or \\wsl$\\... +func isWindowsWSLUNCPath(path string) bool { + lower := strings.ToLower(path) + return strings.HasPrefix(lower, `\\wsl.localhost\`) || strings.HasPrefix(lower, `\\wsl$\`) +} + +// normalizeWindowsWSLPath converts Windows UNC WSL paths to Unix format. +// Handles paths like: +// - \\wsl.localhost\Ubuntu\home\user\project -> /home/user/project +// - \\wsl$\Ubuntu\home\user\project -> /home/user/project +func normalizeWindowsWSLPath(path string) string { + if !strings.Contains(path, "\\") { + return path + } + + // Convert backslashes to forward slashes + normalized := strings.ReplaceAll(path, "\\", "/") + + // Strip Windows UNC WSL prefixes and the distro name + lower := strings.ToLower(normalized) + for _, prefix := range []string{"//wsl.localhost/", "//wsl$/"} { + if strings.HasPrefix(lower, prefix) { + remainder := normalized[len(prefix):] + // Skip the distro name (e.g., "Ubuntu/home/user" -> "/home/user") + if slashIdx := strings.Index(remainder, "/"); slashIdx >= 0 { + return remainder[slashIdx:] + } + return "/" + } + } + + return normalized +} + +// normalizePathForComparison normalizes a path for workspace matching. +// Handles three cases: +// 1. Windows UNC WSL paths: \\wsl.localhost\Ubuntu\... -> /home/user/... +// 2. Unix-style paths on Windows (WSL/SSH remotes): preserved as-is +// 3. Normal paths: resolved to canonical form with symlinks and case normalization +func normalizePathForComparison(path string) (string, error) { + originalPath := path + + // Step 1: Normalize Windows UNC WSL paths (\\wsl.localhost\... or \\wsl$\...) to Unix format. + // We only trigger this for actual WSL UNC paths, not for ordinary Windows paths like C:\Users\... + if runtime.GOOS == "windows" && isWindowsWSLUNCPath(path) { + path = normalizeWindowsWSLPath(path) + if path != originalPath { + slog.Debug("Normalized Windows UNC WSL path", + "original", originalPath, + "normalized", path) + } + } + + // Step 2: Check if it's a Unix-style path on Windows (WSL/SSH remote) + if isUnixStylePathOnWindows(path) { + // Don't use filepath.Abs or GetCanonicalPath - they would corrupt the path + // on Windows by treating "/home/user/project" as a relative path + cleaned := filepath.Clean(path) + slog.Debug("Preserved Unix-style path on Windows", + "path", cleaned) + return cleaned, nil + } + + // Step 3: Normal path handling - resolve to canonical form + absPath, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("failed to get absolute path: %w", err) + } + + canonicalPath, err := spi.GetCanonicalPath(absPath) + if err != nil { + slog.Warn("Failed to get canonical path, using absolute path", + "path", path, + "error", err) + return absPath, nil + } + + return canonicalPath, nil +} + +// FindWorkspaceForProject finds the workspace directory that matches the given project path +// Returns the workspace match or an error if not found +func FindWorkspaceForProject(projectPath string) (*WorkspaceMatch, error) { + // Get canonical project path (resolve symlinks, normalize case) + absProjectPath, err := filepath.Abs(projectPath) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %w", err) + } + + canonicalProjectPath, err := spi.GetCanonicalPath(absProjectPath) + if err != nil { + slog.Warn("Failed to get canonical path, using absolute path", + "projectPath", projectPath, + "error", err) + canonicalProjectPath = absProjectPath + } + + slog.Debug("Searching for workspace matching project", + "projectPath", projectPath, + "canonicalPath", canonicalProjectPath) + + // Get workspace storage directory + workspaceStoragePath, err := GetWorkspaceStoragePath() + if err != nil { + return nil, fmt.Errorf("failed to get workspace storage path: %w", err) + } + + // Read all workspace directories + entries, err := os.ReadDir(workspaceStoragePath) + if err != nil { + return nil, fmt.Errorf("failed to read workspace storage directory: %w", err) + } + + // Track all workspace directories for potential matches + var matches []WorkspaceMatch + + // Check each workspace directory + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + workspaceID := entry.Name() + workspacePath := filepath.Join(workspaceStoragePath, workspaceID) + + // Read workspace.json + workspaceJSONPath := filepath.Join(workspacePath, "workspace.json") + workspaceJSON, err := readWorkspaceJSON(workspaceJSONPath) + if err != nil { + slog.Debug("Skipping workspace directory (no valid workspace.json)", + "workspaceID", workspaceID, + "error", err) + continue + } + + // Get the workspace URI (prefer workspace over folder) + workspaceURI := workspaceJSON.Workspace + if workspaceURI == "" { + workspaceURI = workspaceJSON.Folder + } + + if workspaceURI == "" { + slog.Debug("Skipping workspace directory (no workspace or folder URI)", + "workspaceID", workspaceID) + continue + } + + // Convert URI to file path + workspaceFilePath, err := uriToPath(workspaceURI) + if err != nil { + slog.Debug("Skipping workspace directory (invalid URI)", + "workspaceID", workspaceID, + "uri", workspaceURI, + "error", err) + continue + } + + // Get canonical workspace path + canonicalWorkspacePath, err := spi.GetCanonicalPath(workspaceFilePath) + if err != nil { + slog.Debug("Failed to get canonical workspace path", + "workspacePath", workspaceFilePath, + "error", err) + canonicalWorkspacePath = workspaceFilePath + } + + // Compare paths + if canonicalProjectPath == canonicalWorkspacePath { + // Check if workspace database exists + dbPath := filepath.Join(workspacePath, "state.vscdb") + if _, err := os.Stat(dbPath); err != nil { + slog.Debug("Workspace match found but database missing", + "workspaceID", workspaceID, + "dbPath", dbPath) + continue + } + + matches = append(matches, WorkspaceMatch{ + ID: workspaceID, + Path: workspacePath, + DBPath: dbPath, + URI: workspaceURI, + }) + + slog.Info("Found matching workspace", + "workspaceID", workspaceID, + "projectPath", canonicalProjectPath, + "workspaceURI", workspaceURI) + } + } + + if len(matches) == 0 { + return nil, fmt.Errorf("no workspace found for project path: %s", projectPath) + } + + // If multiple matches, return the first one + if len(matches) > 1 { + slog.Warn("Multiple workspaces match project path, using first", + "projectPath", projectPath, + "matchCount", len(matches)) + } + + return &matches[0], nil +} + +// FindAllWorkspacesForProject finds all workspace directories that match the given project path. +// In WSL, the same project may have multiple workspaces with different URI formats +// (e.g., file://wsl.localhost/... and vscode-remote://wsl+...). +// For SSH remotes, matches are based on Git repository identity when available. +func FindAllWorkspacesForProject(projectPath string) ([]WorkspaceMatch, error) { + // Normalize project path for comparison (handles Windows WSL paths, Unix paths on Windows, etc.) + canonicalProjectPath, err := normalizePathForComparison(projectPath) + if err != nil { + return nil, fmt.Errorf("failed to normalize project path: %w", err) + } + + // Get the project basename for SSH remote matching + // SSH remotes have paths on different machines, so we match by repository name + projectBasename := filepath.Base(canonicalProjectPath) + + // If the project path is itself a .code-workspace file, pre-collect its folders. + // This lets us also match workspace entries opened directly from those folders + // (Method 4 below), so both usage patterns are discoverable via the workspace file path. + var workspaceFileFolders []string + if strings.HasSuffix(canonicalProjectPath, ".code-workspace") { + workspaceFileFolders = collectCodeWorkspaceFolders(canonicalProjectPath) + } + + slog.Debug("Searching for all workspaces matching project", + "projectPath", projectPath, + "canonicalPath", canonicalProjectPath, + "projectBasename", projectBasename) + + // Get workspace storage directory + workspaceStoragePath, err := GetWorkspaceStoragePath() + if err != nil { + return nil, fmt.Errorf("failed to get workspace storage path: %w", err) + } + + // Read all workspace directories + entries, err := os.ReadDir(workspaceStoragePath) + if err != nil { + return nil, fmt.Errorf("failed to read workspace storage directory: %w", err) + } + + var matches []WorkspaceMatch + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + workspaceID := entry.Name() + workspacePath := filepath.Join(workspaceStoragePath, workspaceID) + + // Read workspace.json + workspaceJSONPath := filepath.Join(workspacePath, "workspace.json") + workspaceJSON, err := readWorkspaceJSON(workspaceJSONPath) + if err != nil { + continue + } + + workspaceURI := workspaceJSON.Workspace + if workspaceURI == "" { + workspaceURI = workspaceJSON.Folder + } + if workspaceURI == "" { + continue + } + + workspaceFilePath, err := uriToPath(workspaceURI) + if err != nil { + continue + } + + // Normalize workspace path for comparison (handles Unix paths on Windows, etc.) + canonicalWorkspacePath, err := normalizePathForComparison(workspaceFilePath) + if err != nil { + slog.Debug("Failed to normalize workspace path", + "workspaceID", workspaceID, + "path", workspaceFilePath, + "error", err) + canonicalWorkspacePath = workspaceFilePath + } + + // Method 1: Direct path matching (works for local and WSL workspaces) + isMatch := canonicalProjectPath == canonicalWorkspacePath + + // Method 2: Basename matching (for SSH remotes) + // When direct path comparison fails, fall back to basename matching. + // This handles SSH remotes where the workspace path is on a different machine. + if !isMatch { + workspaceBasename := filepath.Base(canonicalWorkspacePath) + + if projectBasename == workspaceBasename { + isMatch = true + if isSSHRemoteURI(workspaceURI) { + slog.Info("Matched SSH remote workspace by repository name", + "workspaceID", workspaceID, + "workspaceURI", workspaceURI, + "localPath", canonicalProjectPath, + "remotePath", canonicalWorkspacePath, + "repoName", projectBasename) + } + } + } + + // Method 3: Code workspace file matching. + // When Cursor IDE is opened via a .code-workspace file, the workspace.json stores + // the workspace file URI rather than the folder URI. Check whether that workspace + // file lists our target folder as one of its folders. + if !isMatch && strings.HasSuffix(canonicalWorkspacePath, ".code-workspace") { + if codeWorkspaceContainsFolder(canonicalWorkspacePath, canonicalProjectPath) { + isMatch = true + slog.Debug("Matched workspace by .code-workspace folder reference", + "workspaceID", workspaceID, + "workspaceFile", canonicalWorkspacePath, + "targetFolder", canonicalProjectPath) + } + } + + // Method 4: project path is a .code-workspace file — also match workspace entries + // opened directly from a folder listed in that workspace file. + if !isMatch { + for _, folderPath := range workspaceFileFolders { + if canonicalWorkspacePath == folderPath { + isMatch = true + slog.Debug("Matched workspace entry by folder listed in .code-workspace", + "workspaceID", workspaceID, + "folder", folderPath, + "workspaceFile", canonicalProjectPath) + break + } + } + } + + if isMatch { + dbPath := filepath.Join(workspacePath, "state.vscdb") + if _, err := os.Stat(dbPath); err != nil { + continue + } + + matches = append(matches, WorkspaceMatch{ + ID: workspaceID, + Path: workspacePath, + DBPath: dbPath, + URI: workspaceURI, + }) + + slog.Debug("Found matching workspace", + "workspaceID", workspaceID, + "workspaceURI", workspaceURI) + } + } + + if len(matches) == 0 { + return nil, fmt.Errorf("no workspace found for project path: %s", projectPath) + } + + slog.Debug("Found all matching workspaces", + "projectPath", projectPath, + "matchCount", len(matches)) + + return matches, nil +} + +// isSSHRemoteURI checks if a URI is a vscode-remote SSH URI +func isSSHRemoteURI(uri string) bool { + return strings.HasPrefix(uri, "vscode-remote://ssh-remote") +} + +// LoadComposerIDsFromAllWorkspaces loads and deduplicates composer IDs from all matching workspaces. +// This handles WSL environments where the same project may have multiple workspace entries. +func LoadComposerIDsFromAllWorkspaces(workspaces []WorkspaceMatch) ([]string, error) { + seen := make(map[string]bool) + var allIDs []string + + for _, ws := range workspaces { + ids, err := LoadWorkspaceComposerIDs(ws.DBPath) + if err != nil { + slog.Warn("Failed to load composer IDs from workspace", + "workspaceID", ws.ID, + "error", err) + continue + } + + for _, id := range ids { + if !seen[id] { + seen[id] = true + allIDs = append(allIDs, id) + } + } + } + + return allIDs, nil +} + +// readWorkspaceJSON reads and parses a workspace.json file +func readWorkspaceJSON(path string) (*WorkspaceJSON, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read workspace.json: %w", err) + } + + var workspace WorkspaceJSON + if err := json.Unmarshal(data, &workspace); err != nil { + return nil, fmt.Errorf("failed to parse workspace.json: %w", err) + } + + return &workspace, nil +} + +// uriToPath converts a workspace URI to a local file path. +// Handles standard file:// URIs, WSL file://wsl.localhost/ URIs, +// vscode-remote://wsl+distro/ URIs used by Cursor/VS Code in WSL, +// and vscode-remote://ssh-remote+config/path URIs for SSH remotes. +func uriToPath(uri string) (string, error) { + // Handle vscode-remote:// URIs before url.Parse because Go's URL parser + // rejects percent-encoded characters like %2B in the host component + // (e.g., vscode-remote://wsl%2Bubuntu/home/user/project) + if strings.HasPrefix(uri, "vscode-remote://") { + return parseVSCodeRemoteURI(uri) + } + + // Parse the URI + parsedURI, err := url.Parse(uri) + if err != nil { + return "", fmt.Errorf("failed to parse URI: %w", err) + } + + // Reject non-file schemes + if parsedURI.Scheme != "file" && parsedURI.Scheme != "" { + return "", fmt.Errorf("unsupported URI scheme %q: %s", parsedURI.Scheme, uri) + } + + // Get the path from the URI and decode it + // This converts %3A to : and other URL-encoded characters + path, err := url.PathUnescape(parsedURI.Path) + if err != nil { + return "", fmt.Errorf("failed to decode URI path: %w", err) + } + + // Handle WSL file:// URIs (e.g., file://wsl.localhost/Ubuntu/home/user/project) + // Host is "wsl.localhost" and path starts with /{DistroName}/... + // Strip the distro name to get the actual WSL filesystem path + if strings.EqualFold(parsedURI.Host, "wsl.localhost") || strings.HasPrefix(strings.ToLower(parsedURI.Host), "wsl$") { + // path = /Ubuntu/home/user/project → strip /Ubuntu → /home/user/project + if len(path) > 1 { + if idx := strings.Index(path[1:], "/"); idx >= 0 { + path = path[idx+1:] + slog.Debug("Converted WSL file URI to path", "uri", uri, "path", path) + return path, nil + } + } + return "", fmt.Errorf("malformed WSL URI path: %s", uri) + } + + // On Windows, URL paths have an extra leading slash (e.g., file:///c:/Users becomes /c:/Users) + // We need to remove the leading slash and normalize the path + if filepath.Separator == '\\' { + // Remove leading slash on Windows + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + // Normalize path separators to backslashes + path = filepath.FromSlash(path) + } + + return path, nil +} + +// collectCodeWorkspaceFolders reads a .code-workspace JSON file and returns the +// normalized paths of all listed folders. Relative paths are resolved against the +// workspace file's directory. +func collectCodeWorkspaceFolders(workspaceFilePath string) []string { + data, err := os.ReadFile(workspaceFilePath) + if err != nil { + slog.Debug("collectCodeWorkspaceFolders: failed to read workspace file", + "path", workspaceFilePath, "error", err) + return nil + } + + var workspace struct { + Folders []struct { + Path string `json:"path"` + } `json:"folders"` + } + if err := json.Unmarshal(data, &workspace); err != nil { + slog.Debug("collectCodeWorkspaceFolders: failed to parse workspace file", + "path", workspaceFilePath, "error", err) + return nil + } + + workspaceDir := filepath.Dir(workspaceFilePath) + var folders []string + for _, folder := range workspace.Folders { + if folder.Path == "" { + continue + } + + // Resolve relative paths against the workspace file's directory. + var resolved string + if filepath.IsAbs(folder.Path) { + resolved = folder.Path + } else { + resolved = filepath.Join(workspaceDir, folder.Path) + } + + canonical, err := normalizePathForComparison(resolved) + if err != nil { + canonical = filepath.Clean(resolved) + } + folders = append(folders, canonical) + } + + return folders +} + +// codeWorkspaceContainsFolder reports whether canonicalFolder is listed in the +// .code-workspace file at workspaceFilePath. +func codeWorkspaceContainsFolder(workspaceFilePath, canonicalFolder string) bool { + for _, f := range collectCodeWorkspaceFolders(workspaceFilePath) { + if f == canonicalFolder { + return true + } + } + return false +} + +// parseVSCodeRemoteURI extracts the filesystem path from a vscode-remote:// URI. +// Handles two types of remote URIs: +// 1. WSL: vscode-remote://wsl%2B{distro}/{path} - path is the WSL filesystem path +// 2. SSH: vscode-remote://ssh-remote%2B{config-hex}/{path} - path is the remote filesystem path +// Go's url.Parse rejects %2B in the host component, so we parse manually. +func parseVSCodeRemoteURI(uri string) (string, error) { + // Strip scheme prefix: "vscode-remote://" + remainder := strings.TrimPrefix(uri, "vscode-remote://") + + // Split host from path at the first unencoded slash after the host + // The host is percent-encoded (e.g., "wsl%2Bubuntu" or "ssh-remote%2B{config-hex}") + slashIdx := strings.Index(remainder, "/") + if slashIdx < 0 { + return "", fmt.Errorf("malformed vscode-remote URI (no path): %s", uri) + } + + host := remainder[:slashIdx] + pathPart := remainder[slashIdx:] + + // Decode the host to determine remote type + decodedHost, err := url.PathUnescape(host) + if err != nil { + decodedHost = host + } + + hostLower := strings.ToLower(decodedHost) + + // Check if it's a supported remote type + if !strings.HasPrefix(hostLower, "wsl+") && + !strings.EqualFold(decodedHost, "wsl") && + !strings.HasPrefix(hostLower, "ssh-remote+") && + !strings.EqualFold(decodedHost, "ssh-remote") && + !strings.HasPrefix(hostLower, "dev-container+") && + !strings.EqualFold(decodedHost, "dev-container") { + return "", fmt.Errorf("unsupported vscode-remote host %q: %s", decodedHost, uri) + } + + // Decode the path portion + path, err := url.PathUnescape(pathPart) + if err != nil { + return "", fmt.Errorf("failed to decode vscode-remote URI path: %w", err) + } + + // Log the conversion with appropriate context + switch { + case strings.HasPrefix(hostLower, "ssh-remote"): + slog.Debug("Converted vscode-remote SSH URI to path", "uri", uri, "path", path) + case strings.HasPrefix(hostLower, "dev-container"): + slog.Debug("Converted vscode-remote dev container URI to path", "uri", uri, "path", path) + default: + slog.Debug("Converted vscode-remote WSL URI to path", "uri", uri, "path", path) + } + + return path, nil +} diff --git a/specstory-cli/pkg/providers/cursoride/workspace_test.go b/specstory-cli/pkg/providers/cursoride/workspace_test.go new file mode 100644 index 00000000..65d9cda4 --- /dev/null +++ b/specstory-cli/pkg/providers/cursoride/workspace_test.go @@ -0,0 +1,364 @@ +package cursoride + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestUriToPath(t *testing.T) { + tests := []struct { + name string + uri string + wantPath string + wantError string + }{ + // Standard file:// URIs (Linux/macOS) + { + name: "standard Linux file URI", + uri: "file:///home/user/project", + wantPath: "/home/user/project", + }, + { + name: "standard Linux file URI with spaces", + uri: "file:///home/user/my%20project", + wantPath: "/home/user/my project", + }, + + // WSL file://wsl.localhost URIs + { + name: "WSL wsl.localhost URI with Ubuntu", + uri: "file://wsl.localhost/Ubuntu/home/user/project", + wantPath: "/home/user/project", + }, + { + name: "WSL wsl.localhost URI with different distro", + uri: "file://wsl.localhost/Debian/home/user/project", + wantPath: "/home/user/project", + }, + { + name: "WSL wsl.localhost URI case insensitive host", + uri: "file://WSL.LOCALHOST/Ubuntu/home/user/project", + wantPath: "/home/user/project", + }, + { + name: "WSL wsl.localhost URI with deep path", + uri: "file://wsl.localhost/Ubuntu/home/user/code/specstory-monorepo", + wantPath: "/home/user/code/specstory-monorepo", + }, + { + name: "WSL wsl.localhost URI with only distro (no path)", + uri: "file://wsl.localhost/Ubuntu", + wantError: "malformed WSL URI path", + }, + + // WSL wsl$ URIs + { + name: "WSL wsl$ URI", + uri: "file://wsl$/Ubuntu/home/user/project", + wantPath: "/home/user/project", + }, + { + name: "WSL wsl$ URI case insensitive", + uri: "file://WSL$/Ubuntu/home/user/project", + wantPath: "/home/user/project", + }, + + // vscode-remote:// URIs (delegated to parseVSCodeRemoteURI) + { + name: "vscode-remote URI with percent-encoded host", + uri: "vscode-remote://wsl%2Bubuntu/home/user/project", + wantPath: "/home/user/project", + }, + { + name: "vscode-remote URI with plus in host", + uri: "vscode-remote://wsl+ubuntu/home/user/project", + wantPath: "/home/user/project", + }, + { + name: "vscode-remote SSH URI with hex-encoded config", + uri: "vscode-remote://ssh-remote%2B7b22686f73744e616d65223a226d61632d6d696e69227d/Users/bago/code/getspecstory", + wantPath: "/Users/bago/code/getspecstory", + }, + + // Unsupported schemes + { + name: "unsupported http scheme", + uri: "http://example.com/path", + wantError: "unsupported URI scheme", + }, + { + name: "unsupported https scheme", + uri: "https://example.com/path", + wantError: "unsupported URI scheme", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := uriToPath(tt.uri) + + if tt.wantError != "" { + if err == nil { + t.Errorf("uriToPath(%q) expected error containing %q, got nil", tt.uri, tt.wantError) + return + } + if got := err.Error(); !contains(got, tt.wantError) { + t.Errorf("uriToPath(%q) error = %q, want error containing %q", tt.uri, got, tt.wantError) + } + return + } + + if err != nil { + t.Errorf("uriToPath(%q) unexpected error: %v", tt.uri, err) + return + } + + if got != tt.wantPath { + t.Errorf("uriToPath(%q) = %q, want %q", tt.uri, got, tt.wantPath) + } + }) + } +} + +func TestUriToPath_WindowsPaths(t *testing.T) { + // Windows-specific path handling only runs on Windows + if runtime.GOOS != "windows" { + t.Skip("Windows path tests only run on Windows") + } + + tests := []struct { + name string + uri string + wantPath string + }{ + { + name: "Windows file URI", + uri: "file:///c%3A/Users/Admin/project", + wantPath: "c:\\Users\\Admin\\project", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := uriToPath(tt.uri) + if err != nil { + t.Errorf("uriToPath(%q) unexpected error: %v", tt.uri, err) + return + } + if got != tt.wantPath { + t.Errorf("uriToPath(%q) = %q, want %q", tt.uri, got, tt.wantPath) + } + }) + } +} + +func TestParseVSCodeRemoteURI(t *testing.T) { + tests := []struct { + name string + uri string + wantPath string + wantError string + }{ + // Valid WSL URIs + { + name: "percent-encoded wsl+ubuntu", + uri: "vscode-remote://wsl%2Bubuntu/home/user/project", + wantPath: "/home/user/project", + }, + { + name: "unencoded wsl+ubuntu", + uri: "vscode-remote://wsl+ubuntu/home/user/project", + wantPath: "/home/user/project", + }, + { + name: "percent-encoded wsl+Debian", + uri: "vscode-remote://wsl%2BDebian/home/user/project", + wantPath: "/home/user/project", + }, + { + name: "case insensitive WSL host", + uri: "vscode-remote://WSL%2BUbuntu/home/user/project", + wantPath: "/home/user/project", + }, + { + name: "wsl host without distro name", + uri: "vscode-remote://wsl/home/user/project", + wantPath: "/home/user/project", + }, + { + name: "deep path", + uri: "vscode-remote://wsl%2Bubuntu/home/user/code/specstory-monorepo", + wantPath: "/home/user/code/specstory-monorepo", + }, + { + name: "path with spaces encoded", + uri: "vscode-remote://wsl%2Bubuntu/home/user/my%20project", + wantPath: "/home/user/my project", + }, + { + name: "root path", + uri: "vscode-remote://wsl%2Bubuntu/", + wantPath: "/", + }, + + // Valid SSH remote URIs + { + name: "ssh-remote with simple config", + uri: "vscode-remote://ssh-remote+myserver/home/user/project", + wantPath: "/home/user/project", + }, + { + name: "ssh-remote with hex-encoded config", + uri: "vscode-remote://ssh-remote%2B7b22686f73744e616d65223a226d61632d6d696e69227d/Users/bago/code/getspecstory", + wantPath: "/Users/bago/code/getspecstory", + }, + { + name: "ssh-remote case insensitive", + uri: "vscode-remote://SSH-REMOTE+myserver/home/user/project", + wantPath: "/home/user/project", + }, + + // Dev container URIs - path returned as-is (container-internal path) + { + name: "dev container URI with hex-encoded config", + uri: "vscode-remote://dev-container%2B7b2273657474696e6754797065223a22636f6e7461696e6572222c22636f6e7461696e65724964223a22656335613261653766636632227d/workspace", + wantPath: "/workspace", + }, + { + name: "dev container URI case insensitive", + uri: "vscode-remote://DEV-CONTAINER%2Babc123/home/user/project", + wantPath: "/home/user/project", + }, + + // Error cases + { + name: "no path component", + uri: "vscode-remote://wsl%2Bubuntu", + wantError: "malformed vscode-remote URI (no path)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseVSCodeRemoteURI(tt.uri) + + if tt.wantError != "" { + if err == nil { + t.Errorf("parseVSCodeRemoteURI(%q) expected error containing %q, got nil", tt.uri, tt.wantError) + return + } + if got := err.Error(); !contains(got, tt.wantError) { + t.Errorf("parseVSCodeRemoteURI(%q) error = %q, want error containing %q", tt.uri, got, tt.wantError) + } + return + } + + if err != nil { + t.Errorf("parseVSCodeRemoteURI(%q) unexpected error: %v", tt.uri, err) + return + } + + if got != tt.wantPath { + t.Errorf("parseVSCodeRemoteURI(%q) = %q, want %q", tt.uri, got, tt.wantPath) + } + }) + } +} + +func TestCodeWorkspaceContainsFolder(t *testing.T) { + // Create a temporary directory structure. + tmpDir, err := os.MkdirTemp("", "workspace-contains-test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + // Create the target project folder. + projectDir := filepath.Join(tmpDir, "my-project") + if err := os.Mkdir(projectDir, 0755); err != nil { + t.Fatalf("Failed to create project dir: %v", err) + } + // Resolve symlinks so the canonical path matches what normalizePathForComparison returns + // (e.g. /var → /private/var on macOS). + canonicalProjectDir, err := filepath.EvalSymlinks(projectDir) + if err != nil { + canonicalProjectDir = projectDir + } + + // Create a workspace file in a sibling directory (common real-world pattern). + workspacesDir := filepath.Join(tmpDir, "workspaces") + if err := os.Mkdir(workspacesDir, 0755); err != nil { + t.Fatalf("Failed to create workspaces dir: %v", err) + } + workspaceFile := filepath.Join(workspacesDir, "my-project.code-workspace") + + writeWorkspaceFile := func(content string) { + if err := os.WriteFile(workspaceFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write workspace file: %v", err) + } + } + + tests := []struct { + name string + workspaceContent string + targetFolder string + expected bool + }{ + { + name: "relative path that resolves to target folder", + workspaceContent: `{"folders": [{"path": "../my-project"}]}`, + targetFolder: canonicalProjectDir, + expected: true, + }, + { + name: "absolute path matching target folder", + workspaceContent: `{"folders": [{"path": "` + projectDir + `"}]}`, + targetFolder: canonicalProjectDir, + expected: true, + }, + { + name: "no folders entry matching target", + workspaceContent: `{"folders": [{"path": "../other-project"}]}`, + targetFolder: canonicalProjectDir, + expected: false, + }, + { + name: "empty folders array", + workspaceContent: `{"folders": []}`, + targetFolder: canonicalProjectDir, + expected: false, + }, + { + name: "malformed JSON", + workspaceContent: `not json`, + targetFolder: canonicalProjectDir, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + writeWorkspaceFile(tt.workspaceContent) + result := codeWorkspaceContainsFolder(workspaceFile, tt.targetFolder) + if result != tt.expected { + t.Errorf("codeWorkspaceContainsFolder() = %v, want %v", result, tt.expected) + } + }) + } +} + +// contains checks if s contains substr (simple helper to avoid importing strings in tests) +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/specstory-cli/pkg/session/markdown.go b/specstory-cli/pkg/session/markdown.go index 75c5c737..ef68b733 100644 --- a/specstory-cli/pkg/session/markdown.go +++ b/specstory-cli/pkg/session/markdown.go @@ -256,7 +256,15 @@ func renderGenericTool(tool *ToolInfo) string { sort.Strings(keys) for _, key := range keys { - fmt.Fprintf(&markdown, "- %s: `%v`\n", key, tool.Input[key]) + value := tool.Input[key] + valueStr := fmt.Sprintf("%v", value) + + // Check if value is multiline - if so, use code block + if strings.Contains(valueStr, "\n") { + fmt.Fprintf(&markdown, "- %s:\n\n```\n%v\n```\n\n", key, valueStr) + } else { + fmt.Fprintf(&markdown, "- %s: `%v`\n", key, valueStr) + } } markdown.WriteString("\n") } diff --git a/specstory-cli/pkg/spi/factory/registry.go b/specstory-cli/pkg/spi/factory/registry.go index a665a5d1..7d9ba1d3 100644 --- a/specstory-cli/pkg/spi/factory/registry.go +++ b/specstory-cli/pkg/spi/factory/registry.go @@ -12,7 +12,9 @@ import ( "github.com/specstoryai/getspecstory/specstory-cli/pkg/providers/claudecode" "github.com/specstoryai/getspecstory/specstory-cli/pkg/providers/codexcli" + "github.com/specstoryai/getspecstory/specstory-cli/pkg/providers/copilotide" "github.com/specstoryai/getspecstory/specstory-cli/pkg/providers/cursorcli" + "github.com/specstoryai/getspecstory/specstory-cli/pkg/providers/cursoride" "github.com/specstoryai/getspecstory/specstory-cli/pkg/providers/deepseektui" "github.com/specstoryai/getspecstory/specstory-cli/pkg/providers/droidcli" "github.com/specstoryai/getspecstory/specstory-cli/pkg/providers/geminicli" @@ -78,6 +80,14 @@ func (r *Registry) registerAll() { r.providers["droid"] = droidProvider slog.Debug("Registered provider", "id", "droid", "name", droidProvider.Name()) + cursorideProvider := cursoride.NewProvider() + r.providers["cursoride"] = cursorideProvider + slog.Debug("Registered provider", "id", "cursoride", "name", cursorideProvider.Name()) + + copilotideProvider := copilotide.NewProvider() + r.providers["copilotide"] = copilotideProvider + slog.Debug("Registered provider", "id", "copilotide", "name", copilotideProvider.Name()) + deepseekProvider := deepseektui.NewProvider() r.providers["deepseek"] = deepseekProvider slog.Debug("Registered provider", "id", "deepseek", "name", deepseekProvider.Name()) diff --git a/specstory-cli/pkg/spi/filename_test.go b/specstory-cli/pkg/spi/filename_test.go index a0fcdc25..7c594eb8 100644 --- a/specstory-cli/pkg/spi/filename_test.go +++ b/specstory-cli/pkg/spi/filename_test.go @@ -101,6 +101,24 @@ func TestExtractWordsFromMessage(t *testing.T) { maxWords: 4, expected: []string{"visit", "https", "example", "com"}, }, + { + name: "Markdown heading stripped", + message: "# Plan: Replace Gemini Watcher", + maxWords: 4, + expected: []string{"plan", "replace", "gemini", "watcher"}, + }, + { + name: "Multi-level markdown heading stripped", + message: "## Fix: Config Template Issue", + maxWords: 4, + expected: []string{"fix", "config", "template", "issue"}, + }, + { + name: "Non-heading hash preserved", + message: "Fix bug in #login component", + maxWords: 4, + expected: []string{"fix", "bug", "in", "hash"}, + }, } for _, tt := range tests { @@ -230,6 +248,11 @@ func TestGenerateFilenameFromUserMessage(t *testing.T) { message: "Fix the bug in foo.bar() method", expected: "fix-the-bug-in", }, + { + name: "Markdown heading produces clean slug", + message: "# Plan: Replace Gemini Watcher Polling", + expected: "plan-replace-gemini-watcher", + }, { name: "Uppercase message produces lowercase filename", message: "Create A New Feature", diff --git a/specstory-cli/pkg/spi/path_utils.go b/specstory-cli/pkg/spi/path_utils.go index 1d8cf274..5507a3ef 100644 --- a/specstory-cli/pkg/spi/path_utils.go +++ b/specstory-cli/pkg/spi/path_utils.go @@ -16,6 +16,17 @@ import ( "golang.org/x/text/unicode/norm" ) +// stripMarkdownHeading removes leading markdown heading markers (e.g. "# Title" → "Title"). +// These are structural formatting, not meaningful slug content. +func stripMarkdownHeading(message string) string { + trimmed := strings.TrimSpace(message) + if len(trimmed) > 0 && trimmed[0] == '#' { + trimmed = strings.TrimLeft(trimmed, "#") + trimmed = strings.TrimSpace(trimmed) + } + return trimmed +} + // xmlTagsRe matches XML-style tag blocks including their content, such as // ... or self-closing . // These are injected by IDE tools/system prompts and should be stripped before @@ -51,6 +62,9 @@ func extractWordsFromMessage(message string, maxWords int) []string { return []string{} } + // Strip leading markdown heading markers before they get converted to "hash" + message = stripMarkdownHeading(message) + // Normalize unicode and remove accents t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) normalized, _, _ := transform.String(t, message)