botholomew 0.8.6 → 0.8.8

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
@@ -139,6 +139,7 @@ Everything the agent can touch is here. No surprises.
139
139
  | `botholomew task list\|add\|view\|update\|reset\|delete` | Manage the task queue |
140
140
  | `botholomew schedule list\|add\|enable\|trigger\|delete` | Recurring work |
141
141
  | `botholomew context add\|list\|view\|search\|refresh\|remove` | Ingest & browse knowledge (files, folders, URLs) |
142
+ | `botholomew capabilities` | Rescan built-in + MCPX tools and rewrite `.botholomew/capabilities.md` |
142
143
  | `botholomew mcpx servers\|add\|remove\|info\|search\|exec\|ping\|auth\|import-global` | Configure external MCP servers |
143
144
  | `botholomew skill list\|show\|create\|validate` | Manage slash-command skills |
144
145
  | `botholomew context ... \| search ...` | Direct access to the agent's virtual filesystem |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -140,3 +140,22 @@ export async function endChatSession(session: ChatSession): Promise<void> {
140
140
  await withDb(session.dbPath, (conn) => endThread(conn, session.threadId));
141
141
  await session.cleanup();
142
142
  }
143
+
144
+ /**
145
+ * End the current thread and start a fresh one on the same session.
146
+ * The old thread is persisted (marked ended) and can still be resumed
147
+ * via `botholomew chat --thread-id <id>`. Returns the previous thread
148
+ * ID so callers can display it to the user.
149
+ */
150
+ export async function clearChatSession(
151
+ session: ChatSession,
152
+ ): Promise<{ previousThreadId: string; newThreadId: string }> {
153
+ const previousThreadId = session.threadId;
154
+ const newThreadId = await withDb(session.dbPath, async (conn) => {
155
+ await endThread(conn, previousThreadId);
156
+ return createThread(conn, "chat_session", undefined, "New chat");
157
+ });
158
+ session.threadId = newThreadId;
159
+ session.messages.length = 0;
160
+ return { previousThreadId, newThreadId };
161
+ }
package/src/cli.ts CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import ansis from "ansis";
4
4
  import { program } from "commander";
5
+ import { registerCapabilitiesCommand } from "./commands/capabilities.ts";
5
6
  import { registerChatCommand } from "./commands/chat.ts";
6
7
  import { registerCheckUpdateCommand } from "./commands/check-update.ts";
7
8
  import { registerContextCommand } from "./commands/context.ts";
@@ -39,6 +40,7 @@ registerThreadCommand(program);
39
40
  registerScheduleCommand(program);
40
41
  registerChatCommand(program);
41
42
  registerContextCommand(program);
43
+ registerCapabilitiesCommand(program);
42
44
  registerMcpxCommand(program);
43
45
  registerSkillCommand(program);
44
46
  registerNukeCommand(program);
