Skip to main content

velesdb_memory/
config.rs

1//! The optional TOML configuration file: one place to set every knob.
2//!
3//! Until now the daemon was configured exclusively through eighteen
4//! `VELESDB_MEMORY_*` environment variables. That is workable for a one-shot
5//! shell invocation and painful for a long-lived daemon: a launchd plist or a
6//! systemd unit is the wrong place to keep a model name, and nothing there can
7//! carry a comment explaining *why* a value is what it is.
8//!
9//! This module adds a file without taking anything away. It resolves the
10//! config, then exports each setting into the process environment **only when
11//! that variable is not already set**. Every existing reader keeps reading the
12//! environment exactly as before, and the precedence falls out of that one
13//! rule:
14//!
15//! ```text
16//! command line  >  environment  >  config file  >  built-in default
17//! ```
18//!
19//! So an operator can pin a model in the file and still override it for a
20//! single run with `VELESDB_MEMORY_OLLAMA_MODEL=… velesdb-memory`, which is
21//! the behaviour anyone who has used a dotfile-driven tool expects.
22//!
23//! The file is entirely optional: no file, or a file with only some keys, is
24//! not an error. A file that exists but cannot be parsed **is** an error —
25//! silently ignoring a malformed config is how a daemon ends up quietly
26//! running on defaults the operator believes they overrode.
27
28use std::collections::BTreeMap;
29use std::path::{Path, PathBuf};
30
31use serde::Deserialize;
32
33/// Environment variable naming the config file explicitly.
34pub const CONFIG_PATH_VAR: &str = "VELESDB_MEMORY_CONFIG";
35
36/// File name looked up in the default locations.
37pub const CONFIG_FILE_NAME: &str = "velesdb-memory.toml";
38
39/// Why a config file could not be used.
40#[derive(Debug, thiserror::Error)]
41pub enum ConfigError {
42    /// The file could not be read.
43    #[error("config file {path} could not be read: {source}")]
44    Read {
45        /// The path that failed.
46        path: PathBuf,
47        /// The underlying I/O failure.
48        source: std::io::Error,
49    },
50    /// The file is not valid TOML, or does not match the expected shape.
51    #[error("config file {path} is not valid: {message}")]
52    Parse {
53        /// The path that failed.
54        path: PathBuf,
55        /// The parser's complaint.
56        message: String,
57    },
58    /// A path list could not be joined into the platform's list syntax.
59    #[error("config file {path}: {field} contains a path with the list separator in it")]
60    PathList {
61        /// The path that failed.
62        path: PathBuf,
63        /// The offending field.
64        field: &'static str,
65    },
66}
67
68/// Top-level shape of `velesdb-memory.toml`.
69///
70/// `deny_unknown_fields` is deliberate: a typo'd key (`mdoel = "…"`) that is
71/// silently dropped leaves the operator convinced they set something they did
72/// not. Failing loudly at startup is the whole reason to have a file.
73#[derive(Debug, Default, Deserialize)]
74#[serde(deny_unknown_fields)]
75pub struct ConfigFile {
76    /// Store directory (`VELESDB_MEMORY_PATH`).
77    pub path: Option<String>,
78    /// Suppress the startup banner (`VELESDB_MEMORY_QUIET`).
79    pub quiet: Option<bool>,
80    /// Default TTL in seconds applied to facts with no explicit one
81    /// (`VELESDB_MEMORY_DEFAULT_TTL`).
82    pub default_ttl: Option<u64>,
83    /// HTTP transport settings.
84    #[serde(default)]
85    pub http: HttpConfig,
86    /// Embedding backend settings.
87    #[serde(default)]
88    pub embedder: EmbedderConfig,
89    /// Extraction backend settings.
90    #[serde(default)]
91    pub extractor: ExtractorConfig,
92    /// Context-compiler settings.
93    #[serde(default)]
94    pub context: ContextConfig,
95    /// Knowledge-graph settings.
96    #[serde(default)]
97    pub graph: GraphConfig,
98}
99
100/// `[graph]` — how much structure the memory builds on its own.
101#[derive(Debug, Default, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct GraphConfig {
104    /// Let every `remember` also wire the entities, typed edges and attributes
105    /// its text states (`VELESDB_MEMORY_AUTOGRAPH`).
106    ///
107    /// Off by default. It costs one generation per `remember`, so it is a
108    /// deliberate choice, not something to inherit silently — and it needs an
109    /// `[extractor]` backend to have anything to do.
110    pub autograph: Option<bool>,
111}
112
113/// `[http]` — the streamable-HTTP transport (multi-client daemon mode).
114#[derive(Debug, Default, Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct HttpConfig {
117    /// Serve over HTTP instead of stdio (`VELESDB_MEMORY_HTTP`).
118    pub enabled: Option<bool>,
119    /// Address to bind (`VELESDB_MEMORY_HTTP_BIND`).
120    pub bind: Option<String>,
121    /// Serve plaintext instead of TLS (`VELESDB_MEMORY_HTTP_INSECURE`).
122    pub insecure: Option<bool>,
123    /// Permit a non-loopback bind (`VELESDB_MEMORY_HTTP_ALLOW_REMOTE`).
124    pub allow_remote: Option<bool>,
125    /// Request body ceiling (`VELESDB_MEMORY_HTTP_MAX_BODY_BYTES`).
126    pub max_body_bytes: Option<u64>,
127    /// Concurrent session ceiling (`VELESDB_MEMORY_HTTP_MAX_SESSIONS`).
128    pub max_sessions: Option<u64>,
129    /// Directory holding the local CA and leaf certificate
130    /// (`VELESDB_MEMORY_TLS_DIR`).
131    pub tls_dir: Option<String>,
132}
133
134/// `[embedder]` — how text becomes vectors.
135///
136/// **No `api_token` field, deliberately** — see [`TOKEN_HINT`]. The two roles
137/// carry the same four settings under the same names on purpose: an operator
138/// who has configured one has configured the other.
139#[derive(Debug, Default, Deserialize)]
140#[serde(deny_unknown_fields)]
141pub struct EmbedderConfig {
142    /// `hash`, `ollama` or `openai` (`VELESDB_MEMORY_EMBEDDER`).
143    pub backend: Option<String>,
144    /// Embedding model (`VELESDB_MEMORY_EMBEDDER_MODEL`).
145    pub model: Option<String>,
146    /// Base URL, origin and port, no path (`VELESDB_MEMORY_EMBEDDER_URL`).
147    pub url: Option<String>,
148    /// How long Ollama keeps the model resident
149    /// (`VELESDB_MEMORY_OLLAMA_KEEP_ALIVE`).
150    ///
151    /// The one setting here that keeps a product name, and legitimately: it is
152    /// a field of Ollama's own wire protocol, not a role-level knob an
153    /// OpenAI-compatible server would know what to do with.
154    pub keep_alive: Option<String>,
155}
156
157/// `[extractor]` — the backend that reads facts, relations and attributes out
158/// of raw text for `remember_extracted`.
159///
160/// **No `api_token` field, deliberately** — see [`TOKEN_HINT`].
161#[derive(Debug, Default, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct ExtractorConfig {
164    /// `outline`, `ollama`, `openai`, or absent for none
165    /// (`VELESDB_MEMORY_EXTRACTOR`).
166    pub backend: Option<String>,
167    /// Generative model (`VELESDB_MEMORY_EXTRACTOR_MODEL`).
168    pub model: Option<String>,
169    /// Base URL, origin and port, no path (`VELESDB_MEMORY_EXTRACTOR_URL`).
170    pub url: Option<String>,
171}
172
173/// `[context]` — the deterministic context compiler.
174#[derive(Debug, Default, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct ContextConfig {
177    /// Directories `path`-referenced fragments may be read from
178    /// (`VELESDB_MEMORY_INGEST_ROOTS`). Written as a list here and joined
179    /// into the platform's `PATH` syntax, so the file stays readable and
180    /// portable where the raw variable is neither.
181    pub ingest_roots: Option<Vec<String>>,
182}
183
184/// Where the config file was found, and what it asked for.
185#[derive(Debug)]
186pub struct LoadedConfig {
187    /// The file that was read.
188    pub path: PathBuf,
189    /// The variables it defines, in `VELESDB_MEMORY_*` form.
190    pub values: BTreeMap<String, String>,
191}
192
193/// Resolve the config file path: an explicit `--config`, then
194/// [`CONFIG_PATH_VAR`], then `<store>/velesdb-memory.toml`, then
195/// `./velesdb-memory.toml`.
196///
197/// The store directory is checked before the working directory on purpose: a
198/// daemon's working directory is whatever launchd or systemd happened to give
199/// it, which is not a location an operator would think to put a file in.
200#[must_use]
201pub fn resolve_path(explicit: Option<&str>, store_dir: Option<&Path>) -> Option<PathBuf> {
202    if let Some(explicit) = explicit {
203        return Some(PathBuf::from(explicit));
204    }
205    if let Ok(from_env) = std::env::var(CONFIG_PATH_VAR) {
206        if !from_env.trim().is_empty() {
207            return Some(PathBuf::from(from_env));
208        }
209    }
210    if let Some(dir) = store_dir {
211        let candidate = dir.join(CONFIG_FILE_NAME);
212        if candidate.is_file() {
213            return Some(candidate);
214        }
215    }
216    let cwd = PathBuf::from(CONFIG_FILE_NAME);
217    cwd.is_file().then_some(cwd)
218}
219
220/// Read and parse `path` into the `VELESDB_MEMORY_*` variables it defines.
221///
222/// # Errors
223/// Returns [`ConfigError`] if the file cannot be read or is not valid TOML.
224pub fn load(path: &Path) -> Result<LoadedConfig, ConfigError> {
225    let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
226        path: path.to_path_buf(),
227        source,
228    })?;
229    let file: ConfigFile = toml::from_str(&text).map_err(|err| ConfigError::Parse {
230        path: path.to_path_buf(),
231        message: describe_parse_failure(&err.to_string()),
232    })?;
233    Ok(LoadedConfig {
234        path: path.to_path_buf(),
235        values: file.into_env(path)?,
236    })
237}
238
239/// Where an API token belongs, quoted verbatim by the refusal that rejects one
240/// found in the file.
241///
242/// `deny_unknown_fields` already refuses an `api_token` key, since no section
243/// declares one. What it cannot do is say where the operator should put the
244/// token they legitimately have — so the refusal is rewritten to carry this.
245pub const TOKEN_HINT: &str = "an API token is read from the environment only, never from a \
246     file: set VELESDB_MEMORY_EMBEDDER_API_TOKEN or \
247     VELESDB_MEMORY_EXTRACTOR_API_TOKEN instead. A credential in a TOML is one \
248     `git add .` away from a public history, and no pre-commit secret scan can \
249     police a file it has never seen.";
250
251/// Render a TOML parse failure, redacting it when it concerns a credential.
252///
253/// `toml`'s own error quotes the offending source line back — which is exactly
254/// the right thing for `mdoel = "bge-m3"` and exactly the wrong thing for
255/// `api_token = "sk-…"`: the daemon would print the secret to stderr, where a
256/// launch agent's log file keeps it. So a failure naming `api_token` loses the
257/// snippet and gains [`TOKEN_HINT`]; every other failure is untouched.
258fn describe_parse_failure(rendered: &str) -> String {
259    if rendered.contains("api_token") {
260        return format!("unknown field `api_token` — {TOKEN_HINT}");
261    }
262    rendered.to_owned()
263}
264
265/// One setting reachable under two environment-variable names: the canonical,
266/// role-named one and a legacy alias kept working for compatibility.
267///
268/// See [`resolve_alias`] for the precedence rule.
269pub struct AliasResolution {
270    /// The value to use, or `None` when neither name is set.
271    pub value: Option<String>,
272    /// Both names are set to **different** values. The caller is expected to
273    /// gather these and emit a single [`alias_conflict_notice`].
274    pub conflicting: bool,
275}
276
277/// Resolve a setting the caller can name two ways: canonical wins, the legacy
278/// alias is the fallback (C1).
279///
280/// The embedding role's URL and model were named after a *product*
281/// (`VELESDB_MEMORY_OLLAMA_URL`) while the extraction role's were named after
282/// the *role* (`VELESDB_MEMORY_EXTRACTOR_URL`). Once a non-Ollama backend can
283/// serve either role, a variable that says `OLLAMA` while pointing at oMLX is
284/// a lie the operator has to hold in their head. This closes the asymmetry
285/// without breaking a single existing setup: the alias keeps working, and only
286/// a genuine disagreement between the two is worth a word.
287///
288/// Canonical wins **whatever the source** — including a role-named value that
289/// came from the config file against a legacy one exported by the shell, which
290/// is the one case where this rule and [`apply`]'s "environment outranks the
291/// file" point different ways. That case is not silent: it is precisely what
292/// [`alias_conflict_notice`] reports.
293#[must_use]
294pub fn resolve_alias(canonical: Option<&str>, legacy: Option<&str>) -> AliasResolution {
295    AliasResolution {
296        conflicting: matches!((canonical, legacy), (Some(role), Some(old)) if role != old),
297        value: canonical.or(legacy).map(str::to_owned),
298    }
299}
300
301/// One line naming every variable whose legacy alias disagrees with it, or
302/// `None` when nothing disagrees.
303///
304/// **One notice, however many settings conflict.** A warning per variable is
305/// how a startup log becomes noise an operator learns to scroll past, and the
306/// operator's next action is the same for all of them. This is also
307/// deliberately not a deprecation warning: the aliases are supported, and
308/// shouting at someone whose setup works is how a message gets filtered out
309/// before the day it finally matters.
310#[must_use]
311pub fn alias_conflict_notice(conflicts: &[(&str, &str)]) -> Option<String> {
312    if conflicts.is_empty() {
313        return None;
314    }
315    let pairs = conflicts
316        .iter()
317        .map(|(canonical, legacy)| format!("{canonical} over {legacy}"))
318        .collect::<Vec<_>>()
319        .join(", ");
320    Some(format!(
321        "[velesdb-memory] set under two names with different values — using {pairs}. \
322         The role-named variable wins; unset the other to silence this."
323    ))
324}
325
326/// Export `values` into the process environment, skipping any variable that is
327/// already set. Returns the names actually applied, in order.
328///
329/// The skip is the precedence rule: the environment was set by whoever
330/// launched the process, and that intent outranks a file on disk.
331#[must_use]
332pub fn apply(values: &BTreeMap<String, String>) -> Vec<String> {
333    let mut applied = Vec::new();
334    for (key, value) in values {
335        if std::env::var_os(key).is_some() {
336            continue;
337        }
338        std::env::set_var(key, value);
339        applied.push(key.clone());
340    }
341    applied
342}
343
344impl ConfigFile {
345    /// Flatten the typed sections into the `VELESDB_MEMORY_*` variables the
346    /// rest of the binary already reads.
347    fn into_env(self, path: &Path) -> Result<BTreeMap<String, String>, ConfigError> {
348        let mut out = BTreeMap::new();
349        let mut set = |key: &str, value: Option<String>| {
350            if let Some(value) = value {
351                out.insert(key.to_string(), value);
352            }
353        };
354        set("VELESDB_MEMORY_PATH", self.path);
355        set("VELESDB_MEMORY_QUIET", self.quiet.map(flag));
356        set(
357            "VELESDB_MEMORY_DEFAULT_TTL",
358            self.default_ttl.map(|v| v.to_string()),
359        );
360
361        set("VELESDB_MEMORY_HTTP", self.http.enabled.map(flag));
362        set("VELESDB_MEMORY_HTTP_BIND", self.http.bind);
363        set("VELESDB_MEMORY_HTTP_INSECURE", self.http.insecure.map(flag));
364        set(
365            "VELESDB_MEMORY_HTTP_ALLOW_REMOTE",
366            self.http.allow_remote.map(flag),
367        );
368        set(
369            "VELESDB_MEMORY_HTTP_MAX_BODY_BYTES",
370            self.http.max_body_bytes.map(|v| v.to_string()),
371        );
372        set(
373            "VELESDB_MEMORY_HTTP_MAX_SESSIONS",
374            self.http.max_sessions.map(|v| v.to_string()),
375        );
376        set("VELESDB_MEMORY_TLS_DIR", self.http.tls_dir);
377
378        set("VELESDB_MEMORY_EMBEDDER", self.embedder.backend);
379        // The role-named variables, not the `VELESDB_MEMORY_OLLAMA_*` aliases:
380        // the section is named after the role, so what it writes should be too.
381        // The aliases stay readable from the environment (see
382        // [`resolve_alias`]) for setups that already export them.
383        set("VELESDB_MEMORY_EMBEDDER_MODEL", self.embedder.model);
384        set("VELESDB_MEMORY_EMBEDDER_URL", self.embedder.url);
385        set("VELESDB_MEMORY_OLLAMA_KEEP_ALIVE", self.embedder.keep_alive);
386
387        set("VELESDB_MEMORY_EXTRACTOR", self.extractor.backend);
388        set("VELESDB_MEMORY_EXTRACTOR_MODEL", self.extractor.model);
389        set("VELESDB_MEMORY_EXTRACTOR_URL", self.extractor.url);
390
391        set("VELESDB_MEMORY_AUTOGRAPH", self.graph.autograph.map(flag));
392
393        if let Some(roots) = self.context.ingest_roots {
394            let joined = std::env::join_paths(roots).map_err(|_| ConfigError::PathList {
395                path: path.to_path_buf(),
396                field: "context.ingest_roots",
397            })?;
398            out.insert(
399                "VELESDB_MEMORY_INGEST_ROOTS".to_string(),
400                joined.to_string_lossy().into_owned(),
401            );
402        }
403        Ok(out)
404    }
405}
406
407/// Render a boolean the way every reader in the binary tests for it: the
408/// truthy form is the exact string `"1"`. `false` becomes `"0"` rather than
409/// being omitted, so writing `enabled = false` in the file genuinely holds the
410/// setting off instead of falling through to a default that might be on.
411fn flag(value: bool) -> String {
412    if value { "1" } else { "0" }.to_string()
413}
414
415#[cfg(test)]
416#[path = "config_tests.rs"]
417mod tests;