Skip to main content

lean_ctx/core/config/
sections.rs

1//! Auxiliary configuration section structs.
2//!
3//! Nested config structs (secret-detection, setup, archive, providers,
4//! autonomy, updates, cloud, gain, loop-detection, embedding, …) split out of
5//! `config/mod.rs` to keep the top-level module focused on `Config` itself.
6//! Re-exported via `pub use sections::*`, so external paths stay stable.
7
8use super::serde_defaults;
9#[allow(clippy::wildcard_imports)]
10use super::*;
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(default)]
15pub struct SecretDetectionConfig {
16    pub enabled: bool,
17    pub redact: bool,
18    pub custom_patterns: Vec<String>,
19}
20
21/// Controls what lean-ctx injects during `setup` and `update --rewire`.
22/// Fresh installs default to non-invasive (rules/skills off, MCP on).
23/// Users who ran setup interactively get explicit true/false.
24/// `None` = undecided (legacy: check if rules already exist and preserve behavior).
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(default)]
27pub struct SetupConfig {
28    /// Inject agent rule files (CLAUDE.md, .cursor/rules/, etc.).
29    /// None = undecided (legacy compat: inject if rules already present).
30    /// Some(true) = always inject. Some(false) = never inject.
31    pub auto_inject_rules: Option<bool>,
32    /// Install SKILL.md files for supported agents.
33    /// None = undecided. Some(true) = install. Some(false) = skip.
34    pub auto_inject_skills: Option<bool>,
35    /// Register lean-ctx as an MCP server in editor configs.
36    #[serde(default = "serde_defaults::default_true")]
37    pub auto_update_mcp: bool,
38}
39
40impl Default for SetupConfig {
41    fn default() -> Self {
42        Self {
43            auto_inject_rules: None,
44            auto_inject_skills: None,
45            auto_update_mcp: true,
46        }
47    }
48}
49
50impl SetupConfig {
51    /// Returns whether rules should be injected, considering legacy installs.
52    /// If undecided (None), checks if lean-ctx rules markers already exist
53    /// in any agent config — if so, keeps injecting for backward compat.
54    pub fn should_inject_rules(&self) -> bool {
55        match self.auto_inject_rules {
56            Some(v) => v,
57            None => Self::rules_already_present(),
58        }
59    }
60
61    /// Returns whether skills should be installed.
62    pub fn should_inject_skills(&self) -> bool {
63        match self.auto_inject_skills {
64            Some(v) => v,
65            None => Self::rules_already_present(),
66        }
67    }
68
69    /// Returns whether `setup`/`onboard`/`init` may (re)register the lean-ctx
70    /// MCP server in editor configs. Honors `auto_update_mcp` (#281) so locked-
71    /// down environments can keep MCP out of agent settings while still getting
72    /// hooks, rules and skills.
73    pub fn should_update_mcp(&self) -> bool {
74        self.auto_update_mcp
75    }
76
77    /// Check if lean-ctx rules markers exist in any known agent config location.
78    ///
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/// Settings for the code graph — in particular the *traversal* (co-access) edges
541/// learned from real agent sessions (#289).
542///
543/// The static AST/import graph captures how code is wired structurally; it cannot
544/// see which files an agent actually opens *together* while solving a task.
545/// Traversal edges add that behavioural signal: files surfaced together are
546/// associated with a decaying weight (Hebbian co-access), folded into the graph
547/// as `co_access` edges and mixed into recall. The store is bounded and decays,
548/// so stale associations fade.
549#[derive(Debug, Clone, Serialize, Deserialize)]
550#[serde(default)]
551pub struct GraphConfig {
552    /// Record co-access between files surfaced together in a session, surface them
553    /// as decaying `co_access` edges in the graph, and boost recall by them.
554    /// On by default; set to `false` for a purely static (AST-only) graph.
555    pub traversal_edges: bool,
556}
557
558impl Default for GraphConfig {
559    fn default() -> Self {
560        Self {
561            traversal_edges: true,
562        }
563    }
564}
565
566/// Skillify (#290): mine the project's session diary + knowledge facts into
567/// versioned, git-committable `.cursor/rules/skillify-*.mdc` rule files.
568///
569/// The miner is precision-biased — it only codifies recurring or high-confidence
570/// patterns and never invents content. Runs on demand (`ctx_skillify` /
571/// `lean-ctx skillify`); re-running merges (bumps version) only when the distilled
572/// content actually changes.
573#[derive(Debug, Clone, Serialize, Deserialize)]
574#[serde(default)]
575pub struct SkillifyConfig {
576    /// Master switch for the skillify miner. On by default; the miner only ever
577    /// acts when explicitly invoked, so this never writes files unprompted.
578    pub enabled: bool,
579    /// Where generated rules are written: `project` (`<repo>/.cursor/rules`,
580    /// git-committable, default) or `global` (`~/.cursor/rules`).
581    pub scope: String,
582    /// Minimum confidence for a single curated knowledge fact to be codified even
583    /// without repetition. 0.0..=1.0.
584    pub min_confidence: f32,
585    /// Minimum number of reinforcements (confirmations / repeated mentions) before
586    /// a pattern is codified when its confidence is below `min_confidence`.
587    pub min_recurrence: u32,
588}
589
590impl Default for SkillifyConfig {
591    fn default() -> Self {
592        Self {
593            enabled: true,
594            scope: "project".to_string(),
595            min_confidence: 0.7,
596            min_recurrence: 2,
597        }
598    }
599}
600
601/// AI session summaries (#292): periodically distil the working session into a
602/// compact, *semantically recallable* summary so a future session can answer
603/// "what did I do last time on X?". Deterministic and local-first — recall uses
604/// embeddings when the `embeddings` feature is on, else a lexical fallback.
605#[derive(Debug, Clone, Serialize, Deserialize)]
606#[serde(default)]
607pub struct SummariesConfig {
608    /// Record periodic session summaries. On by default; recording is cheap and
609    /// happens at most once per `every_n_turns` tool calls.
610    pub enabled: bool,
611    /// Tool calls between automatic summaries. The auto-checkpoint cadence still
612    /// gates the check, so the effective minimum is the checkpoint interval.
613    pub every_n_turns: u32,
614    /// Maximum summaries kept per project (oldest pruned first).
615    pub max_kept: u32,
616}
617
618impl Default for SummariesConfig {
619    fn default() -> Self {
620        Self {
621            enabled: true,
622            every_n_turns: 25,
623            max_kept: 100,
624        }
625    }
626}
627
628/// A user-defined command alias mapping for shell compression patterns.
629#[derive(Debug, Clone, Serialize, Deserialize)]
630pub struct AliasEntry {
631    pub command: String,
632    pub alias: String,
633}
634
635/// Thresholds for detecting and throttling repetitive agent tool call loops.
636#[derive(Debug, Clone, Serialize, Deserialize)]
637#[serde(default)]
638pub struct LoopDetectionConfig {
639    pub normal_threshold: u32,
640    pub reduced_threshold: u32,
641    pub blocked_threshold: u32,
642    pub window_secs: u64,
643    pub search_group_limit: u32,
644    pub tool_total_limits: HashMap<String, u32>,
645}
646
647impl Default for LoopDetectionConfig {
648    fn default() -> Self {
649        let mut tool_total_limits = HashMap::new();
650        tool_total_limits.insert("ctx_read".to_string(), 100);
651        tool_total_limits.insert("ctx_search".to_string(), 80);
652        tool_total_limits.insert("ctx_shell".to_string(), 50);
653        tool_total_limits.insert("ctx_semantic_search".to_string(), 60);
654        Self {
655            normal_threshold: 2,
656            reduced_threshold: 4,
657            blocked_threshold: 0,
658            window_secs: 300,
659            search_group_limit: 10,
660            tool_total_limits,
661        }
662    }
663}
664
665/// Semantic-embedding engine settings.
666///
667/// `model` selects which local ONNX embedding model lean-ctx downloads and uses for
668/// `ctx_semantic_search`. Accepts the same aliases as the `LEAN_CTX_EMBEDDING_MODEL` env
669/// var: `minilm` (all-MiniLM-L6-v2, 384d — the default), `nomic` (768d) — or any
670/// HuggingFace repo with an ONNX export via `hf:org/repo[@revision]` (GL #397), e.g.
671/// `hf:jinaai/jina-embeddings-v2-base-code` for code-specialized embeddings. When the
672/// env var is set it takes precedence; an
673/// unset/`None` value uses the default model. Switching models triggers a one-time
674/// re-index on the next semantic search (vector dimensions follow from the model).
675///
676/// `dimensions` is only consulted for `hf:` custom models as the declared fallback
677/// width; the real width is probed from the ONNX graph at load time. Built-ins ignore it.
678#[derive(Debug, Clone, Default, Serialize, Deserialize)]
679#[serde(default)]
680pub struct EmbeddingConfig {
681    #[serde(default, skip_serializing_if = "Option::is_none")]
682    pub model: Option<String>,
683    #[serde(default, skip_serializing_if = "Option::is_none")]
684    pub dimensions: Option<usize>,
685    /// Allow downloading the embedding model on first semantic need (#551).
686    /// `None` (unset) means **allowed** — the soft default that activates the
687    /// semantic features without manual setup. Set `false` for air-gapped
688    /// machines. The `LEAN_CTX_EMBEDDINGS_AUTO_DOWNLOAD` env var, when set,
689    /// overrides this in either direction.
690    #[serde(default, skip_serializing_if = "Option::is_none")]
691    pub auto_download: Option<bool>,
692    /// Pin embedding inference to a single CPU thread (no GPU EP) so vectors are
693    /// bit-identical across machines, not just run-to-run on one host (#895).
694    /// `None`/`false` keeps the multi-threaded GPU-capable path. Extractive prose
695    /// ranking is already deterministic via score quantization + stable tiebreak;
696    /// this flag is the extra hardening for cross-machine reproducibility. The
697    /// `LEAN_CTX_EMBEDDING_DETERMINISTIC` env var overrides this either way.
698    #[serde(default, skip_serializing_if = "Option::is_none")]
699    pub deterministic: Option<bool>,
700}