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.
- package/README.md +56 -2
- package/package.json +12 -9
- package/src/chat/agent.ts +175 -181
- package/src/chat/session.ts +30 -31
- package/src/chat/usage.ts +19 -20
- package/src/commands/init.ts +20 -0
- package/src/config/loader.ts +50 -10
- package/src/config/schemas.ts +48 -22
- package/src/init/index.ts +12 -5
- package/src/init/templates.ts +45 -4
- package/src/llm/abort.ts +9 -0
- package/src/llm/cache-control.ts +65 -0
- package/src/llm/capabilities.ts +155 -0
- package/src/llm/error-format.ts +95 -0
- package/src/llm/fake.ts +226 -0
- package/src/llm/index.ts +19 -0
- package/src/llm/provider-options.ts +29 -0
- package/src/llm/provider.ts +65 -0
- package/src/llm/tools.ts +24 -0
- package/src/llm/types.ts +20 -0
- package/src/llm/usage.ts +33 -0
- package/src/prompts/capabilities.ts +72 -108
- package/src/tools/membot/adapter.ts +8 -6
- package/src/tools/membot/edit.ts +1 -1
- package/src/tools/tool.ts +2 -22
- package/src/tui/components/ContextPanel.tsx +1 -1
- package/src/tui/hooks/useMessageQueue.ts +2 -1
- package/src/tui/markdown.ts +45 -2
- package/src/tui/markdownTables.ts +288 -0
- package/src/utils/title.ts +21 -22
- package/src/worker/context.ts +45 -77
- package/src/worker/llm.ts +147 -112
- package/src/worker/prompt.ts +1 -1
- package/src/worker/schedules.ts +43 -54
- package/src/worker/tick.ts +3 -3
- package/src/worker/fake-llm.ts +0 -277
- package/src/worker/llm-client.ts +0 -12
|
@@ -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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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:
|
|
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
|
|
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
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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(
|
|
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 `
|
|
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
|
|
422
|
-
*
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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`, `
|
|
41
|
-
* `MembotClient` method that actually runs it.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
-
|
|
58
|
+
membot_remove: "remove",
|
|
58
59
|
membot_refresh: "refresh",
|
|
59
60
|
membot_prune: "prune",
|
|
61
|
+
membot_sources: "sources",
|
|
60
62
|
};
|
|
61
63
|
|
|
62
64
|
/**
|
package/src/tools/membot/edit.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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: ${
|
|
229
|
+
content: `Error: ${message}`,
|
|
229
230
|
timestamp: new Date(),
|
|
230
231
|
};
|
|
231
232
|
setMessages((prev) => [...prev, errorMsg]);
|
package/src/tui/markdown.ts
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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 {
|