From b1eabeb93da874030619aa754f031c4b4e790bc9 Mon Sep 17 00:00:00 2001 From: Andy Owens Date: Sun, 5 Apr 2026 21:53:18 -0400 Subject: [PATCH 1/5] feat: add Cortex Code session support Add complete parser and sync support for Snowflake Cortex Code sessions: - Add AgentCortex type and registry entry to internal/parser/types.go - Implement ParseCortexSession parser in internal/parser/cortex.go: - Handle split-file format (.json + .history.jsonl) - Parse messages with tool calls and tool results - Filter internal blocks and system reminders - Support backup file detection and skipping - Extract session metadata and timestamps - Add processCortex function to sync engine (internal/sync/engine.go) - Wire up Cortex agent in sync engine switch statement - Add Cortex to frontend agent definitions with cyan color - Update README with Cortex directory and env var Session discovery path: ~/.snowflake/cortex/conversations/ Environment variable: CORTEX_DIR Co-Authored-By: Claude Sonnet 4.5 --- README.md | 1 + frontend/src/lib/utils/agents.ts | 3 +- internal/parser/cortex.go | 569 +++++++++++++++++++++++++++++++ internal/parser/types.go | 14 + internal/sync/engine.go | 31 ++ 5 files changed, 617 insertions(+), 1 deletion(-) create mode 100644 internal/parser/cortex.go 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/parser/cortex.go b/internal/parser/cortex.go new file mode 100644 index 00000000..baa80797 --- /dev/null +++ b/internal/parser/cortex.go @@ -0,0 +1,569 @@ +package parser + +import ( + "encoding/json" + "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), + }) + } + } + + // 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 there's no history and no JSONL either, skip silently. + return nil, nil, nil + } + 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++ + } + } + + // Pick file size: prefer JSONL if it exists (it's authoritative). + fileSize := info.Size() + fileMtime := info.ModTime().UnixNano() + filePath := path + if ji, err := os.Stat(histFile); err == nil { + fileSize = ji.Size() + fileMtime = ji.ModTime().UnixNano() + filePath = histFile + } + + 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: filePath, + Size: fileSize, + Mtime: fileMtime, + }, + } + + 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.Split(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 its raw UUID +// (without the "cortex:" prefix). Returns the path to the .json metadata +// file if found, otherwise "". +func FindCortexSourceFile( + conversationsDir, sessionID string, +) string { + if conversationsDir == "" || !IsValidSessionID(sessionID) { + return "" + } + // Strip "cortex:" prefix if caller forgot to strip it. + sessionID = strings.TrimPrefix(sessionID, "cortex:") + + candidate := filepath.Join(conversationsDir, sessionID+".json") + if _, err := os.Stat(candidate); err == nil { + return candidate + } + return "" +} 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/engine.go b/internal/sync/engine.go index 703f03a6..38f0488e 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -1307,6 +1307,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 +1880,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 { From 5d1e01914b0ef60839f91811f28520969a010147 Mon Sep 17 00:00:00 2001 From: Andy Owens Date: Sun, 5 Apr 2026 22:15:39 -0400 Subject: [PATCH 2/5] fix: use discovered .json file for Cortex File metadata Keep ParsedSession.File aligned with the discovered .json file instead of the .history.jsonl companion. The sync engine's shouldSkipByPath checks the discovered file against stored metadata, so they must match for caching to work. This fixes a High-severity issue where split Cortex sessions would bypass sync caching and be reparsed on every sync tick. Fixes roborev feedback on PR #284. --- internal/parser/cortex.go | 57 +++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/internal/parser/cortex.go b/internal/parser/cortex.go index baa80797..7b534734 100644 --- a/internal/parser/cortex.go +++ b/internal/parser/cortex.go @@ -22,18 +22,18 @@ var cortexBackupRe = regexp.MustCompile( // 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"` + 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. @@ -48,13 +48,13 @@ type cortexMessage struct { // 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"` + 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. @@ -418,16 +418,9 @@ func ParseCortexSession( } } - // Pick file size: prefer JSONL if it exists (it's authoritative). - fileSize := info.Size() - fileMtime := info.ModTime().UnixNano() - filePath := path - if ji, err := os.Stat(histFile); err == nil { - fileSize = ji.Size() - fileMtime = ji.ModTime().UnixNano() - filePath = histFile - } - + // 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, @@ -441,9 +434,9 @@ func ParseCortexSession( MessageCount: len(msgs), UserMessageCount: userCount, File: FileInfo{ - Path: filePath, - Size: fileSize, - Mtime: fileMtime, + Path: path, + Size: info.Size(), + Mtime: info.ModTime().UnixNano(), }, } From 1a948670dd4a1788716044c46bd11ca686e22bca Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 8 Apr 2026 14:56:46 -0500 Subject: [PATCH 3/5] fix: cortex prefix validation order and silent I/O error swallowing Strip the "cortex:" prefix before calling IsValidSessionID in FindCortexSourceFile so prefixed IDs are accepted. Propagate non-ENOENT errors from reading .history.jsonl instead of treating all failures as "session missing". Add comprehensive test suite for the Cortex parser. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/parser/cortex.go | 22 +- internal/parser/cortex_test.go | 423 +++++++++++++++++++++++++++++++++ 2 files changed, 437 insertions(+), 8 deletions(-) create mode 100644 internal/parser/cortex_test.go diff --git a/internal/parser/cortex.go b/internal/parser/cortex.go index 7b534734..3e71e82e 100644 --- a/internal/parser/cortex.go +++ b/internal/parser/cortex.go @@ -2,6 +2,7 @@ package parser import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -366,8 +367,12 @@ func ParseCortexSession( if len(history) == 0 { loaded, err := readCortexHistoryJSONL(histFile) if err != nil { - // If there's no history and no JSONL either, skip silently. - return nil, nil, nil + if errors.Is(err, os.ErrNotExist) { + return nil, nil, nil + } + return nil, nil, fmt.Errorf( + "read history %s: %w", histFile, err, + ) } history = loaded } @@ -454,7 +459,7 @@ func readCortexHistoryJSONL( } var msgs []cortexMessage - for _, line := range strings.Split(string(data), "\n") { + for line := range strings.SplitSeq(string(data), "\n") { line = strings.TrimSpace(line) if line == "" { continue @@ -542,17 +547,18 @@ func DiscoverCortexSessions( return files } -// FindCortexSourceFile locates a Cortex session file by its raw UUID -// (without the "cortex:" prefix). Returns the path to the .json metadata -// file if found, otherwise "". +// 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 "" } - // Strip "cortex:" prefix if caller forgot to strip it. - sessionID = strings.TrimPrefix(sessionID, "cortex:") candidate := filepath.Join(conversationsDir, sessionID+".json") if _, err := os.Stat(candidate); err == nil { diff --git a/internal/parser/cortex_test.go b/internal/parser/cortex_test.go new file mode 100644 index 00000000..31767ffc --- /dev/null +++ b/internal/parser/cortex_test.go @@ -0,0 +1,423 @@ +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") +} + +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)) + }) + } +} From 94aa44949f4b82042a5af9cbdb50f0d8a32bb837 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 8 Apr 2026 16:29:18 -0500 Subject: [PATCH 4/5] style: fix alignment in ValidAgents map Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/insight/generate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From df9f864fd16e5582b6dfc68ffe50769aa9d94580 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 8 Apr 2026 17:03:26 -0500 Subject: [PATCH 5/5] fix: add Cortex watcher classification and tool result ContentLength classifyOnePath had no Cortex branch, so watcher-driven SyncPaths ignored Cortex files entirely. Add classification for .json and remap .history.jsonl events back to the metadata .json file. Populate ParsedToolResult.ContentLength for Cortex tool results so analytics and fingerprinting work correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/parser/cortex.go | 5 +- internal/parser/cortex_test.go | 6 ++ internal/sync/classify_cortex_test.go | 82 +++++++++++++++++++++++++++ internal/sync/engine.go | 37 ++++++++++++ 4 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 internal/sync/classify_cortex_test.go diff --git a/internal/parser/cortex.go b/internal/parser/cortex.go index 3e71e82e..7e139261 100644 --- a/internal/parser/cortex.go +++ b/internal/parser/cortex.go @@ -190,8 +190,9 @@ func parseCortexMessages( tr := b.ToolResult raw, _ := json.Marshal(tr.Content) toolResults = append(toolResults, ParsedToolResult{ - ToolUseID: tr.ToolUseID, - ContentRaw: string(raw), + ToolUseID: tr.ToolUseID, + ContentRaw: string(raw), + ContentLength: len(raw), }) } } diff --git a/internal/parser/cortex_test.go b/internal/parser/cortex_test.go index 31767ffc..ee92199c 100644 --- a/internal/parser/cortex_test.go +++ b/internal/parser/cortex_test.go @@ -146,6 +146,12 @@ func TestParseCortexSession_ToolUse(t *testing.T) { 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) { 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 38f0488e..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 }