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_-]*>|<[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_-]*>|<[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)