diff options
| author | Fuwn <[email protected]> | 2026-01-30 07:32:54 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-30 07:32:54 +0000 |
| commit | 5f3eba126201e4d679539aa2517bf6a132f29cd0 (patch) | |
| tree | 961afe2ae1d6ca0f23bdbb30930e37bc88884146 /internal/claude | |
| download | faustus-5f3eba126201e4d679539aa2517bf6a132f29cd0.tar.xz faustus-5f3eba126201e4d679539aa2517bf6a132f29cd0.zip | |
feat: Initial commit
Diffstat (limited to 'internal/claude')
| -rw-r--r-- | internal/claude/preview.go | 177 | ||||
| -rw-r--r-- | internal/claude/search.go | 180 | ||||
| -rw-r--r-- | internal/claude/session.go | 360 |
3 files changed, 717 insertions, 0 deletions
diff --git a/internal/claude/preview.go b/internal/claude/preview.go new file mode 100644 index 0000000..1ea08c1 --- /dev/null +++ b/internal/claude/preview.go @@ -0,0 +1,177 @@ +package claude + +import ( + "bufio" + "encoding/json" + "os" + "strings" +) + +type RawMessage struct { + Type string `json:"type"` + Message json.RawMessage `json:"message"` +} + +type UserMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type AssistantMessage struct { + Role string `json:"role"` + Content []ContentBlock `json:"content"` +} + +type ContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Thinking string `json:"thinking,omitempty"` + Name string `json:"name,omitempty"` + Input any `json:"input,omitempty"` +} + +type PreviewContent struct { + Messages []PreviewMessage + Error string +} + +type PreviewMessage struct { + Role string + Content string +} + +func LoadSessionPreview(session *Session, maxMessages int) PreviewContent { + if session == nil || session.FullPath == "" { + return PreviewContent{Error: "No session selected"} + } + + file, openError := os.Open(session.FullPath) + + if openError != nil { + return PreviewContent{Error: "Could not open session file"} + } + + defer func() { _ = file.Close() }() + + var messages []PreviewMessage + + scanner := bufio.NewScanner(file) + scanBuffer := make([]byte, 0, 64*1024) + + scanner.Buffer(scanBuffer, 10*1024*1024) + + for scanner.Scan() { + line := scanner.Text() + + if line == "" { + continue + } + + var rawMessage RawMessage + + if unmarshalError := json.Unmarshal([]byte(line), &rawMessage); unmarshalError != nil { + continue + } + + parsedMessages := parseRawMessage(rawMessage) + + messages = append(messages, parsedMessages...) + } + + if len(messages) > maxMessages { + messages = messages[len(messages)-maxMessages:] + } + + if len(messages) == 0 { + return PreviewContent{Error: "No messages in session"} + } + + return PreviewContent{Messages: messages} +} + +func parseRawMessage(rawMessage RawMessage) []PreviewMessage { + var result []PreviewMessage + + switch rawMessage.Type { + case "user": + var userMessage UserMessage + + if unmarshalError := json.Unmarshal(rawMessage.Message, &userMessage); unmarshalError != nil { + return nil + } + + content := userMessage.Content + + if len(content) > 500 { + content = content[:500] + " …" + } + + if content != "" { + result = append(result, PreviewMessage{Role: "user", Content: content}) + } + + case "assistant": + var assistantMessage AssistantMessage + + if unmarshalError := json.Unmarshal(rawMessage.Message, &assistantMessage); unmarshalError != nil { + return nil + } + + for _, contentBlock := range assistantMessage.Content { + switch contentBlock.Type { + case "text": + text := contentBlock.Text + + if len(text) > 500 { + text = text[:500] + " …" + } + + if text != "" { + result = append(result, PreviewMessage{Role: "assistant", Content: text}) + } + + case "tool_use": + toolInfo := contentBlock.Name + + if toolInfo != "" { + if inputMap, isMap := contentBlock.Input.(map[string]interface{}); isMap { + if command, exists := inputMap["command"]; exists { + if commandString, isString := command.(string); isString { + if len(commandString) > 60 { + commandString = commandString[:60] + " …" + } + + toolInfo += ": " + commandString + } + } else if pattern, exists := inputMap["pattern"]; exists { + if patternString, isString := pattern.(string); isString { + toolInfo += ": " + patternString + } + } else if filePath, exists := inputMap["file_path"]; exists { + if filePathString, isString := filePath.(string); isString { + pathParts := strings.Split(filePathString, "/") + + toolInfo += ": " + pathParts[len(pathParts)-1] + } + } + } + + result = append(result, PreviewMessage{Role: "tool", Content: toolInfo}) + } + + case "thinking": + thinking := contentBlock.Thinking + + if len(thinking) > 200 { + thinking = thinking[:200] + " …" + } + + if thinking != "" { + result = append(result, PreviewMessage{Role: "thinking", Content: thinking}) + } + } + } + } + + return result +} diff --git a/internal/claude/search.go b/internal/claude/search.go new file mode 100644 index 0000000..c79f871 --- /dev/null +++ b/internal/claude/search.go @@ -0,0 +1,180 @@ +package claude + +import ( + "bufio" + "encoding/json" + "os" + "strings" +) + +type SearchResult struct { + Session *Session + MessageIndex int + Role string + Content string + MatchPosition int +} + +func SearchAllSessions(sessions []Session, query string) []SearchResult { + if query == "" { + return nil + } + + query = strings.ToLower(query) + + var results []SearchResult + + for sessionIndex := range sessions { + session := &sessions[sessionIndex] + matches := searchSession(session, query) + + results = append(results, matches...) + } + + return results +} + +func searchSession(session *Session, query string) []SearchResult { + file, openError := os.Open(session.FullPath) + + if openError != nil { + return nil + } + + defer func() { _ = file.Close() }() + + var results []SearchResult + + scanner := bufio.NewScanner(file) + scanBuffer := make([]byte, 0, 64*1024) + + scanner.Buffer(scanBuffer, 10*1024*1024) + + messageIndex := 0 + + for scanner.Scan() { + line := scanner.Text() + + if line == "" { + continue + } + + var rawMessage RawMessage + + if unmarshalError := json.Unmarshal([]byte(line), &rawMessage); unmarshalError != nil { + continue + } + + matches := searchRawMessage(session, &rawMessage, query, messageIndex) + + results = append(results, matches...) + + if rawMessage.Type == "user" || rawMessage.Type == "assistant" { + messageIndex += 1 + } + } + + return results +} + +func searchRawMessage(session *Session, rawMessage *RawMessage, query string, messageIndex int) []SearchResult { + var results []SearchResult + + switch rawMessage.Type { + case "user": + var userMessage UserMessage + + if unmarshalError := json.Unmarshal(rawMessage.Message, &userMessage); unmarshalError != nil { + return nil + } + + contentLowercase := strings.ToLower(userMessage.Content) + + if matchPosition := strings.Index(contentLowercase, query); matchPosition != -1 { + content := matchContext(userMessage.Content, matchPosition, len(query)) + + results = append(results, SearchResult{ + Session: session, + MessageIndex: messageIndex, + Role: "user", + Content: content, + MatchPosition: matchPosition, + }) + } + + case "assistant": + var assistantMessage AssistantMessage + + if unmarshalError := json.Unmarshal(rawMessage.Message, &assistantMessage); unmarshalError != nil { + return nil + } + + for _, contentBlock := range assistantMessage.Content { + if contentBlock.Type == "text" { + textLowercase := strings.ToLower(contentBlock.Text) + + if matchPosition := strings.Index(textLowercase, query); matchPosition != -1 { + content := matchContext(contentBlock.Text, matchPosition, len(query)) + + results = append(results, SearchResult{ + Session: session, + MessageIndex: messageIndex, + Role: "assistant", + Content: content, + MatchPosition: matchPosition, + }) + } + } + } + } + + return results +} + +func matchContext(text string, matchPosition, matchLength int) string { + const contextLength = 100 + + start := matchPosition - contextLength/2 + + if start < 0 { + start = 0 + } + + end := matchPosition + matchLength + contextLength/2 + + if end > len(text) { + end = len(text) + } + + result := text[start:end] + result = strings.ReplaceAll(result, "\n", " ") + result = strings.Join(strings.Fields(result), " ") + + if start > 0 { + result = "… " + result + } + + if end < len(text) { + result = result + " …" + } + + return result +} + +func SearchPreview(preview *PreviewContent, query string) []int { + if preview == nil || query == "" { + return nil + } + + query = strings.ToLower(query) + + var matches []int + + for messageIndex, previewMessage := range preview.Messages { + if strings.Contains(strings.ToLower(previewMessage.Content), query) { + matches = append(matches, messageIndex) + } + } + + return matches +} diff --git a/internal/claude/session.go b/internal/claude/session.go new file mode 100644 index 0000000..7e7b311 --- /dev/null +++ b/internal/claude/session.go @@ -0,0 +1,360 @@ +package claude + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +type Session struct { + SessionID string `json:"sessionId"` + FullPath string `json:"fullPath"` + FirstPrompt string `json:"firstPrompt"` + Summary string `json:"summary"` + MessageCount int `json:"messageCount"` + Created time.Time `json:"created"` + Modified time.Time `json:"modified"` + GitBranch string `json:"gitBranch"` + ProjectPath string `json:"projectPath"` + IsSidechain bool `json:"isSidechain"` + + ProjectName string `json:"-"` + InTrash bool `json:"-"` +} + +type SessionIndex struct { + Version int `json:"version"` + Entries []Session `json:"entries"` + OriginalPath string `json:"originalPath"` +} + +func ClaudeDir() string { + homeDirectory, _ := os.UserHomeDir() + + return filepath.Join(homeDirectory, ".claude") +} + +func ProjectsDir() string { + return filepath.Join(ClaudeDir(), "projects") +} + +func TrashDir() string { + return filepath.Join(ClaudeDir(), "faustus-trash") +} + +func EnsureTrashDir() error { + return os.MkdirAll(TrashDir(), 0o755) +} + +func LoadAllSessions() ([]Session, error) { + var allSessions []Session + + projectsDirectory := ProjectsDir() + directoryEntries, readError := os.ReadDir(projectsDirectory) + + if readError != nil { + return nil, readError + } + + for _, directoryEntry := range directoryEntries { + if !directoryEntry.IsDir() { + continue + } + + projectDirectory := filepath.Join(projectsDirectory, directoryEntry.Name()) + indexPath := filepath.Join(projectDirectory, "sessions-index.json") + sessions, loadError := loadSessionsFromIndex(indexPath, directoryEntry.Name(), false) + + if loadError != nil { + continue + } + + allSessions = append(allSessions, sessions...) + } + + trashDirectory := TrashDir() + + if _, statError := os.Stat(trashDirectory); statError == nil { + trashEntries, readError := os.ReadDir(trashDirectory) + + if readError == nil { + for _, directoryEntry := range trashEntries { + if !directoryEntry.IsDir() { + continue + } + + projectDirectory := filepath.Join(trashDirectory, directoryEntry.Name()) + indexPath := filepath.Join(projectDirectory, "sessions-index.json") + sessions, loadError := loadSessionsFromIndex(indexPath, directoryEntry.Name(), true) + + if loadError != nil { + continue + } + + allSessions = append(allSessions, sessions...) + } + } + } + + sort.Slice(allSessions, func(first, second int) bool { + return allSessions[first].Modified.After(allSessions[second].Modified) + }) + + return allSessions, nil +} + +func loadSessionsFromIndex(indexPath, projectDirectoryName string, inTrash bool) ([]Session, error) { + fileData, readError := os.ReadFile(indexPath) + + if readError != nil { + return nil, readError + } + + var sessionIndex SessionIndex + + if unmarshalError := json.Unmarshal(fileData, &sessionIndex); unmarshalError != nil { + return nil, unmarshalError + } + + projectName := deriveProjectName(projectDirectoryName) + + for entryIndex := range sessionIndex.Entries { + sessionIndex.Entries[entryIndex].ProjectName = projectName + sessionIndex.Entries[entryIndex].InTrash = inTrash + + if inTrash { + sessionIndex.Entries[entryIndex].FullPath = filepath.Join(TrashDir(), projectDirectoryName, + sessionIndex.Entries[entryIndex].SessionID+".jsonl") + } + } + + return sessionIndex.Entries, nil +} + +func deriveProjectName(directoryName string) string { + parts := strings.Split(directoryName, "-") + + if len(parts) > 0 { + for partIndex := len(parts) - 1; partIndex >= 0; partIndex-- { + if parts[partIndex] != "" { + if partIndex > 0 && len(parts[partIndex-1]) > 0 { + return parts[partIndex-1] + "/" + parts[partIndex] + } + + return parts[partIndex] + } + } + } + + return directoryName +} + +func ProjectDir(session *Session) string { + return filepath.Dir(session.FullPath) +} + +func MoveToTrash(session *Session) error { + if session.InTrash { + return nil + } + + if ensureError := EnsureTrashDir(); ensureError != nil { + return ensureError + } + + sourceProjectDirectory := ProjectDir(session) + projectDirectoryName := filepath.Base(sourceProjectDirectory) + destinationProjectDirectory := filepath.Join(TrashDir(), projectDirectoryName) + + if mkdirError := os.MkdirAll(destinationProjectDirectory, 0o755); mkdirError != nil { + return mkdirError + } + + sourceFile := session.FullPath + destinationFile := filepath.Join(destinationProjectDirectory, session.SessionID+".jsonl") + + if renameError := os.Rename(sourceFile, destinationFile); renameError != nil { + return renameError + } + + sourceAssociatedDirectory := filepath.Join(sourceProjectDirectory, session.SessionID) + + if _, statError := os.Stat(sourceAssociatedDirectory); statError == nil { + destinationAssociatedDirectory := filepath.Join(destinationProjectDirectory, session.SessionID) + + _ = os.Rename(sourceAssociatedDirectory, destinationAssociatedDirectory) + } + + if removeError := removeFromIndex(sourceProjectDirectory, session.SessionID); removeError != nil { + return removeError + } + + session.InTrash = true + session.FullPath = destinationFile + + return addToIndex(destinationProjectDirectory, session) +} + +func RestoreFromTrash(session *Session) error { + if !session.InTrash { + return nil + } + + sourceProjectDirectory := ProjectDir(session) + projectDirectoryName := filepath.Base(sourceProjectDirectory) + destinationProjectDirectory := filepath.Join(ProjectsDir(), projectDirectoryName) + sourceFile := session.FullPath + destinationFile := filepath.Join(destinationProjectDirectory, session.SessionID+".jsonl") + + if renameError := os.Rename(sourceFile, destinationFile); renameError != nil { + return renameError + } + + sourceAssociatedDirectory := filepath.Join(sourceProjectDirectory, session.SessionID) + + if _, statError := os.Stat(sourceAssociatedDirectory); statError == nil { + destinationAssociatedDirectory := filepath.Join(destinationProjectDirectory, session.SessionID) + + _ = os.Rename(sourceAssociatedDirectory, destinationAssociatedDirectory) + } + + if removeError := removeFromIndex(sourceProjectDirectory, session.SessionID); removeError != nil { + return removeError + } + + session.InTrash = false + session.FullPath = destinationFile + + return addToIndex(destinationProjectDirectory, session) +} + +func PermanentlyDelete(session *Session) error { + projectDirectory := ProjectDir(session) + + if removeError := os.Remove(session.FullPath); removeError != nil && !os.IsNotExist(removeError) { + return removeError + } + + associatedDirectory := filepath.Join(projectDirectory, session.SessionID) + + _ = os.RemoveAll(associatedDirectory) + + return removeFromIndex(projectDirectory, session.SessionID) +} + +func EmptyTrash() error { + trashDirectory := TrashDir() + + if _, statError := os.Stat(trashDirectory); os.IsNotExist(statError) { + return nil + } + + return os.RemoveAll(trashDirectory) +} + +func RenameSession(session *Session, newSummary string) error { + projectDirectory := ProjectDir(session) + indexPath := filepath.Join(projectDirectory, "sessions-index.json") + fileData, readError := os.ReadFile(indexPath) + + if readError != nil { + return readError + } + + var sessionIndex SessionIndex + + if unmarshalError := json.Unmarshal(fileData, &sessionIndex); unmarshalError != nil { + return unmarshalError + } + + for entryIndex := range sessionIndex.Entries { + if sessionIndex.Entries[entryIndex].SessionID == session.SessionID { + sessionIndex.Entries[entryIndex].Summary = newSummary + + break + } + } + + return writeIndex(indexPath, &sessionIndex) +} + +func removeFromIndex(projectDirectory, sessionID string) error { + indexPath := filepath.Join(projectDirectory, "sessions-index.json") + fileData, readError := os.ReadFile(indexPath) + + if readError != nil { + return readError + } + + var sessionIndex SessionIndex + + if unmarshalError := json.Unmarshal(fileData, &sessionIndex); unmarshalError != nil { + return unmarshalError + } + + filteredEntries := make([]Session, 0, len(sessionIndex.Entries)-1) + + for _, entry := range sessionIndex.Entries { + if entry.SessionID != sessionID { + filteredEntries = append(filteredEntries, entry) + } + } + + sessionIndex.Entries = filteredEntries + + return writeIndex(indexPath, &sessionIndex) +} + +func addToIndex(projectDirectory string, session *Session) error { + indexPath := filepath.Join(projectDirectory, "sessions-index.json") + + var sessionIndex SessionIndex + + fileData, readError := os.ReadFile(indexPath) + + if readError != nil { + if os.IsNotExist(readError) { + sessionIndex = SessionIndex{ + Version: 1, + Entries: []Session{}, + OriginalPath: session.ProjectPath, + } + } else { + return readError + } + } else { + if unmarshalError := json.Unmarshal(fileData, &sessionIndex); unmarshalError != nil { + return unmarshalError + } + } + + found := false + + for entryIndex := range sessionIndex.Entries { + if sessionIndex.Entries[entryIndex].SessionID == session.SessionID { + sessionIndex.Entries[entryIndex] = *session + found = true + + break + } + } + + if !found { + sessionIndex.Entries = append(sessionIndex.Entries, *session) + } + + return writeIndex(indexPath, &sessionIndex) +} + +func writeIndex(indexPath string, sessionIndex *SessionIndex) error { + jsonData, marshalError := json.MarshalIndent(sessionIndex, "", " ") + + if marshalError != nil { + return marshalError + } + + return os.WriteFile(indexPath, jsonData, 0o644) +} |