diff --git a/README.md b/README.md index 5cbbeece..8e89e6de 100644 --- a/README.md +++ b/README.md @@ -354,6 +354,7 @@ frontend/ Svelte 5 SPA (Vite, TypeScript) | Kimi | `~/.kimi/sessions/` | `KIMI_DIR` | | Kiro CLI | `~/.kiro/sessions/cli/` | `KIRO_SESSIONS_DIR` | | Kiro IDE | `~/Library/Application Support/Kiro/` (macOS) | `KIRO_IDE_DIR` | +| Cortex Code | `~/.snowflake/cortex/conversations/` | `CORTEX_DIR` | ## Acknowledgements diff --git a/frontend/src/lib/utils/agents.ts b/frontend/src/lib/utils/agents.ts index bba7fdbb..b66e5cf2 100644 --- a/frontend/src/lib/utils/agents.ts +++ b/frontend/src/lib/utils/agents.ts @@ -29,7 +29,8 @@ export const KNOWN_AGENTS: readonly AgentMeta[] = [ { name: "claude-ai", color: "var(--accent-violet)", label: "Claude.ai" }, { name: "chatgpt", color: "var(--accent-lime)", label: "ChatGPT" }, { name: "kiro", color: "var(--accent-lime)", label: "Kiro" }, - { name: "kiro-ide", color: "var(--accent-lime)", label: "Kiro IDE" } + { name: "kiro-ide", color: "var(--accent-lime)", label: "Kiro IDE" }, + { name: "cortex", color: "var(--accent-cyan)", label: "Cortex Code" } ]; const agentColorMap = new Map( diff --git a/internal/insight/generate.go b/internal/insight/generate.go index e8f29c2f..47fb01c6 100644 --- a/internal/insight/generate.go +++ b/internal/insight/generate.go @@ -34,7 +34,7 @@ var ValidAgents = map[string]bool{ "codex": true, "copilot": true, "gemini": true, - "kiro": true, + "kiro": true, } // GenerateFunc is the signature for insight generation, diff --git a/internal/parser/cortex.go b/internal/parser/cortex.go new file mode 100644 index 00000000..7e139261 --- /dev/null +++ b/internal/parser/cortex.go @@ -0,0 +1,569 @@ +package parser + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// cortexBackupRe matches backup filenames like +// .back..json — these must be skipped. +var cortexBackupRe = regexp.MustCompile( + `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.back\.`, +) + +// cortexSessionJSON is the top-level structure of a Cortex +// session file (.json). When the session has grown beyond +// the in-process write limit, Cortex splits the conversation into +// a companion .history.jsonl file and this JSON stores only +// metadata (no "history" key). +type cortexSessionJSON struct { + SessionID string `json:"session_id"` + Title string `json:"title"` + History []cortexMessage `json:"history"` + ConnectionName string `json:"connection_name"` + WorkingDirectory string `json:"working_directory"` + GitRoot string `json:"git_root"` + GitBranch string `json:"git_branch"` + CreatedAt string `json:"created_at"` + LastUpdated string `json:"last_updated"` + HistoryLength int `json:"history_length"` + SessionType string `json:"session_type"` + PermissionCache map[string]string `json:"permission_cache"` +} + +// cortexMessage represents a single turn in the conversation history. +type cortexMessage struct { + Role string `json:"role"` + ID string `json:"id"` + Content []cortexContentBlock `json:"content"` + UserSentTime string `json:"user_sent_time"` +} + +// cortexContentBlock is a single block inside a message's content array. +// Cortex uses a nested structure: tool_use and tool_result are wrapped +// inside an object under the key matching the block type. +type cortexContentBlock struct { + Type string `json:"type"` + Text string `json:"text"` + InternalOnly *bool `json:"internalOnly"` + IsUserPrompt *bool `json:"is_user_prompt"` + MessageID string `json:"message_id"` + ToolUse *cortexToolUse `json:"tool_use"` + ToolResult *cortexToolResult `json:"tool_result"` +} + +// cortexToolUse is the payload for a tool_use content block. +type cortexToolUse struct { + ToolUseID string `json:"tool_use_id"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` +} + +// cortexToolResult is the payload for a tool_result content block. +type cortexToolResult struct { + Name string `json:"name"` + ToolUseID string `json:"tool_use_id"` + Content []cortexContentBlock `json:"content"` + Status string `json:"status"` +} + +// isCortexInternalBlock reports whether a content block should be +// suppressed. Cortex marks injected system context with +// internalOnly=true and/or wraps it in tags. +func isCortexInternalBlock(b cortexContentBlock) bool { + if b.InternalOnly != nil && *b.InternalOnly { + return true + } + if strings.Contains(b.Text, "") { + return true + } + return false +} + +// extractCortexText extracts display text from a Cortex message, +// filtering internal-only blocks and system reminders. +func extractCortexText(msg cortexMessage) string { + var parts []string + for _, b := range msg.Content { + if b.Type != "text" { + continue + } + if isCortexInternalBlock(b) { + continue + } + t := strings.TrimSpace(b.Text) + if t != "" { + parts = append(parts, t) + } + } + return strings.Join(parts, "\n") +} + +// hasRealUserContent reports whether a user message contains at +// least one non-internal text block. +func hasRealUserContent(msg cortexMessage) bool { + for _, b := range msg.Content { + if b.Type == "text" && !isCortexInternalBlock(b) { + t := strings.TrimSpace(b.Text) + if t != "" { + return true + } + } + } + return false +} + +// parseCortexMessages converts a slice of raw cortexMessage values +// into ParsedMessage entries. It skips: +// - the entire first user turn (it contains only system reminders), +// unless the message has actual user text +// - user messages that consist solely of tool_result blocks +// +// Returns messages and the first real user prompt string. +func parseCortexMessages( + history []cortexMessage, + timestamps map[string]time.Time, +) ([]ParsedMessage, string) { + var msgs []ParsedMessage + var firstMessage string + ordinal := 0 + + for i, msg := range history { + role := RoleType(msg.Role) + if role != RoleUser && role != RoleAssistant { + continue + } + + // For user messages, skip turns that only contain + // internal/system-reminder content. + if role == RoleUser && i == 0 && !hasRealUserContent(msg) { + continue + } + + // Determine timestamp: prefer user_sent_time on user messages, + // fall back to timestamps map (keyed by message ID). + var ts time.Time + if msg.UserSentTime != "" { + ts, _ = time.Parse(time.RFC3339Nano, msg.UserSentTime) + if ts.IsZero() { + ts, _ = time.Parse(time.RFC3339, msg.UserSentTime) + } + } + if ts.IsZero() { + ts = timestamps[msg.ID] + } + + // Build the display content string. + text := extractCortexText(msg) + + // Collect tool calls (from assistant messages). + var toolCalls []ParsedToolCall + hasToolUse := false + for _, b := range msg.Content { + if b.Type == "tool_use" && b.ToolUse != nil { + hasToolUse = true + tu := b.ToolUse + cat := NormalizeToolCategory(tu.Name) + inputJSON := "" + if len(tu.Input) > 0 && string(tu.Input) != "null" { + inputJSON = string(tu.Input) + } + toolCalls = append(toolCalls, ParsedToolCall{ + ToolUseID: tu.ToolUseID, + ToolName: tu.Name, + Category: cat, + InputJSON: inputJSON, + }) + } + } + + // Collect tool results (from user messages carrying tool_result blocks). + var toolResults []ParsedToolResult + for _, b := range msg.Content { + if b.Type == "tool_result" && b.ToolResult != nil { + tr := b.ToolResult + raw, _ := json.Marshal(tr.Content) + toolResults = append(toolResults, ParsedToolResult{ + ToolUseID: tr.ToolUseID, + ContentRaw: string(raw), + ContentLength: len(raw), + }) + } + } + + // User messages that only contain tool results are responses + // to prior tool calls — include them but with empty content. + if role == RoleUser && + text == "" && + len(toolCalls) == 0 && + len(toolResults) == 0 { + continue + } + + // Capture first real user prompt. + if role == RoleUser && firstMessage == "" && text != "" { + firstMessage = truncate( + strings.ReplaceAll(text, "\n", " "), 300, + ) + } + + // Build the content for display: use text if available, + // otherwise synthesize from tool calls. + content := text + if content == "" && len(toolCalls) > 0 { + var labels []string + for _, tc := range toolCalls { + labels = append(labels, formatCortexToolHeader(tc)) + } + content = strings.Join(labels, "\n") + } + + if content == "" && len(toolResults) > 0 { + content = fmt.Sprintf("[%d tool result(s)]", len(toolResults)) + } + + if content == "" { + continue + } + + msgs = append(msgs, ParsedMessage{ + Ordinal: ordinal, + Role: role, + Content: content, + Timestamp: ts, + HasToolUse: hasToolUse, + ContentLength: len(content), + ToolCalls: toolCalls, + ToolResults: toolResults, + }) + ordinal++ + } + + return msgs, firstMessage +} + +// formatCortexToolHeader renders a one-line label for a tool call, +// mirroring the style used by the Claude and Codex parsers. +func formatCortexToolHeader(tc ParsedToolCall) string { + if tc.InputJSON == "" { + return formatToolHeader(tc.Category, tc.ToolName) + } + detail := cortexToolDetail(tc.ToolName, tc.InputJSON) + return formatToolHeader(tc.Category, detail) +} + +// cortexToolDetail extracts a concise label from a tool's input JSON. +func cortexToolDetail(name, inputJSON string) string { + if !strings.HasPrefix(strings.TrimSpace(inputJSON), "{") { + return name + } + input := make(map[string]json.RawMessage) + if err := json.Unmarshal([]byte(inputJSON), &input); err != nil { + return name + } + getString := func(keys ...string) string { + for _, k := range keys { + v, ok := input[k] + if !ok { + continue + } + var s string + if err := json.Unmarshal(v, &s); err == nil { + if t := strings.TrimSpace(s); t != "" { + return t + } + } + } + return "" + } + switch name { + case "bash": + if cmd := getString("command", "cmd"); cmd != "" { + first, _, _ := strings.Cut(cmd, "\n") + return truncate("$ "+strings.TrimSpace(first), 200) + } + case "read": + if p := getString("file_path", "path"); p != "" { + return p + } + case "write": + if p := getString("file_path", "path"); p != "" { + return p + } + case "edit": + if p := getString("file_path", "path"); p != "" { + return p + } + case "grep": + if pat := getString("pattern"); pat != "" { + return pat + } + case "glob": + if pat := getString("pattern"); pat != "" { + if path := getString("path"); path != "" { + return pat + " in " + path + } + return pat + } + case "web_fetch": + if url := getString("url"); url != "" { + return url + } + case "snowflake_sql_execute": + if q := getString("query", "sql"); q != "" { + first, _, _ := strings.Cut(q, "\n") + return truncate(strings.TrimSpace(first), 200) + } + } + return name +} + +// parseCortexTimestamps builds a map from message ID to timestamp +// by scanning the history JSONL file. This is used for sessions that +// store their history in a companion .history.jsonl file, which +// contains no explicit time field other than what's in tool results +// or user_sent_time — but the JSONL append order gives us ordering. +// +// For now we return an empty map; timestamps will come from user_sent_time. +func parseCortexTimestamps(_ string) map[string]time.Time { + return make(map[string]time.Time) +} + +// ParseCortexSession parses a Cortex session from its .json metadata +// file. If the file contains an embedded "history" array, it is used +// directly. If no history is embedded (the split-file format), the +// companion .history.jsonl file is read instead. +func ParseCortexSession( + path, machine string, +) (*ParsedSession, []ParsedMessage, error) { + info, err := os.Stat(path) + if err != nil { + return nil, nil, fmt.Errorf("stat %s: %w", path, err) + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, nil, fmt.Errorf("read %s: %w", path, err) + } + + var meta cortexSessionJSON + if err := json.Unmarshal(data, &meta); err != nil { + return nil, nil, fmt.Errorf("parse %s: %w", path, err) + } + + if meta.SessionID == "" { + return nil, nil, nil + } + + // Choose history source: prefer embedded, fall back to JSONL. + history := meta.History + histFile := strings.TrimSuffix(path, ".json") + ".history.jsonl" + + if len(history) == 0 { + loaded, err := readCortexHistoryJSONL(histFile) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil, nil + } + return nil, nil, fmt.Errorf( + "read history %s: %w", histFile, err, + ) + } + history = loaded + } + + if len(history) == 0 { + return nil, nil, nil + } + + msgs, firstMessage := parseCortexMessages( + history, + parseCortexTimestamps(histFile), + ) + + startedAt, _ := time.Parse(time.RFC3339Nano, meta.CreatedAt) + if startedAt.IsZero() { + startedAt, _ = time.Parse(time.RFC3339, meta.CreatedAt) + } + endedAt, _ := time.Parse(time.RFC3339Nano, meta.LastUpdated) + if endedAt.IsZero() { + endedAt, _ = time.Parse(time.RFC3339, meta.LastUpdated) + } + + // Derive timestamps from messages when meta fields are absent. + for _, m := range msgs { + if !m.Timestamp.IsZero() { + if startedAt.IsZero() || m.Timestamp.Before(startedAt) { + startedAt = m.Timestamp + } + if m.Timestamp.After(endedAt) { + endedAt = m.Timestamp + } + } + } + + project := extractCortexProject(meta) + + // Use the title if it's not the default auto-generated one. + displayName := "" + if meta.Title != "" && + !strings.HasPrefix(meta.Title, "Chat for session:") { + displayName = meta.Title + } + + userCount := 0 + for _, m := range msgs { + if m.Role == RoleUser { + userCount++ + } + } + + // Always use the discovered .json file for File metadata, even + // when we read from .history.jsonl. The sync engine tracks the + // .json file for skip/hash logic, so this must match. + sess := &ParsedSession{ + ID: "cortex:" + meta.SessionID, + Project: project, + Machine: machine, + Agent: AgentCortex, + Cwd: meta.WorkingDirectory, + FirstMessage: firstMessage, + DisplayName: displayName, + StartedAt: startedAt, + EndedAt: endedAt, + MessageCount: len(msgs), + UserMessageCount: userCount, + File: FileInfo{ + Path: path, + Size: info.Size(), + Mtime: info.ModTime().UnixNano(), + }, + } + + return sess, msgs, nil +} + +// readCortexHistoryJSONL reads a .history.jsonl file and returns the +// messages it contains. Each line is a JSON-encoded cortexMessage. +func readCortexHistoryJSONL( + path string, +) ([]cortexMessage, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var msgs []cortexMessage + for line := range strings.SplitSeq(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var msg cortexMessage + if err := json.Unmarshal([]byte(line), &msg); err != nil { + continue + } + if msg.Role == "" { + continue + } + msgs = append(msgs, msg) + } + return msgs, nil +} + +// extractCortexProject derives a project name from session metadata, +// using the working directory or git root as the source. +func extractCortexProject(meta cortexSessionJSON) string { + cwd := meta.WorkingDirectory + if cwd == "" { + cwd = meta.GitRoot + } + branch := meta.GitBranch + if proj := ExtractProjectFromCwdWithBranch(cwd, branch); proj != "" { + return proj + } + return "unknown" +} + +// IsCortexBackupFile reports whether a filename is a Cortex backup +// file (e.g. .back..json) that should be ignored +// during discovery. +func IsCortexBackupFile(name string) bool { + return cortexBackupRe.MatchString(name) +} + +// IsCortexSessionFile reports whether name is a primary Cortex +// session metadata file: a UUID followed by ".json" (but not a +// backup or .history.jsonl file). +func IsCortexSessionFile(name string) bool { + if !strings.HasSuffix(name, ".json") { + return false + } + if IsCortexBackupFile(name) { + return false + } + stem := strings.TrimSuffix(name, ".json") + return IsValidSessionID(stem) +} + +// DiscoverCortexSessions finds all primary session metadata files +// in the Cortex conversations directory (~/.snowflake/cortex/conversations). +// Backup files (*.back.*.json) are silently skipped. Both embedded-history +// sessions (.json with a "history" key) and split sessions +// (.json + .history.jsonl) are returned as a single entry +// pointing to the .json metadata file. +func DiscoverCortexSessions( + conversationsDir string, +) []DiscoveredFile { + if conversationsDir == "" { + return nil + } + + entries, err := os.ReadDir(conversationsDir) + if err != nil { + return nil + } + + var files []DiscoveredFile + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !IsCortexSessionFile(name) { + continue + } + files = append(files, DiscoveredFile{ + Path: filepath.Join(conversationsDir, name), + Agent: AgentCortex, + }) + } + + return files +} + +// FindCortexSourceFile locates a Cortex session file by UUID. Accepts +// both the raw UUID and the prefixed "cortex:" form. Returns the +// path to the .json metadata file if found, otherwise "". +func FindCortexSourceFile( + conversationsDir, sessionID string, +) string { + // Strip "cortex:" prefix before validation — callers may + // pass the full prefixed ID. + sessionID = strings.TrimPrefix(sessionID, "cortex:") + if conversationsDir == "" || !IsValidSessionID(sessionID) { + return "" + } + + candidate := filepath.Join(conversationsDir, sessionID+".json") + if _, err := os.Stat(candidate); err == nil { + return candidate + } + return "" +} diff --git a/internal/parser/cortex_test.go b/internal/parser/cortex_test.go new file mode 100644 index 00000000..ee92199c --- /dev/null +++ b/internal/parser/cortex_test.go @@ -0,0 +1,429 @@ +package parser + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const cortexTestUUID = "11111111-2222-3333-4444-555555555555" + +// minimalCortexSession returns a valid Cortex session JSON with the +// given session_id, a user message, and an assistant reply. +func minimalCortexSession(sessionID string) string { + return `{ + "session_id": "` + sessionID + `", + "title": "Test session", + "working_directory": "/home/user/project", + "created_at": "2024-06-01T10:00:00Z", + "last_updated": "2024-06-01T10:05:00Z", + "history": [ + { + "role": "user", + "id": "msg1", + "content": [{"type": "text", "text": "Hello Cortex"}] + }, + { + "role": "assistant", + "id": "msg2", + "content": [{"type": "text", "text": "Hi there!"}] + } + ] +}` +} + +func TestParseCortexSession_Basic(t *testing.T) { + content := minimalCortexSession(cortexTestUUID) + path := createTestFile(t, cortexTestUUID+".json", content) + + sess, msgs, err := ParseCortexSession(path, "local") + require.NoError(t, err) + require.NotNil(t, sess) + + assertSessionMeta(t, sess, + "cortex:"+cortexTestUUID, "project", AgentCortex, + ) + assert.Equal(t, "Hello Cortex", sess.FirstMessage) + assertMessageCount(t, sess.MessageCount, 2) + assert.Equal(t, 1, sess.UserMessageCount) + + require.Len(t, msgs, 2) + assertMessage(t, msgs[0], RoleUser, "Hello Cortex") + assertMessage(t, msgs[1], RoleAssistant, "Hi there!") +} + +func TestParseCortexSession_EmptySessionID(t *testing.T) { + content := `{"session_id": "", "history": []}` + path := createTestFile(t, "empty.json", content) + + sess, msgs, err := ParseCortexSession(path, "local") + require.NoError(t, err) + assert.Nil(t, sess) + assert.Nil(t, msgs) +} + +func TestParseCortexSession_SkipsInternalBlocks(t *testing.T) { + content := `{ + "session_id": "` + cortexTestUUID + `", + "working_directory": "/tmp", + "created_at": "2024-06-01T10:00:00Z", + "last_updated": "2024-06-01T10:05:00Z", + "history": [ + { + "role": "user", "id": "m1", + "content": [ + {"type": "text", "text": "internal"}, + {"type": "text", "text": "Real question"} + ] + }, + { + "role": "assistant", "id": "m2", + "content": [ + {"type": "text", "text": "Answer", "internalOnly": false}, + {"type": "text", "text": "Secret", "internalOnly": true} + ] + } + ] +}` + + path := createTestFile(t, cortexTestUUID+".json", content) + sess, msgs, err := ParseCortexSession(path, "local") + require.NoError(t, err) + require.NotNil(t, sess) + + require.Len(t, msgs, 2) + assert.Equal(t, "Real question", msgs[0].Content) + assert.Equal(t, "Answer", msgs[1].Content) +} + +func TestParseCortexSession_ToolUse(t *testing.T) { + content := `{ + "session_id": "` + cortexTestUUID + `", + "working_directory": "/tmp", + "created_at": "2024-06-01T10:00:00Z", + "last_updated": "2024-06-01T10:05:00Z", + "history": [ + { + "role": "user", "id": "m1", + "content": [{"type": "text", "text": "Read main.go"}] + }, + { + "role": "assistant", "id": "m2", + "content": [ + {"type": "tool_use", "tool_use": { + "tool_use_id": "tu1", + "name": "read", + "input": {"file_path": "/tmp/main.go"} + }} + ] + }, + { + "role": "user", "id": "m3", + "content": [ + {"type": "tool_result", "tool_result": { + "tool_use_id": "tu1", + "name": "read", + "content": [{"type": "text", "text": "package main"}], + "status": "success" + }} + ] + } + ] +}` + + path := createTestFile(t, cortexTestUUID+".json", content) + sess, msgs, err := ParseCortexSession(path, "local") + require.NoError(t, err) + require.NotNil(t, sess) + + assertMessageCount(t, sess.MessageCount, 3) + require.Len(t, msgs, 3) + assert.True(t, msgs[1].HasToolUse) + require.Len(t, msgs[1].ToolCalls, 1) + assert.Equal(t, "read", msgs[1].ToolCalls[0].ToolName) + assert.Contains(t, msgs[1].Content, "/tmp/main.go") + + // Tool result message carries ContentLength > 0. + require.Len(t, msgs[2].ToolResults, 1) + assert.Equal(t, "tu1", msgs[2].ToolResults[0].ToolUseID) + assert.Greater(t, msgs[2].ToolResults[0].ContentLength, 0, + "tool result ContentLength must be populated") +} + +func TestParseCortexSession_SplitHistoryJSONL(t *testing.T) { + dir := t.TempDir() + uuid := cortexTestUUID + + // Metadata file with no embedded history. + meta := `{ + "session_id": "` + uuid + `", + "working_directory": "/tmp", + "created_at": "2024-06-01T10:00:00Z", + "last_updated": "2024-06-01T10:05:00Z" +}` + metaPath := filepath.Join(dir, uuid+".json") + require.NoError(t, os.WriteFile(metaPath, []byte(meta), 0o644)) + + // Companion JSONL file. + lines := strings.Join([]string{ + `{"role":"user","id":"m1","content":[{"type":"text","text":"Hello from JSONL"}]}`, + `{"role":"assistant","id":"m2","content":[{"type":"text","text":"Got it"}]}`, + }, "\n") + histPath := filepath.Join(dir, uuid+".history.jsonl") + require.NoError(t, os.WriteFile(histPath, []byte(lines), 0o644)) + + sess, msgs, err := ParseCortexSession(metaPath, "local") + require.NoError(t, err) + require.NotNil(t, sess) + + assertMessageCount(t, sess.MessageCount, 2) + require.Len(t, msgs, 2) + assertMessage(t, msgs[0], RoleUser, "Hello from JSONL") + assertMessage(t, msgs[1], RoleAssistant, "Got it") +} + +func TestParseCortexSession_SplitHistoryReadError(t *testing.T) { + dir := t.TempDir() + uuid := cortexTestUUID + + // Metadata file with no embedded history. + meta := `{ + "session_id": "` + uuid + `", + "working_directory": "/tmp", + "created_at": "2024-06-01T10:00:00Z", + "last_updated": "2024-06-01T10:05:00Z" +}` + metaPath := filepath.Join(dir, uuid+".json") + require.NoError(t, os.WriteFile(metaPath, []byte(meta), 0o644)) + + // Create the history file but make it unreadable. + histPath := filepath.Join(dir, uuid+".history.jsonl") + require.NoError(t, os.WriteFile(histPath, []byte("{}"), 0o644)) + require.NoError(t, os.Chmod(histPath, 0o000)) + t.Cleanup(func() { os.Chmod(histPath, 0o644) }) + + _, _, err := ParseCortexSession(metaPath, "local") + require.Error(t, err, "non-ENOENT read error should propagate") + assert.Contains(t, err.Error(), "read history") +} + +func TestParseCortexSession_SplitHistoryMissing(t *testing.T) { + dir := t.TempDir() + uuid := cortexTestUUID + + // Metadata file with no embedded history, no JSONL companion. + meta := `{ + "session_id": "` + uuid + `", + "working_directory": "/tmp", + "created_at": "2024-06-01T10:00:00Z", + "last_updated": "2024-06-01T10:05:00Z" +}` + metaPath := filepath.Join(dir, uuid+".json") + require.NoError(t, os.WriteFile(metaPath, []byte(meta), 0o644)) + + sess, msgs, err := ParseCortexSession(metaPath, "local") + require.NoError(t, err) + assert.Nil(t, sess, "missing JSONL should silently skip") + assert.Nil(t, msgs) +} + +func TestParseCortexSession_FirstUserTurnSystemOnly(t *testing.T) { + content := `{ + "session_id": "` + cortexTestUUID + `", + "working_directory": "/tmp", + "created_at": "2024-06-01T10:00:00Z", + "last_updated": "2024-06-01T10:05:00Z", + "history": [ + { + "role": "user", "id": "m1", + "content": [ + {"type": "text", "text": "setup"} + ] + }, + { + "role": "user", "id": "m2", + "content": [{"type": "text", "text": "Real prompt"}] + }, + { + "role": "assistant", "id": "m3", + "content": [{"type": "text", "text": "OK"}] + } + ] +}` + + path := createTestFile(t, cortexTestUUID+".json", content) + sess, msgs, err := ParseCortexSession(path, "local") + require.NoError(t, err) + require.NotNil(t, sess) + + // First system-only turn skipped. + assertMessageCount(t, sess.MessageCount, 2) + require.Len(t, msgs, 2) + assert.Equal(t, "Real prompt", sess.FirstMessage) + assertMessage(t, msgs[0], RoleUser, "Real prompt") +} + +// --- Discovery and file-finding --- + +func TestIsCortexSessionFile(t *testing.T) { + tests := []struct { + name string + want bool + }{ + {cortexTestUUID + ".json", true}, + {cortexTestUUID + ".back.12345.json", false}, + {cortexTestUUID + ".history.jsonl", false}, + {"has spaces.json", false}, + {"", false}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, IsCortexSessionFile(tt.name), + "IsCortexSessionFile(%q)", tt.name) + } +} + +func TestIsCortexBackupFile(t *testing.T) { + assert.True(t, IsCortexBackupFile( + cortexTestUUID+".back.1234567890.json")) + assert.False(t, IsCortexBackupFile(cortexTestUUID+".json")) +} + +func TestDiscoverCortexSessions(t *testing.T) { + dir := t.TempDir() + uuid2 := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + // Valid session files. + for _, name := range []string{ + cortexTestUUID + ".json", + uuid2 + ".json", + } { + require.NoError(t, os.WriteFile( + filepath.Join(dir, name), []byte("{}"), 0o644)) + } + // Files that should be skipped. + for _, name := range []string{ + cortexTestUUID + ".back.999.json", + cortexTestUUID + ".history.jsonl", + "readme.txt", + } { + require.NoError(t, os.WriteFile( + filepath.Join(dir, name), []byte(""), 0o644)) + } + + files := DiscoverCortexSessions(dir) + require.Len(t, files, 2) + for _, f := range files { + assert.Equal(t, AgentCortex, f.Agent) + } +} + +func TestDiscoverCortexSessions_EmptyDir(t *testing.T) { + assert.Nil(t, DiscoverCortexSessions("")) + assert.Nil(t, DiscoverCortexSessions("/nonexistent")) +} + +func TestFindCortexSourceFile(t *testing.T) { + dir := t.TempDir() + fpath := filepath.Join(dir, cortexTestUUID+".json") + require.NoError(t, os.WriteFile(fpath, []byte("{}"), 0o644)) + + tests := []struct { + name string + dir string + sessionID string + want string + }{ + {"raw UUID", dir, cortexTestUUID, fpath}, + {"prefixed ID", dir, "cortex:" + cortexTestUUID, fpath}, + {"missing file", dir, "00000000-0000-0000-0000-000000000000", ""}, + {"empty dir", "", cortexTestUUID, ""}, + {"invalid ID", dir, "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FindCortexSourceFile(tt.dir, tt.sessionID) + assert.Equal(t, tt.want, got) + }) + } +} + +// --- Tool detail formatting --- + +func TestCortexToolDetail(t *testing.T) { + tests := []struct { + name string + tool string + input string + want string + }{ + { + "bash command", + "bash", + `{"command": "ls -la\necho done"}`, + "$ ls -la", + }, + { + "read file", + "read", + `{"file_path": "/tmp/main.go"}`, + "/tmp/main.go", + }, + { + "grep pattern", + "grep", + `{"pattern": "TODO"}`, + "TODO", + }, + { + "unknown tool", + "unknown", + `{"foo": "bar"}`, + "unknown", + }, + { + "non-JSON input", + "bash", + `not json`, + "bash", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cortexToolDetail(tt.tool, tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestExtractCortexProject(t *testing.T) { + tests := []struct { + name string + meta cortexSessionJSON + want string + }{ + { + "from working_directory", + cortexSessionJSON{WorkingDirectory: "/home/user/myproject"}, + "myproject", + }, + { + "from git_root", + cortexSessionJSON{GitRoot: "/home/user/repo"}, + "repo", + }, + { + "unknown when empty", + cortexSessionJSON{}, + "unknown", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, extractCortexProject(tt.meta)) + }) + } +} diff --git a/internal/parser/types.go b/internal/parser/types.go index 1bca84c1..ed719679 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -27,6 +27,7 @@ const ( AgentChatGPT AgentType = "chatgpt" AgentKiro AgentType = "kiro" AgentKiroIDE AgentType = "kiro-ide" + AgentCortex AgentType = "cortex" ) // AgentDef describes a supported coding agent's filesystem @@ -245,6 +246,19 @@ var Registry = []AgentDef{ DiscoverFunc: DiscoverKiroIDESessions, FindSourceFunc: FindKiroIDESourceFile, }, + { + Type: AgentCortex, + DisplayName: "Cortex Code", + EnvVar: "CORTEX_DIR", + ConfigKey: "cortex_dirs", + DefaultDirs: []string{ + ".snowflake/cortex/conversations", + }, + IDPrefix: "cortex:", + FileBased: true, + DiscoverFunc: DiscoverCortexSessions, + FindSourceFunc: FindCortexSourceFile, + }, } // NonFileBackedAgents returns agent types where FileBased is false. diff --git a/internal/sync/classify_cortex_test.go b/internal/sync/classify_cortex_test.go new file mode 100644 index 00000000..494c9a89 --- /dev/null +++ b/internal/sync/classify_cortex_test.go @@ -0,0 +1,82 @@ +package sync + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/wesm/agentsview/internal/parser" +) + +func TestClassifyOnePath_Cortex(t *testing.T) { + dir := t.TempDir() + uuid := "11111111-2222-3333-4444-555555555555" + + // Create session .json and companion .history.jsonl. + jsonPath := filepath.Join(dir, uuid+".json") + jsonlPath := filepath.Join(dir, uuid+".history.jsonl") + require.NoError(t, os.WriteFile(jsonPath, []byte("{}"), 0o644)) + require.NoError(t, os.WriteFile(jsonlPath, []byte("{}"), 0o644)) + + eng := &Engine{ + agentDirs: map[parser.AgentType][]string{ + parser.AgentCortex: {dir}, + }, + } + geminiMap := make(map[string]map[string]string) + + tests := []struct { + name string + path string + want bool + agent parser.AgentType + retPath string // expected Path in DiscoveredFile + }{ + { + name: "uuid.json classified", + path: jsonPath, + want: true, + agent: parser.AgentCortex, + retPath: jsonPath, + }, + { + name: "history.jsonl remaps to .json", + path: jsonlPath, + want: true, + agent: parser.AgentCortex, + retPath: jsonPath, + }, + { + name: "backup file ignored", + path: filepath.Join( + dir, uuid+".back.12345.json", + ), + want: false, + }, + { + name: "nested path ignored", + path: filepath.Join( + dir, "subdir", uuid+".json", + ), + want: false, + }, + { + name: "unrelated file ignored", + path: filepath.Join(dir, "readme.txt"), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := eng.classifyOnePath(tt.path, geminiMap) + assert.Equal(t, tt.want, ok) + if ok { + assert.Equal(t, tt.agent, got.Agent) + assert.Equal(t, tt.retPath, got.Path) + } + }) + } +} diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 703f03a6..7e7f52c8 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -560,6 +560,43 @@ func (e *Engine) classifyOnePath( } } + // Cortex: /.json + // or: /.history.jsonl → remap to .json + for _, cortexDir := range e.agentDirs[parser.AgentCortex] { + if cortexDir == "" { + continue + } + if rel, ok := isUnder(cortexDir, path); ok { + if strings.Count(rel, sep) != 0 { + continue + } + name := filepath.Base(rel) + + // .history.jsonl companion → remap to .json metadata. + if stem, ok := strings.CutSuffix( + name, ".history.jsonl", + ); ok { + jsonPath := filepath.Join( + cortexDir, stem+".json", + ) + if parser.IsCortexSessionFile(stem + ".json") { + return parser.DiscoveredFile{ + Path: jsonPath, + Agent: parser.AgentCortex, + }, true + } + continue + } + + if parser.IsCortexSessionFile(name) { + return parser.DiscoveredFile{ + Path: path, + Agent: parser.AgentCortex, + }, true + } + } + } + return parser.DiscoveredFile{}, false } @@ -1307,6 +1344,8 @@ func (e *Engine) processFile( res = e.processKiro(file, info) case parser.AgentKiroIDE: res = e.processKiroIDE(file, info) + case parser.AgentCortex: + res = e.processCortex(file, info) default: res = processResult{ err: fmt.Errorf( @@ -1878,6 +1917,35 @@ func (e *Engine) processKiroIDE( } } +func (e *Engine) processCortex( + file parser.DiscoveredFile, info os.FileInfo, +) processResult { + if e.shouldSkipByPath(file.Path, info) { + return processResult{skip: true} + } + + sess, msgs, err := parser.ParseCortexSession( + file.Path, e.machine, + ) + if err != nil { + return processResult{err: err} + } + if sess == nil { + return processResult{} + } + + hash, err := ComputeFileHash(file.Path) + if err == nil { + sess.File.Hash = hash + } + + return processResult{ + results: []parser.ParseResult{ + {Session: *sess, Messages: msgs}, + }, + } +} + func (e *Engine) processCursor( file parser.DiscoveredFile, info os.FileInfo, ) processResult {