Skip to main content

nexus_memory_agent/
session_manager.rs

1//! Manages session scratch files and learning extraction.
2
3use chrono::Utc;
4use regex::Regex;
5use std::fs;
6use std::io::{self, Write};
7use std::path::{Path, PathBuf};
8
9use crate::cognitive_cache::{ConfidenceTier, HotCache, HotCacheEntry};
10use crate::error::AgentError;
11
12/// Manages session-scoped scratch files for memory ingestion.
13pub struct SessionManager {
14    nexus_dir: PathBuf,
15}
16
17#[derive(Debug, Clone)]
18pub struct ScratchLearning {
19    pub content: String,
20    pub confidence: f32,
21}
22
23impl SessionManager {
24    /// Reject session IDs that could escape the sessions directory.
25    fn validate_session_id(id: &str) -> io::Result<()> {
26        if id.is_empty() || id.len() > 128 {
27            return Err(io::Error::new(
28                io::ErrorKind::InvalidInput,
29                "session_id must be 1-128 chars",
30            ));
31        }
32        if id.contains('/') || id.contains('\\') || id.contains("..") {
33            return Err(io::Error::new(
34                io::ErrorKind::InvalidInput,
35                "session_id contains invalid characters",
36            ));
37        }
38        Ok(())
39    }
40
41    /// Create a new session manager.
42    pub fn new(project_root: &Path) -> Self {
43        Self {
44            nexus_dir: project_root.join(".nexus"),
45        }
46    }
47
48    /// Start a new agent session and create a scratch file.
49    ///
50    /// If a scratch file already exists for this session (e.g. from a previous
51    /// session start in the same project), it is overwritten to reflect the new session.
52    pub fn start_session(&self, session_id: &str, agent_type: &str) -> io::Result<PathBuf> {
53        Self::validate_session_id(session_id)?;
54
55        let sessions_dir = self.nexus_dir.join("sessions");
56        fs::create_dir_all(&sessions_dir)?;
57
58        let scratch_path = sessions_dir.join(format!("{}.md", session_id));
59        let header = format!(
60            "---\nid: {}\nagent: {}\nstarted: {}\nstatus: active\n---\n\n# Session Learnings\n\n",
61            session_id,
62            agent_type,
63            Utc::now().to_rfc3339()
64        );
65
66        if scratch_path.exists() {
67            // Append separator rather than truncating existing learnings
68            let mut file = fs::OpenOptions::new().append(true).open(&scratch_path)?;
69            writeln!(file)?;
70            write!(file, "{}", &header)?;
71        } else {
72            let mut file = fs::OpenOptions::new()
73                .write(true)
74                .create(true)
75                .truncate(true)
76                .open(&scratch_path)?;
77            file.write_all(header.as_bytes())?;
78        }
79
80        Ok(scratch_path)
81    }
82
83    /// Append a learning entry to a session scratch file.
84    pub fn append_learning(
85        &self,
86        session_id: &str,
87        content: &str,
88        confidence: f32,
89    ) -> io::Result<()> {
90        let confidence = if confidence.is_finite() {
91            confidence.clamp(0.0, 1.0)
92        } else {
93            tracing::warn!("Non-finite confidence value in append_learning, defaulting to 0.5");
94            0.5
95        };
96
97        Self::validate_session_id(session_id)?;
98
99        let scratch_path = self
100            .nexus_dir
101            .join("sessions")
102            .join(format!("{}.md", session_id));
103        let mut file = fs::OpenOptions::new().append(true).open(scratch_path)?;
104
105        let entry = format!(
106            "- [confidence: {:.2}] {}\n",
107            confidence,
108            content.replace('\n', " ")
109        );
110        file.write_all(entry.as_bytes())?;
111
112        Ok(())
113    }
114
115    /// Merge learnings from a scratch file into the hot cache.
116    /// Does NOT rename the scratch file — caller must call mark_session_merged()
117    /// after persisting the cache to avoid data loss on save failure.
118    pub fn merge_session(
119        &self,
120        session_id: &str,
121        hot_cache: &mut HotCache,
122        max_entries: usize,
123    ) -> Result<usize, AgentError> {
124        Self::validate_session_id(session_id)?;
125
126        let sessions_dir = self.nexus_dir.join("sessions");
127        let scratch_path = sessions_dir.join(format!("{}.md", session_id));
128
129        if !scratch_path.exists() {
130            return Ok(0);
131        }
132
133        let content = fs::read_to_string(&scratch_path).map_err(AgentError::Io)?;
134
135        let learnings = parse_scratch_learnings(&content);
136        let mut inserted = 0;
137
138        for learning in learnings {
139            if promote_to_hot_cache(hot_cache, learning, max_entries) {
140                inserted += 1;
141            }
142        }
143
144        Ok(inserted)
145    }
146
147    /// Mark a session scratch file as merged (rename .md → .merged.md).
148    /// Call this AFTER cache persistence succeeds to avoid data loss.
149    pub fn mark_session_merged(&self, session_id: &str) -> Result<(), AgentError> {
150        Self::validate_session_id(session_id)?;
151        let sessions_dir = self.nexus_dir.join("sessions");
152        let scratch_path = sessions_dir.join(format!("{}.md", session_id));
153        if scratch_path.exists() {
154            let merged_path = sessions_dir.join(format!("{}.merged.md", session_id));
155            fs::rename(&scratch_path, &merged_path).map_err(AgentError::Io)?;
156        }
157        Ok(())
158    }
159
160    /// Clean up merged session files older than 7 days.
161    pub fn cleanup_old_sessions(&self) -> io::Result<usize> {
162        let sessions_dir = self.nexus_dir.join("sessions");
163        if !sessions_dir.exists() {
164            return Ok(0);
165        }
166
167        let mut count = 0;
168        let now = Utc::now();
169        let week_ago = now - chrono::Duration::days(7);
170
171        for entry in fs::read_dir(sessions_dir)? {
172            let entry = entry?;
173            let path = entry.path();
174            if path
175                .file_name()
176                .and_then(|name| name.to_str())
177                .is_some_and(|name| name.ends_with(".merged.md"))
178            {
179                let metadata = entry.metadata()?;
180                let modified: chrono::DateTime<Utc> = metadata.modified()?.into();
181                if modified < week_ago {
182                    fs::remove_file(path)?;
183                    count += 1;
184                }
185            }
186        }
187
188        Ok(count)
189    }
190}
191
192/// Parse learnings from scratch file content.
193pub fn parse_scratch_learnings(content: &str) -> Vec<ScratchLearning> {
194    static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
195    let re =
196        RE.get_or_init(|| Regex::new(r"- \[confidence: ([\d.]+)\] (.*)").expect("valid regex"));
197    let mut learnings = Vec::new();
198
199    for line in content.lines() {
200        if let Some(caps) = re.captures(line) {
201            if let Some(conf_str) = caps.get(1) {
202                if let Ok(conf) = conf_str.as_str().parse::<f32>() {
203                    if let Some(text_match) = caps.get(2) {
204                        learnings.push(ScratchLearning {
205                            content: text_match.as_str().to_string(),
206                            confidence: conf,
207                        });
208                    }
209                }
210            }
211        }
212    }
213    learnings
214}
215
216/// Promote a single learning to the hot cache.
217/// Uses UUID-based negative IDs that survive process restarts without collision.
218/// Returns true if the entry was inserted, false if the cache was full.
219pub fn promote_to_hot_cache(
220    hot: &mut HotCache,
221    learning: ScratchLearning,
222    max_entries: usize,
223) -> bool {
224    // Skip if content already in hot cache (idempotent retry safety)
225    if hot.entries.iter().any(|e| e.content == learning.content) {
226        return false;
227    }
228    let entry = HotCacheEntry {
229        // Mask UUID to 63 bits to fit i64, clamp to >=1 to avoid producing 0
230        // (which would remain 0 after negation), then negate so all IDs are
231        // strictly negative (range [-i64::MAX, -1]).
232        memory_id: {
233            let raw = (uuid::Uuid::new_v4().as_u128() & (i64::MAX as u128)) as i64;
234            -(raw.max(1))
235        },
236        content: learning.content,
237        relevance_score: learning.confidence,
238        tier: ConfidenceTier::from_score(learning.confidence),
239        promoted_at: Utc::now(),
240        last_surfaced: Utc::now(),
241        hot_streak: 1,
242        pinned: false,
243        source_agent: None,
244    };
245    hot.promote(entry, max_entries)
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use tempfile::tempdir;
252
253    #[test]
254    fn test_session_lifecycle() {
255        let dir = tempdir().unwrap();
256        let manager = SessionManager::new(dir.path());
257        let session_id = "test-session";
258
259        // 1. Start
260        let path = manager.start_session(session_id, "claude-code").unwrap();
261        assert!(path.exists());
262        let content = fs::read_to_string(&path).unwrap();
263        assert!(content.contains("agent: claude-code"));
264
265        // 2. Append
266        manager
267            .append_learning(session_id, "Found a pattern", 0.9)
268            .unwrap();
269        manager
270            .append_learning(session_id, "Another insight", 0.75)
271            .unwrap();
272
273        // 3. Merge
274        let mut hot = HotCache::default();
275        let count = manager.merge_session(session_id, &mut hot, 10).unwrap();
276        assert_eq!(count, 2);
277        assert_eq!(hot.entries.len(), 2);
278        assert!(hot.entries.iter().any(|e| e.content == "Found a pattern"));
279
280        // 4. Mark merged (normally done after cache save)
281        manager.mark_session_merged(session_id).unwrap();
282
283        // 5. Verification
284        assert!(!path.exists()); // Original scratch should be gone
285        let merged_path = dir
286            .path()
287            .join(".nexus/sessions")
288            .join(format!("{}.merged.md", session_id));
289        assert!(merged_path.exists());
290    }
291    #[test]
292    fn test_parse_scratch_learnings() {
293        let content = r#"---
294header: ignored
295---
296- [confidence: 0.95] Valid entry 1
297- [confidence: 0.50] Valid entry 2
298- malformed entry
299- [confidence: invalid] entry 3
300"#;
301        let learnings = parse_scratch_learnings(content);
302        assert_eq!(learnings.len(), 2);
303        assert_eq!(learnings[0].content, "Valid entry 1");
304        assert_eq!(learnings[0].confidence, 0.95);
305    }
306
307    #[test]
308    fn test_concurrent_sessions() {
309        let dir = tempdir().unwrap();
310        let manager = SessionManager::new(dir.path());
311
312        manager.start_session("s1", "a1").unwrap();
313        manager.start_session("s2", "a2").unwrap();
314
315        manager.append_learning("s1", "l1", 0.9).unwrap();
316        manager.append_learning("s2", "l2", 0.8).unwrap();
317
318        let mut hot = HotCache::default();
319        manager.merge_session("s1", &mut hot, 10).unwrap();
320        assert_eq!(hot.entries.len(), 1);
321        assert_eq!(hot.entries[0].content, "l1");
322
323        manager.merge_session("s2", &mut hot, 10).unwrap();
324        assert_eq!(hot.entries.len(), 2);
325        assert!(hot.entries.iter().any(|e| e.content == "l2"));
326    }
327}