Skip to main content

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}