membot 0.4.2 → 0.5.1

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.
@@ -125,6 +125,7 @@ Tombstones hide a path from `ls` / `tree` / `search` but `versions` and `read --
125
125
  | `membot prune --before <ts>` | Permanently drop non-current versions older than cutoff (irreversible) |
126
126
  | `membot serve` | Start MCP server (stdio default, `--http <port>` for HTTP) |
127
127
  | `membot reindex` | Rebuild the FTS keyword index over current chunks |
128
+ | `membot config <subcommand>` | Host-side config management (`get` / `set` / `unset` / `list` / `path`). **Don't run** — this is for the human operator, not for agents |
128
129
 
129
130
  ## Output formats
130
131
 
@@ -125,6 +125,7 @@ Tombstones hide a path from `ls` / `tree` / `search` but `versions` and `read --
125
125
  | `membot prune --before <ts>` | Permanently drop non-current versions older than cutoff (irreversible) |
126
126
  | `membot serve` | Start MCP server (stdio default, `--http <port>` for HTTP) |
127
127
  | `membot reindex` | Rebuild the FTS keyword index over current chunks |
128
+ | `membot config <subcommand>` | Host-side config management (`get` / `set` / `unset` / `list` / `path`). **Don't run** — this is for the human operator, not for agents |
128
129
 
129
130
  ## Output formats
130
131
 
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Evan Tahler
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  > Versioned context store with hybrid search for AI agents. Stdio + HTTP MCP server and CLI.
4
4
 
