trusty_memory/service/user_config.rs
1//! User config (`~/.trusty-memory/config.toml`) loading + `DreamConfig`
2//! derivation.
3//!
4//! Why: split out of `helpers.rs` (issue #2593 follow-up, code review on
5//! #2977) to keep that file under the 500-SLOC production cap after the
6//! `dream_config_from_user_config` addition pushed it over. This is also a
7//! cohesive unit on its own: "read config.toml" and "translate it into the
8//! shapes downstream consumers need" belong together, separate from the
9//! unrelated preview/snippet/palace-info transforms that fill the rest of
10//! `helpers.rs`.
11//! What: `UserConfigMin`/`OpenRouterMin`/`LocalModelMin` (the minimal TOML
12//! mirror), `LoadedUserConfig` (the public, normalised shape), `load_user_config`
13//! (file → `LoadedUserConfig`), and `dream_config_from_user_config`
14//! (`LoadedUserConfig` → `DreamConfig`, used by both the idle dream scheduler
15//! and the on-demand `dream_consolidate_room`/`palace_dream` tools). Re-exported
16//! from `service::mod` unchanged so `crate::service::{load_user_config,
17//! dream_config_from_user_config, LoadedUserConfig}` keeps resolving exactly as
18//! before this split — no public API change.
19//! Test: `dream_config_is_off_and_names_no_local_model_by_default`,
20//! `dream_config_from_user_config_prefers_openrouter_model_with_key`,
21//! `semantic_consolidation_is_off_without_a_config_file`.
22
23use serde::Deserialize;
24use trusty_common::memory_core::dream::DreamConfig;
25use trusty_common::memory_core::semantic_consolidation::SemanticConsolidationConfig;
26
27/// Minimal mirror of the user-config schema.
28#[derive(Deserialize, Default, Clone)]
29struct UserConfigMin {
30 #[serde(default)]
31 openrouter: OpenRouterMin,
32 #[serde(default)]
33 local_model: LocalModelMin,
34 /// `[semantic_consolidation]` — the switch for the dream cycle's LLM phase.
35 /// Absent from the schema until #5188, so a `config.toml` asking for the
36 /// phase to be off was parsed and discarded while the phase ran anyway.
37 /// `[semantic]` is accepted as an alias: it matches `DreamConfig`'s field
38 /// name, so both spellings are in circulation and silently dropping either
39 /// one is the defect this table exists to fix.
40 #[serde(default, alias = "semantic")]
41 semantic_consolidation: SemanticConsolidationMin,
42}
43
44#[derive(Deserialize, Default, Clone)]
45struct OpenRouterMin {
46 #[serde(default)]
47 api_key: String,
48 #[serde(default)]
49 model: String,
50}
51
52/// `[local_model]` — a local OpenAI-compatible server (Ollama, LM Studio).
53///
54/// #5188: `enabled` now defaults to FALSE. It defaulted to true, which is how a
55/// daemon with no config file at all decided a local model was available.
56#[derive(Deserialize, Clone)]
57struct LocalModelMin {
58 #[serde(default)]
59 enabled: bool,
60 #[serde(default = "default_local_base_url")]
61 base_url: String,
62 #[serde(default = "default_local_model")]
63 model: String,
64}
65
66impl Default for LocalModelMin {
67 fn default() -> Self {
68 Self {
69 // #5188: opt-in, so an absent `[local_model]` table means "no".
70 enabled: false,
71 base_url: default_local_base_url(),
72 model: default_local_model(),
73 }
74 }
75}
76
77/// `[semantic_consolidation]` — the dream cycle's LLM phase (#5188).
78#[derive(Deserialize, Default, Clone)]
79struct SemanticConsolidationMin {
80 /// Defaults to false: the phase costs money and calls an external model,
81 /// so the file has to ask for it.
82 #[serde(default)]
83 enabled: bool,
84 /// Model id. Empty falls back to `[openrouter] model`. An `ollama/` or
85 /// `local/` prefix is the only thing that selects a local model server.
86 #[serde(default)]
87 model: String,
88}
89
90fn default_local_base_url() -> String {
91 "http://localhost:11434".to_string()
92}
93fn default_local_model() -> String {
94 "llama3.2".to_string()
95}
96
97/// Loaded user config (mirrors the public `LoadedUserConfig` from `web.rs`).
98#[derive(Clone)]
99pub struct LoadedUserConfig {
100 pub openrouter_api_key: String,
101 pub openrouter_model: String,
102 pub local_model: trusty_common::LocalModelConfig,
103}
104
105impl Default for LoadedUserConfig {
106 fn default() -> Self {
107 Self {
108 openrouter_api_key: String::new(),
109 openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
110 // #5188: NOT `LocalModelConfig::default()`, whose `enabled: true`
111 // is what let a daemon with no config file probe a local Ollama.
112 // trusty-search shares that struct, so the default stays as it is
113 // and trusty-memory states its own answer here.
114 local_model: trusty_common::LocalModelConfig {
115 enabled: false,
116 base_url: default_local_base_url(),
117 model: default_local_model(),
118 },
119 }
120 }
121}
122
123/// Path of the user config file this module reads.
124///
125/// Why (#5188): `load_user_config` and `load_semantic_consolidation_config`
126/// project two different shapes out of the same file; one path expression
127/// keeps them from drifting apart.
128/// What: `~/.trusty-memory/config.toml`; `None` when the home directory
129/// cannot be resolved.
130fn user_config_path() -> Option<std::path::PathBuf> {
131 Some(dirs::home_dir()?.join(".trusty-memory").join("config.toml"))
132}
133
134/// Parse the whole config file into its minimal mirror.
135///
136/// Why (#5188): the single reader for `~/.trusty-memory/config.toml`. A
137/// malformed file yields defaults rather than an error, matching the
138/// pre-existing behaviour of `load_user_config` — the daemon starts either way.
139/// What: `None` when the home directory cannot be resolved or the file cannot
140/// be read; `Some(UserConfigMin::default())` when the file is absent or
141/// unparseable.
142fn read_user_config_min() -> Option<UserConfigMin> {
143 let path = user_config_path()?;
144 if !path.exists() {
145 return Some(UserConfigMin::default());
146 }
147 let raw = std::fs::read_to_string(&path).ok()?;
148 Some(toml::from_str(&raw).unwrap_or_default())
149}
150
151/// Read the `[semantic_consolidation]` table into a
152/// [`SemanticConsolidationConfig`].
153///
154/// Why (#5188): the dream cycle's LLM phase had no config key at all — the
155/// struct's `enabled` field was hardcoded true by `dream_config_from_user_config`
156/// and a `[semantic_consolidation]` block in the file was silently discarded.
157/// This is the key that turns the phase on.
158/// What: `enabled` and `model` come from the file; every other field keeps its
159/// [`SemanticConsolidationConfig::default`] value. An empty `model` is left
160/// empty for [`dream_config_from_user_config`] to fill from `[openrouter]`.
161/// Returns the all-default (disabled) config when the file is absent.
162/// Test: `semantic_consolidation_is_off_without_a_config_file`.
163pub fn load_semantic_consolidation_config() -> SemanticConsolidationConfig {
164 load_semantic_consolidation_config_from(&read_user_config_min().unwrap_or_default())
165}
166
167/// The pure projection behind [`load_semantic_consolidation_config`].
168///
169/// Why (#5188): separates "read the file" from "read the table" so a test can
170/// state its own input instead of asserting against the developer's real
171/// `~/.trusty-memory/config.toml`.
172/// Test: `semantic_consolidation_is_off_without_a_config_file`.
173fn load_semantic_consolidation_config_from(parsed: &UserConfigMin) -> SemanticConsolidationConfig {
174 SemanticConsolidationConfig {
175 enabled: parsed.semantic_consolidation.enabled,
176 model: parsed.semantic_consolidation.model.clone(),
177 ..SemanticConsolidationConfig::default()
178 }
179}
180
181/// Read the user's `~/.trusty-memory/config.toml`, falling back to defaults.
182///
183/// Why: shared between HTTP config endpoint, chat tool dispatch, and
184/// provider auto-detection.
185/// What: returns `Some(LoadedUserConfig)` even when the file is missing
186/// (so callers see defaults consistently); `None` only when the home
187/// directory itself can't be resolved.
188/// Test: indirectly via `config_endpoint_returns_payload`.
189pub fn load_user_config() -> Option<LoadedUserConfig> {
190 let parsed = read_user_config_min()?;
191 let model = if parsed.openrouter.model.is_empty() {
192 "anthropic/claude-3-5-sonnet".to_string()
193 } else {
194 parsed.openrouter.model
195 };
196 Some(LoadedUserConfig {
197 openrouter_api_key: parsed.openrouter.api_key,
198 openrouter_model: model,
199 local_model: trusty_common::LocalModelConfig {
200 enabled: parsed.local_model.enabled,
201 base_url: parsed.local_model.base_url,
202 model: parsed.local_model.model,
203 },
204 })
205}
206
207/// Derive a `DreamConfig` seed from the user's config file.
208///
209/// Why (#2593): the idle dream scheduler and the on-demand
210/// `dream_consolidate_room`/`palace_dream` tools must translate the user's
211/// config into `DreamConfig` identically, or the two paths silently diverge —
212/// the idle scheduler once used `DreamConfig::default()` outright and never
213/// saw `config.toml` at all.
214///
215/// Why (#5188): the semantic phase's enable switch and model id now come from
216/// `[semantic_consolidation]` rather than being hardcoded. Two behaviours
217/// changed here. The phase is off unless the file says otherwise, and
218/// `[local_model] model` no longer leaks into `semantic.model`: forwarding it
219/// meant "no OpenRouter key" chose a local model server by itself, which is
220/// how an unconfigured daemon loaded a 45 GB model into a crash loop. A local
221/// server is now named explicitly — `model = "ollama/llama3.2"` — and
222/// `[local_model] enabled` only permits that choice.
223/// What: `semantic.enabled` and `semantic.model` come from
224/// [`load_semantic_consolidation_config`], with an empty model falling back to
225/// `[openrouter] model`. `openrouter_api_key` and `local_model_enabled` come
226/// from `cfg`. Every other `DreamConfig` field keeps its default.
227/// Test: `dream_config_is_off_and_names_no_local_model_by_default`,
228/// `dream_config_forwards_an_explicit_ollama_model`,
229/// `dream_config_from_user_config_prefers_openrouter_model_with_key`.
230pub fn dream_config_from_user_config(cfg: &LoadedUserConfig) -> DreamConfig {
231 dream_config_from_parts(cfg, load_semantic_consolidation_config())
232}
233
234/// [`dream_config_from_user_config`] with the semantic section passed in.
235///
236/// Why (#5188): `load_semantic_consolidation_config` reads the developer's real
237/// `~/.trusty-memory/config.toml`, so a test driving the public wrapper asserts
238/// against whatever that machine happens to hold. Splitting the file read from
239/// the derivation lets the tests state their own input.
240/// What: pure — no file, no environment.
241/// Test: `dream_config_is_off_and_names_no_local_model_by_default`,
242/// `dream_config_forwards_an_explicit_ollama_model`.
243fn dream_config_from_parts(
244 cfg: &LoadedUserConfig,
245 semantic: SemanticConsolidationConfig,
246) -> DreamConfig {
247 // #5188: an empty `[semantic_consolidation] model` inherits the OpenRouter
248 // model id — never the local-model id, which would pick a local backend
249 // nobody asked for.
250 let model = if semantic.model.trim().is_empty() {
251 cfg.openrouter_model.clone()
252 } else {
253 semantic.model.clone()
254 };
255
256 DreamConfig {
257 openrouter_api_key: cfg.openrouter_api_key.clone(),
258 local_model_enabled: cfg.local_model.enabled,
259 semantic: SemanticConsolidationConfig { model, ..semantic },
260 ..DreamConfig::default()
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 fn openrouter_only_cfg() -> LoadedUserConfig {
269 LoadedUserConfig {
270 openrouter_api_key: String::new(),
271 openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
272 local_model: trusty_common::LocalModelConfig {
273 enabled: false,
274 base_url: "http://localhost:11434".to_string(),
275 model: "llama3.2".to_string(),
276 },
277 }
278 }
279
280 /// Why (#5188): the reported repro — no `~/.trusty-memory/config.toml`, no
281 /// provider key — must produce a `DreamConfig` that cannot reach a local
282 /// model server. Before the fix this config had `semantic.enabled = true`,
283 /// `local_model_enabled = true`, and `semantic.model = "qwen3:30b"`, which
284 /// is exactly what drove a 45 GB model into a crash loop.
285 /// What: derives from the all-defaults user config and asserts the phase is
286 /// off, the local backend is not permitted, and no local model id was
287 /// forwarded.
288 #[test]
289 fn dream_config_is_off_and_names_no_local_model_by_default() {
290 let dream_cfg = dream_config_from_parts(
291 &LoadedUserConfig::default(),
292 load_semantic_consolidation_config_from(&UserConfigMin::default()),
293 );
294
295 assert!(
296 !dream_cfg.semantic.enabled,
297 "semantic consolidation must be off until a config key enables it"
298 );
299 assert!(
300 !dream_cfg.local_model_enabled,
301 "a local model server must not be permitted by default"
302 );
303 assert!(
304 !dream_cfg.semantic.model.starts_with("ollama/")
305 && !dream_cfg.semantic.model.starts_with("local/"),
306 "no local model id may be forwarded by default, got {:?}",
307 dream_cfg.semantic.model
308 );
309 }
310
311 /// Why (#5188): `LoadedUserConfig::default()` is what `load_user_config`
312 /// returns when the file is absent, so its `local_model.enabled` IS the
313 /// no-config-file answer.
314 #[test]
315 fn loaded_user_config_default_disables_the_local_model() {
316 assert!(!LoadedUserConfig::default().local_model.enabled);
317 }
318
319 /// Why (#5188): an absent `[local_model]` table must mean "no", not
320 /// "yes" — that default is how the daemon decided a local model existed.
321 #[test]
322 fn absent_local_model_table_parses_as_disabled() {
323 let parsed: UserConfigMin = toml::from_str("").expect("empty config parses");
324 assert!(!parsed.local_model.enabled);
325 assert!(!parsed.semantic_consolidation.enabled);
326 }
327
328 /// Why (#5188): the `[semantic_consolidation]` table was not in the schema
329 /// at all, so a file asking for the phase was — like a file asking against
330 /// it — silently discarded. Pins that both directions now parse.
331 #[test]
332 fn semantic_consolidation_table_is_read_from_the_file() {
333 let parsed: UserConfigMin = toml::from_str(
334 r#"
335[semantic_consolidation]
336enabled = true
337model = "ollama/llama3.2"
338"#,
339 )
340 .expect("config parses");
341 assert!(parsed.semantic_consolidation.enabled);
342 assert_eq!(parsed.semantic_consolidation.model, "ollama/llama3.2");
343 }
344
345 /// Why (#5188): `[semantic]` matches `DreamConfig`'s field name, so an
346 /// operator reading the struct writes that spelling. Accepting only
347 /// `[semantic_consolidation]` would drop it silently — the same failure
348 /// this table was added to fix.
349 #[test]
350 fn semantic_table_alias_is_accepted() {
351 let parsed: UserConfigMin = toml::from_str(
352 r#"
353[semantic]
354enabled = true
355"#,
356 )
357 .expect("config parses");
358 assert!(parsed.semantic_consolidation.enabled);
359 }
360
361 /// Why (#5188): a local model server is reachable only when the operator
362 /// names it. Pins that the explicit `ollama/` id survives the derivation
363 /// verbatim — the prefix is what `resolve_consolidation_provider` reads.
364 #[test]
365 fn dream_config_forwards_an_explicit_ollama_model() {
366 let mut cfg = openrouter_only_cfg();
367 cfg.local_model.enabled = true;
368 let semantic = SemanticConsolidationConfig {
369 enabled: true,
370 model: "ollama/llama3.2".to_string(),
371 ..SemanticConsolidationConfig::default()
372 };
373
374 let dream_cfg = dream_config_from_parts(&cfg, semantic);
375
376 assert!(dream_cfg.semantic.enabled);
377 assert_eq!(dream_cfg.semantic.model, "ollama/llama3.2");
378 assert!(dream_cfg.local_model_enabled);
379 }
380
381 /// Why (#5188): with the phase enabled but no model named, the id must come
382 /// from `[openrouter]` — never from `[local_model]`, which is how "no key"
383 /// used to select a local backend on its own.
384 #[test]
385 fn empty_semantic_model_inherits_the_openrouter_model_not_the_local_one() {
386 let mut cfg = openrouter_only_cfg();
387 cfg.local_model.enabled = true;
388 cfg.local_model.model = "qwen3:30b".to_string();
389 let semantic = SemanticConsolidationConfig {
390 enabled: true,
391 model: String::new(),
392 ..SemanticConsolidationConfig::default()
393 };
394
395 let dream_cfg = dream_config_from_parts(&cfg, semantic);
396
397 assert_eq!(dream_cfg.semantic.model, "anthropic/claude-3-5-sonnet");
398 }
399
400 /// Why: an OpenRouter key configured in the file must reach `DreamConfig`
401 /// so the consolidator can build the OpenRouter backend.
402 #[test]
403 fn dream_config_from_user_config_prefers_openrouter_model_with_key() {
404 let cfg = LoadedUserConfig {
405 openrouter_api_key: "sk-test-key".to_string(),
406 openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
407 local_model: trusty_common::LocalModelConfig {
408 enabled: true,
409 base_url: "http://localhost:11434".to_string(),
410 model: "llama3.2".to_string(),
411 },
412 };
413 let semantic = SemanticConsolidationConfig {
414 enabled: true,
415 // No `[semantic_consolidation] model`, so `[openrouter] model` fills in.
416 model: String::new(),
417 ..SemanticConsolidationConfig::default()
418 };
419
420 let dream_cfg = dream_config_from_parts(&cfg, semantic);
421
422 assert_eq!(dream_cfg.semantic.model, "anthropic/claude-3-5-sonnet");
423 assert_eq!(dream_cfg.openrouter_api_key, "sk-test-key");
424 }
425
426 /// Why (#5188): `load_semantic_consolidation_config` reads the developer's
427 /// real config file, so the hermetic half of its contract — "an absent or
428 /// empty file yields a disabled phase" — is asserted through the same
429 /// projection with a stated input.
430 #[test]
431 fn semantic_consolidation_is_off_without_a_config_file() {
432 let cfg = load_semantic_consolidation_config_from(&UserConfigMin::default());
433 assert!(!cfg.enabled);
434 assert!(cfg.model.is_empty());
435 }
436}