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