botholomew 0.8.7 → 0.8.9

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.7",
3
+ "version": "0.8.9",
4
4
  "description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
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,46 @@
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
+ await mcpxClient?.close();
40
+ process.exit(1);
41
+ }
42
+ await mcpxClient?.close();
43
+ process.exit(0);
44
+ }),
45
+ );
46
+ }
@@ -0,0 +1,507 @@
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 client.messages.create(
272
+ {
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
+ { timeout: SUMMARIZE_TIMEOUT_MS },
281
+ );
282
+
283
+ const toolBlock = response.content.find((b) => b.type === "tool_use");
284
+ if (!toolBlock || toolBlock.type !== "tool_use") return null;
285
+
286
+ const input = toolBlock.input as SummarizedCapabilities;
287
+ if (!Array.isArray(input.internal_themes)) return null;
288
+ if (!Array.isArray(input.mcpx_servers)) return null;
289
+ return input;
290
+ } catch (err) {
291
+ logger.debug(`Capability summarization failed: ${(err as Error).message}`);
292
+ return null;
293
+ }
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Rendering
298
+ // ---------------------------------------------------------------------------
299
+
300
+ function renderHeader(now: Date): string[] {
301
+ return [
302
+ "# Capabilities",
303
+ "",
304
+ `*Generated ${now.toISOString()}. Regenerate with \`botholomew capabilities\`, the \`capabilities_refresh\` tool, or the \`/capabilities\` skill.*`,
305
+ "",
306
+ "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.",
307
+ "",
308
+ ];
309
+ }
310
+
311
+ function renderSummarized(
312
+ summary: SummarizedCapabilities,
313
+ inv: RawInventory,
314
+ now: Date,
315
+ ): string {
316
+ const parts: string[] = [];
317
+ parts.push(...renderHeader(now));
318
+
319
+ parts.push("## Internal capabilities");
320
+ parts.push("");
321
+ for (const theme of summary.internal_themes) {
322
+ parts.push(`- **${theme.name}** — ${theme.summary}`);
323
+ }
324
+ parts.push("");
325
+
326
+ parts.push("## External capabilities (via MCPX)");
327
+ parts.push("");
328
+ if (!inv.mcpConfigured) {
329
+ parts.push(
330
+ "_No MCPX servers configured. Add one with `botholomew mcpx add` and rerun `botholomew capabilities`._",
331
+ );
332
+ } else if (inv.mcpError) {
333
+ parts.push(
334
+ `_Failed to list MCPX tools: ${inv.mcpError}. Check your MCPX server configuration._`,
335
+ );
336
+ } else if (summary.mcpx_servers.length === 0) {
337
+ parts.push(
338
+ "_MCPX is configured but no tools are exposed by the connected servers._",
339
+ );
340
+ } else {
341
+ for (const srv of summary.mcpx_servers) {
342
+ parts.push(`### ${srv.server}`);
343
+ parts.push("");
344
+ for (const theme of srv.themes) {
345
+ parts.push(`- **${theme.name}** — ${theme.summary}`);
346
+ }
347
+ parts.push("");
348
+ }
349
+ }
350
+
351
+ return parts.join("\n").trimEnd();
352
+ }
353
+
354
+ /**
355
+ * Fallback rendering when no API key is set or the LLM call fails.
356
+ * Produces a static high-level summary of internal tools plus a server-level
357
+ * listing for MCPX (with tool counts), still far more compact than listing
358
+ * every tool. The agent uses the MCPX meta-tools to drill in when needed.
359
+ */
360
+ function renderFallback(inv: RawInventory, now: Date): string {
361
+ const parts: string[] = [];
362
+ parts.push(...renderHeader(now));
363
+
364
+ parts.push("## Internal capabilities");
365
+ parts.push("");
366
+ const fallbackInternal: Record<string, string> = {
367
+ task: "create, list, view, update, complete, fail, and wait on tasks in the agent's work queue",
368
+ schedule:
369
+ "create and list recurring schedules that automatically generate tasks",
370
+ context:
371
+ "read, write, edit, move, copy, delete, and navigate items in the agent's persistent memory store; update beliefs and goals; read large tool results",
372
+ search: "keyword and semantic search over the virtual filesystem",
373
+ thread: "list and view past conversation threads and tool interactions",
374
+ mcp: "search, list, inspect, and execute tools exposed by configured MCPX servers",
375
+ worker: "spawn background workers to run tasks asynchronously",
376
+ capabilities: "refresh this capabilities file (the tool inventory)",
377
+ };
378
+ for (const group of [...GROUP_ORDER, "other" as const]) {
379
+ const tools = inv.internal.get(group);
380
+ if (!tools || tools.length === 0) continue;
381
+ const heading = GROUP_HEADINGS[group] ?? group;
382
+ const summary = fallbackInternal[group] ?? "(no summary)";
383
+ parts.push(`- **${heading}** — ${summary}`);
384
+ }
385
+ parts.push("");
386
+
387
+ parts.push("## External capabilities (via MCPX)");
388
+ parts.push("");
389
+ if (!inv.mcpConfigured) {
390
+ parts.push(
391
+ "_No MCPX servers configured. Add one with `botholomew mcpx add` and rerun `botholomew capabilities`._",
392
+ );
393
+ } else if (inv.mcpError) {
394
+ parts.push(
395
+ `_Failed to list MCPX tools: ${inv.mcpError}. Check your MCPX server configuration._`,
396
+ );
397
+ } else if (inv.mcpByServer.size === 0) {
398
+ parts.push(
399
+ "_MCPX is configured but no tools are exposed by the connected servers._",
400
+ );
401
+ } else {
402
+ parts.push(
403
+ "_(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.)_",
404
+ );
405
+ parts.push("");
406
+ const servers = [...inv.mcpByServer.keys()].sort();
407
+ for (const server of servers) {
408
+ const tools = inv.mcpByServer.get(server) ?? [];
409
+ parts.push(`- **${server}** — ${tools.length} tool(s)`);
410
+ }
411
+ }
412
+
413
+ return parts.join("\n").trimEnd();
414
+ }
415
+
416
+ /**
417
+ * Build the body of capabilities.md. When `config.anthropic_api_key` is set,
418
+ * Claude is asked to produce thematic summaries. Otherwise (or on failure) a
419
+ * static fallback listing is rendered.
420
+ */
421
+ export async function generateCapabilitiesMarkdown(
422
+ mcpxClient: McpxClient | null,
423
+ config: Required<BotholomewConfig>,
424
+ now: Date = new Date(),
425
+ onPhase?: ProgressCallback,
426
+ ): Promise<GenerateResult> {
427
+ const inv = await collectInventory(mcpxClient, onPhase);
428
+
429
+ // Don't call the LLM when the inventory is empty / broken — the fallback
430
+ // conveys the same information and avoids an unnecessary API round trip.
431
+ const hasAnythingToSummarize =
432
+ inv.mcpByServer.size > 0 || inv.internalTotal > 0;
433
+
434
+ let summary: SummarizedCapabilities | null = null;
435
+ if (hasAnythingToSummarize) {
436
+ const canSummarize =
437
+ config.anthropic_api_key &&
438
+ config.anthropic_api_key !== "your-api-key-here";
439
+ if (canSummarize) {
440
+ onPhase?.(
441
+ `Summarizing ${inv.internalTotal} internal + ${inv.mcpTotal} MCPX tools with Claude`,
442
+ );
443
+ }
444
+ summary = await summarizeViaLLM(inv, config);
445
+ }
446
+
447
+ const body = summary
448
+ ? renderSummarized(summary, inv, now)
449
+ : renderFallback(inv, now);
450
+
451
+ return {
452
+ body,
453
+ counts: { internal: inv.internalTotal, mcp: inv.mcpTotal },
454
+ };
455
+ }
456
+
457
+ export interface WriteResult {
458
+ path: string;
459
+ counts: CapabilitiesCounts;
460
+ createdFile: boolean;
461
+ }
462
+
463
+ /**
464
+ * Regenerate and write `.botholomew/capabilities.md`. Preserves any existing
465
+ * frontmatter (so a human-edited `loading:` flag survives). On first write
466
+ * the default frontmatter is `loading: always`, `agent-modification: true`.
467
+ */
468
+ export async function writeCapabilitiesFile(
469
+ projectDir: string,
470
+ mcpxClient: McpxClient | null,
471
+ config: Required<BotholomewConfig>,
472
+ onPhase?: ProgressCallback,
473
+ ): Promise<WriteResult> {
474
+ const filePath = join(getBotholomewDir(projectDir), CAPABILITIES_FILENAME);
475
+ const file = Bun.file(filePath);
476
+
477
+ let meta: ContextFileMeta = {
478
+ loading: "always",
479
+ "agent-modification": true,
480
+ };
481
+ let createdFile = true;
482
+
483
+ if (await file.exists()) {
484
+ const raw = await file.text();
485
+ const parsed = parseContextFile(raw);
486
+ if (parsed.meta && typeof parsed.meta === "object") {
487
+ meta = {
488
+ loading: parsed.meta.loading ?? meta.loading,
489
+ "agent-modification":
490
+ parsed.meta["agent-modification"] ?? meta["agent-modification"],
491
+ };
492
+ }
493
+ createdFile = false;
494
+ }
495
+
496
+ const { body, counts } = await generateCapabilitiesMarkdown(
497
+ mcpxClient,
498
+ config,
499
+ new Date(),
500
+ onPhase,
501
+ );
502
+ onPhase?.(`Writing ${CAPABILITIES_FILENAME}`);
503
+ const serialized = serializeContextFile(meta, body);
504
+ await Bun.write(filePath, serialized);
505
+
506
+ return { path: filePath, counts, createdFile };
507
+ }
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"
@@ -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);