Conversation
…d global databases, find the session ids, retrieve the sessions data, output raw debug data for markdown export implementation.
…ser messages, agent messages and thinking blocks to parity with the extension output. Still no tool handling
…ead of single to avoid markdown formatting issues
There was a problem hiding this comment.
Pull request overview
This PR adds support for exporting VS Code Copilot IDE chat sessions by implementing a new copilotide provider. The implementation reads chat session JSON files from VS Code's workspace storage, matches workspaces to project paths, and converts the data to the CLI's unified format for markdown export.
Changes:
- Added
copilotideprovider for VS Code Copilot IDE chat session export - Added
cursorideprovider for Cursor IDE chat session export (shares similar workspace matching logic) - Enhanced markdown rendering to handle multiline tool input values with code blocks
Reviewed changes
Copilot reviewed 27 out of 27 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
specstory-cli/pkg/spi/factory/registry.go |
Registered new copilotide and cursoride providers |
specstory-cli/pkg/providers/copilotide/*.go |
Complete implementation of VS Code Copilot provider (workspace matching, JSON parsing, session conversion) |
specstory-cli/pkg/providers/cursoride/*.go |
Complete implementation of Cursor IDE provider (SQLite database access, workspace matching, session conversion) |
specstory-cli/pkg/markdown/markdown.go |
Enhanced generic tool rendering to display multiline input values in code blocks |
specstory-cli/main.go |
Added hidden flag for Cursor IDE database check interval configuration |
specstory-cli/docs/*.md |
Implementation plans and documentation for both new providers |
… the new exchanges are not yet on disk. Minor improvements to use messages data
| knownComposers: make(map[string]int64), | ||
| checkInterval: checkInterval, | ||
| throttleDuration: 10 * time.Second, | ||
| lastThrottledCall: time.Now(), | ||
| pendingCheck: false, | ||
| fsWatcher: fsWatcher, |
There was a problem hiding this comment.
NewCursorIDEWatcher initializes lastThrottledCall to time.Now(), which means the first triggerCheck("initial") (after 5s) will almost always be throttled and delayed until the full throttle window expires. If the intent is to run an initial scan promptly, initialize lastThrottledCall to the zero time (or set it to time.Now().Add(-throttleDuration)).
| // ParseResponsesForTools extracts tool invocations from response array | ||
| func ParseResponsesForTools(responses []json.RawMessage, metadata VSCodeResultMetadata, modelID string) []schema.Message { | ||
| var toolMessages []schema.Message | ||
|
|
||
| // Build ordered sequence of tool calls from metadata | ||
| toolCallSequence := BuildToolCallSequence(metadata) | ||
|
|
||
| // Track sequence index for matching | ||
| sequenceIndex := 0 | ||
|
|
||
| // Process each response | ||
| for _, rawResp := range responses { | ||
| // Parse "kind" field first | ||
| kind, err := ParseResponseKind(rawResp) | ||
| if err != nil { | ||
| slog.Debug("Failed to parse response kind", "error", err) | ||
| continue | ||
| } | ||
|
|
||
| switch kind { | ||
| case "toolInvocationSerialized": | ||
| var invocation VSCodeToolInvocationResponse | ||
| if err := json.Unmarshal(rawResp, &invocation); err != nil { | ||
| slog.Debug("Failed to parse tool invocation", "error", err) | ||
| continue | ||
| } | ||
|
|
||
| // Skip hidden tools (don't increment sequence index for hidden tools) | ||
| if invocation.Presentation == "hidden" { | ||
| continue | ||
| } | ||
|
|
||
| // Match by sequence: get the next tool call from the ordered list | ||
| if sequenceIndex >= len(toolCallSequence) { | ||
| slog.Debug("Tool invocation has no matching tool call in sequence", | ||
| "sequenceIndex", sequenceIndex, | ||
| "totalToolCalls", len(toolCallSequence), | ||
| "toolCallId", invocation.ToolCallID) | ||
| continue | ||
| } | ||
|
|
||
| toolCall := toolCallSequence[sequenceIndex] | ||
| sequenceIndex++ | ||
|
|
||
| slog.Debug("Matched tool by sequence", | ||
| "sequenceIndex", sequenceIndex-1, | ||
| "toolName", toolCall.Name, | ||
| "invocationId", invocation.ToolCallID, | ||
| "metadataId", toolCall.ID) | ||
|
|
||
| // Build tool info using the matched tool call | ||
| toolInfo := BuildToolInfoFromInvocation(invocation, toolCall, metadata.ToolCallResults) | ||
| if toolInfo != nil { | ||
| toolMsg := schema.Message{ | ||
| Role: schema.RoleAgent, | ||
| Model: modelID, | ||
| Tool: toolInfo, | ||
| } | ||
| toolMessages = append(toolMessages, toolMsg) |
There was a problem hiding this comment.
There’s substantial new parsing/matching logic here (JSONL incremental updates, sequence-based tool correlation, editing-only synthetic requests), but copilotide currently has no unit tests. Given other providers in this repo are heavily tested, adding focused tests for ParseResponsesForTools/BuildToolInfoFromInvocation (ID mismatch + ordering, hidden tools, outputs mapping) would help prevent regressions.
| func stripMarkdownHeading(message string) string { | ||
| trimmed := strings.TrimSpace(message) | ||
| if len(trimmed) > 0 && trimmed[0] == '#' { | ||
| trimmed = strings.TrimLeft(trimmed, "#") | ||
| trimmed = strings.TrimSpace(trimmed) | ||
| } |
There was a problem hiding this comment.
stripMarkdownHeading currently strips any leading # even when it's not a markdown heading (e.g. #login would become login). This can change the meaning of the first word in slugs. Consider only stripping when the string matches a real heading marker like ^#{1,6}\s+ (hashes followed by whitespace) and leave other leading hashes intact.
| // Check if value is multiline - if so, use code block | ||
| if strings.Contains(valueStr, "\n") { | ||
| fmt.Fprintf(&markdown, "- %s:\n\n```\n%v\n```\n\n", key, valueStr) | ||
| } else { | ||
| fmt.Fprintf(&markdown, "- %s: `%v`\n", key, valueStr) |
There was a problem hiding this comment.
renderGenericTool writes multiline input values inside a fenced code block without escaping. If a tool input contains ``` (or other markdown that closes the fence), the rendered markdown becomes malformed and could leak content outside the block. Consider escaping backticks in valueStr or using a fence length that can't occur in the content (e.g. dynamically choose 4+ backticks).
| // Add output from results map | ||
| // Note: We still look up results by invocation.ToolCallID since that's the VS Code ID | ||
| if result, ok := toolResults[invocation.ToolCallID]; ok { | ||
| output := make(map[string]any) | ||
| if len(result.Content) > 0 { | ||
| var contentParts []string | ||
| for _, content := range result.Content { | ||
| // Value can be string or object - convert to string | ||
| valueStr := valueToString(content.Value) | ||
| if valueStr != "" { | ||
| contentParts = append(contentParts, valueStr) | ||
| } | ||
| } | ||
| if len(contentParts) > 0 { | ||
| output["result"] = strings.Join(contentParts, "\n") | ||
| } | ||
| } | ||
| if len(output) > 0 { | ||
| toolInfo.Output = output | ||
| } | ||
| } |
There was a problem hiding this comment.
BuildToolInfoFromInvocation looks up tool outputs using invocation.ToolCallID, but the rest of this file establishes that VS Code invocation IDs don't match the OpenAI-style IDs in metadata. This will likely prevent outputs from ever being populated. Since you already have the matched toolCall, use toolCall.ID (or whichever ID keys metadata.ToolCallResults) when looking up toolResults.
…own but not the agent response
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated 6 comments.
You can also share your feedback on Copilot code review. Take the survey.
| // 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 |
| // 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 |
| value := tool.Input[key] | ||
| valueStr := fmt.Sprintf("%v", value) | ||
|
|
||
| // Check if value is multiline - if so, use code block | ||
| if strings.Contains(valueStr, "\n") { | ||
| fmt.Fprintf(&markdown, "- %s:\n\n```\n%v\n```\n\n", key, valueStr) | ||
| } else { | ||
| fmt.Fprintf(&markdown, "- %s: `%v`\n", key, valueStr) | ||
| } |
| if languageId, hasLang := codeblockData["languageId"].(string); hasLang { | ||
| lang = languageId | ||
| } | ||
| fmt.Fprintf(&message, "```%s\n%s\n```\n\n", lang, content) |
| if timeSinceLastCall >= w.throttleDuration { | ||
| w.lastThrottledCall = now | ||
| w.pendingCheck = false | ||
| go w.checkForChanges("throttled") | ||
| } |
| knownComposers: make(map[string]int64), | ||
| checkInterval: checkInterval, | ||
| throttleDuration: 10 * time.Second, | ||
| lastThrottledCall: time.Now(), | ||
| pendingCheck: false, | ||
| fsWatcher: fsWatcher, | ||
| pendingIncomplete: make(map[string]time.Time), | ||
| incompleteTimeout: 60 * time.Second, | ||
| }, nil |
| return &CursorIDEWatcher{ | ||
| projectPath: projectPath, | ||
| workspace: workspace, | ||
| globalDbPath: globalDbPath, | ||
| debugRaw: debugRaw, | ||
| sessionCallback: sessionCallback, | ||
| ctx: ctx, | ||
| cancel: cancel, | ||
| knownComposers: make(map[string]int64), | ||
| checkInterval: checkInterval, | ||
| throttleDuration: 10 * time.Second, | ||
| lastThrottledCall: time.Now(), | ||
| pendingCheck: false, | ||
| fsWatcher: fsWatcher, | ||
| pendingIncomplete: make(map[string]time.Time), | ||
| incompleteTimeout: 60 * time.Second, | ||
| }, nil |
There was a problem hiding this comment.
CursorIDEWatcher initializes lastThrottledCall to time.Now(), which causes the first triggerCheck (e.g. the initial check scheduled 5s after Start) to be throttled and delayed until throttleDuration elapses. Consider initializing lastThrottledCall to the zero time or to time.Now().Add(-throttleDuration) so the initial check runs immediately.
| value := tool.Input[key] | ||
| valueStr := fmt.Sprintf("%v", value) | ||
|
|
||
| // Check if value is multiline - if so, use code block | ||
| if strings.Contains(valueStr, "\n") { | ||
| fmt.Fprintf(&markdown, "- %s:\n\n```\n%v\n```\n\n", key, valueStr) | ||
| } else { | ||
| fmt.Fprintf(&markdown, "- %s: `%v`\n", key, valueStr) | ||
| } |
There was a problem hiding this comment.
renderGenericTool now emits multi-line tool input values inside fenced code blocks, but it doesn’t escape backticks/HTML. If a value contains ``` or HTML tags, it can break the markdown structure or inject unintended markup. Consider escaping code-block content (similar to cursoride.escapeCodeBlock) or using an indented code block / a safer escaping helper here.
| // 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, "-") | ||
|
|
There was a problem hiding this comment.
The comment says “Replace non-alphanumeric characters with hyphens”, but the implementation only turns Unicode spaces into '-', and silently drops punctuation. This can concatenate words (e.g., "Plan: Replace" -> "planreplace") and diverges from the documented behavior; consider mapping other non-alphanumeric separators to '-' as well.
| // 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, "-") | |
| // Treat any non-alphanumeric rune as a separator so punctuation | |
| // doesn't silently merge adjacent words. | |
| var builder strings.Builder | |
| lastWasHyphen := false | |
| for _, r := range text { | |
| if unicode.IsLetter(r) || unicode.IsDigit(r) { | |
| builder.WriteRune(r) | |
| lastWasHyphen = false | |
| continue | |
| } | |
| if builder.Len() > 0 && !lastWasHyphen { | |
| builder.WriteRune('-') | |
| lastWasHyphen = true | |
| } | |
| } | |
| slug := strings.Trim(builder.String(), "-") |
| // 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) | ||
| } | ||
|
|
There was a problem hiding this comment.
parseJSONL applies each kind:1/kind:2 update by marshaling/unmarshaling the entire composer to/from map form. For longer sessions with many JSONL updates, this becomes unnecessarily expensive. Consider handling known key paths directly (e.g., requests/customTitle) or using a targeted update strategy to avoid full re-marshal per line.
| // Add table rows | ||
| for _, file := range result.Files { | ||
| icon := "📄" | ||
| if file.IsDirectory { | ||
| icon = "📁" | ||
| } | ||
| message += fmt.Sprintf("| %s `%s` |\n", icon, file.Name) | ||
| } |
There was a problem hiding this comment.
Markdown table rows interpolate file.Name directly inside backticks without escaping. Filenames can legally contain '|' or backticks on Unix, which will break the table formatting. Consider reusing escapeTableCellValue (from tool_grep.go) or adding an escaping helper for table/backtick contexts.
| // 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) | ||
| } |
There was a problem hiding this comment.
The file list table interpolates displayName directly inside backticks without escaping. If a filename/URI contains '|' or backticks, the markdown table/code formatting can break. Consider escaping table-cell content (e.g., via escapeTableCellValue) before formatting.
| // 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) | ||
| } |
There was a problem hiding this comment.
This markdown table prints file paths without escaping. Directory/file names can contain characters like '|' or backticks, which will break the table. Consider escaping the displayed name before embedding it in a table row.
| // Add row | ||
| fmt.Fprintf(&message, "| `%s` | L%d | `%s` |\n", | ||
| fileResult.Resource, |
There was a problem hiding this comment.
This markdown table prints fileResult.Resource directly inside backticks. If the path contains '|' or backticks, the table formatting can break. Consider escaping the file path similarly to escapeTableCellValue used elsewhere in this provider.
| // Add row | |
| fmt.Fprintf(&message, "| `%s` | L%d | `%s` |\n", | |
| fileResult.Resource, | |
| escapedResource := escapeTableCellValue(fileResult.Resource) | |
| // Add row | |
| fmt.Fprintf(&message, "| `%s` | L%d | `%s` |\n", | |
| escapedResource, |
| // If we have a command, show it in the summary and as a bash block | ||
| if command != "" { | ||
| fmt.Fprintf(&message, " • Run command: %s</summary>\n\n", command) | ||
| fmt.Fprintf(&message, "```bash\n%s\n```", command) | ||
| } else { |
There was a problem hiding this comment.
ShellCommandHandler injects the terminal command directly into the
text and into a fenced code block without escaping. Commands can contain characters like <, &, or backticks which may break the HTML/markdown structure. Consider escaping command text (HTML + code block) before rendering.
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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
| // 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{}, | ||
| } |
There was a problem hiding this comment.
schema.SessionData requires workspaceRoot (see schema/types.go:179-182). Cursor IDE sessions currently build SessionData without setting WorkspaceRoot, which will fail schema validation in debug mode and leaves consumers without project context. Consider passing projectPath into ConvertToAgentChatSession (or setting WorkspaceRoot in the provider before writing) and populating WorkspaceRoot consistently with other providers.
| SchemaVersion: "1.0", | ||
| Provider: schema.ProviderInfo{ | ||
| ID: "cursoride", | ||
| Name: "cursoride", |
There was a problem hiding this comment.
ProviderInfo.Name is set to "cursoride", but other providers use a human-readable name (e.g. "Cursor CLI", "Claude Code"). Since schema validation requires provider.name and this is user-facing in exports, consider setting it to "Cursor IDE" (and keep ID as "cursoride").
| Name: "cursoride", | |
| Name: "Cursor IDE", |
| // generateSlug creates a slug from the composer name or first user message | ||
| func generateSlug(composer *ComposerData) string { | ||
| // Use composer name if available | ||
| if composer.Name != "" { | ||
| return slugify(composer.Name) | ||
| } | ||
|
|
||
| // Otherwise, use first user message | ||
| for _, bubble := range composer.Conversation { | ||
| if bubble.Type == 1 && bubble.Text != "" { | ||
| // Take first 4 words | ||
| return slugifyText(bubble.Text, 4) | ||
| } | ||
| } | ||
|
|
||
| // Fallback to composer ID | ||
| return composer.ComposerID | ||
| } | ||
|
|
||
| // slugify converts a string to a slug while preserving casing | ||
| // The casing is preserved for use in titles, and will be lowercased for filenames by the caller | ||
| func slugify(s string) string { | ||
| // Replace spaces with hyphens, keep original casing | ||
| s = strings.ReplaceAll(s, " ", "-") | ||
| return s | ||
| } | ||
|
|
||
| // slugifyText converts text to a slug using the first N words |
There was a problem hiding this comment.
slugify/slugifyText only replace spaces with '-', so slugs can still contain characters that are unsafe for filenames/paths (e.g. '/', '\', ':', '?'). Since BuildSessionFilePath uses session.Slug directly in the filename (session/session.go:62-66), this can break exports or create unintended paths. Consider generating slugs via spi.GenerateFilenameFromUserMessage (or equivalent sanitization) and updating the comment that says the caller lowercases the slug (the caller currently does not).
| // generateSlug creates a slug from the composer name or first user message | |
| func generateSlug(composer *ComposerData) string { | |
| // Use composer name if available | |
| if composer.Name != "" { | |
| return slugify(composer.Name) | |
| } | |
| // Otherwise, use first user message | |
| for _, bubble := range composer.Conversation { | |
| if bubble.Type == 1 && bubble.Text != "" { | |
| // Take first 4 words | |
| return slugifyText(bubble.Text, 4) | |
| } | |
| } | |
| // Fallback to composer ID | |
| return composer.ComposerID | |
| } | |
| // slugify converts a string to a slug while preserving casing | |
| // The casing is preserved for use in titles, and will be lowercased for filenames by the caller | |
| func slugify(s string) string { | |
| // Replace spaces with hyphens, keep original casing | |
| s = strings.ReplaceAll(s, " ", "-") | |
| return s | |
| } | |
| // slugifyText converts text to a slug using the first N words | |
| // generateSlug creates a filename-safe slug from the composer name or first user message. | |
| func generateSlug(composer *ComposerData) string { | |
| // Prefer the composer name when it produces a usable filename-safe slug. | |
| if composer.Name != "" { | |
| if slug := slugify(composer.Name); slug != "" { | |
| return slug | |
| } | |
| } | |
| // Otherwise, use the first user message. | |
| for _, bubble := range composer.Conversation { | |
| if bubble.Type == 1 && bubble.Text != "" { | |
| // Keep the existing behavior of using the first 4 words, then sanitize them | |
| // for safe use in filenames and paths. | |
| if slug := slugifyText(bubble.Text, 4); slug != "" { | |
| return slug | |
| } | |
| } | |
| } | |
| // Fallback to the composer ID, sanitized when possible. | |
| if slug := slugify(composer.ComposerID); slug != "" { | |
| return slug | |
| } | |
| return composer.ComposerID | |
| } | |
| // slugify converts a string to a filename-safe slug. | |
| func slugify(s string) string { | |
| return spi.GenerateFilenameFromUserMessage(s) | |
| } | |
| // slugifyText converts text to a filename-safe slug using the first N words. |
| knownComposers: make(map[string]int64), | ||
| checkInterval: checkInterval, | ||
| throttleDuration: 10 * time.Second, | ||
| lastThrottledCall: time.Now(), | ||
| pendingCheck: false, | ||
| fsWatcher: fsWatcher, |
There was a problem hiding this comment.
lastThrottledCall is initialized to time.Now(), which means the first triggerCheck() call within the first 10s will always be throttled. With the current Start() logic (initial trigger after 5s), the first DB check is delayed to ~10s after watcher creation. If the intent is to allow an immediate first check, initialize lastThrottledCall to time.Time{} (or time.Now().Add(-throttleDuration)).
| // 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) |
There was a problem hiding this comment.
FindAllWorkspacesForProject and LoadComposerIDsFromAllWorkspaces are currently unused (no references outside this file). This adds a lot of extra surface area/complexity without affecting behavior. Consider either wiring these into the provider/watcher (so WSL/multi-workspace matching is actually supported) or removing them until needed.
| // 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 |
There was a problem hiding this comment.
uriToPath returns parsedURI.Path without URL-decoding. workspace.json URIs can contain percent-encoded characters (e.g. %20 for spaces, %3A, etc.), so path comparisons/canonicalization can fail and the workspace won’t match. Consider applying url.PathUnescape(parsedURI.Path) and using the decoded path for matching.
| // 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 | |
| // Decode the URI path so workspace matching uses the filesystem path form. | |
| decodedPath, err := url.PathUnescape(parsedURI.Path) | |
| if err != nil { | |
| return "", fmt.Errorf("failed to decode URI path: %w", err) | |
| } | |
| // 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 decodedPath, nil |
| SchemaVersion: "1.0", | ||
| Provider: schema.ProviderInfo{ | ||
| ID: "copilotide", | ||
| Name: "copilotide", |
There was a problem hiding this comment.
ProviderInfo.Name is set to "copilotide", but other providers use a human-readable provider name in session exports (e.g. "Codex CLI", "Claude Code"). Consider setting provider.name to something like "VS Code Copilot IDE" (and keep ID as "copilotide") so downstream consumers and users see a consistent provider label.
| Name: "copilotide", | |
| Name: "VS Code Copilot IDE", |
| // ConvertToAgentChatSession converts Cursor composer data to AgentChatSession format | ||
| // This is a minimal implementation - markdown output will be improved later | ||
| func ConvertToAgentChatSession(composer *ComposerData) (*spi.AgentChatSession, error) { | ||
| // Use composer ID as session ID | ||
| sessionID := composer.ComposerID | ||
|
|
||
| // Convert timestamp (milliseconds to ISO 8601) | ||
| var createdAt string | ||
| if composer.CreatedAt > 0 { | ||
| t := time.Unix(composer.CreatedAt/1000, (composer.CreatedAt%1000)*1000000) | ||
| createdAt = t.Format(time.RFC3339) | ||
| } else { | ||
| createdAt = time.Now().Format(time.RFC3339) | ||
| } | ||
|
|
||
| // Generate slug from composer name or first user message | ||
| slug := generateSlug(composer) | ||
|
|
||
| // Build SessionData with provider info | ||
| sessionData := &schema.SessionData{ | ||
| SchemaVersion: "1.0", | ||
| Provider: schema.ProviderInfo{ | ||
| ID: "cursoride", | ||
| Name: "cursoride", | ||
| Version: "unknown", | ||
| }, | ||
| SessionID: sessionID, | ||
| CreatedAt: createdAt, | ||
| Slug: slug, | ||
| Exchanges: []schema.Exchange{}, | ||
| } |
There was a problem hiding this comment.
There are unit tests for workspace URI/path parsing, but the Cursor IDE session conversion logic (ConvertToAgentChatSession) is currently untested. Given the amount of edge-case handling (exchange grouping, tool bubbles, timestamps, slug generation), adding a few focused tests would help prevent regressions (e.g. WorkspaceRoot populated, slug is filesystem-safe, tool bubbles produce ToolInfo metadata).
NB: merge after #159
Support to export VS Code AI chats (Copilot)
Question: do we want to have any code shared between providers? Cursor and Copilot IDEs have a few things in common, most important the workspace selection. For now the duplicate the few functions/structures used for it