1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use std::sync::Mutex;
4use std::time::SystemTime;
5
6use super::memory_policy::MemoryPolicy;
7
8#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
10#[serde(rename_all = "lowercase")]
11pub enum TeeMode {
12 Never,
13 #[default]
14 Failures,
15 Always,
16}
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
20#[serde(rename_all = "lowercase")]
21pub enum TerseAgent {
22 #[default]
23 Off,
24 Lite,
25 Full,
26 Ultra,
27}
28
29impl TerseAgent {
30 pub fn from_env() -> Self {
32 match std::env::var("LEAN_CTX_TERSE_AGENT")
33 .unwrap_or_default()
34 .to_lowercase()
35 .as_str()
36 {
37 "lite" => Self::Lite,
38 "full" => Self::Full,
39 "ultra" => Self::Ultra,
40 _ => Self::Off,
41 }
42 }
43
44 pub fn effective(config_val: &TerseAgent) -> Self {
46 match std::env::var("LEAN_CTX_TERSE_AGENT") {
47 Ok(val) if !val.is_empty() => match val.to_lowercase().as_str() {
48 "lite" => Self::Lite,
49 "full" => Self::Full,
50 "ultra" => Self::Ultra,
51 _ => Self::Off,
52 },
53 _ => config_val.clone(),
54 }
55 }
56
57 pub fn is_active(&self) -> bool {
59 !matches!(self, Self::Off)
60 }
61}
62
63#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
65#[serde(rename_all = "lowercase")]
66pub enum OutputDensity {
67 #[default]
68 Normal,
69 Terse,
70 Ultra,
71}
72
73impl OutputDensity {
74 pub fn from_env() -> Self {
76 match std::env::var("LEAN_CTX_OUTPUT_DENSITY")
77 .unwrap_or_default()
78 .to_lowercase()
79 .as_str()
80 {
81 "terse" => Self::Terse,
82 "ultra" => Self::Ultra,
83 _ => Self::Normal,
84 }
85 }
86
87 pub fn effective(config_val: &OutputDensity) -> Self {
89 let env_val = Self::from_env();
90 if env_val != Self::Normal {
91 return env_val;
92 }
93 let profile_val = crate::core::profiles::active_profile()
94 .compression
95 .output_density_effective()
96 .to_lowercase();
97 let profile_density = match profile_val.as_str() {
98 "terse" => Self::Terse,
99 "ultra" => Self::Ultra,
100 _ => Self::Normal,
101 };
102 if profile_density != Self::Normal {
103 return profile_density;
104 }
105 config_val.clone()
106 }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(default)]
112pub struct Config {
113 pub ultra_compact: bool,
114 #[serde(default, deserialize_with = "deserialize_tee_mode")]
115 pub tee_mode: TeeMode,
116 #[serde(default)]
117 pub output_density: OutputDensity,
118 pub checkpoint_interval: u32,
119 pub excluded_commands: Vec<String>,
120 pub passthrough_urls: Vec<String>,
121 pub custom_aliases: Vec<AliasEntry>,
122 pub slow_command_threshold_ms: u64,
125 #[serde(default = "default_theme")]
126 pub theme: String,
127 #[serde(default)]
128 pub cloud: CloudConfig,
129 #[serde(default)]
130 pub autonomy: AutonomyConfig,
131 #[serde(default = "default_buddy_enabled")]
132 pub buddy_enabled: bool,
133 #[serde(default)]
134 pub redirect_exclude: Vec<String>,
135 #[serde(default)]
139 pub disabled_tools: Vec<String>,
140 #[serde(default)]
141 pub loop_detection: LoopDetectionConfig,
142 #[serde(default)]
146 pub rules_scope: Option<String>,
147 #[serde(default)]
150 pub extra_ignore_patterns: Vec<String>,
151 #[serde(default)]
155 pub terse_agent: TerseAgent,
156 #[serde(default)]
158 pub archive: ArchiveConfig,
159 #[serde(default)]
161 pub memory: MemoryPolicy,
162 #[serde(default)]
166 pub allow_paths: Vec<String>,
167 #[serde(default)]
170 pub content_defined_chunking: bool,
171 #[serde(default)]
174 pub minimal_overhead: bool,
175 #[serde(default)]
178 pub shell_hook_disabled: bool,
179 #[serde(default)]
182 pub update_check_disabled: bool,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187#[serde(default)]
188pub struct ArchiveConfig {
189 pub enabled: bool,
190 pub threshold_chars: usize,
191 pub max_age_hours: u64,
192 pub max_disk_mb: u64,
193}
194
195impl Default for ArchiveConfig {
196 fn default() -> Self {
197 Self {
198 enabled: true,
199 threshold_chars: 4096,
200 max_age_hours: 48,
201 max_disk_mb: 500,
202 }
203 }
204}
205
206fn default_buddy_enabled() -> bool {
207 true
208}
209
210fn deserialize_tee_mode<'de, D>(deserializer: D) -> Result<TeeMode, D::Error>
211where
212 D: serde::Deserializer<'de>,
213{
214 use serde::de::Error;
215 let v = serde_json::Value::deserialize(deserializer)?;
216 match &v {
217 serde_json::Value::Bool(true) => Ok(TeeMode::Failures),
218 serde_json::Value::Bool(false) => Ok(TeeMode::Never),
219 serde_json::Value::String(s) => match s.as_str() {
220 "never" => Ok(TeeMode::Never),
221 "failures" => Ok(TeeMode::Failures),
222 "always" => Ok(TeeMode::Always),
223 other => Err(D::Error::custom(format!("unknown tee_mode: {other}"))),
224 },
225 _ => Err(D::Error::custom("tee_mode must be string or bool")),
226 }
227}
228
229fn default_theme() -> String {
230 "default".to_string()
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
235#[serde(default)]
236pub struct AutonomyConfig {
237 pub enabled: bool,
238 pub auto_preload: bool,
239 pub auto_dedup: bool,
240 pub auto_related: bool,
241 pub auto_consolidate: bool,
242 pub silent_preload: bool,
243 pub dedup_threshold: usize,
244 pub consolidate_every_calls: u32,
245 pub consolidate_cooldown_secs: u64,
246}
247
248impl Default for AutonomyConfig {
249 fn default() -> Self {
250 Self {
251 enabled: true,
252 auto_preload: true,
253 auto_dedup: true,
254 auto_related: true,
255 auto_consolidate: true,
256 silent_preload: true,
257 dedup_threshold: 8,
258 consolidate_every_calls: 25,
259 consolidate_cooldown_secs: 120,
260 }
261 }
262}
263
264impl AutonomyConfig {
265 pub fn from_env() -> Self {
267 let mut cfg = Self::default();
268 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
269 if v == "false" || v == "0" {
270 cfg.enabled = false;
271 }
272 }
273 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
274 cfg.auto_preload = v != "false" && v != "0";
275 }
276 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
277 cfg.auto_dedup = v != "false" && v != "0";
278 }
279 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
280 cfg.auto_related = v != "false" && v != "0";
281 }
282 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
283 cfg.auto_consolidate = v != "false" && v != "0";
284 }
285 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
286 cfg.silent_preload = v != "false" && v != "0";
287 }
288 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
289 if let Ok(n) = v.parse() {
290 cfg.dedup_threshold = n;
291 }
292 }
293 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
294 if let Ok(n) = v.parse() {
295 cfg.consolidate_every_calls = n;
296 }
297 }
298 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
299 if let Ok(n) = v.parse() {
300 cfg.consolidate_cooldown_secs = n;
301 }
302 }
303 cfg
304 }
305
306 pub fn load() -> Self {
308 let file_cfg = Config::load().autonomy;
309 let mut cfg = file_cfg;
310 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
311 if v == "false" || v == "0" {
312 cfg.enabled = false;
313 }
314 }
315 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
316 cfg.auto_preload = v != "false" && v != "0";
317 }
318 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
319 cfg.auto_dedup = v != "false" && v != "0";
320 }
321 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
322 cfg.auto_related = v != "false" && v != "0";
323 }
324 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
325 cfg.silent_preload = v != "false" && v != "0";
326 }
327 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
328 if let Ok(n) = v.parse() {
329 cfg.dedup_threshold = n;
330 }
331 }
332 cfg
333 }
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize, Default)]
338#[serde(default)]
339pub struct CloudConfig {
340 pub contribute_enabled: bool,
341 pub last_contribute: Option<String>,
342 pub last_sync: Option<String>,
343 pub last_gain_sync: Option<String>,
344 pub last_model_pull: Option<String>,
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct AliasEntry {
350 pub command: String,
351 pub alias: String,
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize)]
356#[serde(default)]
357pub struct LoopDetectionConfig {
358 pub normal_threshold: u32,
359 pub reduced_threshold: u32,
360 pub blocked_threshold: u32,
361 pub window_secs: u64,
362 pub search_group_limit: u32,
363}
364
365impl Default for LoopDetectionConfig {
366 fn default() -> Self {
367 Self {
368 normal_threshold: 2,
369 reduced_threshold: 4,
370 blocked_threshold: 0,
373 window_secs: 300,
374 search_group_limit: 10,
375 }
376 }
377}
378
379impl Default for Config {
380 fn default() -> Self {
381 Self {
382 ultra_compact: false,
383 tee_mode: TeeMode::default(),
384 output_density: OutputDensity::default(),
385 checkpoint_interval: 15,
386 excluded_commands: Vec::new(),
387 passthrough_urls: Vec::new(),
388 custom_aliases: Vec::new(),
389 slow_command_threshold_ms: 5000,
390 theme: default_theme(),
391 cloud: CloudConfig::default(),
392 autonomy: AutonomyConfig::default(),
393 buddy_enabled: default_buddy_enabled(),
394 redirect_exclude: Vec::new(),
395 disabled_tools: Vec::new(),
396 loop_detection: LoopDetectionConfig::default(),
397 rules_scope: None,
398 extra_ignore_patterns: Vec::new(),
399 terse_agent: TerseAgent::default(),
400 archive: ArchiveConfig::default(),
401 memory: MemoryPolicy::default(),
402 allow_paths: Vec::new(),
403 content_defined_chunking: false,
404 minimal_overhead: false,
405 shell_hook_disabled: false,
406 update_check_disabled: false,
407 }
408 }
409}
410
411#[derive(Debug, Clone, Copy, PartialEq, Eq)]
413pub enum RulesScope {
414 Both,
415 Global,
416 Project,
417}
418
419impl Config {
420 pub fn rules_scope_effective(&self) -> RulesScope {
422 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
423 .ok()
424 .or_else(|| self.rules_scope.clone())
425 .unwrap_or_default();
426 match raw.trim().to_lowercase().as_str() {
427 "global" => RulesScope::Global,
428 "project" => RulesScope::Project,
429 _ => RulesScope::Both,
430 }
431 }
432
433 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
434 val.split(',')
435 .map(|s| s.trim().to_string())
436 .filter(|s| !s.is_empty())
437 .collect()
438 }
439
440 pub fn disabled_tools_effective(&self) -> Vec<String> {
442 if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
443 Self::parse_disabled_tools_env(&val)
444 } else {
445 self.disabled_tools.clone()
446 }
447 }
448
449 pub fn minimal_overhead_effective(&self) -> bool {
451 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
452 }
453
454 pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
462 if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
463 match raw.trim().to_lowercase().as_str() {
464 "minimal" => return true,
465 "full" => return self.minimal_overhead_effective(),
466 _ => {}
467 }
468 }
469
470 if self.minimal_overhead_effective() {
471 return true;
472 }
473
474 let client_lower = client_name.trim().to_lowercase();
475 if !client_lower.is_empty() {
476 if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
477 for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
478 if !needle.is_empty() && client_lower.contains(&needle) {
479 return true;
480 }
481 }
482 } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
483 return true;
484 }
485 }
486
487 let model = std::env::var("LEAN_CTX_MODEL")
488 .or_else(|_| std::env::var("LCTX_MODEL"))
489 .unwrap_or_default();
490 let model = model.trim().to_lowercase();
491 if !model.is_empty() {
492 let m = model.replace(['_', ' '], "-");
493 if m.contains("minimax")
494 || m.contains("mini-max")
495 || m.contains("m2.7")
496 || m.contains("m2-7")
497 {
498 return true;
499 }
500 }
501
502 false
503 }
504
505 pub fn shell_hook_disabled_effective(&self) -> bool {
507 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
508 }
509
510 pub fn update_check_disabled_effective(&self) -> bool {
512 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
513 }
514
515 pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
516 let mut policy = self.memory.clone();
517 policy.apply_env_overrides();
518 policy.validate()?;
519 Ok(policy)
520 }
521}
522
523#[cfg(test)]
524mod disabled_tools_tests {
525 use super::*;
526
527 #[test]
528 fn config_field_default_is_empty() {
529 let cfg = Config::default();
530 assert!(cfg.disabled_tools.is_empty());
531 }
532
533 #[test]
534 fn effective_returns_config_field_when_no_env_var() {
535 if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
537 return;
538 }
539 let cfg = Config {
540 disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
541 ..Default::default()
542 };
543 assert_eq!(
544 cfg.disabled_tools_effective(),
545 vec!["ctx_graph", "ctx_agent"]
546 );
547 }
548
549 #[test]
550 fn parse_env_basic() {
551 let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
552 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
553 }
554
555 #[test]
556 fn parse_env_trims_whitespace_and_skips_empty() {
557 let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
558 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
559 }
560
561 #[test]
562 fn parse_env_single_entry() {
563 let result = Config::parse_disabled_tools_env("ctx_graph");
564 assert_eq!(result, vec!["ctx_graph"]);
565 }
566
567 #[test]
568 fn parse_env_empty_string_returns_empty() {
569 let result = Config::parse_disabled_tools_env("");
570 assert!(result.is_empty());
571 }
572
573 #[test]
574 fn disabled_tools_deserialization_defaults_to_empty() {
575 let cfg: Config = toml::from_str("").unwrap();
576 assert!(cfg.disabled_tools.is_empty());
577 }
578
579 #[test]
580 fn disabled_tools_deserialization_from_toml() {
581 let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
582 assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
583 }
584}
585
586#[cfg(test)]
587mod rules_scope_tests {
588 use super::*;
589
590 #[test]
591 fn default_is_both() {
592 let cfg = Config::default();
593 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
594 }
595
596 #[test]
597 fn config_global() {
598 let cfg = Config {
599 rules_scope: Some("global".to_string()),
600 ..Default::default()
601 };
602 assert_eq!(cfg.rules_scope_effective(), RulesScope::Global);
603 }
604
605 #[test]
606 fn config_project() {
607 let cfg = Config {
608 rules_scope: Some("project".to_string()),
609 ..Default::default()
610 };
611 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
612 }
613
614 #[test]
615 fn unknown_value_falls_back_to_both() {
616 let cfg = Config {
617 rules_scope: Some("nonsense".to_string()),
618 ..Default::default()
619 };
620 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
621 }
622
623 #[test]
624 fn deserialization_none_by_default() {
625 let cfg: Config = toml::from_str("").unwrap();
626 assert!(cfg.rules_scope.is_none());
627 assert_eq!(cfg.rules_scope_effective(), RulesScope::Both);
628 }
629
630 #[test]
631 fn deserialization_from_toml() {
632 let cfg: Config = toml::from_str(r#"rules_scope = "project""#).unwrap();
633 assert_eq!(cfg.rules_scope.as_deref(), Some("project"));
634 assert_eq!(cfg.rules_scope_effective(), RulesScope::Project);
635 }
636}
637
638#[cfg(test)]
639mod loop_detection_config_tests {
640 use super::*;
641
642 #[test]
643 fn defaults_are_reasonable() {
644 let cfg = LoopDetectionConfig::default();
645 assert_eq!(cfg.normal_threshold, 2);
646 assert_eq!(cfg.reduced_threshold, 4);
647 assert_eq!(cfg.blocked_threshold, 0);
649 assert_eq!(cfg.window_secs, 300);
650 assert_eq!(cfg.search_group_limit, 10);
651 }
652
653 #[test]
654 fn deserialization_defaults_when_missing() {
655 let cfg: Config = toml::from_str("").unwrap();
656 assert_eq!(cfg.loop_detection.blocked_threshold, 0);
658 assert_eq!(cfg.loop_detection.search_group_limit, 10);
659 }
660
661 #[test]
662 fn deserialization_from_toml() {
663 let cfg: Config = toml::from_str(
664 r"
665 [loop_detection]
666 normal_threshold = 1
667 reduced_threshold = 3
668 blocked_threshold = 5
669 window_secs = 120
670 search_group_limit = 8
671 ",
672 )
673 .unwrap();
674 assert_eq!(cfg.loop_detection.normal_threshold, 1);
675 assert_eq!(cfg.loop_detection.reduced_threshold, 3);
676 assert_eq!(cfg.loop_detection.blocked_threshold, 5);
677 assert_eq!(cfg.loop_detection.window_secs, 120);
678 assert_eq!(cfg.loop_detection.search_group_limit, 8);
679 }
680
681 #[test]
682 fn partial_override_keeps_defaults() {
683 let cfg: Config = toml::from_str(
684 r"
685 [loop_detection]
686 blocked_threshold = 10
687 ",
688 )
689 .unwrap();
690 assert_eq!(cfg.loop_detection.blocked_threshold, 10);
691 assert_eq!(cfg.loop_detection.normal_threshold, 2);
692 assert_eq!(cfg.loop_detection.search_group_limit, 10);
693 }
694}
695
696impl Config {
697 pub fn path() -> Option<PathBuf> {
699 crate::core::data_dir::lean_ctx_data_dir()
700 .ok()
701 .map(|d| d.join("config.toml"))
702 }
703
704 pub fn local_path(project_root: &str) -> PathBuf {
706 PathBuf::from(project_root).join(".lean-ctx.toml")
707 }
708
709 fn find_project_root() -> Option<String> {
710 let cwd = std::env::current_dir().ok();
711
712 if let Some(root) =
713 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
714 {
715 let root_path = std::path::Path::new(&root);
716 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
717 let has_marker = root_path.join(".git").exists()
718 || root_path.join("Cargo.toml").exists()
719 || root_path.join("package.json").exists()
720 || root_path.join("go.mod").exists()
721 || root_path.join("pyproject.toml").exists()
722 || root_path.join(".lean-ctx.toml").exists();
723
724 if cwd_is_under_root || has_marker {
725 return Some(root);
726 }
727 }
728
729 if let Some(ref cwd) = cwd {
730 let git_root = std::process::Command::new("git")
731 .args(["rev-parse", "--show-toplevel"])
732 .current_dir(cwd)
733 .stdout(std::process::Stdio::piped())
734 .stderr(std::process::Stdio::null())
735 .output()
736 .ok()
737 .and_then(|o| {
738 if o.status.success() {
739 String::from_utf8(o.stdout)
740 .ok()
741 .map(|s| s.trim().to_string())
742 } else {
743 None
744 }
745 });
746 if let Some(root) = git_root {
747 return Some(root);
748 }
749 return Some(cwd.to_string_lossy().to_string());
750 }
751 None
752 }
753
754 pub fn load() -> Self {
756 static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
757
758 let Some(path) = Self::path() else {
759 return Self::default();
760 };
761
762 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
763
764 let mtime = std::fs::metadata(&path)
765 .and_then(|m| m.modified())
766 .unwrap_or(SystemTime::UNIX_EPOCH);
767
768 let local_mtime = local_path
769 .as_ref()
770 .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
771
772 if let Ok(guard) = CACHE.lock() {
773 if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
774 if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
775 return cfg.clone();
776 }
777 }
778 }
779
780 let mut cfg: Config = match std::fs::read_to_string(&path) {
781 Ok(content) => toml::from_str(&content).unwrap_or_default(),
782 Err(_) => Self::default(),
783 };
784
785 if let Some(ref lp) = local_path {
786 if let Ok(local_content) = std::fs::read_to_string(lp) {
787 cfg.merge_local(&local_content);
788 }
789 }
790
791 if let Ok(mut guard) = CACHE.lock() {
792 *guard = Some((cfg.clone(), mtime, local_mtime));
793 }
794
795 cfg
796 }
797
798 fn merge_local(&mut self, local_toml: &str) {
799 let local: Config = match toml::from_str(local_toml) {
800 Ok(c) => c,
801 Err(_) => return,
802 };
803 if local.ultra_compact {
804 self.ultra_compact = true;
805 }
806 if local.tee_mode != TeeMode::default() {
807 self.tee_mode = local.tee_mode;
808 }
809 if local.output_density != OutputDensity::default() {
810 self.output_density = local.output_density;
811 }
812 if local.checkpoint_interval != 15 {
813 self.checkpoint_interval = local.checkpoint_interval;
814 }
815 if !local.excluded_commands.is_empty() {
816 self.excluded_commands.extend(local.excluded_commands);
817 }
818 if !local.passthrough_urls.is_empty() {
819 self.passthrough_urls.extend(local.passthrough_urls);
820 }
821 if !local.custom_aliases.is_empty() {
822 self.custom_aliases.extend(local.custom_aliases);
823 }
824 if local.slow_command_threshold_ms != 5000 {
825 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
826 }
827 if local.theme != "default" {
828 self.theme = local.theme;
829 }
830 if !local.buddy_enabled {
831 self.buddy_enabled = false;
832 }
833 if !local.redirect_exclude.is_empty() {
834 self.redirect_exclude.extend(local.redirect_exclude);
835 }
836 if !local.disabled_tools.is_empty() {
837 self.disabled_tools.extend(local.disabled_tools);
838 }
839 if !local.extra_ignore_patterns.is_empty() {
840 self.extra_ignore_patterns
841 .extend(local.extra_ignore_patterns);
842 }
843 if local.rules_scope.is_some() {
844 self.rules_scope = local.rules_scope;
845 }
846 if !local.autonomy.enabled {
847 self.autonomy.enabled = false;
848 }
849 if !local.autonomy.auto_preload {
850 self.autonomy.auto_preload = false;
851 }
852 if !local.autonomy.auto_dedup {
853 self.autonomy.auto_dedup = false;
854 }
855 if !local.autonomy.auto_related {
856 self.autonomy.auto_related = false;
857 }
858 if !local.autonomy.auto_consolidate {
859 self.autonomy.auto_consolidate = false;
860 }
861 if local.autonomy.silent_preload {
862 self.autonomy.silent_preload = true;
863 }
864 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
865 self.autonomy.silent_preload = false;
866 }
867 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
868 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
869 }
870 if local.autonomy.consolidate_every_calls
871 != AutonomyConfig::default().consolidate_every_calls
872 {
873 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
874 }
875 if local.autonomy.consolidate_cooldown_secs
876 != AutonomyConfig::default().consolidate_cooldown_secs
877 {
878 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
879 }
880 if local.terse_agent != TerseAgent::default() {
881 self.terse_agent = local.terse_agent;
882 }
883 if !local.archive.enabled {
884 self.archive.enabled = false;
885 }
886 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
887 self.archive.threshold_chars = local.archive.threshold_chars;
888 }
889 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
890 self.archive.max_age_hours = local.archive.max_age_hours;
891 }
892 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
893 self.archive.max_disk_mb = local.archive.max_disk_mb;
894 }
895 let mem_def = MemoryPolicy::default();
896 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
897 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
898 }
899 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
900 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
901 }
902 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
903 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
904 }
905 if local.memory.knowledge.contradiction_threshold
906 != mem_def.knowledge.contradiction_threshold
907 {
908 self.memory.knowledge.contradiction_threshold =
909 local.memory.knowledge.contradiction_threshold;
910 }
911
912 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
913 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
914 }
915 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
916 {
917 self.memory.episodic.max_actions_per_episode =
918 local.memory.episodic.max_actions_per_episode;
919 }
920 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
921 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
922 }
923
924 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
925 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
926 }
927 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
928 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
929 }
930 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
931 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
932 }
933 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
934 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
935 }
936
937 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
938 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
939 }
940 if local.memory.lifecycle.low_confidence_threshold
941 != mem_def.lifecycle.low_confidence_threshold
942 {
943 self.memory.lifecycle.low_confidence_threshold =
944 local.memory.lifecycle.low_confidence_threshold;
945 }
946 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
947 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
948 }
949 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
950 self.memory.lifecycle.similarity_threshold =
951 local.memory.lifecycle.similarity_threshold;
952 }
953
954 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
955 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
956 }
957 if !local.allow_paths.is_empty() {
958 self.allow_paths.extend(local.allow_paths);
959 }
960 if local.minimal_overhead {
961 self.minimal_overhead = true;
962 }
963 if local.shell_hook_disabled {
964 self.shell_hook_disabled = true;
965 }
966 }
967
968 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
970 let path = Self::path().ok_or_else(|| {
971 super::error::LeanCtxError::Config("cannot determine home directory".into())
972 })?;
973 if let Some(parent) = path.parent() {
974 std::fs::create_dir_all(parent)?;
975 }
976 let content = toml::to_string_pretty(self)
977 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
978 std::fs::write(&path, content)?;
979 Ok(())
980 }
981
982 pub fn show(&self) -> String {
984 let global_path = Self::path().map_or_else(
985 || "~/.lean-ctx/config.toml".to_string(),
986 |p| p.to_string_lossy().to_string(),
987 );
988 let content = toml::to_string_pretty(self).unwrap_or_default();
989 let mut out = format!("Global config: {global_path}\n\n{content}");
990
991 if let Some(root) = Self::find_project_root() {
992 let local = Self::local_path(&root);
993 if local.exists() {
994 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
995 } else {
996 out.push_str(&format!(
997 "\n\nLocal config: not found (create {} to override per-project)\n",
998 local.display()
999 ));
1000 }
1001 }
1002 out
1003 }
1004}