cortex-agent 0.3.1

Self-learning AI agent with persistent memory, tools, plugins, and a beautiful terminal UI
use std::collections::HashMap;
use std::path::Path;

use serde::Deserialize;

/// A single provider configuration entry.
#[derive(Debug, Clone, Deserialize)]
pub struct ProviderEntry {
    #[serde(default)]
    pub api_key: String,
    #[serde(default = "default_base_url")]
    pub base_url: String,
}

fn default_base_url() -> String {
    "https://api.openai.com/v1".to_string()
}

/// Runtime configuration — supports multiple providers.
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Config {
    pub active_provider: String,
    pub active_model: String,
    pub providers: HashMap<String, ProviderEntry>,

    // Legacy single-provider fallback
    pub provider: String,
    pub model: String,
    pub api_key: String,
    pub base_url: String,

    pub system_prompt: String,
    pub max_tokens: Option<u32>,
    pub max_iterations: u32,
    pub temperature: f32,

    pub tool_modules: Option<String>,
    pub memory_enabled: bool,
    pub memory_dir: Option<String>,
    pub memory_db: Option<String>,

    pub verbose: bool,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            active_provider: "default".into(),
            active_model: "gpt-4o".into(),
            providers: HashMap::new(),
            provider: "openai".into(),
            model: "gpt-4o".into(),
            api_key: String::new(),
            base_url: default_base_url(),
            system_prompt: "You are Cortex, an autonomous agent. Answer from your own knowledge first.".into(),
            max_tokens: None,
            max_iterations: 10,
            temperature: 0.7,
            tool_modules: Some("cortex.tools.core,cortex.tools.memory_tools,cortex.tools.skill_tools,cortex.tools.self_tools".into()),
            memory_enabled: true,
            memory_dir: None,
            memory_db: None,
            verbose: false,
        }
    }
}

/// Resolve `${ENV_VAR}` references in strings, recursing into maps/lists.
fn resolve_env_vars(value: serde_json::Value) -> serde_json::Value {
    match value {
        serde_json::Value::String(s) => {
            let mut result = s;
            while let Some(start) = result.find("${") {
                if let Some(end) = result[start..].find('}') {
                    let var_name = &result[start + 2..start + end];
                    let env_val = std::env::var(var_name).unwrap_or_default();
                    result.replace_range(start..=start + end, &env_val);
                } else {
                    break;
                }
            }
            serde_json::Value::String(result)
        }
        serde_json::Value::Object(map) => {
            let new_map: serde_json::Map<String, serde_json::Value> = map
                .into_iter()
                .map(|(k, v)| (k, resolve_env_vars(v)))
                .collect();
            serde_json::Value::Object(new_map)
        }
        serde_json::Value::Array(arr) => {
            serde_json::Value::Array(arr.into_iter().map(resolve_env_vars).collect())
        }
        other => other,
    }
}

impl Config {
    /// Load config from a YAML file.
    pub fn from_yaml(path: &str) -> anyhow::Result<Self> {
        let path = Path::new(path);
        if !path.exists() {
            return Ok(Self::default());
        }
        let contents = std::fs::read_to_string(path)?;
        let raw: serde_json::Value = serde_yaml::from_str(&contents)?;
        let resolved = resolve_env_vars(raw);
        let config: Self = serde_json::from_value(resolved)?;
        Ok(config)
    }

    /// Return (provider_name, api_key, base_url) for the active provider.
    pub fn get_active_provider_config(&self) -> (String, String, String) {
        let name = &self.active_provider;

        // Look in multi-provider map
        if let Some(entry) = self.providers.get(name) {
            return (name.clone(), entry.api_key.clone(), entry.base_url.clone());
        }

        // Fallback to legacy fields
        (self.provider.clone(), self.api_key.clone(), self.base_url.clone())
    }

    /// Return list of available provider names.
    pub fn get_provider_names(&self) -> Vec<String> {
        let names: Vec<String> = self.providers.keys().cloned().collect();
        if names.is_empty() {
            vec![if self.provider.is_empty() {
                "default".into()
            } else {
                self.provider.clone()
            }]
        } else {
            names
        }
    }

    #[allow(dead_code)]
    pub fn provider_summary(&self) -> String {
        let (_name, _key, url) = self.get_active_provider_config();
        let short_url = url
            .replace("https://", "")
            .split('/')
            .next()
            .unwrap_or(&url)
            .to_string();
        format!("{} (via {})", self.active_model, short_url)
    }

    pub fn memory_dir_resolved(&self) -> String {
        self.memory_dir
            .clone()
            .unwrap_or_else(|| {
                let home = std::env::var("HOME").unwrap_or_else(|_| "~".into());
                format!("{}/.cortex/memory", home)
            })
            .replace('~', &std::env::var("HOME").unwrap_or_default())
    }

    pub fn memory_db_resolved(&self) -> String {
        self.memory_db.clone().unwrap_or_else(|| "cortex.db".into())
    }
}