2026-03-05 15:41:35 -05:00
|
|
|
// Configuration for poc-memory
|
|
|
|
|
//
|
2026-03-19 21:49:58 -04:00
|
|
|
// 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.
|
2026-03-05 16:08:15 -05:00
|
|
|
//
|
2026-03-19 21:49:58 -04:00
|
|
|
// Fallback: ~/.config/poc-memory/config.jsonl (legacy, still supported)
|
|
|
|
|
// Env override: POC_MEMORY_CONFIG
|
2026-03-05 16:08:15 -05:00
|
|
|
//
|
2026-03-19 21:49:58 -04:00
|
|
|
// The shared config eliminates API credential duplication between
|
|
|
|
|
// poc-memory and poc-agent.
|
2026-03-05 15:41:35 -05:00
|
|
|
|
|
|
|
|
use std::path::PathBuf;
|
2026-03-19 13:41:13 -04:00
|
|
|
use std::sync::{Arc, OnceLock, RwLock};
|
2026-03-05 15:41:35 -05:00
|
|
|
|
2026-03-19 13:41:13 -04:00
|
|
|
static CONFIG: OnceLock<RwLock<Arc<Config>>> = OnceLock::new();
|
2026-03-05 15:41:35 -05:00
|
|
|
|
2026-03-05 16:08:15 -05:00
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
|
|
|
pub enum ContextSource {
|
|
|
|
|
Store,
|
|
|
|
|
File,
|
|
|
|
|
Journal,
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 15:54:44 -05:00
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct ContextGroup {
|
|
|
|
|
pub label: String,
|
|
|
|
|
pub keys: Vec<String>,
|
2026-03-05 16:08:15 -05:00
|
|
|
pub source: ContextSource,
|
2026-03-05 15:54:44 -05:00
|
|
|
}
|
|
|
|
|
|
2026-03-05 15:41:35 -05:00
|
|
|
#[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<String>,
|
2026-03-05 15:54:44 -05:00
|
|
|
/// 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<ContextGroup>,
|
2026-03-05 22:56:16 -05:00
|
|
|
/// Max concurrent LLM calls in the daemon.
|
|
|
|
|
pub llm_concurrency: usize,
|
2026-03-14 20:05:53 -04:00
|
|
|
/// Total agent runs per consolidation cycle.
|
|
|
|
|
pub agent_budget: usize,
|
2026-03-08 21:16:52 -04:00
|
|
|
/// Directory containing prompt templates for agents.
|
|
|
|
|
pub prompts_dir: PathBuf,
|
2026-03-05 22:43:50 -05:00
|
|
|
/// Separate Claude config dir for background agent work (daemon jobs).
|
|
|
|
|
pub agent_config_dir: Option<PathBuf>,
|
2026-03-19 21:49:58 -04:00
|
|
|
/// OpenAI-compatible API base URL for direct LLM calls.
|
2026-03-18 23:05:14 -04:00
|
|
|
pub api_base_url: Option<String>,
|
|
|
|
|
/// API key for the direct API endpoint.
|
|
|
|
|
pub api_key: Option<String>,
|
|
|
|
|
/// Model name to use with the direct API endpoint.
|
|
|
|
|
pub api_model: Option<String>,
|
2026-03-19 22:58:54 -04:00
|
|
|
/// Reasoning effort for API calls ("none", "low", "medium", "high").
|
|
|
|
|
pub api_reasoning: String,
|
2026-03-20 14:04:47 -04:00
|
|
|
/// Active agent types for consolidation cycles.
|
|
|
|
|
pub agent_types: Vec<String>,
|
2026-03-05 15:41:35 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"),
|
2026-03-08 19:41:26 -04:00
|
|
|
core_nodes: vec!["identity".to_string(), "core-practices".to_string()],
|
2026-03-05 15:54:44 -05:00
|
|
|
journal_days: 7,
|
|
|
|
|
journal_max: 20,
|
|
|
|
|
context_groups: vec![
|
2026-03-05 16:08:15 -05:00
|
|
|
ContextGroup {
|
|
|
|
|
label: "identity".into(),
|
2026-03-08 19:41:26 -04:00
|
|
|
keys: vec!["identity".into()],
|
2026-03-05 16:08:15 -05:00
|
|
|
source: ContextSource::Store,
|
|
|
|
|
},
|
2026-03-07 01:02:54 -05:00
|
|
|
ContextGroup {
|
|
|
|
|
label: "core-practices".into(),
|
2026-03-08 19:41:26 -04:00
|
|
|
keys: vec!["core-practices".into()],
|
2026-03-07 01:02:54 -05:00
|
|
|
source: ContextSource::Store,
|
|
|
|
|
},
|
2026-03-05 15:54:44 -05:00
|
|
|
],
|
2026-03-05 22:56:16 -05:00
|
|
|
llm_concurrency: 1,
|
2026-03-14 20:05:53 -04:00
|
|
|
agent_budget: 1000,
|
2026-03-08 21:16:52 -04:00
|
|
|
prompts_dir: home.join("poc/memory/prompts"),
|
2026-03-05 22:43:50 -05:00
|
|
|
agent_config_dir: None,
|
2026-03-18 23:05:14 -04:00
|
|
|
api_base_url: None,
|
|
|
|
|
api_key: None,
|
|
|
|
|
api_model: None,
|
2026-03-19 22:58:54 -04:00
|
|
|
api_reasoning: "high".to_string(),
|
2026-03-20 14:04:47 -04:00
|
|
|
agent_types: vec![
|
|
|
|
|
"linker".into(), "organize".into(), "distill".into(),
|
|
|
|
|
"separator".into(), "split".into(),
|
|
|
|
|
],
|
2026-03-05 15:41:35 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Config {
|
|
|
|
|
fn load_from_file() -> Self {
|
2026-03-19 21:49:58 -04:00
|
|
|
// 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<Self> {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 22:58:54 -04:00
|
|
|
if let Some(s) = mem.get("api_reasoning").and_then(|v| v.as_str()) {
|
|
|
|
|
config.api_reasoning = s.to_string();
|
|
|
|
|
}
|
2026-03-20 14:04:47 -04:00
|
|
|
if let Some(arr) = mem.get("agent_types").and_then(|v| v.as_array()) {
|
|
|
|
|
let types: Vec<String> = arr.iter()
|
|
|
|
|
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
|
|
|
|
.collect();
|
|
|
|
|
if !types.is_empty() {
|
|
|
|
|
config.agent_types = types;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-19 22:58:54 -04:00
|
|
|
|
2026-03-19 21:49:58 -04:00
|
|
|
// 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 {
|
2026-03-05 15:41:35 -05:00
|
|
|
let path = std::env::var("POC_MEMORY_CONFIG")
|
|
|
|
|
.map(PathBuf::from)
|
|
|
|
|
.unwrap_or_else(|_| {
|
|
|
|
|
PathBuf::from(std::env::var("HOME").expect("HOME not set"))
|
2026-03-05 16:08:15 -05:00
|
|
|
.join(".config/poc-memory/config.jsonl")
|
2026-03-05 15:41:35 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let mut config = Config::default();
|
|
|
|
|
|
|
|
|
|
let Ok(content) = std::fs::read_to_string(&path) else {
|
|
|
|
|
return config;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-05 15:54:44 -05:00
|
|
|
let mut context_groups: Vec<ContextGroup> = Vec::new();
|
|
|
|
|
|
2026-03-05 16:08:15 -05:00
|
|
|
let stream = serde_json::Deserializer::from_str(&content)
|
|
|
|
|
.into_iter::<serde_json::Value>();
|
2026-03-05 15:54:44 -05:00
|
|
|
|
2026-03-05 16:08:15 -05:00
|
|
|
for result in stream {
|
|
|
|
|
let Ok(obj) = result else { continue };
|
2026-03-05 15:54:44 -05:00
|
|
|
|
2026-03-05 16:08:15 -05:00
|
|
|
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();
|
2026-03-05 15:54:44 -05:00
|
|
|
}
|
2026-03-05 16:08:15 -05:00
|
|
|
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()))
|
2026-03-05 15:41:35 -05:00
|
|
|
.collect();
|
|
|
|
|
}
|
2026-03-05 16:08:15 -05:00
|
|
|
if let Some(d) = cfg.get("journal_days").and_then(|v| v.as_u64()) {
|
|
|
|
|
config.journal_days = d as u32;
|
2026-03-05 15:54:44 -05:00
|
|
|
}
|
2026-03-05 16:08:15 -05:00
|
|
|
if let Some(m) = cfg.get("journal_max").and_then(|v| v.as_u64()) {
|
|
|
|
|
config.journal_max = m as usize;
|
2026-03-05 15:54:44 -05:00
|
|
|
}
|
2026-03-05 22:56:16 -05:00
|
|
|
if let Some(n) = cfg.get("llm_concurrency").and_then(|v| v.as_u64()) {
|
|
|
|
|
config.llm_concurrency = n.max(1) as usize;
|
|
|
|
|
}
|
2026-03-14 20:05:53 -04:00
|
|
|
if let Some(n) = cfg.get("agent_budget").and_then(|v| v.as_u64()) {
|
|
|
|
|
config.agent_budget = n as usize;
|
|
|
|
|
}
|
2026-03-08 21:16:52 -04:00
|
|
|
if let Some(s) = cfg.get("prompts_dir").and_then(|v| v.as_str()) {
|
|
|
|
|
config.prompts_dir = expand_home(s);
|
|
|
|
|
}
|
2026-03-05 22:43:50 -05:00
|
|
|
if let Some(s) = cfg.get("agent_config_dir").and_then(|v| v.as_str()) {
|
|
|
|
|
config.agent_config_dir = Some(expand_home(s));
|
2026-03-05 22:28:39 -05:00
|
|
|
}
|
2026-03-18 23:05:14 -04:00
|
|
|
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());
|
|
|
|
|
}
|
2026-03-05 16:08:15 -05:00
|
|
|
continue;
|
2026-03-05 15:41:35 -05:00
|
|
|
}
|
|
|
|
|
|
2026-03-05 16:08:15 -05:00
|
|
|
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 });
|
|
|
|
|
}
|
2026-03-05 15:54:44 -05:00
|
|
|
}
|
|
|
|
|
|
2026-03-05 16:08:15 -05:00
|
|
|
if !context_groups.is_empty() {
|
2026-03-05 15:54:44 -05:00
|
|
|
config.context_groups = context_groups;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 15:41:35 -05:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:41:13 -04:00
|
|
|
/// Get the global config (cheap Arc clone).
|
|
|
|
|
pub fn get() -> Arc<Config> {
|
|
|
|
|
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
|
2026-03-05 15:41:35 -05:00
|
|
|
}
|