1use serde::{Deserialize, Serialize};
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23use std::sync::RwLock;
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Profile {
28 #[serde(default)]
29 pub profile: ProfileMeta,
30 #[serde(default)]
31 pub read: ReadConfig,
32 #[serde(default)]
33 pub compression: CompressionConfig,
34 #[serde(default)]
35 pub translation: TranslationConfig,
36 #[serde(default)]
37 pub layout: LayoutConfig,
38 #[serde(default)]
39 pub memory: crate::core::memory_policy::MemoryPolicyOverrides,
40 #[serde(default)]
41 pub verification: crate::core::output_verification::VerificationConfig,
42 #[serde(default)]
43 pub budget: BudgetConfig,
44 #[serde(default)]
45 pub pipeline: PipelineConfig,
46 #[serde(default)]
47 pub routing: RoutingConfig,
48 #[serde(default)]
49 pub degradation: DegradationConfig,
50 #[serde(default)]
51 pub autonomy: ProfileAutonomy,
52 #[serde(default)]
53 pub output_hints: OutputHints,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, Default)]
58pub struct ProfileMeta {
59 #[serde(default)]
60 pub name: String,
61 pub inherits: Option<String>,
62 #[serde(default)]
63 pub description: String,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, Default)]
71#[serde(default)]
72pub struct ReadConfig {
73 pub default_mode: Option<String>,
74 pub max_tokens_per_file: Option<usize>,
75 pub prefer_cache: Option<bool>,
76}
77
78impl ReadConfig {
79 pub fn default_mode_effective(&self) -> &str {
80 self.default_mode.as_deref().unwrap_or("auto")
81 }
82 pub fn max_tokens_per_file_effective(&self) -> usize {
83 self.max_tokens_per_file.unwrap_or(50_000)
84 }
85 pub fn prefer_cache_effective(&self) -> bool {
86 self.prefer_cache.unwrap_or(false)
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, Default)]
92#[serde(default)]
93pub struct CompressionConfig {
94 pub crp_mode: Option<String>,
95 pub output_density: Option<String>,
96 pub entropy_threshold: Option<f64>,
97 pub terse_mode: Option<bool>,
98}
99
100impl CompressionConfig {
101 pub fn crp_mode_effective(&self) -> &str {
102 self.crp_mode.as_deref().unwrap_or("tdd")
103 }
104 pub fn output_density_effective(&self) -> &str {
105 self.output_density.as_deref().unwrap_or("normal")
106 }
107 pub fn entropy_threshold_effective(&self) -> f64 {
108 self.entropy_threshold.unwrap_or(0.3)
109 }
110 pub fn terse_mode_effective(&self) -> bool {
111 self.terse_mode.unwrap_or(false)
112 }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, Default)]
117#[serde(default)]
118pub struct TranslationConfig {
119 pub enabled: Option<bool>,
121 pub ruleset: Option<String>,
123}
124
125impl TranslationConfig {
126 pub fn enabled_effective(&self) -> bool {
127 self.enabled.unwrap_or(false)
128 }
129 pub fn ruleset_effective(&self) -> &str {
130 self.ruleset.as_deref().unwrap_or("legacy")
131 }
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, Default)]
136#[serde(default)]
137pub struct LayoutConfig {
138 pub enabled: Option<bool>,
140 pub min_lines: Option<usize>,
142}
143
144impl LayoutConfig {
145 pub fn enabled_effective(&self) -> bool {
146 self.enabled.unwrap_or(false)
147 }
148 pub fn min_lines_effective(&self) -> usize {
149 self.min_lines.unwrap_or(15)
150 }
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, Default)]
155pub struct RoutingConfig {
156 #[serde(default)]
158 pub max_model_tier: Option<String>,
159 #[serde(default)]
161 pub degrade_under_pressure: Option<bool>,
162}
163
164impl RoutingConfig {
165 pub fn max_model_tier_effective(&self) -> &str {
166 self.max_model_tier.as_deref().unwrap_or("premium")
167 }
168
169 pub fn degrade_under_pressure_effective(&self) -> bool {
170 self.degrade_under_pressure.unwrap_or(true)
171 }
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, Default)]
176pub struct DegradationConfig {
177 #[serde(default)]
179 pub enforce: Option<bool>,
180 #[serde(default)]
182 pub throttle_ms: Option<u64>,
183}
184
185impl DegradationConfig {
186 pub fn enforce_effective(&self) -> bool {
187 self.enforce.unwrap_or(false)
188 }
189
190 pub fn throttle_ms_effective(&self) -> u64 {
191 self.throttle_ms.unwrap_or(250)
192 }
193}
194
195#[derive(Debug, Clone, Default, Serialize, Deserialize)]
198#[serde(default)]
199pub struct OutputHints {
200 pub compressed_hint: Option<bool>,
201 pub archive_hint: Option<bool>,
202 pub verify_footer: Option<bool>,
203 pub related_hint: Option<bool>,
204 pub semantic_hint: Option<bool>,
205 pub elicitation_hint: Option<bool>,
206 pub checkpoint_in_output: Option<bool>,
207 pub graph_context_block: Option<bool>,
208 pub efficiency_hint: Option<bool>,
209}
210
211impl OutputHints {
212 pub fn compressed_hint(&self) -> bool {
213 self.compressed_hint.unwrap_or(false)
214 }
215 pub fn archive_hint(&self) -> bool {
216 self.archive_hint.unwrap_or(false)
217 }
218 pub fn verify_footer(&self) -> bool {
219 self.verify_footer.unwrap_or(false)
220 }
221 pub fn related_hint(&self) -> bool {
222 self.related_hint.unwrap_or(false)
223 }
224 pub fn semantic_hint(&self) -> bool {
225 self.semantic_hint.unwrap_or(false)
226 }
227 pub fn elicitation_hint(&self) -> bool {
228 self.elicitation_hint.unwrap_or(false)
229 }
230 pub fn checkpoint_in_output(&self) -> bool {
231 self.checkpoint_in_output.unwrap_or(false)
232 }
233 pub fn graph_context_block(&self) -> bool {
234 self.graph_context_block.unwrap_or(false)
235 }
236 pub fn efficiency_hint(&self) -> bool {
237 self.efficiency_hint.unwrap_or(false)
238 }
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize, Default)]
243#[serde(default)]
244pub struct BudgetConfig {
245 pub max_context_tokens: Option<usize>,
246 pub max_shell_invocations: Option<usize>,
247 pub max_cost_usd: Option<f64>,
248}
249
250impl BudgetConfig {
251 pub fn max_context_tokens_effective(&self) -> usize {
252 self.max_context_tokens.unwrap_or(200_000)
253 }
254 pub fn max_shell_invocations_effective(&self) -> usize {
255 self.max_shell_invocations.unwrap_or(100)
256 }
257 pub fn max_cost_usd_effective(&self) -> f64 {
258 self.max_cost_usd.unwrap_or(5.0)
259 }
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, Default)]
264#[serde(default)]
265pub struct PipelineConfig {
266 pub intent: Option<bool>,
267 pub relevance: Option<bool>,
268 pub compression: Option<bool>,
269 pub translation: Option<bool>,
270}
271
272impl PipelineConfig {
273 pub fn intent_effective(&self) -> bool {
274 self.intent.unwrap_or(true)
275 }
276 pub fn relevance_effective(&self) -> bool {
277 self.relevance.unwrap_or(true)
278 }
279 pub fn compression_effective(&self) -> bool {
280 self.compression.unwrap_or(true)
281 }
282 pub fn translation_effective(&self) -> bool {
283 self.translation.unwrap_or(true)
284 }
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize, Default)]
289#[serde(default)]
290pub struct ProfileAutonomy {
291 pub enabled: Option<bool>,
292 pub auto_preload: Option<bool>,
293 pub auto_dedup: Option<bool>,
294 pub auto_related: Option<bool>,
295 pub silent_preload: Option<bool>,
296 pub auto_prefetch: Option<bool>,
298 pub auto_response: Option<bool>,
300 pub dedup_threshold: Option<usize>,
301 pub prefetch_max_files: Option<usize>,
302 pub prefetch_budget_tokens: Option<usize>,
303 pub response_min_tokens: Option<usize>,
304 pub checkpoint_interval: Option<u32>,
305}
306
307impl ProfileAutonomy {
308 pub fn enabled_effective(&self) -> bool {
309 self.enabled.unwrap_or(true)
310 }
311 pub fn auto_preload_effective(&self) -> bool {
312 self.auto_preload.unwrap_or(true)
313 }
314 pub fn auto_dedup_effective(&self) -> bool {
315 self.auto_dedup.unwrap_or(true)
316 }
317 pub fn auto_related_effective(&self) -> bool {
318 self.auto_related.unwrap_or(true)
319 }
320 pub fn silent_preload_effective(&self) -> bool {
321 self.silent_preload.unwrap_or(true)
322 }
323 pub fn auto_prefetch_effective(&self) -> bool {
324 self.auto_prefetch.unwrap_or(false)
325 }
326 pub fn auto_response_effective(&self) -> bool {
327 self.auto_response.unwrap_or(false)
328 }
329 pub fn dedup_threshold_effective(&self) -> usize {
330 self.dedup_threshold.unwrap_or(8)
331 }
332 pub fn prefetch_max_files_effective(&self) -> usize {
333 self.prefetch_max_files.unwrap_or(3)
334 }
335 pub fn prefetch_budget_tokens_effective(&self) -> usize {
336 self.prefetch_budget_tokens.unwrap_or(4000)
337 }
338 pub fn response_min_tokens_effective(&self) -> usize {
339 self.response_min_tokens.unwrap_or(600)
340 }
341 pub fn checkpoint_interval_effective(&self) -> u32 {
342 self.checkpoint_interval.unwrap_or(15)
343 }
344}
345
346fn builtin_coder() -> Profile {
349 Profile {
350 profile: ProfileMeta {
351 name: "coder".to_string(),
352 inherits: None,
353 description: "Default coding workflow with guarded autonomy drivers".to_string(),
354 },
355 read: ReadConfig {
356 default_mode: Some("auto".to_string()),
357 max_tokens_per_file: Some(50_000),
358 prefer_cache: Some(true),
359 },
360 compression: CompressionConfig {
361 crp_mode: Some("tdd".to_string()),
362 output_density: Some("terse".to_string()),
363 terse_mode: Some(true),
364 ..CompressionConfig::default()
365 },
366 translation: TranslationConfig {
367 enabled: Some(true),
368 ruleset: Some("auto".to_string()),
369 },
370 layout: LayoutConfig::default(),
371 memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
372 verification: crate::core::output_verification::VerificationConfig::default(),
373 budget: BudgetConfig {
374 max_context_tokens: Some(150_000),
375 max_shell_invocations: Some(100),
376 ..BudgetConfig::default()
377 },
378 pipeline: PipelineConfig::default(),
379 routing: RoutingConfig::default(),
380 degradation: DegradationConfig::default(),
381 autonomy: ProfileAutonomy {
382 auto_prefetch: Some(true),
383 auto_response: Some(true),
384 checkpoint_interval: Some(10),
385 ..ProfileAutonomy::default()
386 },
387 output_hints: OutputHints::default(),
388 }
389}
390
391fn builtin_exploration() -> Profile {
392 Profile {
393 profile: ProfileMeta {
394 name: "exploration".to_string(),
395 inherits: None,
396 description: "Broad context for understanding codebases".to_string(),
397 },
398 read: ReadConfig {
399 default_mode: Some("map".to_string()),
400 max_tokens_per_file: Some(80_000),
401 prefer_cache: Some(true),
402 },
403 compression: CompressionConfig {
404 terse_mode: Some(true),
405 output_density: Some("terse".to_string()),
406 ..CompressionConfig::default()
407 },
408 translation: TranslationConfig::default(),
409 layout: LayoutConfig::default(),
410 memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
411 verification: crate::core::output_verification::VerificationConfig::default(),
412 budget: BudgetConfig {
413 max_context_tokens: Some(200_000),
414 ..BudgetConfig::default()
415 },
416 pipeline: PipelineConfig::default(),
417 routing: RoutingConfig::default(),
418 degradation: DegradationConfig::default(),
419 autonomy: ProfileAutonomy::default(),
420 output_hints: OutputHints {
421 related_hint: Some(true),
422 compressed_hint: Some(true),
423 ..OutputHints::default()
424 },
425 }
426}
427
428fn builtin_bugfix() -> Profile {
429 Profile {
430 profile: ProfileMeta {
431 name: "bugfix".to_string(),
432 inherits: None,
433 description: "Focused context for debugging specific issues".to_string(),
434 },
435 read: ReadConfig {
436 default_mode: Some("auto".to_string()),
437 max_tokens_per_file: Some(30_000),
438 prefer_cache: Some(false),
439 },
440 compression: CompressionConfig {
441 crp_mode: Some("tdd".to_string()),
442 output_density: Some("terse".to_string()),
443 ..CompressionConfig::default()
444 },
445 translation: TranslationConfig::default(),
446 layout: LayoutConfig::default(),
447 memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
448 verification: crate::core::output_verification::VerificationConfig::default(),
449 budget: BudgetConfig {
450 max_context_tokens: Some(100_000),
451 max_shell_invocations: Some(50),
452 ..BudgetConfig::default()
453 },
454 pipeline: PipelineConfig::default(),
455 routing: RoutingConfig {
456 max_model_tier: Some("standard".to_string()),
457 ..RoutingConfig::default()
458 },
459 degradation: DegradationConfig::default(),
460 autonomy: ProfileAutonomy {
461 checkpoint_interval: Some(10),
462 ..ProfileAutonomy::default()
463 },
464 output_hints: OutputHints::default(),
465 }
466}
467
468fn builtin_hotfix() -> Profile {
469 Profile {
470 profile: ProfileMeta {
471 name: "hotfix".to_string(),
472 inherits: None,
473 description: "Minimal context, fast iteration for urgent fixes".to_string(),
474 },
475 read: ReadConfig {
476 default_mode: Some("signatures".to_string()),
477 max_tokens_per_file: Some(2_000),
478 prefer_cache: Some(true),
479 },
480 compression: CompressionConfig {
481 crp_mode: Some("tdd".to_string()),
482 output_density: Some("ultra".to_string()),
483 ..CompressionConfig::default()
484 },
485 translation: TranslationConfig::default(),
486 layout: LayoutConfig::default(),
487 memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
488 verification: crate::core::output_verification::VerificationConfig::default(),
489 budget: BudgetConfig {
490 max_context_tokens: Some(30_000),
491 max_shell_invocations: Some(20),
492 max_cost_usd: Some(1.0),
493 },
494 pipeline: PipelineConfig::default(),
495 routing: RoutingConfig {
496 max_model_tier: Some("fast".to_string()),
497 ..RoutingConfig::default()
498 },
499 degradation: DegradationConfig::default(),
500 autonomy: ProfileAutonomy {
501 checkpoint_interval: Some(5),
502 ..ProfileAutonomy::default()
503 },
504 output_hints: OutputHints::default(),
505 }
506}
507
508fn builtin_ci_debug() -> Profile {
509 Profile {
510 profile: ProfileMeta {
511 name: "ci-debug".to_string(),
512 inherits: None,
513 description: "CI/CD debugging with shell-heavy workflows".to_string(),
514 },
515 read: ReadConfig {
516 default_mode: Some("auto".to_string()),
517 max_tokens_per_file: Some(50_000),
518 prefer_cache: Some(false),
519 },
520 compression: CompressionConfig {
521 output_density: Some("terse".to_string()),
522 ..CompressionConfig::default()
523 },
524 translation: TranslationConfig::default(),
525 layout: LayoutConfig::default(),
526 memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
527 verification: crate::core::output_verification::VerificationConfig::default(),
528 budget: BudgetConfig {
529 max_context_tokens: Some(150_000),
530 max_shell_invocations: Some(200),
531 ..BudgetConfig::default()
532 },
533 pipeline: PipelineConfig::default(),
534 routing: RoutingConfig {
535 max_model_tier: Some("standard".to_string()),
536 ..RoutingConfig::default()
537 },
538 degradation: DegradationConfig::default(),
539 autonomy: ProfileAutonomy::default(),
540 output_hints: OutputHints::default(),
541 }
542}
543
544fn builtin_review() -> Profile {
545 Profile {
546 profile: ProfileMeta {
547 name: "review".to_string(),
548 inherits: None,
549 description: "Code review with broad read-only context".to_string(),
550 },
551 read: ReadConfig {
552 default_mode: Some("map".to_string()),
553 max_tokens_per_file: Some(60_000),
554 prefer_cache: Some(true),
555 },
556 compression: CompressionConfig {
557 crp_mode: Some("compact".to_string()),
558 ..CompressionConfig::default()
559 },
560 translation: TranslationConfig::default(),
561 layout: LayoutConfig {
562 enabled: Some(true),
563 ..LayoutConfig::default()
564 },
565 memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
566 verification: crate::core::output_verification::VerificationConfig::default(),
567 budget: BudgetConfig {
568 max_context_tokens: Some(150_000),
569 max_shell_invocations: Some(30),
570 ..BudgetConfig::default()
571 },
572 pipeline: PipelineConfig::default(),
573 routing: RoutingConfig {
574 max_model_tier: Some("standard".to_string()),
575 ..RoutingConfig::default()
576 },
577 degradation: DegradationConfig::default(),
578 autonomy: ProfileAutonomy::default(),
579 output_hints: OutputHints {
580 verify_footer: Some(true),
581 related_hint: Some(true),
582 compressed_hint: Some(true),
583 ..OutputHints::default()
584 },
585 }
586}
587
588fn builtin_passthrough() -> Profile {
589 Profile {
590 profile: ProfileMeta {
591 name: "passthrough".to_string(),
592 inherits: None,
593 description: "No output modification — always full content, no compression".to_string(),
594 },
595 read: ReadConfig {
596 default_mode: Some("full".to_string()),
597 max_tokens_per_file: Some(10_000_000),
598 prefer_cache: Some(false),
599 },
600 compression: CompressionConfig {
601 crp_mode: Some("off".to_string()),
602 output_density: Some("normal".to_string()),
603 entropy_threshold: None,
604 terse_mode: Some(false),
605 },
606 translation: TranslationConfig {
607 enabled: Some(false),
608 ..TranslationConfig::default()
609 },
610 layout: LayoutConfig::default(),
611 memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
612 verification: crate::core::output_verification::VerificationConfig::default(),
613 budget: BudgetConfig {
614 max_context_tokens: Some(1_000_000),
615 ..BudgetConfig::default()
616 },
617 pipeline: PipelineConfig {
618 intent: Some(false),
619 relevance: Some(false),
620 compression: Some(false),
621 translation: Some(false),
622 },
623 routing: RoutingConfig::default(),
624 degradation: DegradationConfig {
625 enforce: Some(false),
626 ..DegradationConfig::default()
627 },
628 autonomy: ProfileAutonomy::default(),
629 output_hints: OutputHints::default(),
630 }
631}
632
633pub fn builtin_profiles() -> HashMap<String, Profile> {
635 let mut map = HashMap::new();
636 for p in [
637 builtin_coder(),
638 builtin_exploration(),
639 builtin_bugfix(),
640 builtin_hotfix(),
641 builtin_ci_debug(),
642 builtin_review(),
643 builtin_passthrough(),
644 ] {
645 map.insert(p.profile.name.clone(), p);
646 }
647 map
648}
649
650fn builtin_profile(name: &str) -> Option<Profile> {
659 match name {
660 "coder" => Some(builtin_coder()),
661 "exploration" => Some(builtin_exploration()),
662 "bugfix" => Some(builtin_bugfix()),
663 "hotfix" => Some(builtin_hotfix()),
664 "ci-debug" => Some(builtin_ci_debug()),
665 "review" => Some(builtin_review()),
666 "passthrough" => Some(builtin_passthrough()),
667 _ => None,
668 }
669}
670
671fn profiles_dir_global() -> Option<PathBuf> {
674 crate::core::data_dir::lean_ctx_data_dir()
675 .ok()
676 .map(|d| d.join("profiles"))
677}
678
679fn profiles_dir_project() -> Option<PathBuf> {
680 let mut current = std::env::current_dir().ok()?;
681 for _ in 0..12 {
682 let candidate = current.join(".lean-ctx").join("profiles");
683 if candidate.is_dir() {
684 return Some(candidate);
685 }
686 if !current.pop() {
687 break;
688 }
689 }
690 None
691}
692
693pub fn load_profile(name: &str) -> Option<Profile> {
700 load_profile_recursive(name, 0)
701}
702
703fn load_profile_recursive(name: &str, depth: usize) -> Option<Profile> {
704 if depth > 5 {
705 return None;
706 }
707
708 let mut profile = load_profile_from_disk(name).or_else(|| builtin_profile(name))?;
709 profile.profile.name = name.to_string();
710
711 if let Some(ref parent_name) = profile.profile.inherits.clone()
712 && let Some(parent) = load_profile_recursive(parent_name, depth + 1)
713 {
714 profile = merge_profiles(parent, profile);
715 }
716
717 Some(profile)
718}
719
720fn load_profile_from_disk(name: &str) -> Option<Profile> {
721 let filename = format!("{name}.toml");
722
723 if let Some(project_dir) = profiles_dir_project() {
724 let path = project_dir.join(&filename);
725 if let Some(p) = try_load_toml(&path) {
726 return Some(p);
727 }
728 }
729
730 if let Some(global_dir) = profiles_dir_global() {
731 let path = global_dir.join(&filename);
732 if let Some(p) = try_load_toml(&path) {
733 return Some(p);
734 }
735 }
736
737 None
738}
739
740fn try_load_toml(path: &Path) -> Option<Profile> {
741 let content = std::fs::read_to_string(path).ok()?;
742 toml::from_str(&content).ok()
743}
744
745fn merge_profiles(parent: Profile, child: Profile) -> Profile {
751 let read = ReadConfig {
752 default_mode: child.read.default_mode.or(parent.read.default_mode),
753 max_tokens_per_file: child
754 .read
755 .max_tokens_per_file
756 .or(parent.read.max_tokens_per_file),
757 prefer_cache: child.read.prefer_cache.or(parent.read.prefer_cache),
758 };
759 let compression = CompressionConfig {
760 crp_mode: child.compression.crp_mode.or(parent.compression.crp_mode),
761 output_density: child
762 .compression
763 .output_density
764 .or(parent.compression.output_density),
765 entropy_threshold: child
766 .compression
767 .entropy_threshold
768 .or(parent.compression.entropy_threshold),
769 terse_mode: child
770 .compression
771 .terse_mode
772 .or(parent.compression.terse_mode),
773 };
774 let translation = TranslationConfig {
775 enabled: child.translation.enabled.or(parent.translation.enabled),
776 ruleset: child.translation.ruleset.or(parent.translation.ruleset),
777 };
778 let layout = LayoutConfig {
779 enabled: child.layout.enabled.or(parent.layout.enabled),
780 min_lines: child.layout.min_lines.or(parent.layout.min_lines),
781 };
782 let memory = crate::core::memory_policy::MemoryPolicyOverrides {
783 knowledge: crate::core::memory_policy::KnowledgePolicyOverrides {
784 max_facts: child
785 .memory
786 .knowledge
787 .max_facts
788 .or(parent.memory.knowledge.max_facts),
789 max_patterns: child
790 .memory
791 .knowledge
792 .max_patterns
793 .or(parent.memory.knowledge.max_patterns),
794 max_history: child
795 .memory
796 .knowledge
797 .max_history
798 .or(parent.memory.knowledge.max_history),
799 contradiction_threshold: child
800 .memory
801 .knowledge
802 .contradiction_threshold
803 .or(parent.memory.knowledge.contradiction_threshold),
804 recall_facts_limit: child
805 .memory
806 .knowledge
807 .recall_facts_limit
808 .or(parent.memory.knowledge.recall_facts_limit),
809 rooms_limit: child
810 .memory
811 .knowledge
812 .rooms_limit
813 .or(parent.memory.knowledge.rooms_limit),
814 timeline_limit: child
815 .memory
816 .knowledge
817 .timeline_limit
818 .or(parent.memory.knowledge.timeline_limit),
819 relations_limit: child
820 .memory
821 .knowledge
822 .relations_limit
823 .or(parent.memory.knowledge.relations_limit),
824 },
825 lifecycle: crate::core::memory_policy::LifecyclePolicyOverrides {
826 decay_rate: child
827 .memory
828 .lifecycle
829 .decay_rate
830 .or(parent.memory.lifecycle.decay_rate),
831 low_confidence_threshold: child
832 .memory
833 .lifecycle
834 .low_confidence_threshold
835 .or(parent.memory.lifecycle.low_confidence_threshold),
836 stale_days: child
837 .memory
838 .lifecycle
839 .stale_days
840 .or(parent.memory.lifecycle.stale_days),
841 similarity_threshold: child
842 .memory
843 .lifecycle
844 .similarity_threshold
845 .or(parent.memory.lifecycle.similarity_threshold),
846 forgetting_model: child
847 .memory
848 .lifecycle
849 .forgetting_model
850 .clone()
851 .or_else(|| parent.memory.lifecycle.forgetting_model.clone()),
852 base_stability_days: child
853 .memory
854 .lifecycle
855 .base_stability_days
856 .or(parent.memory.lifecycle.base_stability_days),
857 archetype_aware_decay: child
858 .memory
859 .lifecycle
860 .archetype_aware_decay
861 .or(parent.memory.lifecycle.archetype_aware_decay),
862 },
863 };
864 let verification = crate::core::output_verification::VerificationConfig {
865 enabled: child.verification.enabled.or(parent.verification.enabled),
866 mode: child.verification.mode.or(parent.verification.mode),
867 strict_mode: child
868 .verification
869 .strict_mode
870 .or(parent.verification.strict_mode),
871 check_paths: child
872 .verification
873 .check_paths
874 .or(parent.verification.check_paths),
875 check_identifiers: child
876 .verification
877 .check_identifiers
878 .or(parent.verification.check_identifiers),
879 check_line_numbers: child
880 .verification
881 .check_line_numbers
882 .or(parent.verification.check_line_numbers),
883 check_structure: child
884 .verification
885 .check_structure
886 .or(parent.verification.check_structure),
887 };
888 let budget = BudgetConfig {
889 max_context_tokens: child
890 .budget
891 .max_context_tokens
892 .or(parent.budget.max_context_tokens),
893 max_shell_invocations: child
894 .budget
895 .max_shell_invocations
896 .or(parent.budget.max_shell_invocations),
897 max_cost_usd: child.budget.max_cost_usd.or(parent.budget.max_cost_usd),
898 };
899 let pipeline = PipelineConfig {
900 intent: child.pipeline.intent.or(parent.pipeline.intent),
901 relevance: child.pipeline.relevance.or(parent.pipeline.relevance),
902 compression: child.pipeline.compression.or(parent.pipeline.compression),
903 translation: child.pipeline.translation.or(parent.pipeline.translation),
904 };
905 let routing = RoutingConfig {
906 max_model_tier: child
907 .routing
908 .max_model_tier
909 .or(parent.routing.max_model_tier),
910 degrade_under_pressure: child
911 .routing
912 .degrade_under_pressure
913 .or(parent.routing.degrade_under_pressure),
914 };
915 let degradation = DegradationConfig {
916 enforce: child.degradation.enforce.or(parent.degradation.enforce),
917 throttle_ms: child
918 .degradation
919 .throttle_ms
920 .or(parent.degradation.throttle_ms),
921 };
922 let autonomy = ProfileAutonomy {
923 enabled: child.autonomy.enabled.or(parent.autonomy.enabled),
924 auto_preload: child.autonomy.auto_preload.or(parent.autonomy.auto_preload),
925 auto_dedup: child.autonomy.auto_dedup.or(parent.autonomy.auto_dedup),
926 auto_related: child.autonomy.auto_related.or(parent.autonomy.auto_related),
927 silent_preload: child
928 .autonomy
929 .silent_preload
930 .or(parent.autonomy.silent_preload),
931 auto_prefetch: child
932 .autonomy
933 .auto_prefetch
934 .or(parent.autonomy.auto_prefetch),
935 auto_response: child
936 .autonomy
937 .auto_response
938 .or(parent.autonomy.auto_response),
939 dedup_threshold: child
940 .autonomy
941 .dedup_threshold
942 .or(parent.autonomy.dedup_threshold),
943 prefetch_max_files: child
944 .autonomy
945 .prefetch_max_files
946 .or(parent.autonomy.prefetch_max_files),
947 prefetch_budget_tokens: child
948 .autonomy
949 .prefetch_budget_tokens
950 .or(parent.autonomy.prefetch_budget_tokens),
951 response_min_tokens: child
952 .autonomy
953 .response_min_tokens
954 .or(parent.autonomy.response_min_tokens),
955 checkpoint_interval: child
956 .autonomy
957 .checkpoint_interval
958 .or(parent.autonomy.checkpoint_interval),
959 };
960 let output_hints = OutputHints {
961 compressed_hint: child
962 .output_hints
963 .compressed_hint
964 .or(parent.output_hints.compressed_hint),
965 archive_hint: child
966 .output_hints
967 .archive_hint
968 .or(parent.output_hints.archive_hint),
969 verify_footer: child
970 .output_hints
971 .verify_footer
972 .or(parent.output_hints.verify_footer),
973 related_hint: child
974 .output_hints
975 .related_hint
976 .or(parent.output_hints.related_hint),
977 semantic_hint: child
978 .output_hints
979 .semantic_hint
980 .or(parent.output_hints.semantic_hint),
981 elicitation_hint: child
982 .output_hints
983 .elicitation_hint
984 .or(parent.output_hints.elicitation_hint),
985 checkpoint_in_output: child
986 .output_hints
987 .checkpoint_in_output
988 .or(parent.output_hints.checkpoint_in_output),
989 graph_context_block: child
990 .output_hints
991 .graph_context_block
992 .or(parent.output_hints.graph_context_block),
993 efficiency_hint: child
994 .output_hints
995 .efficiency_hint
996 .or(parent.output_hints.efficiency_hint),
997 };
998 Profile {
999 profile: ProfileMeta {
1000 name: child.profile.name,
1001 inherits: child.profile.inherits,
1002 description: if child.profile.description.is_empty() {
1003 parent.profile.description
1004 } else {
1005 child.profile.description
1006 },
1007 },
1008 read,
1009 compression,
1010 translation,
1011 layout,
1012 memory,
1013 verification,
1014 budget,
1015 pipeline,
1016 routing,
1017 degradation,
1018 autonomy,
1019 output_hints,
1020 }
1021}
1022
1023fn profile_name_from_config_file() -> Option<String> {
1028 let path = crate::core::config::Config::path()?;
1029 let content = std::fs::read_to_string(path).ok()?;
1030 let table: toml::Table = toml::from_str(&content).ok()?;
1031 table
1032 .get("profile")?
1033 .as_str()
1034 .map(str::trim)
1035 .filter(|s| !s.is_empty())
1036 .map(String::from)
1037}
1038
1039static ACTIVE_PROFILE_OVERRIDE: RwLock<Option<String>> = RwLock::new(None);
1047
1048pub fn active_profile_name() -> String {
1053 if let Some(name) = ACTIVE_PROFILE_OVERRIDE
1054 .read()
1055 .unwrap_or_else(std::sync::PoisonError::into_inner)
1056 .clone()
1057 {
1058 return name;
1059 }
1060 if let Ok(v) = std::env::var("LEAN_CTX_PROFILE") {
1061 let v = v.trim().to_string();
1062 if !v.is_empty() {
1063 return v;
1064 }
1065 }
1066 if let Some(name) = profile_name_from_config_file() {
1067 return name;
1068 }
1069 "coder".to_string()
1070}
1071
1072pub fn active_profile() -> Profile {
1074 let name = active_profile_name();
1075 if let Some(p) = load_profile(&name) {
1076 p
1077 } else {
1078 if name != "coder" {
1079 tracing::warn!(
1080 "Profile '{name}' not found (no built-in or disk file). \
1081 Falling back to 'coder'. Create it with: lean-ctx profile create {name}"
1082 );
1083 }
1084 builtin_coder()
1085 }
1086}
1087
1088pub fn set_active_profile(name: &str) -> Result<Profile, String> {
1094 let name = name.trim();
1095 if name.is_empty() {
1096 return Err("profile name is empty".to_string());
1097 }
1098 let prev = active_profile_name();
1099 let profile = load_profile(name).ok_or_else(|| format!("profile '{name}' not found"))?;
1100 *ACTIVE_PROFILE_OVERRIDE
1101 .write()
1102 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(name.to_string());
1103 if prev != name {
1104 crate::core::events::emit_profile_changed(&prev, name);
1105 }
1106 Ok(profile)
1107}
1108
1109pub fn list_profiles() -> Vec<ProfileInfo> {
1111 let mut profiles: HashMap<String, ProfileInfo> = HashMap::new();
1112
1113 for (name, p) in builtin_profiles() {
1114 profiles.insert(
1115 name.clone(),
1116 ProfileInfo {
1117 name,
1118 description: p.profile.description,
1119 source: ProfileSource::Builtin,
1120 },
1121 );
1122 }
1123
1124 for (source, dir) in [
1125 (ProfileSource::Global, profiles_dir_global()),
1126 (ProfileSource::Project, profiles_dir_project()),
1127 ] {
1128 if let Some(dir) = dir
1129 && let Ok(entries) = std::fs::read_dir(&dir)
1130 {
1131 for entry in entries.flatten() {
1132 let path = entry.path();
1133 if path.extension().and_then(|e| e.to_str()) == Some("toml")
1134 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
1135 {
1136 let name = stem.to_string();
1137 let desc = try_load_toml(&path)
1138 .map(|p| p.profile.description)
1139 .unwrap_or_default();
1140 profiles.insert(
1141 name.clone(),
1142 ProfileInfo {
1143 name,
1144 description: desc,
1145 source,
1146 },
1147 );
1148 }
1149 }
1150 }
1151 }
1152
1153 let mut result: Vec<ProfileInfo> = profiles.into_values().collect();
1154 result.sort_by_key(|p| p.name.clone());
1155 result
1156}
1157
1158#[derive(Debug, Clone)]
1160pub struct ProfileInfo {
1161 pub name: String,
1162 pub description: String,
1163 pub source: ProfileSource,
1164}
1165
1166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1168pub enum ProfileSource {
1169 Builtin,
1170 Global,
1171 Project,
1172}
1173
1174impl std::fmt::Display for ProfileSource {
1175 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1176 match self {
1177 Self::Builtin => write!(f, "built-in"),
1178 Self::Global => write!(f, "global"),
1179 Self::Project => write!(f, "project"),
1180 }
1181 }
1182}
1183
1184pub fn format_as_toml(profile: &Profile) -> String {
1186 toml::to_string_pretty(profile).unwrap_or_else(|_| "[error serializing profile]".to_string())
1187}
1188
1189#[cfg(test)]
1192mod tests {
1193 use super::*;
1194
1195 #[test]
1196 fn builtin_profiles_count() {
1197 let builtins = builtin_profiles();
1198 assert_eq!(builtins.len(), 7);
1199 assert!(builtins.contains_key("coder"));
1200 assert!(builtins.contains_key("exploration"));
1201 assert!(builtins.contains_key("bugfix"));
1202 assert!(builtins.contains_key("hotfix"));
1203 assert!(builtins.contains_key("ci-debug"));
1204 assert!(builtins.contains_key("review"));
1205 assert!(builtins.contains_key("passthrough"));
1206 }
1207
1208 #[test]
1209 fn hotfix_has_minimal_budget() {
1210 let p = builtin_profiles().remove("hotfix").unwrap();
1211 assert_eq!(p.budget.max_context_tokens_effective(), 30_000);
1212 assert_eq!(p.budget.max_shell_invocations_effective(), 20);
1213 assert_eq!(p.read.default_mode_effective(), "signatures");
1214 assert_eq!(p.compression.output_density_effective(), "ultra");
1215 }
1216
1217 #[test]
1218 fn exploration_has_broad_context() {
1219 let p = builtin_profiles().remove("exploration").unwrap();
1220 assert_eq!(p.budget.max_context_tokens_effective(), 200_000);
1221 assert_eq!(p.read.default_mode_effective(), "map");
1222 assert!(p.read.prefer_cache_effective());
1223 }
1224
1225 #[test]
1226 fn profile_roundtrip_toml() {
1227 let original = builtin_exploration();
1228 let toml_str = format_as_toml(&original);
1229 let parsed: Profile = toml::from_str(&toml_str).unwrap();
1230 assert_eq!(parsed.profile.name, "exploration");
1231 assert_eq!(parsed.read.default_mode_effective(), "map");
1232 assert_eq!(parsed.budget.max_context_tokens_effective(), 200_000);
1233 }
1234
1235 #[test]
1236 fn merge_child_overrides_parent() {
1237 let parent = builtin_exploration();
1238 let child = Profile {
1239 profile: ProfileMeta {
1240 name: "custom".to_string(),
1241 inherits: Some("exploration".to_string()),
1242 description: String::new(),
1243 },
1244 read: ReadConfig {
1245 default_mode: Some("signatures".to_string()),
1246 ..ReadConfig::default()
1247 },
1248 compression: CompressionConfig::default(),
1249 translation: TranslationConfig::default(),
1250 layout: LayoutConfig::default(),
1251 memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
1252 verification: crate::core::output_verification::VerificationConfig::default(),
1253 budget: BudgetConfig {
1254 max_context_tokens: Some(10_000),
1255 ..BudgetConfig::default()
1256 },
1257 pipeline: PipelineConfig::default(),
1258 routing: RoutingConfig::default(),
1259 degradation: DegradationConfig::default(),
1260 autonomy: ProfileAutonomy::default(),
1261 output_hints: OutputHints::default(),
1262 };
1263
1264 let merged = merge_profiles(parent, child);
1265 assert_eq!(merged.read.default_mode_effective(), "signatures");
1266 assert_eq!(merged.budget.max_context_tokens_effective(), 10_000);
1267 assert_eq!(
1268 merged.profile.description,
1269 "Broad context for understanding codebases"
1270 );
1271 }
1272
1273 #[test]
1274 fn merge_partial_child_inherits_parent_fields() {
1275 let parent = builtin_exploration();
1276 let child = Profile {
1277 profile: ProfileMeta {
1278 name: "partial".to_string(),
1279 inherits: Some("exploration".to_string()),
1280 description: String::new(),
1281 },
1282 read: ReadConfig {
1283 default_mode: Some("map".to_string()),
1284 ..ReadConfig::default()
1285 },
1286 compression: CompressionConfig::default(),
1287 translation: TranslationConfig::default(),
1288 layout: LayoutConfig::default(),
1289 memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
1290 verification: crate::core::output_verification::VerificationConfig::default(),
1291 budget: BudgetConfig::default(),
1292 pipeline: PipelineConfig::default(),
1293 routing: RoutingConfig::default(),
1294 degradation: DegradationConfig::default(),
1295 autonomy: ProfileAutonomy::default(),
1296 output_hints: OutputHints::default(),
1297 };
1298
1299 let merged = merge_profiles(parent, child);
1300 assert_eq!(merged.read.default_mode_effective(), "map");
1301 assert_eq!(
1302 merged.read.max_tokens_per_file_effective(),
1303 80_000,
1304 "should inherit max_tokens_per_file from parent"
1305 );
1306 assert!(
1307 merged.read.prefer_cache_effective(),
1308 "should inherit prefer_cache from parent"
1309 );
1310 assert_eq!(
1311 merged.budget.max_context_tokens_effective(),
1312 200_000,
1313 "should inherit budget from parent"
1314 );
1315 }
1316
1317 #[test]
1318 fn load_builtin_by_name() {
1319 let p = load_profile("hotfix").unwrap();
1320 assert_eq!(p.profile.name, "hotfix");
1321 assert_eq!(p.read.default_mode_effective(), "signatures");
1322 }
1323
1324 #[test]
1325 fn load_nonexistent_returns_none() {
1326 assert!(load_profile("does-not-exist-xyz").is_none());
1327 }
1328
1329 #[test]
1330 fn list_profiles_includes_builtins() {
1331 let list = list_profiles();
1332 assert!(list.len() >= 5);
1333 let names: Vec<&str> = list.iter().map(|p| p.name.as_str()).collect();
1334 assert!(names.contains(&"exploration"));
1335 assert!(names.contains(&"hotfix"));
1336 assert!(names.contains(&"review"));
1337 }
1338
1339 #[test]
1340 fn active_profile_defaults_to_coder() {
1341 let _lock = crate::core::data_dir::test_env_lock();
1342 crate::test_env::remove_var("LEAN_CTX_PROFILE");
1343 let p = active_profile();
1344 assert_eq!(p.profile.name, "coder");
1345 }
1346
1347 #[test]
1348 fn active_profile_from_env() {
1349 let _lock = crate::core::data_dir::test_env_lock();
1350 crate::test_env::set_var("LEAN_CTX_PROFILE", "hotfix");
1351 let name = active_profile_name();
1352 assert_eq!(name, "hotfix");
1353 crate::test_env::remove_var("LEAN_CTX_PROFILE");
1354 }
1355
1356 #[test]
1357 fn profile_source_display() {
1358 assert_eq!(ProfileSource::Builtin.to_string(), "built-in");
1359 assert_eq!(ProfileSource::Global.to_string(), "global");
1360 assert_eq!(ProfileSource::Project.to_string(), "project");
1361 }
1362
1363 #[test]
1364 fn default_profile_has_sane_values() {
1365 let p = Profile {
1366 profile: ProfileMeta::default(),
1367 read: ReadConfig::default(),
1368 compression: CompressionConfig::default(),
1369 translation: TranslationConfig::default(),
1370 layout: LayoutConfig::default(),
1371 memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
1372 verification: crate::core::output_verification::VerificationConfig::default(),
1373 budget: BudgetConfig::default(),
1374 pipeline: PipelineConfig::default(),
1375 routing: RoutingConfig::default(),
1376 degradation: DegradationConfig::default(),
1377 autonomy: ProfileAutonomy::default(),
1378 output_hints: OutputHints::default(),
1379 };
1380 assert_eq!(p.read.default_mode_effective(), "auto");
1381 assert_eq!(p.compression.crp_mode_effective(), "tdd");
1382 assert_eq!(p.budget.max_context_tokens_effective(), 200_000);
1383 assert!(p.pipeline.compression_effective());
1384 assert!(p.pipeline.intent_effective());
1385 }
1386
1387 #[test]
1388 fn pipeline_layers_configurable() {
1389 let toml_str = r#"
1390[profile]
1391name = "no-intent"
1392
1393[pipeline]
1394intent = false
1395relevance = false
1396"#;
1397 let p: Profile = toml::from_str(toml_str).unwrap();
1398 assert!(!p.pipeline.intent_effective());
1399 assert!(!p.pipeline.relevance_effective());
1400 assert!(p.pipeline.compression_effective());
1401 assert!(p.pipeline.translation_effective());
1402 }
1403
1404 #[test]
1405 fn partial_toml_fills_defaults() {
1406 let toml_str = r#"
1407[profile]
1408name = "minimal"
1409
1410[read]
1411default_mode = "entropy"
1412"#;
1413 let p: Profile = toml::from_str(toml_str).unwrap();
1414 assert_eq!(p.read.default_mode_effective(), "entropy");
1415 assert_eq!(p.read.max_tokens_per_file_effective(), 50_000);
1416 assert_eq!(p.budget.max_context_tokens_effective(), 200_000);
1417 assert_eq!(p.compression.crp_mode_effective(), "tdd");
1418 }
1419
1420 #[test]
1421 fn partial_toml_leaves_unset_as_none() {
1422 let toml_str = r#"
1423[profile]
1424name = "sparse"
1425
1426[read]
1427default_mode = "map"
1428"#;
1429 let p: Profile = toml::from_str(toml_str).unwrap();
1430 assert_eq!(p.read.default_mode, Some("map".to_string()));
1431 assert_eq!(p.read.max_tokens_per_file, None);
1432 assert_eq!(p.read.prefer_cache, None);
1433 assert_eq!(p.budget.max_context_tokens, None);
1434 assert_eq!(p.compression.crp_mode, None);
1435 }
1436}