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    /// #718: subtractive counterpart to `custom_patterns` — a detected secret
20    /// whose matched text is covered by any of these regexes is neither
21    /// reported nor redacted. Lets users carve out known-safe identifiers or
22    /// repo naming conventions without disabling secret detection wholesale.
23    pub exclude_patterns: Vec<String>,
24}
25
26/// Controls what lean-ctx injects during `setup` and `update --rewire`.
27/// Fresh installs default to non-invasive (rules/skills off, MCP on).
28/// Users who ran setup interactively get explicit true/false.
29/// `None` = undecided (legacy: check if rules already exist and preserve behavior).
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(default)]
32pub struct SetupConfig {
33    /// Inject agent rule files (CLAUDE.md, .cursor/rules/, etc.).
34    /// None = undecided (legacy compat: inject if rules already present).
35    /// Some(true) = always inject. Some(false) = never inject.
36    pub auto_inject_rules: Option<bool>,
37    /// Install SKILL.md files for supported agents.
38    /// None = undecided. Some(true) = install. Some(false) = skip.
39    pub auto_inject_skills: Option<bool>,
40    /// Register lean-ctx as an MCP server in editor configs.
41    #[serde(default = "serde_defaults::default_true")]
42    pub auto_update_mcp: bool,
43}
44
45impl Default for SetupConfig {
46    fn default() -> Self {
47        Self {
48            auto_inject_rules: None,
49            auto_inject_skills: None,
50            auto_update_mcp: true,
51        }
52    }
53}
54
55impl SetupConfig {
56    /// Returns whether rules should be injected, considering legacy installs.
57    /// If undecided (None), checks if lean-ctx rules markers already exist
58    /// in any agent config — if so, keeps injecting for backward compat.
59    pub fn should_inject_rules(&self) -> bool {
60        match self.auto_inject_rules {
61            Some(v) => v,
62            None => Self::rules_already_present(),
63        }
64    }
65
66    /// Returns whether skills should be installed.
67    pub fn should_inject_skills(&self) -> bool {
68        match self.auto_inject_skills {
69            Some(v) => v,
70            None => Self::rules_already_present(),
71        }
72    }
73
74    /// Returns whether `setup`/`onboard`/`init` may (re)register the lean-ctx
75    /// MCP server in editor configs. Honors `auto_update_mcp` (#281) so locked-
76    /// down environments can keep MCP out of agent settings while still getting
77    /// hooks, rules and skills.
78    pub fn should_update_mcp(&self) -> bool {
79        self.auto_update_mcp
80    }
81
82    /// Check if lean-ctx rules markers exist in any known agent config location.
83    ///
84    /// Delegates the per-agent path catalog to `rules_inject::any_rules_marker_present`
85    /// (derived from the injector's own target list) so this never drifts behind
86    /// newly supported agents again (#442). Claude Code and CodeBuddy have no
87    /// rules *target* (they auto-load an inline block instead), so their legacy
88    /// rule files are checked separately to keep honoring older installs.
89    fn rules_already_present() -> bool {
90        let Some(home) = dirs::home_dir() else {
91            return false;
92        };
93        if crate::rules_inject::any_rules_marker_present(&home) {
94            return true;
95        }
96        let legacy_paths = [
97            crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md"),
98            crate::core::editor_registry::codebuddy_rules_dir(&home).join("lean-ctx.md"),
99        ];
100        legacy_paths.iter().any(|p| {
101            std::fs::read_to_string(p)
102                .is_ok_and(|c| c.contains(crate::core::rules_canonical::START_MARK))
103        })
104    }
105}
106
107impl Default for SecretDetectionConfig {
108    fn default() -> Self {
109        Self {
110            enabled: true,
111            redact: true,
112            custom_patterns: Vec::new(),
113            exclude_patterns: Vec::new(),
114        }
115    }
116}
117
118/// Settings for the zero-loss compression archive (large tool outputs saved to disk).
119#[derive(Debug, Clone, Serialize, Deserialize)]
120#[serde(default)]
121pub struct ArchiveConfig {
122    pub enabled: bool,
123    pub threshold_chars: usize,
124    pub max_age_hours: u64,
125    pub max_disk_mb: u64,
126    pub ephemeral: bool,
127    /// Minimum output tokens before the ephemeral firewall replaces an inline tool
128    /// result with a summary + retrieval ref. Outputs below this stay fully inline.
129    pub ephemeral_min_tokens: usize,
130}
131
132impl Default for ArchiveConfig {
133    fn default() -> Self {
134        Self {
135            enabled: true,
136            threshold_chars: 800,
137            max_age_hours: 48,
138            max_disk_mb: 500,
139            ephemeral: true,
140            ephemeral_min_tokens: 2000,
141        }
142    }
143}
144
145impl ArchiveConfig {
146    pub fn ephemeral_effective(&self) -> bool {
147        if let Ok(v) = std::env::var("LEAN_CTX_EPHEMERAL") {
148            return !matches!(v.trim(), "0" | "false" | "off");
149        }
150        self.ephemeral && self.enabled
151    }
152
153    pub fn ephemeral_min_tokens_effective(&self) -> usize {
154        if let Ok(v) = std::env::var("LEAN_CTX_EPHEMERAL_MIN_TOKENS")
155            && let Ok(n) = v.trim().parse::<usize>()
156        {
157            return n;
158        }
159        self.ephemeral_min_tokens
160    }
161}
162
163/// Configuration for external context providers (GitHub, GitLab, Jira, etc.).
164/// Each provider can be enabled/disabled and configured with auth tokens.
165/// Override individual tokens via env vars (GITHUB_TOKEN, GITLAB_TOKEN, etc.).
166#[derive(Debug, Clone, Serialize, Deserialize)]
167#[serde(default)]
168pub struct ProvidersConfig {
169    /// Master switch for the provider subsystem.
170    pub enabled: bool,
171    /// GitHub provider configuration.
172    pub github: ProviderEntryConfig,
173    /// GitLab provider configuration.
174    pub gitlab: ProviderEntryConfig,
175    /// Auto-ingest provider results into BM25/embedding indexes.
176    pub auto_index: bool,
177    /// Default cache TTL for provider results (seconds).
178    pub cache_ttl_secs: u64,
179    /// MCP Bridge providers: `{ "name" = { url = "...", description = "..." } }`.
180    #[serde(default)]
181    pub mcp_bridges: std::collections::HashMap<String, McpBridgeEntry>,
182}
183
184impl Default for ProvidersConfig {
185    fn default() -> Self {
186        Self {
187            enabled: true,
188            github: ProviderEntryConfig::default(),
189            gitlab: ProviderEntryConfig::default(),
190            auto_index: true,
191            cache_ttl_secs: 120,
192            mcp_bridges: std::collections::HashMap::new(),
193        }
194    }
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct McpBridgeEntry {
199    /// HTTP/SSE URL for remote MCP servers.
200    #[serde(default)]
201    pub url: Option<String>,
202    /// Command to spawn a local MCP server (stdio transport).
203    #[serde(default)]
204    pub command: Option<String>,
205    /// Arguments for the command.
206    #[serde(default)]
207    pub args: Vec<String>,
208    /// Human-readable description.
209    #[serde(default)]
210    pub description: Option<String>,
211    /// Environment variable name containing an auth token.
212    #[serde(default)]
213    pub auth_env: Option<String>,
214}
215
216/// Per-provider configuration entry.
217#[derive(Debug, Clone, Serialize, Deserialize)]
218#[serde(default)]
219pub struct ProviderEntryConfig {
220    /// Whether this specific provider is enabled.
221    pub enabled: bool,
222    /// Auth token (prefer env var; only use this for project-local overrides).
223    pub token: Option<String>,
224    /// API base URL override (for GitHub Enterprise, self-hosted GitLab, etc.).
225    pub api_url: Option<String>,
226    /// Default project/repo for this provider (auto-detected from git remote if empty).
227    pub project: Option<String>,
228}
229
230impl Default for ProviderEntryConfig {
231    fn default() -> Self {
232        Self {
233            enabled: true,
234            token: None,
235            api_url: None,
236            project: None,
237        }
238    }
239}
240
241/// Controls autonomous background behaviors (preload, dedup, consolidation).
242#[derive(Debug, Clone, Serialize, Deserialize)]
243#[serde(default)]
244pub struct AutonomyConfig {
245    pub enabled: bool,
246    pub auto_preload: bool,
247    pub auto_dedup: bool,
248    pub auto_related: bool,
249    pub auto_consolidate: bool,
250    pub silent_preload: bool,
251    pub dedup_threshold: usize,
252    pub consolidate_every_calls: u32,
253    pub consolidate_cooldown_secs: u64,
254    #[serde(default = "serde_defaults::default_true")]
255    pub cognition_loop_enabled: bool,
256    #[serde(default = "serde_defaults::default_cognition_loop_interval")]
257    pub cognition_loop_interval_secs: u64,
258    #[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
259    pub cognition_loop_max_steps: u8,
260    /// Minimum facts an entity needs before observation synthesis (#802) writes a
261    /// summary. Synthesis itself is gated by `cognition_loop_max_steps >= 9`.
262    #[serde(default = "serde_defaults::default_cognition_synthesis_min_cluster")]
263    pub cognition_synthesis_min_cluster: usize,
264}
265
266impl Default for AutonomyConfig {
267    fn default() -> Self {
268        Self {
269            enabled: true,
270            auto_preload: true,
271            auto_dedup: true,
272            auto_related: true,
273            auto_consolidate: true,
274            silent_preload: true,
275            dedup_threshold: 8,
276            consolidate_every_calls: 25,
277            consolidate_cooldown_secs: 120,
278            cognition_loop_enabled: true,
279            cognition_loop_interval_secs: 3600,
280            cognition_loop_max_steps: 9,
281            cognition_synthesis_min_cluster: 3,
282        }
283    }
284}
285
286/// Controls automatic update behavior. All defaults are OFF — auto-updates
287/// require explicit opt-in via `lean-ctx setup` or `lean-ctx update --schedule`.
288#[derive(Debug, Clone, Serialize, Deserialize)]
289#[serde(default)]
290pub struct UpdatesConfig {
291    pub auto_update: bool,
292    pub check_interval_hours: u64,
293    pub notify_only: bool,
294}
295
296impl Default for UpdatesConfig {
297    fn default() -> Self {
298        Self {
299            auto_update: false,
300            check_interval_hours: 6,
301            notify_only: false,
302        }
303    }
304}
305
306/// Fixed-context budget accounting (#964). The per-session footprint lean-ctx
307/// adds — tool schemas + MCP instructions + auto-loaded rules files + the wakeup
308/// briefing — is warned about once it crosses `budget_tokens`. The
309/// `LEAN_CTX_CONTEXT_BUDGET_TOKENS` env var overrides it; `lean-ctx doctor
310/// overhead --gate` turns a breach into a non-zero exit for CI.
311#[derive(Debug, Clone, Serialize, Deserialize)]
312#[serde(default)]
313pub struct ContextConfig {
314    pub budget_tokens: usize,
315}
316
317impl Default for ContextConfig {
318    fn default() -> Self {
319        Self {
320            budget_tokens: 8000,
321        }
322    }
323}
324
325impl UpdatesConfig {
326    pub fn from_env() -> Self {
327        let mut cfg = Self::default();
328        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
329            cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
330        }
331        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS")
332            && let Ok(h) = v.parse::<u64>()
333        {
334            cfg.check_interval_hours = h.clamp(1, 168);
335        }
336        if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
337            cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
338        }
339        cfg
340    }
341}
342
343impl AutonomyConfig {
344    /// Creates an autonomy config from env vars, falling back to defaults.
345    pub fn from_env() -> Self {
346        let mut cfg = Self::default();
347        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY")
348            && (v == "false" || v == "0")
349        {
350            cfg.enabled = false;
351        }
352        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
353            cfg.auto_preload = v != "false" && v != "0";
354        }
355        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
356            cfg.auto_dedup = v != "false" && v != "0";
357        }
358        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
359            cfg.auto_related = v != "false" && v != "0";
360        }
361        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
362            cfg.auto_consolidate = v != "false" && v != "0";
363        }
364        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
365            cfg.silent_preload = v != "false" && v != "0";
366        }
367        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD")
368            && let Ok(n) = v.parse()
369        {
370            cfg.dedup_threshold = n;
371        }
372        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS")
373            && let Ok(n) = v.parse()
374        {
375            cfg.consolidate_every_calls = n;
376        }
377        if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS")
378            && let Ok(n) = v.parse()
379        {
380            cfg.consolidate_cooldown_secs = n;
381        }
382        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
383            cfg.cognition_loop_enabled = v != "false" && v != "0";
384        }
385        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS")
386            && let Ok(n) = v.parse()
387        {
388            cfg.cognition_loop_interval_secs = n;
389        }
390        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS")
391            && let Ok(n) = v.parse()
392        {
393            cfg.cognition_loop_max_steps = n;
394        }
395        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_SYNTHESIS_MIN_CLUSTER")
396            && let Ok(n) = v.parse()
397        {
398            cfg.cognition_synthesis_min_cluster = n;
399        }
400        cfg
401    }
402
403    /// Loads autonomy config from disk, with env var overrides applied.
404    pub fn load() -> Self {
405        let file_cfg = Config::load().autonomy;
406        let mut cfg = file_cfg;
407        if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY")
408            && (v == "false" || v == "0")
409        {
410            cfg.enabled = false;
411        }
412        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
413            cfg.auto_preload = v != "false" && v != "0";
414        }
415        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
416            cfg.auto_dedup = v != "false" && v != "0";
417        }
418        if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
419            cfg.auto_related = v != "false" && v != "0";
420        }
421        if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
422            cfg.silent_preload = v != "false" && v != "0";
423        }
424        if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD")
425            && let Ok(n) = v.parse()
426        {
427            cfg.dedup_threshold = n;
428        }
429        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
430            cfg.cognition_loop_enabled = v != "false" && v != "0";
431        }
432        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS")
433            && let Ok(n) = v.parse()
434        {
435            cfg.cognition_loop_interval_secs = n;
436        }
437        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS")
438            && let Ok(n) = v.parse()
439        {
440            cfg.cognition_loop_max_steps = n;
441        }
442        if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_SYNTHESIS_MIN_CLUSTER")
443            && let Ok(n) = v.parse()
444        {
445            cfg.cognition_synthesis_min_cluster = n;
446        }
447        cfg
448    }
449}
450
451/// Cloud sync and contribution settings (pattern sharing, model pulls).
452#[derive(Debug, Clone, Serialize, Deserialize, Default)]
453#[serde(default)]
454pub struct CloudConfig {
455    pub contribute_enabled: bool,
456    pub last_contribute: Option<String>,
457    pub last_sync: Option<String>,
458    pub last_gain_sync: Option<String>,
459    pub last_model_pull: Option<String>,
460    /// Auto-push the Pro Personal-Cloud surfaces (knowledge, commands, CEP,
461    /// gotchas, buddy, feedback) from the background task — opt-in, once per
462    /// day, offline-tolerant (GL #384). Toggle: `lean-ctx cloud autosync on`.
463    pub auto_sync: bool,
464    pub last_auto_sync: Option<String>,
465    /// Auto-push the project's encrypted retrieval-index bundle (hosted
466    /// Personal Index, GL #392) alongside the daily auto-sync — separate
467    /// opt-in because index bundles are orders of magnitude larger than the
468    /// other surfaces. Toggle: `lean-ctx cloud autoindex on`.
469    pub auto_index: bool,
470    /// Per-project debounce: `project_hash → YYYY-MM-DD` of the last
471    /// successful background index push.
472    pub last_index_push: std::collections::HashMap<String, String>,
473}
474
475/// Settings for publishing your token-savings recap (`gain --publish` / auto-publish).
476///
477/// Publishing is always opt-in: it sends a small, whitelisted *aggregate* payload (tokens
478/// saved, $ avoided, compression % — never code, paths or counts) to the cloud.
479/// `auto_publish` simply removes the need to re-run `gain --publish` by hand; it stays off
480/// until the user explicitly enables it.
481#[derive(Debug, Clone, Serialize, Deserialize)]
482#[serde(default)]
483pub struct GainConfig {
484    /// When true, `lean-ctx gain` automatically (re)publishes the recap, throttled to
485    /// `auto_publish_interval_hours`. Off by default.
486    pub auto_publish: bool,
487    /// When auto-publishing, also opt into the public leaderboard.
488    pub leaderboard: bool,
489    /// Optional display name for the published card / leaderboard entry.
490    pub display_name: Option<String>,
491    /// Minimum hours between automatic publishes (throttle).
492    pub auto_publish_interval_hours: u64,
493    /// Runtime state — RFC3339 timestamp of the last automatic publish. Managed by the
494    /// tool, not meant to be set by hand.
495    pub last_auto_publish: Option<String>,
496}
497
498impl Default for GainConfig {
499    fn default() -> Self {
500        Self {
501            auto_publish: false,
502            leaderboard: true,
503            display_name: None,
504            auto_publish_interval_hours: 24,
505            last_auto_publish: None,
506        }
507    }
508}
509
510/// Model declaration for **measured-vs-estimated** cost reporting.
511///
512/// Proxy-routed clients (Claude Code, Codex, Pi, Gemini CLI, OpenCode) report
513/// their real model and billed tokens, so lean-ctx prices them *measured* with
514/// no configuration. MCP-only IDEs (Cursor, Copilot, Windsurf, VS Code, Zed)
515/// send their LLM traffic straight to the provider, bypassing lean-ctx — their
516/// real model is invisible. Declaring it here lets those *estimated* turns be
517/// priced with the correct model instead of a blended fallback.
518#[derive(Debug, Clone, Default, Serialize, Deserialize)]
519#[serde(default)]
520pub struct CostConfig {
521    /// Per-session cost cap in USD. When accumulated cost exceeds this value,
522    /// subsequent tool calls receive a `[COST CAP]` warning instead of the
523    /// normal output (#794). 0 = unlimited (default).
524    /// Override at runtime: `LEAN_CTX_COST_CAP_OVERRIDE=1` bypasses the cap.
525    #[serde(default)]
526    pub max_session_cost_usd: f64,
527    /// Fallback pricing model for any client without a per-client entry.
528    /// Unset/empty → lean-ctx keeps its blended heuristic.
529    #[serde(default, skip_serializing_if = "Option::is_none")]
530    pub default_model: Option<String>,
531    /// Per-client pricing model, keyed by client id (`cursor`, `copilot`,
532    /// `windsurf`, `claude`, `codex`, …). Used for MCP-only IDEs whose real
533    /// model lean-ctx cannot observe. Example:
534    /// `[cost.models]` then `cursor = "claude-opus-4.5"`.
535    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
536    pub models: HashMap<String, String>,
537    /// Operator price overrides (#1189), keyed by model name — for negotiated
538    /// enterprise rates (committed-use discounts, Azure PTU, zero-rated
539    /// internal models) that no public catalog can know. Merged into the
540    /// pricing table as **exact** entries, overriding embedded and live rows;
541    /// only a provider-measured bill beats them. Example:
542    /// `[cost.prices."internal-llm"]` then `input_per_m = 0.10`,
543    /// `output_per_m = 0.40`.
544    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
545    pub prices: HashMap<String, PriceOverride>,
546}
547
548/// One `[cost.prices.<model>]` row: USD per million tokens. Omitted cache
549/// rates default to the input rate (the same convention the catalogs use).
550#[derive(Debug, Clone, Default, Serialize, Deserialize)]
551#[serde(default)]
552pub struct PriceOverride {
553    pub input_per_m: Option<f64>,
554    pub output_per_m: Option<f64>,
555    pub cache_write_per_m: Option<f64>,
556    pub cache_read_per_m: Option<f64>,
557}
558
559impl CostConfig {
560    /// Configured pricing model for a client id: the per-client entry first, then
561    /// the global default. `None` when neither is set (the caller then falls back
562    /// to the env override / heuristic). Blank entries are ignored.
563    pub fn model_for_client(&self, client: &str) -> Option<String> {
564        self.models
565            .get(client)
566            .or(self.default_model.as_ref())
567            .map(|s| s.trim().to_string())
568            .filter(|s| !s.is_empty())
569    }
570}
571
572/// Code-health engine (`[code_health]`): clean code as a token-cost lever.
573///
574/// Cognitive complexity, naming quality, and coupling are computed once during
575/// indexing and surfaced at read- and edit-time. These switches tune the
576/// thresholds and how assertively findings are surfaced.
577#[derive(Debug, Clone, Serialize, Deserialize)]
578#[serde(default)]
579pub struct CodeHealthConfig {
580    /// Cognitive-complexity threshold above which a function is a hotspot.
581    /// Mirrors `core::code_health::DEFAULT_COGNITIVE_THRESHOLD` (15).
582    pub cognitive_threshold: u32,
583    /// Edit-gate behavior on complexity drift: `"warn"` (annotate, default),
584    /// `"block"` (refuse clean→over-threshold edits), or `"off"`.
585    pub gate: String,
586    /// Annotate over-threshold functions inline in `ctx_read` output.
587    pub annotate_reads: bool,
588    /// Run the naming-quality heuristic.
589    pub naming: bool,
590    /// Compute module-coupling metrics.
591    pub coupling: bool,
592    /// Inject `[CODE HEALTH]` notices as `additionalContext` in PostToolUse stdout.
593    /// Default: **false** — prevents prompt-cache invalidation on Anthropic models
594    /// (#778: each injection causes 440-520k tokens of cache re-bills when Claude
595    /// Code strips stale system-reminders retroactively).
596    /// When false, notices route to `ctx_knowledge` + dashboard instead.
597    #[serde(default)]
598    pub inject_context: bool,
599}
600
601impl Default for CodeHealthConfig {
602    fn default() -> Self {
603        Self {
604            cognitive_threshold: 15,
605            gate: "warn".to_string(),
606            annotate_reads: true,
607            naming: true,
608            coupling: true,
609            inject_context: false,
610        }
611    }
612}
613
614/// Index-time file filters (#735): declare the retrieval corpus explicitly
615/// instead of abusing `.gitignore` for retrieval policy.
616///
617/// Applies to every index builder through one shared filter layer
618/// (`core::index_filter`): BM25, graph, and the watch/incremental path; the
619/// semantic index chunks the BM25 corpus and inherits the same universe.
620/// Excluded files never produce chunks, graph nodes, or embeddings. Globs are
621/// matched against the root-relative path (forward slashes); exclude wins
622/// over include. The empty default preserves today's behavior byte-for-byte.
623#[derive(Debug, Clone, Serialize, Deserialize)]
624#[serde(default)]
625pub struct IndexConfig {
626    /// Honor `.gitignore` / global gitignore / `.git/info/exclude` during
627    /// index walks. `false` indexes ignored files too (rarely wanted; the
628    /// vendor-directory guard still applies).
629    pub respect_gitignore: bool,
630    /// Files to drop from the index corpus, e.g. `["**/*.csv", "fixtures/**"]`.
631    /// Evaluated after `include`; a file matching both is excluded.
632    pub exclude: Vec<String>,
633    /// When non-empty, ONLY matching files enter the index corpus, e.g.
634    /// `["**/*.rs", "**/*.ts"]`. Empty = no restriction.
635    pub include: Vec<String>,
636}
637
638impl Default for IndexConfig {
639    fn default() -> Self {
640        Self {
641            respect_gitignore: true,
642            exclude: Vec::new(),
643            include: Vec::new(),
644        }
645    }
646}
647
648/// Settings for the code graph — in particular the *traversal* (co-access) edges
649/// learned from real agent sessions (#289).
650///
651/// The static AST/import graph captures how code is wired structurally; it cannot
652/// see which files an agent actually opens *together* while solving a task.
653/// Traversal edges add that behavioural signal: files surfaced together are
654/// associated with a decaying weight (Hebbian co-access), folded into the graph
655/// as `co_access` edges and mixed into recall. The store is bounded and decays,
656/// so stale associations fade.
657#[derive(Debug, Clone, Serialize, Deserialize)]
658#[serde(default)]
659pub struct GraphConfig {
660    /// Record co-access between files surfaced together in a session, surface them
661    /// as decaying `co_access` edges in the graph, and boost recall by them.
662    /// On by default; set to `false` for a purely static (AST-only) graph.
663    pub traversal_edges: bool,
664}
665
666impl Default for GraphConfig {
667    fn default() -> Self {
668        Self {
669            traversal_edges: true,
670        }
671    }
672}
673
674/// Skillify (#290): mine the project's session diary + knowledge facts into
675/// versioned, git-committable `.cursor/rules/skillify-*.mdc` rule files.
676///
677/// The miner is precision-biased — it only codifies recurring or high-confidence
678/// patterns and never invents content. Runs on demand (`ctx_skillify` /
679/// `lean-ctx skillify`); re-running merges (bumps version) only when the distilled
680/// content actually changes.
681#[derive(Debug, Clone, Serialize, Deserialize)]
682#[serde(default)]
683pub struct SkillifyConfig {
684    /// Master switch for the skillify miner. On by default; the miner only ever
685    /// acts when explicitly invoked, so this never writes files unprompted.
686    pub enabled: bool,
687    /// Where generated rules are written: `project` (`<repo>/.cursor/rules`,
688    /// git-committable, default) or `global` (`~/.cursor/rules`).
689    pub scope: String,
690    /// Minimum confidence for a single curated knowledge fact to be codified even
691    /// without repetition. 0.0..=1.0.
692    pub min_confidence: f32,
693    /// Minimum number of reinforcements (confirmations / repeated mentions) before
694    /// a pattern is codified when its confidence is below `min_confidence`.
695    pub min_recurrence: u32,
696}
697
698impl Default for SkillifyConfig {
699    fn default() -> Self {
700        Self {
701            enabled: true,
702            scope: "project".to_string(),
703            min_confidence: 0.7,
704            min_recurrence: 2,
705        }
706    }
707}
708
709/// AI session summaries (#292): periodically distil the working session into a
710/// compact, *semantically recallable* summary so a future session can answer
711/// "what did I do last time on X?". Deterministic and local-first — recall uses
712/// embeddings when the `embeddings` feature is on, else a lexical fallback.
713#[derive(Debug, Clone, Serialize, Deserialize)]
714#[serde(default)]
715pub struct SummariesConfig {
716    /// Record periodic session summaries. On by default; recording is cheap and
717    /// happens at most once per `every_n_turns` tool calls.
718    pub enabled: bool,
719    /// Tool calls between automatic summaries. The auto-checkpoint cadence still
720    /// gates the check, so the effective minimum is the checkpoint interval.
721    pub every_n_turns: u32,
722    /// Maximum summaries kept per project (oldest pruned first).
723    pub max_kept: u32,
724}
725
726impl Default for SummariesConfig {
727    fn default() -> Self {
728        Self {
729            enabled: true,
730            every_n_turns: 25,
731            max_kept: 100,
732        }
733    }
734}
735
736/// A user-defined command alias mapping for shell compression patterns.
737#[derive(Debug, Clone, Serialize, Deserialize)]
738pub struct AliasEntry {
739    pub command: String,
740    pub alias: String,
741}
742
743/// Thresholds for detecting and throttling repetitive agent tool call loops.
744#[derive(Debug, Clone, Serialize, Deserialize)]
745#[serde(default)]
746pub struct LoopDetectionConfig {
747    pub normal_threshold: u32,
748    pub reduced_threshold: u32,
749    pub blocked_threshold: u32,
750    pub window_secs: u64,
751    pub search_group_limit: u32,
752    pub tool_total_limits: HashMap<String, u32>,
753}
754
755impl Default for LoopDetectionConfig {
756    fn default() -> Self {
757        let mut tool_total_limits = HashMap::new();
758        tool_total_limits.insert("ctx_read".to_string(), 100);
759        tool_total_limits.insert("ctx_search".to_string(), 80);
760        tool_total_limits.insert("ctx_shell".to_string(), 50);
761        tool_total_limits.insert("ctx_semantic_search".to_string(), 60);
762        Self {
763            normal_threshold: 2,
764            reduced_threshold: 4,
765            blocked_threshold: 0,
766            window_secs: 300,
767            search_group_limit: 10,
768            tool_total_limits,
769        }
770    }
771}
772
773/// Semantic-embedding engine settings.
774///
775/// `model` selects which local ONNX embedding model lean-ctx downloads and uses for
776/// `ctx_semantic_search`. Accepts the same aliases as the `LEAN_CTX_EMBEDDING_MODEL` env
777/// var: `minilm` (all-MiniLM-L6-v2, 384d — the default), `nomic` (768d) — or any
778/// HuggingFace repo with an ONNX export via `hf:org/repo[@revision]` (GL #397), e.g.
779/// `hf:jinaai/jina-embeddings-v2-base-code` for code-specialized embeddings. When the
780/// env var is set it takes precedence; an
781/// unset/`None` value uses the default model. Switching models triggers a one-time
782/// re-index on the next semantic search (vector dimensions follow from the model).
783///
784/// `dimensions` is only consulted for `hf:` custom models as the declared fallback
785/// width; the real width is probed from the ONNX graph at load time. Built-ins ignore it.
786/// `[gateway_server]` — deployment parameters of the self-hosted org gateway
787/// (enterprise#20). Distinct from `[gateway]` (the MCP tool-catalog gateway):
788/// this section describes the LLM-proxy *server* deployment and its cockpit.
789///
790/// All fields optional; an empty section keeps every local behavior unchanged.
791#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
792#[serde(default)]
793pub struct GatewayServerConfig {
794    /// Seats the org-wide projection extrapolates to (e.g. `800`). `None`
795    /// disables the projection — the cockpit never invents a seat count.
796    #[serde(default, skip_serializing_if = "Option::is_none")]
797    pub seats: Option<u32>,
798    /// Display label for the cockpit header (e.g. `"Zühlke AI Gateway"`).
799    #[serde(default, skip_serializing_if = "Option::is_none")]
800    pub org_label: Option<String>,
801    /// Central admin API base URL (e.g. `https://ai-gateway.example.com`).
802    /// When set, the local cockpit's usage breakdown reads the org-wide
803    /// `GET /api/admin/usage` instead of the machine-local snapshot. The
804    /// bearer token comes from `LEAN_CTX_GATEWAY_ADMIN_TOKEN` (never config).
805    #[serde(default, skip_serializing_if = "Option::is_none")]
806    pub admin_url: Option<String>,
807    /// Bind address of the admin listener (dashboard + `/api/admin/*` +
808    /// `/metrics`). Defaults to loopback — **secure by default** (#54/#56):
809    /// exposing the console is an explicit decision. Container deployments set
810    /// `"0.0.0.0"` here (the pod/compose port mapping stays the outer guard).
811    /// `LEAN_CTX_GATEWAY_ADMIN_BIND_HOST` overrides. Invalid values fall back
812    /// to loopback: a typo can only ever narrow exposure, never open it.
813    #[serde(default, skip_serializing_if = "Option::is_none")]
814    pub admin_bind_host: Option<String>,
815    /// Days to keep `usage_events` rows (enterprise#36). `None`/`0` = keep
816    /// forever (the local-free default — retention is a deployment decision).
817    /// A running gateway purges older rows periodically; typical compliance
818    /// values are `365` or `3650` (EU AI Act evidence horizon).
819    #[serde(default, skip_serializing_if = "Option::is_none")]
820    pub usage_retention_days: Option<u32>,
821    /// Replace `person` with a stable keyed pseudonym (`p:<hash>`) before it
822    /// reaches metering, budgets, dashboards and logs (enterprise#39, GDPR).
823    /// The salt lives in `<data_dir>/gateway_pii_salt`; `gateway gdpr`
824    /// re-derives pseudonyms from e-mail input, so DSGVO delete/export keep
825    /// working. Default `false` (cleartext person tags).
826    #[serde(default, skip_serializing_if = "Option::is_none")]
827    pub pseudonymize_persons: Option<bool>,
828    /// MCP upstream registry (GL#91/#99, Doc 15 §7 — the observe stage of MCP
829    /// context governance). Each entry publishes a governed reverse-proxy
830    /// route `/mcp/{id}` on the proxy port: same per-person key auth as the
831    /// LLM channel, tool calls metered into `mcp_events`, tool definitions
832    /// inventoried + hash-tracked (rug-pull detection). Observe-only: the
833    /// gateway never blocks or rewrites MCP traffic in this stage.
834    #[serde(default, skip_serializing_if = "Vec::is_empty")]
835    pub mcp_servers: Vec<McpServerEntry>,
836}
837
838/// One `[[gateway_server.mcp_servers]]` registry entry — an MCP server the org
839/// gateway fronts. Distinct from `[[gateway.servers]]` (the *local* tool-
840/// catalog aggregator, #210): this registry is the org-facing reverse proxy.
841#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
842pub struct McpServerEntry {
843    /// Registry id, used in the `/mcp/{id}` route. Lowercase alphanumeric
844    /// plus `-`/`_` (it becomes a URL path segment).
845    pub id: String,
846    /// Upstream Streamable-HTTP endpoint (the server's single MCP endpoint,
847    /// e.g. `https://mcp.example.com/mcp`). HTTPS for any non-loopback host;
848    /// plaintext HTTP needs the same explicit opt-in as LLM upstreams
849    /// (`[proxy] allow_insecure_http_upstream`).
850    pub url: String,
851    /// Name of the environment variable holding the upstream credential. When
852    /// set, the gateway sends `Authorization: Bearer <value>` upstream — the
853    /// credential lives in the gateway's environment, never on laptops. The
854    /// caller's own `Authorization` header (their gateway key) is **always**
855    /// stripped before forwarding, with or without this field.
856    #[serde(default, skip_serializing_if = "Option::is_none")]
857    pub auth_env: Option<String>,
858    /// Set `false` to keep the entry in config but take it out of service.
859    #[serde(default, skip_serializing_if = "Option::is_none")]
860    pub enabled: Option<bool>,
861}
862
863/// A validated, ready-to-serve MCP registry entry (runtime view of
864/// [`McpServerEntry`]).
865#[derive(Debug, Clone, PartialEq, Eq)]
866pub struct ResolvedMcpServer {
867    pub id: String,
868    pub url: String,
869    pub auth_env: Option<String>,
870}
871
872impl GatewayServerConfig {
873    /// Validate + resolve the `[[gateway_server.mcp_servers]]` registry.
874    /// Same resilience contract as `[[proxy.providers]]`: invalid entries are
875    /// logged and skipped (one typo never takes the gateway down), duplicates
876    /// keep the first occurrence. `allow_insecure_http` mirrors the proxy's
877    /// plaintext-HTTP opt-in so the two registries share one security posture.
878    #[must_use]
879    pub fn resolve_mcp_servers(&self, allow_insecure_http: bool) -> Vec<ResolvedMcpServer> {
880        let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
881        let mut out = Vec::new();
882        for entry in &self.mcp_servers {
883            if !entry.enabled.unwrap_or(true) {
884                continue;
885            }
886            let id = entry.id.trim();
887            if !is_valid_mcp_server_id(id) {
888                tracing::warn!(
889                    "[gateway_server.mcp_servers] invalid id '{id}' \
890                     (lowercase alnum/-/_ only) — entry skipped"
891                );
892                continue;
893            }
894            if !seen.insert(id) {
895                tracing::warn!(
896                    "[gateway_server.mcp_servers] duplicate id '{id}' — keeping first entry"
897                );
898                continue;
899            }
900            match validate_mcp_upstream_url(&entry.url, allow_insecure_http) {
901                Ok(url) => out.push(ResolvedMcpServer {
902                    id: id.to_string(),
903                    url,
904                    auth_env: entry
905                        .auth_env
906                        .as_deref()
907                        .map(str::trim)
908                        .filter(|v| !v.is_empty())
909                        .map(str::to_string),
910                }),
911                Err(e) => {
912                    tracing::warn!(
913                        "[gateway_server.mcp_servers] '{id}' has invalid url — skipped: {e}"
914                    );
915                }
916            }
917        }
918        out
919    }
920
921    /// Effective admin bind address (see `admin_bind_host`). Precedence:
922    /// `LEAN_CTX_GATEWAY_ADMIN_BIND_HOST` env > config > `127.0.0.1`.
923    #[must_use]
924    pub fn resolved_admin_bind_host(&self) -> std::net::IpAddr {
925        let raw = std::env::var("LEAN_CTX_GATEWAY_ADMIN_BIND_HOST")
926            .ok()
927            .filter(|v| !v.trim().is_empty())
928            .or_else(|| self.admin_bind_host.clone());
929        match raw.as_deref().map(str::trim) {
930            Some(v) if !v.is_empty() => v.parse().unwrap_or_else(|_| {
931                tracing::warn!(
932                    "gateway_server.admin_bind_host '{v}' is not a valid IP address — binding 127.0.0.1"
933                );
934                std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
935            }),
936            _ => std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
937        }
938    }
939}
940
941/// True when `id` is usable as an MCP registry id: non-empty, lowercase alnum
942/// plus `-`/`_` (it becomes a URL path segment). Same shape rule as
943/// `[[proxy.providers]]` ids; no built-in namespace exists to shadow here.
944fn is_valid_mcp_server_id(id: &str) -> bool {
945    !id.is_empty()
946        && id
947            .chars()
948            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
949}
950
951/// Validates an MCP upstream URL. A declared registry entry is itself the
952/// deliberate custom-host opt-in (same rationale as `[[proxy.providers]]`):
953/// any HTTPS host is accepted; loopback HTTP is always fine; non-loopback
954/// plaintext HTTP requires the explicit insecure-HTTP opt-in. This is the
955/// SSRF boundary — the proxy only ever connects to URLs that passed here.
956fn validate_mcp_upstream_url(url: &str, allow_insecure_http: bool) -> Result<String, String> {
957    let trimmed = url.trim().trim_end_matches('/');
958    if trimmed.is_empty() {
959        return Err("empty url".into());
960    }
961    if crate::core::config::is_local_proxy_url(trimmed) {
962        return Ok(trimmed.to_string());
963    }
964    if trimmed.starts_with("http://") {
965        if allow_insecure_http {
966            return Ok(trimmed.to_string());
967        }
968        return Err(format!(
969            "MCP upstream must use HTTPS: {trimmed} (for a trusted local-network HTTP \
970             upstream opt in with `[proxy] allow_insecure_http_upstream = true`)"
971        ));
972    }
973    if trimmed.starts_with("https://") {
974        return Ok(trimmed.to_string());
975    }
976    Err(format!(
977        "MCP upstream must start with http:// or https://: {trimmed}"
978    ))
979}
980
981#[derive(Debug, Clone, Default, Serialize, Deserialize)]
982#[serde(default)]
983pub struct EmbeddingConfig {
984    #[serde(default, skip_serializing_if = "Option::is_none")]
985    pub model: Option<String>,
986    #[serde(default, skip_serializing_if = "Option::is_none")]
987    pub dimensions: Option<usize>,
988    /// Allow downloading the embedding model on first semantic need (#551).
989    /// `None` (unset) means **allowed** — the soft default that activates the
990    /// semantic features without manual setup. Set `false` for air-gapped
991    /// machines. The `LEAN_CTX_EMBEDDINGS_AUTO_DOWNLOAD` env var, when set,
992    /// overrides this in either direction.
993    #[serde(default, skip_serializing_if = "Option::is_none")]
994    pub auto_download: Option<bool>,
995    /// Pin embedding inference to a single CPU thread (no GPU EP) so vectors are
996    /// bit-identical across machines, not just run-to-run on one host (#895).
997    /// `None`/`false` keeps the multi-threaded GPU-capable path. Extractive prose
998    /// ranking is already deterministic via score quantization + stable tiebreak;
999    /// this flag is the extra hardening for cross-machine reproducibility. The
1000    /// `LEAN_CTX_EMBEDDING_DETERMINISTIC` env var overrides this either way.
1001    #[serde(default, skip_serializing_if = "Option::is_none")]
1002    pub deterministic: Option<bool>,
1003}
1004
1005#[cfg(test)]
1006mod gateway_server_tests {
1007    use super::*;
1008
1009    #[test]
1010    fn admin_bind_defaults_to_loopback_and_rejects_garbage() {
1011        // Secure by default (#54/#56): unset and invalid both land on loopback.
1012        let cfg = GatewayServerConfig::default();
1013        assert!(cfg.resolved_admin_bind_host().is_loopback());
1014
1015        let cfg = GatewayServerConfig {
1016            admin_bind_host: Some("not-an-ip".into()),
1017            ..Default::default()
1018        };
1019        assert!(
1020            cfg.resolved_admin_bind_host().is_loopback(),
1021            "a typo must narrow exposure, never widen it"
1022        );
1023
1024        let cfg = GatewayServerConfig {
1025            admin_bind_host: Some("0.0.0.0".into()),
1026            ..Default::default()
1027        };
1028        assert!(
1029            !cfg.resolved_admin_bind_host().is_loopback(),
1030            "explicit opt-in widens the bind"
1031        );
1032    }
1033
1034    fn mcp_entry(id: &str, url: &str) -> McpServerEntry {
1035        McpServerEntry {
1036            id: id.into(),
1037            url: url.into(),
1038            auth_env: None,
1039            enabled: None,
1040        }
1041    }
1042
1043    #[test]
1044    fn mcp_registry_validates_ids_urls_and_duplicates() {
1045        let cfg = GatewayServerConfig {
1046            mcp_servers: vec![
1047                mcp_entry("github", "https://mcp.example.com/mcp/"),
1048                // invalid id (uppercase) — skipped, never panics
1049                mcp_entry("GitHub", "https://mcp.example.com/mcp"),
1050                // duplicate — first occurrence wins
1051                mcp_entry("github", "https://other.example.com/mcp"),
1052                // plaintext HTTP on a non-loopback host without the opt-in — skipped
1053                mcp_entry("plain", "http://mcp.example.com/mcp"),
1054                // loopback HTTP is always fine (local/dev)
1055                mcp_entry("local", "http://127.0.0.1:9200/mcp"),
1056                McpServerEntry {
1057                    enabled: Some(false),
1058                    ..mcp_entry("disabled", "https://mcp.example.com/mcp")
1059                },
1060                McpServerEntry {
1061                    auth_env: Some("  GITHUB_MCP_PAT  ".into()),
1062                    ..mcp_entry("authed", "https://api.githubcopilot.com/mcp")
1063                },
1064            ],
1065            ..Default::default()
1066        };
1067        let resolved = cfg.resolve_mcp_servers(false);
1068        let ids: Vec<&str> = resolved.iter().map(|s| s.id.as_str()).collect();
1069        assert_eq!(ids, ["github", "local", "authed"]);
1070        // Trailing slash normalized; the duplicate kept the first URL.
1071        assert_eq!(resolved[0].url, "https://mcp.example.com/mcp");
1072        assert_eq!(resolved[2].auth_env.as_deref(), Some("GITHUB_MCP_PAT"));
1073
1074        // The insecure-HTTP opt-in admits the plaintext entry (trusted LAN).
1075        let with_optin = cfg.resolve_mcp_servers(true);
1076        assert!(with_optin.iter().any(|s| s.id == "plain"));
1077    }
1078
1079    #[test]
1080    fn mcp_upstream_url_rules_match_the_proxy_posture() {
1081        assert!(validate_mcp_upstream_url("https://mcp.example.com/mcp", false).is_ok());
1082        assert!(validate_mcp_upstream_url("http://localhost:9200/mcp", false).is_ok());
1083        assert!(validate_mcp_upstream_url("http://mcp.example.com/mcp", false).is_err());
1084        assert!(validate_mcp_upstream_url("http://mcp.example.com/mcp", true).is_ok());
1085        assert!(validate_mcp_upstream_url("ftp://mcp.example.com", false).is_err());
1086        assert!(validate_mcp_upstream_url("   ", false).is_err());
1087    }
1088}