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;