nexus_memory_agent/
session_manager.rs1use 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
12pub 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 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 pub fn new(project_root: &Path) -> Self {
43 Self {
44 nexus_dir: project_root.join(".nexus"),
45 }
46 }
47
48 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 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 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 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 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 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
192pub 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
216pub fn promote_to_hot_cache(
220 hot: &mut HotCache,
221 learning: ScratchLearning,
222 max_entries: usize,
223) -> bool {
224 if hot.entries.iter().any(|e| e.content == learning.content) {
226 return false;
227 }
228 let entry = HotCacheEntry {
229 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 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 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 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 manager.mark_session_merged(session_id).unwrap();
282
283 assert!(!path.exists()); 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}