// config.rs — Unified configuration // // Single config file: ~/.config/poc-agent/config.json5 // Memory settings in the "memory" section (Config) // Agent/backend settings at top level (AppConfig) // // Legacy fallback: ~/.config/poc-memory/config.jsonl // Env override: POC_MEMORY_CONFIG use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, OnceLock, RwLock}; use anyhow::{Context as _, Result}; use figment::providers::Serialized; use figment::{Figment, Provider}; use serde::{Deserialize, Serialize}; /// Config file path shared by all loaders. pub fn config_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".config/poc-agent/config.json5") } // ============================================================ // Memory config (the "memory" section) // ============================================================ static CONFIG: OnceLock>> = OnceLock::new(); #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(rename_all = "lowercase")] #[derive(Default)] pub enum ContextSource { #[serde(alias = "")] #[default] Store, File, Journal, } #[derive(Debug, Clone, Deserialize)] pub struct ContextGroup { pub label: String, #[serde(default)] pub keys: Vec, #[serde(default)] pub source: ContextSource, /// Include this group in agent context (default true) #[serde(default = "default_true")] pub agent: bool, } fn default_true() -> bool { true } #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct Config { pub user_name: String, pub assistant_name: String, #[serde(deserialize_with = "deserialize_path")] pub data_dir: PathBuf, #[serde(deserialize_with = "deserialize_path")] pub projects_dir: PathBuf, pub core_nodes: Vec, pub journal_days: u32, pub journal_max: usize, pub context_groups: Vec, pub llm_concurrency: usize, pub agent_budget: usize, #[serde(deserialize_with = "deserialize_path")] pub prompts_dir: PathBuf, #[serde(default, deserialize_with = "deserialize_path_opt")] pub agent_config_dir: Option, /// Resolved from agent_model → models → backend (not in config directly) #[serde(skip)] pub api_base_url: Option, #[serde(skip)] pub api_key: Option, #[serde(skip)] pub api_model: Option, /// Used to resolve API settings, not stored on Config #[serde(default)] agent_model: Option, pub api_reasoning: String, pub agent_types: Vec, /// Surface agent timeout in seconds. #[serde(default)] pub surface_timeout_secs: Option, /// Max conversation bytes to include in surface agent context. #[serde(default)] pub surface_conversation_bytes: Option, /// Hook events that trigger the surface agent. #[serde(default)] pub surface_hooks: Vec, } impl Default for Config { fn default() -> Self { let home = PathBuf::from(std::env::var("HOME").expect("HOME not set")); Self { user_name: "User".to_string(), assistant_name: "Assistant".to_string(), data_dir: home.join(".consciousness"), projects_dir: home.join(".claude/projects"), core_nodes: vec!["identity".to_string(), "core-practices".to_string()], journal_days: 7, journal_max: 20, context_groups: vec![ ContextGroup { label: "identity".into(), keys: vec!["identity".into()], source: ContextSource::Store, agent: true, }, ContextGroup { label: "core-practices".into(), keys: vec!["core-practices".into()], source: ContextSource::Store, agent: true, }, ], llm_concurrency: 1, agent_budget: 1000, prompts_dir: home.join("poc/consciousness/src/subconscious/prompts"), agent_config_dir: None, api_base_url: None, api_key: None, api_model: None, agent_model: None, api_reasoning: "high".to_string(), agent_types: vec![ "linker".into(), "organize".into(), "distill".into(), "separator".into(), "split".into(), ], surface_timeout_secs: None, surface_conversation_bytes: None, surface_hooks: vec![], } } } impl Config { fn load_from_file() -> Self { if let Some(config) = Self::try_load_shared() { return config; } Self::load_legacy_jsonl() } /// Load from shared config. Memory settings in the "memory" section; /// API settings resolved from models + backend configuration. fn try_load_shared() -> Option { let content = std::fs::read_to_string(config_path()).ok()?; let root: serde_json::Value = json5::from_str(&content).ok()?; let mem_value = root.get("memory")?; let mut config: Config = serde_json::from_value(mem_value.clone()).ok()?; config.llm_concurrency = config.llm_concurrency.max(1); // Resolve API settings: agent_model → models → backend if let Some(model_name) = &config.agent_model && let Some(model_cfg) = root.get("models").and_then(|m| m.get(model_name.as_str())) { let backend_name = model_cfg.get("backend").and_then(|v| v.as_str()).unwrap_or(""); let model_id = model_cfg.get("model_id").and_then(|v| v.as_str()).unwrap_or(""); if let Some(backend) = root.get(backend_name) { config.api_base_url = backend.get("base_url") .and_then(|v| v.as_str()).map(String::from); config.api_key = backend.get("api_key") .and_then(|v| v.as_str()).map(String::from); } config.api_model = Some(model_id.to_string()); } Some(config) } /// Load from legacy JSONL config (~/.config/poc-memory/config.jsonl). fn load_legacy_jsonl() -> Self { let path = std::env::var("POC_MEMORY_CONFIG") .map(PathBuf::from) .unwrap_or_else(|_| { PathBuf::from(std::env::var("HOME").expect("HOME not set")) .join(".config/poc-memory/config.jsonl") }); let mut config = Config::default(); let Ok(content) = std::fs::read_to_string(&path) else { return config; }; let mut context_groups: Vec = Vec::new(); let stream = serde_json::Deserializer::from_str(&content) .into_iter::(); for result in stream { let Ok(obj) = result else { continue }; if let Some(cfg) = obj.get("config") { if let Some(s) = cfg.get("user_name").and_then(|v| v.as_str()) { config.user_name = s.to_string(); } if let Some(s) = cfg.get("assistant_name").and_then(|v| v.as_str()) { config.assistant_name = s.to_string(); } if let Some(s) = cfg.get("data_dir").and_then(|v| v.as_str()) { config.data_dir = expand_home(s); } if let Some(s) = cfg.get("projects_dir").and_then(|v| v.as_str()) { config.projects_dir = expand_home(s); } if let Some(arr) = cfg.get("core_nodes").and_then(|v| v.as_array()) { config.core_nodes = arr.iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect(); } if let Some(d) = cfg.get("journal_days").and_then(|v| v.as_u64()) { config.journal_days = d as u32; } if let Some(m) = cfg.get("journal_max").and_then(|v| v.as_u64()) { config.journal_max = m as usize; } if let Some(n) = cfg.get("llm_concurrency").and_then(|v| v.as_u64()) { config.llm_concurrency = n.max(1) as usize; } if let Some(n) = cfg.get("agent_budget").and_then(|v| v.as_u64()) { config.agent_budget = n as usize; } if let Some(s) = cfg.get("prompts_dir").and_then(|v| v.as_str()) { config.prompts_dir = expand_home(s); } if let Some(s) = cfg.get("agent_config_dir").and_then(|v| v.as_str()) { config.agent_config_dir = Some(expand_home(s)); } if let Some(s) = cfg.get("api_base_url").and_then(|v| v.as_str()) { config.api_base_url = Some(s.to_string()); } if let Some(s) = cfg.get("api_key").and_then(|v| v.as_str()) { config.api_key = Some(s.to_string()); } if let Some(s) = cfg.get("api_model").and_then(|v| v.as_str()) { config.api_model = Some(s.to_string()); } continue; } if let Some(label) = obj.get("group").and_then(|v| v.as_str()) { let keys = obj.get("keys") .and_then(|v| v.as_array()) .map(|arr| arr.iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect()) .unwrap_or_default(); let source = match obj.get("source").and_then(|v| v.as_str()) { Some("file") => ContextSource::File, Some("journal") => ContextSource::Journal, _ => ContextSource::Store, }; let agent = obj.get("agent").and_then(|v| v.as_bool()).unwrap_or(true); context_groups.push(ContextGroup { label: label.to_string(), keys, source, agent }); } } if !context_groups.is_empty() { config.context_groups = context_groups; } config } } /// Get the global memory config (cheap Arc clone). pub fn get() -> Arc { CONFIG .get_or_init(|| RwLock::new(Arc::new(Config::load_from_file()))) .read() .unwrap() .clone() } /// Reload the config from disk. Returns true if changed. pub fn reload() -> bool { let lock = CONFIG.get_or_init(|| RwLock::new(Arc::new(Config::load_from_file()))); let new = Config::load_from_file(); let mut current = lock.write().unwrap(); let changed = format!("{:?}", **current) != format!("{:?}", new); if changed { *current = Arc::new(new); } changed } // ============================================================ // Agent config (top-level settings) // ============================================================ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { pub backend: String, pub anthropic: BackendConfig, pub openrouter: BackendConfig, #[serde(default)] pub deepinfra: BackendConfig, pub prompts: PromptConfig, pub debug: bool, pub compaction: CompactionConfig, pub dmn: DmnConfig, #[serde(skip_serializing_if = "Option::is_none")] pub memory_project: Option, #[serde(skip_serializing_if = "Option::is_none")] pub system_prompt_file: Option, #[serde(default)] pub models: HashMap, #[serde(default = "default_model_name")] pub default_model: String, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct BackendConfig { #[serde(default)] pub api_key: String, #[serde(default)] pub model: String, #[serde(skip_serializing_if = "Option::is_none")] pub base_url: Option, } impl BackendConfig { fn resolve(&self, default_base: &str) -> Result<(String, String, String)> { if self.api_key.is_empty() { anyhow::bail!( "No API key. Set it in {} or use --api-key", config_path().display() ); } let base = self.base_url.clone() .unwrap_or_else(|| default_base.to_string()); Ok((base, self.api_key.clone(), self.model.clone())) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PromptConfig { pub anthropic: String, pub other: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CompactionConfig { pub hard_threshold_pct: u32, pub soft_threshold_pct: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DmnConfig { pub max_turns: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ModelConfig { /// Backend name ("anthropic" or "openrouter") pub backend: String, /// Model identifier sent to the API pub model_id: String, /// Instruction file ("CLAUDE.md" or "POC.md"). #[serde(default)] pub prompt_file: Option, /// Context window size in tokens. #[serde(default)] pub context_window: Option, } impl Default for AppConfig { fn default() -> Self { Self { backend: "openrouter".to_string(), anthropic: BackendConfig { api_key: String::new(), model: "claude-opus-4-6-20250918".to_string(), base_url: None, }, openrouter: BackendConfig { api_key: String::new(), model: "qwen/qwen3.5-397b-a17b".to_string(), base_url: Some("https://openrouter.ai/api/v1".to_string()), }, deepinfra: BackendConfig { api_key: String::new(), model: String::new(), base_url: Some("https://api.deepinfra.com/v1/openai".to_string()), }, prompts: PromptConfig { anthropic: "CLAUDE.md".to_string(), other: "POC.md".to_string(), }, debug: false, compaction: CompactionConfig { hard_threshold_pct: 90, soft_threshold_pct: 80, }, dmn: DmnConfig { max_turns: 20 }, memory_project: None, system_prompt_file: None, models: HashMap::new(), default_model: String::new(), } } } fn default_model_name() -> String { String::new() } /// Resolved, ready-to-use agent session config. pub struct SessionConfig { pub api_base: String, pub api_key: String, pub model: String, pub prompt_file: String, pub system_prompt: String, /// Identity/personality files as (name, content) pairs. pub context_parts: Vec<(String, String)>, pub config_file_count: usize, pub memory_file_count: usize, pub session_dir: PathBuf, pub app: AppConfig, } impl SessionConfig { /// Join context parts into a single string for legacy interfaces. #[allow(dead_code)] pub fn context_message(&self) -> String { self.context_parts.iter() .map(|(name, content)| format!("## {}\n\n{}", name, content)) .collect::>() .join("\n\n---\n\n") } } /// A fully resolved model ready to construct an ApiClient. #[allow(dead_code)] pub struct ResolvedModel { pub name: String, pub api_base: String, pub api_key: String, pub model_id: String, pub prompt_file: String, pub context_window: Option, } impl AppConfig { /// Resolve the active backend and assemble prompts into a SessionConfig. pub fn resolve(&self, cli: &crate::agent::cli::CliArgs) -> Result { let cwd = std::env::current_dir().context("Failed to get current directory")?; let (api_base, api_key, model, prompt_file); if !self.models.is_empty() { let resolved = self.resolve_model(&self.default_model)?; api_base = resolved.api_base; api_key = resolved.api_key; model = resolved.model_id; prompt_file = resolved.prompt_file; } else { let (base, key, mdl) = match self.backend.as_str() { "anthropic" => self.anthropic.resolve("https://api.anthropic.com"), _ => self.openrouter.resolve("https://openrouter.ai/api/v1"), }?; api_base = base; api_key = key; model = mdl; prompt_file = if is_anthropic_model(&model) { self.prompts.anthropic.clone() } else { self.prompts.other.clone() }; } let context_groups = get().context_groups.clone(); let (system_prompt, context_parts, config_file_count, memory_file_count) = if let Some(ref path) = cli.system_prompt_file.as_ref().or(self.system_prompt_file.as_ref()) { let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; (content, Vec::new(), 0, 0) } else { let system_prompt = crate::agent::identity::assemble_system_prompt(); let (context_parts, cc, mc) = crate::agent::identity::assemble_context_message(&cwd, &prompt_file, self.memory_project.as_deref(), &context_groups)?; (system_prompt, context_parts, cc, mc) }; let session_dir = dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".cache/poc-agent/sessions"); std::fs::create_dir_all(&session_dir).ok(); Ok(SessionConfig { api_base, api_key, model, prompt_file, system_prompt, context_parts, config_file_count, memory_file_count, session_dir, app: self.clone(), }) } /// Look up a named model and resolve its credentials from the backend config. pub fn resolve_model(&self, name: &str) -> Result { let model = self.models.get(name) .ok_or_else(|| anyhow::anyhow!( "Unknown model '{}'. Available: {}", name, self.model_names().join(", "), ))?; let (api_base, api_key) = match model.backend.as_str() { "anthropic" => ( self.anthropic.base_url.clone() .unwrap_or_else(|| "https://api.anthropic.com".to_string()), self.anthropic.api_key.clone(), ), "deepinfra" => ( self.deepinfra.base_url.clone() .unwrap_or_else(|| "https://api.deepinfra.com/v1/openai".to_string()), self.deepinfra.api_key.clone(), ), _ => ( self.openrouter.base_url.clone() .unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string()), self.openrouter.api_key.clone(), ), }; let prompt_file = model.prompt_file.clone() .unwrap_or_else(|| { if is_anthropic_model(&model.model_id) { self.prompts.anthropic.clone() } else { self.prompts.other.clone() } }); Ok(ResolvedModel { name: name.to_string(), api_base, api_key, model_id: model.model_id.clone(), prompt_file, context_window: model.context_window, }) } /// List available model names, sorted. pub fn model_names(&self) -> Vec { let mut names: Vec<_> = self.models.keys().cloned().collect(); names.sort(); names } } // ============================================================ // Figment-based agent config loading // ============================================================ struct Json5File(PathBuf); impl Provider for Json5File { fn metadata(&self) -> figment::Metadata { figment::Metadata::named(format!("JSON5 file ({})", self.0.display())) } fn data(&self) -> figment::Result> { match std::fs::read_to_string(&self.0) { Ok(content) => { let value: figment::value::Value = json5::from_str(&content) .map_err(|e| figment::Error::from(format!("{}: {}", self.0.display(), e)))?; Serialized::defaults(value).data() } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(figment::value::Map::new()), Err(e) => Err(figment::Error::from(format!("{}: {}", self.0.display(), e))), } } } macro_rules! merge_opt { ($fig:expr, $val:expr, $($key:expr),+) => { if let Some(ref v) = $val { $( $fig = $fig.merge(Serialized::default($key, v)); )+ } }; } fn build_figment(cli: &crate::agent::cli::CliArgs) -> Figment { let mut f = Figment::from(Serialized::defaults(AppConfig::default())) .merge(Json5File(config_path())); merge_opt!(f, cli.backend, "backend"); merge_opt!(f, cli.model, "anthropic.model", "openrouter.model"); merge_opt!(f, cli.api_key, "anthropic.api_key", "openrouter.api_key"); merge_opt!(f, cli.api_base, "anthropic.base_url", "openrouter.base_url"); merge_opt!(f, cli.system_prompt_file, "system_prompt_file"); merge_opt!(f, cli.memory_project, "memory_project"); merge_opt!(f, cli.dmn_max_turns, "dmn.max_turns"); if cli.debug { f = f.merge(Serialized::default("debug", true)); } f } /// Load just the AppConfig — no validation, no prompt assembly. pub fn load_app(cli: &crate::agent::cli::CliArgs) -> Result<(AppConfig, Figment)> { let figment = build_figment(cli); let app: AppConfig = figment.extract().context("Failed to load configuration")?; Ok((app, figment)) } /// Load the full config: figment → AppConfig → resolve backend → assemble prompts. pub fn load_session(cli: &crate::agent::cli::CliArgs) -> Result<(SessionConfig, Figment)> { let (app, figment) = load_app(cli)?; let config = app.resolve(cli)?; Ok((config, figment)) } /// Re-assemble prompts for a specific model's prompt file. pub fn reload_for_model(app: &AppConfig, prompt_file: &str) -> Result<(String, Vec<(String, String)>)> { let cwd = std::env::current_dir().context("Failed to get current directory")?; if let Some(ref path) = app.system_prompt_file { let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; return Ok((content, Vec::new())); } let system_prompt = crate::agent::identity::assemble_system_prompt(); let context_groups = get().context_groups.clone(); let (context_parts, _, _) = crate::agent::identity::assemble_context_message(&cwd, prompt_file, app.memory_project.as_deref(), &context_groups)?; Ok((system_prompt, context_parts)) } fn is_anthropic_model(model: &str) -> bool { let m = model.to_lowercase(); m.contains("claude") || m.contains("opus") || m.contains("sonnet") } pub fn show_config(app: &AppConfig, figment: &Figment) { fn mask(key: &str) -> String { if key.is_empty() { "(not set)".into() } else if key.len() <= 8 { "****".into() } else { format!("{}...{}", &key[..4], &key[key.len() - 4..]) } } fn src(figment: &Figment, key: &str) -> String { figment.find_metadata(key).map_or("default".into(), |m| m.name.to_string()) } println!("# Effective configuration\n"); println!("backend: {:?} ({})", app.backend, src(figment, "backend")); for (name, b) in [("anthropic", &app.anthropic), ("openrouter", &app.openrouter)] { println!("\n{}:", name); println!(" api_key: {} ({})", mask(&b.api_key), src(figment, &format!("{name}.api_key"))); println!(" model: {:?} ({})", b.model, src(figment, &format!("{name}.model"))); if let Some(ref url) = b.base_url { println!(" base_url: {:?} ({})", url, src(figment, &format!("{name}.base_url"))); } } println!("\nprompts:"); println!(" anthropic: {:?} ({})", app.prompts.anthropic, src(figment, "prompts.anthropic")); println!(" other: {:?} ({})", app.prompts.other, src(figment, "prompts.other")); println!("\ndebug: {} ({})", app.debug, src(figment, "debug")); println!("\ncompaction:"); println!(" hard_threshold_pct: {} ({})", app.compaction.hard_threshold_pct, src(figment, "compaction.hard_threshold_pct")); println!(" soft_threshold_pct: {} ({})", app.compaction.soft_threshold_pct, src(figment, "compaction.soft_threshold_pct")); println!("\ndmn:"); println!(" max_turns: {} ({})", app.dmn.max_turns, src(figment, "dmn.max_turns")); if let Some(ref p) = app.system_prompt_file { println!("\nsystem_prompt_file: {:?} ({})", p, src(figment, "system_prompt_file")); } if let Some(ref p) = app.memory_project { println!("\nmemory_project: {:?} ({})", p, src(figment, "memory_project")); } println!("\ndefault_model: {:?}", app.default_model); if !app.models.is_empty() { println!("\nmodels:"); for (name, m) in &app.models { println!(" {}:", name); println!(" backend: {:?}", m.backend); println!(" model_id: {:?}", m.model_id); if let Some(ref pf) = m.prompt_file { println!(" prompt_file: {:?}", pf); } if let Some(cw) = m.context_window { println!(" context_window: {}", cw); } } } } // ============================================================ // Helpers // ============================================================ fn deserialize_path<'de, D: serde::Deserializer<'de>>(d: D) -> Result { let s: String = serde::Deserialize::deserialize(d)?; Ok(expand_home(&s)) } fn deserialize_path_opt<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { let s: Option = serde::Deserialize::deserialize(d)?; Ok(s.map(|s| expand_home(&s))) } pub fn expand_home(path: &str) -> PathBuf { if let Some(rest) = path.strip_prefix("~/") { PathBuf::from(std::env::var("HOME").expect("HOME not set")).join(rest) } else { PathBuf::from(path) } }