diff --git a/HISTORY_IMPLEMENTATION_PLAN.md b/HISTORY_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..ea6e21e --- /dev/null +++ b/HISTORY_IMPLEMENTATION_PLAN.md @@ -0,0 +1,576 @@ +# Simple Agent Go - History and Resume Feature Implementation Plan + +## Overview + +This document outlines the implementation plan for adding conversation persistence, history management, and resume capabilities to the Simple Agent Go framework. The feature will allow users to continue their last conversation (`-c/--continue`) or resume specific sessions (`-r/--resume`) from any directory. + +## Research Summary + +### Claude Code Approach +- **Storage Location**: `~/.claude/projects/` +- **Format**: JSONL (JSON Lines) files with full message history +- **Organization**: By project with session metadata +- **Commands**: `--continue` for last session, `--resume` for picker +- **Features**: Auto-compact, context management, complete context preservation + +### Cursor IDE Approach +- **Storage Location**: `%APPDATA%/Cursor/User/workspaceStorage/` (Windows) +- **Format**: SQLite database (`state.vscdb`) per workspace +- **Organization**: MD5 hash folders for each workspace +- **Limitations**: Local only, no cloud sync, workspace-specific + +### Gemini CLI Approach +- **Commands**: `/chat save `, `/chat resume `, `/chat list` +- **Features**: Manual save/resume with tags, context compression +- **Limitations**: No automatic persistence, requires explicit saves + +## Proposed Implementation Approaches + +### Approach 1: Claude Code Style - JSONL with Auto-Save + +**Storage Structure**: +``` +~/.simple-agent/ +├── config.json +└── conversations/ + ├── index.json + └── sessions/ + ├── 20250731_143052_abc123.jsonl + ├── 20250731_150234_def456.jsonl + └── ... +``` + +**Key Features**: +- Automatic saving after each message exchange +- JSONL format for streaming writes and partial reads +- Index file mapping paths to sessions +- Session ID: `YYYYMMDD_HHMMSS_` + +**Pros**: +- Simple file format, easy to debug +- Streaming-friendly JSONL +- Human-readable timestamps +- Easy to implement backup/export + +**Cons**: +- Multiple files to manage +- Potential for file corruption +- No built-in query capabilities + +### Approach 2: SQLite Database - Structured and Queryable + +**Database Schema**: +```sql +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + created_at TIMESTAMP, + updated_at TIMESTAMP, + path TEXT, + provider TEXT, + model TEXT, + metadata JSON +); + +CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, + role TEXT, + content TEXT, + tool_calls JSON, + timestamp TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES sessions(id) +); + +CREATE INDEX idx_sessions_path ON sessions(path); +CREATE INDEX idx_sessions_updated ON sessions(updated_at); +``` + +**Key Features**: +- Single database file at `~/.simple-agent/history.db` +- Efficient querying and filtering +- Atomic transactions +- Built-in search capabilities + +**Pros**: +- ACID compliance, data integrity +- Powerful query capabilities +- Single file to manage +- Efficient storage + +**Cons**: +- Binary format, harder to debug +- Requires SQLite dependency +- More complex implementation + +### Approach 3: Hybrid - JSON Index with Separate Message Files + +**Storage Structure**: +``` +~/.simple-agent/ +├── config.json +├── conversations/ +│ └── index.json +└── messages/ + ├── 2025/ + │ └── 07/ + │ └── 31/ + │ ├── session_143052_abc123.json + │ └── session_150234_def456.json +``` + +**Index File** (`conversations/index.json`): +```json +{ + "sessions": [ + { + "id": "20250731_143052_abc123", + "path": "/Users/user/projects/myapp", + "created_at": "2025-07-31T14:30:52Z", + "updated_at": "2025-07-31T14:45:23Z", + "provider": "openai", + "model": "gpt-4", + "message_count": 12, + "file_path": "messages/2025/07/31/session_143052_abc123.json" + } + ] +} +``` + +**Pros**: +- Organized by date for easy cleanup +- Fast index lookups +- Separate concerns (metadata vs content) +- Good balance of features + +**Cons**: +- More complex directory structure +- Two-step read process +- Potential sync issues between index and files + +## Recommended Approach: Enhanced Approach 1 with Smart Features + +Based on the research and considering Go's strengths, I recommend an enhanced version of Approach 1 that combines the simplicity of Claude Code's approach with additional features: + +### Storage Structure + +``` +~/.simple-agent/ +├── config.json +├── sessions/ +│ ├── meta.json # Global metadata and indexes +│ ├── 20250731_143052_abc123.json +│ ├── 20250731_150234_def456.json +│ └── ... +``` + +### Session File Format (JSON, not JSONL) + +```json +{ + "id": "20250731_143052_abc123", + "version": "1.0", + "created_at": "2025-07-31T14:30:52Z", + "updated_at": "2025-07-31T14:45:23Z", + "path": "/Users/user/projects/myapp", + "provider": "openai", + "model": "gpt-4", + "metadata": { + "title": "Auto-generated from first message", + "tags": [], + "token_count": 1234 + }, + "messages": [ + { + "role": "system", + "content": "You are an AI assistant...", + "timestamp": "2025-07-31T14:30:52Z" + }, + { + "role": "user", + "content": "Hello, can you help me?", + "timestamp": "2025-07-31T14:30:55Z" + }, + { + "role": "assistant", + "content": "Of course! I'd be happy to help...", + "tool_calls": [], + "timestamp": "2025-07-31T14:31:02Z" + } + ] +} +``` + +### Meta Index File + +```json +{ + "version": "1.0", + "last_session_id": "20250731_143052_abc123", + "path_index": { + "/Users/user/projects/myapp": [ + "20250731_143052_abc123", + "20250731_120234_xyz789" + ], + "/Users/user/projects/another": [ + "20250730_090122_qwe456" + ] + } +} +``` + +## Implementation Steps (Pseudocode) + +### 1. Create History Package + +```go +// history/types.go +type Session struct { + ID string `json:"id"` + Version string `json:"version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Path string `json:"path"` + Provider string `json:"provider"` + Model string `json:"model"` + Metadata Metadata `json:"metadata"` + Messages []Message `json:"messages"` +} + +type Metadata struct { + Title string `json:"title"` + Tags []string `json:"tags"` + TokenCount int `json:"token_count"` +} + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +type MetaIndex struct { + Version string `json:"version"` + LastSession string `json:"last_session_id"` + PathIndex map[string][]string `json:"path_index"` +} +``` + +### 2. Implement History Manager + +```go +// history/manager.go +type Manager struct { + sessionsDir string + metaPath string + mu sync.RWMutex +} + +func NewManager() (*Manager, error) { + homeDir, _ := os.UserHomeDir() + sessionsDir := filepath.Join(homeDir, ".simple-agent", "sessions") + + m := &Manager{ + sessionsDir: sessionsDir, + metaPath: filepath.Join(sessionsDir, "meta.json"), + } + + // Create directory + os.MkdirAll(m.sessionsDir, 0755) + + // Initialize meta if not exists + if _, err := os.Stat(m.metaPath); os.IsNotExist(err) { + m.saveMeta(&MetaIndex{Version: "1.0", PathIndex: make(map[string][]string)}) + } + + return m, nil +} + +func (m *Manager) StartSession(path, provider, model string) (*Session, error) { + // Generate session ID + id := fmt.Sprintf("%s_%s", + time.Now().Format("20060102_150405"), + generateRandomID(6)) + + session := &Session{ + ID: id, + Version: "1.0", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Path: path, + Provider: provider, + Model: model, + Messages: []Message{}, + } + + // Update meta index + m.updatePathIndex(path, id) + + return session, nil +} + +func (m *Manager) SaveSession(session *Session) error { + session.UpdatedAt = time.Now() + + // Save to file + data, _ := json.MarshalIndent(session, "", " ") + filename := filepath.Join(m.sessionsDir, session.ID+".json") + + return os.WriteFile(filename, data, 0644) +} + +func (m *Manager) LoadSession(id string) (*Session, error) { + filename := filepath.Join(m.sessionsDir, id+".json") + data, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + + var session Session + err = json.Unmarshal(data, &session) + return &session, err +} + +func (m *Manager) GetLastSessionForPath(path string) (*Session, error) { + meta, _ := m.loadMeta() + + sessionIDs, ok := meta.PathIndex[path] + if !ok || len(sessionIDs) == 0 { + return nil, fmt.Errorf("no sessions found for path: %s", path) + } + + // Get the most recent (last in list) + lastID := sessionIDs[len(sessionIDs)-1] + return m.LoadSession(lastID) +} + +func (m *Manager) ListSessionsForPath(path string) ([]SessionInfo, error) { + meta, _ := m.loadMeta() + + sessionIDs, ok := meta.PathIndex[path] + if !ok { + return []SessionInfo{}, nil + } + + var sessions []SessionInfo + for _, id := range sessionIDs { + session, err := m.LoadSession(id) + if err != nil { + continue + } + + // Create summary + title := m.generateTitle(session) + sessions = append(sessions, SessionInfo{ + ID: session.ID, + Title: title, + CreatedAt: session.CreatedAt, + UpdatedAt: session.UpdatedAt, + Messages: len(session.Messages), + }) + } + + return sessions, nil +} +``` + +### 3. Integrate with Agent + +```go +// agent/agent.go additions +type AgentWithHistory struct { + agent.Agent + historyManager *history.Manager + currentSession *history.Session +} + +func (a *AgentWithHistory) Query(ctx context.Context, query string) (*Response, error) { + // Add to history before query + if a.currentSession != nil { + a.currentSession.Messages = append(a.currentSession.Messages, history.Message{ + Role: "user", + Content: query, + Timestamp: time.Now(), + }) + } + + // Execute query + response, err := a.Agent.Query(ctx, query) + + // Add response to history + if a.currentSession != nil && err == nil { + a.currentSession.Messages = append(a.currentSession.Messages, history.Message{ + Role: "assistant", + Content: response.Content, + ToolCalls: response.ToolCalls, + Timestamp: time.Now(), + }) + + // Save session + a.historyManager.SaveSession(a.currentSession) + } + + return response, err +} +``` + +### 4. Update CLI Commands + +```go +// cmd/simple-agent/main.go modifications +func runTUI(cmd *cobra.Command, args []string) error { + // ... existing setup ... + + // Initialize history manager + historyMgr, err := history.NewManager() + if err != nil { + return fmt.Errorf("failed to initialize history: %w", err) + } + + // Get current working directory + cwd, _ := os.Getwd() + + var session *history.Session + + // Handle continue flag + if continueConv { + session, err = historyMgr.GetLastSessionForPath(cwd) + if err != nil { + fmt.Printf("No previous conversation found for this directory\n") + // Start new session + session, _ = historyMgr.StartSession(cwd, provider, model) + } else { + // Restore messages to agent + for _, msg := range session.Messages { + // Convert and add to agent memory + } + } + } else if resume != "" { + // Show session picker or load specific session + if resume == "list" { + sessions, _ := historyMgr.ListSessionsForPath(cwd) + // Display session picker UI + selectedSession := showSessionPicker(sessions) + session = historyMgr.LoadSession(selectedSession.ID) + } else { + // Load specific session ID + session, err = historyMgr.LoadSession(resume) + } + } else { + // Start new session + session, _ = historyMgr.StartSession(cwd, provider, model) + } + + // Create agent with history + agentWithHistory := &AgentWithHistory{ + Agent: agentInstance, + historyManager: historyMgr, + currentSession: session, + } + + // ... rest of TUI setup ... +} +``` + +### 5. Add Session Picker UI + +```go +// tui/session_picker.go +type SessionPicker struct { + sessions []history.SessionInfo + selected int + done bool +} + +func NewSessionPicker(sessions []history.SessionInfo) *SessionPicker { + return &SessionPicker{ + sessions: sessions, + selected: 0, + } +} + +func (p *SessionPicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if p.selected > 0 { + p.selected-- + } + case "down", "j": + if p.selected < len(p.sessions)-1 { + p.selected++ + } + case "enter": + p.done = true + return p, tea.Quit + case "esc", "q": + return p, tea.Quit + } + } + return p, nil +} + +func (p *SessionPicker) View() string { + if len(p.sessions) == 0 { + return "No previous conversations found for this directory." + } + + var b strings.Builder + b.WriteString("Select a conversation to resume:\n\n") + + for i, session := range p.sessions { + cursor := " " + if i == p.selected { + cursor = "> " + } + + b.WriteString(fmt.Sprintf("%s%s - %s (%d messages)\n", + cursor, + session.CreatedAt.Format("Jan 02 15:04"), + session.Title, + session.Messages)) + } + + b.WriteString("\n[↑/↓] Navigate [Enter] Select [Esc] Cancel") + return b.String() +} +``` + +## Additional Features to Consider + +1. **Auto-cleanup**: Remove sessions older than X days +2. **Export functionality**: Export conversations to Markdown/PDF +3. **Search**: Full-text search across all conversations +4. **Compression**: Compress old sessions to save space +5. **Sync**: Optional cloud sync (future enhancement) +6. **Privacy**: Encryption for sensitive conversations + +## Benefits of Recommended Approach + +1. **Simplicity**: Single JSON file per session, easy to implement and debug +2. **Performance**: Fast meta index for path lookups +3. **Flexibility**: Easy to add features like search, export, tags +4. **Go-idiomatic**: Uses standard library, no external dependencies +5. **Human-readable**: JSON format is easy to inspect and migrate +6. **Atomic writes**: Each session is a separate file, reducing corruption risk + +## Migration Path + +If we need to migrate to a different approach later (e.g., SQLite for better performance with large histories), the JSON format makes it easy to write migration scripts. + +## Security Considerations + +1. Store files with 0600 permissions (user read/write only) +2. Consider optional encryption for sensitive conversations +3. Implement session expiry/cleanup policies +4. Sanitize file paths to prevent directory traversal + +## Testing Strategy + +1. Unit tests for history manager operations +2. Integration tests for CLI commands +3. Stress tests with large conversation histories +4. Edge cases: corrupted files, missing indexes, concurrent access + +This implementation plan provides a solid foundation for conversation persistence while maintaining the simplicity and performance characteristics that make Simple Agent Go successful. \ No newline at end of file diff --git a/agent/agent.go b/agent/agent.go index a00f7d3..4cd02b2 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -643,6 +643,19 @@ func (a *agent) getToolListForPrompt() string { return toolInfo.String() } +// SetMemory sets the conversation memory +func (a *agent) SetMemory(messages []llm.Message) { + a.mu.Lock() + defer a.mu.Unlock() + + a.memory.Messages = make([]llm.Message, len(messages)) + copy(a.memory.Messages, messages) + + // Update token count if needed + // TODO: Implement token counting + a.memory.TokenCount = 0 +} + // parseToolCallsFromContent attempts to parse tool calls from content // This is for compatibility with providers that return tool calls in content func (a *agent) parseToolCallsFromContent(content string) []llm.ToolCall { diff --git a/agent/history_agent.go b/agent/history_agent.go new file mode 100644 index 0000000..02e1dd8 --- /dev/null +++ b/agent/history_agent.go @@ -0,0 +1,148 @@ +package agent + +import ( + "context" + "fmt" + "os" + + "github.com/nachoal/simple-agent-go/history" +) + +// HistoryAgent wraps an agent with conversation history support +type HistoryAgent struct { + Agent + historyManager *history.Manager + currentSession *history.Session +} + +// NewHistoryAgent creates a new agent with history support +func NewHistoryAgent(agent Agent, historyManager *history.Manager, session *history.Session) *HistoryAgent { + return &HistoryAgent{ + Agent: agent, + historyManager: historyManager, + currentSession: session, + } +} + +// Query sends a query and saves the conversation to history +func (ha *HistoryAgent) Query(ctx context.Context, query string) (*Response, error) { + // Remember the initial message count to rollback on failure + initialMessageCount := 0 + if ha.currentSession != nil { + initialMessageCount = len(ha.currentSession.Messages) + } + + // Execute query first + response, err := ha.Agent.Query(ctx, query) + + // If successful, update history with the complete conversation + if err == nil && ha.currentSession != nil { + // Get the complete memory from the agent (includes all tool interactions) + agentMemory := ha.Agent.GetMemory() + + // Convert and store all new messages since our last save + // We need to sync our session with the agent's memory + ha.currentSession.Messages = ha.historyManager.ConvertFromLLMMessages(agentMemory) + + // Save session with complete history + if saveErr := ha.historyManager.SaveSession(ha.currentSession); saveErr != nil { + // Log error but don't fail the query + fmt.Fprintf(os.Stderr, "\n[WARNING] Failed to save conversation history: %v\n", saveErr) + fmt.Fprintf(os.Stderr, "Your conversation may not be saved. Please check disk space and permissions.\n\n") + } + } else if err != nil && ha.currentSession != nil { + // Query failed - rollback to initial state + ha.currentSession.Messages = ha.currentSession.Messages[:initialMessageCount] + } + + return response, err +} + +// QueryStream sends a query and streams the response while saving to history +func (ha *HistoryAgent) QueryStream(ctx context.Context, query string) (<-chan StreamEvent, error) { + // Remember the initial message count to rollback on failure + initialMessageCount := 0 + if ha.currentSession != nil { + initialMessageCount = len(ha.currentSession.Messages) + } + + // Get the stream + events, err := ha.Agent.QueryStream(ctx, query) + if err != nil { + return nil, err + } + + // Create a new channel to intercept events + intercepted := make(chan StreamEvent, 100) + + go func() { + defer close(intercepted) + + streamSucceeded := false + + for event := range events { + // Forward the event + intercepted <- event + + // Check for completion or error + switch event.Type { + case EventTypeComplete: + streamSucceeded = true + // Get the complete memory from the agent (includes all tool interactions) + if ha.currentSession != nil { + agentMemory := ha.Agent.GetMemory() + ha.currentSession.Messages = ha.historyManager.ConvertFromLLMMessages(agentMemory) + + // Save session with complete history + if err := ha.historyManager.SaveSession(ha.currentSession); err != nil { + // Send error event through the stream + intercepted <- StreamEvent{ + Type: EventTypeError, + Error: fmt.Errorf("failed to save conversation history: %w", err), + } + // Also log to stderr + fmt.Fprintf(os.Stderr, "\n[WARNING] Failed to save conversation history: %v\n", err) + } + } + case EventTypeError: + // Stream failed - rollback the session + if ha.currentSession != nil && !streamSucceeded { + ha.currentSession.Messages = ha.currentSession.Messages[:initialMessageCount] + } + } + } + + // If stream ended without completion or error, rollback + if !streamSucceeded && ha.currentSession != nil { + ha.currentSession.Messages = ha.currentSession.Messages[:initialMessageCount] + } + }() + + return intercepted, nil +} + +// GetSession returns the current session +func (ha *HistoryAgent) GetSession() *history.Session { + return ha.currentSession +} + +// SetSession updates the current session +func (ha *HistoryAgent) SetSession(session *history.Session) { + ha.currentSession = session +} + +// RestoreMemoryFromSession restores the agent's memory from a session +func (ha *HistoryAgent) RestoreMemoryFromSession(session *history.Session) { + if session == nil || len(session.Messages) == 0 { + return + } + + // Convert and restore messages + llmMessages := ha.historyManager.ConvertToLLMMessages(session.Messages) + + // Set the memory directly + ha.Agent.SetMemory(llmMessages) + + // Update current session + ha.currentSession = session +} \ No newline at end of file diff --git a/agent/types.go b/agent/types.go index c92762d..aaea98c 100644 --- a/agent/types.go +++ b/agent/types.go @@ -118,6 +118,9 @@ type Agent interface { // SetSystemPrompt updates the system prompt SetSystemPrompt(prompt string) + + // SetMemory sets the conversation memory + SetMemory(messages []llm.Message) } const defaultSystemPrompt = `You are an AI assistant that can leverage external tools to answer the user. diff --git a/cmd/simple-agent/main.go b/cmd/simple-agent/main.go index 9f65100..88fe4c3 100644 --- a/cmd/simple-agent/main.go +++ b/cmd/simple-agent/main.go @@ -14,6 +14,7 @@ import ( "github.com/nachoal/simple-agent-go/agent" "github.com/nachoal/simple-agent-go/config" + "github.com/nachoal/simple-agent-go/history" "github.com/nachoal/simple-agent-go/llm" "github.com/nachoal/simple-agent-go/llm/anthropic" "github.com/nachoal/simple-agent-go/llm/deepseek" @@ -35,12 +36,17 @@ var ( verbose bool continueConv bool resume string + resumeSet bool // Root command rootCmd = &cobra.Command{ Use: "simple-agent", Short: "AI agent with tool support", Long: "Simple Agent Go - A powerful AI agent framework with multiple LLM providers and tool support", + PreRun: func(cmd *cobra.Command, args []string) { + // Check if resume flag was explicitly set + resumeSet = cmd.Flags().Changed("resume") + }, RunE: runTUI, } @@ -77,7 +83,10 @@ func init() { // TUI-specific flags rootCmd.Flags().BoolVarP(&continueConv, "continue", "c", false, "Continue last conversation") - rootCmd.Flags().StringVarP(&resume, "resume", "r", "", "Resume specific session or show picker if empty") + rootCmd.Flags().StringVarP(&resume, "resume", "r", "", "Resume specific session ID or show picker if no ID provided") + + // Set NoOptDefVal for resume flag - this value is used when -r is provided without an argument + rootCmd.Flags().Lookup("resume").NoOptDefVal = "picker" // Add subcommands rootCmd.AddCommand(queryCmd) @@ -181,13 +190,152 @@ func runTUI(cmd *cobra.Command, args []string) error { agent.WithTemperature(0.7), ) + // Initialize history manager + historyMgr, err := history.NewManager() + if err != nil { + return fmt.Errorf("failed to initialize history: %w", err) + } + + // Get current working directory + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %w", err) + } + + var session *history.Session + var historyAgent *agent.HistoryAgent + // Handle continue/resume flags if continueConv { - // TODO: Load last session - fmt.Println("Loading last conversation...") - } else if resume != "" { - // TODO: Load specific session or show picker - fmt.Printf("Resuming session: %s\n", resume) + // Try to load last session for this directory + session, err = historyMgr.GetLastSessionForPath(cwd) + if err != nil { + fmt.Printf("No previous conversation found for this directory.\n") + // Start new session + session, err = historyMgr.StartSession(cwd, provider, model) + if err != nil { + return fmt.Errorf("failed to start session: %w", err) + } + } else { + fmt.Printf("Continuing conversation from %s...\n", session.UpdatedAt.Format("Jan 02 15:04")) + // Update provider/model from session if different + if session.Provider != provider || session.Model != model { + provider = session.Provider + model = session.Model + // Recreate client with session's provider/model + llmClient.Close() + llmClient, err = createLLMClient(provider, model) + if err != nil { + return fmt.Errorf("failed to create %s client: %w", provider, err) + } + agentInstance = agent.New(llmClient, + agent.WithMaxIterations(10), + agent.WithTemperature(0.7), + ) + } + } + } else if resumeSet { + // Show session picker if no ID provided, or load specific session + if resume == "picker" || resume == "list" || resume == "" { + sessions, err := historyMgr.ListSessionsForPath(cwd) + if err != nil { + return fmt.Errorf("failed to list sessions: %w", err) + } + + if len(sessions) == 0 { + fmt.Println("No previous conversations found for this directory.") + // Start new session + session, err = historyMgr.StartSession(cwd, provider, model) + if err != nil { + return fmt.Errorf("failed to start session: %w", err) + } + } else { + // Show session picker + picker := tui.NewSessionPicker(sessions) + p := tea.NewProgram(picker) + + pickerModel, err := p.Run() + if err != nil { + return fmt.Errorf("failed to run session picker: %w", err) + } + + // Check if a session was selected + if verbose { + fmt.Printf("Picker model type: %T\n", pickerModel) + } + if pickerResult, ok := pickerModel.(*tui.SessionPicker); ok { + if verbose { + fmt.Printf("Picker result type assertion successful, SelectedSessionID: '%s'\n", pickerResult.SelectedSessionID) + } + if pickerResult.SelectedSessionID != "" { + // Session was selected + if verbose { + fmt.Printf("Selected session ID: %s\n", pickerResult.SelectedSessionID) + } + session, err = historyMgr.LoadSession(pickerResult.SelectedSessionID) + if err != nil { + return fmt.Errorf("failed to load session: %w", err) + } + fmt.Printf("Resuming session from %s...\n", session.UpdatedAt.Format("Jan 02 15:04")) + if verbose { + fmt.Printf("Session has %d messages\n", len(session.Messages)) + } + } + } else { + if verbose { + fmt.Printf("Type assertion failed! Model type is: %T\n", pickerModel) + } + } + + if session == nil { + // User cancelled - start new session instead + session, err = historyMgr.StartSession(cwd, provider, model) + if err != nil { + return fmt.Errorf("failed to start session: %w", err) + } + } + } + } else { + // Load specific session ID + session, err = historyMgr.LoadSession(resume) + if err != nil { + return fmt.Errorf("failed to load session %s: %w", resume, err) + } + } + + // Update provider/model from session + if session != nil { + // Always update provider/model from the session + provider = session.Provider + model = session.Model + // Recreate client with session's provider/model + llmClient.Close() + llmClient, err = createLLMClient(provider, model) + if err != nil { + return fmt.Errorf("failed to create %s client: %w", provider, err) + } + agentInstance = agent.New(llmClient, + agent.WithMaxIterations(10), + agent.WithTemperature(0.7), + ) + } + } else { + // Start new session + session, err = historyMgr.StartSession(cwd, provider, model) + if err != nil { + return fmt.Errorf("failed to start session: %w", err) + } + } + + // Create history-aware agent + historyAgent = agent.NewHistoryAgent(agentInstance, historyMgr, session) + + // Restore memory if continuing/resuming + if continueConv || resumeSet { + historyAgent.RestoreMemoryFromSession(session) + if verbose && session != nil { + fmt.Printf("Restored %d messages from session %s\n", len(session.Messages), session.ID) + } } // If verbose, show the enhanced system prompt (including tools) @@ -210,13 +358,11 @@ func runTUI(cmd *cobra.Command, args []string) error { } } fmt.Println("===================\n") - fmt.Println("Press Enter to continue...") - fmt.Scanln() } - // Create and run TUI (bordered version with providers) + // Create and run TUI (bordered version with providers and history) p := tea.NewProgram( - tui.NewBorderedTUIWithProviders(llmClient, agentInstance, provider, model, providers, configManager), + tui.NewBorderedTUIWithHistory(llmClient, historyAgent, provider, model, providers, configManager), ) if _, err := p.Run(); err != nil { diff --git a/history/manager.go b/history/manager.go new file mode 100644 index 0000000..c496512 --- /dev/null +++ b/history/manager.go @@ -0,0 +1,335 @@ +package history + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/nachoal/simple-agent-go/llm" +) + +// Manager handles conversation history persistence +type Manager struct { + sessionsDir string + metaPath string + mu sync.RWMutex +} + +// NewManager creates a new history manager +func NewManager() (*Manager, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get home directory: %w", err) + } + + sessionsDir := filepath.Join(homeDir, ".simple-agent", "sessions") + + m := &Manager{ + sessionsDir: sessionsDir, + metaPath: filepath.Join(sessionsDir, "meta.json"), + } + + // Create directory + if err := os.MkdirAll(m.sessionsDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create sessions directory: %w", err) + } + + // Initialize meta if not exists + if _, err := os.Stat(m.metaPath); os.IsNotExist(err) { + if err := m.saveMeta(&MetaIndex{ + Version: "1.0", + PathIndex: make(map[string][]string), + }); err != nil { + return nil, fmt.Errorf("failed to initialize meta index: %w", err) + } + } + + return m, nil +} + +// StartSession creates a new session +func (m *Manager) StartSession(path, provider, model string) (*Session, error) { + // Generate session ID + id := fmt.Sprintf("%s_%s", + time.Now().Format("20060102_150405"), + generateRandomID(6)) + + session := &Session{ + ID: id, + Version: "1.0", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Path: path, + Provider: provider, + Model: model, + Metadata: Metadata{ + Tags: []string{}, + }, + Messages: []Message{}, + } + + // Update meta index + if err := m.updatePathIndex(path, id); err != nil { + return nil, fmt.Errorf("failed to update path index: %w", err) + } + + return session, nil +} + +// SaveSession saves a session to disk +func (m *Manager) SaveSession(session *Session) error { + m.mu.Lock() + defer m.mu.Unlock() + + session.UpdatedAt = time.Now() + + // Generate title if empty + if session.Metadata.Title == "" { + session.Metadata.Title = m.generateTitle(session) + } + + // Save to file + data, err := json.MarshalIndent(session, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal session: %w", err) + } + + filename := filepath.Join(m.sessionsDir, session.ID+".json") + if err := os.WriteFile(filename, data, 0644); err != nil { + return fmt.Errorf("failed to write session file: %w", err) + } + + // Update last session in meta + meta, err := m.loadMeta() + if err != nil { + return fmt.Errorf("failed to load meta: %w", err) + } + + meta.LastSession = session.ID + if err := m.saveMeta(meta); err != nil { + return fmt.Errorf("failed to save meta: %w", err) + } + + return nil +} + +// LoadSession loads a session from disk +func (m *Manager) LoadSession(id string) (*Session, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + filename := filepath.Join(m.sessionsDir, id+".json") + data, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("failed to read session file: %w", err) + } + + var session Session + if err := json.Unmarshal(data, &session); err != nil { + return nil, fmt.Errorf("failed to unmarshal session: %w", err) + } + + return &session, nil +} + +// GetLastSessionForPath returns the most recent session for a given path +func (m *Manager) GetLastSessionForPath(path string) (*Session, error) { + m.mu.RLock() + meta, err := m.loadMeta() + m.mu.RUnlock() + + if err != nil { + return nil, fmt.Errorf("failed to load meta: %w", err) + } + + sessionIDs, ok := meta.PathIndex[path] + if !ok || len(sessionIDs) == 0 { + return nil, fmt.Errorf("no sessions found for path: %s", path) + } + + // Get the most recent (last in list) + lastID := sessionIDs[len(sessionIDs)-1] + return m.LoadSession(lastID) +} + +// ListSessionsForPath returns all sessions for a given path +func (m *Manager) ListSessionsForPath(path string) ([]SessionInfo, error) { + m.mu.RLock() + meta, err := m.loadMeta() + m.mu.RUnlock() + + if err != nil { + return nil, fmt.Errorf("failed to load meta: %w", err) + } + + sessionIDs, ok := meta.PathIndex[path] + if !ok { + return []SessionInfo{}, nil + } + + var sessions []SessionInfo + for _, id := range sessionIDs { + session, err := m.LoadSession(id) + if err != nil { + continue + } + + sessions = append(sessions, SessionInfo{ + ID: session.ID, + Title: session.Metadata.Title, + CreatedAt: session.CreatedAt, + UpdatedAt: session.UpdatedAt, + Messages: len(session.Messages), + Provider: session.Provider, + Model: session.Model, + }) + } + + // Sort by creation date, newest first + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].CreatedAt.After(sessions[j].CreatedAt) + }) + + return sessions, nil +} + +// ConvertFromLLMMessages converts LLM messages to history messages +func (m *Manager) ConvertFromLLMMessages(llmMessages []llm.Message) []Message { + messages := make([]Message, len(llmMessages)) + for i, msg := range llmMessages { + messages[i] = Message{ + Role: string(msg.Role), + Content: msg.Content, + ToolCallID: msg.ToolCallID, + Timestamp: time.Now(), // We don't have original timestamps + } + + // Convert tool calls + if len(msg.ToolCalls) > 0 { + messages[i].ToolCalls = make([]ToolCall, len(msg.ToolCalls)) + for j, tc := range msg.ToolCalls { + messages[i].ToolCalls[j] = ToolCall{ + ID: tc.ID, + Type: tc.Type, + Function: FunctionCall{ + Name: tc.Function.Name, + Arguments: string(tc.Function.Arguments), + }, + } + } + } + } + return messages +} + +// ConvertToLLMMessages converts history messages to LLM messages +func (m *Manager) ConvertToLLMMessages(histMessages []Message) []llm.Message { + messages := make([]llm.Message, len(histMessages)) + for i, msg := range histMessages { + messages[i] = llm.Message{ + Role: llm.Role(msg.Role), + Content: msg.Content, + ToolCallID: msg.ToolCallID, + } + + // Convert tool calls + if len(msg.ToolCalls) > 0 { + messages[i].ToolCalls = make([]llm.ToolCall, len(msg.ToolCalls)) + for j, tc := range msg.ToolCalls { + messages[i].ToolCalls[j] = llm.ToolCall{ + ID: tc.ID, + Type: tc.Type, + Function: llm.FunctionCall{ + Name: tc.Function.Name, + Arguments: json.RawMessage(tc.Function.Arguments), + }, + } + } + } + } + return messages +} + +// Private methods + +func (m *Manager) loadMeta() (*MetaIndex, error) { + data, err := os.ReadFile(m.metaPath) + if err != nil { + return nil, err + } + + var meta MetaIndex + if err := json.Unmarshal(data, &meta); err != nil { + return nil, err + } + + return &meta, nil +} + +func (m *Manager) saveMeta(meta *MetaIndex) error { + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return err + } + + return os.WriteFile(m.metaPath, data, 0644) +} + +func (m *Manager) updatePathIndex(path, sessionID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + meta, err := m.loadMeta() + if err != nil { + return err + } + + if meta.PathIndex == nil { + meta.PathIndex = make(map[string][]string) + } + + // Append session ID to path index + meta.PathIndex[path] = append(meta.PathIndex[path], sessionID) + + return m.saveMeta(meta) +} + +func (m *Manager) generateTitle(session *Session) string { + // Find first user message + for _, msg := range session.Messages { + if msg.Role == "user" && msg.Content != nil { + // Take first 50 characters or until newline + content := *msg.Content + if idx := strings.IndexByte(content, '\n'); idx != -1 { + content = content[:idx] + } + if len(content) > 50 { + content = content[:47] + "..." + } + return content + } + } + + // Fallback to timestamp + return fmt.Sprintf("Session %s", session.CreatedAt.Format("Jan 02 15:04")) +} + +func generateRandomID(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + // Fall back to time-based seed if crypto/rand fails + // This should be extremely rare + panic(fmt.Sprintf("crypto/rand failed: %v", err)) + } + for i := range b { + b[i] = charset[b[i]%byte(len(charset))] + } + return string(b) +} \ No newline at end of file diff --git a/history/types.go b/history/types.go new file mode 100644 index 0000000..f300a50 --- /dev/null +++ b/history/types.go @@ -0,0 +1,65 @@ +package history + +import ( + "time" +) + +// Session represents a conversation session +type Session struct { + ID string `json:"id"` + Version string `json:"version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Path string `json:"path"` + Provider string `json:"provider"` + Model string `json:"model"` + Metadata Metadata `json:"metadata"` + Messages []Message `json:"messages"` +} + +// Metadata contains session metadata +type Metadata struct { + Title string `json:"title"` + Tags []string `json:"tags"` + TokenCount int `json:"token_count"` +} + +// Message represents a conversation message +type Message struct { + Role string `json:"role"` + Content *string `json:"content,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// ToolCall represents a tool invocation +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function FunctionCall `json:"function"` +} + +// FunctionCall contains function call details +type FunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +// MetaIndex contains session indexing information +type MetaIndex struct { + Version string `json:"version"` + LastSession string `json:"last_session_id,omitempty"` + PathIndex map[string][]string `json:"path_index"` +} + +// SessionInfo provides summary information for session listing +type SessionInfo struct { + ID string `json:"id"` + Title string `json:"title"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Messages int `json:"messages"` + Provider string `json:"provider"` + Model string `json:"model"` +} \ No newline at end of file diff --git a/tui/bordered.go b/tui/bordered.go index 4635d9a..bf598f7 100644 --- a/tui/bordered.go +++ b/tui/bordered.go @@ -120,6 +120,48 @@ func NewBorderedTUIWithProviders(llmClient llm.Client, agentInstance agent.Agent return tui } +// NewBorderedTUIWithHistory creates a new bordered TUI with history support +func NewBorderedTUIWithHistory(llmClient llm.Client, historyAgent *agent.HistoryAgent, provider, model string, providers map[string]llm.Client, configManager *config.Manager) *BorderedTUI { + tui := NewBorderedTUI(llmClient, historyAgent, provider, model) + tui.providers = providers + tui.configManager = configManager + + // Load messages from history if available + if historyAgent != nil { + session := historyAgent.GetSession() + if session != nil && len(session.Messages) > 0 { + // Debug output + if os.Getenv("SIMPLE_AGENT_DEBUG") == "true" { + fmt.Fprintf(os.Stderr, "[TUI] Loading %d messages from session %s\n", len(session.Messages), session.ID) + } + + // Convert history messages to TUI messages + for _, msg := range session.Messages { + // Skip system messages in the display + if msg.Role == "system" { + continue + } + + content := "" + if msg.Content != nil { + content = *msg.Content + } + + tui.messages = append(tui.messages, BorderedMessage{ + Role: msg.Role, + Content: content, + }) + } + + if os.Getenv("SIMPLE_AGENT_DEBUG") == "true" { + fmt.Fprintf(os.Stderr, "[TUI] Loaded %d messages into TUI display\n", len(tui.messages)) + } + } + } + + return tui +} + func (m BorderedTUI) Init() tea.Cmd { // Initialize with a default width if not set if m.width == 0 { diff --git a/tui/session_picker.go b/tui/session_picker.go new file mode 100644 index 0000000..1748f8b --- /dev/null +++ b/tui/session_picker.go @@ -0,0 +1,159 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/nachoal/simple-agent-go/history" +) + +// SessionPicker is a TUI component for selecting a conversation session +type SessionPicker struct { + sessions []history.SessionInfo + selected int + done bool + width int + height int + SelectedSessionID string // The ID of the selected session +} + +// SelectedSessionMsg is sent when a session is selected +type SelectedSessionMsg struct { + SessionID string +} + +// NewSessionPicker creates a new session picker +func NewSessionPicker(sessions []history.SessionInfo) *SessionPicker { + return &SessionPicker{ + sessions: sessions, + selected: 0, + width: 80, + height: 24, + } +} + +func (p SessionPicker) Init() tea.Cmd { + return nil +} + +func (p *SessionPicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + p.width = msg.Width + p.height = msg.Height + return p, nil + + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if p.selected > 0 { + p.selected-- + } + case "down", "j": + if p.selected < len(p.sessions)-1 { + p.selected++ + } + case "enter": + if len(p.sessions) > 0 { + p.SelectedSessionID = p.sessions[p.selected].ID + return p, tea.Quit + } + case "esc", "q", "ctrl+c": + return p, tea.Quit + } + } + return p, nil +} + +func (p SessionPicker) View() string { + if len(p.sessions) == 0 { + return "\nNo previous conversations found for this directory.\n\nPress [Esc] to start a new conversation." + } + + // Styles + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("75")). + MarginBottom(1) + + selectedStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("75")). + Bold(true) + + normalStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("246")) + + helpStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + MarginTop(1) + + var b strings.Builder + + // Title + b.WriteString(titleStyle.Render("Select a conversation to resume:")) + b.WriteString("\n\n") + + // Calculate visible sessions based on height + visibleHeight := p.height - 6 // Account for title, help, and margins + startIdx := 0 + endIdx := len(p.sessions) + + if visibleHeight < len(p.sessions) { + // Implement scrolling + if p.selected > visibleHeight/2 { + startIdx = p.selected - visibleHeight/2 + if startIdx+visibleHeight > len(p.sessions) { + startIdx = len(p.sessions) - visibleHeight + } + } + endIdx = startIdx + visibleHeight + if endIdx > len(p.sessions) { + endIdx = len(p.sessions) + } + } + + // Sessions + for i := startIdx; i < endIdx; i++ { + session := p.sessions[i] + cursor := " " + style := normalStyle + + if i == p.selected { + cursor = "▸ " + style = selectedStyle + } + + // Format session info + line := fmt.Sprintf("%s%s - %s (%d messages, %s/%s)", + cursor, + session.CreatedAt.Format("Jan 02 15:04"), + truncateString(session.Title, 40), + session.Messages, + session.Provider, + session.Model) + + b.WriteString(style.Render(line)) + b.WriteString("\n") + } + + // Scroll indicator + if startIdx > 0 || endIdx < len(p.sessions) { + scrollInfo := fmt.Sprintf("\n[%d-%d of %d sessions]", startIdx+1, endIdx, len(p.sessions)) + b.WriteString(normalStyle.Render(scrollInfo)) + } + + // Help + help := "\n[↑/↓/j/k] Navigate [Enter] Select [Esc/q] Cancel" + b.WriteString(helpStyle.Render(help)) + + return b.String() +} + +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} \ No newline at end of file