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