Skip to main content

lean_ctx/core/config/
schema.rs

1//! Auto-generated config schema from `Config` struct metadata.
2//!
3//! Used by `lean-ctx config schema` to emit JSON and by
4//! `lean-ctx config validate` to check user config.toml files.
5
6use serde::Serialize;
7use std::collections::BTreeMap;
8
9#[derive(Debug, Clone, Serialize)]
10pub struct ConfigSchema {
11    pub version: u32,
12    pub sections: BTreeMap<String, SectionSchema>,
13}
14
15#[derive(Debug, Clone, Serialize)]
16pub struct SectionSchema {
17    pub description: String,
18    pub keys: BTreeMap<String, KeySchema>,
19}
20
21#[derive(Debug, Clone, Serialize)]
22pub struct KeySchema {
23    #[serde(rename = "type")]
24    pub ty: String,
25    pub default: serde_json::Value,
26    pub description: String,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub values: Option<Vec<String>>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub env_override: Option<String>,
31}
32
33fn clean_f32(v: f32) -> serde_json::Value {
34    let clean: f64 = format!("{v}").parse().unwrap_or(v as f64);
35    serde_json::json!(clean)
36}
37
38fn key(ty: &str, default: serde_json::Value, desc: &str) -> KeySchema {
39    KeySchema {
40        ty: ty.to_string(),
41        default,
42        description: desc.to_string(),
43        values: None,
44        env_override: None,
45    }
46}
47
48fn key_enum(values: &[&str], default: &str, desc: &str) -> KeySchema {
49    KeySchema {
50        ty: "enum".to_string(),
51        default: serde_json::Value::String(default.to_string()),
52        description: desc.to_string(),
53        values: Some(values.iter().map(ToString::to_string).collect()),
54        env_override: None,
55    }
56}
57
58fn key_with_env(ty: &str, default: serde_json::Value, desc: &str, env: &str) -> KeySchema {
59    KeySchema {
60        ty: ty.to_string(),
61        default,
62        description: desc.to_string(),
63        values: None,
64        env_override: Some(env.to_string()),
65    }
66}
67
68fn key_enum_with_env(values: &[&str], default: &str, desc: &str, env: &str) -> KeySchema {
69    KeySchema {
70        ty: "enum".to_string(),
71        default: serde_json::Value::String(default.to_string()),
72        description: desc.to_string(),
73        values: Some(values.iter().map(ToString::to_string).collect()),
74        env_override: Some(env.to_string()),
75    }
76}
77
78impl ConfigSchema {
79    pub fn generate() -> Self {
80        let cfg = super::Config::default();
81        let mut sections = BTreeMap::new();
82
83        let mut root = BTreeMap::new();
84        root.insert(
85            "ultra_compact".into(),
86            key(
87                "bool",
88                serde_json::json!(false),
89                "Legacy flag for maximum compression (use compression_level instead)",
90            ),
91        );
92        root.insert(
93            "tee_mode".into(),
94            key_enum(
95                &["never", "failures", "always"],
96                "failures",
97                "Controls when shell output is tee'd to disk for later retrieval",
98            ),
99        );
100        root.insert(
101            "output_density".into(),
102            key_enum_with_env(
103                &["normal", "terse", "ultra"],
104                "normal",
105                "Controls how dense/compact MCP tool output is formatted",
106                "LEAN_CTX_OUTPUT_DENSITY",
107            ),
108        );
109        root.insert(
110            "checkpoint_interval".into(),
111            key(
112                "u32",
113                serde_json::json!(cfg.checkpoint_interval),
114                "Session checkpoint interval in minutes",
115            ),
116        );
117        root.insert(
118            "excluded_commands".into(),
119            key(
120                "string[]",
121                serde_json::json!(cfg.excluded_commands),
122                "Commands to exclude from shell hook interception",
123            ),
124        );
125        root.insert(
126            "passthrough_urls".into(),
127            key(
128                "string[]",
129                serde_json::json!(cfg.passthrough_urls),
130                "URLs to pass through without proxy interception",
131            ),
132        );
133        root.insert("slow_command_threshold_ms".into(), key("u64", serde_json::json!(cfg.slow_command_threshold_ms), "Commands taking longer than this (ms) are recorded in the slow log. Set to 0 to disable"));
134        root.insert(
135            "theme".into(),
136            key(
137                "string",
138                serde_json::json!(cfg.theme),
139                "Dashboard color theme",
140            ),
141        );
142        root.insert(
143            "buddy_enabled".into(),
144            key(
145                "bool",
146                serde_json::json!(cfg.buddy_enabled),
147                "Enable the buddy system for multi-agent coordination",
148            ),
149        );
150        root.insert(
151            "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    /// All known TOML keys (dot-separated) for validation.
797    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}