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 ///
79 /// Delegates the per-agent path catalog to `rules_inject::any_rules_marker_present`
80 /// (derived from the injector's own target list) so this never drifts behind
81 /// newly supported agents again (#442). Claude Code and CodeBuddy have no
82 /// rules *target* (they auto-load an inline block instead), so their legacy
83 /// rule files are checked separately to keep honoring older installs.
84 fn rules_already_present() -> bool {
85 let Some(home) = dirs::home_dir() else {
86 return false;
87 };
88 if crate::rules_inject::any_rules_marker_present(&home) {
89 return true;
90 }
91 let legacy_paths = [
92 crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md"),
93 crate::core::editor_registry::codebuddy_rules_dir(&home).join("lean-ctx.md"),
94 ];
95 legacy_paths.iter().any(|p| {
96 std::fs::read_to_string(p)
97 .is_ok_and(|c| c.contains(crate::core::rules_canonical::START_MARK))
98 })
99 }
100}
101
102impl Default for SecretDetectionConfig {
103 fn default() -> Self {
104 Self {
105 enabled: true,
106 redact: true,
107 custom_patterns: Vec::new(),
108 }
109 }
110}
111
112/// Settings for the zero-loss compression archive (large tool outputs saved to disk).
113#[derive(Debug, Clone, Serialize, Deserialize)]
114#[serde(default)]
115pub struct ArchiveConfig {
116 pub enabled: bool,
117 pub threshold_chars: usize,
118 pub max_age_hours: u64,
119 pub max_disk_mb: u64,
120 pub ephemeral: bool,
121 /// Minimum output tokens before the ephemeral firewall replaces an inline tool
122 /// result with a summary + retrieval ref. Outputs below this stay fully inline.
123 pub ephemeral_min_tokens: usize,
124}
125
126impl Default for ArchiveConfig {
127 fn default() -> Self {
128 Self {
129 enabled: true,
130 threshold_chars: 800,
131 max_age_hours: 48,
132 max_disk_mb: 500,
133 ephemeral: true,
134 ephemeral_min_tokens: 2000,
135 }
136 }
137}
138
139impl ArchiveConfig {
140 pub fn ephemeral_effective(&self) -> bool {
141 if let Ok(v) = std::env::var("LEAN_CTX_EPHEMERAL") {
142 return !matches!(v.trim(), "0" | "false" | "off");
143 }
144 self.ephemeral && self.enabled
145 }
146
147 pub fn ephemeral_min_tokens_effective(&self) -> usize {
148 if let Ok(v) = std::env::var("LEAN_CTX_EPHEMERAL_MIN_TOKENS")
149 && let Ok(n) = v.trim().parse::<usize>()
150 {
151 return n;
152 }
153 self.ephemeral_min_tokens
154 }
155}
156
157/// Configuration for external context providers (GitHub, GitLab, Jira, etc.).
158/// Each provider can be enabled/disabled and configured with auth tokens.
159/// Override individual tokens via env vars (GITHUB_TOKEN, GITLAB_TOKEN, etc.).
160#[derive(Debug, Clone, Serialize, Deserialize)]
161#[serde(default)]
162pub struct ProvidersConfig {
163 /// Master switch for the provider subsystem.
164 pub enabled: bool,
165 /// GitHub provider configuration.
166 pub github: ProviderEntryConfig,
167 /// GitLab provider configuration.
168 pub gitlab: ProviderEntryConfig,
169 /// Auto-ingest provider results into BM25/embedding indexes.
170 pub auto_index: bool,
171 /// Default cache TTL for provider results (seconds).
172 pub cache_ttl_secs: u64,
173 /// MCP Bridge providers: `{ "name" = { url = "...", description = "..." } }`.
174 #[serde(default)]
175 pub mcp_bridges: std::collections::HashMap<String, McpBridgeEntry>,
176}
177
178impl Default for ProvidersConfig {
179 fn default() -> Self {
180 Self {
181 enabled: true,
182 github: ProviderEntryConfig::default(),
183 gitlab: ProviderEntryConfig::default(),
184 auto_index: true,
185 cache_ttl_secs: 120,
186 mcp_bridges: std::collections::HashMap::new(),
187 }
188 }
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct McpBridgeEntry {
193 /// HTTP/SSE URL for remote MCP servers.
194 #[serde(default)]
195 pub url: Option<String>,
196 /// Command to spawn a local MCP server (stdio transport).
197 #[serde(default)]
198 pub command: Option<String>,
199 /// Arguments for the command.
200 #[serde(default)]
201 pub args: Vec<String>,
202 /// Human-readable description.
203 #[serde(default)]
204 pub description: Option<String>,
205 /// Environment variable name containing an auth token.
206 #[serde(default)]
207 pub auth_env: Option<String>,
208}
209
210/// Per-provider configuration entry.
211#[derive(Debug, Clone, Serialize, Deserialize)]
212#[serde(default)]
213pub struct ProviderEntryConfig {
214 /// Whether this specific provider is enabled.
215 pub enabled: bool,
216 /// Auth token (prefer env var; only use this for project-local overrides).
217 pub token: Option<String>,
218 /// API base URL override (for GitHub Enterprise, self-hosted GitLab, etc.).
219 pub api_url: Option<String>,
220 /// Default project/repo for this provider (auto-detected from git remote if empty).
221 pub project: Option<String>,
222}
223
224impl Default for ProviderEntryConfig {
225 fn default() -> Self {
226 Self {
227 enabled: true,
228 token: None,
229 api_url: None,
230 project: None,
231 }
232 }
233}
234
235/// Controls autonomous background behaviors (preload, dedup, consolidation).
236#[derive(Debug, Clone, Serialize, Deserialize)]
237#[serde(default)]
238pub struct AutonomyConfig {
239 pub enabled: bool,
240 pub auto_preload: bool,
241 pub auto_dedup: bool,
242 pub auto_related: bool,
243 pub auto_consolidate: bool,
244 pub silent_preload: bool,
245 pub dedup_threshold: usize,
246 pub consolidate_every_calls: u32,
247 pub consolidate_cooldown_secs: u64,
248 #[serde(default = "serde_defaults::default_true")]
249 pub cognition_loop_enabled: bool,
250 #[serde(default = "serde_defaults::default_cognition_loop_interval")]
251 pub cognition_loop_interval_secs: u64,
252 #[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
253 pub cognition_loop_max_steps: u8,
254 /// Minimum facts an entity needs before observation synthesis (#802) writes a
255 /// summary. Synthesis itself is gated by `cognition_loop_max_steps >= 9`.
256 #[serde(default = "serde_defaults::default_cognition_synthesis_min_cluster")]
257 pub cognition_synthesis_min_cluster: usize,
258}
259
260impl Default for AutonomyConfig {
261 fn default() -> Self {
262 Self {
263 enabled: true,
264 auto_preload: true,
265 auto_dedup: true,
266 auto_related: true,
267 auto_consolidate: true,
268 silent_preload: true,
269 dedup_threshold: 8,
270 consolidate_every_calls: 25,
271 consolidate_cooldown_secs: 120,
272 cognition_loop_enabled: true,
273 cognition_loop_interval_secs: 3600,
274 cognition_loop_max_steps: 9,
275 cognition_synthesis_min_cluster: 3,
276 }
277 }
278}
279
280/// Controls automatic update behavior. All defaults are OFF — auto-updates
281/// require explicit opt-in via `lean-ctx setup` or `lean-ctx update --schedule`.
282#[derive(Debug, Clone, Serialize, Deserialize)]
283#[serde(default)]
284pub struct UpdatesConfig {
285 pub auto_update: bool,
286 pub check_interval_hours: u64,
287 pub notify_only: bool,
288}
289
290impl Default for UpdatesConfig {
291 fn default() -> Self {
292 Self {
293 auto_update: false,
294 check_interval_hours: 6,
295 notify_only: false,
296 }
297 }
298}
299
300/// Fixed-context budget accounting (#964). The per-session footprint lean-ctx
301/// adds — tool schemas + MCP instructions + auto-loaded rules files + the wakeup
302/// briefing — is warned about once it crosses `budget_tokens`. The
303/// `LEAN_CTX_CONTEXT_BUDGET_TOKENS` env var overrides it; `lean-ctx doctor
304/// overhead --gate` turns a breach into a non-zero exit for CI.
305#[derive(Debug, Clone, Serialize, Deserialize)]
306#[serde(default)]
307pub struct ContextConfig {
308 pub budget_tokens: usize,
309}
310
311impl Default for ContextConfig {
312 fn default() -> Self {
313 Self {
314 budget_tokens: 8000,
315 }
316 }
317}
318
319impl UpdatesConfig {
320 pub fn from_env() -> Self {
321 let mut cfg = Self::default();
322 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
323 cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
324 }
325 if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS")
326 && let Ok(h) = v.parse::<u64>()
327 {
328 cfg.check_interval_hours = h.clamp(1, 168);
329 }
330 if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
331 cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
332 }
333 cfg
334 }
335}
336
337impl AutonomyConfig {
338 /// Creates an autonomy config from env vars, falling back to defaults.
339 pub fn from_env() -> Self {
340 let mut cfg = Self::default();
341 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY")
342 && (v == "false" || v == "0")
343 {
344 cfg.enabled = false;
345 }
346 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
347 cfg.auto_preload = v != "false" && v != "0";
348 }
349 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
350 cfg.auto_dedup = v != "false" && v != "0";
351 }
352 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
353 cfg.auto_related = v != "false" && v != "0";
354 }
355 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
356 cfg.auto_consolidate = v != "false" && v != "0";
357 }
358 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
359 cfg.silent_preload = v != "false" && v != "0";
360 }
361 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD")
362 && let Ok(n) = v.parse()
363 {
364 cfg.dedup_threshold = n;
365 }
366 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS")
367 && let Ok(n) = v.parse()
368 {
369 cfg.consolidate_every_calls = n;
370 }
371 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS")
372 && let Ok(n) = v.parse()
373 {
374 cfg.consolidate_cooldown_secs = n;
375 }
376 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
377 cfg.cognition_loop_enabled = v != "false" && v != "0";
378 }
379 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS")
380 && let Ok(n) = v.parse()
381 {
382 cfg.cognition_loop_interval_secs = n;
383 }
384 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS")
385 && let Ok(n) = v.parse()
386 {
387 cfg.cognition_loop_max_steps = n;
388 }
389 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_SYNTHESIS_MIN_CLUSTER")
390 && let Ok(n) = v.parse()
391 {
392 cfg.cognition_synthesis_min_cluster = n;
393 }
394 cfg
395 }
396
397 /// Loads autonomy config from disk, with env var overrides applied.
398 pub fn load() -> Self {
399 let file_cfg = Config::load().autonomy;
400 let mut cfg = file_cfg;
401 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY")
402 && (v == "false" || v == "0")
403 {
404 cfg.enabled = false;
405 }
406 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
407 cfg.auto_preload = v != "false" && v != "0";
408 }
409 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
410 cfg.auto_dedup = v != "false" && v != "0";
411 }
412 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
413 cfg.auto_related = v != "false" && v != "0";
414 }
415 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
416 cfg.silent_preload = v != "false" && v != "0";
417 }
418 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD")
419 && let Ok(n) = v.parse()
420 {
421 cfg.dedup_threshold = n;
422 }
423 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
424 cfg.cognition_loop_enabled = v != "false" && v != "0";
425 }
426 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS")
427 && let Ok(n) = v.parse()
428 {
429 cfg.cognition_loop_interval_secs = n;
430 }
431 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS")
432 && let Ok(n) = v.parse()
433 {
434 cfg.cognition_loop_max_steps = n;
435 }
436 if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_SYNTHESIS_MIN_CLUSTER")
437 && let Ok(n) = v.parse()
438 {
439 cfg.cognition_synthesis_min_cluster = n;
440 }
441 cfg
442 }
443}
444
445/// Cloud sync and contribution settings (pattern sharing, model pulls).
446#[derive(Debug, Clone, Serialize, Deserialize, Default)]
447#[serde(default)]
448pub struct CloudConfig {
449 pub contribute_enabled: bool,
450 pub last_contribute: Option<String>,
451 pub last_sync: Option<String>,
452 pub last_gain_sync: Option<String>,
453 pub last_model_pull: Option<String>,
454 /// Auto-push the Pro Personal-Cloud surfaces (knowledge, commands, CEP,
455 /// gotchas, buddy, feedback) from the background task — opt-in, once per
456 /// day, offline-tolerant (GL #384). Toggle: `lean-ctx cloud autosync on`.
457 pub auto_sync: bool,
458 pub last_auto_sync: Option<String>,
459 /// Auto-push the project's encrypted retrieval-index bundle (hosted
460 /// Personal Index, GL #392) alongside the daily auto-sync — separate
461 /// opt-in because index bundles are orders of magnitude larger than the
462 /// other surfaces. Toggle: `lean-ctx cloud autoindex on`.
463 pub auto_index: bool,
464 /// Per-project debounce: `project_hash → YYYY-MM-DD` of the last
465 /// successful background index push.
466 pub last_index_push: std::collections::HashMap<String, String>,
467}
468
469/// Settings for publishing your token-savings recap (`gain --publish` / auto-publish).
470///
471/// Publishing is always opt-in: it sends a small, whitelisted *aggregate* payload (tokens
472/// saved, $ avoided, compression % — never code, paths or counts) to the cloud.
473/// `auto_publish` simply removes the need to re-run `gain --publish` by hand; it stays off
474/// until the user explicitly enables it.
475#[derive(Debug, Clone, Serialize, Deserialize)]
476#[serde(default)]
477pub struct GainConfig {
478 /// When true, `lean-ctx gain` automatically (re)publishes the recap, throttled to
479 /// `auto_publish_interval_hours`. Off by default.
480 pub auto_publish: bool,
481 /// When auto-publishing, also opt into the public leaderboard.
482 pub leaderboard: bool,
483 /// Optional display name for the published card / leaderboard entry.
484 pub display_name: Option<String>,
485 /// Minimum hours between automatic publishes (throttle).
486 pub auto_publish_interval_hours: u64,
487 /// Runtime state — RFC3339 timestamp of the last automatic publish. Managed by the
488 /// tool, not meant to be set by hand.
489 pub last_auto_publish: Option<String>,
490}
491
492impl Default for GainConfig {
493 fn default() -> Self {
494 Self {
495 auto_publish: false,
496 leaderboard: true,
497 display_name: None,
498 auto_publish_interval_hours: 24,
499 last_auto_publish: None,
500 }
501 }
502}
503
504/// Model declaration for **measured-vs-estimated** cost reporting.
505///
506/// Proxy-routed clients (Claude Code, Codex, Pi, Gemini CLI, OpenCode) report
507/// their real model and billed tokens, so lean-ctx prices them *measured* with
508/// no configuration. MCP-only IDEs (Cursor, Copilot, Windsurf, VS Code, Zed)
509/// send their LLM traffic straight to the provider, bypassing lean-ctx — their
510/// real model is invisible. Declaring it here lets those *estimated* turns be
511/// priced with the correct model instead of a blended fallback.
512#[derive(Debug, Clone, Default, Serialize, Deserialize)]
513#[serde(default)]
514pub struct CostConfig {
515 /// Fallback pricing model for any client without a per-client entry.
516 /// Unset/empty → lean-ctx keeps its blended heuristic.
517 #[serde(default, skip_serializing_if = "Option::is_none")]
518 pub default_model: Option<String>,
519 /// Per-client pricing model, keyed by client id (`cursor`, `copilot`,
520 /// `windsurf`, `claude`, `codex`, …). Used for MCP-only IDEs whose real
521 /// model lean-ctx cannot observe. Example:
522 /// `[cost.models]` then `cursor = "claude-opus-4.5"`.
523 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
524 pub models: HashMap<String, String>,
525}
526
527impl CostConfig {
528 /// Configured pricing model for a client id: the per-client entry first, then
529 /// the global default. `None` when neither is set (the caller then falls back
530 /// to the env override / heuristic). Blank entries are ignored.
531 pub fn model_for_client(&self, client: &str) -> Option<String> {
532 self.models
533 .get(client)
534 .or(self.default_model.as_ref())
535 .map(|s| s.trim().to_string())
536 .filter(|s| !s.is_empty())
537 }
538}
539
540/// Code-health engine (`[code_health]`): clean code as a token-cost lever.
541///
542/// Cognitive complexity, naming quality, and coupling are computed once during
543/// indexing and surfaced at read- and edit-time. These switches tune the
544/// thresholds and how assertively findings are surfaced.
545#[derive(Debug, Clone, Serialize, Deserialize)]
546#[serde(default)]
547pub struct CodeHealthConfig {
548 /// Cognitive-complexity threshold above which a function is a hotspot.
549 /// Mirrors `core::code_health::DEFAULT_COGNITIVE_THRESHOLD` (15).
550 pub cognitive_threshold: u32,
551 /// Edit-gate behavior on complexity drift: `"warn"` (annotate, default),
552 /// `"block"` (refuse clean→over-threshold edits), or `"off"`.
553 pub gate: String,
554 /// Annotate over-threshold functions inline in `ctx_read` output.
555 pub annotate_reads: bool,
556 /// Run the naming-quality heuristic.
557 pub naming: bool,
558 /// Compute module-coupling metrics.
559 pub coupling: bool,
560}
561
562impl Default for CodeHealthConfig {
563 fn default() -> Self {
564 Self {
565 cognitive_threshold: 15,
566 gate: "warn".to_string(),
567 annotate_reads: true,
568 naming: true,
569 coupling: true,
570 }
571 }
572}
573
574/// Settings for the code graph — in particular the *traversal* (co-access) edges
575/// learned from real agent sessions (#289).
576///
577/// The static AST/import graph captures how code is wired structurally; it cannot
578/// see which files an agent actually opens *together* while solving a task.
579/// Traversal edges add that behavioural signal: files surfaced together are
580/// associated with a decaying weight (Hebbian co-access), folded into the graph
581/// as `co_access` edges and mixed into recall. The store is bounded and decays,
582/// so stale associations fade.
583#[derive(Debug, Clone, Serialize, Deserialize)]
584#[serde(default)]
585pub struct GraphConfig {
586 /// Record co-access between files surfaced together in a session, surface them
587 /// as decaying `co_access` edges in the graph, and boost recall by them.
588 /// On by default; set to `false` for a purely static (AST-only) graph.
589 pub traversal_edges: bool,
590}
591
592impl Default for GraphConfig {
593 fn default() -> Self {
594 Self {
595 traversal_edges: true,
596 }
597 }
598}
599
600/// Skillify (#290): mine the project's session diary + knowledge facts into
601/// versioned, git-committable `.cursor/rules/skillify-*.mdc` rule files.
602///
603/// The miner is precision-biased — it only codifies recurring or high-confidence
604/// patterns and never invents content. Runs on demand (`ctx_skillify` /
605/// `lean-ctx skillify`); re-running merges (bumps version) only when the distilled
606/// content actually changes.
607#[derive(Debug, Clone, Serialize, Deserialize)]
608#[serde(default)]
609pub struct SkillifyConfig {
610 /// Master switch for the skillify miner. On by default; the miner only ever
611 /// acts when explicitly invoked, so this never writes files unprompted.
612 pub enabled: bool,
613 /// Where generated rules are written: `project` (`<repo>/.cursor/rules`,
614 /// git-committable, default) or `global` (`~/.cursor/rules`).
615 pub scope: String,
616 /// Minimum confidence for a single curated knowledge fact to be codified even
617 /// without repetition. 0.0..=1.0.
618 pub min_confidence: f32,
619 /// Minimum number of reinforcements (confirmations / repeated mentions) before
620 /// a pattern is codified when its confidence is below `min_confidence`.
621 pub min_recurrence: u32,
622}
623
624impl Default for SkillifyConfig {
625 fn default() -> Self {
626 Self {
627 enabled: true,
628 scope: "project".to_string(),
629 min_confidence: 0.7,
630 min_recurrence: 2,
631 }
632 }
633}
634
635/// AI session summaries (#292): periodically distil the working session into a
636/// compact, *semantically recallable* summary so a future session can answer
637/// "what did I do last time on X?". Deterministic and local-first — recall uses
638/// embeddings when the `embeddings` feature is on, else a lexical fallback.
639#[derive(Debug, Clone, Serialize, Deserialize)]
640#[serde(default)]
641pub struct SummariesConfig {
642 /// Record periodic session summaries. On by default; recording is cheap and
643 /// happens at most once per `every_n_turns` tool calls.
644 pub enabled: bool,
645 /// Tool calls between automatic summaries. The auto-checkpoint cadence still
646 /// gates the check, so the effective minimum is the checkpoint interval.
647 pub every_n_turns: u32,
648 /// Maximum summaries kept per project (oldest pruned first).
649 pub max_kept: u32,
650}
651
652impl Default for SummariesConfig {
653 fn default() -> Self {
654 Self {
655 enabled: true,
656 every_n_turns: 25,
657 max_kept: 100,
658 }
659 }
660}
661
662/// A user-defined command alias mapping for shell compression patterns.
663#[derive(Debug, Clone, Serialize, Deserialize)]
664pub struct AliasEntry {
665 pub command: String,
666 pub alias: String,
667}
668
669/// Thresholds for detecting and throttling repetitive agent tool call loops.
670#[derive(Debug, Clone, Serialize, Deserialize)]
671#[serde(default)]
672pub struct LoopDetectionConfig {
673 pub normal_threshold: u32,
674 pub reduced_threshold: u32,
675 pub blocked_threshold: u32,
676 pub window_secs: u64,
677 pub search_group_limit: u32,
678 pub tool_total_limits: HashMap<String, u32>,
679}
680
681impl Default for LoopDetectionConfig {
682 fn default() -> Self {
683 let mut tool_total_limits = HashMap::new();
684 tool_total_limits.insert("ctx_read".to_string(), 100);
685 tool_total_limits.insert("ctx_search".to_string(), 80);
686 tool_total_limits.insert("ctx_shell".to_string(), 50);
687 tool_total_limits.insert("ctx_semantic_search".to_string(), 60);
688 Self {
689 normal_threshold: 2,
690 reduced_threshold: 4,
691 blocked_threshold: 0,
692 window_secs: 300,
693 search_group_limit: 10,
694 tool_total_limits,
695 }
696 }
697}
698
699/// Semantic-embedding engine settings.
700///
701/// `model` selects which local ONNX embedding model lean-ctx downloads and uses for
702/// `ctx_semantic_search`. Accepts the same aliases as the `LEAN_CTX_EMBEDDING_MODEL` env
703/// var: `minilm` (all-MiniLM-L6-v2, 384d — the default), `nomic` (768d) — or any
704/// HuggingFace repo with an ONNX export via `hf:org/repo[@revision]` (GL #397), e.g.
705/// `hf:jinaai/jina-embeddings-v2-base-code` for code-specialized embeddings. When the
706/// env var is set it takes precedence; an
707/// unset/`None` value uses the default model. Switching models triggers a one-time
708/// re-index on the next semantic search (vector dimensions follow from the model).
709///
710/// `dimensions` is only consulted for `hf:` custom models as the declared fallback
711/// width; the real width is probed from the ONNX graph at load time. Built-ins ignore it.
712/// `[gateway_server]` — deployment parameters of the self-hosted org gateway
713/// (enterprise#20). Distinct from `[gateway]` (the MCP tool-catalog gateway):
714/// this section describes the LLM-proxy *server* deployment and its cockpit.
715///
716/// All fields optional; an empty section keeps every local behavior unchanged.
717#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
718#[serde(default)]
719pub struct GatewayServerConfig {
720 /// Seats the org-wide projection extrapolates to (e.g. `800`). `None`
721 /// disables the projection — the cockpit never invents a seat count.
722 #[serde(default, skip_serializing_if = "Option::is_none")]
723 pub seats: Option<u32>,
724 /// Display label for the cockpit header (e.g. `"Zühlke AI Gateway"`).
725 #[serde(default, skip_serializing_if = "Option::is_none")]
726 pub org_label: Option<String>,
727 /// Central admin API base URL (e.g. `https://ai-gateway.example.com`).
728 /// When set, the local cockpit's usage breakdown reads the org-wide
729 /// `GET /api/admin/usage` instead of the machine-local snapshot. The
730 /// bearer token comes from `LEAN_CTX_GATEWAY_ADMIN_TOKEN` (never config).
731 #[serde(default, skip_serializing_if = "Option::is_none")]
732 pub admin_url: Option<String>,
733 /// Bind address of the admin listener (dashboard + `/api/admin/*` +
734 /// `/metrics`). Defaults to loopback — **secure by default** (#54/#56):
735 /// exposing the console is an explicit decision. Container deployments set
736 /// `"0.0.0.0"` here (the pod/compose port mapping stays the outer guard).
737 /// `LEAN_CTX_GATEWAY_ADMIN_BIND_HOST` overrides. Invalid values fall back
738 /// to loopback: a typo can only ever narrow exposure, never open it.
739 #[serde(default, skip_serializing_if = "Option::is_none")]
740 pub admin_bind_host: Option<String>,
741 /// Days to keep `usage_events` rows (enterprise#36). `None`/`0` = keep
742 /// forever (the local-free default — retention is a deployment decision).
743 /// A running gateway purges older rows periodically; typical compliance
744 /// values are `365` or `3650` (EU AI Act evidence horizon).
745 #[serde(default, skip_serializing_if = "Option::is_none")]
746 pub usage_retention_days: Option<u32>,
747 /// Replace `person` with a stable keyed pseudonym (`p:<hash>`) before it
748 /// reaches metering, budgets, dashboards and logs (enterprise#39, GDPR).
749 /// The salt lives in `<data_dir>/gateway_pii_salt`; `gateway gdpr`
750 /// re-derives pseudonyms from e-mail input, so DSGVO delete/export keep
751 /// working. Default `false` (cleartext person tags).
752 #[serde(default, skip_serializing_if = "Option::is_none")]
753 pub pseudonymize_persons: Option<bool>,
754}
755
756impl GatewayServerConfig {
757 /// Effective admin bind address (see `admin_bind_host`). Precedence:
758 /// `LEAN_CTX_GATEWAY_ADMIN_BIND_HOST` env > config > `127.0.0.1`.
759 #[must_use]
760 pub fn resolved_admin_bind_host(&self) -> std::net::IpAddr {
761 let raw = std::env::var("LEAN_CTX_GATEWAY_ADMIN_BIND_HOST")
762 .ok()
763 .filter(|v| !v.trim().is_empty())
764 .or_else(|| self.admin_bind_host.clone());
765 match raw.as_deref().map(str::trim) {
766 Some(v) if !v.is_empty() => v.parse().unwrap_or_else(|_| {
767 tracing::warn!(
768 "gateway_server.admin_bind_host '{v}' is not a valid IP address — binding 127.0.0.1"
769 );
770 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
771 }),
772 _ => std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
773 }
774 }
775}
776
777#[derive(Debug, Clone, Default, Serialize, Deserialize)]
778#[serde(default)]
779pub struct EmbeddingConfig {
780 #[serde(default, skip_serializing_if = "Option::is_none")]
781 pub model: Option<String>,
782 #[serde(default, skip_serializing_if = "Option::is_none")]
783 pub dimensions: Option<usize>,
784 /// Allow downloading the embedding model on first semantic need (#551).
785 /// `None` (unset) means **allowed** — the soft default that activates the
786 /// semantic features without manual setup. Set `false` for air-gapped
787 /// machines. The `LEAN_CTX_EMBEDDINGS_AUTO_DOWNLOAD` env var, when set,
788 /// overrides this in either direction.
789 #[serde(default, skip_serializing_if = "Option::is_none")]
790 pub auto_download: Option<bool>,
791 /// Pin embedding inference to a single CPU thread (no GPU EP) so vectors are
792 /// bit-identical across machines, not just run-to-run on one host (#895).
793 /// `None`/`false` keeps the multi-threaded GPU-capable path. Extractive prose
794 /// ranking is already deterministic via score quantization + stable tiebreak;
795 /// this flag is the extra hardening for cross-machine reproducibility. The
796 /// `LEAN_CTX_EMBEDDING_DETERMINISTIC` env var overrides this either way.
797 #[serde(default, skip_serializing_if = "Option::is_none")]
798 pub deterministic: Option<bool>,
799}
800
801#[cfg(test)]
802mod gateway_server_tests {
803 use super::*;
804
805 #[test]
806 fn admin_bind_defaults_to_loopback_and_rejects_garbage() {
807 // Secure by default (#54/#56): unset and invalid both land on loopback.
808 let cfg = GatewayServerConfig::default();
809 assert!(cfg.resolved_admin_bind_host().is_loopback());
810
811 let cfg = GatewayServerConfig {
812 admin_bind_host: Some("not-an-ip".into()),
813 ..Default::default()
814 };
815 assert!(
816 cfg.resolved_admin_bind_host().is_loopback(),
817 "a typo must narrow exposure, never widen it"
818 );
819
820 let cfg = GatewayServerConfig {
821 admin_bind_host: Some("0.0.0.0".into()),
822 ..Default::default()
823 };
824 assert!(
825 !cfg.resolved_admin_bind_host().is_loopback(),
826 "explicit opt-in widens the bind"
827 );
828 }
829}