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: "Hard limit on open workspace databases; an inactive least-recently-used entry is closed, all-active returns Busy",
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: "enabled",
447        value_type: "boolean",
448        default: "automatic",
449        description: "Enable or disable creation and use of the configured OpenAI-compatible embedder",
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: "space_id",
471        value_type: "string",
472        default: "model",
473        description: "Stable semantic-space identity; change it only for incompatible vectors and reembed explicitly",
474        scope: SettingScope::Shared,
475    },
476    SettingDoc {
477        section: "embedder",
478        key: "api_key_env",
479        value_type: "string",
480        default: "unset",
481        description: "Environment variable containing the bearer token",
482        scope: SettingScope::Shared,
483    },
484    SettingDoc {
485        section: "maintenance",
486        key: "snapshot_every_ops",
487        value_type: "non-negative integer",
488        default: "1024",
489        description: "Snapshot after this many mutations",
490        scope: SettingScope::Shared,
491    },
492    SettingDoc {
493        section: "maintenance",
494        key: "snapshot_journal_bytes",
495        value_type: "non-negative integer",
496        default: "4194304",
497        description: "Snapshot when the journal reaches this size",
498        scope: SettingScope::Shared,
499    },
500    SettingDoc {
501        section: "maintenance",
502        key: "maintain_every_forgets",
503        value_type: "non-negative integer",
504        default: "off",
505        description: "Run policy maintenance after this many forgets",
506        scope: SettingScope::Shared,
507    },
508    SettingDoc {
509        section: "maintenance",
510        key: "fsync",
511        value_type: "\"each_op\" | \"on_snapshot\"",
512        default: "each_op",
513        description: "When journal appends reach the disk. \"each_op\": every acknowledged write \
514survives a power cut. \"on_snapshot\": faster, an OS crash may lose the journal tail since the \
515last snapshot",
516        scope: SettingScope::Shared,
517    },
518    SettingDoc {
519        section: "maintenance",
520        key: "batch_size",
521        value_type: "positive integer",
522        default: "128",
523        description: "CLI import facts per embedding request and journal fsync",
524        scope: SettingScope::Cli,
525    },
526    SettingDoc {
527        section: "server",
528        key: "workers",
529        value_type: "positive integer",
530        default: "half of available cores",
531        description: "MCP worker threads",
532        scope: SettingScope::Mcp,
533    },
534];
535
536static SETTINGS_HELP: SettingsHelp = SettingsHelp {
537    docs: DOCS,
538    config_path_precedence: CONFIG_PATH_PRECEDENCE,
539};
540
541/// Returns the shared settings catalogue used by host, CLI, MCP and NAPI.
542pub const fn settings_help() -> &'static SettingsHelp {
543    &SETTINGS_HELP
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    #[test]
551    fn edit_distance_holds_at_the_degenerate_ends() {
552        // The empty string against anything is that thing's length, from both
553        // sides: with `a` empty the inner loop never runs and the seeded first
554        // row is the answer; with `b` empty the table is one column wide and
555        // only `row[0]` ever moves. Both are easy to get wrong by one.
556        assert_eq!(edit_distance("", ""), 0);
557        assert_eq!(edit_distance("", "dim"), 3);
558        assert_eq!(edit_distance("dim", ""), 3);
559
560        // One character each way, same and different.
561        assert_eq!(edit_distance("a", "a"), 0);
562        assert_eq!(edit_distance("a", "b"), 1);
563        assert_eq!(edit_distance("a", ""), 1);
564        assert_eq!(edit_distance(" ", ""), 1);
565        assert_eq!(edit_distance(" ", "a"), 1);
566
567        // The three edits, each in isolation.
568        assert_eq!(edit_distance("dim", "dm"), 1, "deletion");
569        assert_eq!(edit_distance("dim", "diim"), 1, "insertion");
570        assert_eq!(edit_distance("dim", "dir"), 1, "substitution");
571
572        // Counted in characters, not bytes: a multi-byte name must not read as
573        // several edits away from itself.
574        assert_eq!(edit_distance("ключ", "ключ"), 0);
575        assert_eq!(edit_distance("ключ", "клуч"), 1);
576        assert_eq!(edit_distance("ключ", ""), 4);
577
578        // Symmetric, which a two-row implementation can quietly break.
579        for (a, b) in [("dim", "max_text"), ("", "fsync"), ("a", "workers")] {
580            assert_eq!(edit_distance(a, b), edit_distance(b, a), "{a} vs {b}");
581        }
582    }
583
584    #[test]
585    fn a_suggestion_is_offered_only_when_it_is_worth_offering() {
586        let engine = || settings_help().keys_in("engine");
587
588        // Close enough to be the obvious intent.
589        assert_eq!(nearest("dm", engine()), Some("dim"));
590        assert_eq!(nearest("max_txt", engine()), Some("max_text"));
591
592        // The one the budget exists for: three edits on an eight-character
593        // name, and the likeliest typo in the catalogue.
594        let recall = || settings_help().keys_in("recall");
595        assert_eq!(nearest("w_vector", recall()), Some("w_vec"));
596        assert_eq!(nearest("similar_cosine", recall()), Some("similar_cos"));
597
598        // A truncation is not chased. `half_life` is five edits from
599        // `half_life_days`, and widening the budget far enough to reach it
600        // would start matching keys that share a prefix and nothing else.
601        // The warning still names the key; only the guess is withheld.
602        assert_eq!(nearest("half_life", recall()), None);
603
604        // Not close to anything: silence beats sending someone to the wrong
605        // line. A single character is the sharpest case — the budget floors at
606        // one edit, so it must not reach a three-character key.
607        assert_eq!(nearest("a", engine()), None);
608        assert_eq!(nearest("", engine()), None);
609        assert_eq!(nearest(" ", engine()), None);
610        assert_eq!(nearest("completely_unrelated", engine()), None);
611    }
612
613    /// A config table from lines, so the fixtures indent with the code instead
614    /// of being pinned to the file's left margin.
615    fn toml_of(lines: &[&str]) -> toml::Table {
616        lines.join("\n").parse().expect("valid TOML fixture")
617    }
618
619    #[test]
620    fn unknown_sections_and_keys_are_reported_with_their_context() {
621        let table = toml_of(&[
622            "[engine]",
623            "dim = 8",
624            "max_txt = 10",
625            "",
626            "[embedder]",
627            "enabled = false",
628            "",
629            "[engin]",
630            "dim = 4",
631        ]);
632
633        let found = settings_help().unknown_in(&table);
634        // A misspelled key inside a real section, and a misspelled section.
635        assert_eq!(
636            found,
637            vec![
638                SettingWarning {
639                    section: String::new(),
640                    key: "engin".to_string(),
641                    did_you_mean: Some("engine"),
642                },
643                SettingWarning {
644                    section: "engine".to_string(),
645                    key: "max_txt".to_string(),
646                    did_you_mean: Some("max_text"),
647                },
648            ]
649        );
650        assert!(
651            found[0]
652                .to_string()
653                .contains("unknown config section [engin]")
654        );
655        assert!(found[1].to_string().contains("[engine].max_txt"));
656    }
657
658    #[test]
659    fn keys_a_wrapper_owns_are_not_warned_about() {
660        // The reason the catalogue is the authority and the parser's own lists
661        // are not: host parses neither of these, and warning about them would
662        // fire on every CLI and MCP run.
663        let table = toml_of(&[
664            "[maintenance]",
665            "batch_size = 256",
666            "",
667            "[server]",
668            "workers = 4",
669        ]);
670        assert_eq!(settings_help().unknown_in(&table), vec![]);
671    }
672
673    #[test]
674    fn a_clean_config_warns_about_nothing() {
675        let mut text = String::new();
676        let mut section = "";
677        for doc in DOCS {
678            if doc.section != section {
679                let _ = writeln!(text, "[{}]", doc.section);
680                section = doc.section;
681            }
682            // The value is irrelevant here: this scan checks names, and the
683            // parser owns types. Every documented key must pass it.
684            let _ = writeln!(text, "{} = 0", doc.key);
685        }
686        let table: toml::Table = text.parse().unwrap();
687        assert_eq!(
688            settings_help().unknown_in(&table),
689            vec![],
690            "the catalogue must accept everything it documents"
691        );
692    }
693
694    #[test]
695    fn every_documented_setting_has_a_complete_description() {
696        assert!(!DOCS.is_empty());
697        for doc in DOCS {
698            assert!(!doc.section.is_empty());
699            assert!(!doc.key.is_empty());
700            assert!(!doc.value_type.is_empty());
701            assert!(!doc.default.is_empty());
702            assert!(!doc.description.is_empty());
703        }
704    }
705
706    #[test]
707    fn human_help_contains_every_documented_key() {
708        let rendered = settings_help().render_human();
709        for doc in DOCS {
710            assert!(
711                rendered.contains(doc.key),
712                "missing {}.{}",
713                doc.section,
714                doc.key
715            );
716        }
717    }
718}