1use std::fmt::Write as _;
8
9const PLATFORM_DEFAULT_SOURCE: &str = "platform default config path";
10
11#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct SettingWarning {
20 pub section: String,
23 pub key: String,
25 pub did_you_mean: Option<&'static str>,
27}
28
29impl std::fmt::Display for SettingWarning {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 if self.section.is_empty() {
33 write!(f, "unknown config section [{}]", self.key)?;
34 } else {
35 write!(f, "unknown setting [{}].{}", self.section, self.key)?;
36 }
37 match self.did_you_mean {
38 Some(near) => write!(f, " — did you mean `{near}`?"),
39 None => write!(f, " (ignored)"),
40 }
41 }
42}
43
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub enum SettingScope {
47 Shared,
49 Cli,
51 Mcp,
53}
54
55impl SettingScope {
56 pub const fn as_str(self) -> &'static str {
58 match self {
59 Self::Shared => "shared",
60 Self::Cli => "CLI",
61 Self::Mcp => "MCP",
62 }
63 }
64}
65
66#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub struct SettingDoc {
69 pub section: &'static str,
71 pub key: &'static str,
73 pub value_type: &'static str,
75 pub default: &'static str,
77 pub description: &'static str,
79 pub scope: SettingScope,
81}
82
83#[derive(Clone, Copy, Debug)]
85pub struct SettingsHelp {
86 docs: &'static [SettingDoc],
87 config_path_precedence: &'static [&'static str],
88}
89
90impl SettingsHelp {
91 pub const fn docs(self) -> &'static [SettingDoc] {
93 self.docs
94 }
95
96 pub const fn config_path_precedence(self) -> &'static [&'static str] {
98 self.config_path_precedence
99 }
100
101 fn sections(self) -> impl Iterator<Item = &'static str> {
106 self.docs
107 .iter()
108 .enumerate()
109 .filter(|(i, doc)| *i == 0 || self.docs[i - 1].section != doc.section)
110 .map(|(_, doc)| doc.section)
111 }
112
113 fn keys_in(self, section: &str) -> impl Iterator<Item = &'static str> {
115 self.docs
116 .iter()
117 .filter(move |doc| doc.section == section)
118 .map(|doc| doc.key)
119 }
120
121 pub fn unknown_in(self, table: &toml::Table) -> Vec<SettingWarning> {
131 let mut out = Vec::new();
132 for (name, value) in table {
133 let Some(section) = self.sections().find(|s| s == name) else {
134 out.push(SettingWarning {
135 section: String::new(),
136 key: name.clone(),
137 did_you_mean: nearest(name, self.sections()),
138 });
139 continue;
140 };
141 let Some(entries) = value.as_table() else {
144 continue;
145 };
146 for key in entries.keys() {
147 if self.keys_in(section).any(|k| k == key) {
148 continue;
149 }
150 out.push(SettingWarning {
151 section: section.to_string(),
152 key: key.clone(),
153 did_you_mean: nearest(key, self.keys_in(section)),
154 });
155 }
156 }
157 out
158 }
159
160 pub fn render_human(self) -> String {
162 let mut output = String::from("plugmem settings\n\n");
163 output.push_str("Config file precedence:\n");
164 for (index, source) in self.config_path_precedence.iter().enumerate() {
165 if *source == PLATFORM_DEFAULT_SOURCE {
166 match crate::default_config_path() {
167 Some(path) => {
168 let _ = writeln!(output, " {}. {}", index + 1, path.display());
169 }
170 None => {
171 let _ = writeln!(output, " {}. {source} (unavailable)", index + 1);
172 }
173 }
174 } else {
175 let _ = writeln!(output, " {}. {source}", index + 1);
176 }
177 }
178 output.push('\n');
179
180 let mut section = None;
181 for doc in self.docs {
182 if section != Some(doc.section) {
183 if section.is_some() {
184 output.push('\n');
185 }
186 let _ = writeln!(output, "[{}]", doc.section);
187 section = Some(doc.section);
188 }
189 let _ = writeln!(
190 output,
191 " {} ({}, default: {}) — {} [{}]",
192 doc.key,
193 doc.value_type,
194 doc.default,
195 doc.description,
196 doc.scope.as_str()
197 );
198 }
199
200 output
201 }
202}
203
204fn nearest(typo: &str, candidates: impl Iterator<Item = &'static str>) -> Option<&'static str> {
216 let budget = 1 + typo.chars().count() / 4;
217 candidates
218 .map(|c| (edit_distance(typo, c), c))
219 .filter(|(d, _)| *d <= budget)
220 .min_by_key(|(d, _)| *d)
221 .map(|(_, c)| c)
222}
223
224fn edit_distance(a: &str, b: &str) -> usize {
229 let b: Vec<char> = b.chars().collect();
230 let mut prev: Vec<usize> = (0..=b.len()).collect();
231 let mut row = vec![0; b.len() + 1];
232 for (i, ca) in a.chars().enumerate() {
233 row[0] = i + 1;
234 for (j, cb) in b.iter().enumerate() {
235 let cost = usize::from(ca != *cb);
236 row[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(row[j] + 1);
237 }
238 core::mem::swap(&mut prev, &mut row);
239 }
240 prev[b.len()]
241}
242
243const CONFIG_PATH_PRECEDENCE: &[&str] = &[
244 "--config PATH",
245 "$PLUGMEM_CONFIG",
246 "platform default config path",
247 "built-in defaults",
248];
249
250const DOCS: &[SettingDoc] = &[
251 SettingDoc {
252 section: "database",
253 key: "path",
254 value_type: "path string",
255 default: "platform data directory/memory.plugmem",
256 description: "Persistent database file; explicit --db/constructor path and PLUGMEM_DB override it",
257 scope: SettingScope::Shared,
258 },
259 SettingDoc {
260 section: "workspace",
261 key: "dir",
262 value_type: "path string",
263 default: "unset (one database, no workspace)",
264 description: "Directory of named databases; unset means the single-database default",
265 scope: SettingScope::Shared,
266 },
267 SettingDoc {
268 section: "workspace",
269 key: "max_open",
270 value_type: "positive integer",
271 default: "16",
272 description: "Workspace databases kept open at once; the least recently used is closed",
273 scope: SettingScope::Shared,
274 },
275 SettingDoc {
276 section: "workspace",
277 key: "idle_timeout_ms",
278 value_type: "non-negative integer",
279 default: "60000",
280 description: "Close a workspace database unused this long, releasing its lock; 0 never closes",
281 scope: SettingScope::Shared,
282 },
283 SettingDoc {
284 section: "engine",
285 key: "dim",
286 value_type: "non-negative integer",
287 default: "0",
288 description: "Embedding dimension; 0 disables vector storage",
289 scope: SettingScope::Shared,
290 },
291 SettingDoc {
292 section: "engine",
293 key: "max_bytes",
294 value_type: "non-negative integer",
295 default: "2147483648",
296 description: "Ceiling applied to each byte pool separately, not to their sum",
297 scope: SettingScope::Shared,
298 },
299 SettingDoc {
300 section: "engine",
301 key: "max_text",
302 value_type: "non-negative integer",
303 default: "4096",
304 description: "Maximum fact text length in bytes",
305 scope: SettingScope::Shared,
306 },
307 SettingDoc {
308 section: "engine",
309 key: "max_blob",
310 value_type: "non-negative integer",
311 default: "65536",
312 description: "Maximum single blob length in bytes",
313 scope: SettingScope::Shared,
314 },
315 SettingDoc {
316 section: "recall",
317 key: "bm25_k1",
318 value_type: "number > 0",
319 default: "1.2",
320 description: "BM25 term-frequency saturation: higher lets a repeated word keep counting",
321 scope: SettingScope::Shared,
322 },
323 SettingDoc {
324 section: "recall",
325 key: "bm25_b",
326 value_type: "number in [0, 1]",
327 default: "0.75",
328 description: "BM25 length normalisation: 0 ignores fact length, 1 penalises long facts fully",
329 scope: SettingScope::Shared,
330 },
331 SettingDoc {
332 section: "recall",
333 key: "rrf_k",
334 value_type: "integer >= 1",
335 default: "60",
336 description: "Reciprocal-rank-fusion constant: larger flattens the gap between rank 1 and rank 10",
337 scope: SettingScope::Shared,
338 },
339 SettingDoc {
340 section: "recall",
341 key: "w_bm25",
342 value_type: "number >= 0",
343 default: "1.0",
344 description: "Weight of the lexical source in the fused score; 0 switches it off",
345 scope: SettingScope::Shared,
346 },
347 SettingDoc {
348 section: "recall",
349 key: "w_vec",
350 value_type: "number >= 0",
351 default: "1.0",
352 description: "Weight of the vector source; 0 switches it off (and costs nothing when dim = 0)",
353 scope: SettingScope::Shared,
354 },
355 SettingDoc {
356 section: "recall",
357 key: "w_graph",
358 value_type: "number >= 0",
359 default: "1.0",
360 description: "Weight of the entity-graph source; 0 switches off relational expansion",
361 scope: SettingScope::Shared,
362 },
363 SettingDoc {
364 section: "recall",
365 key: "w_time",
366 value_type: "number >= 0",
367 default: "1.0",
368 description: "Weight of the temporal source (the recorded_at window); 0 switches it off",
369 scope: SettingScope::Shared,
370 },
371 SettingDoc {
372 section: "recall",
373 key: "w_recency",
374 value_type: "number >= 0",
375 default: "0.25",
376 description: "How much a fact's age discounts it, on top of the sources above",
377 scope: SettingScope::Shared,
378 },
379 SettingDoc {
380 section: "recall",
381 key: "half_life_days",
382 value_type: "integer >= 1",
383 default: "180",
384 description: "Age at which the recency discount has halved; larger keeps old facts competitive",
385 scope: SettingScope::Shared,
386 },
387 SettingDoc {
388 section: "recall",
389 key: "graph_depth",
390 value_type: "non-negative integer",
391 default: "2",
392 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",
393 scope: SettingScope::Shared,
394 },
395 SettingDoc {
396 section: "recall",
397 key: "graph_decay",
398 value_type: "number in (0, 1]",
399 default: "0.5",
400 description: "How much each extra hop discounts a fact reached through the graph",
401 scope: SettingScope::Shared,
402 },
403 SettingDoc {
404 section: "recall",
405 key: "hnsw_ef_search",
406 value_type: "integer >= 1",
407 default: "64",
408 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",
409 scope: SettingScope::Shared,
410 },
411 SettingDoc {
412 section: "recall",
413 key: "similar_cos",
414 value_type: "number in [0, 1]",
415 default: "0.85",
416 description: "Cosine above which remember reports an existing fact as possibly conflicting (it never revises on its own)",
417 scope: SettingScope::Shared,
418 },
419 SettingDoc {
420 section: "recall",
421 key: "similar_jaccard",
422 value_type: "number in [0, 1]",
423 default: "0.5",
424 description: "Token overlap above which remember reports a possible conflict, for memories with no vectors",
425 scope: SettingScope::Shared,
426 },
427 SettingDoc {
428 section: "index",
429 key: "hnsw_ef_construction",
430 value_type: "integer >= hnsw_m (16 by default)",
431 default: "200",
432 description: "Beam width while building the vector graph: higher builds a better index, slower",
433 scope: SettingScope::Shared,
434 },
435 SettingDoc {
436 section: "index",
437 key: "flat_to_hnsw",
438 value_type: "integer >= 1",
439 default: "24000",
440 description: "Vector count at which maintenance stops scanning flat and builds the HNSW graph",
441 scope: SettingScope::Shared,
442 },
443 SettingDoc {
444 section: "embedder",
445 key: "kind",
446 value_type: "string",
447 default: "none",
448 description: "Embedding provider: none, ollama, openai, lmstudio, vllm or llamacpp",
449 scope: SettingScope::Shared,
450 },
451 SettingDoc {
452 section: "embedder",
453 key: "url",
454 value_type: "string",
455 default: "unset",
456 description: "OpenAI-compatible /v1/embeddings endpoint",
457 scope: SettingScope::Shared,
458 },
459 SettingDoc {
460 section: "embedder",
461 key: "model",
462 value_type: "string",
463 default: "unset",
464 description: "Embedding model name",
465 scope: SettingScope::Shared,
466 },
467 SettingDoc {
468 section: "embedder",
469 key: "api_key_env",
470 value_type: "string",
471 default: "unset",
472 description: "Environment variable containing the bearer token",
473 scope: SettingScope::Shared,
474 },
475 SettingDoc {
476 section: "maintenance",
477 key: "snapshot_every_ops",
478 value_type: "non-negative integer",
479 default: "1024",
480 description: "Snapshot after this many mutations",
481 scope: SettingScope::Shared,
482 },
483 SettingDoc {
484 section: "maintenance",
485 key: "snapshot_journal_bytes",
486 value_type: "non-negative integer",
487 default: "4194304",
488 description: "Snapshot when the journal reaches this size",
489 scope: SettingScope::Shared,
490 },
491 SettingDoc {
492 section: "maintenance",
493 key: "maintain_every_forgets",
494 value_type: "non-negative integer",
495 default: "off",
496 description: "Run policy maintenance after this many forgets",
497 scope: SettingScope::Shared,
498 },
499 SettingDoc {
500 section: "maintenance",
501 key: "fsync",
502 value_type: "\"each_op\" | \"on_snapshot\"",
503 default: "each_op",
504 description: "When journal appends reach the disk. \"each_op\": every acknowledged write \
505survives a power cut. \"on_snapshot\": faster, an OS crash may lose the journal tail since the \
506last snapshot",
507 scope: SettingScope::Shared,
508 },
509 SettingDoc {
510 section: "maintenance",
511 key: "batch_size",
512 value_type: "positive integer",
513 default: "128",
514 description: "CLI import facts per embedding request and journal fsync",
515 scope: SettingScope::Cli,
516 },
517 SettingDoc {
518 section: "server",
519 key: "workers",
520 value_type: "positive integer",
521 default: "half of available cores",
522 description: "MCP worker threads",
523 scope: SettingScope::Mcp,
524 },
525];
526
527static SETTINGS_HELP: SettingsHelp = SettingsHelp {
528 docs: DOCS,
529 config_path_precedence: CONFIG_PATH_PRECEDENCE,
530};
531
532pub const fn settings_help() -> &'static SettingsHelp {
534 &SETTINGS_HELP
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 #[test]
542 fn edit_distance_holds_at_the_degenerate_ends() {
543 assert_eq!(edit_distance("", ""), 0);
548 assert_eq!(edit_distance("", "dim"), 3);
549 assert_eq!(edit_distance("dim", ""), 3);
550
551 assert_eq!(edit_distance("a", "a"), 0);
553 assert_eq!(edit_distance("a", "b"), 1);
554 assert_eq!(edit_distance("a", ""), 1);
555 assert_eq!(edit_distance(" ", ""), 1);
556 assert_eq!(edit_distance(" ", "a"), 1);
557
558 assert_eq!(edit_distance("dim", "dm"), 1, "deletion");
560 assert_eq!(edit_distance("dim", "diim"), 1, "insertion");
561 assert_eq!(edit_distance("dim", "dir"), 1, "substitution");
562
563 assert_eq!(edit_distance("ключ", "ключ"), 0);
566 assert_eq!(edit_distance("ключ", "клуч"), 1);
567 assert_eq!(edit_distance("ключ", ""), 4);
568
569 for (a, b) in [("dim", "max_text"), ("", "fsync"), ("a", "workers")] {
571 assert_eq!(edit_distance(a, b), edit_distance(b, a), "{a} vs {b}");
572 }
573 }
574
575 #[test]
576 fn a_suggestion_is_offered_only_when_it_is_worth_offering() {
577 let engine = || settings_help().keys_in("engine");
578
579 assert_eq!(nearest("dm", engine()), Some("dim"));
581 assert_eq!(nearest("max_txt", engine()), Some("max_text"));
582
583 let recall = || settings_help().keys_in("recall");
586 assert_eq!(nearest("w_vector", recall()), Some("w_vec"));
587 assert_eq!(nearest("similar_cosine", recall()), Some("similar_cos"));
588
589 assert_eq!(nearest("half_life", recall()), None);
594
595 assert_eq!(nearest("a", engine()), None);
599 assert_eq!(nearest("", engine()), None);
600 assert_eq!(nearest(" ", engine()), None);
601 assert_eq!(nearest("completely_unrelated", engine()), None);
602 }
603
604 fn toml_of(lines: &[&str]) -> toml::Table {
607 lines.join("\n").parse().expect("valid TOML fixture")
608 }
609
610 #[test]
611 fn unknown_sections_and_keys_are_reported_with_their_context() {
612 let table = toml_of(&[
613 "[engine]",
614 "dim = 8",
615 "max_txt = 10",
616 "",
617 "[embedder]",
618 r#"kind = "none""#,
619 "",
620 "[engin]",
621 "dim = 4",
622 ]);
623
624 let found = settings_help().unknown_in(&table);
625 assert_eq!(
627 found,
628 vec![
629 SettingWarning {
630 section: String::new(),
631 key: "engin".to_string(),
632 did_you_mean: Some("engine"),
633 },
634 SettingWarning {
635 section: "engine".to_string(),
636 key: "max_txt".to_string(),
637 did_you_mean: Some("max_text"),
638 },
639 ]
640 );
641 assert!(
642 found[0]
643 .to_string()
644 .contains("unknown config section [engin]")
645 );
646 assert!(found[1].to_string().contains("[engine].max_txt"));
647 }
648
649 #[test]
650 fn keys_a_wrapper_owns_are_not_warned_about() {
651 let table = toml_of(&[
655 "[maintenance]",
656 "batch_size = 256",
657 "",
658 "[server]",
659 "workers = 4",
660 ]);
661 assert_eq!(settings_help().unknown_in(&table), vec![]);
662 }
663
664 #[test]
665 fn a_clean_config_warns_about_nothing() {
666 let mut text = String::new();
667 let mut section = "";
668 for doc in DOCS {
669 if doc.section != section {
670 let _ = writeln!(text, "[{}]", doc.section);
671 section = doc.section;
672 }
673 let _ = writeln!(text, "{} = 0", doc.key);
676 }
677 let table: toml::Table = text.parse().unwrap();
678 assert_eq!(
679 settings_help().unknown_in(&table),
680 vec![],
681 "the catalogue must accept everything it documents"
682 );
683 }
684
685 #[test]
686 fn every_documented_setting_has_a_complete_description() {
687 assert!(!DOCS.is_empty());
688 for doc in DOCS {
689 assert!(!doc.section.is_empty());
690 assert!(!doc.key.is_empty());
691 assert!(!doc.value_type.is_empty());
692 assert!(!doc.default.is_empty());
693 assert!(!doc.description.is_empty());
694 }
695 }
696
697 #[test]
698 fn human_help_contains_every_documented_key() {
699 let rendered = settings_help().render_human();
700 for doc in DOCS {
701 assert!(
702 rendered.contains(doc.key),
703 "missing {}.{}",
704 doc.section,
705 doc.key
706 );
707 }
708 }
709}