1use std::collections::HashMap;
4use std::ffi::OsStr;
5use std::fs::OpenOptions;
6use std::io::Read as _;
7use std::path::{Path, PathBuf};
8use std::time::{Duration, Instant, SystemTime};
9
10use agentic_memory::{
11 AmemReader, AmemWriter, CognitiveEventBuilder, Edge, EdgeType, EventType, MemoryGraph,
12 PatternParams, PatternSort, QueryEngine, WriteEngine,
13};
14use serde_json::Value;
15
16use crate::types::{McpError, McpResult};
17
18const DEFAULT_AUTO_SAVE_SECS: u64 = 30;
20const DEFAULT_BACKUP_INTERVAL_SECS: u64 = 900;
22const DEFAULT_BACKUP_RETENTION: usize = 24;
24const DEFAULT_SLEEP_CYCLE_SECS: u64 = 1800;
26const DEFAULT_ARCHIVE_MIN_SESSION_NODES: usize = 25;
28const DEFAULT_HOT_MIN_DECAY: f32 = 0.7;
30const DEFAULT_WARM_MIN_DECAY: f32 = 0.3;
32const DEFAULT_SLA_MAX_MUTATIONS_PER_MIN: u32 = 240;
34const DEFAULT_HEALTH_LEDGER_EMIT_SECS: u64 = 30;
36const DEFAULT_STORAGE_BUDGET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
38const DEFAULT_STORAGE_BUDGET_HORIZON_YEARS: u32 = 20;
40const DEFAULT_AUTO_CAPTURE_MAX_CHARS: usize = 2048;
42const CURRENT_AMEM_VERSION: u32 = 1;
44
45#[derive(Debug, Clone, Copy)]
46enum AutonomicProfile {
47 Desktop,
48 Cloud,
49 Aggressive,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53enum StorageMigrationPolicy {
54 AutoSafe,
55 Strict,
56 Off,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60enum StorageBudgetMode {
61 AutoRollup,
62 Warn,
63 Off,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67enum AutoCaptureMode {
68 Safe,
70 Full,
72 Off,
74}
75
76#[derive(Debug, Clone, Copy)]
77struct ProfileDefaults {
78 auto_save_secs: u64,
79 backup_secs: u64,
80 backup_retention: usize,
81 sleep_cycle_secs: u64,
82 sleep_idle_secs: u64,
83 archive_min_session_nodes: usize,
84 hot_min_decay: f32,
85 warm_min_decay: f32,
86 sla_max_mutations_per_min: u32,
87}
88
89impl AutonomicProfile {
90 fn from_env(name: &str) -> Self {
91 let raw = read_env_string(name).unwrap_or_else(|| "desktop".to_string());
92 match raw.trim().to_ascii_lowercase().as_str() {
93 "cloud" => Self::Cloud,
94 "aggressive" => Self::Aggressive,
95 _ => Self::Desktop,
96 }
97 }
98
99 fn defaults(self) -> ProfileDefaults {
100 match self {
101 Self::Desktop => ProfileDefaults {
102 auto_save_secs: DEFAULT_AUTO_SAVE_SECS,
103 backup_secs: DEFAULT_BACKUP_INTERVAL_SECS,
104 backup_retention: DEFAULT_BACKUP_RETENTION,
105 sleep_cycle_secs: DEFAULT_SLEEP_CYCLE_SECS,
106 sleep_idle_secs: 180,
107 archive_min_session_nodes: DEFAULT_ARCHIVE_MIN_SESSION_NODES,
108 hot_min_decay: DEFAULT_HOT_MIN_DECAY,
109 warm_min_decay: DEFAULT_WARM_MIN_DECAY,
110 sla_max_mutations_per_min: DEFAULT_SLA_MAX_MUTATIONS_PER_MIN,
111 },
112 Self::Cloud => ProfileDefaults {
113 auto_save_secs: 15,
114 backup_secs: 600,
115 backup_retention: 48,
116 sleep_cycle_secs: 900,
117 sleep_idle_secs: 90,
118 archive_min_session_nodes: 50,
119 hot_min_decay: 0.75,
120 warm_min_decay: 0.4,
121 sla_max_mutations_per_min: 600,
122 },
123 Self::Aggressive => ProfileDefaults {
124 auto_save_secs: 10,
125 backup_secs: 300,
126 backup_retention: 16,
127 sleep_cycle_secs: 300,
128 sleep_idle_secs: 45,
129 archive_min_session_nodes: 15,
130 hot_min_decay: 0.8,
131 warm_min_decay: 0.5,
132 sla_max_mutations_per_min: 900,
133 },
134 }
135 }
136
137 fn as_str(self) -> &'static str {
138 match self {
139 Self::Desktop => "desktop",
140 Self::Cloud => "cloud",
141 Self::Aggressive => "aggressive",
142 }
143 }
144}
145
146impl StorageMigrationPolicy {
147 fn from_env(name: &str) -> Self {
148 let raw = read_env_string(name).unwrap_or_else(|| "auto-safe".to_string());
149 match raw.trim().to_ascii_lowercase().as_str() {
150 "strict" => Self::Strict,
151 "off" | "disabled" | "none" => Self::Off,
152 _ => Self::AutoSafe,
153 }
154 }
155
156 fn as_str(self) -> &'static str {
157 match self {
158 Self::AutoSafe => "auto-safe",
159 Self::Strict => "strict",
160 Self::Off => "off",
161 }
162 }
163}
164
165impl StorageBudgetMode {
166 fn from_env(name: &str) -> Self {
167 let raw = read_env_string(name).unwrap_or_else(|| "auto-rollup".to_string());
168 match raw.trim().to_ascii_lowercase().as_str() {
169 "warn" => Self::Warn,
170 "off" | "disabled" | "none" => Self::Off,
171 _ => Self::AutoRollup,
172 }
173 }
174
175 fn as_str(self) -> &'static str {
176 match self {
177 Self::AutoRollup => "auto-rollup",
178 Self::Warn => "warn",
179 Self::Off => "off",
180 }
181 }
182}
183
184impl AutoCaptureMode {
185 fn from_env(name: &str) -> Self {
186 let raw = read_env_string(name).unwrap_or_else(|| "safe".to_string());
187 match raw.trim().to_ascii_lowercase().as_str() {
188 "full" => Self::Full,
189 "off" | "disabled" | "none" => Self::Off,
190 _ => Self::Safe,
191 }
192 }
193
194 fn as_str(self) -> &'static str {
195 match self {
196 Self::Safe => "safe",
197 Self::Full => "full",
198 Self::Off => "off",
199 }
200 }
201}
202
203pub struct SessionManager {
205 graph: MemoryGraph,
206 query_engine: QueryEngine,
207 write_engine: WriteEngine,
208 file_path: PathBuf,
209 current_session: u32,
210 profile: AutonomicProfile,
211 migration_policy: StorageMigrationPolicy,
212 dirty: bool,
213 last_save: Instant,
214 auto_save_interval: Duration,
215 backup_interval: Duration,
216 backup_retention: usize,
217 backups_dir: PathBuf,
218 save_generation: u64,
219 last_backup_generation: u64,
220 last_backup: Instant,
221 sleep_cycle_interval: Duration,
222 archive_min_session_nodes: usize,
223 hot_min_decay: f32,
224 warm_min_decay: f32,
225 sla_max_mutations_per_min: u32,
226 last_sleep_cycle: Instant,
227 sleep_idle_min: Duration,
228 last_activity: Instant,
229 mutation_window_started: Instant,
230 mutation_window_count: u32,
231 maintenance_throttle_count: u64,
232 last_health_ledger_emit: Instant,
233 health_ledger_emit_interval: Duration,
234 storage_budget_mode: StorageBudgetMode,
235 storage_budget_max_bytes: u64,
236 storage_budget_horizon_years: u32,
237 storage_budget_target_fraction: f32,
238 storage_budget_rollup_count: u64,
239 auto_capture_mode: AutoCaptureMode,
240 auto_capture_redact: bool,
241 auto_capture_max_chars: usize,
242 auto_capture_count: u64,
243 last_temporal_node_id: Option<u64>,
246 last_file_mtime: Option<SystemTime>,
248 workspace_manager: super::workspace::WorkspaceManager,
250}
251
252impl SessionManager {
253 pub fn open(path: &str) -> McpResult<Self> {
255 let file_path = PathBuf::from(path);
256 let dimension = agentic_memory::DEFAULT_DIMENSION;
257 let file_existed = file_path.exists();
258 let profile = AutonomicProfile::from_env("AMEM_AUTONOMIC_PROFILE");
259 let defaults = profile.defaults();
260 let migration_policy = StorageMigrationPolicy::from_env("AMEM_STORAGE_MIGRATION_POLICY");
261 let detected_version = if file_existed {
262 read_storage_version(&file_path)
263 } else {
264 None
265 };
266 let legacy_version = detected_version.filter(|v| *v < CURRENT_AMEM_VERSION);
267
268 let graph = if file_existed {
269 tracing::info!("Opening existing memory file: {}", file_path.display());
270 AmemReader::read_from_file(&file_path)
271 .map_err(|e| McpError::AgenticMemory(format!("Failed to read memory file: {e}")))?
272 } else {
273 tracing::info!("Creating new memory file: {}", file_path.display());
274 if let Some(parent) = file_path.parent() {
276 std::fs::create_dir_all(parent).map_err(|e| {
277 McpError::Io(std::io::Error::other(format!(
278 "Failed to create directory {}: {e}",
279 parent.display()
280 )))
281 })?;
282 }
283 MemoryGraph::new(dimension)
284 };
285
286 let session_ids = graph.session_index().session_ids();
290 let max_existing = session_ids.iter().copied().max().unwrap_or(0);
291 let pid_component = std::process::id() % 1000;
292 let current_session = max_existing.saturating_add(1).saturating_add(pid_component);
293
294 tracing::info!(
295 "Session {} started. Graph has {} nodes, {} edges.",
296 current_session,
297 graph.node_count(),
298 graph.edge_count()
299 );
300 tracing::info!(
301 "Autonomic profile={} migration_policy={}",
302 profile.as_str(),
303 migration_policy.as_str()
304 );
305
306 let auto_save_secs = read_env_u64("AMEM_AUTOSAVE_SECS", defaults.auto_save_secs);
307 let backup_secs = read_env_u64("AMEM_AUTO_BACKUP_SECS", defaults.backup_secs).max(30);
308 let backup_retention =
309 read_env_usize("AMEM_AUTO_BACKUP_RETENTION", defaults.backup_retention).max(1);
310 let backups_dir = resolve_backups_dir(&file_path);
311 let sleep_cycle_secs =
312 read_env_u64("AMEM_SLEEP_CYCLE_SECS", defaults.sleep_cycle_secs).max(60);
313 let sleep_idle_secs =
314 read_env_u64("AMEM_SLEEP_IDLE_SECS", defaults.sleep_idle_secs).max(30);
315 let archive_min_session_nodes = read_env_usize(
316 "AMEM_ARCHIVE_MIN_SESSION_NODES",
317 defaults.archive_min_session_nodes,
318 )
319 .max(1);
320 let hot_min_decay =
321 read_env_f32("AMEM_TIER_HOT_MIN_DECAY", defaults.hot_min_decay).clamp(0.0, 1.0);
322 let warm_min_decay = read_env_f32("AMEM_TIER_WARM_MIN_DECAY", defaults.warm_min_decay)
323 .clamp(0.0, 1.0)
324 .min(hot_min_decay);
325 let sla_max_mutations_per_min = read_env_u32(
326 "AMEM_SLA_MAX_MUTATIONS_PER_MIN",
327 defaults.sla_max_mutations_per_min,
328 )
329 .max(1);
330 let health_ledger_emit_interval = Duration::from_secs(
331 read_env_u64(
332 "AMEM_HEALTH_LEDGER_EMIT_SECS",
333 DEFAULT_HEALTH_LEDGER_EMIT_SECS,
334 )
335 .max(5),
336 );
337 let storage_budget_mode = StorageBudgetMode::from_env("AMEM_STORAGE_BUDGET_MODE");
338 let storage_budget_max_bytes =
339 read_env_u64("AMEM_STORAGE_BUDGET_BYTES", DEFAULT_STORAGE_BUDGET_BYTES).max(1);
340 let storage_budget_horizon_years = read_env_u32(
341 "AMEM_STORAGE_BUDGET_HORIZON_YEARS",
342 DEFAULT_STORAGE_BUDGET_HORIZON_YEARS,
343 )
344 .max(1);
345 let storage_budget_target_fraction =
346 read_env_f32("AMEM_STORAGE_BUDGET_TARGET_FRACTION", 0.85).clamp(0.50, 0.99);
347 let auto_capture_mode = AutoCaptureMode::from_env("AMEM_AUTO_CAPTURE_MODE");
348 let auto_capture_redact = read_env_bool("AMEM_AUTO_CAPTURE_REDACT", true);
349 let auto_capture_max_chars = read_env_usize(
350 "AMEM_AUTO_CAPTURE_MAX_CHARS",
351 DEFAULT_AUTO_CAPTURE_MAX_CHARS,
352 )
353 .clamp(256, 16384);
354
355 let mut manager = Self {
356 graph,
357 query_engine: QueryEngine::new(),
358 write_engine: WriteEngine::new(dimension),
359 file_path,
360 current_session,
361 profile,
362 migration_policy,
363 dirty: false,
364 last_save: Instant::now(),
365 auto_save_interval: Duration::from_secs(auto_save_secs),
366 backup_interval: Duration::from_secs(backup_secs),
367 backup_retention,
368 backups_dir,
369 save_generation: if file_existed { 1 } else { 0 },
370 last_backup_generation: 0,
371 last_backup: Instant::now(),
372 sleep_cycle_interval: Duration::from_secs(sleep_cycle_secs),
373 archive_min_session_nodes,
374 hot_min_decay,
375 warm_min_decay,
376 sla_max_mutations_per_min,
377 last_sleep_cycle: Instant::now(),
378 sleep_idle_min: Duration::from_secs(sleep_idle_secs),
379 last_activity: Instant::now(),
380 mutation_window_started: Instant::now(),
381 mutation_window_count: 0,
382 maintenance_throttle_count: 0,
383 last_health_ledger_emit: Instant::now()
384 .checked_sub(health_ledger_emit_interval)
385 .unwrap_or_else(Instant::now),
386 health_ledger_emit_interval,
387 storage_budget_mode,
388 storage_budget_max_bytes,
389 storage_budget_horizon_years,
390 storage_budget_target_fraction,
391 storage_budget_rollup_count: 0,
392 auto_capture_mode,
393 auto_capture_redact,
394 auto_capture_max_chars,
395 auto_capture_count: 0,
396 last_temporal_node_id: None,
397 last_file_mtime: if file_existed {
398 std::fs::metadata(path).and_then(|m| m.modified()).ok()
399 } else {
400 None
401 },
402 workspace_manager: super::workspace::WorkspaceManager::new(),
403 };
404
405 if let Some(version) = legacy_version {
406 match migration_policy {
407 StorageMigrationPolicy::Strict => {
408 return Err(McpError::AgenticMemory(format!(
409 "Legacy .amem version {} blocked by strict migration policy",
410 version
411 )));
412 }
413 StorageMigrationPolicy::Off => {
414 tracing::warn!(
415 "Legacy storage version detected (v{}), auto-migration disabled by policy",
416 version
417 );
418 }
419 StorageMigrationPolicy::AutoSafe => {
420 if let Some(checkpoint) = manager.create_migration_checkpoint(version)? {
421 tracing::info!(
422 "Legacy storage version detected (v{}), checkpoint created at {}",
423 version,
424 checkpoint.display()
425 );
426 }
427 manager.dirty = true;
428 manager.save()?;
429 tracing::info!(
430 "Auto-migrated memory storage from v{} to v{} at {}",
431 version,
432 CURRENT_AMEM_VERSION,
433 manager.file_path.display()
434 );
435 }
436 }
437 }
438
439 Ok(manager)
440 }
441
442 pub fn graph(&self) -> &MemoryGraph {
444 &self.graph
445 }
446
447 pub fn graph_mut(&mut self) -> &mut MemoryGraph {
449 self.dirty = true;
450 self.last_activity = Instant::now();
451 self.record_mutation();
452 &mut self.graph
453 }
454
455 pub fn query_engine(&self) -> &QueryEngine {
457 &self.query_engine
458 }
459
460 pub fn write_engine(&self) -> &WriteEngine {
462 &self.write_engine
463 }
464
465 pub fn workspace_manager(&self) -> &super::workspace::WorkspaceManager {
467 &self.workspace_manager
468 }
469
470 pub fn workspace_manager_mut(&mut self) -> &mut super::workspace::WorkspaceManager {
472 &mut self.workspace_manager
473 }
474
475 pub fn current_session_id(&self) -> u32 {
477 self.current_session
478 }
479
480 pub fn start_session(&mut self, explicit_id: Option<u32>) -> McpResult<u32> {
482 let session_id = explicit_id.unwrap_or_else(|| {
483 let ids = self.graph.session_index().session_ids();
484 let max_indexed = ids.iter().copied().max().unwrap_or(0);
485 max_indexed.max(self.current_session).saturating_add(1)
487 });
488
489 self.current_session = session_id;
490 self.last_temporal_node_id = None;
491 self.last_activity = Instant::now();
492 tracing::info!("Started session {session_id}");
493 Ok(session_id)
494 }
495
496 pub fn end_session_with_episode(&mut self, session_id: u32, summary: &str) -> McpResult<u64> {
498 let episode_id = self
499 .write_engine
500 .compress_session(&mut self.graph, session_id, summary)
501 .map_err(|e| McpError::AgenticMemory(format!("Failed to compress session: {e}")))?;
502
503 self.dirty = true;
504 self.last_activity = Instant::now();
505 self.record_mutation();
506 self.save()?;
507
508 tracing::info!("Ended session {session_id}, created episode node {episode_id}");
509
510 if let Err(e) = self.write_context_files(summary) {
512 tracing::warn!("Failed to write context files: {e}");
513 }
514
515 Ok(episode_id)
516 }
517
518 fn write_context_files(&self, session_summary: &str) -> McpResult<()> {
527 let query = self.query_engine();
528 let graph = self.graph();
529
530 let decisions = query
532 .pattern(
533 graph,
534 PatternParams {
535 event_types: vec![EventType::Decision],
536 min_confidence: Some(0.7),
537 max_confidence: None,
538 session_ids: vec![],
539 created_after: None,
540 created_before: None,
541 min_decay_score: None,
542 max_results: 5,
543 sort_by: PatternSort::MostRecent,
544 },
545 )
546 .unwrap_or_default();
547
548 let facts = query
550 .pattern(
551 graph,
552 PatternParams {
553 event_types: vec![EventType::Fact],
554 min_confidence: Some(0.8),
555 max_confidence: None,
556 session_ids: vec![],
557 created_after: None,
558 created_before: None,
559 min_decay_score: None,
560 max_results: 5,
561 sort_by: PatternSort::MostRecent,
562 },
563 )
564 .unwrap_or_default();
565
566 let mut md = String::from("# Agent Memory Context\n\n");
568 md.push_str("> Auto-generated on session end. Do not edit.\n\n");
569
570 md.push_str("## Last Session\n\n");
571 md.push_str(session_summary);
572 md.push_str("\n\n");
573
574 if !decisions.is_empty() {
575 md.push_str("## Recent Decisions\n\n");
576 for d in &decisions {
577 md.push_str(&format!("- {}\n", d.content));
578 }
579 md.push('\n');
580 }
581
582 if !facts.is_empty() {
583 md.push_str("## Key Facts\n\n");
584 for f in &facts {
585 md.push_str(&format!("- {}\n", f.content));
586 }
587 md.push('\n');
588 }
589
590 if let Some(home) = std::env::var_os("HOME") {
592 let global_dir = PathBuf::from(home).join(".agentic");
593 if std::fs::create_dir_all(&global_dir).is_ok() {
594 let global_path = global_dir.join("memory-context.md");
595 if let Err(e) = std::fs::write(&global_path, &md) {
596 tracing::warn!("Failed to write global context file: {e}");
597 } else {
598 tracing::info!("Wrote context to {}", global_path.display());
599 }
600 }
601 }
602
603 if let Some(amem_dir) = self.file_path.parent() {
607 let project_root = find_project_root(amem_dir);
608 if let Some(root) = project_root {
609 let claude_dir = root.join(".claude");
610 if std::fs::create_dir_all(&claude_dir).is_ok() {
611 let project_path = claude_dir.join("memory-context.md");
612 if let Err(e) = std::fs::write(&project_path, &md) {
613 tracing::warn!("Failed to write project context file: {e}");
614 } else {
615 tracing::info!("Wrote context to {}", project_path.display());
616 }
617 }
618 }
619 }
620
621 Ok(())
622 }
623
624 pub fn save(&mut self) -> McpResult<()> {
632 if !self.dirty {
633 return Ok(());
634 }
635
636 let _lock = FileLock::acquire(&self.file_path)?;
637
638 if self.file_path.exists() {
640 let current_mtime = std::fs::metadata(&self.file_path)
641 .and_then(|m| m.modified())
642 .ok();
643 if let (Some(current), Some(last_known)) = (current_mtime, self.last_file_mtime) {
644 if current > last_known {
645 tracing::info!("Detected external modification, merging with disk state");
646 self.merge_with_disk()?;
647 }
648 }
649 }
650
651 let writer = AmemWriter::new(self.graph.dimension());
652 writer
653 .write_to_file(&self.graph, &self.file_path)
654 .map_err(|e| McpError::AgenticMemory(format!("Failed to write memory file: {e}")))?;
655
656 self.last_file_mtime = std::fs::metadata(&self.file_path)
658 .and_then(|m| m.modified())
659 .ok();
660
661 self.dirty = false;
662 self.last_save = Instant::now();
663 self.save_generation = self.save_generation.saturating_add(1);
664 tracing::debug!("Saved memory file: {}", self.file_path.display());
665 Ok(())
666 }
667
668 fn merge_with_disk(&mut self) -> McpResult<()> {
674 let disk_graph = AmemReader::read_from_file(&self.file_path)
675 .map_err(|e| McpError::AgenticMemory(format!("Failed to re-read for merge: {e}")))?;
676
677 let our_nodes: Vec<_> = self
679 .graph
680 .nodes()
681 .iter()
682 .filter(|n| n.session_id == self.current_session)
683 .cloned()
684 .collect();
685
686 let our_node_ids: std::collections::HashSet<u64> = our_nodes.iter().map(|n| n.id).collect();
688 let our_edges: Vec<_> = self
689 .graph
690 .edges()
691 .iter()
692 .filter(|e| our_node_ids.contains(&e.source_id) || our_node_ids.contains(&e.target_id))
693 .cloned()
694 .collect();
695
696 self.graph = disk_graph;
698
699 let mut id_map: HashMap<u64, u64> = HashMap::new();
701 for node in &our_nodes {
702 let event = CognitiveEventBuilder::new(node.event_type, node.content.clone())
703 .session_id(self.current_session)
704 .confidence(node.confidence)
705 .build();
706 let result = self
707 .write_engine
708 .ingest(&mut self.graph, vec![event], vec![])
709 .map_err(|e| McpError::AgenticMemory(format!("Merge node re-add failed: {e}")))?;
710 if let Some(&new_id) = result.new_node_ids.first() {
711 id_map.insert(node.id, new_id);
712 }
713 }
714
715 for edge in &our_edges {
717 let source = id_map
718 .get(&edge.source_id)
719 .copied()
720 .unwrap_or(edge.source_id);
721 let target = id_map
722 .get(&edge.target_id)
723 .copied()
724 .unwrap_or(edge.target_id);
725 let new_edge = Edge::new(source, target, edge.edge_type, edge.weight);
726 if let Err(e) = self.graph.add_edge(new_edge) {
727 tracing::warn!("Merge edge re-add skipped: {e}");
728 }
729 }
730
731 tracing::info!(
732 "Merged {} nodes and {} edges from session {} into disk state",
733 our_nodes.len(),
734 our_edges.len(),
735 self.current_session
736 );
737 Ok(())
738 }
739
740 pub fn maybe_auto_save(&mut self) -> McpResult<()> {
742 if self.dirty && self.last_save.elapsed() >= self.auto_save_interval {
743 self.save()?;
744 }
745 Ok(())
746 }
747
748 pub fn run_maintenance_tick(&mut self) -> McpResult<()> {
750 if self.should_throttle_maintenance() {
751 self.maintenance_throttle_count = self.maintenance_throttle_count.saturating_add(1);
752 self.maybe_auto_save()?;
753 self.emit_health_ledger("throttled")?;
754 tracing::debug!(
755 "Maintenance throttled by SLA guard: mutation_rate={} threshold={}",
756 self.mutation_rate_per_min(),
757 self.sla_max_mutations_per_min
758 );
759 return Ok(());
760 }
761
762 self.maybe_run_sleep_cycle()?;
763 self.maybe_auto_save()?;
764 self.maybe_enforce_storage_budget()?;
765 self.maybe_auto_backup()?;
766 self.emit_health_ledger("normal")?;
767 Ok(())
768 }
769
770 pub fn maybe_run_sleep_cycle(&mut self) -> McpResult<()> {
772 if self.last_sleep_cycle.elapsed() < self.sleep_cycle_interval {
773 return Ok(());
774 }
775 if self.last_activity.elapsed() < self.sleep_idle_min {
776 return Ok(());
777 }
778
779 let now = agentic_memory::now_micros();
780 let decay_report = self
781 .write_engine
782 .run_decay(&mut self.graph, now)
783 .map_err(|e| McpError::AgenticMemory(format!("Sleep-cycle decay failed: {e}")))?;
784 let archived_sessions = self.auto_archive_completed_sessions()?;
785
786 if decay_report.nodes_decayed > 0 || archived_sessions > 0 {
787 self.dirty = true;
788 self.save()?;
789 }
790
791 let (hot, warm, cold) = self.tier_counts();
792 self.last_sleep_cycle = Instant::now();
793 tracing::info!(
794 "Sleep-cycle complete: decayed={} archived_sessions={} tiers(h/w/c)={}/{}/{}",
795 decay_report.nodes_decayed,
796 archived_sessions,
797 hot,
798 warm,
799 cold
800 );
801 Ok(())
802 }
803
804 pub fn maybe_auto_backup(&mut self) -> McpResult<()> {
806 if self.last_backup.elapsed() < self.backup_interval {
807 return Ok(());
808 }
809 if self.save_generation <= self.last_backup_generation {
810 return Ok(());
811 }
812 if !self.file_path.exists() {
813 return Ok(());
814 }
815
816 std::fs::create_dir_all(&self.backups_dir).map_err(McpError::Io)?;
817 let backup_path = self.next_backup_path();
818 std::fs::copy(&self.file_path, &backup_path).map_err(McpError::Io)?;
819 self.last_backup_generation = self.save_generation;
820 self.last_backup = Instant::now();
821 self.prune_old_backups()?;
822 tracing::info!("Auto-backup written: {}", backup_path.display());
823 Ok(())
824 }
825
826 pub fn mark_dirty(&mut self) {
828 self.dirty = true;
829 self.last_activity = Instant::now();
830 self.record_mutation();
831 }
832
833 pub fn file_path(&self) -> &PathBuf {
835 &self.file_path
836 }
837
838 pub fn last_temporal_node_id(&self) -> Option<u64> {
840 self.last_temporal_node_id
841 }
842
843 pub fn advance_temporal_chain(&mut self, node_id: u64) {
845 self.last_temporal_node_id = Some(node_id);
846 }
847
848 pub fn link_temporal(&mut self, prev_id: u64, next_id: u64) -> McpResult<()> {
850 let edge = Edge::new(prev_id, next_id, EdgeType::TemporalNext, 1.0);
851 self.graph
852 .add_edge(edge)
853 .map_err(|e| McpError::AgenticMemory(format!("Failed to add temporal edge: {e}")))?;
854 self.dirty = true;
855 Ok(())
856 }
857
858 pub fn maintenance_interval(&self) -> Duration {
860 self.auto_save_interval
861 .min(self.backup_interval)
862 .min(self.sleep_cycle_interval)
863 }
864
865 pub fn capture_prompt_request(
867 &mut self,
868 prompt_name: &str,
869 arguments: Option<&Value>,
870 ) -> McpResult<Option<u64>> {
871 if self.auto_capture_mode == AutoCaptureMode::Off {
872 return Ok(None);
873 }
874 match extract_prompt_capture_text(prompt_name, arguments)? {
875 Some(text) => self.persist_auto_capture(EventType::Fact, &text, 0.90),
876 None => Ok(None),
877 }
878 }
879
880 pub fn capture_tool_call(
882 &mut self,
883 tool_name: &str,
884 arguments: Option<&Value>,
885 ) -> McpResult<Option<u64>> {
886 if self.auto_capture_mode == AutoCaptureMode::Off {
887 return Ok(None);
888 }
889
890 let text = match self.auto_capture_mode {
891 AutoCaptureMode::Safe => extract_safe_tool_capture_text(tool_name, arguments)?,
892 AutoCaptureMode::Full => extract_full_tool_capture_text(tool_name, arguments)?,
893 AutoCaptureMode::Off => None,
894 };
895 match text {
896 Some(v) => self.persist_auto_capture(EventType::Inference, &v, 0.82),
897 None => Ok(None),
898 }
899 }
900
901 pub fn add_event(
903 &mut self,
904 event_type: EventType,
905 content: &str,
906 confidence: f32,
907 edges: Vec<(u64, EdgeType, f32)>,
908 ) -> McpResult<(u64, usize)> {
909 let event = CognitiveEventBuilder::new(event_type, content.to_string())
910 .session_id(self.current_session)
911 .confidence(confidence)
912 .build();
913
914 let result = self
916 .write_engine
917 .ingest(&mut self.graph, vec![event], vec![])
918 .map_err(|e| McpError::AgenticMemory(format!("Failed to add event: {e}")))?;
919
920 let node_id = result.new_node_ids.first().copied().ok_or_else(|| {
921 McpError::InternalError("No node ID returned from ingest".to_string())
922 })?;
923
924 let mut edge_count = 0;
926 for (target_id, edge_type, weight) in &edges {
927 let edge = Edge::new(node_id, *target_id, *edge_type, *weight);
928 self.graph
929 .add_edge(edge)
930 .map_err(|e| McpError::AgenticMemory(format!("Failed to add edge: {e}")))?;
931 edge_count += 1;
932 }
933
934 self.dirty = true;
935 self.last_activity = Instant::now();
936 self.record_mutation();
937 self.maybe_auto_save()?;
938
939 Ok((node_id, edge_count))
940 }
941
942 pub fn correct_node(&mut self, old_node_id: u64, new_content: &str) -> McpResult<u64> {
944 let new_id = self
945 .write_engine
946 .correct(
947 &mut self.graph,
948 old_node_id,
949 new_content,
950 self.current_session,
951 )
952 .map_err(|e| McpError::AgenticMemory(format!("Failed to correct node: {e}")))?;
953
954 self.dirty = true;
955 self.last_activity = Instant::now();
956 self.record_mutation();
957 self.maybe_auto_save()?;
958
959 Ok(new_id)
960 }
961
962 fn record_mutation(&mut self) {
963 if self.mutation_window_started.elapsed() >= Duration::from_secs(60) {
964 self.mutation_window_started = Instant::now();
965 self.mutation_window_count = 0;
966 }
967 self.mutation_window_count = self.mutation_window_count.saturating_add(1);
968 }
969
970 fn mutation_rate_per_min(&self) -> u32 {
971 let elapsed = self.mutation_window_started.elapsed().as_secs().max(1);
972 let scaled = (self.mutation_window_count as u64)
973 .saturating_mul(60)
974 .saturating_div(elapsed);
975 scaled.min(u32::MAX as u64) as u32
976 }
977
978 fn should_throttle_maintenance(&self) -> bool {
979 self.mutation_rate_per_min() > self.sla_max_mutations_per_min
980 }
981
982 fn emit_health_ledger(&mut self, maintenance_mode: &str) -> McpResult<()> {
983 if self.last_health_ledger_emit.elapsed() < self.health_ledger_emit_interval {
984 return Ok(());
985 }
986
987 let dir = resolve_health_ledger_dir();
988 std::fs::create_dir_all(&dir).map_err(McpError::Io)?;
989 let path = dir.join("agentic-memory.json");
990 let tmp = dir.join("agentic-memory.json.tmp");
991 let (hot, warm, cold) = self.tier_counts();
992 let current_size_bytes = self.current_file_size_bytes();
993 let projected_size_bytes = self.projected_file_size_bytes(current_size_bytes);
994 let over_budget = current_size_bytes > self.storage_budget_max_bytes
995 || projected_size_bytes
996 .map(|v| v > self.storage_budget_max_bytes)
997 .unwrap_or(false);
998 let payload = serde_json::json!({
999 "project": "AgenticMemory",
1000 "timestamp": chrono::Utc::now().to_rfc3339(),
1001 "status": "ok",
1002 "autonomic": {
1003 "profile": self.profile.as_str(),
1004 "migration_policy": self.migration_policy.as_str(),
1005 "maintenance_mode": maintenance_mode,
1006 "throttle_count": self.maintenance_throttle_count,
1007 },
1008 "sla": {
1009 "mutation_rate_per_min": self.mutation_rate_per_min(),
1010 "max_mutations_per_min": self.sla_max_mutations_per_min
1011 },
1012 "storage": {
1013 "file": self.file_path.display().to_string(),
1014 "dirty": self.dirty,
1015 "save_generation": self.save_generation,
1016 "backup_retention": self.backup_retention,
1017 },
1018 "storage_budget": {
1019 "mode": self.storage_budget_mode.as_str(),
1020 "max_bytes": self.storage_budget_max_bytes,
1021 "horizon_years": self.storage_budget_horizon_years,
1022 "target_fraction": self.storage_budget_target_fraction,
1023 "current_size_bytes": current_size_bytes,
1024 "projected_size_bytes": projected_size_bytes,
1025 "over_budget": over_budget,
1026 "rollup_count": self.storage_budget_rollup_count,
1027 },
1028 "auto_capture": {
1029 "mode": self.auto_capture_mode.as_str(),
1030 "redact": self.auto_capture_redact,
1031 "max_chars": self.auto_capture_max_chars,
1032 "captured_count": self.auto_capture_count
1033 },
1034 "graph": {
1035 "nodes": self.graph.node_count(),
1036 "edges": self.graph.edge_count(),
1037 "tiers": {
1038 "hot": hot,
1039 "warm": warm,
1040 "cold": cold,
1041 },
1042 },
1043 });
1044 let bytes = serde_json::to_vec_pretty(&payload).map_err(|e| {
1045 McpError::AgenticMemory(format!("Failed to encode health ledger payload: {e}"))
1046 })?;
1047 std::fs::write(&tmp, bytes).map_err(McpError::Io)?;
1048 std::fs::rename(&tmp, &path).map_err(McpError::Io)?;
1049 self.last_health_ledger_emit = Instant::now();
1050 Ok(())
1051 }
1052}
1053
1054impl Drop for SessionManager {
1055 fn drop(&mut self) {
1056 if self.dirty {
1057 if let Err(e) = self.save() {
1058 tracing::error!("Failed to save on drop: {e}");
1059 }
1060 }
1061 if let Err(e) = self.maybe_auto_backup() {
1062 tracing::error!("Failed auto-backup on drop: {e}");
1063 }
1064 }
1065}
1066
1067impl SessionManager {
1068 fn auto_archive_completed_sessions(&mut self) -> McpResult<usize> {
1069 self.auto_archive_completed_sessions_with_min(self.archive_min_session_nodes)
1070 }
1071
1072 fn auto_archive_completed_sessions_with_min(
1073 &mut self,
1074 min_session_nodes: usize,
1075 ) -> McpResult<usize> {
1076 let mut session_ids = self.graph.session_index().session_ids();
1077 session_ids.sort_unstable();
1078
1079 let mut archived = 0usize;
1080 for session_id in session_ids {
1081 if session_id >= self.current_session {
1082 continue;
1083 }
1084
1085 let node_ids = self.graph.session_index().get_session(session_id).to_vec();
1086 if node_ids.is_empty() {
1087 continue;
1088 }
1089
1090 let mut has_episode = false;
1091 let mut event_nodes = 0usize;
1092 let mut hot = 0usize;
1093 let mut warm = 0usize;
1094 let mut cold = 0usize;
1095
1096 for node_id in &node_ids {
1097 if let Some(node) = self.graph.get_node(*node_id) {
1098 if node.event_type == EventType::Episode {
1099 has_episode = true;
1100 continue;
1101 }
1102 event_nodes += 1;
1103 if node.decay_score >= self.hot_min_decay {
1104 hot += 1;
1105 } else if node.decay_score >= self.warm_min_decay {
1106 warm += 1;
1107 } else {
1108 cold += 1;
1109 }
1110 }
1111 }
1112
1113 if has_episode || event_nodes < min_session_nodes {
1114 continue;
1115 }
1116
1117 let summary = format!(
1118 "Auto-archive session {}: {} events ({} hot / {} warm / {} cold)",
1119 session_id, event_nodes, hot, warm, cold
1120 );
1121 self.write_engine
1122 .compress_session(&mut self.graph, session_id, &summary)
1123 .map_err(|e| {
1124 McpError::AgenticMemory(format!(
1125 "Auto-archive failed for session {session_id}: {e}"
1126 ))
1127 })?;
1128 archived = archived.saturating_add(1);
1129 }
1130
1131 Ok(archived)
1132 }
1133
1134 fn maybe_enforce_storage_budget(&mut self) -> McpResult<()> {
1135 if self.storage_budget_mode == StorageBudgetMode::Off {
1136 return Ok(());
1137 }
1138
1139 let current_size = self.current_file_size_bytes();
1140 if current_size == 0 {
1141 return Ok(());
1142 }
1143 let projected = self.projected_file_size_bytes(current_size);
1144 let over_current = current_size > self.storage_budget_max_bytes;
1145 let over_projected = projected
1146 .map(|v| v > self.storage_budget_max_bytes)
1147 .unwrap_or(false);
1148
1149 if !over_current && !over_projected {
1150 return Ok(());
1151 }
1152
1153 if self.storage_budget_mode == StorageBudgetMode::Warn {
1154 tracing::warn!(
1155 "Storage budget warning: current={} projected={:?} budget={} (mode=warn)",
1156 current_size,
1157 projected,
1158 self.storage_budget_max_bytes
1159 );
1160 return Ok(());
1161 }
1162
1163 let target_bytes = ((self.storage_budget_max_bytes as f64
1164 * self.storage_budget_target_fraction as f64)
1165 .round() as u64)
1166 .max(1);
1167 let mut rollup_count = 0usize;
1168 let mut threshold = self.archive_min_session_nodes.saturating_div(2).max(1);
1169
1170 for _ in 0..3 {
1171 let archived = self.auto_archive_completed_sessions_with_min(threshold)?;
1172 if archived == 0 {
1173 if threshold > 1 {
1174 threshold = 1;
1175 continue;
1176 }
1177 break;
1178 }
1179 rollup_count += archived;
1180 self.dirty = true;
1181 self.save()?;
1182 let new_size = self.current_file_size_bytes();
1183 if new_size <= target_bytes {
1184 break;
1185 }
1186 threshold = 1;
1187 }
1188
1189 if rollup_count > 0 {
1190 self.storage_budget_rollup_count = self
1191 .storage_budget_rollup_count
1192 .saturating_add(rollup_count as u64);
1193 tracing::info!(
1194 "Storage budget rollup applied: archived_sessions={} budget={} target={} current={}",
1195 rollup_count,
1196 self.storage_budget_max_bytes,
1197 target_bytes,
1198 self.current_file_size_bytes()
1199 );
1200 } else {
1201 tracing::warn!(
1202 "Storage budget exceeded but no completed sessions eligible for rollup (current={} projected={:?} budget={})",
1203 current_size,
1204 projected,
1205 self.storage_budget_max_bytes
1206 );
1207 }
1208
1209 Ok(())
1210 }
1211
1212 fn current_file_size_bytes(&self) -> u64 {
1213 std::fs::metadata(&self.file_path)
1214 .map(|m| m.len())
1215 .unwrap_or(0)
1216 }
1217
1218 fn persist_auto_capture(
1219 &mut self,
1220 event_type: EventType,
1221 raw_text: &str,
1222 confidence: f32,
1223 ) -> McpResult<Option<u64>> {
1224 let mut text = raw_text.trim().to_string();
1225 if text.is_empty() {
1226 return Ok(None);
1227 }
1228
1229 if self.auto_capture_redact {
1230 text = redact_sensitive_tokens(&text);
1231 }
1232
1233 if text.len() > self.auto_capture_max_chars {
1234 text.truncate(self.auto_capture_max_chars);
1235 text.push_str(" …[truncated]");
1236 }
1237
1238 let prev_id = self.last_temporal_node_id;
1239 let (node_id, _) = self.add_event(event_type, &text, confidence, vec![])?;
1240
1241 if let Some(prev) = prev_id {
1243 if let Err(e) = self.link_temporal(prev, node_id) {
1244 tracing::warn!("Failed to link temporal chain: {e}");
1245 }
1246 }
1247 self.last_temporal_node_id = Some(node_id);
1248
1249 self.auto_capture_count = self.auto_capture_count.saturating_add(1);
1250 Ok(Some(node_id))
1251 }
1252
1253 fn projected_file_size_bytes(&self, current_size: u64) -> Option<u64> {
1254 if current_size == 0 || self.graph.node_count() < 2 {
1255 return None;
1256 }
1257 let mut min_ts = u64::MAX;
1258 let mut max_ts = 0u64;
1259 for node in self.graph.nodes() {
1260 min_ts = min_ts.min(node.created_at);
1261 max_ts = max_ts.max(node.created_at);
1262 }
1263 if min_ts == u64::MAX || max_ts <= min_ts {
1264 return None;
1265 }
1266
1267 let span_secs_raw = (max_ts - min_ts) / 1_000_000;
1268 let span_secs = span_secs_raw.max(7 * 24 * 3600) as f64;
1270 let per_sec = current_size as f64 / span_secs;
1271 let horizon_secs = (self.storage_budget_horizon_years as f64) * 365.25 * 24.0 * 3600.0;
1272 let projected = (per_sec * horizon_secs).round();
1273 Some(projected.max(0.0).min(u64::MAX as f64) as u64)
1274 }
1275
1276 fn tier_counts(&self) -> (usize, usize, usize) {
1277 let mut hot = 0usize;
1278 let mut warm = 0usize;
1279 let mut cold = 0usize;
1280
1281 for node in self.graph.nodes() {
1282 if node.event_type == EventType::Episode {
1283 continue;
1284 }
1285 if node.decay_score >= self.hot_min_decay {
1286 hot += 1;
1287 } else if node.decay_score >= self.warm_min_decay {
1288 warm += 1;
1289 } else {
1290 cold += 1;
1291 }
1292 }
1293
1294 (hot, warm, cold)
1295 }
1296
1297 fn create_migration_checkpoint(&self, from_version: u32) -> McpResult<Option<PathBuf>> {
1298 if !self.file_path.exists() {
1299 return Ok(None);
1300 }
1301
1302 let migration_dir = resolve_migration_dir(&self.file_path);
1303 std::fs::create_dir_all(&migration_dir).map_err(McpError::Io)?;
1304
1305 let ts = chrono::Utc::now().format("%Y%m%d%H%M%S");
1306 let stem = self
1307 .file_path
1308 .file_stem()
1309 .and_then(OsStr::to_str)
1310 .unwrap_or("brain");
1311 let checkpoint = migration_dir.join(format!("{stem}.v{from_version}.{ts}.amem.checkpoint"));
1312 std::fs::copy(&self.file_path, &checkpoint).map_err(McpError::Io)?;
1313 Ok(Some(checkpoint))
1314 }
1315
1316 fn next_backup_path(&self) -> PathBuf {
1317 let ts = chrono::Utc::now().format("%Y%m%d%H%M%S");
1318 let stem = self
1319 .file_path
1320 .file_stem()
1321 .and_then(OsStr::to_str)
1322 .unwrap_or("brain");
1323 self.backups_dir.join(format!("{stem}.{ts}.amem.bak"))
1324 }
1325
1326 fn prune_old_backups(&self) -> McpResult<()> {
1327 let mut entries = std::fs::read_dir(&self.backups_dir)
1328 .map_err(McpError::Io)?
1329 .filter_map(Result::ok)
1330 .filter(|entry| {
1331 entry
1332 .file_name()
1333 .to_str()
1334 .map(|name| name.ends_with(".amem.bak"))
1335 .unwrap_or(false)
1336 })
1337 .collect::<Vec<_>>();
1338
1339 if entries.len() <= self.backup_retention {
1340 return Ok(());
1341 }
1342
1343 entries.sort_by_key(|entry| {
1344 entry
1345 .metadata()
1346 .and_then(|m| m.modified())
1347 .ok()
1348 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
1349 });
1350 let to_remove = entries.len().saturating_sub(self.backup_retention);
1351 for entry in entries.into_iter().take(to_remove) {
1352 let _ = std::fs::remove_file(entry.path());
1353 }
1354 Ok(())
1355 }
1356}
1357
1358fn resolve_backups_dir(memory_path: &std::path::Path) -> PathBuf {
1359 if let Ok(custom) = std::env::var("AMEM_AUTO_BACKUP_DIR") {
1360 let trimmed = custom.trim();
1361 if !trimmed.is_empty() {
1362 return PathBuf::from(trimmed);
1363 }
1364 }
1365
1366 let parent = memory_path.parent().unwrap_or(std::path::Path::new("."));
1367 parent.join(".amem-backups")
1368}
1369
1370fn resolve_migration_dir(memory_path: &Path) -> PathBuf {
1371 let parent = memory_path.parent().unwrap_or(std::path::Path::new("."));
1372 parent.join(".amem-migrations")
1373}
1374
1375fn read_storage_version(path: &Path) -> Option<u32> {
1376 let mut file = std::fs::File::open(path).ok()?;
1377 let mut header = [0u8; 8];
1378 file.read_exact(&mut header).ok()?;
1379 if &header[0..4] != b"AMEM" {
1380 return None;
1381 }
1382 Some(u32::from_le_bytes([
1383 header[4], header[5], header[6], header[7],
1384 ]))
1385}
1386
1387fn read_env_u64(name: &str, default_value: u64) -> u64 {
1388 std::env::var(name)
1389 .ok()
1390 .and_then(|v| v.parse::<u64>().ok())
1391 .unwrap_or(default_value)
1392}
1393
1394fn read_env_u32(name: &str, default_value: u32) -> u32 {
1395 std::env::var(name)
1396 .ok()
1397 .and_then(|v| v.parse::<u32>().ok())
1398 .unwrap_or(default_value)
1399}
1400
1401fn read_env_usize(name: &str, default_value: usize) -> usize {
1402 std::env::var(name)
1403 .ok()
1404 .and_then(|v| v.parse::<usize>().ok())
1405 .unwrap_or(default_value)
1406}
1407
1408fn read_env_f32(name: &str, default_value: f32) -> f32 {
1409 std::env::var(name)
1410 .ok()
1411 .and_then(|v| v.parse::<f32>().ok())
1412 .unwrap_or(default_value)
1413}
1414
1415fn read_env_bool(name: &str, default_value: bool) -> bool {
1416 std::env::var(name)
1417 .ok()
1418 .map(|v| {
1419 matches!(
1420 v.trim().to_ascii_lowercase().as_str(),
1421 "1" | "true" | "yes" | "on"
1422 )
1423 })
1424 .unwrap_or(default_value)
1425}
1426
1427fn read_env_string(name: &str) -> Option<String> {
1428 std::env::var(name).ok().map(|v| v.trim().to_string())
1429}
1430
1431fn resolve_health_ledger_dir() -> PathBuf {
1432 if let Some(custom) = read_env_string("AMEM_HEALTH_LEDGER_DIR") {
1433 if !custom.is_empty() {
1434 return PathBuf::from(custom);
1435 }
1436 }
1437 if let Some(custom) = read_env_string("AGENTRA_HEALTH_LEDGER_DIR") {
1438 if !custom.is_empty() {
1439 return PathBuf::from(custom);
1440 }
1441 }
1442
1443 let home = std::env::var("HOME")
1444 .ok()
1445 .map(PathBuf::from)
1446 .unwrap_or_else(|| PathBuf::from("."));
1447 home.join(".agentra").join("health-ledger")
1448}
1449
1450fn extract_prompt_capture_text(
1451 prompt_name: &str,
1452 arguments: Option<&Value>,
1453) -> McpResult<Option<String>> {
1454 let args = arguments.unwrap_or(&Value::Null);
1455 let fields = collect_text_fields_by_keys(
1456 args,
1457 &[
1458 "information",
1459 "context",
1460 "topic",
1461 "old_belief",
1462 "new_information",
1463 "reason",
1464 "summary",
1465 "instruction",
1466 "prompt",
1467 "query",
1468 ],
1469 8,
1470 );
1471 if fields.is_empty() {
1472 return Ok(None);
1473 }
1474 let joined = fields.join(" | ");
1475 Ok(Some(format!(
1476 "[auto-capture][prompt] template={prompt_name} input={joined}"
1477 )))
1478}
1479
1480fn extract_safe_tool_capture_text(
1481 tool_name: &str,
1482 arguments: Option<&Value>,
1483) -> McpResult<Option<String>> {
1484 let args = arguments.unwrap_or(&Value::Null);
1485 let keys = ["feedback", "summary", "note"];
1486 if tool_name != "session_end" {
1487 let explicit_feedback = collect_text_fields_by_keys(args, &["feedback", "note"], 4);
1489 if explicit_feedback.is_empty() {
1490 return Ok(None);
1491 }
1492 }
1493 let fields = collect_text_fields_by_keys(args, &keys, 6);
1494 if fields.is_empty() {
1495 return Ok(None);
1496 }
1497 Ok(Some(format!(
1498 "[auto-capture][feedback] tool={tool_name} context={}",
1499 fields.join(" | ")
1500 )))
1501}
1502
1503fn extract_full_tool_capture_text(
1504 tool_name: &str,
1505 arguments: Option<&Value>,
1506) -> McpResult<Option<String>> {
1507 if tool_name == "memory_add" {
1508 return Ok(None);
1509 }
1510 let args = arguments.unwrap_or(&Value::Null);
1511 let preferred = collect_text_fields_by_keys(
1512 args,
1513 &[
1514 "query",
1515 "content",
1516 "prompt",
1517 "new_content",
1518 "reason",
1519 "summary",
1520 "topic",
1521 "instruction",
1522 "information",
1523 "context",
1524 "feedback",
1525 ],
1526 10,
1527 );
1528
1529 let fields = if preferred.is_empty() {
1530 collect_all_string_like_fields(args, 8)
1531 } else {
1532 preferred
1533 };
1534
1535 if fields.is_empty() {
1536 return Ok(None);
1537 }
1538 Ok(Some(format!(
1539 "[auto-capture][tool] tool={tool_name} input={}",
1540 fields.join(" | ")
1541 )))
1542}
1543
1544fn collect_text_fields_by_keys(value: &Value, keys: &[&str], limit: usize) -> Vec<String> {
1545 let mut out = Vec::new();
1546 let mut seen = std::collections::BTreeSet::<String>::new();
1547
1548 fn walk(
1549 value: &Value,
1550 path: String,
1551 keys: &[&str],
1552 out: &mut Vec<String>,
1553 seen: &mut std::collections::BTreeSet<String>,
1554 limit: usize,
1555 ) {
1556 if out.len() >= limit {
1557 return;
1558 }
1559 match value {
1560 Value::Object(map) => {
1561 for (k, v) in map {
1562 if out.len() >= limit {
1563 break;
1564 }
1565 let next = if path.is_empty() {
1566 k.to_string()
1567 } else {
1568 format!("{path}.{k}")
1569 };
1570 let key_match = keys
1571 .iter()
1572 .any(|needle| k.eq_ignore_ascii_case(needle) || next.ends_with(needle));
1573 if key_match {
1574 if let Some(s) = value_to_compact_string(v) {
1575 let entry = format!("{next}={s}");
1576 if seen.insert(entry.clone()) {
1577 out.push(entry);
1578 }
1579 }
1580 }
1581 walk(v, next, keys, out, seen, limit);
1582 }
1583 }
1584 Value::Array(items) => {
1585 for (idx, item) in items.iter().enumerate() {
1586 if out.len() >= limit {
1587 break;
1588 }
1589 let next = format!("{path}[{idx}]");
1590 walk(item, next, keys, out, seen, limit);
1591 }
1592 }
1593 _ => {}
1594 }
1595 }
1596
1597 walk(value, String::new(), keys, &mut out, &mut seen, limit);
1598 out
1599}
1600
1601fn collect_all_string_like_fields(value: &Value, limit: usize) -> Vec<String> {
1602 let mut out = Vec::new();
1603 fn walk(value: &Value, path: String, out: &mut Vec<String>, limit: usize) {
1604 if out.len() >= limit {
1605 return;
1606 }
1607 match value {
1608 Value::Object(map) => {
1609 for (k, v) in map {
1610 if out.len() >= limit {
1611 break;
1612 }
1613 let next = if path.is_empty() {
1614 k.to_string()
1615 } else {
1616 format!("{path}.{k}")
1617 };
1618 walk(v, next, out, limit);
1619 }
1620 }
1621 Value::Array(items) => {
1622 for (idx, item) in items.iter().enumerate() {
1623 if out.len() >= limit {
1624 break;
1625 }
1626 walk(item, format!("{path}[{idx}]"), out, limit);
1627 }
1628 }
1629 _ => {
1630 if let Some(s) = value_to_compact_string(value) {
1631 out.push(format!("{path}={s}"));
1632 }
1633 }
1634 }
1635 }
1636 walk(value, String::new(), &mut out, limit);
1637 out
1638}
1639
1640fn value_to_compact_string(value: &Value) -> Option<String> {
1641 match value {
1642 Value::String(s) => {
1643 let trimmed = s.trim();
1644 if trimmed.is_empty() {
1645 None
1646 } else {
1647 Some(trimmed.to_string())
1648 }
1649 }
1650 Value::Number(n) => Some(n.to_string()),
1651 Value::Bool(b) => Some(b.to_string()),
1652 Value::Null => None,
1653 Value::Array(arr) => {
1654 if arr.is_empty() {
1655 None
1656 } else {
1657 Some(format!("<array:{}>", arr.len()))
1658 }
1659 }
1660 Value::Object(map) => {
1661 if map.is_empty() {
1662 None
1663 } else {
1664 Some(format!("<object:{}>", map.len()))
1665 }
1666 }
1667 }
1668}
1669
1670fn redact_sensitive_tokens(text: &str) -> String {
1671 text.split_whitespace()
1672 .map(redact_token)
1673 .collect::<Vec<_>>()
1674 .join(" ")
1675}
1676
1677fn redact_token(token: &str) -> String {
1678 let trimmed = token.trim_matches(|c: char| c == '"' || c == '\'' || c == ',' || c == ';');
1679 let lower = trimmed.to_ascii_lowercase();
1680 if trimmed.starts_with("/Users/")
1681 || trimmed.starts_with("C:\\Users\\")
1682 || trimmed.contains("/Users/")
1683 || trimmed.contains("C:\\Users\\")
1684 {
1685 return "[REDACTED_PATH]".to_string();
1686 }
1687 if trimmed.contains('@') && trimmed.contains('.') {
1688 return "[REDACTED_EMAIL]".to_string();
1689 }
1690 if lower.starts_with("sk-")
1691 || lower.contains("api_key")
1692 || lower.contains("access_token")
1693 || lower.contains("bearer")
1694 || lower.contains("authorization")
1695 {
1696 return "[REDACTED_SECRET]".to_string();
1697 }
1698 if looks_like_long_secret(trimmed) {
1699 return "[REDACTED_SECRET]".to_string();
1700 }
1701 token.to_string()
1702}
1703
1704fn looks_like_long_secret(token: &str) -> bool {
1705 if token.len() < 24 {
1706 return false;
1707 }
1708 token
1709 .chars()
1710 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
1711}
1712
1713struct FileLock {
1719 lock_path: PathBuf,
1720}
1721
1722impl FileLock {
1723 fn acquire(data_path: &Path) -> McpResult<Self> {
1726 let lock_path = data_path.with_extension("amem.lock");
1727 let stale_threshold = Duration::from_secs(60);
1728 let max_attempts = 200; for attempt in 0..max_attempts {
1731 match OpenOptions::new()
1732 .write(true)
1733 .create_new(true)
1734 .open(&lock_path)
1735 {
1736 Ok(_file) => {
1737 if attempt > 0 {
1738 tracing::debug!(
1739 "Acquired file lock after {} attempts: {}",
1740 attempt + 1,
1741 lock_path.display()
1742 );
1743 }
1744 return Ok(FileLock { lock_path });
1745 }
1746 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
1747 if let Ok(meta) = std::fs::metadata(&lock_path) {
1749 let is_stale = meta
1750 .modified()
1751 .ok()
1752 .and_then(|m| m.elapsed().ok())
1753 .map(|age| age > stale_threshold)
1754 .unwrap_or(false);
1755 if is_stale {
1756 tracing::warn!("Removing stale lock file: {}", lock_path.display());
1757 let _ = std::fs::remove_file(&lock_path);
1758 continue;
1759 }
1760 }
1761 std::thread::sleep(Duration::from_millis(50));
1762 }
1763 Err(e) => {
1764 return Err(McpError::Io(e));
1765 }
1766 }
1767 }
1768
1769 Err(McpError::AgenticMemory(format!(
1770 "Timed out waiting for file lock: {}",
1771 lock_path.display()
1772 )))
1773 }
1774}
1775
1776impl Drop for FileLock {
1777 fn drop(&mut self) {
1778 let _ = std::fs::remove_file(&self.lock_path);
1779 }
1780}
1781
1782fn find_project_root(start: &Path) -> Option<PathBuf> {
1785 let mut current = start.to_path_buf();
1786 for _ in 0..20 {
1787 if current.join(".claude").is_dir() || current.join(".git").is_dir() {
1789 return Some(current);
1790 }
1791 if !current.pop() {
1792 break;
1793 }
1794 }
1795 None
1796}
1797
1798#[cfg(test)]
1799mod tests {
1800 use super::*;
1801 use serde_json::json;
1802
1803 #[test]
1804 fn budget_projection_available_with_timeline() {
1805 let dir = tempfile::tempdir().expect("tempdir");
1806 let brain = dir.path().join("projection.amem");
1807 let mut manager = SessionManager::open(brain.to_str().expect("path")).expect("open");
1808
1809 let (id_a, _) = manager
1810 .add_event(EventType::Fact, "old fact", 0.9, vec![])
1811 .expect("add fact");
1812 let (_id_b, _) = manager
1813 .add_event(EventType::Fact, "new fact", 0.9, vec![])
1814 .expect("add fact");
1815
1816 {
1817 let graph = manager.graph_mut();
1818 let old = graph.get_node_mut(id_a).expect("node");
1819 old.created_at = old.created_at.saturating_sub(15 * 24 * 3600 * 1_000_000);
1820 }
1821 manager.save().expect("save");
1822 let size = manager.current_file_size_bytes();
1823 let projected = manager.projected_file_size_bytes(size);
1824 assert!(size > 0);
1825 assert!(projected.is_some());
1826 }
1827
1828 #[test]
1829 fn budget_auto_rollup_archives_completed_session() {
1830 let dir = tempfile::tempdir().expect("tempdir");
1831 let brain = dir.path().join("rollup.amem");
1832 let mut manager = SessionManager::open(brain.to_str().expect("path")).expect("open");
1833
1834 let _ = manager
1836 .add_event(EventType::Fact, "alpha", 0.8, vec![])
1837 .expect("add");
1838 let _ = manager
1839 .add_event(EventType::Decision, "beta", 0.9, vec![])
1840 .expect("add");
1841 manager.start_session(None).expect("session");
1842 manager.save().expect("save");
1843
1844 manager.storage_budget_mode = StorageBudgetMode::AutoRollup;
1846 manager.storage_budget_max_bytes = 1;
1847 manager.storage_budget_target_fraction = 0.5;
1848
1849 manager
1850 .maybe_enforce_storage_budget()
1851 .expect("enforce budget");
1852
1853 let episode_count = manager
1854 .graph()
1855 .nodes()
1856 .iter()
1857 .filter(|n| n.event_type == EventType::Episode)
1858 .count();
1859 assert!(episode_count >= 1);
1860 assert!(manager.storage_budget_rollup_count >= 1);
1861 }
1862
1863 #[test]
1864 fn auto_capture_off_noop() {
1865 let dir = tempfile::tempdir().expect("tempdir");
1866 let brain = dir.path().join("capture-off.amem");
1867 let mut manager = SessionManager::open(brain.to_str().expect("path")).expect("open");
1868 manager.auto_capture_mode = AutoCaptureMode::Off;
1869
1870 let captured = manager
1871 .capture_prompt_request(
1872 "remember",
1873 Some(&json!({"information":"hello world","context":"ctx"})),
1874 )
1875 .expect("capture");
1876 assert!(captured.is_none());
1877 assert_eq!(manager.graph().node_count(), 0);
1878 }
1879
1880 #[test]
1881 fn auto_capture_full_records_and_redacts() {
1882 let dir = tempfile::tempdir().expect("tempdir");
1883 let brain = dir.path().join("capture-full.amem");
1884 let mut manager = SessionManager::open(brain.to_str().expect("path")).expect("open");
1885 manager.auto_capture_mode = AutoCaptureMode::Full;
1886 manager.auto_capture_redact = true;
1887
1888 manager
1889 .capture_tool_call(
1890 "memory_query",
1891 Some(&json!({
1892 "query":"Find anything about token sk-THISISALONGSECRET123456",
1893 "context":"/Users/omoshola/Documents/private.txt",
1894 "reason":"email me at test@example.com"
1895 })),
1896 )
1897 .expect("capture");
1898
1899 assert!(manager.graph().node_count() >= 1);
1900 let latest = manager
1901 .graph()
1902 .nodes()
1903 .iter()
1904 .max_by_key(|n| n.id)
1905 .expect("node");
1906 assert!(latest.content.contains("[auto-capture][tool]"));
1907 assert!(latest.content.contains("[REDACTED_SECRET]"));
1908 assert!(latest.content.contains("[REDACTED_PATH]"));
1909 assert!(latest.content.contains("[REDACTED_EMAIL]"));
1910 }
1911
1912 #[test]
1913 fn auto_capture_temporal_chain() {
1914 let dir = tempfile::tempdir().expect("tempdir");
1915 let brain = dir.path().join("chain.amem");
1916 let mut manager = SessionManager::open(brain.to_str().expect("path")).expect("open");
1917 manager.auto_capture_mode = AutoCaptureMode::Full;
1918
1919 let id1 = manager
1921 .capture_tool_call("memory_query", Some(&json!({"query": "first question"})))
1922 .expect("capture")
1923 .expect("node_id");
1924
1925 assert_eq!(manager.last_temporal_node_id(), Some(id1));
1926
1927 let id2 = manager
1929 .capture_tool_call(
1930 "memory_similar",
1931 Some(&json!({"query_text": "second question"})),
1932 )
1933 .expect("capture")
1934 .expect("node_id");
1935
1936 assert_eq!(manager.last_temporal_node_id(), Some(id2));
1937
1938 let has_temporal = manager.graph().edges().iter().any(|e| {
1940 e.source_id == id1 && e.target_id == id2 && e.edge_type == EdgeType::TemporalNext
1941 });
1942 assert!(has_temporal, "Expected TemporalNext edge from id1 to id2");
1943 }
1944
1945 #[test]
1946 fn temporal_chain_resets_on_new_session() {
1947 let dir = tempfile::tempdir().expect("tempdir");
1948 let brain = dir.path().join("reset.amem");
1949 let mut manager = SessionManager::open(brain.to_str().expect("path")).expect("open");
1950 manager.auto_capture_mode = AutoCaptureMode::Full;
1951
1952 let _id1 = manager
1953 .capture_tool_call("memory_query", Some(&json!({"query": "first"})))
1954 .expect("capture");
1955
1956 assert!(manager.last_temporal_node_id().is_some());
1957
1958 manager.start_session(None).expect("new session");
1960 assert!(manager.last_temporal_node_id().is_none());
1961 }
1962
1963 #[test]
1964 fn memory_add_joins_temporal_chain() {
1965 let dir = tempfile::tempdir().expect("tempdir");
1966 let brain = dir.path().join("splice.amem");
1967 let mut manager = SessionManager::open(brain.to_str().expect("path")).expect("open");
1968 manager.auto_capture_mode = AutoCaptureMode::Full;
1969
1970 let id1 = manager
1972 .capture_tool_call("memory_query", Some(&json!({"query": "something"})))
1973 .expect("capture")
1974 .expect("node_id");
1975
1976 let (id2, _) = manager
1978 .add_event(EventType::Fact, "User prefers dark mode", 0.9, vec![])
1979 .expect("add_event");
1980 manager.link_temporal(id1, id2).expect("link");
1981 manager.advance_temporal_chain(id2);
1982
1983 assert_eq!(manager.last_temporal_node_id(), Some(id2));
1984
1985 let has_edge = manager.graph().edges().iter().any(|e| {
1986 e.source_id == id1 && e.target_id == id2 && e.edge_type == EdgeType::TemporalNext
1987 });
1988 assert!(has_edge, "memory_add node should be linked into chain");
1989 }
1990}