1use 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
15const DEFAULT_AUTO_SAVE_SECS: u64 = 30;
17const DEFAULT_BACKUP_INTERVAL_SECS: u64 = 900;
19const DEFAULT_BACKUP_RETENTION: usize = 24;
21const DEFAULT_SLEEP_CYCLE_SECS: u64 = 1800;
23const DEFAULT_ARCHIVE_MIN_SESSION_NODES: usize = 25;
25const DEFAULT_HOT_MIN_DECAY: f32 = 0.7;
27const DEFAULT_WARM_MIN_DECAY: f32 = 0.3;
29const DEFAULT_SLA_MAX_MUTATIONS_PER_MIN: u32 = 240;
31const DEFAULT_HEALTH_LEDGER_EMIT_SECS: u64 = 30;
33const 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
139pub 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 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 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 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 pub fn graph(&self) -> &MemoryGraph {
327 &self.graph
328 }
329
330 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 pub fn query_engine(&self) -> &QueryEngine {
340 &self.query_engine
341 }
342
343 pub fn write_engine(&self) -> &WriteEngine {
345 &self.write_engine
346 }
347
348 pub fn current_session_id(&self) -> u32 {
350 self.current_session
351 }
352
353 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 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 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 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 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 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 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 pub fn mark_dirty(&mut self) {
488 self.dirty = true;
489 self.last_activity = Instant::now();
490 self.record_mutation();
491 }
492
493 pub fn file_path(&self) -> &PathBuf {
495 &self.file_path
496 }
497
498 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 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 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 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 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}