From 357eb6f4065bd8d8391a8245ee424ac12d52256e Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Wed, 21 Jan 2026 11:39:07 +0100 Subject: [PATCH 01/33] Initial implementation for Cursor IDE provider: read the workspace and global databases, find the session ids, retrieve the sessions data, output raw debug data for markdown export implementation. --- pkg/providers/cursoride/agent_session.go | 128 +++++++ pkg/providers/cursoride/database.go | 219 +++++++++++ pkg/providers/cursoride/path_utils.go | 95 +++++ pkg/providers/cursoride/provider.go | 242 ++++++++++++ pkg/providers/cursoride/types.go | 58 +++ pkg/providers/cursoride/workspace.go | 183 ++++++++++ specstory-cli/docs/CURSORIDE-PROVIDER.md | 446 +++++++++++++++++++++++ 7 files changed, 1371 insertions(+) create mode 100644 pkg/providers/cursoride/agent_session.go create mode 100644 pkg/providers/cursoride/database.go create mode 100644 pkg/providers/cursoride/path_utils.go create mode 100644 pkg/providers/cursoride/provider.go create mode 100644 pkg/providers/cursoride/types.go create mode 100644 pkg/providers/cursoride/workspace.go create mode 100644 specstory-cli/docs/CURSORIDE-PROVIDER.md diff --git a/pkg/providers/cursoride/agent_session.go b/pkg/providers/cursoride/agent_session.go new file mode 100644 index 00000000..2982c00c --- /dev/null +++ b/pkg/providers/cursoride/agent_session.go @@ -0,0 +1,128 @@ +package cursoride + +import ( + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/specstoryai/SpecStoryCLI/pkg/spi" + "github.com/specstoryai/SpecStoryCLI/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 minimal SessionData + // For now, we just need something that works - markdown can be improved later + sessionData := &schema.SessionData{ + SessionID: sessionID, + CreatedAt: createdAt, + Exchanges: []schema.Exchange{}, + } + + // Convert conversation messages to exchanges + // For now, create a simple exchange for each user+assistant pair + var currentMessages []schema.Message + for _, bubble := range composer.Conversation { + message := schema.Message{ + Role: getRoleFromType(bubble.Type), + Content: []schema.ContentPart{ + { + Type: schema.ContentTypeText, + Text: bubble.Text, + }, + }, + } + currentMessages = append(currentMessages, message) + + // Create an exchange when we have both user and agent messages + if len(currentMessages) >= 2 { + exchange := schema.Exchange{ + Messages: currentMessages, + } + sessionData.Exchanges = append(sessionData.Exchanges, exchange) + currentMessages = nil + } + } + + // 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 +func slugify(s string) string { + // Simple slugification - just replace spaces and lowercase + // Can be improved later + s = strings.ToLower(s) + 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 +} diff --git a/pkg/providers/cursoride/database.go b/pkg/providers/cursoride/database.go new file mode 100644 index 00000000..48aab66d --- /dev/null +++ b/pkg/providers/cursoride/database.go @@ -0,0 +1,219 @@ +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 +// Returns a list of composer IDs that belong to this workspace +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 db.Close() + + // Query for the composer.composerData key in the ItemTable + var valueJSON string + err = db.QueryRow("SELECT value FROM ItemTable WHERE key = ?", "composer.composerData").Scan(&valueJSON) + if err != nil { + if err == sql.ErrNoRows { + // No composer data in this workspace + slog.Debug("No composer data found in workspace database") + return []string{}, nil + } + return nil, fmt.Errorf("failed to query workspace composer data: %w", err) + } + + // Parse the JSON value + var composerRefs WorkspaceComposerRefs + if err := json.Unmarshal([]byte(valueJSON), &composerRefs); err != nil { + return nil, fmt.Errorf("failed to parse workspace composer refs: %w", err) + } + + // Extract composer IDs + composerIDs := make([]string, 0, len(composerRefs.AllComposers)) + for _, ref := range composerRefs.AllComposers { + composerIDs = append(composerIDs, ref.ComposerID) + } + + 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 db.Close() + + // 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 rows.Close() + + // 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) + } + } + } + } + } + + slog.Info("Loaded composers from global database", + "composerCount", len(composers)) + + return composers, nil +} diff --git a/pkg/providers/cursoride/path_utils.go b/pkg/providers/cursoride/path_utils.go new file mode 100644 index 00000000..44f5eb3b --- /dev/null +++ b/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/pkg/providers/cursoride/provider.go b/pkg/providers/cursoride/provider.go new file mode 100644 index 00000000..20b72bc2 --- /dev/null +++ b/pkg/providers/cursoride/provider.go @@ -0,0 +1,242 @@ +package cursoride + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/specstoryai/SpecStoryCLI/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 db.Close() + + 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) ([]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)) + 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) + } + + 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) + + // TODO: Implement single session loading + return nil, fmt.Errorf("not implemented yet") +} + +// 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) + + // TODO: Implement watching + return fmt.Errorf("not implemented yet") +} + +// 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 +} diff --git a/pkg/providers/cursoride/types.go b/pkg/providers/cursoride/types.go new file mode 100644 index 00000000..80144ef1 --- /dev/null +++ b/pkg/providers/cursoride/types.go @@ -0,0 +1,58 @@ +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"` + Conversation []ComposerConversation `json:"conversation,omitempty"` + FullConversationHeadersOnly []ComposerConversationHeader `json:"fullConversationHeadersOnly"` + 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"` + 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"` +} + +// TimingInfo contains timing information for a conversation bubble +type TimingInfo struct { + StartedAt int64 `json:"startedAt,omitempty"` + CompletedAt int64 `json:"completedAt,omitempty"` +} + +// ToolInvocationData represents tool invocation information +type ToolInvocationData struct { + ToolName string `json:"toolName,omitempty"` + // Add more fields as needed +} + +// WorkspaceComposerRefs represents the workspace-specific composer references +// Stored in workspace database with key "composer.composerData" +type WorkspaceComposerRefs struct { + AllComposers []ComposerRef `json:"allComposers"` +} + +// 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/pkg/providers/cursoride/workspace.go b/pkg/providers/cursoride/workspace.go new file mode 100644 index 00000000..de967daf --- /dev/null +++ b/pkg/providers/cursoride/workspace.go @@ -0,0 +1,183 @@ +package cursoride + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/specstoryai/SpecStoryCLI/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 +} + +// 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 (could sort by timestamp if needed) + if len(matches) > 1 { + slog.Warn("Multiple workspaces match project path, using first", + "projectPath", projectPath, + "matchCount", len(matches)) + } + + return &matches[0], 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 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/docs/CURSORIDE-PROVIDER.md b/specstory-cli/docs/CURSORIDE-PROVIDER.md new file mode 100644 index 00000000..cc48df49 --- /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: +- [ ] `./specstory check cursoride` successfully finds database +- [ ] `./specstory sync cursoride` exports conversations to `.specstory/history/` +- [ ] `./specstory sync cursoride --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 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 From 023d5b03b47c4cd2ebcfe5bb6f62ff2fbd5de132 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Wed, 21 Jan 2026 22:09:45 +0100 Subject: [PATCH 02/33] Generic improvements to the markdown output to get markdown header, user messages, agent messages and thinking blocks to parity with the extension output. Still no tool handling --- pkg/providers/cursoride/agent_session.go | 170 ++++++++++++++++++++-- pkg/providers/cursoride/database.go | 44 +++++- pkg/providers/cursoride/provider.go | 8 +- pkg/providers/cursoride/types.go | 36 ++++- specstory-cli/pkg/spi/factory/registry.go | 5 + 5 files changed, 239 insertions(+), 24 deletions(-) diff --git a/pkg/providers/cursoride/agent_session.go b/pkg/providers/cursoride/agent_session.go index 2982c00c..71250a20 100644 --- a/pkg/providers/cursoride/agent_session.go +++ b/pkg/providers/cursoride/agent_session.go @@ -29,37 +29,106 @@ func ConvertToAgentChatSession(composer *ComposerData) (*spi.AgentChatSession, e // Generate slug from composer name or first user message slug := generateSlug(composer) - // Build minimal SessionData - // For now, we just need something that works - markdown can be improved later + // Build SessionData with provider info sessionData := &schema.SessionData{ + SchemaVersion: "1.0", + Provider: schema.ProviderInfo{ + ID: "cursoride", + Name: "cursoride", + 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 + } + // Convert conversation messages to exchanges // For now, create a simple exchange for each user+assistant pair var currentMessages []schema.Message - for _, bubble := range composer.Conversation { + for i, bubble := range composer.Conversation { + // Skip empty bubbles that have no content to display + // These are typically capability/tool placeholders with no text or thinking + if bubble.Text == "" && (bubble.Thinking == nil || bubble.Thinking.Text == "") && bubble.CapabilityType != 0 { + slog.Debug("Skipping empty capability bubble", + "bubbleId", bubble.BubbleID, + "capabilityType", bubble.CapabilityType) + continue + } + // Build the message text, including thinking blocks if present + messageText := buildMessageText(&bubble) + + // 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: bubble-level modelInfo > composer-level modelConfig + modelName := "" + 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), + Role: getRoleFromType(bubble.Type), + Timestamp: timestamp, + Model: modelName, Content: []schema.ContentPart{ { Type: schema.ContentTypeText, - Text: bubble.Text, + Text: messageText, }, }, } - currentMessages = append(currentMessages, message) - // Create an exchange when we have both user and agent messages - if len(currentMessages) >= 2 { + // 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, create an exchange first + if bubble.Type == 1 && len(currentMessages) > 0 { exchange := schema.Exchange{ Messages: currentMessages, } sessionData.Exchanges = append(sessionData.Exchanges, exchange) currentMessages = nil } + + currentMessages = append(currentMessages, message) + } + + // Create final exchange if there are remaining messages + if len(currentMessages) > 0 { + exchange := schema.Exchange{ + Messages: currentMessages, + } + sessionData.Exchanges = append(sessionData.Exchanges, exchange) } // Marshal to JSON for raw data @@ -101,11 +170,10 @@ func generateSlug(composer *ComposerData) string { return composer.ComposerID } -// slugify converts a string to a slug +// 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 { - // Simple slugification - just replace spaces and lowercase - // Can be improved later - s = strings.ToLower(s) + // Replace spaces with hyphens, keep original casing s = strings.ReplaceAll(s, " ", "-") return s } @@ -126,3 +194,81 @@ func getRoleFromType(messageType int) string { } return schema.RoleAgent } + +// buildMessageText constructs the full message text including thinking blocks +func buildMessageText(bubble *ComposerConversation) 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) + } + + // Add main text if present + if bubble.Text != "" { + parts = append(parts, bubble.Text) + } + + return strings.Join(parts, "\n\n---\n\n") +} + +// 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/pkg/providers/cursoride/database.go b/pkg/providers/cursoride/database.go index 48aab66d..59f1dff7 100644 --- a/pkg/providers/cursoride/database.go +++ b/pkg/providers/cursoride/database.go @@ -39,7 +39,11 @@ func LoadWorkspaceComposerIDs(workspaceDbPath string) ([]string, error) { if err != nil { return nil, fmt.Errorf("failed to open workspace database: %w", err) } - defer db.Close() + defer func() { + if closeErr := db.Close(); closeErr != nil { + slog.Warn("Failed to close workspace database", "error", closeErr) + } + }() // Query for the composer.composerData key in the ItemTable var valueJSON string @@ -83,7 +87,11 @@ func LoadComposerDataBatch(globalDbPath string, composerIDs []string) (map[strin if err != nil { return nil, fmt.Errorf("failed to open global database: %w", err) } - defer db.Close() + 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 @@ -129,7 +137,11 @@ func LoadComposerDataBatch(globalDbPath string, composerIDs []string) (map[strin if err != nil { return nil, fmt.Errorf("failed to query composer data: %w", err) } - defer rows.Close() + defer func() { + if closeErr := rows.Close(); closeErr != nil { + slog.Warn("Failed to close query rows", "error", closeErr) + } + }() // Parse results composers := make(map[string]*ComposerData) @@ -208,6 +220,32 @@ func LoadComposerDataBatch(globalDbPath string, composerIDs []string) (map[strin 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 + } + } + } } } } diff --git a/pkg/providers/cursoride/provider.go b/pkg/providers/cursoride/provider.go index 20b72bc2..43395feb 100644 --- a/pkg/providers/cursoride/provider.go +++ b/pkg/providers/cursoride/provider.go @@ -48,7 +48,11 @@ func (p *Provider) Check(customCommand string) spi.CheckResult { ErrorMessage: fmt.Sprintf("Failed to open global database: %v", err), } } - defer db.Close() + 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) @@ -205,7 +209,7 @@ func (p *Provider) GetAgentChatSession(projectPath string, sessionID string, deb // 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)") + 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 diff --git a/pkg/providers/cursoride/types.go b/pkg/providers/cursoride/types.go index 80144ef1..368b8545 100644 --- a/pkg/providers/cursoride/types.go +++ b/pkg/providers/cursoride/types.go @@ -3,12 +3,14 @@ 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"` - Conversation []ComposerConversation `json:"conversation,omitempty"` + ComposerID string `json:"composerId"` + Name string `json:"name,omitempty"` + Version int `json:"_v,omitempty"` + Conversation []ComposerConversation `json:"conversation,omitempty"` FullConversationHeadersOnly []ComposerConversationHeader `json:"fullConversationHeadersOnly"` - CreatedAt int64 `json:"createdAt"` - LastUpdatedAt int64 `json:"lastUpdatedAt,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) @@ -22,16 +24,36 @@ 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 { - StartedAt int64 `json:"startedAt,omitempty"` - CompletedAt int64 `json:"completedAt,omitempty"` + ClientStartTime float64 `json:"clientStartTime,omitempty"` + ClientRpcSendTime float64 `json:"clientRpcSendTime,omitempty"` + ClientSettleTime float64 `json:"clientSettleTime,omitempty"` + ClientEndTime float64 `json:"clientEndTime,omitempty"` } // ToolInvocationData represents tool invocation information diff --git a/specstory-cli/pkg/spi/factory/registry.go b/specstory-cli/pkg/spi/factory/registry.go index 4691bb12..3f50390c 100644 --- a/specstory-cli/pkg/spi/factory/registry.go +++ b/specstory-cli/pkg/spi/factory/registry.go @@ -13,6 +13,7 @@ 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/cursorcli" + "github.com/specstoryai/getspecstory/specstory-cli/pkg/providers/cursoride" "github.com/specstoryai/getspecstory/specstory-cli/pkg/providers/geminicli" "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi" ) @@ -72,6 +73,10 @@ func (r *Registry) registerAll() { r.providers["gemini"] = geminiProvider slog.Debug("Registered provider", "id", "gemini", "name", geminiProvider.Name()) + cursorideProvider := cursoride.NewProvider() + r.providers["cursoride"] = cursorideProvider + slog.Debug("Registered provider", "id", "cursoride", "name", cursorideProvider.Name()) + r.initialized = true slog.Info("Provider registry initialized", "count", len(r.providers), "providers", r.ListIDsUnsafe()) } From 11196f6612c963d53265a757b0fa3495a94b3109 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Fri, 23 Jan 2026 18:30:27 +0100 Subject: [PATCH 03/33] Implement some tool handlers: write, read, search and shell types --- pkg/providers/cursoride/agent_session.go | 109 +++++- pkg/providers/cursoride/tool_grep.go | 247 ++++++++++++++ pkg/providers/cursoride/tool_grep_search.go | 119 +++++++ pkg/providers/cursoride/tool_handlers.go | 256 ++++++++++++++ pkg/providers/cursoride/tool_read_file.go | 44 +++ pkg/providers/cursoride/tool_shell.go | 78 +++++ pkg/providers/cursoride/tool_write.go | 360 ++++++++++++++++++++ pkg/providers/cursoride/types.go | 44 ++- specstory-cli/docs/CURSORIDE-PROVIDER.md | 8 +- 9 files changed, 1250 insertions(+), 15 deletions(-) create mode 100644 pkg/providers/cursoride/tool_grep.go create mode 100644 pkg/providers/cursoride/tool_grep_search.go create mode 100644 pkg/providers/cursoride/tool_handlers.go create mode 100644 pkg/providers/cursoride/tool_read_file.go create mode 100644 pkg/providers/cursoride/tool_shell.go create mode 100644 pkg/providers/cursoride/tool_write.go diff --git a/pkg/providers/cursoride/agent_session.go b/pkg/providers/cursoride/agent_session.go index 71250a20..f6cfa23c 100644 --- a/pkg/providers/cursoride/agent_session.go +++ b/pkg/providers/cursoride/agent_session.go @@ -49,20 +49,34 @@ func ConvertToAgentChatSession(composer *ComposerData) (*spi.AgentChatSession, e 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 // For now, create a simple exchange for each user+assistant pair var currentMessages []schema.Message for i, bubble := range composer.Conversation { // Skip empty bubbles that have no content to display - // These are typically capability/tool placeholders with no text or thinking - if bubble.Text == "" && (bubble.Thinking == nil || bubble.Thinking.Text == "") && bubble.CapabilityType != 0 { + // 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 if present - messageText := buildMessageText(&bubble) + // 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) @@ -195,8 +209,8 @@ func getRoleFromType(messageType int) string { return schema.RoleAgent } -// buildMessageText constructs the full message text including thinking blocks -func buildMessageText(bubble *ComposerConversation) string { +// 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 @@ -205,14 +219,93 @@ func buildMessageText(bubble *ComposerConversation) string { parts = append(parts, thinkingBlock) } - // Add main text if present - if bubble.Text != "" { + // 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") } +// processToolInvocation processes a tool invocation bubble and returns formatted markdown +func processToolInvocation(bubble *ComposerConversation, capabilitiesMap map[int]*CapabilityData, toolRegistry *ToolRegistry, composerVersion int) string { + var toolData *BubbleConversation + + // V3+ format: tool data is in bubble.ToolFormerData + if composerVersion >= 3 && bubble.ToolFormerData != nil { + toolData = convertToolInvocationData(bubble.ToolFormerData) + } else { + // V1 format: tool data is in capabilities[15].bubbleDataMap[bubbleId] + if capData, ok := capabilitiesMap[bubble.CapabilityType]; ok { + if capData.ParsedBubbleMap != nil { + if bubbleData, found := capData.ParsedBubbleMap[bubble.BubbleID]; found { + toolData = bubbleData + } + } + } + } + + 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) +} + +// 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) diff --git a/pkg/providers/cursoride/tool_grep.go b/pkg/providers/cursoride/tool_grep.go new file mode 100644 index 00000000..24869690 --- /dev/null +++ b/pkg/providers/cursoride/tool_grep.go @@ -0,0 +1,247 @@ +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" + } + message.WriteString(fmt.Sprintf(`Tool use: **%s** • Grep for "%s"%s • %d %s + +`, bubble.Name, params.Pattern, inString, resultsLength, matchWord)) + + // Add output mode + outputMode := params.OutputMode + if outputMode == "" { + outputMode = "content" + } + message.WriteString(fmt.Sprintf("Output mode: %s\n", outputMode)) + + // Add details + message.WriteString(fmt.Sprintf("\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 { + result.WriteString(fmt.Sprintf("| `%s` ", escapeTableCellValue(fileMatch.File))) + } + + // Add content and line number + result.WriteString(fmt.Sprintf("| `%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 { + result.WriteString(fmt.Sprintf("| `%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/pkg/providers/cursoride/tool_grep_search.go b/pkg/providers/cursoride/tool_grep_search.go new file mode 100644 index 00000000..e6d5034b --- /dev/null +++ b/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(`" • **`) + message.WriteString(fmt.Sprintf("%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 + message.WriteString(fmt.Sprintf("| `%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/pkg/providers/cursoride/tool_handlers.go b/pkg/providers/cursoride/tool_handlers.go new file mode 100644 index 00000000..acddc42e --- /dev/null +++ b/pkg/providers/cursoride/tool_handlers.go @@ -0,0 +1,256 @@ +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) + + 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) + message.WriteString(fmt.Sprintf(`
+Tool use: **%s** + +`, 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 != "" { + message.WriteString(fmt.Sprintf("User decision: **%s**\n\n", bubble.UserDecision)) + } + + // Add status + if bubble.Status != "" { + message.WriteString(fmt.Sprintf("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/pkg/providers/cursoride/tool_read_file.go b/pkg/providers/cursoride/tool_read_file.go new file mode 100644 index 00000000..7ba4dd47 --- /dev/null +++ b/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/pkg/providers/cursoride/tool_shell.go b/pkg/providers/cursoride/tool_shell.go new file mode 100644 index 00000000..aec91ed0 --- /dev/null +++ b/pkg/providers/cursoride/tool_shell.go @@ -0,0 +1,78 @@ +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 + message.WriteString(fmt.Sprintf(`
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 != "" { + message.WriteString(fmt.Sprintf(` • Run command: %s + +`, command)) + message.WriteString(fmt.Sprintf("```bash\n%s\n```", command)) + } else { + message.WriteString("\n") + } + + // If we have output, show it in a code block + if result.Output != "" { + message.WriteString(fmt.Sprintf("\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/pkg/providers/cursoride/tool_write.go b/pkg/providers/cursoride/tool_write.go new file mode 100644 index 00000000..20c0676f --- /dev/null +++ b/pkg/providers/cursoride/tool_write.go @@ -0,0 +1,360 @@ +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 != "" { + message.WriteString(fmt.Sprintf(`
Tool use: **%s** • Edit file: %s + +`, bubble.Name, params.RelativeWorkspacePath)) + } else { + message.WriteString(fmt.Sprintf(`
Tool use: **%s** + +`, bubble.Name)) + } + + // Add instructions if present + if params.Instructions != "" { + message.WriteString(fmt.Sprintf("%s\n\n", params.Instructions)) + } + + // Add status if not completed + if bubble.Status != "" && bubble.Status != "completed" { + message.WriteString(fmt.Sprintf("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 { + message.WriteString(fmt.Sprintf("**Chunk %d**\n", i+1)) + message.WriteString(fmt.Sprintf("Lines added: %d, lines removed: %d\n\n", chunk.LinesAdded, chunk.LinesRemoved)) + message.WriteString("```diff\n") + message.WriteString(fmt.Sprintf("@@ -%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 + } + message.WriteString(fmt.Sprintf("```%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 + message.WriteString(fmt.Sprintf(`
Tool use: **%s** + +`, bubble.Name)) + + if rawArgs.Explanation != "" { + message.WriteString(fmt.Sprintf("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 + message.WriteString(fmt.Sprintf(`
+ Tool use: **%s** • Apply patch for %s + `, bubble.Name, rawArgs.FilePath)) + + if rawArgs.Patch != "" { + message.WriteString(fmt.Sprintf("\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 + message.WriteString(fmt.Sprintf(`
+Tool use: **%s** • %s + +`, 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") + message.WriteString(fmt.Sprintf("%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) + message.WriteString(fmt.Sprintf("```%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 != "" { + message.WriteString(fmt.Sprintf("\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/pkg/providers/cursoride/types.go b/pkg/providers/cursoride/types.go index 368b8545..d67367ab 100644 --- a/pkg/providers/cursoride/types.go +++ b/pkg/providers/cursoride/types.go @@ -8,6 +8,7 @@ type ComposerData struct { 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"` @@ -56,10 +57,47 @@ type TimingInfo struct { ClientEndTime float64 `json:"clientEndTime,omitempty"` } -// ToolInvocationData represents tool invocation information +// 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 { - ToolName string `json:"toolName,omitempty"` - // Add more fields as needed + 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 diff --git a/specstory-cli/docs/CURSORIDE-PROVIDER.md b/specstory-cli/docs/CURSORIDE-PROVIDER.md index cc48df49..a6b1850d 100644 --- a/specstory-cli/docs/CURSORIDE-PROVIDER.md +++ b/specstory-cli/docs/CURSORIDE-PROVIDER.md @@ -332,10 +332,10 @@ Manual testing approach (no unit tests initially per project conventions): ## Verification Checklist After implementation: -- [ ] `./specstory check cursoride` successfully finds database -- [ ] `./specstory sync cursoride` exports conversations to `.specstory/history/` -- [ ] `./specstory sync cursoride --debug-raw` creates debug files -- [ ] Markdown files have correct format (timestamps, speakers, messages) +- [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 From 4aaf324ebe3ea4e39f249171650792389f0c8123 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Tue, 27 Jan 2026 11:46:32 +0100 Subject: [PATCH 04/33] Move cursoride code to the right directly after cherry pick of commits --- .../pkg}/providers/cursoride/agent_session.go | 4 +-- .../pkg}/providers/cursoride/database.go | 0 .../pkg}/providers/cursoride/path_utils.go | 0 .../pkg}/providers/cursoride/provider.go | 35 +++++++++++++++++-- .../pkg}/providers/cursoride/tool_grep.go | 0 .../providers/cursoride/tool_grep_search.go | 0 .../pkg}/providers/cursoride/tool_handlers.go | 0 .../providers/cursoride/tool_read_file.go | 0 .../pkg}/providers/cursoride/tool_shell.go | 0 .../pkg}/providers/cursoride/tool_write.go | 0 .../pkg}/providers/cursoride/types.go | 0 .../pkg}/providers/cursoride/workspace.go | 2 +- 12 files changed, 35 insertions(+), 6 deletions(-) rename {pkg => specstory-cli/pkg}/providers/cursoride/agent_session.go (98%) rename {pkg => specstory-cli/pkg}/providers/cursoride/database.go (100%) rename {pkg => specstory-cli/pkg}/providers/cursoride/path_utils.go (100%) rename {pkg => specstory-cli/pkg}/providers/cursoride/provider.go (87%) rename {pkg => specstory-cli/pkg}/providers/cursoride/tool_grep.go (100%) rename {pkg => specstory-cli/pkg}/providers/cursoride/tool_grep_search.go (100%) rename {pkg => specstory-cli/pkg}/providers/cursoride/tool_handlers.go (100%) rename {pkg => specstory-cli/pkg}/providers/cursoride/tool_read_file.go (100%) rename {pkg => specstory-cli/pkg}/providers/cursoride/tool_shell.go (100%) rename {pkg => specstory-cli/pkg}/providers/cursoride/tool_write.go (100%) rename {pkg => specstory-cli/pkg}/providers/cursoride/types.go (100%) rename {pkg => specstory-cli/pkg}/providers/cursoride/workspace.go (98%) diff --git a/pkg/providers/cursoride/agent_session.go b/specstory-cli/pkg/providers/cursoride/agent_session.go similarity index 98% rename from pkg/providers/cursoride/agent_session.go rename to specstory-cli/pkg/providers/cursoride/agent_session.go index f6cfa23c..92726f98 100644 --- a/pkg/providers/cursoride/agent_session.go +++ b/specstory-cli/pkg/providers/cursoride/agent_session.go @@ -7,8 +7,8 @@ import ( "strings" "time" - "github.com/specstoryai/SpecStoryCLI/pkg/spi" - "github.com/specstoryai/SpecStoryCLI/pkg/spi/schema" + "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 diff --git a/pkg/providers/cursoride/database.go b/specstory-cli/pkg/providers/cursoride/database.go similarity index 100% rename from pkg/providers/cursoride/database.go rename to specstory-cli/pkg/providers/cursoride/database.go diff --git a/pkg/providers/cursoride/path_utils.go b/specstory-cli/pkg/providers/cursoride/path_utils.go similarity index 100% rename from pkg/providers/cursoride/path_utils.go rename to specstory-cli/pkg/providers/cursoride/path_utils.go diff --git a/pkg/providers/cursoride/provider.go b/specstory-cli/pkg/providers/cursoride/provider.go similarity index 87% rename from pkg/providers/cursoride/provider.go rename to specstory-cli/pkg/providers/cursoride/provider.go index 43395feb..838db729 100644 --- a/pkg/providers/cursoride/provider.go +++ b/specstory-cli/pkg/providers/cursoride/provider.go @@ -6,8 +6,9 @@ import ( "log/slog" "os" "path/filepath" + "time" - "github.com/specstoryai/SpecStoryCLI/pkg/spi" + "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi" ) // Provider implements the SPI Provider interface for Cursor IDE @@ -218,8 +219,36 @@ func (p *Provider) WatchAgent(ctx context.Context, projectPath string, debugRaw "projectPath", projectPath, "debugRaw", debugRaw) - // TODO: Implement watching - return fmt.Errorf("not implemented yet") + // Default check interval: 2 minutes + // This can be overridden via environment variable for testing/debugging + checkInterval := 2 * time.Minute + if envInterval := os.Getenv("CURSORIDE_DB_CHECK_INTERVAL"); envInterval != "" { + if duration, err := time.ParseDuration(envInterval); err == nil { + checkInterval = duration + slog.Info("Using custom database check interval from environment", "interval", checkInterval) + } else { + slog.Warn("Failed to parse CURSORIDE_DB_CHECK_INTERVAL, using default", "value", envInterval, "error", err) + } + } + + // 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 diff --git a/pkg/providers/cursoride/tool_grep.go b/specstory-cli/pkg/providers/cursoride/tool_grep.go similarity index 100% rename from pkg/providers/cursoride/tool_grep.go rename to specstory-cli/pkg/providers/cursoride/tool_grep.go diff --git a/pkg/providers/cursoride/tool_grep_search.go b/specstory-cli/pkg/providers/cursoride/tool_grep_search.go similarity index 100% rename from pkg/providers/cursoride/tool_grep_search.go rename to specstory-cli/pkg/providers/cursoride/tool_grep_search.go diff --git a/pkg/providers/cursoride/tool_handlers.go b/specstory-cli/pkg/providers/cursoride/tool_handlers.go similarity index 100% rename from pkg/providers/cursoride/tool_handlers.go rename to specstory-cli/pkg/providers/cursoride/tool_handlers.go diff --git a/pkg/providers/cursoride/tool_read_file.go b/specstory-cli/pkg/providers/cursoride/tool_read_file.go similarity index 100% rename from pkg/providers/cursoride/tool_read_file.go rename to specstory-cli/pkg/providers/cursoride/tool_read_file.go diff --git a/pkg/providers/cursoride/tool_shell.go b/specstory-cli/pkg/providers/cursoride/tool_shell.go similarity index 100% rename from pkg/providers/cursoride/tool_shell.go rename to specstory-cli/pkg/providers/cursoride/tool_shell.go diff --git a/pkg/providers/cursoride/tool_write.go b/specstory-cli/pkg/providers/cursoride/tool_write.go similarity index 100% rename from pkg/providers/cursoride/tool_write.go rename to specstory-cli/pkg/providers/cursoride/tool_write.go diff --git a/pkg/providers/cursoride/types.go b/specstory-cli/pkg/providers/cursoride/types.go similarity index 100% rename from pkg/providers/cursoride/types.go rename to specstory-cli/pkg/providers/cursoride/types.go diff --git a/pkg/providers/cursoride/workspace.go b/specstory-cli/pkg/providers/cursoride/workspace.go similarity index 98% rename from pkg/providers/cursoride/workspace.go rename to specstory-cli/pkg/providers/cursoride/workspace.go index de967daf..8dd7d944 100644 --- a/pkg/providers/cursoride/workspace.go +++ b/specstory-cli/pkg/providers/cursoride/workspace.go @@ -9,7 +9,7 @@ import ( "path/filepath" "strings" - "github.com/specstoryai/SpecStoryCLI/pkg/spi" + "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi" ) // WorkspaceMatch represents a matched workspace directory From 56bf48cb88cf32a229a3e645582b9c32d5689c0e Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Tue, 27 Jan 2026 11:59:44 +0100 Subject: [PATCH 05/33] Watch command for Cursor IDE provider --- specstory-cli/main.go | 13 + .../pkg/providers/cursoride/watcher.go | 370 ++++++++++++++++++ 2 files changed, 383 insertions(+) create mode 100644 specstory-cli/pkg/providers/cursoride/watcher.go diff --git a/specstory-cli/main.go b/specstory-cli/main.go index dc1e0f00..202b21e7 100644 --- a/specstory-cli/main.go +++ b/specstory-cli/main.go @@ -567,6 +567,17 @@ By default, 'watch' is for activity from all registered agent providers. Specify debugRaw, _ := cmd.Flags().GetBool("debug-raw") useUTC := getUseUTC(cmd) + // Get cursoride-db-check-interval flag value and set as environment variable + // This is only used by the cursoride provider + if dbCheckInterval, err := cmd.Flags().GetString("cursoride-db-check-interval"); err == nil && dbCheckInterval != "" { + // Validate the duration format + if _, err := time.ParseDuration(dbCheckInterval); err != nil { + return fmt.Errorf("invalid --cursoride-db-check-interval value: %w", err) + } + os.Setenv("CURSORIDE_DB_CHECK_INTERVAL", dbCheckInterval) + slog.Debug("Set Cursor IDE database check interval", "interval", dbCheckInterval) + } + // Setup output configuration config, err := utils.SetupOutputConfig(outputDir) if err != nil { @@ -2086,6 +2097,8 @@ func main() { _ = watchCmd.Flags().MarkHidden("cloud-url") // Hidden flag watchCmd.Flags().Bool("debug-raw", false, "debug mode to output pretty-printed raw data files") _ = watchCmd.Flags().MarkHidden("debug-raw") // Hidden flag + watchCmd.Flags().String("cursoride-db-check-interval", "2m", "Cursor IDE only: how often to check the database if no file events are received (e.g., 2m, 30s)") + _ = watchCmd.Flags().MarkHidden("cursoride-db-check-interval") // Hidden flag watchCmd.Flags().BoolP("local-time-zone", "", false, "use local timezone for file name and content timestamps (when not present: UTC)") checkCmd.Flags().StringP("command", "c", "", "custom agent execution command for the provider") diff --git a/specstory-cli/pkg/providers/cursoride/watcher.go b/specstory-cli/pkg/providers/cursoride/watcher.go new file mode 100644 index 00000000..83364d8e --- /dev/null +++ b/specstory-cli/pkg/providers/cursoride/watcher.go @@ -0,0 +1,370 @@ +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 +} + +// 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, // Configurable in code as requested + lastThrottledCall: time.Now(), + pendingCheck: false, + fsWatcher: fsWatcher, + }, 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 { + // 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) +} From 880a03e408a009e6d6dddd397ea7d4b13d3225b3 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Tue, 27 Jan 2026 12:08:45 +0100 Subject: [PATCH 06/33] Implement single session sync for Cursor IDE provider --- .../pkg/providers/cursoride/provider.go | 84 ++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/specstory-cli/pkg/providers/cursoride/provider.go b/specstory-cli/pkg/providers/cursoride/provider.go index 838db729..1d88cc85 100644 --- a/specstory-cli/pkg/providers/cursoride/provider.go +++ b/specstory-cli/pkg/providers/cursoride/provider.go @@ -204,8 +204,88 @@ func (p *Provider) GetAgentChatSession(projectPath string, sessionID string, deb "sessionID", sessionID, "debugRaw", debugRaw) - // TODO: Implement single session loading - return nil, fmt.Errorf("not implemented yet") + // 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 } // ExecAgentAndWatch is not supported for Cursor IDE (IDE-based, not CLI) From ff77b4385a4a04f6f2d8ee80d0c2ccc668ed0a19 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Wed, 28 Jan 2026 15:23:29 +0100 Subject: [PATCH 07/33] VSCode Copilot IDE plan and basic implementation --- specstory-cli/docs/COPILOTIDE-PROVIDER.md | 1112 +++++++++++++++++ .../pkg/providers/copilotide/agent_session.go | 283 +++++ .../pkg/providers/copilotide/loader.go | 122 ++ .../pkg/providers/copilotide/parser.go | 172 +++ .../pkg/providers/copilotide/path_utils.go | 70 ++ .../pkg/providers/copilotide/provider.go | 176 +++ .../pkg/providers/copilotide/types.go | 176 +++ .../pkg/providers/copilotide/watcher.go | 158 +++ .../pkg/providers/copilotide/workspace.go | 220 ++++ specstory-cli/pkg/spi/factory/registry.go | 5 + 10 files changed, 2494 insertions(+) create mode 100644 specstory-cli/docs/COPILOTIDE-PROVIDER.md create mode 100644 specstory-cli/pkg/providers/copilotide/agent_session.go create mode 100644 specstory-cli/pkg/providers/copilotide/loader.go create mode 100644 specstory-cli/pkg/providers/copilotide/parser.go create mode 100644 specstory-cli/pkg/providers/copilotide/path_utils.go create mode 100644 specstory-cli/pkg/providers/copilotide/provider.go create mode 100644 specstory-cli/pkg/providers/copilotide/types.go create mode 100644 specstory-cli/pkg/providers/copilotide/watcher.go create mode 100644 specstory-cli/pkg/providers/copilotide/workspace.go 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/pkg/providers/copilotide/agent_session.go b/specstory-cli/pkg/providers/copilotide/agent_session.go new file mode 100644 index 00000000..5e3d9122 --- /dev/null +++ b/specstory-cli/pkg/providers/copilotide/agent_session.go @@ -0,0 +1,283 @@ +package copilotide + +import ( + "encoding/json" + "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) spi.AgentChatSession { + // Format timestamps + createdAt := FormatTimestamp(composer.CreationDate) + updatedAt := FormatTimestamp(composer.LastMessageDate) + + // Generate slug + slug := GenerateSlug(composer) + + // Build SessionData + sessionData := &schema.SessionData{ + SchemaVersion: "1.0", + Provider: schema.ProviderInfo{ + ID: "copilotide", + Name: "copilotide", + Version: "1.0", + }, + SessionID: composer.SessionID, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + Slug: slug, + WorkspaceRoot: projectPath, + Exchanges: ConvertRequestsToExchanges(composer.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, + 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 + // 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) []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 + 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 + if invocation.Presentation == "hidden" { + continue + } + + // Find matching tool call and result + toolInfo := BuildToolInfoFromInvocation(invocation, toolCalls, metadata.ToolCallResults) + 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 + 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 + metadata +func BuildToolInfoFromInvocation( + invocation VSCodeToolInvocationResponse, + toolCalls map[string]VSCodeToolCallInfo, + toolResults map[string]VSCodeToolCallResult, +) *schema.ToolInfo { + // Find tool call details from metadata + toolCall, ok := toolCalls[invocation.ToolCallID] + if !ok { + slog.Debug("Tool call not found in metadata", "toolCallId", invocation.ToolCallID) + 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]any + if err := json.Unmarshal([]byte(toolCall.Arguments), &args); err == nil { + toolInfo.Input = args + } + } + + // Add output from results map + 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 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, + "grep": schema.ToolTypeSearch, + "find": schema.ToolTypeSearch, + // Add more as needed + } + + if toolType, ok := mapping[toolName]; ok { + return toolType + } + + slog.Debug("Unknown tool type, mapping to unknown", "toolName", toolName) + return schema.ToolTypeUnknown +} diff --git a/specstory-cli/pkg/providers/copilotide/loader.go b/specstory-cli/pkg/providers/copilotide/loader.go new file mode 100644 index 00000000..8e30ced0 --- /dev/null +++ b/specstory-cli/pkg/providers/copilotide/loader.go @@ -0,0 +1,122 @@ +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 + } + + // Only include .json files + if !strings.HasSuffix(file.Name(), ".json") { + 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 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) + } + + var composer VSCodeComposer + 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)) + + return &composer, nil +} + +// LoadSessionByID loads a specific session by ID from the workspace +func LoadSessionByID(workspaceDir, sessionID string) (*VSCodeComposer, error) { + chatSessionsPath := GetChatSessionsPath(workspaceDir) + sessionPath := filepath.Join(chatSessionsPath, sessionID+".json") + + // Check if file exists + if _, err := os.Stat(sessionPath); os.IsNotExist(err) { + 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) + return nil, nil + } + + 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) + } + + slog.Debug("Loaded state file", "sessionId", sessionID) + 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 +} diff --git a/specstory-cli/pkg/providers/copilotide/parser.go b/specstory-cli/pkg/providers/copilotide/parser.go new file mode 100644 index 00000000..3f2aa733 --- /dev/null +++ b/specstory-cli/pkg/providers/copilotide/parser.go @@ -0,0 +1,172 @@ +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 +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 +} + +// 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..bb8f127c --- /dev/null +++ b/specstory-cli/pkg/providers/copilotide/provider.go @@ -0,0 +1,176 @@ +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) + } + + // Convert to AgentChatSession + agentSession := ConvertToSessionData(*session, projectPath) + + // 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) ([]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 + for _, sessionFile := range sessionFiles { + composer, err := LoadSessionFile(sessionFile) + if err != nil { + slog.Warn("Failed to load session", "file", sessionFile, "error", err) + continue + } + + // Filter empty conversations + if len(composer.Requests) == 0 { + slog.Debug("Skipping empty session", "sessionId", composer.SessionID) + continue + } + + // Convert to AgentChatSession + session := ConvertToSessionData(*composer, projectPath) + 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) + } + } + } + + slog.Debug("Loaded sessions", "count", len(sessions)) + return sessions, 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) +} diff --git a/specstory-cli/pkg/providers/copilotide/types.go b/specstory-cli/pkg/providers/copilotide/types.go new file mode 100644 index 00000000..2e21e65a --- /dev/null +++ b/specstory-cli/pkg/providers/copilotide/types.go @@ -0,0 +1,176 @@ +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 string `json:"message"` + IsUsed bool `json:"isUsed"` +} + +// 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"` + // Add other fields as needed +} + +// 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 { + // Define as needed - can defer to phase 2 +} diff --git a/specstory-cli/pkg/providers/copilotide/watcher.go b/specstory-cli/pkg/providers/copilotide/watcher.go new file mode 100644 index 00000000..75443c3a --- /dev/null +++ b/specstory-cli/pkg/providers/copilotide/watcher.go @@ -0,0 +1,158 @@ +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 files + if !strings.HasSuffix(event.Name, ".json") { + 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) + } + + // Convert to AgentChatSession + session := ConvertToSessionData(*composer, projectPath) + + // 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 extension + 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..617290af --- /dev/null +++ b/specstory-cli/pkg/providers/copilotide/workspace.go @@ -0,0 +1,220 @@ +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 + } + + 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 + } + + // Compare paths + if canonicalProjectPath == canonicalWorkspacePath { + // 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 +} + +// 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/spi/factory/registry.go b/specstory-cli/pkg/spi/factory/registry.go index 3f50390c..8b7c5203 100644 --- a/specstory-cli/pkg/spi/factory/registry.go +++ b/specstory-cli/pkg/spi/factory/registry.go @@ -12,6 +12,7 @@ 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/geminicli" @@ -77,6 +78,10 @@ func (r *Registry) registerAll() { 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()) + r.initialized = true slog.Info("Provider registry initialized", "count", len(r.providers), "providers", r.ListIDsUnsafe()) } From 50e3bf127158fa418cb0613891a6dc49f0fabf69 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Thu, 29 Jan 2026 13:58:25 +0100 Subject: [PATCH 08/33] Adjustments for copilot tool layout --- .../pkg/providers/copilotide/agent_session.go | 83 +++++++++++++------ .../pkg/providers/copilotide/parser.go | 13 +++ 2 files changed, 72 insertions(+), 24 deletions(-) diff --git a/specstory-cli/pkg/providers/copilotide/agent_session.go b/specstory-cli/pkg/providers/copilotide/agent_session.go index 5e3d9122..30d84e37 100644 --- a/specstory-cli/pkg/providers/copilotide/agent_session.go +++ b/specstory-cli/pkg/providers/copilotide/agent_session.go @@ -89,7 +89,8 @@ func ConvertRequestToMessages(req VSCodeRequestBlock) []schema.Message { thinking := ExtractThinkingFromMetadata(req.Result.Metadata) if thinking != "" { thinkingMsg := schema.Message{ - Role: schema.RoleAgent, + Role: schema.RoleAgent, + Model: req.ModelID, Content: []schema.ContentPart{ {Type: schema.ContentTypeThinking, Text: thinking}, }, @@ -99,7 +100,7 @@ func ConvertRequestToMessages(req VSCodeRequestBlock) []schema.Message { } // 3. Parse responses and extract tool calls - toolMessages := ParseResponsesForTools(req.Response, req.Result.Metadata) + toolMessages := ParseResponsesForTools(req.Response, req.Result.Metadata, req.ModelID) messages = append(messages, toolMessages...) // 4. Final agent text message @@ -134,11 +135,14 @@ func ConvertRequestToMessages(req VSCodeRequestBlock) []schema.Message { } // ParseResponsesForTools extracts tool invocations from response array -func ParseResponsesForTools(responses []json.RawMessage, metadata VSCodeResultMetadata) []schema.Message { +func ParseResponsesForTools(responses []json.RawMessage, metadata VSCodeResultMetadata, modelID string) []schema.Message { var toolMessages []schema.Message - // Build tool call map from metadata - toolCalls := BuildToolCallMap(metadata) + // 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 { @@ -157,17 +161,36 @@ func ParseResponsesForTools(responses []json.RawMessage, metadata VSCodeResultMe continue } - // Skip hidden tools + // Skip hidden tools (don't increment sequence index for hidden tools) if invocation.Presentation == "hidden" { continue } - // Find matching tool call and result - toolInfo := BuildToolInfoFromInvocation(invocation, toolCalls, metadata.ToolCallResults) + // 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, - Tool: toolInfo, + Role: schema.RoleAgent, + Model: modelID, + Tool: toolInfo, } toolMessages = append(toolMessages, toolMsg) } @@ -190,19 +213,13 @@ func ParseResponsesForTools(responses []json.RawMessage, metadata VSCodeResultMe return toolMessages } -// BuildToolInfoFromInvocation creates ToolInfo from VS Code invocation + metadata +// 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, - toolCalls map[string]VSCodeToolCallInfo, + toolCall VSCodeToolCallInfo, toolResults map[string]VSCodeToolCallResult, ) *schema.ToolInfo { - // Find tool call details from metadata - toolCall, ok := toolCalls[invocation.ToolCallID] - if !ok { - slog.Debug("Tool call not found in metadata", "toolCallId", invocation.ToolCallID) - return nil - } - toolInfo := &schema.ToolInfo{ Name: toolCall.Name, Type: MapToolType(toolCall.Name), @@ -218,6 +235,7 @@ func BuildToolInfoFromInvocation( } // 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 { @@ -260,24 +278,41 @@ func valueToString(value any) string { return string(jsonBytes) } -// MapToolType maps VS Code tool names to schema.ToolType constants +// 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, - "read_file": schema.ToolTypeRead, "write_to_file": schema.ToolTypeWrite, "str_replace_editor": schema.ToolTypeWrite, "list_files": schema.ToolTypeSearch, "grep": schema.ToolTypeSearch, "find": schema.ToolTypeSearch, - // Add more as needed } if toolType, ok := mapping[toolName]; ok { return toolType } - slog.Debug("Unknown tool type, mapping to unknown", "toolName", toolName) - return schema.ToolTypeUnknown + slog.Debug("Unknown tool type, mapping to generic", "toolName", toolName) + return schema.ToolTypeGeneric } diff --git a/specstory-cli/pkg/providers/copilotide/parser.go b/specstory-cli/pkg/providers/copilotide/parser.go index 3f2aa733..8df593b0 100644 --- a/specstory-cli/pkg/providers/copilotide/parser.go +++ b/specstory-cli/pkg/providers/copilotide/parser.go @@ -29,6 +29,7 @@ func IsHiddenTool(rawResponse json.RawMessage) bool { } // 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) @@ -41,6 +42,18 @@ func BuildToolCallMap(metadata VSCodeResultMetadata) map[string]VSCodeToolCallIn 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 { From d9207ba62a8cf1c330513fe8ac48827995dd3fbc Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Thu, 29 Jan 2026 13:59:16 +0100 Subject: [PATCH 09/33] Copilot tools plan --- specstory-cli/docs/COPILOTIDE-TOOLS-PLAN.md | 313 ++++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 specstory-cli/docs/COPILOTIDE-TOOLS-PLAN.md 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) From 6b39212436dc40ac84d5e230b84af94f9aff5249 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Thu, 29 Jan 2026 14:03:19 +0100 Subject: [PATCH 10/33] If tool input is a multiline string wrap it in triple backthicks instead of single to avoid markdown formatting issues --- specstory-cli/pkg/markdown/markdown.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/specstory-cli/pkg/markdown/markdown.go b/specstory-cli/pkg/markdown/markdown.go index 2db6d2d0..081e19a7 100644 --- a/specstory-cli/pkg/markdown/markdown.go +++ b/specstory-cli/pkg/markdown/markdown.go @@ -256,7 +256,15 @@ func renderGenericTool(tool *ToolInfo) string { sort.Strings(keys) for _, key := range keys { - markdown.WriteString(fmt.Sprintf("- %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") { + markdown.WriteString(fmt.Sprintf("- %s:\n\n```\n%s\n```\n\n", key, valueStr)) + } else { + markdown.WriteString(fmt.Sprintf("- %s: `%s`\n", key, valueStr)) + } } markdown.WriteString("\n") } From f6c738f352fff1fd6a862e69a9c358d3d08f0a4e Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Thu, 29 Jan 2026 14:12:04 +0100 Subject: [PATCH 11/33] Avoid a flag and env vars for the Cursor IDE db watcher interval, just use 2 minutes constant for now --- specstory-cli/main.go | 13 ------------- specstory-cli/pkg/providers/cursoride/provider.go | 11 +---------- specstory-cli/pkg/providers/cursoride/watcher.go | 2 +- 3 files changed, 2 insertions(+), 24 deletions(-) diff --git a/specstory-cli/main.go b/specstory-cli/main.go index 202b21e7..dc1e0f00 100644 --- a/specstory-cli/main.go +++ b/specstory-cli/main.go @@ -567,17 +567,6 @@ By default, 'watch' is for activity from all registered agent providers. Specify debugRaw, _ := cmd.Flags().GetBool("debug-raw") useUTC := getUseUTC(cmd) - // Get cursoride-db-check-interval flag value and set as environment variable - // This is only used by the cursoride provider - if dbCheckInterval, err := cmd.Flags().GetString("cursoride-db-check-interval"); err == nil && dbCheckInterval != "" { - // Validate the duration format - if _, err := time.ParseDuration(dbCheckInterval); err != nil { - return fmt.Errorf("invalid --cursoride-db-check-interval value: %w", err) - } - os.Setenv("CURSORIDE_DB_CHECK_INTERVAL", dbCheckInterval) - slog.Debug("Set Cursor IDE database check interval", "interval", dbCheckInterval) - } - // Setup output configuration config, err := utils.SetupOutputConfig(outputDir) if err != nil { @@ -2097,8 +2086,6 @@ func main() { _ = watchCmd.Flags().MarkHidden("cloud-url") // Hidden flag watchCmd.Flags().Bool("debug-raw", false, "debug mode to output pretty-printed raw data files") _ = watchCmd.Flags().MarkHidden("debug-raw") // Hidden flag - watchCmd.Flags().String("cursoride-db-check-interval", "2m", "Cursor IDE only: how often to check the database if no file events are received (e.g., 2m, 30s)") - _ = watchCmd.Flags().MarkHidden("cursoride-db-check-interval") // Hidden flag watchCmd.Flags().BoolP("local-time-zone", "", false, "use local timezone for file name and content timestamps (when not present: UTC)") checkCmd.Flags().StringP("command", "c", "", "custom agent execution command for the provider") diff --git a/specstory-cli/pkg/providers/cursoride/provider.go b/specstory-cli/pkg/providers/cursoride/provider.go index 1d88cc85..58b62bf6 100644 --- a/specstory-cli/pkg/providers/cursoride/provider.go +++ b/specstory-cli/pkg/providers/cursoride/provider.go @@ -299,17 +299,8 @@ func (p *Provider) WatchAgent(ctx context.Context, projectPath string, debugRaw "projectPath", projectPath, "debugRaw", debugRaw) - // Default check interval: 2 minutes - // This can be overridden via environment variable for testing/debugging + // Check interval for polling the database checkInterval := 2 * time.Minute - if envInterval := os.Getenv("CURSORIDE_DB_CHECK_INTERVAL"); envInterval != "" { - if duration, err := time.ParseDuration(envInterval); err == nil { - checkInterval = duration - slog.Info("Using custom database check interval from environment", "interval", checkInterval) - } else { - slog.Warn("Failed to parse CURSORIDE_DB_CHECK_INTERVAL, using default", "value", envInterval, "error", err) - } - } // Create and start watcher watcher, err := NewCursorIDEWatcher(projectPath, debugRaw, sessionCallback, checkInterval) diff --git a/specstory-cli/pkg/providers/cursoride/watcher.go b/specstory-cli/pkg/providers/cursoride/watcher.go index 83364d8e..d9d99052 100644 --- a/specstory-cli/pkg/providers/cursoride/watcher.go +++ b/specstory-cli/pkg/providers/cursoride/watcher.go @@ -74,7 +74,7 @@ func NewCursorIDEWatcher( cancel: cancel, knownComposers: make(map[string]int64), checkInterval: checkInterval, - throttleDuration: 10 * time.Second, // Configurable in code as requested + throttleDuration: 10 * time.Second, lastThrottledCall: time.Now(), pendingCheck: false, fsWatcher: fsWatcher, From fbd11371d354ca49d857ff8edb3db54b7e295cda Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Thu, 29 Jan 2026 14:39:24 +0100 Subject: [PATCH 12/33] Consistent value string output --- specstory-cli/pkg/markdown/markdown.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specstory-cli/pkg/markdown/markdown.go b/specstory-cli/pkg/markdown/markdown.go index 081e19a7..8fab00c0 100644 --- a/specstory-cli/pkg/markdown/markdown.go +++ b/specstory-cli/pkg/markdown/markdown.go @@ -261,9 +261,9 @@ func renderGenericTool(tool *ToolInfo) string { // Check if value is multiline - if so, use code block if strings.Contains(valueStr, "\n") { - markdown.WriteString(fmt.Sprintf("- %s:\n\n```\n%s\n```\n\n", key, valueStr)) + markdown.WriteString(fmt.Sprintf("- %s:\n\n```\n%v\n```\n\n", key, valueStr)) } else { - markdown.WriteString(fmt.Sprintf("- %s: `%s`\n", key, valueStr)) + markdown.WriteString(fmt.Sprintf("- %s: `%v`\n", key, valueStr)) } } markdown.WriteString("\n") From b2f20eed3231cceda881d60ff67e28256bac9cb3 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Thu, 29 Jan 2026 17:54:13 +0100 Subject: [PATCH 13/33] Add file_search, glob_file_search and list_directory tool handlers --- .../providers/cursoride/tool_file_search.go | 80 ++++++++++++ .../cursoride/tool_glob_file_search.go | 119 ++++++++++++++++++ .../pkg/providers/cursoride/tool_handlers.go | 12 ++ .../cursoride/tool_list_directory.go | 88 +++++++++++++ 4 files changed, 299 insertions(+) create mode 100644 specstory-cli/pkg/providers/cursoride/tool_file_search.go create mode 100644 specstory-cli/pkg/providers/cursoride/tool_glob_file_search.go create mode 100644 specstory-cli/pkg/providers/cursoride/tool_list_directory.go 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_handlers.go b/specstory-cli/pkg/providers/cursoride/tool_handlers.go index acddc42e..7c2e7a15 100644 --- a/specstory-cli/pkg/providers/cursoride/tool_handlers.go +++ b/specstory-cli/pkg/providers/cursoride/tool_handlers.go @@ -87,6 +87,18 @@ func NewToolRegistry() *ToolRegistry { 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 } 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 +} From 03218ced5c22ff93f855b0ed4f6c00a30675892f Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Wed, 4 Feb 2026 17:17:25 +0100 Subject: [PATCH 14/33] Implement list for Cursor IDE --- .../pkg/providers/cursoride/provider.go | 127 +++++++++++++++++- 1 file changed, 126 insertions(+), 1 deletion(-) diff --git a/specstory-cli/pkg/providers/cursoride/provider.go b/specstory-cli/pkg/providers/cursoride/provider.go index 58b62bf6..4e2c99a3 100644 --- a/specstory-cli/pkg/providers/cursoride/provider.go +++ b/specstory-cli/pkg/providers/cursoride/provider.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" "path/filepath" + "sort" "time" "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi" @@ -115,7 +116,7 @@ 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) ([]spi.AgentChatSession, error) { +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) @@ -161,6 +162,9 @@ func (p *Provider) GetAgentChatSessions(projectPath string, debugRaw bool) ([]sp // 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 { @@ -188,6 +192,12 @@ func (p *Provider) GetAgentChatSessions(projectPath string, debugRaw bool) ([]sp } sessions = append(sessions, *session) + + // Report progress + processedCount++ + if progress != nil { + progress(processedCount, totalCount) + } } slog.Info("Converted sessions", @@ -288,6 +298,74 @@ func (p *Provider) GetAgentChatSession(projectPath string, sessionID string, deb 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) + } + + // Step 6: Sort by creation date (oldest first) + sort.Slice(metadataList, func(i, j int) bool { + return metadataList[i].CreatedAt < metadataList[j].CreatedAt + }) + + 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)") @@ -344,3 +422,50 @@ func writeDebugOutput(session *spi.AgentChatSession) error { 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 "" +} From d9f19630775beaa5ceb6c56bc7996dccd9adb102 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Wed, 4 Feb 2026 17:23:22 +0100 Subject: [PATCH 15/33] Implement list for VSCode Copilot IDE --- .../pkg/providers/copilotide/provider.go | 117 +++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/specstory-cli/pkg/providers/copilotide/provider.go b/specstory-cli/pkg/providers/copilotide/provider.go index bb8f127c..7cc666b7 100644 --- a/specstory-cli/pkg/providers/copilotide/provider.go +++ b/specstory-cli/pkg/providers/copilotide/provider.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "sort" "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi" ) @@ -111,7 +112,7 @@ func (p *Provider) GetAgentChatSession(projectPath string, sessionID string, deb } // GetAgentChatSessions retrieves all chat sessions for the given project path -func (p *Provider) GetAgentChatSessions(projectPath string, debugRaw bool) ([]spi.AgentChatSession, error) { +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 @@ -127,6 +128,9 @@ func (p *Provider) GetAgentChatSessions(projectPath string, debugRaw bool) ([]sp } var sessions []spi.AgentChatSession + processedCount := 0 + totalCount := len(sessionFiles) + for _, sessionFile := range sessionFiles { composer, err := LoadSessionFile(sessionFile) if err != nil { @@ -150,12 +154,76 @@ func (p *Provider) GetAgentChatSessions(projectPath string, debugRaw bool) ([]sp 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 + } + + // Skip empty conversations + if len(composer.Requests) == 0 { + slog.Debug("Skipping empty session", "sessionId", composer.SessionID) + continue + } + + metadata := extractCopilotIDESessionMetadata(composer) + metadataList = append(metadataList, metadata) + } + + // Step 4: Sort by creation date (oldest first) + sort.Slice(metadataList, func(i, j int) bool { + return metadataList[i].CreatedAt < metadataList[j].CreatedAt + }) + + 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)") @@ -174,3 +242,50 @@ func (p *Provider) WatchAgent(ctx context.Context, projectPath string, debugRaw // 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 "" +} From 9706a5428bd6e7ffc52833e84603296c9cc4d4b2 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Wed, 4 Feb 2026 17:33:58 +0100 Subject: [PATCH 16/33] Reuse SPI structure --- specstory-cli/main.go | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/specstory-cli/main.go b/specstory-cli/main.go index 9c083504..5ad67e3a 100644 --- a/specstory-cli/main.go +++ b/specstory-cli/main.go @@ -1667,14 +1667,11 @@ func listSingleProvider(registry *factory.Registry, providerID string) error { return nil } -// sessionMetadataWithProvider wraps SessionMetadata with provider information +// sessionMetadataWithProvider embeds SessionMetadata and adds provider information // Used when listing sessions from all providers to show which provider each session came from type sessionMetadataWithProvider struct { - SessionID string `json:"session_id"` // Stable and unique identifier for the session - CreatedAt string `json:"created_at"` // Stable ISO 8601 timestamp when session was created - Slug string `json:"slug"` // Stable human-readable session name/slug - Name string `json:"name"` // Human-readable name of the session (may be empty if not available) - Provider string `json:"provider"` // Provider ID (e.g., "claude", "cursor", "codex") + spi.SessionMetadata // Embedded struct - promotes all fields with their JSON tags + Provider string `json:"provider"` // Provider ID (e.g., "claude", "cursor", "codex") } // listAllProviders lists sessions from all providers that have activity @@ -1753,11 +1750,8 @@ func listAllProviders(registry *factory.Registry) error { // Wrap each session with provider information for _, session := range sessions { allSessions = append(allSessions, sessionMetadataWithProvider{ - SessionID: session.SessionID, - CreatedAt: session.CreatedAt, - Slug: session.Slug, - Name: session.Name, - Provider: id, + SessionMetadata: session, + Provider: id, }) } } From e7b18fd5886f9ea8ca9cf2ae1babbeeac5dc7d02 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Wed, 4 Feb 2026 17:52:21 +0100 Subject: [PATCH 17/33] Remove list session sorting from cursor ide --- specstory-cli/pkg/providers/cursoride/provider.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/specstory-cli/pkg/providers/cursoride/provider.go b/specstory-cli/pkg/providers/cursoride/provider.go index 4e2c99a3..0523de08 100644 --- a/specstory-cli/pkg/providers/cursoride/provider.go +++ b/specstory-cli/pkg/providers/cursoride/provider.go @@ -6,7 +6,6 @@ import ( "log/slog" "os" "path/filepath" - "sort" "time" "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi" @@ -354,11 +353,6 @@ func (p *Provider) ListAgentChatSessions(projectPath string) ([]spi.SessionMetad metadataList = append(metadataList, metadata) } - // Step 6: Sort by creation date (oldest first) - sort.Slice(metadataList, func(i, j int) bool { - return metadataList[i].CreatedAt < metadataList[j].CreatedAt - }) - slog.Info("Listed Cursor IDE sessions", "totalComposers", len(composers), "sessionCount", len(metadataList)) From 42a5d67a9535de3f0b346d1cbed78750d8e11418 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Wed, 4 Feb 2026 17:54:54 +0100 Subject: [PATCH 18/33] Remove list session sorting from copilot ide --- specstory-cli/pkg/providers/copilotide/provider.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/specstory-cli/pkg/providers/copilotide/provider.go b/specstory-cli/pkg/providers/copilotide/provider.go index 7cc666b7..161f25e7 100644 --- a/specstory-cli/pkg/providers/copilotide/provider.go +++ b/specstory-cli/pkg/providers/copilotide/provider.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log/slog" - "sort" "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi" ) @@ -212,11 +211,6 @@ func (p *Provider) ListAgentChatSessions(projectPath string) ([]spi.SessionMetad metadataList = append(metadataList, metadata) } - // Step 4: Sort by creation date (oldest first) - sort.Slice(metadataList, func(i, j int) bool { - return metadataList[i].CreatedAt < metadataList[j].CreatedAt - }) - slog.Info("Listed VS Code Copilot sessions", "totalFiles", len(sessionFiles), "sessionCount", len(metadataList)) From a1979281f5b29bd691df6e3c4593ab6e1cc5a786 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Wed, 4 Feb 2026 18:18:25 +0100 Subject: [PATCH 19/33] Update comment --- specstory-cli/pkg/providers/cursoride/workspace.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specstory-cli/pkg/providers/cursoride/workspace.go b/specstory-cli/pkg/providers/cursoride/workspace.go index 8dd7d944..7b4d2c61 100644 --- a/specstory-cli/pkg/providers/cursoride/workspace.go +++ b/specstory-cli/pkg/providers/cursoride/workspace.go @@ -135,7 +135,7 @@ func FindWorkspaceForProject(projectPath string) (*WorkspaceMatch, error) { return nil, fmt.Errorf("no workspace found for project path: %s", projectPath) } - // If multiple matches, return the first one (could sort by timestamp if needed) + // If multiple matches, return the first one if len(matches) > 1 { slog.Warn("Multiple workspaces match project path, using first", "projectPath", projectPath, From aa3a7d3a9ff0862c3e459cf34e4cdea4b41d0aba Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Wed, 11 Feb 2026 18:38:36 +0100 Subject: [PATCH 20/33] Update copilot logic to support JSONL format for session data and other minor improvements --- .../pkg/providers/copilotide/agent_session.go | 239 +++++++++++++++++- .../pkg/providers/copilotide/loader.go | 150 ++++++++++- .../pkg/providers/copilotide/provider.go | 74 +++++- .../pkg/providers/copilotide/types.go | 52 +++- .../pkg/providers/copilotide/watcher.go | 17 +- 5 files changed, 499 insertions(+), 33 deletions(-) diff --git a/specstory-cli/pkg/providers/copilotide/agent_session.go b/specstory-cli/pkg/providers/copilotide/agent_session.go index 30d84e37..c46c25f8 100644 --- a/specstory-cli/pkg/providers/copilotide/agent_session.go +++ b/specstory-cli/pkg/providers/copilotide/agent_session.go @@ -2,6 +2,7 @@ package copilotide import ( "encoding/json" + "fmt" "log/slog" "strings" @@ -10,7 +11,7 @@ import ( ) // ConvertToSessionData converts VS Code raw format to CLI's unified schema -func ConvertToSessionData(composer VSCodeComposer, projectPath string) spi.AgentChatSession { +func ConvertToSessionData(composer VSCodeComposer, projectPath string, state *VSCodeStateFile) spi.AgentChatSession { // Format timestamps createdAt := FormatTimestamp(composer.CreationDate) updatedAt := FormatTimestamp(composer.LastMessageDate) @@ -18,6 +19,15 @@ func ConvertToSessionData(composer VSCodeComposer, projectPath string) spi.Agent // 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", @@ -31,7 +41,7 @@ func ConvertToSessionData(composer VSCodeComposer, projectPath string) spi.Agent UpdatedAt: updatedAt, Slug: slug, WorkspaceRoot: projectPath, - Exchanges: ConvertRequestsToExchanges(composer.Requests), + Exchanges: ConvertRequestsToExchanges(requests), } // Marshal to JSON for raw data @@ -316,3 +326,228 @@ func MapToolType(toolName string) string { 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 index 8e30ced0..eb92f950 100644 --- a/specstory-cli/pkg/providers/copilotide/loader.go +++ b/specstory-cli/pkg/providers/copilotide/loader.go @@ -29,8 +29,8 @@ func LoadAllSessionFiles(workspaceDir string) ([]string, error) { continue } - // Only include .json files - if !strings.HasSuffix(file.Name(), ".json") { + // Include both .json and .jsonl files + if !strings.HasSuffix(file.Name(), ".json") && !strings.HasSuffix(file.Name(), ".jsonl") { continue } @@ -42,21 +42,35 @@ func LoadAllSessionFiles(workspaceDir string) ([]string, error) { return sessionFiles, nil } -// LoadSessionFile reads and parses a single session JSON file +// 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 err := json.Unmarshal(data, &composer); err != nil { - return nil, fmt.Errorf("failed to parse session JSON: %w", err) + + 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)) + "requestCount", len(composer.Requests), + "isJSONL", isJSONL) return &composer, nil } @@ -64,10 +78,17 @@ func LoadSessionFile(sessionPath string) (*VSCodeComposer, error) { // LoadSessionByID loads a specific session by ID from the workspace func LoadSessionByID(workspaceDir, sessionID string) (*VSCodeComposer, error) { chatSessionsPath := GetChatSessionsPath(workspaceDir) - sessionPath := filepath.Join(chatSessionsPath, sessionID+".json") - // Check if file exists - if _, err := os.Stat(sessionPath); os.IsNotExist(err) { + // 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) } @@ -81,21 +102,29 @@ func LoadStateFile(workspaceDir, sessionID string) (*VSCodeStateFile, error) { // State file is optional if _, err := os.Stat(statePath); os.IsNotExist(err) { - slog.Debug("No state file found (optional)", "sessionId", sessionID) + slog.Debug("No state file found (optional)", "sessionId", sessionID, "path", statePath) return nil, nil } data, err := os.ReadFile(statePath) if err != nil { - return nil, fmt.Errorf("failed to read state file: %w", err) + 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 { - return nil, fmt.Errorf("failed to parse state JSON: %w", err) + 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) + slog.Debug("Loaded state file", "sessionId", sessionID, "version", state.Version) return &state, nil } @@ -120,3 +149,98 @@ func WriteDebugFiles(composer *VSCodeComposer, sessionID string) error { 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) + 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 + } + + if update.Kind == 1 && len(update.K) > 0 { + // Apply the update by setting 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, "keyPath", update.K, "error", err) + } + } + } + + return composer, 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/provider.go b/specstory-cli/pkg/providers/copilotide/provider.go index 161f25e7..742f9184 100644 --- a/specstory-cli/pkg/providers/copilotide/provider.go +++ b/specstory-cli/pkg/providers/copilotide/provider.go @@ -97,8 +97,14 @@ func (p *Provider) GetAgentChatSession(projectPath string, sessionID string, deb 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) + agentSession := ConvertToSessionData(*session, projectPath, state) // Write debug files if requested if debugRaw { @@ -137,14 +143,23 @@ func (p *Provider) GetAgentChatSessions(projectPath string, debugRaw bool, progr continue } - // Filter empty conversations - if len(composer.Requests) == 0 { - slog.Debug("Skipping empty session", "sessionId", composer.SessionID) + // 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) + session := ConvertToSessionData(*composer, projectPath, state) sessions = append(sessions, session) // Write debug files if requested @@ -201,9 +216,18 @@ func (p *Provider) ListAgentChatSessions(projectPath string) ([]spi.SessionMetad continue } - // Skip empty conversations - if len(composer.Requests) == 0 { - slog.Debug("Skipping empty session", "sessionId", composer.SessionID) + // 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 } @@ -283,3 +307,37 @@ func generateCopilotIDESessionName(composer *VSCodeComposer) string { // 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 index 2e21e65a..2f30930e 100644 --- a/specstory-cli/pkg/providers/copilotide/types.go +++ b/specstory-cli/pkg/providers/copilotide/types.go @@ -72,10 +72,31 @@ type VSCodeCodeblockUriResponse struct { type VSCodeConfirmationResponse struct { Kind string `json:"kind"` // "confirmation" Title string `json:"title"` - Message string `json:"message"` + 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" @@ -158,10 +179,24 @@ type VSCodeRange struct { // VSCodeStateFile represents the state file for editing sessions type VSCodeStateFile struct { - Version int `json:"version"` - SessionID string `json:"sessionId"` - LinearHistory []VSCodeLinearHistory `json:"linearHistory,omitempty"` - // Add other fields as needed + 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 @@ -172,5 +207,10 @@ type VSCodeLinearHistory struct { // VSCodeStop represents a stop in the edit history type VSCodeStop struct { - // Define as needed - can defer to phase 2 + 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 index 75443c3a..b95c4f06 100644 --- a/specstory-cli/pkg/providers/copilotide/watcher.go +++ b/specstory-cli/pkg/providers/copilotide/watcher.go @@ -82,8 +82,8 @@ func WatchChatSessions( return fmt.Errorf("watcher events channel closed") } - // Only process JSON files - if !strings.HasSuffix(event.Name, ".json") { + // Only process JSON and JSONL files + if !strings.HasSuffix(event.Name, ".json") && !strings.HasSuffix(event.Name, ".jsonl") { continue } @@ -126,8 +126,14 @@ func WatchChatSessions( 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) + session := ConvertToSessionData(*composer, projectPath, state) // Write debug files if requested if debugRaw { @@ -153,6 +159,9 @@ func WatchChatSessions( // GetSessionIDFromPath extracts session ID from a file path func GetSessionIDFromPath(path string) string { filename := filepath.Base(path) - // Remove .json extension + // Remove .json or .jsonl extension + if strings.HasSuffix(filename, ".jsonl") { + return strings.TrimSuffix(filename, ".jsonl") + } return strings.TrimSuffix(filename, ".json") } From eafe5d371398bf9cb418f512e7e41ee79484d310 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Thu, 5 Mar 2026 12:29:30 +0100 Subject: [PATCH 21/33] Linting --- .../pkg/providers/cursoride/tool_grep.go | 16 +++---- .../providers/cursoride/tool_grep_search.go | 6 +-- .../pkg/providers/cursoride/tool_handlers.go | 9 ++-- .../pkg/providers/cursoride/tool_shell.go | 10 ++--- .../pkg/providers/cursoride/tool_write.go | 43 +++++++------------ 5 files changed, 33 insertions(+), 51 deletions(-) diff --git a/specstory-cli/pkg/providers/cursoride/tool_grep.go b/specstory-cli/pkg/providers/cursoride/tool_grep.go index 24869690..95cac061 100644 --- a/specstory-cli/pkg/providers/cursoride/tool_grep.go +++ b/specstory-cli/pkg/providers/cursoride/tool_grep.go @@ -121,19 +121,17 @@ func (h *GrepHandler) AdaptMessage(bubble *BubbleConversation) (string, error) { if resultsLength != 1 { matchWord = "matches" } - message.WriteString(fmt.Sprintf(`Tool use: **%s** • Grep for "%s"%s • %d %s - -`, bubble.Name, params.Pattern, inString, resultsLength, matchWord)) + 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" } - message.WriteString(fmt.Sprintf("Output mode: %s\n", outputMode)) + fmt.Fprintf(&message, "Output mode: %s\n", outputMode) // Add details - message.WriteString(fmt.Sprintf("\n%s\n", messageDetails)) + fmt.Fprintf(&message, "\n%s\n", messageDetails) message.WriteString("\n
") return message.String(), nil @@ -177,13 +175,13 @@ func (h *GrepHandler) formatContentResults(content *GrepContentResult) string { // Add file column if needed if hasFiles { - result.WriteString(fmt.Sprintf("| `%s` ", escapeTableCellValue(fileMatch.File))) + fmt.Fprintf(&result, "| `%s` ", escapeTableCellValue(fileMatch.File)) } // Add content and line number - result.WriteString(fmt.Sprintf("| `%s` | L%d |\n", + fmt.Fprintf(&result, "| `%s` | L%d |\n", escapeTableCellValue(match.Content), - match.LineNumber)) + match.LineNumber) } } @@ -200,7 +198,7 @@ func (h *GrepHandler) formatFilesResults(files *GrepFilesResult) string { result.WriteString("\n| File |\n|------|\n") for _, file := range files.Files { - result.WriteString(fmt.Sprintf("| `%s` |\n", file)) + fmt.Fprintf(&result, "| `%s` |\n", file) } 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 index e6d5034b..7ceedcd7 100644 --- a/specstory-cli/pkg/providers/cursoride/tool_grep_search.go +++ b/specstory-cli/pkg/providers/cursoride/tool_grep_search.go @@ -81,7 +81,7 @@ func (h *GrepSearchHandler) AdaptMessage(bubble *BubbleConversation) (string, er message.WriteString(`** • Grep search for "`) message.WriteString(rawArgs.Query) message.WriteString(`" • **`) - message.WriteString(fmt.Sprintf("%d", resultsLength)) + fmt.Fprintf(&message, "%d", resultsLength) message.WriteString(`** files `) @@ -101,10 +101,10 @@ func (h *GrepSearchHandler) AdaptMessage(bubble *BubbleConversation) (string, er } // Add row - message.WriteString(fmt.Sprintf("| `%s` | L%d | `%s` |\n", + fmt.Fprintf(&message, "| `%s` | L%d | `%s` |\n", fileResult.Resource, lineNumber, - escapeTableCellValue(matchEntry.Match.PreviewText))) + escapeTableCellValue(matchEntry.Match.PreviewText)) } } } diff --git a/specstory-cli/pkg/providers/cursoride/tool_handlers.go b/specstory-cli/pkg/providers/cursoride/tool_handlers.go index 7c2e7a15..245d8912 100644 --- a/specstory-cli/pkg/providers/cursoride/tool_handlers.go +++ b/specstory-cli/pkg/providers/cursoride/tool_handlers.go @@ -185,10 +185,7 @@ func formatCatchAll(bubble *BubbleConversation) string { var message strings.Builder // Start with summary line (just tool name, no params) - message.WriteString(fmt.Sprintf(`
-Tool use: **%s** - -`, bubble.Name)) + fmt.Fprintf(&message, "
\nTool use: **%s**\n\n", bubble.Name) // Parse params var params map[string]interface{} @@ -228,12 +225,12 @@ func formatCatchAll(bubble *BubbleConversation) string { // Add user decision if bubble.UserDecision != "" { - message.WriteString(fmt.Sprintf("User decision: **%s**\n\n", bubble.UserDecision)) + fmt.Fprintf(&message, "User decision: **%s**\n\n", bubble.UserDecision) } // Add status if bubble.Status != "" { - message.WriteString(fmt.Sprintf("Status: **%s**\n\n", bubble.Status)) + fmt.Fprintf(&message, "Status: **%s**\n\n", bubble.Status) } // Add error section diff --git a/specstory-cli/pkg/providers/cursoride/tool_shell.go b/specstory-cli/pkg/providers/cursoride/tool_shell.go index aec91ed0..51e27e70 100644 --- a/specstory-cli/pkg/providers/cursoride/tool_shell.go +++ b/specstory-cli/pkg/providers/cursoride/tool_shell.go @@ -45,7 +45,7 @@ func (h *ShellCommandHandler) AdaptMessage(bubble *BubbleConversation) (string, } var message strings.Builder - message.WriteString(fmt.Sprintf(`
Tool use: **%s**`, bubble.Name)) + fmt.Fprintf(&message, "
Tool use: **%s**", bubble.Name) // Get command from params or rawArgs (prefer params) command := params.Command @@ -55,17 +55,15 @@ func (h *ShellCommandHandler) AdaptMessage(bubble *BubbleConversation) (string, // If we have a command, show it in the summary and as a bash block if command != "" { - message.WriteString(fmt.Sprintf(` • Run command: %s - -`, command)) - message.WriteString(fmt.Sprintf("```bash\n%s\n```", 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 != "" { - message.WriteString(fmt.Sprintf("\n\n```\n%s\n```", escapeCodeBlock(result.Output))) + fmt.Fprintf(&message, "\n\n```\n%s\n```", escapeCodeBlock(result.Output)) } message.WriteString("\n
") diff --git a/specstory-cli/pkg/providers/cursoride/tool_write.go b/specstory-cli/pkg/providers/cursoride/tool_write.go index 20c0676f..77f539cb 100644 --- a/specstory-cli/pkg/providers/cursoride/tool_write.go +++ b/specstory-cli/pkg/providers/cursoride/tool_write.go @@ -53,23 +53,19 @@ func (h *CodeEditHandler) AdaptMessage(bubble *BubbleConversation) (string, erro // Build summary line if params.RelativeWorkspacePath != "" { - message.WriteString(fmt.Sprintf(`
Tool use: **%s** • Edit file: %s - -`, bubble.Name, params.RelativeWorkspacePath)) + fmt.Fprintf(&message, "
Tool use: **%s** • Edit file: %s\n\n", bubble.Name, params.RelativeWorkspacePath) } else { - message.WriteString(fmt.Sprintf(`
Tool use: **%s** - -`, bubble.Name)) + fmt.Fprintf(&message, "
Tool use: **%s**\n\n", bubble.Name) } // Add instructions if present if params.Instructions != "" { - message.WriteString(fmt.Sprintf("%s\n\n", params.Instructions)) + fmt.Fprintf(&message, "%s\n\n", params.Instructions) } // Add status if not completed if bubble.Status != "" && bubble.Status != "completed" { - message.WriteString(fmt.Sprintf("Status: **%s**\n\n", bubble.Status)) + fmt.Fprintf(&message, "Status: **%s**\n\n", bubble.Status) } // Add apply failed message @@ -80,10 +76,10 @@ func (h *CodeEditHandler) AdaptMessage(bubble *BubbleConversation) (string, erro // Add diff chunks if present if result.Diff != nil && len(result.Diff.Chunks) > 0 { for i, chunk := range result.Diff.Chunks { - message.WriteString(fmt.Sprintf("**Chunk %d**\n", i+1)) - message.WriteString(fmt.Sprintf("Lines added: %d, lines removed: %d\n\n", chunk.LinesAdded, chunk.LinesRemoved)) + 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") - message.WriteString(fmt.Sprintf("@@ -%d,%d +%d,%d @@\n", chunk.OldStart, chunk.OldLines, chunk.NewStart, chunk.NewLines)) + 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") } @@ -95,7 +91,7 @@ func (h *CodeEditHandler) AdaptMessage(bubble *BubbleConversation) (string, erro if languageId, hasLang := codeblockData["languageId"].(string); hasLang { lang = languageId } - message.WriteString(fmt.Sprintf("```%s\n%s\n```\n\n", lang, content)) + fmt.Fprintf(&message, "```%s\n%s\n```\n\n", lang, content) } } } @@ -127,12 +123,10 @@ func (h *DeleteFileHandler) AdaptMessage(bubble *BubbleConversation) (string, er } var message strings.Builder - message.WriteString(fmt.Sprintf(`
Tool use: **%s** - -`, bubble.Name)) + fmt.Fprintf(&message, "
Tool use: **%s**\n\n", bubble.Name) if rawArgs.Explanation != "" { - message.WriteString(fmt.Sprintf("Explanation: %s\n\n", rawArgs.Explanation)) + fmt.Fprintf(&message, "Explanation: %s\n\n", rawArgs.Explanation) } message.WriteString("\n
") @@ -163,12 +157,10 @@ func (h *ApplyPatchHandler) AdaptMessage(bubble *BubbleConversation) (string, er } var message strings.Builder - message.WriteString(fmt.Sprintf(`
- Tool use: **%s** • Apply patch for %s - `, bubble.Name, rawArgs.FilePath)) + fmt.Fprintf(&message, "
\n Tool use: **%s** • Apply patch for %s\n ", bubble.Name, rawArgs.FilePath) if rawArgs.Patch != "" { - message.WriteString(fmt.Sprintf("\n\n```diff\n%s\n```\n", escapeCodeBlock(rawArgs.Patch))) + fmt.Fprintf(&message, "\n\n```diff\n%s\n```\n", escapeCodeBlock(rawArgs.Patch)) } message.WriteString("\n
") @@ -248,10 +240,7 @@ func (h *CopilotApplyPatchHandler) AdaptMessage(bubble *BubbleConversation) (str } var message strings.Builder - message.WriteString(fmt.Sprintf(`
-Tool use: **%s** • %s - -`, bubble.Name, invocationMsg)) + fmt.Fprintf(&message, "
\nTool use: **%s** • %s\n\n", bubble.Name, invocationMsg) // Check if operation failed if bubble.Status == "error" && bubble.Error != "" { @@ -260,7 +249,7 @@ func (h *CopilotApplyPatchHandler) AdaptMessage(bubble *BubbleConversation) (str } if err := json.Unmarshal([]byte(bubble.Error), &errorData); err == nil { message.WriteString("**❌ Patch Failed**\n\n") - message.WriteString(fmt.Sprintf("%s\n", errorData.Message)) + fmt.Fprintf(&message, "%s\n", errorData.Message) } } else { // Add the collected content @@ -276,14 +265,14 @@ func (h *CopilotApplyPatchHandler) AdaptMessage(bubble *BubbleConversation) (str } } language := extensionToLanguage(extension) - message.WriteString(fmt.Sprintf("```%s\n%s\n```\n\n", language, result.TextEditContent)) + 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 != "" { - message.WriteString(fmt.Sprintf("\n**Status:** %s\n", result.PastTenseMessage)) + fmt.Fprintf(&message, "\n**Status:** %s\n", result.PastTenseMessage) } } From 70ca4f46428cc1537865f04b9e66d4600921222a Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Thu, 5 Mar 2026 14:50:56 +0100 Subject: [PATCH 22/33] Handle Copilot kind 2 messages, append messages to the right session --- .../pkg/providers/copilotide/loader.go | 85 +++++++++++++++++-- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/specstory-cli/pkg/providers/copilotide/loader.go b/specstory-cli/pkg/providers/copilotide/loader.go index eb92f950..26e2e63a 100644 --- a/specstory-cli/pkg/providers/copilotide/loader.go +++ b/specstory-cli/pkg/providers/copilotide/loader.go @@ -175,7 +175,10 @@ func parseJSONL(data []byte) (VSCodeComposer, error) { composer := firstLine.V - // Apply subsequent updates (kind: 1) + // 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"` @@ -188,17 +191,89 @@ func parseJSONL(data []byte) (VSCodeComposer, error) { continue } - if update.Kind == 1 && len(update.K) > 0 { - // Apply the update by setting 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, "keyPath", update.K, "error", err) + 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 { From ca3ff72adabd793426556683e57c29583be4cb20 Mon Sep 17 00:00:00 2001 From: Sean Johnson Date: Fri, 6 Mar 2026 15:40:19 -0500 Subject: [PATCH 23/33] Further cleanup of file slugs in Claude Code provider. --- specstory-cli/.gitignore | 3 + specstory-cli/changelog.md | 6 ++ .../pkg/providers/claudecode/provider.go | 21 ++++++- .../pkg/providers/claudecode/provider_test.go | 60 +++++++++++++++++++ specstory-cli/pkg/spi/filename_test.go | 23 +++++++ specstory-cli/pkg/spi/path_utils.go | 14 +++++ 6 files changed, 125 insertions(+), 2 deletions(-) diff --git a/specstory-cli/.gitignore b/specstory-cli/.gitignore index fecf242b..548365f3 100644 --- a/specstory-cli/.gitignore +++ b/specstory-cli/.gitignore @@ -55,6 +55,9 @@ dist/ # SpecStory configuration .specstory/cli/config.toml +# SpecStory statistics +.specstory/statistics.json + # Our binary specstory specstory-cli diff --git a/specstory-cli/changelog.md b/specstory-cli/changelog.md index c9a682ea..2ecf6428 100644 --- a/specstory-cli/changelog.md +++ b/specstory-cli/changelog.md @@ -1,5 +1,11 @@ # Specstory CLI Changelog +## v1.12.0 2026-03-06 + +### ⚙️ Improvements + +- Claude Code provider generates more meaningful markdown file name slugs more often. + ## v1.11.0 2026-03-02 ### 📢 Announcements diff --git a/specstory-cli/pkg/providers/claudecode/provider.go b/specstory-cli/pkg/providers/claudecode/provider.go index e034e577..45c2e82e 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 +} + // findFirstUserMessage finds the first user message in a session for slug generation // Returns empty string if no suitable user message is found func findFirstUserMessage(session Session) string { @@ -513,7 +530,7 @@ func findFirstUserMessage(session Session) string { if isSyntheticMessage(content) { continue } - return content + return cleanSyntheticPrefixes(content) } } } @@ -657,7 +674,7 @@ func extractSessionMetadata(filePath string) (*spi.SessionMetadata, error) { if message, ok := record["message"].(map[string]interface{}); ok { if content, ok := message["content"].(string); ok && content != "" { 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/spi/filename_test.go b/specstory-cli/pkg/spi/filename_test.go index 36e88ef3..e64abbe2 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 { @@ -220,6 +238,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", + }, } for _, tt := range tests { diff --git a/specstory-cli/pkg/spi/path_utils.go b/specstory-cli/pkg/spi/path_utils.go index d3a768b9..e96c4cf3 100644 --- a/specstory-cli/pkg/spi/path_utils.go +++ b/specstory-cli/pkg/spi/path_utils.go @@ -16,12 +16,26 @@ 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 +} + // extractWordsFromMessage extracts up to maxWords from the message, handling various edge cases func extractWordsFromMessage(message string, maxWords int) []string { if message == "" { 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) From 95c2aa4c53d53471c8072939d0ab5be2bf75f56a Mon Sep 17 00:00:00 2001 From: Jake Levirne <51732+jakelevirne@users.noreply.github.com> Date: Sat, 7 Mar 2026 16:06:35 -0500 Subject: [PATCH 24/33] Fix cursoride tool stats missing from OTel traces Tool bubbles were rendered as markdown text in message.Content but message.Tool was never set. Telemetry stats (extractToolInfo, countSessionTools) only check msg.Tool, so tool_count was always 0. Extracts resolveToolData helper from processToolInvocation, adds toSchemaToolType converter (mcp -> generic), and populates message.Tool for all tool bubbles so telemetry counts are accurate. Co-Authored-By: Claude Sonnet 4.6 --- .../pkg/providers/cursoride/agent_session.go | 52 ++++++++++++++----- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/specstory-cli/pkg/providers/cursoride/agent_session.go b/specstory-cli/pkg/providers/cursoride/agent_session.go index 92726f98..79ccdce1 100644 --- a/specstory-cli/pkg/providers/cursoride/agent_session.go +++ b/specstory-cli/pkg/providers/cursoride/agent_session.go @@ -118,6 +118,21 @@ func ConvertToAgentChatSession(composer *ComposerData) (*spi.AgentChatSession, e }, } + // 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{}{ @@ -233,24 +248,26 @@ func buildMessageText(bubble *ComposerConversation, capabilitiesMap map[int]*Cap return strings.Join(parts, "\n\n---\n\n") } -// processToolInvocation processes a tool invocation bubble and returns formatted markdown -func processToolInvocation(bubble *ComposerConversation, capabilitiesMap map[int]*CapabilityData, toolRegistry *ToolRegistry, composerVersion int) string { - var toolData *BubbleConversation - - // V3+ format: tool data is in bubble.ToolFormerData +// 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 { - toolData = convertToolInvocationData(bubble.ToolFormerData) - } else { - // V1 format: tool data is in capabilities[15].bubbleDataMap[bubbleId] - if capData, ok := capabilitiesMap[bubble.CapabilityType]; ok { - if capData.ParsedBubbleMap != nil { - if bubbleData, found := capData.ParsedBubbleMap[bubble.BubbleID]; found { - toolData = bubbleData - } + 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, @@ -262,6 +279,15 @@ func processToolInvocation(bubble *ComposerConversation, capabilitiesMap map[int 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{ From 9ccbe3953de05ff62ac8ed11b6783ff2d815aa46 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Tue, 10 Mar 2026 13:44:12 +0100 Subject: [PATCH 25/33] Resolve a race condition in Cursor IDE where a session is updated but the new exchanges are not yet on disk. Minor improvements to use messages data --- .../pkg/providers/cursoride/agent_session.go | 30 +++++++++++++------ .../pkg/providers/cursoride/watcher.go | 15 ++++++++++ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/specstory-cli/pkg/providers/cursoride/agent_session.go b/specstory-cli/pkg/providers/cursoride/agent_session.go index 79ccdce1..fa3e72c6 100644 --- a/specstory-cli/pkg/providers/cursoride/agent_session.go +++ b/specstory-cli/pkg/providers/cursoride/agent_session.go @@ -56,8 +56,9 @@ func ConvertToAgentChatSession(composer *ComposerData) (*spi.AgentChatSession, e toolRegistry := NewToolRegistry() // Convert conversation messages to exchanges - // For now, create a simple exchange for each user+assistant pair + // 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 @@ -97,12 +98,14 @@ func ConvertToAgentChatSession(composer *ComposerData) (*spi.AgentChatSession, e } } - // Get model name: bubble-level modelInfo > composer-level modelConfig + // Get model name for agent messages only — user messages must not carry a model. modelName := "" - if bubble.ModelInfo != nil && bubble.ModelInfo.ModelName != "" { - modelName = bubble.ModelInfo.ModelName - } else if composerModelName != "" { - modelName = composerModelName + 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 @@ -140,13 +143,21 @@ func ConvertToAgentChatSession(composer *ComposerData) (*spi.AgentChatSession, e } } - // If this is a user message and we have pending messages, create an exchange first + // 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{ - Messages: currentMessages, + 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) @@ -155,7 +166,8 @@ func ConvertToAgentChatSession(composer *ComposerData) (*spi.AgentChatSession, e // Create final exchange if there are remaining messages if len(currentMessages) > 0 { exchange := schema.Exchange{ - Messages: currentMessages, + ExchangeID: currentExchangeID, + Messages: currentMessages, } sessionData.Exchanges = append(sessionData.Exchanges, exchange) } diff --git a/specstory-cli/pkg/providers/cursoride/watcher.go b/specstory-cli/pkg/providers/cursoride/watcher.go index d9d99052..48c3a71c 100644 --- a/specstory-cli/pkg/providers/cursoride/watcher.go +++ b/specstory-cli/pkg/providers/cursoride/watcher.go @@ -329,6 +329,21 @@ func (w *CursorIDEWatcher) checkForChanges(trigger string) { } 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 + } + // Update known timestamp w.mu.Lock() w.knownComposers[composerID] = currentTimestamp From 9c5ecde3ddc8cc3b536d211e1a2f621ca689d473 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Thu, 12 Mar 2026 15:32:34 +0100 Subject: [PATCH 26/33] Fix secondary race condition where user message is added to the markdown but not the agent response --- .../pkg/providers/cursoride/watcher.go | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/specstory-cli/pkg/providers/cursoride/watcher.go b/specstory-cli/pkg/providers/cursoride/watcher.go index 48c3a71c..d228cb8d 100644 --- a/specstory-cli/pkg/providers/cursoride/watcher.go +++ b/specstory-cli/pkg/providers/cursoride/watcher.go @@ -30,6 +30,15 @@ type CursorIDEWatcher struct { 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 @@ -78,6 +87,8 @@ func NewCursorIDEWatcher( lastThrottledCall: time.Now(), pendingCheck: false, fsWatcher: fsWatcher, + pendingIncomplete: make(map[string]time.Time), + incompleteTimeout: 60 * time.Second, }, nil } @@ -344,6 +355,53 @@ func (w *CursorIDEWatcher) checkForChanges(trigger string) { 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 From 218351b0a06a2bd03c4c2ae5f7907b09f8d5b061 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Mon, 6 Apr 2026 20:51:39 +0200 Subject: [PATCH 27/33] Support for Cursor 3 --- .../pkg/providers/cursoride/database.go | 94 +++++++++++++++---- .../pkg/providers/cursoride/types.go | 6 +- 2 files changed, 80 insertions(+), 20 deletions(-) diff --git a/specstory-cli/pkg/providers/cursoride/database.go b/specstory-cli/pkg/providers/cursoride/database.go index 59f1dff7..9910b14c 100644 --- a/specstory-cli/pkg/providers/cursoride/database.go +++ b/specstory-cli/pkg/providers/cursoride/database.go @@ -32,8 +32,16 @@ func OpenDatabase(dbPath string) (*sql.DB, error) { return db, nil } -// LoadWorkspaceComposerIDs loads the composer IDs from a workspace database -// Returns a list of composer IDs that belong to this workspace +// 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 { @@ -45,28 +53,78 @@ func LoadWorkspaceComposerIDs(workspaceDbPath string) ([]string, error) { } }() - // Query for the composer.composerData key in the ItemTable + 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 { - if err == sql.ErrNoRows { - // No composer data in this workspace - slog.Debug("No composer data found in workspace database") - return []string{}, nil + 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) + } + } } - return nil, fmt.Errorf("failed to query workspace composer data: %w", err) } - // Parse the JSON value - var composerRefs WorkspaceComposerRefs - if err := json.Unmarshal([]byte(valueJSON), &composerRefs); err != nil { - return nil, fmt.Errorf("failed to parse workspace composer refs: %w", err) - } + // 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) + } + }() - // Extract composer IDs - composerIDs := make([]string, 0, len(composerRefs.AllComposers)) - for _, ref := range composerRefs.AllComposers { - composerIDs = append(composerIDs, ref.ComposerID) + 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", diff --git a/specstory-cli/pkg/providers/cursoride/types.go b/specstory-cli/pkg/providers/cursoride/types.go index d67367ab..fecea74f 100644 --- a/specstory-cli/pkg/providers/cursoride/types.go +++ b/specstory-cli/pkg/providers/cursoride/types.go @@ -101,9 +101,11 @@ type ToolInvocationData struct { } // WorkspaceComposerRefs represents the workspace-specific composer references -// Stored in workspace database with key "composer.composerData" +// 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"` + 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 From 97817dc75b2d5be40e2940ac6e1a79775574b4aa Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Tue, 7 Apr 2026 13:56:52 +0200 Subject: [PATCH 28/33] Fix cursoride session discovery for .code-workspace file opens When Cursor IDE is opened via a .code-workspace file, workspace.json stores the workspace file URI rather than the folder URI. Add Method 3 to FindAllWorkspacesForProject to detect this case: read the .code-workspace JSON and check if any listed folder resolves to the target project folder. Also adds codeWorkspaceContainsFolder helper and TestCodeWorkspaceContainsFolder tests. Co-Authored-By: Claude Sonnet 4.6 --- .../pkg/providers/cursorcli/provider.go | 29 +- .../pkg/providers/cursoride/workspace.go | 432 +++++++++++++++++- .../pkg/providers/cursoride/workspace_test.go | 364 +++++++++++++++ 3 files changed, 799 insertions(+), 26 deletions(-) create mode 100644 specstory-cli/pkg/providers/cursoride/workspace_test.go 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/workspace.go b/specstory-cli/pkg/providers/cursoride/workspace.go index 8dd7d944..1b79448b 100644 --- a/specstory-cli/pkg/providers/cursoride/workspace.go +++ b/specstory-cli/pkg/providers/cursoride/workspace.go @@ -7,6 +7,7 @@ import ( "net/url" "os" "path/filepath" + "runtime" "strings" "github.com/specstoryai/getspecstory/specstory-cli/pkg/spi" @@ -20,6 +21,110 @@ type WorkspaceMatch struct { 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) { @@ -145,6 +250,175 @@ func FindWorkspaceForProject(projectPath string) (*WorkspaceMatch, error) { 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) + + 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) + } + } + + 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) @@ -160,11 +434,16 @@ func readWorkspaceJSON(path string) (*WorkspaceJSON, error) { return &workspace, nil } -// uriToPath converts a file:// URI to a local file path +// 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 file:// URIs - if !strings.HasPrefix(uri, "file://") { - return "", fmt.Errorf("URI must start with file://: %s", uri) + // 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 @@ -173,11 +452,148 @@ func uriToPath(uri string) (string, error) { return "", fmt.Errorf("failed to parse URI: %w", err) } - // Get the path from the URI - path := parsedURI.Path + // Reject non-file schemes + if parsedURI.Scheme != "file" && parsedURI.Scheme != "" { + return "", fmt.Errorf("unsupported URI scheme %q: %s", parsedURI.Scheme, uri) + } - // 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 + // 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 +} + +// codeWorkspaceContainsFolder reads a .code-workspace JSON file and checks whether +// any of its folder entries resolves to canonicalFolder. This is used to find +// workspaces that were opened via a .code-workspace file referencing the target folder. +func codeWorkspaceContainsFolder(workspaceFilePath, canonicalFolder string) bool { + data, err := os.ReadFile(workspaceFilePath) + if err != nil { + slog.Debug("codeWorkspaceContainsFolder: failed to read workspace file", + "path", workspaceFilePath, "error", err) + return false + } + + var workspace struct { + Folders []struct { + Path string `json:"path"` + } `json:"folders"` + } + if err := json.Unmarshal(data, &workspace); err != nil { + slog.Debug("codeWorkspaceContainsFolder: failed to parse workspace file", + "path", workspaceFilePath, "error", err) + return false + } + + workspaceDir := filepath.Dir(workspaceFilePath) + 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) + } + + if canonical == 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 +} From 09437c388c00f31d5b1601e06241322ffcd8f0b8 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Tue, 7 Apr 2026 14:45:27 +0200 Subject: [PATCH 29/33] Support .code-workspace file path as --project-path in copilotide and cursoride When --project-path is a .code-workspace file, also find workspace entries opened directly from folders listed in that file (Method 4). This is the reverse of Method 3 (folder path finding sessions opened via workspace file). Also refactors codeWorkspaceContainsFolder to use the new collectCodeWorkspaceFolders helper, eliminating duplicated JSON parsing logic. Co-Authored-By: Claude Sonnet 4.6 --- .../pkg/providers/copilotide/workspace.go | 101 +++++++++++++++++- .../pkg/providers/cursoride/workspace.go | 52 +++++++-- 2 files changed, 141 insertions(+), 12 deletions(-) diff --git a/specstory-cli/pkg/providers/copilotide/workspace.go b/specstory-cli/pkg/providers/copilotide/workspace.go index 617290af..288934e1 100644 --- a/specstory-cli/pkg/providers/copilotide/workspace.go +++ b/specstory-cli/pkg/providers/copilotide/workspace.go @@ -43,6 +43,14 @@ func FindWorkspaceForProject(projectPath string) (*WorkspaceMatch, error) { 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) @@ -112,8 +120,39 @@ func FindWorkspaceForProject(projectPath string) (*WorkspaceMatch, error) { canonicalWorkspacePath = workspaceFilePath } - // Compare paths - if canonicalProjectPath == canonicalWorkspacePath { + // 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 { @@ -182,6 +221,64 @@ func selectNewestWorkspace(matches []WorkspaceMatch) (*WorkspaceMatch, error) { 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) diff --git a/specstory-cli/pkg/providers/cursoride/workspace.go b/specstory-cli/pkg/providers/cursoride/workspace.go index 1a2a651a..dd4b1a08 100644 --- a/specstory-cli/pkg/providers/cursoride/workspace.go +++ b/specstory-cli/pkg/providers/cursoride/workspace.go @@ -265,6 +265,14 @@ func FindAllWorkspacesForProject(projectPath string) ([]WorkspaceMatch, error) { // 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, @@ -358,6 +366,21 @@ func FindAllWorkspacesForProject(projectPath string) ([]WorkspaceMatch, error) { } } + // 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 { @@ -493,15 +516,15 @@ func uriToPath(uri string) (string, error) { return path, nil } -// codeWorkspaceContainsFolder reads a .code-workspace JSON file and checks whether -// any of its folder entries resolves to canonicalFolder. This is used to find -// workspaces that were opened via a .code-workspace file referencing the target folder. -func codeWorkspaceContainsFolder(workspaceFilePath, canonicalFolder string) bool { +// 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("codeWorkspaceContainsFolder: failed to read workspace file", + slog.Debug("collectCodeWorkspaceFolders: failed to read workspace file", "path", workspaceFilePath, "error", err) - return false + return nil } var workspace struct { @@ -510,12 +533,13 @@ func codeWorkspaceContainsFolder(workspaceFilePath, canonicalFolder string) bool } `json:"folders"` } if err := json.Unmarshal(data, &workspace); err != nil { - slog.Debug("codeWorkspaceContainsFolder: failed to parse workspace file", + slog.Debug("collectCodeWorkspaceFolders: failed to parse workspace file", "path", workspaceFilePath, "error", err) - return false + return nil } workspaceDir := filepath.Dir(workspaceFilePath) + var folders []string for _, folder := range workspace.Folders { if folder.Path == "" { continue @@ -533,12 +557,20 @@ func codeWorkspaceContainsFolder(workspaceFilePath, canonicalFolder string) bool if err != nil { canonical = filepath.Clean(resolved) } + folders = append(folders, canonical) + } - if canonical == canonicalFolder { + 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 } From 28c2fb987d5d57e554f6e1a3cf9808fd7903f3e0 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Thu, 9 Apr 2026 19:29:41 +0200 Subject: [PATCH 30/33] Use the same agent name as for sync during run and watch --- specstory-cli/pkg/providers/cursoride/agent_session.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specstory-cli/pkg/providers/cursoride/agent_session.go b/specstory-cli/pkg/providers/cursoride/agent_session.go index fa3e72c6..4de11163 100644 --- a/specstory-cli/pkg/providers/cursoride/agent_session.go +++ b/specstory-cli/pkg/providers/cursoride/agent_session.go @@ -34,7 +34,7 @@ func ConvertToAgentChatSession(composer *ComposerData) (*spi.AgentChatSession, e SchemaVersion: "1.0", Provider: schema.ProviderInfo{ ID: "cursoride", - Name: "cursoride", + Name: "Cursor IDE", Version: "unknown", }, SessionID: sessionID, From 611c78989cd0df960fb1876059119fe705011e25 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Thu, 9 Apr 2026 19:30:11 +0200 Subject: [PATCH 31/33] Use the same agent name as for sync during run and watch for Copilot IDE too --- specstory-cli/pkg/providers/copilotide/agent_session.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specstory-cli/pkg/providers/copilotide/agent_session.go b/specstory-cli/pkg/providers/copilotide/agent_session.go index c46c25f8..60c411dc 100644 --- a/specstory-cli/pkg/providers/copilotide/agent_session.go +++ b/specstory-cli/pkg/providers/copilotide/agent_session.go @@ -33,7 +33,7 @@ func ConvertToSessionData(composer VSCodeComposer, projectPath string, state *VS SchemaVersion: "1.0", Provider: schema.ProviderInfo{ ID: "copilotide", - Name: "copilotide", + Name: "VS Code Copilot IDE", Version: "1.0", }, SessionID: composer.SessionID, From e0bf9888b79b4441078c8dc4e67e6d83717de434 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Thu, 7 May 2026 13:40:00 +0200 Subject: [PATCH 32/33] Fix URI-to-path conversion for Windows mapped drives in copilotide The copilotide uriToPath was a minimal stub that didn't percent-decode the path or handle Windows drive letters. URIs like file:///h%3A/General%20Workspace.code-workspace produced a broken path instead of H:\General Workspace.code-workspace. Replace with the robust implementation from cursoride that handles percent-decoding, Windows drive letter slash stripping, WSL URIs, and vscode-remote URIs. Fixes #216 Co-Authored-By: Claude Opus 4.6 --- .../pkg/providers/copilotide/workspace.go | 108 ++++- .../providers/copilotide/workspace_test.go | 374 ++++++++++++++++++ 2 files changed, 474 insertions(+), 8 deletions(-) create mode 100644 specstory-cli/pkg/providers/copilotide/workspace_test.go diff --git a/specstory-cli/pkg/providers/copilotide/workspace.go b/specstory-cli/pkg/providers/copilotide/workspace.go index 288934e1..7a0c3609 100644 --- a/specstory-cli/pkg/providers/copilotide/workspace.go +++ b/specstory-cli/pkg/providers/copilotide/workspace.go @@ -294,11 +294,16 @@ func readWorkspaceJSON(path string) (*WorkspaceJSON, error) { return &workspace, nil } -// uriToPath converts a file:// URI to a local file path +// 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 VS Code in WSL, +// and vscode-remote://ssh-remote+config/path URIs for SSH remotes. func uriToPath(uri string) (string, error) { - // Handle file:// URIs - if !strings.HasPrefix(uri, "file://") { - return "", fmt.Errorf("URI must start with file://: %s", uri) + // 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 @@ -307,11 +312,98 @@ func uriToPath(uri string) (string, error) { return "", fmt.Errorf("failed to parse URI: %w", err) } - // Get the path from the URI - path := parsedURI.Path + // 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 +} - // 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 +// 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 + 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/copilotide/workspace_test.go b/specstory-cli/pkg/providers/copilotide/workspace_test.go new file mode 100644 index 00000000..8b49380a --- /dev/null +++ b/specstory-cli/pkg/providers/copilotide/workspace_test.go @@ -0,0 +1,374 @@ +package copilotide + +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", + }, + { + name: "file URI with encoded colon in path", + uri: "file:///h%3A/General%20Workspace.code-workspace", + wantPath: "/h:/General Workspace.code-workspace", + }, + + // 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", + }, + { + name: "Windows mapped drive with code-workspace file", + uri: "file:///h%3A/General%20Workspace.code-workspace", + wantPath: "h:\\General Workspace.code-workspace", + }, + } + + 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 + { + 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 TestCollectCodeWorkspaceFolders(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 GetCanonicalPath 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 +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 +} From 375f6688ff24721e04c45e58b07f49a9384b2c98 Mon Sep 17 00:00:00 2001 From: bago2k4 Date: Thu, 7 May 2026 13:51:47 +0200 Subject: [PATCH 33/33] Revert "Fix URI-to-path conversion for Windows mapped drives in copilotide" This reverts commit e0bf9888b79b4441078c8dc4e67e6d83717de434. --- .../pkg/providers/copilotide/workspace.go | 108 +---- .../providers/copilotide/workspace_test.go | 374 ------------------ 2 files changed, 8 insertions(+), 474 deletions(-) delete mode 100644 specstory-cli/pkg/providers/copilotide/workspace_test.go diff --git a/specstory-cli/pkg/providers/copilotide/workspace.go b/specstory-cli/pkg/providers/copilotide/workspace.go index 7a0c3609..288934e1 100644 --- a/specstory-cli/pkg/providers/copilotide/workspace.go +++ b/specstory-cli/pkg/providers/copilotide/workspace.go @@ -294,16 +294,11 @@ func readWorkspaceJSON(path string) (*WorkspaceJSON, error) { 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 VS Code in WSL, -// and vscode-remote://ssh-remote+config/path URIs for SSH remotes. +// uriToPath converts a file:// URI to a local file path 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) + // Handle file:// URIs + if !strings.HasPrefix(uri, "file://") { + return "", fmt.Errorf("URI must start with file://: %s", uri) } // Parse the URI @@ -312,98 +307,11 @@ func uriToPath(uri string) (string, error) { 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 -} + // Get the path from the URI + path := parsedURI.Path -// 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 - 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) - } + // 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/copilotide/workspace_test.go b/specstory-cli/pkg/providers/copilotide/workspace_test.go deleted file mode 100644 index 8b49380a..00000000 --- a/specstory-cli/pkg/providers/copilotide/workspace_test.go +++ /dev/null @@ -1,374 +0,0 @@ -package copilotide - -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", - }, - { - name: "file URI with encoded colon in path", - uri: "file:///h%3A/General%20Workspace.code-workspace", - wantPath: "/h:/General Workspace.code-workspace", - }, - - // 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", - }, - { - name: "Windows mapped drive with code-workspace file", - uri: "file:///h%3A/General%20Workspace.code-workspace", - wantPath: "h:\\General Workspace.code-workspace", - }, - } - - 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 - { - 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 TestCollectCodeWorkspaceFolders(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 GetCanonicalPath 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 -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 -}