Skip to main content

brainos_core/
config.rs

1//! Configuration management for Brain.
2//!
3//! Loads configuration from multiple sources with this priority (highest -> lowest):
4//! 1. Environment variables (`BRAIN_` prefix, e.g. `BRAIN_LLM__MODEL`)
5//! 2. User config file (`~/.brain/config.yaml`)
6//! 3. Embedded defaults (compiled into the binary)
7
8/// Default configuration embedded at compile time.
9/// This means `brain` works anywhere without needing config files on disk.
10const DEFAULT_CONFIG: &str = include_str!("../default.yaml");
11
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14
15/// Top-level Brain configuration.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct BrainConfig {
18    pub brain: GeneralConfig,
19    pub storage: StorageConfig,
20    pub llm: LlmConfig,
21    pub embedding: EmbeddingConfig,
22    pub memory: MemoryConfig,
23    pub encryption: EncryptionConfig,
24    pub security: SecurityConfig,
25    pub actions: ActionsConfig,
26    pub proactivity: ProactivityConfig,
27    pub adapters: AdaptersConfig,
28    pub access: AccessConfig,
29    #[serde(default)]
30    pub channel: ChannelIntelligenceConfig,
31    #[serde(default)]
32    pub agents: AgentsConfig,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct GeneralConfig {
37    pub version: String,
38    pub data_dir: String,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct StorageConfig {
43    pub ruvector_path: String,
44    pub sqlite_path: String,
45    pub hnsw: HnswConfig,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct HnswConfig {
50    pub ef_construction: u32,
51    pub m: u32,
52    pub ef_search: u32,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct LlmConfig {
57    pub provider: String,
58    pub model: String,
59    pub base_url: String,
60    pub temperature: f64,
61    pub max_tokens: u32,
62    /// API key for the LLM provider (required for OpenAI, OpenRouter, etc.).
63    /// Can also be set via `BRAIN_LLM__API_KEY` environment variable.
64    #[serde(default)]
65    pub api_key: String,
66    /// Optional multi-provider entries. When non-empty, startup probes each
67    /// entry's `/models` endpoint and selects the first reachable one whose
68    /// `preferred_models` are live. When empty, the legacy single-provider
69    /// fields above are used as-is.
70    #[serde(default)]
71    pub providers: Vec<ProviderEntry>,
72}
73
74/// One entry in `llm.providers` — a named destination that the cortex
75/// will probe at startup. Only two transport kinds are recognised:
76/// `ollama` (local) and `openai_compat` (any OpenAI-compatible endpoint).
77/// A preset name (`groq`, `openrouter`, `deepseek`, `together`,
78/// `gemini-compat`, `openai`) is also accepted as shorthand for
79/// `openai_compat` with a prefilled `base_url`.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct ProviderEntry {
82    /// Human-readable identifier (`"primary"`, `"groq-free"`, …).
83    pub name: String,
84    /// Transport kind or preset name.
85    pub kind: String,
86    /// Override the preset's base_url; required when `kind` is
87    /// `openai_compat` without a preset.
88    #[serde(default)]
89    pub base_url: String,
90    /// Bearer token for OpenAI-compatible providers.
91    #[serde(default)]
92    pub api_key: String,
93    /// Fallback model used when no `preferred_models` entry is live.
94    pub model: String,
95    /// Priority-ordered models. The first one present in the live
96    /// `list_models` response wins.
97    #[serde(default)]
98    pub preferred_models: Vec<String>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct EmbeddingConfig {
103    /// Embedding model name (e.g. "nomic-embed-text" for Ollama,
104    /// "text-embedding-3-small" for OpenAI). Must be available in
105    /// the same service configured under `llm`.
106    pub model: String,
107    /// Output vector dimension — must exactly match the model's output size.
108    /// Ollama nomic-embed-text → 768, OpenAI text-embedding-3-small → 1536.
109    pub dimensions: u32,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct MemoryConfig {
114    pub episodic: EpisodicConfig,
115    pub semantic: SemanticConfig,
116    pub search: SearchConfig,
117    pub consolidation: ConsolidationConfig,
118}
119
120#[derive(Debug, Clone, Default, Serialize, Deserialize)]
121pub struct EpisodicConfig {}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct SemanticConfig {
125    pub similarity_threshold: f64,
126    pub max_results: u32,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct SearchConfig {
131    pub rrf_k: u32,
132    /// Candidates fetched from each source (BM25, ANN) before RRF fusion.
133    #[serde(default = "default_pre_fusion_limit")]
134    pub pre_fusion_limit: u32,
135    /// Weight for importance in final reranking (0.0–1.0).
136    #[serde(default = "default_importance_weight")]
137    pub importance_weight: f64,
138    /// Weight for recency in final reranking (0.0–1.0).
139    #[serde(default = "default_recency_weight")]
140    pub recency_weight: f64,
141    /// Decay rate for the forgetting curve (higher = faster forgetting).
142    #[serde(default = "default_decay_rate")]
143    pub decay_rate: f64,
144}
145
146fn default_pre_fusion_limit() -> u32 {
147    50
148}
149fn default_importance_weight() -> f64 {
150    0.3
151}
152fn default_recency_weight() -> f64 {
153    0.2
154}
155fn default_decay_rate() -> f64 {
156    0.01
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct ConsolidationConfig {
161    pub enabled: bool,
162    pub interval_hours: u32,
163    pub forgetting_threshold: f64,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct EncryptionConfig {
168    pub enabled: bool,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct SecurityConfig {
173    pub exec_allowlist: Vec<String>,
174    pub exec_timeout_seconds: u32,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ActionsConfig {
179    pub web_search: WebSearchActionConfig,
180    pub scheduling: SchedulingActionConfig,
181    pub messaging: MessagingActionConfig,
182    #[serde(default)]
183    pub resilience: ResilienceConfig,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct ResilienceConfig {
188    pub max_retries: u32,
189    pub retry_base_ms: u64,
190    pub circuit_breaker_threshold: u32,
191    pub circuit_breaker_cooldown_secs: u64,
192}
193
194impl Default for ResilienceConfig {
195    fn default() -> Self {
196        Self {
197            max_retries: 2,
198            retry_base_ms: 500,
199            circuit_breaker_threshold: 5,
200            circuit_breaker_cooldown_secs: 60,
201        }
202    }
203}
204
205#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
206#[serde(rename_all = "snake_case")]
207pub enum WebSearchProvider {
208    /// Built-in DuckDuckGo HTML scraper. Zero-config, no API key, no
209    /// Docker — basic quality but always available.
210    #[default]
211    #[serde(alias = "duckduckgo", rename = "duckduckgo")]
212    DuckDuckGo,
213    Searxng,
214    Tavily,
215    Custom,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct WebSearchActionConfig {
220    pub enabled: bool,
221    #[serde(default)]
222    pub provider: WebSearchProvider,
223    pub endpoint: String,
224    #[serde(default)]
225    pub api_key: String,
226    pub timeout_ms: u64,
227    pub default_top_k: usize,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct SchedulingActionConfig {
232    pub enabled: bool,
233    pub mode: SchedulingMode,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
237#[serde(rename_all = "snake_case")]
238pub enum SchedulingMode {
239    PersistOnly,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct ChannelConfig {
244    pub url: String,
245    #[serde(default)]
246    pub body: String,
247    #[serde(default)]
248    pub headers: HashMap<String, String>,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct MessagingActionConfig {
253    pub enabled: bool,
254    pub timeout_ms: u64,
255    #[serde(deserialize_with = "deserialize_channels", default)]
256    pub channels: HashMap<String, ChannelConfig>,
257}
258
259/// Deserialize channels supporting both old format (string URL) and new format (ChannelConfig).
260fn deserialize_channels<'de, D>(deserializer: D) -> Result<HashMap<String, ChannelConfig>, D::Error>
261where
262    D: serde::Deserializer<'de>,
263{
264    #[derive(Deserialize)]
265    #[serde(untagged)]
266    enum ChannelEntry {
267        Full(ChannelConfig),
268        UrlOnly(String),
269    }
270
271    let raw: HashMap<String, ChannelEntry> = HashMap::deserialize(deserializer)?;
272    Ok(raw
273        .into_iter()
274        .map(|(k, v)| {
275            let config = match v {
276                ChannelEntry::Full(c) => c,
277                ChannelEntry::UrlOnly(url) => ChannelConfig {
278                    url,
279                    body: String::new(),
280                    headers: HashMap::new(),
281                },
282            };
283            (k, config)
284        })
285        .collect())
286}
287
288/// Channel intelligence configuration — bidirectional relay gateways
289/// (custom WS agents) that integrate with the channel router and
290/// confirmation correlator.
291///
292/// Distinct from `actions.messaging.channels`, which configures one-way
293/// webhook pushes. Entries here open a long-lived WebSocket and can
294/// carry user responses back into Brain.
295#[derive(Debug, Clone, Default, Serialize, Deserialize)]
296pub struct ChannelIntelligenceConfig {
297    #[serde(default)]
298    pub relays: Vec<RelayEntry>,
299    /// Generic preset-driven transports (`http_polled`, `webhook_inbound`,
300    /// `webhook_outbound`). Each entry names a preset id that ships
301    /// embedded under `crates/channel/presets/` or lives at
302    /// `~/.brain/presets/<id>.yaml`.
303    #[serde(default)]
304    pub transports: Vec<TransportEntry>,
305}
306
307/// A single preset-driven transport — which preset, what id, what
308/// secrets to plug into the preset's templates.
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct TransportEntry {
311    /// Stable id registered with the channel router (e.g. `"chat-main"`).
312    pub id: String,
313    /// Human-readable label.
314    pub label: String,
315    /// Preset id — resolved via the channel crate's preset loader.
316    pub preset: String,
317    /// Memory namespace attributed to inbound messages on this transport.
318    #[serde(default = "default_relay_namespace")]
319    pub namespace: String,
320    /// Credential substituted into `{credential}` in url/body templates
321    /// (bot token, webhook URL, app id — whatever the preset expects).
322    /// May be empty.
323    #[serde(default)]
324    pub credential: String,
325    /// Optional signing secret used by `webhook_inbound` transports
326    /// whose preset declares a `verifier` (HMAC shared key, Ed25519
327    /// pubkey hex, ...).
328    #[serde(default)]
329    pub signing_secret: Option<String>,
330}
331
332/// One relay gateway entry.
333#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct RelayEntry {
335    /// Stable id registered with the channel router (e.g. `"chat-main"`).
336    pub id: String,
337    /// Human-readable label used in CLI and audit entries.
338    pub label: String,
339    /// WebSocket URL of the gateway.
340    pub url: String,
341    /// Memory namespace attributed to messages arriving on this relay.
342    #[serde(default = "default_relay_namespace")]
343    pub namespace: String,
344    /// Optional bearer token forwarded to the gateway (if supported).
345    #[serde(default)]
346    pub api_key: String,
347    /// Reconnection tuning — initial backoff in milliseconds.
348    #[serde(default = "default_relay_initial_backoff_ms")]
349    pub initial_backoff_ms: u64,
350    /// Reconnection tuning — max backoff in milliseconds.
351    #[serde(default = "default_relay_max_backoff_ms")]
352    pub max_backoff_ms: u64,
353}
354
355fn default_relay_namespace() -> String {
356    "personal".to_string()
357}
358fn default_relay_initial_backoff_ms() -> u64 {
359    1_000
360}
361fn default_relay_max_backoff_ms() -> u64 {
362    60_000
363}
364
365/// Agent delegation configuration — specialist CLI/HTTP agents that
366/// orchestrator-level `Implement` steps can hand off to.
367#[derive(Debug, Clone, Default, Serialize, Deserialize)]
368pub struct AgentsConfig {
369    /// Manually-registered delegates. Kept for advanced setups and
370    /// backward compatibility; most users rely on `auto_discovery`.
371    #[serde(default)]
372    pub delegates: Vec<AgentEntry>,
373    /// Ordered fallback agent names applied when a delegation fails on
374    /// a retryable error. Names must match discovered ids or `delegates`
375    /// entries.
376    #[serde(default)]
377    pub fallbacks: Vec<String>,
378    /// Whether timeout failures should trigger fallback retries
379    /// (default: true). Set to false for tasks where retry cost is
380    /// prohibitive.
381    #[serde(default = "default_retry_on_timeout")]
382    pub retry_on_timeout: bool,
383    /// Scan `$PATH` on startup and auto-register known CLI agents using
384    /// the built-in fingerprint table. Default: true. Set to `false` to
385    /// go fully manual via `delegates[]`.
386    #[serde(default = "default_auto_discovery")]
387    pub auto_discovery: bool,
388    /// Per-agent overrides merged on top of discovery defaults. Keyed
389    /// by the canonical agent id from the fingerprint table.
390    #[serde(default)]
391    pub discovery_overrides: std::collections::HashMap<String, AgentDiscoveryOverride>,
392}
393
394fn default_retry_on_timeout() -> bool {
395    true
396}
397
398fn default_auto_discovery() -> bool {
399    true
400}
401
402/// Tweak a single auto-discovered agent. All fields are optional —
403/// unset ones keep the fingerprint default.
404#[derive(Debug, Clone, Default, Serialize, Deserialize)]
405pub struct AgentDiscoveryOverride {
406    /// Force a specific binary path instead of the `$PATH` hit.
407    #[serde(default)]
408    pub binary: Option<String>,
409    /// Exclude from the registry entirely.
410    #[serde(default)]
411    pub disabled: bool,
412    /// Override the invocation args (supports `{prompt}` / `{task_id}`).
413    #[serde(default)]
414    pub args: Option<Vec<String>>,
415    /// Force stdin vs. argv prompt delivery.
416    #[serde(default)]
417    pub prompt_via_stdin: Option<bool>,
418}
419
420/// One registered delegate. Currently only `kind = "subprocess"` is
421/// supported — any CLI agent the orchestrator can spawn. Auto-discovery
422/// covers most common agents without needing manual entries here.
423#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct AgentEntry {
425    /// Registered name — this is what appears in `StepAction::Implement`.
426    pub name: String,
427    /// Adapter kind (`"subprocess"`).
428    pub kind: String,
429    /// Optional alias registered alongside `name`. Handy for routing
430    /// shorthand request names to the canonical entry.
431    #[serde(default)]
432    pub alias: Option<String>,
433    /// Binary to launch. Required for `subprocess`.
434    #[serde(default)]
435    pub binary: String,
436    /// Args passed to the binary. Supports `{prompt}` and `{task_id}`
437    /// substitution.
438    #[serde(default)]
439    pub args: Vec<String>,
440    /// Default working directory for the delegate. Task-level workdir
441    /// (set by the orchestrator) wins when present.
442    #[serde(default)]
443    pub workdir: Option<String>,
444    /// Whether the prompt is written to the child's stdin rather than
445    /// templated into `args`. Defaults to `true`. Ignored for
446    /// argv-templated entries that don't read stdin.
447    #[serde(default = "default_prompt_via_stdin")]
448    pub prompt_via_stdin: bool,
449    /// Declared capability tags (e.g. `["code-edit","rust"]`).
450    #[serde(default)]
451    pub tags: Vec<String>,
452}
453
454fn default_prompt_via_stdin() -> bool {
455    true
456}
457
458#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct ProactivityConfig {
460    pub enabled: bool,
461    pub max_per_day: u32,
462    pub min_interval_minutes: u32,
463    pub quiet_hours: QuietHoursConfig,
464    #[serde(default)]
465    pub delivery: DeliveryConfig,
466    #[serde(default)]
467    pub open_loop: OpenLoopDetectionConfig,
468}
469
470/// Configuration for open-loop (unresolved commitment) detection.
471#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct OpenLoopDetectionConfig {
473    /// Enable open-loop detection.
474    pub enabled: bool,
475    /// How many hours back to scan for commitments.
476    pub scan_window_hours: u32,
477    /// Hours after a commitment before it's flagged as unresolved.
478    pub resolution_window_hours: u32,
479    /// Check interval in minutes.
480    pub check_interval_minutes: u32,
481}
482
483impl Default for OpenLoopDetectionConfig {
484    fn default() -> Self {
485        Self {
486            enabled: true,
487            scan_window_hours: 72,
488            resolution_window_hours: 24,
489            check_interval_minutes: 120,
490        }
491    }
492}
493
494/// Configuration for proactive notification delivery.
495#[derive(Debug, Clone, Serialize, Deserialize)]
496pub struct DeliveryConfig {
497    /// Always write to outbox (drain on next interaction).
498    pub outbox: bool,
499    /// Push to live sessions via broadcast channel.
500    pub broadcast: bool,
501    /// Messaging channel keys (from actions.messaging.channels) to push proactive notifications.
502    pub webhook_channels: Vec<String>,
503    /// Maximum age (days) before undelivered outbox items are pruned.
504    pub max_outbox_age_days: u32,
505}
506
507impl Default for DeliveryConfig {
508    fn default() -> Self {
509        Self {
510            outbox: true,
511            broadcast: true,
512            webhook_channels: Vec::new(),
513            max_outbox_age_days: 7,
514        }
515    }
516}
517
518#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct QuietHoursConfig {
520    pub start: String,
521    pub end: String,
522    #[serde(default = "default_timezone")]
523    pub timezone: String,
524}
525
526fn default_timezone() -> String {
527    "UTC".to_string()
528}
529
530/// A single API key entry.
531#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct ApiKeyConfig {
533    /// The raw API key string.
534    pub key: String,
535    /// Human-readable name for this key (for display/audit purposes).
536    pub name: String,
537    /// Granted permissions: `"read"` and/or `"write"`.
538    pub permissions: Vec<String>,
539}
540
541impl ApiKeyConfig {
542    /// Returns true if this key grants the requested permission.
543    pub fn has_permission(&self, perm: &str) -> bool {
544        self.permissions.iter().any(|p| p == perm)
545    }
546}
547
548/// Access-control configuration (API keys).
549#[derive(Debug, Clone, Serialize, Deserialize)]
550pub struct AccessConfig {
551    pub api_keys: Vec<ApiKeyConfig>,
552}
553
554impl AccessConfig {
555    /// Find a key entry by its raw key string.
556    pub fn find_key(&self, key: &str) -> Option<&ApiKeyConfig> {
557        self.api_keys.iter().find(|k| k.key == key)
558    }
559}
560
561#[derive(Debug, Clone, Serialize, Deserialize)]
562pub struct AdaptersConfig {
563    pub http: HttpAdapterConfig,
564    pub ws: WebSocketAdapterConfig,
565    pub mcp: McpAdapterConfig,
566    pub grpc: GrpcAdapterConfig,
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize)]
570pub struct HttpAdapterConfig {
571    pub enabled: bool,
572    pub host: String,
573    pub port: u16,
574    pub cors: bool,
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize)]
578pub struct WebSocketAdapterConfig {
579    pub enabled: bool,
580    pub port: u16,
581}
582
583#[derive(Debug, Clone, Serialize, Deserialize)]
584pub struct McpAdapterConfig {
585    pub enabled: bool,
586    pub stdio: bool,
587    pub http: bool,
588    pub port: u16,
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize)]
592pub struct GrpcAdapterConfig {
593    pub enabled: bool,
594    pub port: u16,
595}
596
597impl BrainConfig {}
598
599mod loader;
600
601#[cfg(test)]
602mod tests;