Skip to main content

aster/session/
resume.rs

1//! Session Resume Support
2//!
3//! Provides functionality for saving and loading session summaries,
4//! enabling context continuation when sessions run out of context.
5
6use crate::config::paths::Paths;
7use anyhow::Result;
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::fs;
11use std::path::PathBuf;
12
13/// Summary cache data structure
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SummaryCacheData {
16    /// Session UUID
17    pub uuid: String,
18    /// Summary text
19    pub summary: String,
20    /// Timestamp when summary was created
21    pub timestamp: DateTime<Utc>,
22    /// Number of conversation turns summarized
23    pub turn_count: Option<usize>,
24}
25
26/// Get the summaries directory path
27fn get_summaries_dir() -> PathBuf {
28    Paths::data_dir().join("sessions").join("summaries")
29}
30
31/// Ensure the summaries directory exists
32fn ensure_summaries_dir() -> Result<PathBuf> {
33    let dir = get_summaries_dir();
34    if !dir.exists() {
35        fs::create_dir_all(&dir)?;
36    }
37    Ok(dir)
38}
39
40/// Save a summary to cache
41///
42/// # Arguments
43/// * `session_id` - The session ID
44/// * `summary` - The summary text
45/// * `turn_count` - Optional number of turns summarized
46pub fn save_summary(session_id: &str, summary: &str, turn_count: Option<usize>) -> Result<()> {
47    let dir = ensure_summaries_dir()?;
48    let file_path = dir.join(format!("{}.json", session_id));
49
50    let data = SummaryCacheData {
51        uuid: session_id.to_string(),
52        summary: summary.to_string(),
53        timestamp: Utc::now(),
54        turn_count,
55    };
56
57    let json = serde_json::to_string_pretty(&data)?;
58    fs::write(&file_path, json)?;
59
60    Ok(())
61}
62
63/// Load a summary from cache
64///
65/// # Arguments
66/// * `session_id` - The session ID
67///
68/// # Returns
69/// The summary text if found, None otherwise
70pub fn load_summary(session_id: &str) -> Option<String> {
71    let dir = get_summaries_dir();
72    let file_path = dir.join(format!("{}.json", session_id));
73
74    if !file_path.exists() {
75        return None;
76    }
77
78    match fs::read_to_string(&file_path) {
79        Ok(content) => match serde_json::from_str::<SummaryCacheData>(&content) {
80            Ok(data) => Some(data.summary),
81            Err(e) => {
82                tracing::warn!("Failed to parse summary for session {}: {}", session_id, e);
83                None
84            }
85        },
86        Err(e) => {
87            tracing::warn!("Failed to read summary for session {}: {}", session_id, e);
88            None
89        }
90    }
91}
92
93/// Load full summary cache data
94///
95/// # Arguments
96/// * `session_id` - The session ID
97///
98/// # Returns
99/// The full summary cache data if found
100pub fn load_summary_data(session_id: &str) -> Option<SummaryCacheData> {
101    let dir = get_summaries_dir();
102    let file_path = dir.join(format!("{}.json", session_id));
103
104    if !file_path.exists() {
105        return None;
106    }
107
108    fs::read_to_string(&file_path)
109        .ok()
110        .and_then(|content| serde_json::from_str(&content).ok())
111}
112
113/// Check if a session has a cached summary
114///
115/// # Arguments
116/// * `session_id` - The session ID
117pub fn has_summary(session_id: &str) -> bool {
118    let dir = get_summaries_dir();
119    let file_path = dir.join(format!("{}.json", session_id));
120    file_path.exists()
121}
122
123/// Delete a summary from cache
124///
125/// # Arguments
126/// * `session_id` - The session ID
127pub fn delete_summary(session_id: &str) -> Result<()> {
128    let dir = get_summaries_dir();
129    let file_path = dir.join(format!("{}.json", session_id));
130
131    if file_path.exists() {
132        fs::remove_file(&file_path)?;
133    }
134
135    Ok(())
136}
137
138/// List all cached summaries
139///
140/// # Returns
141/// A vector of summary cache data
142pub fn list_summaries() -> Vec<SummaryCacheData> {
143    let dir = get_summaries_dir();
144
145    if !dir.exists() {
146        return Vec::new();
147    }
148
149    let mut summaries = Vec::new();
150
151    if let Ok(entries) = fs::read_dir(&dir) {
152        for entry in entries.flatten() {
153            let path = entry.path();
154            if path.extension().is_some_and(|ext| ext == "json") {
155                if let Ok(content) = fs::read_to_string(&path) {
156                    if let Ok(data) = serde_json::from_str::<SummaryCacheData>(&content) {
157                        summaries.push(data);
158                    }
159                }
160            }
161        }
162    }
163
164    // Sort by timestamp, newest first
165    summaries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
166    summaries
167}
168
169/// Build a resume message for continuing a session
170///
171/// When a session's context overflows and needs to continue,
172/// this message informs the AI about the previous conversation.
173///
174/// # Arguments
175/// * `summary` - The conversation summary
176/// * `is_non_interactive` - Whether this is a non-interactive session
177///
178/// # Returns
179/// The resume message text
180pub fn build_resume_message(summary: &str, is_non_interactive: bool) -> String {
181    let base = format!(
182        "This session is being continued from a previous conversation that ran out of context. \
183         The conversation is summarized below:\n{}",
184        summary
185    );
186
187    if is_non_interactive {
188        // Non-interactive mode: just add the summary
189        base
190    } else {
191        // Interactive mode: add continuation instructions
192        format!(
193            "{}\n\nPlease continue the conversation from where we left it off \
194             without asking the user any further questions. \
195             Continue with the last task that you were asked to work on.",
196            base
197        )
198    }
199}
200
201/// Clean up old summaries
202///
203/// # Arguments
204/// * `max_age_days` - Maximum age in days for summaries to keep
205///
206/// # Returns
207/// Number of summaries deleted
208pub fn cleanup_old_summaries(max_age_days: u32) -> Result<usize> {
209    let dir = get_summaries_dir();
210
211    if !dir.exists() {
212        return Ok(0);
213    }
214
215    let cutoff = Utc::now() - chrono::Duration::days(max_age_days as i64);
216    let mut deleted = 0;
217
218    if let Ok(entries) = fs::read_dir(&dir) {
219        for entry in entries.flatten() {
220            let path = entry.path();
221            if path.extension().is_some_and(|ext| ext == "json") {
222                if let Ok(content) = fs::read_to_string(&path) {
223                    if let Ok(data) = serde_json::from_str::<SummaryCacheData>(&content) {
224                        if data.timestamp < cutoff && fs::remove_file(&path).is_ok() {
225                            deleted += 1;
226                        }
227                    }
228                }
229            }
230        }
231    }
232
233    Ok(deleted)
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    #[allow(unused_imports)]
240    use tempfile::TempDir;
241
242    #[test]
243    fn test_build_resume_message_interactive() {
244        let summary = "User asked about Rust programming. Assistant explained ownership.";
245        let message = build_resume_message(summary, false);
246
247        assert!(message.contains(summary));
248        assert!(message.contains("continue the conversation"));
249        assert!(message.contains("without asking the user"));
250    }
251
252    #[test]
253    fn test_build_resume_message_non_interactive() {
254        let summary = "User asked about Rust programming.";
255        let message = build_resume_message(summary, true);
256
257        assert!(message.contains(summary));
258        assert!(!message.contains("without asking the user"));
259    }
260
261    #[test]
262    fn test_summary_cache_data_serialization() {
263        let data = SummaryCacheData {
264            uuid: "test_session_123".to_string(),
265            summary: "Test summary content".to_string(),
266            timestamp: Utc::now(),
267            turn_count: Some(10),
268        };
269
270        let json = serde_json::to_string(&data).unwrap();
271        let deserialized: SummaryCacheData = serde_json::from_str(&json).unwrap();
272
273        assert_eq!(deserialized.uuid, data.uuid);
274        assert_eq!(deserialized.summary, data.summary);
275        assert_eq!(deserialized.turn_count, Some(10));
276    }
277}