botholomew 0.18.1 → 0.18.3

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/README.md CHANGED
@@ -19,7 +19,7 @@ agent's world is a per-project knowledge store managed by
19
19
  search, and delete is addressed by `logical_path` (a DB key, not a
20
20
  filesystem path), so a prompt-injected attempt to reach `~/.ssh/id_rsa`
21
21
  has nowhere to land. Local files and URLs are brought in through
22
- `botholomew context add`. External capabilities (email, Slack, the web,
22
+ `botholomew membot add`. External capabilities (email, Slack, the web,
23
23
  and hundreds of other services) are granted deliberately, per project,
24
24
  through MCP servers wired up via
25
25
  [MCPX](https://github.com/evantahler/mcpx).
@@ -165,11 +165,6 @@ semantic search, append-only versioning, and URL refresh all live there.
165
165
 
166
166
  ![CLI walkthrough: task list, task add, schedule list, context list](docs/assets/cli-tour.gif)
167
167
 
168
- Pulling a remote document straight into the knowledge store via an
169
- LLM-driven fetcher (`mcp_search` → `mcp_exec` → `membot_pipe`):
170
-
171
- ![Importing a Google Doc into context](docs/assets/context-import-gdoc.gif)
172
-
173
168
  | Command | Purpose |
174
169
  |---|---|
175
170
  | `botholomew init` | Initialize the current directory as a project (refuses on iCloud/Dropbox/NFS without `--force`) |
@@ -178,8 +173,8 @@ LLM-driven fetcher (`mcp_search` → `mcp_exec` → `membot_pipe`):
178
173
  | `botholomew chat` | Interactive Ink/React TUI |
179
174
  | `botholomew task list\|add\|view\|update\|reset\|delete` | Manage the task queue (markdown files in `tasks/`) |
180
175
  | `botholomew schedule list\|add\|view\|enable\|disable\|trigger\|delete` | Recurring work (markdown files in `schedules/`) |
181
- | `botholomew context add\|ls\|tree\|read\|write\|search\|info\|versions\|diff\|refresh\|…` | Knowledge-store passthrough to [`membot`](https://github.com/evantahler/membot) — `--config` is set to the project dir automatically |
182
- | `botholomew context import-global` | Seed the project from `~/.membot` (copies `index.duckdb` + `config.json` in) |
176
+ | `botholomew membot add\|ls\|tree\|read\|write\|search\|info\|versions\|diff\|refresh\|…` | Knowledge-store passthrough to [`membot`](https://github.com/evantahler/membot) — `--config` is resolved from `membot_scope` (default `~/.membot`) |
177
+ | `botholomew membot import-global` | Seed the project from `~/.membot` (copies `index.duckdb` + `config.json` in) |
183
178
  | `botholomew capabilities` | Rescan built-in + MCPX tools and rewrite `prompts/capabilities.md` |
184
179
  | `botholomew prompts list\|show\|create\|edit\|delete\|validate` | CRUD over the markdown files in `prompts/` (with strict frontmatter validation) |
185
180
  | `botholomew mcpx servers\|list\|add\|remove\|info\|search\|exec\|ping\|auth\|deauth\|import-global\|…` | Configure external MCP servers (passthrough to `mcpx`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.18.1",
3
+ "version": "0.18.3",
4
4
  "description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,31 +27,31 @@
27
27
  "docs:preview": "vitepress preview docs"
28
28
  },
29
29
  "dependencies": {
30
- "@anthropic-ai/sdk": "^0.92.0",
31
- "@evantahler/mcpx": "0.21.6",
32
- "ansis": "^4.2.0",
30
+ "@anthropic-ai/sdk": "^0.95.2",
31
+ "@evantahler/mcpx": "0.21.7",
32
+ "ansis": "^4.3.0",
33
33
  "commander": "^14.0.0",
34
34
  "gray-matter": "^4.0.3",
35
35
  "ink": "^7.0.1",
36
36
  "ink-spinner": "^5.0.0",
37
37
  "ink-text-input": "^6.0.0",
38
38
  "istextorbinary": "^9.5.0",
39
- "membot": "^0.14.0",
39
+ "membot": "^0.15.0",
40
40
  "nanospinner": "^1.2.2",
41
- "react": "^19.2.0",
41
+ "react": "^19.2.6",
42
42
  "uuid": "^14.0.0",
43
43
  "wrap-ansi": "^10.0.0",
44
- "zod": "^4.4.2"
44
+ "zod": "^4.4.3"
45
45
  },
46
46
  "devDependencies": {
47
- "@biomejs/biome": "^2.4.14",
47
+ "@biomejs/biome": "^2.4.15",
48
48
  "@types/bun": "latest",
49
49
  "@types/react": "^19.2.0",
50
50
  "@types/uuid": "^11.0.0",
51
51
  "typescript": "^6.0.3",
52
52
  "vitepress": "^1.5.0",
53
- "vitepress-plugin-llms": "^1.12.1",
54
- "vue": "^3.5.0"
53
+ "vitepress-plugin-llms": "^1.12.2",
54
+ "vue": "^3.5.34"
55
55
  },
56
56
  "trustedDependencies": [
57
57
  "protobufjs"
package/src/chat/agent.ts CHANGED
@@ -7,8 +7,13 @@ import type {
7
7
  ToolUseBlock,
8
8
  } from "@anthropic-ai/sdk/resources/messages";
9
9
  import type { McpxClient } from "@evantahler/mcpx";
10
- import type { MembotClient } from "membot";
11
10
  import type { BotholomewConfig } from "../config/schemas.ts";
11
+ import {
12
+ openMembot,
13
+ resolveMembotDir,
14
+ sharedWithMem,
15
+ type WithMem,
16
+ } from "../mem/client.ts";
12
17
  import { logInteraction } from "../threads/store.ts";
13
18
  import { registerAllTools } from "../tools/registry.ts";
14
19
  import {
@@ -211,7 +216,6 @@ export async function runChatTurn(input: {
211
216
  messages: MessageParam[];
212
217
  projectDir: string;
213
218
  config: Required<BotholomewConfig>;
214
- mem: MembotClient;
215
219
  threadId: string;
216
220
  mcpxClient: McpxClient | null;
217
221
  callbacks: ChatTurnCallbacks;
@@ -223,12 +227,43 @@ export async function runChatTurn(input: {
223
227
  * Production callers should leave both unset. */
224
228
  _testClient?: Anthropic;
225
229
  _testMaxInputTokens?: number;
230
+ /** Test seam: when set, the turn uses this `withMem` instead of opening its
231
+ * own membot client. Production callers leave this unset. */
232
+ _testWithMem?: WithMem;
233
+ }): Promise<void> {
234
+ // Open membot for the duration of this turn so the DuckDB file lock is held
235
+ // only while the turn is actively executing — idle chat sessions leave the
236
+ // shared `~/.membot` store available to other Botholomew processes.
237
+ if (input._testWithMem) {
238
+ await runChatTurnBody({ ...input, withMem: input._testWithMem });
239
+ return;
240
+ }
241
+ const mem = openMembot(resolveMembotDir(input.projectDir, input.config));
242
+ await mem.connect();
243
+ try {
244
+ await runChatTurnBody({ ...input, withMem: sharedWithMem(mem) });
245
+ } finally {
246
+ await mem.close();
247
+ }
248
+ }
249
+
250
+ async function runChatTurnBody(input: {
251
+ messages: MessageParam[];
252
+ projectDir: string;
253
+ config: Required<BotholomewConfig>;
254
+ withMem: WithMem;
255
+ threadId: string;
256
+ mcpxClient: McpxClient | null;
257
+ callbacks: ChatTurnCallbacks;
258
+ session?: ChatSession;
259
+ _testClient?: Anthropic;
260
+ _testMaxInputTokens?: number;
226
261
  }): Promise<void> {
227
262
  const {
228
263
  messages,
229
264
  projectDir,
230
265
  config,
231
- mem,
266
+ withMem,
232
267
  threadId,
233
268
  mcpxClient,
234
269
  callbacks,
@@ -419,7 +454,7 @@ export async function runChatTurn(input: {
419
454
  toolUseBlocks.map(async (toolUse) => {
420
455
  const start = Date.now();
421
456
  const result = await executeChatToolCall(toolUse, {
422
- mem,
457
+ withMem,
423
458
  projectDir,
424
459
  config,
425
460
  mcpxClient,
@@ -470,7 +505,7 @@ export async function runChatTurn(input: {
470
505
  }
471
506
 
472
507
  interface ChatToolCallCtx {
473
- mem: MembotClient;
508
+ withMem: WithMem;
474
509
  projectDir: string;
475
510
  config: Required<BotholomewConfig>;
476
511
  mcpxClient: McpxClient | null;
@@ -1,10 +1,8 @@
1
1
  import type { MessageStream } from "@anthropic-ai/sdk/lib/MessageStream";
2
2
  import type { MessageParam } from "@anthropic-ai/sdk/resources/messages";
3
- import type { MembotClient } from "membot";
4
3
  import { loadConfig } from "../config/loader.ts";
5
4
  import type { BotholomewConfig } from "../config/schemas.ts";
6
5
  import { createMcpxClient, resolveMcpxDir } from "../mcpx/client.ts";
7
- import { openMembot, resolveMembotDir } from "../mem/client.ts";
8
6
  import { loadSkills } from "../skills/loader.ts";
9
7
  import type { SkillDefinition } from "../skills/parser.ts";
10
8
  import {
@@ -19,7 +17,6 @@ import { generateThreadTitle } from "../utils/title.ts";
19
17
  import { type ChatTurnCallbacks, runChatTurn } from "./agent.ts";
20
18
 
21
19
  export interface ChatSession {
22
- mem: MembotClient;
23
20
  threadId: string;
24
21
  projectDir: string;
25
22
  config: Required<BotholomewConfig>;
@@ -60,8 +57,6 @@ export async function startChatSession(
60
57
  );
61
58
  }
62
59
 
63
- const mem = openMembot(resolveMembotDir(projectDir, config));
64
- await mem.connect();
65
60
  await ensureThreadsDir(projectDir);
66
61
 
67
62
  let threadId: string;
@@ -106,11 +101,9 @@ export async function startChatSession(
106
101
 
107
102
  const cleanup = async () => {
108
103
  await mcpxClient?.close();
109
- await mem.close();
110
104
  };
111
105
 
112
106
  return {
113
- mem,
114
107
  threadId,
115
108
  projectDir,
116
109
  config,
@@ -158,7 +151,6 @@ export async function sendMessage(
158
151
  messages: session.messages,
159
152
  projectDir: session.projectDir,
160
153
  config: session.config,
161
- mem: session.mem,
162
154
  threadId: session.threadId,
163
155
  mcpxClient: session.mcpxClient,
164
156
  callbacks,
package/src/cli.ts CHANGED
@@ -5,9 +5,9 @@ import { program } from "commander";
5
5
  import { registerCapabilitiesCommand } from "./commands/capabilities.ts";
6
6
  import { registerChatCommand } from "./commands/chat.ts";
7
7
  import { registerCheckUpdateCommand } from "./commands/check-update.ts";
8
- import { registerContextCommand } from "./commands/context.ts";
9
8
  import { registerInitCommand } from "./commands/init.ts";
10
9
  import { registerMcpxCommand } from "./commands/mcpx.ts";
10
+ import { registerMembotCommand } from "./commands/membot.ts";
11
11
  import { registerNukeCommand } from "./commands/nuke.ts";
12
12
  import { registerPrepareCommand } from "./commands/prepare.ts";
13
13
  import { registerPromptsCommand } from "./commands/prompts.ts";
@@ -44,7 +44,7 @@ registerTaskCommand(program);
44
44
  registerThreadCommand(program);
45
45
  registerScheduleCommand(program);
46
46
  registerChatCommand(program);
47
- registerContextCommand(program);
47
+ registerMembotCommand(program);
48
48
  registerCapabilitiesCommand(program);
49
49
  registerPromptsCommand(program);
50
50
  registerMcpxCommand(program);
@@ -29,11 +29,11 @@ function getDir(program: Command): string {
29
29
  }
30
30
 
31
31
  /**
32
- * Slice process.argv from the token after "context" so flags (including
32
+ * Slice process.argv from the token after "membot" so flags (including
33
33
  * --help) and positional args flow through to upstream membot verbatim.
34
34
  */
35
- function getRawContextArgs(): string[] {
36
- const idx = process.argv.indexOf("context");
35
+ function getRawMembotArgs(): string[] {
36
+ const idx = process.argv.indexOf("membot");
37
37
  return idx === -1 ? [] : process.argv.slice(idx + 1);
38
38
  }
39
39
 
@@ -121,37 +121,37 @@ function registerImportGlobal(parent: Command, program: Command): void {
121
121
  });
122
122
  }
123
123
 
124
- export function registerContextCommand(program: Command) {
125
- const context = program
126
- .command("context")
124
+ export function registerMembotCommand(program: Command) {
125
+ const membot = program
126
+ .command("membot")
127
127
  .description(
128
- "Manage the project's knowledge store via membot (add, search, ls, read, …)",
128
+ "Manage the project's knowledge store (passthrough to membot: add, search, ls, read, …)",
129
129
  );
130
130
 
131
131
  // Botholomew-specific helpers first so they show up before the membot
132
132
  // passthrough subcommands in --help.
133
- registerImportGlobal(context, program);
133
+ registerImportGlobal(membot, program);
134
134
 
135
135
  // One Commander subcommand per membot Operation. We don't redeclare any
136
136
  // flags — Commander hands the raw argv slice to membot, which owns the
137
137
  // canonical schema.
138
138
  for (const op of OPERATIONS) {
139
139
  const name = defaultCliName(op);
140
- context
140
+ membot
141
141
  .command(name)
142
142
  .description(op.description.split("\n")[0] ?? op.description)
143
143
  .allowUnknownOption(true)
144
144
  .helpOption(false)
145
145
  .argument("[args...]", "arguments forwarded to membot")
146
146
  .action(async () => {
147
- const exitCode = await runMembot(getDir(program), getRawContextArgs());
147
+ const exitCode = await runMembot(getDir(program), getRawMembotArgs());
148
148
  if (exitCode !== 0) process.exit(exitCode);
149
149
  });
150
150
  }
151
151
 
152
- // `botholomew context` (no subcommand) → membot's default action.
153
- context.action(async () => {
154
- const exitCode = await runMembot(getDir(program), getRawContextArgs());
152
+ // `botholomew membot` (no subcommand) → membot's default action.
153
+ membot.action(async () => {
154
+ const exitCode = await runMembot(getDir(program), getRawMembotArgs());
155
155
  if (exitCode !== 0) process.exit(exitCode);
156
156
  });
157
157
  }
package/src/mem/client.ts CHANGED
@@ -31,3 +31,52 @@ export function resolveMembotDir(
31
31
  export function openMembot(dataDir: string): MembotClient {
32
32
  return new MembotClient({ configFlag: dataDir });
33
33
  }
34
+
35
+ /**
36
+ * A scope-bound membot accessor passed via `ToolContext.withMem`. Each call
37
+ * runs `fn` with a live `MembotClient` and is responsible for whatever
38
+ * open/close lifecycle is appropriate for the surrounding scope.
39
+ */
40
+ export type WithMem = <T>(fn: (mem: MembotClient) => Promise<T>) => Promise<T>;
41
+
42
+ /**
43
+ * Build a `WithMem` that forwards to an already-open client, **serializing**
44
+ * concurrent callers through a per-client queue. Used inside a chat turn or
45
+ * worker tick that opens membot once and shares it across all tool calls
46
+ * within that scope.
47
+ *
48
+ * Why serialize: every `MembotClient.<op>` releases the underlying DuckDB
49
+ * instance in `finally` (so other processes can grab the file lock). When
50
+ * two ops run on the same client in parallel via `Promise.all`, the first to
51
+ * finish closes the connection out from under the slower one, and the slower
52
+ * op's next query hangs on a disposed handle. Queuing here keeps the "many
53
+ * parallel tool calls in one chat turn" pattern correct without forcing
54
+ * unrelated (non-membot) tool calls to serialize.
55
+ */
56
+ export function sharedWithMem(mem: MembotClient): WithMem {
57
+ let queue: Promise<unknown> = Promise.resolve();
58
+ return <T>(fn: (m: MembotClient) => Promise<T>): Promise<T> => {
59
+ const result = queue.then(() => fn(mem));
60
+ // One failed op shouldn't poison the rest of the queue.
61
+ queue = result.catch(() => {});
62
+ return result as Promise<T>;
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Build a `WithMem` that opens a fresh `MembotClient` per call, runs `fn`,
68
+ * and closes it in `finally`. Used in sparse, user-triggered contexts (e.g.
69
+ * the TUI ContextPanel) where holding the DuckDB file lock between ops would
70
+ * block other Botholomew processes from the shared `~/.membot` store.
71
+ */
72
+ export function scopedWithMem(dataDir: string): WithMem {
73
+ return async (fn) => {
74
+ const mem = openMembot(dataDir);
75
+ try {
76
+ await mem.connect();
77
+ return await fn(mem);
78
+ } finally {
79
+ await mem.close();
80
+ }
81
+ };
82
+ }
@@ -86,8 +86,10 @@ export function adaptOperation(
86
86
  outputSchema: membotOutputSchema,
87
87
  execute: async (input, ctx: ToolContext) => {
88
88
  try {
89
- const method = ctx.mem[methodName] as (i: unknown) => Promise<unknown>;
90
- const data = await method.call(ctx.mem, input);
89
+ const data = await ctx.withMem(async (mem) => {
90
+ const method = mem[methodName] as (i: unknown) => Promise<unknown>;
91
+ return method.call(mem, input);
92
+ });
91
93
  return { is_error: false, data };
92
94
  } catch (err) {
93
95
  if (isHelpfulError(err)) {
@@ -103,7 +105,7 @@ export function adaptOperation(
103
105
  error_type: "internal_error",
104
106
  message: err instanceof Error ? err.message : String(err),
105
107
  next_action_hint:
106
- "Check the project's membot store (run `botholomew context stats`) and try again. If this persists, file a bug.",
108
+ "Check the project's membot store (run `botholomew membot stats`) and try again. If this persists, file a bug.",
107
109
  };
108
110
  }
109
111
  },
@@ -27,12 +27,14 @@ export const membotCopyTool = {
27
27
  outputSchema,
28
28
  execute: async (input, ctx) => {
29
29
  try {
30
- const src = await ctx.mem.read({ logical_path: input.from_logical_path });
31
- const written = await ctx.mem.write({
32
- logical_path: input.to_logical_path,
33
- content: src.content ?? "",
34
- change_note:
35
- input.change_note ?? `copied from ${input.from_logical_path}`,
30
+ const written = await ctx.withMem(async (mem) => {
31
+ const src = await mem.read({ logical_path: input.from_logical_path });
32
+ return mem.write({
33
+ logical_path: input.to_logical_path,
34
+ content: src.content ?? "",
35
+ change_note:
36
+ input.change_note ?? `copied from ${input.from_logical_path}`,
37
+ });
36
38
  });
37
39
  return {
38
40
  is_error: false,
@@ -25,7 +25,9 @@ export const membotCountLinesTool = {
25
25
  outputSchema,
26
26
  execute: async (input, ctx) => {
27
27
  try {
28
- const result = await ctx.mem.read({ logical_path: input.logical_path });
28
+ const result = await ctx.withMem((mem) =>
29
+ mem.read({ logical_path: input.logical_path }),
30
+ );
29
31
  const content = result.content ?? "";
30
32
  const lineCount = content === "" ? 0 : content.split("\n").length;
31
33
  return {
@@ -38,12 +38,14 @@ export const membotEditTool = {
38
38
  outputSchema,
39
39
  execute: async (input, ctx) => {
40
40
  try {
41
- const current = await ctx.mem.read({ logical_path: input.logical_path });
42
- const next = applyLinePatches(current.content ?? "", input.patches);
43
- const result = await ctx.mem.write({
44
- logical_path: input.logical_path,
45
- content: next,
46
- change_note: input.change_note,
41
+ const result = await ctx.withMem(async (mem) => {
42
+ const current = await mem.read({ logical_path: input.logical_path });
43
+ const next = applyLinePatches(current.content ?? "", input.patches);
44
+ return mem.write({
45
+ logical_path: input.logical_path,
46
+ content: next,
47
+ change_note: input.change_note,
48
+ });
47
49
  });
48
50
  return {
49
51
  is_error: false,
@@ -23,7 +23,9 @@ export const membotExistsTool = {
23
23
  outputSchema,
24
24
  execute: async (input, ctx) => {
25
25
  try {
26
- await ctx.mem.info({ logical_path: input.logical_path });
26
+ await ctx.withMem((mem) =>
27
+ mem.info({ logical_path: input.logical_path }),
28
+ );
27
29
  return {
28
30
  is_error: false,
29
31
  exists: true,
@@ -143,11 +143,13 @@ export const membotPipeTool = {
143
143
  }
144
144
 
145
145
  try {
146
- const written = await ctx.mem.write({
147
- logical_path: input.logical_path,
148
- content: innerOutput,
149
- change_note: input.change_note,
150
- });
146
+ const written = await ctx.withMem((mem) =>
147
+ mem.write({
148
+ logical_path: input.logical_path,
149
+ content: innerOutput,
150
+ change_note: input.change_note,
151
+ }),
152
+ );
151
153
  return {
152
154
  is_error: false,
153
155
  logical_path: written.logical_path,
package/src/tools/tool.ts CHANGED
@@ -1,17 +1,18 @@
1
1
  import type { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/messages";
2
2
  import type { McpxClient } from "@evantahler/mcpx";
3
- import type { MembotClient } from "membot";
4
3
  import { z } from "zod";
5
4
  import type { BotholomewConfig } from "../config/schemas.ts";
5
+ import type { WithMem } from "../mem/client.ts";
6
6
 
7
7
  export interface ToolContext {
8
8
  /**
9
- * Per-process membot client. Backs every `membot_*` tool. Membot manages
10
- * its own DuckDB connection lifecycle internally (lazy claim, release
11
- * between operations), so tools just call `ctx.mem.<op>(...)` directly —
12
- * no per-call open/close needed.
9
+ * Scope-bound membot accessor. Each `membot_*` tool wraps its work in
10
+ * `ctx.withMem((mem) => mem.<op>(...))`. Backed by `sharedWithMem` inside a
11
+ * chat turn / worker tick (one connection, many ops) or `scopedWithMem` in
12
+ * sparse callers like the TUI panel (open per op, release the DuckDB file
13
+ * lock between calls).
13
14
  */
14
- mem: MembotClient;
15
+ withMem: WithMem;
15
16
  projectDir: string;
16
17
  config: Required<BotholomewConfig>;
17
18
  mcpxClient: McpxClient | null;
@@ -1,7 +1,7 @@
1
1
  import { Box, Text, useInput } from "ink";
2
- import type { MembotClient } from "membot";
3
2
  import { memo, useCallback, useEffect, useMemo, useState } from "react";
4
- import { openMembot } from "../../mem/client.ts";
3
+ import { loadConfig } from "../../config/loader.ts";
4
+ import { resolveMembotDir, scopedWithMem } from "../../mem/client.ts";
5
5
  import {
6
6
  detailPaneBorderProps,
7
7
  type FocusState,
@@ -36,8 +36,8 @@ const PAGE_SCROLL_LINES = 10;
36
36
  * Browse the membot knowledge store. Each row is a current-version entry; the
37
37
  * detail pane shows the cleaned markdown surrogate. Membot has no real
38
38
  * directories — `logical_path` segments are just slashes — so this is a flat
39
- * paginated list rather than a tree drill-in. Use `botholomew context tree` /
40
- * `botholomew context search` for hierarchical or content-based discovery.
39
+ * paginated list rather than a tree drill-in. Use `botholomew membot tree` /
40
+ * `botholomew membot search` for hierarchical or content-based discovery.
41
41
  */
42
42
  export const ContextPanel = memo(function ContextPanel({
43
43
  projectDir,
@@ -46,16 +46,26 @@ export const ContextPanel = memo(function ContextPanel({
46
46
  const { rows: termRows, cols: termCols } = useTerminalSize();
47
47
  const detailWidth = Math.max(1, termCols - SIDEBAR_WIDTH - 5);
48
48
 
49
- // One MembotClient per panel mount. Membot manages its DB lock per-op so
50
- // sharing the file with the chat session / workers is safe.
51
- const [client, setClient] = useState<MembotClient | null>(null);
49
+ // Open a fresh membot client per op (list/read/delete) instead of holding
50
+ // one for the panel's lifetime. Holding the DuckDB file lock for the panel
51
+ // mount would block other Botholomew processes (workers, chat turns, the
52
+ // membot CLI) from the shared `~/.membot` store while this panel sits idle.
53
+ const [membotDir, setMembotDir] = useState<string | null>(null);
52
54
  useEffect(() => {
53
- const c = openMembot(projectDir);
54
- setClient(c);
55
+ let cancelled = false;
56
+ (async () => {
57
+ const config = await loadConfig(projectDir);
58
+ if (cancelled) return;
59
+ setMembotDir(resolveMembotDir(projectDir, config));
60
+ })();
55
61
  return () => {
56
- void c.close();
62
+ cancelled = true;
57
63
  };
58
64
  }, [projectDir]);
65
+ const withMem = useMemo(
66
+ () => (membotDir ? scopedWithMem(membotDir) : null),
67
+ [membotDir],
68
+ );
59
69
 
60
70
  const [entries, setEntries] = useState<ContextEntry[]>([]);
61
71
  const [selectedIndex, setSelectedIndex] = useState(0);
@@ -70,9 +80,9 @@ export const ContextPanel = memo(function ContextPanel({
70
80
  const visibleRows = Math.max(1, termRows - 6);
71
81
 
72
82
  const refresh = useCallback(async () => {
73
- if (!client) return;
83
+ if (!withMem) return;
74
84
  try {
75
- const out = await client.list({ limit: 500 });
85
+ const out = await withMem((mem) => mem.list({ limit: 500 }));
76
86
  const list = out.entries.map((e) => ({
77
87
  logical_path: e.logical_path,
78
88
  version_id: e.version_id,
@@ -89,7 +99,7 @@ export const ContextPanel = memo(function ContextPanel({
89
99
  setSelectedIndex(0);
90
100
  setSidebarScrollOffset(0);
91
101
  }
92
- }, [client]);
102
+ }, [withMem]);
93
103
 
94
104
  useEffect(() => {
95
105
  refresh();
@@ -107,14 +117,13 @@ export const ContextPanel = memo(function ContextPanel({
107
117
 
108
118
  useEffect(() => {
109
119
  let cancelled = false;
110
- if (!selectedEntry || !client) {
120
+ if (!selectedEntry || !withMem) {
111
121
  setFileContent(null);
112
122
  setDetailScroll(0);
113
123
  return;
114
124
  }
115
125
  setDetailScroll(0);
116
- client
117
- .read({ logical_path: selectedEntry.logical_path })
126
+ withMem((mem) => mem.read({ logical_path: selectedEntry.logical_path }))
118
127
  .then((result) => {
119
128
  if (cancelled) return;
120
129
  setFileContent({
@@ -132,7 +141,7 @@ export const ContextPanel = memo(function ContextPanel({
132
141
  return () => {
133
142
  cancelled = true;
134
143
  };
135
- }, [client, selectedEntry]);
144
+ }, [withMem, selectedEntry]);
136
145
 
137
146
  const detailLines = useMemo(() => {
138
147
  if (!fileContent || !selectedEntry) return [];
@@ -157,11 +166,11 @@ export const ContextPanel = memo(function ContextPanel({
157
166
 
158
167
  const deleteConfirm = useDeleteConfirm(() => {
159
168
  const entry = selectedEntryRef.current;
160
- if (!entry || !client) return;
169
+ if (!entry || !withMem) return;
161
170
  const path = entry.logical_path;
162
171
  (async () => {
163
172
  try {
164
- await client.remove({ paths: [path] });
173
+ await withMem((mem) => mem.remove({ paths: [path] }));
165
174
  } catch {
166
175
  // ignore — refresh will reflect any partial state
167
176
  }
@@ -227,7 +236,7 @@ export const ContextPanel = memo(function ContextPanel({
227
236
  </Box>
228
237
  {entries.length === 0 ? (
229
238
  <Box paddingX={1}>
230
- <Text dimColor>(empty — try `botholomew context add …`)</Text>
239
+ <Text dimColor>(empty — try `botholomew membot add …`)</Text>
231
240
  </Box>
232
241
  ) : (
233
242
  visibleItems.map((entry, vi) => {
@@ -2,7 +2,6 @@ import { hostname } from "node:os";
2
2
  import ansis from "ansis";
3
3
  import { loadConfig } from "../config/loader.ts";
4
4
  import { createMcpxClient, resolveMcpxDir } from "../mcpx/client.ts";
5
- import { openMembot, resolveMembotDir } from "../mem/client.ts";
6
5
  import { logger } from "../utils/logger.ts";
7
6
  import { uuidv7 } from "../utils/uuid.ts";
8
7
  import { markWorkerStopped, registerWorker } from "../workers/store.ts";
@@ -87,10 +86,6 @@ export async function startWorker(
87
86
  const evalSchedules = options.evalSchedules ?? !taskId;
88
87
 
89
88
  const config = await loadConfig(projectDir);
90
- const mem = openMembot(resolveMembotDir(projectDir, config));
91
- // Surface init-time failures (bad config, locked DB) up front rather than
92
- // letting the first tool call do it.
93
- await mem.connect();
94
89
 
95
90
  const mcpxClient = await createMcpxClient(resolveMcpxDir(projectDir, config));
96
91
  if (mcpxClient) {
@@ -127,7 +122,6 @@ export async function startWorker(
127
122
  stopHeartbeat();
128
123
  stopReaper();
129
124
  await mcpxClient?.close();
130
- await mem.close();
131
125
  try {
132
126
  await markWorkerStopped(projectDir, workerId);
133
127
  } catch (err) {
@@ -150,7 +144,6 @@ export async function startWorker(
150
144
  if (taskId) {
151
145
  await runSpecificTask({
152
146
  projectDir,
153
- mem,
154
147
  config,
155
148
  workerId,
156
149
  taskId,
@@ -160,7 +153,6 @@ export async function startWorker(
160
153
  } else {
161
154
  await tick({
162
155
  projectDir,
163
- mem,
164
156
  config,
165
157
  workerId,
166
158
  mcpxClient,
@@ -181,7 +173,6 @@ export async function startWorker(
181
173
  try {
182
174
  didWork = await tick({
183
175
  projectDir,
184
- mem,
185
176
  config,
186
177
  workerId,
187
178
  mcpxClient,
@@ -207,6 +198,5 @@ export async function startWorker(
207
198
  logger.warn(`failed to mark worker stopped: ${err}`);
208
199
  }
209
200
  await mcpxClient?.close();
210
- await mem.close();
211
201
  }
212
202
  }
package/src/worker/llm.ts CHANGED
@@ -5,8 +5,8 @@ import type {
5
5
  ToolUseBlock,
6
6
  } from "@anthropic-ai/sdk/resources/messages";
7
7
  import type { McpxClient } from "@evantahler/mcpx";
8
- import type { MembotClient } from "membot";
9
8
  import type { BotholomewConfig } from "../config/schemas.ts";
9
+ import type { WithMem } from "../mem/client.ts";
10
10
  import type { Task } from "../tasks/schema.ts";
11
11
  import { getTask } from "../tasks/store.ts";
12
12
  import { logInteraction } from "../threads/store.ts";
@@ -50,7 +50,7 @@ export async function runAgentLoop(input: {
50
50
  systemPrompt: string;
51
51
  task: Task;
52
52
  config: Required<BotholomewConfig>;
53
- mem: MembotClient;
53
+ withMem: WithMem;
54
54
  threadId: string;
55
55
  projectDir: string;
56
56
  workerId?: string;
@@ -61,7 +61,7 @@ export async function runAgentLoop(input: {
61
61
  systemPrompt,
62
62
  task,
63
63
  config,
64
- mem,
64
+ withMem,
65
65
  threadId,
66
66
  projectDir,
67
67
  workerId,
@@ -205,7 +205,7 @@ export async function runAgentLoop(input: {
205
205
  toolUseBlocks.map(async (toolUse) => {
206
206
  const start = Date.now();
207
207
  const result = await executeToolCall(toolUse, {
208
- mem,
208
+ withMem,
209
209
  projectDir,
210
210
  config,
211
211
  mcpxClient: input.mcpxClient ?? null,
@@ -264,7 +264,7 @@ interface ToolCallResult {
264
264
  }
265
265
 
266
266
  interface ToolCallCtx {
267
- mem: MembotClient;
267
+ withMem: WithMem;
268
268
  projectDir: string;
269
269
  config: Required<BotholomewConfig>;
270
270
  mcpxClient: McpxClient | null;
@@ -1,6 +1,11 @@
1
1
  import type { McpxClient } from "@evantahler/mcpx";
2
- import type { MembotClient } from "membot";
3
2
  import type { BotholomewConfig } from "../config/schemas.ts";
3
+ import {
4
+ openMembot,
5
+ resolveMembotDir,
6
+ sharedWithMem,
7
+ type WithMem,
8
+ } from "../mem/client.ts";
4
9
  import type { Task } from "../tasks/schema.ts";
5
10
  import {
6
11
  claimNextTask,
@@ -19,7 +24,6 @@ import { processSchedules } from "./schedules.ts";
19
24
 
20
25
  export interface TickOptions {
21
26
  projectDir: string;
22
- mem: MembotClient;
23
27
  config: Required<BotholomewConfig>;
24
28
  workerId: string;
25
29
  mcpxClient?: McpxClient | null;
@@ -31,11 +35,15 @@ export interface TickOptions {
31
35
  /**
32
36
  * Run one unit of work for a worker: optionally evaluate schedules, claim
33
37
  * the next eligible task, and process it. Returns true if work was done.
38
+ *
39
+ * Opens a membot client for the duration of this tick and closes it on the
40
+ * way out so the DuckDB file lock is released between ticks — other
41
+ * Botholomew processes (other workers, chat, the membot CLI) can read the
42
+ * shared `~/.membot` store while this worker is idle.
34
43
  */
35
44
  export async function tick(opts: TickOptions): Promise<boolean> {
36
45
  const {
37
46
  projectDir,
38
- mem,
39
47
  config,
40
48
  workerId,
41
49
  mcpxClient,
@@ -74,15 +82,21 @@ export async function tick(opts: TickOptions): Promise<boolean> {
74
82
  return false;
75
83
  }
76
84
 
77
- await runClaimedTask({
78
- projectDir,
79
- mem,
80
- config,
81
- workerId,
82
- mcpxClient,
83
- callbacks,
84
- task,
85
- });
85
+ const mem = openMembot(resolveMembotDir(projectDir, config));
86
+ await mem.connect();
87
+ try {
88
+ await runClaimedTask({
89
+ projectDir,
90
+ withMem: sharedWithMem(mem),
91
+ config,
92
+ workerId,
93
+ mcpxClient,
94
+ callbacks,
95
+ task,
96
+ });
97
+ } finally {
98
+ await mem.close();
99
+ }
86
100
 
87
101
  const elapsed = ((Date.now() - tickStart) / 1000).toFixed(1);
88
102
  logger.phase("tick-end", `#${tickNum} ${elapsed}s didWork=true`);
@@ -95,7 +109,6 @@ export async function tick(opts: TickOptions): Promise<boolean> {
95
109
  */
96
110
  export async function runSpecificTask(opts: {
97
111
  projectDir: string;
98
- mem: MembotClient;
99
112
  config: Required<BotholomewConfig>;
100
113
  workerId: string;
101
114
  taskId: string;
@@ -113,28 +126,34 @@ export async function runSpecificTask(opts: {
113
126
  );
114
127
  return false;
115
128
  }
116
- await runClaimedTask({
117
- projectDir: opts.projectDir,
118
- mem: opts.mem,
119
- config: opts.config,
120
- workerId: opts.workerId,
121
- mcpxClient: opts.mcpxClient,
122
- callbacks: opts.callbacks,
123
- task,
124
- });
129
+ const mem = openMembot(resolveMembotDir(opts.projectDir, opts.config));
130
+ await mem.connect();
131
+ try {
132
+ await runClaimedTask({
133
+ projectDir: opts.projectDir,
134
+ withMem: sharedWithMem(mem),
135
+ config: opts.config,
136
+ workerId: opts.workerId,
137
+ mcpxClient: opts.mcpxClient,
138
+ callbacks: opts.callbacks,
139
+ task,
140
+ });
141
+ } finally {
142
+ await mem.close();
143
+ }
125
144
  return true;
126
145
  }
127
146
 
128
147
  async function runClaimedTask(opts: {
129
148
  projectDir: string;
130
- mem: MembotClient;
149
+ withMem: WithMem;
131
150
  config: Required<BotholomewConfig>;
132
151
  workerId: string;
133
152
  mcpxClient?: McpxClient | null;
134
153
  callbacks?: WorkerStreamCallbacks;
135
154
  task: Task;
136
155
  }): Promise<void> {
137
- const { projectDir, mem, config, workerId, mcpxClient, callbacks, task } =
156
+ const { projectDir, withMem, config, workerId, mcpxClient, callbacks, task } =
138
157
  opts;
139
158
 
140
159
  logger.info(`Claimed task: ${task.name} (${task.id})`);
@@ -172,7 +191,7 @@ async function runClaimedTask(opts: {
172
191
  systemPrompt,
173
192
  task,
174
193
  config,
175
- mem,
194
+ withMem,
176
195
  threadId,
177
196
  projectDir,
178
197
  workerId,