// Configuration for poc-memory // // Primary config: ~/.config/poc-agent/config.json5 (shared with poc-agent) // Memory-specific settings live in the "memory" section. // API backend resolved from the shared "models" + backend configs. // // Fallback: ~/.config/poc-memory/config.jsonl (legacy, still supported) // Env override: POC_MEMORY_CONFIG // // The shared config eliminates API credential duplication between // poc-memory and poc-agent. use std::path::PathBuf; use std::sync::{Arc, OnceLock, RwLock}; static CONFIG: OnceLock>> = OnceLock::new(); #[derive(Debug, Clone, PartialEq)] pub enum ContextSource { Store, File, Journal, } #[derive(Debug, Clone)] pub struct ContextGroup { pub label: String, pub keys: Vec, pub source: ContextSource, } #[derive(Debug, Clone)] pub struct Config { /// Display name for the human user in transcripts/prompts. pub user_name: String, /// Display name for the AI assistant. pub assistant_name: String, /// Base directory for memory data (store, logs, status). pub data_dir: PathBuf, /// Directory containing Claude session transcripts. pub projects_dir: PathBuf, /// Core node keys that should never be decayed/deleted. pub core_nodes: Vec, /// How many days of journal to include in load-context. pub journal_days: u32, /// Max journal entries to include in load-context. pub journal_max: usize, /// Ordered context groups for session-start loading. pub context_groups: Vec, /// Max concurrent LLM calls in the daemon. pub llm_concurrency: usize, /// Total agent runs per consolidation cycle. pub agent_budget: usize, /// Directory containing prompt templates for agents. pub prompts_dir: PathBuf, /// Separate Claude config dir for background agent work (daemon jobs). pub agent_config_dir: Option, /// OpenAI-compatible API base URL for direct LLM calls. pub api_base_url: Option, /// API key for the direct API endpoint. pub api_key: Option, /// Model name to use with the direct API endpoint. pub api_model: Option, /// Reasoning effort for API calls ("none", "low", "medium", "high"). pub api_reasoning: String, /// Active agent types for consolidation cycles. pub agent_types: 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(".claude/memory"), 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, }, ContextGroup { label: "core-practices".into(), keys: vec!["core-practices".into()], source: ContextSource::Store, }, ], llm_concurrency: 1, agent_budget: 1000, prompts_dir: home.join("poc/memory/prompts"), agent_config_dir: None, api_base_url: None, api_key: None, api_model: None, api_reasoning: "high".to_string(), agent_types: vec![ "linker".into(), "organize".into(), "distill".into(), "separator".into(), "split".into(), ], } } } impl Config { fn load_from_file() -> Self { // Try shared config first, then legacy JSONL if let Some(config) = Self::try_load_shared() { return config; } Self::load_legacy_jsonl() } /// Load from shared poc-agent config (~/.config/poc-agent/config.json5). /// Memory settings live in the "memory" section; API settings are /// resolved from the shared model/backend configuration. fn try_load_shared() -> Option { let home = PathBuf::from(std::env::var("HOME").ok()?); let path = home.join(".config/poc-agent/config.json5"); let content = std::fs::read_to_string(&path).ok()?; let root: serde_json::Value = json5::from_str(&content).ok()?; let mem = root.get("memory")?; let mut config = Config::default(); // Memory-specific fields if let Some(s) = mem.get("user_name").and_then(|v| v.as_str()) { config.user_name = s.to_string(); } if let Some(s) = mem.get("assistant_name").and_then(|v| v.as_str()) { config.assistant_name = s.to_string(); } if let Some(s) = mem.get("data_dir").and_then(|v| v.as_str()) { config.data_dir = expand_home(s); } if let Some(s) = mem.get("projects_dir").and_then(|v| v.as_str()) { config.projects_dir = expand_home(s); } if let Some(arr) = mem.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) = mem.get("journal_days").and_then(|v| v.as_u64()) { config.journal_days = d as u32; } if let Some(m) = mem.get("journal_max").and_then(|v| v.as_u64()) { config.journal_max = m as usize; } if let Some(n) = mem.get("llm_concurrency").and_then(|v| v.as_u64()) { config.llm_concurrency = n.max(1) as usize; } if let Some(n) = mem.get("agent_budget").and_then(|v| v.as_u64()) { config.agent_budget = n as usize; } if let Some(s) = mem.get("prompts_dir").and_then(|v| v.as_str()) { config.prompts_dir = expand_home(s); } if let Some(s) = mem.get("agent_config_dir").and_then(|v| v.as_str()) { config.agent_config_dir = Some(expand_home(s)); } // Context groups if let Some(groups) = mem.get("context_groups").and_then(|v| v.as_array()) { let mut cgs = Vec::new(); for g in groups { if let Some(label) = g.get("label").and_then(|v| v.as_str()) { let keys = g.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 g.get("source").and_then(|v| v.as_str()) { Some("file") => ContextSource::File, Some("journal") => ContextSource::Journal, _ => ContextSource::Store, }; cgs.push(ContextGroup { label: label.to_string(), keys, source }); } } if !cgs.is_empty() { config.context_groups = cgs; } } if let Some(s) = mem.get("api_reasoning").and_then(|v| v.as_str()) { config.api_reasoning = s.to_string(); } if let Some(arr) = mem.get("agent_types").and_then(|v| v.as_array()) { let types: Vec = arr.iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect(); if !types.is_empty() { config.agent_types = types; } } // Resolve API settings from the shared model/backend config. // memory.agent_model references a named model; we look up its // backend to get base_url and api_key. if let Some(model_name) = mem.get("agent_model").and_then(|v| v.as_str()) { if let Some(model_cfg) = root.get("models") .and_then(|m| m.get(model_name)) { 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(|s| s.to_string()); config.api_key = backend.get("api_key") .and_then(|v| v.as_str()) .map(|s| s.to_string()); } 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, }; context_groups.push(ContextGroup { label: label.to_string(), keys, source }); } } if !context_groups.is_empty() { config.context_groups = context_groups; } config } } 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) } } /// Get the global 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 }