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)]
219 pub team_url: Option<String>,
220 #[serde(default)]
224 pub team_token: Option<String>,
225 #[serde(default)]
230 pub team_auto_push: bool,
231 #[serde(default)]
233 pub journal_enabled: bool,
234 #[serde(default)]
236 pub auto_capture: bool,
237 #[serde(default)]
239 pub search: crate::core::hybrid_search::HybridConfig,
240 #[serde(default)]
242 pub graph: GraphConfig,
243 #[serde(default)]
245 pub skillify: SkillifyConfig,
246 #[serde(default)]
248 pub summaries: SummariesConfig,
249 #[serde(default)]
251 pub llm: crate::core::llm_enhance::LlmConfig,
252 #[serde(default)]
254 pub embedding: EmbeddingConfig,
255 #[serde(default)]
258 pub shell_hook_disabled: bool,
259 #[serde(default)]
264 pub shadow_mode: bool,
265 #[serde(default)]
272 pub shell_activation: ShellActivation,
273 #[serde(default)]
276 pub update_check_disabled: bool,
277 #[serde(default)]
278 pub updates: UpdatesConfig,
279 #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
282 pub bm25_max_cache_mb: u64,
283 #[serde(default = "serde_defaults::default_graph_index_max_files")]
286 pub graph_index_max_files: u64,
287 #[serde(default)]
290 pub memory_profile: MemoryProfile,
291 #[serde(default)]
295 pub memory_cleanup: MemoryCleanup,
296 #[serde(default = "serde_defaults::default_max_ram_percent")]
299 pub max_ram_percent: u8,
300 #[serde(default)]
304 pub max_disk_mb: u64,
305 #[serde(default)]
308 pub max_staleness_days: u32,
309 #[serde(default)]
313 pub savings_footer: SavingsFooter,
314 #[serde(default)]
318 pub project_root: Option<String>,
319 #[serde(default)]
322 pub lsp: std::collections::HashMap<String, String>,
323 #[serde(default)]
327 pub ide_paths: HashMap<String, Vec<String>>,
328 #[serde(default)]
331 pub model_context_windows: HashMap<String, usize>,
332 #[serde(default)]
339 pub response_verbosity: ResponseVerbosity,
340 #[serde(default)]
345 pub bypass_hints: Option<String>,
346 #[serde(default)]
351 pub cache_policy: Option<String>,
352 #[serde(default)]
355 pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
356 #[serde(default)]
357 pub secret_detection: SecretDetectionConfig,
358 #[serde(default)]
361 pub sensitivity: crate::core::sensitivity::SensitivityConfig,
362 #[serde(default)]
366 pub gateway: crate::core::gateway::GatewayConfig,
367 #[serde(default)]
371 pub allow_auto_reroot: bool,
372 #[serde(default)]
376 pub path_jail: Option<bool>,
377 #[serde(default)]
381 pub sandbox_level: u8,
382 #[serde(default)]
386 pub reference_results: bool,
387 #[serde(default)]
390 pub agent_token_budget: usize,
391 #[serde(default = "default_shell_allowlist")]
396 pub shell_allowlist: Vec<String>,
397
398 #[serde(default)]
403 pub shell_allowlist_extra: Vec<String>,
404
405 #[serde(default)]
409 pub shell_strict_mode: bool,
410 #[serde(default)]
412 pub setup: SetupConfig,
413}
414
415impl Default for Config {
416 fn default() -> Self {
417 Self {
418 ultra_compact: false,
419 tee_mode: TeeMode::default(),
420 output_density: OutputDensity::default(),
421 checkpoint_interval: 15,
422 excluded_commands: Vec::new(),
423 passthrough_urls: Vec::new(),
424 custom_aliases: Vec::new(),
425 preserve_compact_formats: serde_defaults::default_preserve_compact_formats(),
426 slow_command_threshold_ms: 5000,
427 theme: serde_defaults::default_theme(),
428 cloud: CloudConfig::default(),
429 gain: GainConfig::default(),
430 autonomy: AutonomyConfig::default(),
431 providers: ProvidersConfig::default(),
432 proxy: ProxyConfig::default(),
433 proxy_enabled: None,
434 proxy_port: None,
435 proxy_timeout_ms: None,
436 buddy_enabled: serde_defaults::default_buddy_enabled(),
437 enable_wakeup_ctx: true,
438 redirect_exclude: Vec::new(),
439 disabled_tools: Vec::new(),
440 default_tool_categories: Vec::new(),
441 no_degrade: false,
442 profile: None,
443 tool_profile: None,
444 tools_enabled: Vec::new(),
445 persona: None,
446 loop_detection: LoopDetectionConfig::default(),
447 rules_scope: None,
448 rules_injection: None,
449 permission_inheritance: None,
450 extra_ignore_patterns: Vec::new(),
451 terse_agent: TerseAgent::default(),
452 compression_level: CompressionLevel::default(),
453 archive: ArchiveConfig::default(),
454 memory: MemoryPolicy::default(),
455 allow_paths: Vec::new(),
456 allow_ide_config_dirs: false,
457 extra_roots: Vec::new(),
458 content_defined_chunking: false,
459 minimal_overhead: true,
460 symbol_map_auto: false,
461 team_url: None,
462 team_token: None,
463 team_auto_push: false,
464 journal_enabled: true,
465 auto_capture: true,
466 search: crate::core::hybrid_search::HybridConfig::default(),
467 graph: GraphConfig::default(),
468 skillify: SkillifyConfig::default(),
469 summaries: SummariesConfig::default(),
470 llm: crate::core::llm_enhance::LlmConfig::default(),
471 embedding: EmbeddingConfig::default(),
472 shell_hook_disabled: false,
473 shadow_mode: false,
474 shell_activation: ShellActivation::default(),
475 update_check_disabled: false,
476 updates: UpdatesConfig::default(),
477 graph_index_max_files: serde_defaults::default_graph_index_max_files(),
478 bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
479 memory_profile: MemoryProfile::default(),
480 memory_cleanup: MemoryCleanup::default(),
481 max_ram_percent: serde_defaults::default_max_ram_percent(),
482 max_disk_mb: 0,
483 max_staleness_days: 0,
484 savings_footer: SavingsFooter::default(),
485 project_root: None,
486 lsp: std::collections::HashMap::new(),
487 ide_paths: HashMap::new(),
488 model_context_windows: HashMap::new(),
489 response_verbosity: ResponseVerbosity::default(),
490 bypass_hints: None,
491 cache_policy: None,
492 boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
493 secret_detection: SecretDetectionConfig::default(),
494 sensitivity: crate::core::sensitivity::SensitivityConfig::default(),
495 gateway: crate::core::gateway::GatewayConfig::default(),
496 allow_auto_reroot: false,
497 path_jail: None,
498 sandbox_level: 0,
499 reference_results: false,
500 agent_token_budget: 0,
501 shell_allowlist: default_shell_allowlist(),
502 shell_allowlist_extra: Vec::new(),
503 shell_strict_mode: false,
504 setup: SetupConfig::default(),
505 }
506 }
507}
508
509static LAST_PARSE_ERROR: Mutex<Option<String>> = Mutex::new(None);
515
516#[must_use]
519pub fn last_config_parse_error() -> Option<String> {
520 LAST_PARSE_ERROR.lock().ok().and_then(|g| g.clone())
521}
522
523fn record_parse_error(err: Option<String>) {
524 if let Ok(mut guard) = LAST_PARSE_ERROR.lock() {
525 *guard = err;
526 }
527}
528
529impl Config {
530 pub fn rules_scope_effective(&self) -> RulesScope {
532 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
533 .ok()
534 .or_else(|| self.rules_scope.clone())
535 .unwrap_or_default();
536 match raw.trim().to_lowercase().as_str() {
537 "global" => RulesScope::Global,
538 "project" => RulesScope::Project,
539 _ => RulesScope::Both,
540 }
541 }
542
543 pub fn rules_injection_effective(&self) -> RulesInjection {
546 let raw = std::env::var("LEAN_CTX_RULES_INJECTION")
547 .ok()
548 .or_else(|| self.rules_injection.clone())
549 .unwrap_or_default();
550 match raw.trim().to_lowercase().as_str() {
551 "dedicated" => RulesInjection::Dedicated,
552 "off" | "none" | "disabled" => RulesInjection::Off,
553 _ => RulesInjection::Shared,
554 }
555 }
556
557 #[must_use]
561 pub fn permission_inheritance_effective(&self) -> PermissionInheritance {
562 let raw = std::env::var("LEAN_CTX_PERMISSION_INHERITANCE")
563 .ok()
564 .or_else(|| self.permission_inheritance.clone())
565 .unwrap_or_default();
566 match raw.trim().to_lowercase().as_str() {
567 "on" | "true" | "1" | "inherit" => PermissionInheritance::On,
568 _ => PermissionInheritance::Off,
569 }
570 }
571
572 #[must_use]
579 pub fn dedicated_session_context_active(&self) -> bool {
580 self.rules_injection_effective() == RulesInjection::Dedicated
581 && self.rules_scope_effective() != RulesScope::Project
582 }
583
584 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
585 val.split(',')
586 .map(|s| s.trim().to_string())
587 .filter(|s| !s.is_empty())
588 .collect()
589 }
590
591 pub fn disabled_tools_effective(&self) -> Vec<String> {
593 if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
594 Self::parse_disabled_tools_env(&val)
595 } else {
596 self.disabled_tools.clone()
597 }
598 }
599
600 pub fn minimal_overhead_effective(&self) -> bool {
602 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
603 }
604
605 pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
613 if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
614 match raw.trim().to_lowercase().as_str() {
615 "minimal" => return true,
616 "full" => return self.minimal_overhead_effective(),
617 _ => {}
618 }
619 }
620
621 if self.minimal_overhead_effective() {
622 return true;
623 }
624
625 let client_lower = client_name.trim().to_lowercase();
626 if !client_lower.is_empty() {
627 if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
628 for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
629 if !needle.is_empty() && client_lower.contains(&needle) {
630 return true;
631 }
632 }
633 } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
634 return true;
635 }
636 }
637
638 let model = std::env::var("LEAN_CTX_MODEL")
639 .or_else(|_| std::env::var("LCTX_MODEL"))
640 .unwrap_or_default();
641 let model = model.trim().to_lowercase();
642 if !model.is_empty() {
643 let m = model.replace(['_', ' '], "-");
644 if m.contains("minimax")
645 || m.contains("mini-max")
646 || m.contains("m2.7")
647 || m.contains("m2-7")
648 {
649 return true;
650 }
651 }
652
653 false
654 }
655
656 pub fn shell_hook_disabled_effective(&self) -> bool {
658 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
659 }
660
661 pub fn shell_activation_effective(&self) -> ShellActivation {
663 ShellActivation::effective(self)
664 }
665
666 pub fn update_check_disabled_effective(&self) -> bool {
668 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
669 }
670
671 pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
672 let mut policy = self.memory.clone();
673 policy.apply_env_overrides();
674
675 let budget = self.max_disk_mb_effective();
678 if budget > 0 {
679 let scale_factor = (budget as f64 / 500.0).clamp(0.5, 10.0);
680 let default_policy = MemoryPolicy::default();
681 if policy.knowledge.max_facts == default_policy.knowledge.max_facts {
682 policy.knowledge.max_facts = (200.0 * scale_factor) as usize;
683 }
684 if policy.knowledge.max_patterns == default_policy.knowledge.max_patterns {
685 policy.knowledge.max_patterns = (50.0 * scale_factor) as usize;
686 }
687 if policy.episodic.max_episodes == default_policy.episodic.max_episodes {
688 policy.episodic.max_episodes = (500.0 * scale_factor) as usize;
689 }
690 if policy.procedural.max_procedures == default_policy.procedural.max_procedures {
691 policy.procedural.max_procedures = (100.0 * scale_factor) as usize;
692 }
693 }
694
695 policy.validate()?;
696 Ok(policy)
697 }
698
699 pub fn default_tool_categories_effective(&self) -> Vec<String> {
702 if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
703 return val
704 .split(',')
705 .map(|s| s.trim().to_lowercase())
706 .filter(|s| !s.is_empty())
707 .collect();
708 }
709 if !self.default_tool_categories.is_empty() {
710 return self
711 .default_tool_categories
712 .iter()
713 .map(|s| s.to_lowercase())
714 .collect();
715 }
716 vec!["core".to_string(), "session".to_string()]
717 }
718
719 pub fn tool_profile_effective(&self) -> super::tool_profiles::ToolProfile {
727 super::persona::Persona::resolve(self).effective_tool_profile(self)
728 }
729
730 pub fn no_degrade_effective(&self) -> bool {
733 if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
734 return val == "1" || val.eq_ignore_ascii_case("true");
735 }
736 self.no_degrade
737 }
738
739 pub fn max_disk_mb_effective(&self) -> u64 {
741 std::env::var("LEAN_CTX_MAX_DISK_MB")
742 .ok()
743 .and_then(|v| v.parse().ok())
744 .unwrap_or(self.max_disk_mb)
745 }
746
747 pub fn max_staleness_days_effective(&self) -> u32 {
749 std::env::var("LEAN_CTX_MAX_STALENESS_DAYS")
750 .ok()
751 .and_then(|v| v.parse().ok())
752 .unwrap_or(self.max_staleness_days)
753 }
754
755 pub fn archive_max_disk_mb_effective(&self) -> u64 {
758 let budget = self.max_disk_mb_effective();
759 if budget > 0 && self.archive.max_disk_mb == ArchiveConfig::default().max_disk_mb {
760 budget * 25 / 100
761 } else {
762 self.archive.max_disk_mb
763 }
764 }
765
766 pub fn archive_max_age_hours_effective(&self) -> u64 {
769 let staleness = self.max_staleness_days_effective();
770 if staleness > 0 && self.archive.max_age_hours == ArchiveConfig::default().max_age_hours {
771 staleness as u64 * 24
772 } else {
773 self.archive.max_age_hours
774 }
775 }
776
777 pub fn bm25_max_cache_mb_effective(&self) -> u64 {
785 if self.bm25_max_cache_mb != serde_defaults::default_bm25_max_cache_mb() {
787 return self.bm25_max_cache_mb;
788 }
789 let budget = self.max_disk_mb_effective();
791 if budget > 0 {
792 return budget * 10 / 100;
793 }
794 DEFAULT_BM25_PERSIST_MB
796 }
797}
798
799impl Config {
800 pub fn path() -> Option<PathBuf> {
806 crate::core::paths::config_dir()
807 .ok()
808 .map(|d| d.join("config.toml"))
809 }
810
811 pub fn local_path(project_root: &str) -> PathBuf {
813 PathBuf::from(project_root).join(".lean-ctx.toml")
814 }
815
816 fn find_project_root() -> Option<String> {
817 static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
818 ROOT_CACHE
819 .get_or_init(Self::find_project_root_inner)
820 .clone()
821 }
822
823 fn find_project_root_inner() -> Option<String> {
824 if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
825 if !env_root.is_empty() {
826 return Some(env_root);
827 }
828 }
829
830 let cwd = std::env::current_dir().ok();
831
832 if let Some(root) =
833 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
834 {
835 let root_path = std::path::Path::new(&root);
836 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
837 let has_marker = root_path.join(".git").exists()
838 || root_path.join("Cargo.toml").exists()
839 || root_path.join("package.json").exists()
840 || root_path.join("go.mod").exists()
841 || root_path.join("pyproject.toml").exists()
842 || root_path.join(".lean-ctx.toml").exists();
843
844 if cwd_is_under_root || has_marker {
845 return Some(root);
846 }
847 }
848
849 if let Some(ref cwd) = cwd {
850 let git_root = std::process::Command::new("git")
851 .args(["rev-parse", "--show-toplevel"])
852 .current_dir(cwd)
853 .stdout(std::process::Stdio::piped())
854 .stderr(std::process::Stdio::null())
855 .output()
856 .ok()
857 .and_then(|o| {
858 if o.status.success() {
859 String::from_utf8(o.stdout)
860 .ok()
861 .map(|s| s.trim().to_string())
862 } else {
863 None
864 }
865 });
866 if let Some(root) = git_root {
867 return Some(root);
868 }
869 if !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
870 return Some(cwd.to_string_lossy().to_string());
871 }
872 }
873 None
874 }
875
876 pub fn load() -> Self {
887 static CACHE: Mutex<Option<(Config, Option<String>, Option<String>)>> = Mutex::new(None);
888
889 let Some(path) = Self::path() else {
890 return Self::default();
891 };
892
893 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
894
895 let global_content = std::fs::read_to_string(&path).ok();
897 let local_content = local_path
898 .as_ref()
899 .and_then(|p| std::fs::read_to_string(p).ok());
900
901 let global_hash = global_content.as_deref().map(crate::core::hasher::hash_str);
902 let local_hash = local_content.as_deref().map(crate::core::hasher::hash_str);
903
904 if let Ok(guard) = CACHE.lock() {
905 if let Some((ref cfg, ref cached_global, ref cached_local)) = *guard {
906 if *cached_global == global_hash && *cached_local == local_hash {
907 return cfg.clone();
908 }
909 }
910 }
911
912 let mut cfg: Config = if let Some(ref content) = global_content {
913 match toml::from_str(content) {
914 Ok(c) => {
915 record_parse_error(None);
916 c
917 }
918 Err(e) => {
919 record_parse_error(Some(format!("{e}")));
920 tracing::warn!("config parse error in {}: {e}", path.display());
921 eprintln!(
922 "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n \
923 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
924 path.display()
925 );
926 Self::default()
927 }
928 }
929 } else {
930 record_parse_error(None);
931 Self::default()
932 };
933
934 if let Some(ref local) = local_content {
935 cfg.merge_local(local);
936 }
937
938 if let Ok(mut guard) = CACHE.lock() {
939 *guard = Some((cfg.clone(), global_hash, local_hash));
940 }
941
942 cfg
943 }
944
945 fn merge_local(&mut self, local_toml: &str) {
946 let local: Config = match toml::from_str(local_toml) {
947 Ok(c) => c,
948 Err(e) => {
949 tracing::warn!("local config parse error: {e}");
950 eprintln!(
951 "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n \
952 Local overrides skipped.\x1b[0m"
953 );
954 return;
955 }
956 };
957 if local.ultra_compact {
958 self.ultra_compact = true;
959 }
960 if local.tee_mode != TeeMode::default() {
961 self.tee_mode = local.tee_mode;
962 }
963 if local.output_density != OutputDensity::default() {
964 self.output_density = local.output_density;
965 }
966 if local.checkpoint_interval != 15 {
967 self.checkpoint_interval = local.checkpoint_interval;
968 }
969 if !local.excluded_commands.is_empty() {
970 self.excluded_commands.extend(local.excluded_commands);
971 }
972 if !local.passthrough_urls.is_empty() {
973 self.passthrough_urls.extend(local.passthrough_urls);
974 }
975 if !local.custom_aliases.is_empty() {
976 self.custom_aliases.extend(local.custom_aliases);
977 }
978 for fmt in local.preserve_compact_formats {
981 if !self
982 .preserve_compact_formats
983 .iter()
984 .any(|f| f.eq_ignore_ascii_case(&fmt))
985 {
986 self.preserve_compact_formats.push(fmt);
987 }
988 }
989 if local.slow_command_threshold_ms != 5000 {
990 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
991 }
992 if local.theme != "default" {
993 self.theme = local.theme;
994 }
995 if !local.buddy_enabled {
996 self.buddy_enabled = false;
997 }
998 if !local.enable_wakeup_ctx {
999 self.enable_wakeup_ctx = false;
1000 }
1001 if !local.redirect_exclude.is_empty() {
1002 self.redirect_exclude.extend(local.redirect_exclude);
1003 }
1004 if !local.disabled_tools.is_empty() {
1005 self.disabled_tools.extend(local.disabled_tools);
1006 }
1007 if !local.extra_ignore_patterns.is_empty() {
1008 self.extra_ignore_patterns
1009 .extend(local.extra_ignore_patterns);
1010 }
1011 if local.rules_scope.is_some() {
1012 self.rules_scope = local.rules_scope;
1013 }
1014 if local.rules_injection.is_some() {
1015 self.rules_injection = local.rules_injection;
1016 }
1017 if local.permission_inheritance.is_some() {
1018 self.permission_inheritance = local.permission_inheritance;
1019 }
1020 if local.proxy.anthropic_upstream.is_some() {
1021 self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1022 }
1023 if local.proxy.openai_upstream.is_some() {
1024 self.proxy.openai_upstream = local.proxy.openai_upstream;
1025 }
1026 if local.proxy.gemini_upstream.is_some() {
1027 self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1028 }
1029 if !local.autonomy.enabled {
1030 self.autonomy.enabled = false;
1031 }
1032 if !local.autonomy.auto_preload {
1033 self.autonomy.auto_preload = false;
1034 }
1035 if !local.autonomy.auto_dedup {
1036 self.autonomy.auto_dedup = false;
1037 }
1038 if !local.autonomy.auto_related {
1039 self.autonomy.auto_related = false;
1040 }
1041 if !local.autonomy.auto_consolidate {
1042 self.autonomy.auto_consolidate = false;
1043 }
1044 if local.autonomy.silent_preload {
1045 self.autonomy.silent_preload = true;
1046 }
1047 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1048 self.autonomy.silent_preload = false;
1049 }
1050 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1051 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1052 }
1053 if local.autonomy.consolidate_every_calls
1054 != AutonomyConfig::default().consolidate_every_calls
1055 {
1056 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1057 }
1058 if local.autonomy.consolidate_cooldown_secs
1059 != AutonomyConfig::default().consolidate_cooldown_secs
1060 {
1061 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1062 }
1063 if !local.autonomy.cognition_loop_enabled {
1064 self.autonomy.cognition_loop_enabled = false;
1065 }
1066 if local.autonomy.cognition_loop_interval_secs
1067 != AutonomyConfig::default().cognition_loop_interval_secs
1068 {
1069 self.autonomy.cognition_loop_interval_secs =
1070 local.autonomy.cognition_loop_interval_secs;
1071 }
1072 if local.autonomy.cognition_loop_max_steps
1073 != AutonomyConfig::default().cognition_loop_max_steps
1074 {
1075 self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1076 }
1077 if local_toml.contains("compression_level") {
1078 self.compression_level = local.compression_level;
1079 }
1080 if local_toml.contains("terse_agent") {
1081 self.terse_agent = local.terse_agent;
1082 }
1083 if !local.archive.enabled {
1084 self.archive.enabled = false;
1085 }
1086 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1087 self.archive.threshold_chars = local.archive.threshold_chars;
1088 }
1089 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1090 self.archive.max_age_hours = local.archive.max_age_hours;
1091 }
1092 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1093 self.archive.max_disk_mb = local.archive.max_disk_mb;
1094 }
1095 if !local.archive.ephemeral {
1096 self.archive.ephemeral = false;
1097 }
1098 if local.archive.ephemeral_min_tokens != ArchiveConfig::default().ephemeral_min_tokens {
1099 self.archive.ephemeral_min_tokens = local.archive.ephemeral_min_tokens;
1100 }
1101 let mem_def = MemoryPolicy::default();
1102 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1103 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1104 }
1105 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1106 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1107 }
1108 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1109 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1110 }
1111 if local.memory.knowledge.contradiction_threshold
1112 != mem_def.knowledge.contradiction_threshold
1113 {
1114 self.memory.knowledge.contradiction_threshold =
1115 local.memory.knowledge.contradiction_threshold;
1116 }
1117
1118 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1119 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1120 }
1121 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1122 {
1123 self.memory.episodic.max_actions_per_episode =
1124 local.memory.episodic.max_actions_per_episode;
1125 }
1126 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1127 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1128 }
1129
1130 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1131 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1132 }
1133 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1134 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1135 }
1136 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1137 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1138 }
1139 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1140 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1141 }
1142
1143 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1144 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1145 }
1146 if local.memory.lifecycle.low_confidence_threshold
1147 != mem_def.lifecycle.low_confidence_threshold
1148 {
1149 self.memory.lifecycle.low_confidence_threshold =
1150 local.memory.lifecycle.low_confidence_threshold;
1151 }
1152 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1153 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1154 }
1155 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1156 self.memory.lifecycle.similarity_threshold =
1157 local.memory.lifecycle.similarity_threshold;
1158 }
1159
1160 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1161 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1162 }
1163 if !local.allow_paths.is_empty() {
1164 self.allow_paths.extend(local.allow_paths);
1165 }
1166 if !local.extra_roots.is_empty() {
1167 self.extra_roots.extend(local.extra_roots);
1168 }
1169 if local.minimal_overhead {
1170 self.minimal_overhead = true;
1171 }
1172 if local.shell_hook_disabled {
1173 self.shell_hook_disabled = true;
1174 }
1175 if local.shell_activation != ShellActivation::default() {
1176 self.shell_activation = local.shell_activation.clone();
1177 }
1178 if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1179 self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1180 }
1181 if local.memory_profile != MemoryProfile::default() {
1182 self.memory_profile = local.memory_profile;
1183 }
1184 if local.memory_cleanup != MemoryCleanup::default() {
1185 self.memory_cleanup = local.memory_cleanup;
1186 }
1187 if local.shell_allowlist != default_shell_allowlist() {
1195 self.shell_allowlist = local.shell_allowlist;
1196 }
1197 if !local.shell_allowlist_extra.is_empty() {
1198 self.shell_allowlist_extra
1199 .extend(local.shell_allowlist_extra);
1200 }
1201 if !local.default_tool_categories.is_empty() {
1202 self.default_tool_categories = local.default_tool_categories;
1203 }
1204 if local.tool_profile.is_some() {
1205 self.tool_profile = local.tool_profile;
1206 }
1207 if !local.tools_enabled.is_empty() {
1208 self.tools_enabled = local.tools_enabled;
1209 }
1210 if local.no_degrade {
1211 self.no_degrade = true;
1212 }
1213 if local.profile.is_some() {
1214 self.profile = local.profile;
1215 }
1216 if local.proxy_timeout_ms.is_some() {
1217 self.proxy_timeout_ms = local.proxy_timeout_ms;
1218 }
1219 }
1220
1221 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1227 let path = Self::path().ok_or_else(|| {
1228 super::error::LeanCtxError::Config("cannot determine home directory".into())
1229 })?;
1230 if let Some(parent) = path.parent() {
1231 std::fs::create_dir_all(parent)?;
1232 }
1233 let content = toml::to_string_pretty(self)
1234 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1235 let baseline = toml::from_str::<Self>("").unwrap_or_else(|_| Self::default());
1240 let defaults = toml::to_string_pretty(&baseline)
1241 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1242 crate::config_io::write_toml_preserving_minimal(&path, &content, &defaults)
1243 .map_err(super::error::LeanCtxError::Config)?;
1244 Ok(())
1245 }
1246
1247 pub fn show(&self) -> String {
1249 let global_path = Self::path().map_or_else(
1250 || "~/.lean-ctx/config.toml".to_string(),
1251 |p| p.to_string_lossy().to_string(),
1252 );
1253 let content = toml::to_string_pretty(self).unwrap_or_default();
1254 let mut out = format!("Global config: {global_path}\n\n{content}");
1255
1256 if let Some(root) = Self::find_project_root() {
1257 let local = Self::local_path(&root);
1258 if local.exists() {
1259 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1260 } else {
1261 out.push_str(&format!(
1262 "\n\nLocal config: not found (create {} to override per-project)\n",
1263 local.display()
1264 ));
1265 }
1266 }
1267 out
1268 }
1269}