use serde::Deserialize;
use trusty_common::memory_core::dream::DreamConfig;
use trusty_common::memory_core::semantic_consolidation::SemanticConsolidationConfig;
#[derive(Deserialize, Default, Clone)]
struct UserConfigMin {
#[serde(default)]
openrouter: OpenRouterMin,
#[serde(default)]
local_model: LocalModelMin,
#[serde(default, alias = "semantic")]
semantic_consolidation: SemanticConsolidationMin,
#[serde(default)]
dream: DreamMin,
}
#[derive(Deserialize, Default, Clone)]
struct DreamMin {
#[serde(default)]
compact: Option<bool>,
#[serde(default)]
prune_history_after_days: Option<i64>,
#[serde(default)]
compact_min_bytes: Option<u64>,
#[serde(default)]
compact_keep_backup: Option<bool>,
}
#[derive(Deserialize, Default, Clone)]
struct OpenRouterMin {
#[serde(default)]
api_key: String,
#[serde(default)]
model: String,
}
#[derive(Deserialize, Clone)]
struct LocalModelMin {
#[serde(default)]
enabled: bool,
#[serde(default = "default_local_base_url")]
base_url: String,
#[serde(default = "default_local_model")]
model: String,
}
impl Default for LocalModelMin {
fn default() -> Self {
Self {
enabled: false,
base_url: default_local_base_url(),
model: default_local_model(),
}
}
}
#[derive(Deserialize, Default, Clone)]
struct SemanticConsolidationMin {
#[serde(default)]
enabled: bool,
#[serde(default)]
model: String,
}
fn default_local_base_url() -> String {
"http://localhost:11434".to_string()
}
fn default_local_model() -> String {
"llama3.2".to_string()
}
#[derive(Clone)]
pub struct LoadedUserConfig {
pub openrouter_api_key: String,
pub openrouter_model: String,
pub local_model: trusty_common::LocalModelConfig,
}
impl Default for LoadedUserConfig {
fn default() -> Self {
Self {
openrouter_api_key: String::new(),
openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
local_model: trusty_common::LocalModelConfig {
enabled: false,
base_url: default_local_base_url(),
model: default_local_model(),
},
}
}
}
fn user_config_path() -> Option<std::path::PathBuf> {
Some(dirs::home_dir()?.join(".trusty-memory").join("config.toml"))
}
fn read_user_config_min() -> Option<UserConfigMin> {
let path = user_config_path()?;
if !path.exists() {
return Some(UserConfigMin::default());
}
let raw = std::fs::read_to_string(&path).ok()?;
Some(toml::from_str(&raw).unwrap_or_default())
}
pub fn load_semantic_consolidation_config() -> SemanticConsolidationConfig {
load_semantic_consolidation_config_from(&read_user_config_min().unwrap_or_default())
}
fn load_semantic_consolidation_config_from(parsed: &UserConfigMin) -> SemanticConsolidationConfig {
SemanticConsolidationConfig {
enabled: parsed.semantic_consolidation.enabled,
model: parsed.semantic_consolidation.model.clone(),
..SemanticConsolidationConfig::default()
}
}
pub fn load_user_config() -> Option<LoadedUserConfig> {
let parsed = read_user_config_min()?;
let model = if parsed.openrouter.model.is_empty() {
"anthropic/claude-3-5-sonnet".to_string()
} else {
parsed.openrouter.model
};
Some(LoadedUserConfig {
openrouter_api_key: parsed.openrouter.api_key,
openrouter_model: model,
local_model: trusty_common::LocalModelConfig {
enabled: parsed.local_model.enabled,
base_url: parsed.local_model.base_url,
model: parsed.local_model.model,
},
})
}
pub fn dream_config_from_user_config(cfg: &LoadedUserConfig) -> DreamConfig {
let parsed = read_user_config_min().unwrap_or_default();
dream_config_from_parts(
cfg,
load_semantic_consolidation_config_from(&parsed),
parsed.dream.clone(),
)
}
fn dream_config_from_parts(
cfg: &LoadedUserConfig,
semantic: SemanticConsolidationConfig,
dream: DreamMin,
) -> DreamConfig {
let defaults = DreamConfig::default();
let model = if semantic.model.trim().is_empty() {
cfg.openrouter_model.clone()
} else {
semantic.model.clone()
};
DreamConfig {
openrouter_api_key: cfg.openrouter_api_key.clone(),
local_model_enabled: cfg.local_model.enabled,
semantic: SemanticConsolidationConfig { model, ..semantic },
compact: dream.compact.unwrap_or(defaults.compact),
prune_history_after_days: dream
.prune_history_after_days
.unwrap_or(defaults.prune_history_after_days),
compact_min_bytes: dream
.compact_min_bytes
.unwrap_or(defaults.compact_min_bytes),
compact_keep_backup: dream
.compact_keep_backup
.unwrap_or(defaults.compact_keep_backup),
..defaults
}
}
#[cfg(test)]
mod tests {
use super::*;
fn openrouter_only_cfg() -> LoadedUserConfig {
LoadedUserConfig {
openrouter_api_key: String::new(),
openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
local_model: trusty_common::LocalModelConfig {
enabled: false,
base_url: "http://localhost:11434".to_string(),
model: "llama3.2".to_string(),
},
}
}
#[test]
fn dream_config_is_off_and_names_no_local_model_by_default() {
let dream_cfg = dream_config_from_parts(
&LoadedUserConfig::default(),
load_semantic_consolidation_config_from(&UserConfigMin::default()),
DreamMin::default(),
);
assert!(
!dream_cfg.semantic.enabled,
"semantic consolidation must be off until a config key enables it"
);
assert!(
!dream_cfg.local_model_enabled,
"a local model server must not be permitted by default"
);
assert!(
!dream_cfg.semantic.model.starts_with("ollama/")
&& !dream_cfg.semantic.model.starts_with("local/"),
"no local model id may be forwarded by default, got {:?}",
dream_cfg.semantic.model
);
}
#[test]
fn loaded_user_config_default_disables_the_local_model() {
assert!(!LoadedUserConfig::default().local_model.enabled);
}
#[test]
fn absent_local_model_table_parses_as_disabled() {
let parsed: UserConfigMin = toml::from_str("").expect("empty config parses");
assert!(!parsed.local_model.enabled);
assert!(!parsed.semantic_consolidation.enabled);
}
#[test]
fn semantic_consolidation_table_is_read_from_the_file() {
let parsed: UserConfigMin = toml::from_str(
r#"
[semantic_consolidation]
enabled = true
model = "ollama/llama3.2"
"#,
)
.expect("config parses");
assert!(parsed.semantic_consolidation.enabled);
assert_eq!(parsed.semantic_consolidation.model, "ollama/llama3.2");
}
#[test]
fn semantic_table_alias_is_accepted() {
let parsed: UserConfigMin = toml::from_str(
r#"
[semantic]
enabled = true
"#,
)
.expect("config parses");
assert!(parsed.semantic_consolidation.enabled);
}
#[test]
fn dream_config_forwards_an_explicit_ollama_model() {
let mut cfg = openrouter_only_cfg();
cfg.local_model.enabled = true;
let semantic = SemanticConsolidationConfig {
enabled: true,
model: "ollama/llama3.2".to_string(),
..SemanticConsolidationConfig::default()
};
let dream_cfg = dream_config_from_parts(&cfg, semantic, DreamMin::default());
assert!(dream_cfg.semantic.enabled);
assert_eq!(dream_cfg.semantic.model, "ollama/llama3.2");
assert!(dream_cfg.local_model_enabled);
}
#[test]
fn empty_semantic_model_inherits_the_openrouter_model_not_the_local_one() {
let mut cfg = openrouter_only_cfg();
cfg.local_model.enabled = true;
cfg.local_model.model = "qwen3:30b".to_string();
let semantic = SemanticConsolidationConfig {
enabled: true,
model: String::new(),
..SemanticConsolidationConfig::default()
};
let dream_cfg = dream_config_from_parts(&cfg, semantic, DreamMin::default());
assert_eq!(dream_cfg.semantic.model, "anthropic/claude-3-5-sonnet");
}
#[test]
fn dream_config_from_user_config_prefers_openrouter_model_with_key() {
let cfg = LoadedUserConfig {
openrouter_api_key: "sk-test-key".to_string(),
openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
local_model: trusty_common::LocalModelConfig {
enabled: true,
base_url: "http://localhost:11434".to_string(),
model: "llama3.2".to_string(),
},
};
let semantic = SemanticConsolidationConfig {
enabled: true,
model: String::new(),
..SemanticConsolidationConfig::default()
};
let dream_cfg = dream_config_from_parts(&cfg, semantic, DreamMin::default());
assert_eq!(dream_cfg.semantic.model, "anthropic/claude-3-5-sonnet");
assert_eq!(dream_cfg.openrouter_api_key, "sk-test-key");
}
#[test]
fn semantic_consolidation_is_off_without_a_config_file() {
let cfg = load_semantic_consolidation_config_from(&UserConfigMin::default());
assert!(!cfg.enabled);
assert!(cfg.model.is_empty());
}
#[test]
fn dream_table_overrides_the_compaction_defaults() {
let dream = DreamMin {
compact: Some(false),
prune_history_after_days: Some(30),
compact_min_bytes: None,
compact_keep_backup: None,
};
let cfg = dream_config_from_parts(
&LoadedUserConfig::default(),
SemanticConsolidationConfig::default(),
dream,
);
let defaults = DreamConfig::default();
assert!(!cfg.compact, "an explicit false must switch compaction off");
assert_eq!(cfg.prune_history_after_days, 30);
assert_eq!(
cfg.compact_min_bytes, defaults.compact_min_bytes,
"an absent key inherits the default, not zero"
);
assert_eq!(cfg.compact_keep_backup, defaults.compact_keep_backup);
}
#[test]
fn an_absent_dream_table_leaves_every_default() {
let cfg = dream_config_from_parts(
&LoadedUserConfig::default(),
SemanticConsolidationConfig::default(),
DreamMin::default(),
);
let defaults = DreamConfig::default();
assert_eq!(cfg.compact, defaults.compact);
assert_eq!(
cfg.prune_history_after_days,
defaults.prune_history_after_days
);
assert_eq!(cfg.compact_min_bytes, defaults.compact_min_bytes);
assert_eq!(cfg.compact_keep_backup, defaults.compact_keep_backup);
}
}