package service import ( "bufio" "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "time" ) type ConversationService interface { GetConversations() (map[string][]*Conversation, error) GetConversation(projectPath, sessionID string) (*Conversation, error) GetConversationsByProject(projectPath string) ([]*Conversation, error) } type conversationService struct { claudeProjectsPath string } func NewConversationService() ConversationService { homeDir, _ := os.UserHomeDir() return &conversationService{ claudeProjectsPath: filepath.Join(homeDir, ".claude", "projects"), } } // ConversationMessage represents a single message in a Claude conversation type ConversationMessage struct { ParentUUID *string `json:"parentUuid"` IsSidechain bool `json:"isSidechain"` UserType string `json:"userType"` CWD string `json:"cwd"` SessionID string `json:"sessionId"` Version string `json:"version"` Type string `json:"type"` Message json.RawMessage `json:"message"` UUID string `json:"uuid"` Timestamp string `json:"timestamp"` ParsedTime time.Time `json:"-"` } // Conversation represents a complete conversation session type Conversation struct { SessionID string `json:"sessionId"` ProjectPath string `json:"projectPath"` ProjectName string `json:"projectName"` Model string `json:"model,omitempty"` Messages []*ConversationMessage `json:"messages"` StartTime time.Time `json:"startTime"` EndTime time.Time `json:"endTime"` MessageCount int `json:"messageCount"` FileModTime time.Time `json:"-"` // Used for sorting, not exported } // GetConversations returns all conversations organized by project func (cs *conversationService) GetConversations() (map[string][]*Conversation, error) { conversations := make(map[string][]*Conversation) var parseErrors []string rootPath, err := cs.projectsRoot() if err != nil { return nil, fmt.Errorf("failed to resolve claude projects root: %w", err) } err = filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error { if err != nil { // Log but don't fail the entire walk parseErrors = append(parseErrors, fmt.Sprintf("Error accessing %s: %v", path, err)) return nil } if !strings.HasSuffix(path, ".jsonl") { return nil } // Reject symlinked files or paths that escape the projects root. resolvedPath, err := cs.resolveExistingPathWithinProjectsRoot(path) if err != nil { parseErrors = append(parseErrors, fmt.Sprintf("Skipping %s: %v", path, err)) return nil } // Get the project path relative to the resolved root. projectDir := filepath.Dir(resolvedPath) projectRelPath, err := filepath.Rel(rootPath, projectDir) if err != nil { parseErrors = append(parseErrors, fmt.Sprintf("Skipping %s: %v", path, err)) return nil } // Skip files directly in the projects directory if projectRelPath == "." || projectRelPath == "" { return nil } conv, err := cs.parseConversationFile(resolvedPath, projectRelPath) if err != nil { // Log parsing errors but continue processing other files parseErrors = append(parseErrors, fmt.Sprintf("Failed to parse %s: %v", path, err)) return nil } if conv != nil { // Include conversations even if they have no messages (edge case) conversations[projectRelPath] = append(conversations[projectRelPath], conv) } return nil }) if err != nil { return nil, fmt.Errorf("failed to walk claude projects: %w", err) } // Some parsing errors may have occurred but were handled // Sort conversations within each project by file modification time (newest first) for project := range conversations { sort.Slice(conversations[project], func(i, j int) bool { return conversations[project][i].FileModTime.After(conversations[project][j].FileModTime) }) } return conversations, nil } // GetConversation returns a specific conversation by project and session ID func (cs *conversationService) GetConversation(projectPath, sessionID string) (*Conversation, error) { filePath, resolvedProjectPath, err := cs.resolveConversationFile(projectPath, sessionID) if err != nil { return nil, fmt.Errorf("failed to resolve conversation path: %w", err) } conv, err := cs.parseConversationFile(filePath, resolvedProjectPath) if err != nil { return nil, fmt.Errorf("failed to parse conversation: %w", err) } return conv, nil } // GetConversationsByProject returns all conversations for a specific project func (cs *conversationService) GetConversationsByProject(projectPath string) ([]*Conversation, error) { var conversations []*Conversation projectDir, resolvedProjectPath, err := cs.resolveProjectDir(projectPath) if err != nil { return nil, fmt.Errorf("failed to resolve project path: %w", err) } files, err := os.ReadDir(projectDir) if err != nil { return nil, fmt.Errorf("failed to read project directory: %w", err) } for _, file := range files { if !strings.HasSuffix(file.Name(), ".jsonl") { continue } filePath := filepath.Join(projectDir, file.Name()) conv, err := cs.parseConversationFile(filePath, resolvedProjectPath) if err != nil { continue } if conv != nil && len(conv.Messages) > 0 { conversations = append(conversations, conv) } } // Sort by file modification time (newest first) sort.Slice(conversations, func(i, j int) bool { return conversations[i].FileModTime.After(conversations[j].FileModTime) }) return conversations, nil } func (cs *conversationService) projectsRoot() (string, error) { root, err := filepath.Abs(cs.claudeProjectsPath) if err != nil { return "", fmt.Errorf("failed to make projects root absolute: %w", err) } resolvedRoot, err := filepath.EvalSymlinks(root) if err != nil { if os.IsNotExist(err) { return root, nil } return "", fmt.Errorf("failed to resolve projects root symlinks: %w", err) } return resolvedRoot, nil } func (cs *conversationService) resolveProjectDir(projectPath string) (string, string, error) { cleanedProjectPath, err := cleanRelativeConversationPath(projectPath) if err != nil { return "", "", err } rootPath, err := cs.projectsRoot() if err != nil { return "", "", err } candidate := filepath.Join(rootPath, cleanedProjectPath) resolvedCandidate, err := cs.resolveExistingPathWithinProjectsRoot(candidate) if err != nil { return "", "", err } return resolvedCandidate, cleanedProjectPath, nil } func (cs *conversationService) resolveConversationFile(projectPath, sessionID string) (string, string, error) { if sessionID == "" { return "", "", fmt.Errorf("session ID is required") } if sessionID != filepath.Base(sessionID) || sessionID == "." || sessionID == ".." { return "", "", fmt.Errorf("invalid session ID: %s", sessionID) } projectDir, cleanedProjectPath, err := cs.resolveProjectDir(projectPath) if err != nil { return "", "", err } candidate := filepath.Join(projectDir, sessionID+".jsonl") resolvedCandidate, err := cs.resolveExistingPathWithinProjectsRoot(candidate) if err != nil { return "", "", err } return resolvedCandidate, cleanedProjectPath, nil } func (cs *conversationService) resolveExistingPathWithinProjectsRoot(path string) (string, error) { rootPath, err := cs.projectsRoot() if err != nil { return "", err } absolutePath, err := filepath.Abs(path) if err != nil { return "", fmt.Errorf("failed to make path absolute: %w", err) } normalizedPath := filepath.Clean(absolutePath) if !pathWithinRoot(normalizedPath, rootPath) { return "", fmt.Errorf("path escapes projects root: %s", path) } resolvedPath, err := filepath.EvalSymlinks(normalizedPath) if err != nil { return "", fmt.Errorf("failed to resolve path symlinks: %w", err) } if !pathWithinRoot(resolvedPath, rootPath) { return "", fmt.Errorf("path escapes projects root after symlink resolution: %s", path) } return resolvedPath, nil } func cleanRelativeConversationPath(p string) (string, error) { if p == "" { return "", fmt.Errorf("path is required") } if filepath.IsAbs(p) { return "", fmt.Errorf("absolute paths are not allowed: %s", p) } cleaned := filepath.Clean(p) if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(os.PathSeparator)) { return "", fmt.Errorf("path escapes projects root: %s", p) } return cleaned, nil } func pathWithinRoot(candidatePath, rootPath string) bool { relPath, err := filepath.Rel(rootPath, candidatePath) if err != nil { return false } if relPath == "." { return true } return relPath != ".." && !strings.HasPrefix(relPath, ".."+string(os.PathSeparator)) } // parseConversationFile reads and parses a JSONL conversation file func (cs *conversationService) parseConversationFile(filePath, projectPath string) (*Conversation, error) { // Get file info for modification time fileInfo, err := os.Stat(filePath) if err != nil { return nil, fmt.Errorf("failed to stat file: %w", err) } file, err := os.Open(filePath) if err != nil { return nil, fmt.Errorf("failed to open file: %w", err) } defer file.Close() var messages []*ConversationMessage var parseErrors int lineNum := 0 conversationModel := "" scanner := bufio.NewScanner(file) // Increase buffer size for large messages const maxScanTokenSize = 10 * 1024 * 1024 // 10MB buf := make([]byte, maxScanTokenSize) scanner.Buffer(buf, maxScanTokenSize) for scanner.Scan() { lineNum++ line := scanner.Bytes() // Skip empty lines if len(line) == 0 { continue } var msg ConversationMessage if err := json.Unmarshal(line, &msg); err != nil { parseErrors++ // Log only first few errors to avoid spam if parseErrors <= 3 { // Skip malformed line } continue } // Parse timestamp if msg.Timestamp != "" { parsedTime, err := time.Parse(time.RFC3339, msg.Timestamp) if err != nil { // Try alternative timestamp formats parsedTime, err = time.Parse(time.RFC3339Nano, msg.Timestamp) if err != nil { // Skip message with invalid timestamp } } msg.ParsedTime = parsedTime } messages = append(messages, &msg) // Claude conversation JSONL records the assistant model inside the nested message object. // Track the latest model we see so the list view can filter by the active model tier. var messageMeta struct { Model string `json:"model"` } if err := json.Unmarshal(msg.Message, &messageMeta); err == nil && messageMeta.Model != "" { conversationModel = messageMeta.Model } } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("scanner error: %w", err) } if parseErrors > 3 { // Some lines failed to parse but were skipped } // Return empty conversation if no messages (caller can decide what to do) if len(messages) == 0 { // Extract session ID from filename sessionID := filepath.Base(filePath) sessionID = strings.TrimSuffix(sessionID, ".jsonl") // Use the full project path as provided projectName := projectPath // If it looks like a file path, extract the last component if strings.Contains(projectPath, "-") { // This handles cases like "-Users-seifghazi-dev-llm-proxy" projectName = projectPath } return &Conversation{ SessionID: sessionID, ProjectPath: projectPath, ProjectName: projectName, Model: conversationModel, Messages: messages, StartTime: time.Time{}, EndTime: time.Time{}, MessageCount: 0, FileModTime: fileInfo.ModTime(), }, nil } // Sort messages by timestamp sort.Slice(messages, func(i, j int) bool { return messages[i].ParsedTime.Before(messages[j].ParsedTime) }) // Extract session ID from filename sessionID := filepath.Base(filePath) sessionID = strings.TrimSuffix(sessionID, ".jsonl") // Use the full project path as provided projectName := projectPath // Find first and last valid timestamps var startTime, endTime time.Time for _, msg := range messages { if !msg.ParsedTime.IsZero() { if startTime.IsZero() || msg.ParsedTime.Before(startTime) { startTime = msg.ParsedTime } if endTime.IsZero() || msg.ParsedTime.After(endTime) { endTime = msg.ParsedTime } } } // If no valid timestamps found, use file modification time if startTime.IsZero() { startTime = fileInfo.ModTime() endTime = fileInfo.ModTime() } return &Conversation{ SessionID: sessionID, ProjectPath: projectPath, ProjectName: projectName, Model: conversationModel, Messages: messages, StartTime: startTime, EndTime: endTime, MessageCount: len(messages), FileModTime: fileInfo.ModTime(), }, nil }