syncable_cli/agent/
persistence.rs

1//! Session persistence for conversation history
2//!
3//! This module provides functionality to save and restore chat sessions,
4//! enabling users to resume previous conversations.
5//!
6//! ## Storage Location
7//! Sessions are stored in `~/.syncable/sessions/<project_hash>/session-{timestamp}-{uuid}.json`
8//!
9//! ## Features
10//! - Automatic session recording on each turn
11//! - Session listing and selection by UUID or index
12//! - Resume from "latest" or specific session
13
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use std::collections::hash_map::DefaultHasher;
17use std::fs;
18use std::hash::{Hash, Hasher};
19use std::io::{self, BufRead, Write};
20use std::path::{Path, PathBuf};
21use uuid::Uuid;
22
23use super::history::ToolCallRecord;
24
25/// Represents a complete conversation record stored on disk
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ConversationRecord {
28    /// Unique session identifier (UUID)
29    pub session_id: String,
30    /// Hash of the project path (for organizing sessions by project)
31    pub project_hash: String,
32    /// When the session started
33    pub start_time: DateTime<Utc>,
34    /// When the session was last updated
35    pub last_updated: DateTime<Utc>,
36    /// All messages in the conversation
37    pub messages: Vec<MessageRecord>,
38    /// Optional AI-generated summary
39    pub summary: Option<String>,
40}
41
42/// A single message in the conversation
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct MessageRecord {
45    /// Unique message ID
46    pub id: String,
47    /// When the message was created
48    pub timestamp: DateTime<Utc>,
49    /// Who sent the message
50    pub role: MessageRole,
51    /// The message content
52    pub content: String,
53    /// Tool calls made during this message (for assistant messages)
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub tool_calls: Option<Vec<SerializableToolCall>>,
56}
57
58/// Simplified tool call record for serialization
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct SerializableToolCall {
61    pub name: String,
62    pub args_summary: String,
63    pub result_summary: String,
64}
65
66impl From<&ToolCallRecord> for SerializableToolCall {
67    fn from(tc: &ToolCallRecord) -> Self {
68        Self {
69            name: tc.tool_name.clone(),
70            args_summary: tc.args_summary.clone(),
71            result_summary: tc.result_summary.clone(),
72        }
73    }
74}
75
76/// Role of the message sender
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78#[serde(rename_all = "lowercase")]
79pub enum MessageRole {
80    User,
81    Assistant,
82    System,
83}
84
85/// Session information for display and selection
86#[derive(Debug, Clone)]
87pub struct SessionInfo {
88    /// Unique session ID
89    pub id: String,
90    /// Path to the session file
91    pub file_path: PathBuf,
92    /// When the session started
93    pub start_time: DateTime<Utc>,
94    /// When the session was last updated
95    pub last_updated: DateTime<Utc>,
96    /// Number of messages
97    pub message_count: usize,
98    /// Display name (first user message or summary)
99    pub display_name: String,
100    /// 1-based index for selection
101    pub index: usize,
102}
103
104/// Records conversations to disk
105pub struct SessionRecorder {
106    session_id: String,
107    file_path: PathBuf,
108    record: ConversationRecord,
109}
110
111impl SessionRecorder {
112    /// Create a new session recorder for the given project
113    pub fn new(project_path: &Path) -> Self {
114        let session_id = Uuid::new_v4().to_string();
115        let project_hash = hash_project_path(project_path);
116        let start_time = Utc::now();
117
118        // Format: session-{timestamp}-{uuid_short}.json
119        let timestamp = start_time.format("%Y%m%d-%H%M%S").to_string();
120        let uuid_short = &session_id[..8];
121        let filename = format!("session-{}-{}.json", timestamp, uuid_short);
122
123        // Storage location: ~/.syncable/sessions/<project_hash>/
124        let sessions_dir = get_sessions_dir(&project_hash);
125        let file_path = sessions_dir.join(filename);
126
127        let record = ConversationRecord {
128            session_id: session_id.clone(),
129            project_hash,
130            start_time,
131            last_updated: start_time,
132            messages: Vec::new(),
133            summary: None,
134        };
135
136        Self {
137            session_id,
138            file_path,
139            record,
140        }
141    }
142
143    /// Get the session ID
144    pub fn session_id(&self) -> &str {
145        &self.session_id
146    }
147
148    /// Record a user message
149    pub fn record_user_message(&mut self, content: &str) {
150        let message = MessageRecord {
151            id: Uuid::new_v4().to_string(),
152            timestamp: Utc::now(),
153            role: MessageRole::User,
154            content: content.to_string(),
155            tool_calls: None,
156        };
157        self.record.messages.push(message);
158        self.record.last_updated = Utc::now();
159    }
160
161    /// Record an assistant message with optional tool calls
162    pub fn record_assistant_message(
163        &mut self,
164        content: &str,
165        tool_calls: Option<&[ToolCallRecord]>,
166    ) {
167        let serializable_tools =
168            tool_calls.map(|calls| calls.iter().map(SerializableToolCall::from).collect());
169
170        let message = MessageRecord {
171            id: Uuid::new_v4().to_string(),
172            timestamp: Utc::now(),
173            role: MessageRole::Assistant,
174            content: content.to_string(),
175            tool_calls: serializable_tools,
176        };
177        self.record.messages.push(message);
178        self.record.last_updated = Utc::now();
179    }
180
181    /// Save the session to disk
182    pub fn save(&self) -> io::Result<()> {
183        // Ensure directory exists
184        if let Some(parent) = self.file_path.parent() {
185            fs::create_dir_all(parent)?;
186        }
187
188        // Write JSON
189        let json = serde_json::to_string_pretty(&self.record)?;
190        fs::write(&self.file_path, json)?;
191        Ok(())
192    }
193
194    /// Check if the session has any messages
195    pub fn has_messages(&self) -> bool {
196        !self.record.messages.is_empty()
197    }
198
199    /// Get the number of messages
200    pub fn message_count(&self) -> usize {
201        self.record.messages.len()
202    }
203}
204
205/// Selects and loads sessions
206pub struct SessionSelector {
207    #[allow(dead_code)]
208    project_path: PathBuf,
209    project_hash: String,
210}
211
212impl SessionSelector {
213    /// Create a new session selector for the given project
214    pub fn new(project_path: &Path) -> Self {
215        let project_hash = hash_project_path(project_path);
216        Self {
217            project_path: project_path.to_path_buf(),
218            project_hash,
219        }
220    }
221
222    /// List all available sessions for this project, sorted by most recent first
223    pub fn list_sessions(&self) -> Vec<SessionInfo> {
224        let sessions_dir = get_sessions_dir(&self.project_hash);
225        if !sessions_dir.exists() {
226            return Vec::new();
227        }
228
229        let mut sessions: Vec<SessionInfo> = fs::read_dir(&sessions_dir)
230            .ok()
231            .into_iter()
232            .flatten()
233            .filter_map(|entry| entry.ok())
234            .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json"))
235            .filter_map(|entry| self.load_session_info(&entry.path()))
236            .collect();
237
238        // Sort by last_updated, most recent first
239        sessions.sort_by(|a, b| b.last_updated.cmp(&a.last_updated));
240
241        // Assign 1-based indices
242        for (i, session) in sessions.iter_mut().enumerate() {
243            session.index = i + 1;
244        }
245
246        sessions
247    }
248
249    /// Find a session by identifier (UUID, partial UUID, or numeric index)
250    pub fn find_session(&self, identifier: &str) -> Option<SessionInfo> {
251        let sessions = self.list_sessions();
252
253        // Try to parse as numeric index first
254        if let Ok(index) = identifier.parse::<usize>() {
255            if index > 0 && index <= sessions.len() {
256                return sessions.into_iter().nth(index - 1);
257            }
258        }
259
260        // Try to find by UUID or partial UUID
261        sessions
262            .into_iter()
263            .find(|s| s.id == identifier || s.id.starts_with(identifier))
264    }
265
266    /// Resolve "latest" or specific identifier to a session
267    pub fn resolve_session(&self, arg: &str) -> Option<SessionInfo> {
268        if arg == "latest" {
269            self.list_sessions().into_iter().next()
270        } else {
271            self.find_session(arg)
272        }
273    }
274
275    /// Load a full conversation record from a session
276    pub fn load_conversation(&self, session_info: &SessionInfo) -> io::Result<ConversationRecord> {
277        let content = fs::read_to_string(&session_info.file_path)?;
278        serde_json::from_str(&content).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
279    }
280
281    /// Load session info from a file path
282    fn load_session_info(&self, file_path: &Path) -> Option<SessionInfo> {
283        let content = fs::read_to_string(file_path).ok()?;
284        let record: ConversationRecord = serde_json::from_str(&content).ok()?;
285
286        // Get display name from summary or first user message
287        let display_name = record.summary.clone().unwrap_or_else(|| {
288            record
289                .messages
290                .iter()
291                .find(|m| m.role == MessageRole::User)
292                .map(|m| truncate_message(&m.content, 60))
293                .unwrap_or_else(|| "Empty session".to_string())
294        });
295
296        Some(SessionInfo {
297            id: record.session_id,
298            file_path: file_path.to_path_buf(),
299            start_time: record.start_time,
300            last_updated: record.last_updated,
301            message_count: record.messages.len(),
302            display_name,
303            index: 0, // Will be set by list_sessions
304        })
305    }
306}
307
308/// Get the sessions directory for a project
309fn get_sessions_dir(project_hash: &str) -> PathBuf {
310    dirs::home_dir()
311        .unwrap_or_else(|| PathBuf::from("."))
312        .join(".syncable")
313        .join("sessions")
314        .join(project_hash)
315}
316
317/// Hash a project path to create a consistent directory name
318fn hash_project_path(project_path: &Path) -> String {
319    let canonical = project_path
320        .canonicalize()
321        .unwrap_or_else(|_| project_path.to_path_buf());
322    let mut hasher = DefaultHasher::new();
323    canonical.hash(&mut hasher);
324    format!("{:016x}", hasher.finish())[..8].to_string()
325}
326
327/// Truncate a message for display
328fn truncate_message(msg: &str, max_len: usize) -> String {
329    // Clean up the message
330    let clean = msg.lines().next().unwrap_or(msg).trim();
331
332    if clean.len() <= max_len {
333        clean.to_string()
334    } else {
335        format!("{}...", &clean[..max_len.saturating_sub(3)])
336    }
337}
338
339/// Format relative time for display
340pub fn format_relative_time(time: DateTime<Utc>) -> String {
341    let now = Utc::now();
342    let duration = now.signed_duration_since(time);
343
344    if duration.num_seconds() < 60 {
345        "just now".to_string()
346    } else if duration.num_minutes() < 60 {
347        let mins = duration.num_minutes();
348        format!("{}m ago", mins)
349    } else if duration.num_hours() < 24 {
350        let hours = duration.num_hours();
351        format!("{}h ago", hours)
352    } else if duration.num_days() < 30 {
353        let days = duration.num_days();
354        format!("{}d ago", days)
355    } else {
356        time.format("%Y-%m-%d").to_string()
357    }
358}
359
360/// Display an interactive session browser and return the selected session
361pub fn browse_sessions(project_path: &Path) -> Option<SessionInfo> {
362    use colored::Colorize;
363
364    let selector = SessionSelector::new(project_path);
365    let sessions = selector.list_sessions();
366
367    if sessions.is_empty() {
368        println!(
369            "{}",
370            "No previous sessions found for this project.".yellow()
371        );
372        return None;
373    }
374
375    // Show sessions
376    println!();
377    println!(
378        "{}",
379        format!("Recent Sessions ({})", sessions.len())
380            .cyan()
381            .bold()
382    );
383    println!();
384
385    for session in &sessions {
386        let time = format_relative_time(session.last_updated);
387        let msg_count = session.message_count;
388
389        println!(
390            "  {} {} {}",
391            format!("[{}]", session.index).cyan(),
392            session.display_name.white(),
393            format!("({})", time).dimmed()
394        );
395        println!("      {} messages", msg_count.to_string().dimmed());
396    }
397
398    println!();
399    print!(
400        "{}",
401        "Enter number to resume, or press Enter to cancel: ".dimmed()
402    );
403    io::stdout().flush().ok()?;
404
405    // Read user input
406    let mut input = String::new();
407    io::stdin().lock().read_line(&mut input).ok()?;
408    let input = input.trim();
409
410    if input.is_empty() {
411        return None;
412    }
413
414    selector.find_session(input)
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use tempfile::tempdir;
421
422    #[test]
423    fn test_session_recorder() {
424        let temp_dir = tempdir().unwrap();
425        let project_path = temp_dir.path();
426
427        let mut recorder = SessionRecorder::new(project_path);
428        assert!(!recorder.has_messages());
429
430        recorder.record_user_message("Hello, world!");
431        assert!(recorder.has_messages());
432        assert_eq!(recorder.message_count(), 1);
433
434        recorder.record_assistant_message("Hello! How can I help?", None);
435        assert_eq!(recorder.message_count(), 2);
436
437        // Save and verify
438        recorder.save().unwrap();
439        assert!(recorder.file_path.exists());
440    }
441
442    #[test]
443    fn test_project_hash() {
444        let hash1 = hash_project_path(Path::new("/tmp/project1"));
445        let hash2 = hash_project_path(Path::new("/tmp/project2"));
446        let hash3 = hash_project_path(Path::new("/tmp/project1"));
447
448        assert_eq!(hash1.len(), 8);
449        assert_ne!(hash1, hash2);
450        assert_eq!(hash1, hash3);
451    }
452
453    #[test]
454    fn test_truncate_message() {
455        assert_eq!(truncate_message("short", 10), "short");
456        assert_eq!(truncate_message("this is a long message", 10), "this is...");
457        assert_eq!(truncate_message("line1\nline2\nline3", 100), "line1");
458    }
459
460    #[test]
461    fn test_format_relative_time() {
462        let now = Utc::now();
463        assert_eq!(format_relative_time(now), "just now");
464
465        let hour_ago = now - chrono::Duration::hours(1);
466        assert_eq!(format_relative_time(hour_ago), "1h ago");
467
468        let day_ago = now - chrono::Duration::days(1);
469        assert_eq!(format_relative_time(day_ago), "1d ago");
470    }
471}