spec_ai_config/config/
agent_config.rs

1//! Application-level configuration
2//!
3//! Defines the top-level application configuration, including model settings,
4//! database configuration, UI preferences, and logging.
5
6use crate::config::agent::AgentProfile;
7use anyhow::{Context, Result};
8use directories::BaseDirs;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13/// Embedded default configuration file
14const DEFAULT_CONFIG: &str =
15    include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/spec-ai.config.toml"));
16
17/// Configuration file name
18const CONFIG_FILE_NAME: &str = "spec-ai.config.toml";
19
20/// Top-level application configuration
21#[derive(Debug, Clone, Serialize, Deserialize, Default)]
22pub struct AppConfig {
23    /// Database configuration
24    #[serde(default)]
25    pub database: DatabaseConfig,
26    /// Model provider configuration
27    #[serde(default)]
28    pub model: ModelConfig,
29    /// UI configuration
30    #[serde(default)]
31    pub ui: UiConfig,
32    /// Logging configuration
33    #[serde(default)]
34    pub logging: LoggingConfig,
35    /// Audio transcription configuration
36    #[serde(default)]
37    pub audio: AudioConfig,
38    /// Mesh networking configuration
39    #[serde(default)]
40    pub mesh: MeshConfig,
41    /// Plugin configuration for custom tools
42    #[serde(default)]
43    pub plugins: PluginConfig,
44    /// Graph synchronization configuration
45    #[serde(default)]
46    pub sync: SyncConfig,
47    /// HTTP API authentication configuration
48    #[serde(default)]
49    pub auth: AuthConfig,
50    /// Available agent profiles
51    #[serde(default)]
52    pub agents: HashMap<String, AgentProfile>,
53    /// Default agent to use (if not specified)
54    #[serde(default)]
55    pub default_agent: Option<String>,
56}
57
58impl AppConfig {
59    /// Load configuration from file or create a default configuration
60    pub fn load() -> Result<Self> {
61        // Try to load from spec-ai.config.toml in current directory
62        if let Ok(content) = std::fs::read_to_string(CONFIG_FILE_NAME) {
63            return toml::from_str(&content)
64                .map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", CONFIG_FILE_NAME, e));
65        }
66
67        // Try to load from ~/.spec-ai/spec-ai.config.toml
68        if let Ok(base_dirs) =
69            BaseDirs::new().ok_or(anyhow::anyhow!("Could not determine home directory"))
70        {
71            let home_config = base_dirs.home_dir().join(".spec-ai").join(CONFIG_FILE_NAME);
72            if let Ok(content) = std::fs::read_to_string(&home_config) {
73                return toml::from_str(&content).map_err(|e| {
74                    anyhow::anyhow!("Failed to parse {}: {}", home_config.display(), e)
75                });
76            }
77        }
78
79        // Try to load from environment variable CONFIG_PATH
80        if let Ok(config_path) = std::env::var("CONFIG_PATH") {
81            if let Ok(content) = std::fs::read_to_string(&config_path) {
82                return toml::from_str(&content)
83                    .map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e));
84            }
85        }
86
87        // No config file found - create one from embedded default
88        eprintln!(
89            "No configuration file found. Creating {} with default settings...",
90            CONFIG_FILE_NAME
91        );
92        if let Err(e) = std::fs::write(CONFIG_FILE_NAME, DEFAULT_CONFIG) {
93            eprintln!("Warning: Could not create {}: {}", CONFIG_FILE_NAME, e);
94            eprintln!("Continuing with default configuration in memory.");
95        } else {
96            eprintln!(
97                "Created {}. You can edit this file to customize your settings.",
98                CONFIG_FILE_NAME
99            );
100        }
101
102        // Parse and return the embedded default config
103        toml::from_str(DEFAULT_CONFIG)
104            .map_err(|e| anyhow::anyhow!("Failed to parse embedded default config: {}", e))
105    }
106
107    /// Load configuration from a specific file path
108    /// If the file doesn't exist, creates it with default settings
109    pub fn load_from_file(path: &std::path::Path) -> Result<Self> {
110        // Try to read existing file
111        match std::fs::read_to_string(path) {
112            Ok(content) => toml::from_str(&content).map_err(|e| {
113                anyhow::anyhow!("Failed to parse config file {}: {}", path.display(), e)
114            }),
115            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
116                // File doesn't exist - create it with default config
117                eprintln!(
118                    "Configuration file not found at {}. Creating with default settings...",
119                    path.display()
120                );
121
122                // Create parent directories if needed
123                if let Some(parent) = path.parent() {
124                    std::fs::create_dir_all(parent)
125                        .context(format!("Failed to create directory {}", parent.display()))?;
126                }
127
128                // Write default config
129                std::fs::write(path, DEFAULT_CONFIG).context(format!(
130                    "Failed to create config file at {}",
131                    path.display()
132                ))?;
133
134                eprintln!(
135                    "Created {}. You can edit this file to customize your settings.",
136                    path.display()
137                );
138
139                // Parse and return the embedded default config
140                toml::from_str(DEFAULT_CONFIG)
141                    .map_err(|e| anyhow::anyhow!("Failed to parse embedded default config: {}", e))
142            }
143            Err(e) => Err(anyhow::anyhow!(
144                "Failed to read config file {}: {}",
145                path.display(),
146                e
147            )),
148        }
149    }
150
151    /// Validate the configuration
152    pub fn validate(&self) -> Result<()> {
153        // Validate model provider: must be non-empty and supported
154        if self.model.provider.is_empty() {
155            return Err(anyhow::anyhow!("Model provider cannot be empty"));
156        }
157        // Validate against known provider names independent of compile-time feature flags
158        {
159            let p = self.model.provider.to_lowercase();
160            let known = ["mock", "openai", "anthropic", "ollama", "mlx", "lmstudio"];
161            if !known.contains(&p.as_str()) {
162                return Err(anyhow::anyhow!(
163                    "Invalid model provider: {}",
164                    self.model.provider
165                ));
166            }
167        }
168
169        // Validate temperature
170        if self.model.temperature < 0.0 || self.model.temperature > 2.0 {
171            return Err(anyhow::anyhow!(
172                "Temperature must be between 0.0 and 2.0, got {}",
173                self.model.temperature
174            ));
175        }
176
177        // Validate log level
178        match self.logging.level.as_str() {
179            "trace" | "debug" | "info" | "warn" | "error" => {}
180            _ => return Err(anyhow::anyhow!("Invalid log level: {}", self.logging.level)),
181        }
182
183        // If a default agent is specified, it must exist in the agents map
184        if let Some(default_agent) = &self.default_agent {
185            if !self.agents.contains_key(default_agent) {
186                return Err(anyhow::anyhow!(
187                    "Default agent '{}' not found in agents map",
188                    default_agent
189                ));
190            }
191        }
192
193        Ok(())
194    }
195
196    /// Apply environment variable overrides to the configuration
197    pub fn apply_env_overrides(&mut self) {
198        // Helper: prefer AGENT_* over SPEC_AI_* if both present
199        fn first(a: &str, b: &str) -> Option<String> {
200            std::env::var(a).ok().or_else(|| std::env::var(b).ok())
201        }
202
203        if let Some(provider) = first("AGENT_MODEL_PROVIDER", "SPEC_AI_PROVIDER") {
204            self.model.provider = provider;
205        }
206        if let Some(model_name) = first("AGENT_MODEL_NAME", "SPEC_AI_MODEL") {
207            self.model.model_name = Some(model_name);
208        }
209        if let Some(api_key_source) = first("AGENT_API_KEY_SOURCE", "SPEC_AI_API_KEY_SOURCE") {
210            self.model.api_key_source = Some(api_key_source);
211        }
212        if let Some(temp_str) = first("AGENT_MODEL_TEMPERATURE", "SPEC_AI_TEMPERATURE") {
213            if let Ok(temp) = temp_str.parse::<f32>() {
214                self.model.temperature = temp;
215            }
216        }
217        if let Some(level) = first("AGENT_LOG_LEVEL", "SPEC_AI_LOG_LEVEL") {
218            self.logging.level = level;
219        }
220        if let Some(db_path) = first("AGENT_DB_PATH", "SPEC_AI_DB_PATH") {
221            self.database.path = PathBuf::from(db_path);
222        }
223        if let Some(theme) = first("AGENT_UI_THEME", "SPEC_AI_UI_THEME") {
224            self.ui.theme = theme;
225        }
226        if let Some(default_agent) = first("AGENT_DEFAULT_AGENT", "SPEC_AI_DEFAULT_AGENT") {
227            self.default_agent = Some(default_agent);
228        }
229    }
230
231    /// Get a summary of the configuration
232    pub fn summary(&self) -> String {
233        let mut summary = String::new();
234        summary.push_str("Configuration loaded:\n");
235        summary.push_str(&format!("Database: {}\n", self.database.path.display()));
236        summary.push_str(&format!("Model Provider: {}\n", self.model.provider));
237        if let Some(model) = &self.model.model_name {
238            summary.push_str(&format!("Model Name: {}\n", model));
239        }
240        summary.push_str(&format!("Temperature: {}\n", self.model.temperature));
241        summary.push_str(&format!("Logging Level: {}\n", self.logging.level));
242        summary.push_str(&format!("UI Theme: {}\n", self.ui.theme));
243        summary.push_str(&format!("Available Agents: {}\n", self.agents.len()));
244        if let Some(default) = &self.default_agent {
245            summary.push_str(&format!("Default Agent: {}\n", default));
246        }
247        summary
248    }
249}
250
251/// Database configuration
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct DatabaseConfig {
254    /// Path to the database file
255    pub path: PathBuf,
256}
257
258impl Default for DatabaseConfig {
259    fn default() -> Self {
260        Self {
261            path: PathBuf::from("spec-ai.duckdb"),
262        }
263    }
264}
265
266/// Model provider configuration
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct ModelConfig {
269    /// Provider name (e.g., "openai", "anthropic", "mlx", "lmstudio", "mock")
270    pub provider: String,
271    /// Model name to use (e.g., "gpt-4", "claude-3-opus")
272    #[serde(default)]
273    pub model_name: Option<String>,
274    /// Embeddings model name (optional, for semantic search)
275    #[serde(default)]
276    pub embeddings_model: Option<String>,
277    /// API key source (e.g., environment variable name or path)
278    #[serde(default)]
279    pub api_key_source: Option<String>,
280    /// Default temperature for model completions (0.0 to 2.0)
281    #[serde(default = "default_temperature")]
282    pub temperature: f32,
283}
284
285fn default_temperature() -> f32 {
286    0.7
287}
288
289impl Default for ModelConfig {
290    fn default() -> Self {
291        Self {
292            provider: "mock".to_string(),
293            model_name: None,
294            embeddings_model: None,
295            api_key_source: None,
296            temperature: default_temperature(),
297        }
298    }
299}
300
301/// UI configuration
302#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct UiConfig {
304    /// Command prompt string
305    pub prompt: String,
306    /// UI theme name
307    pub theme: String,
308}
309
310impl Default for UiConfig {
311    fn default() -> Self {
312        Self {
313            prompt: "> ".to_string(),
314            theme: "default".to_string(),
315        }
316    }
317}
318
319/// Logging configuration
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct LoggingConfig {
322    /// Log level (trace, debug, info, warn, error)
323    pub level: String,
324}
325
326impl Default for LoggingConfig {
327    fn default() -> Self {
328        Self {
329            level: "info".to_string(),
330        }
331    }
332}
333
334/// Mesh networking configuration
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct MeshConfig {
337    /// Enable mesh networking
338    #[serde(default)]
339    pub enabled: bool,
340    /// Registry port for mesh coordination
341    #[serde(default = "default_registry_port")]
342    pub registry_port: u16,
343    /// Heartbeat interval in seconds
344    #[serde(default = "default_heartbeat_interval")]
345    pub heartbeat_interval_secs: u64,
346    /// Leader timeout in seconds (how long before new election)
347    #[serde(default = "default_leader_timeout")]
348    pub leader_timeout_secs: u64,
349    /// Replication factor for knowledge graph
350    #[serde(default = "default_replication_factor")]
351    pub replication_factor: usize,
352    /// Auto-join mesh on startup
353    #[serde(default)]
354    pub auto_join: bool,
355}
356
357fn default_registry_port() -> u16 {
358    3000
359}
360
361fn default_heartbeat_interval() -> u64 {
362    5
363}
364
365fn default_leader_timeout() -> u64 {
366    15
367}
368
369fn default_replication_factor() -> usize {
370    2
371}
372
373impl Default for MeshConfig {
374    fn default() -> Self {
375        Self {
376            enabled: false,
377            registry_port: default_registry_port(),
378            heartbeat_interval_secs: default_heartbeat_interval(),
379            leader_timeout_secs: default_leader_timeout(),
380            replication_factor: default_replication_factor(),
381            auto_join: true,
382        }
383    }
384}
385
386/// Audio transcription configuration
387#[derive(Debug, Clone, Serialize, Deserialize)]
388pub struct AudioConfig {
389    /// Enable audio transcription
390    #[serde(default)]
391    pub enabled: bool,
392    /// Transcription provider (mock, vttrs)
393    #[serde(default = "default_transcription_provider")]
394    pub provider: String,
395    /// Transcription model (e.g., "whisper-1", "whisper-large-v3")
396    #[serde(default)]
397    pub model: Option<String>,
398    /// API key source for cloud transcription
399    #[serde(default)]
400    pub api_key_source: Option<String>,
401    /// Use on-device transcription (offline mode)
402    #[serde(default)]
403    pub on_device: bool,
404    /// Custom API endpoint (optional)
405    #[serde(default)]
406    pub endpoint: Option<String>,
407    /// Audio chunk duration in seconds
408    #[serde(default = "default_chunk_duration")]
409    pub chunk_duration_secs: f64,
410    /// Default transcription duration in seconds
411    #[serde(default = "default_duration")]
412    pub default_duration_secs: u64,
413    /// Default transcription duration in seconds (legacy field name)
414    #[serde(default = "default_duration")]
415    pub default_duration: u64,
416    /// Output file path for transcripts (optional)
417    #[serde(default)]
418    pub out_file: Option<String>,
419    /// Language code (e.g., "en", "es", "fr")
420    #[serde(default)]
421    pub language: Option<String>,
422    /// Whether to automatically respond to transcriptions
423    #[serde(default)]
424    pub auto_respond: bool,
425    /// Mock scenario for testing (e.g., "simple_conversation", "emotional_context")
426    #[serde(default = "default_mock_scenario")]
427    pub mock_scenario: String,
428    /// Delay between mock transcription events in milliseconds
429    #[serde(default = "default_event_delay_ms")]
430    pub event_delay_ms: u64,
431    /// Speak assistant responses aloud (macOS only, uses `say`)
432    #[serde(default)]
433    pub speak_responses: bool,
434}
435
436fn default_transcription_provider() -> String {
437    "vttrs".to_string()
438}
439
440fn default_chunk_duration() -> f64 {
441    5.0
442}
443
444fn default_duration() -> u64 {
445    30
446}
447
448fn default_mock_scenario() -> String {
449    "simple_conversation".to_string()
450}
451
452fn default_event_delay_ms() -> u64 {
453    500
454}
455
456impl Default for AudioConfig {
457    fn default() -> Self {
458        Self {
459            enabled: false,
460            provider: default_transcription_provider(),
461            model: Some("whisper-1".to_string()),
462            api_key_source: None,
463            on_device: false,
464            endpoint: None,
465            chunk_duration_secs: default_chunk_duration(),
466            default_duration_secs: default_duration(),
467            default_duration: default_duration(),
468            out_file: None,
469            language: None,
470            auto_respond: false,
471            mock_scenario: default_mock_scenario(),
472            event_delay_ms: default_event_delay_ms(),
473            speak_responses: false,
474        }
475    }
476}
477
478/// Plugin configuration for custom tools
479#[derive(Debug, Clone, Serialize, Deserialize)]
480pub struct PluginConfig {
481    /// Enable plugin loading
482    #[serde(default)]
483    pub enabled: bool,
484
485    /// Directory containing plugin libraries (.dylib/.so/.dll)
486    #[serde(default = "default_plugins_dir")]
487    pub custom_tools_dir: PathBuf,
488
489    /// Continue startup even if some plugins fail to load
490    #[serde(default = "default_continue_on_error")]
491    pub continue_on_error: bool,
492
493    /// Allow plugins to override built-in tools
494    #[serde(default)]
495    pub allow_override_builtin: bool,
496}
497
498fn default_plugins_dir() -> PathBuf {
499    PathBuf::from("~/.spec-ai/tools")
500}
501
502fn default_continue_on_error() -> bool {
503    true
504}
505
506impl Default for PluginConfig {
507    fn default() -> Self {
508        Self {
509            enabled: false,
510            custom_tools_dir: default_plugins_dir(),
511            continue_on_error: true,
512            allow_override_builtin: false,
513        }
514    }
515}
516
517/// HTTP API authentication configuration
518#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct AuthConfig {
520    /// Enable authentication for the HTTP API
521    #[serde(default)]
522    pub enabled: bool,
523
524    /// Path to JSON file containing user credentials
525    /// The file should contain an array of objects with "username" and "password_hash" fields
526    /// Password hashes should be created using bcrypt
527    #[serde(default)]
528    pub credentials_file: Option<PathBuf>,
529
530    /// Token expiration time in seconds (default: 24 hours)
531    #[serde(default = "default_token_expiry")]
532    pub token_expiry_secs: u64,
533
534    /// Secret key for signing tokens (if not set, a random key is generated at startup)
535    /// Can be set via environment variable for consistency across restarts
536    #[serde(default)]
537    pub token_secret: Option<String>,
538}
539
540fn default_token_expiry() -> u64 {
541    86400 // 24 hours
542}
543
544impl Default for AuthConfig {
545    fn default() -> Self {
546        Self {
547            enabled: false,
548            credentials_file: None,
549            token_expiry_secs: default_token_expiry(),
550            token_secret: None,
551        }
552    }
553}
554
555/// Graph synchronization configuration
556#[derive(Debug, Clone, Serialize, Deserialize)]
557pub struct SyncConfig {
558    /// Enable graph synchronization
559    #[serde(default)]
560    pub enabled: bool,
561
562    /// How often to check for sync opportunities (in seconds)
563    #[serde(default = "default_sync_interval")]
564    pub interval_secs: u64,
565
566    /// Maximum number of concurrent sync operations
567    #[serde(default = "default_max_concurrent_syncs")]
568    pub max_concurrent_syncs: usize,
569
570    /// Retry interval for failed syncs (in seconds)
571    #[serde(default = "default_retry_interval")]
572    pub retry_interval_secs: u64,
573
574    /// Maximum number of retry attempts
575    #[serde(default = "default_max_retries")]
576    pub max_retries: usize,
577
578    /// Graph namespaces to sync automatically on startup
579    #[serde(default)]
580    pub namespaces: Vec<SyncNamespace>,
581}
582
583/// A graph namespace to participate in synchronization
584#[derive(Debug, Clone, Serialize, Deserialize)]
585pub struct SyncNamespace {
586    /// Session ID (namespace) for the graph
587    pub session_id: String,
588    /// Graph name within the session (defaults to "default")
589    #[serde(default = "default_graph_name")]
590    pub graph_name: String,
591}
592
593fn default_sync_interval() -> u64 {
594    60
595}
596
597fn default_max_concurrent_syncs() -> usize {
598    3
599}
600
601fn default_retry_interval() -> u64 {
602    300
603}
604
605fn default_max_retries() -> usize {
606    3
607}
608
609fn default_graph_name() -> String {
610    "default".to_string()
611}
612
613impl Default for SyncConfig {
614    fn default() -> Self {
615        Self {
616            enabled: false,
617            interval_secs: default_sync_interval(),
618            max_concurrent_syncs: default_max_concurrent_syncs(),
619            retry_interval_secs: default_retry_interval(),
620            max_retries: default_max_retries(),
621            namespaces: Vec::new(),
622        }
623    }
624}