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 /// Fallback pricing model for any client without a per-client entry.
522 /// Unset/empty → lean-ctx keeps its blended heuristic.
523 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub default_model: Option<String>,
525 /// Per-client pricing model, keyed by client id (`cursor`, `copilot`,
526 /// `windsurf`, `claude`, `codex`, …). Used for MCP-only IDEs whose real
527 /// model lean-ctx cannot observe. Example:
528 /// `[cost.models]` then `cursor = "claude-opus-4.5"`.
529 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
530 pub models: HashMap<String, String>,
531 /// Operator price overrides (#1189), keyed by model name — for negotiated
532 /// enterprise rates (committed-use discounts, Azure PTU, zero-rated
533 /// internal models) that no public catalog can know. Merged into the
534 /// pricing table as **exact** entries, overriding embedded and live rows;
535 /// only a provider-measured bill beats them. Example:
536 /// `[cost.prices."internal-llm"]` then `input_per_m = 0.10`,
537 /// `output_per_m = 0.40`.
538 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
539 pub prices: HashMap<String, PriceOverride>,
540}
541
542/// One `[cost.prices.<model>]` row: USD per million tokens. Omitted cache
543/// rates default to the input rate (the same convention the catalogs use).
544#[derive(Debug, Clone, Default, Serialize, Deserialize)]
545#[serde(default)]
546pub struct PriceOverride {
547 pub input_per_m: Option<f64>,
548 pub output_per_m: Option<f64>,
549 pub cache_write_per_m: Option<f64>,
550 pub cache_read_per_m: Option<f64>,
551}
552
553impl CostConfig {
554 /// Configured pricing model for a client id: the per-client entry first, then
555 /// the global default. `None` when neither is set (the caller then falls back
556 /// to the env override / heuristic). Blank entries are ignored.
557 pub fn model_for_client(&self, client: &str) -> Option<String> {
558 self.models
559 .get(client)
560 .or(self.default_model.as_ref())
561 .map(|s| s.trim().to_string())
562 .filter(|s| !s.is_empty())
563 }
564}
565
566/// Code-health engine (`[code_health]`): clean code as a token-cost lever.
567///
568/// Cognitive complexity, naming quality, and coupling are computed once during
569/// indexing and surfaced at read- and edit-time. These switches tune the
570/// thresholds and how assertively findings are surfaced.
571#[derive(Debug, Clone, Serialize, Deserialize)]
572#[serde(default)]
573pub struct CodeHealthConfig {
574 /// Cognitive-complexity threshold above which a function is a hotspot.
575 /// Mirrors `core::code_health::DEFAULT_COGNITIVE_THRESHOLD` (15).
576 pub cognitive_threshold: u32,
577 /// Edit-gate behavior on complexity drift: `"warn"` (annotate, default),
578 /// `"block"` (refuse clean→over-threshold edits), or `"off"`.
579 pub gate: String,
580 /// Annotate over-threshold functions inline in `ctx_read` output.
581 pub annotate_reads: bool,
582 /// Run the naming-quality heuristic.
583 pub naming: bool,
584 /// Compute module-coupling metrics.
585 pub coupling: bool,
586 /// Inject `[CODE HEALTH]` notices as `additionalContext` in PostToolUse stdout.
587 /// Default: **false** — prevents prompt-cache invalidation on Anthropic models
588 /// (#778: each injection causes 440-520k tokens of cache re-bills when Claude
589 /// Code strips stale system-reminders retroactively).
590 /// When false, notices route to `ctx_knowledge` + dashboard instead.
591 #[serde(default)]
592 pub inject_context: bool,
593}
594
595impl Default for CodeHealthConfig {
596 fn default() -> Self {
597 Self {
598 cognitive_threshold: 15,
599 gate: "warn".to_string(),
600 annotate_reads: true,
601 naming: true,
602 coupling: true,
603 inject_context: false,
604 }
605 }
606}
607
608/// Index-time file filters (#735): declare the retrieval corpus explicitly
609/// instead of abusing `.gitignore` for retrieval policy.
610///
611/// Applies to every index builder through one shared filter layer
612/// (`core::index_filter`): BM25, graph, and the watch/incremental path; the
613/// semantic index chunks the BM25 corpus and inherits the same universe.
614/// Excluded files never produce chunks, graph nodes, or embeddings. Globs are
615/// matched against the root-relative path (forward slashes); exclude wins
616/// over include. The empty default preserves today's behavior byte-for-byte.
617#[derive(Debug, Clone, Serialize, Deserialize)]
618#[serde(default)]
619pub struct IndexConfig {
620 /// Honor `.gitignore` / global gitignore / `.git/info/exclude` during
621 /// index walks. `false` indexes ignored files too (rarely wanted; the
622 /// vendor-directory guard still applies).
623 pub respect_gitignore: bool,
624 /// Files to drop from the index corpus, e.g. `["**/*.csv", "fixtures/**"]`.
625 /// Evaluated after `include`; a file matching both is excluded.
626 pub exclude: Vec<String>,
627 /// When non-empty, ONLY matching files enter the index corpus, e.g.
628 /// `["**/*.rs", "**/*.ts"]`. Empty = no restriction.
629 pub include: Vec<String>,
630}
631
632impl Default for IndexConfig {
633 fn default() -> Self {
634 Self {
635 respect_gitignore: true,
636 exclude: Vec::new(),
637 include: Vec::new(),
638 }
639 }
640}
641
642/// Settings for the code graph — in particular the *traversal* (co-access) edges
643/// learned from real agent sessions (#289).
644///
645/// The static AST/import graph captures how code is wired structurally; it cannot
646/// see which files an agent actually opens *together* while solving a task.
647/// Traversal edges add that behavioural signal: files surfaced together are
648/// associated with a decaying weight (Hebbian co-access), folded into the graph
649/// as `co_access` edges and mixed into recall. The store is bounded and decays,
650/// so stale associations fade.
651#[derive(Debug, Clone, Serialize, Deserialize)]
652#[serde(default)]
653pub struct GraphConfig {
654 /// Record co-access between files surfaced together in a session, surface them
655 /// as decaying `co_access` edges in the graph, and boost recall by them.
656 /// On by default; set to `false` for a purely static (AST-only) graph.
657 pub traversal_edges: bool,
658}
659
660impl Default for GraphConfig {
661 fn default() -> Self {
662 Self {
663 traversal_edges: true,
664 }
665 }
666}
667
668/// Skillify (#290): mine the project's session diary + knowledge facts into
669/// versioned, git-committable `.cursor/rules/skillify-*.mdc` rule files.
670///
671/// The miner is precision-biased — it only codifies recurring or high-confidence
672/// patterns and never invents content. Runs on demand (`ctx_skillify` /
673/// `lean-ctx skillify`); re-running merges (bumps version) only when the distilled
674/// content actually changes.
675#[derive(Debug, Clone, Serialize, Deserialize)]
676#[serde(default)]
677pub struct SkillifyConfig {
678 /// Master switch for the skillify miner. On by default; the miner only ever
679 /// acts when explicitly invoked, so this never writes files unprompted.
680 pub enabled: bool,
681 /// Where generated rules are written: `project` (`<repo>/.cursor/rules`,
682 /// git-committable, default) or `global` (`~/.cursor/rules`).
683 pub scope: String,
684 /// Minimum confidence for a single curated knowledge fact to be codified even
685 /// without repetition. 0.0..=1.0.
686 pub min_confidence: f32,
687 /// Minimum number of reinforcements (confirmations / repeated mentions) before
688 /// a pattern is codified when its confidence is below `min_confidence`.
689 pub min_recurrence: u32,
690}
691
692impl Default for SkillifyConfig {
693 fn default() -> Self {
694 Self {
695 enabled: true,
696 scope: "project".to_string(),
697 min_confidence: 0.7,
698 min_recurrence: 2,
699 }
700 }
701}
702
703/// AI session summaries (#292): periodically distil the working session into a
704/// compact, *semantically recallable* summary so a future session can answer
705/// "what did I do last time on X?". Deterministic and local-first — recall uses
706/// embeddings when the `embeddings` feature is on, else a lexical fallback.
707#[derive(Debug, Clone, Serialize, Deserialize)]
708#[serde(default)]
709pub struct SummariesConfig {
710 /// Record periodic session summaries. On by default; recording is cheap and
711 /// happens at most once per `every_n_turns` tool calls.
712 pub enabled: bool,
713 /// Tool calls between automatic summaries. The auto-checkpoint cadence still
714 /// gates the check, so the effective minimum is the checkpoint interval.
715 pub every_n_turns: u32,
716 /// Maximum summaries kept per project (oldest pruned first).
717 pub max_kept: u32,
718}
719
720impl Default for SummariesConfig {
721 fn default() -> Self {
722 Self {
723 enabled: true,
724 every_n_turns: 25,
725 max_kept: 100,
726 }
727 }
728}
729
730/// A user-defined command alias mapping for shell compression patterns.
731#[derive(Debug, Clone, Serialize, Deserialize)]
732pub struct AliasEntry {
733 pub command: String,
734 pub alias: String,
735}
736
737/// Thresholds for detecting and throttling repetitive agent tool call loops.
738#[derive(Debug, Clone, Serialize, Deserialize)]
739#[serde(default)]
740pub struct LoopDetectionConfig {
741 pub normal_threshold: u32,
742 pub reduced_threshold: u32,
743 pub blocked_threshold: u32,
744 pub window_secs: u64,
745 pub search_group_limit: u32,
746 pub tool_total_limits: HashMap<String, u32>,
747}
748
749impl Default for LoopDetectionConfig {
750 fn default() -> Self {
751 let mut tool_total_limits = HashMap::new();
752 tool_total_limits.insert("ctx_read".to_string(), 100);
753 tool_total_limits.insert("ctx_search".to_string(), 80);
754 tool_total_limits.insert("ctx_shell".to_string(), 50);
755 tool_total_limits.insert("ctx_semantic_search".to_string(), 60);
756 Self {
757 normal_threshold: 2,
758 reduced_threshold: 4,
759 blocked_threshold: 0,
760 window_secs: 300,
761 search_group_limit: 10,
762 tool_total_limits,
763 }
764 }
765}
766
767/// Semantic-embedding engine settings.
768///
769/// `model` selects which local ONNX embedding model lean-ctx downloads and uses for
770/// `ctx_semantic_search`. Accepts the same aliases as the `LEAN_CTX_EMBEDDING_MODEL` env
771/// var: `minilm` (all-MiniLM-L6-v2, 384d — the default), `nomic` (768d) — or any
772/// HuggingFace repo with an ONNX export via `hf:org/repo[@revision]` (GL #397), e.g.
773/// `hf:jinaai/jina-embeddings-v2-base-code` for code-specialized embeddings. When the
774/// env var is set it takes precedence; an
775/// unset/`None` value uses the default model. Switching models triggers a one-time
776/// re-index on the next semantic search (vector dimensions follow from the model).
777///
778/// `dimensions` is only consulted for `hf:` custom models as the declared fallback
779/// width; the real width is probed from the ONNX graph at load time. Built-ins ignore it.
780/// `[gateway_server]` — deployment parameters of the self-hosted org gateway
781/// (enterprise#20). Distinct from `[gateway]` (the MCP tool-catalog gateway):
782/// this section describes the LLM-proxy *server* deployment and its cockpit.
783///
784/// All fields optional; an empty section keeps every local behavior unchanged.
785#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
786#[serde(default)]
787pub struct GatewayServerConfig {
788 /// Seats the org-wide projection extrapolates to (e.g. `800`). `None`
789 /// disables the projection — the cockpit never invents a seat count.
790 #[serde(default, skip_serializing_if = "Option::is_none")]
791 pub seats: Option<u32>,
792 /// Display label for the cockpit header (e.g. `"Zühlke AI Gateway"`).
793 #[serde(default, skip_serializing_if = "Option::is_none")]
794 pub org_label: Option<String>,
795 /// Central admin API base URL (e.g. `https://ai-gateway.example.com`).
796 /// When set, the local cockpit's usage breakdown reads the org-wide
797 /// `GET /api/admin/usage` instead of the machine-local snapshot. The
798 /// bearer token comes from `LEAN_CTX_GATEWAY_ADMIN_TOKEN` (never config).
799 #[serde(default, skip_serializing_if = "Option::is_none")]
800 pub admin_url: Option<String>,
801 /// Bind address of the admin listener (dashboard + `/api/admin/*` +
802 /// `/metrics`). Defaults to loopback — **secure by default** (#54/#56):
803 /// exposing the console is an explicit decision. Container deployments set
804 /// `"0.0.0.0"` here (the pod/compose port mapping stays the outer guard).
805 /// `LEAN_CTX_GATEWAY_ADMIN_BIND_HOST` overrides. Invalid values fall back
806 /// to loopback: a typo can only ever narrow exposure, never open it.
807 #[serde(default, skip_serializing_if = "Option::is_none")]
808 pub admin_bind_host: Option<String>,
809 /// Days to keep `usage_events` rows (enterprise#36). `None`/`0` = keep
810 /// forever (the local-free default — retention is a deployment decision).
811 /// A running gateway purges older rows periodically; typical compliance
812 /// values are `365` or `3650` (EU AI Act evidence horizon).
813 #[serde(default, skip_serializing_if = "Option::is_none")]
814 pub usage_retention_days: Option<u32>,
815 /// Replace `person` with a stable keyed pseudonym (`p:<hash>`) before it
816 /// reaches metering, budgets, dashboards and logs (enterprise#39, GDPR).
817 /// The salt lives in `<data_dir>/gateway_pii_salt`; `gateway gdpr`
818 /// re-derives pseudonyms from e-mail input, so DSGVO delete/export keep
819 /// working. Default `false` (cleartext person tags).
820 #[serde(default, skip_serializing_if = "Option::is_none")]
821 pub pseudonymize_persons: Option<bool>,
822 /// MCP upstream registry (GL#91/#99, Doc 15 §7 — the observe stage of MCP
823 /// context governance). Each entry publishes a governed reverse-proxy
824 /// route `/mcp/{id}` on the proxy port: same per-person key auth as the
825 /// LLM channel, tool calls metered into `mcp_events`, tool definitions
826 /// inventoried + hash-tracked (rug-pull detection). Observe-only: the
827 /// gateway never blocks or rewrites MCP traffic in this stage.
828 #[serde(default, skip_serializing_if = "Vec::is_empty")]
829 pub mcp_servers: Vec<McpServerEntry>,
830}
831
832/// One `[[gateway_server.mcp_servers]]` registry entry — an MCP server the org
833/// gateway fronts. Distinct from `[[gateway.servers]]` (the *local* tool-
834/// catalog aggregator, #210): this registry is the org-facing reverse proxy.
835#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
836pub struct McpServerEntry {
837 /// Registry id, used in the `/mcp/{id}` route. Lowercase alphanumeric
838 /// plus `-`/`_` (it becomes a URL path segment).
839 pub id: String,
840 /// Upstream Streamable-HTTP endpoint (the server's single MCP endpoint,
841 /// e.g. `https://mcp.example.com/mcp`). HTTPS for any non-loopback host;
842 /// plaintext HTTP needs the same explicit opt-in as LLM upstreams
843 /// (`[proxy] allow_insecure_http_upstream`).
844 pub url: String,
845 /// Name of the environment variable holding the upstream credential. When
846 /// set, the gateway sends `Authorization: Bearer <value>` upstream — the
847 /// credential lives in the gateway's environment, never on laptops. The
848 /// caller's own `Authorization` header (their gateway key) is **always**
849 /// stripped before forwarding, with or without this field.
850 #[serde(default, skip_serializing_if = "Option::is_none")]
851 pub auth_env: Option<String>,
852 /// Set `false` to keep the entry in config but take it out of service.
853 #[serde(default, skip_serializing_if = "Option::is_none")]
854 pub enabled: Option<bool>,
855}
856
857/// A validated, ready-to-serve MCP registry entry (runtime view of
858/// [`McpServerEntry`]).
859#[derive(Debug, Clone, PartialEq, Eq)]
860pub struct ResolvedMcpServer {
861 pub id: String,
862 pub url: String,
863 pub auth_env: Option<String>,
864}
865
866impl GatewayServerConfig {
867 /// Validate + resolve the `[[gateway_server.mcp_servers]]` registry.
868 /// Same resilience contract as `[[proxy.providers]]`: invalid entries are
869 /// logged and skipped (one typo never takes the gateway down), duplicates
870 /// keep the first occurrence. `allow_insecure_http` mirrors the proxy's
871 /// plaintext-HTTP opt-in so the two registries share one security posture.
872 #[must_use]
873 pub fn resolve_mcp_servers(&self, allow_insecure_http: bool) -> Vec<ResolvedMcpServer> {
874 let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
875 let mut out = Vec::new();
876 for entry in &self.mcp_servers {
877 if !entry.enabled.unwrap_or(true) {
878 continue;
879 }
880 let id = entry.id.trim();
881 if !is_valid_mcp_server_id(id) {
882 tracing::warn!(
883 "[gateway_server.mcp_servers] invalid id '{id}' \
884 (lowercase alnum/-/_ only) — entry skipped"
885 );
886 continue;
887 }
888 if !seen.insert(id) {
889 tracing::warn!(
890 "[gateway_server.mcp_servers] duplicate id '{id}' — keeping first entry"
891 );
892 continue;
893 }
894 match validate_mcp_upstream_url(&entry.url, allow_insecure_http) {
895 Ok(url) => out.push(ResolvedMcpServer {
896 id: id.to_string(),
897 url,
898 auth_env: entry
899 .auth_env
900 .as_deref()
901 .map(str::trim)
902 .filter(|v| !v.is_empty())
903 .map(str::to_string),
904 }),
905 Err(e) => {
906 tracing::warn!(
907 "[gateway_server.mcp_servers] '{id}' has invalid url — skipped: {e}"
908 );
909 }
910 }
911 }
912 out
913 }
914
915 /// Effective admin bind address (see `admin_bind_host`). Precedence:
916 /// `LEAN_CTX_GATEWAY_ADMIN_BIND_HOST` env > config > `127.0.0.1`.
917 #[must_use]
918 pub fn resolved_admin_bind_host(&self) -> std::net::IpAddr {
919 let raw = std::env::var("LEAN_CTX_GATEWAY_ADMIN_BIND_HOST")
920 .ok()
921 .filter(|v| !v.trim().is_empty())
922 .or_else(|| self.admin_bind_host.clone());
923 match raw.as_deref().map(str::trim) {
924 Some(v) if !v.is_empty() => v.parse().unwrap_or_else(|_| {
925 tracing::warn!(
926 "gateway_server.admin_bind_host '{v}' is not a valid IP address — binding 127.0.0.1"
927 );
928 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
929 }),
930 _ => std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
931 }
932 }
933}
934
935/// True when `id` is usable as an MCP registry id: non-empty, lowercase alnum
936/// plus `-`/`_` (it becomes a URL path segment). Same shape rule as
937/// `[[proxy.providers]]` ids; no built-in namespace exists to shadow here.
938fn is_valid_mcp_server_id(id: &str) -> bool {
939 !id.is_empty()
940 && id
941 .chars()
942 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
943}
944
945/// Validates an MCP upstream URL. A declared registry entry is itself the
946/// deliberate custom-host opt-in (same rationale as `[[proxy.providers]]`):
947/// any HTTPS host is accepted; loopback HTTP is always fine; non-loopback
948/// plaintext HTTP requires the explicit insecure-HTTP opt-in. This is the
949/// SSRF boundary — the proxy only ever connects to URLs that passed here.
950fn validate_mcp_upstream_url(url: &str, allow_insecure_http: bool) -> Result<String, String> {
951 let trimmed = url.trim().trim_end_matches('/');
952 if trimmed.is_empty() {
953 return Err("empty url".into());
954 }
955 if crate::core::config::is_local_proxy_url(trimmed) {
956 return Ok(trimmed.to_string());
957 }
958 if trimmed.starts_with("http://") {
959 if allow_insecure_http {
960 return Ok(trimmed.to_string());
961 }
962 return Err(format!(
963 "MCP upstream must use HTTPS: {trimmed} (for a trusted local-network HTTP \
964 upstream opt in with `[proxy] allow_insecure_http_upstream = true`)"
965 ));
966 }
967 if trimmed.starts_with("https://") {
968 return Ok(trimmed.to_string());
969 }
970 Err(format!(
971 "MCP upstream must start with http:// or https://: {trimmed}"
972 ))
973}
974
975#[derive(Debug, Clone, Default, Serialize, Deserialize)]
976#[serde(default)]
977pub struct EmbeddingConfig {
978 #[serde(default, skip_serializing_if = "Option::is_none")]
979 pub model: Option<String>,
980 #[serde(default, skip_serializing_if = "Option::is_none")]
981 pub dimensions: Option<usize>,
982 /// Allow downloading the embedding model on first semantic need (#551).
983 /// `None` (unset) means **allowed** — the soft default that activates the
984 /// semantic features without manual setup. Set `false` for air-gapped
985 /// machines. The `LEAN_CTX_EMBEDDINGS_AUTO_DOWNLOAD` env var, when set,
986 /// overrides this in either direction.
987 #[serde(default, skip_serializing_if = "Option::is_none")]
988 pub auto_download: Option<bool>,
989 /// Pin embedding inference to a single CPU thread (no GPU EP) so vectors are
990 /// bit-identical across machines, not just run-to-run on one host (#895).
991 /// `None`/`false` keeps the multi-threaded GPU-capable path. Extractive prose
992 /// ranking is already deterministic via score quantization + stable tiebreak;
993 /// this flag is the extra hardening for cross-machine reproducibility. The
994 /// `LEAN_CTX_EMBEDDING_DETERMINISTIC` env var overrides this either way.
995 #[serde(default, skip_serializing_if = "Option::is_none")]
996 pub deterministic: Option<bool>,
997}
998
999#[cfg(test)]
1000mod gateway_server_tests {
1001 use super::*;
1002
1003 #[test]
1004 fn admin_bind_defaults_to_loopback_and_rejects_garbage() {
1005 // Secure by default (#54/#56): unset and invalid both land on loopback.
1006 let cfg = GatewayServerConfig::default();
1007 assert!(cfg.resolved_admin_bind_host().is_loopback());
1008
1009 let cfg = GatewayServerConfig {
1010 admin_bind_host: Some("not-an-ip".into()),
1011 ..Default::default()
1012 };
1013 assert!(
1014 cfg.resolved_admin_bind_host().is_loopback(),
1015 "a typo must narrow exposure, never widen it"
1016 );
1017
1018 let cfg = GatewayServerConfig {
1019 admin_bind_host: Some("0.0.0.0".into()),
1020 ..Default::default()
1021 };
1022 assert!(
1023 !cfg.resolved_admin_bind_host().is_loopback(),
1024 "explicit opt-in widens the bind"
1025 );
1026 }
1027
1028 fn mcp_entry(id: &str, url: &str) -> McpServerEntry {
1029 McpServerEntry {
1030 id: id.into(),
1031 url: url.into(),
1032 auth_env: None,
1033 enabled: None,
1034 }
1035 }
1036
1037 #[test]
1038 fn mcp_registry_validates_ids_urls_and_duplicates() {
1039 let cfg = GatewayServerConfig {
1040 mcp_servers: vec![
1041 mcp_entry("github", "https://mcp.example.com/mcp/"),
1042 // invalid id (uppercase) — skipped, never panics
1043 mcp_entry("GitHub", "https://mcp.example.com/mcp"),
1044 // duplicate — first occurrence wins
1045 mcp_entry("github", "https://other.example.com/mcp"),
1046 // plaintext HTTP on a non-loopback host without the opt-in — skipped
1047 mcp_entry("plain", "http://mcp.example.com/mcp"),
1048 // loopback HTTP is always fine (local/dev)
1049 mcp_entry("local", "http://127.0.0.1:9200/mcp"),
1050 McpServerEntry {
1051 enabled: Some(false),
1052 ..mcp_entry("disabled", "https://mcp.example.com/mcp")
1053 },
1054 McpServerEntry {
1055 auth_env: Some(" GITHUB_MCP_PAT ".into()),
1056 ..mcp_entry("authed", "https://api.githubcopilot.com/mcp")
1057 },
1058 ],
1059 ..Default::default()
1060 };
1061 let resolved = cfg.resolve_mcp_servers(false);
1062 let ids: Vec<&str> = resolved.iter().map(|s| s.id.as_str()).collect();
1063 assert_eq!(ids, ["github", "local", "authed"]);
1064 // Trailing slash normalized; the duplicate kept the first URL.
1065 assert_eq!(resolved[0].url, "https://mcp.example.com/mcp");
1066 assert_eq!(resolved[2].auth_env.as_deref(), Some("GITHUB_MCP_PAT"));
1067
1068 // The insecure-HTTP opt-in admits the plaintext entry (trusted LAN).
1069 let with_optin = cfg.resolve_mcp_servers(true);
1070 assert!(with_optin.iter().any(|s| s.id == "plain"));
1071 }
1072
1073 #[test]
1074 fn mcp_upstream_url_rules_match_the_proxy_posture() {
1075 assert!(validate_mcp_upstream_url("https://mcp.example.com/mcp", false).is_ok());
1076 assert!(validate_mcp_upstream_url("http://localhost:9200/mcp", false).is_ok());
1077 assert!(validate_mcp_upstream_url("http://mcp.example.com/mcp", false).is_err());
1078 assert!(validate_mcp_upstream_url("http://mcp.example.com/mcp", true).is_ok());
1079 assert!(validate_mcp_upstream_url("ftp://mcp.example.com", false).is_err());
1080 assert!(validate_mcp_upstream_url(" ", false).is_err());
1081 }
1082}