Skip to content

Copilot IDE#160

Open
bago2k4 wants to merge 69 commits intodevfrom
copilot-ide
Open

Copilot IDE#160
bago2k4 wants to merge 69 commits intodevfrom
copilot-ide

Conversation

@bago2k4
Copy link
Copy Markdown
Contributor

@bago2k4 bago2k4 commented Jan 29, 2026

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

@bago2k4 bago2k4 self-assigned this Jan 29, 2026
Copilot AI review requested due to automatic review settings January 29, 2026 13:03
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 copilotide provider for VS Code Copilot IDE chat session export
  • Added cursoride provider 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

Copilot AI review requested due to automatic review settings January 29, 2026 13:39
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 26 out of 26 changed files in this pull request and generated 4 comments.

Copilot AI review requested due to automatic review settings February 4, 2026 16:23
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 40 out of 40 changed files in this pull request and generated 4 comments.

Copilot AI review requested due to automatic review settings March 10, 2026 12:44
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 35 out of 35 changed files in this pull request and generated 5 comments.

Comment on lines +75 to +80
knownComposers: make(map[string]int64),
checkInterval: checkInterval,
throttleDuration: 10 * time.Second,
lastThrottledCall: time.Now(),
pendingCheck: false,
fsWatcher: fsWatcher,
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)).

Copilot uses AI. Check for mistakes.
Comment on lines +147 to +205
// 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)
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +21 to +26
func stripMarkdownHeading(message string) string {
trimmed := strings.TrimSpace(message)
if len(trimmed) > 0 && trimmed[0] == '#' {
trimmed = strings.TrimLeft(trimmed, "#")
trimmed = strings.TrimSpace(trimmed)
}
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +262 to +266
// 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)
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +247 to +267
// 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
}
}
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@bago2k4 bago2k4 changed the title [WIP] Copilot IDE Copilot IDE Mar 13, 2026
Copilot AI review requested due to automatic review settings March 18, 2026 10:47
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +195 to +211
// 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
Comment on lines +163 to +182
// 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
Comment on lines +259 to +267
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)
Comment on lines +269 to +273
if timeSinceLastCall >= w.throttleDuration {
w.lastThrottledCall = now
w.pendingCheck = false
go w.checkForChanges("throttled")
}
Comment on lines +84 to +92
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
Copilot AI review requested due to automatic review settings April 6, 2026 18:54
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 35 out of 35 changed files in this pull request and generated 9 comments.

Comment on lines +76 to +92
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
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +259 to +267
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)
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +159 to +178
// 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, "-")

Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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(), "-")

Copilot uses AI. Check for mistakes.
Comment on lines +220 to +273
// 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)
}

Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +70 to +77
// Add table rows
for _, file := range result.Files {
icon := "📄"
if file.IsDirectory {
icon = "📁"
}
message += fmt.Sprintf("| %s `%s` |\n", icon, file.Name)
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +58 to +69
// 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)
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +79 to +93
// 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)
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +103 to +105
// Add row
fmt.Fprintf(&message, "| `%s` | L%d | `%s` |\n",
fileResult.Resource,
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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,

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +60
// 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 {
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
bago2k4 and others added 3 commits April 7, 2026 13:56
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>
Copilot AI review requested due to automatic review settings April 7, 2026 12:50
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 37 out of 37 changed files in this pull request and generated 8 comments.

Comment on lines +32 to +44
// 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{},
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
SchemaVersion: "1.0",
Provider: schema.ProviderInfo{
ID: "cursoride",
Name: "cursoride",
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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").

Suggested change
Name: "cursoride",
Name: "Cursor IDE",

Copilot uses AI. Check for mistakes.
Comment on lines +195 to +222
// 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
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
// 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.

Copilot uses AI. Check for mistakes.
Comment on lines +84 to +89
knownComposers: make(map[string]int64),
checkInterval: checkInterval,
throttleDuration: 10 * time.Second,
lastThrottledCall: time.Now(),
pendingCheck: false,
fsWatcher: fsWatcher,
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)).

Copilot uses AI. Check for mistakes.
Comment on lines +253 to +261
// 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)
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +310 to +316
// 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
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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

Copilot uses AI. Check for mistakes.
SchemaVersion: "1.0",
Provider: schema.ProviderInfo{
ID: "copilotide",
Name: "copilotide",
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
Name: "copilotide",
Name: "VS Code Copilot IDE",

Copilot uses AI. Check for mistakes.
Comment on lines +14 to +44
// 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{},
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot generated this review using guidance from repository custom instructions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants