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::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::{is_local_proxy_url, normalize_url, normalize_url_opt, ProxyConfig, ProxyProvider};
29pub use shell_activation::ShellActivation;
30
31pub fn default_bm25_max_cache_mb() -> u64 {
33 serde_defaults::default_bm25_max_cache_mb()
34}
35
36pub const DEFAULT_BM25_PERSIST_MB: u64 = 512;
46
47const _: () = assert!(DEFAULT_BM25_PERSIST_MB >= 512);
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53#[serde(default)]
54pub struct Config {
55 pub ultra_compact: bool,
56 #[serde(default, deserialize_with = "serde_defaults::deserialize_tee_mode")]
57 pub tee_mode: TeeMode,
58 #[serde(default)]
59 pub output_density: OutputDensity,
60 pub checkpoint_interval: u32,
61 pub excluded_commands: Vec<String>,
62 pub passthrough_urls: Vec<String>,
63 pub custom_aliases: Vec<AliasEntry>,
64 #[serde(default = "serde_defaults::default_preserve_compact_formats")]
70 pub preserve_compact_formats: Vec<String>,
71 pub slow_command_threshold_ms: u64,
74 #[serde(default = "serde_defaults::default_theme")]
75 pub theme: String,
76 #[serde(default)]
77 pub cloud: CloudConfig,
78 #[serde(default)]
79 pub gain: GainConfig,
80 #[serde(default)]
81 pub autonomy: AutonomyConfig,
82 #[serde(default)]
83 pub providers: ProvidersConfig,
84 #[serde(default)]
85 pub proxy: ProxyConfig,
86 #[serde(default)]
91 pub proxy_enabled: Option<bool>,
92 #[serde(default)]
93 pub proxy_port: Option<u16>,
94 #[serde(default)]
97 pub proxy_timeout_ms: Option<u64>,
98 #[serde(default = "serde_defaults::default_buddy_enabled")]
99 pub buddy_enabled: bool,
100 #[serde(default = "serde_defaults::default_true")]
101 pub enable_wakeup_ctx: bool,
102 #[serde(default)]
103 pub redirect_exclude: Vec<String>,
104 #[serde(default)]
108 pub disabled_tools: Vec<String>,
109 #[serde(default)]
115 pub default_tool_categories: Vec<String>,
116 #[serde(default)]
120 pub no_degrade: bool,
121 #[serde(default)]
124 pub profile: Option<String>,
125 #[serde(default)]
129 pub tool_profile: Option<String>,
130 #[serde(default)]
133 pub tools_enabled: Vec<String>,
134 #[serde(default)]
135 pub loop_detection: LoopDetectionConfig,
136 #[serde(default)]
140 pub rules_scope: Option<String>,
141 #[serde(default)]
147 pub rules_injection: Option<String>,
148 #[serde(default)]
155 pub permission_inheritance: Option<String>,
156 #[serde(default)]
159 pub extra_ignore_patterns: Vec<String>,
160 #[serde(default)]
164 pub terse_agent: TerseAgent,
165 #[serde(default)]
169 pub compression_level: CompressionLevel,
170 #[serde(default)]
172 pub archive: ArchiveConfig,
173 #[serde(default)]
175 pub memory: MemoryPolicy,
176 #[serde(default)]
180 pub allow_paths: Vec<String>,
181 #[serde(default)]
186 pub extra_roots: Vec<String>,
187 #[serde(default)]
190 pub content_defined_chunking: bool,
191 #[serde(default)]
194 pub minimal_overhead: bool,
195 #[serde(default)]
200 pub symbol_map_auto: bool,
201 #[serde(default)]
205 pub team_url: Option<String>,
206 #[serde(default)]
208 pub journal_enabled: bool,
209 #[serde(default)]
211 pub auto_capture: bool,
212 #[serde(default)]
214 pub search: crate::core::hybrid_search::HybridConfig,
215 #[serde(default)]
217 pub llm: crate::core::llm_enhance::LlmConfig,
218 #[serde(default)]
220 pub embedding: EmbeddingConfig,
221 #[serde(default)]
224 pub shell_hook_disabled: bool,
225 #[serde(default)]
230 pub shadow_mode: bool,
231 #[serde(default)]
238 pub shell_activation: ShellActivation,
239 #[serde(default)]
242 pub update_check_disabled: bool,
243 #[serde(default)]
244 pub updates: UpdatesConfig,
245 #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
248 pub bm25_max_cache_mb: u64,
249 #[serde(default = "serde_defaults::default_graph_index_max_files")]
252 pub graph_index_max_files: u64,
253 #[serde(default)]
256 pub memory_profile: MemoryProfile,
257 #[serde(default)]
261 pub memory_cleanup: MemoryCleanup,
262 #[serde(default = "serde_defaults::default_max_ram_percent")]
265 pub max_ram_percent: u8,
266 #[serde(default)]
270 pub max_disk_mb: u64,
271 #[serde(default)]
274 pub max_staleness_days: u32,
275 #[serde(default)]
279 pub savings_footer: SavingsFooter,
280 #[serde(default)]
284 pub project_root: Option<String>,
285 #[serde(default)]
288 pub lsp: std::collections::HashMap<String, String>,
289 #[serde(default)]
293 pub ide_paths: HashMap<String, Vec<String>>,
294 #[serde(default)]
297 pub model_context_windows: HashMap<String, usize>,
298 #[serde(default)]
305 pub response_verbosity: ResponseVerbosity,
306 #[serde(default)]
311 pub bypass_hints: Option<String>,
312 #[serde(default)]
317 pub cache_policy: Option<String>,
318 #[serde(default)]
321 pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
322 #[serde(default)]
323 pub secret_detection: SecretDetectionConfig,
324 #[serde(default)]
328 pub allow_auto_reroot: bool,
329 #[serde(default)]
332 pub path_jail: Option<bool>,
333 #[serde(default)]
337 pub sandbox_level: u8,
338 #[serde(default)]
342 pub reference_results: bool,
343 #[serde(default)]
346 pub agent_token_budget: usize,
347 #[serde(default = "default_shell_allowlist")]
352 pub shell_allowlist: Vec<String>,
353
354 #[serde(default)]
359 pub shell_allowlist_extra: Vec<String>,
360
361 #[serde(default)]
365 pub shell_strict_mode: bool,
366 #[serde(default)]
368 pub setup: SetupConfig,
369}
370
371impl Default for Config {
372 fn default() -> Self {
373 Self {
374 ultra_compact: false,
375 tee_mode: TeeMode::default(),
376 output_density: OutputDensity::default(),
377 checkpoint_interval: 15,
378 excluded_commands: Vec::new(),
379 passthrough_urls: Vec::new(),
380 custom_aliases: Vec::new(),
381 preserve_compact_formats: serde_defaults::default_preserve_compact_formats(),
382 slow_command_threshold_ms: 5000,
383 theme: serde_defaults::default_theme(),
384 cloud: CloudConfig::default(),
385 gain: GainConfig::default(),
386 autonomy: AutonomyConfig::default(),
387 providers: ProvidersConfig::default(),
388 proxy: ProxyConfig::default(),
389 proxy_enabled: None,
390 proxy_port: None,
391 proxy_timeout_ms: None,
392 buddy_enabled: serde_defaults::default_buddy_enabled(),
393 enable_wakeup_ctx: true,
394 redirect_exclude: Vec::new(),
395 disabled_tools: Vec::new(),
396 default_tool_categories: Vec::new(),
397 no_degrade: false,
398 profile: None,
399 tool_profile: None,
400 tools_enabled: Vec::new(),
401 loop_detection: LoopDetectionConfig::default(),
402 rules_scope: None,
403 rules_injection: None,
404 permission_inheritance: None,
405 extra_ignore_patterns: Vec::new(),
406 terse_agent: TerseAgent::default(),
407 compression_level: CompressionLevel::default(),
408 archive: ArchiveConfig::default(),
409 memory: MemoryPolicy::default(),
410 allow_paths: Vec::new(),
411 extra_roots: Vec::new(),
412 content_defined_chunking: false,
413 minimal_overhead: true,
414 symbol_map_auto: false,
415 team_url: None,
416 journal_enabled: true,
417 auto_capture: true,
418 search: crate::core::hybrid_search::HybridConfig::default(),
419 llm: crate::core::llm_enhance::LlmConfig::default(),
420 embedding: EmbeddingConfig::default(),
421 shell_hook_disabled: false,
422 shadow_mode: false,
423 shell_activation: ShellActivation::default(),
424 update_check_disabled: false,
425 updates: UpdatesConfig::default(),
426 graph_index_max_files: serde_defaults::default_graph_index_max_files(),
427 bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
428 memory_profile: MemoryProfile::default(),
429 memory_cleanup: MemoryCleanup::default(),
430 max_ram_percent: serde_defaults::default_max_ram_percent(),
431 max_disk_mb: 0,
432 max_staleness_days: 0,
433 savings_footer: SavingsFooter::default(),
434 project_root: None,
435 lsp: std::collections::HashMap::new(),
436 ide_paths: HashMap::new(),
437 model_context_windows: HashMap::new(),
438 response_verbosity: ResponseVerbosity::default(),
439 bypass_hints: None,
440 cache_policy: None,
441 boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
442 secret_detection: SecretDetectionConfig::default(),
443 allow_auto_reroot: false,
444 path_jail: None,
445 sandbox_level: 0,
446 reference_results: false,
447 agent_token_budget: 0,
448 shell_allowlist: default_shell_allowlist(),
449 shell_allowlist_extra: Vec::new(),
450 shell_strict_mode: false,
451 setup: SetupConfig::default(),
452 }
453 }
454}
455
456static LAST_PARSE_ERROR: Mutex<Option<String>> = Mutex::new(None);
462
463#[must_use]
466pub fn last_config_parse_error() -> Option<String> {
467 LAST_PARSE_ERROR.lock().ok().and_then(|g| g.clone())
468}
469
470fn record_parse_error(err: Option<String>) {
471 if let Ok(mut guard) = LAST_PARSE_ERROR.lock() {
472 *guard = err;
473 }
474}
475
476impl Config {
477 pub fn rules_scope_effective(&self) -> RulesScope {
479 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
480 .ok()
481 .or_else(|| self.rules_scope.clone())
482 .unwrap_or_default();
483 match raw.trim().to_lowercase().as_str() {
484 "global" => RulesScope::Global,
485 "project" => RulesScope::Project,
486 _ => RulesScope::Both,
487 }
488 }
489
490 pub fn rules_injection_effective(&self) -> RulesInjection {
493 let raw = std::env::var("LEAN_CTX_RULES_INJECTION")
494 .ok()
495 .or_else(|| self.rules_injection.clone())
496 .unwrap_or_default();
497 match raw.trim().to_lowercase().as_str() {
498 "dedicated" => RulesInjection::Dedicated,
499 _ => RulesInjection::Shared,
500 }
501 }
502
503 #[must_use]
507 pub fn permission_inheritance_effective(&self) -> PermissionInheritance {
508 let raw = std::env::var("LEAN_CTX_PERMISSION_INHERITANCE")
509 .ok()
510 .or_else(|| self.permission_inheritance.clone())
511 .unwrap_or_default();
512 match raw.trim().to_lowercase().as_str() {
513 "on" | "true" | "1" | "inherit" => PermissionInheritance::On,
514 _ => PermissionInheritance::Off,
515 }
516 }
517
518 #[must_use]
525 pub fn dedicated_session_context_active(&self) -> bool {
526 self.rules_injection_effective() == RulesInjection::Dedicated
527 && self.rules_scope_effective() != RulesScope::Project
528 }
529
530 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
531 val.split(',')
532 .map(|s| s.trim().to_string())
533 .filter(|s| !s.is_empty())
534 .collect()
535 }
536
537 pub fn disabled_tools_effective(&self) -> Vec<String> {
539 if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
540 Self::parse_disabled_tools_env(&val)
541 } else {
542 self.disabled_tools.clone()
543 }
544 }
545
546 pub fn minimal_overhead_effective(&self) -> bool {
548 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
549 }
550
551 pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
559 if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
560 match raw.trim().to_lowercase().as_str() {
561 "minimal" => return true,
562 "full" => return self.minimal_overhead_effective(),
563 _ => {}
564 }
565 }
566
567 if self.minimal_overhead_effective() {
568 return true;
569 }
570
571 let client_lower = client_name.trim().to_lowercase();
572 if !client_lower.is_empty() {
573 if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
574 for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
575 if !needle.is_empty() && client_lower.contains(&needle) {
576 return true;
577 }
578 }
579 } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
580 return true;
581 }
582 }
583
584 let model = std::env::var("LEAN_CTX_MODEL")
585 .or_else(|_| std::env::var("LCTX_MODEL"))
586 .unwrap_or_default();
587 let model = model.trim().to_lowercase();
588 if !model.is_empty() {
589 let m = model.replace(['_', ' '], "-");
590 if m.contains("minimax")
591 || m.contains("mini-max")
592 || m.contains("m2.7")
593 || m.contains("m2-7")
594 {
595 return true;
596 }
597 }
598
599 false
600 }
601
602 pub fn shell_hook_disabled_effective(&self) -> bool {
604 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
605 }
606
607 pub fn shell_activation_effective(&self) -> ShellActivation {
609 ShellActivation::effective(self)
610 }
611
612 pub fn update_check_disabled_effective(&self) -> bool {
614 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
615 }
616
617 pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
618 let mut policy = self.memory.clone();
619 policy.apply_env_overrides();
620
621 let budget = self.max_disk_mb_effective();
624 if budget > 0 {
625 let scale_factor = (budget as f64 / 500.0).clamp(0.5, 10.0);
626 let default_policy = MemoryPolicy::default();
627 if policy.knowledge.max_facts == default_policy.knowledge.max_facts {
628 policy.knowledge.max_facts = (200.0 * scale_factor) as usize;
629 }
630 if policy.knowledge.max_patterns == default_policy.knowledge.max_patterns {
631 policy.knowledge.max_patterns = (50.0 * scale_factor) as usize;
632 }
633 if policy.episodic.max_episodes == default_policy.episodic.max_episodes {
634 policy.episodic.max_episodes = (500.0 * scale_factor) as usize;
635 }
636 if policy.procedural.max_procedures == default_policy.procedural.max_procedures {
637 policy.procedural.max_procedures = (100.0 * scale_factor) as usize;
638 }
639 }
640
641 policy.validate()?;
642 Ok(policy)
643 }
644
645 pub fn default_tool_categories_effective(&self) -> Vec<String> {
648 if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
649 return val
650 .split(',')
651 .map(|s| s.trim().to_lowercase())
652 .filter(|s| !s.is_empty())
653 .collect();
654 }
655 if !self.default_tool_categories.is_empty() {
656 return self
657 .default_tool_categories
658 .iter()
659 .map(|s| s.to_lowercase())
660 .collect();
661 }
662 vec!["core".to_string(), "session".to_string()]
663 }
664
665 pub fn tool_profile_effective(&self) -> super::tool_profiles::ToolProfile {
668 super::tool_profiles::ToolProfile::from_config(self)
669 }
670
671 pub fn no_degrade_effective(&self) -> bool {
674 if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
675 return val == "1" || val.eq_ignore_ascii_case("true");
676 }
677 self.no_degrade
678 }
679
680 pub fn max_disk_mb_effective(&self) -> u64 {
682 std::env::var("LEAN_CTX_MAX_DISK_MB")
683 .ok()
684 .and_then(|v| v.parse().ok())
685 .unwrap_or(self.max_disk_mb)
686 }
687
688 pub fn max_staleness_days_effective(&self) -> u32 {
690 std::env::var("LEAN_CTX_MAX_STALENESS_DAYS")
691 .ok()
692 .and_then(|v| v.parse().ok())
693 .unwrap_or(self.max_staleness_days)
694 }
695
696 pub fn archive_max_disk_mb_effective(&self) -> u64 {
699 let budget = self.max_disk_mb_effective();
700 if budget > 0 && self.archive.max_disk_mb == ArchiveConfig::default().max_disk_mb {
701 budget * 25 / 100
702 } else {
703 self.archive.max_disk_mb
704 }
705 }
706
707 pub fn archive_max_age_hours_effective(&self) -> u64 {
710 let staleness = self.max_staleness_days_effective();
711 if staleness > 0 && self.archive.max_age_hours == ArchiveConfig::default().max_age_hours {
712 staleness as u64 * 24
713 } else {
714 self.archive.max_age_hours
715 }
716 }
717
718 pub fn bm25_max_cache_mb_effective(&self) -> u64 {
726 if self.bm25_max_cache_mb != serde_defaults::default_bm25_max_cache_mb() {
728 return self.bm25_max_cache_mb;
729 }
730 let budget = self.max_disk_mb_effective();
732 if budget > 0 {
733 return budget * 10 / 100;
734 }
735 DEFAULT_BM25_PERSIST_MB
737 }
738}
739
740impl Config {
741 pub fn path() -> Option<PathBuf> {
743 crate::core::data_dir::lean_ctx_data_dir()
744 .ok()
745 .map(|d| d.join("config.toml"))
746 }
747
748 pub fn local_path(project_root: &str) -> PathBuf {
750 PathBuf::from(project_root).join(".lean-ctx.toml")
751 }
752
753 fn find_project_root() -> Option<String> {
754 static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
755 ROOT_CACHE
756 .get_or_init(Self::find_project_root_inner)
757 .clone()
758 }
759
760 fn find_project_root_inner() -> Option<String> {
761 if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
762 if !env_root.is_empty() {
763 return Some(env_root);
764 }
765 }
766
767 let cwd = std::env::current_dir().ok();
768
769 if let Some(root) =
770 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
771 {
772 let root_path = std::path::Path::new(&root);
773 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
774 let has_marker = root_path.join(".git").exists()
775 || root_path.join("Cargo.toml").exists()
776 || root_path.join("package.json").exists()
777 || root_path.join("go.mod").exists()
778 || root_path.join("pyproject.toml").exists()
779 || root_path.join(".lean-ctx.toml").exists();
780
781 if cwd_is_under_root || has_marker {
782 return Some(root);
783 }
784 }
785
786 if let Some(ref cwd) = cwd {
787 let git_root = std::process::Command::new("git")
788 .args(["rev-parse", "--show-toplevel"])
789 .current_dir(cwd)
790 .stdout(std::process::Stdio::piped())
791 .stderr(std::process::Stdio::null())
792 .output()
793 .ok()
794 .and_then(|o| {
795 if o.status.success() {
796 String::from_utf8(o.stdout)
797 .ok()
798 .map(|s| s.trim().to_string())
799 } else {
800 None
801 }
802 });
803 if let Some(root) = git_root {
804 return Some(root);
805 }
806 if !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
807 return Some(cwd.to_string_lossy().to_string());
808 }
809 }
810 None
811 }
812
813 pub fn load() -> Self {
815 static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
816
817 let Some(path) = Self::path() else {
818 return Self::default();
819 };
820
821 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
822
823 let mtime = std::fs::metadata(&path)
824 .and_then(|m| m.modified())
825 .unwrap_or(SystemTime::UNIX_EPOCH);
826
827 let local_mtime = local_path
828 .as_ref()
829 .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
830
831 if let Ok(guard) = CACHE.lock() {
832 if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
833 if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
834 return cfg.clone();
835 }
836 }
837 }
838
839 let mut cfg: Config = if let Ok(content) = std::fs::read_to_string(&path) {
840 match toml::from_str(&content) {
841 Ok(c) => {
842 record_parse_error(None);
843 c
844 }
845 Err(e) => {
846 record_parse_error(Some(format!("{e}")));
847 tracing::warn!("config parse error in {}: {e}", path.display());
848 eprintln!(
849 "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n \
850 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
851 path.display()
852 );
853 Self::default()
854 }
855 }
856 } else {
857 record_parse_error(None);
858 Self::default()
859 };
860
861 if let Some(ref lp) = local_path {
862 if let Ok(local_content) = std::fs::read_to_string(lp) {
863 cfg.merge_local(&local_content);
864 }
865 }
866
867 if let Ok(mut guard) = CACHE.lock() {
868 *guard = Some((cfg.clone(), mtime, local_mtime));
869 }
870
871 cfg
872 }
873
874 fn merge_local(&mut self, local_toml: &str) {
875 let local: Config = match toml::from_str(local_toml) {
876 Ok(c) => c,
877 Err(e) => {
878 tracing::warn!("local config parse error: {e}");
879 eprintln!(
880 "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n \
881 Local overrides skipped.\x1b[0m"
882 );
883 return;
884 }
885 };
886 if local.ultra_compact {
887 self.ultra_compact = true;
888 }
889 if local.tee_mode != TeeMode::default() {
890 self.tee_mode = local.tee_mode;
891 }
892 if local.output_density != OutputDensity::default() {
893 self.output_density = local.output_density;
894 }
895 if local.checkpoint_interval != 15 {
896 self.checkpoint_interval = local.checkpoint_interval;
897 }
898 if !local.excluded_commands.is_empty() {
899 self.excluded_commands.extend(local.excluded_commands);
900 }
901 if !local.passthrough_urls.is_empty() {
902 self.passthrough_urls.extend(local.passthrough_urls);
903 }
904 if !local.custom_aliases.is_empty() {
905 self.custom_aliases.extend(local.custom_aliases);
906 }
907 for fmt in local.preserve_compact_formats {
910 if !self
911 .preserve_compact_formats
912 .iter()
913 .any(|f| f.eq_ignore_ascii_case(&fmt))
914 {
915 self.preserve_compact_formats.push(fmt);
916 }
917 }
918 if local.slow_command_threshold_ms != 5000 {
919 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
920 }
921 if local.theme != "default" {
922 self.theme = local.theme;
923 }
924 if !local.buddy_enabled {
925 self.buddy_enabled = false;
926 }
927 if !local.enable_wakeup_ctx {
928 self.enable_wakeup_ctx = false;
929 }
930 if !local.redirect_exclude.is_empty() {
931 self.redirect_exclude.extend(local.redirect_exclude);
932 }
933 if !local.disabled_tools.is_empty() {
934 self.disabled_tools.extend(local.disabled_tools);
935 }
936 if !local.extra_ignore_patterns.is_empty() {
937 self.extra_ignore_patterns
938 .extend(local.extra_ignore_patterns);
939 }
940 if local.rules_scope.is_some() {
941 self.rules_scope = local.rules_scope;
942 }
943 if local.rules_injection.is_some() {
944 self.rules_injection = local.rules_injection;
945 }
946 if local.permission_inheritance.is_some() {
947 self.permission_inheritance = local.permission_inheritance;
948 }
949 if local.proxy.anthropic_upstream.is_some() {
950 self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
951 }
952 if local.proxy.openai_upstream.is_some() {
953 self.proxy.openai_upstream = local.proxy.openai_upstream;
954 }
955 if local.proxy.gemini_upstream.is_some() {
956 self.proxy.gemini_upstream = local.proxy.gemini_upstream;
957 }
958 if !local.autonomy.enabled {
959 self.autonomy.enabled = false;
960 }
961 if !local.autonomy.auto_preload {
962 self.autonomy.auto_preload = false;
963 }
964 if !local.autonomy.auto_dedup {
965 self.autonomy.auto_dedup = false;
966 }
967 if !local.autonomy.auto_related {
968 self.autonomy.auto_related = false;
969 }
970 if !local.autonomy.auto_consolidate {
971 self.autonomy.auto_consolidate = false;
972 }
973 if local.autonomy.silent_preload {
974 self.autonomy.silent_preload = true;
975 }
976 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
977 self.autonomy.silent_preload = false;
978 }
979 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
980 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
981 }
982 if local.autonomy.consolidate_every_calls
983 != AutonomyConfig::default().consolidate_every_calls
984 {
985 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
986 }
987 if local.autonomy.consolidate_cooldown_secs
988 != AutonomyConfig::default().consolidate_cooldown_secs
989 {
990 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
991 }
992 if !local.autonomy.cognition_loop_enabled {
993 self.autonomy.cognition_loop_enabled = false;
994 }
995 if local.autonomy.cognition_loop_interval_secs
996 != AutonomyConfig::default().cognition_loop_interval_secs
997 {
998 self.autonomy.cognition_loop_interval_secs =
999 local.autonomy.cognition_loop_interval_secs;
1000 }
1001 if local.autonomy.cognition_loop_max_steps
1002 != AutonomyConfig::default().cognition_loop_max_steps
1003 {
1004 self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1005 }
1006 if local_toml.contains("compression_level") {
1007 self.compression_level = local.compression_level;
1008 }
1009 if local_toml.contains("terse_agent") {
1010 self.terse_agent = local.terse_agent;
1011 }
1012 if !local.archive.enabled {
1013 self.archive.enabled = false;
1014 }
1015 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1016 self.archive.threshold_chars = local.archive.threshold_chars;
1017 }
1018 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1019 self.archive.max_age_hours = local.archive.max_age_hours;
1020 }
1021 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1022 self.archive.max_disk_mb = local.archive.max_disk_mb;
1023 }
1024 if !local.archive.ephemeral {
1025 self.archive.ephemeral = false;
1026 }
1027 let mem_def = MemoryPolicy::default();
1028 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1029 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1030 }
1031 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1032 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1033 }
1034 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1035 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1036 }
1037 if local.memory.knowledge.contradiction_threshold
1038 != mem_def.knowledge.contradiction_threshold
1039 {
1040 self.memory.knowledge.contradiction_threshold =
1041 local.memory.knowledge.contradiction_threshold;
1042 }
1043
1044 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1045 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1046 }
1047 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1048 {
1049 self.memory.episodic.max_actions_per_episode =
1050 local.memory.episodic.max_actions_per_episode;
1051 }
1052 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1053 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1054 }
1055
1056 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1057 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1058 }
1059 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1060 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1061 }
1062 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1063 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1064 }
1065 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1066 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1067 }
1068
1069 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1070 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1071 }
1072 if local.memory.lifecycle.low_confidence_threshold
1073 != mem_def.lifecycle.low_confidence_threshold
1074 {
1075 self.memory.lifecycle.low_confidence_threshold =
1076 local.memory.lifecycle.low_confidence_threshold;
1077 }
1078 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1079 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1080 }
1081 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1082 self.memory.lifecycle.similarity_threshold =
1083 local.memory.lifecycle.similarity_threshold;
1084 }
1085
1086 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1087 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1088 }
1089 if !local.allow_paths.is_empty() {
1090 self.allow_paths.extend(local.allow_paths);
1091 }
1092 if !local.extra_roots.is_empty() {
1093 self.extra_roots.extend(local.extra_roots);
1094 }
1095 if local.minimal_overhead {
1096 self.minimal_overhead = true;
1097 }
1098 if local.shell_hook_disabled {
1099 self.shell_hook_disabled = true;
1100 }
1101 if local.shell_activation != ShellActivation::default() {
1102 self.shell_activation = local.shell_activation.clone();
1103 }
1104 if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1105 self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1106 }
1107 if local.memory_profile != MemoryProfile::default() {
1108 self.memory_profile = local.memory_profile;
1109 }
1110 if local.memory_cleanup != MemoryCleanup::default() {
1111 self.memory_cleanup = local.memory_cleanup;
1112 }
1113 if !local.shell_allowlist.is_empty() {
1114 self.shell_allowlist = local.shell_allowlist;
1115 }
1116 if !local.shell_allowlist_extra.is_empty() {
1117 self.shell_allowlist_extra
1118 .extend(local.shell_allowlist_extra);
1119 }
1120 if !local.default_tool_categories.is_empty() {
1121 self.default_tool_categories = local.default_tool_categories;
1122 }
1123 if local.tool_profile.is_some() {
1124 self.tool_profile = local.tool_profile;
1125 }
1126 if !local.tools_enabled.is_empty() {
1127 self.tools_enabled = local.tools_enabled;
1128 }
1129 if local.no_degrade {
1130 self.no_degrade = true;
1131 }
1132 if local.profile.is_some() {
1133 self.profile = local.profile;
1134 }
1135 if local.proxy_timeout_ms.is_some() {
1136 self.proxy_timeout_ms = local.proxy_timeout_ms;
1137 }
1138 }
1139
1140 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1146 let path = Self::path().ok_or_else(|| {
1147 super::error::LeanCtxError::Config("cannot determine home directory".into())
1148 })?;
1149 if let Some(parent) = path.parent() {
1150 std::fs::create_dir_all(parent)?;
1151 }
1152 let content = toml::to_string_pretty(self)
1153 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1154 let baseline = toml::from_str::<Self>("").unwrap_or_else(|_| Self::default());
1159 let defaults = toml::to_string_pretty(&baseline)
1160 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1161 crate::config_io::write_toml_preserving_minimal(&path, &content, &defaults)
1162 .map_err(super::error::LeanCtxError::Config)?;
1163 Ok(())
1164 }
1165
1166 pub fn show(&self) -> String {
1168 let global_path = Self::path().map_or_else(
1169 || "~/.lean-ctx/config.toml".to_string(),
1170 |p| p.to_string_lossy().to_string(),
1171 );
1172 let content = toml::to_string_pretty(self).unwrap_or_default();
1173 let mut out = format!("Global config: {global_path}\n\n{content}");
1174
1175 if let Some(root) = Self::find_project_root() {
1176 let local = Self::local_path(&root);
1177 if local.exists() {
1178 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1179 } else {
1180 out.push_str(&format!(
1181 "\n\nLocal config: not found (create {} to override per-project)\n",
1182 local.display()
1183 ));
1184 }
1185 }
1186 out
1187 }
1188}