Skip to main content

agentic_memory_mcp/session/
manager.rs

1//! Graph lifecycle management, file I/O, and session tracking.
2
3use 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
18/// Default auto-save interval.
19const DEFAULT_AUTO_SAVE_SECS: u64 = 30;
20/// Default backup interval.
21const DEFAULT_BACKUP_INTERVAL_SECS: u64 = 900;
22/// Default number of backups to retain per brain file.
23const DEFAULT_BACKUP_RETENTION: usize = 24;
24/// Default maintenance sleep-cycle interval.
25const DEFAULT_SLEEP_CYCLE_SECS: u64 = 1800;
26/// Minimum completed-session size before auto-archive.
27const DEFAULT_ARCHIVE_MIN_SESSION_NODES: usize = 25;
28/// Default hot-tier threshold (decay score).
29const DEFAULT_HOT_MIN_DECAY: f32 = 0.7;
30/// Default warm-tier threshold (decay score).
31const DEFAULT_WARM_MIN_DECAY: f32 = 0.3;
32/// Default sustained mutation rate threshold before throttling heavy maintenance.
33const DEFAULT_SLA_MAX_MUTATIONS_PER_MIN: u32 = 240;
34/// Default interval for writing health-ledger snapshots.
35const DEFAULT_HEALTH_LEDGER_EMIT_SECS: u64 = 30;
36/// Default long-horizon storage budget target (2 GiB over 20 years).
37const DEFAULT_STORAGE_BUDGET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
38/// Default storage budget projection horizon.
39const DEFAULT_STORAGE_BUDGET_HORIZON_YEARS: u32 = 20;
40/// Default maximum chars persisted for one auto-captured prompt/feedback item.
41const DEFAULT_AUTO_CAPTURE_MAX_CHARS: usize = 2048;
42/// Current `.amem` storage version used by this server.
43const 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    /// Capture prompt-focused events and feedback context.
69    Safe,
70    /// Capture broader tool input text (except explicit memory_add payload duplication).
71    Full,
72    /// Disable automatic capture.
73    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
203/// Manages the memory graph lifecycle, file I/O, and session state.
204pub 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    /// ID of the last node added to the temporal chain in this session.
244    /// Used to create TemporalNext edges between consecutive captures.
245    last_temporal_node_id: Option<u64>,
246    /// Last known file modification time (for detecting external writes).
247    last_file_mtime: Option<SystemTime>,
248    /// Multi-context workspace manager for cross-memory queries.
249    workspace_manager: super::workspace::WorkspaceManager,
250}
251
252impl SessionManager {
253    /// Open or create a memory file at the given path.
254    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            // Ensure parent directory exists
275            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        // Determine the next session ID from existing sessions.
287        // Incorporate PID to avoid collisions when multiple MCP instances share
288        // the same .amem file (e.g. two Claude Code windows on different projects).
289        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    /// Get an immutable reference to the graph.
443    pub fn graph(&self) -> &MemoryGraph {
444        &self.graph
445    }
446
447    /// Get a mutable reference to the graph and mark as dirty.
448    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    /// Get the query engine.
456    pub fn query_engine(&self) -> &QueryEngine {
457        &self.query_engine
458    }
459
460    /// Get the write engine.
461    pub fn write_engine(&self) -> &WriteEngine {
462        &self.write_engine
463    }
464
465    /// Get the workspace manager (immutable).
466    pub fn workspace_manager(&self) -> &super::workspace::WorkspaceManager {
467        &self.workspace_manager
468    }
469
470    /// Get the workspace manager (mutable).
471    pub fn workspace_manager_mut(&mut self) -> &mut super::workspace::WorkspaceManager {
472        &mut self.workspace_manager
473    }
474
475    /// Current session ID.
476    pub fn current_session_id(&self) -> u32 {
477        self.current_session
478    }
479
480    /// Start a new session, optionally with an explicit ID.
481    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            // Ensure monotonic: new session must be > current session.
486            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    /// End a session and optionally create an episode summary.
497    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        // Write auto-context files for next session bootstrap.
511        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    /// Write session context to file(s) for the next session to bootstrap from.
519    ///
520    /// Writes to:
521    ///   1. `~/.agentic/memory-context.md` (global fallback for any client)
522    ///   2. `.claude/memory-context.md`     (project-scoped for Claude Code)
523    ///
524    /// This solves the bootstrap problem: the next session has context ready
525    /// before the agent even calls any tools.
526    fn write_context_files(&self, session_summary: &str) -> McpResult<()> {
527        let query = self.query_engine();
528        let graph = self.graph();
529
530        // Gather recent decisions.
531        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        // Gather recent high-confidence facts.
549        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        // Build markdown content.
567        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        // 1. Write global context file.
591        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        // 2. Write project-scoped context file (for Claude Code).
604        //    Detect project root by walking up from the .amem file location
605        //    looking for .claude/ directory or .git/.
606        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    /// Save the graph to file with file-locking for concurrent session safety.
625    ///
626    /// When multiple MCP instances share the same `.amem` file, this method:
627    /// 1. Acquires an exclusive file lock (sidecar `.amem.lock`)
628    /// 2. Checks if the file was modified externally (by another instance)
629    /// 3. If so, re-reads the disk graph and merges our session's new nodes
630    /// 4. Writes the merged graph and releases the lock
631    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        // Detect external modifications from concurrent sessions.
639        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        // Update our mtime tracking after successful write.
657        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    /// Merge our session's nodes/edges with the latest disk state.
669    ///
670    /// This handles the case where another MCP instance wrote to the same file
671    /// since we last read it. We re-read the disk, then re-add our session's
672    /// nodes on top of the latest state.
673    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        // Collect our session's nodes (those we created in this process).
678        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        // Collect edges where source belongs to our session.
687        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        // Replace our graph with the latest disk state.
697        self.graph = disk_graph;
698
699        // Re-add our session's nodes with fresh IDs from the merged graph.
700        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        // Re-add our session's edges with remapped IDs.
716        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    /// Check if auto-save is needed and save if so.
741    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    /// Runs autonomous maintenance: sleep-cycle, auto-save, and periodic backup.
749    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    /// Run a periodic sleep-cycle: decay refresh + tier balancing + auto-archive.
771    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    /// Periodic backup of persisted state with retention pruning.
805    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    /// Mark the graph as dirty (needs saving).
827    pub fn mark_dirty(&mut self) {
828        self.dirty = true;
829        self.last_activity = Instant::now();
830        self.record_mutation();
831    }
832
833    /// Get the file path.
834    pub fn file_path(&self) -> &PathBuf {
835        &self.file_path
836    }
837
838    /// The ID of the most recent node in the temporal chain for this session.
839    pub fn last_temporal_node_id(&self) -> Option<u64> {
840        self.last_temporal_node_id
841    }
842
843    /// Advance the temporal chain pointer to the given node ID.
844    pub fn advance_temporal_chain(&mut self, node_id: u64) {
845        self.last_temporal_node_id = Some(node_id);
846    }
847
848    /// Create a TemporalNext edge from `prev_id` to `next_id` (forward in time).
849    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    /// Background maintenance loop interval.
859    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    /// Capture a prompt template invocation (`prompts/get`) into memory.
866    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    /// Capture a tool call input context into memory based on capture mode.
881    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    /// Add a cognitive event to the graph.
902    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        // First, add the node to get its assigned ID
915        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        // Then add edges with the correct source_id
925        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    /// Correct a previous belief.
943    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        // Chain this capture to the previous node in the session's temporal thread.
1242        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        // Clamp to at least one week to avoid unstable extrapolation on tiny windows.
1269        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        // Keep safe mode low-noise and non-invasive: only capture explicit feedback fields.
1488        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
1713/// File-based exclusive lock for concurrent `.amem` access.
1714///
1715/// Uses a sidecar `.amem.lock` file with `create_new` (O_EXCL) for atomic
1716/// creation. Stale locks older than 60 seconds are auto-cleaned. The lock
1717/// is released on drop.
1718struct FileLock {
1719    lock_path: PathBuf,
1720}
1721
1722impl FileLock {
1723    /// Acquire an exclusive lock for the given data file.
1724    /// Spins with a 50ms backoff until the lock is available.
1725    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; // 200 * 50ms = 10 seconds max wait
1729
1730        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                    // Check if the lock is stale (owner crashed).
1748                    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
1782/// Walk up from `start` looking for a directory that contains `.claude/` or `.git/`.
1783/// Returns the first such ancestor, or `None` if we reach the filesystem root.
1784fn find_project_root(start: &Path) -> Option<PathBuf> {
1785    let mut current = start.to_path_buf();
1786    for _ in 0..20 {
1787        // Prefer .claude/ (Claude Code project), fall back to .git/
1788        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        // Build current session with enough content, then advance so it becomes completed.
1835        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        // Force tiny budget to trigger rollup.
1845        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        // First capture: no predecessor.
1920        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        // Second capture: should link to first.
1928        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        // Verify the TemporalNext edge exists: id1 -> id2.
1939        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        // Starting a new session should reset the chain.
1959        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        // Create a chain head via auto-capture.
1971        let id1 = manager
1972            .capture_tool_call("memory_query", Some(&json!({"query": "something"})))
1973            .expect("capture")
1974            .expect("node_id");
1975
1976        // Simulate what memory_add tool does: add_event + link_temporal + advance.
1977        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}