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            "shell_hook_disabled".into(),
318            key_with_env(
319                "bool",
320                serde_json::json!(false),
321                "Disable shell hook injection",
322                "LEAN_CTX_NO_HOOK",
323            ),
324        );
325        root.insert(
326            "shell_activation".into(),
327            key_enum_with_env(
328                &["always", "agents-only", "off"],
329                "always",
330                "Controls when the shell hook auto-activates aliases",
331                "LEAN_CTX_SHELL_ACTIVATION",
332            ),
333        );
334        root.insert(
335            "update_check_disabled".into(),
336            key_with_env(
337                "bool",
338                serde_json::json!(false),
339                "Disable the daily version check",
340                "LEAN_CTX_NO_UPDATE_CHECK",
341            ),
342        );
343        root.insert(
344            "bm25_max_cache_mb".into(),
345            key_with_env(
346                "u64",
347                serde_json::json!(cfg.bm25_max_cache_mb),
348                "Maximum BM25 cache file size in MB",
349                "LEAN_CTX_BM25_MAX_CACHE_MB",
350            ),
351        );
352        root.insert(
353            "graph_index_max_files".into(),
354            key(
355                "u64",
356                serde_json::json!(cfg.graph_index_max_files),
357                "Maximum files in graph index. 0 = unlimited (default). Set >0 to cap for constrained systems",
358            ),
359        );
360        root.insert(
361            "memory_profile".into(),
362            key_enum_with_env(
363                &["low", "balanced", "performance"],
364                "performance",
365                "Controls RAM vs feature trade-off (performance = max quality)",
366                "LEAN_CTX_MEMORY_PROFILE",
367            ),
368        );
369        root.insert(
370            "memory_cleanup".into(),
371            key_enum_with_env(
372                &["aggressive", "shared"],
373                "aggressive",
374                "Controls how aggressively memory is freed when idle",
375                "LEAN_CTX_MEMORY_CLEANUP",
376            ),
377        );
378        root.insert(
379            "savings_footer".into(),
380            key_enum_with_env(
381                &["auto", "always", "never"],
382                "always",
383                "Controls visibility of token savings footers: always (default, show on every response), never, auto (context-dependent). Also: LEAN_CTX_SHOW_SAVINGS=1|0",
384                "LEAN_CTX_SAVINGS_FOOTER",
385            ),
386        );
387        root.insert(
388            "max_ram_percent".into(),
389            key_with_env(
390                "u8",
391                serde_json::json!(cfg.max_ram_percent),
392                "Maximum percentage of system RAM that lean-ctx may use (1-50, default 5)",
393                "LEAN_CTX_MAX_RAM_PERCENT",
394            ),
395        );
396        root.insert(
397            "max_disk_mb".into(),
398            key_with_env(
399                "u64",
400                serde_json::json!(cfg.max_disk_mb),
401                "Simplified disk budget in MB (0 = disabled). Distributes: archive ~25%, BM25 ~10%",
402                "LEAN_CTX_MAX_DISK_MB",
403            ),
404        );
405        root.insert(
406            "max_staleness_days".into(),
407            key_with_env(
408                "u32",
409                serde_json::json!(cfg.max_staleness_days),
410                "Auto-purge data older than N days (0 = disabled). Flows into archive.max_age_hours",
411                "LEAN_CTX_MAX_STALENESS_DAYS",
412            ),
413        );
414        root.insert(
415            "project_root".into(),
416            key_with_env(
417                "string?",
418                serde_json::json!(null),
419                "Explicit project root directory. Prevents accidental home-directory scans",
420                "LEAN_CTX_PROJECT_ROOT",
421            ),
422        );
423        root.insert(
424            "proxy_enabled".into(),
425            key(
426                "bool?",
427                serde_json::json!(null),
428                "Enable/disable the proxy layer. null = auto-detect, true = force on, false = force off",
429            ),
430        );
431        root.insert(
432            "proxy_port".into(),
433            key(
434                "u16?",
435                serde_json::json!(null),
436                "Custom proxy port (default: 4444). Useful for multi-user systems. Env: LEAN_CTX_PROXY_PORT",
437            ),
438        );
439        root.insert(
440            "proxy_timeout_ms".into(),
441            key(
442                "u64?",
443                serde_json::json!(null),
444                "Proxy reachability timeout in ms (default: 200). Override via LEAN_CTX_PROXY_TIMEOUT_MS",
445            ),
446        );
447        root.insert(
448            "response_verbosity".into(),
449            key_enum_with_env(
450                &["normal", "compact", "minimal"],
451                "normal",
452                "Controls how verbose tool responses are",
453                "LEAN_CTX_RESPONSE_VERBOSITY",
454            ),
455        );
456        root.insert(
457            "allow_auto_reroot".into(),
458            key_with_env(
459                "bool",
460                serde_json::json!(false),
461                "Allow automatic project-root re-rooting when absolute paths outside the jail are seen",
462                "LEAN_CTX_ALLOW_REROOT",
463            ),
464        );
465        root.insert(
466            "sandbox_level".into(),
467            key_with_env(
468                "u8",
469                serde_json::json!(0),
470                "Sandbox strictness level (0=default, 1=strict, 2=paranoid)",
471                "LEAN_CTX_SANDBOX_LEVEL",
472            ),
473        );
474        root.insert(
475            "reference_results".into(),
476            key_with_env(
477                "bool",
478                serde_json::json!(false),
479                "Store large tool outputs as references instead of inline content",
480                "LEAN_CTX_REFERENCE_RESULTS",
481            ),
482        );
483        root.insert(
484            "agent_token_budget".into(),
485            key(
486                "usize",
487                serde_json::json!(0),
488                "Default per-agent token budget. 0 = unlimited",
489            ),
490        );
491        root.insert(
492            "shell_allowlist".into(),
493            key_with_env(
494                "array",
495                serde_json::json!([]),
496                "Optional shell command allowlist. When non-empty, only listed binaries are permitted",
497                "LEAN_CTX_SHELL_ALLOWLIST",
498            ),
499        );
500        root.insert(
501            "shell_strict_mode".into(),
502            key(
503                "bool",
504                serde_json::json!(false),
505                "Block $(), backticks, <() in shell arguments. Default false = warn only.",
506            ),
507        );
508
509        sections.insert(
510            "root".into(),
511            SectionSchema {
512                description: "Top-level configuration keys".into(),
513                keys: root,
514            },
515        );
516
517        sections.insert(
518            "ide_paths".into(),
519            SectionSchema {
520                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(),
521                keys: BTreeMap::new(),
522            },
523        );
524
525        let mut lsp_keys = BTreeMap::new();
526        lsp_keys.insert(
527            "rust".into(),
528            key(
529                "string?",
530                serde_json::json!(null),
531                "Custom path to rust-analyzer binary",
532            ),
533        );
534        lsp_keys.insert(
535            "typescript".into(),
536            key(
537                "string?",
538                serde_json::json!(null),
539                "Custom path to typescript-language-server binary",
540            ),
541        );
542        lsp_keys.insert(
543            "python".into(),
544            key(
545                "string?",
546                serde_json::json!(null),
547                "Custom path to pylsp binary",
548            ),
549        );
550        lsp_keys.insert(
551            "go".into(),
552            key(
553                "string?",
554                serde_json::json!(null),
555                "Custom path to gopls binary",
556            ),
557        );
558        sections.insert(
559            "lsp".into(),
560            SectionSchema {
561                description: "LSP server binary overrides. Map language name to custom binary path"
562                    .into(),
563                keys: lsp_keys,
564            },
565        );
566
567        let mut archive = BTreeMap::new();
568        archive.insert(
569            "enabled".into(),
570            key(
571                "bool",
572                serde_json::json!(cfg.archive.enabled),
573                "Enable zero-loss compression archive",
574            ),
575        );
576        archive.insert(
577            "threshold_chars".into(),
578            key(
579                "usize",
580                serde_json::json!(cfg.archive.threshold_chars),
581                "Minimum output size (chars) to trigger archiving",
582            ),
583        );
584        archive.insert(
585            "max_age_hours".into(),
586            key(
587                "u64",
588                serde_json::json!(cfg.archive.max_age_hours),
589                "Maximum age of archived entries before cleanup",
590            ),
591        );
592        archive.insert(
593            "max_disk_mb".into(),
594            key(
595                "u64",
596                serde_json::json!(cfg.archive.max_disk_mb),
597                "Maximum total disk usage for the archive",
598            ),
599        );
600        archive.insert(
601            "ephemeral".into(),
602            key("bool", serde_json::json!(cfg.archive.ephemeral), "Replace large results with summary+ref (ctx_expand to retrieve). Env: LEAN_CTX_EPHEMERAL"),
603        );
604        sections.insert("archive".into(), SectionSchema {
605            description: "Settings for the zero-loss compression archive (large tool outputs saved to disk)".into(),
606            keys: archive,
607        });
608
609        let mut search = BTreeMap::new();
610        search.insert(
611            "bm25_weight".into(),
612            key(
613                "f64",
614                serde_json::json!(cfg.search.bm25_weight),
615                "BM25 lexical search weight in RRF fusion",
616            ),
617        );
618        search.insert(
619            "dense_weight".into(),
620            key(
621                "f64",
622                serde_json::json!(cfg.search.dense_weight),
623                "Dense vector search weight in RRF fusion",
624            ),
625        );
626        search.insert(
627            "bm25_candidates".into(),
628            key(
629                "usize",
630                serde_json::json!(cfg.search.bm25_candidates),
631                "Number of BM25 candidates to retrieve before fusion",
632            ),
633        );
634        search.insert(
635            "dense_candidates".into(),
636            key(
637                "usize",
638                serde_json::json!(cfg.search.dense_candidates),
639                "Number of dense candidates to retrieve before fusion",
640            ),
641        );
642        search.insert(
643            "splade_weight".into(),
644            key(
645                "f64",
646                serde_json::json!(cfg.search.splade_weight),
647                "SPLADE expansion weight (0.0 to disable)",
648            ),
649        );
650        sections.insert("search".into(), SectionSchema {
651            description: "Hybrid search weights for ctx_semantic_search (BM25 + dense vector + SPLADE + graph proximity)".into(),
652            keys: search,
653        });
654
655        let mut autonomy = BTreeMap::new();
656        autonomy.insert(
657            "enabled".into(),
658            key(
659                "bool",
660                serde_json::json!(cfg.autonomy.enabled),
661                "Enable autonomous background behaviors",
662            ),
663        );
664        autonomy.insert(
665            "auto_preload".into(),
666            key(
667                "bool",
668                serde_json::json!(cfg.autonomy.auto_preload),
669                "Auto-preload related files on first read",
670            ),
671        );
672        autonomy.insert(
673            "auto_dedup".into(),
674            key(
675                "bool",
676                serde_json::json!(cfg.autonomy.auto_dedup),
677                "Auto-deduplicate repeated reads",
678            ),
679        );
680        autonomy.insert(
681            "auto_related".into(),
682            key(
683                "bool",
684                serde_json::json!(cfg.autonomy.auto_related),
685                "Auto-load graph-related files",
686            ),
687        );
688        autonomy.insert(
689            "auto_consolidate".into(),
690            key(
691                "bool",
692                serde_json::json!(cfg.autonomy.auto_consolidate),
693                "Auto-consolidate knowledge periodically",
694            ),
695        );
696        autonomy.insert(
697            "silent_preload".into(),
698            key(
699                "bool",
700                serde_json::json!(cfg.autonomy.silent_preload),
701                "Suppress preload notifications in output",
702            ),
703        );
704        autonomy.insert(
705            "dedup_threshold".into(),
706            key(
707                "usize",
708                serde_json::json!(cfg.autonomy.dedup_threshold),
709                "Number of repeated reads before dedup triggers",
710            ),
711        );
712        autonomy.insert(
713            "consolidate_every_calls".into(),
714            key(
715                "u32",
716                serde_json::json!(cfg.autonomy.consolidate_every_calls),
717                "Consolidate knowledge every N tool calls",
718            ),
719        );
720        autonomy.insert(
721            "consolidate_cooldown_secs".into(),
722            key(
723                "u64",
724                serde_json::json!(cfg.autonomy.consolidate_cooldown_secs),
725                "Minimum seconds between consolidation runs",
726            ),
727        );
728        sections.insert(
729            "autonomy".into(),
730            SectionSchema {
731                description:
732                    "Controls autonomous background behaviors (preload, dedup, consolidation)"
733                        .into(),
734                keys: autonomy,
735            },
736        );
737
738        let mut providers = BTreeMap::new();
739        providers.insert(
740            "enabled".into(),
741            key(
742                "bool",
743                serde_json::json!(cfg.providers.enabled),
744                "Master switch for the provider subsystem (GitHub, GitLab, etc.)",
745            ),
746        );
747        providers.insert(
748            "auto_index".into(),
749            key(
750                "bool",
751                serde_json::json!(cfg.providers.auto_index),
752                "Auto-ingest provider results into BM25/embedding indexes",
753            ),
754        );
755        providers.insert(
756            "cache_ttl_secs".into(),
757            key(
758                "u64",
759                serde_json::json!(cfg.providers.cache_ttl_secs),
760                "Default cache TTL for provider results (seconds)",
761            ),
762        );
763        providers.insert(
764            "github.enabled".into(),
765            key(
766                "bool",
767                serde_json::json!(cfg.providers.github.enabled),
768                "Enable/disable GitHub provider",
769            ),
770        );
771        providers.insert(
772            "github.api_url".into(),
773            key(
774                "string",
775                serde_json::json!(cfg.providers.github.api_url),
776                "GitHub API base URL (for GitHub Enterprise)",
777            ),
778        );
779        providers.insert(
780            "gitlab.enabled".into(),
781            key(
782                "bool",
783                serde_json::json!(cfg.providers.gitlab.enabled),
784                "Enable/disable GitLab provider",
785            ),
786        );
787        providers.insert(
788            "gitlab.api_url".into(),
789            key(
790                "string",
791                serde_json::json!(cfg.providers.gitlab.api_url),
792                "GitLab API base URL (for self-hosted instances)",
793            ),
794        );
795        providers.insert(
796            "mcp_bridges.<name>.url".into(),
797            key(
798                "string",
799                serde_json::json!(null),
800                "HTTP/SSE URL for a remote MCP server",
801            ),
802        );
803        providers.insert(
804            "mcp_bridges.<name>.command".into(),
805            key(
806                "string",
807                serde_json::json!(null),
808                "Command to spawn a local MCP server (stdio transport)",
809            ),
810        );
811        providers.insert(
812            "mcp_bridges.<name>.args".into(),
813            key(
814                "array",
815                serde_json::json!([]),
816                "Arguments for the MCP server command",
817            ),
818        );
819        providers.insert(
820            "mcp_bridges.<name>.auth_env".into(),
821            key(
822                "string",
823                serde_json::json!(null),
824                "Environment variable name containing auth token for MCP server",
825            ),
826        );
827        sections.insert(
828            "providers".into(),
829            SectionSchema {
830                description:
831                    "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."
832                        .into(),
833                keys: providers,
834            },
835        );
836
837        let mut loop_det = BTreeMap::new();
838        loop_det.insert(
839            "normal_threshold".into(),
840            key(
841                "u32",
842                serde_json::json!(cfg.loop_detection.normal_threshold),
843                "Repetitions before reducing output",
844            ),
845        );
846        loop_det.insert(
847            "reduced_threshold".into(),
848            key(
849                "u32",
850                serde_json::json!(cfg.loop_detection.reduced_threshold),
851                "Repetitions before further reducing output",
852            ),
853        );
854        loop_det.insert(
855            "blocked_threshold".into(),
856            key(
857                "u32",
858                serde_json::json!(cfg.loop_detection.blocked_threshold),
859                "Repetitions before blocking. 0 = disabled",
860            ),
861        );
862        loop_det.insert(
863            "window_secs".into(),
864            key(
865                "u64",
866                serde_json::json!(cfg.loop_detection.window_secs),
867                "Time window in seconds for loop detection",
868            ),
869        );
870        loop_det.insert(
871            "search_group_limit".into(),
872            key(
873                "u32",
874                serde_json::json!(cfg.loop_detection.search_group_limit),
875                "Maximum unique searches within a loop window",
876            ),
877        );
878        loop_det.insert(
879            "tool_total_limits".into(),
880            key(
881                "table",
882                serde_json::json!({"ctx_read": 100, "ctx_search": 80, "ctx_shell": 50, "ctx_semantic_search": 60}),
883                "Per-tool total call limits within a session. Keys are tool names, values are max calls",
884            ),
885        );
886        sections.insert(
887            "loop_detection".into(),
888            SectionSchema {
889                description: "Loop detection settings for preventing repeated identical tool calls"
890                    .into(),
891                keys: loop_det,
892            },
893        );
894
895        let mut updates = BTreeMap::new();
896        updates.insert(
897            "auto_update".into(),
898            key(
899                "bool",
900                serde_json::json!(cfg.updates.auto_update),
901                "Enable automatic updates (requires explicit opt-in)",
902            ),
903        );
904        updates.insert(
905            "check_interval_hours".into(),
906            key(
907                "u64",
908                serde_json::json!(cfg.updates.check_interval_hours),
909                "How often to check for updates (hours)",
910            ),
911        );
912        updates.insert(
913            "notify_only".into(),
914            key(
915                "bool",
916                serde_json::json!(cfg.updates.notify_only),
917                "Only notify about updates, don't install automatically",
918            ),
919        );
920        sections.insert(
921            "updates".into(),
922            SectionSchema {
923                description: "Automatic update configuration".into(),
924                keys: updates,
925            },
926        );
927
928        let mut boundary = BTreeMap::new();
929        boundary.insert(
930            "cross_project_search".into(),
931            key(
932                "bool",
933                serde_json::json!(cfg.boundary_policy.cross_project_search),
934                "Allow searching across project boundaries",
935            ),
936        );
937        boundary.insert(
938            "cross_project_import".into(),
939            key(
940                "bool",
941                serde_json::json!(cfg.boundary_policy.cross_project_import),
942                "Allow importing knowledge from other projects",
943            ),
944        );
945        boundary.insert(
946            "audit_cross_access".into(),
947            key(
948                "bool",
949                serde_json::json!(cfg.boundary_policy.audit_cross_access),
950                "Log audit events when cross-project access occurs",
951            ),
952        );
953        boundary.insert(
954            "universal_gotchas_enabled".into(),
955            key(
956                "bool",
957                serde_json::json!(cfg.boundary_policy.universal_gotchas_enabled),
958                "Load universal (cross-project) gotchas",
959            ),
960        );
961        sections.insert(
962            "boundary_policy".into(),
963            SectionSchema {
964                description: "Cross-project boundary and access control policies".into(),
965                keys: boundary,
966            },
967        );
968
969        let mut secret_det = BTreeMap::new();
970        secret_det.insert(
971            "enabled".into(),
972            key(
973                "bool",
974                serde_json::json!(cfg.secret_detection.enabled),
975                "Enable secret/credential detection in tool outputs",
976            ),
977        );
978        secret_det.insert(
979            "redact".into(),
980            key(
981                "bool",
982                serde_json::json!(cfg.secret_detection.redact),
983                "Redact detected secrets from output",
984            ),
985        );
986        secret_det.insert(
987            "custom_patterns".into(),
988            key(
989                "array",
990                serde_json::json!(cfg.secret_detection.custom_patterns),
991                "Additional regex patterns to detect as secrets",
992            ),
993        );
994        sections.insert(
995            "secret_detection".into(),
996            SectionSchema {
997                description: "Secret/credential detection and redaction settings".into(),
998                keys: secret_det,
999            },
1000        );
1001
1002        let mut cloud = BTreeMap::new();
1003        cloud.insert(
1004            "contribute_enabled".into(),
1005            key(
1006                "bool",
1007                serde_json::json!(cfg.cloud.contribute_enabled),
1008                "Enable contributing anonymized stats to lean-ctx cloud",
1009            ),
1010        );
1011        sections.insert(
1012            "cloud".into(),
1013            SectionSchema {
1014                description: "Cloud feature settings".into(),
1015                keys: cloud,
1016            },
1017        );
1018
1019        let mut proxy = BTreeMap::new();
1020        proxy.insert(
1021            "anthropic_upstream".into(),
1022            key(
1023                "string?",
1024                serde_json::json!(cfg.proxy.anthropic_upstream),
1025                "Custom upstream URL for Anthropic API proxy",
1026            ),
1027        );
1028        proxy.insert(
1029            "openai_upstream".into(),
1030            key(
1031                "string?",
1032                serde_json::json!(cfg.proxy.openai_upstream),
1033                "Custom upstream URL for OpenAI API proxy",
1034            ),
1035        );
1036        proxy.insert(
1037            "gemini_upstream".into(),
1038            key(
1039                "string?",
1040                serde_json::json!(cfg.proxy.gemini_upstream),
1041                "Custom upstream URL for Gemini API proxy",
1042            ),
1043        );
1044        sections.insert(
1045            "proxy".into(),
1046            SectionSchema {
1047                description: "Proxy upstream configuration for API routing".into(),
1048                keys: proxy,
1049            },
1050        );
1051
1052        let mem = &cfg.memory;
1053        let mut mem_knowledge = BTreeMap::new();
1054        mem_knowledge.insert(
1055            "max_facts".into(),
1056            key(
1057                "usize",
1058                serde_json::json!(mem.knowledge.max_facts),
1059                "Maximum number of knowledge facts stored per project",
1060            ),
1061        );
1062        mem_knowledge.insert(
1063            "max_patterns".into(),
1064            key(
1065                "usize",
1066                serde_json::json!(mem.knowledge.max_patterns),
1067                "Maximum number of patterns stored",
1068            ),
1069        );
1070        mem_knowledge.insert(
1071            "max_history".into(),
1072            key(
1073                "usize",
1074                serde_json::json!(mem.knowledge.max_history),
1075                "Maximum history entries retained",
1076            ),
1077        );
1078        mem_knowledge.insert(
1079            "contradiction_threshold".into(),
1080            key(
1081                "f32",
1082                clean_f32(mem.knowledge.contradiction_threshold),
1083                "Confidence threshold for contradiction detection",
1084            ),
1085        );
1086        mem_knowledge.insert(
1087            "recall_facts_limit".into(),
1088            key(
1089                "usize",
1090                serde_json::json!(mem.knowledge.recall_facts_limit),
1091                "Maximum facts returned per recall query",
1092            ),
1093        );
1094        mem_knowledge.insert(
1095            "rooms_limit".into(),
1096            key(
1097                "usize",
1098                serde_json::json!(mem.knowledge.rooms_limit),
1099                "Maximum number of rooms returned",
1100            ),
1101        );
1102        mem_knowledge.insert(
1103            "timeline_limit".into(),
1104            key(
1105                "usize",
1106                serde_json::json!(mem.knowledge.timeline_limit),
1107                "Maximum number of timeline entries returned",
1108            ),
1109        );
1110        mem_knowledge.insert(
1111            "relations_limit".into(),
1112            key(
1113                "usize",
1114                serde_json::json!(mem.knowledge.relations_limit),
1115                "Maximum number of relations returned",
1116            ),
1117        );
1118        sections.insert(
1119            "memory.knowledge".into(),
1120            SectionSchema {
1121                description: "Knowledge memory budgets (facts, patterns, gotchas)".into(),
1122                keys: mem_knowledge,
1123            },
1124        );
1125
1126        let mut mem_episodic = BTreeMap::new();
1127        mem_episodic.insert(
1128            "max_episodes".into(),
1129            key(
1130                "usize",
1131                serde_json::json!(mem.episodic.max_episodes),
1132                "Maximum number of episodes retained",
1133            ),
1134        );
1135        mem_episodic.insert(
1136            "max_actions_per_episode".into(),
1137            key(
1138                "usize",
1139                serde_json::json!(mem.episodic.max_actions_per_episode),
1140                "Maximum actions tracked per episode",
1141            ),
1142        );
1143        mem_episodic.insert(
1144            "summary_max_chars".into(),
1145            key(
1146                "usize",
1147                serde_json::json!(mem.episodic.summary_max_chars),
1148                "Maximum characters in episode summary",
1149            ),
1150        );
1151        sections.insert(
1152            "memory.episodic".into(),
1153            SectionSchema {
1154                description: "Episodic memory budgets (session episodes)".into(),
1155                keys: mem_episodic,
1156            },
1157        );
1158
1159        let mut mem_procedural = BTreeMap::new();
1160        mem_procedural.insert(
1161            "max_procedures".into(),
1162            key(
1163                "usize",
1164                serde_json::json!(mem.procedural.max_procedures),
1165                "Maximum number of learned procedures stored",
1166            ),
1167        );
1168        mem_procedural.insert(
1169            "min_repetitions".into(),
1170            key(
1171                "usize",
1172                serde_json::json!(mem.procedural.min_repetitions),
1173                "Minimum repetitions before a pattern is stored",
1174            ),
1175        );
1176        mem_procedural.insert(
1177            "min_sequence_len".into(),
1178            key(
1179                "usize",
1180                serde_json::json!(mem.procedural.min_sequence_len),
1181                "Minimum sequence length for procedure detection",
1182            ),
1183        );
1184        mem_procedural.insert(
1185            "max_window_size".into(),
1186            key(
1187                "usize",
1188                serde_json::json!(mem.procedural.max_window_size),
1189                "Maximum window size for pattern analysis",
1190            ),
1191        );
1192        sections.insert(
1193            "memory.procedural".into(),
1194            SectionSchema {
1195                description: "Procedural memory budgets (learned patterns)".into(),
1196                keys: mem_procedural,
1197            },
1198        );
1199
1200        let mut mem_lifecycle = BTreeMap::new();
1201        mem_lifecycle.insert(
1202            "decay_rate".into(),
1203            key(
1204                "f32",
1205                clean_f32(mem.lifecycle.decay_rate),
1206                "Rate at which knowledge confidence decays over time",
1207            ),
1208        );
1209        mem_lifecycle.insert(
1210            "low_confidence_threshold".into(),
1211            key(
1212                "f32",
1213                clean_f32(mem.lifecycle.low_confidence_threshold),
1214                "Threshold below which facts are considered low-confidence",
1215            ),
1216        );
1217        mem_lifecycle.insert(
1218            "stale_days".into(),
1219            key(
1220                "i64",
1221                serde_json::json!(mem.lifecycle.stale_days),
1222                "Days after which unused facts are considered stale",
1223            ),
1224        );
1225        mem_lifecycle.insert(
1226            "similarity_threshold".into(),
1227            key(
1228                "f32",
1229                clean_f32(mem.lifecycle.similarity_threshold),
1230                "Similarity threshold for deduplication",
1231            ),
1232        );
1233        sections.insert(
1234            "memory.lifecycle".into(),
1235            SectionSchema {
1236                description: "Knowledge lifecycle policy (decay, staleness, dedup)".into(),
1237                keys: mem_lifecycle,
1238            },
1239        );
1240
1241        let mut mem_gotcha = BTreeMap::new();
1242        mem_gotcha.insert(
1243            "max_gotchas_per_project".into(),
1244            key(
1245                "usize",
1246                serde_json::json!(mem.gotcha.max_gotchas_per_project),
1247                "Maximum gotchas stored per project",
1248            ),
1249        );
1250        mem_gotcha.insert(
1251            "retrieval_budget_per_room".into(),
1252            key(
1253                "usize",
1254                serde_json::json!(mem.gotcha.retrieval_budget_per_room),
1255                "Maximum gotchas retrieved per room per query",
1256            ),
1257        );
1258        mem_gotcha.insert(
1259            "default_decay_rate".into(),
1260            key(
1261                "f32",
1262                clean_f32(mem.gotcha.default_decay_rate),
1263                "Default decay rate for gotcha importance",
1264            ),
1265        );
1266        sections.insert(
1267            "memory.gotcha".into(),
1268            SectionSchema {
1269                description: "Gotcha memory settings (project-specific warnings and pitfalls)"
1270                    .into(),
1271                keys: mem_gotcha,
1272            },
1273        );
1274
1275        let mut mem_embeddings = BTreeMap::new();
1276        mem_embeddings.insert(
1277            "max_facts".into(),
1278            key(
1279                "usize",
1280                serde_json::json!(mem.embeddings.max_facts),
1281                "Maximum number of embedding facts stored",
1282            ),
1283        );
1284        sections.insert(
1285            "memory.embeddings".into(),
1286            SectionSchema {
1287                description: "Embeddings memory settings for semantic search".into(),
1288                keys: mem_embeddings,
1289            },
1290        );
1291
1292        let mut aliases = BTreeMap::new();
1293        aliases.insert(
1294            "command".into(),
1295            key(
1296                "string",
1297                serde_json::json!(""),
1298                "The command pattern to match (e.g. 'deploy')",
1299            ),
1300        );
1301        aliases.insert(
1302            "alias".into(),
1303            key(
1304                "string",
1305                serde_json::json!(""),
1306                "The alias definition to execute",
1307            ),
1308        );
1309        sections.insert("custom_aliases".into(), SectionSchema {
1310            description: "Custom command aliases (array of {command, alias} entries). Note: field names are 'command' and 'alias' (not 'name')".into(),
1311            keys: aliases,
1312        });
1313
1314        if let Some(root_section) = sections.get_mut("root") {
1315            root_section.keys.insert(
1316                "custom_aliases".into(),
1317                key(
1318                    "array",
1319                    serde_json::json!([]),
1320                    "Custom command aliases (array of {command, alias} entries)",
1321                ),
1322            );
1323        }
1324
1325        let mut setup_keys = BTreeMap::new();
1326        setup_keys.insert(
1327            "auto_inject_rules".into(),
1328            key(
1329                "bool?",
1330                serde_json::json!(null),
1331                "Inject agent rule files during setup/update. null=auto (inject if already present), true=always, false=never",
1332            ),
1333        );
1334        setup_keys.insert(
1335            "auto_inject_skills".into(),
1336            key(
1337                "bool?",
1338                serde_json::json!(null),
1339                "Install SKILL.md files during setup/update. null=auto (install if rules present), true=always, false=never",
1340            ),
1341        );
1342        setup_keys.insert(
1343            "auto_update_mcp".into(),
1344            key(
1345                "bool",
1346                serde_json::json!(true),
1347                "Register lean-ctx MCP server in editor configs during setup/update",
1348            ),
1349        );
1350        sections.insert(
1351            "setup".into(),
1352            SectionSchema {
1353                description: "Controls what lean-ctx injects during setup and updates. Fresh installs default to non-invasive (rules/skills off, MCP on).".into(),
1354                keys: setup_keys,
1355            },
1356        );
1357
1358        let mut llm_keys = BTreeMap::new();
1359        llm_keys.insert(
1360            "enabled".into(),
1361            key(
1362                "bool",
1363                serde_json::json!(false),
1364                "Enable optional LLM enhancements (query expansion, contradiction explanation)",
1365            ),
1366        );
1367        llm_keys.insert(
1368            "backend".into(),
1369            key_enum(
1370                &["ollama", "openrouter", "anthropic"],
1371                "ollama",
1372                "LLM backend provider",
1373            ),
1374        );
1375        llm_keys.insert(
1376            "model".into(),
1377            key(
1378                "string",
1379                serde_json::json!("llama3.2"),
1380                "Model name for the selected backend",
1381            ),
1382        );
1383        llm_keys.insert(
1384            "api_key".into(),
1385            key(
1386                "string",
1387                serde_json::json!(""),
1388                "API key for OpenRouter or Anthropic backends",
1389            ),
1390        );
1391        llm_keys.insert(
1392            "timeout_secs".into(),
1393            key(
1394                "u64",
1395                serde_json::json!(10),
1396                "HTTP timeout for LLM requests",
1397            ),
1398        );
1399        sections.insert("llm".into(), SectionSchema {
1400            description: "Optional LLM enhancement settings (query expansion, contradiction explanation). Deterministic fallback when disabled or unreachable.".into(),
1401            keys: llm_keys,
1402        });
1403
1404        ConfigSchema {
1405            version: 1,
1406            sections,
1407        }
1408    }
1409
1410    /// Looks up a key schema by its dot-separated TOML path.
1411    /// Returns `None` if the key is not part of the schema.
1412    pub fn lookup(&self, key: &str) -> Option<&KeySchema> {
1413        if let Some(dot_pos) = key.find('.') {
1414            let section = &key[..dot_pos];
1415            let field = &key[dot_pos + 1..];
1416            self.sections.get(section)?.keys.get(field)
1417        } else {
1418            self.sections.get("root")?.keys.get(key)
1419        }
1420    }
1421
1422    /// All known TOML keys (dot-separated) for validation.
1423    pub fn known_keys(&self) -> Vec<String> {
1424        let mut keys = Vec::new();
1425        for (section, schema) in &self.sections {
1426            if section == "root" {
1427                for key_name in schema.keys.keys() {
1428                    keys.push(key_name.clone());
1429                }
1430            } else {
1431                if schema.keys.is_empty() {
1432                    keys.push(section.clone());
1433                }
1434                for key_name in schema.keys.keys() {
1435                    keys.push(format!("{section}.{key_name}"));
1436                }
1437            }
1438        }
1439        keys
1440    }
1441}