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 "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 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 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}