Skip to main content

lean_ctx/core/config/
schema.rs

1//! Auto-generated config schema from `Config` struct metadata.
2//!
3//! Used by `lean-ctx config schema` to emit JSON and by
4//! `lean-ctx config validate` to check user config.toml files.
5
6use serde::Serialize;
7use std::collections::BTreeMap;
8
9#[derive(Debug, Clone, Serialize)]
10pub struct ConfigSchema {
11    pub version: u32,
12    pub sections: BTreeMap<String, SectionSchema>,
13}
14
15#[derive(Debug, Clone, Serialize)]
16pub struct SectionSchema {
17    pub description: String,
18    pub keys: BTreeMap<String, KeySchema>,
19}
20
21#[derive(Debug, Clone, Serialize)]
22pub struct KeySchema {
23    #[serde(rename = "type")]
24    pub ty: String,
25    pub default: serde_json::Value,
26    pub description: String,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub values: Option<Vec<String>>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub env_override: Option<String>,
31}
32
33fn clean_f32(v: f32) -> serde_json::Value {
34    let clean: f64 = format!("{v}").parse().unwrap_or(v as f64);
35    serde_json::json!(clean)
36}
37
38fn key(ty: &str, default: serde_json::Value, desc: &str) -> KeySchema {
39    KeySchema {
40        ty: ty.to_string(),
41        default,
42        description: desc.to_string(),
43        values: None,
44        env_override: None,
45    }
46}
47
48fn key_enum(values: &[&str], default: &str, desc: &str) -> KeySchema {
49    KeySchema {
50        ty: "enum".to_string(),
51        default: serde_json::Value::String(default.to_string()),
52        description: desc.to_string(),
53        values: Some(values.iter().map(ToString::to_string).collect()),
54        env_override: None,
55    }
56}
57
58fn key_with_env(ty: &str, default: serde_json::Value, desc: &str, env: &str) -> KeySchema {
59    KeySchema {
60        ty: ty.to_string(),
61        default,
62        description: desc.to_string(),
63        values: None,
64        env_override: Some(env.to_string()),
65    }
66}
67
68fn key_enum_with_env(values: &[&str], default: &str, desc: &str, env: &str) -> KeySchema {
69    KeySchema {
70        ty: "enum".to_string(),
71        default: serde_json::Value::String(default.to_string()),
72        description: desc.to_string(),
73        values: Some(values.iter().map(ToString::to_string).collect()),
74        env_override: Some(env.to_string()),
75    }
76}
77
78impl ConfigSchema {
79    pub fn generate() -> Self {
80        let cfg = super::Config::default();
81        let mut sections = BTreeMap::new();
82
83        let mut root = BTreeMap::new();
84        root.insert(
85            "ultra_compact".into(),
86            key(
87                "bool",
88                serde_json::json!(false),
89                "Legacy flag for maximum compression (use compression_level instead)",
90            ),
91        );
92        root.insert(
93            "tee_mode".into(),
94            key_enum(
95                &["never", "failures", "always"],
96                "failures",
97                "Controls when shell output is tee'd to disk for later retrieval",
98            ),
99        );
100        root.insert(
101            "output_density".into(),
102            key_enum_with_env(
103                &["normal", "terse", "ultra"],
104                "normal",
105                "Controls how dense/compact MCP tool output is formatted",
106                "LEAN_CTX_OUTPUT_DENSITY",
107            ),
108        );
109        root.insert(
110            "checkpoint_interval".into(),
111            key(
112                "u32",
113                serde_json::json!(cfg.checkpoint_interval),
114                "Session checkpoint interval in minutes",
115            ),
116        );
117        root.insert(
118            "excluded_commands".into(),
119            key(
120                "string[]",
121                serde_json::json!(cfg.excluded_commands),
122                "Commands to exclude from shell hook interception",
123            ),
124        );
125        root.insert(
126            "passthrough_urls".into(),
127            key(
128                "string[]",
129                serde_json::json!(cfg.passthrough_urls),
130                "URLs to pass through without proxy interception",
131            ),
132        );
133        root.insert("slow_command_threshold_ms".into(), key("u64", serde_json::json!(cfg.slow_command_threshold_ms), "Commands taking longer than this (ms) are recorded in the slow log. Set to 0 to disable"));
134        root.insert(
135            "theme".into(),
136            key(
137                "string",
138                serde_json::json!(cfg.theme),
139                "Dashboard color theme",
140            ),
141        );
142        root.insert(
143            "buddy_enabled".into(),
144            key(
145                "bool",
146                serde_json::json!(cfg.buddy_enabled),
147                "Enable the buddy system for multi-agent coordination",
148            ),
149        );
150        root.insert(
151            "enable_wakeup_ctx".into(),
152            key(
153                "bool",
154                serde_json::json!(cfg.enable_wakeup_ctx),
155                "Append wakeup briefing (facts, session summary) to ctx_overview output. Set false to reduce context bloat when calling ctx_overview frequently.",
156            ),
157        );
158        root.insert(
159            "redirect_exclude".into(),
160            key(
161                "string[]",
162                serde_json::json!(cfg.redirect_exclude),
163                "URL patterns to exclude from proxy redirection",
164            ),
165        );
166        root.insert(
167            "disabled_tools".into(),
168            key(
169                "string[]",
170                serde_json::json!(cfg.disabled_tools),
171                "Tools to exclude from the MCP tool list",
172            ),
173        );
174        root.insert(
175            "default_tool_categories".into(),
176            key(
177                "string[]",
178                serde_json::json!(cfg.default_tool_categories),
179                "Tool categories active by default (core, arch, debug, memory, metrics, session). Override via LCTX_DEFAULT_CATEGORIES",
180            ),
181        );
182        root.insert(
183            "no_degrade".into(),
184            key(
185                "boolean",
186                serde_json::json!(cfg.no_degrade),
187                "Disable all automatic read-mode degradation. Override via LCTX_NO_DEGRADE=1",
188            ),
189        );
190        root.insert(
191            "profile".into(),
192            key(
193                "string",
194                serde_json::json!(cfg.profile.as_deref().unwrap_or("")),
195                "Persistent profile name. Checked after LEAN_CTX_PROFILE env var. Set via: lean-ctx config set profile passthrough",
196            ),
197        );
198        root.insert(
199            "tool_profile".into(),
200            key_enum(
201                &["minimal", "standard", "power"],
202                cfg.tool_profile.as_deref().unwrap_or(""),
203                "Tool visibility profile: minimal (5 tools), standard (20), power (all). Override via LEAN_CTX_TOOL_PROFILE",
204            ),
205        );
206        root.insert(
207            "tools_enabled".into(),
208            key(
209                "string[]",
210                serde_json::json!(cfg.tools_enabled),
211                "Explicit list of enabled tool names (overrides tool_profile when non-empty)",
212            ),
213        );
214        root.insert(
215            "rules_scope".into(),
216            key_enum(
217                &["both", "global", "project"],
218                "both",
219                "Where agent rule files are installed. Override via LEAN_CTX_RULES_SCOPE",
220            ),
221        );
222        root.insert(
223            "extra_ignore_patterns".into(),
224            key(
225                "string[]",
226                serde_json::json!(cfg.extra_ignore_patterns),
227                "Extra glob patterns to ignore in graph/overview/preload",
228            ),
229        );
230        root.insert(
231            "terse_agent".into(),
232            key_enum_with_env(
233                &["off", "lite", "full", "ultra"],
234                "off",
235                "Controls agent output verbosity via instructions injection",
236                "LEAN_CTX_TERSE_AGENT",
237            ),
238        );
239        root.insert(
240            "compression_level".into(),
241            key_enum_with_env(
242                &["off", "lite", "standard", "max"],
243                "lite",
244                "Unified output-style level for the model's prose (not tool-output compression). lite=plain concise (default), standard/max=denser symbolic 'power modes'",
245                "LEAN_CTX_COMPRESSION",
246            ),
247        );
248        root.insert(
249            "allow_paths".into(),
250            key_with_env(
251                "string[]",
252                serde_json::json!(cfg.allow_paths),
253                "Additional paths allowed by PathJail (absolute)",
254                "LEAN_CTX_ALLOW_PATH",
255            ),
256        );
257        root.insert(
258            "extra_roots".into(),
259            key_with_env(
260                "string[]",
261                serde_json::json!(cfg.extra_roots),
262                "Extra project roots for multi-root workspaces (auto-added to PathJail allow-list)",
263                "LEAN_CTX_EXTRA_ROOTS",
264            ),
265        );
266        root.insert(
267            "content_defined_chunking".into(),
268            key(
269                "bool",
270                serde_json::json!(false),
271                "Enable Rabin-Karp chunking for cache-optimal output ordering",
272            ),
273        );
274        root.insert(
275            "minimal_overhead".into(),
276            key_with_env(
277                "bool",
278                serde_json::json!(true),
279                "Skip session/knowledge/gotcha blocks in MCP instructions",
280                "LEAN_CTX_MINIMAL",
281            ),
282        );
283        root.insert(
284            "symbol_map_auto".into(),
285            key(
286                "bool",
287                serde_json::json!(true),
288                "Auto-enable SymbolMap for projects with >50 source files",
289            ),
290        );
291        root.insert(
292            "journal_enabled".into(),
293            key(
294                "bool",
295                serde_json::json!(true),
296                "Write human-readable activity journal to ~/.lean-ctx/journal.md",
297            ),
298        );
299        root.insert(
300            "auto_capture".into(),
301            key(
302                "bool",
303                serde_json::json!(true),
304                "Automatic knowledge capture from tool findings",
305            ),
306        );
307        root.insert(
308            "cache_policy".into(),
309            key_with_env(
310                "enum(aggressive|safe|off)",
311                serde_json::json!("aggressive"),
312                "Cache policy for ctx_read: aggressive (13-tok stubs), safe (map on hit), off (always disk)",
313                "LEAN_CTX_CACHE_POLICY",
314            ),
315        );
316        root.insert(
317            "shadow_mode".into(),
318            key_with_env(
319                "bool",
320                serde_json::json!(false),
321                "Transparently intercept native Read/Grep/Shell calls via hooks and route them through lean-ctx",
322                "LEAN_CTX_SHADOW_MODE",
323            ),
324        );
325        root.insert(
326            "shell_hook_disabled".into(),
327            key_with_env(
328                "bool",
329                serde_json::json!(false),
330                "Disable shell hook injection",
331                "LEAN_CTX_NO_HOOK",
332            ),
333        );
334        root.insert(
335            "shell_activation".into(),
336            key_enum_with_env(
337                &["always", "agents-only", "off"],
338                "always",
339                "Controls when the shell hook auto-activates aliases",
340                "LEAN_CTX_SHELL_ACTIVATION",
341            ),
342        );
343        root.insert(
344            "update_check_disabled".into(),
345            key_with_env(
346                "bool",
347                serde_json::json!(false),
348                "Disable the daily version check",
349                "LEAN_CTX_NO_UPDATE_CHECK",
350            ),
351        );
352        root.insert(
353            "bm25_max_cache_mb".into(),
354            key_with_env(
355                "u64",
356                serde_json::json!(cfg.bm25_max_cache_mb),
357                "Maximum BM25 cache file size in MB",
358                "LEAN_CTX_BM25_MAX_CACHE_MB",
359            ),
360        );
361        root.insert(
362            "graph_index_max_files".into(),
363            key(
364                "u64",
365                serde_json::json!(cfg.graph_index_max_files),
366                "Maximum files in graph index. 0 = unlimited (default). Set >0 to cap for constrained systems",
367            ),
368        );
369        root.insert(
370            "memory_profile".into(),
371            key_enum_with_env(
372                &["low", "balanced", "performance"],
373                "performance",
374                "Controls RAM vs feature trade-off (performance = max quality)",
375                "LEAN_CTX_MEMORY_PROFILE",
376            ),
377        );
378        root.insert(
379            "memory_cleanup".into(),
380            key_enum_with_env(
381                &["aggressive", "shared"],
382                "aggressive",
383                "Controls how aggressively memory is freed when idle",
384                "LEAN_CTX_MEMORY_CLEANUP",
385            ),
386        );
387        root.insert(
388            "savings_footer".into(),
389            key_enum_with_env(
390                &["auto", "always", "never"],
391                "always",
392                "Controls visibility of token savings footers: always (default, show on every response), never, auto (context-dependent). Also: LEAN_CTX_SHOW_SAVINGS=1|0",
393                "LEAN_CTX_SAVINGS_FOOTER",
394            ),
395        );
396        root.insert(
397            "max_ram_percent".into(),
398            key_with_env(
399                "u8",
400                serde_json::json!(cfg.max_ram_percent),
401                "Maximum percentage of system RAM that lean-ctx may use (1-50, default 5)",
402                "LEAN_CTX_MAX_RAM_PERCENT",
403            ),
404        );
405        root.insert(
406            "max_disk_mb".into(),
407            key_with_env(
408                "u64",
409                serde_json::json!(cfg.max_disk_mb),
410                "Simplified disk budget in MB (0 = disabled). Distributes: archive ~25%, BM25 ~10%",
411                "LEAN_CTX_MAX_DISK_MB",
412            ),
413        );
414        root.insert(
415            "max_staleness_days".into(),
416            key_with_env(
417                "u32",
418                serde_json::json!(cfg.max_staleness_days),
419                "Auto-purge data older than N days (0 = disabled). Flows into archive.max_age_hours",
420                "LEAN_CTX_MAX_STALENESS_DAYS",
421            ),
422        );
423        root.insert(
424            "project_root".into(),
425            key_with_env(
426                "string?",
427                serde_json::json!(null),
428                "Explicit project root directory. Prevents accidental home-directory scans",
429                "LEAN_CTX_PROJECT_ROOT",
430            ),
431        );
432        root.insert(
433            "proxy_enabled".into(),
434            key(
435                "bool?",
436                serde_json::json!(null),
437                "Enable/disable the proxy layer. null = auto-detect, true = force on, false = force off",
438            ),
439        );
440        root.insert(
441            "proxy_port".into(),
442            key(
443                "u16?",
444                serde_json::json!(null),
445                "Custom proxy port (default: 4444). Useful for multi-user systems. Env: LEAN_CTX_PROXY_PORT",
446            ),
447        );
448        root.insert(
449            "proxy_timeout_ms".into(),
450            key(
451                "u64?",
452                serde_json::json!(null),
453                "Proxy reachability timeout in ms (default: 200). Override via LEAN_CTX_PROXY_TIMEOUT_MS",
454            ),
455        );
456        root.insert(
457            "response_verbosity".into(),
458            key_enum_with_env(
459                &["normal", "compact", "minimal"],
460                "normal",
461                "Controls how verbose tool responses are",
462                "LEAN_CTX_RESPONSE_VERBOSITY",
463            ),
464        );
465        root.insert(
466            "allow_auto_reroot".into(),
467            key_with_env(
468                "bool",
469                serde_json::json!(false),
470                "Allow automatic project-root re-rooting when absolute paths outside the jail are seen",
471                "LEAN_CTX_ALLOW_REROOT",
472            ),
473        );
474        root.insert(
475            "sandbox_level".into(),
476            key_with_env(
477                "u8",
478                serde_json::json!(0),
479                "Sandbox strictness level (0=default, 1=strict, 2=paranoid)",
480                "LEAN_CTX_SANDBOX_LEVEL",
481            ),
482        );
483        root.insert(
484            "reference_results".into(),
485            key_with_env(
486                "bool",
487                serde_json::json!(false),
488                "Store large tool outputs as references instead of inline content",
489                "LEAN_CTX_REFERENCE_RESULTS",
490            ),
491        );
492        root.insert(
493            "agent_token_budget".into(),
494            key(
495                "usize",
496                serde_json::json!(0),
497                "Default per-agent token budget. 0 = unlimited",
498            ),
499        );
500        root.insert(
501            "shell_allowlist".into(),
502            key_with_env(
503                "array",
504                serde_json::json!([]),
505                "Optional shell command allowlist. When non-empty, only listed binaries are permitted",
506                "LEAN_CTX_SHELL_ALLOWLIST",
507            ),
508        );
509        root.insert(
510            "shell_strict_mode".into(),
511            key(
512                "bool",
513                serde_json::json!(false),
514                "Block $(), backticks, <() in shell arguments. Default false = warn only.",
515            ),
516        );
517
518        sections.insert(
519            "root".into(),
520            SectionSchema {
521                description: "Top-level configuration keys".into(),
522                keys: root,
523            },
524        );
525
526        sections.insert(
527            "ide_paths".into(),
528            SectionSchema {
529                description: "Per-IDE allowed paths. Keys are agent names (cursor, codex, opencode, antigravity, etc.), values are arrays of paths to index for that agent".into(),
530                keys: BTreeMap::new(),
531            },
532        );
533
534        let mut lsp_keys = BTreeMap::new();
535        lsp_keys.insert(
536            "rust".into(),
537            key(
538                "string?",
539                serde_json::json!(null),
540                "Custom path to rust-analyzer binary",
541            ),
542        );
543        lsp_keys.insert(
544            "typescript".into(),
545            key(
546                "string?",
547                serde_json::json!(null),
548                "Custom path to typescript-language-server binary",
549            ),
550        );
551        lsp_keys.insert(
552            "python".into(),
553            key(
554                "string?",
555                serde_json::json!(null),
556                "Custom path to pylsp binary",
557            ),
558        );
559        lsp_keys.insert(
560            "go".into(),
561            key(
562                "string?",
563                serde_json::json!(null),
564                "Custom path to gopls binary",
565            ),
566        );
567        sections.insert(
568            "lsp".into(),
569            SectionSchema {
570                description: "LSP server binary overrides. Map language name to custom binary path"
571                    .into(),
572                keys: lsp_keys,
573            },
574        );
575
576        let mut archive = BTreeMap::new();
577        archive.insert(
578            "enabled".into(),
579            key(
580                "bool",
581                serde_json::json!(cfg.archive.enabled),
582                "Enable zero-loss compression archive",
583            ),
584        );
585        archive.insert(
586            "threshold_chars".into(),
587            key(
588                "usize",
589                serde_json::json!(cfg.archive.threshold_chars),
590                "Minimum output size (chars) to trigger archiving",
591            ),
592        );
593        archive.insert(
594            "max_age_hours".into(),
595            key(
596                "u64",
597                serde_json::json!(cfg.archive.max_age_hours),
598                "Maximum age of archived entries before cleanup",
599            ),
600        );
601        archive.insert(
602            "max_disk_mb".into(),
603            key(
604                "u64",
605                serde_json::json!(cfg.archive.max_disk_mb),
606                "Maximum total disk usage for the archive",
607            ),
608        );
609        archive.insert(
610            "ephemeral".into(),
611            key("bool", serde_json::json!(cfg.archive.ephemeral), "Replace large results with summary+ref (ctx_expand to retrieve). Env: LEAN_CTX_EPHEMERAL"),
612        );
613        sections.insert("archive".into(), SectionSchema {
614            description: "Settings for the zero-loss compression archive (large tool outputs saved to disk)".into(),
615            keys: archive,
616        });
617
618        let mut search = BTreeMap::new();
619        search.insert(
620            "bm25_weight".into(),
621            key(
622                "f64",
623                serde_json::json!(cfg.search.bm25_weight),
624                "BM25 lexical search weight in RRF fusion",
625            ),
626        );
627        search.insert(
628            "dense_weight".into(),
629            key(
630                "f64",
631                serde_json::json!(cfg.search.dense_weight),
632                "Dense vector search weight in RRF fusion",
633            ),
634        );
635        search.insert(
636            "bm25_candidates".into(),
637            key(
638                "usize",
639                serde_json::json!(cfg.search.bm25_candidates),
640                "Number of BM25 candidates to retrieve before fusion",
641            ),
642        );
643        search.insert(
644            "dense_candidates".into(),
645            key(
646                "usize",
647                serde_json::json!(cfg.search.dense_candidates),
648                "Number of dense candidates to retrieve before fusion",
649            ),
650        );
651        search.insert(
652            "splade_weight".into(),
653            key(
654                "f64",
655                serde_json::json!(cfg.search.splade_weight),
656                "SPLADE expansion weight (0.0 to disable)",
657            ),
658        );
659        sections.insert("search".into(), SectionSchema {
660            description: "Hybrid search weights for ctx_semantic_search (BM25 + dense vector + SPLADE + graph proximity)".into(),
661            keys: search,
662        });
663
664        let mut embedding = BTreeMap::new();
665        embedding.insert(
666            "model".into(),
667            key_with_env(
668                "string",
669                serde_json::json!("minilm"),
670                "Local ONNX embedding model for ctx_semantic_search. One of: minilm (all-MiniLM-L6-v2, 384d, default), jina-code-v2 (768d, code-optimized), nomic (768d). Switching models re-indexes once on the next search.",
671                "LEAN_CTX_EMBEDDING_MODEL",
672            ),
673        );
674        sections.insert(
675            "embedding".into(),
676            SectionSchema {
677                description:
678                    "Semantic-embedding engine settings (model selection for ctx_semantic_search)"
679                        .into(),
680                keys: embedding,
681            },
682        );
683
684        let mut autonomy = BTreeMap::new();
685        autonomy.insert(
686            "enabled".into(),
687            key(
688                "bool",
689                serde_json::json!(cfg.autonomy.enabled),
690                "Enable autonomous background behaviors",
691            ),
692        );
693        autonomy.insert(
694            "auto_preload".into(),
695            key(
696                "bool",
697                serde_json::json!(cfg.autonomy.auto_preload),
698                "Auto-preload related files on first read",
699            ),
700        );
701        autonomy.insert(
702            "auto_dedup".into(),
703            key(
704                "bool",
705                serde_json::json!(cfg.autonomy.auto_dedup),
706                "Auto-deduplicate repeated reads",
707            ),
708        );
709        autonomy.insert(
710            "auto_related".into(),
711            key(
712                "bool",
713                serde_json::json!(cfg.autonomy.auto_related),
714                "Auto-load graph-related files",
715            ),
716        );
717        autonomy.insert(
718            "auto_consolidate".into(),
719            key(
720                "bool",
721                serde_json::json!(cfg.autonomy.auto_consolidate),
722                "Auto-consolidate knowledge periodically",
723            ),
724        );
725        autonomy.insert(
726            "silent_preload".into(),
727            key(
728                "bool",
729                serde_json::json!(cfg.autonomy.silent_preload),
730                "Suppress preload notifications in output",
731            ),
732        );
733        autonomy.insert(
734            "dedup_threshold".into(),
735            key(
736                "usize",
737                serde_json::json!(cfg.autonomy.dedup_threshold),
738                "Number of repeated reads before dedup triggers",
739            ),
740        );
741        autonomy.insert(
742            "consolidate_every_calls".into(),
743            key(
744                "u32",
745                serde_json::json!(cfg.autonomy.consolidate_every_calls),
746                "Consolidate knowledge every N tool calls",
747            ),
748        );
749        autonomy.insert(
750            "consolidate_cooldown_secs".into(),
751            key(
752                "u64",
753                serde_json::json!(cfg.autonomy.consolidate_cooldown_secs),
754                "Minimum seconds between consolidation runs",
755            ),
756        );
757        autonomy.insert(
758            "cognition_loop_enabled".into(),
759            key_with_env(
760                "bool",
761                serde_json::json!(cfg.autonomy.cognition_loop_enabled),
762                "Enable the background cognition loop (periodic knowledge consolidation)",
763                "LEAN_CTX_COGNITION_LOOP_ENABLED",
764            ),
765        );
766        autonomy.insert(
767            "cognition_loop_interval_secs".into(),
768            key_with_env(
769                "u64",
770                serde_json::json!(cfg.autonomy.cognition_loop_interval_secs),
771                "Seconds between cognition loop iterations",
772                "LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS",
773            ),
774        );
775        autonomy.insert(
776            "cognition_loop_max_steps".into(),
777            key_with_env(
778                "u8",
779                serde_json::json!(cfg.autonomy.cognition_loop_max_steps),
780                "Maximum steps per cognition loop iteration",
781                "LEAN_CTX_COGNITION_LOOP_MAX_STEPS",
782            ),
783        );
784        sections.insert(
785            "autonomy".into(),
786            SectionSchema {
787                description:
788                    "Controls autonomous background behaviors (preload, dedup, consolidation)"
789                        .into(),
790                keys: autonomy,
791            },
792        );
793
794        let mut providers = BTreeMap::new();
795        providers.insert(
796            "enabled".into(),
797            key(
798                "bool",
799                serde_json::json!(cfg.providers.enabled),
800                "Master switch for the provider subsystem (GitHub, GitLab, etc.)",
801            ),
802        );
803        providers.insert(
804            "auto_index".into(),
805            key(
806                "bool",
807                serde_json::json!(cfg.providers.auto_index),
808                "Auto-ingest provider results into BM25/embedding indexes",
809            ),
810        );
811        providers.insert(
812            "cache_ttl_secs".into(),
813            key(
814                "u64",
815                serde_json::json!(cfg.providers.cache_ttl_secs),
816                "Default cache TTL for provider results (seconds)",
817            ),
818        );
819        providers.insert(
820            "github.enabled".into(),
821            key(
822                "bool",
823                serde_json::json!(cfg.providers.github.enabled),
824                "Enable/disable GitHub provider",
825            ),
826        );
827        providers.insert(
828            "github.api_url".into(),
829            key(
830                "string",
831                serde_json::json!(cfg.providers.github.api_url),
832                "GitHub API base URL (for GitHub Enterprise)",
833            ),
834        );
835        providers.insert(
836            "gitlab.enabled".into(),
837            key(
838                "bool",
839                serde_json::json!(cfg.providers.gitlab.enabled),
840                "Enable/disable GitLab provider",
841            ),
842        );
843        providers.insert(
844            "gitlab.api_url".into(),
845            key(
846                "string",
847                serde_json::json!(cfg.providers.gitlab.api_url),
848                "GitLab API base URL (for self-hosted instances)",
849            ),
850        );
851        providers.insert(
852            "mcp_bridges.<name>.url".into(),
853            key(
854                "string",
855                serde_json::json!(null),
856                "HTTP/SSE URL for a remote MCP server",
857            ),
858        );
859        providers.insert(
860            "mcp_bridges.<name>.command".into(),
861            key(
862                "string",
863                serde_json::json!(null),
864                "Command to spawn a local MCP server (stdio transport)",
865            ),
866        );
867        providers.insert(
868            "mcp_bridges.<name>.args".into(),
869            key(
870                "array",
871                serde_json::json!([]),
872                "Arguments for the MCP server command",
873            ),
874        );
875        providers.insert(
876            "mcp_bridges.<name>.auth_env".into(),
877            key(
878                "string",
879                serde_json::json!(null),
880                "Environment variable name containing auth token for MCP server",
881            ),
882        );
883        sections.insert(
884            "providers".into(),
885            SectionSchema {
886                description:
887                    "External context providers (GitHub, GitLab, Jira, MCP bridges, etc.). Set tokens via env vars (GITHUB_TOKEN, GITLAB_TOKEN). MCP bridges connect external MCP servers as context sources."
888                        .into(),
889                keys: providers,
890            },
891        );
892
893        let mut loop_det = BTreeMap::new();
894        loop_det.insert(
895            "normal_threshold".into(),
896            key(
897                "u32",
898                serde_json::json!(cfg.loop_detection.normal_threshold),
899                "Repetitions before reducing output",
900            ),
901        );
902        loop_det.insert(
903            "reduced_threshold".into(),
904            key(
905                "u32",
906                serde_json::json!(cfg.loop_detection.reduced_threshold),
907                "Repetitions before further reducing output",
908            ),
909        );
910        loop_det.insert(
911            "blocked_threshold".into(),
912            key(
913                "u32",
914                serde_json::json!(cfg.loop_detection.blocked_threshold),
915                "Repetitions before blocking. 0 = disabled",
916            ),
917        );
918        loop_det.insert(
919            "window_secs".into(),
920            key(
921                "u64",
922                serde_json::json!(cfg.loop_detection.window_secs),
923                "Time window in seconds for loop detection",
924            ),
925        );
926        loop_det.insert(
927            "search_group_limit".into(),
928            key(
929                "u32",
930                serde_json::json!(cfg.loop_detection.search_group_limit),
931                "Maximum unique searches within a loop window",
932            ),
933        );
934        loop_det.insert(
935            "tool_total_limits".into(),
936            key(
937                "table",
938                serde_json::json!({"ctx_read": 100, "ctx_search": 80, "ctx_shell": 50, "ctx_semantic_search": 60}),
939                "Per-tool total call limits within a session. Keys are tool names, values are max calls",
940            ),
941        );
942        sections.insert(
943            "loop_detection".into(),
944            SectionSchema {
945                description: "Loop detection settings for preventing repeated identical tool calls"
946                    .into(),
947                keys: loop_det,
948            },
949        );
950
951        let mut updates = BTreeMap::new();
952        updates.insert(
953            "auto_update".into(),
954            key(
955                "bool",
956                serde_json::json!(cfg.updates.auto_update),
957                "Enable automatic updates (requires explicit opt-in)",
958            ),
959        );
960        updates.insert(
961            "check_interval_hours".into(),
962            key(
963                "u64",
964                serde_json::json!(cfg.updates.check_interval_hours),
965                "How often to check for updates (hours)",
966            ),
967        );
968        updates.insert(
969            "notify_only".into(),
970            key(
971                "bool",
972                serde_json::json!(cfg.updates.notify_only),
973                "Only notify about updates, don't install automatically",
974            ),
975        );
976        sections.insert(
977            "updates".into(),
978            SectionSchema {
979                description: "Automatic update configuration".into(),
980                keys: updates,
981            },
982        );
983
984        let mut boundary = BTreeMap::new();
985        boundary.insert(
986            "cross_project_search".into(),
987            key(
988                "bool",
989                serde_json::json!(cfg.boundary_policy.cross_project_search),
990                "Allow searching across project boundaries",
991            ),
992        );
993        boundary.insert(
994            "cross_project_import".into(),
995            key(
996                "bool",
997                serde_json::json!(cfg.boundary_policy.cross_project_import),
998                "Allow importing knowledge from other projects",
999            ),
1000        );
1001        boundary.insert(
1002            "audit_cross_access".into(),
1003            key(
1004                "bool",
1005                serde_json::json!(cfg.boundary_policy.audit_cross_access),
1006                "Log audit events when cross-project access occurs",
1007            ),
1008        );
1009        boundary.insert(
1010            "universal_gotchas_enabled".into(),
1011            key(
1012                "bool",
1013                serde_json::json!(cfg.boundary_policy.universal_gotchas_enabled),
1014                "Load universal (cross-project) gotchas",
1015            ),
1016        );
1017        sections.insert(
1018            "boundary_policy".into(),
1019            SectionSchema {
1020                description: "Cross-project boundary and access control policies".into(),
1021                keys: boundary,
1022            },
1023        );
1024
1025        let mut secret_det = BTreeMap::new();
1026        secret_det.insert(
1027            "enabled".into(),
1028            key(
1029                "bool",
1030                serde_json::json!(cfg.secret_detection.enabled),
1031                "Enable secret/credential detection in tool outputs",
1032            ),
1033        );
1034        secret_det.insert(
1035            "redact".into(),
1036            key(
1037                "bool",
1038                serde_json::json!(cfg.secret_detection.redact),
1039                "Redact detected secrets from output",
1040            ),
1041        );
1042        secret_det.insert(
1043            "custom_patterns".into(),
1044            key(
1045                "array",
1046                serde_json::json!(cfg.secret_detection.custom_patterns),
1047                "Additional regex patterns to detect as secrets",
1048            ),
1049        );
1050        sections.insert(
1051            "secret_detection".into(),
1052            SectionSchema {
1053                description: "Secret/credential detection and redaction settings".into(),
1054                keys: secret_det,
1055            },
1056        );
1057
1058        let mut cloud = BTreeMap::new();
1059        cloud.insert(
1060            "contribute_enabled".into(),
1061            key(
1062                "bool",
1063                serde_json::json!(cfg.cloud.contribute_enabled),
1064                "Enable contributing anonymized stats to lean-ctx cloud",
1065            ),
1066        );
1067        sections.insert(
1068            "cloud".into(),
1069            SectionSchema {
1070                description: "Cloud feature settings".into(),
1071                keys: cloud,
1072            },
1073        );
1074
1075        let mut gain = BTreeMap::new();
1076        gain.insert(
1077            "auto_publish".into(),
1078            key(
1079                "bool",
1080                serde_json::json!(cfg.gain.auto_publish),
1081                "Automatically (re)publish your Wrapped recap when you run `lean-ctx gain` (opt-in, off by default; throttled and sends only an aggregate payload)",
1082            ),
1083        );
1084        gain.insert(
1085            "leaderboard".into(),
1086            key(
1087                "bool",
1088                serde_json::json!(cfg.gain.leaderboard),
1089                "When auto-publishing, also list the card on the public opt-in leaderboard",
1090            ),
1091        );
1092        gain.insert(
1093            "display_name".into(),
1094            key(
1095                "string?",
1096                serde_json::json!(cfg.gain.display_name),
1097                "Optional display name shown on your published card / leaderboard entry",
1098            ),
1099        );
1100        gain.insert(
1101            "auto_publish_interval_hours".into(),
1102            key(
1103                "u64",
1104                serde_json::json!(cfg.gain.auto_publish_interval_hours),
1105                "Minimum hours between automatic publishes (throttle; default 24)",
1106            ),
1107        );
1108        sections.insert(
1109            "gain".into(),
1110            SectionSchema {
1111                description: "Token-savings recap publishing (gain --publish / auto-publish)"
1112                    .into(),
1113                keys: gain,
1114            },
1115        );
1116
1117        let mut proxy = BTreeMap::new();
1118        proxy.insert(
1119            "anthropic_upstream".into(),
1120            key(
1121                "string?",
1122                serde_json::json!(cfg.proxy.anthropic_upstream),
1123                "Custom upstream URL for Anthropic API proxy",
1124            ),
1125        );
1126        proxy.insert(
1127            "openai_upstream".into(),
1128            key(
1129                "string?",
1130                serde_json::json!(cfg.proxy.openai_upstream),
1131                "Custom upstream URL for OpenAI API proxy",
1132            ),
1133        );
1134        proxy.insert(
1135            "gemini_upstream".into(),
1136            key(
1137                "string?",
1138                serde_json::json!(cfg.proxy.gemini_upstream),
1139                "Custom upstream URL for Gemini API proxy",
1140            ),
1141        );
1142        sections.insert(
1143            "proxy".into(),
1144            SectionSchema {
1145                description: "Proxy upstream configuration for API routing".into(),
1146                keys: proxy,
1147            },
1148        );
1149
1150        let mem = &cfg.memory;
1151        let mut mem_knowledge = BTreeMap::new();
1152        mem_knowledge.insert(
1153            "max_facts".into(),
1154            key(
1155                "usize",
1156                serde_json::json!(mem.knowledge.max_facts),
1157                "Maximum number of knowledge facts stored per project",
1158            ),
1159        );
1160        mem_knowledge.insert(
1161            "max_patterns".into(),
1162            key(
1163                "usize",
1164                serde_json::json!(mem.knowledge.max_patterns),
1165                "Maximum number of patterns stored",
1166            ),
1167        );
1168        mem_knowledge.insert(
1169            "max_history".into(),
1170            key(
1171                "usize",
1172                serde_json::json!(mem.knowledge.max_history),
1173                "Maximum history entries retained",
1174            ),
1175        );
1176        mem_knowledge.insert(
1177            "contradiction_threshold".into(),
1178            key(
1179                "f32",
1180                clean_f32(mem.knowledge.contradiction_threshold),
1181                "Confidence threshold for contradiction detection",
1182            ),
1183        );
1184        mem_knowledge.insert(
1185            "recall_facts_limit".into(),
1186            key(
1187                "usize",
1188                serde_json::json!(mem.knowledge.recall_facts_limit),
1189                "Maximum facts returned per recall query",
1190            ),
1191        );
1192        mem_knowledge.insert(
1193            "rooms_limit".into(),
1194            key(
1195                "usize",
1196                serde_json::json!(mem.knowledge.rooms_limit),
1197                "Maximum number of rooms returned",
1198            ),
1199        );
1200        mem_knowledge.insert(
1201            "timeline_limit".into(),
1202            key(
1203                "usize",
1204                serde_json::json!(mem.knowledge.timeline_limit),
1205                "Maximum number of timeline entries returned",
1206            ),
1207        );
1208        mem_knowledge.insert(
1209            "relations_limit".into(),
1210            key(
1211                "usize",
1212                serde_json::json!(mem.knowledge.relations_limit),
1213                "Maximum number of relations returned",
1214            ),
1215        );
1216        sections.insert(
1217            "memory.knowledge".into(),
1218            SectionSchema {
1219                description: "Knowledge memory budgets (facts, patterns, gotchas)".into(),
1220                keys: mem_knowledge,
1221            },
1222        );
1223
1224        let mut mem_episodic = BTreeMap::new();
1225        mem_episodic.insert(
1226            "max_episodes".into(),
1227            key(
1228                "usize",
1229                serde_json::json!(mem.episodic.max_episodes),
1230                "Maximum number of episodes retained",
1231            ),
1232        );
1233        mem_episodic.insert(
1234            "max_actions_per_episode".into(),
1235            key(
1236                "usize",
1237                serde_json::json!(mem.episodic.max_actions_per_episode),
1238                "Maximum actions tracked per episode",
1239            ),
1240        );
1241        mem_episodic.insert(
1242            "summary_max_chars".into(),
1243            key(
1244                "usize",
1245                serde_json::json!(mem.episodic.summary_max_chars),
1246                "Maximum characters in episode summary",
1247            ),
1248        );
1249        sections.insert(
1250            "memory.episodic".into(),
1251            SectionSchema {
1252                description: "Episodic memory budgets (session episodes)".into(),
1253                keys: mem_episodic,
1254            },
1255        );
1256
1257        let mut mem_procedural = BTreeMap::new();
1258        mem_procedural.insert(
1259            "max_procedures".into(),
1260            key(
1261                "usize",
1262                serde_json::json!(mem.procedural.max_procedures),
1263                "Maximum number of learned procedures stored",
1264            ),
1265        );
1266        mem_procedural.insert(
1267            "min_repetitions".into(),
1268            key(
1269                "usize",
1270                serde_json::json!(mem.procedural.min_repetitions),
1271                "Minimum repetitions before a pattern is stored",
1272            ),
1273        );
1274        mem_procedural.insert(
1275            "min_sequence_len".into(),
1276            key(
1277                "usize",
1278                serde_json::json!(mem.procedural.min_sequence_len),
1279                "Minimum sequence length for procedure detection",
1280            ),
1281        );
1282        mem_procedural.insert(
1283            "max_window_size".into(),
1284            key(
1285                "usize",
1286                serde_json::json!(mem.procedural.max_window_size),
1287                "Maximum window size for pattern analysis",
1288            ),
1289        );
1290        sections.insert(
1291            "memory.procedural".into(),
1292            SectionSchema {
1293                description: "Procedural memory budgets (learned patterns)".into(),
1294                keys: mem_procedural,
1295            },
1296        );
1297
1298        let mut mem_lifecycle = BTreeMap::new();
1299        mem_lifecycle.insert(
1300            "decay_rate".into(),
1301            key(
1302                "f32",
1303                clean_f32(mem.lifecycle.decay_rate),
1304                "Rate at which knowledge confidence decays over time",
1305            ),
1306        );
1307        mem_lifecycle.insert(
1308            "low_confidence_threshold".into(),
1309            key(
1310                "f32",
1311                clean_f32(mem.lifecycle.low_confidence_threshold),
1312                "Threshold below which facts are considered low-confidence",
1313            ),
1314        );
1315        mem_lifecycle.insert(
1316            "stale_days".into(),
1317            key(
1318                "i64",
1319                serde_json::json!(mem.lifecycle.stale_days),
1320                "Days after which unused facts are considered stale",
1321            ),
1322        );
1323        mem_lifecycle.insert(
1324            "similarity_threshold".into(),
1325            key(
1326                "f32",
1327                clean_f32(mem.lifecycle.similarity_threshold),
1328                "Similarity threshold for deduplication",
1329            ),
1330        );
1331        sections.insert(
1332            "memory.lifecycle".into(),
1333            SectionSchema {
1334                description: "Knowledge lifecycle policy (decay, staleness, dedup)".into(),
1335                keys: mem_lifecycle,
1336            },
1337        );
1338
1339        let mut mem_gotcha = BTreeMap::new();
1340        mem_gotcha.insert(
1341            "max_gotchas_per_project".into(),
1342            key(
1343                "usize",
1344                serde_json::json!(mem.gotcha.max_gotchas_per_project),
1345                "Maximum gotchas stored per project",
1346            ),
1347        );
1348        mem_gotcha.insert(
1349            "retrieval_budget_per_room".into(),
1350            key(
1351                "usize",
1352                serde_json::json!(mem.gotcha.retrieval_budget_per_room),
1353                "Maximum gotchas retrieved per room per query",
1354            ),
1355        );
1356        mem_gotcha.insert(
1357            "default_decay_rate".into(),
1358            key(
1359                "f32",
1360                clean_f32(mem.gotcha.default_decay_rate),
1361                "Default decay rate for gotcha importance",
1362            ),
1363        );
1364        sections.insert(
1365            "memory.gotcha".into(),
1366            SectionSchema {
1367                description: "Gotcha memory settings (project-specific warnings and pitfalls)"
1368                    .into(),
1369                keys: mem_gotcha,
1370            },
1371        );
1372
1373        let mut mem_embeddings = BTreeMap::new();
1374        mem_embeddings.insert(
1375            "max_facts".into(),
1376            key(
1377                "usize",
1378                serde_json::json!(mem.embeddings.max_facts),
1379                "Maximum number of embedding facts stored",
1380            ),
1381        );
1382        sections.insert(
1383            "memory.embeddings".into(),
1384            SectionSchema {
1385                description: "Embeddings memory settings for semantic search".into(),
1386                keys: mem_embeddings,
1387            },
1388        );
1389
1390        let mut aliases = BTreeMap::new();
1391        aliases.insert(
1392            "command".into(),
1393            key(
1394                "string",
1395                serde_json::json!(""),
1396                "The command pattern to match (e.g. 'deploy')",
1397            ),
1398        );
1399        aliases.insert(
1400            "alias".into(),
1401            key(
1402                "string",
1403                serde_json::json!(""),
1404                "The alias definition to execute",
1405            ),
1406        );
1407        sections.insert("custom_aliases".into(), SectionSchema {
1408            description: "Custom command aliases (array of {command, alias} entries). Note: field names are 'command' and 'alias' (not 'name')".into(),
1409            keys: aliases,
1410        });
1411
1412        if let Some(root_section) = sections.get_mut("root") {
1413            root_section.keys.insert(
1414                "custom_aliases".into(),
1415                key(
1416                    "array",
1417                    serde_json::json!([]),
1418                    "Custom command aliases (array of {command, alias} entries)",
1419                ),
1420            );
1421        }
1422
1423        let mut setup_keys = BTreeMap::new();
1424        setup_keys.insert(
1425            "auto_inject_rules".into(),
1426            key(
1427                "bool?",
1428                serde_json::json!(null),
1429                "Inject agent rule files during setup/update. null=auto (inject if already present), true=always, false=never",
1430            ),
1431        );
1432        setup_keys.insert(
1433            "auto_inject_skills".into(),
1434            key(
1435                "bool?",
1436                serde_json::json!(null),
1437                "Install SKILL.md files during setup/update. null=auto (install if rules present), true=always, false=never",
1438            ),
1439        );
1440        setup_keys.insert(
1441            "auto_update_mcp".into(),
1442            key(
1443                "bool",
1444                serde_json::json!(true),
1445                "Register lean-ctx MCP server in editor configs during setup/update",
1446            ),
1447        );
1448        sections.insert(
1449            "setup".into(),
1450            SectionSchema {
1451                description: "Controls what lean-ctx injects during setup and updates. Fresh installs default to non-invasive (rules/skills off, MCP on).".into(),
1452                keys: setup_keys,
1453            },
1454        );
1455
1456        let mut llm_keys = BTreeMap::new();
1457        llm_keys.insert(
1458            "enabled".into(),
1459            key(
1460                "bool",
1461                serde_json::json!(false),
1462                "Enable optional LLM enhancements (query expansion, contradiction explanation)",
1463            ),
1464        );
1465        llm_keys.insert(
1466            "backend".into(),
1467            key_enum(
1468                &["ollama", "openrouter", "anthropic"],
1469                "ollama",
1470                "LLM backend provider",
1471            ),
1472        );
1473        llm_keys.insert(
1474            "model".into(),
1475            key(
1476                "string",
1477                serde_json::json!("llama3.2"),
1478                "Model name for the selected backend",
1479            ),
1480        );
1481        llm_keys.insert(
1482            "api_key".into(),
1483            key(
1484                "string",
1485                serde_json::json!(""),
1486                "API key for OpenRouter or Anthropic backends",
1487            ),
1488        );
1489        llm_keys.insert(
1490            "timeout_secs".into(),
1491            key(
1492                "u64",
1493                serde_json::json!(10),
1494                "HTTP timeout for LLM requests",
1495            ),
1496        );
1497        sections.insert("llm".into(), SectionSchema {
1498            description: "Optional LLM enhancement settings (query expansion, contradiction explanation). Deterministic fallback when disabled or unreachable.".into(),
1499            keys: llm_keys,
1500        });
1501
1502        ConfigSchema {
1503            version: 1,
1504            sections,
1505        }
1506    }
1507
1508    /// Looks up a key schema by its dot-separated TOML path.
1509    /// Returns `None` if the key is not part of the schema.
1510    pub fn lookup(&self, key: &str) -> Option<&KeySchema> {
1511        if let Some(dot_pos) = key.find('.') {
1512            let section = &key[..dot_pos];
1513            let field = &key[dot_pos + 1..];
1514            self.sections.get(section)?.keys.get(field)
1515        } else {
1516            self.sections.get("root")?.keys.get(key)
1517        }
1518    }
1519
1520    /// All known TOML keys (dot-separated) for validation.
1521    pub fn known_keys(&self) -> Vec<String> {
1522        let mut keys = Vec::new();
1523        for (section, schema) in &self.sections {
1524            if section == "root" {
1525                for key_name in schema.keys.keys() {
1526                    keys.push(key_name.clone());
1527                }
1528            } else {
1529                if schema.keys.is_empty() {
1530                    keys.push(section.clone());
1531                }
1532                for key_name in schema.keys.keys() {
1533                    keys.push(format!("{section}.{key_name}"));
1534                }
1535            }
1536        }
1537        keys
1538    }
1539}