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, ResponseVerbosity, RulesScope, TeeMode, TerseAgent,
25};
26pub use memory::{MemoryCleanup, MemoryGuardConfig, MemoryProfile, SavingsFooter};
27pub use proxy::{is_local_proxy_url, normalize_url, normalize_url_opt, ProxyConfig, ProxyProvider};
28pub use shell_activation::ShellActivation;
29
30pub fn default_bm25_max_cache_mb() -> u64 {
32 serde_defaults::default_bm25_max_cache_mb()
33}
34
35pub const DEFAULT_BM25_PERSIST_MB: u64 = 512;
45
46const _: () = assert!(DEFAULT_BM25_PERSIST_MB >= 512);
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(default)]
53pub struct Config {
54 pub ultra_compact: bool,
55 #[serde(default, deserialize_with = "serde_defaults::deserialize_tee_mode")]
56 pub tee_mode: TeeMode,
57 #[serde(default)]
58 pub output_density: OutputDensity,
59 pub checkpoint_interval: u32,
60 pub excluded_commands: Vec<String>,
61 pub passthrough_urls: Vec<String>,
62 pub custom_aliases: Vec<AliasEntry>,
63 #[serde(default = "serde_defaults::default_preserve_compact_formats")]
69 pub preserve_compact_formats: Vec<String>,
70 pub slow_command_threshold_ms: u64,
73 #[serde(default = "serde_defaults::default_theme")]
74 pub theme: String,
75 #[serde(default)]
76 pub cloud: CloudConfig,
77 #[serde(default)]
78 pub gain: GainConfig,
79 #[serde(default)]
80 pub autonomy: AutonomyConfig,
81 #[serde(default)]
82 pub providers: ProvidersConfig,
83 #[serde(default)]
84 pub proxy: ProxyConfig,
85 #[serde(default)]
90 pub proxy_enabled: Option<bool>,
91 #[serde(default)]
92 pub proxy_port: Option<u16>,
93 #[serde(default)]
96 pub proxy_timeout_ms: Option<u64>,
97 #[serde(default = "serde_defaults::default_buddy_enabled")]
98 pub buddy_enabled: bool,
99 #[serde(default = "serde_defaults::default_true")]
100 pub enable_wakeup_ctx: bool,
101 #[serde(default)]
102 pub redirect_exclude: Vec<String>,
103 #[serde(default)]
107 pub disabled_tools: Vec<String>,
108 #[serde(default)]
114 pub default_tool_categories: Vec<String>,
115 #[serde(default)]
119 pub no_degrade: bool,
120 #[serde(default)]
123 pub profile: Option<String>,
124 #[serde(default)]
128 pub tool_profile: Option<String>,
129 #[serde(default)]
132 pub tools_enabled: Vec<String>,
133 #[serde(default)]
134 pub loop_detection: LoopDetectionConfig,
135 #[serde(default)]
139 pub rules_scope: Option<String>,
140 #[serde(default)]
143 pub extra_ignore_patterns: Vec<String>,
144 #[serde(default)]
148 pub terse_agent: TerseAgent,
149 #[serde(default)]
153 pub compression_level: CompressionLevel,
154 #[serde(default)]
156 pub archive: ArchiveConfig,
157 #[serde(default)]
159 pub memory: MemoryPolicy,
160 #[serde(default)]
164 pub allow_paths: Vec<String>,
165 #[serde(default)]
170 pub extra_roots: Vec<String>,
171 #[serde(default)]
174 pub content_defined_chunking: bool,
175 #[serde(default)]
178 pub minimal_overhead: bool,
179 #[serde(default = "serde_defaults::default_true")]
181 pub symbol_map_auto: bool,
182 #[serde(default)]
186 pub team_url: Option<String>,
187 #[serde(default)]
189 pub journal_enabled: bool,
190 #[serde(default)]
192 pub auto_capture: bool,
193 #[serde(default)]
195 pub search: crate::core::hybrid_search::HybridConfig,
196 #[serde(default)]
198 pub llm: crate::core::llm_enhance::LlmConfig,
199 #[serde(default)]
201 pub embedding: EmbeddingConfig,
202 #[serde(default)]
205 pub shell_hook_disabled: bool,
206 #[serde(default)]
211 pub shadow_mode: bool,
212 #[serde(default)]
219 pub shell_activation: ShellActivation,
220 #[serde(default)]
223 pub update_check_disabled: bool,
224 #[serde(default)]
225 pub updates: UpdatesConfig,
226 #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
229 pub bm25_max_cache_mb: u64,
230 #[serde(default = "serde_defaults::default_graph_index_max_files")]
233 pub graph_index_max_files: u64,
234 #[serde(default)]
237 pub memory_profile: MemoryProfile,
238 #[serde(default)]
242 pub memory_cleanup: MemoryCleanup,
243 #[serde(default = "serde_defaults::default_max_ram_percent")]
246 pub max_ram_percent: u8,
247 #[serde(default)]
251 pub max_disk_mb: u64,
252 #[serde(default)]
255 pub max_staleness_days: u32,
256 #[serde(default)]
260 pub savings_footer: SavingsFooter,
261 #[serde(default)]
265 pub project_root: Option<String>,
266 #[serde(default)]
269 pub lsp: std::collections::HashMap<String, String>,
270 #[serde(default)]
274 pub ide_paths: HashMap<String, Vec<String>>,
275 #[serde(default)]
278 pub model_context_windows: HashMap<String, usize>,
279 #[serde(default)]
286 pub response_verbosity: ResponseVerbosity,
287 #[serde(default)]
292 pub bypass_hints: Option<String>,
293 #[serde(default)]
298 pub cache_policy: Option<String>,
299 #[serde(default)]
302 pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
303 #[serde(default)]
304 pub secret_detection: SecretDetectionConfig,
305 #[serde(default)]
309 pub allow_auto_reroot: bool,
310 #[serde(default)]
313 pub path_jail: Option<bool>,
314 #[serde(default)]
318 pub sandbox_level: u8,
319 #[serde(default)]
323 pub reference_results: bool,
324 #[serde(default)]
327 pub agent_token_budget: usize,
328 #[serde(default = "default_shell_allowlist")]
333 pub shell_allowlist: Vec<String>,
334
335 #[serde(default)]
340 pub shell_allowlist_extra: Vec<String>,
341
342 #[serde(default)]
346 pub shell_strict_mode: bool,
347 #[serde(default)]
349 pub setup: SetupConfig,
350}
351
352impl Default for Config {
353 fn default() -> Self {
354 Self {
355 ultra_compact: false,
356 tee_mode: TeeMode::default(),
357 output_density: OutputDensity::default(),
358 checkpoint_interval: 15,
359 excluded_commands: Vec::new(),
360 passthrough_urls: Vec::new(),
361 custom_aliases: Vec::new(),
362 preserve_compact_formats: serde_defaults::default_preserve_compact_formats(),
363 slow_command_threshold_ms: 5000,
364 theme: serde_defaults::default_theme(),
365 cloud: CloudConfig::default(),
366 gain: GainConfig::default(),
367 autonomy: AutonomyConfig::default(),
368 providers: ProvidersConfig::default(),
369 proxy: ProxyConfig::default(),
370 proxy_enabled: None,
371 proxy_port: None,
372 proxy_timeout_ms: None,
373 buddy_enabled: serde_defaults::default_buddy_enabled(),
374 enable_wakeup_ctx: true,
375 redirect_exclude: Vec::new(),
376 disabled_tools: Vec::new(),
377 default_tool_categories: Vec::new(),
378 no_degrade: false,
379 profile: None,
380 tool_profile: None,
381 tools_enabled: Vec::new(),
382 loop_detection: LoopDetectionConfig::default(),
383 rules_scope: None,
384 extra_ignore_patterns: Vec::new(),
385 terse_agent: TerseAgent::default(),
386 compression_level: CompressionLevel::default(),
387 archive: ArchiveConfig::default(),
388 memory: MemoryPolicy::default(),
389 allow_paths: Vec::new(),
390 extra_roots: Vec::new(),
391 content_defined_chunking: false,
392 minimal_overhead: true,
393 symbol_map_auto: true,
394 team_url: None,
395 journal_enabled: true,
396 auto_capture: true,
397 search: crate::core::hybrid_search::HybridConfig::default(),
398 llm: crate::core::llm_enhance::LlmConfig::default(),
399 embedding: EmbeddingConfig::default(),
400 shell_hook_disabled: false,
401 shadow_mode: false,
402 shell_activation: ShellActivation::default(),
403 update_check_disabled: false,
404 updates: UpdatesConfig::default(),
405 graph_index_max_files: serde_defaults::default_graph_index_max_files(),
406 bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
407 memory_profile: MemoryProfile::default(),
408 memory_cleanup: MemoryCleanup::default(),
409 max_ram_percent: serde_defaults::default_max_ram_percent(),
410 max_disk_mb: 0,
411 max_staleness_days: 0,
412 savings_footer: SavingsFooter::default(),
413 project_root: None,
414 lsp: std::collections::HashMap::new(),
415 ide_paths: HashMap::new(),
416 model_context_windows: HashMap::new(),
417 response_verbosity: ResponseVerbosity::default(),
418 bypass_hints: None,
419 cache_policy: None,
420 boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
421 secret_detection: SecretDetectionConfig::default(),
422 allow_auto_reroot: false,
423 path_jail: None,
424 sandbox_level: 0,
425 reference_results: false,
426 agent_token_budget: 0,
427 shell_allowlist: default_shell_allowlist(),
428 shell_allowlist_extra: Vec::new(),
429 shell_strict_mode: false,
430 setup: SetupConfig::default(),
431 }
432 }
433}
434
435static LAST_PARSE_ERROR: Mutex<Option<String>> = Mutex::new(None);
441
442#[must_use]
445pub fn last_config_parse_error() -> Option<String> {
446 LAST_PARSE_ERROR.lock().ok().and_then(|g| g.clone())
447}
448
449fn record_parse_error(err: Option<String>) {
450 if let Ok(mut guard) = LAST_PARSE_ERROR.lock() {
451 *guard = err;
452 }
453}
454
455impl Config {
456 pub fn rules_scope_effective(&self) -> RulesScope {
458 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
459 .ok()
460 .or_else(|| self.rules_scope.clone())
461 .unwrap_or_default();
462 match raw.trim().to_lowercase().as_str() {
463 "global" => RulesScope::Global,
464 "project" => RulesScope::Project,
465 _ => RulesScope::Both,
466 }
467 }
468
469 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
470 val.split(',')
471 .map(|s| s.trim().to_string())
472 .filter(|s| !s.is_empty())
473 .collect()
474 }
475
476 pub fn disabled_tools_effective(&self) -> Vec<String> {
478 if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
479 Self::parse_disabled_tools_env(&val)
480 } else {
481 self.disabled_tools.clone()
482 }
483 }
484
485 pub fn minimal_overhead_effective(&self) -> bool {
487 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
488 }
489
490 pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
498 if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
499 match raw.trim().to_lowercase().as_str() {
500 "minimal" => return true,
501 "full" => return self.minimal_overhead_effective(),
502 _ => {}
503 }
504 }
505
506 if self.minimal_overhead_effective() {
507 return true;
508 }
509
510 let client_lower = client_name.trim().to_lowercase();
511 if !client_lower.is_empty() {
512 if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
513 for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
514 if !needle.is_empty() && client_lower.contains(&needle) {
515 return true;
516 }
517 }
518 } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
519 return true;
520 }
521 }
522
523 let model = std::env::var("LEAN_CTX_MODEL")
524 .or_else(|_| std::env::var("LCTX_MODEL"))
525 .unwrap_or_default();
526 let model = model.trim().to_lowercase();
527 if !model.is_empty() {
528 let m = model.replace(['_', ' '], "-");
529 if m.contains("minimax")
530 || m.contains("mini-max")
531 || m.contains("m2.7")
532 || m.contains("m2-7")
533 {
534 return true;
535 }
536 }
537
538 false
539 }
540
541 pub fn shell_hook_disabled_effective(&self) -> bool {
543 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
544 }
545
546 pub fn shell_activation_effective(&self) -> ShellActivation {
548 ShellActivation::effective(self)
549 }
550
551 pub fn update_check_disabled_effective(&self) -> bool {
553 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
554 }
555
556 pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
557 let mut policy = self.memory.clone();
558 policy.apply_env_overrides();
559
560 let budget = self.max_disk_mb_effective();
563 if budget > 0 {
564 let scale_factor = (budget as f64 / 500.0).clamp(0.5, 10.0);
565 let default_policy = MemoryPolicy::default();
566 if policy.knowledge.max_facts == default_policy.knowledge.max_facts {
567 policy.knowledge.max_facts = (200.0 * scale_factor) as usize;
568 }
569 if policy.knowledge.max_patterns == default_policy.knowledge.max_patterns {
570 policy.knowledge.max_patterns = (50.0 * scale_factor) as usize;
571 }
572 if policy.episodic.max_episodes == default_policy.episodic.max_episodes {
573 policy.episodic.max_episodes = (500.0 * scale_factor) as usize;
574 }
575 if policy.procedural.max_procedures == default_policy.procedural.max_procedures {
576 policy.procedural.max_procedures = (100.0 * scale_factor) as usize;
577 }
578 }
579
580 policy.validate()?;
581 Ok(policy)
582 }
583
584 pub fn default_tool_categories_effective(&self) -> Vec<String> {
587 if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
588 return val
589 .split(',')
590 .map(|s| s.trim().to_lowercase())
591 .filter(|s| !s.is_empty())
592 .collect();
593 }
594 if !self.default_tool_categories.is_empty() {
595 return self
596 .default_tool_categories
597 .iter()
598 .map(|s| s.to_lowercase())
599 .collect();
600 }
601 vec!["core".to_string(), "session".to_string()]
602 }
603
604 pub fn tool_profile_effective(&self) -> super::tool_profiles::ToolProfile {
607 super::tool_profiles::ToolProfile::from_config(self)
608 }
609
610 pub fn no_degrade_effective(&self) -> bool {
613 if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
614 return val == "1" || val.eq_ignore_ascii_case("true");
615 }
616 self.no_degrade
617 }
618
619 pub fn max_disk_mb_effective(&self) -> u64 {
621 std::env::var("LEAN_CTX_MAX_DISK_MB")
622 .ok()
623 .and_then(|v| v.parse().ok())
624 .unwrap_or(self.max_disk_mb)
625 }
626
627 pub fn max_staleness_days_effective(&self) -> u32 {
629 std::env::var("LEAN_CTX_MAX_STALENESS_DAYS")
630 .ok()
631 .and_then(|v| v.parse().ok())
632 .unwrap_or(self.max_staleness_days)
633 }
634
635 pub fn archive_max_disk_mb_effective(&self) -> u64 {
638 let budget = self.max_disk_mb_effective();
639 if budget > 0 && self.archive.max_disk_mb == ArchiveConfig::default().max_disk_mb {
640 budget * 25 / 100
641 } else {
642 self.archive.max_disk_mb
643 }
644 }
645
646 pub fn archive_max_age_hours_effective(&self) -> u64 {
649 let staleness = self.max_staleness_days_effective();
650 if staleness > 0 && self.archive.max_age_hours == ArchiveConfig::default().max_age_hours {
651 staleness as u64 * 24
652 } else {
653 self.archive.max_age_hours
654 }
655 }
656
657 pub fn bm25_max_cache_mb_effective(&self) -> u64 {
665 if self.bm25_max_cache_mb != serde_defaults::default_bm25_max_cache_mb() {
667 return self.bm25_max_cache_mb;
668 }
669 let budget = self.max_disk_mb_effective();
671 if budget > 0 {
672 return budget * 10 / 100;
673 }
674 DEFAULT_BM25_PERSIST_MB
676 }
677}
678
679impl Config {
680 pub fn path() -> Option<PathBuf> {
682 crate::core::data_dir::lean_ctx_data_dir()
683 .ok()
684 .map(|d| d.join("config.toml"))
685 }
686
687 pub fn local_path(project_root: &str) -> PathBuf {
689 PathBuf::from(project_root).join(".lean-ctx.toml")
690 }
691
692 fn find_project_root() -> Option<String> {
693 static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
694 ROOT_CACHE
695 .get_or_init(Self::find_project_root_inner)
696 .clone()
697 }
698
699 fn find_project_root_inner() -> Option<String> {
700 if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
701 if !env_root.is_empty() {
702 return Some(env_root);
703 }
704 }
705
706 let cwd = std::env::current_dir().ok();
707
708 if let Some(root) =
709 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
710 {
711 let root_path = std::path::Path::new(&root);
712 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
713 let has_marker = root_path.join(".git").exists()
714 || root_path.join("Cargo.toml").exists()
715 || root_path.join("package.json").exists()
716 || root_path.join("go.mod").exists()
717 || root_path.join("pyproject.toml").exists()
718 || root_path.join(".lean-ctx.toml").exists();
719
720 if cwd_is_under_root || has_marker {
721 return Some(root);
722 }
723 }
724
725 if let Some(ref cwd) = cwd {
726 let git_root = std::process::Command::new("git")
727 .args(["rev-parse", "--show-toplevel"])
728 .current_dir(cwd)
729 .stdout(std::process::Stdio::piped())
730 .stderr(std::process::Stdio::null())
731 .output()
732 .ok()
733 .and_then(|o| {
734 if o.status.success() {
735 String::from_utf8(o.stdout)
736 .ok()
737 .map(|s| s.trim().to_string())
738 } else {
739 None
740 }
741 });
742 if let Some(root) = git_root {
743 return Some(root);
744 }
745 if !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
746 return Some(cwd.to_string_lossy().to_string());
747 }
748 }
749 None
750 }
751
752 pub fn load() -> Self {
754 static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
755
756 let Some(path) = Self::path() else {
757 return Self::default();
758 };
759
760 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
761
762 let mtime = std::fs::metadata(&path)
763 .and_then(|m| m.modified())
764 .unwrap_or(SystemTime::UNIX_EPOCH);
765
766 let local_mtime = local_path
767 .as_ref()
768 .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
769
770 if let Ok(guard) = CACHE.lock() {
771 if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
772 if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
773 return cfg.clone();
774 }
775 }
776 }
777
778 let mut cfg: Config = if let Ok(content) = std::fs::read_to_string(&path) {
779 match toml::from_str(&content) {
780 Ok(c) => {
781 record_parse_error(None);
782 c
783 }
784 Err(e) => {
785 record_parse_error(Some(format!("{e}")));
786 tracing::warn!("config parse error in {}: {e}", path.display());
787 eprintln!(
788 "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n \
789 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
790 path.display()
791 );
792 Self::default()
793 }
794 }
795 } else {
796 record_parse_error(None);
797 Self::default()
798 };
799
800 if let Some(ref lp) = local_path {
801 if let Ok(local_content) = std::fs::read_to_string(lp) {
802 cfg.merge_local(&local_content);
803 }
804 }
805
806 if let Ok(mut guard) = CACHE.lock() {
807 *guard = Some((cfg.clone(), mtime, local_mtime));
808 }
809
810 cfg
811 }
812
813 fn merge_local(&mut self, local_toml: &str) {
814 let local: Config = match toml::from_str(local_toml) {
815 Ok(c) => c,
816 Err(e) => {
817 tracing::warn!("local config parse error: {e}");
818 eprintln!(
819 "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n \
820 Local overrides skipped.\x1b[0m"
821 );
822 return;
823 }
824 };
825 if local.ultra_compact {
826 self.ultra_compact = true;
827 }
828 if local.tee_mode != TeeMode::default() {
829 self.tee_mode = local.tee_mode;
830 }
831 if local.output_density != OutputDensity::default() {
832 self.output_density = local.output_density;
833 }
834 if local.checkpoint_interval != 15 {
835 self.checkpoint_interval = local.checkpoint_interval;
836 }
837 if !local.excluded_commands.is_empty() {
838 self.excluded_commands.extend(local.excluded_commands);
839 }
840 if !local.passthrough_urls.is_empty() {
841 self.passthrough_urls.extend(local.passthrough_urls);
842 }
843 if !local.custom_aliases.is_empty() {
844 self.custom_aliases.extend(local.custom_aliases);
845 }
846 for fmt in local.preserve_compact_formats {
849 if !self
850 .preserve_compact_formats
851 .iter()
852 .any(|f| f.eq_ignore_ascii_case(&fmt))
853 {
854 self.preserve_compact_formats.push(fmt);
855 }
856 }
857 if local.slow_command_threshold_ms != 5000 {
858 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
859 }
860 if local.theme != "default" {
861 self.theme = local.theme;
862 }
863 if !local.buddy_enabled {
864 self.buddy_enabled = false;
865 }
866 if !local.enable_wakeup_ctx {
867 self.enable_wakeup_ctx = false;
868 }
869 if !local.redirect_exclude.is_empty() {
870 self.redirect_exclude.extend(local.redirect_exclude);
871 }
872 if !local.disabled_tools.is_empty() {
873 self.disabled_tools.extend(local.disabled_tools);
874 }
875 if !local.extra_ignore_patterns.is_empty() {
876 self.extra_ignore_patterns
877 .extend(local.extra_ignore_patterns);
878 }
879 if local.rules_scope.is_some() {
880 self.rules_scope = local.rules_scope;
881 }
882 if local.proxy.anthropic_upstream.is_some() {
883 self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
884 }
885 if local.proxy.openai_upstream.is_some() {
886 self.proxy.openai_upstream = local.proxy.openai_upstream;
887 }
888 if local.proxy.gemini_upstream.is_some() {
889 self.proxy.gemini_upstream = local.proxy.gemini_upstream;
890 }
891 if !local.autonomy.enabled {
892 self.autonomy.enabled = false;
893 }
894 if !local.autonomy.auto_preload {
895 self.autonomy.auto_preload = false;
896 }
897 if !local.autonomy.auto_dedup {
898 self.autonomy.auto_dedup = false;
899 }
900 if !local.autonomy.auto_related {
901 self.autonomy.auto_related = false;
902 }
903 if !local.autonomy.auto_consolidate {
904 self.autonomy.auto_consolidate = false;
905 }
906 if local.autonomy.silent_preload {
907 self.autonomy.silent_preload = true;
908 }
909 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
910 self.autonomy.silent_preload = false;
911 }
912 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
913 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
914 }
915 if local.autonomy.consolidate_every_calls
916 != AutonomyConfig::default().consolidate_every_calls
917 {
918 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
919 }
920 if local.autonomy.consolidate_cooldown_secs
921 != AutonomyConfig::default().consolidate_cooldown_secs
922 {
923 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
924 }
925 if !local.autonomy.cognition_loop_enabled {
926 self.autonomy.cognition_loop_enabled = false;
927 }
928 if local.autonomy.cognition_loop_interval_secs
929 != AutonomyConfig::default().cognition_loop_interval_secs
930 {
931 self.autonomy.cognition_loop_interval_secs =
932 local.autonomy.cognition_loop_interval_secs;
933 }
934 if local.autonomy.cognition_loop_max_steps
935 != AutonomyConfig::default().cognition_loop_max_steps
936 {
937 self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
938 }
939 if local_toml.contains("compression_level") {
940 self.compression_level = local.compression_level;
941 }
942 if local_toml.contains("terse_agent") {
943 self.terse_agent = local.terse_agent;
944 }
945 if !local.archive.enabled {
946 self.archive.enabled = false;
947 }
948 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
949 self.archive.threshold_chars = local.archive.threshold_chars;
950 }
951 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
952 self.archive.max_age_hours = local.archive.max_age_hours;
953 }
954 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
955 self.archive.max_disk_mb = local.archive.max_disk_mb;
956 }
957 if !local.archive.ephemeral {
958 self.archive.ephemeral = false;
959 }
960 let mem_def = MemoryPolicy::default();
961 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
962 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
963 }
964 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
965 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
966 }
967 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
968 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
969 }
970 if local.memory.knowledge.contradiction_threshold
971 != mem_def.knowledge.contradiction_threshold
972 {
973 self.memory.knowledge.contradiction_threshold =
974 local.memory.knowledge.contradiction_threshold;
975 }
976
977 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
978 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
979 }
980 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
981 {
982 self.memory.episodic.max_actions_per_episode =
983 local.memory.episodic.max_actions_per_episode;
984 }
985 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
986 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
987 }
988
989 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
990 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
991 }
992 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
993 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
994 }
995 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
996 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
997 }
998 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
999 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1000 }
1001
1002 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1003 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1004 }
1005 if local.memory.lifecycle.low_confidence_threshold
1006 != mem_def.lifecycle.low_confidence_threshold
1007 {
1008 self.memory.lifecycle.low_confidence_threshold =
1009 local.memory.lifecycle.low_confidence_threshold;
1010 }
1011 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1012 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1013 }
1014 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1015 self.memory.lifecycle.similarity_threshold =
1016 local.memory.lifecycle.similarity_threshold;
1017 }
1018
1019 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1020 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1021 }
1022 if !local.allow_paths.is_empty() {
1023 self.allow_paths.extend(local.allow_paths);
1024 }
1025 if !local.extra_roots.is_empty() {
1026 self.extra_roots.extend(local.extra_roots);
1027 }
1028 if local.minimal_overhead {
1029 self.minimal_overhead = true;
1030 }
1031 if local.shell_hook_disabled {
1032 self.shell_hook_disabled = true;
1033 }
1034 if local.shell_activation != ShellActivation::default() {
1035 self.shell_activation = local.shell_activation.clone();
1036 }
1037 if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1038 self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1039 }
1040 if local.memory_profile != MemoryProfile::default() {
1041 self.memory_profile = local.memory_profile;
1042 }
1043 if local.memory_cleanup != MemoryCleanup::default() {
1044 self.memory_cleanup = local.memory_cleanup;
1045 }
1046 if !local.shell_allowlist.is_empty() {
1047 self.shell_allowlist = local.shell_allowlist;
1048 }
1049 if !local.shell_allowlist_extra.is_empty() {
1050 self.shell_allowlist_extra
1051 .extend(local.shell_allowlist_extra);
1052 }
1053 if !local.default_tool_categories.is_empty() {
1054 self.default_tool_categories = local.default_tool_categories;
1055 }
1056 if local.tool_profile.is_some() {
1057 self.tool_profile = local.tool_profile;
1058 }
1059 if !local.tools_enabled.is_empty() {
1060 self.tools_enabled = local.tools_enabled;
1061 }
1062 if local.no_degrade {
1063 self.no_degrade = true;
1064 }
1065 if local.profile.is_some() {
1066 self.profile = local.profile;
1067 }
1068 if local.proxy_timeout_ms.is_some() {
1069 self.proxy_timeout_ms = local.proxy_timeout_ms;
1070 }
1071 }
1072
1073 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1079 let path = Self::path().ok_or_else(|| {
1080 super::error::LeanCtxError::Config("cannot determine home directory".into())
1081 })?;
1082 if let Some(parent) = path.parent() {
1083 std::fs::create_dir_all(parent)?;
1084 }
1085 let content = toml::to_string_pretty(self)
1086 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1087 let baseline = toml::from_str::<Self>("").unwrap_or_else(|_| Self::default());
1092 let defaults = toml::to_string_pretty(&baseline)
1093 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1094 crate::config_io::write_toml_preserving_minimal(&path, &content, &defaults)
1095 .map_err(super::error::LeanCtxError::Config)?;
1096 Ok(())
1097 }
1098
1099 pub fn show(&self) -> String {
1101 let global_path = Self::path().map_or_else(
1102 || "~/.lean-ctx/config.toml".to_string(),
1103 |p| p.to_string_lossy().to_string(),
1104 );
1105 let content = toml::to_string_pretty(self).unwrap_or_default();
1106 let mut out = format!("Global config: {global_path}\n\n{content}");
1107
1108 if let Some(root) = Self::find_project_root() {
1109 let local = Self::local_path(&root);
1110 if local.exists() {
1111 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1112 } else {
1113 out.push_str(&format!(
1114 "\n\nLocal config: not found (create {} to override per-project)\n",
1115 local.display()
1116 ));
1117 }
1118 }
1119 out
1120 }
1121}