@matatbread/matbot-core 0.4.9 → 0.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.4.9",
7
+ "version": "0.4.10",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -35,7 +35,7 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "@matatbread/matbot-plugin-api": "^0.4.9"
38
+ "@matatbread/matbot-plugin-api": "^0.4.10"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^6.0.3"
@@ -14,6 +14,13 @@ export interface MatbotConfig {
14
14
  /** Install-default boot identity. The lowest-precedence source for the entry's principal
15
15
  * (a `--principal` flag or `MATBOT_PRINCIPAL` env override it); absent ⇒ the system principal. */
16
16
  principal?: Principal;
17
+ /**
18
+ * Install-supplied floor for plugin settings, keyed by plugin NAME (the settings namespace), read
19
+ * by {@link installSettingsDefaults}. Read-only: matbot never writes it back, so a default cannot
20
+ * be destroyed by a plugin or provider update, and it applies to every principal because it is
21
+ * config rather than data. A stored value always wins; `delete` reverts to the default here.
22
+ */
23
+ defaultSettings?: ReadonlyMap<string, Readonly<Record<string, unknown>>>;
17
24
  }
18
25
 
19
26
  function asString(v: YamlValue | undefined, label: string): string {
@@ -104,6 +111,18 @@ export function parseConfig(
104
111
  }
105
112
  }
106
113
 
114
+ // Keyed by plugin name, not by the specifier written in `plugins:` — the name IS the settings
115
+ // namespace, and the same plugin can be reached by several specifiers (a local path, the package
116
+ // name, a version range). A key naming no loaded plugin is warned about at boot by the host: it is
117
+ // otherwise the one silent failure mode here, and reads as "I set the default and nothing happened".
118
+ const defaultSettingsRaw = doc['default_settings'];
119
+ const defaultSettings = new Map<string, Readonly<Record<string, unknown>>>();
120
+ if (defaultSettingsRaw !== undefined && defaultSettingsRaw !== null) {
121
+ for (const [name, raw] of Object.entries(asRecord(defaultSettingsRaw, 'default_settings'))) {
122
+ defaultSettings.set(name, { ...asRecord(raw, `default_settings.${name}`) });
123
+ }
124
+ }
125
+
107
126
  const prompt = typeof doc['prompt'] === 'string' ? doc['prompt'] : undefined;
108
127
  const ephemeral = doc['ephemeral'] === true ? true : undefined;
109
128
  const defaultProvider = typeof doc['default_provider'] === 'string' ? doc['default_provider'] : undefined;
@@ -116,6 +135,7 @@ export function parseConfig(
116
135
  ...(ephemeral !== undefined ? { ephemeral } : {}),
117
136
  ...(defaultProvider !== undefined ? { defaultProvider } : {}),
118
137
  ...(principal !== undefined ? { principal } : {}),
138
+ ...(defaultSettings.size > 0 ? { defaultSettings } : {}),
119
139
  };
120
140
  }
121
141
 
@@ -3,14 +3,27 @@
3
3
  *
4
4
  * Supports:
5
5
  * - Nested block mappings (key: value / key: followed by indented block)
6
- * - Block sequences (- item)
6
+ * - Block sequences (- item), including an item whose value is an indented block under a bare `-`
7
7
  * - Scalar types: string, number, boolean, null
