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 "redirect_exclude".into(),
152 key(
153 "string[]",
154 serde_json::json!(cfg.redirect_exclude),
155 "URL patterns to exclude from proxy redirection",
156 ),
157 );
158 root.insert(
159 "disabled_tools".into(),
160 key(
161 "string[]",
162 serde_json::json!(cfg.disabled_tools),
163 "Tools to exclude from the MCP tool list",
164 ),
165 );
166 root.insert(
167 "rules_scope".into(),
168 key_enum(
169 &["both", "global", "project"],
170 "both",
171 "Where agent rule files are installed. Override via LEAN_CTX_RULES_SCOPE",
172 ),
173 );
174 root.insert(
175 "extra_ignore_patterns".into(),
176 key(
177 "string[]",
178 serde_json::json!(cfg.extra_ignore_patterns),
179 "Extra glob patterns to ignore in graph/overview/preload",
180 ),
181 );
182 root.insert(
183 "terse_agent".into(),
184 key_enum_with_env(
185 &["off", "lite", "full", "ultra"],
186 "off",
187 "Controls agent output verbosity via instructions injection",
188 "LEAN_CTX_TERSE_AGENT",
189 ),
190 );
191 root.insert(
192 "compression_level".into(),
193 key_enum_with_env(
194 &["off", "lite", "standard", "max"],
195 "off",
196 "Unified compression level for all output",
197 "LEAN_CTX_COMPRESSION",
198 ),
199 );
200 root.insert(
201 "allow_paths".into(),
202 key_with_env(
203 "string[]",
204 serde_json::json!(cfg.allow_paths),
205 "Additional paths allowed by PathJail (absolute)",
206 "LEAN_CTX_ALLOW_PATH",
207 ),
208 );
209 root.insert(
210 "content_defined_chunking".into(),
211 key(
212 "bool",
213 serde_json::json!(false),
214 "Enable Rabin-Karp chunking for cache-optimal output ordering",
215 ),
216 );
217 root.insert(
218 "minimal_overhead".into(),
219 key_with_env(
220 "bool",
221 serde_json::json!(false),
222 "Skip session/knowledge/gotcha blocks in MCP instructions",
223 "LEAN_CTX_MINIMAL",
224 ),
225 );
226 root.insert(
227 "shell_hook_disabled".into(),
228 key_with_env(
229 "bool",
230 serde_json::json!(false),
231 "Disable shell hook injection",
232 "LEAN_CTX_NO_HOOK",
233 ),
234 );
235 root.insert(
236 "shell_activation".into(),
237 key_enum_with_env(
238 &["always", "agents-only", "off"],
239 "always",
240 "Controls when the shell hook auto-activates aliases",
241 "LEAN_CTX_SHELL_ACTIVATION",
242 ),
243 );
244 root.insert(
245 "update_check_disabled".into(),
246 key_with_env(
247 "bool",
248 serde_json::json!(false),
249 "Disable the daily version check",
250 "LEAN_CTX_NO_UPDATE_CHECK",
251 ),
252 );
253 root.insert(
254 "bm25_max_cache_mb".into(),
255 key_with_env(
256 "u64",
257 serde_json::json!(cfg.bm25_max_cache_mb),
258 "Maximum BM25 cache file size in MB",
259 "LEAN_CTX_BM25_MAX_CACHE_MB",
260 ),
261 );
262 root.insert(
263 "graph_index_max_files".into(),
264 key(
265 "u64",
266 serde_json::json!(cfg.graph_index_max_files),
267 "Maximum files scanned by the JSON graph index. Increase for large monorepos",
268 ),
269 );
270 root.insert(
271 "memory_profile".into(),
272 key_enum_with_env(
273 &["low", "balanced", "performance"],
274 "balanced",
275 "Controls RAM vs feature trade-off",
276 "LEAN_CTX_MEMORY_PROFILE",
277 ),
278 );
279 root.insert(
280 "memory_cleanup".into(),
281 key_enum_with_env(
282 &["aggressive", "shared"],
283 "aggressive",
284 "Controls how aggressively memory is freed when idle",
285 "LEAN_CTX_MEMORY_CLEANUP",
286 ),
287 );
288 sections.insert(
289 "root".into(),
290 SectionSchema {
291 description: "Top-level configuration keys".into(),
292 keys: root,
293 },
294 );
295
296 let mut archive = BTreeMap::new();
297 archive.insert(
298 "enabled".into(),
299 key(
300 "bool",
301 serde_json::json!(cfg.archive.enabled),
302 "Enable zero-loss compression archive",
303 ),
304 );
305 archive.insert(
306 "threshold_chars".into(),
307 key(
308 "usize",
309 serde_json::json!(cfg.archive.threshold_chars),
310 "Minimum output size (chars) to trigger archiving",
311 ),
312 );
313 archive.insert(
314 "max_age_hours".into(),
315 key(
316 "u64",
317 serde_json::json!(cfg.archive.max_age_hours),
318 "Maximum age of archived entries before cleanup",
319 ),
320 );
321 archive.insert(
322 "max_disk_mb".into(),
323 key(
324 "u64",
325 serde_json::json!(cfg.archive.max_disk_mb),
326 "Maximum total disk usage for the archive",
327 ),
328 );
329 sections.insert("archive".into(), SectionSchema {
330 description: "Settings for the zero-loss compression archive (large tool outputs saved to disk)".into(),
331 keys: archive,
332 });
333
334 let mut autonomy = BTreeMap::new();
335 autonomy.insert(
336 "enabled".into(),
337 key(
338 "bool",
339 serde_json::json!(cfg.autonomy.enabled),
340 "Enable autonomous background behaviors",
341 ),
342 );
343 autonomy.insert(
344 "auto_preload".into(),
345 key(
346 "bool",
347 serde_json::json!(cfg.autonomy.auto_preload),
348 "Auto-preload related files on first read",
349 ),
350 );
351 autonomy.insert(
352 "auto_dedup".into(),
353 key(
354 "bool",
355 serde_json::json!(cfg.autonomy.auto_dedup),
356 "Auto-deduplicate repeated reads",
357 ),
358 );
359 autonomy.insert(
360 "auto_related".into(),
361 key(
362 "bool",
363 serde_json::json!(cfg.autonomy.auto_related),
364 "Auto-load graph-related files",
365 ),
366 );
367 autonomy.insert(
368 "auto_consolidate".into(),
369 key(
370 "bool",
371 serde_json::json!(cfg.autonomy.auto_consolidate),
372 "Auto-consolidate knowledge periodically",
373 ),
374 );
375 autonomy.insert(
376 "silent_preload".into(),
377 key(
378 "bool",
379 serde_json::json!(cfg.autonomy.silent_preload),
380 "Suppress preload notifications in output",
381 ),
382 );
383 autonomy.insert(
384 "dedup_threshold".into(),
385 key(
386 "usize",
387 serde_json::json!(cfg.autonomy.dedup_threshold),
388 "Number of repeated reads before dedup triggers",
389 ),
390 );
391 autonomy.insert(
392 "consolidate_every_calls".into(),
393 key(
394 "u32",
395 serde_json::json!(cfg.autonomy.consolidate_every_calls),
396 "Consolidate knowledge every N tool calls",
397 ),
398 );
399 autonomy.insert(
400 "consolidate_cooldown_secs".into(),
401 key(
402 "u64",
403 serde_json::json!(cfg.autonomy.consolidate_cooldown_secs),
404 "Minimum seconds between consolidation runs",
405 ),
406 );
407 sections.insert(
408 "autonomy".into(),
409 SectionSchema {
410 description:
411 "Controls autonomous background behaviors (preload, dedup, consolidation)"
412 .into(),
413 keys: autonomy,
414 },
415 );
416
417 let mut loop_det = BTreeMap::new();
418 loop_det.insert(
419 "normal_threshold".into(),
420 key(
421 "u32",
422 serde_json::json!(cfg.loop_detection.normal_threshold),
423 "Repetitions before reducing output",
424 ),
425 );
426 loop_det.insert(
427 "reduced_threshold".into(),
428 key(
429 "u32",
430 serde_json::json!(cfg.loop_detection.reduced_threshold),
431 "Repetitions before further reducing output",
432 ),
433 );
434 loop_det.insert(
435 "blocked_threshold".into(),
436 key(
437 "u32",
438 serde_json::json!(cfg.loop_detection.blocked_threshold),
439 "Repetitions before blocking. 0 = disabled",
440 ),
441 );
442 loop_det.insert(
443 "window_secs".into(),
444 key(
445 "u64",
446 serde_json::json!(cfg.loop_detection.window_secs),
447 "Time window in seconds for loop detection",
448 ),
449 );
450 loop_det.insert(
451 "search_group_limit".into(),
452 key(
453 "u32",
454 serde_json::json!(cfg.loop_detection.search_group_limit),
455 "Maximum unique searches within a loop window",
456 ),
457 );
458 sections.insert(
459 "loop_detection".into(),
460 SectionSchema {
461 description: "Loop detection settings for preventing repeated identical tool calls"
462 .into(),
463 keys: loop_det,
464 },
465 );
466
467 let mut cloud = BTreeMap::new();
468 cloud.insert(
469 "contribute_enabled".into(),
470 key(
471 "bool",
472 serde_json::json!(cfg.cloud.contribute_enabled),
473 "Enable contributing anonymized stats to lean-ctx cloud",
474 ),
475 );
476 sections.insert(
477 "cloud".into(),
478 SectionSchema {
479 description: "Cloud feature settings".into(),
480 keys: cloud,
481 },
482 );
483
484 let mut proxy = BTreeMap::new();
485 proxy.insert(
486 "anthropic_upstream".into(),
487 key(
488 "string?",
489 serde_json::json!(cfg.proxy.anthropic_upstream),
490 "Custom upstream URL for Anthropic API proxy",
491 ),
492 );
493 proxy.insert(
494 "openai_upstream".into(),
495 key(
496 "string?",
497 serde_json::json!(cfg.proxy.openai_upstream),
498 "Custom upstream URL for OpenAI API proxy",
499 ),
500 );
501 proxy.insert(
502 "gemini_upstream".into(),
503 key(
504 "string?",
505 serde_json::json!(cfg.proxy.gemini_upstream),
506 "Custom upstream URL for Gemini API proxy",
507 ),
508 );
509 sections.insert(
510 "proxy".into(),
511 SectionSchema {
512 description: "Proxy upstream configuration for API routing".into(),
513 keys: proxy,
514 },
515 );
516
517 let mem = &cfg.memory;
518 let mut mem_knowledge = BTreeMap::new();
519 mem_knowledge.insert(
520 "max_facts".into(),
521 key(
522 "usize",
523 serde_json::json!(mem.knowledge.max_facts),
524 "Maximum number of knowledge facts stored per project",
525 ),
526 );
527 mem_knowledge.insert(
528 "max_patterns".into(),
529 key(
530 "usize",
531 serde_json::json!(mem.knowledge.max_patterns),
532 "Maximum number of patterns stored",
533 ),
534 );
535 mem_knowledge.insert(
536 "max_history".into(),
537 key(
538 "usize",
539 serde_json::json!(mem.knowledge.max_history),
540 "Maximum history entries retained",
541 ),
542 );
543 mem_knowledge.insert(
544 "contradiction_threshold".into(),
545 key(
546 "f32",
547 clean_f32(mem.knowledge.contradiction_threshold),
548 "Confidence threshold for contradiction detection",
549 ),
550 );
551 mem_knowledge.insert(
552 "recall_facts_limit".into(),
553 key(
554 "usize",
555 serde_json::json!(mem.knowledge.recall_facts_limit),
556 "Maximum facts returned per recall query",
557 ),
558 );
559 mem_knowledge.insert(
560 "rooms_limit".into(),
561 key(
562 "usize",
563 serde_json::json!(mem.knowledge.rooms_limit),
564 "Maximum number of rooms returned",
565 ),
566 );
567 mem_knowledge.insert(
568 "timeline_limit".into(),
569 key(
570 "usize",
571 serde_json::json!(mem.knowledge.timeline_limit),
572 "Maximum number of timeline entries returned",
573 ),
574 );
575 mem_knowledge.insert(
576 "relations_limit".into(),
577 key(
578 "usize",
579 serde_json::json!(mem.knowledge.relations_limit),
580 "Maximum number of relations returned",
581 ),
582 );
583 sections.insert(
584 "memory.knowledge".into(),
585 SectionSchema {
586 description: "Knowledge memory budgets (facts, patterns, gotchas)".into(),
587 keys: mem_knowledge,
588 },
589 );
590
591 let mut mem_episodic = BTreeMap::new();
592 mem_episodic.insert(
593 "max_episodes".into(),
594 key(
595 "usize",
596 serde_json::json!(mem.episodic.max_episodes),
597 "Maximum number of episodes retained",
598 ),
599 );
600 mem_episodic.insert(
601 "max_actions_per_episode".into(),
602 key(
603 "usize",
604 serde_json::json!(mem.episodic.max_actions_per_episode),
605 "Maximum actions tracked per episode",
606 ),
607 );
608 mem_episodic.insert(
609 "summary_max_chars".into(),
610 key(
611 "usize",
612 serde_json::json!(mem.episodic.summary_max_chars),
613 "Maximum characters in episode summary",
614 ),
615 );
616 sections.insert(
617 "memory.episodic".into(),
618 SectionSchema {
619 description: "Episodic memory budgets (session episodes)".into(),
620 keys: mem_episodic,
621 },
622 );
623
624 let mut mem_procedural = BTreeMap::new();
625 mem_procedural.insert(
626 "max_procedures".into(),
627 key(
628 "usize",
629 serde_json::json!(mem.procedural.max_procedures),
630 "Maximum number of learned procedures stored",
631 ),
632 );
633 mem_procedural.insert(
634 "min_repetitions".into(),
635 key(
636 "usize",
637 serde_json::json!(mem.procedural.min_repetitions),
638 "Minimum repetitions before a pattern is stored",
639 ),
640 );
641 mem_procedural.insert(
642 "min_sequence_len".into(),
643 key(
644 "usize",
645 serde_json::json!(mem.procedural.min_sequence_len),
646 "Minimum sequence length for procedure detection",
647 ),
648 );
649 mem_procedural.insert(
650 "max_window_size".into(),
651 key(
652 "usize",
653 serde_json::json!(mem.procedural.max_window_size),
654 "Maximum window size for pattern analysis",
655 ),
656 );
657 sections.insert(
658 "memory.procedural".into(),
659 SectionSchema {
660 description: "Procedural memory budgets (learned patterns)".into(),
661 keys: mem_procedural,
662 },
663 );
664
665 let mut mem_lifecycle = BTreeMap::new();
666 mem_lifecycle.insert(
667 "decay_rate".into(),
668 key(
669 "f32",
670 clean_f32(mem.lifecycle.decay_rate),
671 "Rate at which knowledge confidence decays over time",
672 ),
673 );
674 mem_lifecycle.insert(
675 "low_confidence_threshold".into(),
676 key(
677 "f32",
678 clean_f32(mem.lifecycle.low_confidence_threshold),
679 "Threshold below which facts are considered low-confidence",
680 ),
681 );
682 mem_lifecycle.insert(
683 "stale_days".into(),
684 key(
685 "i64",
686 serde_json::json!(mem.lifecycle.stale_days),
687 "Days after which unused facts are considered stale",
688 ),
689 );
690 mem_lifecycle.insert(
691 "similarity_threshold".into(),
692 key(
693 "f32",
694 clean_f32(mem.lifecycle.similarity_threshold),
695 "Similarity threshold for deduplication",
696 ),
697 );
698 sections.insert(
699 "memory.lifecycle".into(),
700 SectionSchema {
701 description: "Knowledge lifecycle policy (decay, staleness, dedup)".into(),
702 keys: mem_lifecycle,
703 },
704 );
705
706 let mut mem_gotcha = BTreeMap::new();
707 mem_gotcha.insert(
708 "max_gotchas_per_project".into(),
709 key(
710 "usize",
711 serde_json::json!(mem.gotcha.max_gotchas_per_project),
712 "Maximum gotchas stored per project",
713 ),
714 );
715 mem_gotcha.insert(
716 "retrieval_budget_per_room".into(),
717 key(
718 "usize",
719 serde_json::json!(mem.gotcha.retrieval_budget_per_room),
720 "Maximum gotchas retrieved per room per query",
721 ),
722 );
723 mem_gotcha.insert(
724 "default_decay_rate".into(),
725 key(
726 "f32",
727 clean_f32(mem.gotcha.default_decay_rate),
728 "Default decay rate for gotcha importance",
729 ),
730 );
731 sections.insert(
732 "memory.gotcha".into(),
733 SectionSchema {
734 description: "Gotcha memory settings (project-specific warnings and pitfalls)"
735 .into(),
736 keys: mem_gotcha,
737 },
738 );
739
740 let mut mem_embeddings = BTreeMap::new();
741 mem_embeddings.insert(
742 "max_facts".into(),
743 key(
744 "usize",
745 serde_json::json!(mem.embeddings.max_facts),
746 "Maximum number of embedding facts stored",
747 ),
748 );
749 sections.insert(
750 "memory.embeddings".into(),
751 SectionSchema {
752 description: "Embeddings memory settings for semantic search".into(),
753 keys: mem_embeddings,
754 },
755 );
756
757 let mut aliases = BTreeMap::new();
758 aliases.insert(
759 "command".into(),
760 key(
761 "string",
762 serde_json::json!(""),
763 "The command pattern to match (e.g. 'deploy')",
764 ),
765 );
766 aliases.insert(
767 "alias".into(),
768 key(
769 "string",
770 serde_json::json!(""),
771 "The alias definition to execute",
772 ),
773 );
774 sections.insert("custom_aliases".into(), SectionSchema {
775 description: "Custom command aliases (array of {command, alias} entries). Note: field names are 'command' and 'alias' (not 'name')".into(),
776 keys: aliases,
777 });
778
779 if let Some(root_section) = sections.get_mut("root") {
780 root_section.keys.insert(
781 "custom_aliases".into(),
782 key(
783 "array",
784 serde_json::json!([]),
785 "Custom command aliases (array of {command, alias} entries)",
786 ),
787 );
788 }
789
790 ConfigSchema {
791 version: 1,
792 sections,
793 }
794 }
795
796 pub fn known_keys(&self) -> Vec<String> {
798 let mut keys = Vec::new();
799 for (section, schema) in &self.sections {
800 for key_name in schema.keys.keys() {
801 if section == "root" {
802 keys.push(key_name.clone());
803 } else {
804 keys.push(format!("{section}.{key_name}"));
805 }
806 }
807 }
808 keys
809 }
810}