Skip to main content

lean_ctx/core/config/
sections.rs

1//! Auxiliary configuration section structs.
2//!
3//! Nested config structs (secret-detection, setup, archive, providers,
4//! autonomy, updates, cloud, gain, loop-detection, embedding, …) split out of
5//! `config/mod.rs` to keep the top-level module focused on `Config` itself.
6//! Re-exported via `pub use sections::*`, so external paths stay stable.
7
8use super::serde_defaults;
9#[allow(clippy::wildcard_imports)]
10use super::*;
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(default)]
15pub struct SecretDetectionConfig {
16    pub enabled: bool,
17    pub redact: bool,
18    pub custom_patterns: Vec<String>,
19}
20
21/// Controls what lean-ctx injects during `setup` and `update --rewire`.
22/// Fresh installs default to non-invasive (rules/skills off, MCP on).
23/// Users who ran setup interactively get explicit true/false.
24/// `None` = undecided (legacy: check if rules already exist and preserve behavior).
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(default)]
27pub struct SetupConfig {
28    /// Inject agent rule files (CLAUDE.md, .cursor/rules/, etc.).
29    /// None = undecided (legacy compat: inject if rules already present).
30    /// Some(true) = always inject. Some(false) = never inject.
31    pub auto_inject_rules: Option<bool>,
32    /// Install SKILL.md files for supported agents.
33    /// None = undecided. Some(true) = install. Some(false) = skip.
34    pub auto_inject_skills: Option<bool>,
35    /// Register lean-ctx as an MCP server in editor configs.
36    #[serde(default = "serde_defaults::default_true")]
37    pub auto_update_mcp: bool,
38}
39
40impl Default for SetupConfig {
41    fn default() -> Self {
42        Self {
43            auto_inject_rules: None,
44            auto_inject_skills: None,
45            auto_update_mcp: true,
46        }
47    }
48}
49
50impl SetupConfig {
51    /// Returns whether rules should be injected, considering legacy installs.
52    /// If undecided (None), checks if lean-ctx rules markers already exist
53    /// in any agent config — if so, keeps injecting for backward compat.
54    pub fn should_inject_rules(&self) -> bool {
55        match self.auto_inject_rules {
56            Some(v) => v,
57            None => Self::rules_already_present(),
58        }
59    }
60
61    /// Returns whether skills should be installed.
62    pub fn should_inject_skills(&self) -> bool {
63        match self.auto_inject_skills {
64            Some(v) => v,
65            None => Self::rules_already_present(),
66        }
67    }
68
69    /// Check if lean-ctx rules markers exist in any known agent config location.
70    fn rules_already_present() -> bool {
71        let Some(home) = dirs::home_dir() else {
72            return false;
73        };
74        let marker = crate::rules_inject::RULES_MARKER;
75        let check_paths = [
76            home.join(".cursor/rules/lean-ctx.mdc"),
77            crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md"),
78            home.join(".gemini/GEMINI.md"),
79            home.join(".codeium/windsurf/rules/lean-ctx.md"),
80        ];
81        for p in &check_paths {
82            if let Ok(content) = std::fs::read_to_string(p) {
83                if content.contains(marker) {
84                    return true;
85                }
86            }
87        }
88        false
89    }
90}
91
92impl Default for SecretDetectionConfig {
93    fn default() -> Self {
94        Self {
95            enabled: true,
96            redact: true,
97            custom_patterns: Vec::new(),
98        }
99    }
100}
101
102/// Settings for the zero-loss compression archive (large tool outputs saved to disk).
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(default)]
105pub struct ArchiveConfig {
106    pub enabled: bool,
107    pub threshold_chars: usize,
108    pub max_age_hours: u64,
109    pub max_disk_mb: u64,
110    pub ephemeral: bool,
111    /// Minimum output tokens before the ephemeral firewall replaces an inline tool
112    /// result with a summary + retrieval ref. Outputs below this stay fully inline.
113    pub ephemeral_min_tokens: usize,
114}
115
116impl Default for ArchiveConfig {
117    fn default() -> Self {
118        Self {
119            enabled: true,
120            threshold_chars: 800,
121            max_age_hours: 48,
122            max_disk_mb: 500,
123            ephemeral: true,
124            ephemeral_min_tokens: 2000,
125        }
126    }
127}
128
129impl ArchiveConfig {
130    pub fn ephemeral_effective(&self) -> bool {
131        if let Ok(v) = std::env::var("LEAN_CTX_EPHEMERAL") {
132            return !matches!(v.trim(), "0" | "false" | "off");
133        }
134        self.ephemeral && self.enabled
135    }
136
137    pub fn ephemeral_min_tokens_effective(&self) -> usize {
138        if let Ok(v) = std::env::var("LEAN_CTX_EPHEMERAL_MIN_TOKENS") {
139            if let Ok(n) = v.trim().parse::<usize>() {
140                return n;
141            }
142        }
143        self.ephemeral_min_tokens
144    }
145}
146
147/// Configuration for external context providers (GitHub, GitLab, Jira, etc.).
148/// Each provider can be enabled/disabled and configured with auth tokens.
149/// Override individual tokens via env vars (GITHUB_TOKEN, GITLAB_TOKEN, etc.).
150#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(default)]
152pub struct ProvidersConfig {
153    /// Master switch for the provider subsystem.
154    pub enabled: bool,
155    /// GitHub provider configuration.
156    pub github: ProviderEntryConfig,
157    /// GitLab provider configuration.
158    pub gitlab: ProviderEntryConfig,
159    /// Auto-ingest provider results into BM25/embedding indexes.
160    pub auto_index: bool,
161    /// Default cache TTL for provider results (seconds).
162    pub cache_ttl_secs: u64,
163    /// MCP Bridge providers: `{ "name" = { url = "...", description = "..." } }`.
164    #[serde(default)]
165    pub mcp_bridges: std::collections::HashMap<String, McpBridgeEntry>,
166}
167
168impl Default for ProvidersConfig {
169    fn default() -> Self {
170        Self {
171            enabled: true,
172            github: ProviderEntryConfig::default(),
173            gitlab: ProviderEntryConfig::default(),
174            auto_index: true,
175            cache_ttl_secs: 120,
176            mcp_bridges: std::collections::HashMap::new(),
177        }
178    }
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct McpBridgeEntry {
183    /// HTTP/SSE URL for remote MCP servers.
184    #[serde(default)]
185    pub url: Option<String>,
186    /// Command to spawn a local MCP server (stdio transport).
187    #[serde(default)]
188    pub command: Option<String>,
189    /// Arguments for the command.
190    #[serde(default)]
191    pub args: Vec<String>,
192    /// Human-readable description.
193    #[serde(default)]
194    pub description: Option<String>,
195    /// Environment variable name containing an auth token.
196    #[serde(default)]
197    pub auth_env: Option<String>,
198}
199
200/// Per-provider configuration entry.
201#[derive(Debug, Clone, Serialize, Deserialize)]
202#[serde(default)]
203pub struct ProviderEntryConfig {
204    /// Whether this specific provider is enabled.
205    pub enabled: bool,
206    /// Auth token (prefer env var; only use this for project-local overrides).
207    pub token: Option<String>,
208    /// API base URL override (for GitHub Enterprise, self-hosted GitLab, etc.).
209    pub api_url: Option<String>,
210    /// Default project/repo for this provider (auto-detected from git remote if empty).
211    pub project: Option<String>,
212}
213
214impl Default for ProviderEntryConfig {
215    fn default() -> Self {
216        Self {
217            enabled: true,
218            token: None,
219            api_url: None,
220            project: None,
221        }
222    }
223}
224
225/// Controls autonomous background behaviors (preload, dedup, consolidation).
226#[derive(Debug, Clone, Serialize, Deserialize)]
227#[serde(default)]
228pub struct AutonomyConfig {
229    pub enabled: bool,
230    pub auto_preload: bool,
231    pub auto_dedup: bool,
232    pub auto_related: bool,
233    pub auto_consolidate: bool,
234    pub silent_preload: bool,
235    pub dedup_threshold: usize,
236    pub consolidate_every_calls: u32,
237    pub consolidate_cooldown_secs: u64,
238    #[serde(default = "serde_defaults::default_true")]
239    pub cognition_loop_enabled: bool,
240    #[serde(default = "serde_defaults::default_cognition_loop_interval")]
241    pub cognition_loop_interval_secs: u64,
242    #[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
243    pub cognition_loop_max_steps: u8,
244}
245
246impl Default for AutonomyConfig {
247    fn default() -> Self {
248        Self {
249            enabled: true,
250            auto_preload: true,
251            auto_dedup: true,
252            auto_related: true,
253            auto_consolidate: true,
254            silent_preload: true,
255            dedup_threshold: 8,
256            consolidate_every_calls: 25,
257            consolidate_cooldown_secs: 120,
258            cognition_loop_enabled: true,
259            cognition_loop_interval_secs: 3600,
260            cognition_loop_max_steps: 8,
261        }
262    }
263}
264
265/// Controls automatic update behavior. All defaults are OFF — auto-updates
266/// require explicit opt-in via `lean-ctx setup` or `lean-ctx update --schedule`.
267#[derive(Debug, Clone, Serialize, Deserialize)]
268#[serde(default)]
269pub struct UpdatesConfig {
270    pub auto_update: bool,
271    pub check_interval_hours: u64,
272    pub notify_only: bool,
273}
274
275impl Default for UpdatesConfig {
276    fn default() -> Self {
277        Self {
278            auto_update: false,
279            check_interval_hours: 6,
280            notify_only: false,
281        }
282    }
283}
284
285impl UpdatesConfig {
286    pub fn from_env() -> Self {
287        let mut cfg = Self::default();
288        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
289            cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
290        }
291        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS") {
292            if let Ok(h) = v.parse::<u64>() {
293                cfg.check_interval_hours = h.clamp(1, 168);
294            }
295        }
296        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
297            cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
298        }
299        cfg
300    }
301}
302
303impl AutonomyConfig {
304    /// Creates an autonomy config from env vars, falling back to defaults.
305    pub fn from_env() -> Self {
306        let mut cfg = Self::default();
307        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
308            if v == "false" || v == "0" {
309                cfg.enabled = false;
310            }
311        }
312        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
313            cfg.auto_preload = v != "false" && v != "0";
314        }
315        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
316            cfg.auto_dedup = v != "false" && v != "0";
317        }
318        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
319            cfg.auto_related = v != "false" && v != "0";
320        }
321        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
322            cfg.auto_consolidate = 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        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
333            if let Ok(n) = v.parse() {
334                cfg.consolidate_every_calls = n;
335            }
336        }
337        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
338            if let Ok(n) = v.parse() {
339                cfg.consolidate_cooldown_secs = n;
340            }
341        }
342        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
343            cfg.cognition_loop_enabled = v != "false" && v != "0";
344        }
345        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
346            if let Ok(n) = v.parse() {
347                cfg.cognition_loop_interval_secs = n;
348            }
349        }
350        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
351            if let Ok(n) = v.parse() {
352                cfg.cognition_loop_max_steps = n;
353            }
354        }
355        cfg
356    }
357
358    /// Loads autonomy config from disk, with env var overrides applied.
359    pub fn load() -> Self {
360        let file_cfg = Config::load().autonomy;
361        let mut cfg = file_cfg;
362        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
363            if v == "false" || v == "0" {
364                cfg.enabled = false;
365            }
366        }
367        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
368            cfg.auto_preload = v != "false" && v != "0";
369        }
370        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
371            cfg.auto_dedup = v != "false" && v != "0";
372        }
373        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
374            cfg.auto_related = v != "false" && v != "0";
375        }
376        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
377            cfg.silent_preload = v != "false" && v != "0";
378        }
379        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
380            if let Ok(n) = v.parse() {
381                cfg.dedup_threshold = n;
382            }
383        }
384        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
385            cfg.cognition_loop_enabled = v != "false" && v != "0";
386        }
387        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS") {
388            if let Ok(n) = v.parse() {
389                cfg.cognition_loop_interval_secs = n;
390            }
391        }
392        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS") {
393            if let Ok(n) = v.parse() {
394                cfg.cognition_loop_max_steps = n;
395            }
396        }
397        cfg
398    }
399}
400
401/// Cloud sync and contribution settings (pattern sharing, model pulls).
402#[derive(Debug, Clone, Serialize, Deserialize, Default)]
403#[serde(default)]
404pub struct CloudConfig {
405    pub contribute_enabled: bool,
406    pub last_contribute: Option<String>,
407    pub last_sync: Option<String>,
408    pub last_gain_sync: Option<String>,
409    pub last_model_pull: Option<String>,
410    /// Auto-push the Pro Personal-Cloud surfaces (knowledge, commands, CEP,
411    /// gotchas, buddy, feedback) from the background task — opt-in, once per
412    /// day, offline-tolerant (GL #384). Toggle: `lean-ctx cloud autosync on`.
413    pub auto_sync: bool,
414    pub last_auto_sync: Option<String>,
415    /// Auto-push the project's encrypted retrieval-index bundle (hosted
416    /// Personal Index, GL #392) alongside the daily auto-sync — separate
417    /// opt-in because index bundles are orders of magnitude larger than the
418    /// other surfaces. Toggle: `lean-ctx cloud autoindex on`.
419    pub auto_index: bool,
420    /// Per-project debounce: `project_hash → YYYY-MM-DD` of the last
421    /// successful background index push.
422    pub last_index_push: std::collections::HashMap<String, String>,
423}
424
425/// Settings for publishing your token-savings recap (`gain --publish` / auto-publish).
426///
427/// Publishing is always opt-in: it sends a small, whitelisted *aggregate* payload (tokens
428/// saved, $ avoided, compression % — never code, paths or counts) to the cloud.
429/// `auto_publish` simply removes the need to re-run `gain --publish` by hand; it stays off
430/// until the user explicitly enables it.
431#[derive(Debug, Clone, Serialize, Deserialize)]
432#[serde(default)]
433pub struct GainConfig {
434    /// When true, `lean-ctx gain` automatically (re)publishes the recap, throttled to
435    /// `auto_publish_interval_hours`. Off by default.
436    pub auto_publish: bool,
437    /// When auto-publishing, also opt into the public leaderboard.
438    pub leaderboard: bool,
439    /// Optional display name for the published card / leaderboard entry.
440    pub display_name: Option<String>,
441    /// Minimum hours between automatic publishes (throttle).
442    pub auto_publish_interval_hours: u64,
443    /// Runtime state — RFC3339 timestamp of the last automatic publish. Managed by the
444    /// tool, not meant to be set by hand.
445    pub last_auto_publish: Option<String>,
446}
447
448impl Default for GainConfig {
449    fn default() -> Self {
450        Self {
451            auto_publish: false,
452            leaderboard: true,
453            display_name: None,
454            auto_publish_interval_hours: 24,
455            last_auto_publish: None,
456        }
457    }
458}
459
460/// Settings for the code graph — in particular the *traversal* (co-access) edges
461/// learned from real agent sessions (#289).
462///
463/// The static AST/import graph captures how code is wired structurally; it cannot
464/// see which files an agent actually opens *together* while solving a task.
465/// Traversal edges add that behavioural signal: files surfaced together are
466/// associated with a decaying weight (Hebbian co-access), folded into the graph
467/// as `co_access` edges and mixed into recall. The store is bounded and decays,
468/// so stale associations fade.
469#[derive(Debug, Clone, Serialize, Deserialize)]
470#[serde(default)]
471pub struct GraphConfig {
472    /// Record co-access between files surfaced together in a session, surface them
473    /// as decaying `co_access` edges in the graph, and boost recall by them.
474    /// On by default; set to `false` for a purely static (AST-only) graph.
475    pub traversal_edges: bool,
476}
477
478impl Default for GraphConfig {
479    fn default() -> Self {
480        Self {
481            traversal_edges: true,
482        }
483    }
484}
485
486/// Skillify (#290): mine the project's session diary + knowledge facts into
487/// versioned, git-committable `.cursor/rules/skillify-*.mdc` rule files.
488///
489/// The miner is precision-biased — it only codifies recurring or high-confidence
490/// patterns and never invents content. Runs on demand (`ctx_skillify` /
491/// `lean-ctx skillify`); re-running merges (bumps version) only when the distilled
492/// content actually changes.
493#[derive(Debug, Clone, Serialize, Deserialize)]
494#[serde(default)]
495pub struct SkillifyConfig {
496    /// Master switch for the skillify miner. On by default; the miner only ever
497    /// acts when explicitly invoked, so this never writes files unprompted.
498    pub enabled: bool,
499    /// Where generated rules are written: `project` (`<repo>/.cursor/rules`,
500    /// git-committable, default) or `global` (`~/.cursor/rules`).
501    pub scope: String,
502    /// Minimum confidence for a single curated knowledge fact to be codified even
503    /// without repetition. 0.0..=1.0.
504    pub min_confidence: f32,
505    /// Minimum number of reinforcements (confirmations / repeated mentions) before
506    /// a pattern is codified when its confidence is below `min_confidence`.
507    pub min_recurrence: u32,
508}
509
510impl Default for SkillifyConfig {
511    fn default() -> Self {
512        Self {
513            enabled: true,
514            scope: "project".to_string(),
515            min_confidence: 0.7,
516            min_recurrence: 2,
517        }
518    }
519}
520
521/// AI session summaries (#292): periodically distil the working session into a
522/// compact, *semantically recallable* summary so a future session can answer
523/// "what did I do last time on X?". Deterministic and local-first — recall uses
524/// embeddings when the `embeddings` feature is on, else a lexical fallback.
525#[derive(Debug, Clone, Serialize, Deserialize)]
526#[serde(default)]
527pub struct SummariesConfig {
528    /// Record periodic session summaries. On by default; recording is cheap and
529    /// happens at most once per `every_n_turns` tool calls.
530    pub enabled: bool,
531    /// Tool calls between automatic summaries. The auto-checkpoint cadence still
532    /// gates the check, so the effective minimum is the checkpoint interval.
533    pub every_n_turns: u32,
534    /// Maximum summaries kept per project (oldest pruned first).
535    pub max_kept: u32,
536}
537
538impl Default for SummariesConfig {
539    fn default() -> Self {
540        Self {
541            enabled: true,
542            every_n_turns: 25,
543            max_kept: 100,
544        }
545    }
546}
547
548/// A user-defined command alias mapping for shell compression patterns.
549#[derive(Debug, Clone, Serialize, Deserialize)]
550pub struct AliasEntry {
551    pub command: String,
552    pub alias: String,
553}
554
555/// Thresholds for detecting and throttling repetitive agent tool call loops.
556#[derive(Debug, Clone, Serialize, Deserialize)]
557#[serde(default)]
558pub struct LoopDetectionConfig {
559    pub normal_threshold: u32,
560    pub reduced_threshold: u32,
561    pub blocked_threshold: u32,
562    pub window_secs: u64,
563    pub search_group_limit: u32,
564    pub tool_total_limits: HashMap<String, u32>,
565}
566
567impl Default for LoopDetectionConfig {
568    fn default() -> Self {
569        let mut tool_total_limits = HashMap::new();
570        tool_total_limits.insert("ctx_read".to_string(), 100);
571        tool_total_limits.insert("ctx_search".to_string(), 80);
572        tool_total_limits.insert("ctx_shell".to_string(), 50);
573        tool_total_limits.insert("ctx_semantic_search".to_string(), 60);
574        Self {
575            normal_threshold: 2,
576            reduced_threshold: 4,
577            blocked_threshold: 0,
578            window_secs: 300,
579            search_group_limit: 10,
580            tool_total_limits,
581        }
582    }
583}
584
585/// Semantic-embedding engine settings.
586///
587/// `model` selects which local ONNX embedding model lean-ctx downloads and uses for
588/// `ctx_semantic_search`. Accepts the same aliases as the `LEAN_CTX_EMBEDDING_MODEL` env
589/// var: `minilm` (all-MiniLM-L6-v2, 384d — the default), `jina-code-v2` (768d,
590/// code-optimized), `nomic` (768d) — or any HuggingFace repo with an ONNX export via
591/// `hf:org/repo[@revision]` (GL #397). When the env var is set it takes precedence; an
592/// unset/`None` value uses the default model. Switching models triggers a one-time
593/// re-index on the next semantic search (vector dimensions follow from the model).
594///
595/// `dimensions` is only consulted for `hf:` custom models as the declared fallback
596/// width; the real width is probed from the ONNX graph at load time. Built-ins ignore it.
597#[derive(Debug, Clone, Default, Serialize, Deserialize)]
598#[serde(default)]
599pub struct EmbeddingConfig {
600    #[serde(default, skip_serializing_if = "Option::is_none")]
601    pub model: Option<String>,
602    #[serde(default, skip_serializing_if = "Option::is_none")]
603    pub dimensions: Option<usize>,
604    /// Allow downloading the embedding model on first semantic need (#551).
605    /// `None` (unset) means **allowed** — the soft default that activates the
606    /// semantic features without manual setup. Set `false` for air-gapped
607    /// machines. The `LEAN_CTX_EMBEDDINGS_AUTO_DOWNLOAD` env var, when set,
608    /// overrides this in either direction.
609    #[serde(default, skip_serializing_if = "Option::is_none")]
610    pub auto_download: Option<bool>,
611}