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)]
184 pub symbol_map_auto: bool,
185 #[serde(default)]
189 pub team_url: Option<String>,
190 #[serde(default)]
192 pub journal_enabled: bool,
193 #[serde(default)]
195 pub auto_capture: bool,
196 #[serde(default)]
198 pub search: crate::core::hybrid_search::HybridConfig,
199 #[serde(default)]
201 pub llm: crate::core::llm_enhance::LlmConfig,
202 #[serde(default)]
204 pub embedding: EmbeddingConfig,
205 #[serde(default)]
208 pub shell_hook_disabled: bool,
209 #[serde(default)]
214 pub shadow_mode: bool,
215 #[serde(default)]
222 pub shell_activation: ShellActivation,
223 #[serde(default)]
226 pub update_check_disabled: bool,
227 #[serde(default)]
228 pub updates: UpdatesConfig,
229 #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
232 pub bm25_max_cache_mb: u64,
233 #[serde(default = "serde_defaults::default_graph_index_max_files")]
236 pub graph_index_max_files: u64,
237 #[serde(default)]
240 pub memory_profile: MemoryProfile,
241 #[serde(default)]
245 pub memory_cleanup: MemoryCleanup,
246 #[serde(default = "serde_defaults::default_max_ram_percent")]
249 pub max_ram_percent: u8,
250 #[serde(default)]
254 pub max_disk_mb: u64,
255 #[serde(default)]
258 pub max_staleness_days: u32,
259 #[serde(default)]
263 pub savings_footer: SavingsFooter,
264 #[serde(default)]
268 pub project_root: Option<String>,
269 #[serde(default)]
272 pub lsp: std::collections::HashMap<String, String>,
273 #[serde(default)]
277 pub ide_paths: HashMap<String, Vec<String>>,
278 #[serde(default)]
281 pub model_context_windows: HashMap<String, usize>,
282 #[serde(default)]
289 pub response_verbosity: ResponseVerbosity,
290 #[serde(default)]
295 pub bypass_hints: Option<String>,
296 #[serde(default)]
301 pub cache_policy: Option<String>,
302 #[serde(default)]
305 pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
306 #[serde(default)]
307 pub secret_detection: SecretDetectionConfig,
308 #[serde(default)]
312 pub allow_auto_reroot: bool,
313 #[serde(default)]
316 pub path_jail: Option<bool>,
317 #[serde(default)]
321 pub sandbox_level: u8,
322 #[serde(default)]
326 pub reference_results: bool,
327 #[serde(default)]
330 pub agent_token_budget: usize,
331 #[serde(default = "default_shell_allowlist")]
336 pub shell_allowlist: Vec<String>,
337
338 #[serde(default)]
343 pub shell_allowlist_extra: Vec<String>,
344
345 #[serde(default)]
349 pub shell_strict_mode: bool,
350 #[serde(default)]
352 pub setup: SetupConfig,
353}
354
355impl Default for Config {
356 fn default() -> Self {
357 Self {
358 ultra_compact: false,
359 tee_mode: TeeMode::default(),
360 output_density: OutputDensity::default(),
361 checkpoint_interval: 15,
362 excluded_commands: Vec::new(),
363 passthrough_urls: Vec::new(),
364 custom_aliases: Vec::new(),
365 preserve_compact_formats: serde_defaults::default_preserve_compact_formats(),
366 slow_command_threshold_ms: 5000,
367 theme: serde_defaults::default_theme(),
368 cloud: CloudConfig::default(),
369 gain: GainConfig::default(),
370 autonomy: AutonomyConfig::default(),
371 providers: ProvidersConfig::default(),
372 proxy: ProxyConfig::default(),
373 proxy_enabled: None,
374 proxy_port: None,
375 proxy_timeout_ms: None,
376 buddy_enabled: serde_defaults::default_buddy_enabled(),
377 enable_wakeup_ctx: true,
378 redirect_exclude: Vec::new(),
379 disabled_tools: Vec::new(),
380 default_tool_categories: Vec::new(),
381 no_degrade: false,
382 profile: None,
383 tool_profile: None,
384 tools_enabled: Vec::new(),
385 loop_detection: LoopDetectionConfig::default(),
386 rules_scope: None,
387 extra_ignore_patterns: Vec::new(),
388 terse_agent: TerseAgent::default(),
389 compression_level: CompressionLevel::default(),
390 archive: ArchiveConfig::default(),
391 memory: MemoryPolicy::default(),
392 allow_paths: Vec::new(),
393 extra_roots: Vec::new(),
394 content_defined_chunking: false,
395 minimal_overhead: true,
396 symbol_map_auto: false,
397 team_url: None,
398 journal_enabled: true,
399 auto_capture: true,
400 search: crate::core::hybrid_search::HybridConfig::default(),
401 llm: crate::core::llm_enhance::LlmConfig::default(),
402 embedding: EmbeddingConfig::default(),
403 shell_hook_disabled: false,
404 shadow_mode: false,
405 shell_activation: ShellActivation::default(),
406 update_check_disabled: false,
407 updates: UpdatesConfig::default(),
408 graph_index_max_files: serde_defaults::default_graph_index_max_files(),
409 bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
410 memory_profile: MemoryProfile::default(),
411 memory_cleanup: MemoryCleanup::default(),
412 max_ram_percent: serde_defaults::default_max_ram_percent(),
413 max_disk_mb: 0,
414 max_staleness_days: 0,
415 savings_footer: SavingsFooter::default(),
416 project_root: None,
417 lsp: std::collections::HashMap::new(),
418 ide_paths: HashMap::new(),
419 model_context_windows: HashMap::new(),
420 response_verbosity: ResponseVerbosity::default(),
421 bypass_hints: None,
422 cache_policy: None,
423 boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
424 secret_detection: SecretDetectionConfig::default(),
425 allow_auto_reroot: false,
426 path_jail: None,
427 sandbox_level: 0,
428 reference_results: false,
429 agent_token_budget: 0,
430 shell_allowlist: default_shell_allowlist(),
431 shell_allowlist_extra: Vec::new(),
432 shell_strict_mode: false,
433 setup: SetupConfig::default(),
434 }
435 }
436}
437
438static LAST_PARSE_ERROR: Mutex<Option<String>> = Mutex::new(None);
444
445#[must_use]
448pub fn last_config_parse_error() -> Option<String> {
449 LAST_PARSE_ERROR.lock().ok().and_then(|g| g.clone())
450}
451
452fn record_parse_error(err: Option<String>) {
453 if let Ok(mut guard) = LAST_PARSE_ERROR.lock() {
454 *guard = err;
455 }
456}
457
458impl Config {
459 pub fn rules_scope_effective(&self) -> RulesScope {
461 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
462 .ok()
463 .or_else(|| self.rules_scope.clone())
464 .unwrap_or_default();
465 match raw.trim().to_lowercase().as_str() {
466 "global" => RulesScope::Global,
467 "project" => RulesScope::Project,
468 _ => RulesScope::Both,
469 }
470 }
471
472 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
473 val.split(',')
474 .map(|s| s.trim().to_string())
475 .filter(|s| !s.is_empty())
476 .collect()
477 }
478
479 pub fn disabled_tools_effective(&self) -> Vec<String> {
481 if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
482 Self::parse_disabled_tools_env(&val)
483 } else {
484 self.disabled_tools.clone()
485 }
486 }
487
488 pub fn minimal_overhead_effective(&self) -> bool {
490 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
491 }
492
493 pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
501 if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
502 match raw.trim().to_lowercase().as_str() {
503 "minimal" => return true,
504 "full" => return self.minimal_overhead_effective(),
505 _ => {}
506 }
507 }
508
509 if self.minimal_overhead_effective() {
510 return true;
511 }
512
513 let client_lower = client_name.trim().to_lowercase();
514 if !client_lower.is_empty() {
515 if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
516 for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
517 if !needle.is_empty() && client_lower.contains(&needle) {
518 return true;
519 }
520 }
521 } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
522 return true;
523 }
524 }
525
526 let model = std::env::var("LEAN_CTX_MODEL")
527 .or_else(|_| std::env::var("LCTX_MODEL"))
528 .unwrap_or_default();
529 let model = model.trim().to_lowercase();
530 if !model.is_empty() {
531 let m = model.replace(['_', ' '], "-");
532 if m.contains("minimax")
533 || m.contains("mini-max")
534 || m.contains("m2.7")
535 || m.contains("m2-7")
536 {
537 return true;
538 }
539 }
540
541 false
542 }
543
544 pub fn shell_hook_disabled_effective(&self) -> bool {
546 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
547 }
548
549 pub fn shell_activation_effective(&self) -> ShellActivation {
551 ShellActivation::effective(self)
552 }
553
554 pub fn update_check_disabled_effective(&self) -> bool {
556 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
557 }
558
559 pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
560 let mut policy = self.memory.clone();
561 policy.apply_env_overrides();
562
563 let budget = self.max_disk_mb_effective();
566 if budget > 0 {
567 let scale_factor = (budget as f64 / 500.0).clamp(0.5, 10.0);
568 let default_policy = MemoryPolicy::default();
569 if policy.knowledge.max_facts == default_policy.knowledge.max_facts {
570 policy.knowledge.max_facts = (200.0 * scale_factor) as usize;
571 }
572 if policy.knowledge.max_patterns == default_policy.knowledge.max_patterns {
573 policy.knowledge.max_patterns = (50.0 * scale_factor) as usize;
574 }
575 if policy.episodic.max_episodes == default_policy.episodic.max_episodes {
576 policy.episodic.max_episodes = (500.0 * scale_factor) as usize;
577 }
578 if policy.procedural.max_procedures == default_policy.procedural.max_procedures {
579 policy.procedural.max_procedures = (100.0 * scale_factor) as usize;
580 }
581 }
582
583 policy.validate()?;
584 Ok(policy)
585 }
586
587 pub fn default_tool_categories_effective(&self) -> Vec<String> {
590 if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
591 return val
592 .split(',')
593 .map(|s| s.trim().to_lowercase())
594 .filter(|s| !s.is_empty())
595 .collect();
596 }
597 if !self.default_tool_categories.is_empty() {
598 return self
599 .default_tool_categories
600 .iter()
601 .map(|s| s.to_lowercase())
602 .collect();
603 }
604 vec!["core".to_string(), "session".to_string()]
605 }
606
607 pub fn tool_profile_effective(&self) -> super::tool_profiles::ToolProfile {
610 super::tool_profiles::ToolProfile::from_config(self)
611 }
612
613 pub fn no_degrade_effective(&self) -> bool {
616 if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
617 return val == "1" || val.eq_ignore_ascii_case("true");
618 }
619 self.no_degrade
620 }
621
622 pub fn max_disk_mb_effective(&self) -> u64 {
624 std::env::var("LEAN_CTX_MAX_DISK_MB")
625 .ok()
626 .and_then(|v| v.parse().ok())
627 .unwrap_or(self.max_disk_mb)
628 }
629
630 pub fn max_staleness_days_effective(&self) -> u32 {
632 std::env::var("LEAN_CTX_MAX_STALENESS_DAYS")
633 .ok()
634 .and_then(|v| v.parse().ok())
635 .unwrap_or(self.max_staleness_days)
636 }
637
638 pub fn archive_max_disk_mb_effective(&self) -> u64 {
641 let budget = self.max_disk_mb_effective();
642 if budget > 0 && self.archive.max_disk_mb == ArchiveConfig::default().max_disk_mb {
643 budget * 25 / 100
644 } else {
645 self.archive.max_disk_mb
646 }
647 }
648
649 pub fn archive_max_age_hours_effective(&self) -> u64 {
652 let staleness = self.max_staleness_days_effective();
653 if staleness > 0 && self.archive.max_age_hours == ArchiveConfig::default().max_age_hours {
654 staleness as u64 * 24
655 } else {
656 self.archive.max_age_hours
657 }
658 }
659
660 pub fn bm25_max_cache_mb_effective(&self) -> u64 {
668 if self.bm25_max_cache_mb != serde_defaults::default_bm25_max_cache_mb() {
670 return self.bm25_max_cache_mb;
671 }
672 let budget = self.max_disk_mb_effective();
674 if budget > 0 {
675 return budget * 10 / 100;
676 }
677 DEFAULT_BM25_PERSIST_MB
679 }
680}
681
682impl Config {
683 pub fn path() -> Option<PathBuf> {
685 crate::core::data_dir::lean_ctx_data_dir()
686 .ok()
687 .map(|d| d.join("config.toml"))
688 }
689
690 pub fn local_path(project_root: &str) -> PathBuf {
692 PathBuf::from(project_root).join(".lean-ctx.toml")
693 }
694
695 fn find_project_root() -> Option<String> {
696 static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
697 ROOT_CACHE
698 .get_or_init(Self::find_project_root_inner)
699 .clone()
700 }
701
702 fn find_project_root_inner() -> Option<String> {
703 if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
704 if !env_root.is_empty() {
705 return Some(env_root);
706 }
707 }
708
709 let cwd = std::env::current_dir().ok();
710
711 if let Some(root) =
712 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
713 {
714 let root_path = std::path::Path::new(&root);
715 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
716 let has_marker = root_path.join(".git").exists()
717 || root_path.join("Cargo.toml").exists()
718 || root_path.join("package.json").exists()
719 || root_path.join("go.mod").exists()
720 || root_path.join("pyproject.toml").exists()
721 || root_path.join(".lean-ctx.toml").exists();
722
723 if cwd_is_under_root || has_marker {
724 return Some(root);
725 }
726 }
727
728 if let Some(ref cwd) = cwd {
729 let git_root = std::process::Command::new("git")
730 .args(["rev-parse", "--show-toplevel"])
731 .current_dir(cwd)
732 .stdout(std::process::Stdio::piped())
733 .stderr(std::process::Stdio::null())
734 .output()
735 .ok()
736 .and_then(|o| {
737 if o.status.success() {
738 String::from_utf8(o.stdout)
739 .ok()
740 .map(|s| s.trim().to_string())
741 } else {
742 None
743 }
744 });
745 if let Some(root) = git_root {
746 return Some(root);
747 }
748 if !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
749 return Some(cwd.to_string_lossy().to_string());
750 }
751 }
752 None
753 }
754
755 pub fn load() -> Self {
757 static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
758
759 let Some(path) = Self::path() else {
760 return Self::default();
761 };
762
763 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
764
765 let mtime = std::fs::metadata(&path)
766 .and_then(|m| m.modified())
767 .unwrap_or(SystemTime::UNIX_EPOCH);
768
769 let local_mtime = local_path
770 .as_ref()
771 .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
772
773 if let Ok(guard) = CACHE.lock() {
774 if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
775 if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
776 return cfg.clone();
777 }
778 }
779 }
780
781 let mut cfg: Config = if let Ok(content) = std::fs::read_to_string(&path) {
782 match toml::from_str(&content) {
783 Ok(c) => {
784 record_parse_error(None);
785 c
786 }
787 Err(e) => {
788 record_parse_error(Some(format!("{e}")));
789 tracing::warn!("config parse error in {}: {e}", path.display());
790 eprintln!(
791 "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n \
792 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
793 path.display()
794 );
795 Self::default()
796 }
797 }
798 } else {
799 record_parse_error(None);
800 Self::default()
801 };
802
803 if let Some(ref lp) = local_path {
804 if let Ok(local_content) = std::fs::read_to_string(lp) {
805 cfg.merge_local(&local_content);
806 }
807 }
808
809 if let Ok(mut guard) = CACHE.lock() {
810 *guard = Some((cfg.clone(), mtime, local_mtime));
811 }
812
813 cfg
814 }
815
816 fn merge_local(&mut self, local_toml: &str) {
817 let local: Config = match toml::from_str(local_toml) {
818 Ok(c) => c,
819 Err(e) => {
820 tracing::warn!("local config parse error: {e}");
821 eprintln!(
822 "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n \
823 Local overrides skipped.\x1b[0m"
824 );
825 return;
826 }
827 };
828 if local.ultra_compact {
829 self.ultra_compact = true;
830 }
831 if local.tee_mode != TeeMode::default() {
832 self.tee_mode = local.tee_mode;
833 }
834 if local.output_density != OutputDensity::default() {
835 self.output_density = local.output_density;
836 }
837 if local.checkpoint_interval != 15 {
838 self.checkpoint_interval = local.checkpoint_interval;
839 }
840 if !local.excluded_commands.is_empty() {
841 self.excluded_commands.extend(local.excluded_commands);
842 }
843 if !local.passthrough_urls.is_empty() {
844 self.passthrough_urls.extend(local.passthrough_urls);
845 }
846 if !local.custom_aliases.is_empty() {
847 self.custom_aliases.extend(local.custom_aliases);
848 }
849 for fmt in local.preserve_compact_formats {
852 if !self
853 .preserve_compact_formats
854 .iter()
855 .any(|f| f.eq_ignore_ascii_case(&fmt))
856 {
857 self.preserve_compact_formats.push(fmt);
858 }
859 }
860 if local.slow_command_threshold_ms != 5000 {
861 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
862 }
863 if local.theme != "default" {
864 self.theme = local.theme;
865 }
866 if !local.buddy_enabled {
867 self.buddy_enabled = false;
868 }
869 if !local.enable_wakeup_ctx {
870 self.enable_wakeup_ctx = false;
871 }
872 if !local.redirect_exclude.is_empty() {
873 self.redirect_exclude.extend(local.redirect_exclude);
874 }
875 if !local.disabled_tools.is_empty() {
876 self.disabled_tools.extend(local.disabled_tools);
877 }
878 if !local.extra_ignore_patterns.is_empty() {
879 self.extra_ignore_patterns
880 .extend(local.extra_ignore_patterns);
881 }
882 if local.rules_scope.is_some() {
883 self.rules_scope = local.rules_scope;
884 }
885 if local.proxy.anthropic_upstream.is_some() {
886 self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
887 }
888 if local.proxy.openai_upstream.is_some() {
889 self.proxy.openai_upstream = local.proxy.openai_upstream;
890 }
891 if local.proxy.gemini_upstream.is_some() {
892 self.proxy.gemini_upstream = local.proxy.gemini_upstream;
893 }
894 if !local.autonomy.enabled {
895 self.autonomy.enabled = false;
896 }
897 if !local.autonomy.auto_preload {
898 self.autonomy.auto_preload = false;
899 }
900 if !local.autonomy.auto_dedup {
901 self.autonomy.auto_dedup = false;
902 }
903 if !local.autonomy.auto_related {
904 self.autonomy.auto_related = false;
905 }
906 if !local.autonomy.auto_consolidate {
907 self.autonomy.auto_consolidate = false;
908 }
909 if local.autonomy.silent_preload {
910 self.autonomy.silent_preload = true;
911 }
912 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
913 self.autonomy.silent_preload = false;
914 }
915 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
916 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
917 }
918 if local.autonomy.consolidate_every_calls
919 != AutonomyConfig::default().consolidate_every_calls
920 {
921 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
922 }
923 if local.autonomy.consolidate_cooldown_secs
924 != AutonomyConfig::default().consolidate_cooldown_secs
925 {
926 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
927 }
928 if !local.autonomy.cognition_loop_enabled {
929 self.autonomy.cognition_loop_enabled = false;
930 }
931 if local.autonomy.cognition_loop_interval_secs
932 != AutonomyConfig::default().cognition_loop_interval_secs
933 {
934 self.autonomy.cognition_loop_interval_secs =
935 local.autonomy.cognition_loop_interval_secs;
936 }
937 if local.autonomy.cognition_loop_max_steps
938 != AutonomyConfig::default().cognition_loop_max_steps
939 {
940 self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
941 }
942 if local_toml.contains("compression_level") {
943 self.compression_level = local.compression_level;
944 }
945 if local_toml.contains("terse_agent") {
946 self.terse_agent = local.terse_agent;
947 }
948 if !local.archive.enabled {
949 self.archive.enabled = false;
950 }
951 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
952 self.archive.threshold_chars = local.archive.threshold_chars;
953 }
954 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
955 self.archive.max_age_hours = local.archive.max_age_hours;
956 }
957 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
958 self.archive.max_disk_mb = local.archive.max_disk_mb;
959 }
960 if !local.archive.ephemeral {
961 self.archive.ephemeral = false;
962 }
963 let mem_def = MemoryPolicy::default();
964 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
965 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
966 }
967 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
968 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
969 }
970 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
971 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
972 }
973 if local.memory.knowledge.contradiction_threshold
974 != mem_def.knowledge.contradiction_threshold
975 {
976 self.memory.knowledge.contradiction_threshold =
977 local.memory.knowledge.contradiction_threshold;
978 }
979
980 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
981 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
982 }
983 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
984 {
985 self.memory.episodic.max_actions_per_episode =
986 local.memory.episodic.max_actions_per_episode;
987 }
988 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
989 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
990 }
991
992 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
993 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
994 }
995 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
996 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
997 }
998 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
999 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1000 }
1001 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1002 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1003 }
1004
1005 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1006 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1007 }
1008 if local.memory.lifecycle.low_confidence_threshold
1009 != mem_def.lifecycle.low_confidence_threshold
1010 {
1011 self.memory.lifecycle.low_confidence_threshold =
1012 local.memory.lifecycle.low_confidence_threshold;
1013 }
1014 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1015 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1016 }
1017 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1018 self.memory.lifecycle.similarity_threshold =
1019 local.memory.lifecycle.similarity_threshold;
1020 }
1021
1022 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1023 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1024 }
1025 if !local.allow_paths.is_empty() {
1026 self.allow_paths.extend(local.allow_paths);
1027 }
1028 if !local.extra_roots.is_empty() {
1029 self.extra_roots.extend(local.extra_roots);
1030 }
1031 if local.minimal_overhead {
1032 self.minimal_overhead = true;
1033 }
1034 if local.shell_hook_disabled {
1035 self.shell_hook_disabled = true;
1036 }
1037 if local.shell_activation != ShellActivation::default() {
1038 self.shell_activation = local.shell_activation.clone();
1039 }
1040 if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1041 self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1042 }
1043 if local.memory_profile != MemoryProfile::default() {
1044 self.memory_profile = local.memory_profile;
1045 }
1046 if local.memory_cleanup != MemoryCleanup::default() {
1047 self.memory_cleanup = local.memory_cleanup;
1048 }
1049 if !local.shell_allowlist.is_empty() {
1050 self.shell_allowlist = local.shell_allowlist;
1051 }
1052 if !local.shell_allowlist_extra.is_empty() {
1053 self.shell_allowlist_extra
1054 .extend(local.shell_allowlist_extra);
1055 }
1056 if !local.default_tool_categories.is_empty() {
1057 self.default_tool_categories = local.default_tool_categories;
1058 }
1059 if local.tool_profile.is_some() {
1060 self.tool_profile = local.tool_profile;
1061 }
1062 if !local.tools_enabled.is_empty() {
1063 self.tools_enabled = local.tools_enabled;
1064 }
1065 if local.no_degrade {
1066 self.no_degrade = true;
1067 }
1068 if local.profile.is_some() {
1069 self.profile = local.profile;
1070 }
1071 if local.proxy_timeout_ms.is_some() {
1072 self.proxy_timeout_ms = local.proxy_timeout_ms;
1073 }
1074 }
1075
1076 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1082 let path = Self::path().ok_or_else(|| {
1083 super::error::LeanCtxError::Config("cannot determine home directory".into())
1084 })?;
1085 if let Some(parent) = path.parent() {
1086 std::fs::create_dir_all(parent)?;
1087 }
1088 let content = toml::to_string_pretty(self)
1089 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1090 let baseline = toml::from_str::<Self>("").unwrap_or_else(|_| Self::default());
1095 let defaults = toml::to_string_pretty(&baseline)
1096 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1097 crate::config_io::write_toml_preserving_minimal(&path, &content, &defaults)
1098 .map_err(super::error::LeanCtxError::Config)?;
1099 Ok(())
1100 }
1101
1102 pub fn show(&self) -> String {
1104 let global_path = Self::path().map_or_else(
1105 || "~/.lean-ctx/config.toml".to_string(),
1106 |p| p.to_string_lossy().to_string(),
1107 );
1108 let content = toml::to_string_pretty(self).unwrap_or_default();
1109 let mut out = format!("Global config: {global_path}\n\n{content}");
1110
1111 if let Some(root) = Self::find_project_root() {
1112 let local = Self::local_path(&root);
1113 if local.exists() {
1114 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1115 } else {
1116 out.push_str(&format!(
1117 "\n\nLocal config: not found (create {} to override per-project)\n",
1118 local.display()
1119 ));
1120 }
1121 }
1122 out
1123 }
1124}