Skip to main content

lean_ctx/core/config/
mod.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use std::sync::Mutex;
5
6use super::memory_policy::MemoryPolicy;
7
8mod defaults_allowlist;
9mod enums;
10mod memory;
11mod proxy;
12pub mod schema;
13mod sections;
14mod serde_defaults;
15pub mod setter;
16mod shell_activation;
17pub use sections::*;
18#[cfg(test)]
19mod tests;
20
21pub(crate) use defaults_allowlist::{cloud_infra_commands, default_shell_allowlist};
22pub use enums::{
23    CompressionLevel, OutputDensity, PermissionInheritance, ResponseVerbosity, RulesInjection,
24    RulesScope, TeeMode, TerseAgent,
25};
26pub use memory::{MemoryCleanup, MemoryGuardConfig, MemoryProfile, SavingsFooter};
27pub use proxy::{
28    is_local_proxy_url, normalize_url, normalize_url_opt, HistoryMode, ProxyConfig, ProxyProvider,
29};
30pub use shell_activation::ShellActivation;
31
32/// Default BM25 cache cap from config (also used by `bm25_index` heuristics).
33pub fn default_bm25_max_cache_mb() -> u64 {
34    serde_defaults::default_bm25_max_cache_mb()
35}
36
37/// Effective on-disk ceiling (MB) for the persisted BM25 index when nothing is
38/// explicitly configured (no `bm25_max_cache_mb`, no `max_disk_mb` budget).
39///
40/// Deliberately decoupled from the RAM `MemoryProfile` (64/128/512 MB): this is
41/// a *disk* file, and tying it to the profile silently refused persistence on
42/// large repos under Low/Balanced, forcing a cold rebuild on every call (the
43/// perpetual "index warming" of issue #249). 512 MB compressed covers
44/// essentially every real repo; RAM pressure is governed separately by the
45/// eviction orchestrator (which measures real heap).
46pub const DEFAULT_BM25_PERSIST_MB: u64 = 512;
47
48// Compile-time regression guard (#249): the default disk ceiling must stay well
49// above the old RAM-profile caps (64/128 MB) that starved large repos.
50const _: () = assert!(DEFAULT_BM25_PERSIST_MB >= 512);
51
52/// Global lean-ctx configuration loaded from `config.toml`, merged with project-local overrides.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(default)]
55pub struct Config {
56    pub ultra_compact: bool,
57    #[serde(default, deserialize_with = "serde_defaults::deserialize_tee_mode")]
58    pub tee_mode: TeeMode,
59    #[serde(default)]
60    pub output_density: OutputDensity,
61    pub checkpoint_interval: u32,
62    pub excluded_commands: Vec<String>,
63    pub passthrough_urls: Vec<String>,
64    pub custom_aliases: Vec<AliasEntry>,
65    /// Output formats that are already compact/token-oriented and must be
66    /// preserved verbatim instead of being recompressed (#342). Matched against
67    /// the *output shape* (not the command name), so any tool emitting the
68    /// format is covered without enumerating commands in `excluded_commands`.
69    /// Default: `["toon"]`. Set to `[]` to disable and always recompress.
70    #[serde(default = "serde_defaults::default_preserve_compact_formats")]
71    pub preserve_compact_formats: Vec<String>,
72    /// Commands taking longer than this threshold (ms) are recorded in the slow log.
73    /// Set to 0 to disable slow logging.
74    pub slow_command_threshold_ms: u64,
75    #[serde(default = "serde_defaults::default_theme")]
76    pub theme: String,
77    #[serde(default)]
78    pub cloud: CloudConfig,
79    #[serde(default)]
80    pub gain: GainConfig,
81    #[serde(default)]
82    pub autonomy: AutonomyConfig,
83    #[serde(default)]
84    pub providers: ProvidersConfig,
85    #[serde(default)]
86    pub proxy: ProxyConfig,
87    /// Whether the API proxy is enabled. Tri-state:
88    /// - None: undecided (fresh install, will prompt on interactive setup)
89    /// - Some(true): user opted in, proxy managed by lean-ctx
90    /// - Some(false): user opted out, never touch proxy or endpoints
91    #[serde(default)]
92    pub proxy_enabled: Option<bool>,
93    #[serde(default)]
94    pub proxy_port: Option<u16>,
95    /// Proxy reachability timeout in milliseconds. Default: 200.
96    /// Override via LEAN_CTX_PROXY_TIMEOUT_MS env var.
97    #[serde(default)]
98    pub proxy_timeout_ms: Option<u64>,
99    #[serde(default = "serde_defaults::default_buddy_enabled")]
100    pub buddy_enabled: bool,
101    #[serde(default = "serde_defaults::default_true")]
102    pub enable_wakeup_ctx: bool,
103    #[serde(default)]
104    pub redirect_exclude: Vec<String>,
105    /// Tools to exclude from the MCP tool list returned by list_tools.
106    /// Accepts exact tool names (e.g. `["ctx_graph", "ctx_agent"]`).
107    /// Empty by default — all tools listed, no behaviour change.
108    #[serde(default)]
109    pub disabled_tools: Vec<String>,
110    /// Tool categories to activate by default for dynamic-tool-capable clients.
111    /// Values: "core" (always on), "arch", "debug", "memory", "metrics", "session".
112    /// Example: `default_tool_categories = ["core", "arch", "memory"]`
113    /// Override via LCTX_DEFAULT_CATEGORIES env var (comma-separated).
114    /// Empty = lean-ctx default (core + session).
115    #[serde(default)]
116    pub default_tool_categories: Vec<String>,
117    /// Disable all automatic read-mode degradation (auto_degrade + context_gate pressure).
118    /// When true, lean-ctx never downgrades requested read modes regardless of pressure.
119    /// Override via LCTX_NO_DEGRADE=1 env var.
120    #[serde(default)]
121    pub no_degrade: bool,
122    /// Persistent profile name. Checked after LEAN_CTX_PROFILE env var.
123    /// Set via `lean-ctx config set profile passthrough` or editing config.toml.
124    #[serde(default)]
125    pub profile: Option<String>,
126    /// Tool visibility profile: "minimal" (6), "standard" (22), or "power" (all).
127    /// Override via LEAN_CTX_TOOL_PROFILE env var.
128    /// Existing installs default to "power" (backward compat).
129    #[serde(default)]
130    pub tool_profile: Option<String>,
131    /// Explicit list of enabled tool names (overrides tool_profile when non-empty).
132    /// Example: `tools_enabled = ["ctx_read", "ctx_shell", "ctx_search"]`
133    #[serde(default)]
134    pub tools_enabled: Vec<String>,
135    /// Active context persona (`persona-spec-v1`). Selects the domain bundle —
136    /// tool surface, read-mode/compressor/chunker defaults, intent taxonomy,
137    /// sensitivity floor. Override via `LEAN_CTX_PERSONA`. Defaults to `coding`.
138    #[serde(default)]
139    pub persona: Option<String>,
140    #[serde(default)]
141    pub loop_detection: LoopDetectionConfig,
142    /// Controls where lean-ctx installs agent rule files.
143    /// Values: "both" (default), "global" (home-dir only), "project" (repo-local only).
144    /// Override via LEAN_CTX_RULES_SCOPE env var.
145    #[serde(default)]
146    pub rules_scope: Option<String>,
147    /// Controls how rules are injected for shared-instruction-file agents.
148    /// Values: "shared" (default, marker block in CLAUDE.md/CODEBUDDY.md/AGENTS.md/GEMINI.md),
149    /// "dedicated" (never touch those files; use each agent's config-driven
150    /// auto-load: SessionStart hook / instructions[] / context.fileName, #343), or
151    /// "off" (write no rules file at all — for hosts that supply their own
152    /// tool-steering workflow or phase-isolated/non-caching harnesses, #361).
153    /// Override via LEAN_CTX_RULES_INJECTION env var.
154    #[serde(default)]
155    pub rules_injection: Option<String>,
156    /// Mirror the host IDE's tool-permission rules onto lean-ctx's own MCP tools.
157    /// Values: "off" (default) or "on". When "on", lean-ctx reads the active
158    /// IDE's permission config (v1: OpenCode) and applies the equivalent
159    /// deny/ask/allow decision to the matching lean-ctx tool — so `ctx_shell`
160    /// honors your `bash`/`rm *` rules instead of bypassing them.
161    /// Override via LEAN_CTX_PERMISSION_INHERITANCE env var.
162    #[serde(default)]
163    pub permission_inheritance: Option<String>,
164    /// Extra glob patterns to ignore in graph/overview/preload (repo-local).
165    /// Example: `["externals/**", "target/**", "temp/**"]`
166    #[serde(default)]
167    pub extra_ignore_patterns: Vec<String>,
168    /// Controls agent output verbosity via instructions injection.
169    /// Values: "off" (default), "lite", "full", "ultra".
170    /// Override via LEAN_CTX_TERSE_AGENT env var.
171    #[serde(default)]
172    pub terse_agent: TerseAgent,
173    /// Unified compression level (replaces separate terse_agent + output_density).
174    /// Values: "off" (default), "lite", "standard", "max".
175    /// Override via LEAN_CTX_COMPRESSION env var.
176    #[serde(default)]
177    pub compression_level: CompressionLevel,
178    /// Archive configuration for zero-loss compression.
179    #[serde(default)]
180    pub archive: ArchiveConfig,
181    /// Memory policy (knowledge/episodic/procedural/lifecycle budgets & thresholds).
182    #[serde(default)]
183    pub memory: MemoryPolicy,
184    /// Additional paths allowed by PathJail (absolute).
185    /// Useful for multi-project workspaces where the jail root is a parent directory.
186    /// Override via LEAN_CTX_ALLOW_PATH env var (path-list separator).
187    #[serde(default)]
188    pub allow_paths: Vec<String>,
189    /// Allow jailed tool access to home-level IDE config dirs (~/.cursor,
190    /// ~/.claude, ~/.codebuddy, …). Default false: those dirs expose other projects'
191    /// sessions, MCP configs and credentials. `~/.lean-ctx` (own data dir)
192    /// is always allowed. Override via LEAN_CTX_ALLOW_IDE_DIRS=1.
193    #[serde(default)]
194    pub allow_ide_config_dirs: bool,
195    /// Extra project roots for multi-root workspaces.
196    /// Tools like ctx_tree and ctx_search can scan across all roots in a single call.
197    /// These paths are automatically added to PathJail's allow-list.
198    /// Override via LEAN_CTX_EXTRA_ROOTS env var (path-list separator).
199    #[serde(default)]
200    pub extra_roots: Vec<String>,
201    /// Enable content-defined chunking (Rabin-Karp) for cache-optimal output ordering.
202    /// Stable chunks are emitted first to maximize prompt cache hits.
203    #[serde(default)]
204    pub content_defined_chunking: bool,
205    /// Skip session/knowledge/gotcha blocks in MCP instructions to minimize token overhead.
206    /// Override via LEAN_CTX_MINIMAL env var.
207    #[serde(default)]
208    pub minimal_overhead: bool,
209    /// Opt-in: substitute long identifiers with short α-codes (+ a `§MAP` table)
210    /// in `aggressive` reads for projects with >50 source files. Off by default —
211    /// the abbreviated form is confusing for editing/refactoring, where the agent
212    /// needs the real package and symbol names. Enable for max exploration savings.
213    #[serde(default)]
214    pub symbol_map_auto: bool,
215    /// Opt-in: bias `auto` toward structure-first reads (`map`) for medium code
216    /// files on a cold read. Off by default — interactive sessions keep the
217    /// conservative `full` floor that avoids a follow-up body read. Enable for
218    /// phase-isolated harnesses (no warm-session cache payback), where a cold
219    /// `full` read is pure overhead and structure-first reads aid localization.
220    /// Override via the LEAN_CTX_STRUCTURE_FIRST env var.
221    #[serde(default)]
222    pub structure_first: bool,
223    /// Team server URL for opt-in savings roll-up.
224    /// Set via `lean-ctx config set team_url https://...` or `[team] url` in config.toml.
225    /// Override via LEAN_CTX_TEAM_URL env var.
226    #[serde(default)]
227    pub team_url: Option<String>,
228    /// Bearer token for the team server (Authorization header on savings push /
229    /// pull). Set via `lean-ctx config set team_token <tok>` or `team_token` in
230    /// config.toml. Override via the LEAN_CTX_TEAM_TOKEN env var.
231    #[serde(default)]
232    pub team_token: Option<String>,
233    /// Opt-in: when true, the running daemon periodically pushes this machine's
234    /// signed savings batch to `team_url` so the team roll-up fills itself (no
235    /// manual `savings push` per dev). Off by default; requires `team_url` +
236    /// `team_token`. Set via `lean-ctx config set team_auto_push true`.
237    #[serde(default)]
238    pub team_auto_push: bool,
239    /// Enable human-readable activity journal (~/.lean-ctx/journal.md).
240    #[serde(default)]
241    pub journal_enabled: bool,
242    /// Opt-in: auto-persist interesting findings as knowledge facts.
243    #[serde(default)]
244    pub auto_capture: bool,
245    /// Hybrid search weights (BM25/dense/candidates).
246    #[serde(default)]
247    pub search: crate::core::hybrid_search::HybridConfig,
248    /// Code-graph settings, including traversal (co-access) edges (#289).
249    #[serde(default)]
250    pub graph: GraphConfig,
251    /// Skillify miner settings (#290): codify recurring patterns into rules.
252    #[serde(default)]
253    pub skillify: SkillifyConfig,
254    /// AI session-summary settings (#292): periodic, semantically-recallable summaries.
255    #[serde(default)]
256    pub summaries: SummariesConfig,
257    /// Optional LLM enhancement (query expansion, contradiction explanation).
258    #[serde(default)]
259    pub llm: crate::core::llm_enhance::LlmConfig,
260    /// Semantic-embedding engine settings (which local ONNX model to use).
261    #[serde(default)]
262    pub embedding: EmbeddingConfig,
263    /// Disable shell hook injection (the _lc() function that wraps CLI commands).
264    /// Override via LEAN_CTX_NO_HOOK env var.
265    #[serde(default)]
266    pub shell_hook_disabled: bool,
267    /// Shadow mode: transparently intercepts native tool calls (Read/Grep/Shell)
268    /// via hooks, strengthens MCP instructions to MUST-level, and activates
269    /// immediate bypass hints on first native tool use. Enables "transparent
270    /// replacement" so agents use ctx_* without explicit opt-in.
271    #[serde(default)]
272    pub shadow_mode: bool,
273    /// Controls when the shell hook auto-activates aliases.
274    /// - `always`: (Default) Aliases active in every interactive shell.
275    /// - `agents-only`: Aliases only active when an AI agent env var is detected.
276    /// - `off`: Aliases never auto-activate (user must call `lean-ctx-on` manually).
277    ///
278    /// Override via `LEAN_CTX_SHELL_ACTIVATION` env var.
279    #[serde(default)]
280    pub shell_activation: ShellActivation,
281    /// Disable the daily version check against leanctx.com/version.txt.
282    /// Override via LEAN_CTX_NO_UPDATE_CHECK env var.
283    #[serde(default)]
284    pub update_check_disabled: bool,
285    #[serde(default)]
286    pub updates: UpdatesConfig,
287    /// Maximum BM25 cache file size in MB. Indexes exceeding this are quarantined on load
288    /// and refused on save. Override via LEAN_CTX_BM25_MAX_CACHE_MB env var.
289    #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
290    pub bm25_max_cache_mb: u64,
291    /// Maximum number of files scanned by the lightweight JSON graph index.
292    /// 0 = unlimited (default). Set >0 to cap for constrained systems.
293    #[serde(default = "serde_defaults::default_graph_index_max_files")]
294    pub graph_index_max_files: u64,
295    /// Controls RAM vs feature trade-off. Values: "low", "balanced" (default), "performance".
296    /// Override via LEAN_CTX_MEMORY_PROFILE env var.
297    #[serde(default)]
298    pub memory_profile: MemoryProfile,
299    /// Controls how aggressively memory is freed when idle.
300    /// Values: "aggressive" (default, 5 min TTL), "shared" (30 min TTL for multi-IDE use).
301    /// Override via LEAN_CTX_MEMORY_CLEANUP env var.
302    #[serde(default)]
303    pub memory_cleanup: MemoryCleanup,
304    /// Maximum percentage of system RAM that lean-ctx may use (default: 5).
305    /// Override via LEAN_CTX_MAX_RAM_PERCENT env var.
306    #[serde(default = "serde_defaults::default_max_ram_percent")]
307    pub max_ram_percent: u8,
308    /// Simplified disk budget (MB). When set and detail values are at defaults,
309    /// distributes proportionally: archive=25%, bm25=10%, remainder for stores.
310    /// 0 = disabled (use individual settings). Override via LEAN_CTX_MAX_DISK_MB.
311    #[serde(default)]
312    pub max_disk_mb: u64,
313    /// Auto-purge data older than this many days. 0 = disabled.
314    /// Flows into archive.max_age_hours and lifecycle idle TTL.
315    #[serde(default)]
316    pub max_staleness_days: u32,
317    /// Controls visibility of token savings footers in tool output.
318    /// Values: "always" (default, show on every response), "never", "auto" (legacy compatibility).
319    /// Override via LEAN_CTX_SAVINGS_FOOTER or LEAN_CTX_SHOW_SAVINGS=1|0 env var.
320    #[serde(default)]
321    pub savings_footer: SavingsFooter,
322    /// Explicit project root override. When set, lean-ctx uses this instead of auto-detection.
323    /// This prevents accidental home-directory scans when running from $HOME.
324    /// Override via LEAN_CTX_PROJECT_ROOT env var.
325    #[serde(default)]
326    pub project_root: Option<String>,
327    /// LSP server overrides. Map language name to custom binary path.
328    /// Example: `[lsp]\nrust = "/opt/rust-analyzer"\npython = "~/.venvs/main/bin/pylsp"`
329    #[serde(default)]
330    pub lsp: std::collections::HashMap<String, String>,
331    /// Per-IDE allowed paths. Restricts which directories lean-ctx will scan/index for each IDE.
332    /// Example: `[ide_paths]\ncursor = ["/home/user/projects/app1"]\ncodex = ["/home/user/codex"]`
333    /// When set, only these paths are indexed for the matching agent. Global `allow_paths` still applies.
334    #[serde(default)]
335    pub ide_paths: HashMap<String, Vec<String>>,
336    /// Custom model context window overrides.
337    /// Example: `[model_context_windows]\n"my-custom-model" = 500000`
338    #[serde(default)]
339    pub model_context_windows: HashMap<String, usize>,
340    /// Controls how much detail tool responses include.
341    ///
342    /// - `full` (default): complete compressed output
343    /// - `headers_only`: metadata line only (path, mode, token count)
344    ///
345    /// Override via `LEAN_CTX_RESPONSE_VERBOSITY` env var.
346    #[serde(default)]
347    pub response_verbosity: ResponseVerbosity,
348    /// Bypass hint mode. When agents use native Read/Grep instead of lean-ctx tools,
349    /// a hint is appended to the next tool response.
350    /// Values: "on" (default), "off", "aggressive" (hint on every call, no cooldown).
351    /// Override via LEAN_CTX_BYPASS_HINTS env var.
352    #[serde(default)]
353    pub bypass_hints: Option<String>,
354    /// Cache policy for ctx_read. Controls behavior on cache hits.
355    /// Values: "aggressive" (default, 13-tok stubs + compaction-aware reset),
356    /// "safe" (delivers map instead of stub), "off" (no caching, always disk read).
357    /// Override via LEAN_CTX_CACHE_POLICY env var.
358    #[serde(default)]
359    pub cache_policy: Option<String>,
360    /// Cross-project boundary policy.
361    /// Controls whether cross-project search/import is allowed and whether access is audited.
362    #[serde(default)]
363    pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
364    #[serde(default)]
365    pub secret_detection: SecretDetectionConfig,
366    /// Per-item sensitivity model with a uniform policy floor (#212).
367    /// Disabled by default → fully no-op until `sensitivity.enabled = true`.
368    #[serde(default)]
369    pub sensitivity: crate::core::sensitivity::SensitivityConfig,
370    /// MCP Tool-Catalog Gateway (#210): aggregate + query-route downstream MCP
371    /// servers. Global-only (never merged from project-local config) and a full
372    /// no-op until `gateway.enabled = true`.
373    #[serde(default)]
374    pub gateway: crate::core::gateway::GatewayConfig,
375    /// Allow automatic project-root re-rooting when absolute paths outside the jail are seen.
376    /// When false (default), absolute paths outside the jail are rejected without re-rooting.
377    /// Override via LEAN_CTX_ALLOW_REROOT env var.
378    #[serde(default)]
379    pub allow_auto_reroot: bool,
380    /// Disable PathJail entirely by setting `path_jail = false` in config.toml.
381    /// Useful in container/Docker environments where the sandbox is the boundary.
382    /// (The former `LEAN_CTX_NO_JAIL=1` env override was removed in v3.7.3.)
383    #[serde(default)]
384    pub path_jail: Option<bool>,
385    /// Sandbox level for code execution (ctx_exec).
386    /// 0 = subprocess only (current), 1 = OS-level restriction (Seatbelt/Landlock).
387    /// Override via LEAN_CTX_SANDBOX_LEVEL env var.
388    #[serde(default)]
389    pub sandbox_level: u8,
390    /// When true, large tool outputs (>4000 chars) are stored as references
391    /// and a short URI is returned instead of the full content.
392    /// Override via LEAN_CTX_REFERENCE_RESULTS env var.
393    #[serde(default)]
394    pub reference_results: bool,
395    /// Default per-agent token budget. 0 means unlimited.
396    /// Override per-agent via ctx_session or programmatically.
397    #[serde(default)]
398    pub agent_token_budget: usize,
399    /// Optional shell command allowlist. When non-empty, only commands whose base binary
400    /// is in this list are permitted by ctx_shell. Empty = disable allowlist (allow all).
401    /// Default includes common dev tools. Set to `[]` to disable.
402    /// Override via LEAN_CTX_SHELL_ALLOWLIST env var (comma-separated).
403    #[serde(default = "default_shell_allowlist")]
404    pub shell_allowlist: Vec<String>,
405
406    /// Extra commands MERGED on top of the effective `shell_allowlist` without replacing
407    /// the defaults. Setting `shell_allowlist` replaces the whole built-in list (a common
408    /// footgun); entries here are purely additive, which is what `lean-ctx allow <cmd>`
409    /// writes. Only applied in restricted mode (when the base allowlist is non-empty).
410    #[serde(default)]
411    pub shell_allowlist_extra: Vec<String>,
412
413    /// When true, block command substitution ($(), backticks) and process substitution
414    /// (<(), >()) in shell arguments. When false (default), only warn via tracing.
415    /// Default false preserves backward compatibility — set true for maximum security.
416    #[serde(default)]
417    pub shell_strict_mode: bool,
418    /// Setup behavior: controls what gets injected during setup and updates.
419    #[serde(default)]
420    pub setup: SetupConfig,
421}
422
423impl Default for Config {
424    fn default() -> Self {
425        Self {
426            ultra_compact: false,
427            tee_mode: TeeMode::default(),
428            output_density: OutputDensity::default(),
429            checkpoint_interval: 15,
430            excluded_commands: Vec::new(),
431            passthrough_urls: Vec::new(),
432            custom_aliases: Vec::new(),
433            preserve_compact_formats: serde_defaults::default_preserve_compact_formats(),
434            slow_command_threshold_ms: 5000,
435            theme: serde_defaults::default_theme(),
436            cloud: CloudConfig::default(),
437            gain: GainConfig::default(),
438            autonomy: AutonomyConfig::default(),
439            providers: ProvidersConfig::default(),
440            proxy: ProxyConfig::default(),
441            proxy_enabled: None,
442            proxy_port: None,
443            proxy_timeout_ms: None,
444            buddy_enabled: serde_defaults::default_buddy_enabled(),
445            enable_wakeup_ctx: true,
446            redirect_exclude: Vec::new(),
447            disabled_tools: Vec::new(),
448            default_tool_categories: Vec::new(),
449            no_degrade: false,
450            profile: None,
451            tool_profile: None,
452            tools_enabled: Vec::new(),
453            persona: None,
454            loop_detection: LoopDetectionConfig::default(),
455            rules_scope: None,
456            rules_injection: None,
457            permission_inheritance: None,
458            extra_ignore_patterns: Vec::new(),
459            terse_agent: TerseAgent::default(),
460            compression_level: CompressionLevel::default(),
461            archive: ArchiveConfig::default(),
462            memory: MemoryPolicy::default(),
463            allow_paths: Vec::new(),
464            allow_ide_config_dirs: false,
465            extra_roots: Vec::new(),
466            content_defined_chunking: false,
467            minimal_overhead: true,
468            symbol_map_auto: false,
469            structure_first: false,
470            team_url: None,
471            team_token: None,
472            team_auto_push: false,
473            journal_enabled: true,
474            auto_capture: true,
475            search: crate::core::hybrid_search::HybridConfig::default(),
476            graph: GraphConfig::default(),
477            skillify: SkillifyConfig::default(),
478            summaries: SummariesConfig::default(),
479            llm: crate::core::llm_enhance::LlmConfig::default(),
480            embedding: EmbeddingConfig::default(),
481            shell_hook_disabled: false,
482            shadow_mode: false,
483            shell_activation: ShellActivation::default(),
484            update_check_disabled: false,
485            updates: UpdatesConfig::default(),
486            graph_index_max_files: serde_defaults::default_graph_index_max_files(),
487            bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
488            memory_profile: MemoryProfile::default(),
489            memory_cleanup: MemoryCleanup::default(),
490            max_ram_percent: serde_defaults::default_max_ram_percent(),
491            max_disk_mb: 0,
492            max_staleness_days: 0,
493            savings_footer: SavingsFooter::default(),
494            project_root: None,
495            lsp: std::collections::HashMap::new(),
496            ide_paths: HashMap::new(),
497            model_context_windows: HashMap::new(),
498            response_verbosity: ResponseVerbosity::default(),
499            bypass_hints: None,
500            cache_policy: None,
501            boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
502            secret_detection: SecretDetectionConfig::default(),
503            sensitivity: crate::core::sensitivity::SensitivityConfig::default(),
504            gateway: crate::core::gateway::GatewayConfig::default(),
505            allow_auto_reroot: false,
506            path_jail: None,
507            sandbox_level: 0,
508            reference_results: false,
509            agent_token_budget: 0,
510            shell_allowlist: default_shell_allowlist(),
511            shell_allowlist_extra: Vec::new(),
512            shell_strict_mode: false,
513            setup: SetupConfig::default(),
514        }
515    }
516}
517
518/// Holds the most recent global `config.toml` parse error, if the file currently
519/// fails to parse. When that happens `Config::load()` silently falls back to the
520/// built-in defaults and only logs to stderr — which is invisible over an MCP/stdio
521/// transport. Recording it here lets callers (e.g. the shell-allowlist diagnostic
522/// and `lean-ctx doctor`) surface "you're on defaults because your config is broken".
523static LAST_PARSE_ERROR: Mutex<Option<String>> = Mutex::new(None);
524
525/// Returns the most recent global config parse error, or `None` if the current
526/// `config.toml` parsed successfully (or no config file exists).
527#[must_use]
528pub fn last_config_parse_error() -> Option<String> {
529    LAST_PARSE_ERROR.lock().ok().and_then(|g| g.clone())
530}
531
532fn record_parse_error(err: Option<String>) {
533    if let Ok(mut guard) = LAST_PARSE_ERROR.lock() {
534        *guard = err;
535    }
536}
537
538impl Config {
539    /// Returns the effective rules scope, preferring env var over config file.
540    pub fn rules_scope_effective(&self) -> RulesScope {
541        let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
542            .ok()
543            .or_else(|| self.rules_scope.clone())
544            .unwrap_or_default();
545        match raw.trim().to_lowercase().as_str() {
546            "global" => RulesScope::Global,
547            "project" => RulesScope::Project,
548            _ => RulesScope::Both,
549        }
550    }
551
552    /// Returns the effective rules injection mode, preferring env var over config.
553    /// Default is `Shared` (zero-config discovery via a CLAUDE.md/CODEBUDDY.md/AGENTS.md block).
554    pub fn rules_injection_effective(&self) -> RulesInjection {
555        let raw = std::env::var("LEAN_CTX_RULES_INJECTION")
556            .ok()
557            .or_else(|| self.rules_injection.clone())
558            .unwrap_or_default();
559        match raw.trim().to_lowercase().as_str() {
560            "dedicated" => RulesInjection::Dedicated,
561            "off" | "none" | "disabled" => RulesInjection::Off,
562            _ => RulesInjection::Shared,
563        }
564    }
565
566    /// Returns the effective permission-inheritance mode, preferring the
567    /// `LEAN_CTX_PERMISSION_INHERITANCE` env var over config. Default is `Off`.
568    /// Accepts `on`/`true`/`1` as enabled.
569    #[must_use]
570    pub fn permission_inheritance_effective(&self) -> PermissionInheritance {
571        let raw = std::env::var("LEAN_CTX_PERMISSION_INHERITANCE")
572            .ok()
573            .or_else(|| self.permission_inheritance.clone())
574            .unwrap_or_default();
575        match raw.trim().to_lowercase().as_str() {
576            "on" | "true" | "1" | "inherit" => PermissionInheritance::On,
577            _ => PermissionInheritance::Off,
578        }
579    }
580
581    /// True when lean-ctx should inject its rules via each agent's dedicated,
582    /// non-polluting auto-load path *and* global rules are in scope.
583    ///
584    /// Gates the Claude/Codex `SessionStart` `additionalContext` summary: it
585    /// stands in for the (now-skipped) shared CLAUDE.md/CODEBUDDY.md/AGENTS.md block, so it
586    /// only fires when injection is `Dedicated` and the scope isn't project-only.
587    #[must_use]
588    pub fn dedicated_session_context_active(&self) -> bool {
589        self.rules_injection_effective() == RulesInjection::Dedicated
590            && self.rules_scope_effective() != RulesScope::Project
591    }
592
593    fn parse_disabled_tools_env(val: &str) -> Vec<String> {
594        val.split(',')
595            .map(|s| s.trim().to_string())
596            .filter(|s| !s.is_empty())
597            .collect()
598    }
599
600    /// Returns the effective disabled tools list, preferring env var over config file.
601    pub fn disabled_tools_effective(&self) -> Vec<String> {
602        if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
603            Self::parse_disabled_tools_env(&val)
604        } else {
605            self.disabled_tools.clone()
606        }
607    }
608
609    /// Returns `true` if minimal overhead is enabled via env var or config.
610    pub fn minimal_overhead_effective(&self) -> bool {
611        std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
612    }
613
614    /// Returns `true` if structure-first auto reads are enabled.
615    ///
616    /// The `LEAN_CTX_STRUCTURE_FIRST` env var wins over the config field, and
617    /// accepts the usual truthy/falsy spellings so a harness can flip it per run
618    /// (`LEAN_CTX_STRUCTURE_FIRST=0` forces it off even if config enables it).
619    pub fn structure_first_effective(&self) -> bool {
620        match std::env::var("LEAN_CTX_STRUCTURE_FIRST") {
621            Ok(raw) => matches!(
622                raw.trim().to_lowercase().as_str(),
623                "1" | "true" | "yes" | "on"
624            ),
625            Err(_) => self.structure_first,
626        }
627    }
628
629    /// Returns `true` if minimal overhead should be enabled for this MCP client.
630    ///
631    /// This is a superset of `minimal_overhead_effective()`:
632    /// - `LEAN_CTX_OVERHEAD_MODE=minimal` forces minimal overhead
633    /// - `LEAN_CTX_OVERHEAD_MODE=full` disables client/model heuristics (still honors LEAN_CTX_MINIMAL / config)
634    /// - In auto mode (default), certain low-context clients/models are treated as minimal to prevent
635    ///   large metadata blocks from destabilizing smaller context windows (e.g. Hermes + MiniMax).
636    pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
637        if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
638            match raw.trim().to_lowercase().as_str() {
639                "minimal" => return true,
640                "full" => return self.minimal_overhead_effective(),
641                _ => {}
642            }
643        }
644
645        if self.minimal_overhead_effective() {
646            return true;
647        }
648
649        let client_lower = client_name.trim().to_lowercase();
650        if !client_lower.is_empty() {
651            if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
652                for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
653                    if !needle.is_empty() && client_lower.contains(&needle) {
654                        return true;
655                    }
656                }
657            } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
658                return true;
659            }
660        }
661
662        let model = std::env::var("LEAN_CTX_MODEL")
663            .or_else(|_| std::env::var("LCTX_MODEL"))
664            .unwrap_or_default();
665        let model = model.trim().to_lowercase();
666        if !model.is_empty() {
667            let m = model.replace(['_', ' '], "-");
668            if m.contains("minimax")
669                || m.contains("mini-max")
670                || m.contains("m2.7")
671                || m.contains("m2-7")
672            {
673                return true;
674            }
675        }
676
677        false
678    }
679
680    /// Returns `true` if shell hook injection is disabled via env var or config.
681    pub fn shell_hook_disabled_effective(&self) -> bool {
682        std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
683    }
684
685    /// Returns the effective shell activation mode (env var > config > default).
686    pub fn shell_activation_effective(&self) -> ShellActivation {
687        ShellActivation::effective(self)
688    }
689
690    /// Returns `true` if the daily update check is disabled via env var or config.
691    pub fn update_check_disabled_effective(&self) -> bool {
692        std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
693    }
694
695    pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
696        let mut policy = self.memory.clone();
697        policy.apply_env_overrides();
698
699        // Scale memory limits proportionally when max_disk_mb is set
700        // and individual limits are still at their defaults.
701        let budget = self.max_disk_mb_effective();
702        if budget > 0 {
703            let scale_factor = (budget as f64 / 500.0).clamp(0.5, 10.0);
704            let default_policy = MemoryPolicy::default();
705            if policy.knowledge.max_facts == default_policy.knowledge.max_facts {
706                policy.knowledge.max_facts = (200.0 * scale_factor) as usize;
707            }
708            if policy.knowledge.max_patterns == default_policy.knowledge.max_patterns {
709                policy.knowledge.max_patterns = (50.0 * scale_factor) as usize;
710            }
711            if policy.episodic.max_episodes == default_policy.episodic.max_episodes {
712                policy.episodic.max_episodes = (500.0 * scale_factor) as usize;
713            }
714            if policy.procedural.max_procedures == default_policy.procedural.max_procedures {
715                policy.procedural.max_procedures = (100.0 * scale_factor) as usize;
716            }
717        }
718
719        policy.validate()?;
720        Ok(policy)
721    }
722
723    /// Returns the effective set of default tool categories.
724    /// Priority: LCTX_DEFAULT_CATEGORIES env var > config.toml > hardcoded default.
725    pub fn default_tool_categories_effective(&self) -> Vec<String> {
726        if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
727            return val
728                .split(',')
729                .map(|s| s.trim().to_lowercase())
730                .filter(|s| !s.is_empty())
731                .collect();
732        }
733        if !self.default_tool_categories.is_empty() {
734            return self
735                .default_tool_categories
736                .iter()
737                .map(|s| s.to_lowercase())
738                .collect();
739        }
740        vec!["core".to_string(), "session".to_string()]
741    }
742
743    /// Returns the effective tool profile.
744    /// Priority: LEAN_CTX_TOOL_PROFILE env > config tool_profile > config
745    /// tools_enabled > active persona's tool surface > power.
746    ///
747    /// Explicit settings win (backward compatible); when none are set, the
748    /// active persona supplies the tool surface (the `coding` default resolves
749    /// to `power`, so existing installs are unaffected).
750    pub fn tool_profile_effective(&self) -> super::tool_profiles::ToolProfile {
751        super::persona::Persona::resolve(self).effective_tool_profile(self)
752    }
753
754    /// Returns `true` if all automatic read-mode degradation is disabled.
755    /// Checks LCTX_NO_DEGRADE env var first, then config.toml field.
756    pub fn no_degrade_effective(&self) -> bool {
757        if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
758            return val == "1" || val.eq_ignore_ascii_case("true");
759        }
760        self.no_degrade
761    }
762
763    /// Effective max_disk_mb from env or config.
764    pub fn max_disk_mb_effective(&self) -> u64 {
765        std::env::var("LEAN_CTX_MAX_DISK_MB")
766            .ok()
767            .and_then(|v| v.parse().ok())
768            .unwrap_or(self.max_disk_mb)
769    }
770
771    /// Effective max_staleness_days from env or config.
772    pub fn max_staleness_days_effective(&self) -> u32 {
773        std::env::var("LEAN_CTX_MAX_STALENESS_DAYS")
774            .ok()
775            .and_then(|v| v.parse().ok())
776            .unwrap_or(self.max_staleness_days)
777    }
778
779    /// Archive max_disk_mb derived from simplified max_disk_mb if the detail
780    /// value is still at its default. Explicit overrides take priority.
781    pub fn archive_max_disk_mb_effective(&self) -> u64 {
782        let budget = self.max_disk_mb_effective();
783        if budget > 0 && self.archive.max_disk_mb == ArchiveConfig::default().max_disk_mb {
784            budget * 25 / 100
785        } else {
786            self.archive.max_disk_mb
787        }
788    }
789
790    /// Archive max_age_hours derived from max_staleness_days if the detail
791    /// value is still at its default. Explicit overrides take priority.
792    pub fn archive_max_age_hours_effective(&self) -> u64 {
793        let staleness = self.max_staleness_days_effective();
794        if staleness > 0 && self.archive.max_age_hours == ArchiveConfig::default().max_age_hours {
795            staleness as u64 * 24
796        } else {
797            self.archive.max_age_hours
798        }
799    }
800
801    /// Effective on-disk ceiling (MB) for the persisted BM25 index. Single source
802    /// of truth for `save`/`load`, `cache prune`, and the doctor health check.
803    ///
804    /// Priority: explicit `bm25_max_cache_mb` › `max_disk_mb` budget (10%) ›
805    /// generous default ([`DEFAULT_BM25_PERSIST_MB`]). The default is decoupled
806    /// from the RAM profile so large repos persist instead of rebuilding forever
807    /// (issue #249).
808    pub fn bm25_max_cache_mb_effective(&self) -> u64 {
809        // Explicit per-key override always wins.
810        if self.bm25_max_cache_mb != serde_defaults::default_bm25_max_cache_mb() {
811            return self.bm25_max_cache_mb;
812        }
813        // Otherwise derive from an explicit overall disk budget when present …
814        let budget = self.max_disk_mb_effective();
815        if budget > 0 {
816            return budget * 10 / 100;
817        }
818        // … else fall back to the generous, profile-independent disk default.
819        DEFAULT_BM25_PERSIST_MB
820    }
821}
822
823impl Config {
824    /// Returns the path to the global config file (`$XDG_CONFIG_HOME/lean-ctx/config.toml`).
825    ///
826    /// Resolves via [`crate::core::paths::config_dir`] so config lives in the
827    /// RO-safe config category. Behavior-neutral today: `config_dir()` equals the
828    /// legacy data dir for existing/single-dir installs (GH #408 / GL #602).
829    pub fn path() -> Option<PathBuf> {
830        crate::core::paths::config_dir()
831            .ok()
832            .map(|d| d.join("config.toml"))
833    }
834
835    /// Returns the path to the project-local config override file.
836    pub fn local_path(project_root: &str) -> PathBuf {
837        PathBuf::from(project_root).join(".lean-ctx.toml")
838    }
839
840    fn find_project_root() -> Option<String> {
841        static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
842        ROOT_CACHE
843            .get_or_init(Self::find_project_root_inner)
844            .clone()
845    }
846
847    fn find_project_root_inner() -> Option<String> {
848        if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
849            if !env_root.is_empty() {
850                return Some(env_root);
851            }
852        }
853
854        let cwd = std::env::current_dir().ok();
855
856        if let Some(root) =
857            crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
858        {
859            let root_path = std::path::Path::new(&root);
860            let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
861            let has_marker = root_path.join(".git").exists()
862                || root_path.join("Cargo.toml").exists()
863                || root_path.join("package.json").exists()
864                || root_path.join("go.mod").exists()
865                || root_path.join("pyproject.toml").exists()
866                || root_path.join(".lean-ctx.toml").exists();
867
868            if cwd_is_under_root || has_marker {
869                return Some(root);
870            }
871        }
872
873        if let Some(ref cwd) = cwd {
874            let git_root = std::process::Command::new("git")
875                .args(["rev-parse", "--show-toplevel"])
876                .current_dir(cwd)
877                .stdout(std::process::Stdio::piped())
878                .stderr(std::process::Stdio::null())
879                .output()
880                .ok()
881                .and_then(|o| {
882                    if o.status.success() {
883                        String::from_utf8(o.stdout)
884                            .ok()
885                            .map(|s| s.trim().to_string())
886                    } else {
887                        None
888                    }
889                });
890            if let Some(root) = git_root {
891                return Some(root);
892            }
893            if !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
894                return Some(cwd.to_string_lossy().to_string());
895            }
896        }
897        None
898    }
899
900    /// Loads config from disk with caching, merging global + project-local overrides.
901    ///
902    /// The cache is keyed on a **content hash** of the global + project-local
903    /// files, not their mtime. mtime-only invalidation silently served a stale
904    /// `Config` whenever a content edit preserved the mtime (coarse filesystem
905    /// mtime resolution, `cp -p`, atomic save-then-rename, two edits within the
906    /// same second). A long-lived MCP server then kept the old value (e.g.
907    /// `path_jail`) while a fresh `lean-ctx doctor` process — with an empty
908    /// cache — saw the new one (#406). Config files are tiny, so reading +
909    /// hashing them on every load is negligible and guarantees liveness.
910    pub fn load() -> Self {
911        static CACHE: Mutex<Option<(Config, Option<String>, Option<String>)>> = Mutex::new(None);
912
913        let Some(path) = Self::path() else {
914            return Self::default();
915        };
916
917        let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
918
919        // Read raw content up front so the cache key is a content hash.
920        let global_content = std::fs::read_to_string(&path).ok();
921        let local_content = local_path
922            .as_ref()
923            .and_then(|p| std::fs::read_to_string(p).ok());
924
925        let global_hash = global_content.as_deref().map(crate::core::hasher::hash_str);
926        let local_hash = local_content.as_deref().map(crate::core::hasher::hash_str);
927
928        if let Ok(guard) = CACHE.lock() {
929            if let Some((ref cfg, ref cached_global, ref cached_local)) = *guard {
930                if *cached_global == global_hash && *cached_local == local_hash {
931                    return cfg.clone();
932                }
933            }
934        }
935
936        let mut cfg: Config = if let Some(ref content) = global_content {
937            match toml::from_str(content) {
938                Ok(c) => {
939                    record_parse_error(None);
940                    c
941                }
942                Err(e) => {
943                    record_parse_error(Some(format!("{e}")));
944                    tracing::warn!("config parse error in {}: {e}", path.display());
945                    eprintln!(
946                        "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n  \
947                         Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
948                        path.display()
949                    );
950                    Self::default()
951                }
952            }
953        } else {
954            record_parse_error(None);
955            Self::default()
956        };
957
958        if let Some(ref local) = local_content {
959            cfg.merge_local(local);
960        }
961
962        if let Ok(mut guard) = CACHE.lock() {
963            *guard = Some((cfg.clone(), global_hash, local_hash));
964        }
965
966        cfg
967    }
968
969    fn merge_local(&mut self, local_toml: &str) {
970        let local: Config = match toml::from_str(local_toml) {
971            Ok(c) => c,
972            Err(e) => {
973                tracing::warn!("local config parse error: {e}");
974                eprintln!(
975                    "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n  \
976                     Local overrides skipped.\x1b[0m"
977                );
978                return;
979            }
980        };
981        if local.ultra_compact {
982            self.ultra_compact = true;
983        }
984        if local.tee_mode != TeeMode::default() {
985            self.tee_mode = local.tee_mode;
986        }
987        if local.output_density != OutputDensity::default() {
988            self.output_density = local.output_density;
989        }
990        if local.checkpoint_interval != 15 {
991            self.checkpoint_interval = local.checkpoint_interval;
992        }
993        if !local.excluded_commands.is_empty() {
994            self.excluded_commands.extend(local.excluded_commands);
995        }
996        if !local.passthrough_urls.is_empty() {
997            self.passthrough_urls.extend(local.passthrough_urls);
998        }
999        if !local.custom_aliases.is_empty() {
1000            self.custom_aliases.extend(local.custom_aliases);
1001        }
1002        // Additive merge with dedup: project-local config can add formats on top
1003        // of the global default (`["toon"]`) without re-listing it.
1004        for fmt in local.preserve_compact_formats {
1005            if !self
1006                .preserve_compact_formats
1007                .iter()
1008                .any(|f| f.eq_ignore_ascii_case(&fmt))
1009            {
1010                self.preserve_compact_formats.push(fmt);
1011            }
1012        }
1013        if local.slow_command_threshold_ms != 5000 {
1014            self.slow_command_threshold_ms = local.slow_command_threshold_ms;
1015        }
1016        if local.theme != "default" {
1017            self.theme = local.theme;
1018        }
1019        if !local.buddy_enabled {
1020            self.buddy_enabled = false;
1021        }
1022        if !local.enable_wakeup_ctx {
1023            self.enable_wakeup_ctx = false;
1024        }
1025        if !local.redirect_exclude.is_empty() {
1026            self.redirect_exclude.extend(local.redirect_exclude);
1027        }
1028        if !local.disabled_tools.is_empty() {
1029            self.disabled_tools.extend(local.disabled_tools);
1030        }
1031        if !local.extra_ignore_patterns.is_empty() {
1032            self.extra_ignore_patterns
1033                .extend(local.extra_ignore_patterns);
1034        }
1035        if local.rules_scope.is_some() {
1036            self.rules_scope = local.rules_scope;
1037        }
1038        if local.rules_injection.is_some() {
1039            self.rules_injection = local.rules_injection;
1040        }
1041        if local.permission_inheritance.is_some() {
1042            self.permission_inheritance = local.permission_inheritance;
1043        }
1044        if local.proxy.anthropic_upstream.is_some() {
1045            self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1046        }
1047        if local.proxy.openai_upstream.is_some() {
1048            self.proxy.openai_upstream = local.proxy.openai_upstream;
1049        }
1050        if local.proxy.gemini_upstream.is_some() {
1051            self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1052        }
1053        if !local.autonomy.enabled {
1054            self.autonomy.enabled = false;
1055        }
1056        if !local.autonomy.auto_preload {
1057            self.autonomy.auto_preload = false;
1058        }
1059        if !local.autonomy.auto_dedup {
1060            self.autonomy.auto_dedup = false;
1061        }
1062        if !local.autonomy.auto_related {
1063            self.autonomy.auto_related = false;
1064        }
1065        if !local.autonomy.auto_consolidate {
1066            self.autonomy.auto_consolidate = false;
1067        }
1068        if local.autonomy.silent_preload {
1069            self.autonomy.silent_preload = true;
1070        }
1071        if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1072            self.autonomy.silent_preload = false;
1073        }
1074        if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1075            self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1076        }
1077        if local.autonomy.consolidate_every_calls
1078            != AutonomyConfig::default().consolidate_every_calls
1079        {
1080            self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1081        }
1082        if local.autonomy.consolidate_cooldown_secs
1083            != AutonomyConfig::default().consolidate_cooldown_secs
1084        {
1085            self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1086        }
1087        if !local.autonomy.cognition_loop_enabled {
1088            self.autonomy.cognition_loop_enabled = false;
1089        }
1090        if local.autonomy.cognition_loop_interval_secs
1091            != AutonomyConfig::default().cognition_loop_interval_secs
1092        {
1093            self.autonomy.cognition_loop_interval_secs =
1094                local.autonomy.cognition_loop_interval_secs;
1095        }
1096        if local.autonomy.cognition_loop_max_steps
1097            != AutonomyConfig::default().cognition_loop_max_steps
1098        {
1099            self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1100        }
1101        if local_toml.contains("compression_level") {
1102            self.compression_level = local.compression_level;
1103        }
1104        if local_toml.contains("terse_agent") {
1105            self.terse_agent = local.terse_agent;
1106        }
1107        if !local.archive.enabled {
1108            self.archive.enabled = false;
1109        }
1110        if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1111            self.archive.threshold_chars = local.archive.threshold_chars;
1112        }
1113        if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1114            self.archive.max_age_hours = local.archive.max_age_hours;
1115        }
1116        if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1117            self.archive.max_disk_mb = local.archive.max_disk_mb;
1118        }
1119        if !local.archive.ephemeral {
1120            self.archive.ephemeral = false;
1121        }
1122        if local.archive.ephemeral_min_tokens != ArchiveConfig::default().ephemeral_min_tokens {
1123            self.archive.ephemeral_min_tokens = local.archive.ephemeral_min_tokens;
1124        }
1125        let mem_def = MemoryPolicy::default();
1126        if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1127            self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1128        }
1129        if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1130            self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1131        }
1132        if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1133            self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1134        }
1135        if local.memory.knowledge.contradiction_threshold
1136            != mem_def.knowledge.contradiction_threshold
1137        {
1138            self.memory.knowledge.contradiction_threshold =
1139                local.memory.knowledge.contradiction_threshold;
1140        }
1141
1142        if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1143            self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1144        }
1145        if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1146        {
1147            self.memory.episodic.max_actions_per_episode =
1148                local.memory.episodic.max_actions_per_episode;
1149        }
1150        if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1151            self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1152        }
1153
1154        if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1155            self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1156        }
1157        if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1158            self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1159        }
1160        if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1161            self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1162        }
1163        if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1164            self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1165        }
1166
1167        if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1168            self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1169        }
1170        if local.memory.lifecycle.low_confidence_threshold
1171            != mem_def.lifecycle.low_confidence_threshold
1172        {
1173            self.memory.lifecycle.low_confidence_threshold =
1174                local.memory.lifecycle.low_confidence_threshold;
1175        }
1176        if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1177            self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1178        }
1179        if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1180            self.memory.lifecycle.similarity_threshold =
1181                local.memory.lifecycle.similarity_threshold;
1182        }
1183
1184        if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1185            self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1186        }
1187        if !local.allow_paths.is_empty() {
1188            self.allow_paths.extend(local.allow_paths);
1189        }
1190        if !local.extra_roots.is_empty() {
1191            self.extra_roots.extend(local.extra_roots);
1192        }
1193        if local.minimal_overhead {
1194            self.minimal_overhead = true;
1195        }
1196        if local.shell_hook_disabled {
1197            self.shell_hook_disabled = true;
1198        }
1199        if local.shell_activation != ShellActivation::default() {
1200            self.shell_activation = local.shell_activation.clone();
1201        }
1202        if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1203            self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1204        }
1205        if local.memory_profile != MemoryProfile::default() {
1206            self.memory_profile = local.memory_profile;
1207        }
1208        if local.memory_cleanup != MemoryCleanup::default() {
1209            self.memory_cleanup = local.memory_cleanup;
1210        }
1211        // Only override when the local file actually defines `shell_allowlist`.
1212        // The field carries `#[serde(default = "default_shell_allowlist")]`, so a
1213        // local `.lean-ctx.toml` that omits the key still deserializes to the full
1214        // 201-entry built-in list — an `is_empty()` guard would then silently clobber
1215        // a deliberately shorter global allowlist with the defaults. Comparing against
1216        // the default (the same pattern used for every other merged field) treats
1217        // "omitted" as "no override".
1218        if local.shell_allowlist != default_shell_allowlist() {
1219            self.shell_allowlist = local.shell_allowlist;
1220        }
1221        if !local.shell_allowlist_extra.is_empty() {
1222            self.shell_allowlist_extra
1223                .extend(local.shell_allowlist_extra);
1224        }
1225        if !local.default_tool_categories.is_empty() {
1226            self.default_tool_categories = local.default_tool_categories;
1227        }
1228        if local.tool_profile.is_some() {
1229            self.tool_profile = local.tool_profile;
1230        }
1231        if !local.tools_enabled.is_empty() {
1232            self.tools_enabled = local.tools_enabled;
1233        }
1234        if local.no_degrade {
1235            self.no_degrade = true;
1236        }
1237        if local.profile.is_some() {
1238            self.profile = local.profile;
1239        }
1240        if local.proxy_timeout_ms.is_some() {
1241            self.proxy_timeout_ms = local.proxy_timeout_ms;
1242        }
1243    }
1244
1245    /// Persists the current config to the global config file.
1246    ///
1247    /// Preserves user comments, formatting, and unknown keys, keeps the file
1248    /// minimal (defaults that were never set on disk stay implicit), and writes
1249    /// atomically with a `.bak` backup so customizations are always recoverable.
1250    pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1251        let path = Self::path().ok_or_else(|| {
1252            super::error::LeanCtxError::Config("cannot determine home directory".into())
1253        })?;
1254        if let Some(parent) = path.parent() {
1255            std::fs::create_dir_all(parent)?;
1256        }
1257        let content = toml::to_string_pretty(self)
1258            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1259        // Baseline = what loading an empty config yields. This honors serde's
1260        // field-level `#[serde(default)]` (which can diverge from the struct's
1261        // `Default` impl), so minimal mode skips exactly the keys that a fresh
1262        // load would produce — no spurious lines on save.
1263        let baseline = toml::from_str::<Self>("").unwrap_or_else(|_| Self::default());
1264        let defaults = toml::to_string_pretty(&baseline)
1265            .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1266        crate::config_io::write_toml_preserving_minimal(&path, &content, &defaults)
1267            .map_err(super::error::LeanCtxError::Config)?;
1268        Ok(())
1269    }
1270
1271    /// Formats the current config as a human-readable string with file paths.
1272    pub fn show(&self) -> String {
1273        let global_path = Self::path().map_or_else(
1274            || "~/.lean-ctx/config.toml".to_string(),
1275            |p| p.to_string_lossy().to_string(),
1276        );
1277        let content = toml::to_string_pretty(self).unwrap_or_default();
1278        let mut out = format!("Global config: {global_path}\n\n{content}");
1279
1280        if let Some(root) = Self::find_project_root() {
1281            let local = Self::local_path(&root);
1282            if local.exists() {
1283                out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1284            } else {
1285                out.push_str(&format!(
1286                    "\n\nLocal config: not found (create {} to override per-project)\n",
1287                    local.display()
1288                ));
1289            }
1290        }
1291        out
1292    }
1293}