botholomew 0.18.6 → 0.19.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.
@@ -1,8 +1,15 @@
1
1
  import { join } from "node:path";
2
- import Anthropic from "@anthropic-ai/sdk";
3
2
  import type { McpxClient } from "@evantahler/mcpx";
3
+ import { generateObject } from "ai";
4
+ import { z } from "zod";
4
5
  import type { BotholomewConfig } from "../config/schemas.ts";
5
6
  import { getPromptsDir } from "../constants.ts";
7
+ import {
8
+ buildProviderOptions,
9
+ formatLlmError,
10
+ getLanguageModel,
11
+ getMaxInputTokens,
12
+ } from "../llm/index.ts";
6
13
  import { getAllTools, type ToolDefinition } from "../tools/tool.ts";
7
14
  import {
8
15
  type ContextFileMeta,
@@ -14,7 +21,6 @@ import { logger } from "../utils/logger.ts";
14
21
  export const CAPABILITIES_FILENAME = "capabilities.md";
15
22
 
16
23
  // LLM config — summarization is one call per refresh, no streaming needed.
17
- const SUMMARIZE_TIMEOUT_MS = 30_000;
18
24
  const SUMMARIZE_MAX_TOKENS = 4096;
19
25
 
20
26
  // biome-ignore lint/suspicious/noExplicitAny: Zod-free tool schema for Anthropic SDK
@@ -142,71 +148,34 @@ interface SummarizedCapabilities {
142
148
  mcpx_servers: ServerThemes[];
143
149
  }
144
150
 
145
- const SUMMARIZE_TOOL_NAME = "return_capability_summary";
146
- const SUMMARIZE_TOOL = {
147
- name: SUMMARIZE_TOOL_NAME,
148
- description:
149
- "Return thematic capability summaries for the agent's tool inventory.",
150
- input_schema: {
151
- type: "object" as const,
152
- properties: {
153
- internal_themes: {
154
- type: "array",
155
- description:
156
- "Themes covering the agent's built-in tools (task queue, files & sandbox, search, threads, MCPX meta-tools, workers, self-reflection, etc.).",
157
- items: {
158
- type: "object",
159
- properties: {
160
- name: {
161
- type: "string",
162
- description: "Short theme name (2-4 words).",
163
- },
164
- summary: {
165
- type: "string",
166
- description:
167
- "One sentence with concrete action verbs. No tool names. No preamble.",
168
- },
169
- },
170
- required: ["name", "summary"],
171
- },
172
- },
173
- mcpx_servers: {
174
- type: "array",
175
- description:
176
- "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).",
177
- items: {
178
- type: "object",
179
- properties: {
180
- server: {
181
- type: "string",
182
- description: "Server name exactly as given in the inventory.",
183
- },
184
- themes: {
185
- type: "array",
186
- items: {
187
- type: "object",
188
- properties: {
189
- name: {
190
- type: "string",
191
- description: "Theme name (usually the service, e.g. Gmail)",
192
- },
193
- summary: {
194
- type: "string",
195
- description:
196
- "One sentence with concrete action verbs. No tool names.",
197
- },
198
- },
199
- required: ["name", "summary"],
200
- },
201
- },
202
- },
203
- required: ["server", "themes"],
204
- },
205
- },
206
- },
207
- required: ["internal_themes", "mcpx_servers"],
208
- },
209
- };
151
+ const ThemeSchema = z.object({
152
+ name: z.string().describe("Short theme name (2-4 words)."),
153
+ summary: z
154
+ .string()
155
+ .describe(
156
+ "One sentence with concrete action verbs. No tool names. No preamble.",
157
+ ),
158
+ });
159
+
160
+ const SummarySchema = z.object({
161
+ internal_themes: z
162
+ .array(ThemeSchema)
163
+ .describe(
164
+ "Themes covering the agent's built-in tools (task queue, files & sandbox, search, threads, MCPX meta-tools, workers, self-reflection, etc.).",
165
+ ),
166
+ mcpx_servers: z
167
+ .array(
168
+ z.object({
169
+ server: z
170
+ .string()
171
+ .describe("Server name exactly as given in the inventory."),
172
+ themes: z.array(ThemeSchema),
173
+ }),
174
+ )
175
+ .describe(
176
+ "MCPX tools grouped by their source server. Within each server, split into themes only when the server exposes distinct services.",
177
+ ),
178
+ });
210
179
 
211
180
  function renderInventoryForPrompt(inv: RawInventory): string {
212
181
  const sections: string[] = [];
@@ -255,42 +224,42 @@ BAD examples (do not produce):
255
224
  "Provides access to Gmail operations via tools like Gmail_SendEmail..."
256
225
  "Tools for working with email"`;
257
226
 
227
+ function hasUsableCreds(config: BotholomewConfig): boolean {
228
+ const cfg = config.chunker_llm;
229
+ if (cfg.provider === "anthropic") {
230
+ return !!cfg.api_key && cfg.api_key !== "your-api-key-here";
231
+ }
232
+ if (cfg.provider === "openai-compatible") {
233
+ return !!cfg.base_url;
234
+ }
235
+ // ollama: no credentials required, assume reachable.
236
+ return true;
237
+ }
238
+
258
239
  async function summarizeViaLLM(
259
240
  inv: RawInventory,
260
- config: Required<BotholomewConfig>,
241
+ config: BotholomewConfig,
261
242
  ): Promise<SummarizedCapabilities | null> {
262
- if (
263
- !config.anthropic_api_key ||
264
- config.anthropic_api_key === "your-api-key-here"
265
- ) {
266
- return null;
267
- }
243
+ if (!hasUsableCreds(config)) return null;
268
244
 
269
- const client = new Anthropic({ apiKey: config.anthropic_api_key });
270
- const userPrompt = `Summarize this tool inventory. Return via the \`${SUMMARIZE_TOOL_NAME}\` tool.\n\n${renderInventoryForPrompt(inv)}`;
245
+ const userPrompt = `Summarize this tool inventory.\n\n${renderInventoryForPrompt(inv)}`;
271
246
 
272
247
  try {
273
- const response = await client.messages.create(
274
- {
275
- model: config.chunker_model,
276
- max_tokens: SUMMARIZE_MAX_TOKENS,
277
- system: SUMMARIZE_SYSTEM,
278
- tools: [SUMMARIZE_TOOL],
279
- tool_choice: { type: "tool", name: SUMMARIZE_TOOL_NAME },
280
- messages: [{ role: "user", content: userPrompt }],
281
- },
282
- { timeout: SUMMARIZE_TIMEOUT_MS },
283
- );
284
-
285
- const toolBlock = response.content.find((b) => b.type === "tool_use");
286
- if (!toolBlock || toolBlock.type !== "tool_use") return null;
287
-
288
- const input = toolBlock.input as SummarizedCapabilities;
289
- if (!Array.isArray(input.internal_themes)) return null;
290
- if (!Array.isArray(input.mcpx_servers)) return null;
291
- return input;
248
+ const model = getLanguageModel(config.chunker_llm);
249
+ const numCtx = await getMaxInputTokens(config.chunker_llm);
250
+ const { object } = await generateObject({
251
+ model,
252
+ schema: SummarySchema,
253
+ system: SUMMARIZE_SYSTEM,
254
+ prompt: userPrompt,
255
+ maxOutputTokens: SUMMARIZE_MAX_TOKENS,
256
+ providerOptions: buildProviderOptions(config.chunker_llm, numCtx),
257
+ });
258
+ return object;
292
259
  } catch (err) {
293
- logger.debug(`Capability summarization failed: ${(err as Error).message}`);
260
+ logger.debug(
261
+ `Capability summarization failed: ${formatLlmError(err, config.chunker_llm)}`,
262
+ );
294
263
  return null;
295
264
  }
296
265
  }
@@ -404,7 +373,7 @@ function renderFallback(inv: RawInventory, now: Date): string {
404
373
  );
405
374
  } else {
406
375
  parts.push(
407
- "_(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.)_",
376
+ "_(LLM summarization unavailable — set `llm.api_key` (or `llm.base_url` for local providers) and rerun to generate themed summaries. Until then, use `mcp_list_tools` with each server to see what's exposed.)_",
408
377
  );
409
378
  parts.push("");
410
379
  const servers = [...inv.mcpByServer.keys()].sort();
@@ -418,29 +387,24 @@ function renderFallback(inv: RawInventory, now: Date): string {
418
387
  }
419
388
 
420
389
  /**
421
- * Build the body of capabilities.md. When `config.anthropic_api_key` is set,
422
- * Claude is asked to produce thematic summaries. Otherwise (or on failure) a
423
- * static fallback listing is rendered.
390
+ * Build the body of capabilities.md. When the configured chunker LLM has
391
+ * usable credentials, the model is asked to produce thematic summaries.
392
+ * Otherwise (or on failure) a static fallback listing is rendered.
424
393
  */
425
394
  export async function generateCapabilitiesMarkdown(
426
395
  mcpxClient: McpxClient | null,
427
- config: Required<BotholomewConfig>,
396
+ config: BotholomewConfig,
428
397
  now: Date = new Date(),
429
398
  onPhase?: ProgressCallback,
430
399
  ): Promise<GenerateResult> {
431
400
  const inv = await collectInventory(mcpxClient, onPhase);
432
401
 
433
- // Don't call the LLM when the inventory is empty / broken — the fallback
434
- // conveys the same information and avoids an unnecessary API round trip.
435
402
  const hasAnythingToSummarize =
436
403
  inv.mcpByServer.size > 0 || inv.internalTotal > 0;
437
404
 
438
405
  let summary: SummarizedCapabilities | null = null;
439
406
  if (hasAnythingToSummarize) {
440
- const canSummarize =
441
- config.anthropic_api_key &&
442
- config.anthropic_api_key !== "your-api-key-here";
443
- if (canSummarize) {
407
+ if (hasUsableCreds(config)) {
444
408
  onPhase?.(
445
409
  `Summarizing ${inv.internalTotal} internal + ${inv.mcpTotal} MCPX tools`,
446
410
  );
@@ -472,7 +436,7 @@ export interface WriteResult {
472
436
  export async function writeCapabilitiesFile(
473
437
  projectDir: string,
474
438
  mcpxClient: McpxClient | null,
475
- config: Required<BotholomewConfig>,
439
+ config: BotholomewConfig,
476
440
  onPhase?: ProgressCallback,
477
441
  ): Promise<WriteResult> {
478
442
  const filePath = join(getPromptsDir(projectDir), CAPABILITIES_FILENAME);
@@ -34,13 +34,14 @@ type MembotMethodName =
34
34
  | "move"
35
35
  | "remove"
36
36
  | "refresh"
37
- | "prune";
37
+ | "prune"
38
+ | "sources";
38
39
 
39
40
  /**
40
- * Map an Operation's exposed name (`membot_add`, `membot_delete`, …) to the
41
- * `MembotClient` method that actually runs it. The two diverge in a couple
42
- * of spots `membot_delete` calls `client.remove`, `membot_move` calls
43
- * `client.move` so we keep the routing explicit rather than guessing.
41
+ * Map an Operation's exposed name (`membot_add`, `membot_remove`, …) to the
42
+ * `MembotClient` method that actually runs it. Mostly 1:1 with the op name
43
+ * minus the `membot_` prefix; kept explicit so a renamed/added op fails
44
+ * loudly at registration instead of silently misrouting.
44
45
  */
45
46
  const METHOD_BY_OP_NAME: Record<string, MembotMethodName> = {
46
47
  membot_add: "add",
@@ -54,9 +55,10 @@ const METHOD_BY_OP_NAME: Record<string, MembotMethodName> = {
54
55
  membot_diff: "diff",
55
56
  membot_write: "write",
56
57
  membot_move: "move",
57
- membot_delete: "remove",
58
+ membot_remove: "remove",
58
59
  membot_refresh: "refresh",
59
60
  membot_prune: "prune",
61
+ membot_sources: "sources",
60
62
  };
61
63
 
62
64
  /**
@@ -32,7 +32,7 @@ const outputSchema = z.object({
32
32
  export const membotEditTool = {
33
33
  name: "membot_edit",
34
34
  description:
35
- "[[ bash equivalent command: patch ]] Apply line-range edits to a stored file: reads the current version, applies bottom-up patches, and writes the result back as a new version. Prefer this over membot_write when you only need to change part of a file — the diff is small and the change_note travels with the new version. To replace the whole body, use membot_write. To delete the file, use membot_delete.",
35
+ "[[ bash equivalent command: patch ]] Apply line-range edits to a stored file: reads the current version, applies bottom-up patches, and writes the result back as a new version. Prefer this over membot_write when you only need to change part of a file — the diff is small and the change_note travels with the new version. To replace the whole body, use membot_write. To delete the file, use membot_remove.",
36
36
  group: "membot",
37
37
  inputSchema,
38
38
  outputSchema,
package/src/tools/tool.ts CHANGED
@@ -1,6 +1,5 @@
1
- import type { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/messages";
2
1
  import type { McpxClient } from "@evantahler/mcpx";
3
- import { z } from "zod";
2
+ import type { z } from "zod";
4
3
  import type { BotholomewConfig } from "../config/schemas.ts";
5
4
  import type { WithMem } from "../mem/client.ts";
6
5
 
@@ -14,7 +13,7 @@ export interface ToolContext {
14
13
  */
15
14
  withMem: WithMem;
16
15
  projectDir: string;
17
- config: Required<BotholomewConfig>;
16
+ config: BotholomewConfig;
18
17
  mcpxClient: McpxClient | null;
19
18
  /**
20
19
  * Identifier of the agent process running this tool, used as the holder
@@ -84,22 +83,3 @@ export function getAllTools(): AnyToolDefinition[] {
84
83
  export function getToolsByGroup(group: string): AnyToolDefinition[] {
85
84
  return getAllTools().filter((t) => t.group === group);
86
85
  }
87
-
88
- // --- Anthropic adapter ---
89
-
90
- export function toAnthropicTool(tool: AnyToolDefinition): AnthropicTool {
91
- const jsonSchema = z.toJSONSchema(tool.inputSchema);
92
- return {
93
- name: tool.name,
94
- description: tool.description,
95
- input_schema: {
96
- type: "object" as const,
97
- properties: jsonSchema.properties ?? {},
98
- required: jsonSchema.required as string[] | undefined,
99
- },
100
- };
101
- }
102
-
103
- export function toAnthropicTools(): AnthropicTool[] {
104
- return getAllTools().map(toAnthropicTool);
105
- }
@@ -240,7 +240,7 @@ export const ContextPanel = memo(function ContextPanel({
240
240
  ? `🔍 match (score=${selectedRow.hit.score.toFixed(3)}, chunk #${selectedRow.hit.chunk_index})\n${selectedRow.hit.snippet}\n\n---\n\n`
241
241
  : "";
242
242
  const body = isMarkdownPath(fileContent.logical_path)
243
- ? renderMarkdown(fileContent.content)
243
+ ? renderMarkdown(fileContent.content, detailWidth)
244
244
  : fileContent.content;
245
245
  return wrapDetailLines(snippetHeader + body, detailWidth);
246
246
  }, [selectedRow, fileContent, detailWidth]);
@@ -222,10 +222,11 @@ export function useMessageQueue({
222
222
  }
223
223
  finalizeSegment();
224
224
  } catch (err) {
225
+ const message = err instanceof Error ? err.message : String(err);
225
226
  const errorMsg: ChatMessage = {
226
227
  id: msgId(),
227
228
  role: "system",
228
- content: `Error: ${err}`,
229
+ content: `Error: ${message}`,
229
230
  timestamp: new Date(),
230
231
  };
231
232
  setMessages((prev) => [...prev, errorMsg]);
@@ -1,6 +1,49 @@
1
- export function renderMarkdown(text: string): string {
1
+ import { extractTableBlocks, renderTable } from "./markdownTables.ts";
2
+
3
+ /**
4
+ * Render markdown to ANSI for a TUI detail pane. When `width` is provided,
5
+ * GFM tables are pulled out and rendered ourselves at that width before
6
+ * handing the rest off to `Bun.markdown.ansi` — Bun's renderer ignores any
7
+ * width hint and emits tables at their natural width, which `wrap-ansi` then
8
+ * shreds mid-cell.
9
+ */
10
+ export function renderMarkdown(text: string, width?: number): string {
2
11
  if (!text) return "";
3
- return Bun.markdown.ansi(text).trimEnd();
12
+ if (width === undefined || width <= 0) {
13
+ return Bun.markdown.ansi(text).trimEnd();
14
+ }
15
+
16
+ const blocks = extractTableBlocks(text);
17
+ if (blocks.length === 0) {
18
+ return Bun.markdown.ansi(text).trimEnd();
19
+ }
20
+
21
+ const lines = text.split("\n");
22
+ const rendered: string[] = blocks.map((b) =>
23
+ renderTable(b.rows, b.aligns, width),
24
+ );
25
+ // Bun.markdown.ansi mangles NUL bytes (→ U+FFFD), so use a plain alphanumeric
26
+ // sentinel that survives the markdown pass intact. Wrap each block's
27
+ // line-range with a single sentinel line, then splice the pre-rendered
28
+ // table back in after Bun finishes styling the rest of the document.
29
+ const sentinel = (i: number) => `BHTBLSENTINEL${i}BHTBLEND`;
30
+ const out = lines.slice();
31
+ for (let i = blocks.length - 1; i >= 0; i--) {
32
+ const b = blocks[i];
33
+ if (!b) continue;
34
+ out.splice(b.start, b.end - b.start + 1, sentinel(i));
35
+ }
36
+ const piped = Bun.markdown.ansi(out.join("\n")).trimEnd();
37
+ let stitched = piped;
38
+ for (let i = 0; i < blocks.length; i++) {
39
+ // Bun wraps each paragraph with a trailing reset (`\x1b[0m`). Strip any
40
+ // SGR escapes that hug the sentinel so the table doesn't inherit them.
41
+ const re = new RegExp(
42
+ `(?:\\x1b\\[[0-9;]*m)*${sentinel(i)}(?:\\x1b\\[[0-9;]*m)*`,
43
+ );
44
+ stitched = stitched.replace(re, rendered[i] ?? "");
45
+ }
46
+ return stitched;
4
47
  }
5
48
 
6
49
  export function isMarkdownPath(path: string): boolean {