@@ -0,0 +1,45 @@
1
+ import type { Command } from "commander";
2
+ import { createSpinner } from "nanospinner";
3
+ import { loadConfig } from "../config/loader.ts";
4
+ import { writeCapabilitiesFile } from "../context/capabilities.ts";
5
+ import { createMcpxClient } from "../mcpx/client.ts";
6
+ import { withDb } from "./with-db.ts";
7
+
8
+ export function registerCapabilitiesCommand(program: Command) {
9
+ program
10
+ .command("capabilities")
11
+ .description(
12
+ "Regenerate .botholomew/capabilities.md by scanning built-in tools and MCPX tools",
13
+ )
14
+ .option("--no-mcp", "Skip MCPX tool enumeration (built-in tools only)")
15
+ .action((opts: { mcp?: boolean }) =>
16
+ withDb(program, async (_conn, dir) => {
17
+ const includeMcp = opts.mcp !== false;
18
+ const spinner = createSpinner("Loading config").start();
19
+ const config = await loadConfig(dir);
20
+ spinner.update({ text: "Connecting to MCPX servers" });
21
+ const mcpxClient = includeMcp ? await createMcpxClient(dir) : null;
22
+ try {
23
+ const result = await writeCapabilitiesFile(
24
+ dir,
25
+ mcpxClient,
26
+ config,
27
+ (phase) => spinner.update({ text: phase }),
28
+ );
29
+ const bits = [
30
+ `${result.counts.internal} built-in`,
31
+ `${result.counts.mcp} MCPX`,
32
+ ];
33
+ if (!includeMcp) bits.push("MCPX skipped");
34
+ spinner.success({
35
+ text: `Wrote ${result.path} (${bits.join(", ")})`,
36
+ });
37
+ } catch (err) {
38
+ spinner.error({ text: `Failed: ${(err as Error).message}` });
39
+ process.exit(1);
40
+ } finally {
41
+ await mcpxClient?.close();
42
+ }
43
+ }),
44
+ );
45
+ }
@@ -0,0 +1,512 @@
1
+ import { join } from "node:path";
2
+ import Anthropic from "@anthropic-ai/sdk";
3
+ import type { McpxClient } from "@evantahler/mcpx";
4
+ import type { BotholomewConfig } from "../config/schemas.ts";
5
+ import { getBotholomewDir } from "../constants.ts";
6
+ import { getAllTools, type ToolDefinition } from "../tools/tool.ts";
7
+ import {
8
+ type ContextFileMeta,
9
+ parseContextFile,
10
+ serializeContextFile,
11
+ } from "../utils/frontmatter.ts";
12
+ import { logger } from "../utils/logger.ts";
13
+
14
+ export const CAPABILITIES_FILENAME = "capabilities.md";
15
+
16
+ // LLM config — summarization is one call per refresh, no streaming needed.
17
+ const SUMMARIZE_TIMEOUT_MS = 30_000;
18
+ const SUMMARIZE_MAX_TOKENS = 4096;
19
+
20
+ // biome-ignore lint/suspicious/noExplicitAny: Zod-free tool schema for Anthropic SDK
21
+ type AnyTool = ToolDefinition<any, any>;
22
+
23
+ /**
24
+ * Groups rendered for built-in tools when we can't summarize via LLM.
25
+ * Order here controls rendering order in the fallback.
26
+ */
27
+ const GROUP_ORDER = [
28
+ "task",
29
+ "schedule",
30
+ "context",
31
+ "search",
32
+ "thread",
33
+ "mcp",
34
+ "worker",
35
+ "capabilities",
36
+ ] as const;
37
+
38
+ const GROUP_HEADINGS: Record<string, string> = {
39
+ task: "Task management",
40
+ schedule: "Schedules",
41
+ context: "Virtual filesystem & self-reflection",
42
+ search: "Search",
43
+ thread: "Threads",
44
+ mcp: "MCPX meta-tools",
45
+ worker: "Workers",
46
+ capabilities: "Capabilities",
47
+ other: "Other",
48
+ };
49
+
50
+ export interface CapabilitiesCounts {
51
+ internal: number;
52
+ mcp: number;
53
+ }
54
+
55
+ export interface GenerateResult {
56
+ body: string;
57
+ counts: CapabilitiesCounts;
58
+ }
59
+
60
+ /** Called at each phase transition so callers (CLI) can render progress. */
61
+ export type ProgressCallback = (phase: string) => void;
62
+
63
+ interface RawInventory {
64
+ internal: Map<string, AnyTool[]>;
65
+ internalTotal: number;
66
+ mcpByServer: Map<string, Array<{ name: string; description: string }>>;
67
+ mcpTotal: number;
68
+ mcpError: string | null;
69
+ mcpConfigured: boolean;
70
+ }
71
+
72
+ /** Collect the tool inventory without rendering. */
73
+ async function collectInventory(
74
+ mcpxClient: McpxClient | null,
75
+ onPhase?: ProgressCallback,
76
+ ): Promise<RawInventory> {
77
+ onPhase?.("Scanning internal tools");
78
+ const allTools = getAllTools();
79
+ const internal = new Map<string, AnyTool[]>();
80
+ for (const tool of allTools) {
81
+ const key = (GROUP_ORDER as readonly string[]).includes(tool.group)
82
+ ? tool.group
83
+ : "other";
84
+ const list = internal.get(key) ?? [];
85
+ list.push(tool);
86
+ internal.set(key, list);
87
+ }
88
+
89
+ const mcpByServer = new Map<
90
+ string,
91
+ Array<{ name: string; description: string }>
92
+ >();
93
+ let mcpTotal = 0;
94
+ let mcpError: string | null = null;
95
+
96
+ if (mcpxClient) {
97
+ onPhase?.("Querying MCPX servers");
98
+ try {
99
+ const mcpTools = await mcpxClient.listTools();
100
+ mcpTotal = mcpTools.length;
101
+ for (const entry of mcpTools) {
102
+ const list = mcpByServer.get(entry.server) ?? [];
103
+ list.push({
104
+ name: entry.tool.name,
105
+ description: (entry.tool.description ?? "").trim(),
106
+ });
107
+ mcpByServer.set(entry.server, list);
108
+ }
109
+ } catch (err) {
110
+ mcpError = (err as Error).message;
111
+ }
112
+ }
113
+
114
+ return {
115
+ internal,
116
+ internalTotal: allTools.length,
117
+ mcpByServer,
118
+ mcpTotal,
119
+ mcpError,
120
+ mcpConfigured: mcpxClient !== null,
121
+ };
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // LLM summarization
126
+ // ---------------------------------------------------------------------------
127
+
128
+ interface Theme {
129
+ name: string;
130
+ summary: string;
131
+ }
132
+
133
+ interface ServerThemes {
134
+ server: string;
135
+ themes: Theme[];
136
+ }
137
+
138
+ interface SummarizedCapabilities {
139
+ internal_themes: Theme[];
140
+ mcpx_servers: ServerThemes[];
141
+ }
142
+
143
+ const SUMMARIZE_TOOL_NAME = "return_capability_summary";
144
+ const SUMMARIZE_TOOL = {
145
+ name: SUMMARIZE_TOOL_NAME,
146
+ description:
147
+ "Return thematic capability summaries for the agent's tool inventory.",
148
+ input_schema: {
149
+ type: "object" as const,
150
+ properties: {
151
+ internal_themes: {
152
+ type: "array",
153
+ description:
154
+ "Themes covering the agent's built-in tools (task queue, virtual filesystem, search, threads, MCPX meta-tools, workers, self-reflection, etc.).",
155
+ items: {
156
+ type: "object",
157
+ properties: {
158
+ name: {
159
+ type: "string",
160
+ description: "Short theme name (2-4 words).",
161
+ },
162
+ summary: {
163
+ type: "string",
164
+ description:
165
+ "One sentence with concrete action verbs. No tool names. No preamble.",
166
+ },
167
+ },
168
+ required: ["name", "summary"],
169
+ },
170
+ },
171
+ mcpx_servers: {
172
+ type: "array",
173
+ description:
174
+ "MCPX tools grouped by their source server. Within each server, split into themes only when the server exposes distinct services (e.g. Gmail + Google Calendar on one server).",
175
+ items: {
176
+ type: "object",
177
+ properties: {
178
+ server: {
179
+ type: "string",
180
+ description: "Server name exactly as given in the inventory.",
181
+ },
182
+ themes: {
183
+ type: "array",
184
+ items: {
185
+ type: "object",
186
+ properties: {
187
+ name: {
188
+ type: "string",
189
+ description: "Theme name (usually the service, e.g. Gmail)",
190
+ },
191
+ summary: {
192
+ type: "string",
193
+ description:
194
+ "One sentence with concrete action verbs. No tool names.",
195
+ },
196
+ },
197
+ required: ["name", "summary"],
198
+ },
199
+ },
200
+ },
201
+ required: ["server", "themes"],
202
+ },
203
+ },
204
+ },
205
+ required: ["internal_themes", "mcpx_servers"],
206
+ },
207
+ };
208
+
209
+ function renderInventoryForPrompt(inv: RawInventory): string {
210
+ const sections: string[] = [];
211
+ sections.push("## Internal tools");
212
+ for (const group of [...GROUP_ORDER, "other" as const]) {
213
+ const tools = inv.internal.get(group);
214
+ if (!tools || tools.length === 0) continue;
215
+ sections.push(`\n### ${GROUP_HEADINGS[group] ?? group}`);
216
+ const sorted = [...tools].sort((a, b) => a.name.localeCompare(b.name));
217
+ for (const t of sorted) {
218
+ sections.push(`- ${t.name}: ${t.description}`);
219
+ }
220
+ }
221
+
222
+ if (inv.mcpByServer.size > 0) {
223
+ sections.push("\n## MCPX tools");
224
+ const servers = [...inv.mcpByServer.keys()].sort();
225
+ for (const server of servers) {
226
+ sections.push(`\n### ${server}`);
227
+ const tools = inv.mcpByServer.get(server) ?? [];
228
+ const sorted = [...tools].sort((a, b) => a.name.localeCompare(b.name));
229
+ for (const t of sorted) {
230
+ sections.push(`- ${t.name}: ${t.description || "(no description)"}`);
231
+ }
232
+ }
233
+ }
234
+
235
+ return sections.join("\n");
236
+ }
237
+
238
+ const SUMMARIZE_SYSTEM = `You summarize an AI agent's tool inventory into a terse "capabilities" document. The agent loads this document into every system prompt, so it MUST be compact — 1 line per theme.
239
+
240
+ Rules:
241
+ - Do NOT list specific tool names. The agent discovers exact names via the MCPX meta-tools (mcp_search, mcp_list_tools, mcp_info) when it actually needs to invoke one.
242
+ - Group tools into natural themes.
243
+ - For MCPX tools, one theme usually = one external service (Gmail, Google Calendar, GitHub, Linear, Slack, Google Docs, Google Drive, Google Sheets, Apple Notes, etc.). Split a single server into multiple themes when it clearly exposes distinct services.
244
+ - For internal tools, use coarse buckets aligned with the provided groups (task management, virtual filesystem, search, threads, MCPX meta-tools, workers, self-reflection, capabilities). Merge overlapping groups if natural.
245
+ - Each summary is ONE sentence with concrete action verbs. Present-tense imperative, no preamble.
246
+
247
+ GOOD examples:
248
+ "Gmail — read, send, draft, search, and reply to emails; manage labels and threads"
249
+ "Virtual filesystem — read, write, edit, move, copy, delete, and navigate items in the agent's persistent memory store"
250
+ "GitHub — read and write repositories, branches, files, issues, pull requests, reviews, and labels"
251
+
252
+ BAD examples (do not produce):
253
+ "Provides access to Gmail operations via tools like Gmail_SendEmail..."
254
+ "Tools for working with email"`;
255
+
256
+ async function summarizeViaLLM(
257
+ inv: RawInventory,
258
+ config: Required<BotholomewConfig>,
259
+ ): Promise<SummarizedCapabilities | null> {
260
+ if (
261
+ !config.anthropic_api_key ||
262
+ config.anthropic_api_key === "your-api-key-here"
263
+ ) {
264
+ return null;
265
+ }
266
+
267
+ const client = new Anthropic({ apiKey: config.anthropic_api_key });
268
+ const userPrompt = `Summarize this tool inventory. Return via the \`${SUMMARIZE_TOOL_NAME}\` tool.\n\n${renderInventoryForPrompt(inv)}`;
269
+
270
+ try {
271
+ const response = await Promise.race([
272
+ client.messages.create({
273
+ model: config.chunker_model,
274
+ max_tokens: SUMMARIZE_MAX_TOKENS,
275
+ system: SUMMARIZE_SYSTEM,
276
+ tools: [SUMMARIZE_TOOL],
277
+ tool_choice: { type: "tool", name: SUMMARIZE_TOOL_NAME },
278
+ messages: [{ role: "user", content: userPrompt }],
279
+ }),
280
+ new Promise<never>((_, reject) =>
281
+ setTimeout(
282
+ () => reject(new Error("Capability summarization timeout")),
283
+ SUMMARIZE_TIMEOUT_MS,
284
+ ),
285
+ ),
286
+ ]);
287
+
288
+ const toolBlock = response.content.find((b) => b.type === "tool_use");
289
+ if (!toolBlock || toolBlock.type !== "tool_use") return null;
290
+
291
+ const input = toolBlock.input as SummarizedCapabilities;
292
+ if (!Array.isArray(input.internal_themes)) return null;
293
+ if (!Array.isArray(input.mcpx_servers)) return null;
294
+ return input;
295
+ } catch (err) {
296
+ logger.debug(`Capability summarization failed: ${(err as Error).message}`);
297
+ return null;
298
+ }
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // Rendering
303
+ // ---------------------------------------------------------------------------
304
+
305
+ function renderHeader(now: Date): string[] {
306
+ return [
307
+ "# Capabilities",
308
+ "",
309
+ `*Generated ${now.toISOString()}. Regenerate with \`botholomew capabilities\`, the \`capabilities_refresh\` tool, or the \`/capabilities\` skill.*`,
310
+ "",
311
+ "A high-level summary of what this agent can do. Specific tool names are **not** listed — use `mcp_list_tools`, `mcp_search`, or `mcp_info` to find exact names when you need to invoke an external tool.",
312
+ "",
313
+ ];
314
+ }
315
+
316
+ function renderSummarized(
317
+ summary: SummarizedCapabilities,
318
+ inv: RawInventory,
319
+ now: Date,
320
+ ): string {
321
+ const parts: string[] = [];
322
+ parts.push(...renderHeader(now));
323
+
324
+ parts.push("## Internal capabilities");
325
+ parts.push("");
326
+ for (const theme of summary.internal_themes) {
327
+ parts.push(`- **${theme.name}** — ${theme.summary}`);
328
+ }
329
+ parts.push("");
330
+
331
+ parts.push("## External capabilities (via MCPX)");
332
+ parts.push("");
333
+ if (!inv.mcpConfigured) {
334
+ parts.push(
335
+ "_No MCPX servers configured. Add one with `botholomew mcpx add` and rerun `botholomew capabilities`._",
336
+ );
337
+ } else if (inv.mcpError) {
338
+ parts.push(
339
+ `_Failed to list MCPX tools: ${inv.mcpError}. Check your MCPX server configuration._`,
340
+ );
341
+ } else if (summary.mcpx_servers.length === 0) {
342
+ parts.push(
343
+ "_MCPX is configured but no tools are exposed by the connected servers._",
344
+ );
345
+ } else {
346
+ for (const srv of summary.mcpx_servers) {
347
+ parts.push(`### ${srv.server}`);
348
+ parts.push("");
349
+ for (const theme of srv.themes) {
350
+ parts.push(`- **${theme.name}** — ${theme.summary}`);
351
+ }
352
+ parts.push("");
353
+ }
354
+ }
355
+
356
+ return parts.join("\n").trimEnd();
357
+ }
358
+
359
+ /**
360
+ * Fallback rendering when no API key is set or the LLM call fails.
361
+ * Produces a static high-level summary of internal tools plus a server-level
362
+ * listing for MCPX (with tool counts), still far more compact than listing
363
+ * every tool. The agent uses the MCPX meta-tools to drill in when needed.
364
+ */
365
+ function renderFallback(inv: RawInventory, now: Date): string {
366
+ const parts: string[] = [];
367
+ parts.push(...renderHeader(now));
368
+
369
+ parts.push("## Internal capabilities");
370
+ parts.push("");
371
+ const fallbackInternal: Record<string, string> = {
372
+ task: "create, list, view, update, complete, fail, and wait on tasks in the agent's work queue",
373
+ schedule:
374
+ "create and list recurring schedules that automatically generate tasks",
375
+ context:
376
+ "read, write, edit, move, copy, delete, and navigate items in the agent's persistent memory store; update beliefs and goals; read large tool results",
377
+ search: "keyword and semantic search over the virtual filesystem",
378
+ thread: "list and view past conversation threads and tool interactions",
379
+ mcp: "search, list, inspect, and execute tools exposed by configured MCPX servers",
380
+ worker: "spawn background workers to run tasks asynchronously",
381
+ capabilities: "refresh this capabilities file (the tool inventory)",
382
+ };
383
+ for (const group of [...GROUP_ORDER, "other" as const]) {
384
+ const tools = inv.internal.get(group);
385
+ if (!tools || tools.length === 0) continue;
386
+ const heading = GROUP_HEADINGS[group] ?? group;
387
+ const summary = fallbackInternal[group] ?? "(no summary)";
388
+ parts.push(`- **${heading}** — ${summary}`);
389
+ }
390
+ parts.push("");
391
+
392
+ parts.push("## External capabilities (via MCPX)");
393
+ parts.push("");
394
+ if (!inv.mcpConfigured) {
395
+ parts.push(
396
+ "_No MCPX servers configured. Add one with `botholomew mcpx add` and rerun `botholomew capabilities`._",
397
+ );
398
+ } else if (inv.mcpError) {
399
+ parts.push(
400
+ `_Failed to list MCPX tools: ${inv.mcpError}. Check your MCPX server configuration._`,
401
+ );
402
+ } else if (inv.mcpByServer.size === 0) {
403
+ parts.push(
404
+ "_MCPX is configured but no tools are exposed by the connected servers._",
405
+ );
406
+ } else {
407
+ parts.push(
408
+ "_(LLM summarization unavailable — set `anthropic_api_key` and rerun to generate themed summaries. Until then, use `mcp_list_tools` with each server to see what's exposed.)_",
409
+ );
410
+ parts.push("");
411
+ const servers = [...inv.mcpByServer.keys()].sort();
412
+ for (const server of servers) {
413
+ const tools = inv.mcpByServer.get(server) ?? [];
414
+ parts.push(`- **${server}** — ${tools.length} tool(s)`);
415
+ }
416
+ }
417
+
418
+ return parts.join("\n").trimEnd();
419
+ }
420
+
421
+ /**
422
+ * Build the body of capabilities.md. When `config.anthropic_api_key` is set,
423
+ * Claude is asked to produce thematic summaries. Otherwise (or on failure) a
424
+ * static fallback listing is rendered.
425
+ */
426
+ export async function generateCapabilitiesMarkdown(
427
+ mcpxClient: McpxClient | null,
428
+ config: Required<BotholomewConfig>,
429
+ now: Date = new Date(),
430
+ onPhase?: ProgressCallback,
431
+ ): Promise<GenerateResult> {
432
+ const inv = await collectInventory(mcpxClient, onPhase);
433
+
434
+ // Don't call the LLM when the inventory is empty / broken — the fallback
435
+ // conveys the same information and avoids an unnecessary API round trip.
436
+ const hasAnythingToSummarize =
437
+ inv.mcpByServer.size > 0 || inv.internalTotal > 0;
438
+
439
+ let summary: SummarizedCapabilities | null = null;
440
+ if (hasAnythingToSummarize) {
441
+ const canSummarize =
442
+ config.anthropic_api_key &&
443
+ config.anthropic_api_key !== "your-api-key-here";
444
+ if (canSummarize) {
445
+ onPhase?.(
446
+ `Summarizing ${inv.internalTotal} internal + ${inv.mcpTotal} MCPX tools with Claude`,
447
+ );
448
+ }
449
+ summary = await summarizeViaLLM(inv, config);
450
+ }
451
+
452
+ const body = summary
453
+ ? renderSummarized(summary, inv, now)
454
+ : renderFallback(inv, now);
455
+
456
+ return {
457
+ body,
458
+ counts: { internal: inv.internalTotal, mcp: inv.mcpTotal },
459
+ };
460
+ }
461
+
462
+ export interface WriteResult {
463
+ path: string;
464
+ counts: CapabilitiesCounts;
465
+ createdFile: boolean;
466
+ }
467
+
468
+ /**
469
+ * Regenerate and write `.botholomew/capabilities.md`. Preserves any existing
470
+ * frontmatter (so a human-edited `loading:` flag survives). On first write
471
+ * the default frontmatter is `loading: always`, `agent-modification: true`.
472
+ */
473
+ export async function writeCapabilitiesFile(
474
+ projectDir: string,
475
+ mcpxClient: McpxClient | null,
476
+ config: Required<BotholomewConfig>,
477
+ onPhase?: ProgressCallback,
478
+ ): Promise<WriteResult> {
479
+ const filePath = join(getBotholomewDir(projectDir), CAPABILITIES_FILENAME);
480
+ const file = Bun.file(filePath);
481
+
482
+ let meta: ContextFileMeta = {
483
+ loading: "always",
484
+ "agent-modification": true,
485
+ };
486
+ let createdFile = true;
487
+
488
+ if (await file.exists()) {
489
+ const raw = await file.text();
490
+ const parsed = parseContextFile(raw);
491
+ if (parsed.meta && typeof parsed.meta === "object") {
492
+ meta = {
493
+ loading: parsed.meta.loading ?? meta.loading,
494
+ "agent-modification":
495
+ parsed.meta["agent-modification"] ?? meta["agent-modification"],
496
+ };
497
+ }
498
+ createdFile = false;
499
+ }
500
+
501
+ const { body, counts } = await generateCapabilitiesMarkdown(
502
+ mcpxClient,
503
+ config,
504
+ new Date(),
505
+ onPhase,
506
+ );
507
+ onPhase?.(`Writing ${CAPABILITIES_FILENAME}`);
508
+ const serialized = serializeContextFile(meta, body);
509
+ await Bun.write(filePath, serialized);
510
+
511
+ return { path: filePath, counts, createdFile };
512
+ }
package/src/init/index.ts CHANGED
@@ -1,16 +1,22 @@
1
1
  import { mkdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
+ import { loadConfig } from "../config/loader.ts";
3
4
  import {
4
5
  getBotholomewDir,
5
6
  getDbPath,
6
7
  getMcpxDir,
7
8
  getSkillsDir,
8
9
  } from "../constants.ts";
10
+ import { writeCapabilitiesFile } from "../context/capabilities.ts";
9
11
  import { getConnection } from "../db/connection.ts";
10
12
  import { migrate } from "../db/schema.ts";
13
+ import { createMcpxClient } from "../mcpx/client.ts";
14
+ import { registerAllTools } from "../tools/registry.ts";
11
15
  import { logger } from "../utils/logger.ts";
12
16
  import {
13
17
  BELIEFS_MD,
18
+ CAPABILITIES_MD,
19
+ CAPABILITIES_SKILL,
14
20
  DEFAULT_CONFIG,
15
21
  DEFAULT_MCPX_SERVERS,
16
22
  GOALS_MD,
@@ -44,10 +50,12 @@ export async function initProject(
44
50
  await Bun.write(join(dotDir, "soul.md"), SOUL_MD);
45
51
  await Bun.write(join(dotDir, "beliefs.md"), BELIEFS_MD);
46
52
  await Bun.write(join(dotDir, "goals.md"), GOALS_MD);
53
+ await Bun.write(join(dotDir, "capabilities.md"), CAPABILITIES_MD);
47
54
 
48
55
  // Write default skills
49
56
  await Bun.write(join(skillsDir, "summarize.md"), SUMMARIZE_SKILL);
50
57
  await Bun.write(join(skillsDir, "standup.md"), STANDUP_SKILL);
58
+ await Bun.write(join(skillsDir, "capabilities.md"), CAPABILITIES_SKILL);
51
59
 
52
60
  // Write config (with placeholder API key)
53
61
  await Bun.write(
@@ -67,6 +75,19 @@ export async function initProject(
67
75
  await migrate(conn);
68
76
  conn.close();
69
77
 
78
+ // Populate capabilities.md with the real tool inventory. Seeded mcpx
79
+ // servers.json has no entries on first init, so this lists only the
80
+ // built-in tools; running `botholomew capabilities` later after
81
+ // adding MCPX servers picks those up.
82
+ registerAllTools();
83
+ const config = await loadConfig(projectDir);
84
+ const mcpxClient = await createMcpxClient(projectDir);
85
+ try {
86
+ await writeCapabilitiesFile(projectDir, mcpxClient, config);
87
+ } finally {
88
+ await mcpxClient?.close();
89
+ }
90
+
70
91
  // Update .gitignore
71
92
  await updateGitignore(projectDir);
72
93
 
@@ -37,6 +37,28 @@ agent-modification: true
37
37
  - Get set up and ready to help.
38
38
  `;
39
39
 
40
+ export const CAPABILITIES_MD = `---
41
+ loading: always
42
+ agent-modification: true
43
+ ---
44
+
45
+ # Capabilities
46
+
47
+ *This file is an auto-generated inventory of every tool available to Botholomew — built-in tools and tools exposed via configured MCPX servers.*
48
+ *Regenerate with \`botholomew capabilities\`, the \`capabilities_refresh\` tool, or the \`/capabilities\` slash command.*
49
+
50
+ _(Pending first scan. Run \`botholomew capabilities\` to populate.)_
51
+ `;
52
+
53
+ export const CAPABILITIES_SKILL = `---
54
+ name: capabilities
55
+ description: "Refresh capabilities.md — rescan internal and MCPX tools"
56
+ arguments: []
57
+ ---
58
+
59
+ Call \`capabilities_refresh\` to rescan every available tool (built-in and MCPX) and rewrite \`.botholomew/capabilities.md\`. After it finishes, give me a one-line summary of the counts.
60
+ `;
61
+
40
62
  export const SUMMARIZE_SKILL = `---
41
63
  name: summarize
42
64
  description: "Summarize the current conversation"
@@ -4,11 +4,13 @@ import { renderSkill } from "./parser.ts";
4
4
  export interface SlashCommand {
5
5
  name: string;
6
6
  description: string;
7
+ takesArgs?: boolean;
7
8
  }
8
9
 
9
10
  export const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
10
11
  { name: "help", description: "Show command reference and shortcuts" },
11
12
  { name: "skills", description: "List available skills" },
13
+ { name: "clear", description: "End current thread and start a new one" },
12
14
  { name: "exit", description: "End the chat session" },
13
15
  ];
14
16
 
@@ -17,6 +19,7 @@ export interface SlashCommandContext {
17
19
  addSystemMessage: (content: string) => void;
18
20
  queueUserMessage: (content: string) => void;
19
21
  exit: () => void;
22
+ clearChat?: () => void;
20
23
  }
21
24
 
22
25
  /**
@@ -38,6 +41,15 @@ export function handleSlashCommand(
38
41
  return true;
39
42
  }
40
43
 
44
+ if (name === "clear") {
45
+ if (ctx.clearChat) {
46
+ ctx.clearChat();
47
+ } else {
48
+ ctx.addSystemMessage("/clear is only available in the chat TUI.");
49
+ }
50
+ return true;
51
+ }
52
+
41
53
  if (name === "skills") {
42
54
  if (ctx.skills.size === 0) {
43
55
  ctx.addSystemMessage(
@@ -0,0 +1,53 @@
1
+ import { z } from "zod";
2
+ import { writeCapabilitiesFile } from "../../context/capabilities.ts";
3
+ import type { ToolDefinition } from "../tool.ts";
4
+
5
+ const inputSchema = z.object({
6
+ include_mcp: z
7
+ .boolean()
8
+ .optional()
9
+ .describe(
10
+ "When false, skip MCPX tool enumeration (internal tools only). Defaults to true.",
11
+ ),
12
+ });
13
+
14
+ const outputSchema = z.object({
15
+ path: z.string(),
16
+ internal_tool_count: z.number(),
17
+ mcp_tool_count: z.number(),
18
+ created_file: z.boolean(),
19
+ message: z.string(),
20
+ is_error: z.boolean(),
21
+ });
22
+
23
+ export const capabilitiesRefreshTool = {
24
+ name: "capabilities_refresh",
25
+ description:
26
+ "[[ bash equivalent command: which ]] Rescan every available tool (built-in + configured MCPX servers) and rewrite `.botholomew/capabilities.md`. Call this when you think the inventory is stale — new MCP servers were added, tools were renamed, or the capabilities file was deleted. The regenerated file is automatically loaded into every subsequent system prompt.",
27
+ group: "capabilities",
28
+ inputSchema,
29
+ outputSchema,
30
+ execute: async (input, ctx) => {
31
+ const includeMcp = input.include_mcp !== false;
32
+ const client = includeMcp ? ctx.mcpxClient : null;
33
+ const result = await writeCapabilitiesFile(
34
+ ctx.projectDir,
35
+ client,
36
+ ctx.config,
37
+ );
38
+ const parts = [
39
+ `${result.counts.internal} internal tool(s)`,
40
+ `${result.counts.mcp} MCPX tool(s)`,
41
+ ];
42
+ if (!includeMcp) parts.push("MCPX skipped");
43
+ if (result.createdFile) parts.push("file created");
44
+ return {
45
+ path: result.path,
46
+ internal_tool_count: result.counts.internal,
47
+ mcp_tool_count: result.counts.mcp,
48
+ created_file: result.createdFile,
49
+ message: `Wrote capabilities.md (${parts.join(", ")})`,
50
+ is_error: false,
51
+ };
52
+ },
53
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -1,5 +1,6 @@
1
+ // Capabilities tools
2
+ import { capabilitiesRefreshTool } from "./capabilities/refresh.ts";
1
3
  // Context tools
2
-
3
4
  import { readLargeResultTool } from "./context/read-large-result.ts";
4
5
  import { contextRefreshTool } from "./context/refresh.ts";
5
6
  import { contextSearchTool } from "./context/search.ts";
@@ -76,6 +77,9 @@ export function registerAllTools(): void {
76
77
  registerTool(updateGoalsTool);
77
78
  registerTool(readLargeResultTool);
78
79
 
80
+ // Capabilities
81
+ registerTool(capabilitiesRefreshTool);
82
+
79
83
  // Schedule
80
84
  registerTool(createScheduleTool);
81
85
  registerTool(listSchedulesTool);
package/src/tui/App.tsx CHANGED
@@ -2,6 +2,7 @@ import { Box, Static, Text, useApp, useInput } from "ink";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import {
4
4
  type ChatSession,
5
+ clearChatSession,
5
6
  endChatSession,
6
7
  sendMessage,
7
8
  startChatSession,
@@ -126,6 +127,7 @@ export function App({
126
127
  }: AppProps) {
127
128
  const { exit } = useApp();
128
129
  const [messages, setMessages] = useState<ChatMessage[]>([]);
130
+ const [messagesEpoch, setMessagesEpoch] = useState(0);
129
131
  const [inputValue, setInputValue] = useState("");
130
132
  const [inputHistory, setInputHistory] = useState<string[]>([]);
131
133
  const [isLoading, setIsLoading] = useState(false);
@@ -490,7 +492,8 @@ export function App({
490
492
  " ⌥+Enter Insert newline",
491
493
  " ↑/↓ Browse input history",
492
494
  " / Open slash-command autocomplete",
493
- " Tab/Enter Accept highlighted command (popup open)",
495
+ " Enter Run highlighted command / insert if it takes args (popup open)",
496
+ " Tab Insert highlighted command without submitting (popup open)",
494
497
  " ↑/↓ Move highlight (popup open)",
495
498
  " Esc Close popup",
496
499
  "",
@@ -534,6 +537,7 @@ export function App({
534
537
  "Commands:",
535
538
  " /help Show this help",
536
539
  " /skills List available skills",
540
+ " /clear End current thread and start a new one",
537
541
  " /exit End the chat session",
538
542
  ...skillLines,
539
543
  ].join("\n"),
@@ -563,6 +567,42 @@ export function App({
563
567
  processQueue();
564
568
  },
565
569
  exit,
570
+ clearChat: () => {
571
+ const session = sessionRef.current;
572
+ if (!session) return;
573
+ // Drain any queued messages so they don't leak into the new thread.
574
+ queueRef.current.length = 0;
575
+ syncQueue();
576
+ clearChatSession(session)
577
+ .then(({ previousThreadId, newThreadId }) => {
578
+ // Ink's <Static> writes messages to terminal scrollback and
579
+ // can't un-write them, so setMessages alone leaves the old
580
+ // lines visible. Clear the terminal (including scrollback)
581
+ // and bump the epoch key on <Static> to force a fresh mount.
582
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
583
+ setMessages([
584
+ {
585
+ id: msgId(),
586
+ role: "system",
587
+ content: `Started a new chat thread (${newThreadId}). Previous thread saved — resume with: botholomew chat --thread-id ${previousThreadId}`,
588
+ timestamp: new Date(),
589
+ },
590
+ ]);
591
+ setMessagesEpoch((n) => n + 1);
592
+ setChatTitle(undefined);
593
+ })
594
+ .catch((err) => {
595
+ setMessages((prev) => [
596
+ ...prev,
597
+ {
598
+ id: msgId(),
599
+ role: "system",
600
+ content: `Failed to clear chat: ${err}`,
601
+ timestamp: new Date(),
602
+ },
603
+ ]);
604
+ });
605
+ },
566
606
  });
567
607
  if (handled) return;
568
608
  }
@@ -595,6 +635,10 @@ export function App({
595
635
  ? Array.from(sessionSkills.values()).map((s) => ({
596
636
  name: s.name,
597
637
  description: s.description,
638
+ takesArgs:
639
+ s.arguments.length > 0 ||
640
+ /\$ARGUMENTS\b/.test(s.body) ||
641
+ /\$[1-9]\b/.test(s.body),
598
642
  }))
599
643
  : [];
600
644
  return buildSlashCommands(BUILTIN_SLASH_COMMANDS, skillList);
@@ -640,7 +684,7 @@ export function App({
640
684
  node always has proper terminal width in its Yoga layout.
641
685
  Otherwise Ink's border renderer crashes with a negative
642
686
  contentWidth when tool-call boxes are rendered at width 0. */}
643
- <Static items={messages}>
687
+ <Static key={messagesEpoch} items={messages}>
644
688
  {(msg) => <MessageBubble key={msg.id} message={msg} />}
645
689
  </Static>
646
690
 
@@ -9,7 +9,7 @@ import {
9
9
  useState,
10
10
  } from "react";
11
11
  import type { SlashCommand } from "../../skills/commands.ts";
12
- import { getSlashMatches } from "../slashCompletion.ts";
12
+ import { getSlashMatches, shouldSubmitOnEnter } from "../slashCompletion.ts";
13
13
  import { SlashCommandPopup } from "./SlashCommandPopup.tsx";
14
14
 
15
15
  interface InputBarProps {
@@ -128,11 +128,23 @@ export const InputBar = memo(function InputBar({
128
128
  ? getSlashMatches(val, slashCommandsRef.current ?? [])
129
129
  : null;
130
130
 
131
- const acceptSelection = () => {
131
+ const acceptSelection = (mode: "insert" | "submit") => {
132
132
  if (!popupOpen) return false;
133
133
  const chosen =
134
134
  popupOpen[Math.min(selectedIndexRef.current, popupOpen.length - 1)];
135
135
  if (!chosen) return false;
136
+ if (mode === "submit") {
137
+ const completed = `/${chosen.name}`;
138
+ valueRef.current = completed;
139
+ cursorPosRef.current = 0;
140
+ onChangeRef.current(completed);
141
+ setCursorPos(0);
142
+ historyIndexRef.current = -1;
143
+ setHistoryIndex(-1);
144
+ savedInput.current = "";
145
+ onSubmitRef.current(completed);
146
+ return true;
147
+ }
136
148
  const completed = `/${chosen.name} `;
137
149
  valueRef.current = completed;
138
150
  cursorPosRef.current = completed.length;
@@ -152,11 +164,16 @@ export const InputBar = memo(function InputBar({
152
164
  return;
153
165
  }
154
166
 
155
- // Enter: if popup is open, accept selection (do not submit).
156
- // Otherwise submit as before.
167
+ // Enter: if popup is open, accept the highlighted entry. No-arg
168
+ // commands submit in one keystroke; commands that take args insert
169
+ // `/<name> ` and wait for the user to finish typing.
157
170
  if (key.return) {
158
171
  if (popupOpen && !key.shift && !key.meta) {
159
- acceptSelection();
172
+ const chosen =
173
+ popupOpen[Math.min(selectedIndexRef.current, popupOpen.length - 1)];
174
+ acceptSelection(
175
+ chosen && shouldSubmitOnEnter(chosen) ? "submit" : "insert",
176
+ );
160
177
  return;
161
178
  }
162
179
  if (key.shift || key.meta) {
@@ -179,10 +196,10 @@ export const InputBar = memo(function InputBar({
179
196
  return;
180
197
  }
181
198
 
182
- // Tab: accept popup selection if open. No-op otherwise.
199
+ // Tab: insert the highlighted completion so the user can keep editing.
183
200
  if (key.tab) {
184
201
  if (popupOpen) {
185
- acceptSelection();
202
+ acceptSelection("insert");
186
203
  }
187
204
  return;
188
205
  }
@@ -28,11 +28,25 @@ export function getSlashMatches(
28
28
 
29
29
  export function buildSlashCommands(
30
30
  builtins: SlashCommand[],
31
- skills: Iterable<{ name: string; description: string }>,
31
+ skills: Iterable<{ name: string; description: string; takesArgs?: boolean }>,
32
32
  ): SlashCommand[] {
33
33
  const out: SlashCommand[] = [...builtins];
34
34
  for (const s of skills) {
35
- out.push({ name: s.name, description: s.description });
35
+ out.push({
36
+ name: s.name,
37
+ description: s.description,
38
+ takesArgs: s.takesArgs,
39
+ });
36
40
  }
37
41
  return out;
38
42
  }
43
+
44
+ /**
45
+ * Decide whether pressing Enter on a highlighted popup entry should both
46
+ * accept the completion and immediately submit. True for no-argument
47
+ * commands (single-Enter runs them); false for commands that take args,
48
+ * where we insert `/<name> ` and wait for the user to finish typing.
49
+ */
50
+ export function shouldSubmitOnEnter(cmd: SlashCommand): boolean {
51
+ return !cmd.takesArgs;
52
+ }