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