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.
10/// Also the single source of truth the product self-model (the `selfmodel`
11/// crate) slices into config-schema grounding for the SOUL, handed in via
12/// [`BrainConfig::default_config_content`].
13pub(crate) const DEFAULT_CONFIG: &str = include_str!("../default.yaml");
14
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17
18/// Top-level Brain configuration.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct BrainConfig {
21    pub brain: GeneralConfig,
22    pub storage: StorageConfig,
23    pub llm: LlmConfig,
24    pub embedding: EmbeddingConfig,
25    pub memory: MemoryConfig,
26    pub encryption: EncryptionConfig,
27    pub security: SecurityConfig,
28    pub actions: ActionsConfig,
29    pub proactivity: ProactivityConfig,
30    pub adapters: AdaptersConfig,
31    pub access: AccessConfig,
32    #[serde(default)]
33    pub channel: ChannelIntelligenceConfig,
34    #[serde(default)]
35    pub agents: AgentsConfig,
36    #[serde(default)]
37    pub confirm: ConfirmConfig,
38    /// Principal & identity configuration consumed by
39    /// `identity::ConfigIdentityStore`. Default is empty — signals carry
40    /// `Principal = None` and the identity gate is silently skipped.
41    #[serde(default)]
42    pub identity: identity::IdentityConfig,
43    /// Reactive signal sources. Each subsection drives one reflex type;
44    /// default is empty/disabled across the board, so a fresh install
45    /// spawns no reflex tasks. `cmd_serve` reads this to construct
46    /// `FsReflex` / `CronReflex` / `SysStateReflex` and bridge their
47    /// streams into the pipeline via `signal::spawn_reflex`.
48    #[serde(default)]
49    pub reflex: ReflexConfig,
50    /// Logging policy — base level, per-subsystem overrides, output format,
51    /// and daemon log-file rotation. Default is empty/`info` pretty with daily
52    /// rotation; `RUST_LOG` still overrides the computed filter at runtime.
53    #[serde(default)]
54    pub logging: LoggingConfig,
55    /// Learned self-model knobs — currently the capability-fitness loop that
56    /// records per-tool success/failure and feeds it back into tool ranking
57    /// and the SOUL capability digest. Default is on with a 30-day half-life.
58    #[serde(default)]
59    pub learning: LearningConfig,
60    /// Runtime resource-observability knobs — the resource sampler's cadence
61    /// and the per-gauge ceilings that trip a `ResourcePressure` event.
62    /// Default is a 30s sample with generous, fail-safe ceilings.
63    #[serde(default)]
64    pub observability: ObservabilityConfig,
65    /// External-service health monitoring — a list of HTTP/TCP endpoints to
66    /// probe on a cadence, alerting on up↔down transitions. Default is empty,
67    /// so a fresh install probes nothing.
68    #[serde(default)]
69    pub monitoring: MonitoringConfig,
70}
71
72/// Learned self-model configuration.
73#[derive(Debug, Clone, Serialize, Deserialize, Default)]
74pub struct LearningConfig {
75    #[serde(default)]
76    pub capability_fitness: CapabilityFitnessConfig,
77}
78
79/// Capability-fitness learning: per-tool success/failure mass that decays
80/// under the forgetting curve and nudges the chat tool-loop's advertised
81/// ranking. See `cerebellum::CapabilityFitnessStore`.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct CapabilityFitnessConfig {
84    /// Record outcomes, boost ranking, and surface "proven tools" in the
85    /// digest. When false the store is inert (nothing recorded or surfaced).
86    #[serde(default = "CapabilityFitnessConfig::default_enabled")]
87    pub enabled: bool,
88    /// Decay half-life in days: how long a success/failure observation keeps
89    /// half its weight. Longer = slower forgetting.
90    #[serde(default = "CapabilityFitnessConfig::default_half_life_days")]
91    pub half_life_days: f64,
92}
93
94impl CapabilityFitnessConfig {
95    fn default_enabled() -> bool {
96        true
97    }
98    fn default_half_life_days() -> f64 {
99        30.0
100    }
101    /// Half-life expressed in hours, as the fitness store consumes it.
102    pub fn half_life_hours(&self) -> f64 {
103        self.half_life_days * 24.0
104    }
105}
106
107impl Default for CapabilityFitnessConfig {
108    fn default() -> Self {
109        Self {
110            enabled: Self::default_enabled(),
111            half_life_days: Self::default_half_life_days(),
112        }
113    }
114}
115
116/// Runtime resource-observability configuration. Drives the resource sampler
117/// that gauges process RSS, CPU, open SQLite connections, and `~/.brain` disk
118/// usage, plus the thresholds at which a `ResourcePressure` event is emitted.
119/// Default is a 30s sample cadence with generous, fail-safe ceilings, so a
120/// fresh install never trips a pressure event under normal load.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ObservabilityConfig {
123    /// Seconds between resource samples. The sampler is a single bounded
124    /// background task; lower = more responsive pressure detection at a
125    /// slightly higher idle cost.
126    #[serde(default = "ObservabilityConfig::default_resource_sample_secs")]
127    pub resource_sample_secs: u64,
128    /// Per-gauge ceilings above which a `ResourcePressure` event fires
129    /// (edge-triggered, not per sample).
130    #[serde(default)]
131    pub thresholds: ResourceThresholds,
132    /// Sampling for high-volume, low-information log lines (the resource
133    /// sampler heartbeat, etc.).
134    #[serde(default)]
135    pub log_sampling: LogSamplingConfig,
136}
137
138impl ObservabilityConfig {
139    fn default_resource_sample_secs() -> u64 {
140        30
141    }
142}
143
144impl Default for ObservabilityConfig {
145    fn default() -> Self {
146        Self {
147            resource_sample_secs: Self::default_resource_sample_secs(),
148            thresholds: ResourceThresholds::default(),
149            log_sampling: LogSamplingConfig::default(),
150        }
151    }
152}
153
154/// Log-sampling policy: emit only 1 in N of designated high-volume log lines
155/// so a hot loop doesn't drown the log. The metric/event behind each line is
156/// still recorded every time — only the *log line* is throttled.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct LogSamplingConfig {
159    /// Emit 1 in N of high-volume log lines. `1` (the default) logs every
160    /// line — sampling off. Raise it in production to thin periodic chatter.
161    #[serde(default = "LogSamplingConfig::default_high_volume_1_in_n")]
162    pub high_volume_1_in_n: u32,
163}
164
165impl LogSamplingConfig {
166    fn default_high_volume_1_in_n() -> u32 {
167        1
168    }
169}
170
171impl Default for LogSamplingConfig {
172    fn default() -> Self {
173        Self {
174            high_volume_1_in_n: Self::default_high_volume_1_in_n(),
175        }
176    }
177}
178
179/// Per-gauge pressure ceilings. A gauge crossing its ceiling emits a
180/// `ResourcePressure` event; defaults are generous so normal operation is
181/// silent. A `0` disables that gauge's threshold (it never fires).
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct ResourceThresholds {
184    /// Resident-set-size ceiling, in mebibytes.
185    #[serde(default = "ResourceThresholds::default_rss_mb")]
186    pub rss_mb: u64,
187    /// Process CPU-utilisation ceiling, in percent (single-core basis, so
188    /// values above 100 are possible on a multi-core busy loop).
189    #[serde(default = "ResourceThresholds::default_cpu_pct")]
190    pub cpu_pct: f64,
191    /// `~/.brain` data-directory disk-usage ceiling, in mebibytes.
192    #[serde(default = "ResourceThresholds::default_disk_mb")]
193    pub disk_mb: u64,
194    /// Open-file-descriptor ceiling (count). Crossing it warns of a possible
195    /// fd leak before the process hits its OS `RLIMIT_NOFILE` and starts
196    /// failing to open files/sockets. Generous by default so normal operation
197    /// is silent.
198    #[serde(default = "ResourceThresholds::default_open_fds")]
199    pub open_fds: u64,
200}
201
202impl ResourceThresholds {
203    fn default_rss_mb() -> u64 {
204        2048
205    }
206    fn default_cpu_pct() -> f64 {
207        90.0
208    }
209    fn default_disk_mb() -> u64 {
210        10_240
211    }
212    fn default_open_fds() -> u64 {
213        1024
214    }
215}
216
217impl Default for ResourceThresholds {
218    fn default() -> Self {
219        Self {
220            rss_mb: Self::default_rss_mb(),
221            cpu_pct: Self::default_cpu_pct(),
222            disk_mb: Self::default_disk_mb(),
223            open_fds: Self::default_open_fds(),
224        }
225    }
226}
227
228/// External-service health monitoring. Each [`ServiceCheck`] drives one bounded
229/// background probe loop that periodically reaches a service (HTTP or raw TCP)
230/// and, on an up↔down *transition*, surfaces a proactive notification through
231/// the same router the resource sampler uses. Default is an empty list, so a
232/// fresh install spawns no probes.
233#[derive(Debug, Clone, Default, Serialize, Deserialize)]
234pub struct MonitoringConfig {
235    /// Services to health-check. One bounded background loop is spawned per
236    /// entry; an empty list (the default) spawns none.
237    #[serde(default)]
238    pub services: Vec<ServiceCheck>,
239}
240
241/// Probe protocol for a [`ServiceCheck`].
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
243#[serde(rename_all = "snake_case")]
244pub enum ServiceCheckKind {
245    /// HTTP(S) GET — healthy when the response status matches `expect_status`
246    /// (or is any 2xx when that is unset).
247    #[default]
248    Http,
249    /// Raw TCP connect — healthy when the connection is accepted before the
250    /// timeout. `target` is `host:port`.
251    Tcp,
252}
253
254/// One external service to health-check. `target` is a URL for the `http` kind
255/// or `host:port` for the `tcp` kind. Probes are edge-triggered: a notification
256/// fires only when the service crosses between reachable and unreachable, never
257/// once per interval while it stays in one state.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct ServiceCheck {
260    /// Stable label used in the alert text and the notification's
261    /// `triggered_by` (`service_health:<name>`).
262    pub name: String,
263    /// Probe protocol. Defaults to `http`.
264    #[serde(default)]
265    pub kind: ServiceCheckKind,
266    /// URL (`http` kind) or `host:port` (`tcp` kind) to reach.
267    pub target: String,
268    /// Seconds between probes. Default 60.
269    #[serde(default = "ServiceCheck::default_interval_secs")]
270    pub interval_secs: u64,
271    /// Per-probe timeout in seconds — a probe that does not complete in this
272    /// window counts as unreachable. Default 10.
273    #[serde(default = "ServiceCheck::default_timeout_secs")]
274    pub timeout_secs: u64,
275    /// HTTP only: the exact status code that counts as healthy. When unset,
276    /// any 2xx response is healthy. Ignored for the `tcp` kind.
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub expect_status: Option<u16>,
279}
280
281impl ServiceCheck {
282    fn default_interval_secs() -> u64 {
283        60
284    }
285    fn default_timeout_secs() -> u64 {
286        10
287    }
288}
289
290/// Logging configuration. Drives the `tracing` subscriber the CLI installs.
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct LoggingConfig {
293    /// Base level applied to the `brain` target when neither `RUST_LOG` nor a
294    /// per-command default is in force: `trace|debug|info|warn|error`.
295    #[serde(default = "LoggingConfig::default_level")]
296    pub level: String,
297    /// Per-subsystem level overrides, keyed by tracing target (crate name,
298    /// e.g. `hippocampus`, `signal`). Each becomes an `EnvFilter` directive.
299    #[serde(default)]
300    pub targets: HashMap<String, String>,
301    /// Output format: `pretty` (human) or `json` (structured/machine).
302    #[serde(default)]
303    pub format: LogFormat,
304    /// Daemon log-file rotation cadence for `logs/brain.log`.
305    #[serde(default)]
306    pub rotation: LogRotation,
307}
308
309impl LoggingConfig {
310    fn default_level() -> String {
311        "info".to_string()
312    }
313}
314
315impl Default for LoggingConfig {
316    fn default() -> Self {
317        Self {
318            level: Self::default_level(),
319            targets: HashMap::new(),
320            format: LogFormat::default(),
321            rotation: LogRotation::default(),
322        }
323    }
324}
325
326/// Log output format.
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
328#[serde(rename_all = "snake_case")]
329pub enum LogFormat {
330    #[default]
331    Pretty,
332    Json,
333}
334
335/// Daemon log-file rotation cadence.
336#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
337#[serde(rename_all = "snake_case")]
338pub enum LogRotation {
339    #[default]
340    Daily,
341    Hourly,
342    Never,
343}
344
345/// Top-level reactive-source configuration.
346#[derive(Debug, Clone, Default, Serialize, Deserialize)]
347pub struct ReflexConfig {
348    /// Filesystem watchers. One entry per `FsReflex` source. Empty list
349    /// means no FS reflex is spawned.
350    #[serde(default)]
351    pub fs: Vec<FsReflexEntry>,
352    /// Cron-style reflex bridging the scheduler. Disabled by default.
353    #[serde(default)]
354    pub cron: CronReflexEntry,
355    /// System-state edge-trigger reflex. Disabled by default. Uses a
356    /// noop sampler until a per-platform implementation is wired.
357    #[serde(default)]
358    pub sys: SysReflexEntry,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct FsReflexEntry {
363    /// Stable name used in tracing + as the reflex's `name()`. Also
364    /// embedded in the resulting `Provenance::Reflex { trigger }`.
365    pub name: String,
366    /// Filesystem paths to watch (absolute or `~`-relative).
367    pub paths: Vec<String>,
368    #[serde(default)]
369    pub recursive: bool,
370    /// Debounce window in milliseconds. Default 200ms when omitted.
371    #[serde(default = "FsReflexEntry::default_debounce_ms")]
372    pub debounce_ms: u64,
373}
374
375impl FsReflexEntry {
376    pub fn default_debounce_ms() -> u64 {
377        200
378    }
379}
380
381#[derive(Debug, Clone, Default, Serialize, Deserialize)]
382pub struct CronReflexEntry {
383    #[serde(default)]
384    pub enabled: bool,
385    /// Poll interval in seconds. Default 60s when omitted (matches the
386    /// historical `cli::serve` ticker).
387    #[serde(default = "CronReflexEntry::default_poll_seconds")]
388    pub poll_interval_seconds: u64,
389    /// Optional namespace filter — only intents in this namespace fire.
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub namespace_filter: Option<String>,
392}
393
394impl CronReflexEntry {
395    pub fn default_poll_seconds() -> u64 {
396        60
397    }
398}
399
400#[derive(Debug, Clone, Default, Serialize, Deserialize)]
401pub struct SysReflexEntry {
402    #[serde(default)]
403    pub enabled: bool,
404    /// Sampler poll cadence in seconds. Default 30s.
405    #[serde(default = "SysReflexEntry::default_poll_seconds")]
406    pub poll_interval_seconds: u64,
407    /// Edge-triggered rules to evaluate on each transition.
408    #[serde(default)]
409    pub rules: Vec<SysReflexRuleEntry>,
410}
411
412impl SysReflexEntry {
413    pub fn default_poll_seconds() -> u64 {
414        30
415    }
416}
417
418/// YAML-bound mirror of `reflex::SysStateRule`. Kept here so `brain`
419/// doesn't take a dependency on `reflex` (which depends on `brain`
420/// transitively); `cmd_serve` converts each entry to a concrete
421/// `SysStateRule` at spawn time.
422#[derive(Debug, Clone, Serialize, Deserialize)]
423#[serde(tag = "kind", rename_all = "snake_case")]
424pub enum SysReflexRuleEntry {
425    /// Fires when battery percentage crosses below `threshold`.
426    BatteryBelow { threshold: u8 },
427    /// Fires when `on_ac` flips in either direction.
428    OnAcChanged,
429    /// Fires when network reachability flips between online and offline.
430    NetworkChanged,
431    /// Fires when session lock state flips.
432    LockChanged,
433}
434
435/// Confirmation-engine configuration. Currently only declares standing
436/// approvals — pre-granted (agent, verb) consent that bypasses the
437/// human-confirm prompt. Empty defaults preserve pre-Phase-5 behavior.
438#[derive(Debug, Clone, Default, Serialize, Deserialize)]
439pub struct ConfirmConfig {
440    #[serde(default)]
441    pub standing_approvals: Vec<StandingApprovalDecl>,
442}
443
444/// One standing-approval declaration. Loaded at startup into the
445/// `StandingApprovalStore`; idempotent across launches (an existing
446/// active grant for the same triple is left alone).
447#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct StandingApprovalDecl {
449    pub agent_id: String,
450    pub verb_ns: String,
451    pub verb_action: String,
452    #[serde(default)]
453    pub note: Option<String>,
454}
455
456#[derive(Debug, Clone, Serialize, Deserialize)]
457pub struct GeneralConfig {
458    pub version: String,
459    pub data_dir: String,
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct StorageConfig {
464    pub ruvector_path: String,
465    pub sqlite_path: String,
466    pub hnsw: HnswConfig,
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
470pub struct HnswConfig {
471    pub ef_construction: u32,
472    pub m: u32,
473    pub ef_search: u32,
474    /// Maximum number of vectors a single HNSW table can hold. Threaded
475    /// into the underlying ruvector database at `open` time (Issue 37);
476    /// previously hardcoded at 10_000_000 inside the storage crate.
477    ///
478    /// HNSW pre-allocates the index graph for `max_elements` entries
479    /// up-front, so this knob is a real memory cost — not just an
480    /// upper bound. Personal-scale installs rarely need more than
481    /// 100k facts/episodes; production / shared installs that need
482    /// more should raise this explicitly in their config rather than
483    /// pay for the headroom in every dev install. (Wave F, Issue 71.)
484    #[serde(default = "HnswConfig::default_max_elements")]
485    pub max_elements: u32,
486}
487
488impl HnswConfig {
489    /// 100k vectors — covers the vast majority of personal-scale
490    /// deployments without pre-allocating headroom for a million users
491    /// of facts that nobody will ever store. Raise via
492    /// `storage.hnsw.max_elements` when you actually need it.
493    pub fn default_max_elements() -> u32 {
494        100_000
495    }
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize)]
499pub struct LlmConfig {
500    /// Legacy single-provider selector. Superseded by `providers[]`,
501    /// which supports multi-provider failover and runtime health
502    /// checks. Still honoured as the implicit single entry when
503    /// `providers[]` is empty, and `Embedder::from_config` reads it
504    /// to pick the embedding transport — so it can't be removed yet.
505    /// New configs should leave this set to a reasonable default and
506    /// drive everything from `providers[]` instead.
507    #[deprecated(
508        note = "Set `llm.providers[]` instead. Single-provider mode is still functional but no longer the recommended shape."
509    )]
510    pub provider: String,
511    /// Legacy single-provider model name. Superseded by per-entry
512    /// `llm.providers[].model` + `preferred_models[]`. Still consulted
513    /// when `providers[]` is empty.
514    #[deprecated(
515        note = "Set `llm.providers[].model` (and optionally `preferred_models`) instead."
516    )]
517    pub model: String,
518    /// Legacy single-provider endpoint. Superseded by per-entry
519    /// `llm.providers[].base_url`. Still consulted when `providers[]`
520    /// is empty and by `Embedder::from_config` to pick the embedding
521    /// transport.
522    #[deprecated(
523        note = "Set `llm.providers[].base_url` instead. Embedder transport selection still reads this field as a fallback."
524    )]
525    pub base_url: String,
526    pub temperature: f64,
527    pub max_tokens: u32,
528    /// The active model's input context window, in tokens. Drives the
529    /// prompt assembler's [`TokenBudget`](cortex) so a large-window model
530    /// (e.g. 128k) reads far more file/attachment + memory content instead
531    /// of being clipped to the conservative 8k default. Set this to your
532    /// model's real context size. Defaults to 8192 (safe for most models)
533    /// when omitted, preserving the historical budget.
534    #[serde(default = "default_context_window")]
535    pub context_window: usize,
536    /// API key for the LLM provider (required for OpenAI, OpenRouter, etc.).
537    /// Can also be set via `BRAIN_LLM__API_KEY` environment variable.
538    /// Prefer `api_key_file` (chmod-0600) for secrets that shouldn't live
539    /// in YAML, or move credentials to `llm.providers[].api_key_file`.
540    #[deprecated(
541        note = "Move credentials to `llm.providers[].api_key_file` (or `api_key_file` here) — the YAML field gets backed up and replicated."
542    )]
543    #[serde(default)]
544    pub api_key: String,
545    /// Issue 125: path to a chmod-0600 file holding the API key. Preferred
546    /// over `api_key` because the YAML config typically gets backed up,
547    /// version-controlled, and replicated; a sibling file with restricted
548    /// perms keeps the secret out of those flows. When both are set,
549    /// `api_key_file` wins.
550    #[serde(default)]
551    pub api_key_file: Option<std::path::PathBuf>,
552    /// Optional multi-provider entries. When non-empty, startup probes each
553    /// entry's `/models` endpoint and selects the first reachable one whose
554    /// `preferred_models` are live. When empty, the legacy single-provider
555    /// fields above are used as-is.
556    #[serde(default)]
557    pub providers: Vec<ProviderEntry>,
558}
559
560/// Default context window when `llm.context_window` is omitted. 8192 is the
561/// historical assembler budget — safe for nearly every model, and large-window
562/// models opt into more by setting their real size.
563pub(crate) fn default_context_window() -> usize {
564    8192
565}
566
567/// One entry in `llm.providers` — a named destination that the cortex
568/// will probe at startup. Only two transport kinds are recognised:
569/// `ollama` (local) and `openai_compat` (any OpenAI-compatible endpoint).
570/// A preset name (`groq`, `openrouter`, `deepseek`, `together`,
571/// `gemini-compat`, `openai`) is also accepted as shorthand for
572/// `openai_compat` with a prefilled `base_url`.
573#[derive(Debug, Clone, Serialize, Deserialize)]
574pub struct ProviderEntry {
575    /// Human-readable identifier (`"primary"`, `"groq-free"`, …).
576    pub name: String,
577    /// Transport kind or preset name.
578    pub kind: String,
579    /// Override the preset's base_url; required when `kind` is
580    /// `openai_compat` without a preset.
581    #[serde(default)]
582    pub base_url: String,
583    /// Bearer token for OpenAI-compatible providers.
584    #[serde(default)]
585    pub api_key: String,
586    /// Issue 125: file-backed alternative to `api_key`. When set, the
587    /// trimmed contents of the file are used as the bearer token.
588    #[serde(default)]
589    pub api_key_file: Option<std::path::PathBuf>,
590    /// Fallback model used when no `preferred_models` entry is live.
591    pub model: String,
592    /// Priority-ordered models. The first one present in the live
593    /// `list_models` response wins.
594    #[serde(default)]
595    pub preferred_models: Vec<String>,
596}
597
598#[derive(Debug, Clone, Serialize, Deserialize)]
599pub struct EmbeddingConfig {
600    /// Embedding model name (e.g. "nomic-embed-text" for Ollama,
601    /// "text-embedding-3-small" for OpenAI). Must be available in
602    /// the same service configured under `llm`.
603    pub model: String,
604    /// Output vector dimension — must exactly match the model's output size.
605    /// Ollama nomic-embed-text → 768, OpenAI text-embedding-3-small → 1536.
606    pub dimensions: u32,
607}
608
609#[derive(Debug, Clone, Serialize, Deserialize)]
610pub struct MemoryConfig {
611    pub semantic: SemanticConfig,
612    pub search: SearchConfig,
613    pub consolidation: ConsolidationConfig,
614}
615
616#[derive(Debug, Clone, Serialize, Deserialize)]
617pub struct SemanticConfig {
618    pub similarity_threshold: f64,
619    pub max_results: u32,
620}
621
622#[derive(Debug, Clone, Serialize, Deserialize)]
623pub struct SearchConfig {
624    pub rrf_k: u32,
625    /// Candidates fetched from each source (BM25, ANN) before RRF fusion.
626    #[serde(default = "default_pre_fusion_limit")]
627    pub pre_fusion_limit: u32,
628    /// Weight for importance in final reranking (0.0–1.0).
629    #[serde(default = "default_importance_weight")]
630    pub importance_weight: f64,
631    /// Weight for recency in final reranking (0.0–1.0).
632    #[serde(default = "default_recency_weight")]
633    pub recency_weight: f64,
634    /// Decay rate for the forgetting curve (higher = faster forgetting).
635    #[serde(default = "default_decay_rate")]
636    pub decay_rate: f64,
637}
638
639fn default_pre_fusion_limit() -> u32 {
640    50
641}
642fn default_importance_weight() -> f64 {
643    0.3
644}
645fn default_recency_weight() -> f64 {
646    0.2
647}
648fn default_decay_rate() -> f64 {
649    0.01
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize)]
653pub struct ConsolidationConfig {
654    pub enabled: bool,
655    pub interval_hours: u32,
656    pub forgetting_threshold: f64,
657}
658
659#[derive(Debug, Clone, Serialize, Deserialize)]
660pub struct EncryptionConfig {
661    pub enabled: bool,
662}
663
664#[derive(Debug, Clone, Serialize, Deserialize)]
665pub struct SecurityConfig {
666    pub exec_allowlist: Vec<String>,
667    pub exec_timeout_seconds: u32,
668    /// Roots that read-only filesystem reads (chat-time path
669    /// attachments and decompose path excerpts) are allowed to touch.
670    /// Each entry may use `~` for the user's home and is canonicalized
671    /// at use time. An empty list means "default to `$HOME`" — never
672    /// "anywhere" — so a fresh install can't be coaxed into reading
673    /// `/etc` or `/Users/<other>/...`.
674    #[serde(default)]
675    pub allowed_paths: Vec<String>,
676}
677
678#[derive(Debug, Clone, Serialize, Deserialize)]
679pub struct ActionsConfig {
680    pub web_search: WebSearchActionConfig,
681    pub scheduling: SchedulingActionConfig,
682    pub messaging: MessagingActionConfig,
683    #[serde(default)]
684    pub resilience: ResilienceConfig,
685}
686
687#[derive(Debug, Clone, Serialize, Deserialize)]
688pub struct ResilienceConfig {
689    pub max_retries: u32,
690    pub retry_base_ms: u64,
691    pub circuit_breaker_threshold: u32,
692    pub circuit_breaker_cooldown_secs: u64,
693}
694
695impl Default for ResilienceConfig {
696    fn default() -> Self {
697        Self {
698            max_retries: 2,
699            retry_base_ms: 500,
700            circuit_breaker_threshold: 5,
701            circuit_breaker_cooldown_secs: 60,
702        }
703    }
704}
705
706#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
707#[serde(rename_all = "snake_case")]
708pub enum WebSearchProvider {
709    /// Built-in DuckDuckGo HTML scraper. Zero-config, no API key, no
710    /// Docker — basic quality but always available.
711    #[default]
712    #[serde(alias = "duckduckgo", rename = "duckduckgo")]
713    DuckDuckGo,
714    Searxng,
715    Tavily,
716    Custom,
717}
718
719#[derive(Debug, Clone, Serialize, Deserialize)]
720pub struct WebSearchActionConfig {
721    pub enabled: bool,
722    #[serde(default)]
723    pub provider: WebSearchProvider,
724    pub endpoint: String,
725    #[serde(default)]
726    pub api_key: String,
727    pub timeout_ms: u64,
728    pub default_top_k: usize,
729}
730
731#[derive(Debug, Clone, Serialize, Deserialize)]
732pub struct SchedulingActionConfig {
733    pub enabled: bool,
734    pub mode: SchedulingMode,
735}
736
737#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
738#[serde(rename_all = "snake_case")]
739pub enum SchedulingMode {
740    PersistOnly,
741}
742
743#[derive(Debug, Clone, Serialize, Deserialize)]
744pub struct ChannelConfig {
745    pub url: String,
746    #[serde(default)]
747    pub body: String,
748    #[serde(default)]
749    pub headers: HashMap<String, String>,
750}
751
752#[derive(Debug, Clone, Serialize, Deserialize)]
753pub struct MessagingActionConfig {
754    pub enabled: bool,
755    pub timeout_ms: u64,
756    #[serde(deserialize_with = "deserialize_channels", default)]
757    pub channels: HashMap<String, ChannelConfig>,
758}
759
760/// Deserialize channels supporting both old format (string URL) and new format (ChannelConfig).
761fn deserialize_channels<'de, D>(deserializer: D) -> Result<HashMap<String, ChannelConfig>, D::Error>
762where
763    D: serde::Deserializer<'de>,
764{
765    #[derive(Deserialize)]
766    #[serde(untagged)]
767    enum ChannelEntry {
768        Full(ChannelConfig),
769        UrlOnly(String),
770    }
771
772    let raw: HashMap<String, ChannelEntry> = HashMap::deserialize(deserializer)?;
773    Ok(raw
774        .into_iter()
775        .map(|(k, v)| {
776            let config = match v {
777                ChannelEntry::Full(c) => c,
778                ChannelEntry::UrlOnly(url) => ChannelConfig {
779                    url,
780                    body: String::new(),
781                    headers: HashMap::new(),
782                },
783            };
784            (k, config)
785        })
786        .collect())
787}
788
789/// Channel intelligence configuration — bidirectional relay gateways
790/// (custom WS agents) that integrate with the channel router and
791/// confirmation correlator.
792///
793/// Distinct from `actions.messaging.channels`, which configures one-way
794/// webhook pushes. Entries here open a long-lived WebSocket and can
795/// carry user responses back into Brain.
796#[derive(Debug, Clone, Default, Serialize, Deserialize)]
797pub struct ChannelIntelligenceConfig {
798    #[serde(default)]
799    pub relays: Vec<RelayEntry>,
800    /// Generic preset-driven transports (`http_polled`, `webhook_inbound`,
801    /// `webhook_outbound`). Each entry names a preset id that ships
802    /// embedded under `crates/channel/presets/` or lives at
803    /// `~/.brain/presets/<id>.yaml`.
804    #[serde(default)]
805    pub transports: Vec<TransportEntry>,
806}
807
808/// A single preset-driven transport — which preset, what id, what
809/// secrets to plug into the preset's templates.
810#[derive(Debug, Clone, Serialize, Deserialize)]
811pub struct TransportEntry {
812    /// Stable id registered with the channel router (e.g. `"chat-main"`).
813    pub id: String,
814    /// Human-readable label.
815    pub label: String,
816    /// Preset id — resolved via the channel crate's preset loader.
817    pub preset: String,
818    /// Memory namespace attributed to inbound messages on this transport.
819    #[serde(default = "default_relay_namespace")]
820    pub namespace: String,
821    /// Credential substituted into `{credential}` in url/body templates
822    /// (bot token, webhook URL, app id — whatever the preset expects).
823    /// May be empty.
824    #[serde(default)]
825    pub credential: String,
826    /// Optional signing secret used by `webhook_inbound` transports
827    /// whose preset declares a `verifier` (HMAC shared key, Ed25519
828    /// pubkey hex, ...).
829    #[serde(default)]
830    pub signing_secret: Option<String>,
831}
832
833/// One relay gateway entry.
834#[derive(Debug, Clone, Serialize, Deserialize)]
835pub struct RelayEntry {
836    /// Stable id registered with the channel router (e.g. `"chat-main"`).
837    pub id: String,
838    /// Human-readable label used in CLI and audit entries.
839    pub label: String,
840    /// WebSocket URL of the gateway.
841    pub url: String,
842    /// Memory namespace attributed to messages arriving on this relay.
843    #[serde(default = "default_relay_namespace")]
844    pub namespace: String,
845    /// Optional bearer token forwarded to the gateway (if supported).
846    #[serde(default)]
847    pub api_key: String,
848    /// Reconnection tuning — initial backoff in milliseconds.
849    #[serde(default = "default_relay_initial_backoff_ms")]
850    pub initial_backoff_ms: u64,
851    /// Reconnection tuning — max backoff in milliseconds.
852    #[serde(default = "default_relay_max_backoff_ms")]
853    pub max_backoff_ms: u64,
854}
855
856fn default_relay_namespace() -> String {
857    "personal".to_string()
858}
859fn default_relay_initial_backoff_ms() -> u64 {
860    1_000
861}
862fn default_relay_max_backoff_ms() -> u64 {
863    60_000
864}
865
866/// Agent delegation configuration — specialist CLI/HTTP agents that
867/// orchestrator-level `Implement` steps can hand off to.
868#[derive(Debug, Clone, Default, Serialize, Deserialize)]
869pub struct AgentsConfig {
870    /// Manually-registered delegates. Kept for advanced setups and
871    /// backward compatibility; most users rely on `auto_discovery`.
872    #[serde(default)]
873    pub delegates: Vec<AgentEntry>,
874    /// Ordered fallback agent names applied when a delegation fails on
875    /// a retryable error. Names must match discovered ids or `delegates`
876    /// entries.
877    #[serde(default)]
878    pub fallbacks: Vec<String>,
879    /// Whether timeout failures should trigger fallback retries
880    /// (default: true). Set to false for tasks where retry cost is
881    /// prohibitive.
882    #[serde(default = "default_retry_on_timeout")]
883    pub retry_on_timeout: bool,
884    /// Scan `$PATH` on startup and auto-register known CLI agents using
885    /// the built-in fingerprint table. Default: true. Set to `false` to
886    /// go fully manual via `delegates[]`.
887    #[serde(default = "default_auto_discovery")]
888    pub auto_discovery: bool,
889    /// Per-agent overrides merged on top of discovery defaults. Keyed
890    /// by the canonical agent id from the fingerprint table.
891    #[serde(default)]
892    pub discovery_overrides: std::collections::HashMap<String, AgentDiscoveryOverride>,
893}
894
895fn default_retry_on_timeout() -> bool {
896    true
897}
898
899fn default_auto_discovery() -> bool {
900    true
901}
902
903/// Tweak a single auto-discovered agent. All fields are optional —
904/// unset ones keep the fingerprint default.
905#[derive(Debug, Clone, Default, Serialize, Deserialize)]
906pub struct AgentDiscoveryOverride {
907    /// Force a specific binary path instead of the `$PATH` hit.
908    #[serde(default)]
909    pub binary: Option<String>,
910    /// Exclude from the registry entirely.
911    #[serde(default)]
912    pub disabled: bool,
913    /// Override the invocation args (supports `{prompt}` / `{task_id}`).
914    #[serde(default)]
915    pub args: Option<Vec<String>>,
916    /// Force stdin vs. argv prompt delivery.
917    #[serde(default)]
918    pub prompt_via_stdin: Option<bool>,
919    /// Replace the fingerprint's default capability declaration.
920    /// Mirrors the runtime `AgentCapabilities` shape in `brainos-delegate`;
921    /// when set, every listed field is forwarded onto the registry entry
922    /// in place of the discovery default.
923    #[serde(default)]
924    pub capabilities: Option<CapabilitiesOverride>,
925}
926
927/// YAML-side mirror of `brainos_delegate::AgentCapabilities`. Lives here
928/// to keep `brainos-core` free of a `brainos-delegate` dependency
929/// (delegate already depends on us, so the reverse would be a cycle).
930/// The CLI bootstrap layer converts this into the runtime type when
931/// building the registry.
932#[derive(Debug, Clone, Default, Serialize, Deserialize)]
933pub struct CapabilitiesOverride {
934    /// Free-form capability tags (`"code-edit"`, `"plan"`, `"research"`).
935    #[serde(default)]
936    pub tags: Vec<String>,
937    /// Preferred languages/frameworks (`"rust"`, `"typescript"`).
938    #[serde(default)]
939    pub languages: Vec<String>,
940    /// Maximum concurrent delegations the orchestrator will dispatch to
941    /// this agent at once. Defaults to 1 (conservative).
942    #[serde(default = "default_capability_concurrency")]
943    pub max_concurrency: u32,
944    /// Whether this delegate needs network — informs sandbox policy.
945    #[serde(default)]
946    pub needs_network: bool,
947}
948
949fn default_capability_concurrency() -> u32 {
950    1
951}
952
953/// One registered delegate. Currently only `kind = "subprocess"` is
954/// supported — any CLI agent the orchestrator can spawn. Auto-discovery
955/// covers most common agents without needing manual entries here.
956#[derive(Debug, Clone, Serialize, Deserialize)]
957pub struct AgentEntry {
958    /// Registered name — this is what appears in `StepAction::Implement`.
959    pub name: String,
960    /// Adapter kind (`"subprocess"`).
961    pub kind: String,
962    /// Optional alias registered alongside `name`. Handy for routing
963    /// shorthand request names to the canonical entry.
964    #[serde(default)]
965    pub alias: Option<String>,
966    /// Binary to launch. Required for `subprocess`.
967    #[serde(default)]
968    pub binary: String,
969    /// Args passed to the binary. Supports `{prompt}` and `{task_id}`
970    /// substitution.
971    #[serde(default)]
972    pub args: Vec<String>,
973    /// Default working directory for the delegate. Task-level workdir
974    /// (set by the orchestrator) wins when present.
975    #[serde(default)]
976    pub workdir: Option<String>,
977    /// Whether the prompt is written to the child's stdin rather than
978    /// templated into `args`. Defaults to `true`. Ignored for
979    /// argv-templated entries that don't read stdin.
980    #[serde(default = "default_prompt_via_stdin")]
981    pub prompt_via_stdin: bool,
982    /// Declared capability tags (e.g. `["code-edit","rust"]`).
983    #[serde(default)]
984    pub tags: Vec<String>,
985}
986
987fn default_prompt_via_stdin() -> bool {
988    true
989}
990
991#[derive(Debug, Clone, Serialize, Deserialize)]
992pub struct ProactivityConfig {
993    pub enabled: bool,
994    pub max_per_day: u32,
995    pub min_interval_minutes: u32,
996    pub quiet_hours: QuietHoursConfig,
997    #[serde(default)]
998    pub delivery: DeliveryConfig,
999    #[serde(default)]
1000    pub open_loop: OpenLoopDetectionConfig,
1001}
1002
1003/// Configuration for open-loop (unresolved commitment) detection.
1004#[derive(Debug, Clone, Serialize, Deserialize)]
1005pub struct OpenLoopDetectionConfig {
1006    /// Enable open-loop detection.
1007    pub enabled: bool,
1008    /// How many hours back to scan for commitments.
1009    pub scan_window_hours: u32,
1010    /// Hours after a commitment before it's flagged as unresolved.
1011    pub resolution_window_hours: u32,
1012    /// Check interval in minutes.
1013    pub check_interval_minutes: u32,
1014}
1015
1016impl Default for OpenLoopDetectionConfig {
1017    fn default() -> Self {
1018        Self {
1019            enabled: true,
1020            scan_window_hours: 72,
1021            resolution_window_hours: 24,
1022            check_interval_minutes: 120,
1023        }
1024    }
1025}
1026
1027/// Configuration for proactive notification delivery.
1028#[derive(Debug, Clone, Serialize, Deserialize)]
1029pub struct DeliveryConfig {
1030    /// Always write to outbox (drain on next interaction).
1031    pub outbox: bool,
1032    /// Push to live sessions via broadcast channel.
1033    pub broadcast: bool,
1034    /// Messaging channel keys (from actions.messaging.channels) to push proactive notifications.
1035    pub webhook_channels: Vec<String>,
1036    /// Maximum age (days) before undelivered outbox items are pruned.
1037    pub max_outbox_age_days: u32,
1038}
1039
1040impl Default for DeliveryConfig {
1041    fn default() -> Self {
1042        Self {
1043            outbox: true,
1044            broadcast: true,
1045            webhook_channels: Vec::new(),
1046            max_outbox_age_days: 7,
1047        }
1048    }
1049}
1050
1051#[derive(Debug, Clone, Serialize, Deserialize)]
1052pub struct QuietHoursConfig {
1053    pub start: String,
1054    pub end: String,
1055    #[serde(default = "default_timezone")]
1056    pub timezone: String,
1057}
1058
1059fn default_timezone() -> String {
1060    "UTC".to_string()
1061}
1062
1063/// A single API key entry.
1064#[derive(Debug, Clone, Serialize, Deserialize)]
1065pub struct ApiKeyConfig {
1066    /// The raw API key string.
1067    pub key: String,
1068    /// Human-readable name for this key (for display/audit purposes).
1069    pub name: String,
1070    /// Granted permissions. Recognised scopes:
1071    /// - `"read"`   — read-only memory + signal/status endpoints
1072    /// - `"write"`  — submit signals, store/forget facts (Issue 127: does
1073    ///   NOT imply `read`; list both if needed)
1074    /// - `"export"` — bulk memory export (Issue 123)
1075    /// - `"admin"`  — implicit superset of every other scope (Issue 127)
1076    pub permissions: Vec<String>,
1077    /// Agent identity bound to this key. Used by adapters to resolve a
1078    /// `Principal` from the `identity:` config. Backwards-compatible
1079    /// default: `None` — adapters then send `Signal.principal = None`
1080    /// and the identity gate is skipped.
1081    #[serde(default, skip_serializing_if = "Option::is_none")]
1082    pub agent_id: Option<String>,
1083}
1084
1085impl ApiKeyConfig {
1086    /// Returns true if this key grants the requested permission.
1087    ///
1088    /// Issue 127: the `admin` permission implicitly grants every other
1089    /// scope (read, write, export). All other scopes are exact match —
1090    /// `write` does **not** imply `read`, so historically a key with
1091    /// `["write"]` could not call read endpoints, and that contract is
1092    /// preserved.
1093    pub fn has_permission(&self, perm: &str) -> bool {
1094        if self.permissions.iter().any(|p| p == "admin") {
1095            return true;
1096        }
1097        self.permissions.iter().any(|p| p == perm)
1098    }
1099}
1100
1101/// Access-control configuration (API keys).
1102#[derive(Debug, Clone, Serialize, Deserialize)]
1103pub struct AccessConfig {
1104    pub api_keys: Vec<ApiKeyConfig>,
1105    /// Per-client (per-API-key) rate limiting applied across HTTP / WS /
1106    /// gRPC adapters. Disabled by default so a fresh install behaves like
1107    /// older versions; enable in `default.yaml` to throttle abusive
1108    /// clients without changing identity wiring.
1109    #[serde(default)]
1110    pub rate_limit: ClientRateLimitConfig,
1111}
1112
1113impl AccessConfig {
1114    /// Find a key entry by its raw key string. Delegates to the constant-
1115    /// time helper in `auth` (Issue 62).
1116    pub fn find_key(&self, key: &str) -> Option<&ApiKeyConfig> {
1117        crate::auth::find_key_ct(&self.api_keys, key)
1118    }
1119}
1120
1121/// Tuning surface for adapter-level rate limiting (Issue 51).
1122///
1123/// Defaults are conservative: 60 tokens/min with a burst of 20, so a
1124/// well-behaved client sees no impact while a tight loop is rejected
1125/// after the burst is drained.
1126#[derive(Debug, Clone, Serialize, Deserialize)]
1127pub struct ClientRateLimitConfig {
1128    #[serde(default = "ClientRateLimitConfig::default_enabled")]
1129    pub enabled: bool,
1130    /// Token grant per `refill_interval_ms`. Steady-state rate is
1131    /// `tokens_per_refill / refill_interval_ms * 1000` per second.
1132    #[serde(default = "ClientRateLimitConfig::default_tokens_per_refill")]
1133    pub tokens_per_refill: u32,
1134    #[serde(default = "ClientRateLimitConfig::default_refill_interval_ms")]
1135    pub refill_interval_ms: u64,
1136    /// Maximum tokens the bucket holds — the burst ceiling.
1137    #[serde(default = "ClientRateLimitConfig::default_burst_capacity")]
1138    pub burst_capacity: u32,
1139}
1140
1141impl Default for ClientRateLimitConfig {
1142    fn default() -> Self {
1143        Self {
1144            enabled: Self::default_enabled(),
1145            tokens_per_refill: Self::default_tokens_per_refill(),
1146            refill_interval_ms: Self::default_refill_interval_ms(),
1147            burst_capacity: Self::default_burst_capacity(),
1148        }
1149    }
1150}
1151
1152impl ClientRateLimitConfig {
1153    pub fn default_enabled() -> bool {
1154        true
1155    }
1156    pub fn default_tokens_per_refill() -> u32 {
1157        60
1158    }
1159    pub fn default_refill_interval_ms() -> u64 {
1160        60_000
1161    }
1162    pub fn default_burst_capacity() -> u32 {
1163        20
1164    }
1165}
1166
1167#[derive(Debug, Clone, Serialize, Deserialize)]
1168pub struct AdaptersConfig {
1169    pub http: HttpAdapterConfig,
1170    pub ws: WebSocketAdapterConfig,
1171    pub mcp: McpAdapterConfig,
1172    pub grpc: GrpcAdapterConfig,
1173    /// Terminal Bridge gRPC server — backs `Intent::OpenTerminalSession`
1174    /// and friends. Default enabled so AI agents can drive PTY sessions
1175    /// out of the box.
1176    #[serde(default = "TerminalAdapterConfig::default_enabled")]
1177    pub terminal: TerminalAdapterConfig,
1178}
1179
1180#[derive(Debug, Clone, Serialize, Deserialize)]
1181pub struct HttpAdapterConfig {
1182    pub enabled: bool,
1183    pub host: String,
1184    pub port: u16,
1185    pub cors: bool,
1186    /// Issue 131: when true, the SSE `/v1/events` stream replaces
1187    /// content-bearing fields (LLM responses, notification bodies) with a
1188    /// `[redacted]` marker so an observer with `read` scope sees event
1189    /// shape and counts but no message text. Default `false` to preserve
1190    /// the existing local-dev behavior; flip on for shared deployments.
1191    #[serde(default)]
1192    pub sse_redact_previews: bool,
1193}
1194
1195#[derive(Debug, Clone, Serialize, Deserialize)]
1196pub struct WebSocketAdapterConfig {
1197    pub enabled: bool,
1198    pub port: u16,
1199}
1200
1201#[derive(Debug, Clone, Serialize, Deserialize)]
1202pub struct McpAdapterConfig {
1203    pub enabled: bool,
1204    pub port: u16,
1205}
1206
1207#[derive(Debug, Clone, Serialize, Deserialize)]
1208pub struct GrpcAdapterConfig {
1209    pub enabled: bool,
1210    pub port: u16,
1211}
1212
1213/// Terminal Bridge gRPC server configuration.
1214#[derive(Debug, Clone, Serialize, Deserialize)]
1215pub struct TerminalAdapterConfig {
1216    pub enabled: bool,
1217    pub port: u16,
1218}
1219
1220impl TerminalAdapterConfig {
1221    /// Default for `#[serde(default)]` on `AdaptersConfig.terminal` — keeps
1222    /// the bridge available out of the box for fresh installs whose YAML
1223    /// pre-dates this field.
1224    pub fn default_enabled() -> Self {
1225        Self {
1226            enabled: true,
1227            port: 19793,
1228        }
1229    }
1230}
1231
1232impl Default for TerminalAdapterConfig {
1233    fn default() -> Self {
1234        Self::default_enabled()
1235    }
1236}
1237
1238impl BrainConfig {}
1239
1240mod loader;
1241pub mod migrate;
1242
1243#[cfg(test)]
1244mod tests;