1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use std::sync::Mutex;
5
6use super::memory_policy::MemoryPolicy;
7
8mod defaults_allowlist;
9mod enums;
10mod memory;
11mod proxy;
12pub mod schema;
13mod sections;
14mod serde_defaults;
15pub mod setter;
16mod shell_activation;
17pub use sections::*;
18#[cfg(test)]
19mod tests;
20
21pub(crate) use defaults_allowlist::{cloud_infra_commands, default_shell_allowlist};
22pub use enums::{
23 CompressionLevel, OutputDensity, PermissionInheritance, ResponseVerbosity, RulesInjection,
24 RulesScope, TeeMode, TerseAgent,
25};
26pub use memory::{MemoryCleanup, MemoryGuardConfig, MemoryProfile, SavingsFooter};
27pub use proxy::{
28 is_local_proxy_url, normalize_url, normalize_url_opt, HistoryMode, ProxyConfig, ProxyProvider,
29};
30pub use shell_activation::ShellActivation;
31
32pub fn default_bm25_max_cache_mb() -> u64 {
34 serde_defaults::default_bm25_max_cache_mb()
35}
36
37pub const DEFAULT_BM25_PERSIST_MB: u64 = 512;
47
48const _: () = assert!(DEFAULT_BM25_PERSIST_MB >= 512);
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(default)]
55pub struct Config {
56 pub ultra_compact: bool,
57 #[serde(default, deserialize_with = "serde_defaults::deserialize_tee_mode")]
58 pub tee_mode: TeeMode,
59 #[serde(default)]
60 pub output_density: OutputDensity,
61 pub checkpoint_interval: u32,
62 pub excluded_commands: Vec<String>,
63 pub passthrough_urls: Vec<String>,
64 pub custom_aliases: Vec<AliasEntry>,
65 #[serde(default = "serde_defaults::default_preserve_compact_formats")]
71 pub preserve_compact_formats: Vec<String>,
72 pub slow_command_threshold_ms: u64,
75 #[serde(default = "serde_defaults::default_theme")]
76 pub theme: String,
77 #[serde(default)]
78 pub cloud: CloudConfig,
79 #[serde(default)]
80 pub gain: GainConfig,
81 #[serde(default)]
82 pub autonomy: AutonomyConfig,
83 #[serde(default)]
84 pub providers: ProvidersConfig,
85 #[serde(default)]
86 pub proxy: ProxyConfig,
87 #[serde(default)]
92 pub proxy_enabled: Option<bool>,
93 #[serde(default)]
94 pub proxy_port: Option<u16>,
95 #[serde(default)]
98 pub proxy_timeout_ms: Option<u64>,
99 #[serde(default = "serde_defaults::default_buddy_enabled")]
100 pub buddy_enabled: bool,
101 #[serde(default = "serde_defaults::default_true")]
102 pub enable_wakeup_ctx: bool,
103 #[serde(default)]
104 pub redirect_exclude: Vec<String>,
105 #[serde(default)]
109 pub disabled_tools: Vec<String>,
110 #[serde(default)]
116 pub default_tool_categories: Vec<String>,
117 #[serde(default)]
121 pub no_degrade: bool,
122 #[serde(default)]
125 pub profile: Option<String>,
126 #[serde(default)]
130 pub tool_profile: Option<String>,
131 #[serde(default)]
134 pub tools_enabled: Vec<String>,
135 #[serde(default)]
139 pub persona: Option<String>,
140 #[serde(default)]
141 pub loop_detection: LoopDetectionConfig,
142 #[serde(default)]
146 pub rules_scope: Option<String>,
147 #[serde(default)]
155 pub rules_injection: Option<String>,
156 #[serde(default)]
163 pub permission_inheritance: Option<String>,
164 #[serde(default)]
167 pub extra_ignore_patterns: Vec<String>,
168 #[serde(default)]
172 pub terse_agent: TerseAgent,
173 #[serde(default)]
177 pub compression_level: CompressionLevel,
178 #[serde(default)]
180 pub archive: ArchiveConfig,
181 #[serde(default)]
183 pub memory: MemoryPolicy,
184 #[serde(default)]
188 pub allow_paths: Vec<String>,
189 #[serde(default)]
194 pub allow_ide_config_dirs: bool,
195 #[serde(default)]
200 pub extra_roots: Vec<String>,
201 #[serde(default)]
204 pub content_defined_chunking: bool,
205 #[serde(default)]
208 pub minimal_overhead: bool,
209 #[serde(default)]
214 pub symbol_map_auto: bool,
215 #[serde(default)]
222 pub structure_first: bool,
223 #[serde(default)]
227 pub team_url: Option<String>,
228 #[serde(default)]
232 pub team_token: Option<String>,
233 #[serde(default)]
238 pub team_auto_push: bool,
239 #[serde(default)]
241 pub journal_enabled: bool,
242 #[serde(default)]
244 pub auto_capture: bool,
245 #[serde(default)]
247 pub search: crate::core::hybrid_search::HybridConfig,
248 #[serde(default)]
250 pub graph: GraphConfig,
251 #[serde(default)]
253 pub skillify: SkillifyConfig,
254 #[serde(default)]
256 pub summaries: SummariesConfig,
257 #[serde(default)]
259 pub llm: crate::core::llm_enhance::LlmConfig,
260 #[serde(default)]
262 pub embedding: EmbeddingConfig,
263 #[serde(default)]
266 pub shell_hook_disabled: bool,
267 #[serde(default)]
272 pub shadow_mode: bool,
273 #[serde(default)]
280 pub shell_activation: ShellActivation,
281 #[serde(default)]
284 pub update_check_disabled: bool,
285 #[serde(default)]
286 pub updates: UpdatesConfig,
287 #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
290 pub bm25_max_cache_mb: u64,
291 #[serde(default = "serde_defaults::default_graph_index_max_files")]
294 pub graph_index_max_files: u64,
295 #[serde(default)]
298 pub memory_profile: MemoryProfile,
299 #[serde(default)]
303 pub memory_cleanup: MemoryCleanup,
304 #[serde(default = "serde_defaults::default_max_ram_percent")]
307 pub max_ram_percent: u8,
308 #[serde(default)]
312 pub max_disk_mb: u64,
313 #[serde(default)]
316 pub max_staleness_days: u32,
317 #[serde(default)]
321 pub savings_footer: SavingsFooter,
322 #[serde(default)]
326 pub project_root: Option<String>,
327 #[serde(default)]
330 pub lsp: std::collections::HashMap<String, String>,
331 #[serde(default)]
335 pub ide_paths: HashMap<String, Vec<String>>,
336 #[serde(default)]
339 pub model_context_windows: HashMap<String, usize>,
340 #[serde(default)]
347 pub response_verbosity: ResponseVerbosity,
348 #[serde(default)]
353 pub bypass_hints: Option<String>,
354 #[serde(default)]
359 pub cache_policy: Option<String>,
360 #[serde(default)]
363 pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
364 #[serde(default)]
365 pub secret_detection: SecretDetectionConfig,
366 #[serde(default)]
369 pub sensitivity: crate::core::sensitivity::SensitivityConfig,
370 #[serde(default)]
374 pub gateway: crate::core::gateway::GatewayConfig,
375 #[serde(default)]
379 pub allow_auto_reroot: bool,
380 #[serde(default)]
384 pub path_jail: Option<bool>,
385 #[serde(default)]
389 pub sandbox_level: u8,
390 #[serde(default)]
394 pub reference_results: bool,
395 #[serde(default)]
398 pub agent_token_budget: usize,
399 #[serde(default = "default_shell_allowlist")]
404 pub shell_allowlist: Vec<String>,
405
406 #[serde(default)]
411 pub shell_allowlist_extra: Vec<String>,
412
413 #[serde(default)]
417 pub shell_strict_mode: bool,
418 #[serde(default)]
420 pub setup: SetupConfig,
421}
422
423impl Default for Config {
424 fn default() -> Self {
425 Self {
426 ultra_compact: false,
427 tee_mode: TeeMode::default(),
428 output_density: OutputDensity::default(),
429 checkpoint_interval: 15,
430 excluded_commands: Vec::new(),
431 passthrough_urls: Vec::new(),
432 custom_aliases: Vec::new(),
433 preserve_compact_formats: serde_defaults::default_preserve_compact_formats(),
434 slow_command_threshold_ms: 5000,
435 theme: serde_defaults::default_theme(),
436 cloud: CloudConfig::default(),
437 gain: GainConfig::default(),
438 autonomy: AutonomyConfig::default(),
439 providers: ProvidersConfig::default(),
440 proxy: ProxyConfig::default(),
441 proxy_enabled: None,
442 proxy_port: None,
443 proxy_timeout_ms: None,
444 buddy_enabled: serde_defaults::default_buddy_enabled(),
445 enable_wakeup_ctx: true,
446 redirect_exclude: Vec::new(),
447 disabled_tools: Vec::new(),
448 default_tool_categories: Vec::new(),
449 no_degrade: false,
450 profile: None,
451 tool_profile: None,
452 tools_enabled: Vec::new(),
453 persona: None,
454 loop_detection: LoopDetectionConfig::default(),
455 rules_scope: None,
456 rules_injection: None,
457 permission_inheritance: None,
458 extra_ignore_patterns: Vec::new(),
459 terse_agent: TerseAgent::default(),
460 compression_level: CompressionLevel::default(),
461 archive: ArchiveConfig::default(),
462 memory: MemoryPolicy::default(),
463 allow_paths: Vec::new(),
464 allow_ide_config_dirs: false,
465 extra_roots: Vec::new(),
466 content_defined_chunking: false,
467 minimal_overhead: true,
468 symbol_map_auto: false,
469 structure_first: false,
470 team_url: None,
471 team_token: None,
472 team_auto_push: false,
473 journal_enabled: true,
474 auto_capture: true,
475 search: crate::core::hybrid_search::HybridConfig::default(),
476 graph: GraphConfig::default(),
477 skillify: SkillifyConfig::default(),
478 summaries: SummariesConfig::default(),
479 llm: crate::core::llm_enhance::LlmConfig::default(),
480 embedding: EmbeddingConfig::default(),
481 shell_hook_disabled: false,
482 shadow_mode: false,
483 shell_activation: ShellActivation::default(),
484 update_check_disabled: false,
485 updates: UpdatesConfig::default(),
486 graph_index_max_files: serde_defaults::default_graph_index_max_files(),
487 bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
488 memory_profile: MemoryProfile::default(),
489 memory_cleanup: MemoryCleanup::default(),
490 max_ram_percent: serde_defaults::default_max_ram_percent(),
491 max_disk_mb: 0,
492 max_staleness_days: 0,
493 savings_footer: SavingsFooter::default(),
494 project_root: None,
495 lsp: std::collections::HashMap::new(),
496 ide_paths: HashMap::new(),
497 model_context_windows: HashMap::new(),
498 response_verbosity: ResponseVerbosity::default(),
499 bypass_hints: None,
500 cache_policy: None,
501 boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
502 secret_detection: SecretDetectionConfig::default(),
503 sensitivity: crate::core::sensitivity::SensitivityConfig::default(),
504 gateway: crate::core::gateway::GatewayConfig::default(),
505 allow_auto_reroot: false,
506 path_jail: None,
507 sandbox_level: 0,
508 reference_results: false,
509 agent_token_budget: 0,
510 shell_allowlist: default_shell_allowlist(),
511 shell_allowlist_extra: Vec::new(),
512 shell_strict_mode: false,
513 setup: SetupConfig::default(),
514 }
515 }
516}
517
518static LAST_PARSE_ERROR: Mutex<Option<String>> = Mutex::new(None);
524
525#[must_use]
528pub fn last_config_parse_error() -> Option<String> {
529 LAST_PARSE_ERROR.lock().ok().and_then(|g| g.clone())
530}
531
532fn record_parse_error(err: Option<String>) {
533 if let Ok(mut guard) = LAST_PARSE_ERROR.lock() {
534 *guard = err;
535 }
536}
537
538impl Config {
539 pub fn rules_scope_effective(&self) -> RulesScope {
541 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
542 .ok()
543 .or_else(|| self.rules_scope.clone())
544 .unwrap_or_default();
545 match raw.trim().to_lowercase().as_str() {
546 "global" => RulesScope::Global,
547 "project" => RulesScope::Project,
548 _ => RulesScope::Both,
549 }
550 }
551
552 pub fn rules_injection_effective(&self) -> RulesInjection {
555 let raw = std::env::var("LEAN_CTX_RULES_INJECTION")
556 .ok()
557 .or_else(|| self.rules_injection.clone())
558 .unwrap_or_default();
559 match raw.trim().to_lowercase().as_str() {
560 "dedicated" => RulesInjection::Dedicated,
561 "off" | "none" | "disabled" => RulesInjection::Off,
562 _ => RulesInjection::Shared,
563 }
564 }
565
566 #[must_use]
570 pub fn permission_inheritance_effective(&self) -> PermissionInheritance {
571 let raw = std::env::var("LEAN_CTX_PERMISSION_INHERITANCE")
572 .ok()
573 .or_else(|| self.permission_inheritance.clone())
574 .unwrap_or_default();
575 match raw.trim().to_lowercase().as_str() {
576 "on" | "true" | "1" | "inherit" => PermissionInheritance::On,
577 _ => PermissionInheritance::Off,
578 }
579 }
580
581 #[must_use]
588 pub fn dedicated_session_context_active(&self) -> bool {
589 self.rules_injection_effective() == RulesInjection::Dedicated
590 && self.rules_scope_effective() != RulesScope::Project
591 }
592
593 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
594 val.split(',')
595 .map(|s| s.trim().to_string())
596 .filter(|s| !s.is_empty())
597 .collect()
598 }
599
600 pub fn disabled_tools_effective(&self) -> Vec<String> {
602 if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
603 Self::parse_disabled_tools_env(&val)
604 } else {
605 self.disabled_tools.clone()
606 }
607 }
608
609 pub fn minimal_overhead_effective(&self) -> bool {
611 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
612 }
613
614 pub fn structure_first_effective(&self) -> bool {
620 match std::env::var("LEAN_CTX_STRUCTURE_FIRST") {
621 Ok(raw) => matches!(
622 raw.trim().to_lowercase().as_str(),
623 "1" | "true" | "yes" | "on"
624 ),
625 Err(_) => self.structure_first,
626 }
627 }
628
629 pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
637 if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
638 match raw.trim().to_lowercase().as_str() {
639 "minimal" => return true,
640 "full" => return self.minimal_overhead_effective(),
641 _ => {}
642 }
643 }
644
645 if self.minimal_overhead_effective() {
646 return true;
647 }
648
649 let client_lower = client_name.trim().to_lowercase();
650 if !client_lower.is_empty() {
651 if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
652 for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
653 if !needle.is_empty() && client_lower.contains(&needle) {
654 return true;
655 }
656 }
657 } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
658 return true;
659 }
660 }
661
662 let model = std::env::var("LEAN_CTX_MODEL")
663 .or_else(|_| std::env::var("LCTX_MODEL"))
664 .unwrap_or_default();
665 let model = model.trim().to_lowercase();
666 if !model.is_empty() {
667 let m = model.replace(['_', ' '], "-");
668 if m.contains("minimax")
669 || m.contains("mini-max")
670 || m.contains("m2.7")
671 || m.contains("m2-7")
672 {
673 return true;
674 }
675 }
676
677 false
678 }
679
680 pub fn shell_hook_disabled_effective(&self) -> bool {
682 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
683 }
684
685 pub fn shell_activation_effective(&self) -> ShellActivation {
687 ShellActivation::effective(self)
688 }
689
690 pub fn update_check_disabled_effective(&self) -> bool {
692 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
693 }
694
695 pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
696 let mut policy = self.memory.clone();
697 policy.apply_env_overrides();
698
699 let budget = self.max_disk_mb_effective();
702 if budget > 0 {
703 let scale_factor = (budget as f64 / 500.0).clamp(0.5, 10.0);
704 let default_policy = MemoryPolicy::default();
705 if policy.knowledge.max_facts == default_policy.knowledge.max_facts {
706 policy.knowledge.max_facts = (200.0 * scale_factor) as usize;
707 }
708 if policy.knowledge.max_patterns == default_policy.knowledge.max_patterns {
709 policy.knowledge.max_patterns = (50.0 * scale_factor) as usize;
710 }
711 if policy.episodic.max_episodes == default_policy.episodic.max_episodes {
712 policy.episodic.max_episodes = (500.0 * scale_factor) as usize;
713 }
714 if policy.procedural.max_procedures == default_policy.procedural.max_procedures {
715 policy.procedural.max_procedures = (100.0 * scale_factor) as usize;
716 }
717 }
718
719 policy.validate()?;
720 Ok(policy)
721 }
722
723 pub fn default_tool_categories_effective(&self) -> Vec<String> {
726 if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
727 return val
728 .split(',')
729 .map(|s| s.trim().to_lowercase())
730 .filter(|s| !s.is_empty())
731 .collect();
732 }
733 if !self.default_tool_categories.is_empty() {
734 return self
735 .default_tool_categories
736 .iter()
737 .map(|s| s.to_lowercase())
738 .collect();
739 }
740 vec!["core".to_string(), "session".to_string()]
741 }
742
743 pub fn tool_profile_effective(&self) -> super::tool_profiles::ToolProfile {
751 super::persona::Persona::resolve(self).effective_tool_profile(self)
752 }
753
754 pub fn no_degrade_effective(&self) -> bool {
757 if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
758 return val == "1" || val.eq_ignore_ascii_case("true");
759 }
760 self.no_degrade
761 }
762
763 pub fn max_disk_mb_effective(&self) -> u64 {
765 std::env::var("LEAN_CTX_MAX_DISK_MB")
766 .ok()
767 .and_then(|v| v.parse().ok())
768 .unwrap_or(self.max_disk_mb)
769 }
770
771 pub fn max_staleness_days_effective(&self) -> u32 {
773 std::env::var("LEAN_CTX_MAX_STALENESS_DAYS")
774 .ok()
775 .and_then(|v| v.parse().ok())
776 .unwrap_or(self.max_staleness_days)
777 }
778
779 pub fn archive_max_disk_mb_effective(&self) -> u64 {
782 let budget = self.max_disk_mb_effective();
783 if budget > 0 && self.archive.max_disk_mb == ArchiveConfig::default().max_disk_mb {
784 budget * 25 / 100
785 } else {
786 self.archive.max_disk_mb
787 }
788 }
789
790 pub fn archive_max_age_hours_effective(&self) -> u64 {
793 let staleness = self.max_staleness_days_effective();
794 if staleness > 0 && self.archive.max_age_hours == ArchiveConfig::default().max_age_hours {
795 staleness as u64 * 24
796 } else {
797 self.archive.max_age_hours
798 }
799 }
800
801 pub fn bm25_max_cache_mb_effective(&self) -> u64 {
809 if self.bm25_max_cache_mb != serde_defaults::default_bm25_max_cache_mb() {
811 return self.bm25_max_cache_mb;
812 }
813 let budget = self.max_disk_mb_effective();
815 if budget > 0 {
816 return budget * 10 / 100;
817 }
818 DEFAULT_BM25_PERSIST_MB
820 }
821}
822
823impl Config {
824 pub fn path() -> Option<PathBuf> {
830 crate::core::paths::config_dir()
831 .ok()
832 .map(|d| d.join("config.toml"))
833 }
834
835 pub fn local_path(project_root: &str) -> PathBuf {
837 PathBuf::from(project_root).join(".lean-ctx.toml")
838 }
839
840 fn find_project_root() -> Option<String> {
841 static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
842 ROOT_CACHE
843 .get_or_init(Self::find_project_root_inner)
844 .clone()
845 }
846
847 fn find_project_root_inner() -> Option<String> {
848 if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
849 if !env_root.is_empty() {
850 return Some(env_root);
851 }
852 }
853
854 let cwd = std::env::current_dir().ok();
855
856 if let Some(root) =
857 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
858 {
859 let root_path = std::path::Path::new(&root);
860 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
861 let has_marker = root_path.join(".git").exists()
862 || root_path.join("Cargo.toml").exists()
863 || root_path.join("package.json").exists()
864 || root_path.join("go.mod").exists()
865 || root_path.join("pyproject.toml").exists()
866 || root_path.join(".lean-ctx.toml").exists();
867
868 if cwd_is_under_root || has_marker {
869 return Some(root);
870 }
871 }
872
873 if let Some(ref cwd) = cwd {
874 let git_root = std::process::Command::new("git")
875 .args(["rev-parse", "--show-toplevel"])
876 .current_dir(cwd)
877 .stdout(std::process::Stdio::piped())
878 .stderr(std::process::Stdio::null())
879 .output()
880 .ok()
881 .and_then(|o| {
882 if o.status.success() {
883 String::from_utf8(o.stdout)
884 .ok()
885 .map(|s| s.trim().to_string())
886 } else {
887 None
888 }
889 });
890 if let Some(root) = git_root {
891 return Some(root);
892 }
893 if !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
894 return Some(cwd.to_string_lossy().to_string());
895 }
896 }
897 None
898 }
899
900 pub fn load() -> Self {
911 static CACHE: Mutex<Option<(Config, Option<String>, Option<String>)>> = Mutex::new(None);
912
913 let Some(path) = Self::path() else {
914 return Self::default();
915 };
916
917 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
918
919 let global_content = std::fs::read_to_string(&path).ok();
921 let local_content = local_path
922 .as_ref()
923 .and_then(|p| std::fs::read_to_string(p).ok());
924
925 let global_hash = global_content.as_deref().map(crate::core::hasher::hash_str);
926 let local_hash = local_content.as_deref().map(crate::core::hasher::hash_str);
927
928 if let Ok(guard) = CACHE.lock() {
929 if let Some((ref cfg, ref cached_global, ref cached_local)) = *guard {
930 if *cached_global == global_hash && *cached_local == local_hash {
931 return cfg.clone();
932 }
933 }
934 }
935
936 let mut cfg: Config = if let Some(ref content) = global_content {
937 match toml::from_str(content) {
938 Ok(c) => {
939 record_parse_error(None);
940 c
941 }
942 Err(e) => {
943 record_parse_error(Some(format!("{e}")));
944 tracing::warn!("config parse error in {}: {e}", path.display());
945 eprintln!(
946 "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n \
947 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
948 path.display()
949 );
950 Self::default()
951 }
952 }
953 } else {
954 record_parse_error(None);
955 Self::default()
956 };
957
958 if let Some(ref local) = local_content {
959 cfg.merge_local(local);
960 }
961
962 if let Ok(mut guard) = CACHE.lock() {
963 *guard = Some((cfg.clone(), global_hash, local_hash));
964 }
965
966 cfg
967 }
968
969 fn merge_local(&mut self, local_toml: &str) {
970 let local: Config = match toml::from_str(local_toml) {
971 Ok(c) => c,
972 Err(e) => {
973 tracing::warn!("local config parse error: {e}");
974 eprintln!(
975 "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n \
976 Local overrides skipped.\x1b[0m"
977 );
978 return;
979 }
980 };
981 if local.ultra_compact {
982 self.ultra_compact = true;
983 }
984 if local.tee_mode != TeeMode::default() {
985 self.tee_mode = local.tee_mode;
986 }
987 if local.output_density != OutputDensity::default() {
988 self.output_density = local.output_density;
989 }
990 if local.checkpoint_interval != 15 {
991 self.checkpoint_interval = local.checkpoint_interval;
992 }
993 if !local.excluded_commands.is_empty() {
994 self.excluded_commands.extend(local.excluded_commands);
995 }
996 if !local.passthrough_urls.is_empty() {
997 self.passthrough_urls.extend(local.passthrough_urls);
998 }
999 if !local.custom_aliases.is_empty() {
1000 self.custom_aliases.extend(local.custom_aliases);
1001 }
1002 for fmt in local.preserve_compact_formats {
1005 if !self
1006 .preserve_compact_formats
1007 .iter()
1008 .any(|f| f.eq_ignore_ascii_case(&fmt))
1009 {
1010 self.preserve_compact_formats.push(fmt);
1011 }
1012 }
1013 if local.slow_command_threshold_ms != 5000 {
1014 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
1015 }
1016 if local.theme != "default" {
1017 self.theme = local.theme;
1018 }
1019 if !local.buddy_enabled {
1020 self.buddy_enabled = false;
1021 }
1022 if !local.enable_wakeup_ctx {
1023 self.enable_wakeup_ctx = false;
1024 }
1025 if !local.redirect_exclude.is_empty() {
1026 self.redirect_exclude.extend(local.redirect_exclude);
1027 }
1028 if !local.disabled_tools.is_empty() {
1029 self.disabled_tools.extend(local.disabled_tools);
1030 }
1031 if !local.extra_ignore_patterns.is_empty() {
1032 self.extra_ignore_patterns
1033 .extend(local.extra_ignore_patterns);
1034 }
1035 if local.rules_scope.is_some() {
1036 self.rules_scope = local.rules_scope;
1037 }
1038 if local.rules_injection.is_some() {
1039 self.rules_injection = local.rules_injection;
1040 }
1041 if local.permission_inheritance.is_some() {
1042 self.permission_inheritance = local.permission_inheritance;
1043 }
1044 if local.proxy.anthropic_upstream.is_some() {
1045 self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1046 }
1047 if local.proxy.openai_upstream.is_some() {
1048 self.proxy.openai_upstream = local.proxy.openai_upstream;
1049 }
1050 if local.proxy.gemini_upstream.is_some() {
1051 self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1052 }
1053 if !local.autonomy.enabled {
1054 self.autonomy.enabled = false;
1055 }
1056 if !local.autonomy.auto_preload {
1057 self.autonomy.auto_preload = false;
1058 }
1059 if !local.autonomy.auto_dedup {
1060 self.autonomy.auto_dedup = false;
1061 }
1062 if !local.autonomy.auto_related {
1063 self.autonomy.auto_related = false;
1064 }
1065 if !local.autonomy.auto_consolidate {
1066 self.autonomy.auto_consolidate = false;
1067 }
1068 if local.autonomy.silent_preload {
1069 self.autonomy.silent_preload = true;
1070 }
1071 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1072 self.autonomy.silent_preload = false;
1073 }
1074 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1075 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1076 }
1077 if local.autonomy.consolidate_every_calls
1078 != AutonomyConfig::default().consolidate_every_calls
1079 {
1080 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1081 }
1082 if local.autonomy.consolidate_cooldown_secs
1083 != AutonomyConfig::default().consolidate_cooldown_secs
1084 {
1085 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1086 }
1087 if !local.autonomy.cognition_loop_enabled {
1088 self.autonomy.cognition_loop_enabled = false;
1089 }
1090 if local.autonomy.cognition_loop_interval_secs
1091 != AutonomyConfig::default().cognition_loop_interval_secs
1092 {
1093 self.autonomy.cognition_loop_interval_secs =
1094 local.autonomy.cognition_loop_interval_secs;
1095 }
1096 if local.autonomy.cognition_loop_max_steps
1097 != AutonomyConfig::default().cognition_loop_max_steps
1098 {
1099 self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1100 }
1101 if local_toml.contains("compression_level") {
1102 self.compression_level = local.compression_level;
1103 }
1104 if local_toml.contains("terse_agent") {
1105 self.terse_agent = local.terse_agent;
1106 }
1107 if !local.archive.enabled {
1108 self.archive.enabled = false;
1109 }
1110 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1111 self.archive.threshold_chars = local.archive.threshold_chars;
1112 }
1113 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1114 self.archive.max_age_hours = local.archive.max_age_hours;
1115 }
1116 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1117 self.archive.max_disk_mb = local.archive.max_disk_mb;
1118 }
1119 if !local.archive.ephemeral {
1120 self.archive.ephemeral = false;
1121 }
1122 if local.archive.ephemeral_min_tokens != ArchiveConfig::default().ephemeral_min_tokens {
1123 self.archive.ephemeral_min_tokens = local.archive.ephemeral_min_tokens;
1124 }
1125 let mem_def = MemoryPolicy::default();
1126 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1127 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1128 }
1129 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1130 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1131 }
1132 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1133 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1134 }
1135 if local.memory.knowledge.contradiction_threshold
1136 != mem_def.knowledge.contradiction_threshold
1137 {
1138 self.memory.knowledge.contradiction_threshold =
1139 local.memory.knowledge.contradiction_threshold;
1140 }
1141
1142 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1143 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1144 }
1145 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1146 {
1147 self.memory.episodic.max_actions_per_episode =
1148 local.memory.episodic.max_actions_per_episode;
1149 }
1150 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1151 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1152 }
1153
1154 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1155 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1156 }
1157 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1158 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1159 }
1160 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1161 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1162 }
1163 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1164 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1165 }
1166
1167 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1168 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1169 }
1170 if local.memory.lifecycle.low_confidence_threshold
1171 != mem_def.lifecycle.low_confidence_threshold
1172 {
1173 self.memory.lifecycle.low_confidence_threshold =
1174 local.memory.lifecycle.low_confidence_threshold;
1175 }
1176 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1177 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1178 }
1179 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1180 self.memory.lifecycle.similarity_threshold =
1181 local.memory.lifecycle.similarity_threshold;
1182 }
1183
1184 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1185 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1186 }
1187 if !local.allow_paths.is_empty() {
1188 self.allow_paths.extend(local.allow_paths);
1189 }
1190 if !local.extra_roots.is_empty() {
1191 self.extra_roots.extend(local.extra_roots);
1192 }
1193 if local.minimal_overhead {
1194 self.minimal_overhead = true;
1195 }
1196 if local.shell_hook_disabled {
1197 self.shell_hook_disabled = true;
1198 }
1199 if local.shell_activation != ShellActivation::default() {
1200 self.shell_activation = local.shell_activation.clone();
1201 }
1202 if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1203 self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1204 }
1205 if local.memory_profile != MemoryProfile::default() {
1206 self.memory_profile = local.memory_profile;
1207 }
1208 if local.memory_cleanup != MemoryCleanup::default() {
1209 self.memory_cleanup = local.memory_cleanup;
1210 }
1211 if local.shell_allowlist != default_shell_allowlist() {
1219 self.shell_allowlist = local.shell_allowlist;
1220 }
1221 if !local.shell_allowlist_extra.is_empty() {
1222 self.shell_allowlist_extra
1223 .extend(local.shell_allowlist_extra);
1224 }
1225 if !local.default_tool_categories.is_empty() {
1226 self.default_tool_categories = local.default_tool_categories;
1227 }
1228 if local.tool_profile.is_some() {
1229 self.tool_profile = local.tool_profile;
1230 }
1231 if !local.tools_enabled.is_empty() {
1232 self.tools_enabled = local.tools_enabled;
1233 }
1234 if local.no_degrade {
1235 self.no_degrade = true;
1236 }
1237 if local.profile.is_some() {
1238 self.profile = local.profile;
1239 }
1240 if local.proxy_timeout_ms.is_some() {
1241 self.proxy_timeout_ms = local.proxy_timeout_ms;
1242 }
1243 }
1244
1245 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1251 let path = Self::path().ok_or_else(|| {
1252 super::error::LeanCtxError::Config("cannot determine home directory".into())
1253 })?;
1254 if let Some(parent) = path.parent() {
1255 std::fs::create_dir_all(parent)?;
1256 }
1257 let content = toml::to_string_pretty(self)
1258 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1259 let baseline = toml::from_str::<Self>("").unwrap_or_else(|_| Self::default());
1264 let defaults = toml::to_string_pretty(&baseline)
1265 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1266 crate::config_io::write_toml_preserving_minimal(&path, &content, &defaults)
1267 .map_err(super::error::LeanCtxError::Config)?;
1268 Ok(())
1269 }
1270
1271 pub fn show(&self) -> String {
1273 let global_path = Self::path().map_or_else(
1274 || "~/.lean-ctx/config.toml".to_string(),
1275 |p| p.to_string_lossy().to_string(),
1276 );
1277 let content = toml::to_string_pretty(self).unwrap_or_default();
1278 let mut out = format!("Global config: {global_path}\n\n{content}");
1279
1280 if let Some(root) = Self::find_project_root() {
1281 let local = Self::local_path(&root);
1282 if local.exists() {
1283 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1284 } else {
1285 out.push_str(&format!(
1286 "\n\nLocal config: not found (create {} to override per-project)\n",
1287 local.display()
1288 ));
1289 }
1290 }
1291 out
1292 }
1293}