1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use std::sync::Mutex;
5use std::time::SystemTime;
6
7use super::memory_policy::MemoryPolicy;
8
9mod defaults_allowlist;
10mod enums;
11mod memory;
12mod proxy;
13pub mod schema;
14mod sections;
15mod serde_defaults;
16pub mod setter;
17mod shell_activation;
18pub use sections::*;
19#[cfg(test)]
20mod tests;
21
22pub(crate) use defaults_allowlist::{cloud_infra_commands, default_shell_allowlist};
23pub use enums::{
24 CompressionLevel, OutputDensity, PermissionInheritance, ResponseVerbosity, RulesInjection,
25 RulesScope, TeeMode, TerseAgent,
26};
27pub use memory::{MemoryCleanup, MemoryGuardConfig, MemoryProfile, SavingsFooter};
28pub use proxy::{
29 is_local_proxy_url, normalize_url, normalize_url_opt, HistoryMode, ProxyConfig, ProxyProvider,
30};
31pub use shell_activation::ShellActivation;
32
33pub fn default_bm25_max_cache_mb() -> u64 {
35 serde_defaults::default_bm25_max_cache_mb()
36}
37
38pub const DEFAULT_BM25_PERSIST_MB: u64 = 512;
48
49const _: () = assert!(DEFAULT_BM25_PERSIST_MB >= 512);
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(default)]
56pub struct Config {
57 pub ultra_compact: bool,
58 #[serde(default, deserialize_with = "serde_defaults::deserialize_tee_mode")]
59 pub tee_mode: TeeMode,
60 #[serde(default)]
61 pub output_density: OutputDensity,
62 pub checkpoint_interval: u32,
63 pub excluded_commands: Vec<String>,
64 pub passthrough_urls: Vec<String>,
65 pub custom_aliases: Vec<AliasEntry>,
66 #[serde(default = "serde_defaults::default_preserve_compact_formats")]
72 pub preserve_compact_formats: Vec<String>,
73 pub slow_command_threshold_ms: u64,
76 #[serde(default = "serde_defaults::default_theme")]
77 pub theme: String,
78 #[serde(default)]
79 pub cloud: CloudConfig,
80 #[serde(default)]
81 pub gain: GainConfig,
82 #[serde(default)]
83 pub autonomy: AutonomyConfig,
84 #[serde(default)]
85 pub providers: ProvidersConfig,
86 #[serde(default)]
87 pub proxy: ProxyConfig,
88 #[serde(default)]
93 pub proxy_enabled: Option<bool>,
94 #[serde(default)]
95 pub proxy_port: Option<u16>,
96 #[serde(default)]
99 pub proxy_timeout_ms: Option<u64>,
100 #[serde(default = "serde_defaults::default_buddy_enabled")]
101 pub buddy_enabled: bool,
102 #[serde(default = "serde_defaults::default_true")]
103 pub enable_wakeup_ctx: bool,
104 #[serde(default)]
105 pub redirect_exclude: Vec<String>,
106 #[serde(default)]
110 pub disabled_tools: Vec<String>,
111 #[serde(default)]
117 pub default_tool_categories: Vec<String>,
118 #[serde(default)]
122 pub no_degrade: bool,
123 #[serde(default)]
126 pub profile: Option<String>,
127 #[serde(default)]
131 pub tool_profile: Option<String>,
132 #[serde(default)]
135 pub tools_enabled: Vec<String>,
136 #[serde(default)]
140 pub persona: Option<String>,
141 #[serde(default)]
142 pub loop_detection: LoopDetectionConfig,
143 #[serde(default)]
147 pub rules_scope: Option<String>,
148 #[serde(default)]
156 pub rules_injection: Option<String>,
157 #[serde(default)]
164 pub permission_inheritance: Option<String>,
165 #[serde(default)]
168 pub extra_ignore_patterns: Vec<String>,
169 #[serde(default)]
173 pub terse_agent: TerseAgent,
174 #[serde(default)]
178 pub compression_level: CompressionLevel,
179 #[serde(default)]
181 pub archive: ArchiveConfig,
182 #[serde(default)]
184 pub memory: MemoryPolicy,
185 #[serde(default)]
189 pub allow_paths: Vec<String>,
190 #[serde(default)]
195 pub allow_ide_config_dirs: bool,
196 #[serde(default)]
201 pub extra_roots: Vec<String>,
202 #[serde(default)]
205 pub content_defined_chunking: bool,
206 #[serde(default)]
209 pub minimal_overhead: bool,
210 #[serde(default)]
215 pub symbol_map_auto: bool,
216 #[serde(default)]
220 pub team_url: Option<String>,
221 #[serde(default)]
225 pub team_token: Option<String>,
226 #[serde(default)]
231 pub team_auto_push: bool,
232 #[serde(default)]
234 pub journal_enabled: bool,
235 #[serde(default)]
237 pub auto_capture: bool,
238 #[serde(default)]
240 pub search: crate::core::hybrid_search::HybridConfig,
241 #[serde(default)]
243 pub graph: GraphConfig,
244 #[serde(default)]
246 pub skillify: SkillifyConfig,
247 #[serde(default)]
249 pub summaries: SummariesConfig,
250 #[serde(default)]
252 pub llm: crate::core::llm_enhance::LlmConfig,
253 #[serde(default)]
255 pub embedding: EmbeddingConfig,
256 #[serde(default)]
259 pub shell_hook_disabled: bool,
260 #[serde(default)]
265 pub shadow_mode: bool,
266 #[serde(default)]
273 pub shell_activation: ShellActivation,
274 #[serde(default)]
277 pub update_check_disabled: bool,
278 #[serde(default)]
279 pub updates: UpdatesConfig,
280 #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
283 pub bm25_max_cache_mb: u64,
284 #[serde(default = "serde_defaults::default_graph_index_max_files")]
287 pub graph_index_max_files: u64,
288 #[serde(default)]
291 pub memory_profile: MemoryProfile,
292 #[serde(default)]
296 pub memory_cleanup: MemoryCleanup,
297 #[serde(default = "serde_defaults::default_max_ram_percent")]
300 pub max_ram_percent: u8,
301 #[serde(default)]
305 pub max_disk_mb: u64,
306 #[serde(default)]
309 pub max_staleness_days: u32,
310 #[serde(default)]
314 pub savings_footer: SavingsFooter,
315 #[serde(default)]
319 pub project_root: Option<String>,
320 #[serde(default)]
323 pub lsp: std::collections::HashMap<String, String>,
324 #[serde(default)]
328 pub ide_paths: HashMap<String, Vec<String>>,
329 #[serde(default)]
332 pub model_context_windows: HashMap<String, usize>,
333 #[serde(default)]
340 pub response_verbosity: ResponseVerbosity,
341 #[serde(default)]
346 pub bypass_hints: Option<String>,
347 #[serde(default)]
352 pub cache_policy: Option<String>,
353 #[serde(default)]
356 pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
357 #[serde(default)]
358 pub secret_detection: SecretDetectionConfig,
359 #[serde(default)]
362 pub sensitivity: crate::core::sensitivity::SensitivityConfig,
363 #[serde(default)]
367 pub gateway: crate::core::gateway::GatewayConfig,
368 #[serde(default)]
372 pub allow_auto_reroot: bool,
373 #[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> {
802 crate::core::data_dir::lean_ctx_data_dir()
803 .ok()
804 .map(|d| d.join("config.toml"))
805 }
806
807 pub fn local_path(project_root: &str) -> PathBuf {
809 PathBuf::from(project_root).join(".lean-ctx.toml")
810 }
811
812 fn find_project_root() -> Option<String> {
813 static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
814 ROOT_CACHE
815 .get_or_init(Self::find_project_root_inner)
816 .clone()
817 }
818
819 fn find_project_root_inner() -> Option<String> {
820 if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
821 if !env_root.is_empty() {
822 return Some(env_root);
823 }
824 }
825
826 let cwd = std::env::current_dir().ok();
827
828 if let Some(root) =
829 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
830 {
831 let root_path = std::path::Path::new(&root);
832 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
833 let has_marker = root_path.join(".git").exists()
834 || root_path.join("Cargo.toml").exists()
835 || root_path.join("package.json").exists()
836 || root_path.join("go.mod").exists()
837 || root_path.join("pyproject.toml").exists()
838 || root_path.join(".lean-ctx.toml").exists();
839
840 if cwd_is_under_root || has_marker {
841 return Some(root);
842 }
843 }
844
845 if let Some(ref cwd) = cwd {
846 let git_root = std::process::Command::new("git")
847 .args(["rev-parse", "--show-toplevel"])
848 .current_dir(cwd)
849 .stdout(std::process::Stdio::piped())
850 .stderr(std::process::Stdio::null())
851 .output()
852 .ok()
853 .and_then(|o| {
854 if o.status.success() {
855 String::from_utf8(o.stdout)
856 .ok()
857 .map(|s| s.trim().to_string())
858 } else {
859 None
860 }
861 });
862 if let Some(root) = git_root {
863 return Some(root);
864 }
865 if !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
866 return Some(cwd.to_string_lossy().to_string());
867 }
868 }
869 None
870 }
871
872 pub fn load() -> Self {
874 static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
875
876 let Some(path) = Self::path() else {
877 return Self::default();
878 };
879
880 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
881
882 let mtime = std::fs::metadata(&path)
883 .and_then(|m| m.modified())
884 .unwrap_or(SystemTime::UNIX_EPOCH);
885
886 let local_mtime = local_path
887 .as_ref()
888 .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
889
890 if let Ok(guard) = CACHE.lock() {
891 if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
892 if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
893 return cfg.clone();
894 }
895 }
896 }
897
898 let mut cfg: Config = if let Ok(content) = std::fs::read_to_string(&path) {
899 match toml::from_str(&content) {
900 Ok(c) => {
901 record_parse_error(None);
902 c
903 }
904 Err(e) => {
905 record_parse_error(Some(format!("{e}")));
906 tracing::warn!("config parse error in {}: {e}", path.display());
907 eprintln!(
908 "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n \
909 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
910 path.display()
911 );
912 Self::default()
913 }
914 }
915 } else {
916 record_parse_error(None);
917 Self::default()
918 };
919
920 if let Some(ref lp) = local_path {
921 if let Ok(local_content) = std::fs::read_to_string(lp) {
922 cfg.merge_local(&local_content);
923 }
924 }
925
926 if let Ok(mut guard) = CACHE.lock() {
927 *guard = Some((cfg.clone(), mtime, local_mtime));
928 }
929
930 cfg
931 }
932
933 fn merge_local(&mut self, local_toml: &str) {
934 let local: Config = match toml::from_str(local_toml) {
935 Ok(c) => c,
936 Err(e) => {
937 tracing::warn!("local config parse error: {e}");
938 eprintln!(
939 "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n \
940 Local overrides skipped.\x1b[0m"
941 );
942 return;
943 }
944 };
945 if local.ultra_compact {
946 self.ultra_compact = true;
947 }
948 if local.tee_mode != TeeMode::default() {
949 self.tee_mode = local.tee_mode;
950 }
951 if local.output_density != OutputDensity::default() {
952 self.output_density = local.output_density;
953 }
954 if local.checkpoint_interval != 15 {
955 self.checkpoint_interval = local.checkpoint_interval;
956 }
957 if !local.excluded_commands.is_empty() {
958 self.excluded_commands.extend(local.excluded_commands);
959 }
960 if !local.passthrough_urls.is_empty() {
961 self.passthrough_urls.extend(local.passthrough_urls);
962 }
963 if !local.custom_aliases.is_empty() {
964 self.custom_aliases.extend(local.custom_aliases);
965 }
966 for fmt in local.preserve_compact_formats {
969 if !self
970 .preserve_compact_formats
971 .iter()
972 .any(|f| f.eq_ignore_ascii_case(&fmt))
973 {
974 self.preserve_compact_formats.push(fmt);
975 }
976 }
977 if local.slow_command_threshold_ms != 5000 {
978 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
979 }
980 if local.theme != "default" {
981 self.theme = local.theme;
982 }
983 if !local.buddy_enabled {
984 self.buddy_enabled = false;
985 }
986 if !local.enable_wakeup_ctx {
987 self.enable_wakeup_ctx = false;
988 }
989 if !local.redirect_exclude.is_empty() {
990 self.redirect_exclude.extend(local.redirect_exclude);
991 }
992 if !local.disabled_tools.is_empty() {
993 self.disabled_tools.extend(local.disabled_tools);
994 }
995 if !local.extra_ignore_patterns.is_empty() {
996 self.extra_ignore_patterns
997 .extend(local.extra_ignore_patterns);
998 }
999 if local.rules_scope.is_some() {
1000 self.rules_scope = local.rules_scope;
1001 }
1002 if local.rules_injection.is_some() {
1003 self.rules_injection = local.rules_injection;
1004 }
1005 if local.permission_inheritance.is_some() {
1006 self.permission_inheritance = local.permission_inheritance;
1007 }
1008 if local.proxy.anthropic_upstream.is_some() {
1009 self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1010 }
1011 if local.proxy.openai_upstream.is_some() {
1012 self.proxy.openai_upstream = local.proxy.openai_upstream;
1013 }
1014 if local.proxy.gemini_upstream.is_some() {
1015 self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1016 }
1017 if !local.autonomy.enabled {
1018 self.autonomy.enabled = false;
1019 }
1020 if !local.autonomy.auto_preload {
1021 self.autonomy.auto_preload = false;
1022 }
1023 if !local.autonomy.auto_dedup {
1024 self.autonomy.auto_dedup = false;
1025 }
1026 if !local.autonomy.auto_related {
1027 self.autonomy.auto_related = false;
1028 }
1029 if !local.autonomy.auto_consolidate {
1030 self.autonomy.auto_consolidate = false;
1031 }
1032 if local.autonomy.silent_preload {
1033 self.autonomy.silent_preload = true;
1034 }
1035 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1036 self.autonomy.silent_preload = false;
1037 }
1038 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1039 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1040 }
1041 if local.autonomy.consolidate_every_calls
1042 != AutonomyConfig::default().consolidate_every_calls
1043 {
1044 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1045 }
1046 if local.autonomy.consolidate_cooldown_secs
1047 != AutonomyConfig::default().consolidate_cooldown_secs
1048 {
1049 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1050 }
1051 if !local.autonomy.cognition_loop_enabled {
1052 self.autonomy.cognition_loop_enabled = false;
1053 }
1054 if local.autonomy.cognition_loop_interval_secs
1055 != AutonomyConfig::default().cognition_loop_interval_secs
1056 {
1057 self.autonomy.cognition_loop_interval_secs =
1058 local.autonomy.cognition_loop_interval_secs;
1059 }
1060 if local.autonomy.cognition_loop_max_steps
1061 != AutonomyConfig::default().cognition_loop_max_steps
1062 {
1063 self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1064 }
1065 if local_toml.contains("compression_level") {
1066 self.compression_level = local.compression_level;
1067 }
1068 if local_toml.contains("terse_agent") {
1069 self.terse_agent = local.terse_agent;
1070 }
1071 if !local.archive.enabled {
1072 self.archive.enabled = false;
1073 }
1074 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1075 self.archive.threshold_chars = local.archive.threshold_chars;
1076 }
1077 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1078 self.archive.max_age_hours = local.archive.max_age_hours;
1079 }
1080 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1081 self.archive.max_disk_mb = local.archive.max_disk_mb;
1082 }
1083 if !local.archive.ephemeral {
1084 self.archive.ephemeral = false;
1085 }
1086 if local.archive.ephemeral_min_tokens != ArchiveConfig::default().ephemeral_min_tokens {
1087 self.archive.ephemeral_min_tokens = local.archive.ephemeral_min_tokens;
1088 }
1089 let mem_def = MemoryPolicy::default();
1090 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1091 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1092 }
1093 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1094 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1095 }
1096 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1097 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1098 }
1099 if local.memory.knowledge.contradiction_threshold
1100 != mem_def.knowledge.contradiction_threshold
1101 {
1102 self.memory.knowledge.contradiction_threshold =
1103 local.memory.knowledge.contradiction_threshold;
1104 }
1105
1106 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1107 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1108 }
1109 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1110 {
1111 self.memory.episodic.max_actions_per_episode =
1112 local.memory.episodic.max_actions_per_episode;
1113 }
1114 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1115 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1116 }
1117
1118 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1119 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1120 }
1121 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1122 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1123 }
1124 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1125 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1126 }
1127 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1128 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1129 }
1130
1131 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1132 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1133 }
1134 if local.memory.lifecycle.low_confidence_threshold
1135 != mem_def.lifecycle.low_confidence_threshold
1136 {
1137 self.memory.lifecycle.low_confidence_threshold =
1138 local.memory.lifecycle.low_confidence_threshold;
1139 }
1140 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1141 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1142 }
1143 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1144 self.memory.lifecycle.similarity_threshold =
1145 local.memory.lifecycle.similarity_threshold;
1146 }
1147
1148 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1149 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1150 }
1151 if !local.allow_paths.is_empty() {
1152 self.allow_paths.extend(local.allow_paths);
1153 }
1154 if !local.extra_roots.is_empty() {
1155 self.extra_roots.extend(local.extra_roots);
1156 }
1157 if local.minimal_overhead {
1158 self.minimal_overhead = true;
1159 }
1160 if local.shell_hook_disabled {
1161 self.shell_hook_disabled = true;
1162 }
1163 if local.shell_activation != ShellActivation::default() {
1164 self.shell_activation = local.shell_activation.clone();
1165 }
1166 if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1167 self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1168 }
1169 if local.memory_profile != MemoryProfile::default() {
1170 self.memory_profile = local.memory_profile;
1171 }
1172 if local.memory_cleanup != MemoryCleanup::default() {
1173 self.memory_cleanup = local.memory_cleanup;
1174 }
1175 if !local.shell_allowlist.is_empty() {
1176 self.shell_allowlist = local.shell_allowlist;
1177 }
1178 if !local.shell_allowlist_extra.is_empty() {
1179 self.shell_allowlist_extra
1180 .extend(local.shell_allowlist_extra);
1181 }
1182 if !local.default_tool_categories.is_empty() {
1183 self.default_tool_categories = local.default_tool_categories;
1184 }
1185 if local.tool_profile.is_some() {
1186 self.tool_profile = local.tool_profile;
1187 }
1188 if !local.tools_enabled.is_empty() {
1189 self.tools_enabled = local.tools_enabled;
1190 }
1191 if local.no_degrade {
1192 self.no_degrade = true;
1193 }
1194 if local.profile.is_some() {
1195 self.profile = local.profile;
1196 }
1197 if local.proxy_timeout_ms.is_some() {
1198 self.proxy_timeout_ms = local.proxy_timeout_ms;
1199 }
1200 }
1201
1202 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1208 let path = Self::path().ok_or_else(|| {
1209 super::error::LeanCtxError::Config("cannot determine home directory".into())
1210 })?;
1211 if let Some(parent) = path.parent() {
1212 std::fs::create_dir_all(parent)?;
1213 }
1214 let content = toml::to_string_pretty(self)
1215 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1216 let baseline = toml::from_str::<Self>("").unwrap_or_else(|_| Self::default());
1221 let defaults = toml::to_string_pretty(&baseline)
1222 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1223 crate::config_io::write_toml_preserving_minimal(&path, &content, &defaults)
1224 .map_err(super::error::LeanCtxError::Config)?;
1225 Ok(())
1226 }
1227
1228 pub fn show(&self) -> String {
1230 let global_path = Self::path().map_or_else(
1231 || "~/.lean-ctx/config.toml".to_string(),
1232 |p| p.to_string_lossy().to_string(),
1233 );
1234 let content = toml::to_string_pretty(self).unwrap_or_default();
1235 let mut out = format!("Global config: {global_path}\n\n{content}");
1236
1237 if let Some(root) = Self::find_project_root() {
1238 let local = Self::local_path(&root);
1239 if local.exists() {
1240 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1241 } else {
1242 out.push_str(&format!(
1243 "\n\nLocal config: not found (create {} to override per-project)\n",
1244 local.display()
1245 ));
1246 }
1247 }
1248 out
1249 }
1250}