botholomew 0.16.4 → 0.18.0

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.
Files changed (98) hide show
  1. package/README.md +46 -41
  2. package/package.json +4 -9
  3. package/src/chat/agent.ts +37 -40
  4. package/src/chat/session.ts +10 -10
  5. package/src/cli.ts +0 -2
  6. package/src/commands/capabilities.ts +35 -33
  7. package/src/commands/context.ts +133 -221
  8. package/src/commands/init.ts +22 -1
  9. package/src/commands/mcpx.ts +21 -8
  10. package/src/commands/nuke.ts +52 -15
  11. package/src/commands/prepare.ts +16 -13
  12. package/src/config/loader.ts +1 -8
  13. package/src/config/schemas.ts +6 -0
  14. package/src/constants.ts +16 -32
  15. package/src/init/index.ts +52 -27
  16. package/src/mcpx/client.ts +21 -5
  17. package/src/mem/client.ts +33 -0
  18. package/src/{context → prompts}/capabilities.ts +11 -7
  19. package/src/schedules/store.ts +1 -1
  20. package/src/tasks/store.ts +1 -1
  21. package/src/threads/store.ts +1 -1
  22. package/src/tools/capabilities/refresh.ts +1 -1
  23. package/src/tools/membot/adapter.ts +111 -0
  24. package/src/tools/membot/copy.ts +59 -0
  25. package/src/tools/membot/count_lines.ts +53 -0
  26. package/src/tools/membot/edit.ts +72 -0
  27. package/src/tools/membot/exists.ts +54 -0
  28. package/src/tools/membot/index.ts +26 -0
  29. package/src/tools/{context → membot}/pipe.ts +34 -32
  30. package/src/tools/registry.ts +6 -37
  31. package/src/tools/tool.ts +6 -8
  32. package/src/tui/App.tsx +3 -4
  33. package/src/tui/components/ContextPanel.tsx +109 -226
  34. package/src/tui/components/HelpPanel.tsx +2 -2
  35. package/src/tui/components/StatusBar.tsx +0 -6
  36. package/src/tui/components/ThreadPanel.tsx +8 -7
  37. package/src/tui/wrapDetail.ts +11 -0
  38. package/src/worker/heartbeat.ts +0 -20
  39. package/src/worker/index.ts +13 -13
  40. package/src/worker/llm.ts +7 -9
  41. package/src/worker/prompt.ts +25 -13
  42. package/src/worker/spawn.ts +1 -1
  43. package/src/worker/tick.ts +10 -9
  44. package/src/commands/db.ts +0 -119
  45. package/src/commands/with-db.ts +0 -22
  46. package/src/context/chunker.ts +0 -275
  47. package/src/context/embedder-impl.ts +0 -100
  48. package/src/context/embedder.ts +0 -9
  49. package/src/context/fetcher-errors.ts +0 -8
  50. package/src/context/fetcher.ts +0 -515
  51. package/src/context/locks.ts +0 -146
  52. package/src/context/markdown-converter.ts +0 -186
  53. package/src/context/reindex.ts +0 -198
  54. package/src/context/store.ts +0 -841
  55. package/src/context/url-utils.ts +0 -25
  56. package/src/db/connection.ts +0 -255
  57. package/src/db/doctor.ts +0 -235
  58. package/src/db/embeddings.ts +0 -317
  59. package/src/db/query.ts +0 -56
  60. package/src/db/schema.ts +0 -93
  61. package/src/db/sql/1-core_tables.sql +0 -53
  62. package/src/db/sql/10-dedupe_context_items.sql +0 -26
  63. package/src/db/sql/11-rebuild_hnsw.sql +0 -8
  64. package/src/db/sql/12-workers.sql +0 -66
  65. package/src/db/sql/13-drive-paths.sql +0 -47
  66. package/src/db/sql/14-drop_hnsw_index.sql +0 -8
  67. package/src/db/sql/15-fts_index.sql +0 -8
  68. package/src/db/sql/16-source_url.sql +0 -7
  69. package/src/db/sql/17-worker_log_path.sql +0 -3
  70. package/src/db/sql/18-reset_embeddings_for_local.sql +0 -39
  71. package/src/db/sql/19-disk_backed_index.sql +0 -36
  72. package/src/db/sql/2-logging_tables.sql +0 -24
  73. package/src/db/sql/20-drop_db_tables_for_files.sql +0 -19
  74. package/src/db/sql/3-daemon_state.sql +0 -5
  75. package/src/db/sql/4-unique_context_path.sql +0 -1
  76. package/src/db/sql/5-reset_embeddings_for_openai.sql +0 -1
  77. package/src/db/sql/6-vss_index.sql +0 -7
  78. package/src/db/sql/7-drop_embeddings_fk.sql +0 -23
  79. package/src/db/sql/8-task_output.sql +0 -1
  80. package/src/db/sql/9-source-type.sql +0 -1
  81. package/src/tools/context/read-large-result.ts +0 -33
  82. package/src/tools/dir/create.ts +0 -47
  83. package/src/tools/dir/size.ts +0 -77
  84. package/src/tools/dir/tree.ts +0 -124
  85. package/src/tools/file/copy.ts +0 -73
  86. package/src/tools/file/count-lines.ts +0 -54
  87. package/src/tools/file/delete.ts +0 -83
  88. package/src/tools/file/edit.ts +0 -76
  89. package/src/tools/file/exists.ts +0 -33
  90. package/src/tools/file/info.ts +0 -66
  91. package/src/tools/file/move.ts +0 -66
  92. package/src/tools/file/read.ts +0 -67
  93. package/src/tools/file/write.ts +0 -58
  94. package/src/tools/search/fuse.ts +0 -96
  95. package/src/tools/search/index.ts +0 -127
  96. package/src/tools/search/regexp.ts +0 -82
  97. package/src/tools/search/semantic.ts +0 -167
  98. /package/src/{db → utils}/uuid.ts +0 -0
