Skip to main content

codetether_agent/session/
mod.rs

1//! Session management
2//!
3//! Sessions track the conversation history and state for agent interactions.
4
5use crate::agent::ToolUse;
6use crate::provider::{Message, Usage};
7use anyhow::Result;
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::path::PathBuf;
11use tokio::fs;
12use uuid::Uuid;
13
14/// A conversation session
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Session {
17    pub id: String,
18    pub title: Option<String>,
19    pub created_at: DateTime<Utc>,
20    pub updated_at: DateTime<Utc>,
21    pub messages: Vec<Message>,
22    pub tool_uses: Vec<ToolUse>,
23    pub usage: Usage,
24    pub agent: String,
25    pub metadata: SessionMetadata,
26}
27
28#[derive(Debug, Clone, Default, Serialize, Deserialize)]
29pub struct SessionMetadata {
30    pub directory: Option<PathBuf>,
31    pub model: Option<String>,
32    pub shared: bool,
33    pub share_url: Option<String>,
34}
35
36impl Session {
37    /// Create a new session
38    pub async fn new() -> Result<Self> {
39        let id = Uuid::new_v4().to_string();
40        let now = Utc::now();
41
42        Ok(Self {
43            id,
44            title: None,
45            created_at: now,
46            updated_at: now,
47            messages: Vec::new(),
48            tool_uses: Vec::new(),
49            usage: Usage::default(),
50            agent: "build".to_string(),
51            metadata: SessionMetadata {
52                directory: Some(std::env::current_dir()?),
53                ..Default::default()
54            },
55        })
56    }
57
58    /// Load an existing session
59    pub async fn load(id: &str) -> Result<Self> {
60        let path = Self::session_path(id)?;
61        let content = fs::read_to_string(&path).await?;
62        let session: Session = serde_json::from_str(&content)?;
63        Ok(session)
64    }
65
66    /// Load the last session
67    pub async fn last() -> Result<Self> {
68        let sessions_dir = Self::sessions_dir()?;
69        
70        if !sessions_dir.exists() {
71            anyhow::bail!("No sessions found");
72        }
73
74        let mut entries: Vec<tokio::fs::DirEntry> = Vec::new();
75        let mut read_dir = fs::read_dir(&sessions_dir).await?;
76        while let Some(entry) = read_dir.next_entry().await? {
77            entries.push(entry);
78        }
79
80        if entries.is_empty() {
81            anyhow::bail!("No sessions found");
82        }
83
84        // Sort by modification time (most recent first)
85        // Use std::fs::metadata since we can't await in sort_by_key
86        entries.sort_by_key(|e| {
87            std::cmp::Reverse(
88                std::fs::metadata(e.path())
89                    .ok()
90                    .and_then(|m| m.modified().ok())
91                    .unwrap_or(std::time::SystemTime::UNIX_EPOCH),
92            )
93        });
94
95        if let Some(entry) = entries.first() {
96            let content: String = fs::read_to_string(entry.path()).await?;
97            let session: Session = serde_json::from_str(&content)?;
98            return Ok(session);
99        }
100
101        anyhow::bail!("No sessions found")
102    }
103
104    /// Save the session to disk
105    pub async fn save(&self) -> Result<()> {
106        let path = Self::session_path(&self.id)?;
107        
108        if let Some(parent) = path.parent() {
109            fs::create_dir_all(parent).await?;
110        }
111
112        let content = serde_json::to_string_pretty(self)?;
113        fs::write(&path, content).await?;
114        
115        Ok(())
116    }
117
118    /// Add a message to the session
119    pub fn add_message(&mut self, message: Message) {
120        self.messages.push(message);
121        self.updated_at = Utc::now();
122    }
123
124    /// Execute a prompt and get the result
125    pub async fn prompt(&mut self, message: &str) -> Result<SessionResult> {
126        use crate::provider::{ContentPart, Role, ProviderRegistry, CompletionRequest, parse_model_string};
127        
128        // Load providers from Vault
129        let registry = ProviderRegistry::from_vault().await?;
130        
131        let providers = registry.list();
132        if providers.is_empty() {
133            anyhow::bail!("No providers available. Configure API keys in HashiCorp Vault.");
134        }
135
136        tracing::info!("Available providers: {:?}", providers);
137
138        // Parse model string (format: "provider/model" or just "model")
139        let (provider_name, model_id) = if let Some(ref model_str) = self.metadata.model {
140            let (prov, model) = parse_model_string(model_str);
141            (prov.map(|s| s.to_string()), model.to_string())
142        } else {
143            (None, String::new())
144        };
145
146        // Determine which provider to use
147        let selected_provider = provider_name.as_deref()
148            .filter(|p| providers.contains(p))
149            .unwrap_or(providers[0]);
150
151        let provider = registry.get(selected_provider)
152            .ok_or_else(|| anyhow::anyhow!("Provider {} not found", selected_provider))?;
153
154        // Add user message to session using add_message
155        self.add_message(Message {
156            role: Role::User,
157            content: vec![ContentPart::Text { text: message.to_string() }],
158        });
159
160        // Generate title if this is the first user message and no title exists
161        if self.title.is_none() {
162            self.generate_title().await?;
163        }
164
165        // Build messages from session
166        let messages = self.messages.clone();
167
168        // Determine model to use
169        let model = if !model_id.is_empty() {
170            model_id
171        } else {
172            // Default models per provider
173            match selected_provider {
174                "moonshotai" => "kimi-k2.5".to_string(),
175                "anthropic" => "claude-sonnet-4-20250514".to_string(),
176                "openai" => "gpt-4o".to_string(),
177                "google" => "gemini-2.5-pro".to_string(),
178                "openrouter" => "stepfun/step-3.5-flash:free".to_string(),
179                _ => "kimi-k2.5".to_string(),
180            }
181        };
182
183        // Kimi K2.5 requires temperature=1.0
184        let temperature = if model.starts_with("kimi-k2") {
185            Some(1.0)
186        } else {
187            Some(0.7)
188        };
189
190        tracing::info!("Using model: {} via provider: {}", model, selected_provider);
191
192        // Create completion request
193        let request = CompletionRequest {
194            messages,
195            tools: Vec::new(), // TODO: Add tools
196            model,
197            temperature,
198            top_p: None,
199            max_tokens: Some(4096),
200            stop: Vec::new(),
201        };
202
203        // Call the provider
204        let response = provider.complete(request).await?;
205
206        // Add assistant's response to session using add_message
207        self.add_message(response.message.clone());
208
209        // Save session after each prompt to persist messages
210        self.save().await?;
211
212        // Extract text from response
213        let text = response.message.content
214            .iter()
215            .filter_map(|p| match p {
216                ContentPart::Text { text } => Some(text.clone()),
217                _ => None,
218            })
219            .collect::<Vec<_>>()
220            .join("\n");
221
222        Ok(SessionResult {
223            text,
224            session_id: self.id.clone(),
225        })
226    }
227
228    /// Generate a title for the session based on the first message
229    /// Only sets title if not already set (for initial title generation)
230    pub async fn generate_title(&mut self) -> Result<()> {
231        if self.title.is_some() {
232            return Ok(());
233        }
234
235        // Get first user message
236        let first_message = self.messages.iter().find(|m| m.role == crate::provider::Role::User);
237        
238        if let Some(msg) = first_message {
239            let text: String = msg
240                .content
241                .iter()
242                .filter_map(|p| match p {
243                    crate::provider::ContentPart::Text { text } => Some(text.clone()),
244                    _ => None,
245                })
246                .collect::<Vec<_>>()
247                .join(" ");
248
249            // Truncate to reasonable length
250            self.title = Some(if text.len() > 50 {
251                format!("{}...", &text[..47])
252            } else {
253                text
254            });
255        }
256
257        Ok(())
258    }
259
260    /// Regenerate the title based on the first message, even if already set
261    /// Use this for on-demand title updates or after context changes
262    pub async fn regenerate_title(&mut self) -> Result<()> {
263        // Get first user message
264        let first_message = self.messages.iter().find(|m| m.role == crate::provider::Role::User);
265        
266        if let Some(msg) = first_message {
267            let text: String = msg
268                .content
269                .iter()
270                .filter_map(|p| match p {
271                    crate::provider::ContentPart::Text { text } => Some(text.clone()),
272                    _ => None,
273                })
274                .collect::<Vec<_>>()
275                .join(" ");
276
277            // Truncate to reasonable length
278            self.title = Some(if text.len() > 50 {
279                format!("{}...", &text[..47])
280            } else {
281                text
282            });
283        }
284
285        Ok(())
286    }
287
288    /// Set a custom title for the session
289    pub fn set_title(&mut self, title: impl Into<String>) {
290        self.title = Some(title.into());
291        self.updated_at = Utc::now();
292    }
293
294    /// Clear the title, allowing it to be regenerated
295    pub fn clear_title(&mut self) {
296        self.title = None;
297        self.updated_at = Utc::now();
298    }
299
300    /// Handle context change - updates metadata and optionally regenerates title
301    /// Call this when the session context changes (e.g., directory change, model change)
302    pub async fn on_context_change(&mut self, regenerate_title: bool) -> Result<()> {
303        self.updated_at = Utc::now();
304        
305        if regenerate_title {
306            self.regenerate_title().await?;
307        }
308
309        Ok(())
310    }
311
312    /// Get the sessions directory
313    fn sessions_dir() -> Result<PathBuf> {
314        crate::config::Config::data_dir()
315            .map(|d| d.join("sessions"))
316            .ok_or_else(|| anyhow::anyhow!("Could not determine data directory"))
317    }
318
319    /// Get the path for a session file
320    fn session_path(id: &str) -> Result<PathBuf> {
321        Ok(Self::sessions_dir()?.join(format!("{}.json", id)))
322    }
323}
324
325/// Result from a session prompt
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct SessionResult {
328    pub text: String,
329    pub session_id: String,
330}
331
332/// List all sessions
333pub async fn list_sessions() -> Result<Vec<SessionSummary>> {
334    let sessions_dir = crate::config::Config::data_dir()
335        .map(|d| d.join("sessions"))
336        .ok_or_else(|| anyhow::anyhow!("Could not determine data directory"))?;
337
338    if !sessions_dir.exists() {
339        return Ok(Vec::new());
340    }
341
342    let mut summaries = Vec::new();
343    let mut entries = fs::read_dir(&sessions_dir).await?;
344
345    while let Some(entry) = entries.next_entry().await? {
346        let path = entry.path();
347        if path.extension().map(|e| e == "json").unwrap_or(false) {
348            if let Ok(content) = fs::read_to_string(&path).await {
349                if let Ok(session) = serde_json::from_str::<Session>(&content) {
350                    summaries.push(SessionSummary {
351                        id: session.id,
352                        title: session.title,
353                        created_at: session.created_at,
354                        updated_at: session.updated_at,
355                        message_count: session.messages.len(),
356                        agent: session.agent,
357                    });
358                }
359            }
360        }
361    }
362
363    summaries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
364    Ok(summaries)
365}
366
367/// Summary of a session for listing
368#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct SessionSummary {
370    pub id: String,
371    pub title: Option<String>,
372    pub created_at: DateTime<Utc>,
373    pub updated_at: DateTime<Utc>,
374    pub message_count: usize,
375    pub agent: String,
376}
377
378// Async helper for Vec - kept for potential future use
379#[allow(dead_code)]
380use futures::StreamExt;
381
382#[allow(dead_code)]
383trait AsyncCollect<T> {
384    async fn collect(self) -> Vec<T>;
385}
386
387#[allow(dead_code)]
388impl<S, T> AsyncCollect<T> for S
389where
390    S: futures::Stream<Item = T> + Unpin,
391{
392    async fn collect(mut self) -> Vec<T> {
393        let mut items = Vec::new();
394        while let Some(item) = self.next().await {
395            items.push(item);
396        }
397        items
398    }
399}