diff --git a/src/agent/identity.rs b/src/agent/identity.rs index b5b6634..351a505 100644 --- a/src/agent/identity.rs +++ b/src/agent/identity.rs @@ -5,17 +5,9 @@ // from the shared config file. use anyhow::Result; -use serde::Deserialize; use std::path::{Path, PathBuf}; -#[derive(Debug, Clone, Deserialize)] -pub struct ContextGroup { - pub label: String, - #[serde(default)] - pub keys: Vec, - #[serde(default)] - pub source: Option, // "file" or "journal" -} +use crate::config::{ContextGroup, ContextSource}; /// Read a file if it exists and is non-empty. fn read_nonempty(path: &Path) -> Option { @@ -96,12 +88,12 @@ fn load_memory_files(cwd: &Path, memory_project: Option<&Path>, context_groups: // Load from context_groups for group in context_groups { - match group.source.as_deref() { - Some("journal") => { + match group.source { + ContextSource::Journal => { // Journal loading handled separately continue; } - Some("file") | None => { + ContextSource::File | ContextSource::Store => { // File source - load each key as a file for key in &group.keys { let filename = format!("{}.md", key); @@ -113,9 +105,7 @@ fn load_memory_files(cwd: &Path, memory_project: Option<&Path>, context_groups: } } } - Some(other) => { - eprintln!("Unknown context group source: {}", other); - } + // All variants covered } } diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 3eb7b11..fee97de 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -19,7 +19,8 @@ macro_rules! dbglog { // - tools/ — tool definitions and dispatch // - ui_channel — streaming UI communication // - runner — the interactive agent loop -// - cli, config, context, dmn, identity, log, observe, parsing, tui +// - cli, context, dmn, identity, log, observe, parsing, tui +// Config moved to crate::config (unified with memory config) pub mod api; pub mod types; @@ -29,7 +30,6 @@ pub mod journal; pub mod runner; pub mod cli; -pub mod config; pub mod context; pub mod dmn; pub mod identity; diff --git a/src/bin/poc-agent.rs b/src/bin/poc-agent.rs index 6517086..64443bf 100644 --- a/src/bin/poc-agent.rs +++ b/src/bin/poc-agent.rs @@ -35,8 +35,8 @@ use poc_memory::dbglog; use poc_memory::agent::*; use poc_memory::agent::runner::{Agent, TurnResult}; use poc_memory::agent::api::ApiClient; -use poc_memory::agent::config::{AppConfig, Config}; use poc_memory::agent::tui::HotkeyAction; +use poc_memory::config::{self, AppConfig, SessionConfig}; use poc_memory::agent::ui_channel::{ContextInfo, StatusInfo, StreamTarget, UiMessage}; /// Hard compaction threshold — context is rebuilt immediately. @@ -120,7 +120,7 @@ enum Command { /// and slash commands. struct Session { agent: Arc>, - config: Config, + config: SessionConfig, process_tracker: tools::ProcessTracker, ui_tx: ui_channel::UiSender, turn_tx: mpsc::Sender<(Result, StreamTarget)>, @@ -149,7 +149,7 @@ struct Session { impl Session { fn new( agent: Arc>, - config: Config, + config: SessionConfig, process_tracker: tools::ProcessTracker, ui_tx: ui_channel::UiSender, turn_tx: mpsc::Sender<(Result, StreamTarget)>, @@ -817,25 +817,9 @@ impl Session { self.send_context_info(); } - /// Load context_groups from the shared config file. - fn load_context_groups(&self) -> Vec { - let config_path = dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".config/poc-agent/config.json5"); - - if let Ok(content) = std::fs::read_to_string(&config_path) { - let config: Result = json5::from_str(&content); - if let Ok(config) = config { - if let Some(memory) = config.get("memory") { - if let Some(groups) = memory.get("context_groups") { - if let Ok(context_groups) = serde_json::from_value(groups.clone()) { - return context_groups; - } - } - } - } - } - Vec::new() + /// Get context_groups from the unified config. + fn load_context_groups(&self) -> Vec { + config::get().context_groups.clone() } /// Send context loading info to the TUI debug screen. @@ -885,7 +869,7 @@ impl Session { // --- Event loop --- async fn run(cli: cli::CliArgs) -> Result<()> { - let (config, _figment) = config::load(&cli)?; + let (config, _figment) = config::load_session(&cli)?; // Wire config.debug to the POC_DEBUG env var so all debug checks // throughout the codebase (API, SSE reader, diagnostics) see it. diff --git a/src/agent/config.rs b/src/config.rs similarity index 52% rename from src/agent/config.rs rename to src/config.rs index a183304..271f634 100644 --- a/src/agent/config.rs +++ b/src/config.rs @@ -1,34 +1,300 @@ -// config.rs — Configuration and context loading +// config.rs — Unified configuration // -// Loads configuration from three layers (later overrides earlier): -// 1. Compiled defaults (AppConfig::default()) -// 2. JSON5 config file (~/.config/poc-agent/config.json5) -// 3. CLI arguments +// Single config file: ~/.config/poc-agent/config.json5 +// Memory settings in the "memory" section (Config) +// Agent/backend settings at top level (AppConfig) // -// Prompt assembly is split into two parts: -// -// - system_prompt: Short (~1K chars) — agent identity, tool instructions, -// behavioral norms. Sent as the system message with every API call. -// -// - context_message: Long — CLAUDE.md files + memory files + manifest. -// Sent as the first user message once per session. This is the identity -// layer — same files, same prompt, different model = same person. -// -// The split matters because long system prompts degrade tool-calling -// behavior on models like Qwen 3.5 (documented: >8K chars causes -// degradation). By keeping the system prompt short and putting identity -// context in a user message, we get reliable tool use AND full identity. +// Legacy fallback: ~/.config/poc-memory/config.jsonl +// Env override: POC_MEMORY_CONFIG -use anyhow::{Context, Result}; +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}; -use std::collections::HashMap; -use std::path::PathBuf; -use crate::agent::cli::CliArgs; +/// 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") +} -// --- AppConfig types --- +// ============================================================ +// 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, + /// 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(".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, + 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_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 { @@ -65,7 +331,8 @@ 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 ~/.config/poc-agent/config.json5 or use --api-key" + "No API key. Set it in {} or use --api-key", + config_path().display() ); } let base = self.base_url.clone() @@ -97,11 +364,10 @@ pub struct ModelConfig { pub backend: String, /// Model identifier sent to the API pub model_id: String, - /// Instruction file ("CLAUDE.md" or "POC.md"). Falls back to - /// auto-detection from the model name if not specified. + /// Instruction file ("CLAUDE.md" or "POC.md"). #[serde(default)] pub prompt_file: Option, - /// Context window size in tokens. Auto-detected if absent. + /// Context window size in tokens. #[serde(default)] pub context_window: Option, } @@ -145,66 +411,8 @@ impl Default for AppConfig { fn default_model_name() -> String { String::new() } -// --- Json5File: figment provider --- - -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))), - } - } -} - -// --- Figment construction --- - -/// Merge an Option into one or more figment keys. -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: &CliArgs) -> Figment { - let config_path = dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".config/poc-agent/config.json5"); - - let mut f = Figment::from(Serialized::defaults(AppConfig::default())) - .merge(Json5File(config_path)); - - // CLI overrides — model/key/base go to both backends - 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 -} - -// --- Config loading --- - -/// Resolved, ready-to-use config. -pub struct Config { +/// Resolved, ready-to-use agent session config. +pub struct SessionConfig { pub api_base: String, pub api_key: String, pub model: String, @@ -218,7 +426,7 @@ pub struct Config { pub app: AppConfig, } -impl Config { +impl SessionConfig { /// Join context parts into a single string for legacy interfaces. #[allow(dead_code)] pub fn context_message(&self) -> String { @@ -241,8 +449,8 @@ pub struct ResolvedModel { } impl AppConfig { - /// Resolve the active backend and assemble prompts into a ready-to-use Config. - pub fn resolve(&self, cli: &CliArgs) -> Result { + /// 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); @@ -254,7 +462,6 @@ impl AppConfig { model = resolved.model_id; prompt_file = resolved.prompt_file; } else { - // Legacy path — no models map, use backend field directly 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"), @@ -269,6 +476,8 @@ impl AppConfig { }; } + 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) @@ -276,7 +485,6 @@ impl AppConfig { (content, Vec::new(), 0, 0) } else { let system_prompt = crate::agent::identity::assemble_system_prompt(); - let context_groups = load_context_groups(); 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) }; @@ -286,7 +494,7 @@ impl AppConfig { .join(".cache/poc-agent/sessions"); std::fs::create_dir_all(&session_dir).ok(); - Ok(Config { + Ok(SessionConfig { api_base, api_key, model, prompt_file, system_prompt, context_parts, config_file_count, memory_file_count, @@ -349,41 +557,70 @@ impl AppConfig { } } +// ============================================================ +// 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: &CliArgs) -> Result<(AppConfig, Figment)> { +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(cli: &CliArgs) -> Result<(Config, Figment)> { +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)) } -/// Load context_groups from the shared config file. -fn load_context_groups() -> Vec { - let config_path = dirs::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join(".config/poc-agent/config.json5"); - - if let Ok(content) = std::fs::read_to_string(&config_path) { - let config: Result = json5::from_str(&content); - if let Ok(config) = config { - if let Some(memory) = config.get("memory") { - if let Some(groups) = memory.get("context_groups") { - if let Ok(context_groups) = serde_json::from_value(groups.clone()) { - return context_groups; - } - } - } - } - } - Vec::new() -} - /// 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")?; @@ -395,19 +632,16 @@ pub fn reload_for_model(app: &AppConfig, prompt_file: &str) -> Result<(String, V } let system_prompt = crate::agent::identity::assemble_system_prompt(); - let context_groups = load_context_groups(); + 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") } -// --- --show-config --- - pub fn show_config(app: &AppConfig, figment: &Figment) { fn mask(key: &str) -> String { if key.is_empty() { "(not set)".into() } @@ -460,4 +694,24 @@ pub fn show_config(app: &AppConfig, figment: &Figment) { } } -// Identity file discovery and context assembly live in identity.rs +// ============================================================ +// 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) + } +} diff --git a/src/hippocampus/config.rs b/src/hippocampus/config.rs deleted file mode 100644 index 0ce761a..0000000 --- a/src/hippocampus/config.rs +++ /dev/null @@ -1,304 +0,0 @@ -// 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, serde::Deserialize)] -#[serde(rename_all = "lowercase")] -#[derive(Default)] -pub enum ContextSource { - #[serde(alias = "")] - #[default] - Store, - File, - Journal, -} - -#[derive(Debug, Clone, serde::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, serde::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. Kill if running longer than this. - #[serde(default)] - pub surface_timeout_secs: Option, - /// Hook events that trigger the surface agent (e.g. ["UserPromptSubmit"]). - /// Empty list disables 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(".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, - 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_hooks: vec![], - } - } -} - -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 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 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 - } -} - - -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) - } 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 -} diff --git a/src/hippocampus/mod.rs b/src/hippocampus/mod.rs index 6f6af08..9e36ce6 100644 --- a/src/hippocampus/mod.rs +++ b/src/hippocampus/mod.rs @@ -15,6 +15,5 @@ pub mod spectral; pub mod neuro; pub mod counters; pub mod migrate; -pub mod config; pub mod transcript; pub mod memory_search; diff --git a/src/lib.rs b/src/lib.rs index d7835a2..ca31d64 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,9 @@ pub mod hippocampus; // Autonomous agents pub mod subconscious; +// Unified configuration +pub mod config; + // Shared utilities pub mod util; @@ -31,7 +34,7 @@ pub mod memory_capnp { pub use hippocampus::{ store, graph, lookups, cursor, query, similarity, spectral, neuro, counters, - config, transcript, memory_search, migrate, + transcript, memory_search, migrate, }; pub use hippocampus::query::engine as search; pub use hippocampus::query::parser as query_parser;