use std::fmt::Write as _;
const PLATFORM_DEFAULT_SOURCE: &str = "platform default config path";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SettingWarning {
pub section: String,
pub key: String,
pub did_you_mean: Option<&'static str>,
}
impl std::fmt::Display for SettingWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.section.is_empty() {
write!(f, "unknown config section [{}]", self.key)?;
} else {
write!(f, "unknown setting [{}].{}", self.section, self.key)?;
}
match self.did_you_mean {
Some(near) => write!(f, " — did you mean `{near}`?"),
None => write!(f, " (ignored)"),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SettingScope {
Shared,
Cli,
Mcp,
}
impl SettingScope {
pub const fn as_str(self) -> &'static str {
match self {
Self::Shared => "shared",
Self::Cli => "CLI",
Self::Mcp => "MCP",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SettingDoc {
pub section: &'static str,
pub key: &'static str,
pub value_type: &'static str,
pub default: &'static str,
pub description: &'static str,
pub scope: SettingScope,
}
#[derive(Clone, Copy, Debug)]
pub struct SettingsHelp {
docs: &'static [SettingDoc],
config_path_precedence: &'static [&'static str],
}
impl SettingsHelp {
pub const fn docs(self) -> &'static [SettingDoc] {
self.docs
}
pub const fn config_path_precedence(self) -> &'static [&'static str] {
self.config_path_precedence
}
fn sections(self) -> impl Iterator<Item = &'static str> {
self.docs
.iter()
.enumerate()
.filter(|(i, doc)| *i == 0 || self.docs[i - 1].section != doc.section)
.map(|(_, doc)| doc.section)
}
fn keys_in(self, section: &str) -> impl Iterator<Item = &'static str> {
self.docs
.iter()
.filter(move |doc| doc.section == section)
.map(|doc| doc.key)
}
pub fn unknown_in(self, table: &toml::Table) -> Vec<SettingWarning> {
let mut out = Vec::new();
for (name, value) in table {
let Some(section) = self.sections().find(|s| s == name) else {
out.push(SettingWarning {
section: String::new(),
key: name.clone(),
did_you_mean: nearest(name, self.sections()),
});
continue;
};
let Some(entries) = value.as_table() else {
continue;
};
for key in entries.keys() {
if self.keys_in(section).any(|k| k == key) {
continue;
}
out.push(SettingWarning {
section: section.to_string(),
key: key.clone(),
did_you_mean: nearest(key, self.keys_in(section)),
});
}
}
out
}
pub fn render_human(self) -> String {
let mut output = String::from("plugmem settings\n\n");
output.push_str("Config file precedence:\n");
for (index, source) in self.config_path_precedence.iter().enumerate() {
if *source == PLATFORM_DEFAULT_SOURCE {
match crate::default_config_path() {
Some(path) => {
let _ = writeln!(output, " {}. {}", index + 1, path.display());
}
None => {
let _ = writeln!(output, " {}. {source} (unavailable)", index + 1);
}
}
} else {
let _ = writeln!(output, " {}. {source}", index + 1);
}
}
output.push('\n');
let mut section = None;
for doc in self.docs {
if section != Some(doc.section) {
if section.is_some() {
output.push('\n');
}
let _ = writeln!(output, "[{}]", doc.section);
section = Some(doc.section);
}
let _ = writeln!(
output,
" {} ({}, default: {}) — {} [{}]",
doc.key,
doc.value_type,
doc.default,
doc.description,
doc.scope.as_str()
);
}
output
}
}
fn nearest(typo: &str, candidates: impl Iterator<Item = &'static str>) -> Option<&'static str> {
let budget = 1 + typo.chars().count() / 4;
candidates
.map(|c| (edit_distance(typo, c), c))
.filter(|(d, _)| *d <= budget)
.min_by_key(|(d, _)| *d)
.map(|(_, c)| c)
}
fn edit_distance(a: &str, b: &str) -> usize {
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut row = vec![0; b.len() + 1];
for (i, ca) in a.chars().enumerate() {
row[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = usize::from(ca != *cb);
row[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(row[j] + 1);
}
core::mem::swap(&mut prev, &mut row);
}
prev[b.len()]
}
const CONFIG_PATH_PRECEDENCE: &[&str] = &[
"--config PATH",
"$PLUGMEM_CONFIG",
"platform default config path",
"built-in defaults",
];
const DOCS: &[SettingDoc] = &[
SettingDoc {
section: "database",
key: "path",
value_type: "path string",
default: "platform data directory/memory.plugmem",
description: "Persistent database file; an explicit --db or open path and PLUGMEM_DB override it",
scope: SettingScope::Shared,
},
SettingDoc {
section: "workspace",
key: "dir",
value_type: "path string",
default: "unset (one database, no workspace)",
description: "Directory of named databases; unset means the single-database default",
scope: SettingScope::Shared,
},
SettingDoc {
section: "workspace",
key: "max_open",
value_type: "positive integer",
default: "16",
description: "Workspace databases kept open at once; the least recently used is closed",
scope: SettingScope::Shared,
},
SettingDoc {
section: "workspace",
key: "idle_timeout_ms",
value_type: "non-negative integer",
default: "60000",
description: "Close a workspace database unused this long, releasing its lock; 0 never closes",
scope: SettingScope::Shared,
},
SettingDoc {
section: "engine",
key: "dim",
value_type: "non-negative integer",
default: "0",
description: "Embedding dimension; 0 disables vector storage",
scope: SettingScope::Shared,
},
SettingDoc {
section: "engine",
key: "max_bytes",
value_type: "non-negative integer",
default: "2147483648",
description: "Ceiling applied to each byte pool separately, not to their sum",
scope: SettingScope::Shared,
},
SettingDoc {
section: "engine",
key: "max_text",
value_type: "non-negative integer",
default: "4096",
description: "Maximum fact text length in bytes",
scope: SettingScope::Shared,
},
SettingDoc {
section: "engine",
key: "max_blob",
value_type: "non-negative integer",
default: "65536",
description: "Maximum single blob length in bytes",
scope: SettingScope::Shared,
},
SettingDoc {
section: "recall",
key: "bm25_k1",
value_type: "number > 0",
default: "1.2",
description: "BM25 term-frequency saturation: higher lets a repeated word keep counting",
scope: SettingScope::Shared,
},
SettingDoc {
section: "recall",
key: "bm25_b",
value_type: "number in [0, 1]",
default: "0.75",
description: "BM25 length normalisation: 0 ignores fact length, 1 penalises long facts fully",
scope: SettingScope::Shared,
},
SettingDoc {
section: "recall",
key: "rrf_k",
value_type: "integer >= 1",
default: "60",
description: "Reciprocal-rank-fusion constant: larger flattens the gap between rank 1 and rank 10",
scope: SettingScope::Shared,
},
SettingDoc {
section: "recall",
key: "w_bm25",
value_type: "number >= 0",
default: "1.0",
description: "Weight of the lexical source in the fused score; 0 switches it off",
scope: SettingScope::Shared,
},
SettingDoc {
section: "recall",
key: "w_vec",
value_type: "number >= 0",
default: "1.0",
description: "Weight of the vector source; 0 switches it off (and costs nothing when dim = 0)",
scope: SettingScope::Shared,
},
SettingDoc {
section: "recall",
key: "w_graph",
value_type: "number >= 0",
default: "1.0",
description: "Weight of the entity-graph source; 0 switches off relational expansion",
scope: SettingScope::Shared,
},
SettingDoc {
section: "recall",
key: "w_time",
value_type: "number >= 0",
default: "1.0",
description: "Weight of the temporal source (the recorded_at window); 0 switches it off",
scope: SettingScope::Shared,
},
SettingDoc {
section: "recall",
key: "w_recency",
value_type: "number >= 0",
default: "0.25",
description: "How much a fact's age discounts it, on top of the sources above",
scope: SettingScope::Shared,
},
SettingDoc {
section: "recall",
key: "half_life_days",
value_type: "integer >= 1",
default: "180",
description: "Age at which the recency discount has halved; larger keeps old facts competitive",
scope: SettingScope::Shared,
},
SettingDoc {
section: "recall",
key: "graph_depth",
value_type: "non-negative integer",
default: "2",
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",
scope: SettingScope::Shared,
},
SettingDoc {
section: "recall",
key: "graph_decay",
value_type: "number in (0, 1]",
default: "0.5",
description: "How much each extra hop discounts a fact reached through the graph",
scope: SettingScope::Shared,
},
SettingDoc {
section: "recall",
key: "hnsw_ef_search",
value_type: "integer >= 1",
default: "64",
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",
scope: SettingScope::Shared,
},
SettingDoc {
section: "recall",
key: "similar_cos",
value_type: "number in [0, 1]",
default: "0.85",
description: "Cosine above which remember reports an existing fact as possibly conflicting (it never revises on its own)",
scope: SettingScope::Shared,
},
SettingDoc {
section: "recall",
key: "similar_jaccard",
value_type: "number in [0, 1]",
default: "0.5",
description: "Token overlap above which remember reports a possible conflict, for memories with no vectors",
scope: SettingScope::Shared,
},
SettingDoc {
section: "index",
key: "hnsw_ef_construction",
value_type: "integer >= hnsw_m (16 by default)",
default: "200",
description: "Beam width while building the vector graph: higher builds a better index, slower",
scope: SettingScope::Shared,
},
SettingDoc {
section: "index",
key: "flat_to_hnsw",
value_type: "integer >= 1",
default: "24000",
description: "Vector count at which maintenance stops scanning flat and builds the HNSW graph",
scope: SettingScope::Shared,
},
SettingDoc {
section: "embedder",
key: "kind",
value_type: "string",
default: "none",
description: "Embedding provider: none, ollama, openai, lmstudio, vllm or llamacpp",
scope: SettingScope::Shared,
},
SettingDoc {
section: "embedder",
key: "url",
value_type: "string",
default: "unset",
description: "OpenAI-compatible /v1/embeddings endpoint",
scope: SettingScope::Shared,
},
SettingDoc {
section: "embedder",
key: "model",
value_type: "string",
default: "unset",
description: "Embedding model name",
scope: SettingScope::Shared,
},
SettingDoc {
section: "embedder",
key: "api_key_env",
value_type: "string",
default: "unset",
description: "Environment variable containing the bearer token",
scope: SettingScope::Shared,
},
SettingDoc {
section: "maintenance",
key: "snapshot_every_ops",
value_type: "non-negative integer",
default: "1024",
description: "Snapshot after this many mutations",
scope: SettingScope::Shared,
},
SettingDoc {
section: "maintenance",
key: "snapshot_journal_bytes",
value_type: "non-negative integer",
default: "4194304",
description: "Snapshot when the journal reaches this size",
scope: SettingScope::Shared,
},
SettingDoc {
section: "maintenance",
key: "maintain_every_forgets",
value_type: "non-negative integer",
default: "off",
description: "Run policy maintenance after this many forgets",
scope: SettingScope::Shared,
},
SettingDoc {
section: "maintenance",
key: "fsync",
value_type: "\"each_op\" | \"on_snapshot\"",
default: "each_op",
description: "When journal appends reach the disk. \"each_op\": every acknowledged write \
survives a power cut. \"on_snapshot\": faster, an OS crash may lose the journal tail since the \
last snapshot",
scope: SettingScope::Shared,
},
SettingDoc {
section: "maintenance",
key: "batch_size",
value_type: "positive integer",
default: "128",
description: "CLI import facts per embedding request and journal fsync",
scope: SettingScope::Cli,
},
SettingDoc {
section: "server",
key: "workers",
value_type: "positive integer",
default: "half of available cores",
description: "MCP worker threads",
scope: SettingScope::Mcp,
},
];
static SETTINGS_HELP: SettingsHelp = SettingsHelp {
docs: DOCS,
config_path_precedence: CONFIG_PATH_PRECEDENCE,
};
pub const fn settings_help() -> &'static SettingsHelp {
&SETTINGS_HELP
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edit_distance_holds_at_the_degenerate_ends() {
assert_eq!(edit_distance("", ""), 0);
assert_eq!(edit_distance("", "dim"), 3);
assert_eq!(edit_distance("dim", ""), 3);
assert_eq!(edit_distance("a", "a"), 0);
assert_eq!(edit_distance("a", "b"), 1);
assert_eq!(edit_distance("a", ""), 1);
assert_eq!(edit_distance(" ", ""), 1);
assert_eq!(edit_distance(" ", "a"), 1);
assert_eq!(edit_distance("dim", "dm"), 1, "deletion");
assert_eq!(edit_distance("dim", "diim"), 1, "insertion");
assert_eq!(edit_distance("dim", "dir"), 1, "substitution");
assert_eq!(edit_distance("ключ", "ключ"), 0);
assert_eq!(edit_distance("ключ", "клуч"), 1);
assert_eq!(edit_distance("ключ", ""), 4);
for (a, b) in [("dim", "max_text"), ("", "fsync"), ("a", "workers")] {
assert_eq!(edit_distance(a, b), edit_distance(b, a), "{a} vs {b}");
}
}
#[test]
fn a_suggestion_is_offered_only_when_it_is_worth_offering() {
let engine = || settings_help().keys_in("engine");
assert_eq!(nearest("dm", engine()), Some("dim"));
assert_eq!(nearest("max_txt", engine()), Some("max_text"));
let recall = || settings_help().keys_in("recall");
assert_eq!(nearest("w_vector", recall()), Some("w_vec"));
assert_eq!(nearest("similar_cosine", recall()), Some("similar_cos"));
assert_eq!(nearest("half_life", recall()), None);
assert_eq!(nearest("a", engine()), None);
assert_eq!(nearest("", engine()), None);
assert_eq!(nearest(" ", engine()), None);
assert_eq!(nearest("completely_unrelated", engine()), None);
}
fn toml_of(lines: &[&str]) -> toml::Table {
lines.join("\n").parse().expect("valid TOML fixture")
}
#[test]
fn unknown_sections_and_keys_are_reported_with_their_context() {
let table = toml_of(&[
"[engine]",
"dim = 8",
"max_txt = 10",
"",
"[embedder]",
r#"kind = "none""#,
"",
"[engin]",
"dim = 4",
]);
let found = settings_help().unknown_in(&table);
assert_eq!(
found,
vec![
SettingWarning {
section: String::new(),
key: "engin".to_string(),
did_you_mean: Some("engine"),
},
SettingWarning {
section: "engine".to_string(),
key: "max_txt".to_string(),
did_you_mean: Some("max_text"),
},
]
);
assert!(
found[0]
.to_string()
.contains("unknown config section [engin]")
);
assert!(found[1].to_string().contains("[engine].max_txt"));
}
#[test]
fn keys_a_wrapper_owns_are_not_warned_about() {
let table = toml_of(&[
"[maintenance]",
"batch_size = 256",
"",
"[server]",
"workers = 4",
]);
assert_eq!(settings_help().unknown_in(&table), vec![]);
}
#[test]
fn a_clean_config_warns_about_nothing() {
let mut text = String::new();
let mut section = "";
for doc in DOCS {
if doc.section != section {
let _ = writeln!(text, "[{}]", doc.section);
section = doc.section;
}
let _ = writeln!(text, "{} = 0", doc.key);
}
let table: toml::Table = text.parse().unwrap();
assert_eq!(
settings_help().unknown_in(&table),
vec![],
"the catalogue must accept everything it documents"
);
}
#[test]
fn every_documented_setting_has_a_complete_description() {
assert!(!DOCS.is_empty());
for doc in DOCS {
assert!(!doc.section.is_empty());
assert!(!doc.key.is_empty());
assert!(!doc.value_type.is_empty());
assert!(!doc.default.is_empty());
assert!(!doc.description.is_empty());
}
}
#[test]
fn human_help_contains_every_documented_key() {
let rendered = settings_help().render_human();
for doc in DOCS {
assert!(
rendered.contains(doc.key),
"missing {}.{}",
doc.section,
doc.key
);
}
}
}