Skip to main content

recall_echo/
config.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5use std::fmt;
6use std::fs;
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11/// Evidence weights per provenance class, re-exported from the confidence
12/// model that owns them: `[graph.provenance]` is only their config surface.
13pub use crate::graph::confidence::ProvenanceWeights;
14
15const DEFAULT_MAX_ENTRIES: usize = 5;
16const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 3600;
17const CONFIG_FILE: &str = ".recall-echo.toml";
18
19/// Seconds of quiet before the daemon starts extracting in the background.
20///
21/// Two minutes: long enough that a session's burst of hooks and queries is
22/// over, short enough that a conversation archived at the end of a working day
23/// has become entities before the next one starts.
24const DEFAULT_EXTRACTION_IDLE_AFTER_SECS: u64 = 120;
25/// Archives one background batch extracts before yielding.
26const DEFAULT_EXTRACTION_BATCH_SIZE: usize = 3;
27
28/// Seconds a CLI transcript must go untouched before capture treats the session
29/// as over.
30///
31/// Five minutes: longer than any pause inside a working session, short enough
32/// that a session ended at lunchtime is memory by the afternoon.
33const DEFAULT_CAPTURE_SETTLE_SECS: u64 = 300;
34
35// ── Provider enum ────────────────────────────────────────────────────────
36
37/// LLM provider for entity extraction.
38///
39/// Two families. [`Provider::Anthropic`] and [`Provider::Openai`] talk HTTP —
40/// an API key, billed per token (the OpenAI-compatible one also covers Ollama
41/// and any local server that speaks that protocol). Everything else spawns an
42/// agent CLI the user already pays a subscription for; those are all one
43/// implementation driven by a [`CliPreset`], so supporting a new vendor is a
44/// preset — or just a `[llm.cli]` section — rather than a new code path.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "kebab-case")]
47pub enum Provider {
48    Anthropic,
49    Openai,
50    ClaudeCode,
51    Gemini,
52    Grok,
53    Codex,
54    /// Any other agent CLI, described entirely by `[llm.cli]`.
55    Cli,
56}
57
58impl Provider {
59    #[must_use]
60    pub fn default_model(&self) -> &'static str {
61        match self {
62            Provider::Anthropic => "claude-haiku-4-5-20251001",
63            Provider::Openai => "llama3.2",
64            _ => "",
65        }
66    }
67
68    #[must_use]
69    pub fn default_api_base(&self) -> &'static str {
70        match self {
71            Provider::Anthropic => "https://api.anthropic.com/v1/messages",
72            Provider::Openai => "http://localhost:11434/v1",
73            _ => "",
74        }
75    }
76
77    /// True when this provider completes by spawning an agent CLI.
78    #[must_use]
79    pub fn is_cli(&self) -> bool {
80        self.default_cli_preset().is_some()
81    }
82
83    /// The preset a CLI provider starts from, before `[llm.cli]` overrides.
84    /// `None` for the HTTP providers.
85    #[must_use]
86    pub fn default_cli_preset(&self) -> Option<CliPreset> {
87        match self {
88            Provider::Anthropic | Provider::Openai => None,
89            Provider::ClaudeCode => Some(CliPreset::ClaudeCode),
90            Provider::Gemini => Some(CliPreset::Gemini),
91            Provider::Grok => Some(CliPreset::Grok),
92            Provider::Codex => Some(CliPreset::Codex),
93            Provider::Cli => Some(CliPreset::Custom),
94        }
95    }
96
97    pub fn from_str_loose(s: &str) -> Result<Self, crate::error::RecallError> {
98        match s.to_lowercase().as_str() {
99            "anthropic" | "claude" => Ok(Provider::Anthropic),
100            "openai" | "ollama" | "openai-compat" => Ok(Provider::Openai),
101            "claude-code" | "claudecode" => Ok(Provider::ClaudeCode),
102            "gemini" | "gemini-cli" | "google" => Ok(Provider::Gemini),
103            "grok" | "grok-cli" | "xai" => Ok(Provider::Grok),
104            "codex" | "codex-cli" => Ok(Provider::Codex),
105            "cli" | "custom" | "custom-cli" => Ok(Provider::Cli),
106            other => Err(crate::error::RecallError::Config(format!(
107                "unknown provider: {other} (use 'anthropic', 'ollama', 'claude-code', \
108                 'gemini', 'grok', 'codex', or 'cli' with a [llm.cli] section)"
109            ))),
110        }
111    }
112}
113
114impl fmt::Display for Provider {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        let name = match self {
117            Provider::Anthropic => "anthropic",
118            Provider::Openai => "openai",
119            Provider::ClaudeCode => "claude-code",
120            Provider::Gemini => "gemini",
121            Provider::Grok => "grok",
122            Provider::Codex => "codex",
123            Provider::Cli => "cli",
124        };
125        f.write_str(name)
126    }
127}
128
129// ── Agent-CLI provider config ────────────────────────────────────────────
130
131/// A known agent CLI's calling convention.
132///
133/// A preset is a set of defaults for [`CliSection`], nothing more: every field
134/// it fills can be overridden per key, and [`CliPreset::Custom`] fills almost
135/// nothing, so an unlisted CLI is configured rather than coded.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "kebab-case")]
138pub enum CliPreset {
139    ClaudeCode,
140    Gemini,
141    Grok,
142    Codex,
143    Custom,
144}
145
146impl fmt::Display for CliPreset {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        let name = match self {
149            CliPreset::ClaudeCode => "claude-code",
150            CliPreset::Gemini => "gemini",
151            CliPreset::Grok => "grok",
152            CliPreset::Codex => "codex",
153            CliPreset::Custom => "custom",
154        };
155        f.write_str(name)
156    }
157}
158
159impl CliPreset {
160    pub fn from_str_loose(s: &str) -> Result<Self, crate::error::RecallError> {
161        match s.to_lowercase().as_str() {
162            "claude-code" | "claudecode" | "claude" => Ok(CliPreset::ClaudeCode),
163            "gemini" | "gemini-cli" => Ok(CliPreset::Gemini),
164            "grok" | "grok-cli" => Ok(CliPreset::Grok),
165            "codex" | "codex-cli" => Ok(CliPreset::Codex),
166            "custom" | "none" => Ok(CliPreset::Custom),
167            other => Err(crate::error::RecallError::Config(format!(
168                "unknown CLI preset: {other} (use 'claude-code', 'gemini', 'grok', \
169                 'codex', or 'custom')"
170            ))),
171        }
172    }
173}
174
175/// How the prompt reaches the CLI.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(rename_all = "kebab-case")]
178pub enum PromptDelivery {
179    /// Written to the process's stdin.
180    Stdin,
181    /// Passed as the value of `prompt_flag`.
182    Flag,
183    /// Passed as the last positional argument.
184    Arg,
185}
186
187impl fmt::Display for PromptDelivery {
188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189        let name = match self {
190            PromptDelivery::Stdin => "stdin",
191            PromptDelivery::Flag => "flag",
192            PromptDelivery::Arg => "arg",
193        };
194        f.write_str(name)
195    }
196}
197
198impl PromptDelivery {
199    pub fn from_str_loose(s: &str) -> Result<Self, crate::error::RecallError> {
200        match s.to_lowercase().as_str() {
201            "stdin" | "pipe" => Ok(PromptDelivery::Stdin),
202            "flag" | "option" => Ok(PromptDelivery::Flag),
203            "arg" | "argument" | "positional" => Ok(PromptDelivery::Arg),
204            other => Err(crate::error::RecallError::Config(format!(
205                "unknown prompt delivery: {other} (use 'stdin', 'flag', or 'arg')"
206            ))),
207        }
208    }
209}
210
211/// The shape of a CLI's stdout.
212///
213/// Agent CLIs do not agree on this, and the disagreement is structural rather
214/// than cosmetic: `claude`, `grok` and `gemini` print one JSON object,
215/// `codex --json` prints one object *per line* covering the whole run, and
216/// plenty print prose. A mode plus a path covers all three, so a CLI with a
217/// fourth shape needs a mode — not a provider.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "kebab-case")]
220pub enum OutputMode {
221    /// Stdout is the answer.
222    Raw,
223    /// Stdout is one JSON document; `result_json_path` locates the answer.
224    SingleJson,
225    /// Stdout is newline-delimited JSON; `ndjson_match` selects the event and
226    /// `result_json_path` locates the answer inside it.
227    Ndjson,
228}
229
230impl fmt::Display for OutputMode {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        let name = match self {
233            OutputMode::Raw => "raw",
234            OutputMode::SingleJson => "single-json",
235            OutputMode::Ndjson => "ndjson",
236        };
237        f.write_str(name)
238    }
239}
240
241impl OutputMode {
242    pub fn from_str_loose(s: &str) -> Result<Self, crate::error::RecallError> {
243        match s.to_lowercase().as_str() {
244            "raw" | "text" | "plain" => Ok(OutputMode::Raw),
245            "single-json" | "json" => Ok(OutputMode::SingleJson),
246            "ndjson" | "jsonl" | "json-lines" | "streaming-json" => Ok(OutputMode::Ndjson),
247            other => Err(crate::error::RecallError::Config(format!(
248                "unknown output mode: {other} (use 'raw', 'single-json', or 'ndjson')"
249            ))),
250        }
251    }
252}
253
254/// Predicates that pick one line out of an NDJSON stream.
255///
256/// Each entry is `dotted.path=value`; a line qualifies when every entry
257/// matches, and the last qualifying line is the answer — which is what makes
258/// `codex` readable: its final message is
259/// `type=item.completed` plus `item.type=agent_message`.
260#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
261#[serde(transparent)]
262pub struct LineMatchers(Vec<String>);
263
264impl LineMatchers {
265    #[must_use]
266    pub fn new(matchers: impl IntoIterator<Item = String>) -> Self {
267        Self(
268            matchers
269                .into_iter()
270                .map(|m| m.trim().to_string())
271                .filter(|m| !m.is_empty())
272                .collect(),
273        )
274    }
275
276    /// Parse a comma-separated list, as `config set` receives it.
277    #[must_use]
278    pub fn parse(value: &str) -> Self {
279        Self::new(value.split(',').map(str::to_string))
280    }
281
282    /// The predicates, split into path and expected value. Entries without an
283    /// `=` are dropped rather than matching everything.
284    #[must_use]
285    pub fn predicates(&self) -> Vec<(&str, &str)> {
286        self.0
287            .iter()
288            .filter_map(|matcher| matcher.split_once('='))
289            .map(|(path, value)| (path.trim(), value.trim()))
290            .collect()
291    }
292
293    #[must_use]
294    pub fn is_empty(&self) -> bool {
295        self.0.is_empty()
296    }
297}
298
299impl fmt::Display for LineMatchers {
300    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301        f.write_str(&self.0.join(", "))
302    }
303}
304
305/// Where a CLI's answer sits in its JSON output.
306///
307/// A dotted path per candidate — `result`, `response.text`, `messages.0.text`
308/// (numeric segments index arrays). Candidates are tried in order, which is how
309/// a preset covers a CLI whose envelope is not pinned down; an empty list means
310/// "the CLI prints prose, use stdout verbatim". Accepts a bare string or an
311/// array in TOML.
312#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
313#[serde(from = "JsonPathSpec", into = "JsonPathSpec")]
314pub struct JsonPaths(Vec<String>);
315
316#[derive(Serialize, Deserialize)]
317#[serde(untagged)]
318enum JsonPathSpec {
319    One(String),
320    Many(Vec<String>),
321}
322
323impl From<JsonPathSpec> for JsonPaths {
324    fn from(spec: JsonPathSpec) -> Self {
325        match spec {
326            JsonPathSpec::One(path) => JsonPaths::new(std::iter::once(path)),
327            JsonPathSpec::Many(paths) => JsonPaths::new(paths),
328        }
329    }
330}
331
332impl From<JsonPaths> for JsonPathSpec {
333    fn from(paths: JsonPaths) -> Self {
334        let mut paths = paths.0;
335        if paths.len() == 1 {
336            JsonPathSpec::One(paths.remove(0))
337        } else {
338            JsonPathSpec::Many(paths)
339        }
340    }
341}
342
343impl JsonPaths {
344    /// Collect non-empty, trimmed paths. Empty entries are dropped, so
345    /// `result_json_path = ""` means "raw stdout".
346    #[must_use]
347    pub fn new(paths: impl IntoIterator<Item = String>) -> Self {
348        Self(
349            paths
350                .into_iter()
351                .map(|p| p.trim().to_string())
352                .filter(|p| !p.is_empty())
353                .collect(),
354        )
355    }
356
357    /// Parse a comma-separated list, as `config set` receives it.
358    #[must_use]
359    pub fn parse(value: &str) -> Self {
360        Self::new(value.split(',').map(str::to_string))
361    }
362
363    #[must_use]
364    pub fn paths(&self) -> &[String] {
365        &self.0
366    }
367
368    #[must_use]
369    pub fn is_empty(&self) -> bool {
370        self.0.is_empty()
371    }
372}
373
374impl fmt::Display for JsonPaths {
375    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
376        f.write_str(&self.0.join(", "))
377    }
378}
379
380/// Overrides for the spawned agent CLI (`[llm.cli]`).
381///
382/// Every key is optional and every key overrides the same field of the preset
383/// chosen by `[llm] provider` (or by `preset` here). Omitting the whole section
384/// — which every config written before this existed does — leaves the preset
385/// untouched.
386#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
387pub struct CliSection {
388    /// Calling convention to start from. Defaults to the one implied by
389    /// `[llm] provider`.
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub preset: Option<CliPreset>,
392    /// Binary name or absolute path.
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub command: Option<String>,
395    /// Fixed arguments placed before every generated flag (a subcommand, say).
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub args: Option<Vec<String>>,
398    /// How the prompt reaches the CLI.
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    pub prompt_delivery: Option<PromptDelivery>,
401    /// Flag carrying the prompt when `prompt_delivery = "flag"`.
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub prompt_flag: Option<String>,
404    /// Flag selecting the model. Empty, or an empty model, omits it.
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub model_flag: Option<String>,
407    /// Flag selecting the output format. Empty omits it and its value.
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub output_format_flag: Option<String>,
410    /// Value for `output_format_flag`. Empty passes the flag on its own, for
411    /// the CLIs whose output switch is a boolean (`codex --json`).
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub output_format_value: Option<String>,
414    /// Shape of the CLI's stdout. Defaults to the preset's; setting
415    /// `result_json_path` on a preset that prints prose implies `single-json`.
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub output_mode: Option<OutputMode>,
418    /// `dotted.path=value` predicates selecting the answer's line under
419    /// `output_mode = "ndjson"`.
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub ndjson_match: Option<LineMatchers>,
422    /// Flag carrying the system prompt. Empty prepends it to the message
423    /// instead — what CLIs without the concept need.
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub system_prompt_flag: Option<String>,
426    /// Where the answer sits in the CLI's JSON output; empty means stdout is
427    /// the answer.
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub result_json_path: Option<JsonPaths>,
430    /// Arguments appended after the generated flags.
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    pub extra_args: Option<Vec<String>>,
433    /// Per-call wall-clock limit. `0` waits forever.
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub timeout_secs: Option<u64>,
436}
437
438impl CliSection {
439    /// True when nothing is overridden — the section is then left out of a
440    /// saved config entirely.
441    #[must_use]
442    pub fn is_empty(&self) -> bool {
443        *self == Self::default()
444    }
445
446    /// Set one `llm.cli.*` key, given the part after `llm.cli.`.
447    pub fn set_key(&mut self, key: &str, value: &str) -> Result<(), crate::error::RecallError> {
448        use crate::error::RecallError;
449        match key {
450            "preset" => self.preset = Some(CliPreset::from_str_loose(value)?),
451            "command" => self.command = Some(value.to_string()),
452            "args" => self.args = Some(split_args(value)),
453            "prompt_delivery" => {
454                self.prompt_delivery = Some(PromptDelivery::from_str_loose(value)?)
455            }
456            "prompt_flag" => self.prompt_flag = Some(value.to_string()),
457            "model_flag" => self.model_flag = Some(value.to_string()),
458            "output_format_flag" => self.output_format_flag = Some(value.to_string()),
459            "output_format_value" => self.output_format_value = Some(value.to_string()),
460            "output_mode" => self.output_mode = Some(OutputMode::from_str_loose(value)?),
461            "ndjson_match" => self.ndjson_match = Some(LineMatchers::parse(value)),
462            "system_prompt_flag" => self.system_prompt_flag = Some(value.to_string()),
463            "result_json_path" => self.result_json_path = Some(JsonPaths::parse(value)),
464            "extra_args" => self.extra_args = Some(split_args(value)),
465            "timeout_secs" => {
466                self.timeout_secs = Some(
467                    value
468                        .parse()
469                        .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?,
470                );
471            }
472            other => {
473                return Err(RecallError::Config(format!(
474                    "unknown config key: llm.cli.{other}"
475                )))
476            }
477        }
478        Ok(())
479    }
480}
481
482/// Split a whitespace-separated argument list from `config set`.
483fn split_args(value: &str) -> Vec<String> {
484    value.split_whitespace().map(str::to_string).collect()
485}
486
487// ── Config structs ───────────────────────────────────────────────────────
488
489#[derive(Debug, Default, Serialize, Deserialize)]
490pub struct Config {
491    #[serde(default)]
492    pub ephemeral: EphemeralConfig,
493    #[serde(default)]
494    pub llm: LlmSection,
495    #[serde(default)]
496    pub pipeline: Option<PipelineSection>,
497    #[serde(default)]
498    pub graph: Option<GraphSection>,
499    #[serde(default)]
500    pub serve: ServeSection,
501    #[serde(default)]
502    pub extraction: ExtractionSection,
503    #[serde(default)]
504    pub capture: CaptureSection,
505}
506
507#[derive(Debug, Serialize, Deserialize)]
508pub struct EphemeralConfig {
509    #[serde(default = "default_max_entries")]
510    pub max_entries: usize,
511}
512
513impl Default for EphemeralConfig {
514    fn default() -> Self {
515        Self {
516            max_entries: DEFAULT_MAX_ENTRIES,
517        }
518    }
519}
520
521fn default_max_entries() -> usize {
522    DEFAULT_MAX_ENTRIES
523}
524
525#[derive(Debug, Serialize, Deserialize)]
526pub struct LlmSection {
527    #[serde(default = "default_provider")]
528    pub provider: Provider,
529    #[serde(default)]
530    pub model: String,
531    #[serde(default)]
532    pub api_base: String,
533    /// Overrides for the spawned agent CLI. Serialized only when non-empty, so
534    /// a config that never touches it stays byte-identical.
535    #[serde(default, skip_serializing_if = "CliSection::is_empty")]
536    pub cli: CliSection,
537}
538
539impl Default for LlmSection {
540    fn default() -> Self {
541        Self {
542            provider: Provider::Anthropic,
543            model: String::new(),
544            api_base: String::new(),
545            cli: CliSection::default(),
546        }
547    }
548}
549
550impl LlmSection {
551    /// Resolved model — uses configured value or provider default.
552    #[must_use]
553    pub fn resolved_model(&self) -> &str {
554        if self.model.is_empty() {
555            self.provider.default_model()
556        } else {
557            &self.model
558        }
559    }
560
561    /// Resolved API base — uses configured value or provider default.
562    #[must_use]
563    pub fn resolved_api_base(&self) -> &str {
564        if self.api_base.is_empty() {
565            self.provider.default_api_base()
566        } else {
567            &self.api_base
568        }
569    }
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize)]
573pub struct PipelineSection {
574    /// Directory containing pipeline documents (LEARNING.md, THOUGHTS.md, etc.)
575    #[serde(default)]
576    pub docs_dir: Option<String>,
577    /// Auto-sync pipeline on archive (default: false)
578    #[serde(default)]
579    pub auto_sync: Option<bool>,
580}
581
582#[derive(Debug, Clone, Serialize, Deserialize)]
583pub struct GraphSection {
584    /// Connection mode: "embedded" or "server"
585    #[serde(default = "default_graph_mode")]
586    pub mode: String,
587    /// SurrealDB server URL (server mode only)
588    #[serde(default = "default_graph_url")]
589    pub url: String,
590    /// SurrealDB namespace
591    #[serde(default = "default_graph_namespace")]
592    pub namespace: String,
593    /// SurrealDB database name (typically the entity name)
594    #[serde(default)]
595    pub database: String,
596    /// SurrealDB username (typically the entity name)
597    #[serde(default)]
598    pub username: String,
599    /// Path to file containing the database password
600    #[serde(default)]
601    pub password_file: String,
602    /// Scoring weights for utility-weighted semantic search.
603    ///
604    /// Maps to the `[graph.scoring]` section of `.recall-echo.toml`. When
605    /// absent, defaults preserve the original hard-coded weights
606    /// (0.45 / 0.30 / 0.25). See `GraphScoringConfig` for details.
607    #[serde(default)]
608    pub scoring: GraphScoringConfig,
609    /// Evidence weights per provenance class.
610    ///
611    /// Maps to the `[graph.provenance]` section of `.recall-echo.toml`. When
612    /// absent, defaults are 1.0 external / 0.8 user / 0.05 self. See
613    /// [`ProvenanceWeights`] for details.
614    #[serde(default)]
615    pub provenance: ProvenanceWeights,
616    /// Similarity bands that decide when entity dedup pays for a model call.
617    ///
618    /// Maps to the `[graph.dedup]` section of `.recall-echo.toml`. See
619    /// [`GraphDedupConfig`] for the bands and their defaults.
620    #[serde(default)]
621    pub dedup: GraphDedupConfig,
622}
623
624impl Default for GraphSection {
625    fn default() -> Self {
626        Self {
627            mode: default_graph_mode(),
628            url: default_graph_url(),
629            namespace: default_graph_namespace(),
630            database: String::new(),
631            username: String::new(),
632            password_file: String::new(),
633            scoring: GraphScoringConfig::default(),
634            provenance: ProvenanceWeights::default(),
635            dedup: GraphDedupConfig::default(),
636        }
637    }
638}
639
640/// Settings for the `recall-echo serve` graph daemon.
641///
642/// Maps to the `[serve]` section of `.recall-echo.toml`. The daemon is started
643/// transparently by graph commands and hooks when `[graph] mode = "embedded"`
644/// (the default); these keys only tune where it listens and how long it lives.
645#[derive(Debug, Clone, Serialize, Deserialize)]
646#[serde(default)]
647pub struct ServeSection {
648    /// Override the unix socket path. Defaults to
649    /// `$XDG_RUNTIME_DIR/recall-echo/<hash of memory dir>.sock`.
650    pub socket_path: Option<String>,
651    /// Seconds of inactivity before the daemon shuts itself down.
652    /// `0` disables idle shutdown. Default `3600`.
653    pub idle_timeout_secs: u64,
654}
655
656impl Default for ServeSection {
657    fn default() -> Self {
658        Self {
659            socket_path: None,
660            idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS,
661        }
662    }
663}
664
665/// Background entity extraction inside the graph daemon (`[extraction]`).
666///
667/// Episodes arrive mechanically on `SessionEnd`; turning them into entities,
668/// relationships and confidence used to require a human to run
669/// `recall-echo graph extract`. The daemon already owns the store and knows
670/// when it is unused, so it does that pass itself once the machine is quiet.
671///
672/// Defaults are on, because the alternative is a knowledge graph that stays
673/// empty for everyone who did not read the docs closely. What it costs is
674/// bounded by the provider: the daemon is started with a minimal environment
675/// that deliberately excludes API keys (see `serve_client`), so an
676/// auto-started daemon can only ever use a CLI provider whose credentials live
677/// in `$HOME` — `claude-code` and friends — which bills nothing beyond a
678/// subscription. An API-key provider reaches the daemon only when a human runs
679/// `recall-echo serve --foreground` with the key exported — an explicit act.
680/// Set `background_enabled = false` to turn the pass off entirely.
681#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
682#[serde(default)]
683pub struct ExtractionSection {
684    /// Run entity extraction in the daemon when the machine is quiet.
685    /// Default `true`.
686    pub background_enabled: bool,
687    /// Seconds without a client request before a background batch may start.
688    /// `0` means "as soon as no connection is open". Default `120`.
689    pub idle_after_secs: u64,
690    /// Archives one batch extracts before going back to waiting. Bounds how
691    /// long a burst of background work lasts and how much it can cost in one
692    /// go; the next batch starts one quiet period later. Default `3`.
693    pub batch_size: usize,
694}
695
696impl Default for ExtractionSection {
697    fn default() -> Self {
698        Self {
699            background_enabled: true,
700            idle_after_secs: DEFAULT_EXTRACTION_IDLE_AFTER_SECS,
701            batch_size: DEFAULT_EXTRACTION_BATCH_SIZE,
702        }
703    }
704}
705
706impl ExtractionSection {
707    /// Quiet period before a batch may start.
708    #[must_use]
709    pub fn idle_after(&self) -> std::time::Duration {
710        std::time::Duration::from_secs(self.idle_after_secs)
711    }
712
713    /// Archives per batch — at least one, whatever the config says, or the
714    /// worker would wake up only to do nothing.
715    #[must_use]
716    pub fn effective_batch_size(&self) -> usize {
717        self.batch_size.max(1)
718    }
719}
720
721/// Capturing sessions from the agent CLIs on this machine (`[capture]`).
722///
723/// Claude Code archives itself through a `SessionEnd` hook. Every other agent
724/// CLI records its sessions to disk and tells nobody, so recall-echo reads them
725/// instead: `recall-echo ingest` on demand, and the graph daemon on its own
726/// once the machine has been quiet.
727///
728/// ```toml
729/// [capture]
730/// enabled = true
731/// sources = ["claude-code", "codex", "grok"]  # default: whatever is installed
732/// settle_secs = 300
733/// ```
734///
735/// Defaults are on and auto-detecting, for the same reason background
736/// extraction is: memory that only fills up for people who read the docs is
737/// memory on the honor system. Set `enabled = false` to import nothing in the
738/// background — `recall-echo ingest` still works, because that one was asked
739/// for.
740#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
741#[serde(default)]
742pub struct CaptureSection {
743    /// Sweep for new transcripts in the daemon. Default `true`.
744    pub enabled: bool,
745    /// Which CLIs to capture. `None` — the default — means every CLI that has
746    /// recorded sessions on this machine.
747    #[serde(skip_serializing_if = "Option::is_none")]
748    pub sources: Option<Vec<crate::transcript::Source>>,
749    /// Seconds a transcript must go untouched before it counts as finished.
750    /// Importing a live session would archive half a conversation and then mark
751    /// it captured for good. Default `300`.
752    pub settle_secs: u64,
753}
754
755impl Default for CaptureSection {
756    fn default() -> Self {
757        Self {
758            enabled: true,
759            sources: None,
760            settle_secs: DEFAULT_CAPTURE_SETTLE_SECS,
761        }
762    }
763}
764
765impl CaptureSection {
766    /// How long a transcript must have been untouched to count as finished.
767    #[must_use]
768    pub fn settle(&self) -> std::time::Duration {
769        std::time::Duration::from_secs(self.settle_secs)
770    }
771}
772
773/// Scoring weights for utility-weighted semantic search.
774///
775/// The final score for a retrieved entity is computed as a linear combination
776/// of three signals:
777///
778/// ```text
779/// score = weight_semantic * similarity
780///       + weight_hotness  * hotness
781///       + weight_utility  * utility_score
782/// ```
783///
784/// Defaults (`0.45 / 0.30 / 0.25`) match the original hard-coded values, so
785/// omitting the `[graph.scoring]` section from `.recall-echo.toml` produces
786/// identical behavior to pre-v3.9.0 recall-echo.
787///
788/// Weights are not constrained to sum to 1.0 — the scoring function does not
789/// normalize. Callers that change these should calibrate against their own
790/// retrieval outcomes; see `utility-feedback-loop-spec.md` in pulse-null.
791///
792/// Graph-expanded candidates score through the same three terms; what differs
793/// is where their `similarity` comes from (a parent's similarity discounted by
794/// the edge's effective confidence, rather than a direct measurement against
795/// the query vector). [`GraphScoringConfig::corroboration_boost`] governs the
796/// one case where the two channels meet.
797#[derive(Debug, Clone, Serialize, Deserialize)]
798#[serde(default)]
799pub struct GraphScoringConfig {
800    /// Weight applied to cosine similarity. Default `0.45`.
801    pub weight_semantic: f64,
802    /// Weight applied to the recency/access hotness signal. Default `0.30`.
803    pub weight_hotness: f64,
804    /// Weight applied to the utility score (outcome-feedback EMA). Default `0.25`.
805    pub weight_utility: f64,
806    /// How much an entity's measured relevance is raised when the graph
807    /// corroborates a semantic hit — i.e. when the same entity is reached both
808    /// by the query vector and over a surviving edge from one of the expanded
809    /// top hits. Default `0.05`.
810    ///
811    /// ```text
812    /// similarity = min(1.0, similarity * (1 + corroboration_boost * effective_confidence))
813    /// ```
814    ///
815    /// Scaled by the edge's effective (decayed) confidence, so a stale edge
816    /// corroborates weakly, and clamped at the similarity ceiling of `1.0`, so
817    /// no amount of corroboration can push an entity past what a perfect
818    /// direct match would score on the same hotness and utility. `0.0`
819    /// disables corroboration entirely.
820    ///
821    /// The default is cut to the *scale* of the similarity distribution it
822    /// perturbs, measured over four LongMemEval stores (196–1804 entities):
823    /// the top-20 similarity band there is only `0.086` wide, so a boost of
824    /// `0.134` would let corroboration promote an entity from the bottom of
825    /// the band to the top, and structure would outrank similarity outright.
826    /// `0.05` moves a corroborated entity about a third of the band — enough
827    /// to break the near-ties that dominate a dense embedding space (the
828    /// rank-1-to-rank-2 gap in those stores is `0.005`–`0.051`), and not
829    /// enough to overturn a decided ordering. Raise it only with retrieval
830    /// numbers in hand: corroboration amplifies whatever the extractor put in
831    /// the graph, including its mistakes.
832    pub corroboration_boost: f64,
833}
834
835impl Default for GraphScoringConfig {
836    fn default() -> Self {
837        Self {
838            weight_semantic: 0.45,
839            weight_hotness: 0.30,
840            weight_utility: 0.25,
841            corroboration_boost: 0.05,
842        }
843    }
844}
845
846/// Similarity bands that decide when entity dedup pays for a model call.
847///
848/// Dedup asks one question — *is this the same thing?* — and that is a
849/// question about meaning, so the bands are cut on raw cosine similarity
850/// between the candidate's abstract and an existing entity's, never on the
851/// retrieval score (which folds in hotness and utility: a popular unrelated
852/// entity would otherwise buy a model call, and every entity gets more
853/// popular as the graph grows).
854///
855/// ```text
856/// similarity >= certain_similarity   → the same entity; resolved locally
857/// review_similarity ..< certain      → ambiguous; one model call decides
858/// similarity <  review_similarity    → new entity; created locally
859/// ```
860///
861/// Defaults (`0.92` / `0.82` / `3`) are cut from the similarity distribution of
862/// a LongMemEval baseline store (192 entities, 150 sampled candidates, 750
863/// neighbour pairs). BGE-Small puts every same-language pair in a narrow high
864/// band — median neighbour 0.75, median *nearest* neighbour 0.81 — so the cuts
865/// sit at its tail, not at intuitive-looking round numbers: 0.92 is the 96th
866/// percentile of pairs, where abstracts are paraphrases of each other, and 0.82
867/// the ~78th, below which pairs are merely same-topic. Candidates averaged 1.1
868/// neighbours above 0.82, so a cap of three bounds the worst case without
869/// binding the normal one.
870#[derive(Debug, Clone, Serialize, Deserialize)]
871#[serde(default)]
872pub struct GraphDedupConfig {
873    /// At or above this cosine similarity the candidate is treated as the same
874    /// entity and resolved without a model call. Default `0.92`.
875    pub certain_similarity: f64,
876    /// Below this cosine similarity the candidate is treated as new and created
877    /// without a model call. Default `0.82`.
878    pub review_similarity: f64,
879    /// How many existing entities, by similarity rank, dedup may fetch and hand
880    /// to the model. Caps prompt size and comparison count so neither can grow
881    /// with the graph. Default `3`.
882    pub max_candidates: usize,
883}
884
885impl Default for GraphDedupConfig {
886    fn default() -> Self {
887        Self {
888            certain_similarity: 0.92,
889            review_similarity: 0.82,
890            max_candidates: 3,
891        }
892    }
893}
894
895impl GraphDedupConfig {
896    /// The band a candidate's nearest neighbour falls in.
897    #[must_use]
898    pub fn band(&self, similarity: f64) -> DedupBand {
899        if similarity >= self.certain_similarity {
900            DedupBand::SameEntity
901        } else if similarity >= self.review_similarity {
902            DedupBand::Ambiguous
903        } else {
904            DedupBand::NewEntity
905        }
906    }
907
908    /// How many candidates to fetch and consider — at least one, whatever the
909    /// config says, or dedup would be blind.
910    #[must_use]
911    pub fn candidate_limit(&self) -> usize {
912        self.max_candidates.max(1)
913    }
914}
915
916/// Which of the three dedup bands a similarity falls in.
917#[derive(Debug, Clone, Copy, PartialEq, Eq)]
918pub enum DedupBand {
919    /// Certainly the same entity — resolve without a model call.
920    SameEntity,
921    /// Genuinely ambiguous — worth a model call.
922    Ambiguous,
923    /// Certainly not the same entity — create without a model call.
924    NewEntity,
925}
926
927fn default_graph_mode() -> String {
928    "embedded".to_string()
929}
930
931fn default_graph_url() -> String {
932    "ws://localhost:8787".to_string()
933}
934
935fn default_graph_namespace() -> String {
936    "nullarc".to_string()
937}
938
939fn default_provider() -> Provider {
940    Provider::Anthropic
941}
942
943// ── Load / Save ──────────────────────────────────────────────────────────
944
945/// Config file path for a given base directory.
946#[must_use]
947pub fn config_path(base: &Path) -> std::path::PathBuf {
948    base.join(CONFIG_FILE)
949}
950
951/// Load config from .recall-echo.toml in the given directory.
952/// Returns defaults if file doesn't exist or is malformed.
953#[must_use]
954pub fn load_from_dir(dir: &Path) -> Config {
955    load(dir)
956}
957
958/// Load config from .recall-echo.toml in the base dir.
959/// Returns defaults if file doesn't exist or is malformed.
960#[must_use]
961pub fn load(base: &Path) -> Config {
962    let path = config_path(base);
963    if !path.exists() {
964        return Config::default();
965    }
966
967    let content = match fs::read_to_string(&path) {
968        Ok(c) => c,
969        Err(_) => return Config::default(),
970    };
971
972    match toml::from_str(&content) {
973        Ok(cfg) => validate(cfg),
974        Err(_) => Config::default(),
975    }
976}
977
978/// Save config to .recall-echo.toml in the base dir.
979pub fn save(base: &Path, config: &Config) -> Result<(), crate::error::RecallError> {
980    let path = config_path(base);
981    let content = toml::to_string_pretty(config)?;
982    fs::write(&path, content)?;
983    Ok(())
984}
985
986/// Returns true if .recall-echo.toml exists in the directory.
987#[must_use]
988pub fn exists(base: &Path) -> bool {
989    config_path(base).exists()
990}
991
992fn validate(mut cfg: Config) -> Config {
993    if !(1..=50).contains(&cfg.ephemeral.max_entries) {
994        cfg.ephemeral.max_entries = DEFAULT_MAX_ENTRIES;
995    }
996    cfg
997}
998
999// ── Config mutation helpers ──────────────────────────────────────────────
1000
1001impl Config {
1002    /// Set a dotted config key (e.g. "llm.provider", "ephemeral.max_entries").
1003    pub fn set_key(&mut self, key: &str, value: &str) -> Result<(), crate::error::RecallError> {
1004        use crate::error::RecallError;
1005        match key {
1006            "llm.provider" | "provider" => {
1007                let provider = Provider::from_str_loose(value)?;
1008                // When switching provider, reset model, api_base and the CLI
1009                // overrides to defaults: all three describe the old vendor.
1010                self.llm.model = String::new();
1011                self.llm.api_base = String::new();
1012                self.llm.cli = CliSection::default();
1013                self.llm.provider = provider;
1014                Ok(())
1015            }
1016            _ if key.starts_with("llm.cli.") => {
1017                self.llm.cli.set_key(&key["llm.cli.".len()..], value)
1018            }
1019            "llm.model" | "model" => {
1020                self.llm.model = value.to_string();
1021                Ok(())
1022            }
1023            "llm.api_base" | "api_base" => {
1024                self.llm.api_base = value.to_string();
1025                Ok(())
1026            }
1027            "ephemeral.max_entries" => {
1028                let n: usize = value
1029                    .parse()
1030                    .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1031                if !(1..=50).contains(&n) {
1032                    return Err(RecallError::Config(
1033                        "max_entries must be between 1 and 50".into(),
1034                    ));
1035                }
1036                self.ephemeral.max_entries = n;
1037                Ok(())
1038            }
1039            "pipeline.docs_dir" => {
1040                let section = self.pipeline.get_or_insert(PipelineSection {
1041                    docs_dir: None,
1042                    auto_sync: None,
1043                });
1044                section.docs_dir = Some(value.to_string());
1045                Ok(())
1046            }
1047            "pipeline.auto_sync" => {
1048                let b: bool = value
1049                    .parse()
1050                    .map_err(|_| RecallError::Config(format!("invalid boolean: {value}")))?;
1051                let section = self.pipeline.get_or_insert(PipelineSection {
1052                    docs_dir: None,
1053                    auto_sync: None,
1054                });
1055                section.auto_sync = Some(b);
1056                Ok(())
1057            }
1058            "serve.idle_timeout_secs" => {
1059                let secs: u64 = value
1060                    .parse()
1061                    .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1062                self.serve.idle_timeout_secs = secs;
1063                Ok(())
1064            }
1065            "serve.socket_path" => {
1066                self.serve.socket_path = if value.trim().is_empty() {
1067                    None
1068                } else {
1069                    Some(value.to_string())
1070                };
1071                Ok(())
1072            }
1073            "extraction.background_enabled" => {
1074                self.extraction.background_enabled = value
1075                    .parse()
1076                    .map_err(|_| RecallError::Config(format!("invalid boolean: {value}")))?;
1077                Ok(())
1078            }
1079            "extraction.idle_after_secs" => {
1080                self.extraction.idle_after_secs = value
1081                    .parse()
1082                    .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1083                Ok(())
1084            }
1085            "extraction.batch_size" => {
1086                let size: usize = value
1087                    .parse()
1088                    .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1089                if size == 0 {
1090                    return Err(RecallError::Config("batch_size must be at least 1".into()));
1091                }
1092                self.extraction.batch_size = size;
1093                Ok(())
1094            }
1095            "capture.enabled" => {
1096                self.capture.enabled = value
1097                    .parse()
1098                    .map_err(|_| RecallError::Config(format!("invalid boolean: {value}")))?;
1099                Ok(())
1100            }
1101            "capture.settle_secs" => {
1102                self.capture.settle_secs = value
1103                    .parse()
1104                    .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1105                Ok(())
1106            }
1107            "capture.sources" => {
1108                self.capture.sources = parse_sources(value)?;
1109                Ok(())
1110            }
1111            "graph.provenance.weight_external" => {
1112                self.graph_section().provenance.weight_external = parse_weight(value)?;
1113                Ok(())
1114            }
1115            "graph.provenance.weight_user" => {
1116                self.graph_section().provenance.weight_user = parse_weight(value)?;
1117                Ok(())
1118            }
1119            "graph.provenance.weight_self" => {
1120                self.graph_section().provenance.weight_self = parse_weight(value)?;
1121                Ok(())
1122            }
1123            "graph.dedup.certain_similarity" => {
1124                self.graph_section().dedup.certain_similarity = parse_similarity(value)?;
1125                Ok(())
1126            }
1127            "graph.dedup.review_similarity" => {
1128                self.graph_section().dedup.review_similarity = parse_similarity(value)?;
1129                Ok(())
1130            }
1131            "graph.dedup.max_candidates" => {
1132                let n: usize = value
1133                    .parse()
1134                    .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1135                if n == 0 {
1136                    return Err(RecallError::Config(
1137                        "max_candidates must be at least 1".into(),
1138                    ));
1139                }
1140                self.graph_section().dedup.max_candidates = n;
1141                Ok(())
1142            }
1143            other => Err(RecallError::Config(format!("unknown config key: {other}"))),
1144        }
1145    }
1146
1147    /// The `[graph]` section, created at its defaults if the config has none.
1148    fn graph_section(&mut self) -> &mut GraphSection {
1149        self.graph.get_or_insert_with(GraphSection::default)
1150    }
1151}
1152
1153/// Parse a comma-separated CLI list. Empty means "auto-detect".
1154fn parse_sources(
1155    value: &str,
1156) -> Result<Option<Vec<crate::transcript::Source>>, crate::error::RecallError> {
1157    let names: Vec<&str> = value
1158        .split(',')
1159        .map(str::trim)
1160        .filter(|name| !name.is_empty())
1161        .collect();
1162    if names.is_empty() {
1163        return Ok(None);
1164    }
1165    let mut sources = Vec::with_capacity(names.len());
1166    for name in names {
1167        let source = crate::transcript::Source::from_str_loose(name)?;
1168        if !sources.contains(&source) {
1169            sources.push(source);
1170        }
1171    }
1172    Ok(Some(sources))
1173}
1174
1175/// Parse an evidence weight: a finite, non-negative number.
1176///
1177/// Zero is allowed — it is how a class is switched off entirely.
1178fn parse_weight(value: &str) -> Result<f64, crate::error::RecallError> {
1179    use crate::error::RecallError;
1180    let weight: f64 = value
1181        .parse()
1182        .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1183    if !weight.is_finite() || weight < 0.0 {
1184        return Err(RecallError::Config(format!(
1185            "evidence weight must be finite and non-negative, got {value}"
1186        )));
1187    }
1188    Ok(weight)
1189}
1190
1191/// Parse a cosine-similarity threshold: a finite number in `0.0..=1.0`.
1192fn parse_similarity(value: &str) -> Result<f64, crate::error::RecallError> {
1193    use crate::error::RecallError;
1194    let similarity: f64 = value
1195        .parse()
1196        .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1197    if !similarity.is_finite() || !(0.0..=1.0).contains(&similarity) {
1198        return Err(RecallError::Config(format!(
1199            "similarity threshold must be between 0.0 and 1.0, got {value}"
1200        )));
1201    }
1202    Ok(similarity)
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207    use super::*;
1208
1209    #[test]
1210    fn default_config() {
1211        let cfg = Config::default();
1212        assert_eq!(cfg.ephemeral.max_entries, 5);
1213        assert_eq!(cfg.llm.provider, Provider::Anthropic);
1214        assert!(cfg.llm.model.is_empty());
1215    }
1216
1217    #[test]
1218    fn parse_ephemeral_only() {
1219        let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 10\n").unwrap();
1220        assert_eq!(cfg.ephemeral.max_entries, 10);
1221        assert_eq!(cfg.llm.provider, Provider::Anthropic);
1222    }
1223
1224    #[test]
1225    fn graph_mode_defaults_to_embedded() {
1226        let cfg: Config = toml::from_str("[graph]\n").unwrap();
1227        assert_eq!(cfg.graph.unwrap().mode, "embedded");
1228    }
1229
1230    #[test]
1231    fn graph_mode_parses_server() {
1232        let cfg: Config =
1233            toml::from_str("[graph]\nmode = \"server\"\nurl = \"ws://db.local:8787\"\n").unwrap();
1234        let g = cfg.graph.unwrap();
1235        assert_eq!(g.mode, "server");
1236        assert_eq!(g.url, "ws://db.local:8787");
1237    }
1238
1239    #[test]
1240    fn serve_defaults_when_section_absent() {
1241        let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 3\n").unwrap();
1242        assert_eq!(cfg.serve.idle_timeout_secs, DEFAULT_IDLE_TIMEOUT_SECS);
1243        assert!(cfg.serve.socket_path.is_none());
1244    }
1245
1246    #[test]
1247    fn serve_section_parses_overrides() {
1248        let cfg: Config = toml::from_str(
1249            "[serve]\nsocket_path = \"/run/re/graph.sock\"\nidle_timeout_secs = 60\n",
1250        )
1251        .unwrap();
1252        assert_eq!(cfg.serve.idle_timeout_secs, 60);
1253        assert_eq!(cfg.serve.socket_path.as_deref(), Some("/run/re/graph.sock"));
1254    }
1255
1256    #[test]
1257    fn set_key_serve_idle_timeout() {
1258        let mut cfg = Config::default();
1259        cfg.set_key("serve.idle_timeout_secs", "120").unwrap();
1260        assert_eq!(cfg.serve.idle_timeout_secs, 120);
1261        assert!(cfg.set_key("serve.idle_timeout_secs", "soon").is_err());
1262    }
1263
1264    #[test]
1265    fn parse_llm_section() {
1266        let cfg: Config = toml::from_str(
1267            "[llm]\nprovider = \"openai\"\nmodel = \"llama3.1\"\napi_base = \"http://myhost:11434/v1\"\n",
1268        )
1269        .unwrap();
1270        assert_eq!(cfg.llm.provider, Provider::Openai);
1271        assert_eq!(cfg.llm.model, "llama3.1");
1272        assert_eq!(cfg.llm.api_base, "http://myhost:11434/v1");
1273    }
1274
1275    #[test]
1276    fn parse_claude_code_provider() {
1277        let cfg: Config = toml::from_str("[llm]\nprovider = \"claude-code\"\n").unwrap();
1278        assert_eq!(cfg.llm.provider, Provider::ClaudeCode);
1279    }
1280
1281    #[test]
1282    fn resolved_defaults() {
1283        let llm = LlmSection::default();
1284        assert_eq!(llm.resolved_model(), "claude-haiku-4-5-20251001");
1285        assert_eq!(
1286            llm.resolved_api_base(),
1287            "https://api.anthropic.com/v1/messages"
1288        );
1289    }
1290
1291    #[test]
1292    fn resolved_custom_overrides_default() {
1293        let llm = LlmSection {
1294            provider: Provider::Openai,
1295            model: "mistral-7b".into(),
1296            ..LlmSection::default()
1297        };
1298        assert_eq!(llm.resolved_model(), "mistral-7b");
1299        assert_eq!(llm.resolved_api_base(), "http://localhost:11434/v1");
1300    }
1301
1302    #[test]
1303    fn round_trip_toml() {
1304        let cfg = Config {
1305            ephemeral: EphemeralConfig { max_entries: 3 },
1306            llm: LlmSection {
1307                provider: Provider::Openai,
1308                model: "llama3.2".into(),
1309                api_base: "http://localhost:11434/v1".into(),
1310                ..LlmSection::default()
1311            },
1312            ..Config::default()
1313        };
1314        let s = toml::to_string_pretty(&cfg).unwrap();
1315        let parsed: Config = toml::from_str(&s).unwrap();
1316        assert_eq!(parsed.ephemeral.max_entries, 3);
1317        assert_eq!(parsed.llm.provider, Provider::Openai);
1318        assert_eq!(parsed.llm.model, "llama3.2");
1319    }
1320
1321    #[test]
1322    fn set_key_provider() {
1323        let mut cfg = Config::default();
1324        cfg.set_key("llm.provider", "ollama").unwrap();
1325        assert_eq!(cfg.llm.provider, Provider::Openai);
1326        assert!(cfg.llm.model.is_empty());
1327    }
1328
1329    #[test]
1330    fn set_key_model() {
1331        let mut cfg = Config::default();
1332        cfg.set_key("llm.model", "claude-sonnet-4-6").unwrap();
1333        assert_eq!(cfg.llm.model, "claude-sonnet-4-6");
1334    }
1335
1336    #[test]
1337    fn set_key_unknown_fails() {
1338        let mut cfg = Config::default();
1339        assert!(cfg.set_key("nonexistent.key", "value").is_err());
1340    }
1341
1342    #[test]
1343    fn set_key_cli_overrides() {
1344        let mut cfg = Config::default();
1345        cfg.set_key("llm.provider", "gemini").unwrap();
1346        cfg.set_key("llm.cli.command", "/opt/bin/gemini").unwrap();
1347        cfg.set_key("llm.cli.result_json_path", "response, result")
1348            .unwrap();
1349        cfg.set_key("llm.cli.extra_args", "--yolo --quiet").unwrap();
1350        cfg.set_key("llm.cli.prompt_delivery", "stdin").unwrap();
1351        cfg.set_key("llm.cli.timeout_secs", "45").unwrap();
1352
1353        let cli = &cfg.llm.cli;
1354        assert_eq!(cli.command.as_deref(), Some("/opt/bin/gemini"));
1355        assert_eq!(
1356            cli.result_json_path.as_ref().unwrap().paths(),
1357            ["response", "result"]
1358        );
1359        assert_eq!(
1360            cli.extra_args.as_deref(),
1361            Some(["--yolo".to_string(), "--quiet".to_string()].as_slice())
1362        );
1363        assert_eq!(cli.prompt_delivery, Some(PromptDelivery::Stdin));
1364        assert_eq!(cli.timeout_secs, Some(45));
1365
1366        assert!(cfg.set_key("llm.cli.nonexistent", "x").is_err());
1367        assert!(cfg.set_key("llm.cli.timeout_secs", "soon").is_err());
1368        assert!(cfg.set_key("llm.cli.preset", "nonesuch").is_err());
1369    }
1370
1371    /// The overrides describe one vendor's binary; carrying them to the next
1372    /// provider would spawn the wrong tool with the right flags.
1373    #[test]
1374    fn switching_provider_clears_the_cli_overrides() {
1375        let mut cfg = Config::default();
1376        cfg.set_key("llm.provider", "gemini").unwrap();
1377        cfg.set_key("llm.cli.command", "/opt/bin/gemini").unwrap();
1378        cfg.set_key("llm.provider", "grok").unwrap();
1379
1380        assert_eq!(cfg.llm.provider, Provider::Grok);
1381        assert!(cfg.llm.cli.is_empty());
1382    }
1383
1384    #[test]
1385    fn cli_section_parses_from_toml() {
1386        let cfg: Config = toml::from_str(
1387            "[llm]\nprovider = \"cli\"\n\n[llm.cli]\ncommand = \"mycli\"\n\
1388             prompt_delivery = \"flag\"\nprompt_flag = \"--ask\"\n\
1389             result_json_path = [\"data.text\", \"text\"]\nargs = [\"chat\"]\n",
1390        )
1391        .expect("parse [llm.cli]");
1392        let cli = cfg.llm.cli;
1393        assert_eq!(cfg.llm.provider, Provider::Cli);
1394        assert_eq!(cli.command.as_deref(), Some("mycli"));
1395        assert_eq!(cli.prompt_delivery, Some(PromptDelivery::Flag));
1396        assert_eq!(cli.prompt_flag.as_deref(), Some("--ask"));
1397        assert_eq!(
1398            cli.result_json_path.as_ref().unwrap().paths(),
1399            ["data.text", "text"]
1400        );
1401        assert_eq!(cli.args.as_deref(), Some(["chat".to_string()].as_slice()));
1402    }
1403
1404    #[test]
1405    fn set_key_output_mode_and_ndjson_match() {
1406        let mut cfg = Config::default();
1407        cfg.set_key("llm.provider", "cli").unwrap();
1408        cfg.set_key("llm.cli.output_mode", "ndjson").unwrap();
1409        cfg.set_key(
1410            "llm.cli.ndjson_match",
1411            "type=item.completed, item.type=agent_message",
1412        )
1413        .unwrap();
1414
1415        assert_eq!(cfg.llm.cli.output_mode, Some(OutputMode::Ndjson));
1416        assert_eq!(
1417            cfg.llm.cli.ndjson_match.as_ref().unwrap().predicates(),
1418            [("type", "item.completed"), ("item.type", "agent_message")]
1419        );
1420        assert!(cfg.set_key("llm.cli.output_mode", "yaml").is_err());
1421    }
1422
1423    #[test]
1424    fn output_mode_accepts_the_obvious_spellings() {
1425        assert_eq!(
1426            OutputMode::from_str_loose("jsonl").unwrap(),
1427            OutputMode::Ndjson
1428        );
1429        assert_eq!(
1430            OutputMode::from_str_loose("json").unwrap(),
1431            OutputMode::SingleJson
1432        );
1433        assert_eq!(OutputMode::from_str_loose("TEXT").unwrap(), OutputMode::Raw);
1434    }
1435
1436    /// A predicate without a value would match every line; dropping it is
1437    /// safer than treating it as a wildcard nobody asked for.
1438    #[test]
1439    fn line_matchers_drop_entries_without_a_value() {
1440        let matchers = LineMatchers::parse("type=item.completed, garbage, ");
1441        assert_eq!(matchers.predicates(), [("type", "item.completed")]);
1442    }
1443
1444    #[test]
1445    fn result_json_path_accepts_a_bare_string() {
1446        let cli: CliSection = toml::from_str("result_json_path = \"result\"\n").expect("parse");
1447        assert_eq!(cli.result_json_path.unwrap().paths(), ["result"]);
1448    }
1449
1450    #[test]
1451    fn an_empty_result_json_path_means_raw_stdout() {
1452        let cli: CliSection = toml::from_str("result_json_path = \"\"\n").expect("parse");
1453        assert!(cli.result_json_path.unwrap().is_empty());
1454    }
1455
1456    /// Configs written before `[llm.cli]` existed must keep loading, and keep
1457    /// saving without gaining a section their owner never asked for.
1458    #[test]
1459    fn a_config_without_a_cli_section_round_trips_unchanged() {
1460        let tmp = tempfile::tempdir().unwrap();
1461        let mut cfg = Config::default();
1462        cfg.set_key("llm.provider", "claude-code").unwrap();
1463        save(tmp.path(), &cfg).unwrap();
1464
1465        let rendered = fs::read_to_string(config_path(tmp.path())).unwrap();
1466        assert!(!rendered.contains("[llm.cli]"), "{rendered}");
1467        assert_eq!(load(tmp.path()).llm.provider, Provider::ClaudeCode);
1468    }
1469
1470    #[test]
1471    fn cli_overrides_survive_a_save_and_load() {
1472        let tmp = tempfile::tempdir().unwrap();
1473        let mut cfg = Config::default();
1474        cfg.set_key("llm.provider", "cli").unwrap();
1475        cfg.set_key("llm.cli.command", "mycli").unwrap();
1476        cfg.set_key("llm.cli.result_json_path", "data.text")
1477            .unwrap();
1478        save(tmp.path(), &cfg).unwrap();
1479
1480        let loaded = load(tmp.path());
1481        assert_eq!(loaded.llm.provider, Provider::Cli);
1482        assert_eq!(loaded.llm.cli.command.as_deref(), Some("mycli"));
1483        assert_eq!(
1484            loaded.llm.cli.result_json_path.unwrap().paths(),
1485            ["data.text"]
1486        );
1487    }
1488
1489    #[test]
1490    fn cli_providers_are_distinguished_from_http_ones() {
1491        assert!(Provider::ClaudeCode.is_cli());
1492        assert!(Provider::Gemini.is_cli());
1493        assert!(Provider::Grok.is_cli());
1494        assert!(Provider::Codex.is_cli());
1495        assert!(Provider::Cli.is_cli());
1496        assert!(!Provider::Anthropic.is_cli());
1497        assert!(!Provider::Openai.is_cli());
1498    }
1499
1500    #[test]
1501    fn provider_from_str_loose_accepts_the_cli_vendors() {
1502        assert_eq!(
1503            Provider::from_str_loose("gemini").unwrap(),
1504            Provider::Gemini
1505        );
1506        assert_eq!(
1507            Provider::from_str_loose("gemini-cli").unwrap(),
1508            Provider::Gemini
1509        );
1510        assert_eq!(Provider::from_str_loose("Grok").unwrap(), Provider::Grok);
1511        assert_eq!(Provider::from_str_loose("xai").unwrap(), Provider::Grok);
1512        assert_eq!(Provider::from_str_loose("cli").unwrap(), Provider::Cli);
1513        assert_eq!(Provider::from_str_loose("custom").unwrap(), Provider::Cli);
1514    }
1515
1516    #[test]
1517    fn codex_resolves_to_its_own_preset() {
1518        assert_eq!(Provider::from_str_loose("codex").unwrap(), Provider::Codex);
1519        assert_eq!(
1520            Provider::Codex.default_cli_preset(),
1521            Some(CliPreset::Codex),
1522            "codex must not fall through to the custom preset"
1523        );
1524    }
1525
1526    /// A vendor with no preset at all gets the generic mechanism, not a dead
1527    /// end — the error names it.
1528    #[test]
1529    fn an_unknown_vendor_is_pointed_at_the_cli_provider() {
1530        let err = Provider::from_str_loose("some-new-agent").expect_err("no such preset");
1531        assert!(err.to_string().contains("[llm.cli]"), "{err}");
1532    }
1533
1534    #[test]
1535    fn provider_display_round_trips_through_from_str_loose() {
1536        for provider in [
1537            Provider::Anthropic,
1538            Provider::Openai,
1539            Provider::ClaudeCode,
1540            Provider::Gemini,
1541            Provider::Grok,
1542            Provider::Codex,
1543            Provider::Cli,
1544        ] {
1545            let rendered = provider.to_string();
1546            assert_eq!(
1547                Provider::from_str_loose(&rendered).unwrap(),
1548                provider,
1549                "{rendered}"
1550            );
1551        }
1552    }
1553
1554    #[test]
1555    fn provider_from_str_loose() {
1556        assert_eq!(
1557            Provider::from_str_loose("ollama").unwrap(),
1558            Provider::Openai
1559        );
1560        assert_eq!(
1561            Provider::from_str_loose("claude").unwrap(),
1562            Provider::Anthropic
1563        );
1564        assert_eq!(
1565            Provider::from_str_loose("claude-code").unwrap(),
1566            Provider::ClaudeCode
1567        );
1568        assert!(Provider::from_str_loose("unknown").is_err());
1569    }
1570
1571    #[test]
1572    fn save_and_load() {
1573        let tmp = tempfile::tempdir().unwrap();
1574        let cfg = Config {
1575            ephemeral: EphemeralConfig { max_entries: 7 },
1576            llm: LlmSection {
1577                provider: Provider::ClaudeCode,
1578                ..LlmSection::default()
1579            },
1580            ..Config::default()
1581        };
1582        save(tmp.path(), &cfg).unwrap();
1583        let loaded = load(tmp.path());
1584        assert_eq!(loaded.ephemeral.max_entries, 7);
1585        assert_eq!(loaded.llm.provider, Provider::ClaudeCode);
1586    }
1587
1588    #[test]
1589    fn load_nonexistent_file() {
1590        let tmp = tempfile::tempdir().unwrap();
1591        let cfg = load(tmp.path());
1592        assert_eq!(cfg.ephemeral.max_entries, 5);
1593    }
1594
1595    #[test]
1596    fn validate_out_of_range() {
1597        let cfg = validate(Config {
1598            ephemeral: EphemeralConfig { max_entries: 100 },
1599            ..Config::default()
1600        });
1601        assert_eq!(cfg.ephemeral.max_entries, 5);
1602    }
1603
1604    #[test]
1605    fn capture_defaults_are_on_and_auto_detecting() {
1606        let capture = CaptureSection::default();
1607        assert!(capture.enabled);
1608        assert!(capture.sources.is_none());
1609        assert_eq!(capture.settle(), std::time::Duration::from_secs(300));
1610    }
1611
1612    /// A config written before `[capture]` existed must keep loading, with the
1613    /// defaults it would have had.
1614    #[test]
1615    fn a_config_without_a_capture_section_still_loads() {
1616        let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 3\n").unwrap();
1617        assert!(cfg.capture.enabled);
1618        assert!(cfg.capture.sources.is_none());
1619    }
1620
1621    #[test]
1622    fn capture_section_parses_an_explicit_source_list() {
1623        let cfg: Config = toml::from_str(
1624            "[capture]\nenabled = false\nsources = [\"codex\", \"grok\"]\nsettle_secs = 60\n",
1625        )
1626        .expect("parse [capture]");
1627        assert!(!cfg.capture.enabled);
1628        assert_eq!(cfg.capture.settle_secs, 60);
1629        assert_eq!(
1630            cfg.capture.sources.as_deref(),
1631            Some(
1632                [
1633                    crate::transcript::Source::Codex,
1634                    crate::transcript::Source::Grok
1635                ]
1636                .as_slice()
1637            )
1638        );
1639    }
1640
1641    #[test]
1642    fn set_key_capture_values() {
1643        let mut cfg = Config::default();
1644        cfg.set_key("capture.enabled", "false").unwrap();
1645        cfg.set_key("capture.settle_secs", "30").unwrap();
1646        cfg.set_key("capture.sources", "codex, claude").unwrap();
1647
1648        assert!(!cfg.capture.enabled);
1649        assert_eq!(cfg.capture.settle_secs, 30);
1650        assert_eq!(
1651            cfg.capture.sources.as_deref(),
1652            Some(
1653                [
1654                    crate::transcript::Source::Codex,
1655                    crate::transcript::Source::ClaudeCode
1656                ]
1657                .as_slice()
1658            )
1659        );
1660
1661        // An empty list means "back to auto-detect", not "capture nothing".
1662        cfg.set_key("capture.sources", "").unwrap();
1663        assert!(cfg.capture.sources.is_none());
1664        assert!(cfg.set_key("capture.sources", "cursor").is_err());
1665        assert!(cfg.set_key("capture.enabled", "maybe").is_err());
1666    }
1667
1668    #[test]
1669    fn graph_scoring_defaults_match_legacy_hardcodes() {
1670        let scoring = GraphScoringConfig::default();
1671        assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
1672        assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
1673        assert!((scoring.weight_utility - 0.25).abs() < f64::EPSILON);
1674        assert!((scoring.corroboration_boost - 0.05).abs() < f64::EPSILON);
1675    }
1676
1677    #[test]
1678    fn graph_scoring_partial_toml_fills_defaults() {
1679        let scoring: GraphScoringConfig =
1680            toml::from_str("weight_utility = 0.5\n").expect("parse partial scoring");
1681        assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
1682        assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
1683        assert!((scoring.weight_utility - 0.5).abs() < f64::EPSILON);
1684        assert!((scoring.corroboration_boost - 0.05).abs() < f64::EPSILON);
1685    }
1686
1687    #[test]
1688    fn graph_scoring_corroboration_boost_is_configurable() {
1689        let scoring: GraphScoringConfig =
1690            toml::from_str("corroboration_boost = 0.0\n").expect("parse corroboration boost");
1691        assert!(scoring.corroboration_boost.abs() < f64::EPSILON);
1692        assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
1693    }
1694
1695    #[test]
1696    fn graph_scoring_empty_section_yields_defaults() {
1697        let section: GraphSection = toml::from_str("").expect("parse empty graph section");
1698        let defaults = GraphScoringConfig::default();
1699        assert!((section.scoring.weight_semantic - defaults.weight_semantic).abs() < f64::EPSILON);
1700        assert!((section.scoring.weight_hotness - defaults.weight_hotness).abs() < f64::EPSILON);
1701        assert!((section.scoring.weight_utility - defaults.weight_utility).abs() < f64::EPSILON);
1702    }
1703
1704    #[test]
1705    fn graph_provenance_defaults_when_section_absent() {
1706        let section: GraphSection = toml::from_str("mode = \"embedded\"\n").expect("parse section");
1707        let defaults = ProvenanceWeights::default();
1708        assert_eq!(section.provenance, defaults);
1709        assert!((defaults.weight_external - 1.0).abs() < f64::EPSILON);
1710        assert!((defaults.weight_user - 0.8).abs() < f64::EPSILON);
1711        assert!((defaults.weight_self - 0.05).abs() < f64::EPSILON);
1712    }
1713
1714    #[test]
1715    fn graph_provenance_partial_toml_fills_defaults() {
1716        let cfg: Config =
1717            toml::from_str("[graph]\n\n[graph.provenance]\nweight_self = 0.5\n").expect("parse");
1718        let provenance = cfg.graph.expect("graph section present").provenance;
1719        assert!((provenance.weight_self - 0.5).abs() < f64::EPSILON);
1720        assert!((provenance.weight_external - 1.0).abs() < f64::EPSILON);
1721        assert!((provenance.weight_user - 0.8).abs() < f64::EPSILON);
1722    }
1723
1724    #[test]
1725    fn set_key_provenance_weights() {
1726        let mut cfg = Config::default();
1727        cfg.set_key("graph.provenance.weight_self", "0.2").unwrap();
1728        cfg.set_key("graph.provenance.weight_user", "0").unwrap();
1729        cfg.set_key("graph.provenance.weight_external", "1.5")
1730            .unwrap();
1731
1732        let provenance = cfg
1733            .graph
1734            .as_ref()
1735            .expect("graph section created")
1736            .provenance;
1737        assert!((provenance.weight_self - 0.2).abs() < f64::EPSILON);
1738        assert!(provenance.weight_user.abs() < f64::EPSILON);
1739        assert!((provenance.weight_external - 1.5).abs() < f64::EPSILON);
1740
1741        assert!(cfg.set_key("graph.provenance.weight_self", "-1").is_err());
1742        assert!(cfg.set_key("graph.provenance.weight_self", "lots").is_err());
1743    }
1744
1745    #[test]
1746    fn provenance_weights_round_trip_through_toml() {
1747        let mut cfg = Config::default();
1748        cfg.set_key("graph.provenance.weight_self", "0.05").unwrap();
1749        let rendered = toml::to_string_pretty(&cfg).expect("render");
1750        let parsed: Config = toml::from_str(&rendered).expect("reparse");
1751        assert_eq!(
1752            parsed.graph.expect("graph section survives").provenance,
1753            ProvenanceWeights::default()
1754        );
1755    }
1756
1757    #[test]
1758    fn dedup_defaults_leave_a_gap_between_the_bands() {
1759        let dedup = GraphDedupConfig::default();
1760        assert!((dedup.certain_similarity - 0.92).abs() < f64::EPSILON);
1761        assert!((dedup.review_similarity - 0.82).abs() < f64::EPSILON);
1762        assert_eq!(dedup.max_candidates, 3);
1763        assert!(dedup.review_similarity < dedup.certain_similarity);
1764    }
1765
1766    #[test]
1767    fn dedup_bands_are_cut_at_the_thresholds() {
1768        let dedup = GraphDedupConfig::default();
1769        assert_eq!(dedup.band(0.99), DedupBand::SameEntity);
1770        assert_eq!(dedup.band(0.92), DedupBand::SameEntity);
1771        assert_eq!(dedup.band(0.9), DedupBand::Ambiguous);
1772        assert_eq!(dedup.band(0.82), DedupBand::Ambiguous);
1773        assert_eq!(dedup.band(0.8), DedupBand::NewEntity);
1774        assert_eq!(dedup.band(0.0), DedupBand::NewEntity);
1775    }
1776
1777    /// A store configured to fetch nothing would resolve every candidate as
1778    /// new; the floor of one keeps dedup able to see.
1779    #[test]
1780    fn dedup_candidate_limit_never_falls_below_one() {
1781        let dedup = GraphDedupConfig {
1782            max_candidates: 0,
1783            ..GraphDedupConfig::default()
1784        };
1785        assert_eq!(dedup.candidate_limit(), 1);
1786    }
1787
1788    #[test]
1789    fn dedup_partial_toml_fills_defaults() {
1790        let cfg: Config = toml::from_str("[graph]\n\n[graph.dedup]\ncertain_similarity = 0.95\n")
1791            .expect("parse dedup section");
1792        let dedup = cfg.graph.expect("graph section present").dedup;
1793        assert!((dedup.certain_similarity - 0.95).abs() < f64::EPSILON);
1794        assert!((dedup.review_similarity - 0.82).abs() < f64::EPSILON);
1795        assert_eq!(dedup.max_candidates, 3);
1796    }
1797
1798    #[test]
1799    fn set_key_dedup_thresholds() {
1800        let mut cfg = Config::default();
1801        cfg.set_key("graph.dedup.certain_similarity", "0.9")
1802            .unwrap();
1803        cfg.set_key("graph.dedup.review_similarity", "0.6").unwrap();
1804        cfg.set_key("graph.dedup.max_candidates", "5").unwrap();
1805
1806        let dedup = &cfg.graph.as_ref().expect("graph section created").dedup;
1807        assert!((dedup.certain_similarity - 0.9).abs() < f64::EPSILON);
1808        assert!((dedup.review_similarity - 0.6).abs() < f64::EPSILON);
1809        assert_eq!(dedup.max_candidates, 5);
1810
1811        assert!(cfg
1812            .set_key("graph.dedup.certain_similarity", "1.5")
1813            .is_err());
1814        assert!(cfg
1815            .set_key("graph.dedup.review_similarity", "-0.1")
1816            .is_err());
1817        assert!(cfg.set_key("graph.dedup.max_candidates", "0").is_err());
1818    }
1819
1820    #[test]
1821    fn graph_scoring_nested_under_graph() {
1822        let cfg: Config = toml::from_str(
1823            "[graph]\nmode = \"embedded\"\n\n[graph.scoring]\nweight_utility = 0.5\n",
1824        )
1825        .expect("parse nested scoring");
1826        let scoring = cfg.graph.expect("graph section present").scoring;
1827        assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
1828        assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
1829        assert!((scoring.weight_utility - 0.5).abs() < f64::EPSILON);
1830    }
1831}