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    /// Where the CLI reports prompt tokens. Empty means it reports none, and
431    /// the token bill for its calls is estimated instead of measured.
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    pub usage_input_path: Option<JsonPaths>,
434    /// Where the CLI reports completion tokens.
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub usage_output_path: Option<JsonPaths>,
437    /// Arguments appended after the generated flags.
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub extra_args: Option<Vec<String>>,
440    /// Per-call wall-clock limit. `0` waits forever.
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    pub timeout_secs: Option<u64>,
443}
444
445impl CliSection {
446    /// True when nothing is overridden — the section is then left out of a
447    /// saved config entirely.
448    #[must_use]
449    pub fn is_empty(&self) -> bool {
450        *self == Self::default()
451    }
452
453    /// Set one `llm.cli.*` key, given the part after `llm.cli.`.
454    pub fn set_key(&mut self, key: &str, value: &str) -> Result<(), crate::error::RecallError> {
455        use crate::error::RecallError;
456        match key {
457            "preset" => self.preset = Some(CliPreset::from_str_loose(value)?),
458            "command" => self.command = Some(value.to_string()),
459            "args" => self.args = Some(split_args(value)),
460            "prompt_delivery" => {
461                self.prompt_delivery = Some(PromptDelivery::from_str_loose(value)?)
462            }
463            "prompt_flag" => self.prompt_flag = Some(value.to_string()),
464            "model_flag" => self.model_flag = Some(value.to_string()),
465            "output_format_flag" => self.output_format_flag = Some(value.to_string()),
466            "output_format_value" => self.output_format_value = Some(value.to_string()),
467            "output_mode" => self.output_mode = Some(OutputMode::from_str_loose(value)?),
468            "ndjson_match" => self.ndjson_match = Some(LineMatchers::parse(value)),
469            "system_prompt_flag" => self.system_prompt_flag = Some(value.to_string()),
470            "result_json_path" => self.result_json_path = Some(JsonPaths::parse(value)),
471            "usage_input_path" => self.usage_input_path = Some(JsonPaths::parse(value)),
472            "usage_output_path" => self.usage_output_path = Some(JsonPaths::parse(value)),
473            "extra_args" => self.extra_args = Some(split_args(value)),
474            "timeout_secs" => {
475                self.timeout_secs = Some(
476                    value
477                        .parse()
478                        .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?,
479                );
480            }
481            other => {
482                return Err(RecallError::Config(format!(
483                    "unknown config key: llm.cli.{other}"
484                )))
485            }
486        }
487        Ok(())
488    }
489}
490
491/// Split a whitespace-separated argument list from `config set`.
492fn split_args(value: &str) -> Vec<String> {
493    value.split_whitespace().map(str::to_string).collect()
494}
495
496// ── Config structs ───────────────────────────────────────────────────────
497
498#[derive(Debug, Default, Serialize, Deserialize)]
499pub struct Config {
500    #[serde(default)]
501    pub ephemeral: EphemeralConfig,
502    #[serde(default)]
503    pub llm: LlmSection,
504    #[serde(default)]
505    pub pipeline: Option<PipelineSection>,
506    #[serde(default)]
507    pub graph: Option<GraphSection>,
508    #[serde(default)]
509    pub serve: ServeSection,
510    #[serde(default)]
511    pub extraction: ExtractionSection,
512    #[serde(default)]
513    pub capture: CaptureSection,
514}
515
516#[derive(Debug, Serialize, Deserialize)]
517pub struct EphemeralConfig {
518    #[serde(default = "default_max_entries")]
519    pub max_entries: usize,
520}
521
522impl Default for EphemeralConfig {
523    fn default() -> Self {
524        Self {
525            max_entries: DEFAULT_MAX_ENTRIES,
526        }
527    }
528}
529
530fn default_max_entries() -> usize {
531    DEFAULT_MAX_ENTRIES
532}
533
534#[derive(Debug, Serialize, Deserialize)]
535pub struct LlmSection {
536    #[serde(default = "default_provider")]
537    pub provider: Provider,
538    #[serde(default)]
539    pub model: String,
540    #[serde(default)]
541    pub api_base: String,
542    /// Overrides for the spawned agent CLI. Serialized only when non-empty, so
543    /// a config that never touches it stays byte-identical.
544    #[serde(default, skip_serializing_if = "CliSection::is_empty")]
545    pub cli: CliSection,
546}
547
548impl Default for LlmSection {
549    fn default() -> Self {
550        Self {
551            provider: Provider::Anthropic,
552            model: String::new(),
553            api_base: String::new(),
554            cli: CliSection::default(),
555        }
556    }
557}
558
559impl LlmSection {
560    /// Resolved model — uses configured value or provider default.
561    #[must_use]
562    pub fn resolved_model(&self) -> &str {
563        if self.model.is_empty() {
564            self.provider.default_model()
565        } else {
566            &self.model
567        }
568    }
569
570    /// Resolved API base — uses configured value or provider default.
571    #[must_use]
572    pub fn resolved_api_base(&self) -> &str {
573        if self.api_base.is_empty() {
574            self.provider.default_api_base()
575        } else {
576            &self.api_base
577        }
578    }
579}
580
581#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct PipelineSection {
583    /// Directory containing pipeline documents (LEARNING.md, THOUGHTS.md, etc.)
584    #[serde(default)]
585    pub docs_dir: Option<String>,
586    /// Auto-sync pipeline on archive (default: false)
587    #[serde(default)]
588    pub auto_sync: Option<bool>,
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize)]
592pub struct GraphSection {
593    /// Connection mode: "embedded" or "server"
594    #[serde(default = "default_graph_mode")]
595    pub mode: String,
596    /// SurrealDB server URL (server mode only)
597    #[serde(default = "default_graph_url")]
598    pub url: String,
599    /// SurrealDB namespace
600    #[serde(default = "default_graph_namespace")]
601    pub namespace: String,
602    /// SurrealDB database name (typically the entity name)
603    #[serde(default)]
604    pub database: String,
605    /// SurrealDB username (typically the entity name)
606    #[serde(default)]
607    pub username: String,
608    /// Path to file containing the database password
609    #[serde(default)]
610    pub password_file: String,
611    /// Scoring weights for utility-weighted semantic search.
612    ///
613    /// Maps to the `[graph.scoring]` section of `.recall-echo.toml`. When
614    /// absent, defaults preserve the original hard-coded weights
615    /// (0.45 / 0.30 / 0.25). See `GraphScoringConfig` for details.
616    #[serde(default)]
617    pub scoring: GraphScoringConfig,
618    /// Evidence weights per provenance class.
619    ///
620    /// Maps to the `[graph.provenance]` section of `.recall-echo.toml`. When
621    /// absent, defaults are 1.0 external / 0.8 user / 0.05 self. See
622    /// [`ProvenanceWeights`] for details.
623    #[serde(default)]
624    pub provenance: ProvenanceWeights,
625    /// Similarity bands that decide when entity dedup pays for a model call.
626    ///
627    /// Maps to the `[graph.dedup]` section of `.recall-echo.toml`. See
628    /// [`GraphDedupConfig`] for the bands and their defaults.
629    #[serde(default)]
630    pub dedup: GraphDedupConfig,
631}
632
633impl Default for GraphSection {
634    fn default() -> Self {
635        Self {
636            mode: default_graph_mode(),
637            url: default_graph_url(),
638            namespace: default_graph_namespace(),
639            database: String::new(),
640            username: String::new(),
641            password_file: String::new(),
642            scoring: GraphScoringConfig::default(),
643            provenance: ProvenanceWeights::default(),
644            dedup: GraphDedupConfig::default(),
645        }
646    }
647}
648
649/// Settings for the `recall-echo serve` graph daemon.
650///
651/// Maps to the `[serve]` section of `.recall-echo.toml`. The daemon is started
652/// transparently by graph commands and hooks when `[graph] mode = "embedded"`
653/// (the default); these keys only tune where it listens and how long it lives.
654#[derive(Debug, Clone, Serialize, Deserialize)]
655#[serde(default)]
656pub struct ServeSection {
657    /// Override the unix socket path. Defaults to
658    /// `$XDG_RUNTIME_DIR/recall-echo/<hash of memory dir>.sock`.
659    pub socket_path: Option<String>,
660    /// Seconds of inactivity before the daemon shuts itself down.
661    /// `0` disables idle shutdown. Default `3600`.
662    pub idle_timeout_secs: u64,
663}
664
665impl Default for ServeSection {
666    fn default() -> Self {
667        Self {
668            socket_path: None,
669            idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS,
670        }
671    }
672}
673
674/// Background entity extraction inside the graph daemon (`[extraction]`).
675///
676/// Episodes arrive mechanically on `SessionEnd`; turning them into entities,
677/// relationships and confidence used to require a human to run
678/// `recall-echo graph extract`. The daemon already owns the store and knows
679/// when it is unused, so it does that pass itself once the machine is quiet.
680///
681/// Defaults are on, because the alternative is a knowledge graph that stays
682/// empty for everyone who did not read the docs closely. What it costs is
683/// bounded by the provider: the daemon is started with a minimal environment
684/// that deliberately excludes API keys (see `serve_client`), so an
685/// auto-started daemon can only ever use a CLI provider whose credentials live
686/// in `$HOME` — `claude-code` and friends — which bills nothing beyond a
687/// subscription. An API-key provider reaches the daemon only when a human runs
688/// `recall-echo serve --foreground` with the key exported — an explicit act.
689/// Set `background_enabled = false` to turn the pass off entirely.
690#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
691#[serde(default)]
692pub struct ExtractionSection {
693    /// Run entity extraction in the daemon when the machine is quiet.
694    /// Default `true`.
695    pub background_enabled: bool,
696    /// Seconds without a client request before a background batch may start.
697    /// `0` means "as soon as no connection is open". Default `120`.
698    pub idle_after_secs: u64,
699    /// Archives one batch extracts before going back to waiting. Bounds how
700    /// long a burst of background work lasts and how much it can cost in one
701    /// go; the next batch starts one quiet period later. Default `3`.
702    pub batch_size: usize,
703}
704
705impl Default for ExtractionSection {
706    fn default() -> Self {
707        Self {
708            background_enabled: true,
709            idle_after_secs: DEFAULT_EXTRACTION_IDLE_AFTER_SECS,
710            batch_size: DEFAULT_EXTRACTION_BATCH_SIZE,
711        }
712    }
713}
714
715impl ExtractionSection {
716    /// Quiet period before a batch may start.
717    #[must_use]
718    pub fn idle_after(&self) -> std::time::Duration {
719        std::time::Duration::from_secs(self.idle_after_secs)
720    }
721
722    /// Archives per batch — at least one, whatever the config says, or the
723    /// worker would wake up only to do nothing.
724    #[must_use]
725    pub fn effective_batch_size(&self) -> usize {
726        self.batch_size.max(1)
727    }
728}
729
730/// Capturing sessions from the agent CLIs on this machine (`[capture]`).
731///
732/// Claude Code archives itself through a `SessionEnd` hook. Every other agent
733/// CLI records its sessions to disk and tells nobody, so recall-echo reads them
734/// instead: `recall-echo ingest` on demand, and the graph daemon on its own
735/// once the machine has been quiet.
736///
737/// ```toml
738/// [capture]
739/// enabled = true
740/// sources = ["claude-code", "codex", "grok"]  # default: whatever is installed
741/// settle_secs = 300
742/// ```
743///
744/// Defaults are on and auto-detecting, for the same reason background
745/// extraction is: memory that only fills up for people who read the docs is
746/// memory on the honor system. Set `enabled = false` to import nothing in the
747/// background — `recall-echo ingest` still works, because that one was asked
748/// for.
749#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
750#[serde(default)]
751pub struct CaptureSection {
752    /// Sweep for new transcripts in the daemon. Default `true`.
753    pub enabled: bool,
754    /// Which CLIs to capture. `None` — the default — means every CLI that has
755    /// recorded sessions on this machine.
756    #[serde(skip_serializing_if = "Option::is_none")]
757    pub sources: Option<Vec<crate::transcript::Source>>,
758    /// Seconds a transcript must go untouched before it counts as finished.
759    /// Importing a live session would archive half a conversation and then mark
760    /// it captured for good. Default `300`.
761    pub settle_secs: u64,
762}
763
764impl Default for CaptureSection {
765    fn default() -> Self {
766        Self {
767            enabled: true,
768            sources: None,
769            settle_secs: DEFAULT_CAPTURE_SETTLE_SECS,
770        }
771    }
772}
773
774impl CaptureSection {
775    /// How long a transcript must have been untouched to count as finished.
776    #[must_use]
777    pub fn settle(&self) -> std::time::Duration {
778        std::time::Duration::from_secs(self.settle_secs)
779    }
780}
781
782/// Scoring weights for utility-weighted semantic search.
783///
784/// The final score for a retrieved entity is computed as a linear combination
785/// of three signals:
786///
787/// ```text
788/// score = weight_semantic * similarity
789///       + weight_hotness  * hotness
790///       + weight_utility  * utility_score
791/// ```
792///
793/// Defaults (`0.45 / 0.30 / 0.25`) match the original hard-coded values, so
794/// omitting the `[graph.scoring]` section from `.recall-echo.toml` produces
795/// identical behavior to pre-v3.9.0 recall-echo.
796///
797/// Weights are not constrained to sum to 1.0 — the scoring function does not
798/// normalize. Callers that change these should calibrate against their own
799/// retrieval outcomes; see `utility-feedback-loop-spec.md` in pulse-null.
800///
801/// Graph-expanded candidates score through the same three terms; what differs
802/// is where their `similarity` comes from (a parent's similarity discounted by
803/// the edge's effective confidence, rather than a direct measurement against
804/// the query vector). [`GraphScoringConfig::corroboration_boost`] governs the
805/// one case where the two channels meet.
806#[derive(Debug, Clone, Serialize, Deserialize)]
807#[serde(default)]
808pub struct GraphScoringConfig {
809    /// Weight applied to cosine similarity. Default `0.45`.
810    pub weight_semantic: f64,
811    /// Weight applied to the recency/access hotness signal. Default `0.30`.
812    pub weight_hotness: f64,
813    /// Weight applied to the utility score (outcome-feedback EMA). Default `0.25`.
814    pub weight_utility: f64,
815    /// How much an entity's measured relevance is raised when the graph
816    /// corroborates a semantic hit — i.e. when the same entity is reached both
817    /// by the query vector and over a surviving edge from one of the expanded
818    /// top hits. Default `0.05`.
819    ///
820    /// ```text
821    /// similarity = min(1.0, similarity * (1 + corroboration_boost * effective_confidence))
822    /// ```
823    ///
824    /// Scaled by the edge's effective (decayed) confidence, so a stale edge
825    /// corroborates weakly, and clamped at the similarity ceiling of `1.0`, so
826    /// no amount of corroboration can push an entity past what a perfect
827    /// direct match would score on the same hotness and utility. `0.0`
828    /// disables corroboration entirely.
829    ///
830    /// The default is cut to the *scale* of the similarity distribution it
831    /// perturbs, measured over four LongMemEval stores (196–1804 entities):
832    /// the top-20 similarity band there is only `0.086` wide, so a boost of
833    /// `0.134` would let corroboration promote an entity from the bottom of
834    /// the band to the top, and structure would outrank similarity outright.
835    /// `0.05` moves a corroborated entity about a third of the band — enough
836    /// to break the near-ties that dominate a dense embedding space (the
837    /// rank-1-to-rank-2 gap in those stores is `0.005`–`0.051`), and not
838    /// enough to overturn a decided ordering. Raise it only with retrieval
839    /// numbers in hand: corroboration amplifies whatever the extractor put in
840    /// the graph, including its mistakes.
841    pub corroboration_boost: f64,
842}
843
844impl Default for GraphScoringConfig {
845    fn default() -> Self {
846        Self {
847            weight_semantic: 0.45,
848            weight_hotness: 0.30,
849            weight_utility: 0.25,
850            corroboration_boost: 0.05,
851        }
852    }
853}
854
855/// Similarity bands that decide when entity dedup pays for a model call.
856///
857/// Dedup asks one question — *is this the same thing?* — and that is a
858/// question about meaning, so the bands are cut on raw cosine similarity
859/// between the candidate's abstract and an existing entity's, never on the
860/// retrieval score (which folds in hotness and utility: a popular unrelated
861/// entity would otherwise buy a model call, and every entity gets more
862/// popular as the graph grows).
863///
864/// ```text
865/// similarity >= certain_similarity   → the same entity; resolved locally
866/// review_similarity ..< certain      → ambiguous; one model call decides
867/// similarity <  review_similarity    → new entity; created locally
868/// ```
869///
870/// Defaults (`0.92` / `0.82` / `3`) are cut from the similarity distribution of
871/// a LongMemEval baseline store (192 entities, 150 sampled candidates, 750
872/// neighbour pairs). BGE-Small puts every same-language pair in a narrow high
873/// band — median neighbour 0.75, median *nearest* neighbour 0.81 — so the cuts
874/// sit at its tail, not at intuitive-looking round numbers: 0.92 is the 96th
875/// percentile of pairs, where abstracts are paraphrases of each other, and 0.82
876/// the ~78th, below which pairs are merely same-topic. Candidates averaged 1.1
877/// neighbours above 0.82, so a cap of three bounds the worst case without
878/// binding the normal one.
879#[derive(Debug, Clone, Serialize, Deserialize)]
880#[serde(default)]
881pub struct GraphDedupConfig {
882    /// At or above this cosine similarity the candidate is treated as the same
883    /// entity and resolved without a model call. Default `0.92`.
884    pub certain_similarity: f64,
885    /// Below this cosine similarity the candidate is treated as new and created
886    /// without a model call. Default `0.82`.
887    pub review_similarity: f64,
888    /// How many existing entities, by similarity rank, dedup may fetch and hand
889    /// to the model. Caps prompt size and comparison count so neither can grow
890    /// with the graph. Default `3`.
891    pub max_candidates: usize,
892}
893
894impl Default for GraphDedupConfig {
895    fn default() -> Self {
896        Self {
897            certain_similarity: 0.92,
898            review_similarity: 0.82,
899            max_candidates: 3,
900        }
901    }
902}
903
904impl GraphDedupConfig {
905    /// The band a candidate's nearest neighbour falls in.
906    #[must_use]
907    pub fn band(&self, similarity: f64) -> DedupBand {
908        if similarity >= self.certain_similarity {
909            DedupBand::SameEntity
910        } else if similarity >= self.review_similarity {
911            DedupBand::Ambiguous
912        } else {
913            DedupBand::NewEntity
914        }
915    }
916
917    /// How many candidates to fetch and consider — at least one, whatever the
918    /// config says, or dedup would be blind.
919    #[must_use]
920    pub fn candidate_limit(&self) -> usize {
921        self.max_candidates.max(1)
922    }
923}
924
925/// Which of the three dedup bands a similarity falls in.
926#[derive(Debug, Clone, Copy, PartialEq, Eq)]
927pub enum DedupBand {
928    /// Certainly the same entity — resolve without a model call.
929    SameEntity,
930    /// Genuinely ambiguous — worth a model call.
931    Ambiguous,
932    /// Certainly not the same entity — create without a model call.
933    NewEntity,
934}
935
936fn default_graph_mode() -> String {
937    "embedded".to_string()
938}
939
940fn default_graph_url() -> String {
941    "ws://localhost:8787".to_string()
942}
943
944fn default_graph_namespace() -> String {
945    "nullarc".to_string()
946}
947
948fn default_provider() -> Provider {
949    Provider::Anthropic
950}
951
952// ── Load / Save ──────────────────────────────────────────────────────────
953
954/// Config file path for a given base directory.
955#[must_use]
956pub fn config_path(base: &Path) -> std::path::PathBuf {
957    base.join(CONFIG_FILE)
958}
959
960/// Load config from .recall-echo.toml in the given directory.
961/// Returns defaults if file doesn't exist or is malformed.
962#[must_use]
963pub fn load_from_dir(dir: &Path) -> Config {
964    load(dir)
965}
966
967/// Load config from .recall-echo.toml in the base dir.
968/// Returns defaults if file doesn't exist or is malformed.
969#[must_use]
970pub fn load(base: &Path) -> Config {
971    let path = config_path(base);
972    if !path.exists() {
973        return Config::default();
974    }
975
976    let content = match fs::read_to_string(&path) {
977        Ok(c) => c,
978        Err(_) => return Config::default(),
979    };
980
981    match toml::from_str(&content) {
982        Ok(cfg) => validate(cfg),
983        Err(_) => Config::default(),
984    }
985}
986
987/// Save config to .recall-echo.toml in the base dir.
988pub fn save(base: &Path, config: &Config) -> Result<(), crate::error::RecallError> {
989    let path = config_path(base);
990    let content = toml::to_string_pretty(config)?;
991    fs::write(&path, content)?;
992    Ok(())
993}
994
995/// Returns true if .recall-echo.toml exists in the directory.
996#[must_use]
997pub fn exists(base: &Path) -> bool {
998    config_path(base).exists()
999}
1000
1001fn validate(mut cfg: Config) -> Config {
1002    if !(1..=50).contains(&cfg.ephemeral.max_entries) {
1003        cfg.ephemeral.max_entries = DEFAULT_MAX_ENTRIES;
1004    }
1005    cfg
1006}
1007
1008// ── Config mutation helpers ──────────────────────────────────────────────
1009
1010impl Config {
1011    /// Set a dotted config key (e.g. "llm.provider", "ephemeral.max_entries").
1012    pub fn set_key(&mut self, key: &str, value: &str) -> Result<(), crate::error::RecallError> {
1013        use crate::error::RecallError;
1014        match key {
1015            "llm.provider" | "provider" => {
1016                let provider = Provider::from_str_loose(value)?;
1017                // When switching provider, reset model, api_base and the CLI
1018                // overrides to defaults: all three describe the old vendor.
1019                self.llm.model = String::new();
1020                self.llm.api_base = String::new();
1021                self.llm.cli = CliSection::default();
1022                self.llm.provider = provider;
1023                Ok(())
1024            }
1025            _ if key.starts_with("llm.cli.") => {
1026                self.llm.cli.set_key(&key["llm.cli.".len()..], value)
1027            }
1028            "llm.model" | "model" => {
1029                self.llm.model = value.to_string();
1030                Ok(())
1031            }
1032            "llm.api_base" | "api_base" => {
1033                self.llm.api_base = value.to_string();
1034                Ok(())
1035            }
1036            "ephemeral.max_entries" => {
1037                let n: usize = value
1038                    .parse()
1039                    .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1040                if !(1..=50).contains(&n) {
1041                    return Err(RecallError::Config(
1042                        "max_entries must be between 1 and 50".into(),
1043                    ));
1044                }
1045                self.ephemeral.max_entries = n;
1046                Ok(())
1047            }
1048            "pipeline.docs_dir" => {
1049                let section = self.pipeline.get_or_insert(PipelineSection {
1050                    docs_dir: None,
1051                    auto_sync: None,
1052                });
1053                section.docs_dir = Some(value.to_string());
1054                Ok(())
1055            }
1056            "pipeline.auto_sync" => {
1057                let b: bool = value
1058                    .parse()
1059                    .map_err(|_| RecallError::Config(format!("invalid boolean: {value}")))?;
1060                let section = self.pipeline.get_or_insert(PipelineSection {
1061                    docs_dir: None,
1062                    auto_sync: None,
1063                });
1064                section.auto_sync = Some(b);
1065                Ok(())
1066            }
1067            "serve.idle_timeout_secs" => {
1068                let secs: u64 = value
1069                    .parse()
1070                    .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1071                self.serve.idle_timeout_secs = secs;
1072                Ok(())
1073            }
1074            "serve.socket_path" => {
1075                self.serve.socket_path = if value.trim().is_empty() {
1076                    None
1077                } else {
1078                    Some(value.to_string())
1079                };
1080                Ok(())
1081            }
1082            "extraction.background_enabled" => {
1083                self.extraction.background_enabled = value
1084                    .parse()
1085                    .map_err(|_| RecallError::Config(format!("invalid boolean: {value}")))?;
1086                Ok(())
1087            }
1088            "extraction.idle_after_secs" => {
1089                self.extraction.idle_after_secs = value
1090                    .parse()
1091                    .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1092                Ok(())
1093            }
1094            "extraction.batch_size" => {
1095                let size: usize = value
1096                    .parse()
1097                    .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1098                if size == 0 {
1099                    return Err(RecallError::Config("batch_size must be at least 1".into()));
1100                }
1101                self.extraction.batch_size = size;
1102                Ok(())
1103            }
1104            "capture.enabled" => {
1105                self.capture.enabled = value
1106                    .parse()
1107                    .map_err(|_| RecallError::Config(format!("invalid boolean: {value}")))?;
1108                Ok(())
1109            }
1110            "capture.settle_secs" => {
1111                self.capture.settle_secs = value
1112                    .parse()
1113                    .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1114                Ok(())
1115            }
1116            "capture.sources" => {
1117                self.capture.sources = parse_sources(value)?;
1118                Ok(())
1119            }
1120            "graph.provenance.weight_external" => {
1121                self.graph_section().provenance.weight_external = parse_weight(value)?;
1122                Ok(())
1123            }
1124            "graph.provenance.weight_user" => {
1125                self.graph_section().provenance.weight_user = parse_weight(value)?;
1126                Ok(())
1127            }
1128            "graph.provenance.weight_self" => {
1129                self.graph_section().provenance.weight_self = parse_weight(value)?;
1130                Ok(())
1131            }
1132            "graph.dedup.certain_similarity" => {
1133                self.graph_section().dedup.certain_similarity = parse_similarity(value)?;
1134                Ok(())
1135            }
1136            "graph.dedup.review_similarity" => {
1137                self.graph_section().dedup.review_similarity = parse_similarity(value)?;
1138                Ok(())
1139            }
1140            "graph.dedup.max_candidates" => {
1141                let n: usize = value
1142                    .parse()
1143                    .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1144                if n == 0 {
1145                    return Err(RecallError::Config(
1146                        "max_candidates must be at least 1".into(),
1147                    ));
1148                }
1149                self.graph_section().dedup.max_candidates = n;
1150                Ok(())
1151            }
1152            other => Err(RecallError::Config(format!("unknown config key: {other}"))),
1153        }
1154    }
1155
1156    /// The `[graph]` section, created at its defaults if the config has none.
1157    fn graph_section(&mut self) -> &mut GraphSection {
1158        self.graph.get_or_insert_with(GraphSection::default)
1159    }
1160}
1161
1162/// Parse a comma-separated CLI list. Empty means "auto-detect".
1163fn parse_sources(
1164    value: &str,
1165) -> Result<Option<Vec<crate::transcript::Source>>, crate::error::RecallError> {
1166    let names: Vec<&str> = value
1167        .split(',')
1168        .map(str::trim)
1169        .filter(|name| !name.is_empty())
1170        .collect();
1171    if names.is_empty() {
1172        return Ok(None);
1173    }
1174    let mut sources = Vec::with_capacity(names.len());
1175    for name in names {
1176        let source = crate::transcript::Source::from_str_loose(name)?;
1177        if !sources.contains(&source) {
1178            sources.push(source);
1179        }
1180    }
1181    Ok(Some(sources))
1182}
1183
1184/// Parse an evidence weight: a finite, non-negative number.
1185///
1186/// Zero is allowed — it is how a class is switched off entirely.
1187fn parse_weight(value: &str) -> Result<f64, crate::error::RecallError> {
1188    use crate::error::RecallError;
1189    let weight: f64 = value
1190        .parse()
1191        .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1192    if !weight.is_finite() || weight < 0.0 {
1193        return Err(RecallError::Config(format!(
1194            "evidence weight must be finite and non-negative, got {value}"
1195        )));
1196    }
1197    Ok(weight)
1198}
1199
1200/// Parse a cosine-similarity threshold: a finite number in `0.0..=1.0`.
1201fn parse_similarity(value: &str) -> Result<f64, crate::error::RecallError> {
1202    use crate::error::RecallError;
1203    let similarity: f64 = value
1204        .parse()
1205        .map_err(|_| RecallError::Config(format!("invalid number: {value}")))?;
1206    if !similarity.is_finite() || !(0.0..=1.0).contains(&similarity) {
1207        return Err(RecallError::Config(format!(
1208            "similarity threshold must be between 0.0 and 1.0, got {value}"
1209        )));
1210    }
1211    Ok(similarity)
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216    use super::*;
1217
1218    #[test]
1219    fn default_config() {
1220        let cfg = Config::default();
1221        assert_eq!(cfg.ephemeral.max_entries, 5);
1222        assert_eq!(cfg.llm.provider, Provider::Anthropic);
1223        assert!(cfg.llm.model.is_empty());
1224    }
1225
1226    #[test]
1227    fn parse_ephemeral_only() {
1228        let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 10\n").unwrap();
1229        assert_eq!(cfg.ephemeral.max_entries, 10);
1230        assert_eq!(cfg.llm.provider, Provider::Anthropic);
1231    }
1232
1233    #[test]
1234    fn graph_mode_defaults_to_embedded() {
1235        let cfg: Config = toml::from_str("[graph]\n").unwrap();
1236        assert_eq!(cfg.graph.unwrap().mode, "embedded");
1237    }
1238
1239    #[test]
1240    fn graph_mode_parses_server() {
1241        let cfg: Config =
1242            toml::from_str("[graph]\nmode = \"server\"\nurl = \"ws://db.local:8787\"\n").unwrap();
1243        let g = cfg.graph.unwrap();
1244        assert_eq!(g.mode, "server");
1245        assert_eq!(g.url, "ws://db.local:8787");
1246    }
1247
1248    #[test]
1249    fn serve_defaults_when_section_absent() {
1250        let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 3\n").unwrap();
1251        assert_eq!(cfg.serve.idle_timeout_secs, DEFAULT_IDLE_TIMEOUT_SECS);
1252        assert!(cfg.serve.socket_path.is_none());
1253    }
1254
1255    #[test]
1256    fn serve_section_parses_overrides() {
1257        let cfg: Config = toml::from_str(
1258            "[serve]\nsocket_path = \"/run/re/graph.sock\"\nidle_timeout_secs = 60\n",
1259        )
1260        .unwrap();
1261        assert_eq!(cfg.serve.idle_timeout_secs, 60);
1262        assert_eq!(cfg.serve.socket_path.as_deref(), Some("/run/re/graph.sock"));
1263    }
1264
1265    #[test]
1266    fn set_key_serve_idle_timeout() {
1267        let mut cfg = Config::default();
1268        cfg.set_key("serve.idle_timeout_secs", "120").unwrap();
1269        assert_eq!(cfg.serve.idle_timeout_secs, 120);
1270        assert!(cfg.set_key("serve.idle_timeout_secs", "soon").is_err());
1271    }
1272
1273    #[test]
1274    fn parse_llm_section() {
1275        let cfg: Config = toml::from_str(
1276            "[llm]\nprovider = \"openai\"\nmodel = \"llama3.1\"\napi_base = \"http://myhost:11434/v1\"\n",
1277        )
1278        .unwrap();
1279        assert_eq!(cfg.llm.provider, Provider::Openai);
1280        assert_eq!(cfg.llm.model, "llama3.1");
1281        assert_eq!(cfg.llm.api_base, "http://myhost:11434/v1");
1282    }
1283
1284    #[test]
1285    fn parse_claude_code_provider() {
1286        let cfg: Config = toml::from_str("[llm]\nprovider = \"claude-code\"\n").unwrap();
1287        assert_eq!(cfg.llm.provider, Provider::ClaudeCode);
1288    }
1289
1290    #[test]
1291    fn resolved_defaults() {
1292        let llm = LlmSection::default();
1293        assert_eq!(llm.resolved_model(), "claude-haiku-4-5-20251001");
1294        assert_eq!(
1295            llm.resolved_api_base(),
1296            "https://api.anthropic.com/v1/messages"
1297        );
1298    }
1299
1300    #[test]
1301    fn resolved_custom_overrides_default() {
1302        let llm = LlmSection {
1303            provider: Provider::Openai,
1304            model: "mistral-7b".into(),
1305            ..LlmSection::default()
1306        };
1307        assert_eq!(llm.resolved_model(), "mistral-7b");
1308        assert_eq!(llm.resolved_api_base(), "http://localhost:11434/v1");
1309    }
1310
1311    #[test]
1312    fn round_trip_toml() {
1313        let cfg = Config {
1314            ephemeral: EphemeralConfig { max_entries: 3 },
1315            llm: LlmSection {
1316                provider: Provider::Openai,
1317                model: "llama3.2".into(),
1318                api_base: "http://localhost:11434/v1".into(),
1319                ..LlmSection::default()
1320            },
1321            ..Config::default()
1322        };
1323        let s = toml::to_string_pretty(&cfg).unwrap();
1324        let parsed: Config = toml::from_str(&s).unwrap();
1325        assert_eq!(parsed.ephemeral.max_entries, 3);
1326        assert_eq!(parsed.llm.provider, Provider::Openai);
1327        assert_eq!(parsed.llm.model, "llama3.2");
1328    }
1329
1330    #[test]
1331    fn set_key_provider() {
1332        let mut cfg = Config::default();
1333        cfg.set_key("llm.provider", "ollama").unwrap();
1334        assert_eq!(cfg.llm.provider, Provider::Openai);
1335        assert!(cfg.llm.model.is_empty());
1336    }
1337
1338    #[test]
1339    fn set_key_model() {
1340        let mut cfg = Config::default();
1341        cfg.set_key("llm.model", "claude-sonnet-4-6").unwrap();
1342        assert_eq!(cfg.llm.model, "claude-sonnet-4-6");
1343    }
1344
1345    #[test]
1346    fn set_key_unknown_fails() {
1347        let mut cfg = Config::default();
1348        assert!(cfg.set_key("nonexistent.key", "value").is_err());
1349    }
1350
1351    #[test]
1352    fn set_key_cli_overrides() {
1353        let mut cfg = Config::default();
1354        cfg.set_key("llm.provider", "gemini").unwrap();
1355        cfg.set_key("llm.cli.command", "/opt/bin/gemini").unwrap();
1356        cfg.set_key("llm.cli.result_json_path", "response, result")
1357            .unwrap();
1358        cfg.set_key("llm.cli.extra_args", "--yolo --quiet").unwrap();
1359        cfg.set_key("llm.cli.prompt_delivery", "stdin").unwrap();
1360        cfg.set_key("llm.cli.timeout_secs", "45").unwrap();
1361
1362        let cli = &cfg.llm.cli;
1363        assert_eq!(cli.command.as_deref(), Some("/opt/bin/gemini"));
1364        assert_eq!(
1365            cli.result_json_path.as_ref().unwrap().paths(),
1366            ["response", "result"]
1367        );
1368        assert_eq!(
1369            cli.extra_args.as_deref(),
1370            Some(["--yolo".to_string(), "--quiet".to_string()].as_slice())
1371        );
1372        assert_eq!(cli.prompt_delivery, Some(PromptDelivery::Stdin));
1373        assert_eq!(cli.timeout_secs, Some(45));
1374
1375        assert!(cfg.set_key("llm.cli.nonexistent", "x").is_err());
1376        assert!(cfg.set_key("llm.cli.timeout_secs", "soon").is_err());
1377        assert!(cfg.set_key("llm.cli.preset", "nonesuch").is_err());
1378    }
1379
1380    /// The overrides describe one vendor's binary; carrying them to the next
1381    /// provider would spawn the wrong tool with the right flags.
1382    #[test]
1383    fn switching_provider_clears_the_cli_overrides() {
1384        let mut cfg = Config::default();
1385        cfg.set_key("llm.provider", "gemini").unwrap();
1386        cfg.set_key("llm.cli.command", "/opt/bin/gemini").unwrap();
1387        cfg.set_key("llm.provider", "grok").unwrap();
1388
1389        assert_eq!(cfg.llm.provider, Provider::Grok);
1390        assert!(cfg.llm.cli.is_empty());
1391    }
1392
1393    #[test]
1394    fn cli_section_parses_from_toml() {
1395        let cfg: Config = toml::from_str(
1396            "[llm]\nprovider = \"cli\"\n\n[llm.cli]\ncommand = \"mycli\"\n\
1397             prompt_delivery = \"flag\"\nprompt_flag = \"--ask\"\n\
1398             result_json_path = [\"data.text\", \"text\"]\nargs = [\"chat\"]\n",
1399        )
1400        .expect("parse [llm.cli]");
1401        let cli = cfg.llm.cli;
1402        assert_eq!(cfg.llm.provider, Provider::Cli);
1403        assert_eq!(cli.command.as_deref(), Some("mycli"));
1404        assert_eq!(cli.prompt_delivery, Some(PromptDelivery::Flag));
1405        assert_eq!(cli.prompt_flag.as_deref(), Some("--ask"));
1406        assert_eq!(
1407            cli.result_json_path.as_ref().unwrap().paths(),
1408            ["data.text", "text"]
1409        );
1410        assert_eq!(cli.args.as_deref(), Some(["chat".to_string()].as_slice()));
1411    }
1412
1413    #[test]
1414    fn set_key_output_mode_and_ndjson_match() {
1415        let mut cfg = Config::default();
1416        cfg.set_key("llm.provider", "cli").unwrap();
1417        cfg.set_key("llm.cli.output_mode", "ndjson").unwrap();
1418        cfg.set_key(
1419            "llm.cli.ndjson_match",
1420            "type=item.completed, item.type=agent_message",
1421        )
1422        .unwrap();
1423
1424        assert_eq!(cfg.llm.cli.output_mode, Some(OutputMode::Ndjson));
1425        assert_eq!(
1426            cfg.llm.cli.ndjson_match.as_ref().unwrap().predicates(),
1427            [("type", "item.completed"), ("item.type", "agent_message")]
1428        );
1429        assert!(cfg.set_key("llm.cli.output_mode", "yaml").is_err());
1430    }
1431
1432    #[test]
1433    fn output_mode_accepts_the_obvious_spellings() {
1434        assert_eq!(
1435            OutputMode::from_str_loose("jsonl").unwrap(),
1436            OutputMode::Ndjson
1437        );
1438        assert_eq!(
1439            OutputMode::from_str_loose("json").unwrap(),
1440            OutputMode::SingleJson
1441        );
1442        assert_eq!(OutputMode::from_str_loose("TEXT").unwrap(), OutputMode::Raw);
1443    }
1444
1445    /// A predicate without a value would match every line; dropping it is
1446    /// safer than treating it as a wildcard nobody asked for.
1447    #[test]
1448    fn line_matchers_drop_entries_without_a_value() {
1449        let matchers = LineMatchers::parse("type=item.completed, garbage, ");
1450        assert_eq!(matchers.predicates(), [("type", "item.completed")]);
1451    }
1452
1453    #[test]
1454    fn result_json_path_accepts_a_bare_string() {
1455        let cli: CliSection = toml::from_str("result_json_path = \"result\"\n").expect("parse");
1456        assert_eq!(cli.result_json_path.unwrap().paths(), ["result"]);
1457    }
1458
1459    #[test]
1460    fn an_empty_result_json_path_means_raw_stdout() {
1461        let cli: CliSection = toml::from_str("result_json_path = \"\"\n").expect("parse");
1462        assert!(cli.result_json_path.unwrap().is_empty());
1463    }
1464
1465    /// Configs written before `[llm.cli]` existed must keep loading, and keep
1466    /// saving without gaining a section their owner never asked for.
1467    #[test]
1468    fn a_config_without_a_cli_section_round_trips_unchanged() {
1469        let tmp = tempfile::tempdir().unwrap();
1470        let mut cfg = Config::default();
1471        cfg.set_key("llm.provider", "claude-code").unwrap();
1472        save(tmp.path(), &cfg).unwrap();
1473
1474        let rendered = fs::read_to_string(config_path(tmp.path())).unwrap();
1475        assert!(!rendered.contains("[llm.cli]"), "{rendered}");
1476        assert_eq!(load(tmp.path()).llm.provider, Provider::ClaudeCode);
1477    }
1478
1479    #[test]
1480    fn cli_overrides_survive_a_save_and_load() {
1481        let tmp = tempfile::tempdir().unwrap();
1482        let mut cfg = Config::default();
1483        cfg.set_key("llm.provider", "cli").unwrap();
1484        cfg.set_key("llm.cli.command", "mycli").unwrap();
1485        cfg.set_key("llm.cli.result_json_path", "data.text")
1486            .unwrap();
1487        save(tmp.path(), &cfg).unwrap();
1488
1489        let loaded = load(tmp.path());
1490        assert_eq!(loaded.llm.provider, Provider::Cli);
1491        assert_eq!(loaded.llm.cli.command.as_deref(), Some("mycli"));
1492        assert_eq!(
1493            loaded.llm.cli.result_json_path.unwrap().paths(),
1494            ["data.text"]
1495        );
1496    }
1497
1498    #[test]
1499    fn cli_providers_are_distinguished_from_http_ones() {
1500        assert!(Provider::ClaudeCode.is_cli());
1501        assert!(Provider::Gemini.is_cli());
1502        assert!(Provider::Grok.is_cli());
1503        assert!(Provider::Codex.is_cli());
1504        assert!(Provider::Cli.is_cli());
1505        assert!(!Provider::Anthropic.is_cli());
1506        assert!(!Provider::Openai.is_cli());
1507    }
1508
1509    #[test]
1510    fn provider_from_str_loose_accepts_the_cli_vendors() {
1511        assert_eq!(
1512            Provider::from_str_loose("gemini").unwrap(),
1513            Provider::Gemini
1514        );
1515        assert_eq!(
1516            Provider::from_str_loose("gemini-cli").unwrap(),
1517            Provider::Gemini
1518        );
1519        assert_eq!(Provider::from_str_loose("Grok").unwrap(), Provider::Grok);
1520        assert_eq!(Provider::from_str_loose("xai").unwrap(), Provider::Grok);
1521        assert_eq!(Provider::from_str_loose("cli").unwrap(), Provider::Cli);
1522        assert_eq!(Provider::from_str_loose("custom").unwrap(), Provider::Cli);
1523    }
1524
1525    #[test]
1526    fn codex_resolves_to_its_own_preset() {
1527        assert_eq!(Provider::from_str_loose("codex").unwrap(), Provider::Codex);
1528        assert_eq!(
1529            Provider::Codex.default_cli_preset(),
1530            Some(CliPreset::Codex),
1531            "codex must not fall through to the custom preset"
1532        );
1533    }
1534
1535    /// A vendor with no preset at all gets the generic mechanism, not a dead
1536    /// end — the error names it.
1537    #[test]
1538    fn an_unknown_vendor_is_pointed_at_the_cli_provider() {
1539        let err = Provider::from_str_loose("some-new-agent").expect_err("no such preset");
1540        assert!(err.to_string().contains("[llm.cli]"), "{err}");
1541    }
1542
1543    #[test]
1544    fn provider_display_round_trips_through_from_str_loose() {
1545        for provider in [
1546            Provider::Anthropic,
1547            Provider::Openai,
1548            Provider::ClaudeCode,
1549            Provider::Gemini,
1550            Provider::Grok,
1551            Provider::Codex,
1552            Provider::Cli,
1553        ] {
1554            let rendered = provider.to_string();
1555            assert_eq!(
1556                Provider::from_str_loose(&rendered).unwrap(),
1557                provider,
1558                "{rendered}"
1559            );
1560        }
1561    }
1562
1563    #[test]
1564    fn provider_from_str_loose() {
1565        assert_eq!(
1566            Provider::from_str_loose("ollama").unwrap(),
1567            Provider::Openai
1568        );
1569        assert_eq!(
1570            Provider::from_str_loose("claude").unwrap(),
1571            Provider::Anthropic
1572        );
1573        assert_eq!(
1574            Provider::from_str_loose("claude-code").unwrap(),
1575            Provider::ClaudeCode
1576        );
1577        assert!(Provider::from_str_loose("unknown").is_err());
1578    }
1579
1580    #[test]
1581    fn save_and_load() {
1582        let tmp = tempfile::tempdir().unwrap();
1583        let cfg = Config {
1584            ephemeral: EphemeralConfig { max_entries: 7 },
1585            llm: LlmSection {
1586                provider: Provider::ClaudeCode,
1587                ..LlmSection::default()
1588            },
1589            ..Config::default()
1590        };
1591        save(tmp.path(), &cfg).unwrap();
1592        let loaded = load(tmp.path());
1593        assert_eq!(loaded.ephemeral.max_entries, 7);
1594        assert_eq!(loaded.llm.provider, Provider::ClaudeCode);
1595    }
1596
1597    #[test]
1598    fn load_nonexistent_file() {
1599        let tmp = tempfile::tempdir().unwrap();
1600        let cfg = load(tmp.path());
1601        assert_eq!(cfg.ephemeral.max_entries, 5);
1602    }
1603
1604    #[test]
1605    fn validate_out_of_range() {
1606        let cfg = validate(Config {
1607            ephemeral: EphemeralConfig { max_entries: 100 },
1608            ..Config::default()
1609        });
1610        assert_eq!(cfg.ephemeral.max_entries, 5);
1611    }
1612
1613    #[test]
1614    fn capture_defaults_are_on_and_auto_detecting() {
1615        let capture = CaptureSection::default();
1616        assert!(capture.enabled);
1617        assert!(capture.sources.is_none());
1618        assert_eq!(capture.settle(), std::time::Duration::from_secs(300));
1619    }
1620
1621    /// A config written before `[capture]` existed must keep loading, with the
1622    /// defaults it would have had.
1623    #[test]
1624    fn a_config_without_a_capture_section_still_loads() {
1625        let cfg: Config = toml::from_str("[ephemeral]\nmax_entries = 3\n").unwrap();
1626        assert!(cfg.capture.enabled);
1627        assert!(cfg.capture.sources.is_none());
1628    }
1629
1630    #[test]
1631    fn capture_section_parses_an_explicit_source_list() {
1632        let cfg: Config = toml::from_str(
1633            "[capture]\nenabled = false\nsources = [\"codex\", \"grok\"]\nsettle_secs = 60\n",
1634        )
1635        .expect("parse [capture]");
1636        assert!(!cfg.capture.enabled);
1637        assert_eq!(cfg.capture.settle_secs, 60);
1638        assert_eq!(
1639            cfg.capture.sources.as_deref(),
1640            Some(
1641                [
1642                    crate::transcript::Source::Codex,
1643                    crate::transcript::Source::Grok
1644                ]
1645                .as_slice()
1646            )
1647        );
1648    }
1649
1650    #[test]
1651    fn set_key_capture_values() {
1652        let mut cfg = Config::default();
1653        cfg.set_key("capture.enabled", "false").unwrap();
1654        cfg.set_key("capture.settle_secs", "30").unwrap();
1655        cfg.set_key("capture.sources", "codex, claude").unwrap();
1656
1657        assert!(!cfg.capture.enabled);
1658        assert_eq!(cfg.capture.settle_secs, 30);
1659        assert_eq!(
1660            cfg.capture.sources.as_deref(),
1661            Some(
1662                [
1663                    crate::transcript::Source::Codex,
1664                    crate::transcript::Source::ClaudeCode
1665                ]
1666                .as_slice()
1667            )
1668        );
1669
1670        // An empty list means "back to auto-detect", not "capture nothing".
1671        cfg.set_key("capture.sources", "").unwrap();
1672        assert!(cfg.capture.sources.is_none());
1673        assert!(cfg.set_key("capture.sources", "cursor").is_err());
1674        assert!(cfg.set_key("capture.enabled", "maybe").is_err());
1675    }
1676
1677    #[test]
1678    fn graph_scoring_defaults_match_legacy_hardcodes() {
1679        let scoring = GraphScoringConfig::default();
1680        assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
1681        assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
1682        assert!((scoring.weight_utility - 0.25).abs() < f64::EPSILON);
1683        assert!((scoring.corroboration_boost - 0.05).abs() < f64::EPSILON);
1684    }
1685
1686    #[test]
1687    fn graph_scoring_partial_toml_fills_defaults() {
1688        let scoring: GraphScoringConfig =
1689            toml::from_str("weight_utility = 0.5\n").expect("parse partial scoring");
1690        assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
1691        assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
1692        assert!((scoring.weight_utility - 0.5).abs() < f64::EPSILON);
1693        assert!((scoring.corroboration_boost - 0.05).abs() < f64::EPSILON);
1694    }
1695
1696    #[test]
1697    fn graph_scoring_corroboration_boost_is_configurable() {
1698        let scoring: GraphScoringConfig =
1699            toml::from_str("corroboration_boost = 0.0\n").expect("parse corroboration boost");
1700        assert!(scoring.corroboration_boost.abs() < f64::EPSILON);
1701        assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
1702    }
1703
1704    #[test]
1705    fn graph_scoring_empty_section_yields_defaults() {
1706        let section: GraphSection = toml::from_str("").expect("parse empty graph section");
1707        let defaults = GraphScoringConfig::default();
1708        assert!((section.scoring.weight_semantic - defaults.weight_semantic).abs() < f64::EPSILON);
1709        assert!((section.scoring.weight_hotness - defaults.weight_hotness).abs() < f64::EPSILON);
1710        assert!((section.scoring.weight_utility - defaults.weight_utility).abs() < f64::EPSILON);
1711    }
1712
1713    #[test]
1714    fn graph_provenance_defaults_when_section_absent() {
1715        let section: GraphSection = toml::from_str("mode = \"embedded\"\n").expect("parse section");
1716        let defaults = ProvenanceWeights::default();
1717        assert_eq!(section.provenance, defaults);
1718        assert!((defaults.weight_external - 1.0).abs() < f64::EPSILON);
1719        assert!((defaults.weight_user - 0.8).abs() < f64::EPSILON);
1720        assert!((defaults.weight_self - 0.05).abs() < f64::EPSILON);
1721    }
1722
1723    #[test]
1724    fn graph_provenance_partial_toml_fills_defaults() {
1725        let cfg: Config =
1726            toml::from_str("[graph]\n\n[graph.provenance]\nweight_self = 0.5\n").expect("parse");
1727        let provenance = cfg.graph.expect("graph section present").provenance;
1728        assert!((provenance.weight_self - 0.5).abs() < f64::EPSILON);
1729        assert!((provenance.weight_external - 1.0).abs() < f64::EPSILON);
1730        assert!((provenance.weight_user - 0.8).abs() < f64::EPSILON);
1731    }
1732
1733    #[test]
1734    fn set_key_provenance_weights() {
1735        let mut cfg = Config::default();
1736        cfg.set_key("graph.provenance.weight_self", "0.2").unwrap();
1737        cfg.set_key("graph.provenance.weight_user", "0").unwrap();
1738        cfg.set_key("graph.provenance.weight_external", "1.5")
1739            .unwrap();
1740
1741        let provenance = cfg
1742            .graph
1743            .as_ref()
1744            .expect("graph section created")
1745            .provenance;
1746        assert!((provenance.weight_self - 0.2).abs() < f64::EPSILON);
1747        assert!(provenance.weight_user.abs() < f64::EPSILON);
1748        assert!((provenance.weight_external - 1.5).abs() < f64::EPSILON);
1749
1750        assert!(cfg.set_key("graph.provenance.weight_self", "-1").is_err());
1751        assert!(cfg.set_key("graph.provenance.weight_self", "lots").is_err());
1752    }
1753
1754    #[test]
1755    fn provenance_weights_round_trip_through_toml() {
1756        let mut cfg = Config::default();
1757        cfg.set_key("graph.provenance.weight_self", "0.05").unwrap();
1758        let rendered = toml::to_string_pretty(&cfg).expect("render");
1759        let parsed: Config = toml::from_str(&rendered).expect("reparse");
1760        assert_eq!(
1761            parsed.graph.expect("graph section survives").provenance,
1762            ProvenanceWeights::default()
1763        );
1764    }
1765
1766    #[test]
1767    fn dedup_defaults_leave_a_gap_between_the_bands() {
1768        let dedup = GraphDedupConfig::default();
1769        assert!((dedup.certain_similarity - 0.92).abs() < f64::EPSILON);
1770        assert!((dedup.review_similarity - 0.82).abs() < f64::EPSILON);
1771        assert_eq!(dedup.max_candidates, 3);
1772        assert!(dedup.review_similarity < dedup.certain_similarity);
1773    }
1774
1775    #[test]
1776    fn dedup_bands_are_cut_at_the_thresholds() {
1777        let dedup = GraphDedupConfig::default();
1778        assert_eq!(dedup.band(0.99), DedupBand::SameEntity);
1779        assert_eq!(dedup.band(0.92), DedupBand::SameEntity);
1780        assert_eq!(dedup.band(0.9), DedupBand::Ambiguous);
1781        assert_eq!(dedup.band(0.82), DedupBand::Ambiguous);
1782        assert_eq!(dedup.band(0.8), DedupBand::NewEntity);
1783        assert_eq!(dedup.band(0.0), DedupBand::NewEntity);
1784    }
1785
1786    /// A store configured to fetch nothing would resolve every candidate as
1787    /// new; the floor of one keeps dedup able to see.
1788    #[test]
1789    fn dedup_candidate_limit_never_falls_below_one() {
1790        let dedup = GraphDedupConfig {
1791            max_candidates: 0,
1792            ..GraphDedupConfig::default()
1793        };
1794        assert_eq!(dedup.candidate_limit(), 1);
1795    }
1796
1797    #[test]
1798    fn dedup_partial_toml_fills_defaults() {
1799        let cfg: Config = toml::from_str("[graph]\n\n[graph.dedup]\ncertain_similarity = 0.95\n")
1800            .expect("parse dedup section");
1801        let dedup = cfg.graph.expect("graph section present").dedup;
1802        assert!((dedup.certain_similarity - 0.95).abs() < f64::EPSILON);
1803        assert!((dedup.review_similarity - 0.82).abs() < f64::EPSILON);
1804        assert_eq!(dedup.max_candidates, 3);
1805    }
1806
1807    #[test]
1808    fn set_key_dedup_thresholds() {
1809        let mut cfg = Config::default();
1810        cfg.set_key("graph.dedup.certain_similarity", "0.9")
1811            .unwrap();
1812        cfg.set_key("graph.dedup.review_similarity", "0.6").unwrap();
1813        cfg.set_key("graph.dedup.max_candidates", "5").unwrap();
1814
1815        let dedup = &cfg.graph.as_ref().expect("graph section created").dedup;
1816        assert!((dedup.certain_similarity - 0.9).abs() < f64::EPSILON);
1817        assert!((dedup.review_similarity - 0.6).abs() < f64::EPSILON);
1818        assert_eq!(dedup.max_candidates, 5);
1819
1820        assert!(cfg
1821            .set_key("graph.dedup.certain_similarity", "1.5")
1822            .is_err());
1823        assert!(cfg
1824            .set_key("graph.dedup.review_similarity", "-0.1")
1825            .is_err());
1826        assert!(cfg.set_key("graph.dedup.max_candidates", "0").is_err());
1827    }
1828
1829    #[test]
1830    fn graph_scoring_nested_under_graph() {
1831        let cfg: Config = toml::from_str(
1832            "[graph]\nmode = \"embedded\"\n\n[graph.scoring]\nweight_utility = 0.5\n",
1833        )
1834        .expect("parse nested scoring");
1835        let scoring = cfg.graph.expect("graph section present").scoring;
1836        assert!((scoring.weight_semantic - 0.45).abs() < f64::EPSILON);
1837        assert!((scoring.weight_hotness - 0.30).abs() < f64::EPSILON);
1838        assert!((scoring.weight_utility - 0.5).abs() < f64::EPSILON);
1839    }
1840}