8
- * - Quoted strings (single and double)
8
+ * - Quoted strings (single and double), as values and as keys
9
9
  * - Comments (# ...)
10
10
  *
11
11
  * Does NOT support anchors, aliases, flow syntax, or ${NAME} expansion.
12
12
  * ${NAME} placeholders are left intact for the Vault to resolve.
13
13
  * Supports literal block scalars (|) and folded block scalars (>).
14
+ *
15
+ * Deliberately NOT supported: YAML's compact mapping in a sequence entry (`- key: value` with the
16
+ * mapping starting on the dash's own line). It is legal YAML, but telling it from a plugin specifier
17
+ * needs the spec's rule that a key separator is a colon followed by SPACE or end-of-line — without
18
+ * which `- https://host/plugin.ts` parses as the key `https`. This tokenizer works on the first colon
19
+ * in a line, so the compact form is rejected (below) rather than guessed at.
20
+ *
21
+ * An unparseable construct THROWS naming the line. It used to `break` the enclosing loop, which
22
+ * returned what had been read so far and left the rest of the document silently discarded — a stray
23
+ * `-` in `plugins:` dropped every plugin after it and every top-level section below it, including
24
+ * `providers:`, with no error. A config parser that returns a subset of the file the user wrote is
25
+ * worse than one that fails: the failure is one message, the subset is an install that boots and
26
+ * behaves as though half its configuration were never written.
14
27
  */
15
28
 
16
29
  type YamlScalar = string | number | boolean | null;
@@ -20,24 +33,34 @@ export type YamlMap = { [key: string]: YamlValue };
20
33
  interface Token {
21
34
  indent: number;
22
35
  raw: string;
36
+ line: number;
23
37
  }
24
38
 
25
39
  function tokenize(text: string): Token[] {
26
40
  const tokens: Token[] = [];
27
- for (const line of text.split('\n')) {
28
- const stripped = line.replace(/#.*$/, '').trimEnd();
41
+ const lines = text.split('\n');
42
+ for (let n = 0; n < lines.length; n++) {
43
+ const stripped = lines[n]!.replace(/#.*$/, '').trimEnd();
29
44
  if (stripped.trim() === '') continue;
30
45
  const indent = stripped.length - stripped.trimStart().length;
31
- tokens.push({ indent, raw: stripped.trimStart() });
46
+ tokens.push({ indent, raw: stripped.trimStart(), line: n + 1 });
32
47
  }
33
48
  return tokens;
34
49
  }
35
50
 
51
+ function fail(tok: Token, what: string): never {
52
+ throw new Error(`Config: could not parse line ${tok.line} ("${tok.raw}") — ${what}`);
53
+ }
54
+
55
+ function unquote(raw: string): string {
56
+ return (raw.length > 1 && ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))))
57
+ ? raw.slice(1, -1)
58
+ : raw;
59
+ }
60
+
36
61
  function parseScalar(raw: string): YamlScalar {
37
- if ((raw.startsWith('"') && raw.endsWith('"')) ||
38
- (raw.startsWith("'") && raw.endsWith("'"))) {
39
- return raw.slice(1, -1);
40
- }
62
+ const unquoted = unquote(raw);
63
+ if (unquoted !== raw) return unquoted;
41
64
 
42
65
  if (raw === 'null' || raw === '~') return null;
43
66
  if (raw === 'true') return true;
@@ -49,29 +72,44 @@ function parseScalar(raw: string): YamlScalar {
49
72
  return raw;
50
73
  }
51
74
 
75
+ // A sequence item is `- value`, or a bare `-` whose value is the block indented beneath it.
76
+ function isSeqItem(raw: string): boolean {
77
+ return raw === '-' || raw.startsWith('- ');
78
+ }
79
+
52
80
  function parse(tokens: Token[], pos: number, baseIndent: number): { value: YamlValue; next: number } {
53
81
  if (pos >= tokens.length) return { value: null, next: pos };
54
82
 
55
83
  const first = tokens[pos]!;
56
84
 
57
- if (first.raw.startsWith('- ')) {
85
+ if (isSeqItem(first.raw)) {
58
86
  const items: YamlValue[] = [];
59
87
  let i = pos;
60
88
  while (i < tokens.length) {
61
89
  const tok = tokens[i]!;
62
90
  if (tok.indent < first.indent) break;
63
- if (tok.indent === first.indent && tok.raw.startsWith('- ')) {
64
- const itemRaw = tok.raw.slice(2).trim();
65
- if (itemRaw === '') {
91
+ // A sibling key at the sequence's own indent ends it — the flush-left style, where `plugins:`
92
+ // and its `-` items share a column, is legal and common.
93
+ if (tok.indent === first.indent && !isSeqItem(tok.raw)) break;
94
+ if (tok.indent > first.indent) {
95
+ fail(tok, 'it is indented further than the sequence item above it. A mapping inside a ' +
96
+ 'sequence entry must start on its own line, under a bare "-".');
97
+ }
98
+ const itemRaw = tok.raw === '-' ? '' : tok.raw.slice(2).trim();
99
+ if (itemRaw === '') {
100
+ // `-` alone: its value is the block indented beneath it, or null if there is no such block.
101
+ const next = tokens[i + 1];
102
+ if (next === undefined || next.indent <= tok.indent) {
103
+ items.push(null);
104
+ i++;
105
+ } else {
66
106
  const sub = parse(tokens, i + 1, tok.indent + 2);
67
107
  items.push(sub.value);
68
108
  i = sub.next;
69
- } else {
70
- items.push(parseScalar(itemRaw));
71
- i++;
72
109
  }
73
110
  } else {
74
- break;
111
+ items.push(parseScalar(itemRaw));
112
+ i++;
75
113
  }
76
114
  }
77
115
  return { value: items, next: i };
@@ -83,10 +121,10 @@ function parse(tokens: Token[], pos: number, baseIndent: number): { value: YamlV
83
121
  while (i < tokens.length) {
84
122
  const tok = tokens[i]!;
85
123
  if (tok.indent < baseIndent) break;
86
- if (!tok.raw.includes(':')) break;
124
+ if (!tok.raw.includes(':')) fail(tok, 'a mapping was expected here (no "key:").');
87
125
 
88
126
  const colonIdx = tok.raw.indexOf(':');
89
- const key = tok.raw.slice(0, colonIdx).trim();
127
+ const key = unquote(tok.raw.slice(0, colonIdx).trim());
90
128
  const rest = tok.raw.slice(colonIdx + 1).trimStart();
91
129
 
92
130
  if (rest === '|' || rest === '>') {
package/src/settings.ts CHANGED
@@ -22,6 +22,29 @@ export function slugSettingsNamespace(name: string): string {
22
22
  return name.replace(/[^\w-]+/g, '_');
23
23
  }
24
24
 
25
+ /**
26
+ * The install's config-supplied floor for plugin settings, keyed by plugin name (the settings
27
+ * namespace). Installed once by the host at boot from `default_settings:` in matbot.yaml (node) or
28
+ * `BrowserConfig.defaultSettings` (browser); empty otherwise.
29
+ *
30
+ * Module state consulted INSIDE {@link makePluginSettings}, rather than a parameter it takes, because
31
+ * a parameter is a thing a call site forgets with no error and no symptom — and there is already a
32
+ * call site that would: `plugins/browser` builds its own facade over a concrete backend on purpose,
33
+ * to pin its plugin list to local storage across a StorageBackend swap. A default it silently didn't
34
+ * see would be indistinguishable from one the user never wrote.
35
+ */
36
+ let installDefaults: ReadonlyMap<string, Readonly<Record<string, unknown>>> = new Map();
37
+
38
+ /** Host boot assembly: install the read-only defaults. Call before any plugin loads. */
39
+ export function installSettingsDefaults(byPlugin: ReadonlyMap<string, Readonly<Record<string, unknown>>> | undefined): void {
40
+ installDefaults = byPlugin ?? new Map();
41
+ }
42
+
43
+ /** The plugin names the install supplies defaults for — the host checks these against what loaded. */
44
+ export function settingsDefaultNamespaces(): readonly string[] {
45
+ return [...installDefaults.keys()];
46
+ }
47
+
25
48
  /**
26
49
  * Build a PluginSettings facade over the shared settings store, scoped to one document id.
27
50
  *
@@ -30,6 +53,12 @@ export function slugSettingsNamespace(name: string): string {
30
53
  * upgrade with `set` instead of `cas` — a three-site migration paid for on every `settings.get()` by
31
54
  * every plugin, forever. Dropped at 0.4.0: an unrecognised document is treated as absent, so a flat one
32
55
  * would be re-created on first write rather than read. Nothing in the wild is still flat.
56
+ *
57
+ * Reads are layered over {@link installDefaults}: a stored key wins, else the install's default, else
58
+ * undefined (and then whatever the plugin's own code defaults to — so config beats code). Writes are
59
+ * NOT layered: `update` reads the stored document only, so setting one key never persists another
60
+ * key's default. Seeding on write would make a later config edit work or not work depending on
61
+ * unrelated write history, which is unexplainable to whoever edits the yaml.
33
62
  */
34
63
  export function makePluginSettings(store: Store<SettingsDoc>, namespace: string): PluginSettings {
35
64
  const id = slugSettingsNamespace(namespace);
@@ -55,7 +84,11 @@ export function makePluginSettings(store: Store<SettingsDoc>, namespace: string)
55
84
 
56
85
  return {
57
86
  async get<T>(key: string): Promise<T | undefined> {
58
- return (await getDoc())?.data[key] as T | undefined;
87
+ const doc = await getDoc();
88
+ // `in`, not a truthiness or undefined test: a stored null is an override the plugin gets to
89
+ // interpret, and `delete` — not writing null — is how a caller reverts to the install default.
90
+ if (doc !== null && key in doc.data) return doc.data[key] as T | undefined;
91
+ return installDefaults.get(namespace)?.[key] as T | undefined;
59
92
  },
60
93
  set<T>(key: string, value: T): Promise<void> {
61
94
  return update(data => ({ ...data, [key]: value as unknown }));