From c766d8839df143947f8bbaee483fae3f7afcfc92 Mon Sep 17 00:00:00 2001 From: Ignacio Alonso Date: Thu, 31 Jul 2025 10:03:33 -0600 Subject: [PATCH 1/6] feat: implement conversation history and resume functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add history package for managing conversation sessions - Create session persistence in ~/.simple-agent/sessions/ - Implement --continue flag to resume last conversation - Implement --resume flag with session picker UI - Add HistoryAgent wrapper for automatic session saving - Store sessions as JSON with full message history - Index sessions by directory path for easy retrieval - Add SessionPicker TUI component for selecting sessions - Extend Agent interface with SetMemory method - Preserve provider/model settings per session This allows users to continue conversations across sessions and maintain context when working in the same directory. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- HISTORY_IMPLEMENTATION_PLAN.md | 576 +++++++++++++++++++++++++++++++++ agent/agent.go | 13 + agent/history_agent.go | 145 +++++++++ agent/types.go | 3 + cmd/simple-agent/main.go | 131 +++++++- history/manager.go | 322 ++++++++++++++++++ history/types.go | 65 ++++ tui/bordered.go | 8 + tui/session_picker.go | 159 +++++++++ 9 files changed, 1416 insertions(+), 6 deletions(-) create mode 100644 HISTORY_IMPLEMENTATION_PLAN.md create mode 100644 agent/history_agent.go create mode 100644 history/manager.go create mode 100644 history/types.go create mode 100644 tui/session_picker.go 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..7f40376 --- /dev/null +++ b/agent/history_agent.go @@ -0,0 +1,145 @@ +package agent + +import ( + "context" + "time" + + "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) { + // Add user message to history + if ha.currentSession != nil { + ha.currentSession.Messages = append(ha.currentSession.Messages, history.Message{ + Role: "user", + Content: &query, + Timestamp: time.Now(), + }) + } + + // Execute query + response, err := ha.Agent.Query(ctx, query) + + // Add response to history + if ha.currentSession != nil && err == nil { + // Convert tool calls + var toolCalls []history.ToolCall + if len(response.ToolCalls) > 0 { + toolCalls = make([]history.ToolCall, 0) + // Note: ToolCalls in Response are ToolResult, not the actual calls + // We'll store the results as part of the message content + } + + ha.currentSession.Messages = append(ha.currentSession.Messages, history.Message{ + Role: "assistant", + Content: &response.Content, + ToolCalls: toolCalls, + Timestamp: time.Now(), + }) + + // Save session + if err := ha.historyManager.SaveSession(ha.currentSession); err != nil { + // Log error but don't fail the query + // TODO: Add proper logging + } + } + + 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) { + // Add user message to history + if ha.currentSession != nil { + ha.currentSession.Messages = append(ha.currentSession.Messages, history.Message{ + Role: "user", + Content: &query, + Timestamp: time.Now(), + }) + } + + // 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) + + var fullContent string + var toolCalls []history.ToolCall + + for event := range events { + // Forward the event + intercepted <- event + + // Collect content for history + switch event.Type { + case EventTypeMessage: + fullContent += event.Content + case EventTypeComplete: + // Save to history when complete + if ha.currentSession != nil && fullContent != "" { + ha.currentSession.Messages = append(ha.currentSession.Messages, history.Message{ + Role: "assistant", + Content: &fullContent, + ToolCalls: toolCalls, + Timestamp: time.Now(), + }) + + // Save session + ha.historyManager.SaveSession(ha.currentSession) + } + } + } + }() + + 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..c0daded 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" @@ -181,13 +182,131 @@ 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...") + // 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 resume != "" { - // TODO: Load specific session or show picker - fmt.Printf("Resuming session: %s\n", resume) + // Show session picker or load specific session + if resume == "list" { + 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 pickerResult, ok := pickerModel.(*tui.SessionPicker); ok { + // Find selected session + for _, s := range sessions { + if s.ID == pickerResult.SelectedSessionID { + session, err = historyMgr.LoadSession(s.ID) + if err != nil { + return fmt.Errorf("failed to load session: %w", err) + } + break + } + } + } + + if session == nil { + // User cancelled or no selection + return nil + } + } + } 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 && (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 { + // 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 || resume != "" { + historyAgent.RestoreMemoryFromSession(session) } // If verbose, show the enhanced system prompt (including tools) @@ -214,9 +333,9 @@ func runTUI(cmd *cobra.Command, args []string) error { 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..c6bfa19 --- /dev/null +++ b/history/manager.go @@ -0,0 +1,322 @@ +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) { + meta, err := m.loadMeta() + 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) { + meta, err := m.loadMeta() + 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 { + 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) + rand.Read(b) + 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..6c7f9c9 100644 --- a/tui/bordered.go +++ b/tui/bordered.go @@ -120,6 +120,14 @@ 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 + 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..8af5f47 --- /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 From 59fe952e679c3c3577819925f678b5173d158f5e Mon Sep 17 00:00:00 2001 From: Ignacio Alonso Date: Thu, 31 Jul 2025 10:48:13 -0600 Subject: [PATCH 2/6] fix(history): resolve session picker and display issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix type assertion for session picker (use value type instead of pointer) - Display conversation history in TUI when resuming sessions - Make -r/--resume flag work without arguments using Cobra's NoOptDefVal - Remove unnecessary 'Press Enter to continue' prompt in verbose mode - Add debug output for troubleshooting session loading - Ensure proper session restoration with correct provider/model The session picker now correctly loads selected sessions and displays the full conversation history in the TUI interface. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/simple-agent/main.go | 65 ++++++++++++++++++++++++++++------------ tui/bordered.go | 34 +++++++++++++++++++++ 2 files changed, 80 insertions(+), 19 deletions(-) diff --git a/cmd/simple-agent/main.go b/cmd/simple-agent/main.go index c0daded..5acb44d 100644 --- a/cmd/simple-agent/main.go +++ b/cmd/simple-agent/main.go @@ -36,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, } @@ -78,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) @@ -226,9 +234,9 @@ func runTUI(cmd *cobra.Command, args []string) error { ) } } - } else if resume != "" { - // Show session picker or load specific session - if resume == "list" { + } 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) @@ -252,22 +260,39 @@ func runTUI(cmd *cobra.Command, args []string) error { } // Check if a session was selected - if pickerResult, ok := pickerModel.(*tui.SessionPicker); ok { - // Find selected session - for _, s := range sessions { - if s.ID == pickerResult.SelectedSessionID { - session, err = historyMgr.LoadSession(s.ID) - if err != nil { - return fmt.Errorf("failed to load session: %w", err) - } - break + 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 or no selection - return 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 { @@ -279,7 +304,8 @@ func runTUI(cmd *cobra.Command, args []string) error { } // Update provider/model from session - if session != nil && (session.Provider != provider || session.Model != model) { + if session != nil { + // Always update provider/model from the session provider = session.Provider model = session.Model // Recreate client with session's provider/model @@ -305,8 +331,11 @@ func runTUI(cmd *cobra.Command, args []string) error { historyAgent = agent.NewHistoryAgent(agentInstance, historyMgr, session) // Restore memory if continuing/resuming - if continueConv || resume != "" { + 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) @@ -329,8 +358,6 @@ 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 and history) diff --git a/tui/bordered.go b/tui/bordered.go index 6c7f9c9..bf598f7 100644 --- a/tui/bordered.go +++ b/tui/bordered.go @@ -125,6 +125,40 @@ func NewBorderedTUIWithHistory(llmClient llm.Client, historyAgent *agent.History 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 } From 290a9fc0c0174514dc8caa0bfe92573081ccc37c Mon Sep 17 00:00:00 2001 From: Ignacio Alonso Date: Thu, 31 Jul 2025 11:02:06 -0600 Subject: [PATCH 3/6] fix: address PR review comments from auto-reviewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix SessionPicker.Update to use pointer receiver (*SessionPicker) This ensures state changes persist across method calls - Add error handling for crypto/rand.Read in generateRandomID Prevents potential security issues with predictable session IDs Both issues were legitimate bugs found by the Cursor auto-reviewer. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/simple-agent/main.go | 2 +- history/manager.go | 6 +++++- tui/session_picker.go | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/cmd/simple-agent/main.go b/cmd/simple-agent/main.go index 5acb44d..88fe4c3 100644 --- a/cmd/simple-agent/main.go +++ b/cmd/simple-agent/main.go @@ -263,7 +263,7 @@ func runTUI(cmd *cobra.Command, args []string) error { if verbose { fmt.Printf("Picker model type: %T\n", pickerModel) } - if pickerResult, ok := pickerModel.(tui.SessionPicker); ok { + if pickerResult, ok := pickerModel.(*tui.SessionPicker); ok { if verbose { fmt.Printf("Picker result type assertion successful, SelectedSessionID: '%s'\n", pickerResult.SelectedSessionID) } diff --git a/history/manager.go b/history/manager.go index c6bfa19..69af9cc 100644 --- a/history/manager.go +++ b/history/manager.go @@ -314,7 +314,11 @@ func (m *Manager) generateTitle(session *Session) string { func generateRandomID(length int) string { const charset = "abcdefghijklmnopqrstuvwxyz0123456789" b := make([]byte, length) - rand.Read(b) + 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))] } diff --git a/tui/session_picker.go b/tui/session_picker.go index 8af5f47..1748f8b 100644 --- a/tui/session_picker.go +++ b/tui/session_picker.go @@ -38,7 +38,7 @@ func (p SessionPicker) Init() tea.Cmd { return nil } -func (p SessionPicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (p *SessionPicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: p.width = msg.Width From cb3b0a99b335c4654fa48119ce78f8b76219f069 Mon Sep 17 00:00:00 2001 From: Ignacio Alonso Date: Thu, 31 Jul 2025 11:10:06 -0600 Subject: [PATCH 4/6] fix: add proper error logging for session save failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add visible error messages when conversation history fails to save - Log to stderr to notify users of potential data loss - Include helpful message about checking disk space and permissions - For streaming, also send error through the event stream This ensures users are aware when their conversations aren't being saved, preventing silent data loss. Addresses PR review comment about error masking data loss. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- agent/history_agent.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/agent/history_agent.go b/agent/history_agent.go index 7f40376..921509d 100644 --- a/agent/history_agent.go +++ b/agent/history_agent.go @@ -2,6 +2,8 @@ package agent import ( "context" + "fmt" + "os" "time" "github.com/nachoal/simple-agent-go/history" @@ -57,7 +59,8 @@ func (ha *HistoryAgent) Query(ctx context.Context, query string) (*Response, err // Save session if err := ha.historyManager.SaveSession(ha.currentSession); err != nil { // Log error but don't fail the query - // TODO: Add proper logging + fmt.Fprintf(os.Stderr, "\n[WARNING] Failed to save conversation history: %v\n", err) + fmt.Fprintf(os.Stderr, "Your conversation may not be saved. Please check disk space and permissions.\n\n") } } @@ -109,7 +112,15 @@ func (ha *HistoryAgent) QueryStream(ctx context.Context, query string) (<-chan S }) // Save session - ha.historyManager.SaveSession(ha.currentSession) + 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) + } } } } From 5f9f79dcbc2f2a944814c8cd11788b2f8cb36935 Mon Sep 17 00:00:00 2001 From: Ignacio Alonso Date: Thu, 31 Jul 2025 11:18:38 -0600 Subject: [PATCH 5/6] fix: add proper locking to prevent race conditions in metadata access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add read locks to GetLastSessionForPath and ListSessionsForPath - Add write lock to updatePathIndex - Prevents concurrent access issues between readers and writers - Ensures metadata consistency across concurrent operations This fixes the race condition where readers could access stale or inconsistent metadata while SaveSession was updating it. Addresses PR review comment about session metadata access without locking. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- history/manager.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/history/manager.go b/history/manager.go index 69af9cc..c496512 100644 --- a/history/manager.go +++ b/history/manager.go @@ -140,7 +140,10 @@ func (m *Manager) LoadSession(id string) (*Session, error) { // 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) } @@ -157,7 +160,10 @@ func (m *Manager) GetLastSessionForPath(path string) (*Session, error) { // 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) } @@ -276,6 +282,9 @@ func (m *Manager) saveMeta(meta *MetaIndex) error { } func (m *Manager) updatePathIndex(path, sessionID string) error { + m.mu.Lock() + defer m.mu.Unlock() + meta, err := m.loadMeta() if err != nil { return err From e162798defd57e694ad4180a3f45e2e694b3c59d Mon Sep 17 00:00:00 2001 From: Ignacio Alonso Date: Thu, 31 Jul 2025 11:38:02 -0600 Subject: [PATCH 6/6] fix: capture complete conversation history including tool interactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, HistoryAgent was only saving the user message and final response, missing all intermediate tool calls and assistant reasoning. This fix ensures the complete agent memory is captured by syncing with GetMemory() after each query completes successfully. - Update Query method to capture full agent memory after successful completion - Update QueryStream method to capture full agent memory on EventTypeComplete - Sync session messages with agent's complete conversation history - Ensure all tool interactions are preserved for accurate session replay 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- agent/history_agent.go | 84 +++++++++++++++++++----------------------- 1 file changed, 38 insertions(+), 46 deletions(-) diff --git a/agent/history_agent.go b/agent/history_agent.go index 921509d..02e1dd8 100644 --- a/agent/history_agent.go +++ b/agent/history_agent.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "time" "github.com/nachoal/simple-agent-go/history" ) @@ -27,41 +26,33 @@ func NewHistoryAgent(agent Agent, historyManager *history.Manager, session *hist // Query sends a query and saves the conversation to history func (ha *HistoryAgent) Query(ctx context.Context, query string) (*Response, error) { - // Add user message to history + // Remember the initial message count to rollback on failure + initialMessageCount := 0 if ha.currentSession != nil { - ha.currentSession.Messages = append(ha.currentSession.Messages, history.Message{ - Role: "user", - Content: &query, - Timestamp: time.Now(), - }) + initialMessageCount = len(ha.currentSession.Messages) } - // Execute query + // Execute query first response, err := ha.Agent.Query(ctx, query) - // Add response to history - if ha.currentSession != nil && err == nil { - // Convert tool calls - var toolCalls []history.ToolCall - if len(response.ToolCalls) > 0 { - toolCalls = make([]history.ToolCall, 0) - // Note: ToolCalls in Response are ToolResult, not the actual calls - // We'll store the results as part of the message content - } + // 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() - ha.currentSession.Messages = append(ha.currentSession.Messages, history.Message{ - Role: "assistant", - Content: &response.Content, - ToolCalls: toolCalls, - Timestamp: time.Now(), - }) + // 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 - if err := ha.historyManager.SaveSession(ha.currentSession); err != nil { + // 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", err) + 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 @@ -69,13 +60,10 @@ func (ha *HistoryAgent) Query(ctx context.Context, query string) (*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) { - // Add user message to history + // Remember the initial message count to rollback on failure + initialMessageCount := 0 if ha.currentSession != nil { - ha.currentSession.Messages = append(ha.currentSession.Messages, history.Message{ - Role: "user", - Content: &query, - Timestamp: time.Now(), - }) + initialMessageCount = len(ha.currentSession.Messages) } // Get the stream @@ -90,28 +78,22 @@ func (ha *HistoryAgent) QueryStream(ctx context.Context, query string) (<-chan S go func() { defer close(intercepted) - var fullContent string - var toolCalls []history.ToolCall + streamSucceeded := false for event := range events { // Forward the event intercepted <- event - // Collect content for history + // Check for completion or error switch event.Type { - case EventTypeMessage: - fullContent += event.Content case EventTypeComplete: - // Save to history when complete - if ha.currentSession != nil && fullContent != "" { - ha.currentSession.Messages = append(ha.currentSession.Messages, history.Message{ - Role: "assistant", - Content: &fullContent, - ToolCalls: toolCalls, - Timestamp: time.Now(), - }) + 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 + // Save session with complete history if err := ha.historyManager.SaveSession(ha.currentSession); err != nil { // Send error event through the stream intercepted <- StreamEvent{ @@ -122,8 +104,18 @@ func (ha *HistoryAgent) QueryStream(ctx context.Context, query string) (<-chan S 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