1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use std::sync::atomic::AtomicU8;
5use std::sync::Mutex;
6use std::time::SystemTime;
7
8static SESSION_DEGRADE_LEVEL: AtomicU8 = AtomicU8::new(0);
9
10use super::memory_policy::MemoryPolicy;
11
12mod memory;
13mod proxy;
14pub mod schema;
15mod serde_defaults;
16mod shell_activation;
17
18pub use memory::{MemoryCleanup, MemoryGuardConfig, MemoryProfile, SavingsFooter};
19pub use proxy::{is_local_proxy_url, normalize_url, normalize_url_opt, ProxyConfig, ProxyProvider};
20pub use shell_activation::ShellActivation;
21
22pub fn default_bm25_max_cache_mb() -> u64 {
24 serde_defaults::default_bm25_max_cache_mb()
25}
26
27#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
29#[serde(rename_all = "lowercase")]
30pub enum TeeMode {
31 Never,
32 #[default]
33 Failures,
34 HighCompression,
35 Always,
36}
37
38#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
42#[serde(rename_all = "lowercase")]
43pub enum TerseAgent {
44 #[default]
45 Off,
46 Lite,
47 Full,
48 Ultra,
49}
50
51impl TerseAgent {
52 pub fn from_env() -> Self {
54 match std::env::var("LEAN_CTX_TERSE_AGENT")
55 .unwrap_or_default()
56 .to_lowercase()
57 .as_str()
58 {
59 "lite" => Self::Lite,
60 "full" => Self::Full,
61 "ultra" => Self::Ultra,
62 _ => Self::Off,
63 }
64 }
65}
66
67#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
71#[serde(rename_all = "lowercase")]
72pub enum OutputDensity {
73 #[default]
74 Normal,
75 Terse,
76 Ultra,
77}
78
79impl OutputDensity {
80 pub fn from_env() -> Self {
82 match std::env::var("LEAN_CTX_OUTPUT_DENSITY")
83 .unwrap_or_default()
84 .to_lowercase()
85 .as_str()
86 {
87 "terse" => Self::Terse,
88 "ultra" => Self::Ultra,
89 _ => Self::Normal,
90 }
91 }
92}
93
94#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
99#[serde(rename_all = "snake_case")]
100pub enum ResponseVerbosity {
101 #[default]
102 Full,
103 HeadersOnly,
104}
105
106impl ResponseVerbosity {
107 pub fn effective() -> Self {
108 if let Ok(v) = std::env::var("LEAN_CTX_RESPONSE_VERBOSITY") {
109 match v.trim().to_lowercase().as_str() {
110 "headers_only" | "headers" | "minimal" => return Self::HeadersOnly,
111 "full" | "" => return Self::Full,
112 _ => {}
113 }
114 }
115 Config::load().response_verbosity
116 }
117
118 pub fn is_headers_only(&self) -> bool {
119 matches!(self, Self::HeadersOnly)
120 }
121}
122
123#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
125#[serde(rename_all = "lowercase")]
126pub enum CompressionLevel {
127 Off,
128 Lite,
129 #[default]
130 Standard,
131 Max,
132}
133
134impl CompressionLevel {
135 pub fn to_components(&self) -> (TerseAgent, OutputDensity, &'static str, bool) {
138 match self {
139 Self::Off => (TerseAgent::Off, OutputDensity::Normal, "off", false),
140 Self::Lite => (TerseAgent::Lite, OutputDensity::Terse, "off", true),
141 Self::Standard => (TerseAgent::Full, OutputDensity::Terse, "compact", true),
142 Self::Max => (TerseAgent::Ultra, OutputDensity::Ultra, "tdd", true),
143 }
144 }
145
146 pub fn from_legacy(terse_agent: &TerseAgent, output_density: &OutputDensity) -> Self {
149 match (terse_agent, output_density) {
150 (TerseAgent::Ultra, _) | (_, OutputDensity::Ultra) => Self::Max,
151 (TerseAgent::Full, _) => Self::Standard,
152 (TerseAgent::Lite, _) | (_, OutputDensity::Terse) => Self::Lite,
153 _ => Self::Off,
154 }
155 }
156
157 pub fn from_env() -> Option<Self> {
159 std::env::var("LEAN_CTX_COMPRESSION").ok().and_then(|v| {
160 match v.trim().to_lowercase().as_str() {
161 "off" => Some(Self::Off),
162 "lite" => Some(Self::Lite),
163 "standard" => Some(Self::Standard),
164 "max" => Some(Self::Max),
165 _ => None,
166 }
167 })
168 }
169
170 pub fn effective(config: &Config) -> Self {
178 if let Some(degraded) = Self::session_degrade_level() {
179 return degraded;
180 }
181 if let Some(env_level) = Self::from_env() {
182 return env_level;
183 }
184 if config.compression_level != Self::Off {
185 return config.compression_level.clone();
186 }
187 if config.ultra_compact {
188 return Self::Max;
189 }
190 let ta_env = TerseAgent::from_env();
191 let od_env = OutputDensity::from_env();
192 let ta = if ta_env == TerseAgent::Off {
193 config.terse_agent.clone()
194 } else {
195 ta_env
196 };
197 let od = if od_env == OutputDensity::Normal {
198 config.output_density.clone()
199 } else {
200 od_env
201 };
202 Self::from_legacy(&ta, &od)
203 }
204
205 pub fn session_degrade_level() -> Option<Self> {
208 match SESSION_DEGRADE_LEVEL.load(std::sync::atomic::Ordering::Relaxed) {
209 1 => Some(Self::Off),
210 2 => Some(Self::Lite),
211 _ => None,
212 }
213 }
214
215 pub fn set_session_degrade(level: &Self) {
217 let val = match level {
218 Self::Off => 1u8,
219 Self::Lite => 2u8,
220 _ => 0u8,
221 };
222 SESSION_DEGRADE_LEVEL.store(val, std::sync::atomic::Ordering::Relaxed);
223 }
224
225 pub fn clear_session_degrade() {
227 SESSION_DEGRADE_LEVEL.store(0, std::sync::atomic::Ordering::Relaxed);
228 }
229
230 pub fn from_str_label(s: &str) -> Option<Self> {
231 match s.trim().to_lowercase().as_str() {
232 "off" => Some(Self::Off),
233 "lite" => Some(Self::Lite),
234 "standard" | "std" => Some(Self::Standard),
235 "max" => Some(Self::Max),
236 _ => None,
237 }
238 }
239
240 pub fn is_active(&self) -> bool {
241 !matches!(self, Self::Off)
242 }
243
244 pub fn label(&self) -> &'static str {
245 match self {
246 Self::Off => "off",
247 Self::Lite => "lite",
248 Self::Standard => "standard",
249 Self::Max => "max",
250 }
251 }
252
253 pub fn description(&self) -> &'static str {
254 match self {
255 Self::Off => "No compression — full verbose output",
256 Self::Lite => "Light compression — concise output, basic terse filtering",
257 Self::Standard => {
258 "Standard compression — dense output, compact protocol, pattern-aware"
259 }
260 Self::Max => "Maximum compression — expert mode, TDD protocol, all layers active",
261 }
262 }
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
267#[serde(default)]
268pub struct Config {
269 pub ultra_compact: bool,
270 #[serde(default, deserialize_with = "serde_defaults::deserialize_tee_mode")]
271 pub tee_mode: TeeMode,
272 #[serde(default)]
273 pub output_density: OutputDensity,
274 pub checkpoint_interval: u32,
275 pub excluded_commands: Vec<String>,
276 pub passthrough_urls: Vec<String>,
277 pub custom_aliases: Vec<AliasEntry>,
278 pub slow_command_threshold_ms: u64,
281 #[serde(default = "serde_defaults::default_theme")]
282 pub theme: String,
283 #[serde(default)]
284 pub cloud: CloudConfig,
285 #[serde(default)]
286 pub autonomy: AutonomyConfig,
287 #[serde(default)]
288 pub providers: ProvidersConfig,
289 #[serde(default)]
290 pub proxy: ProxyConfig,
291 #[serde(default)]
296 pub proxy_enabled: Option<bool>,
297 #[serde(default)]
298 pub proxy_port: Option<u16>,
299 #[serde(default)]
302 pub proxy_timeout_ms: Option<u64>,
303 #[serde(default = "serde_defaults::default_buddy_enabled")]
304 pub buddy_enabled: bool,
305 #[serde(default = "serde_defaults::default_true")]
306 pub enable_wakeup_ctx: bool,
307 #[serde(default)]
308 pub redirect_exclude: Vec<String>,
309 #[serde(default)]
313 pub disabled_tools: Vec<String>,
314 #[serde(default)]
320 pub default_tool_categories: Vec<String>,
321 #[serde(default)]
325 pub no_degrade: bool,
326 #[serde(default)]
327 pub loop_detection: LoopDetectionConfig,
328 #[serde(default)]
332 pub rules_scope: Option<String>,
333 #[serde(default)]
336 pub extra_ignore_patterns: Vec<String>,
337 #[serde(default)]
341 pub terse_agent: TerseAgent,
342 #[serde(default)]
346 pub compression_level: CompressionLevel,
347 #[serde(default)]
349 pub archive: ArchiveConfig,
350 #[serde(default)]
352 pub memory: MemoryPolicy,
353 #[serde(default)]
357 pub allow_paths: Vec<String>,
358 #[serde(default)]
361 pub content_defined_chunking: bool,
362 #[serde(default)]
365 pub minimal_overhead: bool,
366 #[serde(default)]
369 pub shell_hook_disabled: bool,
370 #[serde(default)]
377 pub shell_activation: ShellActivation,
378 #[serde(default)]
381 pub update_check_disabled: bool,
382 #[serde(default)]
383 pub updates: UpdatesConfig,
384 #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
387 pub bm25_max_cache_mb: u64,
388 #[serde(default = "serde_defaults::default_graph_index_max_files")]
391 pub graph_index_max_files: u64,
392 #[serde(default)]
395 pub memory_profile: MemoryProfile,
396 #[serde(default)]
400 pub memory_cleanup: MemoryCleanup,
401 #[serde(default = "serde_defaults::default_max_ram_percent")]
404 pub max_ram_percent: u8,
405 #[serde(default)]
409 pub savings_footer: SavingsFooter,
410 #[serde(default)]
414 pub project_root: Option<String>,
415 #[serde(default)]
418 pub lsp: std::collections::HashMap<String, String>,
419 #[serde(default)]
423 pub ide_paths: HashMap<String, Vec<String>>,
424 #[serde(default)]
427 pub model_context_windows: HashMap<String, usize>,
428 #[serde(default)]
435 pub response_verbosity: ResponseVerbosity,
436 #[serde(default)]
441 pub bypass_hints: Option<String>,
442 #[serde(default)]
447 pub cache_policy: Option<String>,
448 #[serde(default)]
451 pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
452 #[serde(default)]
453 pub secret_detection: SecretDetectionConfig,
454 #[serde(default)]
458 pub allow_auto_reroot: bool,
459 #[serde(default)]
462 pub path_jail: Option<bool>,
463 #[serde(default)]
467 pub sandbox_level: u8,
468 #[serde(default)]
472 pub reference_results: bool,
473 #[serde(default)]
476 pub agent_token_budget: usize,
477 #[serde(default = "default_shell_allowlist")]
482 pub shell_allowlist: Vec<String>,
483}
484
485#[derive(Debug, Clone, Serialize, Deserialize)]
486#[serde(default)]
487pub struct SecretDetectionConfig {
488 pub enabled: bool,
489 pub redact: bool,
490 pub custom_patterns: Vec<String>,
491}
492
493impl Default for SecretDetectionConfig {
494 fn default() -> Self {
495 Self {
496 enabled: true,
497 redact: false,
498 custom_patterns: Vec::new(),
499 }
500 }
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
505#[serde(default)]
506pub struct ArchiveConfig {
507 pub enabled: bool,
508 pub threshold_chars: usize,
509 pub max_age_hours: u64,
510 pub max_disk_mb: u64,
511}
512
513impl Default for ArchiveConfig {
514 fn default() -> Self {
515 Self {
516 enabled: true,
517 threshold_chars: 4096,
518 max_age_hours: 48,
519 max_disk_mb: 500,
520 }
521 }
522}
523
524#[derive(Debug, Clone, Serialize, Deserialize)]
528#[serde(default)]
529pub struct ProvidersConfig {
530 pub enabled: bool,
532 pub github: ProviderEntryConfig,
534 pub gitlab: ProviderEntryConfig,
536 pub auto_index: bool,
538 pub cache_ttl_secs: u64,
540 #[serde(default)]
542 pub mcp_bridges: std::collections::HashMap<String, McpBridgeEntry>,
543}
544
545impl Default for ProvidersConfig {
546 fn default() -> Self {
547 Self {
548 enabled: true,
549 github: ProviderEntryConfig::default(),
550 gitlab: ProviderEntryConfig::default(),
551 auto_index: true,
552 cache_ttl_secs: 120,
553 mcp_bridges: std::collections::HashMap::new(),
554 }
555 }
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize)]
559pub struct McpBridgeEntry {
560 #[serde(default)]
562 pub url: Option<String>,
563 #[serde(default)]
565 pub command: Option<String>,
566 #[serde(default)]
568 pub args: Vec<String>,
569 #[serde(default)]
571 pub description: Option<String>,
572 #[serde(default)]
574 pub auth_env: Option<String>,
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize)]
579#[serde(default)]
580pub struct ProviderEntryConfig {
581 pub enabled: bool,
583 pub token: Option<String>,
585 pub api_url: Option<String>,
587 pub project: Option<String>,
589}
590
591impl Default for ProviderEntryConfig {
592 fn default() -> Self {
593 Self {
594 enabled: true,
595 token: None,
596 api_url: None,
597 project: None,
598 }
599 }
600}
601
602#[derive(Debug, Clone, Serialize, Deserialize)]
604#[serde(default)]
605pub struct AutonomyConfig {
606 pub enabled: bool,
607 pub auto_preload: bool,
608 pub auto_dedup: bool,
609 pub auto_related: bool,
610 pub auto_consolidate: bool,
611 pub silent_preload: bool,
612 pub dedup_threshold: usize,
613 pub consolidate_every_calls: u32,
614 pub consolidate_cooldown_secs: u64,
615 #[serde(default = "serde_defaults::default_true")]
616 pub cognition_loop_enabled: bool,
617 #[serde(default = "serde_defaults::default_cognition_loop_interval")]
618 pub cognition_loop_interval_secs: u64,
619 #[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
620 pub cognition_loop_max_steps: u8,
621}
622
623impl Default for AutonomyConfig {
624 fn default() -> Self {
625 Self {
626 enabled: true,
627 auto_preload: true,
628 auto_dedup: true,
629 auto_related: true,
630 auto_consolidate: true,
631 silent_preload: true,
632 dedup_threshold: 8,
633 consolidate_every_calls: 25,
634 consolidate_cooldown_secs: 120,
635 cognition_loop_enabled: true,
636 cognition_loop_interval_secs: 3600,
637 cognition_loop_max_steps: 8,
638 }
639 }
640}
641
642#[derive(Debug, Clone, Serialize, Deserialize)]
645#[serde(default)]
646pub struct UpdatesConfig {
647 pub auto_update: bool,
648 pub check_interval_hours: u64,
649 pub notify_only: bool,
650}
651
652impl Default for UpdatesConfig {
653 fn default() -> Self {
654 Self {
655 auto_update: false,
656 check_interval_hours: 6,
657 notify_only: false,
658 }
659 }
660}
661
662impl UpdatesConfig {
663 pub fn from_env() -> Self {
664 let mut cfg = Self::default();
665 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
666 cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
667 }
668 if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS") {
669 if let Ok(h) = v.parse::<u64>() {
670 cfg.check_interval_hours = h.clamp(1, 168);
671 }
672 }
673 if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
674 cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
675 }
676 cfg
677 }
678}
679
680impl AutonomyConfig {
681 pub fn from_env() -> Self {
683 let mut cfg = Self::default();
684 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
685 if v == "false" || v == "0" {
686 cfg.enabled = false;
687 }
688 }
689 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
690 cfg.auto_preload = v != "false" && v != "0";
691 }
692 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
693 cfg.auto_dedup = v != "false" && v != "0";
694 }
695 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
696 cfg.auto_related = v != "false" && v != "0";
697 }
698 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
699 cfg.auto_consolidate = v != "false" && v != "0";
700 }
701 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
702 cfg.silent_preload = v != "false" && v != "0";
703 }
704 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
705 if let Ok(n) = v.parse() {
706 cfg.dedup_threshold = n;
707 }
708 }
709 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
710 if let Ok(n) = v.parse() {
711 cfg.consolidate_every_calls = n;
712 }
713 }
714 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
715 if let Ok(n) = v.parse() {
716 cfg.consolidate_cooldown_secs = n;
717 }
718 }
719 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
720 cfg.cognition_loop_enabled = v != "false" && v != "0";
721 }
722 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
723 if let Ok(n) = v.parse() {
724 cfg.cognition_loop_interval_secs = n;
725 }
726 }
727 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
728 if let Ok(n) = v.parse() {
729 cfg.cognition_loop_max_steps = n;
730 }
731 }
732 cfg
733 }
734
735 pub fn load() -> Self {
737 let file_cfg = Config::load().autonomy;
738 let mut cfg = file_cfg;
739 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
740 if v == "false" || v == "0" {
741 cfg.enabled = false;
742 }
743 }
744 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
745 cfg.auto_preload = v != "false" && v != "0";
746 }
747 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
748 cfg.auto_dedup = v != "false" && v != "0";
749 }
750 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
751 cfg.auto_related = v != "false" && v != "0";
752 }
753 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
754 cfg.silent_preload = v != "false" && v != "0";
755 }
756 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
757 if let Ok(n) = v.parse() {
758 cfg.dedup_threshold = n;
759 }
760 }
761 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
762 cfg.cognition_loop_enabled = v != "false" && v != "0";
763 }
764 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
765 if let Ok(n) = v.parse() {
766 cfg.cognition_loop_interval_secs = n;
767 }
768 }
769 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
770 if let Ok(n) = v.parse() {
771 cfg.cognition_loop_max_steps = n;
772 }
773 }
774 cfg
775 }
776}
777
778#[derive(Debug, Clone, Serialize, Deserialize, Default)]
780#[serde(default)]
781pub struct CloudConfig {
782 pub contribute_enabled: bool,
783 pub last_contribute: Option<String>,
784 pub last_sync: Option<String>,
785 pub last_gain_sync: Option<String>,
786 pub last_model_pull: Option<String>,
787}
788
789#[derive(Debug, Clone, Serialize, Deserialize)]
791pub struct AliasEntry {
792 pub command: String,
793 pub alias: String,
794}
795
796#[derive(Debug, Clone, Serialize, Deserialize)]
798#[serde(default)]
799pub struct LoopDetectionConfig {
800 pub normal_threshold: u32,
801 pub reduced_threshold: u32,
802 pub blocked_threshold: u32,
803 pub window_secs: u64,
804 pub search_group_limit: u32,
805 pub tool_total_limits: HashMap<String, u32>,
806}
807
808impl Default for LoopDetectionConfig {
809 fn default() -> Self {
810 let mut tool_total_limits = HashMap::new();
811 tool_total_limits.insert("ctx_read".to_string(), 100);
812 tool_total_limits.insert("ctx_search".to_string(), 80);
813 tool_total_limits.insert("ctx_shell".to_string(), 50);
814 tool_total_limits.insert("ctx_semantic_search".to_string(), 60);
815 Self {
816 normal_threshold: 2,
817 reduced_threshold: 4,
818 blocked_threshold: 0,
819 window_secs: 300,
820 search_group_limit: 10,
821 tool_total_limits,
822 }
823 }
824}
825
826impl Default for Config {
827 fn default() -> Self {
828 Self {
829 ultra_compact: false,
830 tee_mode: TeeMode::default(),
831 output_density: OutputDensity::default(),
832 checkpoint_interval: 15,
833 excluded_commands: Vec::new(),
834 passthrough_urls: Vec::new(),
835 custom_aliases: Vec::new(),
836 slow_command_threshold_ms: 5000,
837 theme: serde_defaults::default_theme(),
838 cloud: CloudConfig::default(),
839 autonomy: AutonomyConfig::default(),
840 providers: ProvidersConfig::default(),
841 proxy: ProxyConfig::default(),
842 proxy_enabled: None,
843 proxy_port: None,
844 proxy_timeout_ms: None,
845 buddy_enabled: serde_defaults::default_buddy_enabled(),
846 enable_wakeup_ctx: true,
847 redirect_exclude: Vec::new(),
848 disabled_tools: Vec::new(),
849 default_tool_categories: Vec::new(),
850 no_degrade: false,
851 loop_detection: LoopDetectionConfig::default(),
852 rules_scope: None,
853 extra_ignore_patterns: Vec::new(),
854 terse_agent: TerseAgent::default(),
855 compression_level: CompressionLevel::default(),
856 archive: ArchiveConfig::default(),
857 memory: MemoryPolicy::default(),
858 allow_paths: Vec::new(),
859 content_defined_chunking: false,
860 minimal_overhead: false,
861 shell_hook_disabled: false,
862 shell_activation: ShellActivation::default(),
863 update_check_disabled: false,
864 updates: UpdatesConfig::default(),
865 graph_index_max_files: serde_defaults::default_graph_index_max_files(),
866 bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
867 memory_profile: MemoryProfile::default(),
868 memory_cleanup: MemoryCleanup::default(),
869 max_ram_percent: serde_defaults::default_max_ram_percent(),
870 savings_footer: SavingsFooter::default(),
871 project_root: None,
872 lsp: std::collections::HashMap::new(),
873 ide_paths: HashMap::new(),
874 model_context_windows: HashMap::new(),
875 response_verbosity: ResponseVerbosity::default(),
876 bypass_hints: None,
877 cache_policy: None,
878 boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
879 secret_detection: SecretDetectionConfig::default(),
880 allow_auto_reroot: false,
881 path_jail: None,
882 sandbox_level: 0,
883 reference_results: false,
884 agent_token_budget: 0,
885 shell_allowlist: default_shell_allowlist(),
886 }
887 }
888}
889
890fn default_shell_allowlist() -> Vec<String> {
891 [
892 "git",
894 "gh",
895 "svn",
896 "cargo",
898 "npm",
899 "npx",
900 "yarn",
901 "pnpm",
902 "bun",
903 "make",
904 "cmake",
905 "pip",
906 "pip3",
907 "poetry",
908 "uv",
909 "go",
910 "mvn",
911 "gradle",
912 "mix",
913 "dotnet",
914 "swift",
915 "zig",
916 "rustup",
917 "rustc",
918 "ls",
920 "cat",
921 "head",
922 "tail",
923 "wc",
924 "sort",
925 "uniq",
926 "tr",
927 "cut",
928 "grep",
929 "rg",
930 "find",
931 "fd",
932 "ag",
933 "ack",
934 "sed",
935 "awk",
936 "echo",
937 "printf",
938 "true",
939 "false",
940 "test",
941 "expr",
942 "cd",
943 "pwd",
944 "basename",
945 "dirname",
946 "realpath",
947 "readlink",
948 "cp",
949 "mv",
950 "mkdir",
951 "rm",
952 "rmdir",
953 "touch",
954 "ln",
955 "chmod",
956 "diff",
957 "patch",
958 "tar",
959 "zip",
960 "unzip",
961 "gzip",
962 "gunzip",
963 "zstd",
964 "curl",
965 "wget",
966 "docker",
968 "docker-compose",
969 "podman",
970 "node",
971 "python",
972 "python3",
973 "ruby",
974 "perl",
975 "java",
976 "javac",
977 "tsc",
978 "eslint",
979 "prettier",
980 "black",
981 "ruff",
982 "clippy",
983 "jq",
984 "yq",
985 "xargs",
986 "env",
987 "which",
988 "type",
989 "file",
990 "stat",
991 "date",
992 "sleep",
993 "timeout",
994 "nice",
995 "ionice",
996 "lean-ctx",
998 ]
999 .iter()
1000 .map(|s| (*s).to_string())
1001 .collect()
1002}
1003
1004#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1006pub enum RulesScope {
1007 Both,
1008 Global,
1009 Project,
1010}
1011
1012impl Config {
1013 pub fn rules_scope_effective(&self) -> RulesScope {
1015 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
1016 .ok()
1017 .or_else(|| self.rules_scope.clone())
1018 .unwrap_or_default();
1019 match raw.trim().to_lowercase().as_str() {
1020 "global" => RulesScope::Global,
1021 "project" => RulesScope::Project,
1022 _ => RulesScope::Both,
1023 }
1024 }
1025
1026 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
1027 val.split(',')
1028 .map(|s| s.trim().to_string())
1029 .filter(|s| !s.is_empty())
1030 .collect()
1031 }
1032
1033 pub fn disabled_tools_effective(&self) -> Vec<String> {
1035 if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
1036 Self::parse_disabled_tools_env(&val)
1037 } else {
1038 self.disabled_tools.clone()
1039 }
1040 }
1041
1042 pub fn minimal_overhead_effective(&self) -> bool {
1044 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
1045 }
1046
1047 pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
1055 if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
1056 match raw.trim().to_lowercase().as_str() {
1057 "minimal" => return true,
1058 "full" => return self.minimal_overhead_effective(),
1059 _ => {}
1060 }
1061 }
1062
1063 if self.minimal_overhead_effective() {
1064 return true;
1065 }
1066
1067 let client_lower = client_name.trim().to_lowercase();
1068 if !client_lower.is_empty() {
1069 if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
1070 for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
1071 if !needle.is_empty() && client_lower.contains(&needle) {
1072 return true;
1073 }
1074 }
1075 } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
1076 return true;
1077 }
1078 }
1079
1080 let model = std::env::var("LEAN_CTX_MODEL")
1081 .or_else(|_| std::env::var("LCTX_MODEL"))
1082 .unwrap_or_default();
1083 let model = model.trim().to_lowercase();
1084 if !model.is_empty() {
1085 let m = model.replace(['_', ' '], "-");
1086 if m.contains("minimax")
1087 || m.contains("mini-max")
1088 || m.contains("m2.7")
1089 || m.contains("m2-7")
1090 {
1091 return true;
1092 }
1093 }
1094
1095 false
1096 }
1097
1098 pub fn shell_hook_disabled_effective(&self) -> bool {
1100 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
1101 }
1102
1103 pub fn shell_activation_effective(&self) -> ShellActivation {
1105 ShellActivation::effective(self)
1106 }
1107
1108 pub fn update_check_disabled_effective(&self) -> bool {
1110 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
1111 }
1112
1113 pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
1114 let mut policy = self.memory.clone();
1115 policy.apply_env_overrides();
1116 policy.validate()?;
1117 Ok(policy)
1118 }
1119
1120 pub fn default_tool_categories_effective(&self) -> Vec<String> {
1123 if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
1124 return val
1125 .split(',')
1126 .map(|s| s.trim().to_lowercase())
1127 .filter(|s| !s.is_empty())
1128 .collect();
1129 }
1130 if !self.default_tool_categories.is_empty() {
1131 return self
1132 .default_tool_categories
1133 .iter()
1134 .map(|s| s.to_lowercase())
1135 .collect();
1136 }
1137 vec!["core".to_string(), "session".to_string()]
1138 }
1139
1140 pub fn no_degrade_effective(&self) -> bool {
1143 if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
1144 return val == "1" || val.eq_ignore_ascii_case("true");
1145 }
1146 self.no_degrade
1147 }
1148}
1149
1150#[cfg(test)]
1151mod disabled_tools_tests {
1152 use super::*;
1153
1154 #[test]
1155 fn config_field_default_is_empty() {
1156 let cfg = Config::default();
1157 assert!(cfg.disabled_tools.is_empty());
1158 }
1159
1160 #[test]
1161 fn effective_returns_config_field_when_no_env_var() {
1162 if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
1164 return;
1165 }
1166 let cfg = Config {
1167 disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
1168 ..Default::default()
1169 };
1170 assert_eq!(
1171 cfg.disabled_tools_effective(),
1172 vec!["ctx_graph", "ctx_agent"]
1173 );
1174 }
1175
1176 #[test]
1177 fn parse_env_basic() {
1178 let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
1179 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1180 }
1181
1182 #[test]
1183 fn parse_env_trims_whitespace_and_skips_empty() {
1184 let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
1185 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
1186 }
1187
1188 #[test]
1189 fn parse_env_single_entry() {
1190 let result = Config::parse_disabled_tools_env("ctx_graph");
1191 assert_eq!(result, vec!["ctx_graph"]);
1192 }
1193
1194 #[test]
1195 fn parse_env_empty_string_returns_empty() {
1196 let result = Config::parse_disabled_tools_env("");
1197 assert!(result.is_empty());
1198 }
1199
1200 #[test]
1201 fn disabled_tools_deserialization_defaults_to_empty() {
1202 let cfg: Config = toml::from_str("").unwrap();
1203 assert!(cfg.disabled_tools.is_empty());
1204 }
1205
1206 #[test]
1207 fn disabled_tools_deserialization_from_toml() {
1208 let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
1209 assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
1210 }
1211}
1212
1213#[cfg(test)]
1214mod default_tool_categories_tests {
1215 use super::*;
1216
1217 #[test]
1220 fn default_returns_core_and_session() {
1221 if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1222 return;
1223 }
1224 let cfg = Config::default();
1225 assert_eq!(
1226 cfg.default_tool_categories_effective(),
1227 vec!["core", "session"]
1228 );
1229 }
1230
1231 #[test]
1232 fn default_struct_field_is_empty_vec() {
1233 let cfg = Config::default();
1234 assert!(cfg.default_tool_categories.is_empty());
1235 }
1236
1237 #[test]
1240 fn config_field_overrides_default() {
1241 if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1242 return;
1243 }
1244 let cfg = Config {
1245 default_tool_categories: vec![
1246 "core".to_string(),
1247 "arch".to_string(),
1248 "memory".to_string(),
1249 ],
1250 ..Default::default()
1251 };
1252 assert_eq!(
1253 cfg.default_tool_categories_effective(),
1254 vec!["core", "arch", "memory"]
1255 );
1256 }
1257
1258 #[test]
1259 fn single_category_in_config() {
1260 if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1261 return;
1262 }
1263 let cfg = Config {
1264 default_tool_categories: vec!["debug".to_string()],
1265 ..Default::default()
1266 };
1267 assert_eq!(cfg.default_tool_categories_effective(), vec!["debug"]);
1268 }
1269
1270 #[test]
1271 fn all_six_categories_in_config() {
1272 if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1273 return;
1274 }
1275 let cfg = Config {
1276 default_tool_categories: vec![
1277 "core".to_string(),
1278 "arch".to_string(),
1279 "debug".to_string(),
1280 "memory".to_string(),
1281 "metrics".to_string(),
1282 "session".to_string(),
1283 ],
1284 ..Default::default()
1285 };
1286 let effective = cfg.default_tool_categories_effective();
1287 assert_eq!(effective.len(), 6);
1288 assert!(effective.contains(&"core".to_string()));
1289 assert!(effective.contains(&"metrics".to_string()));
1290 }
1291
1292 #[test]
1295 fn deserialization_defaults_to_empty() {
1296 let cfg: Config = toml::from_str("").unwrap();
1297 assert!(cfg.default_tool_categories.is_empty());
1298 }
1299
1300 #[test]
1301 fn deserialization_from_toml() {
1302 let cfg: Config =
1303 toml::from_str(r#"default_tool_categories = ["core", "arch", "debug"]"#).unwrap();
1304 assert_eq!(cfg.default_tool_categories, vec!["core", "arch", "debug"]);
1305 }
1306
1307 #[test]
1308 fn deserialization_empty_array() {
1309 let cfg: Config = toml::from_str(r"default_tool_categories = []").unwrap();
1310 assert!(cfg.default_tool_categories.is_empty());
1311 }
1312
1313 #[test]
1314 fn deserialization_single_entry() {
1315 let cfg: Config = toml::from_str(r#"default_tool_categories = ["memory"]"#).unwrap();
1316 assert_eq!(cfg.default_tool_categories, vec!["memory"]);
1317 }
1318
1319 #[test]
1322 fn effective_normalizes_config_to_lowercase() {
1323 if std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok() {
1324 return;
1325 }
1326 let cfg = Config {
1327 default_tool_categories: vec!["ARCH".to_string(), "Debug".to_string()],
1328 ..Default::default()
1329 };
1330 let effective = cfg.default_tool_categories_effective();
1331 assert_eq!(effective, vec!["arch", "debug"]);
1332 }
1333}
1334
1335#[cfg(test)]
1336mod no_degrade_tests {
1337 use super::*;
1338
1339 #[test]
1342 fn default_is_false() {
1343 let cfg = Config::default();
1344 assert!(!cfg.no_degrade);
1345 }
1346
1347 #[test]
1348 fn effective_false_when_unset() {
1349 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1350 return;
1351 }
1352 let cfg = Config::default();
1353 assert!(!cfg.no_degrade_effective());
1354 }
1355
1356 #[test]
1359 fn config_field_true_respected_when_no_env() {
1360 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1361 return;
1362 }
1363 let cfg = Config {
1364 no_degrade: true,
1365 ..Default::default()
1366 };
1367 assert!(cfg.no_degrade_effective());
1368 }
1369
1370 #[test]
1371 fn config_field_false_respected_when_no_env() {
1372 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1373 return;
1374 }
1375 let cfg = Config {
1376 no_degrade: false,
1377 ..Default::default()
1378 };
1379 assert!(!cfg.no_degrade_effective());
1380 }
1381
1382 #[test]
1385 fn deserialization_true() {
1386 let cfg: Config = toml::from_str("no_degrade = true").unwrap();
1387 assert!(cfg.no_degrade);
1388 }
1389
1390 #[test]
1391 fn deserialization_false() {
1392 let cfg: Config = toml::from_str("no_degrade = false").unwrap();
1393 assert!(!cfg.no_degrade);
1394 }
1395
1396 #[test]
1397 fn deserialization_absent_defaults_false() {
1398 let cfg: Config = toml::from_str("").unwrap();
1399 assert!(!cfg.no_degrade);
1400 }
1401
1402 #[test]
1405 fn no_degrade_independent_of_disabled_tools() {
1406 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1407 return;
1408 }
1409 let cfg = Config {
1410 no_degrade: true,
1411 disabled_tools: vec!["ctx_graph".to_string()],
1412 ..Default::default()
1413 };
1414 assert!(cfg.no_degrade_effective());
1415 assert!(!cfg.disabled_tools.is_empty());
1416 }
1417
1418 #[test]
1419 fn no_degrade_independent_of_tool_categories() {
1420 if std::env::var("LCTX_NO_DEGRADE").is_ok()
1421 || std::env::var("LCTX_DEFAULT_CATEGORIES").is_ok()
1422 {
1423 return;
1424 }
1425 let cfg = Config {
1426 no_degrade: true,
1427 default_tool_categories: vec!["core".to_string(), "arch".to_string()],
1428 ..Default::default()
1429 };
1430 assert!(cfg.no_degrade_effective());
1431 assert_eq!(
1432 cfg.default_tool_categories_effective(),
1433 vec!["core", "arch"]
1434 );
1435 }
1436}
1437
1438#[cfg(test)]
1439mod rules_scope_tests {
1440 use super::*;
1441
1442 #[test]
1443 fn default_is_both() {
1444 let cfg = Config::default();
1445 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1446 }
1447
1448 #[test]
1449 fn config_global() {
1450 let cfg = Config {
1451 rules_scope: Some("global".to_string()),
1452 ..Default::default()
1453 };
1454 assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
1455 }
1456
1457 #[test]
1458 fn config_project() {
1459 let cfg = Config {
1460 rules_scope: Some("project".to_string()),
1461 ..Default::default()
1462 };
1463 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1464 }
1465
1466 #[test]
1467 fn unknown_value_falls_back_to_both() {
1468 let cfg = Config {
1469 rules_scope: Some("nonsense".to_string()),
1470 ..Default::default()
1471 };
1472 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1473 }
1474
1475 #[test]
1476 fn deserialization_none_by_default() {
1477 let cfg: Config = toml::from_str("").unwrap();
1478 assert!(cfg.rules_scope.is_none());
1479 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
1480 }
1481
1482 #[test]
1483 fn deserialization_from_toml() {
1484 let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
1485 assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
1486 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
1487 }
1488}
1489
1490#[cfg(test)]
1491mod loop_detection_config_tests {
1492 use super::*;
1493
1494 #[test]
1495 fn defaults_are_reasonable() {
1496 let cfg = LoopDetectionConfig::default();
1497 assert_eq!(cfg.normal_threshold, 2);
1498 assert_eq!(cfg.reduced_threshold, 4);
1499 assert_eq!(cfg.blocked_threshold, 0);
1501 assert_eq!(cfg.window_secs, 300);
1502 assert_eq!(cfg.search_group_limit, 10);
1503 }
1504
1505 #[test]
1506 fn deserialization_defaults_when_missing() {
1507 let cfg: Config = toml::from_str("").unwrap();
1508 assert_eq!(cfg.loop_detection.blocked_threshold, 0);
1510 assert_eq!(cfg.loop_detection.search_group_limit, 10);
1511 }
1512
1513 #[test]
1514 fn deserialization_from_toml() {
1515 let cfg: Config = toml::from_str(
1516 r"
1517 [loop_detection]
1518 normal_threshold = 1
1519 reduced_threshold = 3
1520 blocked_threshold = 5
1521 window_secs = 120
1522 search_group_limit = 8
1523 ",
1524 )
1525 .unwrap();
1526 assert_eq!(cfg.loop_detection.normal_threshold, 1);
1527 assert_eq!(cfg.loop_detection.reduced_threshold, 3);
1528 assert_eq!(cfg.loop_detection.blocked_threshold, 5);
1529 assert_eq!(cfg.loop_detection.window_secs, 120);
1530 assert_eq!(cfg.loop_detection.search_group_limit, 8);
1531 }
1532
1533 #[test]
1534 fn partial_override_keeps_defaults() {
1535 let cfg: Config = toml::from_str(
1536 r"
1537 [loop_detection]
1538 blocked_threshold = 10
1539 ",
1540 )
1541 .unwrap();
1542 assert_eq!(cfg.loop_detection.blocked_threshold, 10);
1543 assert_eq!(cfg.loop_detection.normal_threshold, 2);
1544 assert_eq!(cfg.loop_detection.search_group_limit, 10);
1545 }
1546}
1547
1548impl Config {
1549 pub fn path() -> Option<PathBuf> {
1551 crate::core::data_dir::lean_ctx_data_dir()
1552 .ok()
1553 .map(|d| d.join("config.toml"))
1554 }
1555
1556 pub fn local_path(project_root: &str) -> PathBuf {
1558 PathBuf::from(project_root).join(".lean-ctx.toml")
1559 }
1560
1561 fn find_project_root() -> Option<String> {
1562 static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
1563 ROOT_CACHE
1564 .get_or_init(Self::find_project_root_inner)
1565 .clone()
1566 }
1567
1568 fn find_project_root_inner() -> Option<String> {
1569 if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
1570 if !env_root.is_empty() {
1571 return Some(env_root);
1572 }
1573 }
1574
1575 let cwd = std::env::current_dir().ok();
1576
1577 if let Some(root) =
1578 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
1579 {
1580 let root_path = std::path::Path::new(&root);
1581 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
1582 let has_marker = root_path.join(".git").exists()
1583 || root_path.join("Cargo.toml").exists()
1584 || root_path.join("package.json").exists()
1585 || root_path.join("go.mod").exists()
1586 || root_path.join("pyproject.toml").exists()
1587 || root_path.join(".lean-ctx.toml").exists();
1588
1589 if cwd_is_under_root || has_marker {
1590 return Some(root);
1591 }
1592 }
1593
1594 if let Some(ref cwd) = cwd {
1595 let git_root = std::process::Command::new("git")
1596 .args(["rev-parse", "--show-toplevel"])
1597 .current_dir(cwd)
1598 .stdout(std::process::Stdio::piped())
1599 .stderr(std::process::Stdio::null())
1600 .output()
1601 .ok()
1602 .and_then(|o| {
1603 if o.status.success() {
1604 String::from_utf8(o.stdout)
1605 .ok()
1606 .map(|s| s.trim().to_string())
1607 } else {
1608 None
1609 }
1610 });
1611 if let Some(root) = git_root {
1612 return Some(root);
1613 }
1614 if !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
1615 return Some(cwd.to_string_lossy().to_string());
1616 }
1617 }
1618 None
1619 }
1620
1621 pub fn load() -> Self {
1623 static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
1624
1625 let Some(path) = Self::path() else {
1626 return Self::default();
1627 };
1628
1629 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
1630
1631 let mtime = std::fs::metadata(&path)
1632 .and_then(|m| m.modified())
1633 .unwrap_or(SystemTime::UNIX_EPOCH);
1634
1635 let local_mtime = local_path
1636 .as_ref()
1637 .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
1638
1639 if let Ok(guard) = CACHE.lock() {
1640 if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
1641 if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
1642 return cfg.clone();
1643 }
1644 }
1645 }
1646
1647 let mut cfg: Config = match std::fs::read_to_string(&path) {
1648 Ok(content) => match toml::from_str(&content) {
1649 Ok(c) => c,
1650 Err(e) => {
1651 tracing::warn!("config parse error in {}: {e}", path.display());
1652 eprintln!(
1653 "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n \
1654 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
1655 path.display()
1656 );
1657 Self::default()
1658 }
1659 },
1660 Err(_) => Self::default(),
1661 };
1662
1663 if let Some(ref lp) = local_path {
1664 if let Ok(local_content) = std::fs::read_to_string(lp) {
1665 cfg.merge_local(&local_content);
1666 }
1667 }
1668
1669 if let Ok(mut guard) = CACHE.lock() {
1670 *guard = Some((cfg.clone(), mtime, local_mtime));
1671 }
1672
1673 cfg
1674 }
1675
1676 fn merge_local(&mut self, local_toml: &str) {
1677 let local: Config = match toml::from_str(local_toml) {
1678 Ok(c) => c,
1679 Err(e) => {
1680 tracing::warn!("local config parse error: {e}");
1681 eprintln!(
1682 "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n \
1683 Local overrides skipped.\x1b[0m"
1684 );
1685 return;
1686 }
1687 };
1688 if local.ultra_compact {
1689 self.ultra_compact = true;
1690 }
1691 if local.tee_mode != TeeMode::default() {
1692 self.tee_mode = local.tee_mode;
1693 }
1694 if local.output_density != OutputDensity::default() {
1695 self.output_density = local.output_density;
1696 }
1697 if local.checkpoint_interval != 15 {
1698 self.checkpoint_interval = local.checkpoint_interval;
1699 }
1700 if !local.excluded_commands.is_empty() {
1701 self.excluded_commands.extend(local.excluded_commands);
1702 }
1703 if !local.passthrough_urls.is_empty() {
1704 self.passthrough_urls.extend(local.passthrough_urls);
1705 }
1706 if !local.custom_aliases.is_empty() {
1707 self.custom_aliases.extend(local.custom_aliases);
1708 }
1709 if local.slow_command_threshold_ms != 5000 {
1710 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
1711 }
1712 if local.theme != "default" {
1713 self.theme = local.theme;
1714 }
1715 if !local.buddy_enabled {
1716 self.buddy_enabled = false;
1717 }
1718 if !local.enable_wakeup_ctx {
1719 self.enable_wakeup_ctx = false;
1720 }
1721 if !local.redirect_exclude.is_empty() {
1722 self.redirect_exclude.extend(local.redirect_exclude);
1723 }
1724 if !local.disabled_tools.is_empty() {
1725 self.disabled_tools.extend(local.disabled_tools);
1726 }
1727 if !local.extra_ignore_patterns.is_empty() {
1728 self.extra_ignore_patterns
1729 .extend(local.extra_ignore_patterns);
1730 }
1731 if local.rules_scope.is_some() {
1732 self.rules_scope = local.rules_scope;
1733 }
1734 if local.proxy.anthropic_upstream.is_some() {
1735 self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1736 }
1737 if local.proxy.openai_upstream.is_some() {
1738 self.proxy.openai_upstream = local.proxy.openai_upstream;
1739 }
1740 if local.proxy.gemini_upstream.is_some() {
1741 self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1742 }
1743 if !local.autonomy.enabled {
1744 self.autonomy.enabled = false;
1745 }
1746 if !local.autonomy.auto_preload {
1747 self.autonomy.auto_preload = false;
1748 }
1749 if !local.autonomy.auto_dedup {
1750 self.autonomy.auto_dedup = false;
1751 }
1752 if !local.autonomy.auto_related {
1753 self.autonomy.auto_related = false;
1754 }
1755 if !local.autonomy.auto_consolidate {
1756 self.autonomy.auto_consolidate = false;
1757 }
1758 if local.autonomy.silent_preload {
1759 self.autonomy.silent_preload = true;
1760 }
1761 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1762 self.autonomy.silent_preload = false;
1763 }
1764 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1765 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1766 }
1767 if local.autonomy.consolidate_every_calls
1768 != AutonomyConfig::default().consolidate_every_calls
1769 {
1770 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1771 }
1772 if local.autonomy.consolidate_cooldown_secs
1773 != AutonomyConfig::default().consolidate_cooldown_secs
1774 {
1775 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1776 }
1777 if !local.autonomy.cognition_loop_enabled {
1778 self.autonomy.cognition_loop_enabled = false;
1779 }
1780 if local.autonomy.cognition_loop_interval_secs
1781 != AutonomyConfig::default().cognition_loop_interval_secs
1782 {
1783 self.autonomy.cognition_loop_interval_secs =
1784 local.autonomy.cognition_loop_interval_secs;
1785 }
1786 if local.autonomy.cognition_loop_max_steps
1787 != AutonomyConfig::default().cognition_loop_max_steps
1788 {
1789 self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1790 }
1791 if local_toml.contains("compression_level") {
1792 self.compression_level = local.compression_level;
1793 }
1794 if local_toml.contains("terse_agent") {
1795 self.terse_agent = local.terse_agent;
1796 }
1797 if !local.archive.enabled {
1798 self.archive.enabled = false;
1799 }
1800 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1801 self.archive.threshold_chars = local.archive.threshold_chars;
1802 }
1803 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1804 self.archive.max_age_hours = local.archive.max_age_hours;
1805 }
1806 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1807 self.archive.max_disk_mb = local.archive.max_disk_mb;
1808 }
1809 let mem_def = MemoryPolicy::default();
1810 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1811 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1812 }
1813 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1814 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1815 }
1816 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1817 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1818 }
1819 if local.memory.knowledge.contradiction_threshold
1820 != mem_def.knowledge.contradiction_threshold
1821 {
1822 self.memory.knowledge.contradiction_threshold =
1823 local.memory.knowledge.contradiction_threshold;
1824 }
1825
1826 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1827 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1828 }
1829 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1830 {
1831 self.memory.episodic.max_actions_per_episode =
1832 local.memory.episodic.max_actions_per_episode;
1833 }
1834 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1835 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1836 }
1837
1838 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1839 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1840 }
1841 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1842 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1843 }
1844 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1845 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1846 }
1847 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1848 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1849 }
1850
1851 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1852 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1853 }
1854 if local.memory.lifecycle.low_confidence_threshold
1855 != mem_def.lifecycle.low_confidence_threshold
1856 {
1857 self.memory.lifecycle.low_confidence_threshold =
1858 local.memory.lifecycle.low_confidence_threshold;
1859 }
1860 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1861 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1862 }
1863 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1864 self.memory.lifecycle.similarity_threshold =
1865 local.memory.lifecycle.similarity_threshold;
1866 }
1867
1868 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1869 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1870 }
1871 if !local.allow_paths.is_empty() {
1872 self.allow_paths.extend(local.allow_paths);
1873 }
1874 if local.minimal_overhead {
1875 self.minimal_overhead = true;
1876 }
1877 if local.shell_hook_disabled {
1878 self.shell_hook_disabled = true;
1879 }
1880 if local.shell_activation != ShellActivation::default() {
1881 self.shell_activation = local.shell_activation.clone();
1882 }
1883 if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1884 self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1885 }
1886 if local.memory_profile != MemoryProfile::default() {
1887 self.memory_profile = local.memory_profile;
1888 }
1889 if local.memory_cleanup != MemoryCleanup::default() {
1890 self.memory_cleanup = local.memory_cleanup;
1891 }
1892 if !local.shell_allowlist.is_empty() {
1893 self.shell_allowlist = local.shell_allowlist;
1894 }
1895 if !local.default_tool_categories.is_empty() {
1896 self.default_tool_categories = local.default_tool_categories;
1897 }
1898 if local.no_degrade {
1899 self.no_degrade = true;
1900 }
1901 if local.proxy_timeout_ms.is_some() {
1902 self.proxy_timeout_ms = local.proxy_timeout_ms;
1903 }
1904 }
1905
1906 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1908 let path = Self::path().ok_or_else(|| {
1909 super::error::LeanCtxError::Config("cannot determine home directory".into())
1910 })?;
1911 if let Some(parent) = path.parent() {
1912 std::fs::create_dir_all(parent)?;
1913 }
1914 let content = toml::to_string_pretty(self)
1915 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1916 std::fs::write(&path, content)?;
1917 Ok(())
1918 }
1919
1920 pub fn show(&self) -> String {
1922 let global_path = Self::path().map_or_else(
1923 || "~/.lean-ctx/config.toml".to_string(),
1924 |p| p.to_string_lossy().to_string(),
1925 );
1926 let content = toml::to_string_pretty(self).unwrap_or_default();
1927 let mut out = format!("Global config: {global_path}\n\n{content}");
1928
1929 if let Some(root) = Self::find_project_root() {
1930 let local = Self::local_path(&root);
1931 if local.exists() {
1932 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1933 } else {
1934 out.push_str(&format!(
1935 "\n\nLocal config: not found (create {} to override per-project)\n",
1936 local.display()
1937 ));
1938 }
1939 }
1940 out
1941 }
1942}
1943
1944#[cfg(test)]
1945mod compression_level_tests {
1946 use super::*;
1947
1948 #[test]
1949 fn default_is_standard() {
1950 assert_eq!(CompressionLevel::default(), CompressionLevel::Standard);
1951 }
1952
1953 #[test]
1954 fn to_components_off() {
1955 let (ta, od, crp, tm) = CompressionLevel::Off.to_components();
1956 assert_eq!(ta, TerseAgent::Off);
1957 assert_eq!(od, OutputDensity::Normal);
1958 assert_eq!(crp, "off");
1959 assert!(!tm);
1960 }
1961
1962 #[test]
1963 fn to_components_lite() {
1964 let (ta, od, crp, tm) = CompressionLevel::Lite.to_components();
1965 assert_eq!(ta, TerseAgent::Lite);
1966 assert_eq!(od, OutputDensity::Terse);
1967 assert_eq!(crp, "off");
1968 assert!(tm);
1969 }
1970
1971 #[test]
1972 fn to_components_standard() {
1973 let (ta, od, crp, tm) = CompressionLevel::Standard.to_components();
1974 assert_eq!(ta, TerseAgent::Full);
1975 assert_eq!(od, OutputDensity::Terse);
1976 assert_eq!(crp, "compact");
1977 assert!(tm);
1978 }
1979
1980 #[test]
1981 fn to_components_max() {
1982 let (ta, od, crp, tm) = CompressionLevel::Max.to_components();
1983 assert_eq!(ta, TerseAgent::Ultra);
1984 assert_eq!(od, OutputDensity::Ultra);
1985 assert_eq!(crp, "tdd");
1986 assert!(tm);
1987 }
1988
1989 #[test]
1990 fn from_legacy_ultra_agent_maps_to_max() {
1991 assert_eq!(
1992 CompressionLevel::from_legacy(&TerseAgent::Ultra, &OutputDensity::Normal),
1993 CompressionLevel::Max
1994 );
1995 }
1996
1997 #[test]
1998 fn from_legacy_ultra_density_maps_to_max() {
1999 assert_eq!(
2000 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Ultra),
2001 CompressionLevel::Max
2002 );
2003 }
2004
2005 #[test]
2006 fn from_legacy_full_agent_maps_to_standard() {
2007 assert_eq!(
2008 CompressionLevel::from_legacy(&TerseAgent::Full, &OutputDensity::Normal),
2009 CompressionLevel::Standard
2010 );
2011 }
2012
2013 #[test]
2014 fn from_legacy_lite_agent_maps_to_lite() {
2015 assert_eq!(
2016 CompressionLevel::from_legacy(&TerseAgent::Lite, &OutputDensity::Normal),
2017 CompressionLevel::Lite
2018 );
2019 }
2020
2021 #[test]
2022 fn from_legacy_terse_density_maps_to_lite() {
2023 assert_eq!(
2024 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Terse),
2025 CompressionLevel::Lite
2026 );
2027 }
2028
2029 #[test]
2030 fn from_legacy_both_off_maps_to_off() {
2031 assert_eq!(
2032 CompressionLevel::from_legacy(&TerseAgent::Off, &OutputDensity::Normal),
2033 CompressionLevel::Off
2034 );
2035 }
2036
2037 #[test]
2038 fn labels_match() {
2039 assert_eq!(CompressionLevel::Off.label(), "off");
2040 assert_eq!(CompressionLevel::Lite.label(), "lite");
2041 assert_eq!(CompressionLevel::Standard.label(), "standard");
2042 assert_eq!(CompressionLevel::Max.label(), "max");
2043 }
2044
2045 #[test]
2046 fn is_active_false_for_off() {
2047 assert!(!CompressionLevel::Off.is_active());
2048 }
2049
2050 #[test]
2051 fn is_active_true_for_all_others() {
2052 assert!(CompressionLevel::Lite.is_active());
2053 assert!(CompressionLevel::Standard.is_active());
2054 assert!(CompressionLevel::Max.is_active());
2055 }
2056
2057 #[test]
2058 fn deserialization_defaults_to_standard() {
2059 let cfg: Config = toml::from_str("").unwrap();
2060 assert_eq!(cfg.compression_level, CompressionLevel::Standard);
2061 }
2062
2063 #[test]
2064 fn deserialization_from_toml() {
2065 let cfg: Config = toml::from_str(r#"compression_level = "standard""#).unwrap();
2066 assert_eq!(cfg.compression_level, CompressionLevel::Standard);
2067 }
2068
2069 #[test]
2070 fn roundtrip_all_levels() {
2071 for level in [
2072 CompressionLevel::Off,
2073 CompressionLevel::Lite,
2074 CompressionLevel::Standard,
2075 CompressionLevel::Max,
2076 ] {
2077 let (ta, od, crp, tm) = level.to_components();
2078 assert!(!crp.is_empty());
2079 if level == CompressionLevel::Off {
2080 assert!(!tm);
2081 assert_eq!(ta, TerseAgent::Off);
2082 assert_eq!(od, OutputDensity::Normal);
2083 } else {
2084 assert!(tm);
2085 }
2086 }
2087 }
2088}
2089
2090#[cfg(test)]
2091mod memory_cleanup_tests {
2092 use super::*;
2093
2094 #[test]
2095 fn default_is_aggressive() {
2096 assert_eq!(MemoryCleanup::default(), MemoryCleanup::Aggressive);
2097 }
2098
2099 #[test]
2100 fn aggressive_ttl_is_300() {
2101 assert_eq!(MemoryCleanup::Aggressive.idle_ttl_secs(), 300);
2102 }
2103
2104 #[test]
2105 fn shared_ttl_is_1800() {
2106 assert_eq!(MemoryCleanup::Shared.idle_ttl_secs(), 1800);
2107 }
2108
2109 #[test]
2110 fn index_retention_multiplier_values() {
2111 assert!(
2112 (MemoryCleanup::Aggressive.index_retention_multiplier() - 1.0).abs() < f64::EPSILON
2113 );
2114 assert!((MemoryCleanup::Shared.index_retention_multiplier() - 3.0).abs() < f64::EPSILON);
2115 }
2116
2117 #[test]
2118 fn deserialization_defaults_to_aggressive() {
2119 let cfg: Config = toml::from_str("").unwrap();
2120 assert_eq!(cfg.memory_cleanup, MemoryCleanup::Aggressive);
2121 }
2122
2123 #[test]
2124 fn deserialization_from_toml() {
2125 let cfg: Config = toml::from_str(r#"memory_cleanup = "shared""#).unwrap();
2126 assert_eq!(cfg.memory_cleanup, MemoryCleanup::Shared);
2127 }
2128
2129 #[test]
2130 fn effective_uses_config_when_no_env() {
2131 let cfg = Config {
2132 memory_cleanup: MemoryCleanup::Shared,
2133 ..Default::default()
2134 };
2135 let eff = MemoryCleanup::effective(&cfg);
2136 assert_eq!(eff, MemoryCleanup::Shared);
2137 }
2138}