1use std::{
2 collections::HashMap,
3 path::{Path, PathBuf},
4};
5
6use figment::{
7 providers::{Env, Format, Yaml},
8 Figment,
9};
10
11use super::*;
12
13impl BrainConfig {
14 #[allow(clippy::result_large_err)]
21 pub fn load() -> Result<Self, figment::Error> {
22 Self::load_from(None)
23 }
24
25 #[allow(clippy::result_large_err)]
27 pub fn load_from(config_path: Option<&Path>) -> Result<Self, figment::Error> {
28 let mut figment = Figment::new().merge(Yaml::string(super::DEFAULT_CONFIG));
30
31 let user_config = Self::user_config_path();
33 if user_config.exists() {
34 figment = figment.merge(Yaml::file(&user_config));
35 }
36
37 if let Some(path) = config_path {
39 figment = figment.merge(Yaml::file(path));
40 }
41
42 figment = figment.merge(Env::prefixed("BRAIN_").split("__"));
44
45 let mut cfg: Self = figment.extract()?;
46
47 if !cfg.llm.providers.is_empty() {
53 if std::env::var("BRAIN_LLM__BASE_URL").is_ok() {
54 cfg.llm.providers[0].base_url = cfg.llm.base_url.clone();
55 }
56 if std::env::var("BRAIN_LLM__MODEL").is_ok() {
57 cfg.llm.providers[0].model = cfg.llm.model.clone();
58 }
59 if std::env::var("BRAIN_LLM__API_KEY").is_ok() {
60 cfg.llm.providers[0].api_key = cfg.llm.api_key.clone();
61 }
62 }
63
64 Ok(cfg)
65 }
66
67 pub fn data_dir(&self) -> PathBuf {
69 expand_tilde(&self.brain.data_dir)
70 }
71
72 pub fn ensure_data_dirs(&self) -> std::io::Result<()> {
74 let data_dir = self.data_dir();
75 let dirs = [
76 data_dir.clone(),
77 data_dir.join("db"),
78 data_dir.join("ruvector"),
79 data_dir.join("models"),
80 data_dir.join("logs"),
81 data_dir.join("exports"),
82 ];
83
84 for dir in &dirs {
85 std::fs::create_dir_all(dir)?;
86 }
87
88 Ok(())
89 }
90
91 pub fn sqlite_path(&self) -> PathBuf {
93 self.data_dir().join("db").join("brain.db")
94 }
95
96 pub fn ruvector_path(&self) -> PathBuf {
98 self.data_dir().join("ruvector")
99 }
100
101 pub fn models_path(&self) -> PathBuf {
103 self.data_dir().join("models")
104 }
105
106 pub fn is_initialized() -> bool {
108 expand_tilde("~/.brain").exists()
109 }
110
111 pub fn write_default_config(force: bool) -> std::io::Result<Option<(PathBuf, String)>> {
116 let config_path = Self::user_config_path();
117
118 if config_path.exists() && !force {
119 return Ok(None);
120 }
121
122 if let Some(parent) = config_path.parent() {
123 std::fs::create_dir_all(parent)?;
124 }
125
126 let api_key = Self::generate_api_key();
127 let config = super::DEFAULT_CONFIG.replace(
128 "api_keys: []",
129 &format!(
130 "api_keys:\n - key: \"{}\"\n name: \"Default Key\"\n permissions: [read, write]",
131 api_key
132 ),
133 );
134
135 std::fs::write(&config_path, config)?;
136 Ok(Some((config_path, api_key)))
137 }
138
139 fn generate_api_key() -> String {
141 let mut buf = [0u8; 16];
142 getrandom::getrandom(&mut buf).expect("failed to obtain random bytes from OS");
143 let hex: String = buf.iter().map(|b| format!("{:02x}", b)).collect();
144 format!("brk_{}", hex)
145 }
146
147 pub fn user_config_path() -> PathBuf {
152 if let Ok(p) = std::env::var("BRAIN_CONFIG") {
153 if !p.trim().is_empty() {
154 return PathBuf::from(p);
155 }
156 }
157 expand_tilde("~/.brain/config.yaml")
158 }
159
160 pub fn default_config_content() -> &'static str {
162 super::DEFAULT_CONFIG
163 }
164
165 pub fn validate(&self) -> Result<Vec<String>, String> {
167 let mut warnings: Vec<String> = Vec::new();
168
169 let mut ports: HashMap<u16, &str> = HashMap::new();
170 let adapter_ports = [
171 (self.adapters.http.port, "http"),
172 (self.adapters.ws.port, "ws"),
173 (self.adapters.mcp.port, "mcp"),
174 (self.adapters.grpc.port, "grpc"),
175 ];
176 for (port, name) in &adapter_ports {
177 if let Some(existing) = ports.insert(*port, name) {
178 return Err(format!(
179 "Port conflict: adapters '{}' and '{}' both use port {}",
180 existing, name, port
181 ));
182 }
183 }
184
185 let url = &self.llm.base_url;
186 if !url.starts_with("http://") && !url.starts_with("https://") {
187 return Err(format!(
188 "Invalid LLM base_url '{}': must start with http:// or https://",
189 url
190 ));
191 }
192
193 let data_dir = self.data_dir();
194 if data_dir.exists() {
195 let probe = data_dir.join(".brain_write_probe");
196 if std::fs::write(&probe, b"").is_err() {
197 return Err(format!(
198 "Data directory '{}' is not writable",
199 data_dir.display()
200 ));
201 }
202 let _ = std::fs::remove_file(&probe);
203 }
204
205 if self.access.api_keys.is_empty() {
206 warnings.push("No API keys configured — all adapters will reject authenticated requests. Run `brain init` or add a key under 'access.api_keys'.".to_string());
207 }
208
209 if self.llm.temperature > 1.5 {
210 warnings.push(format!(
211 "LLM temperature {:.1} is very high — responses may be unpredictable.",
212 self.llm.temperature
213 ));
214 }
215
216 if self.memory.consolidation.enabled && self.memory.consolidation.interval_hours == 0 {
217 warnings.push("Consolidation interval_hours is 0 — consolidation will run immediately on every daemon wake-up, which may impact performance.".to_string());
218 }
219
220 if self.actions.web_search.enabled {
221 match self.actions.web_search.provider {
222 WebSearchProvider::Custom if self.actions.web_search.endpoint.trim().is_empty() => {
223 warnings.push("Actions web_search provider is 'custom' but endpoint is empty; dispatches will fail with backend-not-configured.".to_string());
224 }
225 WebSearchProvider::Tavily if self.actions.web_search.api_key.trim().is_empty() => {
226 warnings.push("Actions web_search provider is 'tavily' but api_key is empty; dispatches will fail.".to_string());
227 }
228 _ => {}
229 }
230 }
231
232 if self.actions.messaging.enabled {
233 if self.actions.messaging.channels.is_empty() {
234 if self.channel.transports.is_empty() && self.channel.relays.is_empty() {
235 warnings.push("Actions messaging is enabled but neither actions.messaging.channels, channel.transports, nor channel.relays are configured; dispatches will fail.".to_string());
236 }
237 } else {
238 for (name, channel_cfg) in &self.actions.messaging.channels {
239 if channel_cfg.url.trim().is_empty() {
240 warnings.push(format!(
241 "actions.messaging.channels.{name}: url is empty; dispatches to this channel will fail."
242 ));
243 }
244 }
245 }
246 }
247
248 for (name, ms) in [
249 ("web_search.timeout_ms", self.actions.web_search.timeout_ms),
250 ("messaging.timeout_ms", self.actions.messaging.timeout_ms),
251 ] {
252 if ms == 0 {
253 warnings.push(format!(
254 "actions.{name} is 0; will be clamped to 1ms at runtime."
255 ));
256 } else if ms > 30_000 {
257 warnings.push(format!(
258 "actions.{name} is {}ms (>30s) — requests may block for a long time.",
259 ms
260 ));
261 }
262 }
263
264 let res = &self.actions.resilience;
265 if res.max_retries > 10 {
266 warnings.push(format!("actions.resilience.max_retries is {} (>10) — excessive retries may amplify failures.", res.max_retries));
267 }
268 if res.circuit_breaker_threshold == 0 {
269 warnings.push("actions.resilience.circuit_breaker_threshold is 0; circuit breaker will never trip.".to_string());
270 }
271
272 Ok(warnings)
273 }
274}
275
276impl Default for BrainConfig {
277 fn default() -> Self {
278 Self {
279 brain: GeneralConfig {
280 version: env!("CARGO_PKG_VERSION").to_string(),
281 data_dir: "~/.brain".to_string(),
282 },
283 storage: StorageConfig {
284 ruvector_path: "~/.brain/ruvector/".to_string(),
285 sqlite_path: "~/.brain/db/brain.db".to_string(),
286 hnsw: HnswConfig {
287 ef_construction: 200,
288 m: 16,
289 ef_search: 50,
290 },
291 },
292 llm: LlmConfig {
293 provider: "ollama".to_string(),
294 model: "qwen2.5-coder:7b".to_string(),
295 base_url: "http://localhost:11434".to_string(),
296 temperature: 0.7,
297 max_tokens: 4096,
298 api_key: String::new(),
299 providers: Vec::new(),
300 },
301 embedding: EmbeddingConfig {
302 model: "nomic-embed-text".to_string(),
303 dimensions: 768,
304 },
305 memory: MemoryConfig {
306 episodic: EpisodicConfig {},
307 semantic: SemanticConfig {
308 similarity_threshold: 0.65,
309 max_results: 20,
310 },
311 search: SearchConfig {
312 rrf_k: 60,
313 pre_fusion_limit: 50,
314 importance_weight: 0.3,
315 recency_weight: 0.2,
316 decay_rate: 0.01,
317 },
318 consolidation: ConsolidationConfig {
319 enabled: true,
320 interval_hours: 24,
321 forgetting_threshold: 0.05,
322 },
323 },
324 encryption: EncryptionConfig { enabled: false },
325 security: SecurityConfig {
326 exec_allowlist: vec![
327 "ls".into(),
329 "cat".into(),
330 "head".into(),
331 "tail".into(),
332 "wc".into(),
333 "file".into(),
334 "stat".into(),
335 "grep".into(),
337 "find".into(),
338 "sort".into(),
339 "uniq".into(),
340 "cut".into(),
341 "awk".into(),
342 "sed".into(),
343 "which".into(),
345 "command".into(),
346 "type".into(),
347 "test".into(),
348 "basename".into(),
349 "dirname".into(),
350 "realpath".into(),
351 "echo".into(),
353 "printf".into(),
354 "true".into(),
355 "false".into(),
356 "git".into(),
358 "cargo".into(),
359 "rustc".into(),
360 "rustup".into(),
361 "sh".into(),
367 ],
368 exec_timeout_seconds: 30,
369 },
370 actions: ActionsConfig {
371 web_search: WebSearchActionConfig {
372 enabled: true,
376 provider: WebSearchProvider::DuckDuckGo,
377 endpoint: "http://localhost:8888".to_string(),
378 api_key: String::new(),
379 timeout_ms: 3_000,
380 default_top_k: 5,
381 },
382 scheduling: SchedulingActionConfig {
383 enabled: false,
384 mode: SchedulingMode::PersistOnly,
385 },
386 messaging: MessagingActionConfig {
387 enabled: false,
388 timeout_ms: 3_000,
389 channels: HashMap::new(),
390 },
391 resilience: ResilienceConfig::default(),
392 },
393 proactivity: ProactivityConfig {
394 enabled: false,
395 max_per_day: 5,
396 min_interval_minutes: 60,
397 quiet_hours: QuietHoursConfig {
398 start: "22:00".to_string(),
399 end: "08:00".to_string(),
400 timezone: "UTC".to_string(),
401 },
402 delivery: DeliveryConfig::default(),
403 open_loop: OpenLoopDetectionConfig::default(),
404 },
405 adapters: AdaptersConfig {
406 http: HttpAdapterConfig {
407 enabled: true,
408 host: "127.0.0.1".to_string(),
409 port: 19789,
410 cors: true,
411 },
412 ws: WebSocketAdapterConfig {
413 enabled: true,
414 port: 19790,
415 },
416 mcp: McpAdapterConfig {
417 enabled: true,
418 stdio: true,
419 http: true,
420 port: 19791,
421 },
422 grpc: GrpcAdapterConfig {
423 enabled: true,
424 port: 19792,
425 },
426 },
427 access: AccessConfig {
428 api_keys: vec![ApiKeyConfig {
429 key: Self::generate_api_key(),
430 name: "Default Key".to_string(),
431 permissions: vec!["read".to_string(), "write".to_string()],
432 }],
433 },
434 channel: ChannelIntelligenceConfig::default(),
435 agents: AgentsConfig::default(),
436 }
437 }
438}
439
440pub(crate) fn expand_tilde(path: &str) -> PathBuf {
441 if let Some(rest) = path.strip_prefix("~/") {
442 if let Some(home) = dirs_home() {
443 return home.join(rest);
444 }
445 }
446 PathBuf::from(path)
447}
448
449fn dirs_home() -> Option<PathBuf> {
450 std::env::var_os("HOME").map(PathBuf::from)
451}