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 HistoryMode, ProxyConfig, ProxyProvider, is_local_proxy_url, normalize_url, normalize_url_opt,
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 && !env_root.is_empty()
850 {
851 return Some(env_root);
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 = crate::core::pathutil::has_project_marker(root_path);
866
867 if (cwd_is_under_root || has_marker) && crate::core::pathutil::may_probe_path(root_path)
868 {
869 return Some(root);
870 }
871 }
872
873 if let Some(ref cwd) = cwd {
874 let may_probe_cwd = crate::core::pathutil::may_probe_path(cwd);
878 let git_root = if may_probe_cwd {
879 std::process::Command::new("git")
880 .args(["rev-parse", "--show-toplevel"])
881 .current_dir(cwd)
882 .stdout(std::process::Stdio::piped())
883 .stderr(std::process::Stdio::null())
884 .output()
885 .ok()
886 .and_then(|o| {
887 if o.status.success() {
888 String::from_utf8(o.stdout)
889 .ok()
890 .map(|s| s.trim().to_string())
891 } else {
892 None
893 }
894 })
895 } else {
896 None
897 };
898 if let Some(root) = git_root {
899 return Some(root);
900 }
901 if may_probe_cwd && !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
902 return Some(cwd.to_string_lossy().to_string());
903 }
904 }
905 None
906 }
907
908 pub fn load() -> Self {
919 static CACHE: Mutex<Option<(Config, Option<String>, Option<String>)>> = Mutex::new(None);
920
921 let Some(path) = Self::path() else {
922 return Self::default();
923 };
924
925 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
926
927 let global_content = std::fs::read_to_string(&path).ok();
929 let local_content = local_path
934 .as_ref()
935 .filter(|p| crate::core::pathutil::may_probe_path(p.as_path()))
936 .and_then(|p| std::fs::read_to_string(p).ok());
937
938 let global_hash = global_content.as_deref().map(crate::core::hasher::hash_str);
939 let local_hash = local_content.as_deref().map(crate::core::hasher::hash_str);
940
941 if let Ok(guard) = CACHE.lock()
942 && let Some((ref cfg, ref cached_global, ref cached_local)) = *guard
943 && *cached_global == global_hash
944 && *cached_local == local_hash
945 {
946 return cfg.clone();
947 }
948
949 let mut cfg: Config = if let Some(ref content) = global_content {
950 match toml::from_str(content) {
951 Ok(c) => {
952 record_parse_error(None);
953 c
954 }
955 Err(e) => {
956 record_parse_error(Some(format!("{e}")));
957 tracing::warn!("config parse error in {}: {e}", path.display());
958 eprintln!(
959 "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n \
960 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
961 path.display()
962 );
963 Self::default()
964 }
965 }
966 } else {
967 record_parse_error(None);
968 Self::default()
969 };
970
971 if let Some(ref local) = local_content {
972 cfg.merge_local(local);
973 }
974
975 if let Ok(mut guard) = CACHE.lock() {
976 *guard = Some((cfg.clone(), global_hash, local_hash));
977 }
978
979 cfg
980 }
981
982 fn merge_local(&mut self, local_toml: &str) {
983 let local: Config = match toml::from_str(local_toml) {
984 Ok(c) => c,
985 Err(e) => {
986 tracing::warn!("local config parse error: {e}");
987 eprintln!(
988 "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n \
989 Local overrides skipped.\x1b[0m"
990 );
991 return;
992 }
993 };
994 if local.ultra_compact {
995 self.ultra_compact = true;
996 }
997 if local.tee_mode != TeeMode::default() {
998 self.tee_mode = local.tee_mode;
999 }
1000 if local.output_density != OutputDensity::default() {
1001 self.output_density = local.output_density;
1002 }
1003 if local.checkpoint_interval != 15 {
1004 self.checkpoint_interval = local.checkpoint_interval;
1005 }
1006 if !local.excluded_commands.is_empty() {
1007 self.excluded_commands.extend(local.excluded_commands);
1008 }
1009 if !local.passthrough_urls.is_empty() {
1010 self.passthrough_urls.extend(local.passthrough_urls);
1011 }
1012 if !local.custom_aliases.is_empty() {
1013 self.custom_aliases.extend(local.custom_aliases);
1014 }
1015 for fmt in local.preserve_compact_formats {
1018 if !self
1019 .preserve_compact_formats
1020 .iter()
1021 .any(|f| f.eq_ignore_ascii_case(&fmt))
1022 {
1023 self.preserve_compact_formats.push(fmt);
1024 }
1025 }
1026 if local.slow_command_threshold_ms != 5000 {
1027 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
1028 }
1029 if local.theme != "default" {
1030 self.theme = local.theme;
1031 }
1032 if !local.buddy_enabled {
1033 self.buddy_enabled = false;
1034 }
1035 if !local.enable_wakeup_ctx {
1036 self.enable_wakeup_ctx = false;
1037 }
1038 if !local.redirect_exclude.is_empty() {
1039 self.redirect_exclude.extend(local.redirect_exclude);
1040 }
1041 if !local.disabled_tools.is_empty() {
1042 self.disabled_tools.extend(local.disabled_tools);
1043 }
1044 if !local.extra_ignore_patterns.is_empty() {
1045 self.extra_ignore_patterns
1046 .extend(local.extra_ignore_patterns);
1047 }
1048 if local.rules_scope.is_some() {
1049 self.rules_scope = local.rules_scope;
1050 }
1051 if local.rules_injection.is_some() {
1052 self.rules_injection = local.rules_injection;
1053 }
1054 if local.permission_inheritance.is_some() {
1055 self.permission_inheritance = local.permission_inheritance;
1056 }
1057 if local.proxy.anthropic_upstream.is_some() {
1058 self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1059 }
1060 if local.proxy.openai_upstream.is_some() {
1061 self.proxy.openai_upstream = local.proxy.openai_upstream;
1062 }
1063 if local.proxy.gemini_upstream.is_some() {
1064 self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1065 }
1066 if !local.autonomy.enabled {
1067 self.autonomy.enabled = false;
1068 }
1069 if !local.autonomy.auto_preload {
1070 self.autonomy.auto_preload = false;
1071 }
1072 if !local.autonomy.auto_dedup {
1073 self.autonomy.auto_dedup = false;
1074 }
1075 if !local.autonomy.auto_related {
1076 self.autonomy.auto_related = false;
1077 }
1078 if !local.autonomy.auto_consolidate {
1079 self.autonomy.auto_consolidate = false;
1080 }
1081 if local.autonomy.silent_preload {
1082 self.autonomy.silent_preload = true;
1083 }
1084 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1085 self.autonomy.silent_preload = false;
1086 }
1087 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1088 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1089 }
1090 if local.autonomy.consolidate_every_calls
1091 != AutonomyConfig::default().consolidate_every_calls
1092 {
1093 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1094 }
1095 if local.autonomy.consolidate_cooldown_secs
1096 != AutonomyConfig::default().consolidate_cooldown_secs
1097 {
1098 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1099 }
1100 if !local.autonomy.cognition_loop_enabled {
1101 self.autonomy.cognition_loop_enabled = false;
1102 }
1103 if local.autonomy.cognition_loop_interval_secs
1104 != AutonomyConfig::default().cognition_loop_interval_secs
1105 {
1106 self.autonomy.cognition_loop_interval_secs =
1107 local.autonomy.cognition_loop_interval_secs;
1108 }
1109 if local.autonomy.cognition_loop_max_steps
1110 != AutonomyConfig::default().cognition_loop_max_steps
1111 {
1112 self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1113 }
1114 if local_toml.contains("compression_level") {
1115 self.compression_level = local.compression_level;
1116 }
1117 if local_toml.contains("terse_agent") {
1118 self.terse_agent = local.terse_agent;
1119 }
1120 if !local.archive.enabled {
1121 self.archive.enabled = false;
1122 }
1123 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1124 self.archive.threshold_chars = local.archive.threshold_chars;
1125 }
1126 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1127 self.archive.max_age_hours = local.archive.max_age_hours;
1128 }
1129 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1130 self.archive.max_disk_mb = local.archive.max_disk_mb;
1131 }
1132 if !local.archive.ephemeral {
1133 self.archive.ephemeral = false;
1134 }
1135 if local.archive.ephemeral_min_tokens != ArchiveConfig::default().ephemeral_min_tokens {
1136 self.archive.ephemeral_min_tokens = local.archive.ephemeral_min_tokens;
1137 }
1138 let mem_def = MemoryPolicy::default();
1139 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1140 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1141 }
1142 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1143 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1144 }
1145 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1146 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1147 }
1148 if local.memory.knowledge.contradiction_threshold
1149 != mem_def.knowledge.contradiction_threshold
1150 {
1151 self.memory.knowledge.contradiction_threshold =
1152 local.memory.knowledge.contradiction_threshold;
1153 }
1154
1155 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1156 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1157 }
1158 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1159 {
1160 self.memory.episodic.max_actions_per_episode =
1161 local.memory.episodic.max_actions_per_episode;
1162 }
1163 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1164 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1165 }
1166
1167 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1168 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1169 }
1170 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1171 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1172 }
1173 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1174 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1175 }
1176 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1177 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1178 }
1179
1180 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1181 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1182 }
1183 if local.memory.lifecycle.low_confidence_threshold
1184 != mem_def.lifecycle.low_confidence_threshold
1185 {
1186 self.memory.lifecycle.low_confidence_threshold =
1187 local.memory.lifecycle.low_confidence_threshold;
1188 }
1189 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1190 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1191 }
1192 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1193 self.memory.lifecycle.similarity_threshold =
1194 local.memory.lifecycle.similarity_threshold;
1195 }
1196
1197 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1198 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1199 }
1200 if !local.allow_paths.is_empty() {
1201 self.allow_paths.extend(local.allow_paths);
1202 }
1203 if !local.extra_roots.is_empty() {
1204 self.extra_roots.extend(local.extra_roots);
1205 }
1206 if local.minimal_overhead {
1207 self.minimal_overhead = true;
1208 }
1209 if local.shell_hook_disabled {
1210 self.shell_hook_disabled = true;
1211 }
1212 if local.shell_activation != ShellActivation::default() {
1213 self.shell_activation = local.shell_activation.clone();
1214 }
1215 if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1216 self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1217 }
1218 if local.memory_profile != MemoryProfile::default() {
1219 self.memory_profile = local.memory_profile;
1220 }
1221 if local.memory_cleanup != MemoryCleanup::default() {
1222 self.memory_cleanup = local.memory_cleanup;
1223 }
1224 if local.shell_allowlist != default_shell_allowlist() {
1232 self.shell_allowlist = local.shell_allowlist;
1233 }
1234 if !local.shell_allowlist_extra.is_empty() {
1235 self.shell_allowlist_extra
1236 .extend(local.shell_allowlist_extra);
1237 }
1238 if !local.default_tool_categories.is_empty() {
1239 self.default_tool_categories = local.default_tool_categories;
1240 }
1241 if local.tool_profile.is_some() {
1242 self.tool_profile = local.tool_profile;
1243 }
1244 if !local.tools_enabled.is_empty() {
1245 self.tools_enabled = local.tools_enabled;
1246 }
1247 if local.no_degrade {
1248 self.no_degrade = true;
1249 }
1250 if local.profile.is_some() {
1251 self.profile = local.profile;
1252 }
1253 if local.proxy_timeout_ms.is_some() {
1254 self.proxy_timeout_ms = local.proxy_timeout_ms;
1255 }
1256 }
1257
1258 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1264 let path = Self::path().ok_or_else(|| {
1265 super::error::LeanCtxError::Config("cannot determine home directory".into())
1266 })?;
1267 if let Some(parent) = path.parent() {
1268 std::fs::create_dir_all(parent)?;
1269 }
1270 let content = toml::to_string_pretty(self)
1271 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1272 let baseline = toml::from_str::<Self>("").unwrap_or_else(|_| Self::default());
1277 let defaults = toml::to_string_pretty(&baseline)
1278 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1279 crate::config_io::write_toml_preserving_minimal(&path, &content, &defaults)
1280 .map_err(super::error::LeanCtxError::Config)?;
1281 Ok(())
1282 }
1283
1284 pub fn show(&self) -> String {
1286 let global_path = Self::path().map_or_else(
1287 || "~/.lean-ctx/config.toml".to_string(),
1288 |p| p.to_string_lossy().to_string(),
1289 );
1290 let content = toml::to_string_pretty(self).unwrap_or_default();
1291 let mut out = format!("Global config: {global_path}\n\n{content}");
1292
1293 if let Some(root) = Self::find_project_root() {
1294 let local = Self::local_path(&root);
1295 if local.exists() {
1296 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1297 } else {
1298 out.push_str(&format!(
1299 "\n\nLocal config: not found (create {} to override per-project)\n",
1300 local.display()
1301 ));
1302 }
1303 }
1304 out
1305 }
1306}