From 378a09a9f8156518055f549a27dae969fed69f6d Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Fri, 20 Mar 2026 14:10:57 -0400 Subject: [PATCH] config: derive Deserialize on Config, eliminate manual field extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config now derives serde::Deserialize with #[serde(default)] for all fields. Path fields use custom deserialize_path/deserialize_path_opt for ~ expansion. ContextGroup and ContextSource also derive Deserialize. try_load_shared() is now 20 lines instead of 100: json5 → serde → Config directly, then resolve API settings from the model/backend cross-reference. Removes MemoryConfigRaw intermediate struct entirely. Co-Authored-By: Claude Opus 4.6 (1M context) --- poc-memory/src/config.rs | 154 ++++++++++++--------------------------- 1 file changed, 45 insertions(+), 109 deletions(-) diff --git a/poc-memory/src/config.rs b/poc-memory/src/config.rs index 66d1bcf..0c90fda 100644 --- a/poc-memory/src/config.rs +++ b/poc-memory/src/config.rs @@ -15,55 +15,58 @@ use std::sync::{Arc, OnceLock, RwLock}; static CONFIG: OnceLock>> = OnceLock::new(); -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, serde::Deserialize)] +#[serde(rename_all = "lowercase")] pub enum ContextSource { + #[serde(alias = "")] Store, File, Journal, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Deserialize)] pub struct ContextGroup { pub label: String, + #[serde(default)] pub keys: Vec, + #[serde(default)] pub source: ContextSource, } -#[derive(Debug, Clone)] +impl Default for ContextSource { + fn default() -> Self { Self::Store } +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(default)] 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). + #[serde(deserialize_with = "deserialize_path")] pub data_dir: PathBuf, - /// Directory containing Claude session transcripts. + #[serde(deserialize_with = "deserialize_path")] 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. + #[serde(deserialize_with = "deserialize_path")] pub prompts_dir: PathBuf, - /// Separate Claude config dir for background agent work (daemon jobs). + #[serde(default, deserialize_with = "deserialize_path_opt")] pub agent_config_dir: Option, - /// OpenAI-compatible API base URL for direct LLM calls. + /// Resolved from agent_model → models → backend (not in config directly) + #[serde(skip)] pub api_base_url: Option, - /// API key for the direct API endpoint. + #[serde(skip)] pub api_key: Option, - /// Model name to use with the direct API endpoint. + #[serde(skip)] pub api_model: Option, - /// Reasoning effort for API calls ("none", "low", "medium", "high"). + /// Used to resolve API settings, not stored on Config + #[serde(default)] + agent_model: Option, pub api_reasoning: String, - /// Active agent types for consolidation cycles. pub agent_types: Vec, } @@ -97,6 +100,7 @@ impl Default for Config { 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(), @@ -119,105 +123,26 @@ impl Config { /// 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 path = PathBuf::from(std::env::var("HOME").ok()?) + .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_value = root.get("memory")?; - let mem = root.get("memory")?; - let mut config = Config::default(); + let mut config: Config = serde_json::from_value(mem_value.clone()).ok()?; + config.llm_concurrency = config.llm_concurrency.max(1); - // 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)) - { + // Resolve API settings: agent_model → models → backend + if let Some(model_name) = &config.agent_model { + if 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(|s| s.to_string()); + .and_then(|v| v.as_str()).map(String::from); config.api_key = backend.get("api_key") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); + .and_then(|v| v.as_str()).map(String::from); } config.api_model = Some(model_id.to_string()); } @@ -323,6 +248,17 @@ impl Config { } } + +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))) +} + 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)