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 +3 -8
- package/package.json +10 -10
- package/src/chat/agent.ts +40 -5
- package/src/chat/session.ts +0 -8
- package/src/cli.ts +2 -2
- package/src/commands/{context.ts → membot.ts} +13 -13
- package/src/mem/client.ts +49 -0
- package/src/tools/membot/adapter.ts +5 -3
- package/src/tools/membot/copy.ts +8 -6
- package/src/tools/membot/count_lines.ts +3 -1
- package/src/tools/membot/edit.ts +8 -6
- package/src/tools/membot/exists.ts +3 -1
- package/src/tools/membot/pipe.ts +7 -5
- package/src/tools/tool.ts +7 -6
- package/src/tui/components/ContextPanel.tsx +29 -20
- package/src/worker/index.ts +0 -10
- package/src/worker/llm.ts +5 -5
- package/src/worker/tick.ts +44 -25
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
|
|
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
|

|
|
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
|
-

|
|
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
|
|
182
|
-
| `botholomew
|
|
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.
|
|
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.
|
|
31
|
-
"@evantahler/mcpx": "0.21.
|
|
32
|
-
"ansis": "^4.
|
|
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.
|
|
39
|
+
"membot": "^0.15.0",
|
|
40
40
|
"nanospinner": "^1.2.2",
|
|
41
|
-
"react": "^19.2.
|
|
41
|
+
"react": "^19.2.6",
|
|
42
42
|
"uuid": "^14.0.0",
|
|
43
43
|
"wrap-ansi": "^10.0.0",
|
|
44
|
-
"zod": "^4.4.
|
|
44
|
+
"zod": "^4.4.3"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@biomejs/biome": "^2.4.
|
|
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.
|
|
54
|
-
"vue": "^3.5.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
508
|
+
withMem: WithMem;
|
|
474
509
|
projectDir: string;
|
|
475
510
|
config: Required<BotholomewConfig>;
|
|
476
511
|
mcpxClient: McpxClient | null;
|
package/src/chat/session.ts
CHANGED
|
@@ -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
|
-
|
|
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 "
|
|
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
|
|
36
|
-
const idx = process.argv.indexOf("
|
|
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
|
|
125
|
-
const
|
|
126
|
-
.command("
|
|
124
|
+
export function registerMembotCommand(program: Command) {
|
|
125
|
+
const membot = program
|
|
126
|
+
.command("membot")
|
|
127
127
|
.description(
|
|
128
|
-
"Manage the project's knowledge store
|
|
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(
|
|
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
|
-
|
|
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),
|
|
147
|
+
const exitCode = await runMembot(getDir(program), getRawMembotArgs());
|
|
148
148
|
if (exitCode !== 0) process.exit(exitCode);
|
|
149
149
|
});
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
// `botholomew
|
|
153
|
-
|
|
154
|
-
const exitCode = await runMembot(getDir(program),
|
|
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
|
|
90
|
-
|
|
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
|
|
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
|
},
|
package/src/tools/membot/copy.ts
CHANGED
|
@@ -27,12 +27,14 @@ export const membotCopyTool = {
|
|
|
27
27
|
outputSchema,
|
|
28
28
|
execute: async (input, ctx) => {
|
|
29
29
|
try {
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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 {
|
package/src/tools/membot/edit.ts
CHANGED
|
@@ -38,12 +38,14 @@ export const membotEditTool = {
|
|
|
38
38
|
outputSchema,
|
|
39
39
|
execute: async (input, ctx) => {
|
|
40
40
|
try {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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,
|
package/src/tools/membot/pipe.ts
CHANGED
|
@@ -143,11 +143,13 @@ export const membotPipeTool = {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
try {
|
|
146
|
-
const written = await ctx.mem
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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
|
|
40
|
-
* `botholomew
|
|
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
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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 (!
|
|
83
|
+
if (!withMem) return;
|
|
74
84
|
try {
|
|
75
|
-
const out = await
|
|
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
|
-
}, [
|
|
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 || !
|
|
120
|
+
if (!selectedEntry || !withMem) {
|
|
111
121
|
setFileContent(null);
|
|
112
122
|
setDetailScroll(0);
|
|
113
123
|
return;
|
|
114
124
|
}
|
|
115
125
|
setDetailScroll(0);
|
|
116
|
-
|
|
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
|
-
}, [
|
|
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 || !
|
|
169
|
+
if (!entry || !withMem) return;
|
|
161
170
|
const path = entry.logical_path;
|
|
162
171
|
(async () => {
|
|
163
172
|
try {
|
|
164
|
-
await
|
|
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
|
|
239
|
+
<Text dimColor>(empty — try `botholomew membot add …`)</Text>
|
|
231
240
|
</Box>
|
|
232
241
|
) : (
|
|
233
242
|
visibleItems.map((entry, vi) => {
|
package/src/worker/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
+
withMem: WithMem;
|
|
268
268
|
projectDir: string;
|
|
269
269
|
config: Required<BotholomewConfig>;
|
|
270
270
|
mcpxClient: McpxClient | null;
|
package/src/worker/tick.ts
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
194
|
+
withMem,
|
|
176
195
|
threadId,
|
|
177
196
|
projectDir,
|
|
178
197
|
workerId,
|