5
+ [![npm](https://img.shields.io/npm/v/membot.svg)](https://www.npmjs.com/package/membot)
5
6
  [![license](https://img.shields.io/github/license/evantahler/membot.svg)](./LICENSE)
6
7
 
7
8
  `membot` is a single-binary CLI and MCP server that gives AI agents a persistent, versioned, searchable context store. Files (markdown, PDFs, DOCX, HTML, URLs, agent-authored notes) are ingested, converted to markdown, chunked, embedded **locally** with `@huggingface/transformers` (WASM, no cloud calls), and indexed in DuckDB with hybrid search (semantic vector + BM25). Every change creates a new version — nothing is overwritten in place.
@@ -63,6 +64,7 @@ The skill files describe the discover → ingest → search → read → write w
63
64
  | `membot prune --before <ts>` | Permanently drop non-current versions older than cutoff (irreversible) |
64
65
  | `membot serve` | Run the MCP server (stdio default; `--http <port>` for HTTP) |
65
66
  | `membot reindex` | Rebuild the FTS keyword index over current chunks |
67
+ | `membot config <subcommand>` | Get / set values in `~/.membot/config.json` (`get`, `set`, `unset`, `list`, `path`) |
66
68
  | `membot mcpx <subcommand>` | Forward to the bundled `mcpx` CLI for managing remote MCP servers |
67
69
  | `membot skill install` | Install the Claude Code / Cursor agent skill |
68
70
 
@@ -100,9 +102,20 @@ Add `--watch` (and optional `--tick <sec>`) to also run the refresh daemon, whic
100
102
  - `~/.membot/index.duckdb` — all content, blobs, chunks, embeddings, and metadata.
101
103
  - `~/.membot/models/` — cached embedding model weights (`Xenova/bge-small-en-v1.5`, 384-dim).
102
104
  - `~/.membot/logs/` — daemon logs when running `serve --watch`.
103
- - **Config file:** `~/.membot/config.json` (optional; defaults are sane).
105
+ - **Config file:** `~/.membot/config.json` (optional; defaults are sane). Edit it directly or via `membot config`:
106
+
107
+ ```bash
108
+ membot config list # show every value (secrets masked)
109
+ membot config set llm.anthropic_api_key sk-ant-... # enable LLM-fallback paths
110
+ membot config set chunker.target_chars 800 # tweak any nested value
111
+ membot config get llm.anthropic_api_key --show-secrets # reveal the masked key
112
+ membot config unset chunker.target_chars # back to schema default
113
+ membot config path # print the absolute config path
114
+ ```
115
+
116
+ Values are written with file mode `0600`. `ANTHROPIC_API_KEY` set in the environment still wins on read, so existing env-var setups keep working.
104
117
  - **Environment variables:**
105
- - `ANTHROPIC_API_KEY` — optional. Enables LLM fallback for messy / scanned input (vision captions for images, last-resort markdown conversion). Without it, the pipeline degrades to deterministic native conversion.
118
+ - `ANTHROPIC_API_KEY` — optional. Enables LLM fallback for messy / scanned input (vision captions for images, last-resort markdown conversion). Without it, the pipeline degrades to deterministic native conversion. Equivalent to `membot config set llm.anthropic_api_key ...`; the env var takes precedence on read.
106
119
  - `MEMBOT_HOME` — override the data directory.
107
120
  - `NO_COLOR`, `CI`, `FORCE_COLOR` — standard output controls.
108
121
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "membot",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
4
4
  "description": "Versioned context store with hybrid search for AI agents. Stdio + HTTP MCP server and CLI.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,7 +1,7 @@
1
1
  diff --git a/src/search/onnx-wasm-paths.ts b/src/search/onnx-wasm-paths.ts
2
2
  --- a/src/search/onnx-wasm-paths.ts
3
3
  +++ b/src/search/onnx-wasm-paths.ts
4
- @@ -1,31 +1,9 @@
4
+ @@ -1,31 +1,16 @@
5
5
  -// Embed the onnxruntime-web WASM runtime files into the compiled binary
6
6
  -// (`bun build --compile`) so they survive in a single-binary distribution
7
7
  -// where the user has no node_modules.
@@ -33,12 +33,19 @@ diff --git a/src/search/onnx-wasm-paths.ts b/src/search/onnx-wasm-paths.ts
33
33
  -};
34
34
  -
35
35
  -export { wasmBinPath, wasmMjsPath };
36
- +// PATCHED (membot): upstream mcpx ships static `with { type: "file" }` imports
37
- +// of onnxruntime-web WASM assets via `../../node_modules/...`, which only
38
- +// resolves when mcpx is built standalone. When consumed as an npm dep those
39
- +// paths are unreachable and `bun build --compile` fails at build time. membot
40
- +// never invokes mcpx's semantic search (only `mcpx.exec()` for URL fetching),
41
- +// so we stub the exports — semantic.ts wraps the dynamic import in try/catch
42
- +// and falls back to transformers.js's default WASM loader.
43
- +export const wasmMjsPath = "";
44
- +export const wasmBinPath = "";
36
+ +// PATCHED (membot): point mcpx's onnx-wasm-paths at the onnxruntime-web installed
37
+ +// at the top of membot's node_modules. Upstream's `../../node_modules/...` only
38
+ +// resolves in mcpx's standalone repo layout; for consumers we walk up 4 levels:
39
+ +// node_modules/@evantahler/mcpx/src/search node_modules onnxruntime-web.
40
+ +// biome-ignore lint/suspicious/noTsIgnore: must stay as ts-ignore — relative path only resolves at runtime in consumer layout
41
+ +// @ts-ignore - dynamic-only import
42
+ +import wasmMjsPath from "../../../../onnxruntime-web/dist/ort-wasm-simd-threaded.asyncify.mjs" with {
43
+ + type: "file",
44
+ +};
45
+ +// biome-ignore lint/suspicious/noTsIgnore: must stay as ts-ignore — relative path only resolves at runtime in consumer layout
46
+ +// @ts-ignore - dynamic-only import
47
+ +import wasmBinPath from "../../../../onnxruntime-web/dist/ort-wasm-simd-threaded.asyncify.wasm" with {
48
+ + type: "file",
49
+ +};
50
+ +
51
+ +export { wasmBinPath, wasmMjsPath };
@@ -38,11 +38,13 @@ apply_patch \
38
38
  "node_modules/@huggingface/transformers" \
39
39
  ".membot-transformers-patch-applied"
40
40
 
41
- # @evantahler/mcpx — stub `src/search/onnx-wasm-paths.ts` whose static
42
- # `with { type: "file" }` imports use a relative path that only resolves in
43
- # mcpx's own repo layout. When mcpx is consumed as an npm dep those paths are
44
- # unreachable and `bun build --compile` fails at build time. membot never
45
- # invokes mcpx's semantic search, so the stubbed exports are safe.
41
+ # @evantahler/mcpx — rewrite `src/search/onnx-wasm-paths.ts` so its static
42
+ # `with { type: "file" }` imports of onnxruntime-web's WASM resolve from the
43
+ # consumer's hoisted node_modules layout (../../../../onnxruntime-web/...)
44
+ # instead of mcpx's own repo layout (../../node_modules/...). With this
45
+ # patch in place, mcpx's semantic search runs end-to-end inside membot
46
+ # (the agent fetcher's `mcp_search` exercises it) and `bun build --compile`
47
+ # can bundle the WASM assets into the standalone binary.
46
48
  apply_patch \
47
49
  "patches/@evantahler%2Fmcpx@0.21.4.patch" \
48
50
  "node_modules/@evantahler/mcpx" \
package/src/cli.ts CHANGED
@@ -4,6 +4,7 @@ import { bold, cyan, dim, green, yellow } from "ansis";
4
4
  import { program } from "commander";
5
5
  import pkg from "../package.json" with { type: "json" };
6
6
  import { registerCheckUpdateCommand } from "./commands/check-update.ts";
7
+ import { registerConfigCommand } from "./commands/config.ts";
7
8
  import { registerMcpxCommand } from "./commands/mcpx.ts";
8
9
  import { registerReindexCommand } from "./commands/reindex.ts";
9
10
  import { registerServeCommand } from "./commands/serve.ts";
@@ -57,6 +58,7 @@ for (const op of OPERATIONS) {
57
58
 
58
59
  registerServeCommand(program);
59
60
  registerReindexCommand(program);
61
+ registerConfigCommand(program);
60
62
  registerMcpxCommand(program);
61
63
  registerSkillCommand(program);
62
64
  registerCheckUpdateCommand(program);
@@ -0,0 +1,494 @@
1
+ import type { Command } from "commander";
2
+ import { z } from "zod";
3
+ import { loadConfig, saveConfig } from "../config/loader.ts";
4
+ import { type MembotConfig, MembotConfigSchema } from "../config/schemas.ts";
5
+ import { ENV } from "../constants.ts";
6
+ import { HelpfulError, isHelpfulError, mapKindToExit } from "../errors.ts";
7
+ import { renderCliError } from "../mount/commander.ts";
8
+ import { colors, renderTable } from "../output/formatter.ts";
9
+ import { logger } from "../output/logger.ts";
10
+ import { detectMode, isJson, setMode } from "../output/tty.ts";
11
+
12
+ /**
13
+ * The set of value shapes any config leaf can take. Mirrors the zod leaf
14
+ * types used in `MembotConfigSchema` — extend this when the schema gains a
15
+ * new primitive (e.g. arrays, enums).
16
+ */
17
+ export type ConfigFieldKind = "string" | "number" | "boolean" | "null" | "unknown";
18
+
19
+ /**
20
+ * Single source of truth for "what does this config key look like?":
21
+ * - `path` — dot-notation address (e.g. `llm.anthropic_api_key`)
22
+ * - `kind` — runtime value shape, derived from the zod schema
23
+ * - `nullable` — whether `null` is a legal value
24
+ * - `is_secret` — declared at the schema level via `.meta({ secret: true })`;
25
+ * drives masking on every read path
26
+ */
27
+ export interface ConfigField {
28
+ path: string;
29
+ kind: ConfigFieldKind;
30
+ nullable: boolean;
31
+ is_secret: boolean;
32
+ }
33
+
34
+ interface ConfigGetOptions {
35
+ showSecrets?: boolean;
36
+ }
37
+
38
+ /**
39
+ * Register the `membot config` parent command and its subcommands
40
+ * (`get`, `set`, `unset`, `list`, `path`). All subcommands read from and
41
+ * write to `~/.membot/config.json` via the existing `loadConfig` /
42
+ * `saveConfig` helpers, so dot-paths, defaults, and env-var precedence
43
+ * stay consistent with the rest of membot.
44
+ */
45
+ export function registerConfigCommand(program: Command): void {
46
+ const config = program.command("config").description("Get and set membot config values in ~/.membot/config.json");
47
+
48
+ config
49
+ .command("get")
50
+ .argument("[key]", "dot-notation key (e.g. llm.anthropic_api_key); omit to print all values")
51
+ .option("--show-secrets", "print secret values (e.g. API keys) unmasked")
52
+ .description("Print a config value at the given dot-notation key, or all values if no key is given")
53
+ .action(async (key: string | undefined, opts: ConfigGetOptions) => {
54
+ await runSubcommand(program, async () => {
55
+ if (key === undefined) {
56
+ await runList(opts);
57
+ } else {
58
+ await runGet(key, opts);
59
+ }
60
+ });
61
+ });
62
+
63
+ config
64
+ .command("set")
65
+ .argument("<key>", "dot-notation key (e.g. llm.anthropic_api_key)")
66
+ .argument("<value>", 'JSON literal (42, true, null, "text") or raw string')
67
+ .description("Set a config value at the given dot-notation key. Persists to ~/.membot/config.json")
68
+ .action(async (key: string, value: string) => {
69
+ await runSubcommand(program, async () => {
70
+ await runSet(key, value);
71
+ });
72
+ });
73
+
74
+ config
75
+ .command("unset")
76
+ .argument("<key>", "dot-notation key (e.g. chunker.target_chars)")
77
+ .description("Reset a config value to its schema default")
78
+ .action(async (key: string) => {
79
+ await runSubcommand(program, async () => {
80
+ await runUnset(key);
81
+ });
82
+ });
83
+
84
+ config
85
+ .command("list")
86
+ .option("--show-secrets", "print secret values (e.g. API keys) unmasked")
87
+ .description("Print every config value (table on a TTY, JSON otherwise). Secrets masked by default")
88
+ .action(async (opts: ConfigGetOptions) => {
89
+ await runSubcommand(program, async () => {
90
+ await runList(opts);
91
+ });
92
+ });
93
+
94
+ config
95
+ .command("path")
96
+ .description("Print the absolute path to the config file")
97
+ .action(async () => {
98
+ await runSubcommand(program, async () => {
99
+ await runPath();
100
+ });
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Apply global flags to the output mode (so `--json` / `--no-color` /
106
+ * `CI=true` are honored) and turn any thrown error into a uniform
107
+ * `renderCliError` + appropriate exit code.
108
+ */
109
+ async function runSubcommand(program: Command, fn: () => Promise<void>): Promise<void> {
110
+ const globalOpts = program.optsWithGlobals<{
111
+ json?: boolean;
112
+ verbose?: boolean;
113
+ color?: boolean;
114
+ }>();
115
+ setMode(
116
+ detectMode({
117
+ json: globalOpts.json,
118
+ verbose: globalOpts.verbose,
119
+ noColor: globalOpts.color === false,
120
+ }),
121
+ );
122
+ try {
123
+ await fn();
124
+ } catch (err) {
125
+ renderCliError(err);
126
+ process.exit(isHelpfulError(err) ? mapKindToExit(err.kind) : 1);
127
+ }
128
+ }
129
+
130
+ /** Print a single config value at `key`, masked unless `--show-secrets`. */
131
+ export async function runGet(key: string, opts: ConfigGetOptions): Promise<void> {
132
+ resolveSchemaPath(MembotConfigSchema, key);
133
+ const { config } = await loadConfig();
134
+ const raw = getValueAt(config, key);
135
+ const value = opts.showSecrets ? raw : maskIfSecret(key, raw);
136
+ if (isJson()) {
137
+ process.stdout.write(`${JSON.stringify(value)}\n`);
138
+ return;
139
+ }
140
+ process.stdout.write(`${formatScalar(value)}\n`);
141
+ }
142
+
143
+ /**
144
+ * Coerce + validate + persist `value` at `key`. Coercion rule: try
145
+ * `JSON.parse(value)` first (so `42` / `true` / `null` work); fall back to
146
+ * the raw string. Validation runs the full `MembotConfigSchema` parse, so
147
+ * type errors surface a precise hint.
148
+ */
149
+ export async function runSet(key: string, rawValue: string): Promise<void> {
150
+ resolveSchemaPath(MembotConfigSchema, key);
151
+ const coerced = coerceValue(rawValue);
152
+
153
+ const { config, configPath } = await loadConfig();
154
+ const draft = structuredClone(config);
155
+ setValueAt(draft, key, coerced);
156
+
157
+ const validated = validateOrThrow(draft, key);
158
+ await saveConfig(configPath, validated);
159
+
160
+ if (isJson()) {
161
+ process.stdout.write(
162
+ `${JSON.stringify({ ok: true, key, value: maskIfSecret(key, getValueAt(validated, key)) })}\n`,
163
+ );
164
+ } else {
165
+ const display = formatScalar(maskIfSecret(key, getValueAt(validated, key)));
166
+ logger.info(`set ${key} = ${display}`);
167
+ }
168
+
169
+ // If a user just persisted the API key while ANTHROPIC_API_KEY is also set
170
+ // in the environment, the env wins on read — surface that so they don't
171
+ // wonder why their new value isn't taking effect.
172
+ if (key === "llm.anthropic_api_key" && process.env[ENV.ANTHROPIC_API_KEY]?.trim()) {
173
+ logger.warn(
174
+ `note: ANTHROPIC_API_KEY is set in your environment and overrides the file at read time. Unset it (\`unset ANTHROPIC_API_KEY\`) to use the value you just saved.`,
175
+ );
176
+ }
177
+ }
178
+
179
+ /** Reset `key` to whatever `MembotConfigSchema` produces from `{}`. */
180
+ export async function runUnset(key: string): Promise<void> {
181
+ resolveSchemaPath(MembotConfigSchema, key);
182
+ const defaults = MembotConfigSchema.parse({});
183
+ const defaultValue = getValueAt(defaults, key);
184
+
185
+ const { config, configPath } = await loadConfig();
186
+ const draft = structuredClone(config);
187
+ setValueAt(draft, key, defaultValue);
188
+
189
+ const validated = validateOrThrow(draft, key);
190
+ await saveConfig(configPath, validated);
191
+
192
+ if (isJson()) {
193
+ process.stdout.write(`${JSON.stringify({ ok: true, key, value: maskIfSecret(key, defaultValue) })}\n`);
194
+ } else {
195
+ logger.info(`unset ${key} → ${formatScalar(maskIfSecret(key, defaultValue))}`);
196
+ }
197
+ }
198
+
199
+ /** Print every key/value pair. JSON mode → nested config object; TTY → table. */
200
+ async function runList(opts: ConfigGetOptions): Promise<void> {
201
+ const { config } = await loadConfig();
202
+ if (isJson()) {
203
+ const masked = opts.showSecrets ? config : maskAllSecrets(config);
204
+ process.stdout.write(`${JSON.stringify(masked, null, 2)}\n`);
205
+ return;
206
+ }
207
+ const paths = enumerateSchemaPaths(MembotConfigSchema);
208
+ const rows = paths.map((p) => {
209
+ const raw = getValueAt(config, p);
210
+ const value = opts.showSecrets ? raw : maskIfSecret(p, raw);
211
+ return [colors.cyan(p), formatScalar(value)];
212
+ });
213
+ process.stdout.write(`${renderTable(["key", "value"], rows)}\n`);
214
+ }
215
+
216
+ /** Print the absolute path to the config file. */
217
+ async function runPath(): Promise<void> {
218
+ const { configPath } = await loadConfig();
219
+ if (isJson()) {
220
+ process.stdout.write(`${JSON.stringify({ path: configPath })}\n`);
221
+ return;
222
+ }
223
+ process.stdout.write(`${configPath}\n`);
224
+ }
225
+
226
+ /**
227
+ * Walk a dotted path through `MembotConfigSchema` and return the leaf zod
228
+ * type. Descends into `ZodObject.shape` and transparently unwraps
229
+ * `ZodDefault` / `ZodOptional` / `ZodNullable`. Throws `HelpfulError` if any
230
+ * segment doesn't exist, with a "did you mean" suggestion derived from the
231
+ * full set of valid paths.
232
+ */
233
+ export function resolveSchemaPath(schema: z.ZodTypeAny, dottedPath: string): z.ZodTypeAny {
234
+ const segments = dottedPath.split(".").filter((s) => s.length > 0);
235
+ if (segments.length === 0) {
236
+ throw new HelpfulError({
237
+ kind: "input_error",
238
+ message: "config key is required",
239
+ hint: "Pass a dot-notation key, e.g. `membot config get llm.anthropic_api_key`. Run `membot config list` for the full set.",
240
+ });
241
+ }
242
+
243
+ let current = unwrapSchema(schema);
244
+ const traversed: string[] = [];
245
+ for (const segment of segments) {
246
+ if (!(current instanceof z.ZodObject)) {
247
+ throw unknownKeyError(dottedPath, traversed.join("."));
248
+ }
249
+ const shape = current.shape as Record<string, z.ZodTypeAny>;
250
+ const next = shape[segment];
251
+ if (!next) {
252
+ throw unknownKeyError(dottedPath, [...traversed, segment].join("."));
253
+ }
254
+ traversed.push(segment);
255
+ current = unwrapSchema(next);
256
+ }
257
+ return current;
258
+ }
259
+
260
+ /**
261
+ * Build the `HelpfulError` for an unknown key. Includes a "did you mean"
262
+ * suggestion when there's an obvious near-match (Levenshtein distance ≤ 2).
263
+ */
264
+ function unknownKeyError(badPath: string, _matchedPrefix: string): HelpfulError {
265
+ const valid = enumerateSchemaPaths(MembotConfigSchema);
266
+ const suggestion = nearestPath(badPath, valid);
267
+ const baseHint = "Run `membot config list` to see all valid keys.";
268
+ const hint = suggestion ? `Did you mean \`${suggestion}\`? ${baseHint}` : baseHint;
269
+ return new HelpfulError({
270
+ kind: "input_error",
271
+ message: `unknown config key: ${badPath}`,
272
+ hint,
273
+ });
274
+ }
275
+
276
+ /** Return the closest known path within Levenshtein distance 2, or null. */
277
+ function nearestPath(target: string, candidates: readonly string[]): string | null {
278
+ let best: { path: string; distance: number } | null = null;
279
+ for (const c of candidates) {
280
+ const d = levenshtein(target, c);
281
+ if (d <= 2 && (!best || d < best.distance)) best = { path: c, distance: d };
282
+ }
283
+ return best?.path ?? null;
284
+ }
285
+
286
+ function levenshtein(a: string, b: string): number {
287
+ if (a === b) return 0;
288
+ if (a.length === 0) return b.length;
289
+ if (b.length === 0) return a.length;
290
+ const prev = new Array<number>(b.length + 1);
291
+ const curr = new Array<number>(b.length + 1);
292
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
293
+ for (let i = 1; i <= a.length; i++) {
294
+ curr[0] = i;
295
+ for (let j = 1; j <= b.length; j++) {
296
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
297
+ curr[j] = Math.min((curr[j - 1] ?? 0) + 1, (prev[j] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
298
+ }
299
+ for (let j = 0; j <= b.length; j++) prev[j] = curr[j] ?? 0;
300
+ }
301
+ return prev[b.length] ?? 0;
302
+ }
303
+
304
+ /**
305
+ * Strip every layer of `ZodDefault` / `ZodOptional` / `ZodNullable`. Zod 4
306
+ * types `.unwrap()` as the lower-level `$ZodType` rather than `ZodType`, so
307
+ * we cast back through `unknown` — the runtime instance is a real `ZodType`.
308
+ */
309
+ function unwrapSchema(t: z.ZodTypeAny): z.ZodTypeAny {
310
+ let cur = t;
311
+ while (cur instanceof z.ZodDefault || cur instanceof z.ZodOptional || cur instanceof z.ZodNullable) {
312
+ cur = cur.unwrap() as unknown as z.ZodTypeAny;
313
+ }
314
+ return cur;
315
+ }
316
+
317
+ /**
318
+ * Walk every wrapper layer of a zod leaf (default / optional / nullable)
319
+ * and return: the innermost type, whether `null` is legal, and the merged
320
+ * `.meta()` from every layer (outer layers win on conflict).
321
+ *
322
+ * Zod 4's `.meta()` is bound to the specific layer where it was declared —
323
+ * `.meta({secret:true}).default("")` and `.default("").meta({secret:true})`
324
+ * land it on different wrappers — so we have to scan all of them.
325
+ */
326
+ function walkLeaf(t: z.ZodTypeAny): {
327
+ leaf: z.ZodTypeAny;
328
+ nullable: boolean;
329
+ meta: Record<string, unknown>;
330
+ } {
331
+ let cur = t;
332
+ let nullable = false;
333
+ const layers: z.ZodTypeAny[] = [cur];
334
+ while (cur instanceof z.ZodDefault || cur instanceof z.ZodOptional || cur instanceof z.ZodNullable) {
335
+ if (cur instanceof z.ZodNullable) nullable = true;
336
+ cur = cur.unwrap() as unknown as z.ZodTypeAny;
337
+ layers.push(cur);
338
+ }
339
+ let meta: Record<string, unknown> = {};
340
+ // inner-to-outer merge so outer layers (declared closer to the user) win
341
+ for (const layer of layers) {
342
+ const layerMeta = (layer as { meta?: () => Record<string, unknown> | undefined }).meta?.();
343
+ if (layerMeta) meta = { ...meta, ...layerMeta };
344
+ }
345
+ return { leaf: cur, nullable, meta };
346
+ }
347
+
348
+ /** Map a zod leaf type to its `ConfigFieldKind` discriminator. */
349
+ function inferKind(leaf: z.ZodTypeAny): ConfigFieldKind {
350
+ if (leaf instanceof z.ZodString) return "string";
351
+ if (leaf instanceof z.ZodNumber) return "number";
352
+ if (leaf instanceof z.ZodBoolean) return "boolean";
353
+ if (leaf instanceof z.ZodNull) return "null";
354
+ return "unknown";
355
+ }
356
+
357
+ /**
358
+ * Recursively enumerate every leaf in a zod schema as a `ConfigField`. This
359
+ * is the single source of truth for what's gettable / settable / maskable —
360
+ * adding a new field to `MembotConfigSchema` (and tagging it with
361
+ * `.meta({secret:true})` if appropriate) is enough to make every path here
362
+ * pick it up automatically.
363
+ */
364
+ export function enumerateSchemaFields(schema: z.ZodTypeAny, prefix = ""): ConfigField[] {
365
+ const root = unwrapSchema(schema);
366
+ if (!(root instanceof z.ZodObject)) {
367
+ if (!prefix) return [];
368
+ const { leaf, nullable, meta } = walkLeaf(schema);
369
+ return [{ path: prefix, kind: inferKind(leaf), nullable, is_secret: meta.secret === true }];
370
+ }
371
+ const out: ConfigField[] = [];
372
+ const shape = root.shape as Record<string, z.ZodTypeAny>;
373
+ for (const key of Object.keys(shape)) {
374
+ const child = shape[key] as z.ZodTypeAny;
375
+ const childUnwrapped = unwrapSchema(child);
376
+ const path = prefix ? `${prefix}.${key}` : key;
377
+ if (childUnwrapped instanceof z.ZodObject) {
378
+ out.push(...enumerateSchemaFields(childUnwrapped, path));
379
+ } else {
380
+ const { leaf, nullable, meta } = walkLeaf(child);
381
+ out.push({ path, kind: inferKind(leaf), nullable, is_secret: meta.secret === true });
382
+ }
383
+ }
384
+ return out;
385
+ }
386
+
387
+ /** Backward-compatible wrapper: just the dotted paths, no metadata. */
388
+ export function enumerateSchemaPaths(schema: z.ZodTypeAny, prefix = ""): string[] {
389
+ return enumerateSchemaFields(schema, prefix).map((f) => f.path);
390
+ }
391
+
392
+ /**
393
+ * Field index built once from `MembotConfigSchema` at module load. Every
394
+ * read/write path consults this instead of duplicating schema introspection.
395
+ */
396
+ const FIELD_INDEX: ReadonlyMap<string, ConfigField> = new Map(
397
+ enumerateSchemaFields(MembotConfigSchema).map((f) => [f.path, f]),
398
+ );
399
+
400
+ /** Look up the `ConfigField` for a known dotted path, or `undefined`. */
401
+ export function getField(path: string): ConfigField | undefined {
402
+ return FIELD_INDEX.get(path);
403
+ }
404
+
405
+ /** Read the value at a dotted path from a plain object. */
406
+ function getValueAt(obj: unknown, dottedPath: string): unknown {
407
+ let cur: unknown = obj;
408
+ for (const segment of dottedPath.split(".")) {
409
+ if (cur === null || typeof cur !== "object") return undefined;
410
+ cur = (cur as Record<string, unknown>)[segment];
411
+ }
412
+ return cur;
413
+ }
414
+
415
+ /**
416
+ * Set the value at a dotted path on a plain object, creating intermediate
417
+ * objects as needed. Mutates `obj` in place.
418
+ */
419
+ function setValueAt(obj: Record<string, unknown>, dottedPath: string, value: unknown): void {
420
+ const segments = dottedPath.split(".");
421
+ let cur: Record<string, unknown> = obj;
422
+ for (let i = 0; i < segments.length - 1; i++) {
423
+ const seg = segments[i] as string;
424
+ const next = cur[seg];
425
+ if (next === null || typeof next !== "object") {
426
+ cur[seg] = {};
427
+ }
428
+ cur = cur[seg] as Record<string, unknown>;
429
+ }
430
+ cur[segments[segments.length - 1] as string] = value;
431
+ }
432
+
433
+ /**
434
+ * Try `JSON.parse` (so `42`, `true`, `null`, `"foo"` all coerce correctly);
435
+ * fall back to the raw string when the value isn't valid JSON.
436
+ */
437
+ function coerceValue(raw: string): unknown {
438
+ try {
439
+ return JSON.parse(raw);
440
+ } catch {
441
+ return raw;
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Reparse the entire draft against `MembotConfigSchema`. On failure, throw
447
+ * a `HelpfulError` whose hint names the offending dot-path and shows the
448
+ * zod error message — far more useful than zod's raw issue array.
449
+ */
450
+ function validateOrThrow(draft: unknown, key: string): MembotConfig {
451
+ const result = MembotConfigSchema.safeParse(draft);
452
+ if (result.success) return result.data;
453
+ const issue = result.error.issues.find((i) => i.path.join(".") === key) ?? result.error.issues[0];
454
+ const issuePath = issue?.path.join(".") ?? key;
455
+ const issueMessage = issue?.message ?? result.error.message;
456
+ throw new HelpfulError({
457
+ kind: "input_error",
458
+ message: `invalid value for ${issuePath}: ${issueMessage}`,
459
+ hint: `Run \`membot config get ${issuePath}\` to see the current value, or \`membot config unset ${issuePath}\` to reset to default.`,
460
+ details: result.error.issues,
461
+ cause: result.error,
462
+ });
463
+ }
464
+
465
+ /**
466
+ * Mask a value for display when its `ConfigField.is_secret` is true.
467
+ * Non-secret paths and unknown paths pass through unchanged.
468
+ */
469
+ export function maskIfSecret(path: string, value: unknown): unknown {
470
+ if (!getField(path)?.is_secret) return value;
471
+ if (typeof value !== "string" || value.length === 0) return value;
472
+ if (value.length <= 11) return "****";
473
+ return `${value.slice(0, 7)}...${value.slice(-4)}`;
474
+ }
475
+
476
+ /** Walk a config object and mask every secret field in place. */
477
+ function maskAllSecrets(config: MembotConfig): MembotConfig {
478
+ const clone = structuredClone(config) as Record<string, unknown>;
479
+ for (const field of FIELD_INDEX.values()) {
480
+ if (!field.is_secret) continue;
481
+ const current = getValueAt(clone, field.path);
482
+ setValueAt(clone, field.path, maskIfSecret(field.path, current));
483
+ }
484
+ return clone as MembotConfig;
485
+ }
486
+
487
+ /** Render a scalar (or null/undefined/object) for human-readable output. */
488
+ function formatScalar(value: unknown): string {
489
+ if (value === null) return colors.dim("null");
490
+ if (value === undefined) return colors.dim("(unset)");
491
+ if (typeof value === "string") return value;
492
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
493
+ return JSON.stringify(value);
494
+ }