botholomew 0.7.10 → 0.7.12

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
@@ -6,6 +6,8 @@
6
6
  " "
7
7
  ```
8
8
 
9
+ ![Botholomew chat TUI](docs/assets/chat-happy-path.gif)
10
+
9
11
  **A local-first AI agent for knowledge work.** Botholomew is a long-running
10
12
  autonomous agent that works its way through a task queue — reading email,
11
13
  summarizing documents, researching topics, organizing notes, and maintaining
@@ -111,6 +113,8 @@ Everything the agent can touch is here. No surprises.
111
113
 
112
114
  ## The CLI
113
115
 
116
+ ![CLI walkthrough: task list, task add, schedule list, context list](docs/assets/cli-tour.gif)
117
+
114
118
  | Command | Purpose |
115
119
  |---|---|
116
120
  | `botholomew init` | Create `.botholomew/` with templates and a fresh database |
@@ -193,6 +197,8 @@ Topics worth understanding in detail:
193
197
  multi-project service naming.
194
198
  - **[Configuration](docs/configuration.md)** — every key in `config.json`
195
199
  and its default.
200
+ - **[Doc captures](docs/captures.md)** — how the screenshots and GIFs in
201
+ these docs are regenerated programmatically via VHS and a fake-LLM mode.
196
202
 
197
203
  ---
198
204
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.7.10",
3
+ "version": "0.7.12",
4
4
  "description": "Local, autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,8 @@
19
19
  "dev": "bun run src/cli.ts",
20
20
  "dev:demo": "bun run src/cli.ts chat -p 'learn everything you can about me from the connected MCP services and then save what you'\\''ve learned about me to context'",
21
21
  "test": "bun test",
22
- "lint": "tsc --noEmit && biome check ."
22
+ "lint": "tsc --noEmit && biome check .",
23
+ "capture": "bun run scripts/capture.ts"
23
24
  },
24
25
  "dependencies": {
25
26
  "@anthropic-ai/sdk": "^0.88.0",
package/src/chat/agent.ts CHANGED
@@ -1,4 +1,3 @@
1
- import Anthropic from "@anthropic-ai/sdk";
2
1
  import type {
3
2
  MessageParam,
4
3
  ToolResultBlockParam,
@@ -9,6 +8,7 @@ import type { BotholomewConfig } from "../config/schemas.ts";
9
8
  import { embedSingle } from "../context/embedder.ts";
10
9
  import { fitToContextWindow, getMaxInputTokens } from "../daemon/context.ts";
11
10
  import { maybeStoreResult } from "../daemon/large-results.ts";
11
+ import { createLlmClient } from "../daemon/llm-client.ts";
12
12
  import {
13
13
  buildMetaHeader,
14
14
  extractKeywords,
@@ -178,9 +178,7 @@ export async function runChatTurn(input: {
178
178
  callbacks,
179
179
  } = input;
180
180
 
181
- const client = new Anthropic({
182
- apiKey: config.anthropic_api_key || undefined,
183
- });
181
+ const client = createLlmClient(config);
184
182
 
185
183
  const chatTools = getChatTools();
186
184
  const maxInputTokens = await getMaxInputTokens(
@@ -36,6 +36,12 @@ export function registerChatCommand(program: Command) {
36
36
  await ensureDaemonRunning(dir);
37
37
  }
38
38
 
39
+ // VHS/ttyd doesn't fully negotiate the Kitty Keyboard protocol, so
40
+ // Ink's "enabled" mode drops non-text keystrokes (Tab, Escape) under
41
+ // capture. Use "disabled" mode in capture to keep text input working;
42
+ // captures that need Tab/Escape should use the `-p` prompt flag or
43
+ // a /slash command typed as text instead.
44
+ const isCapture = process.env.BOTHOLOMEW_FAKE_LLM === "1";
39
45
  const instance = render(
40
46
  React.createElement(App, {
41
47
  projectDir: dir,
@@ -44,10 +50,12 @@ export function registerChatCommand(program: Command) {
44
50
  }),
45
51
  {
46
52
  exitOnCtrlC: false,
47
- kittyKeyboard: {
48
- mode: "enabled",
49
- flags: ["disambiguateEscapeCodes"],
50
- },
53
+ kittyKeyboard: isCapture
54
+ ? { mode: "disabled" }
55
+ : {
56
+ mode: "enabled",
57
+ flags: ["disambiguateEscapeCodes"],
58
+ },
51
59
  },
52
60
  );
53
61
  await instance.waitUntilExit();
@@ -0,0 +1,204 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import type Anthropic from "@anthropic-ai/sdk";
4
+ import type {
5
+ Message,
6
+ ToolUseBlock,
7
+ } from "@anthropic-ai/sdk/resources/messages";
8
+
9
+ export interface FakeTurn {
10
+ /** Optional regex matched against the most recent user-authored text. */
11
+ match?: string;
12
+ /** Full reply text; auto-chunked if `chunks` is absent. */
13
+ text?: string;
14
+ /** Explicit token chunks; overrides auto-chunking. */
15
+ chunks?: string[];
16
+ /** Characters per auto-chunk when `chunks` is absent. */
17
+ chunkSize?: number;
18
+ /** Delay between chunks in milliseconds. */
19
+ delayMs?: number;
20
+ /** Optional tool calls to emit after text. */
21
+ toolCalls?: Array<{
22
+ id?: string;
23
+ name: string;
24
+ input: Record<string, unknown>;
25
+ }>;
26
+ }
27
+
28
+ export interface FakeFixture {
29
+ turns: FakeTurn[];
30
+ }
31
+
32
+ let loadedFixture: FakeFixture | null = null;
33
+ let loadedFixturePath: string | undefined;
34
+ let sequentialIndex = 0;
35
+
36
+ function loadFixture(): FakeFixture {
37
+ const fixturePath = process.env.BOTHOLOMEW_FAKE_LLM_FIXTURE;
38
+ // Reload (and reset the sequential cursor) whenever the fixture path
39
+ // changes — tests rotate fixtures between cases, and callers can swap
40
+ // fixtures mid-session without restarting the process.
41
+ if (loadedFixture && loadedFixturePath === fixturePath) {
42
+ return loadedFixture;
43
+ }
44
+ loadedFixturePath = fixturePath;
45
+ sequentialIndex = 0;
46
+ if (!fixturePath) {
47
+ loadedFixture = { turns: [] };
48
+ return loadedFixture;
49
+ }
50
+ if (!existsSync(fixturePath)) {
51
+ throw new Error(
52
+ `BOTHOLOMEW_FAKE_LLM_FIXTURE points to missing file: ${fixturePath}`,
53
+ );
54
+ }
55
+ loadedFixture = JSON.parse(readFileSync(fixturePath, "utf8")) as FakeFixture;
56
+ return loadedFixture;
57
+ }
58
+
59
+ function selectTurn(lastUserText: string): FakeTurn {
60
+ const fixture = loadFixture();
61
+ if (fixture.turns.length === 0) {
62
+ return { text: "(fake LLM: no fixture turns configured)" };
63
+ }
64
+ // Only consider turns at or after the cursor, so that multi-turn fixtures
65
+ // (e.g. text → tool_use → follow-up text) advance past a matched turn even
66
+ // when the agent's next iteration shows the same user text.
67
+ for (let i = sequentialIndex; i < fixture.turns.length; i++) {
68
+ const t = fixture.turns[i];
69
+ if (t?.match && new RegExp(t.match, "i").test(lastUserText)) {
70
+ sequentialIndex = i + 1;
71
+ return t;
72
+ }
73
+ }
74
+ if (sequentialIndex < fixture.turns.length) {
75
+ const t = fixture.turns[sequentialIndex];
76
+ sequentialIndex++;
77
+ if (t) return t;
78
+ }
79
+ // Out of turns — repeat the last one so the agent loop doesn't spin.
80
+ return fixture.turns[fixture.turns.length - 1] ?? { text: "" };
81
+ }
82
+
83
+ function chunkText(text: string, size: number): string[] {
84
+ if (size <= 0 || text.length === 0) return text ? [text] : [];
85
+ const out: string[] = [];
86
+ for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size));
87
+ return out;
88
+ }
89
+
90
+ function buildFinalMessage(
91
+ text: string,
92
+ toolCalls?: FakeTurn["toolCalls"],
93
+ ): Message {
94
+ const content: Array<Record<string, unknown>> = [];
95
+ if (text) content.push({ type: "text", text, citations: null });
96
+ if (toolCalls) {
97
+ for (const tc of toolCalls) {
98
+ content.push({
99
+ type: "tool_use",
100
+ id: tc.id ?? `toolu_${Math.random().toString(36).slice(2, 14)}`,
101
+ name: tc.name,
102
+ input: tc.input,
103
+ });
104
+ }
105
+ }
106
+ return {
107
+ id: `msg_${Math.random().toString(36).slice(2, 14)}`,
108
+ type: "message",
109
+ role: "assistant",
110
+ model: "botholomew-fake-llm",
111
+ content,
112
+ stop_reason: toolCalls?.length ? "tool_use" : "end_turn",
113
+ stop_sequence: null,
114
+ usage: {
115
+ input_tokens: 100,
116
+ output_tokens: Math.max(1, Math.floor(text.length / 4)),
117
+ cache_creation_input_tokens: 0,
118
+ cache_read_input_tokens: 0,
119
+ service_tier: "standard",
120
+ server_tool_use: null,
121
+ },
122
+ } as unknown as Message;
123
+ }
124
+
125
+ class FakeMessageStream extends EventEmitter {
126
+ private resolveFinal: (m: Message) => void = () => {};
127
+ private readonly finalPromise: Promise<Message>;
128
+
129
+ constructor(private readonly turn: FakeTurn) {
130
+ super();
131
+ this.finalPromise = new Promise<Message>((resolve) => {
132
+ this.resolveFinal = resolve;
133
+ });
134
+ queueMicrotask(() => this.run());
135
+ }
136
+
137
+ private async run(): Promise<void> {
138
+ const text = this.turn.text ?? this.turn.chunks?.join("") ?? "";
139
+ const chunks =
140
+ this.turn.chunks ?? chunkText(text, this.turn.chunkSize ?? 6);
141
+ const delay = this.turn.delayMs ?? 40;
142
+ for (const chunk of chunks) {
143
+ this.emit("text", chunk);
144
+ if (delay > 0) await new Promise((r) => setTimeout(r, delay));
145
+ }
146
+ const final = buildFinalMessage(text, this.turn.toolCalls);
147
+ for (const block of final.content) {
148
+ if ((block as { type: string }).type === "tool_use") {
149
+ this.emit("contentBlock", block as ToolUseBlock);
150
+ }
151
+ }
152
+ this.resolveFinal(final);
153
+ }
154
+
155
+ finalMessage(): Promise<Message> {
156
+ return this.finalPromise;
157
+ }
158
+ }
159
+
160
+ function extractLastUserText(
161
+ messages: Array<{ role?: string; content?: unknown }>,
162
+ ): string {
163
+ for (let i = messages.length - 1; i >= 0; i--) {
164
+ const m = messages[i];
165
+ if (m?.role === "user" && typeof m.content === "string") return m.content;
166
+ }
167
+ return "";
168
+ }
169
+
170
+ function isTitleGeneratorCall(system: unknown): boolean {
171
+ return typeof system === "string" && /title generator/i.test(system);
172
+ }
173
+
174
+ export function createFakeAnthropicClient(): Anthropic {
175
+ return {
176
+ messages: {
177
+ stream(params: {
178
+ system?: unknown;
179
+ messages: Array<{ role?: string; content?: unknown }>;
180
+ }) {
181
+ // Title generation runs in parallel with runChatTurn; don't let it
182
+ // consume a fixture turn meant for the main conversation.
183
+ if (isTitleGeneratorCall(params.system)) {
184
+ return new FakeMessageStream({ text: "Chat session", delayMs: 0 });
185
+ }
186
+ const turn = selectTurn(extractLastUserText(params.messages));
187
+ return new FakeMessageStream(turn);
188
+ },
189
+ async create(params: {
190
+ system?: unknown;
191
+ messages: Array<{ role?: string; content?: unknown }>;
192
+ }): Promise<Message> {
193
+ if (isTitleGeneratorCall(params.system)) {
194
+ return buildFinalMessage("Chat session");
195
+ }
196
+ const turn = selectTurn(extractLastUserText(params.messages));
197
+ return buildFinalMessage(
198
+ turn.text ?? turn.chunks?.join("") ?? "",
199
+ turn.toolCalls,
200
+ );
201
+ },
202
+ },
203
+ } as unknown as Anthropic;
204
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Canned MCP responses for capture mode (BOTHOLOMEW_FAKE_LLM=1). Lets
3
+ * `mcp_search` and `mcp_exec` return demo-friendly results without requiring
4
+ * a live MCPX gateway. The shapes mirror what the real tools emit.
5
+ */
6
+
7
+ export interface FakeMcpSearchResult {
8
+ server: string;
9
+ tool: string;
10
+ description: string;
11
+ score: number;
12
+ match_type: string;
13
+ }
14
+
15
+ export function isCaptureMode(): boolean {
16
+ return process.env.BOTHOLOMEW_FAKE_LLM === "1";
17
+ }
18
+
19
+ export function fakeMcpSearch(query: string): FakeMcpSearchResult[] | null {
20
+ const q = query.toLowerCase();
21
+ if (/calendar|schedule|event|meeting/.test(q)) {
22
+ return [
23
+ {
24
+ server: "google-calendar",
25
+ tool: "ListEvents",
26
+ description:
27
+ "List events on a user's Google Calendar within a date range.",
28
+ score: 0.94,
29
+ match_type: "semantic",
30
+ },
31
+ {
32
+ server: "google-calendar",
33
+ tool: "CreateEvent",
34
+ description: "Create a new event on a user's Google Calendar.",
35
+ score: 0.78,
36
+ match_type: "semantic",
37
+ },
38
+ ];
39
+ }
40
+ if (/email|gmail|mail/.test(q)) {
41
+ return [
42
+ {
43
+ server: "gmail",
44
+ tool: "SendEmail",
45
+ description: "Send an email from the user's Gmail account.",
46
+ score: 0.91,
47
+ match_type: "semantic",
48
+ },
49
+ ];
50
+ }
51
+ return null;
52
+ }
53
+
54
+ export function fakeMcpExec(
55
+ server: string,
56
+ tool: string,
57
+ _args: Record<string, unknown> | undefined,
58
+ ): string | null {
59
+ if (server === "google-calendar" && tool === "ListEvents") {
60
+ return JSON.stringify(
61
+ {
62
+ events: [
63
+ { start: "09:00", summary: "Sprint planning" },
64
+ { start: "11:30", summary: "Design review with Pascal" },
65
+ { start: "14:00", summary: "Focus block: v0.8 roadmap" },
66
+ { start: "16:30", summary: "1:1 with Sterling" },
67
+ ],
68
+ },
69
+ null,
70
+ 2,
71
+ );
72
+ }
73
+ return null;
74
+ }
@@ -0,0 +1,12 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import type { BotholomewConfig } from "../config/schemas.ts";
3
+ import { createFakeAnthropicClient } from "./fake-llm.ts";
4
+
5
+ export function createLlmClient(config: BotholomewConfig): Anthropic {
6
+ if (process.env.BOTHOLOMEW_FAKE_LLM === "1") {
7
+ return createFakeAnthropicClient();
8
+ }
9
+ return new Anthropic({
10
+ apiKey: config.anthropic_api_key || undefined,
11
+ });
12
+ }
package/src/daemon/llm.ts CHANGED
@@ -1,4 +1,3 @@
1
- import Anthropic from "@anthropic-ai/sdk";
2
1
  import type {
3
2
  Message,
4
3
  MessageParam,
@@ -14,6 +13,7 @@ import { registerAllTools } from "../tools/registry.ts";
14
13
  import { getTool, type ToolContext, toAnthropicTools } from "../tools/tool.ts";
15
14
  import { fitToContextWindow, getMaxInputTokens } from "./context.ts";
16
15
  import { clearLargeResults, maybeStoreResult } from "./large-results.ts";
16
+ import { createLlmClient } from "./llm-client.ts";
17
17
 
18
18
  registerAllTools();
19
19
 
@@ -60,9 +60,7 @@ export async function runAgentLoop(input: {
60
60
  callbacks,
61
61
  } = input;
62
62
 
63
- const client = new Anthropic({
64
- apiKey: config.anthropic_api_key || undefined,
65
- });
63
+ const client = createLlmClient(config);
66
64
 
67
65
  // Build predecessor context from completed blocking tasks
68
66
  let predecessorContext = "";
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { fakeMcpExec, isCaptureMode } from "../../daemon/fake-mcp.ts";
2
3
  import { formatCallToolResult } from "../../mcpx/client.ts";
3
4
  import type { ToolDefinition } from "../tool.ts";
4
5
 
@@ -81,6 +82,17 @@ export const mcpExecTool = {
81
82
  inputSchema,
82
83
  outputSchema,
83
84
  execute: async (input, ctx) => {
85
+ if (isCaptureMode()) {
86
+ const canned = fakeMcpExec(input.server, input.tool, input.args);
87
+ if (canned) {
88
+ return {
89
+ result: canned,
90
+ is_error: false,
91
+ error_kind: undefined,
92
+ hint: undefined,
93
+ };
94
+ }
95
+ }
84
96
  if (!ctx.mcpxClient) {
85
97
  return {
86
98
  result:
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { fakeMcpSearch, isCaptureMode } from "../../daemon/fake-mcp.ts";
2
3
  import type { ToolDefinition } from "../tool.ts";
3
4
 
4
5
  const inputSchema = z.object({
@@ -36,6 +37,16 @@ export const mcpSearchTool = {
36
37
  inputSchema,
37
38
  outputSchema,
38
39
  execute: async (input, ctx) => {
40
+ if (isCaptureMode()) {
41
+ const canned = fakeMcpSearch(input.query);
42
+ if (canned) {
43
+ return {
44
+ results: canned,
45
+ is_error: false,
46
+ hint: "Use mcp_info with server and tool name to see the full input schema before calling mcp_exec.",
47
+ };
48
+ }
49
+ }
39
50
  if (!ctx.mcpxClient) {
40
51
  return {
41
52
  results: [],
package/src/tui/App.tsx CHANGED
@@ -209,6 +209,28 @@ export function App({
209
209
  return () => clearTimeout(timer);
210
210
  }, []);
211
211
 
212
+ // Capture-mode tab auto-cycle. Under VHS/ttyd the Tab key doesn't reliably
213
+ // reach Ink, so a docs tape can't drive the tab tour by keystroke. When
214
+ // BOTHOLOMEW_CAPTURE_TAB_CYCLE is set, schedule timers that walk through
215
+ // every tab so a single recording can show all panels.
216
+ //
217
+ // Format: "dwellMs" or "dwellMs:startDelayMs". The optional start delay
218
+ // lets a tape finish a streamed chat reply before the cycle kicks in.
219
+ useEffect(() => {
220
+ const spec = process.env.BOTHOLOMEW_CAPTURE_TAB_CYCLE;
221
+ if (!spec) return;
222
+ const [dwellRaw, delayRaw] = spec.split(":");
223
+ const dwellMs = Number.parseInt(dwellRaw ?? "", 10) || 2500;
224
+ const startDelayMs = Number.parseInt(delayRaw ?? "", 10) || 0;
225
+ const sequence: TabId[] = [2, 3, 4, 5, 6, 7, 1];
226
+ const timers = sequence.map((tab, i) =>
227
+ setTimeout(() => setActiveTab(tab), startDelayMs + dwellMs * (i + 1)),
228
+ );
229
+ return () => {
230
+ for (const t of timers) clearTimeout(t);
231
+ };
232
+ }, []);
233
+
212
234
  // Stable ref for App-level input handler — same pattern as InputBar to
213
235
  // prevent Ink's useInput from re-registering stdin listeners on every render.
214
236
  const activeTabRef = useRef(activeTab);
@@ -9,6 +9,7 @@ import {
9
9
  listContextItemsByPrefix,
10
10
  searchContextByKeyword,
11
11
  } from "../../db/context.ts";
12
+ import { isMarkdownItem, renderMarkdown } from "../markdown.ts";
12
13
 
13
14
  interface ContextPanelProps {
14
15
  dbPath: string;
@@ -118,10 +119,15 @@ export const ContextPanel = memo(function ContextPanel({
118
119
  [items, scrollOffset, visibleRows],
119
120
  );
120
121
 
121
- // Preview content split into lines for scrolling
122
+ // Preview content split into lines for scrolling. Markdown files are
123
+ // rendered through Bun.markdown.ansi so headers/emphasis/code display
124
+ // with ANSI formatting in the terminal.
122
125
  const previewLines = useMemo(() => {
123
126
  if (!preview?.content) return [];
124
- return preview.content.split("\n");
127
+ const body = isMarkdownItem(preview)
128
+ ? renderMarkdown(preview.content)
129
+ : preview.content;
130
+ return body.split("\n");
125
131
  }, [preview]);
126
132
 
127
133
  useInput(
@@ -1,6 +1,7 @@
1
1
  import { Box, Text, useStdout } from "ink";
2
2
  import Spinner from "ink-spinner";
3
3
  import { memo, useMemo } from "react";
4
+ import { renderMarkdown } from "../markdown.ts";
4
5
  import { theme } from "../theme.ts";
5
6
  import { ToolCall, type ToolCallData } from "./ToolCall.tsx";
6
7
 
@@ -48,11 +49,6 @@ function wrapAndPad(text: string, width: number): string {
48
49
  return lines.join("\n");
49
50
  }
50
51
 
51
- function renderMarkdown(text: string): string {
52
- if (!text) return "";
53
- return Bun.markdown.ansi(text).trimEnd();
54
- }
55
-
56
52
  export const MessageBubble = memo(function MessageBubble({
57
53
  message,
58
54
  }: {
@@ -0,0 +1,15 @@
1
+ import type { ContextItem } from "../db/context.ts";
2
+
3
+ export function renderMarkdown(text: string): string {
4
+ if (!text) return "";
5
+ return Bun.markdown.ansi(text).trimEnd();
6
+ }
7
+
8
+ export function isMarkdownItem(
9
+ item: Pick<ContextItem, "mime_type" | "source_path" | "context_path">,
10
+ ): boolean {
11
+ if (item.mime_type === "text/markdown") return true;
12
+ if (item.source_path?.toLowerCase().endsWith(".md")) return true;
13
+ if (item.context_path.toLowerCase().endsWith(".md")) return true;
14
+ return false;
15
+ }
@@ -1,5 +1,5 @@
1
- import Anthropic from "@anthropic-ai/sdk";
2
1
  import type { BotholomewConfig } from "../config/schemas.ts";
2
+ import { createLlmClient } from "../daemon/llm-client.ts";
3
3
  import { withDb } from "../db/connection.ts";
4
4
  import { updateThreadTitle } from "../db/threads.ts";
5
5
  import { logger } from "./logger.ts";
@@ -17,9 +17,7 @@ export async function generateThreadTitle(
17
17
  context: string,
18
18
  ): Promise<void> {
19
19
  try {
20
- const client = new Anthropic({
21
- apiKey: config.anthropic_api_key || undefined,
22
- });
20
+ const client = createLlmClient(config);
23
21
 
24
22
  const response = await client.messages.create({
25
23
  model: config.chunker_model,