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