Skip to main content

plugmem_host/
settings_help.rs

1//! The single source of truth for config.toml help.
2//!
3//! The parser lives in [`super::settings`], while the CLI, the MCP server and
4//! the Node and Python bindings are separate surfaces. Keeping the public
5//! setting catalogue here lets those surfaces render their own help without
6//! copying descriptions or defaults.
7
8use std::fmt::Write as _;
9
10const PLATFORM_DEFAULT_SOURCE: &str = "platform default config path";
11
12/// Something in `config.toml` that was read and then ignored.
13///
14/// A warning rather than an error, deliberately: refusing an unknown key would
15/// mean an older binary could not read a config written for a newer one, which
16/// is a worse failure than a typo. But *silence* is worse than both — a
17/// misspelled `w_vec` changes no behaviour and says nothing, and the user is
18/// left believing they tuned something.
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct SettingWarning {
21    /// The TOML section it appeared in, without brackets. Empty for an unknown
22    /// *section*, where [`Self::key`] is the section's own name.
23    pub section: String,
24    /// The key (or section) nobody claimed.
25    pub key: String,
26    /// The closest known name, when one is close enough to be worth offering.
27    pub did_you_mean: Option<&'static str>,
28}
29
30impl std::fmt::Display for SettingWarning {
31    /// One line, ready for a stderr note or a log.
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        if self.section.is_empty() {
34            write!(f, "unknown config section [{}]", self.key)?;
35        } else {
36            write!(f, "unknown setting [{}].{}", self.section, self.key)?;
37        }
38        match self.did_you_mean {
39            Some(near) => write!(f, " — did you mean `{near}`?"),
40            None => write!(f, " (ignored)"),
41        }
42    }
43}
44
45/// Which runtime surface owns a setting.
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum SettingScope {
48    /// Parsed by `plugmem-host` and shared by every wrapper.
49    Shared,
50    /// Read by `plugmem-cli` in addition to the shared settings.
51    Cli,
52    /// Read by `plugmem-mcp` in addition to the shared settings.
53    Mcp,
54}
55
56impl SettingScope {
57    /// The stable, user-facing scope label.
58    pub const fn as_str(self) -> &'static str {
59        match self {
60            Self::Shared => "shared",
61            Self::Cli => "CLI",
62            Self::Mcp => "MCP",
63        }
64    }
65}
66
67/// Documentation for one supported config.toml key.
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub struct SettingDoc {
70    /// TOML section, without brackets.
71    pub section: &'static str,
72    /// TOML key inside [`Self::section`].
73    pub key: &'static str,
74    /// Human-readable value type.
75    pub value_type: &'static str,
76    /// Default as displayed to users.
77    pub default: &'static str,
78    /// What the setting controls.
79    pub description: &'static str,
80    /// The wrapper(s) that consume the setting.
81    pub scope: SettingScope,
82}
83
84/// Runtime access to the complete config.toml help catalogue.
85#[derive(Clone, Copy, Debug)]
86pub struct SettingsHelp {
87    docs: &'static [SettingDoc],
88    config_path_precedence: &'static [&'static str],
89}
90
91impl SettingsHelp {
92    /// Every documented config.toml key.
93    pub const fn docs(self) -> &'static [SettingDoc] {
94        self.docs
95    }
96
97    /// Config-file discovery order, from highest to lowest precedence.
98    pub const fn config_path_precedence(self) -> &'static [&'static str] {
99        self.config_path_precedence
100    }
101
102    /// Every section this catalogue knows, in first-appearance order.
103    ///
104    /// Borrowed `&'static str`s from the catalogue itself: no allocation, and
105    /// there are five of them, so a linear scan beats any index.
106    fn sections(self) -> impl Iterator<Item = &'static str> {
107        self.docs
108            .iter()
109            .enumerate()
110            .filter(|(i, doc)| *i == 0 || self.docs[i - 1].section != doc.section)
111            .map(|(_, doc)| doc.section)
112    }
113
114    /// The keys documented under `section`.
115    fn keys_in(self, section: &str) -> impl Iterator<Item = &'static str> {
116        self.docs
117            .iter()
118            .filter(move |doc| doc.section == section)
119            .map(|doc| doc.key)
120    }
121
122    /// Reports every section and key in `table` that no surface claims.
123    ///
124    /// The catalogue is the authority rather than the parser's own key lists,
125    /// and it has to be: `[maintenance].batch_size` belongs to the CLI and
126    /// `[server].workers` to the MCP server, so a check that only knew what the
127    /// shared loader parses would warn about both on every run.
128    ///
129    /// Allocation-free in the ordinary case — a clean config returns an empty
130    /// `Vec`, which allocates nothing. Only a real mistake costs anything.
131    pub fn unknown_in(self, table: &toml::Table) -> Vec<SettingWarning> {
132        let mut out = Vec::new();
133        for (name, value) in table {
134            let Some(section) = self.sections().find(|s| s == name) else {
135                out.push(SettingWarning {
136                    section: String::new(),
137                    key: name.clone(),
138                    did_you_mean: nearest(name, self.sections()),
139                });
140                continue;
141            };
142            // A section given as something other than a table is the parser's
143            // business to reject, not this scan's.
144            let Some(entries) = value.as_table() else {
145                continue;
146            };
147            for key in entries.keys() {
148                if self.keys_in(section).any(|k| k == key) {
149                    continue;
150                }
151                out.push(SettingWarning {
152                    section: section.to_string(),
153                    key: key.clone(),
154                    did_you_mean: nearest(key, self.keys_in(section)),
155                });
156            }
157        }
158        out
159    }
160
161    /// Render the catalogue for a terminal or a human-facing tool response.
162    pub fn render_human(self) -> String {
163        let mut output = String::from("plugmem settings\n\n");
164        output.push_str("Config file precedence:\n");
165        for (index, source) in self.config_path_precedence.iter().enumerate() {
166            if *source == PLATFORM_DEFAULT_SOURCE {
167                match crate::default_config_path() {
168                    Some(path) => {
169                        let _ = writeln!(output, "  {}. {}", index + 1, path.display());
170                    }
171                    None => {
172                        let _ = writeln!(output, "  {}. {source} (unavailable)", index + 1);
173                    }
174                }
175            } else {
176                let _ = writeln!(output, "  {}. {source}", index + 1);
177            }
178        }
179        output.push('\n');
180
181        let mut section = None;
182        for doc in self.docs {
183            if section != Some(doc.section) {
184                if section.is_some() {
185                    output.push('\n');
186                }
187                let _ = writeln!(output, "[{}]", doc.section);
188                section = Some(doc.section);
189            }
190            let _ = writeln!(
191                output,
192                "  {} ({}, default: {}) — {} [{}]",
193                doc.key,
194                doc.value_type,
195                doc.default,
196                doc.description,
197                doc.scope.as_str()
198            );
199        }
200
201        output
202    }
203}
204
205/// The closest candidate to `typo`, if one is close enough to suggest.
206///
207/// One edit always, plus one per four characters: long names tolerate a bigger
208/// slip than short ones, and `dim` never gets confused with `url`. Offering a
209/// wrong guess is worse than offering none — it sends the reader to fix the
210/// wrong line.
211///
212/// The scale is set by the mistakes people actually make. `w_vector` for
213/// `w_vec` is three edits on an eight-character name: the likeliest typo in the
214/// whole catalogue, since the field is a vector weight and nobody abbreviates
215/// on the first try. A tighter budget looks principled and misses it.
216fn nearest(typo: &str, candidates: impl Iterator<Item = &'static str>) -> Option<&'static str> {
217    let budget = 1 + typo.chars().count() / 4;
218    candidates
219        .map(|c| (edit_distance(typo, c), c))
220        .filter(|(d, _)| *d <= budget)
221        .min_by_key(|(d, _)| *d)
222        .map(|(_, c)| c)
223}
224
225/// Levenshtein distance over `char`s, two rows at a time.
226///
227/// Two `Vec<usize>` the width of the shorter name — setting names are a handful
228/// of characters, and this runs only when something is already wrong.
229fn edit_distance(a: &str, b: &str) -> usize {
230    let b: Vec<char> = b.chars().collect();
231    let mut prev: Vec<usize> = (0..=b.len()).collect();
232    let mut row = vec![0; b.len() + 1];
233    for (i, ca) in a.chars().enumerate() {
234        row[0] = i + 1;
235        for (j, cb) in b.iter().enumerate() {
236            let cost = usize::from(ca != *cb);
237            row[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(row[j] + 1);
238        }
239        core::mem::swap(&mut prev, &mut row);
240    }
241    prev[b.len()]
242}
243
244const CONFIG_PATH_PRECEDENCE: &[&str] = &[
245    "--config PATH",
246    "$PLUGMEM_CONFIG",
247    "platform default config path",
248    "built-in defaults",
249];
250
251const DOCS: &[SettingDoc] = &[
252    SettingDoc {
253        section: "database",
254        key: "path",
255        value_type: "path string",
256        default: "platform data directory/memory.plugmem",
257        description: "Persistent database file; an explicit --db or open path and PLUGMEM_DB override it",
258        scope: SettingScope::Shared,
259    },
260    SettingDoc {
261        section: "workspace",
262        key: "dir",
263        value_type: "path string",
264        default: "unset (one database, no workspace)",
265        description: "Directory of named databases; unset means the single-database default",
266        scope: SettingScope::Shared,
267    },
268    SettingDoc {
269        section: "workspace",
270        key: "max_open",
271        value_type: "positive integer",
272        default: "16",
273        description: "Workspace databases kept open at once; the least recently used is closed",
274        scope: SettingScope::Shared,
275    },
276    SettingDoc {
277        section: "workspace",
278        key: "idle_timeout_ms",
279        value_type: "non-negative integer",
280        default: "60000",
281        description: "Close a workspace database unused this long, releasing its lock; 0 never closes",
282        scope: SettingScope::Shared,
283    },
284    SettingDoc {
285        section: "engine",
286        key: "dim",
287        value_type: "non-negative integer",
288        default: "0",
289        description: "Embedding dimension; 0 disables vector storage",
290        scope: SettingScope::Shared,
291    },
292    SettingDoc {
293        section: "engine",
294        key: "max_bytes",
295        value_type: "non-negative integer",
296        default: "2147483648",
297        description: "Ceiling applied to each byte pool separately, not to their sum",
298        scope: SettingScope::Shared,
299    },
300    SettingDoc {
301        section: "engine",
302        key: "max_text",
303        value_type: "non-negative integer",
304        default: "4096",
305        description: "Maximum fact text length in bytes",
306        scope: SettingScope::Shared,
307    },
308    SettingDoc {
309        section: "engine",
310        key: "max_blob",
311        value_type: "non-negative integer",
312        default: "65536",
313        description: "Maximum single blob length in bytes",
314        scope: SettingScope::Shared,
315    },
316    SettingDoc {
317        section: "recall",
318        key: "bm25_k1",
319        value_type: "number > 0",
320        default: "1.2",
321        description: "BM25 term-frequency saturation: higher lets a repeated word keep counting",
322        scope: SettingScope::Shared,
323    },
324    SettingDoc {
325        section: "recall",
326        key: "bm25_b",
327        value_type: "number in [0, 1]",
328        default: "0.75",
329        description: "BM25 length normalisation: 0 ignores fact length, 1 penalises long facts fully",
330        scope: SettingScope::Shared,
331    },
332    SettingDoc {
333        section: "recall",
334        key: "rrf_k",
335        value_type: "integer >= 1",
336        default: "60",
337        description: "Reciprocal-rank-fusion constant: larger flattens the gap between rank 1 and rank 10",
338        scope: SettingScope::Shared,
339    },
340    SettingDoc {
341        section: "recall",
342        key: "w_bm25",
343        value_type: "number >= 0",
344        default: "1.0",
345        description: "Weight of the lexical source in the fused score; 0 switches it off",
346        scope: SettingScope::Shared,
347    },
348    SettingDoc {
349        section: "recall",
350        key: "w_vec",
351        value_type: "number >= 0",
352        default: "1.0",
353        description: "Weight of the vector source; 0 switches it off (and costs nothing when dim = 0)",
354        scope: SettingScope::Shared,
355    },
356    SettingDoc {
357        section: "recall",
358        key: "w_graph",
359        value_type: "number >= 0",
360        default: "1.0",
361        description: "Weight of the entity-graph source; 0 switches off relational expansion",
362        scope: SettingScope::Shared,
363    },
364    SettingDoc {
365        section: "recall",
366        key: "w_time",
367        value_type: "number >= 0",
368        default: "1.0",
369        description: "Weight of the temporal source (the recorded_at window); 0 switches it off",
370        scope: SettingScope::Shared,
371    },
372    SettingDoc {
373        section: "recall",
374        key: "w_recency",
375        value_type: "number >= 0",
376        default: "0.25",
377        description: "How much a fact's age discounts it, on top of the sources above",
378        scope: SettingScope::Shared,
379    },
380    SettingDoc {
381        section: "recall",
382        key: "half_life_days",
383        value_type: "integer >= 1",
384        default: "180",
385        description: "Age at which the recency discount has halved; larger keeps old facts competitive",
386        scope: SettingScope::Shared,
387    },
388    SettingDoc {
389        section: "recall",
390        key: "graph_depth",
391        value_type: "non-negative integer",
392        default: "2",
393        description: "Default hops the graph source may follow from an anchor entity; a recall's own `graph_depth` overrides it. Uncapped — the walk is bounded by its entity and edge caps, not by depth",
394        scope: SettingScope::Shared,
395    },
396    SettingDoc {
397        section: "recall",
398        key: "graph_decay",
399        value_type: "number in (0, 1]",
400        default: "0.5",
401        description: "How much each extra hop discounts a fact reached through the graph",
402        scope: SettingScope::Shared,
403    },
404    SettingDoc {
405        section: "recall",
406        key: "hnsw_ef_search",
407        value_type: "integer >= 1",
408        default: "64",
409        description: "Default HNSW beam width; higher is more accurate and slower. A recall's own `ef` overrides it, and it does nothing while the index is still flat",
410        scope: SettingScope::Shared,
411    },
412    SettingDoc {
413        section: "recall",
414        key: "similar_cos",
415        value_type: "number in [0, 1]",
416        default: "0.85",
417        description: "Cosine above which remember reports an existing fact as possibly conflicting (it never revises on its own)",
418        scope: SettingScope::Shared,
419    },
420    SettingDoc {
421        section: "recall",
422        key: "similar_jaccard",
423        value_type: "number in [0, 1]",
424        default: "0.5",
425        description: "Token overlap above which remember reports a possible conflict, for memories with no vectors",
426        scope: SettingScope::Shared,
427    },
428    SettingDoc {
429        section: "index",
430        key: "hnsw_ef_construction",
431        value_type: "integer >= hnsw_m (16 by default)",
432        default: "200",
433        description: "Beam width while building the vector graph: higher builds a better index, slower",
434        scope: SettingScope::Shared,
435    },
436    SettingDoc {
437        section: "index",
438        key: "flat_to_hnsw",
439        value_type: "integer >= 1",
440        default: "24000",
441        description: "Vector count at which maintenance stops scanning flat and builds the HNSW graph",
442        scope: SettingScope::Shared,
443    },
444    SettingDoc {
445        section: "embedder",
446        key: "kind",
447        value_type: "string",
448        default: "none",
449        description: "Embedding provider: none, ollama, openai, lmstudio, vllm or llamacpp",
450        scope: SettingScope::Shared,
451    },
452    SettingDoc {
453        section: "embedder",
454        key: "url",
455        value_type: "string",
456        default: "unset",
457        description: "OpenAI-compatible /v1/embeddings endpoint",
458        scope: SettingScope::Shared,
459    },
460    SettingDoc {
461        section: "embedder",
462        key: "model",
463        value_type: "string",
464        default: "unset",
465        description: "Embedding model name",
466        scope: SettingScope::Shared,
467    },
468    SettingDoc {
469        section: "embedder",
470        key: "api_key_env",
471        value_type: "string",
472        default: "unset",
473        description: "Environment variable containing the bearer token",
474        scope: SettingScope::Shared,
475    },
476    SettingDoc {
477        section: "maintenance",
478        key: "snapshot_every_ops",
479        value_type: "non-negative integer",
480        default: "1024",
481        description: "Snapshot after this many mutations",
482        scope: SettingScope::Shared,
483    },
484    SettingDoc {
485        section: "maintenance",
486        key: "snapshot_journal_bytes",
487        value_type: "non-negative integer",
488        default: "4194304",
489        description: "Snapshot when the journal reaches this size",
490        scope: SettingScope::Shared,
491    },
492    SettingDoc {
493        section: "maintenance",
494        key: "maintain_every_forgets",
495        value_type: "non-negative integer",
496        default: "off",
497        description: "Run policy maintenance after this many forgets",
498        scope: SettingScope::Shared,
499    },
500    SettingDoc {
501        section: "maintenance",
502        key: "fsync",
503        value_type: "\"each_op\" | \"on_snapshot\"",
504        default: "each_op",
505        description: "When journal appends reach the disk. \"each_op\": every acknowledged write \
506survives a power cut. \"on_snapshot\": faster, an OS crash may lose the journal tail since the \
507last snapshot",
508        scope: SettingScope::Shared,
509    },
510    SettingDoc {
511        section: "maintenance",
512        key: "batch_size",
513        value_type: "positive integer",
514        default: "128",
515        description: "CLI import facts per embedding request and journal fsync",
516        scope: SettingScope::Cli,
517    },
518    SettingDoc {
519        section: "server",
520        key: "workers",
521        value_type: "positive integer",
522        default: "half of available cores",
523        description: "MCP worker threads",
524        scope: SettingScope::Mcp,
525    },
526];
527
528static SETTINGS_HELP: SettingsHelp = SettingsHelp {
529    docs: DOCS,
530    config_path_precedence: CONFIG_PATH_PRECEDENCE,
531};
532
533/// Returns the shared settings catalogue used by host, CLI, MCP and NAPI.
534pub const fn settings_help() -> &'static SettingsHelp {
535    &SETTINGS_HELP
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    #[test]
543    fn edit_distance_holds_at_the_degenerate_ends() {
544        // The empty string against anything is that thing's length, from both
545        // sides: with `a` empty the inner loop never runs and the seeded first
546        // row is the answer; with `b` empty the table is one column wide and
547        // only `row[0]` ever moves. Both are easy to get wrong by one.
548        assert_eq!(edit_distance("", ""), 0);
549        assert_eq!(edit_distance("", "dim"), 3);
550        assert_eq!(edit_distance("dim", ""), 3);
551
552        // One character each way, same and different.
553        assert_eq!(edit_distance("a", "a"), 0);
554        assert_eq!(edit_distance("a", "b"), 1);
555        assert_eq!(edit_distance("a", ""), 1);
556        assert_eq!(edit_distance(" ", ""), 1);
557        assert_eq!(edit_distance(" ", "a"), 1);
558
559        // The three edits, each in isolation.
560        assert_eq!(edit_distance("dim", "dm"), 1, "deletion");
561        assert_eq!(edit_distance("dim", "diim"), 1, "insertion");
562        assert_eq!(edit_distance("dim", "dir"), 1, "substitution");
563
564        // Counted in characters, not bytes: a multi-byte name must not read as
565        // several edits away from itself.
566        assert_eq!(edit_distance("ключ", "ключ"), 0);
567        assert_eq!(edit_distance("ключ", "клуч"), 1);
568        assert_eq!(edit_distance("ключ", ""), 4);
569
570        // Symmetric, which a two-row implementation can quietly break.
571        for (a, b) in [("dim", "max_text"), ("", "fsync"), ("a", "workers")] {
572            assert_eq!(edit_distance(a, b), edit_distance(b, a), "{a} vs {b}");
573        }
574    }
575
576    #[test]
577    fn a_suggestion_is_offered_only_when_it_is_worth_offering() {
578        let engine = || settings_help().keys_in("engine");
579
580        // Close enough to be the obvious intent.
581        assert_eq!(nearest("dm", engine()), Some("dim"));
582        assert_eq!(nearest("max_txt", engine()), Some("max_text"));
583
584        // The one the budget exists for: three edits on an eight-character
585        // name, and the likeliest typo in the catalogue.
586        let recall = || settings_help().keys_in("recall");
587        assert_eq!(nearest("w_vector", recall()), Some("w_vec"));
588        assert_eq!(nearest("similar_cosine", recall()), Some("similar_cos"));
589
590        // A truncation is not chased. `half_life` is five edits from
591        // `half_life_days`, and widening the budget far enough to reach it
592        // would start matching keys that share a prefix and nothing else.
593        // The warning still names the key; only the guess is withheld.
594        assert_eq!(nearest("half_life", recall()), None);
595
596        // Not close to anything: silence beats sending someone to the wrong
597        // line. A single character is the sharpest case — the budget floors at
598        // one edit, so it must not reach a three-character key.
599        assert_eq!(nearest("a", engine()), None);
600        assert_eq!(nearest("", engine()), None);
601        assert_eq!(nearest(" ", engine()), None);
602        assert_eq!(nearest("completely_unrelated", engine()), None);
603    }
604
605    /// A config table from lines, so the fixtures indent with the code instead
606    /// of being pinned to the file's left margin.
607    fn toml_of(lines: &[&str]) -> toml::Table {
608        lines.join("\n").parse().expect("valid TOML fixture")
609    }
610
611    #[test]
612    fn unknown_sections_and_keys_are_reported_with_their_context() {
613        let table = toml_of(&[
614            "[engine]",
615            "dim = 8",
616            "max_txt = 10",
617            "",
618            "[embedder]",
619            r#"kind = "none""#,
620            "",
621            "[engin]",
622            "dim = 4",
623        ]);
624
625        let found = settings_help().unknown_in(&table);
626        // A misspelled key inside a real section, and a misspelled section.
627        assert_eq!(
628            found,
629            vec![
630                SettingWarning {
631                    section: String::new(),
632                    key: "engin".to_string(),
633                    did_you_mean: Some("engine"),
634                },
635                SettingWarning {
636                    section: "engine".to_string(),
637                    key: "max_txt".to_string(),
638                    did_you_mean: Some("max_text"),
639                },
640            ]
641        );
642        assert!(
643            found[0]
644                .to_string()
645                .contains("unknown config section [engin]")
646        );
647        assert!(found[1].to_string().contains("[engine].max_txt"));
648    }
649
650    #[test]
651    fn keys_a_wrapper_owns_are_not_warned_about() {
652        // The reason the catalogue is the authority and the parser's own lists
653        // are not: host parses neither of these, and warning about them would
654        // fire on every CLI and MCP run.
655        let table = toml_of(&[
656            "[maintenance]",
657            "batch_size = 256",
658            "",
659            "[server]",
660            "workers = 4",
661        ]);
662        assert_eq!(settings_help().unknown_in(&table), vec![]);
663    }
664
665    #[test]
666    fn a_clean_config_warns_about_nothing() {
667        let mut text = String::new();
668        let mut section = "";
669        for doc in DOCS {
670            if doc.section != section {
671                let _ = writeln!(text, "[{}]", doc.section);
672                section = doc.section;
673            }
674            // The value is irrelevant here: this scan checks names, and the
675            // parser owns types. Every documented key must pass it.
676            let _ = writeln!(text, "{} = 0", doc.key);
677        }
678        let table: toml::Table = text.parse().unwrap();
679        assert_eq!(
680            settings_help().unknown_in(&table),
681            vec![],
682            "the catalogue must accept everything it documents"
683        );
684    }
685
686    #[test]
687    fn every_documented_setting_has_a_complete_description() {
688        assert!(!DOCS.is_empty());
689        for doc in DOCS {
690            assert!(!doc.section.is_empty());
691            assert!(!doc.key.is_empty());
692            assert!(!doc.value_type.is_empty());
693            assert!(!doc.default.is_empty());
694            assert!(!doc.description.is_empty());
695        }
696    }
697
698    #[test]
699    fn human_help_contains_every_documented_key() {
700        let rendered = settings_help().render_human();
701        for doc in DOCS {
702            assert!(
703                rendered.contains(doc.key),
704                "missing {}.{}",
705                doc.section,
706                doc.key
707            );
708        }
709    }
710}