Skip to main content

recall_echo/
config.rs

1use std::fmt;
2use std::fs;
3use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6
7const DEFAULT_MAX_ENTRIES: usize = 5;
8const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 3600;
9const CONFIG_FILE: &str = ".recall-echo.toml";
10
11// ── Provider enum ────────────────────────────────────────────────────────
12
13/// LLM provider for entity extraction.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum Provider {
17    Anthropic,
18    Openai,
19    ClaudeCode,
20}
21
22impl Provider {
23    #[must_use]
24    pub fn default_model(&self) -> &'static str {
25        match self {
26            Provider::Anthropic => "claude-haiku-4-5-20251001",
27            Provider::Openai => "llama3.2",
28            Provider::ClaudeCode => "",
29        }
30    }
31
32    #[must_use]
33    pub fn default_api_base(&self) -> &'static str {
34        match self {
35            Provider::Anthropic => "https://api.anthropic.com/v1/messages",
36            Provider::Openai => "http://localhost:11434/v1",
37            Provider::ClaudeCode => "",
38        }
39    }
40
41    pub fn from_str_loose(s: &str) -> Result<Self, crate::error::RecallError> {
42        match s.to_lowercase().as_str() {
43            "anthropic" | "claude" => Ok(Provider::Anthropic),
44            "openai" | "ollama" => Ok(Provider::Openai),
45            "claude-code" | "claudecode" => Ok(Provider::ClaudeCode),
46            other => Err(crate::error::RecallError::Config(format!(
47                "unknown provider: {other} (use 'anthropic', 'ollama', or 'claude-code')"
48            ))),
49        }
50    }
51}
52
53impl fmt::Display for Provider {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        match self {
56            Provider::Anthropic => write!(f, "anthropic"),
57            Provider::Openai => write!(f, "openai"),
58            Provider::ClaudeCode => write!(f, "claude-code"),
59        }
60    }
61}
62
63// ── Config structs ───────────────────────────────────────────────────────
64
65#[derive(Debug, Default, Serialize, Deserialize)]
66pub struct Config {
67    #[serde(default)]
68    pub ephemeral: EphemeralConfig,
69    #[serde(default)]
70    pub llm: LlmSection,
71    #[serde(default)]
72    pub pipeline: Option<PipelineSection>,
73    #[serde(default)]
74    pub graph: Option<GraphSection>,
75    #[serde(default)]
76    pub serve: ServeSection,
77}
78
79#[derive(Debug, Serialize, Deserialize)]
80pub struct EphemeralConfig {
81    #[serde(default = "default_max_entries")]
82    pub max_entries: usize,
83}
84
85impl Default for EphemeralConfig {
86    fn default() -> Self {
87        Self {
88            max_entries: DEFAULT_MAX_ENTRIES,
89        }
90    }
91}
92
93fn default_max_entries() -> usize {
94    DEFAULT_MAX_ENTRIES
95}
96
97#[derive(Debug, Serialize, Deserialize)]
98pub struct LlmSection {
99    #[serde(default = "default_provider")]
100    pub provider: Provider,
101    #[serde(default)]
102    pub model: String,
103    #[serde(default)]
104    pub api_base: String,
105}
106
107impl Default for LlmSection {
108    fn default() -> Self {
109        Self {
110            provider: Provider::Anthropic,
111            model: String::new(),
112            api_base: String::new(),
113        }
114    }
115}
116
117impl LlmSection {
118    /// Resolved model — uses configured value or provider default.
119    #[must_use]
120    pub fn resolved_model(&self) -> &str {
121        if self.model.is_empty() {
122            self.provider.default_model()
123        } else {
124            &self.model
125        }
126    }
127
128    /// Resolved API base — uses configured value or provider default.
129    #[must_use]
130    pub fn resolved_api_base(&self) -> &str {
131        if self.api_base.is_empty() {
132            self.provider.default_api_base()
133        } else {
134            &self.api_base
135        }
136    }
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct PipelineSection {
141    /// Directory containing pipeline documents (LEARNING.md, THOUGHTS.md, etc.)
142    #[serde(default)]
143    pub docs_dir: Option<String>,
144    /// Auto-sync pipeline on archive (default: false)
145    #[serde(default)]
146    pub auto_sync: Option<bool>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct GraphSection {
151    /// Connection mode: "embedded" or "server"
152    #[serde(default = "default_graph_mode")]
153    pub mode: String,
154    /// SurrealDB server URL (server mode only)
155    #[serde(default = "default_graph_url")]
156    pub url: String,
157    /// SurrealDB namespace
158    #[serde(default = "default_graph_namespace")]
159    pub namespace: String,
160    /// SurrealDB database name (typically the entity name)
161    #[serde(default)]
162    pub database: String,
163    /// SurrealDB username (typically the entity name)
164    #[serde(default)]
165    pub username: String,
166    /// Path to file containing the database password
167    #[serde(default)]
168    pub password_file: String,
169    /// Scoring weights for utility-weighted semantic search.
170    ///
171    /// Maps to the `[graph.scoring]` section of `.recall-echo.toml`. When
172    /// absent, defaults preserve the original hard-coded weights
173    /// (0.45 / 0.30 / 0.25). See `GraphScoringConfig` for details.
174    #[serde(default)]
175    pub scoring: GraphScoringConfig,
176}
177
178impl Default for GraphSection {
179    fn default() -> Self {
180        Self {
181            mode: default_graph_mode(),
182            url: default_graph_url(),
183            namespace: default_graph_namespace(),
184            database: String::new(),
185            username: String::new(),
186            password_file: String::new(),
187            scoring: GraphScoringConfig::default(),
188        }
189    }
190}
191
192/// Settings for the `recall-echo serve` graph daemon.
193///
194/// Maps to the `[serve]` section of `.recall-echo.toml`. The daemon is started
195/// transparently by graph commands and hooks when `[graph] mode = "embedded"`
196/// (the default); these keys only tune where it listens and how long it lives.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198#[serde(default)]
199pub struct ServeSection {
200    /// Override the unix socket path. Defaults to
201    /// `$XDG_RUNTIME_DIR/recall-echo/<hash of memory dir>.sock`.
202    pub socket_path: Option<String>,
203    /// Seconds of inactivity before the daemon shuts itself down.
204    /// `0` disables idle shutdown. Default `3600`.
205    pub idle_timeout_secs: u64,
206}
207
208impl Default for ServeSection {
209    fn default() -> Self {
210        Self {
211            socket_path: None,
212            idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS,
213        }
214    }
215}
216
217/// Scoring weights for utility-weighted semantic search.
218///
219/// The final score for a retrieved entity is computed as a linear combination
220/// of three signals:
221///
222/// ```text
223/// score = weight_semantic * similarity
224///       + weight_hotness  * hotness
225///       + weight_utility  * utility_score
226/// ```
227///
228/// Defaults (`0.45 / 0.30 / 0.25`) match the original hard-coded values, so
229/// omitting the `[graph.scoring]` section from `.recall-echo.toml` produces
230/// identical behavior to pre-v3.9.0 recall-echo.
231///
232/// Weights are not constrained to sum to 1.0 — the scoring function does not
233/// normalize. Callers that change these should calibrate against their own
234/// retrieval outcomes; see `utility-feedback-loop-spec.md` in pulse-null.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236#[serde(default)]
237pub struct GraphScoringConfig {
238    /// Weight applied to cosine similarity. Default `0.45`.
239    pub weight_semantic: f64,
240    /// Weight applied to the recency/access hotness signal. Default `0.30`.
241    pub weight_hotness: f64,
242    /// Weight applied to the utility score (outcome-feedback EMA). Default `0.25`.
243    pub weight_utility: f64,
244}
245
246impl Default for GraphScoringConfig {
247    fn default() -> Self {
248        Self {
249            weight_semantic: 0.45,
250            weight_hotness: 0.30,
251            weight_utility: 0.25,
252        }
253    }
254}
255
256fn default_graph_mode() -> String {
257    "embedded".to_string()
258}
259
260fn default_graph_url() -> String {
261    "ws://localhost:8787".to_string()
262}
263
264fn default_graph_namespace() -> String {
265    "nullarc".to_string()
266}
267
268fn default_provider() -> Provider {
269    Provider::Anthropic
270}
271
272// ── Load / Save ──────────────────────────────────────────────────────────
273
274/// Config file path for a given base directory.
275#[must_use]
276pub fn config_path(base: &Path) -> std::path::PathBuf {
277    base.join(CONFIG_FILE)
278}
279
280/// Load config from .recall-echo.toml in the given directory.
281/// Returns defaults if file doesn't exist or is malformed.
282#[must_use]
283pub fn load_from_dir(dir: &Path) -> Config {
284    load(dir)
285}
286
287/// Load config from .recall-echo.toml in the base dir.
288/// Returns defaults if file doesn't exist or is malformed.
289#[must_use]
290pub fn load(base: &Path) -> Config {
291    let path = config_path(base);
292    if !path.exists() {
293        return Config::default();
294    }
295
296    let content = match fs::read_to_string(&path) {
297        Ok(c) => c,
298        Err(_) => return Config::default(),
299    };
300
301    match toml::from_str(&content) {
302        Ok(cfg) => validate(cfg),
303        Err(_) => Config::default(),
304    }
305}
306
307/// Save config to .recall-echo.toml in the base dir.
308pub fn save(base: &Path, config: &Config) -> Result<(), crate::error::RecallError> {
309    let path = config_path(base);
310    let content = toml::to_string_pretty(config)?;
311    fs::write(&path, content)?;
312    Ok(())
313}
314
315/// Returns true if .recall-echo.toml exists in the directory.
316#[must_use]
317pub fn exists(base: &Path) -> bool {
318    config_path(base).exists()
319}
320
321fn validate(mut cfg: Config) -> Config {
322    if !(1..=50).contains(&cfg.ephemeral.max_entries) {
323        cfg.ephemeral.max_entries = DEFAULT_MAX_ENTRIES;
324    }
325    cfg
326}
327
328// ── Config mutation helpers ──────────────────────────────────────────────
329
330impl Config {
331    /// Set a dotted config key (e.g. "llm.provider", "ephemeral.max_entries").
332    pub fn set_key(&mut self, key: &str, value: &str) -> Result<(), crate::error::RecallError> {
333        use crate::error::RecallError;
334        match key {
335            "llm.provider" | "provider" => {
336                let provider = Provider::from_str_loose(value)?;
337                // When switching provider, reset model and api_base to defaults
338                self.llm.model = String::new();
339                self.llm.api_base = String::new();
340                self.llm.provider = provider;
341                Ok(())
342            }
343            "llm.model" | "model" => {
344                self.llm.model = value.to_string();
345                Ok(())
346            }
347            "llm.api_base" | "api_base" => {
348                self.llm.api_base = value.to_string();
349                Ok(())
350            }
351            "ephemeral.max_entries" => {
352                let n: usize = value
353                    .parse()
354                    .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
355                if !(1..=50).contains(&n) {
356                    return Err(RecallError::Config(
357                        "max_entries must be between 1 and 50".into(),
358                    ));
359                }
360                self.ephemeral.max_entries = n;
361                Ok(())
362            }
363            "pipeline.docs_dir" => {
364                let section = self.pipeline.get_or_insert(PipelineSection {
365                    docs_dir: None,
366                    auto_sync: None,
367                });
368                section.docs_dir = Some(value.to_string());
369                Ok(())
370            }
371            "pipeline.auto_sync" => {
372                let b: bool = value
373                    .parse()
374                    .map_err(|_| RecallError::Config(format!("invalid boolean: {value}")))?;
375                let section = self.pipeline.get_or_insert(PipelineSection {
376                    docs_dir: None,
377                    auto_sync: None,
378                });
379                section.auto_sync = Some(b);
380                Ok(())
381            }
382            "serve.idle_timeout_secs" => {
383                let secs: u64 = value
384                    .parse()
385                    .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
386                self.serve.idle_timeout_secs = secs;
387                Ok(())
388            }
389            "serve.socket_path" => {
390                self.serve.socket_path = if value.trim().is_empty() {
391                    None
392                } else {
393                    Some(value.to_string())
394                };
395                Ok(())
396            }
397            other => Err(RecallError::Config(format!("unknown config key: {other}"))),
398        }
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn default_config() {
408        let cfg = Config::default();
409        assert_eq!(cfg.ephemeral.max_entries, 5);
410        assert_eq!(cfg.llm.provider, Provider::Anthropic);
411        assert!(cfg.llm.model.is_empty());
412    }
413
414    #[test]
415    fn parse_ephemeral_only() {
416        let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 10\n").unwrap();
417        assert_eq!(cfg.ephemeral.max_entries, 10);
418        assert_eq!(cfg.llm.provider, Provider::Anthropic);
419    }
420
421    #[test]
422    fn graph_mode_defaults_to_embedded() {
423        let cfg: Config = toml::from_str("[graph]\n").unwrap();
424        assert_eq!(cfg.graph.unwrap().mode, "embedded");
425    }
426
427    #[test]
428    fn graph_mode_parses_server() {
429        let cfg: Config =
430            toml::from_str("[graph]\nmode = \"server\"\nurl = \"ws://db.local:8787\"\n").unwrap();
431        let g = cfg.graph.unwrap();
432        assert_eq!(g.mode, "server");
433        assert_eq!(g.url, "ws://db.local:8787");
434    }
435
436    #[test]
437    fn serve_defaults_when_section_absent() {
438        let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 3\n").unwrap();
439        assert_eq!(cfg.serve.idle_timeout_secs, DEFAULT_IDLE_TIMEOUT_SECS);
440        assert!(cfg.serve.socket_path.is_none());
441    }
442
443    #[test]
444    fn serve_section_parses_overrides() {
445        let cfg: Config = toml::from_str(
446            "[serve]\nsocket_path = \"/run/re/graph.sock\"\nidle_timeout_secs = 60\n",
447        )
448        .unwrap();
449        assert_eq!(cfg.serve.idle_timeout_secs, 60);
450        assert_eq!(cfg.serve.socket_path.as_deref(), Some("/run/re/graph.sock"));
451    }
452
453    #[test]
454    fn set_key_serve_idle_timeout() {
455        let mut cfg = Config::default();
456        cfg.set_key("serve.idle_timeout_secs", "120").unwrap();
457        assert_eq!(cfg.serve.idle_timeout_secs, 120);
458        assert!(cfg.set_key("serve.idle_timeout_secs", "soon").is_err());
459    }
460
461    #[test]
462    fn parse_llm_section() {
463        let cfg: Config = toml::from_str(
464            "[llm]\nprovider = \"openai\"\nmodel = \"llama3.1\"\napi_base = \"http://myhost:11434/v1\"\n",
465        )
466        .unwrap();
467        assert_eq!(cfg.llm.provider, Provider::Openai);
468        assert_eq!(cfg.llm.model, "llama3.1");
469        assert_eq!(cfg.llm.api_base, "http://myhost:11434/v1");
470    }
471
472    #[test]
473    fn parse_claude_code_provider() {
474        let cfg: Config = toml::from_str("[llm]\nprovider = \"claude-code\"\n").unwrap();
475        assert_eq!(cfg.llm.provider, Provider::ClaudeCode);
476    }
477
478    #[test]
479    fn resolved_defaults() {
480        let llm = LlmSection::default();
481        assert_eq!(llm.resolved_model(), "claude-haiku-4-5-20251001");
482        assert_eq!(
483            llm.resolved_api_base(),
484            "https://api.anthropic.com/v1/messages"
485        );
486    }
487
488    #[test]
489    fn resolved_custom_overrides_default() {
490        let llm = LlmSection {
491            provider: Provider::Openai,
492            model: "mistral-7b".into(),
493            api_base: String::new(),
494        };
495        assert_eq!(llm.resolved_model(), "mistral-7b");
496        assert_eq!(llm.resolved_api_base(), "http://localhost:11434/v1");
497    }
498
499    #[test]
500    fn round_trip_toml() {
501        let cfg = Config {
502            ephemeral: EphemeralConfig { max_entries: 3 },
503            llm: LlmSection {
504                provider: Provider::Openai,
505                model: "llama3.2".into(),
506                api_base: "http://localhost:11434/v1".into(),
507            },
508            pipeline: None,
509            graph: None,
510            serve: ServeSection::default(),
511        };
512        let s = toml::to_string_pretty(&cfg).unwrap();
513        let parsed: Config = toml::from_str(&s).unwrap();
514        assert_eq!(parsed.ephemeral.max_entries, 3);
515        assert_eq!(parsed.llm.provider, Provider::Openai);
516        assert_eq!(parsed.llm.model, "llama3.2");
517    }
518
519    #[test]
520    fn set_key_provider() {
521        let mut cfg = Config::default();
522        cfg.set_key("llm.provider", "ollama").unwrap();
523        assert_eq!(cfg.llm.provider, Provider::Openai);
524        assert!(cfg.llm.model.is_empty());
525    }
526
527    #[test]
528    fn set_key_model() {
529        let mut cfg = Config::default();
530        cfg.set_key("llm.model", "claude-sonnet-4-6").unwrap();
531        assert_eq!(cfg.llm.model, "claude-sonnet-4-6");
532    }
533
534    #[test]
535    fn set_key_unknown_fails() {
536        let mut cfg = Config::default();
537        assert!(cfg.set_key("nonexistent.key", "value").is_err());
538    }
539
540    #[test]
541    fn provider_from_str_loose() {
542        assert_eq!(
543            Provider::from_str_loose("ollama").unwrap(),
544            Provider::Openai
545        );
546        assert_eq!(
547            Provider::from_str_loose("claude").unwrap(),
548            Provider::Anthropic
549        );
550        assert_eq!(
551            Provider::from_str_loose("claude-code").unwrap(),
552            Provider::ClaudeCode
553        );
554        assert!(Provider::from_str_loose("unknown").is_err());
555    }
556
557    #[test]
558    fn save_and_load() {
559        let tmp = tempfile::tempdir().unwrap();
560        let cfg = Config {
561            ephemeral: EphemeralConfig { max_entries: 7 },
562            llm: LlmSection {
563                provider: Provider::ClaudeCode,
564                model: String::new(),
565                api_base: String::new(),
566            },
567            pipeline: None,
568            graph: None,
569            serve: ServeSection::default(),
570        };
571        save(tmp.path(), &cfg).unwrap();
572        let loaded = load(tmp.path());
573        assert_eq!(loaded.ephemeral.max_entries, 7);
574        assert_eq!(loaded.llm.provider, Provider::ClaudeCode);
575    }
576
577    #[test]
578    fn load_nonexistent_file() {
579        let tmp = tempfile::tempdir().unwrap();
580        let cfg = load(tmp.path());
581        assert_eq!(cfg.ephemeral.max_entries, 5);
582    }
583
584    #[test]
585    fn validate_out_of_range() {
586        let cfg = validate(Config {
587            ephemeral: EphemeralConfig { max_entries: 100 },
588            llm: LlmSection::default(),
589            pipeline: None,
590            graph: None,
591            serve: ServeSection::default(),
592        });
593        assert_eq!(cfg.ephemeral.max_entries, 5);
594    }
595
596    #[test]
597    fn graph_scoring_defaults_match_legacy_hardcodes() {
598        let scoring = GraphScoringConfig::default();
599        assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
600        assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
601        assert!((scoring.weight_utility - 0.25).abs() < f64::EPSILON);
602    }
603
604    #[test]
605    fn graph_scoring_partial_toml_fills_defaults() {
606        let scoring: GraphScoringConfig =
607            toml::from_str("weight_utility = 0.5\n").expect("parse partial scoring");
608        assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
609        assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
610        assert!((scoring.weight_utility - 0.5).abs() < f64::EPSILON);
611    }
612
613    #[test]
614    fn graph_scoring_empty_section_yields_defaults() {
615        let section: GraphSection = toml::from_str("").expect("parse empty graph section");
616        let defaults = GraphScoringConfig::default();
617        assert!((section.scoring.weight_semantic - defaults.weight_semantic).abs() < f64::EPSILON);
618        assert!((section.scoring.weight_hotness - defaults.weight_hotness).abs() < f64::EPSILON);
619        assert!((section.scoring.weight_utility - defaults.weight_utility).abs() < f64::EPSILON);
620    }
621
622    #[test]
623    fn graph_scoring_nested_under_graph() {
624        let cfg: Config = toml::from_str(
625            "[graph]\nmode = \"embedded\"\n\n[graph.scoring]\nweight_utility = 0.5\n",
626        )
627        .expect("parse nested scoring");
628        let scoring = cfg.graph.expect("graph section present").scoring;
629        assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
630        assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
631        assert!((scoring.weight_utility - 0.5).abs() < f64::EPSILON);
632    }
633}