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    /// `[dream]` — the #6652 kg.redb prune-and-compact tunables. Absent means
43    /// the `DreamConfig` defaults: compaction on, 90-day history retention,
44    /// a 64 MiB file floor, backups kept.
45    #[serde(default)]
46    dream: DreamMin,
47}
48
49/// `[dream]` — the kg.redb prune-and-compact tunables (#6652).
50///
51/// Why: the compaction rewrites the palace's whole knowledge graph, so every
52/// knob that decides whether and how aggressively it runs has to be settable
53/// without recompiling. Each field is `Option` so an absent key inherits
54/// [`DreamConfig::default`] rather than resetting it to this struct's own
55/// default — the difference matters when only one key is present.
56/// What: mirrors four `DreamConfig` fields by name.
57/// Test: `dream_table_overrides_the_compaction_defaults`,
58/// `an_absent_dream_table_leaves_every_default`.
59#[derive(Deserialize, Default, Clone)]
60struct DreamMin {
61    #[serde(default)]
62    compact: Option<bool>,
63    #[serde(default)]
64    prune_history_after_days: Option<i64>,
65    #[serde(default)]
66    compact_min_bytes: Option<u64>,
67    #[serde(default)]
68    compact_keep_backup: Option<bool>,
69}
70
71#[derive(Deserialize, Default, Clone)]
72struct OpenRouterMin {
73    #[serde(default)]
74    api_key: String,
75    #[serde(default)]
76    model: String,
77}
78
79/// `[local_model]` — a local OpenAI-compatible server (Ollama, LM Studio).
80///
81/// #5188: `enabled` now defaults to FALSE. It defaulted to true, which is how a
82/// daemon with no config file at all decided a local model was available.
83#[derive(Deserialize, Clone)]
84struct LocalModelMin {
85    #[serde(default)]
86    enabled: bool,
87    #[serde(default = "default_local_base_url")]
88    base_url: String,
89    #[serde(default = "default_local_model")]
90    model: String,
91}
92
93impl Default for LocalModelMin {
94    fn default() -> Self {
95        Self {
96            // #5188: opt-in, so an absent `[local_model]` table means "no".
97            enabled: false,
98            base_url: default_local_base_url(),
99            model: default_local_model(),
100        }
101    }
102}
103
104/// `[semantic_consolidation]` — the dream cycle's LLM phase (#5188).
105#[derive(Deserialize, Default, Clone)]
106struct SemanticConsolidationMin {
107    /// Defaults to false: the phase costs money and calls an external model,
108    /// so the file has to ask for it.
109    #[serde(default)]
110    enabled: bool,
111    /// Model id. Empty falls back to `[openrouter] model`. An `ollama/` or
112    /// `local/` prefix is the only thing that selects a local model server.
113    #[serde(default)]
114    model: String,
115}
116
117fn default_local_base_url() -> String {
118    "http://localhost:11434".to_string()
119}
120fn default_local_model() -> String {
121    "llama3.2".to_string()
122}
123
124/// Loaded user config (mirrors the public `LoadedUserConfig` from `web.rs`).
125#[derive(Clone)]
126pub struct LoadedUserConfig {
127    pub openrouter_api_key: String,
128    pub openrouter_model: String,
129    pub local_model: trusty_common::LocalModelConfig,
130}
131
132impl Default for LoadedUserConfig {
133    fn default() -> Self {
134        Self {
135            openrouter_api_key: String::new(),
136            openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
137            // #5188: NOT `LocalModelConfig::default()`, whose `enabled: true`
138            // is what let a daemon with no config file probe a local Ollama.
139            // trusty-search shares that struct, so the default stays as it is
140            // and trusty-memory states its own answer here.
141            local_model: trusty_common::LocalModelConfig {
142                enabled: false,
143                base_url: default_local_base_url(),
144                model: default_local_model(),
145            },
146        }
147    }
148}
149
150/// Path of the user config file this module reads.
151///
152/// Why (#5188): `load_user_config` and `load_semantic_consolidation_config`
153/// project two different shapes out of the same file; one path expression
154/// keeps them from drifting apart.
155/// What: `~/.trusty-memory/config.toml`; `None` when the home directory
156/// cannot be resolved.
157fn user_config_path() -> Option<std::path::PathBuf> {
158    Some(dirs::home_dir()?.join(".trusty-memory").join("config.toml"))
159}
160
161/// Parse the whole config file into its minimal mirror.
162///
163/// Why (#5188): the single reader for `~/.trusty-memory/config.toml`. A
164/// malformed file yields defaults rather than an error, matching the
165/// pre-existing behaviour of `load_user_config` — the daemon starts either way.
166/// What: `None` when the home directory cannot be resolved or the file cannot
167/// be read; `Some(UserConfigMin::default())` when the file is absent or
168/// unparseable.
169fn read_user_config_min() -> Option<UserConfigMin> {
170    let path = user_config_path()?;
171    if !path.exists() {
172        return Some(UserConfigMin::default());
173    }
174    let raw = std::fs::read_to_string(&path).ok()?;
175    Some(toml::from_str(&raw).unwrap_or_default())
176}
177
178/// Read the `[semantic_consolidation]` table into a
179/// [`SemanticConsolidationConfig`].
180///
181/// Why (#5188): the dream cycle's LLM phase had no config key at all — the
182/// struct's `enabled` field was hardcoded true by `dream_config_from_user_config`
183/// and a `[semantic_consolidation]` block in the file was silently discarded.
184/// This is the key that turns the phase on.
185/// What: `enabled` and `model` come from the file; every other field keeps its
186/// [`SemanticConsolidationConfig::default`] value. An empty `model` is left
187/// empty for [`dream_config_from_user_config`] to fill from `[openrouter]`.
188/// Returns the all-default (disabled) config when the file is absent.
189/// Test: `semantic_consolidation_is_off_without_a_config_file`.
190pub fn load_semantic_consolidation_config() -> SemanticConsolidationConfig {
191    load_semantic_consolidation_config_from(&read_user_config_min().unwrap_or_default())
192}
193
194/// The pure projection behind [`load_semantic_consolidation_config`].
195///
196/// Why (#5188): separates "read the file" from "read the table" so a test can
197/// state its own input instead of asserting against the developer's real
198/// `~/.trusty-memory/config.toml`.
199/// Test: `semantic_consolidation_is_off_without_a_config_file`.
200fn load_semantic_consolidation_config_from(parsed: &UserConfigMin) -> SemanticConsolidationConfig {
201    SemanticConsolidationConfig {
202        enabled: parsed.semantic_consolidation.enabled,
203        model: parsed.semantic_consolidation.model.clone(),
204        ..SemanticConsolidationConfig::default()
205    }
206}
207
208/// Read the user's `~/.trusty-memory/config.toml`, falling back to defaults.
209///
210/// Why: shared between HTTP config endpoint, chat tool dispatch, and
211/// provider auto-detection.
212/// What: returns `Some(LoadedUserConfig)` even when the file is missing
213/// (so callers see defaults consistently); `None` only when the home
214/// directory itself can't be resolved.
215/// Test: indirectly via `config_endpoint_returns_payload`.
216pub fn load_user_config() -> Option<LoadedUserConfig> {
217    let parsed = read_user_config_min()?;
218    let model = if parsed.openrouter.model.is_empty() {
219        "anthropic/claude-3-5-sonnet".to_string()
220    } else {
221        parsed.openrouter.model
222    };
223    Some(LoadedUserConfig {
224        openrouter_api_key: parsed.openrouter.api_key,
225        openrouter_model: model,
226        local_model: trusty_common::LocalModelConfig {
227            enabled: parsed.local_model.enabled,
228            base_url: parsed.local_model.base_url,
229            model: parsed.local_model.model,
230        },
231    })
232}
233
234/// Derive a `DreamConfig` seed from the user's config file.
235///
236/// Why (#2593): the idle dream scheduler and the on-demand
237/// `dream_consolidate_room`/`palace_dream` tools must translate the user's
238/// config into `DreamConfig` identically, or the two paths silently diverge —
239/// the idle scheduler once used `DreamConfig::default()` outright and never
240/// saw `config.toml` at all.
241///
242/// Why (#5188): the semantic phase's enable switch and model id now come from
243/// `[semantic_consolidation]` rather than being hardcoded. Two behaviours
244/// changed here. The phase is off unless the file says otherwise, and
245/// `[local_model] model` no longer leaks into `semantic.model`: forwarding it
246/// meant "no OpenRouter key" chose a local model server by itself, which is
247/// how an unconfigured daemon loaded a 45 GB model into a crash loop. A local
248/// server is now named explicitly — `model = "ollama/llama3.2"` — and
249/// `[local_model] enabled` only permits that choice.
250/// What: `semantic.enabled` and `semantic.model` come from
251/// [`load_semantic_consolidation_config`], with an empty model falling back to
252/// `[openrouter] model`. `openrouter_api_key` and `local_model_enabled` come
253/// from `cfg`. Every other `DreamConfig` field keeps its default.
254/// Test: `dream_config_is_off_and_names_no_local_model_by_default`,
255/// `dream_config_forwards_an_explicit_ollama_model`,
256/// `dream_config_from_user_config_prefers_openrouter_model_with_key`.
257pub fn dream_config_from_user_config(cfg: &LoadedUserConfig) -> DreamConfig {
258    let parsed = read_user_config_min().unwrap_or_default();
259    dream_config_from_parts(
260        cfg,
261        load_semantic_consolidation_config_from(&parsed),
262        parsed.dream.clone(),
263    )
264}
265
266/// [`dream_config_from_user_config`] with the semantic section passed in.
267///
268/// Why (#5188): `load_semantic_consolidation_config` reads the developer's real
269/// `~/.trusty-memory/config.toml`, so a test driving the public wrapper asserts
270/// against whatever that machine happens to hold. Splitting the file read from
271/// the derivation lets the tests state their own input.
272/// What: pure — no file, no environment.
273/// Test: `dream_config_is_off_and_names_no_local_model_by_default`,
274/// `dream_config_forwards_an_explicit_ollama_model`.
275fn dream_config_from_parts(
276    cfg: &LoadedUserConfig,
277    semantic: SemanticConsolidationConfig,
278    dream: DreamMin,
279) -> DreamConfig {
280    let defaults = DreamConfig::default();
281    // #5188: an empty `[semantic_consolidation] model` inherits the OpenRouter
282    // model id — never the local-model id, which would pick a local backend
283    // nobody asked for.
284    let model = if semantic.model.trim().is_empty() {
285        cfg.openrouter_model.clone()
286    } else {
287        semantic.model.clone()
288    };
289
290    DreamConfig {
291        openrouter_api_key: cfg.openrouter_api_key.clone(),
292        local_model_enabled: cfg.local_model.enabled,
293        semantic: SemanticConsolidationConfig { model, ..semantic },
294        // #6652: an absent key inherits the default rather than zeroing it.
295        compact: dream.compact.unwrap_or(defaults.compact),
296        prune_history_after_days: dream
297            .prune_history_after_days
298            .unwrap_or(defaults.prune_history_after_days),
299        compact_min_bytes: dream
300            .compact_min_bytes
301            .unwrap_or(defaults.compact_min_bytes),
302        compact_keep_backup: dream
303            .compact_keep_backup
304            .unwrap_or(defaults.compact_keep_backup),
305        ..defaults
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    fn openrouter_only_cfg() -> LoadedUserConfig {
314        LoadedUserConfig {
315            openrouter_api_key: String::new(),
316            openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
317            local_model: trusty_common::LocalModelConfig {
318                enabled: false,
319                base_url: "http://localhost:11434".to_string(),
320                model: "llama3.2".to_string(),
321            },
322        }
323    }
324
325    /// Why (#5188): the reported repro — no `~/.trusty-memory/config.toml`, no
326    /// provider key — must produce a `DreamConfig` that cannot reach a local
327    /// model server. Before the fix this config had `semantic.enabled = true`,
328    /// `local_model_enabled = true`, and `semantic.model = "qwen3:30b"`, which
329    /// is exactly what drove a 45 GB model into a crash loop.
330    /// What: derives from the all-defaults user config and asserts the phase is
331    /// off, the local backend is not permitted, and no local model id was
332    /// forwarded.
333    #[test]
334    fn dream_config_is_off_and_names_no_local_model_by_default() {
335        let dream_cfg = dream_config_from_parts(
336            &LoadedUserConfig::default(),
337            load_semantic_consolidation_config_from(&UserConfigMin::default()),
338            DreamMin::default(),
339        );
340
341        assert!(
342            !dream_cfg.semantic.enabled,
343            "semantic consolidation must be off until a config key enables it"
344        );
345        assert!(
346            !dream_cfg.local_model_enabled,
347            "a local model server must not be permitted by default"
348        );
349        assert!(
350            !dream_cfg.semantic.model.starts_with("ollama/")
351                && !dream_cfg.semantic.model.starts_with("local/"),
352            "no local model id may be forwarded by default, got {:?}",
353            dream_cfg.semantic.model
354        );
355    }
356
357    /// Why (#5188): `LoadedUserConfig::default()` is what `load_user_config`
358    /// returns when the file is absent, so its `local_model.enabled` IS the
359    /// no-config-file answer.
360    #[test]
361    fn loaded_user_config_default_disables_the_local_model() {
362        assert!(!LoadedUserConfig::default().local_model.enabled);
363    }
364
365    /// Why (#5188): an absent `[local_model]` table must mean "no", not
366    /// "yes" — that default is how the daemon decided a local model existed.
367    #[test]
368    fn absent_local_model_table_parses_as_disabled() {
369        let parsed: UserConfigMin = toml::from_str("").expect("empty config parses");
370        assert!(!parsed.local_model.enabled);
371        assert!(!parsed.semantic_consolidation.enabled);
372    }
373
374    /// Why (#5188): the `[semantic_consolidation]` table was not in the schema
375    /// at all, so a file asking for the phase was — like a file asking against
376    /// it — silently discarded. Pins that both directions now parse.
377    #[test]
378    fn semantic_consolidation_table_is_read_from_the_file() {
379        let parsed: UserConfigMin = toml::from_str(
380            r#"
381[semantic_consolidation]
382enabled = true
383model = "ollama/llama3.2"
384"#,
385        )
386        .expect("config parses");
387        assert!(parsed.semantic_consolidation.enabled);
388        assert_eq!(parsed.semantic_consolidation.model, "ollama/llama3.2");
389    }
390
391    /// Why (#5188): `[semantic]` matches `DreamConfig`'s field name, so an
392    /// operator reading the struct writes that spelling. Accepting only
393    /// `[semantic_consolidation]` would drop it silently — the same failure
394    /// this table was added to fix.
395    #[test]
396    fn semantic_table_alias_is_accepted() {
397        let parsed: UserConfigMin = toml::from_str(
398            r#"
399[semantic]
400enabled = true
401"#,
402        )
403        .expect("config parses");
404        assert!(parsed.semantic_consolidation.enabled);
405    }
406
407    /// Why (#5188): a local model server is reachable only when the operator
408    /// names it. Pins that the explicit `ollama/` id survives the derivation
409    /// verbatim — the prefix is what `resolve_consolidation_provider` reads.
410    #[test]
411    fn dream_config_forwards_an_explicit_ollama_model() {
412        let mut cfg = openrouter_only_cfg();
413        cfg.local_model.enabled = true;
414        let semantic = SemanticConsolidationConfig {
415            enabled: true,
416            model: "ollama/llama3.2".to_string(),
417            ..SemanticConsolidationConfig::default()
418        };
419
420        let dream_cfg = dream_config_from_parts(&cfg, semantic, DreamMin::default());
421
422        assert!(dream_cfg.semantic.enabled);
423        assert_eq!(dream_cfg.semantic.model, "ollama/llama3.2");
424        assert!(dream_cfg.local_model_enabled);
425    }
426
427    /// Why (#5188): with the phase enabled but no model named, the id must come
428    /// from `[openrouter]` — never from `[local_model]`, which is how "no key"
429    /// used to select a local backend on its own.
430    #[test]
431    fn empty_semantic_model_inherits_the_openrouter_model_not_the_local_one() {
432        let mut cfg = openrouter_only_cfg();
433        cfg.local_model.enabled = true;
434        cfg.local_model.model = "qwen3:30b".to_string();
435        let semantic = SemanticConsolidationConfig {
436            enabled: true,
437            model: String::new(),
438            ..SemanticConsolidationConfig::default()
439        };
440
441        let dream_cfg = dream_config_from_parts(&cfg, semantic, DreamMin::default());
442
443        assert_eq!(dream_cfg.semantic.model, "anthropic/claude-3-5-sonnet");
444    }
445
446    /// Why: an OpenRouter key configured in the file must reach `DreamConfig`
447    /// so the consolidator can build the OpenRouter backend.
448    #[test]
449    fn dream_config_from_user_config_prefers_openrouter_model_with_key() {
450        let cfg = LoadedUserConfig {
451            openrouter_api_key: "sk-test-key".to_string(),
452            openrouter_model: "anthropic/claude-3-5-sonnet".to_string(),
453            local_model: trusty_common::LocalModelConfig {
454                enabled: true,
455                base_url: "http://localhost:11434".to_string(),
456                model: "llama3.2".to_string(),
457            },
458        };
459        let semantic = SemanticConsolidationConfig {
460            enabled: true,
461            // No `[semantic_consolidation] model`, so `[openrouter] model` fills in.
462            model: String::new(),
463            ..SemanticConsolidationConfig::default()
464        };
465
466        let dream_cfg = dream_config_from_parts(&cfg, semantic, DreamMin::default());
467
468        assert_eq!(dream_cfg.semantic.model, "anthropic/claude-3-5-sonnet");
469        assert_eq!(dream_cfg.openrouter_api_key, "sk-test-key");
470    }
471
472    /// Why (#5188): `load_semantic_consolidation_config` reads the developer's
473    /// real config file, so the hermetic half of its contract — "an absent or
474    /// empty file yields a disabled phase" — is asserted through the same
475    /// projection with a stated input.
476    #[test]
477    fn semantic_consolidation_is_off_without_a_config_file() {
478        let cfg = load_semantic_consolidation_config_from(&UserConfigMin::default());
479        assert!(!cfg.enabled);
480        assert!(cfg.model.is_empty());
481    }
482
483    /// Why (#6652): each `[dream]` key is `Option` so an absent one inherits
484    /// the `DreamConfig` default rather than this struct's own. That only
485    /// matters when SOME keys are present — the case a "both empty" test would
486    /// miss entirely.
487    #[test]
488    fn dream_table_overrides_the_compaction_defaults() {
489        let dream = DreamMin {
490            compact: Some(false),
491            prune_history_after_days: Some(30),
492            compact_min_bytes: None,
493            compact_keep_backup: None,
494        };
495        let cfg = dream_config_from_parts(
496            &LoadedUserConfig::default(),
497            SemanticConsolidationConfig::default(),
498            dream,
499        );
500        let defaults = DreamConfig::default();
501        assert!(!cfg.compact, "an explicit false must switch compaction off");
502        assert_eq!(cfg.prune_history_after_days, 30);
503        assert_eq!(
504            cfg.compact_min_bytes, defaults.compact_min_bytes,
505            "an absent key inherits the default, not zero"
506        );
507        assert_eq!(cfg.compact_keep_backup, defaults.compact_keep_backup);
508    }
509
510    /// Why: a machine with no `[dream]` table at all must behave exactly as it
511    /// did before #6652 added the section.
512    #[test]
513    fn an_absent_dream_table_leaves_every_default() {
514        let cfg = dream_config_from_parts(
515            &LoadedUserConfig::default(),
516            SemanticConsolidationConfig::default(),
517            DreamMin::default(),
518        );
519        let defaults = DreamConfig::default();
520        assert_eq!(cfg.compact, defaults.compact);
521        assert_eq!(
522            cfg.prune_history_after_days,
523            defaults.prune_history_after_days
524        );
525        assert_eq!(cfg.compact_min_bytes, defaults.compact_min_bytes);
526        assert_eq!(cfg.compact_keep_backup, defaults.compact_keep_backup);
527    }
528}