aboutsummaryrefslogtreecommitdiff
path: root/internal/claude
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-30 07:32:54 +0000
committerFuwn <[email protected]>2026-01-30 07:32:54 +0000
commit5f3eba126201e4d679539aa2517bf6a132f29cd0 (patch)
tree961afe2ae1d6ca0f23bdbb30930e37bc88884146 /internal/claude
downloadfaustus-5f3eba126201e4d679539aa2517bf6a132f29cd0.tar.xz
faustus-5f3eba126201e4d679539aa2517bf6a132f29cd0.zip
feat: Initial commit
Diffstat (limited to 'internal/claude')
-rw-r--r--internal/claude/preview.go177
-rw-r--r--internal/claude/search.go180
-rw-r--r--internal/claude/session.go360
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)
+}