1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::sync::Mutex;
5
6use super::memory_policy::MemoryPolicy;
7
8mod defaults_allowlist;
9mod enums;
10mod memory;
11mod provenance;
12mod proxy;
13mod render;
14pub mod schema;
15mod sections;
16mod serde_defaults;
17pub mod setter;
18mod shell_activation;
19pub use render::render_annotated_config;
20pub use sections::*;
21#[cfg(test)]
22mod tests;
23
24pub(crate) use defaults_allowlist::{cloud_infra_commands, default_shell_allowlist};
25pub use enums::{
26 CompressionLevel, OutputDensity, PermissionInheritance, ResponseVerbosity, RulesInjection,
27 RulesScope, TeeMode, TerseAgent,
28};
29pub use memory::{MemoryCleanup, MemoryGuardConfig, MemoryProfile, SavingsFooter};
30pub use provenance::{ConfigProvenance, EnvOverride};
31pub use proxy::{
32 HistoryMode, ProxyConfig, ProxyProvider, UpstreamDrift, Upstreams, diagnose_drift,
33 env_upstream_override, is_local_proxy_url, normalize_url, normalize_url_opt,
34};
35pub use shell_activation::ShellActivation;
36
37pub fn default_bm25_max_cache_mb() -> u64 {
39 serde_defaults::default_bm25_max_cache_mb()
40}
41
42pub const DEFAULT_BM25_PERSIST_MB: u64 = 512;
52
53const _: () = assert!(DEFAULT_BM25_PERSIST_MB >= 512);
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59#[serde(default)]
60pub struct Config {
61 pub ultra_compact: bool,
62 #[serde(default, deserialize_with = "serde_defaults::deserialize_tee_mode")]
63 pub tee_mode: TeeMode,
64 #[serde(default)]
65 pub output_density: OutputDensity,
66 pub checkpoint_interval: u32,
67 pub excluded_commands: Vec<String>,
68 pub passthrough_urls: Vec<String>,
69 pub custom_aliases: Vec<AliasEntry>,
70 #[serde(default = "serde_defaults::default_preserve_compact_formats")]
76 pub preserve_compact_formats: Vec<String>,
77 pub slow_command_threshold_ms: u64,
80 #[serde(default = "serde_defaults::default_theme")]
81 pub theme: String,
82 #[serde(default)]
83 pub cloud: CloudConfig,
84 #[serde(default)]
85 pub gain: GainConfig,
86 #[serde(default)]
88 pub cost: CostConfig,
89 #[serde(default)]
90 pub autonomy: AutonomyConfig,
91 #[serde(default)]
92 pub providers: ProvidersConfig,
93 #[serde(default)]
94 pub proxy: ProxyConfig,
95 #[serde(default)]
100 pub proxy_enabled: Option<bool>,
101 #[serde(default)]
102 pub proxy_port: Option<u16>,
103 #[serde(default)]
106 pub proxy_timeout_ms: Option<u64>,
107 #[serde(default = "serde_defaults::default_buddy_enabled")]
108 pub buddy_enabled: bool,
109 #[serde(default = "serde_defaults::default_true")]
110 pub enable_wakeup_ctx: bool,
111 #[serde(default)]
112 pub redirect_exclude: Vec<String>,
113 #[serde(default)]
117 pub disabled_tools: Vec<String>,
118 #[serde(default)]
124 pub default_tool_categories: Vec<String>,
125 #[serde(default)]
129 pub no_degrade: bool,
130 #[serde(default)]
133 pub profile: Option<String>,
134 #[serde(default)]
138 pub tool_profile: Option<String>,
139 #[serde(default)]
142 pub tools_enabled: Vec<String>,
143 #[serde(default)]
147 pub persona: Option<String>,
148 #[serde(default)]
149 pub loop_detection: LoopDetectionConfig,
150 #[serde(default)]
154 pub rules_scope: Option<String>,
155 #[serde(default)]
163 pub rules_injection: Option<String>,
164 #[serde(default)]
171 pub permission_inheritance: Option<String>,
172 #[serde(default)]
175 pub extra_ignore_patterns: Vec<String>,
176 #[serde(default)]
180 pub terse_agent: TerseAgent,
181 #[serde(default)]
185 pub compression_level: CompressionLevel,
186 #[serde(default)]
188 pub archive: ArchiveConfig,
189 #[serde(default)]
191 pub memory: MemoryPolicy,
192 #[serde(default)]
196 pub allow_paths: Vec<String>,
197 #[serde(default)]
202 pub allow_ide_config_dirs: bool,
203 #[serde(default)]
208 pub extra_roots: Vec<String>,
209 #[serde(default)]
212 pub content_defined_chunking: bool,
213 #[serde(default)]
216 pub minimal_overhead: bool,
217 #[serde(default)]
222 pub symbol_map_auto: bool,
223 #[serde(default)]
230 pub structure_first: bool,
231 #[serde(default)]
235 pub team_url: Option<String>,
236 #[serde(default)]
240 pub team_token: Option<String>,
241 #[serde(default)]
246 pub team_auto_push: bool,
247 #[serde(default)]
249 pub journal_enabled: bool,
250 #[serde(default)]
252 pub auto_capture: bool,
253 #[serde(default)]
255 pub search: crate::core::hybrid_search::HybridConfig,
256 #[serde(default)]
258 pub graph: GraphConfig,
259 #[serde(default)]
261 pub skillify: SkillifyConfig,
262 #[serde(default)]
264 pub summaries: SummariesConfig,
265 #[serde(default)]
267 pub llm: crate::core::llm_enhance::LlmConfig,
268 #[serde(default)]
270 pub embedding: EmbeddingConfig,
271 #[serde(default)]
274 pub shell_hook_disabled: bool,
275 #[serde(default)]
280 pub shadow_mode: bool,
281 #[serde(default)]
288 pub shell_activation: ShellActivation,
289 #[serde(default)]
292 pub update_check_disabled: bool,
293 #[serde(default)]
294 pub updates: UpdatesConfig,
295 #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
298 pub bm25_max_cache_mb: u64,
299 #[serde(default = "serde_defaults::default_graph_index_max_files")]
302 pub graph_index_max_files: u64,
303 #[serde(default)]
306 pub memory_profile: MemoryProfile,
307 #[serde(default)]
311 pub memory_cleanup: MemoryCleanup,
312 #[serde(default = "serde_defaults::default_max_ram_percent")]
315 pub max_ram_percent: u8,
316 #[serde(default)]
320 pub max_disk_mb: u64,
321 #[serde(default)]
324 pub max_staleness_days: u32,
325 #[serde(default)]
329 pub savings_footer: SavingsFooter,
330 #[serde(default)]
334 pub project_root: Option<String>,
335 #[serde(default)]
338 pub lsp: std::collections::HashMap<String, String>,
339 #[serde(default)]
343 pub ide_paths: HashMap<String, Vec<String>>,
344 #[serde(default)]
347 pub model_context_windows: HashMap<String, usize>,
348 #[serde(default)]
355 pub response_verbosity: ResponseVerbosity,
356 #[serde(default)]
361 pub bypass_hints: Option<String>,
362 #[serde(default)]
367 pub cache_policy: Option<String>,
368 #[serde(default)]
371 pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
372 #[serde(default)]
373 pub secret_detection: SecretDetectionConfig,
374 #[serde(default)]
377 pub sensitivity: crate::core::sensitivity::SensitivityConfig,
378 #[serde(default)]
382 pub gateway: crate::core::gateway::GatewayConfig,
383 #[serde(default)]
387 pub allow_auto_reroot: bool,
388 #[serde(default)]
392 pub path_jail: Option<bool>,
393 #[serde(default)]
397 pub sandbox_level: u8,
398 #[serde(default)]
402 pub reference_results: bool,
403 #[serde(default)]
406 pub agent_token_budget: usize,
407 #[serde(default = "default_shell_allowlist")]
412 pub shell_allowlist: Vec<String>,
413
414 #[serde(default)]
419 pub shell_allowlist_extra: Vec<String>,
420
421 #[serde(default)]
425 pub shell_strict_mode: bool,
426 #[serde(default)]
428 pub setup: SetupConfig,
429}
430
431impl Default for Config {
432 fn default() -> Self {
433 Self {
434 ultra_compact: false,
435 tee_mode: TeeMode::default(),
436 output_density: OutputDensity::default(),
437 checkpoint_interval: 15,
438 excluded_commands: Vec::new(),
439 passthrough_urls: Vec::new(),
440 custom_aliases: Vec::new(),
441 preserve_compact_formats: serde_defaults::default_preserve_compact_formats(),
442 slow_command_threshold_ms: 5000,
443 theme: serde_defaults::default_theme(),
444 cloud: CloudConfig::default(),
445 gain: GainConfig::default(),
446 cost: CostConfig::default(),
447 autonomy: AutonomyConfig::default(),
448 providers: ProvidersConfig::default(),
449 proxy: ProxyConfig::default(),
450 proxy_enabled: None,
451 proxy_port: None,
452 proxy_timeout_ms: None,
453 buddy_enabled: serde_defaults::default_buddy_enabled(),
454 enable_wakeup_ctx: true,
455 redirect_exclude: Vec::new(),
456 disabled_tools: Vec::new(),
457 default_tool_categories: Vec::new(),
458 no_degrade: false,
459 profile: None,
460 tool_profile: None,
461 tools_enabled: Vec::new(),
462 persona: None,
463 loop_detection: LoopDetectionConfig::default(),
464 rules_scope: None,
465 rules_injection: None,
466 permission_inheritance: None,
467 extra_ignore_patterns: Vec::new(),
468 terse_agent: TerseAgent::default(),
469 compression_level: CompressionLevel::default(),
470 archive: ArchiveConfig::default(),
471 memory: MemoryPolicy::default(),
472 allow_paths: Vec::new(),
473 allow_ide_config_dirs: false,
474 extra_roots: Vec::new(),
475 content_defined_chunking: false,
476 minimal_overhead: true,
477 symbol_map_auto: false,
478 structure_first: false,
479 team_url: None,
480 team_token: None,
481 team_auto_push: false,
482 journal_enabled: true,
483 auto_capture: true,
484 search: crate::core::hybrid_search::HybridConfig::default(),
485 graph: GraphConfig::default(),
486 skillify: SkillifyConfig::default(),
487 summaries: SummariesConfig::default(),
488 llm: crate::core::llm_enhance::LlmConfig::default(),
489 embedding: EmbeddingConfig::default(),
490 shell_hook_disabled: false,
491 shadow_mode: false,
492 shell_activation: ShellActivation::default(),
493 update_check_disabled: false,
494 updates: UpdatesConfig::default(),
495 graph_index_max_files: serde_defaults::default_graph_index_max_files(),
496 bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
497 memory_profile: MemoryProfile::default(),
498 memory_cleanup: MemoryCleanup::default(),
499 max_ram_percent: serde_defaults::default_max_ram_percent(),
500 max_disk_mb: 0,
501 max_staleness_days: 0,
502 savings_footer: SavingsFooter::default(),
503 project_root: None,
504 lsp: std::collections::HashMap::new(),
505 ide_paths: HashMap::new(),
506 model_context_windows: HashMap::new(),
507 response_verbosity: ResponseVerbosity::default(),
508 bypass_hints: None,
509 cache_policy: None,
510 boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
511 secret_detection: SecretDetectionConfig::default(),
512 sensitivity: crate::core::sensitivity::SensitivityConfig::default(),
513 gateway: crate::core::gateway::GatewayConfig::default(),
514 allow_auto_reroot: false,
515 path_jail: None,
516 sandbox_level: 0,
517 reference_results: false,
518 agent_token_budget: 0,
519 shell_allowlist: default_shell_allowlist(),
520 shell_allowlist_extra: Vec::new(),
521 shell_strict_mode: false,
522 setup: SetupConfig::default(),
523 }
524 }
525}
526
527static LAST_PARSE_ERROR: Mutex<Option<String>> = Mutex::new(None);
533
534#[must_use]
537pub fn last_config_parse_error() -> Option<String> {
538 LAST_PARSE_ERROR.lock().ok().and_then(|g| g.clone())
539}
540
541fn record_parse_error(err: Option<String>) {
542 if let Ok(mut guard) = LAST_PARSE_ERROR.lock() {
543 *guard = err;
544 }
545}
546
547impl Config {
548 pub fn rules_scope_effective(&self) -> RulesScope {
550 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
551 .ok()
552 .or_else(|| self.rules_scope.clone())
553 .unwrap_or_default();
554 match raw.trim().to_lowercase().as_str() {
555 "global" => RulesScope::Global,
556 "project" => RulesScope::Project,
557 _ => RulesScope::Both,
558 }
559 }
560
561 pub fn rules_injection_effective(&self) -> RulesInjection {
564 let raw = std::env::var("LEAN_CTX_RULES_INJECTION")
565 .ok()
566 .or_else(|| self.rules_injection.clone())
567 .unwrap_or_default();
568 match raw.trim().to_lowercase().as_str() {
569 "dedicated" => RulesInjection::Dedicated,
570 "off" | "none" | "disabled" => RulesInjection::Off,
571 _ => RulesInjection::Shared,
572 }
573 }
574
575 #[must_use]
579 pub fn permission_inheritance_effective(&self) -> PermissionInheritance {
580 let raw = std::env::var("LEAN_CTX_PERMISSION_INHERITANCE")
581 .ok()
582 .or_else(|| self.permission_inheritance.clone())
583 .unwrap_or_default();
584 match raw.trim().to_lowercase().as_str() {
585 "on" | "true" | "1" | "inherit" => PermissionInheritance::On,
586 _ => PermissionInheritance::Off,
587 }
588 }
589
590 #[must_use]
597 pub fn dedicated_session_context_active(&self) -> bool {
598 self.rules_injection_effective() == RulesInjection::Dedicated
599 && self.rules_scope_effective() != RulesScope::Project
600 }
601
602 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
603 val.split(',')
604 .map(|s| s.trim().to_string())
605 .filter(|s| !s.is_empty())
606 .collect()
607 }
608
609 pub fn disabled_tools_effective(&self) -> Vec<String> {
611 if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
612 Self::parse_disabled_tools_env(&val)
613 } else {
614 self.disabled_tools.clone()
615 }
616 }
617
618 pub fn minimal_overhead_effective(&self) -> bool {
620 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
621 }
622
623 pub fn structure_first_effective(&self) -> bool {
629 match std::env::var("LEAN_CTX_STRUCTURE_FIRST") {
630 Ok(raw) => matches!(
631 raw.trim().to_lowercase().as_str(),
632 "1" | "true" | "yes" | "on"
633 ),
634 Err(_) => self.structure_first,
635 }
636 }
637
638 pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
646 if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
647 match raw.trim().to_lowercase().as_str() {
648 "minimal" => return true,
649 "full" => return self.minimal_overhead_effective(),
650 _ => {}
651 }
652 }
653
654 if self.minimal_overhead_effective() {
655 return true;
656 }
657
658 let client_lower = client_name.trim().to_lowercase();
659 if !client_lower.is_empty() {
660 if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
661 for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
662 if !needle.is_empty() && client_lower.contains(&needle) {
663 return true;
664 }
665 }
666 } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
667 return true;
668 }
669 }
670
671 let model = std::env::var("LEAN_CTX_MODEL")
672 .or_else(|_| std::env::var("LCTX_MODEL"))
673 .unwrap_or_default();
674 let model = model.trim().to_lowercase();
675 if !model.is_empty() {
676 let m = model.replace(['_', ' '], "-");
677 if m.contains("minimax")
678 || m.contains("mini-max")
679 || m.contains("m2.7")
680 || m.contains("m2-7")
681 {
682 return true;
683 }
684 }
685
686 false
687 }
688
689 pub fn shell_hook_disabled_effective(&self) -> bool {
691 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
692 }
693
694 pub fn shell_activation_effective(&self) -> ShellActivation {
696 ShellActivation::effective(self)
697 }
698
699 pub fn update_check_disabled_effective(&self) -> bool {
701 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
702 }
703
704 pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
705 let mut policy = self.memory.clone();
706 policy.apply_env_overrides();
707
708 let budget = self.max_disk_mb_effective();
711 if budget > 0 {
712 let scale_factor = (budget as f64 / 500.0).clamp(0.5, 10.0);
713 let default_policy = MemoryPolicy::default();
714 if policy.knowledge.max_facts == default_policy.knowledge.max_facts {
715 policy.knowledge.max_facts = (200.0 * scale_factor) as usize;
716 }
717 if policy.knowledge.max_patterns == default_policy.knowledge.max_patterns {
718 policy.knowledge.max_patterns = (50.0 * scale_factor) as usize;
719 }
720 if policy.episodic.max_episodes == default_policy.episodic.max_episodes {
721 policy.episodic.max_episodes = (500.0 * scale_factor) as usize;
722 }
723 if policy.procedural.max_procedures == default_policy.procedural.max_procedures {
724 policy.procedural.max_procedures = (100.0 * scale_factor) as usize;
725 }
726 }
727
728 policy.validate()?;
729 Ok(policy)
730 }
731
732 pub fn default_tool_categories_effective(&self) -> Vec<String> {
735 if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
736 return val
737 .split(',')
738 .map(|s| s.trim().to_lowercase())
739 .filter(|s| !s.is_empty())
740 .collect();
741 }
742 if !self.default_tool_categories.is_empty() {
743 return self
744 .default_tool_categories
745 .iter()
746 .map(|s| s.to_lowercase())
747 .collect();
748 }
749 vec!["core".to_string(), "session".to_string()]
750 }
751
752 pub fn tool_profile_effective(&self) -> super::tool_profiles::ToolProfile {
760 super::persona::Persona::resolve(self).effective_tool_profile(self)
761 }
762
763 pub fn no_degrade_effective(&self) -> bool {
766 if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
767 return val == "1" || val.eq_ignore_ascii_case("true");
768 }
769 self.no_degrade
770 }
771
772 pub fn max_disk_mb_effective(&self) -> u64 {
774 std::env::var("LEAN_CTX_MAX_DISK_MB")
775 .ok()
776 .and_then(|v| v.parse().ok())
777 .unwrap_or(self.max_disk_mb)
778 }
779
780 pub fn max_staleness_days_effective(&self) -> u32 {
782 std::env::var("LEAN_CTX_MAX_STALENESS_DAYS")
783 .ok()
784 .and_then(|v| v.parse().ok())
785 .unwrap_or(self.max_staleness_days)
786 }
787
788 pub fn archive_max_disk_mb_effective(&self) -> u64 {
791 let budget = self.max_disk_mb_effective();
792 if budget > 0 && self.archive.max_disk_mb == ArchiveConfig::default().max_disk_mb {
793 budget * 25 / 100
794 } else {
795 self.archive.max_disk_mb
796 }
797 }
798
799 pub fn archive_max_age_hours_effective(&self) -> u64 {
802 let staleness = self.max_staleness_days_effective();
803 if staleness > 0 && self.archive.max_age_hours == ArchiveConfig::default().max_age_hours {
804 staleness as u64 * 24
805 } else {
806 self.archive.max_age_hours
807 }
808 }
809
810 pub fn bm25_max_cache_mb_effective(&self) -> u64 {
818 if self.bm25_max_cache_mb != serde_defaults::default_bm25_max_cache_mb() {
820 return self.bm25_max_cache_mb;
821 }
822 let budget = self.max_disk_mb_effective();
824 if budget > 0 {
825 return budget * 10 / 100;
826 }
827 DEFAULT_BM25_PERSIST_MB
829 }
830}
831
832impl Config {
833 pub fn path() -> Option<PathBuf> {
839 crate::core::paths::config_dir()
840 .ok()
841 .map(|d| d.join("config.toml"))
842 }
843
844 pub fn local_path(project_root: &str) -> PathBuf {
846 PathBuf::from(project_root).join(".lean-ctx.toml")
847 }
848
849 fn find_project_root() -> Option<String> {
850 static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
851 ROOT_CACHE
852 .get_or_init(Self::find_project_root_inner)
853 .clone()
854 }
855
856 fn find_project_root_inner() -> Option<String> {
857 if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT")
858 && !env_root.is_empty()
859 {
860 return Some(env_root);
861 }
862
863 let cwd = std::env::current_dir().ok();
864
865 if let Some(root) =
866 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
867 {
868 let root_path = std::path::Path::new(&root);
869 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
870 let has_marker = crate::core::pathutil::has_project_marker(root_path);
875
876 if (cwd_is_under_root || has_marker) && crate::core::pathutil::may_probe_path(root_path)
877 {
878 return Some(root);
879 }
880 }
881
882 if let Some(ref cwd) = cwd {
883 let may_probe_cwd = crate::core::pathutil::may_probe_path(cwd);
887 let git_root = if may_probe_cwd {
888 std::process::Command::new("git")
889 .args(["rev-parse", "--show-toplevel"])
890 .current_dir(cwd)
891 .stdout(std::process::Stdio::piped())
892 .stderr(std::process::Stdio::null())
893 .output()
894 .ok()
895 .and_then(|o| {
896 if o.status.success() {
897 String::from_utf8(o.stdout)
898 .ok()
899 .map(|s| s.trim().to_string())
900 } else {
901 None
902 }
903 })
904 } else {
905 None
906 };
907 if let Some(root) = git_root {
908 return Some(root);
909 }
910 if may_probe_cwd && !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
911 return Some(cwd.to_string_lossy().to_string());
912 }
913 }
914 None
915 }
916
917 pub fn load() -> Self {
928 static CACHE: Mutex<Option<(Config, Option<String>, Option<String>)>> = Mutex::new(None);
929
930 let Some(path) = Self::path() else {
931 return Self::default();
932 };
933
934 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
935
936 let global_content = std::fs::read_to_string(&path).ok();
938 let local_content = local_path
943 .as_ref()
944 .filter(|p| crate::core::pathutil::may_probe_path(p.as_path()))
945 .and_then(|p| std::fs::read_to_string(p).ok());
946
947 let global_hash = global_content.as_deref().map(crate::core::hasher::hash_str);
948 let local_hash = local_content.as_deref().map(crate::core::hasher::hash_str);
949
950 if let Ok(guard) = CACHE.lock()
951 && let Some((ref cfg, ref cached_global, ref cached_local)) = *guard
952 && *cached_global == global_hash
953 && *cached_local == local_hash
954 {
955 return cfg.clone();
956 }
957
958 let mut cfg: Config = if let Some(ref content) = global_content {
959 match toml::from_str(content) {
960 Ok(c) => {
961 record_parse_error(None);
962 c
963 }
964 Err(e) => {
965 record_parse_error(Some(format!("{e}")));
966 tracing::warn!("config parse error in {}: {e}", path.display());
967 eprintln!(
968 "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n \
969 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
970 path.display()
971 );
972 Self::default()
973 }
974 }
975 } else {
976 record_parse_error(None);
977 Self::default()
978 };
979
980 if let Some(ref local) = local_content {
981 cfg.merge_local(local);
982 }
983
984 if let Ok(mut guard) = CACHE.lock() {
985 *guard = Some((cfg.clone(), global_hash, local_hash));
986 }
987
988 cfg
989 }
990
991 fn merge_local(&mut self, local_toml: &str) {
992 let local: Config = match toml::from_str(local_toml) {
993 Ok(c) => c,
994 Err(e) => {
995 tracing::warn!("local config parse error: {e}");
996 eprintln!(
997 "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n \
998 Local overrides skipped.\x1b[0m"
999 );
1000 return;
1001 }
1002 };
1003 if local.ultra_compact {
1004 self.ultra_compact = true;
1005 }
1006 if local.tee_mode != TeeMode::default() {
1007 self.tee_mode = local.tee_mode;
1008 }
1009 if local.output_density != OutputDensity::default() {
1010 self.output_density = local.output_density;
1011 }
1012 if local.checkpoint_interval != 15 {
1013 self.checkpoint_interval = local.checkpoint_interval;
1014 }
1015 if !local.excluded_commands.is_empty() {
1016 self.excluded_commands.extend(local.excluded_commands);
1017 }
1018 if !local.passthrough_urls.is_empty() {
1019 self.passthrough_urls.extend(local.passthrough_urls);
1020 }
1021 if !local.custom_aliases.is_empty() {
1022 self.custom_aliases.extend(local.custom_aliases);
1023 }
1024 for fmt in local.preserve_compact_formats {
1027 if !self
1028 .preserve_compact_formats
1029 .iter()
1030 .any(|f| f.eq_ignore_ascii_case(&fmt))
1031 {
1032 self.preserve_compact_formats.push(fmt);
1033 }
1034 }
1035 if local.slow_command_threshold_ms != 5000 {
1036 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
1037 }
1038 if local.theme != "default" {
1039 self.theme = local.theme;
1040 }
1041 if !local.buddy_enabled {
1042 self.buddy_enabled = false;
1043 }
1044 if !local.enable_wakeup_ctx {
1045 self.enable_wakeup_ctx = false;
1046 }
1047 if !local.redirect_exclude.is_empty() {
1048 self.redirect_exclude.extend(local.redirect_exclude);
1049 }
1050 if !local.disabled_tools.is_empty() {
1051 self.disabled_tools.extend(local.disabled_tools);
1052 }
1053 if !local.extra_ignore_patterns.is_empty() {
1054 self.extra_ignore_patterns
1055 .extend(local.extra_ignore_patterns);
1056 }
1057 if local.rules_scope.is_some() {
1058 self.rules_scope = local.rules_scope;
1059 }
1060 if local.rules_injection.is_some() {
1061 self.rules_injection = local.rules_injection;
1062 }
1063 if local.permission_inheritance.is_some() {
1064 self.permission_inheritance = local.permission_inheritance;
1065 }
1066 if local.proxy.anthropic_upstream.is_some() {
1067 self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1068 }
1069 if local.proxy.openai_upstream.is_some() {
1070 self.proxy.openai_upstream = local.proxy.openai_upstream;
1071 }
1072 if local.proxy.gemini_upstream.is_some() {
1073 self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1074 }
1075 if !local.autonomy.enabled {
1076 self.autonomy.enabled = false;
1077 }
1078 if !local.autonomy.auto_preload {
1079 self.autonomy.auto_preload = false;
1080 }
1081 if !local.autonomy.auto_dedup {
1082 self.autonomy.auto_dedup = false;
1083 }
1084 if !local.autonomy.auto_related {
1085 self.autonomy.auto_related = false;
1086 }
1087 if !local.autonomy.auto_consolidate {
1088 self.autonomy.auto_consolidate = false;
1089 }
1090 if local.autonomy.silent_preload {
1091 self.autonomy.silent_preload = true;
1092 }
1093 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1094 self.autonomy.silent_preload = false;
1095 }
1096 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1097 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1098 }
1099 if local.autonomy.consolidate_every_calls
1100 != AutonomyConfig::default().consolidate_every_calls
1101 {
1102 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1103 }
1104 if local.autonomy.consolidate_cooldown_secs
1105 != AutonomyConfig::default().consolidate_cooldown_secs
1106 {
1107 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1108 }
1109 if !local.autonomy.cognition_loop_enabled {
1110 self.autonomy.cognition_loop_enabled = false;
1111 }
1112 if local.autonomy.cognition_loop_interval_secs
1113 != AutonomyConfig::default().cognition_loop_interval_secs
1114 {
1115 self.autonomy.cognition_loop_interval_secs =
1116 local.autonomy.cognition_loop_interval_secs;
1117 }
1118 if local.autonomy.cognition_loop_max_steps
1119 != AutonomyConfig::default().cognition_loop_max_steps
1120 {
1121 self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1122 }
1123 if local_toml.contains("compression_level") {
1124 self.compression_level = local.compression_level;
1125 }
1126 if local_toml.contains("terse_agent") {
1127 self.terse_agent = local.terse_agent;
1128 }
1129 if !local.archive.enabled {
1130 self.archive.enabled = false;
1131 }
1132 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1133 self.archive.threshold_chars = local.archive.threshold_chars;
1134 }
1135 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1136 self.archive.max_age_hours = local.archive.max_age_hours;
1137 }
1138 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1139 self.archive.max_disk_mb = local.archive.max_disk_mb;
1140 }
1141 if !local.archive.ephemeral {
1142 self.archive.ephemeral = false;
1143 }
1144 if local.archive.ephemeral_min_tokens != ArchiveConfig::default().ephemeral_min_tokens {
1145 self.archive.ephemeral_min_tokens = local.archive.ephemeral_min_tokens;
1146 }
1147 let mem_def = MemoryPolicy::default();
1148 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1149 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1150 }
1151 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1152 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1153 }
1154 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1155 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1156 }
1157 if local.memory.knowledge.contradiction_threshold
1158 != mem_def.knowledge.contradiction_threshold
1159 {
1160 self.memory.knowledge.contradiction_threshold =
1161 local.memory.knowledge.contradiction_threshold;
1162 }
1163
1164 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1165 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1166 }
1167 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1168 {
1169 self.memory.episodic.max_actions_per_episode =
1170 local.memory.episodic.max_actions_per_episode;
1171 }
1172 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1173 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1174 }
1175
1176 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1177 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1178 }
1179 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1180 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1181 }
1182 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1183 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1184 }
1185 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1186 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1187 }
1188
1189 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1190 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1191 }
1192 if local.memory.lifecycle.low_confidence_threshold
1193 != mem_def.lifecycle.low_confidence_threshold
1194 {
1195 self.memory.lifecycle.low_confidence_threshold =
1196 local.memory.lifecycle.low_confidence_threshold;
1197 }
1198 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1199 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1200 }
1201 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1202 self.memory.lifecycle.similarity_threshold =
1203 local.memory.lifecycle.similarity_threshold;
1204 }
1205
1206 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1207 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1208 }
1209 if !local.allow_paths.is_empty() {
1210 self.allow_paths.extend(local.allow_paths);
1211 }
1212 if !local.extra_roots.is_empty() {
1213 self.extra_roots.extend(local.extra_roots);
1214 }
1215 if local.minimal_overhead {
1216 self.minimal_overhead = true;
1217 }
1218 if local.shell_hook_disabled {
1219 self.shell_hook_disabled = true;
1220 }
1221 if local.shell_activation != ShellActivation::default() {
1222 self.shell_activation = local.shell_activation.clone();
1223 }
1224 if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1225 self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1226 }
1227 if local.memory_profile != MemoryProfile::default() {
1228 self.memory_profile = local.memory_profile;
1229 }
1230 if local.memory_cleanup != MemoryCleanup::default() {
1231 self.memory_cleanup = local.memory_cleanup;
1232 }
1233 if local.shell_allowlist != default_shell_allowlist() {
1241 self.shell_allowlist = local.shell_allowlist;
1242 }
1243 if !local.shell_allowlist_extra.is_empty() {
1244 self.shell_allowlist_extra
1245 .extend(local.shell_allowlist_extra);
1246 }
1247 if !local.default_tool_categories.is_empty() {
1248 self.default_tool_categories = local.default_tool_categories;
1249 }
1250 if local.tool_profile.is_some() {
1251 self.tool_profile = local.tool_profile;
1252 }
1253 if !local.tools_enabled.is_empty() {
1254 self.tools_enabled = local.tools_enabled;
1255 }
1256 if local.no_degrade {
1257 self.no_degrade = true;
1258 }
1259 if local.profile.is_some() {
1260 self.profile = local.profile;
1261 }
1262 if local.proxy_timeout_ms.is_some() {
1263 self.proxy_timeout_ms = local.proxy_timeout_ms;
1264 }
1265 }
1266
1267 pub fn load_global() -> Self {
1276 Self::path().map_or_else(Self::default, |p| Self::load_global_from(&p))
1277 }
1278
1279 fn load_global_from(path: &Path) -> Self {
1284 match std::fs::read_to_string(path) {
1285 Ok(raw) if !raw.trim().is_empty() => toml::from_str(&raw).unwrap_or_default(),
1286 _ => Self::default(),
1287 }
1288 }
1289
1290 pub fn update_global<F>(f: F) -> std::result::Result<Self, super::error::LeanCtxError>
1299 where
1300 F: FnOnce(&mut Self),
1301 {
1302 let path = Self::path().ok_or_else(|| {
1303 super::error::LeanCtxError::Config("cannot determine home directory".into())
1304 })?;
1305 Self::update_global_at(&path, f)
1306 }
1307
1308 fn update_global_at<F>(
1310 path: &Path,
1311 f: F,
1312 ) -> std::result::Result<Self, super::error::LeanCtxError>
1313 where
1314 F: FnOnce(&mut Self),
1315 {
1316 let mut cfg = match std::fs::read_to_string(path) {
1317 Ok(raw) if !raw.trim().is_empty() => toml::from_str::<Self>(&raw).map_err(|e| {
1318 super::error::LeanCtxError::Config(format!(
1319 "refusing to modify an unparseable config.toml ({e}); fix it \
1320 manually or run `lean-ctx doctor --fix`, then retry"
1321 ))
1322 })?,
1323 _ => Self::default(),
1324 };
1325 f(&mut cfg);
1326 cfg.save_to(path)?;
1327 Ok(cfg)
1328 }
1329
1330 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1336 let path = Self::path().ok_or_else(|| {
1337 super::error::LeanCtxError::Config("cannot determine home directory".into())
1338 })?;
1339 self.save_to(&path)
1340 }
1341
1342 fn save_to(&self, path: &Path) -> std::result::Result<(), super::error::LeanCtxError> {
1344 if let Some(parent) = path.parent() {
1345 std::fs::create_dir_all(parent)?;
1346 }
1347 let content = toml::to_string_pretty(self)
1348 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1349 let baseline = toml::from_str::<Self>("").unwrap_or_else(|_| Self::default());
1354 let defaults = toml::to_string_pretty(&baseline)
1355 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1356 crate::config_io::write_toml_preserving_minimal(path, &content, &defaults)
1357 .map_err(super::error::LeanCtxError::Config)?;
1358 Ok(())
1359 }
1360
1361 pub fn show(&self) -> String {
1363 let global_path = Self::path().map_or_else(
1364 || "~/.lean-ctx/config.toml".to_string(),
1365 |p| p.to_string_lossy().to_string(),
1366 );
1367 let content = toml::to_string_pretty(self).unwrap_or_default();
1368 let mut out = format!("Global config: {global_path}\n\n{content}");
1369
1370 if let Some(root) = Self::find_project_root() {
1371 let local = Self::local_path(&root);
1372 if local.exists() {
1373 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1374 } else {
1375 out.push_str(&format!(
1376 "\n\nLocal config: not found (create {} to override per-project)\n",
1377 local.display()
1378 ));
1379 }
1380 }
1381 out
1382 }
1383}