Skip to main content

lean_ctx/core/config/
memory.rs

1//! RAM cleanup profile and memory-footprint presets (`config.toml`).
2
3use serde::{Deserialize, Serialize};
4
5use super::Config;
6
7/// Controls how aggressively lean-ctx frees memory when idle.
8/// - `aggressive`: Cache cleared after short idle period (5 min). Best for low-memory devices.
9/// - `shared`: (Default) Cache retained for 1 hour. Best for typical agent sessions with think pauses.
10#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
11#[serde(rename_all = "lowercase")]
12pub enum MemoryCleanup {
13    Aggressive,
14    #[default]
15    Shared,
16}
17
18impl MemoryCleanup {
19    pub fn from_env() -> Option<Self> {
20        std::env::var("LEAN_CTX_MEMORY_CLEANUP").ok().and_then(|v| {
21            match v.trim().to_lowercase().as_str() {
22                "aggressive" => Some(Self::Aggressive),
23                "shared" => Some(Self::Shared),
24                _ => None,
25            }
26        })
27    }
28
29    pub fn effective(config: &Config) -> Self {
30        if let Some(env_val) = Self::from_env() {
31            return env_val;
32        }
33        config.memory_cleanup.clone()
34    }
35
36    /// Idle TTL in seconds before cache is auto-cleared.
37    pub fn idle_ttl_secs(&self) -> u64 {
38        match self {
39            Self::Aggressive => 300,
40            Self::Shared => 3600,
41        }
42    }
43
44    /// BM25 index eviction age multiplier (shared mode retains longer).
45    pub fn index_retention_multiplier(&self) -> f64 {
46        match self {
47            Self::Aggressive => 1.0,
48            Self::Shared => 3.0,
49        }
50    }
51}
52
53/// Controls RAM usage vs. feature richness trade-off.
54/// - `low`: Minimal RAM footprint, disables optional caches and embedding features
55/// - `balanced`: Default — moderate caches, single embedding engine
56/// - `performance`: Maximum caches, all features enabled
57#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
58#[serde(rename_all = "lowercase")]
59pub enum MemoryProfile {
60    Low,
61    Balanced,
62    #[default]
63    Performance,
64}
65
66impl MemoryProfile {
67    pub fn from_env() -> Option<Self> {
68        std::env::var("LEAN_CTX_MEMORY_PROFILE").ok().and_then(|v| {
69            match v.trim().to_lowercase().as_str() {
70                "low" => Some(Self::Low),
71                "balanced" => Some(Self::Balanced),
72                "performance" => Some(Self::Performance),
73                _ => None,
74            }
75        })
76    }
77
78    pub fn effective(config: &Config) -> Self {
79        if let Some(env_val) = Self::from_env() {
80            return env_val;
81        }
82        config.memory_profile.clone()
83    }
84
85    pub fn bm25_max_cache_mb(&self) -> u64 {
86        match self {
87            Self::Low => 64,
88            Self::Balanced => 128,
89            Self::Performance => 512,
90        }
91    }
92
93    pub fn semantic_cache_enabled(&self) -> bool {
94        !matches!(self, Self::Low)
95    }
96
97    pub fn embeddings_enabled(&self) -> bool {
98        !matches!(self, Self::Low)
99    }
100
101    /// How long an unused language server stays warm before it is shut down.
102    pub fn lsp_idle_ttl(&self) -> std::time::Duration {
103        std::time::Duration::from_mins(match self {
104            Self::Low => 5,
105            Self::Balanced => 15,
106            Self::Performance => 30,
107        })
108    }
109}
110
111/// Controls visibility of token savings footers in tool output.
112///
113/// - `always` (default): shown on every compressed response
114/// - `never`: suppressed everywhere
115/// - `auto`: legacy compatibility mode; behavior is transport/context dependent
116///
117/// Also controllable via `LEAN_CTX_SHOW_SAVINGS=1|0` (overrides this setting).
118#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
119#[serde(rename_all = "lowercase")]
120pub enum SavingsFooter {
121    Auto,
122    Always,
123    #[default]
124    Never,
125}
126
127impl SavingsFooter {
128    pub fn from_env() -> Option<Self> {
129        std::env::var("LEAN_CTX_SAVINGS_FOOTER").ok().and_then(|v| {
130            match v.trim().to_lowercase().as_str() {
131                "auto" => Some(Self::Auto),
132                "always" => Some(Self::Always),
133                "never" => Some(Self::Never),
134                _ => None,
135            }
136        })
137    }
138
139    pub fn effective() -> Self {
140        if let Some(env_val) = Self::from_env() {
141            return env_val;
142        }
143        let cfg = super::Config::load();
144        cfg.savings_footer.clone()
145    }
146}
147
148/// Controls how compression percentages are displayed in savings footers.
149///
150/// - `Full`: Show exact percentage (e.g., `↓42%`) — legacy behavior
151/// - `Quantized`: Round to nearest 10% (e.g., `↓~40%`) — reduces prefix variation
152/// - `None`: Suppress all savings annotations
153#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
154#[serde(rename_all = "lowercase")]
155pub enum CompressionAnnotation {
156    Full,
157    #[default]
158    Quantized,
159    None,
160}
161
162impl CompressionAnnotation {
163    pub fn effective() -> Self {
164        if let Ok(v) = std::env::var("LEAN_CTX_COMPRESSION_ANNOTATION") {
165            return match v.trim().to_lowercase().as_str() {
166                "full" => Self::Full,
167                "quantized" => Self::Quantized,
168                "none" => Self::None,
169                _ => Self::default(),
170            };
171        }
172        let cfg = super::Config::load();
173        cfg.compression_annotation.clone()
174    }
175}
176
177/// RSS-based memory guardian configuration.
178pub struct MemoryGuardConfig {
179    pub max_ram_percent: u8,
180}
181
182impl MemoryGuardConfig {
183    pub fn effective(config: &Config) -> Self {
184        let base_pct = std::env::var("LEAN_CTX_MAX_RAM_PERCENT")
185            .ok()
186            .and_then(|v| v.parse::<u8>().ok())
187            .unwrap_or(config.max_ram_percent)
188            .clamp(1, 50);
189        // memory_profile=low halves max_ram_percent so guardian thresholds
190        // fire earlier, preventing transient RSS spikes (#790).
191        let profile = MemoryProfile::effective(config);
192        let pct = if profile == MemoryProfile::Low {
193            (base_pct / 2).max(3)
194        } else {
195            base_pct
196        };
197        Self {
198            max_ram_percent: pct,
199        }
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn savings_footer_defaults_to_never() {
209        assert_eq!(SavingsFooter::default(), SavingsFooter::Never);
210    }
211
212    #[test]
213    fn savings_footer_from_env_accepts_auto() {
214        let _guard = crate::core::data_dir::test_env_lock();
215        crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "auto");
216        assert_eq!(SavingsFooter::from_env(), Some(SavingsFooter::Auto));
217        crate::test_env::remove_var("LEAN_CTX_SAVINGS_FOOTER");
218    }
219}