Skip to main content

agentic_memory_mcp/session/
manager.rs

1//! Graph lifecycle management, file I/O, and session tracking.
2
3use std::ffi::OsStr;
4use std::io::Read as _;
5use std::path::{Path, PathBuf};
6use std::time::{Duration, Instant};
7
8use agentic_memory::{
9    AmemReader, AmemWriter, CognitiveEventBuilder, Edge, EdgeType, EventType, MemoryGraph,
10    QueryEngine, WriteEngine,
11};
12
13use crate::types::{McpError, McpResult};
14
15/// Default auto-save interval.
16const DEFAULT_AUTO_SAVE_SECS: u64 = 30;
17/// Default backup interval.
18const DEFAULT_BACKUP_INTERVAL_SECS: u64 = 900;
19/// Default number of backups to retain per brain file.
20const DEFAULT_BACKUP_RETENTION: usize = 24;
21/// Default maintenance sleep-cycle interval.
22const DEFAULT_SLEEP_CYCLE_SECS: u64 = 1800;
23/// Minimum completed-session size before auto-archive.
24const DEFAULT_ARCHIVE_MIN_SESSION_NODES: usize = 25;
25/// Default hot-tier threshold (decay score).
26const DEFAULT_HOT_MIN_DECAY: f32 = 0.7;
27/// Default warm-tier threshold (decay score).
28const DEFAULT_WARM_MIN_DECAY: f32 = 0.3;
29/// Default sustained mutation rate threshold before throttling heavy maintenance.
30const DEFAULT_SLA_MAX_MUTATIONS_PER_MIN: u32 = 240;
31/// Default interval for writing health-ledger snapshots.
32const DEFAULT_HEALTH_LEDGER_EMIT_SECS: u64 = 30;
33/// Current `.amem` storage version used by this server.
34const CURRENT_AMEM_VERSION: u32 = 1;
35
36#[derive(Debug, Clone, Copy)]
37enum AutonomicProfile {
38    Desktop,
39    Cloud,
40    Aggressive,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44enum StorageMigrationPolicy {
45    AutoSafe,
46    Strict,
47    Off,
48}
49
50#[derive(Debug, Clone, Copy)]
51struct ProfileDefaults {
52    auto_save_secs: u64,
53    backup_secs: u64,
54    backup_retention: usize,
55    sleep_cycle_secs: u64,
56    sleep_idle_secs: u64,
57    archive_min_session_nodes: usize,
58    hot_min_decay: f32,
59    warm_min_decay: f32,
60    sla_max_mutations_per_min: u32,
61}
62
63impl AutonomicProfile {
64    fn from_env(name: &str) -> Self {
65        let raw = read_env_string(name).unwrap_or_else(|| "desktop".to_string());
66        match raw.trim().to_ascii_lowercase().as_str() {
67            "cloud" => Self::Cloud,
68            "aggressive" => Self::Aggressive,
69            _ => Self::Desktop,
70        }
71    }
72
73    fn defaults(self) -> ProfileDefaults {
74        match self {
75            Self::Desktop => ProfileDefaults {
76                auto_save_secs: DEFAULT_AUTO_SAVE_SECS,
77                backup_secs: DEFAULT_BACKUP_INTERVAL_SECS,
78                backup_retention: DEFAULT_BACKUP_RETENTION,
79                sleep_cycle_secs: DEFAULT_SLEEP_CYCLE_SECS,
80                sleep_idle_secs: 180,
81                archive_min_session_nodes: DEFAULT_ARCHIVE_MIN_SESSION_NODES,
82                hot_min_decay: DEFAULT_HOT_MIN_DECAY,
83                warm_min_decay: DEFAULT_WARM_MIN_DECAY,
84                sla_max_mutations_per_min: DEFAULT_SLA_MAX_MUTATIONS_PER_MIN,
85            },
86            Self::Cloud => ProfileDefaults {
87                auto_save_secs: 15,
88                backup_secs: 600,
89                backup_retention: 48,
90                sleep_cycle_secs: 900,
91                sleep_idle_secs: 90,
92                archive_min_session_nodes: 50,
93                hot_min_decay: 0.75,
94                warm_min_decay: 0.4,
95                sla_max_mutations_per_min: 600,
96            },
97            Self::Aggressive => ProfileDefaults {
98                auto_save_secs: 10,
99                backup_secs: 300,
100                backup_retention: 16,
101                sleep_cycle_secs: 300,
102                sleep_idle_secs: 45,
103                archive_min_session_nodes: 15,
104                hot_min_decay: 0.8,
105                warm_min_decay: 0.5,
106                sla_max_mutations_per_min: 900,
107            },
108        }
109    }
110
111    fn as_str(self) -> &'static str {
112        match self {
113            Self::Desktop => "desktop",
114            Self::Cloud => "cloud",
115            Self::Aggressive => "aggressive",
116        }
117    }
118}
119
120impl StorageMigrationPolicy {
121    fn from_env(name: &str) -> Self {
122        let raw = read_env_string(name).unwrap_or_else(|| "auto-safe".to_string());
123        match raw.trim().to_ascii_lowercase().as_str() {
124            "strict" => Self::Strict,
125            "off" | "disabled" | "none" => Self::Off,
126            _ => Self::AutoSafe,
127        }
128    }
129
130    fn as_str(self) -> &'static str {
131        match self {
132            Self::AutoSafe => "auto-safe",
133            Self::Strict => "strict",
134            Self::Off => "off",
135        }
136    }
137}
138
139/// Manages the memory graph lifecycle, file I/O, and session state.
140pub struct SessionManager {
141    graph: MemoryGraph,
142    query_engine: QueryEngine,
143    write_engine: WriteEngine,
144    file_path: PathBuf,
145    current_session: u32,
146    profile: AutonomicProfile,
147    migration_policy: StorageMigrationPolicy,
148    dirty: bool,
149    last_save: Instant,
150    auto_save_interval: Duration,
151    backup_interval: Duration,
152    backup_retention: usize,
153    backups_dir: PathBuf,
154    save_generation: u64,
155    last_backup_generation: u64,
156    last_backup: Instant,
157    sleep_cycle_interval: Duration,
158    archive_min_session_nodes: usize,
159    hot_min_decay: f32,
160    warm_min_decay: f32,
161    sla_max_mutations_per_min: u32,
162    last_sleep_cycle: Instant,
163    sleep_idle_min: Duration,
164    last_activity: Instant,
165    mutation_window_started: Instant,
166    mutation_window_count: u32,
167    maintenance_throttle_count: u64,
168    last_health_ledger_emit: Instant,
169    health_ledger_emit_interval: Duration,
170}
171
172impl SessionManager {
173    /// Open or create a memory file at the given path.
174    pub fn open(path: &str) -> McpResult<Self> {
175        let file_path = PathBuf::from(path);
176        let dimension = agentic_memory::DEFAULT_DIMENSION;
177        let file_existed = file_path.exists();
178        let profile = AutonomicProfile::from_env("AMEM_AUTONOMIC_PROFILE");
179        let defaults = profile.defaults();
180        let migration_policy = StorageMigrationPolicy::from_env("AMEM_STORAGE_MIGRATION_POLICY");
181        let detected_version = if file_existed {
182            read_storage_version(&file_path)
183        } else {
184            None
185        };
186        let legacy_version = detected_version.filter(|v| *v < CURRENT_AMEM_VERSION);
187
188        let graph = if file_existed {
189            tracing::info!("Opening existing memory file: {}", file_path.display());
190            AmemReader::read_from_file(&file_path)
191                .map_err(|e| McpError::AgenticMemory(format!("Failed to read memory file: {e}")))?
192        } else {
193            tracing::info!("Creating new memory file: {}", file_path.display());
194            // Ensure parent directory exists
195            if let Some(parent) = file_path.parent() {
196                std::fs::create_dir_all(parent).map_err(|e| {
197                    McpError::Io(std::io::Error::other(format!(
198                        "Failed to create directory {}: {e}",
199                        parent.display()
200                    )))
201                })?;
202            }
203            MemoryGraph::new(dimension)
204        };
205
206        // Determine the next session ID from existing sessions
207        let session_ids = graph.session_index().session_ids();
208        let current_session = session_ids.iter().copied().max().unwrap_or(0) + 1;
209
210        tracing::info!(
211            "Session {} started. Graph has {} nodes, {} edges.",
212            current_session,
213            graph.node_count(),
214            graph.edge_count()
215        );
216        tracing::info!(
217            "Autonomic profile={} migration_policy={}",
218            profile.as_str(),
219            migration_policy.as_str()
220        );
221
222        let auto_save_secs = read_env_u64("AMEM_AUTOSAVE_SECS", defaults.auto_save_secs);
223        let backup_secs = read_env_u64("AMEM_AUTO_BACKUP_SECS", defaults.backup_secs).max(30);
224        let backup_retention =
225            read_env_usize("AMEM_AUTO_BACKUP_RETENTION", defaults.backup_retention).max(1);
226        let backups_dir = resolve_backups_dir(&file_path);
227        let sleep_cycle_secs =
228            read_env_u64("AMEM_SLEEP_CYCLE_SECS", defaults.sleep_cycle_secs).max(60);
229        let sleep_idle_secs =
230            read_env_u64("AMEM_SLEEP_IDLE_SECS", defaults.sleep_idle_secs).max(30);
231        let archive_min_session_nodes = read_env_usize(
232            "AMEM_ARCHIVE_MIN_SESSION_NODES",
233            defaults.archive_min_session_nodes,
234        )
235        .max(1);
236        let hot_min_decay =
237            read_env_f32("AMEM_TIER_HOT_MIN_DECAY", defaults.hot_min_decay).clamp(0.0, 1.0);
238        let warm_min_decay = read_env_f32("AMEM_TIER_WARM_MIN_DECAY", defaults.warm_min_decay)
239            .clamp(0.0, 1.0)
240            .min(hot_min_decay);
241        let sla_max_mutations_per_min = read_env_u32(
242            "AMEM_SLA_MAX_MUTATIONS_PER_MIN",
243            defaults.sla_max_mutations_per_min,
244        )
245        .max(1);
246        let health_ledger_emit_interval = Duration::from_secs(
247            read_env_u64(
248                "AMEM_HEALTH_LEDGER_EMIT_SECS",
249                DEFAULT_HEALTH_LEDGER_EMIT_SECS,
250            )
251            .max(5),
252        );
253
254        let mut manager = Self {
255            graph,
256            query_engine: QueryEngine::new(),
257            write_engine: WriteEngine::new(dimension),
258            file_path,
259            current_session,
260            profile,
261            migration_policy,
262            dirty: false,
263            last_save: Instant::now(),
264            auto_save_interval: Duration::from_secs(auto_save_secs),
265            backup_interval: Duration::from_secs(backup_secs),
266            backup_retention,
267            backups_dir,
268            save_generation: if file_existed { 1 } else { 0 },
269            last_backup_generation: 0,
270            last_backup: Instant::now(),
271            sleep_cycle_interval: Duration::from_secs(sleep_cycle_secs),
272            archive_min_session_nodes,
273            hot_min_decay,
274            warm_min_decay,
275            sla_max_mutations_per_min,
276            last_sleep_cycle: Instant::now(),
277            sleep_idle_min: Duration::from_secs(sleep_idle_secs),
278            last_activity: Instant::now(),
279            mutation_window_started: Instant::now(),
280            mutation_window_count: 0,
281            maintenance_throttle_count: 0,
282            last_health_ledger_emit: Instant::now()
283                .checked_sub(health_ledger_emit_interval)
284                .unwrap_or_else(Instant::now),
285            health_ledger_emit_interval,
286        };
287
288        if let Some(version) = legacy_version {
289            match migration_policy {
290                StorageMigrationPolicy::Strict => {
291                    return Err(McpError::AgenticMemory(format!(
292                        "Legacy .amem version {} blocked by strict migration policy",
293                        version
294                    )));
295                }
296                StorageMigrationPolicy::Off => {
297                    tracing::warn!(
298                        "Legacy storage version detected (v{}), auto-migration disabled by policy",
299                        version
300                    );
301                }
302                StorageMigrationPolicy::AutoSafe => {
303                    if let Some(checkpoint) = manager.create_migration_checkpoint(version)? {
304                        tracing::info!(
305                            "Legacy storage version detected (v{}), checkpoint created at {}",
306                            version,
307                            checkpoint.display()
308                        );
309                    }
310                    manager.dirty = true;
311                    manager.save()?;
312                    tracing::info!(
313                        "Auto-migrated memory storage from v{} to v{} at {}",
314                        version,
315                        CURRENT_AMEM_VERSION,
316                        manager.file_path.display()
317                    );
318                }
319            }
320        }
321
322        Ok(manager)
323    }
324
325    /// Get an immutable reference to the graph.
326    pub fn graph(&self) -> &MemoryGraph {
327        &self.graph
328    }
329
330    /// Get a mutable reference to the graph and mark as dirty.
331    pub fn graph_mut(&mut self) -> &mut MemoryGraph {
332        self.dirty = true;
333        self.last_activity = Instant::now();
334        self.record_mutation();
335        &mut self.graph
336    }
337
338    /// Get the query engine.
339    pub fn query_engine(&self) -> &QueryEngine {
340        &self.query_engine
341    }
342
343    /// Get the write engine.
344    pub fn write_engine(&self) -> &WriteEngine {
345        &self.write_engine
346    }
347
348    /// Current session ID.
349    pub fn current_session_id(&self) -> u32 {
350        self.current_session
351    }
352
353    /// Start a new session, optionally with an explicit ID.
354    pub fn start_session(&mut self, explicit_id: Option<u32>) -> McpResult<u32> {
355        let session_id = explicit_id.unwrap_or_else(|| {
356            let ids = self.graph.session_index().session_ids();
357            ids.iter().copied().max().unwrap_or(0) + 1
358        });
359
360        self.current_session = session_id;
361        self.last_activity = Instant::now();
362        tracing::info!("Started session {session_id}");
363        Ok(session_id)
364    }
365
366    /// End a session and optionally create an episode summary.
367    pub fn end_session_with_episode(&mut self, session_id: u32, summary: &str) -> McpResult<u64> {
368        let episode_id = self
369            .write_engine
370            .compress_session(&mut self.graph, session_id, summary)
371            .map_err(|e| McpError::AgenticMemory(format!("Failed to compress session: {e}")))?;
372
373        self.dirty = true;
374        self.last_activity = Instant::now();
375        self.record_mutation();
376        self.save()?;
377
378        tracing::info!("Ended session {session_id}, created episode node {episode_id}");
379
380        Ok(episode_id)
381    }
382
383    /// Save the graph to file.
384    pub fn save(&mut self) -> McpResult<()> {
385        if !self.dirty {
386            return Ok(());
387        }
388
389        let writer = AmemWriter::new(self.graph.dimension());
390        writer
391            .write_to_file(&self.graph, &self.file_path)
392            .map_err(|e| McpError::AgenticMemory(format!("Failed to write memory file: {e}")))?;
393
394        self.dirty = false;
395        self.last_save = Instant::now();
396        self.save_generation = self.save_generation.saturating_add(1);
397        tracing::debug!("Saved memory file: {}", self.file_path.display());
398        Ok(())
399    }
400
401    /// Check if auto-save is needed and save if so.
402    pub fn maybe_auto_save(&mut self) -> McpResult<()> {
403        if self.dirty && self.last_save.elapsed() >= self.auto_save_interval {
404            self.save()?;
405        }
406        Ok(())
407    }
408
409    /// Runs autonomous maintenance: sleep-cycle, auto-save, and periodic backup.
410    pub fn run_maintenance_tick(&mut self) -> McpResult<()> {
411        if self.should_throttle_maintenance() {
412            self.maintenance_throttle_count = self.maintenance_throttle_count.saturating_add(1);
413            self.maybe_auto_save()?;
414            self.emit_health_ledger("throttled")?;
415            tracing::debug!(
416                "Maintenance throttled by SLA guard: mutation_rate={} threshold={}",
417                self.mutation_rate_per_min(),
418                self.sla_max_mutations_per_min
419            );
420            return Ok(());
421        }
422
423        self.maybe_run_sleep_cycle()?;
424        self.maybe_auto_save()?;
425        self.maybe_auto_backup()?;
426        self.emit_health_ledger("normal")?;
427        Ok(())
428    }
429
430    /// Run a periodic sleep-cycle: decay refresh + tier balancing + auto-archive.
431    pub fn maybe_run_sleep_cycle(&mut self) -> McpResult<()> {
432        if self.last_sleep_cycle.elapsed() < self.sleep_cycle_interval {
433            return Ok(());
434        }
435        if self.last_activity.elapsed() < self.sleep_idle_min {
436            return Ok(());
437        }
438
439        let now = agentic_memory::now_micros();
440        let decay_report = self
441            .write_engine
442            .run_decay(&mut self.graph, now)
443            .map_err(|e| McpError::AgenticMemory(format!("Sleep-cycle decay failed: {e}")))?;
444        let archived_sessions = self.auto_archive_completed_sessions()?;
445
446        if decay_report.nodes_decayed > 0 || archived_sessions > 0 {
447            self.dirty = true;
448            self.save()?;
449        }
450
451        let (hot, warm, cold) = self.tier_counts();
452        self.last_sleep_cycle = Instant::now();
453        tracing::info!(
454            "Sleep-cycle complete: decayed={} archived_sessions={} tiers(h/w/c)={}/{}/{}",
455            decay_report.nodes_decayed,
456            archived_sessions,
457            hot,
458            warm,
459            cold
460        );
461        Ok(())
462    }
463
464    /// Periodic backup of persisted state with retention pruning.
465    pub fn maybe_auto_backup(&mut self) -> McpResult<()> {
466        if self.last_backup.elapsed() < self.backup_interval {
467            return Ok(());
468        }
469        if self.save_generation <= self.last_backup_generation {
470            return Ok(());
471        }
472        if !self.file_path.exists() {
473            return Ok(());
474        }
475
476        std::fs::create_dir_all(&self.backups_dir).map_err(McpError::Io)?;
477        let backup_path = self.next_backup_path();
478        std::fs::copy(&self.file_path, &backup_path).map_err(McpError::Io)?;
479        self.last_backup_generation = self.save_generation;
480        self.last_backup = Instant::now();
481        self.prune_old_backups()?;
482        tracing::info!("Auto-backup written: {}", backup_path.display());
483        Ok(())
484    }
485
486    /// Mark the graph as dirty (needs saving).
487    pub fn mark_dirty(&mut self) {
488        self.dirty = true;
489        self.last_activity = Instant::now();
490        self.record_mutation();
491    }
492
493    /// Get the file path.
494    pub fn file_path(&self) -> &PathBuf {
495        &self.file_path
496    }
497
498    /// Background maintenance loop interval.
499    pub fn maintenance_interval(&self) -> Duration {
500        self.auto_save_interval
501            .min(self.backup_interval)
502            .min(self.sleep_cycle_interval)
503    }
504
505    /// Add a cognitive event to the graph.
506    pub fn add_event(
507        &mut self,
508        event_type: EventType,
509        content: &str,
510        confidence: f32,
511        edges: Vec<(u64, EdgeType, f32)>,
512    ) -> McpResult<(u64, usize)> {
513        let event = CognitiveEventBuilder::new(event_type, content.to_string())
514            .session_id(self.current_session)
515            .confidence(confidence)
516            .build();
517
518        // First, add the node to get its assigned ID
519        let result = self
520            .write_engine
521            .ingest(&mut self.graph, vec![event], vec![])
522            .map_err(|e| McpError::AgenticMemory(format!("Failed to add event: {e}")))?;
523
524        let node_id = result.new_node_ids.first().copied().ok_or_else(|| {
525            McpError::InternalError("No node ID returned from ingest".to_string())
526        })?;
527
528        // Then add edges with the correct source_id
529        let mut edge_count = 0;
530        for (target_id, edge_type, weight) in &edges {
531            let edge = Edge::new(node_id, *target_id, *edge_type, *weight);
532            self.graph
533                .add_edge(edge)
534                .map_err(|e| McpError::AgenticMemory(format!("Failed to add edge: {e}")))?;
535            edge_count += 1;
536        }
537
538        self.dirty = true;
539        self.last_activity = Instant::now();
540        self.record_mutation();
541        self.maybe_auto_save()?;
542
543        Ok((node_id, edge_count))
544    }
545
546    /// Correct a previous belief.
547    pub fn correct_node(&mut self, old_node_id: u64, new_content: &str) -> McpResult<u64> {
548        let new_id = self
549            .write_engine
550            .correct(
551                &mut self.graph,
552                old_node_id,
553                new_content,
554                self.current_session,
555            )
556            .map_err(|e| McpError::AgenticMemory(format!("Failed to correct node: {e}")))?;
557
558        self.dirty = true;
559        self.last_activity = Instant::now();
560        self.record_mutation();
561        self.maybe_auto_save()?;
562
563        Ok(new_id)
564    }
565
566    fn record_mutation(&mut self) {
567        if self.mutation_window_started.elapsed() >= Duration::from_secs(60) {
568            self.mutation_window_started = Instant::now();
569            self.mutation_window_count = 0;
570        }
571        self.mutation_window_count = self.mutation_window_count.saturating_add(1);
572    }
573
574    fn mutation_rate_per_min(&self) -> u32 {
575        let elapsed = self.mutation_window_started.elapsed().as_secs().max(1);
576        let scaled = (self.mutation_window_count as u64)
577            .saturating_mul(60)
578            .saturating_div(elapsed);
579        scaled.min(u32::MAX as u64) as u32
580    }
581
582    fn should_throttle_maintenance(&self) -> bool {
583        self.mutation_rate_per_min() > self.sla_max_mutations_per_min
584    }
585
586    fn emit_health_ledger(&mut self, maintenance_mode: &str) -> McpResult<()> {
587        if self.last_health_ledger_emit.elapsed() < self.health_ledger_emit_interval {
588            return Ok(());
589        }
590
591        let dir = resolve_health_ledger_dir();
592        std::fs::create_dir_all(&dir).map_err(McpError::Io)?;
593        let path = dir.join("agentic-memory.json");
594        let tmp = dir.join("agentic-memory.json.tmp");
595        let (hot, warm, cold) = self.tier_counts();
596        let payload = serde_json::json!({
597            "project": "AgenticMemory",
598            "timestamp": chrono::Utc::now().to_rfc3339(),
599            "status": "ok",
600            "autonomic": {
601                "profile": self.profile.as_str(),
602                "migration_policy": self.migration_policy.as_str(),
603                "maintenance_mode": maintenance_mode,
604                "throttle_count": self.maintenance_throttle_count,
605            },
606            "sla": {
607                "mutation_rate_per_min": self.mutation_rate_per_min(),
608                "max_mutations_per_min": self.sla_max_mutations_per_min
609            },
610            "storage": {
611                "file": self.file_path.display().to_string(),
612                "dirty": self.dirty,
613                "save_generation": self.save_generation,
614                "backup_retention": self.backup_retention,
615            },
616            "graph": {
617                "nodes": self.graph.node_count(),
618                "edges": self.graph.edge_count(),
619                "tiers": {
620                    "hot": hot,
621                    "warm": warm,
622                    "cold": cold,
623                },
624            },
625        });
626        let bytes = serde_json::to_vec_pretty(&payload).map_err(|e| {
627            McpError::AgenticMemory(format!("Failed to encode health ledger payload: {e}"))
628        })?;
629        std::fs::write(&tmp, bytes).map_err(McpError::Io)?;
630        std::fs::rename(&tmp, &path).map_err(McpError::Io)?;
631        self.last_health_ledger_emit = Instant::now();
632        Ok(())
633    }
634}
635
636impl Drop for SessionManager {
637    fn drop(&mut self) {
638        if self.dirty {
639            if let Err(e) = self.save() {
640                tracing::error!("Failed to save on drop: {e}");
641            }
642        }
643        if let Err(e) = self.maybe_auto_backup() {
644            tracing::error!("Failed auto-backup on drop: {e}");
645        }
646    }
647}
648
649impl SessionManager {
650    fn auto_archive_completed_sessions(&mut self) -> McpResult<usize> {
651        let mut session_ids = self.graph.session_index().session_ids();
652        session_ids.sort_unstable();
653
654        let mut archived = 0usize;
655        for session_id in session_ids {
656            if session_id >= self.current_session {
657                continue;
658            }
659
660            let node_ids = self.graph.session_index().get_session(session_id).to_vec();
661            if node_ids.is_empty() {
662                continue;
663            }
664
665            let mut has_episode = false;
666            let mut event_nodes = 0usize;
667            let mut hot = 0usize;
668            let mut warm = 0usize;
669            let mut cold = 0usize;
670
671            for node_id in &node_ids {
672                if let Some(node) = self.graph.get_node(*node_id) {
673                    if node.event_type == EventType::Episode {
674                        has_episode = true;
675                        continue;
676                    }
677                    event_nodes += 1;
678                    if node.decay_score >= self.hot_min_decay {
679                        hot += 1;
680                    } else if node.decay_score >= self.warm_min_decay {
681                        warm += 1;
682                    } else {
683                        cold += 1;
684                    }
685                }
686            }
687
688            if has_episode || event_nodes < self.archive_min_session_nodes {
689                continue;
690            }
691
692            let summary = format!(
693                "Auto-archive session {}: {} events ({} hot / {} warm / {} cold)",
694                session_id, event_nodes, hot, warm, cold
695            );
696            self.write_engine
697                .compress_session(&mut self.graph, session_id, &summary)
698                .map_err(|e| {
699                    McpError::AgenticMemory(format!(
700                        "Auto-archive failed for session {session_id}: {e}"
701                    ))
702                })?;
703            archived = archived.saturating_add(1);
704        }
705
706        Ok(archived)
707    }
708
709    fn tier_counts(&self) -> (usize, usize, usize) {
710        let mut hot = 0usize;
711        let mut warm = 0usize;
712        let mut cold = 0usize;
713
714        for node in self.graph.nodes() {
715            if node.event_type == EventType::Episode {
716                continue;
717            }
718            if node.decay_score >= self.hot_min_decay {
719                hot += 1;
720            } else if node.decay_score >= self.warm_min_decay {
721                warm += 1;
722            } else {
723                cold += 1;
724            }
725        }
726
727        (hot, warm, cold)
728    }
729
730    fn create_migration_checkpoint(&self, from_version: u32) -> McpResult<Option<PathBuf>> {
731        if !self.file_path.exists() {
732            return Ok(None);
733        }
734
735        let migration_dir = resolve_migration_dir(&self.file_path);
736        std::fs::create_dir_all(&migration_dir).map_err(McpError::Io)?;
737
738        let ts = chrono::Utc::now().format("%Y%m%d%H%M%S");
739        let stem = self
740            .file_path
741            .file_stem()
742            .and_then(OsStr::to_str)
743            .unwrap_or("brain");
744        let checkpoint = migration_dir.join(format!("{stem}.v{from_version}.{ts}.amem.checkpoint"));
745        std::fs::copy(&self.file_path, &checkpoint).map_err(McpError::Io)?;
746        Ok(Some(checkpoint))
747    }
748
749    fn next_backup_path(&self) -> PathBuf {
750        let ts = chrono::Utc::now().format("%Y%m%d%H%M%S");
751        let stem = self
752            .file_path
753            .file_stem()
754            .and_then(OsStr::to_str)
755            .unwrap_or("brain");
756        self.backups_dir.join(format!("{stem}.{ts}.amem.bak"))
757    }
758
759    fn prune_old_backups(&self) -> McpResult<()> {
760        let mut entries = std::fs::read_dir(&self.backups_dir)
761            .map_err(McpError::Io)?
762            .filter_map(Result::ok)
763            .filter(|entry| {
764                entry
765                    .file_name()
766                    .to_str()
767                    .map(|name| name.ends_with(".amem.bak"))
768                    .unwrap_or(false)
769            })
770            .collect::<Vec<_>>();
771
772        if entries.len() <= self.backup_retention {
773            return Ok(());
774        }
775
776        entries.sort_by_key(|entry| {
777            entry
778                .metadata()
779                .and_then(|m| m.modified())
780                .ok()
781                .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
782        });
783        let to_remove = entries.len().saturating_sub(self.backup_retention);
784        for entry in entries.into_iter().take(to_remove) {
785            let _ = std::fs::remove_file(entry.path());
786        }
787        Ok(())
788    }
789}
790
791fn resolve_backups_dir(memory_path: &std::path::Path) -> PathBuf {
792    if let Ok(custom) = std::env::var("AMEM_AUTO_BACKUP_DIR") {
793        let trimmed = custom.trim();
794        if !trimmed.is_empty() {
795            return PathBuf::from(trimmed);
796        }
797    }
798
799    let parent = memory_path.parent().unwrap_or(std::path::Path::new("."));
800    parent.join(".amem-backups")
801}
802
803fn resolve_migration_dir(memory_path: &Path) -> PathBuf {
804    let parent = memory_path.parent().unwrap_or(std::path::Path::new("."));
805    parent.join(".amem-migrations")
806}
807
808fn read_storage_version(path: &Path) -> Option<u32> {
809    let mut file = std::fs::File::open(path).ok()?;
810    let mut header = [0u8; 8];
811    file.read_exact(&mut header).ok()?;
812    if &header[0..4] != b"AMEM" {
813        return None;
814    }
815    Some(u32::from_le_bytes([
816        header[4], header[5], header[6], header[7],
817    ]))
818}
819
820fn read_env_u64(name: &str, default_value: u64) -> u64 {
821    std::env::var(name)
822        .ok()
823        .and_then(|v| v.parse::<u64>().ok())
824        .unwrap_or(default_value)
825}
826
827fn read_env_u32(name: &str, default_value: u32) -> u32 {
828    std::env::var(name)
829        .ok()
830        .and_then(|v| v.parse::<u32>().ok())
831        .unwrap_or(default_value)
832}
833
834fn read_env_usize(name: &str, default_value: usize) -> usize {
835    std::env::var(name)
836        .ok()
837        .and_then(|v| v.parse::<usize>().ok())
838        .unwrap_or(default_value)
839}
840
841fn read_env_f32(name: &str, default_value: f32) -> f32 {
842    std::env::var(name)
843        .ok()
844        .and_then(|v| v.parse::<f32>().ok())
845        .unwrap_or(default_value)
846}
847
848fn read_env_string(name: &str) -> Option<String> {
849    std::env::var(name).ok().map(|v| v.trim().to_string())
850}
851
852fn resolve_health_ledger_dir() -> PathBuf {
853    if let Some(custom) = read_env_string("AMEM_HEALTH_LEDGER_DIR") {
854        if !custom.is_empty() {
855            return PathBuf::from(custom);
856        }
857    }
858    if let Some(custom) = read_env_string("AGENTRA_HEALTH_LEDGER_DIR") {
859        if !custom.is_empty() {
860            return PathBuf::from(custom);
861        }
862    }
863
864    let home = std::env::var("HOME")
865        .ok()
866        .map(PathBuf::from)
867        .unwrap_or_else(|| PathBuf::from("."));
868    home.join(".agentra").join("health-ledger")
869}