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}