@@ -1,245 +1,157 @@
1
- import { stat } from "node:fs/promises";
1
+ import { copyFileSync, existsSync, mkdirSync, statSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { homedir } from "node:os";
2
4
  import { join } from "node:path";
3
- import ansis from "ansis";
5
+ import { fileURLToPath } from "node:url";
4
6
  import type { Command } from "commander";
5
- import { createSpinner } from "nanospinner";
7
+ import { defaultCliName, OPERATIONS } from "membot";
6
8
  import { loadConfig } from "../config/loader.ts";
7
- import { CONTEXT_DIR, getDbPath } from "../constants.ts";
8
- import { fetchUrl } from "../context/fetcher.ts";
9
- import { reindexContext } from "../context/reindex.ts";
10
- import {
11
- buildTree,
12
- fileExists,
13
- listContextDir,
14
- type TreeNode,
15
- writeContextFile,
16
- } from "../context/store.ts";
17
- import { withDb } from "../db/connection.ts";
18
- import { indexStats } from "../db/embeddings.ts";
19
- import { migrate } from "../db/schema.ts";
20
- import { createMcpxClient } from "../mcpx/client.ts";
21
- import {
22
- type ContextFileMeta,
23
- serializeContextFile,
24
- } from "../utils/frontmatter.ts";
9
+ import { resolveMembotDir } from "../mem/client.ts";
25
10
  import { logger } from "../utils/logger.ts";
26
11
 
27
- export function registerContextCommand(program: Command) {
28
- const context = program
29
- .command("context")
30
- .description(
31
- "Inspect and manage the on-disk context/ tree (the agent's knowledge store)",
32
- );
12
+ const require = createRequire(import.meta.url);
13
+ const ourPkg = require("../../package.json");
14
+ const membotPkg = require("membot/package.json");
33
15
 
34
- // ---- import --------------------------------------------------------------
35
- context
36
- .command("import <url>")
37
- .description(
38
- "Fetch a URL via MCP (Google Docs, Firecrawl, GitHub, etc.) and write the result into context/.",
39
- )
40
- .option(
41
- "-p, --path <path>",
42
- "destination path under context/ (default: derived from the URL)",
43
- )
44
- .option(
45
- "--prompt <text>",
46
- "extra guidance passed to the LLM-driven fetcher (e.g. 'export as markdown')",
47
- )
48
- .option("--overwrite", "replace an existing file at the destination path")
49
- .action(async (url: string, opts) => {
50
- const dir = program.opts().dir;
51
- const config = await loadConfig(dir);
52
- const mcpxClient = await createMcpxClient(dir);
53
- logger.info(`importing ${url}`);
54
- try {
55
- const fetched = await fetchUrl(url, config, mcpxClient, opts.prompt);
56
- const dest = opts.path ?? deriveContextPath(url, fetched.source);
57
- const meta: ContextFileMeta = {
58
- source_url: url,
59
- imported_at: new Date().toISOString(),
60
- };
61
- // Title falls back to the URL when fetcher couldn't extract one —
62
- // skip it in that case to avoid duplicating source_url.
63
- if (fetched.title && fetched.title !== url) {
64
- meta.title = fetched.title;
65
- }
66
- const body = serializeContextFile(meta, fetched.content);
67
- await writeContextFile(dir, dest, body, {
68
- onConflict: opts.overwrite ? "overwrite" : "error",
69
- });
70
- logger.success(
71
- `imported ${body.length} bytes → ${ansis.bold(`context/${dest}`)} (source: ${fetched.source ?? "http"})`,
72
- );
16
+ // Soft warning rather than a hard error — membot's SDK API is stable within a
17
+ // minor version, and dev workspaces sometimes pin a newer copy.
18
+ const requested = (ourPkg.dependencies.membot as string).replace(/^[\^~]/, "");
19
+ if (!membotPkg.version.startsWith(requested.split(".")[0])) {
20
+ logger.warn(
21
+ `membot version drift: installed ${membotPkg.version}, expected ${ourPkg.dependencies.membot}`,
22
+ );
23
+ }
73
24
 
74
- // Reindex so the new file is searchable. reindexContext is
75
- // incremental — files whose content_hash matches the index are
76
- // skipped, so this only embeds the file we just wrote.
77
- const dbPath = getDbPath(dir);
78
- await withDb(dbPath, migrate);
79
- const summary = await reindexContext(dir, config, dbPath, {
80
- onProgress: (msg) => logger.dim(` ${msg}`),
81
- });
82
- logger.success(
83
- `indexed: ${summary.added} added, ${summary.updated} updated, ${summary.unchanged} unchanged, ${summary.chunksWritten} chunks written`,
84
- );
85
- } catch (err) {
86
- logger.error(
87
- `import failed: ${err instanceof Error ? err.message : String(err)}`,
88
- );
89
- process.exit(1);
90
- } finally {
91
- await mcpxClient?.close();
92
- }
93
- });
25
+ const MEMBOT_CLI = fileURLToPath(import.meta.resolve("membot/cli"));
94
26
 
95
- // ---- reindex -------------------------------------------------------------
96
- context
97
- .command("reindex")
98
- .description(
99
- "Walk context/ and reconcile the search index: embed new files, re-embed changed ones, drop rows for removed ones.",
100
- )
101
- .action(async () => {
102
- const dir = program.opts().dir;
103
- const config = await loadConfig(dir);
104
- const dbPath = getDbPath(dir);
105
- // The migrate() call ensures the index DB is initialized, including
106
- // the context_index table from migration 19, before we try to write.
107
- await withDb(dbPath, migrate);
108
- const spinner = createSpinner("reindexing").start();
109
- const summary = await reindexContext(dir, config, dbPath, {
110
- onProgress: (msg) => spinner.update({ text: msg }),
111
- });
112
- const parts = [
113
- `${summary.added} added`,
114
- `${summary.updated} updated`,
115
- `${summary.unchanged} unchanged`,
116
- `${summary.removed} removed`,
117
- `${summary.chunksWritten} chunks written`,
118
- ];
119
- spinner.success({ text: parts.join(", ") });
120
- });
27
+ function getDir(program: Command): string {
28
+ return program.opts().dir;
29
+ }
121
30
 
122
- // ---- tree ---------------------------------------------------------------
123
- context
124
- .command("tree [path]")
125
- .description("Render the context/ tree (or a subdirectory).")
31
+ /**
32
+ * Slice process.argv from the token after "context" so flags (including
33
+ * --help) and positional args flow through to upstream membot verbatim.
34
+ */
35
+ function getRawContextArgs(): string[] {
36
+ const idx = process.argv.indexOf("context");
37
+ return idx === -1 ? [] : process.argv.slice(idx + 1);
38
+ }
39
+
40
+ async function runMembot(projectDir: string, args: string[]): Promise<number> {
41
+ // Resolve membot's data dir from `membot_scope`:
42
+ // - "global" → ~/.membot (default, shared)
43
+ // - "project" → <projectDir>
44
+ // Forward stdio so the user sees the same output they would running
45
+ // `membot` directly.
46
+ const config = await loadConfig(projectDir);
47
+ const membotDir = resolveMembotDir(projectDir, config);
48
+ const proc = Bun.spawn(["bun", MEMBOT_CLI, "--config", membotDir, ...args], {
49
+ stdout: "inherit",
50
+ stderr: "inherit",
51
+ stdin: "inherit",
52
+ });
53
+ return await proc.exited;
54
+ }
55
+
56
+ /**
57
+ * Copy the system-wide `~/.membot` data dir into the project, mirroring
58
+ * `botholomew mcpx import-global`. Useful when the user has built up a
59
+ * personal knowledge base globally and wants to seed a new project with it.
60
+ *
61
+ * Refuses to overwrite a non-empty project membot store unless `--force` is
62
+ * passed — accidentally clobbering an active project's index is much worse
63
+ * than re-running the import.
64
+ */
65
+ function registerImportGlobal(parent: Command, program: Command): void {
66
+ parent
67
+ .command("import-global")
68
+ .description("Copy system-wide membot data (~/.membot) into this project")
126
69
  .option(
127
- "-d, --max-depth <n>",
128
- "max directory depth to render",
129
- Number.parseInt,
130
- 10,
70
+ "-f, --force",
71
+ "Overwrite an existing index.duckdb in the project",
72
+ false,
131
73
  )
132
- .action(async (path: string | undefined, opts) => {
133
- const dir = program.opts().dir;
134
- const node = await buildTree(dir, path ?? "", opts.maxDepth);
135
- console.log(renderTreeAnsi(node));
136
- });
74
+ .action(async (opts: { force: boolean }) => {
75
+ const globalDir = join(homedir(), ".membot");
76
+ if (!existsSync(globalDir)) {
77
+ logger.error("No global membot data found at ~/.membot");
78
+ process.exit(1);
79
+ }
137
80
 
138
- // ---- stats --------------------------------------------------------------
139
- context
140
- .command("stats")
141
- .description(
142
- "Counts and sizes for files under context/ and rows in the search index.",
143
- )
144
- .action(async () => {
145
- const dir = program.opts().dir;
146
- const dbPath = getDbPath(dir);
147
- const exists = await fileExists(dir, "");
148
- if (!exists) {
149
- logger.dim(`context/ does not exist under ${dir}`);
150
- return;
81
+ const projectDir = getDir(program);
82
+ const config = await loadConfig(projectDir);
83
+ if (config.membot_scope !== "project") {
84
+ logger.warn(
85
+ `membot_scope is "${config.membot_scope}" Botholomew currently reads from ~/.membot. After this import, set membot_scope to "project" in ${getDir(program)}/config/config.json for the project-local copy to take effect.`,
86
+ );
151
87
  }
152
- const entries = await listContextDir(dir, "", { recursive: true });
153
- let files = 0;
154
- let textual = 0;
155
- let bytes = 0;
156
- for (const e of entries) {
157
- if (e.is_directory) continue;
158
- files++;
159
- if (e.is_textual) textual++;
160
- try {
161
- const st = await stat(join(dir, CONTEXT_DIR, e.path));
162
- bytes += st.size;
163
- } catch {
164
- // file vanished mid-walk — skip
88
+ const dest = (name: string) => join(projectDir, name);
89
+ const destDb = dest("index.duckdb");
90
+
91
+ if (existsSync(destDb) && !opts.force) {
92
+ const stat = statSync(destDb);
93
+ if (stat.size > 0) {
94
+ logger.error(
95
+ `Refusing to overwrite ${destDb} (${stat.size} bytes). Pass --force to replace it.`,
96
+ );
97
+ process.exit(1);
165
98
  }
166
99
  }
167
- const idx = await withDb(dbPath, async (conn) => {
168
- await migrate(conn);
169
- return indexStats(conn);
170
- });
171
- const rows = [
172
- ["files", String(files)],
173
- ["textual", String(textual)],
174
- ["binary", String(files - textual)],
175
- ["bytes on disk", formatBytes(bytes)],
176
- ["indexed paths", String(idx.paths)],
177
- ["index chunks", String(idx.chunks)],
178
- ["embedded chunks", String(idx.embedded)],
179
- ];
180
- const labelWidth = Math.max(...rows.map((r) => r[0]?.length ?? 0));
181
- for (const [label, value] of rows) {
182
- console.log(
183
- ` ${ansis.dim((label ?? "").padEnd(labelWidth))} ${value}`,
184
- );
100
+
101
+ mkdirSync(projectDir, { recursive: true });
102
+
103
+ const filesToCopy = ["index.duckdb", "config.json"];
104
+ let copied = 0;
105
+ for (const file of filesToCopy) {
106
+ const src = join(globalDir, file);
107
+ if (!existsSync(src)) continue;
108
+ copyFileSync(src, dest(file));
109
+ logger.success(`Copied ${file}`);
110
+ copied++;
111
+ }
112
+
113
+ if (copied === 0) {
114
+ logger.warn("No files found in ~/.membot to copy.");
115
+ return;
185
116
  }
117
+
118
+ logger.success(
119
+ `Imported ${copied} file(s) from ~/.membot into ${projectDir}`,
120
+ );
186
121
  });
187
122
  }
188
123
 
189
- /**
190
- * Pick a sensible default destination under context/ when the user didn't
191
- * supply --path. Strategy:
192
- * - "<source>/<slugified-url>.md" for MCP-served fetches (e.g. google-docs/...)
193
- * - "url/<slugified-url>.md" for raw HTTP fallbacks
194
- */
195
- function deriveContextPath(url: string, source: string | null): string {
196
- const slug = slugifyUrl(url);
197
- const root = source ?? "url";
198
- return `${root}/${slug}.md`;
199
- }
124
+ export function registerContextCommand(program: Command) {
125
+ const context = program
126
+ .command("context")
127
+ .description(
128
+ "Manage the project's knowledge store via membot (add, search, ls, read, …)",
129
+ );
200
130
 
201
- function slugifyUrl(url: string): string {
202
- let parsed: URL;
203
- try {
204
- parsed = new URL(url);
205
- } catch {
206
- return url.replace(/[^a-z0-9]+/gi, "-").slice(0, 80);
207
- }
208
- const path = parsed.pathname.replace(/^\/+|\/+$/g, "").replace(/\//g, "_");
209
- const base = path || parsed.hostname;
210
- return `${parsed.hostname}_${base}`
211
- .replace(/[^a-z0-9._-]+/gi, "-")
212
- .replace(/-+/g, "-")
213
- .slice(0, 80);
214
- }
131
+ // Botholomew-specific helpers first so they show up before the membot
132
+ // passthrough subcommands in --help.
133
+ registerImportGlobal(context, program);
215
134
 
216
- function renderTreeAnsi(
217
- node: TreeNode,
218
- prefix = "",
219
- isLast = true,
220
- isRoot = true,
221
- ): string {
222
- const lines: string[] = [];
223
- const connector = isRoot ? "" : isLast ? "└── " : "├── ";
224
- const base = node.is_directory
225
- ? ansis.blue(node.name === "." ? "context/" : `${node.name}/`)
226
- : node.name;
227
- const label = node.is_symlink ? `${base} ${ansis.cyan("→")}` : base;
228
- lines.push(`${prefix}${connector}${label}`);
229
- if (node.is_directory && node.children) {
230
- const childPrefix = isRoot ? "" : prefix + (isLast ? " " : "│ ");
231
- const children = node.children;
232
- children.forEach((c, i) => {
233
- const last = i === children.length - 1;
234
- lines.push(renderTreeAnsi(c, childPrefix, last, false));
235
- });
135
+ // One Commander subcommand per membot Operation. We don't redeclare any
136
+ // flags — Commander hands the raw argv slice to membot, which owns the
137
+ // canonical schema.
138
+ for (const op of OPERATIONS) {
139
+ const name = defaultCliName(op);
140
+ context
141
+ .command(name)
142
+ .description(op.description.split("\n")[0] ?? op.description)
143
+ .allowUnknownOption(true)
144
+ .helpOption(false)
145
+ .argument("[args...]", "arguments forwarded to membot")
146
+ .action(async () => {
147
+ const exitCode = await runMembot(getDir(program), getRawContextArgs());
148
+ if (exitCode !== 0) process.exit(exitCode);
149
+ });
236
150
  }
237
- return lines.join("\n");
238
- }
239
151
 
240
- function formatBytes(n: number): string {
241
- if (n === 0) return "0 B";
242
- const units = ["B", "KB", "MB", "GB"];
243
- const i = Math.floor(Math.log(n) / Math.log(1024));
244
- return `${(n / 1024 ** i).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
152
+ // `botholomew context` (no subcommand) membot's default action.
153
+ context.action(async () => {
154
+ const exitCode = await runMembot(getDir(program), getRawContextArgs());
155
+ if (exitCode !== 0) process.exit(exitCode);
156
+ });
245
157
  }
@@ -2,6 +2,13 @@ import type { Command } from "commander";
2
2
  import { initProject } from "../init/index.ts";
3
3
  import { logger } from "../utils/logger.ts";
4
4
 
5
+ function parseScope(value: string): "global" | "project" {
6
+ if (value !== "global" && value !== "project") {
7
+ throw new Error(`scope must be "global" or "project" (got "${value}")`);
8
+ }
9
+ return value;
10
+ }
11
+
5
12
  export function registerInitCommand(program: Command) {
6
13
  program
7
14
  .command("init")
@@ -10,10 +17,24 @@ export function registerInitCommand(program: Command) {
10
17
  "--force",
11
18
  "overwrite existing project files; also bypass the unsupported-filesystem check (iCloud/Dropbox/etc)",
12
19
  )
20
+ .option(
21
+ "--membot-scope <scope>",
22
+ 'where this project reads/writes its knowledge store: "global" (default; shared ~/.membot) or "project" (per-project index.duckdb)',
23
+ parseScope,
24
+ )
25
+ .option(
26
+ "--mcpx-scope <scope>",
27
+ 'where this project reads its MCPX config: "global" (default; shared ~/.mcpx) or "project" (per-project mcpx/)',
28
+ parseScope,
29
+ )
13
30
  .action(async (opts) => {
14
31
  const dir = program.opts().dir;
15
32
  try {
16
- await initProject(dir, { force: opts.force });
33
+ await initProject(dir, {
34
+ force: opts.force,
35
+ membotScope: opts.membotScope,
36
+ mcpxScope: opts.mcpxScope,
37
+ });
17
38
  } catch (err) {
18
39
  logger.error(String(err instanceof Error ? err.message : err));
19
40
  process.exit(1);
@@ -7,8 +7,8 @@ import type { Command } from "commander";
7
7
  import { createSpinner } from "nanospinner";
8
8
  import { loadConfig } from "../config/loader.ts";
9
9
  import { getMcpxDir } from "../constants.ts";
10
- import { writeCapabilitiesFile } from "../context/capabilities.ts";
11
- import { createMcpxClient } from "../mcpx/client.ts";
10
+ import { createMcpxClient, resolveMcpxDir } from "../mcpx/client.ts";
11
+ import { writeCapabilitiesFile } from "../prompts/capabilities.ts";
12
12
  import { registerAllTools } from "../tools/registry.ts";
13
13
  import { logger } from "../utils/logger.ts";
14
14
 
@@ -29,7 +29,11 @@ export async function runMcpx(
29
29
  args: (string | undefined)[],
30
30
  opts?: { inherit?: boolean },
31
31
  ): Promise<string> {
32
- const mcpxDir = getMcpxDir(projectDir);
32
+ // Resolve mcpx config dir from `mcpx_scope`:
33
+ // - "global" → ~/.mcpx (default, shared)
34
+ // - "project" → <projectDir>/mcpx
35
+ const config = await loadConfig(projectDir);
36
+ const mcpxDir = resolveMcpxDir(projectDir, config);
33
37
  const filteredArgs = args.filter((a): a is string => a !== undefined);
34
38
  const proc = Bun.spawn(["bun", MCPX_CLI, ...filteredArgs, "-c", mcpxDir], {
35
39
  stdout: opts?.inherit ? "inherit" : "pipe",
@@ -124,7 +128,15 @@ export function registerMcpxCommand(program: Command) {
124
128
  process.exit(1);
125
129
  }
126
130
 
127
- const projectMcpxDir = getMcpxDir(getDir(program));
131
+ const projectDir = getDir(program);
132
+ const cfg = await loadConfig(projectDir);
133
+ if (cfg.mcpx_scope !== "project") {
134
+ logger.warn(
135
+ `mcpx_scope is "${cfg.mcpx_scope}" — Botholomew currently reads from ~/.mcpx. After this import, set mcpx_scope to "project" in ${projectDir}/config/config.json for the project-local copy to take effect.`,
136
+ );
137
+ }
138
+
139
+ const projectMcpxDir = getMcpxDir(projectDir);
128
140
  if (!existsSync(projectMcpxDir)) {
129
141
  mkdirSync(projectMcpxDir, { recursive: true });
130
142
  }
@@ -149,16 +161,17 @@ export function registerMcpxCommand(program: Command) {
149
161
  `Imported ${copied} file(s) from ~/.mcpx into ${projectMcpxDir}`,
150
162
  );
151
163
 
152
- const projectDir = getDir(program);
153
164
  registerAllTools();
154
- const config = await loadConfig(projectDir);
155
- const mcpxClient = await createMcpxClient(projectDir);
165
+ // After import-global, the freshly-copied files live in the project's
166
+ // mcpx dir read them from there so `capabilities.md` reflects what was
167
+ // just imported, regardless of the user's current `mcpx_scope` setting.
168
+ const mcpxClient = await createMcpxClient(projectMcpxDir);
156
169
  const spinner = createSpinner("Rebuilding capabilities.md").start();
157
170
  try {
158
171
  const result = await writeCapabilitiesFile(
159
172
  projectDir,
160
173
  mcpxClient,
161
- config,
174
+ cfg,
162
175
  (phase) => spinner.update({ text: phase }),
163
176
  );
164
177
  spinner.success({
@@ -1,20 +1,17 @@
1
1
  import { rm } from "node:fs/promises";
2
+ import { join } from "node:path";
2
3
  import ansis from "ansis";
3
4
  import type { Command } from "commander";
4
- import {
5
- CONTEXT_DIR,
6
- getContextDir,
7
- SCHEDULES_DIR,
8
- TASKS_DIR,
9
- THREADS_DIR,
10
- } from "../constants.ts";
5
+ import { loadConfig } from "../config/loader.ts";
6
+ import { SCHEDULES_DIR, TASKS_DIR, THREADS_DIR } from "../constants.ts";
7
+ import { openMembot, resolveMembotDir } from "../mem/client.ts";
11
8
  import { deleteAllSchedules } from "../schedules/store.ts";
12
9
  import { deleteAllTasks } from "../tasks/store.ts";
13
10
  import { deleteAllThreads } from "../threads/store.ts";
14
11
  import { logger } from "../utils/logger.ts";
15
12
  import { listWorkers } from "../workers/store.ts";
16
13
 
17
- type NukeScope = "context" | "tasks" | "schedules" | "threads" | "all";
14
+ type NukeScope = "knowledge" | "tasks" | "schedules" | "threads" | "all";
18
15
 
19
16
  async function ensureNoRunningWorkers(projectDir: string): Promise<boolean> {
20
17
  const running = await listWorkers(projectDir, { status: "running" });
@@ -30,10 +27,50 @@ async function ensureNoRunningWorkers(projectDir: string): Promise<boolean> {
30
27
  return true;
31
28
  }
32
29
 
30
+ /**
31
+ * Erase the membot knowledge store: tombstone every current file and prune
32
+ * non-current versions. We do this through the SDK rather than `rm`ing
33
+ * `index.duckdb` directly so a concurrent open client (e.g. a tool call in
34
+ * mid-flight) doesn't lose data unpredictably; if the file is empty / missing
35
+ * we fall back to deleting it outright.
36
+ */
37
+ async function nukeKnowledge(projectDir: string): Promise<void> {
38
+ const config = await loadConfig(projectDir);
39
+ const membotDir = resolveMembotDir(projectDir, config);
40
+ const indexPath = join(membotDir, "index.duckdb");
41
+ if (config.membot_scope !== "project") {
42
+ logger.warn(
43
+ `membot_scope is "${config.membot_scope}"; this will erase the SHARED knowledge store at ${membotDir}`,
44
+ );
45
+ }
46
+ try {
47
+ const mem = openMembot(membotDir);
48
+ try {
49
+ const list = await mem.list({ limit: 100_000 });
50
+ const paths = list.entries.map((e) => e.logical_path);
51
+ if (paths.length > 0) {
52
+ await mem.remove({ paths });
53
+ }
54
+ // Drop all versions older than now (i.e. everything we just tombstoned).
55
+ await mem.prune({ before: new Date().toISOString() });
56
+ logger.success(
57
+ `Cleared the membot knowledge store (${paths.length} entries removed, history pruned)`,
58
+ );
59
+ } finally {
60
+ await mem.close();
61
+ }
62
+ } catch (err) {
63
+ logger.warn(
64
+ `membot prune failed (${(err as Error).message}); removing ${indexPath} instead`,
65
+ );
66
+ await rm(indexPath, { force: true });
67
+ logger.success(`Removed ${indexPath}`);
68
+ }
69
+ }
70
+
33
71
  async function runNuke(projectDir: string, scope: NukeScope): Promise<void> {
34
- if (scope === "context" || scope === "all") {
35
- await rm(getContextDir(projectDir), { recursive: true, force: true });
36
- logger.success(`Removed ${CONTEXT_DIR}/ directory`);
72
+ if (scope === "knowledge" || scope === "all") {
73
+ await nukeKnowledge(projectDir);
37
74
  }
38
75
  if (scope === "tasks" || scope === "all") {
39
76
  const n = await deleteAllTasks(projectDir);
@@ -71,7 +108,7 @@ function registerScope(
71
108
  console.log(ansis.red.bold(`Nuke scope: ${scope}`));
72
109
  console.log(
73
110
  ansis.yellow(
74
- `Re-run with --yes to confirm. This will delete files on disk; cannot be undone.`,
111
+ `Re-run with --yes to confirm. This will delete data; cannot be undone.`,
75
112
  ),
76
113
  );
77
114
  process.exit(1);
@@ -89,8 +126,8 @@ export function registerNukeCommand(program: Command) {
89
126
  registerScope(
90
127
  program,
91
128
  nuke,
92
- "context",
93
- `Erase the entire ${CONTEXT_DIR}/ directory (user-curated knowledge)`,
129
+ "knowledge",
130
+ "Erase the entire membot knowledge store (every current entry tombstoned and pruned)",
94
131
  );
95
132
  registerScope(
96
133
  program,
@@ -114,6 +151,6 @@ export function registerNukeCommand(program: Command) {
114
151
  program,
115
152
  nuke,
116
153
  "all",
117
- "Erase all agent-writable data: context/, tasks/, schedules/, threads/",
154
+ "Erase all agent-writable data: membot store, tasks/, schedules/, threads/",
118
155
  );
119
156
  }
@@ -1,21 +1,24 @@
1
1
  import type { Command } from "commander";
2
2
  import { loadConfig } from "../config/loader.ts";
3
- import { embedSingle } from "../context/embedder.ts";
3
+ import { openMembot, resolveMembotDir } from "../mem/client.ts";
4
4
  import { logger } from "../utils/logger.ts";
5
- import { withDb } from "./with-db.ts";
6
5
 
7
6
  export function registerPrepareCommand(program: Command) {
8
7
  program
9
8
  .command("prepare")
10
- .description("Verify API keys and connectivity. Run this on first setup.")
11
- .action(() =>
12
- withDb(program, async (_conn, dir) => {
13
- logger.info("Preparing Botholomew...");
14
- const config = await loadConfig(dir);
15
- await embedSingle("test", config);
16
- logger.success(
17
- `Embedding model ${config.embedding_model} is loaded and ready.`,
18
- );
19
- }),
20
- );
9
+ .description(
10
+ "Verify the project is healthy: load config and open the membot knowledge store (triggers any first-run migration / model download).",
11
+ )
12
+ .action(async () => {
13
+ const projectDir = program.opts().dir as string;
14
+ logger.info("Preparing Botholomew...");
15
+ const config = await loadConfig(projectDir);
16
+ const mem = openMembot(resolveMembotDir(projectDir, config));
17
+ try {
18
+ await mem.connect();
19
+ logger.success("membot knowledge store opened successfully");
20
+ } finally {
21
+ await mem.close();
22
+ }
23
+ });
21
24
  }
@@ -1,5 +1,4 @@
1
- import { mkdirSync } from "node:fs";
2
- import { getConfigPath, getModelsDir } from "../constants.ts";
1
+ import { getConfigPath } from "../constants.ts";
3
2
  import { setLogLevel } from "../utils/logger.ts";
4
3
  import { type BotholomewConfig, DEFAULT_CONFIG } from "./schemas.ts";
5
4
 
@@ -23,12 +22,6 @@ export async function loadConfig(
23
22
 
24
23
  setLogLevel(config.log_level);
25
24
 
26
- const modelsDir = getModelsDir(projectDir);
27
- mkdirSync(modelsDir, { recursive: true });
28
- // Dynamic import keeps @huggingface/transformers (heavy, pulls ONNX runtime) out of commands that never embed.
29
- const { setEmbeddingCacheDir } = await import("../context/embedder-impl.ts");
30
- setEmbeddingCacheDir(modelsDir);
31
-
32
25
  return config;
33
26
  }
34
27