botholomew 0.8.2 → 0.8.4

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
@@ -8,15 +8,17 @@
8
8
 
9
9
  ![Botholomew chat TUI](docs/assets/chat-happy-path.gif)
10
10
 
11
- **A local AI agent for knowledge work.** Botholomew is an autonomous agent
11
+ **An AI agent for knowledge work.** Botholomew is an autonomous agent
12
12
  that works its way through a task queue — reading email, summarizing
13
13
  documents, researching topics, organizing notes, and maintaining context
14
14
  over time — while you sleep, work, or chat with it.
15
15
 
16
- Unlike coding agents, Botholomew has **no shell, no filesystem, and no network
17
- tools** by default. Everything it touches lives inside a single DuckDB database
18
- at `.botholomew/data.duckdb` and a handful of markdown files. External access
19
- is granted deliberately, per project, through MCP servers.
16
+ Unlike coding agents, Botholomew has **no shell and no direct access to
17
+ your filesystem**. It can't edit files on disk instead, it ingests local
18
+ files, folders, and URLs into a DuckDB-backed context store that it can
19
+ read, search, and summarize. External capabilities (email, Slack, the web,
20
+ and hundreds of other services) are granted deliberately, per project,
21
+ through MCP servers wired up via [MCPX](https://github.com/evantahler/mcpx).
20
22
 
21
23
  ---
22
24
 
@@ -27,19 +29,19 @@ is granted deliberately, per project, through MCP servers.
27
29
  long-running `--persist` worker, or point cron at `botholomew worker run`.
28
30
  - **Portable.** Each project is a `.botholomew/` directory — markdown +
29
31
  DuckDB. Copy it, share it, check it in (or `.gitignore` it).
30
- - **Local.** All data stays on your machine. Embeddings are indexed in
31
- DuckDB's native vector store with HNSW. Model calls go direct to Anthropic
32
- and OpenAI.
32
+ - **Your data, your disk.** Project state tasks, threads, ingested
33
+ context, embeddings lives in `.botholomew/`, indexed in DuckDB with
34
+ HNSW for vector search. Model calls go direct to Anthropic and OpenAI;
35
+ any further reach is scoped to the MCP servers you add.
33
36
  - **Extensible.** External tools come from MCP servers via
34
37
  [MCPX](https://github.com/evantahler/mcpx) — run them locally (Gmail,
35
38
  Slack, GitHub) or connect through an MCP gateway like
36
39
  [Arcade.dev](https://www.arcade.dev/) to reach hundreds of
37
40
  authenticated services without managing each server yourself.
38
41
  Reusable workflows are defined as markdown "skills" (slash commands).
39
- - **Safe by default.** The agent has no shell, no network, and no
40
- filesystem access of its own. Everything it can touch lives in
41
- `.botholomew/` — and every external capability is something you
42
- explicitly add.
42
+ - **Safe by default.** The agent has no shell and no direct filesystem
43
+ access. Out of the box, everything it can touch lives in `.botholomew/`;
44
+ every external capability is a MCP server you explicitly add.
43
45
  - **Concurrent.** Many workers can run at once. Each registers itself in
44
46
  the DB and heartbeats; crashed workers get reaped and their tasks go
45
47
  back into the queue automatically.
@@ -49,6 +51,15 @@ is granted deliberately, per project, through MCP servers.
49
51
 
50
52
  ---
51
53
 
54
+ ## Demo
55
+
56
+ A full tour of the chat TUI — every tab, slash-command autocomplete,
57
+ the message queue, tool-call visualization, and the live workers panel:
58
+
59
+ ![Tour of every tab in the chat TUI](docs/assets/full-tour.gif)
60
+
61
+ ---
62
+
52
63
  ## Install
53
64
 
54
65
  Requires [Bun](https://bun.sh) 1.1+.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.8.2",
4
- "description": "Local, autonomous AI agent for knowledge work — works your task queue while you sleep.",
3
+ "version": "0.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": {
7
7
  "botholomew": "./src/cli.ts"
@@ -1,4 +1,5 @@
1
1
  import { getConfigPath } from "../constants.ts";
2
+ import { setLogLevel } from "../utils/logger.ts";
2
3
  import { type BotholomewConfig, DEFAULT_CONFIG } from "./schemas.ts";
3
4
 
4
5
  export async function loadConfig(
@@ -22,6 +23,8 @@ export async function loadConfig(
22
23
  config.openai_api_key = process.env.OPENAI_API_KEY;
23
24
  }
24
25
 
26
+ setLogLevel(config.log_level);
27
+
25
28
  return config;
26
29
  }
27
30
 
@@ -15,6 +15,7 @@ export interface BotholomewConfig {
15
15
  worker_stopped_retention_seconds?: number;
16
16
  schedule_min_interval_seconds?: number;
17
17
  schedule_claim_stale_seconds?: number;
18
+ log_level?: string;
18
19
  }
19
20
 
20
21
  export const DEFAULT_CONFIG: Required<BotholomewConfig> = {
@@ -34,4 +35,5 @@ export const DEFAULT_CONFIG: Required<BotholomewConfig> = {
34
35
  worker_stopped_retention_seconds: 3600,
35
36
  schedule_min_interval_seconds: 60,
36
37
  schedule_claim_stale_seconds: 300,
38
+ log_level: "",
37
39
  };
@@ -0,0 +1,69 @@
1
+ import type { BotholomewConfig } from "../config/schemas.ts";
2
+
3
+ type EmbedFn = (
4
+ texts: string[],
5
+ config: Required<BotholomewConfig>,
6
+ ) => Promise<number[][]>;
7
+
8
+ interface OpenAIEmbeddingResponse {
9
+ data: { embedding: number[]; index: number }[];
10
+ usage: { total_tokens: number };
11
+ }
12
+
13
+ /**
14
+ * Embed multiple texts using the OpenAI embeddings API.
15
+ * Returns an array of float vectors with the configured dimension.
16
+ */
17
+ export async function embed(
18
+ texts: string[],
19
+ config: Required<BotholomewConfig>,
20
+ ): Promise<number[][]> {
21
+ if (texts.length === 0) return [];
22
+
23
+ if (!config.openai_api_key) {
24
+ throw new Error(
25
+ "OpenAI API key is required for embeddings. Set openai_api_key in config or OPENAI_API_KEY env var.",
26
+ );
27
+ }
28
+
29
+ const response = await fetch("https://api.openai.com/v1/embeddings", {
30
+ method: "POST",
31
+ headers: {
32
+ Authorization: `Bearer ${config.openai_api_key}`,
33
+ "Content-Type": "application/json",
34
+ },
35
+ body: JSON.stringify({
36
+ input: texts,
37
+ model: config.embedding_model,
38
+ dimensions: config.embedding_dimension,
39
+ }),
40
+ });
41
+
42
+ if (!response.ok) {
43
+ const body = await response.text();
44
+ throw new Error(
45
+ `OpenAI embeddings API error (${response.status}): ${body}`,
46
+ );
47
+ }
48
+
49
+ const result = (await response.json()) as OpenAIEmbeddingResponse;
50
+
51
+ // Sort by index to ensure order matches input
52
+ const sorted = result.data.sort((a, b) => a.index - b.index);
53
+ return sorted.map((d) => d.embedding);
54
+ }
55
+
56
+ /**
57
+ * Embed a single text string.
58
+ */
59
+ export async function embedSingle(
60
+ text: string,
61
+ config: Required<BotholomewConfig>,
62
+ ): Promise<number[]> {
63
+ const results = await embed([text], config);
64
+ const vec = results[0];
65
+ if (!vec) throw new Error("embed returned empty results");
66
+ return vec;
67
+ }
68
+
69
+ export type { EmbedFn };
@@ -1,69 +1,9 @@
1
- import type { BotholomewConfig } from "../config/schemas.ts";
2
-
3
- type EmbedFn = (
4
- texts: string[],
5
- config: Required<BotholomewConfig>,
6
- ) => Promise<number[][]>;
7
-
8
- interface OpenAIEmbeddingResponse {
9
- data: { embedding: number[]; index: number }[];
10
- usage: { total_tokens: number };
11
- }
12
-
13
- /**
14
- * Embed multiple texts using the OpenAI embeddings API.
15
- * Returns an array of float vectors with the configured dimension.
16
- */
17
- export async function embed(
18
- texts: string[],
19
- config: Required<BotholomewConfig>,
20
- ): Promise<number[][]> {
21
- if (texts.length === 0) return [];
22
-
23
- if (!config.openai_api_key) {
24
- throw new Error(
25
- "OpenAI API key is required for embeddings. Set openai_api_key in config or OPENAI_API_KEY env var.",
26
- );
27
- }
28
-
29
- const response = await fetch("https://api.openai.com/v1/embeddings", {
30
- method: "POST",
31
- headers: {
32
- Authorization: `Bearer ${config.openai_api_key}`,
33
- "Content-Type": "application/json",
34
- },
35
- body: JSON.stringify({
36
- input: texts,
37
- model: config.embedding_model,
38
- dimensions: config.embedding_dimension,
39
- }),
40
- });
41
-
42
- if (!response.ok) {
43
- const body = await response.text();
44
- throw new Error(
45
- `OpenAI embeddings API error (${response.status}): ${body}`,
46
- );
47
- }
48
-
49
- const result = (await response.json()) as OpenAIEmbeddingResponse;
50
-
51
- // Sort by index to ensure order matches input
52
- const sorted = result.data.sort((a, b) => a.index - b.index);
53
- return sorted.map((d) => d.embedding);
54
- }
55
-
56
- /**
57
- * Embed a single text string.
58
- */
59
- export async function embedSingle(
60
- text: string,
61
- config: Required<BotholomewConfig>,
62
- ): Promise<number[]> {
63
- const results = await embed([text], config);
64
- const vec = results[0];
65
- if (!vec) throw new Error("embed returned empty results");
66
- return vec;
67
- }
68
-
69
- export type { EmbedFn };
1
+ // Re-exports the real embedder implementation from `embedder-impl.ts`.
2
+ //
3
+ // Why the indirection: tests that touch code importing from this file (e.g.,
4
+ // `src/chat/agent.ts`, `src/worker/prompt.ts`) use Bun's `mock.module()` to
5
+ // stub the embedder so they don't hit OpenAI. Bun's module mocks are
6
+ // process-wide and can leak into subsequent test files. By keeping the real
7
+ // implementation in `embedder-impl.ts`, `test/context/embedder.test.ts` can
8
+ // import the real embedder from a path that nothing mocks.
9
+ export * from "./embedder-impl.ts";
@@ -59,7 +59,7 @@ const empty = {
59
59
  export const contextRefreshTool = {
60
60
  name: "context_refresh",
61
61
  description:
62
- "Re-read source files from disk / re-fetch source URLs, update stored content if it changed, and re-embed only changed items. Use `path` for a single item or subtree, or `all: true` for every sourced item. Items without a source_path are skipped. URL fetches use the project's MCPX client when available and fall back to plain HTTP.",
62
+ "[[ bash equivalent command: curl ]] Re-read source files from disk / re-fetch source URLs, update stored content if it changed, and re-embed only changed items. Use `path` for a single item or subtree, or `all: true` for every sourced item. Items without a source_path are skipped. URL fetches use the project's MCPX client when available and fall back to plain HTTP.",
63
63
  group: "context",
64
64
  inputSchema,
65
65
  outputSchema,
@@ -24,7 +24,8 @@ const outputSchema = z.object({
24
24
 
25
25
  export const contextSearchTool = {
26
26
  name: "context_search",
27
- description: "Search context by keyword.",
27
+ description:
28
+ "[[ bash equivalent command: grep -r ]] Search context by keyword.",
28
29
  group: "context",
29
30
  inputSchema,
30
31
  outputSchema,
@@ -18,7 +18,8 @@ const outputSchema = z.object({
18
18
 
19
19
  export const contextCreateDirTool = {
20
20
  name: "context_create_dir",
21
- description: "Create a directory in context.",
21
+ description:
22
+ "[[ bash equivalent command: mkdir -p ]] Create a directory in context.",
22
23
  group: "context",
23
24
  inputSchema,
24
25
  outputSchema,
@@ -38,7 +38,8 @@ const outputSchema = z.object({
38
38
 
39
39
  export const contextListDirTool = {
40
40
  name: "context_list_dir",
41
- description: "List directory contents in context.",
41
+ description:
42
+ "[[ bash equivalent command: ls ]] List directory contents in context.",
42
43
  group: "context",
43
44
  inputSchema,
44
45
  outputSchema,
@@ -26,7 +26,8 @@ const outputSchema = z.object({
26
26
 
27
27
  export const contextDirSizeTool = {
28
28
  name: "context_dir_size",
29
- description: "Get the total size of context items in a directory.",
29
+ description:
30
+ "[[ bash equivalent command: du -s ]] Get the total size of context items in a directory.",
30
31
  group: "context",
31
32
  inputSchema,
32
33
  outputSchema,
@@ -66,7 +66,7 @@ type TreeEntry = DirNode | FileNode;
66
66
  export const contextTreeTool = {
67
67
  name: "context_tree",
68
68
  description:
69
- "Explore your context filesystem with a bird's-eye view — shows many paths across nested directories in one call. Reach for this first when you need to discover what content exists before reading a specific file (context_read) or running a keyword search (context_search). Returns a markdown-style tree; tune max_depth and items_per_dir to bound output, or pass a deeper path to drill into a subtree.",
69
+ "[[ bash equivalent command: tree ]] Explore your context filesystem with a bird's-eye view — shows many paths across nested directories in one call. Reach for this first when you need to discover what content exists before reading a specific file (context_read) or running a keyword search (context_search). Returns a markdown-style tree; tune max_depth and items_per_dir to bound output, or pass a deeper path to drill into a subtree.",
70
70
  group: "context",
71
71
  inputSchema,
72
72
  outputSchema,
@@ -20,7 +20,7 @@ const outputSchema = z.object({
20
20
 
21
21
  export const contextCopyTool = {
22
22
  name: "context_copy",
23
- description: "Copy a context item.",
23
+ description: "[[ bash equivalent command: cp ]] Copy a context item.",
24
24
  group: "context",
25
25
  inputSchema,
26
26
  outputSchema,
@@ -13,7 +13,8 @@ const outputSchema = z.object({
13
13
 
14
14
  export const contextCountLinesTool = {
15
15
  name: "context_count_lines",
16
- description: "Count the number of lines in a text context item.",
16
+ description:
17
+ "[[ bash equivalent command: wc -l ]] Count the number of lines in a text context item.",
17
18
  group: "context",
18
19
  inputSchema,
19
20
  outputSchema,
@@ -24,7 +24,8 @@ const outputSchema = z.object({
24
24
 
25
25
  export const contextDeleteTool = {
26
26
  name: "context_delete",
27
- description: "Delete a context item or directory.",
27
+ description:
28
+ "[[ bash equivalent command: rm -r ]] Delete a context item or directory.",
28
29
  group: "context",
29
30
  inputSchema,
30
31
  outputSchema,
@@ -27,7 +27,7 @@ const outputSchema = z.object({
27
27
  export const contextEditTool = {
28
28
  name: "context_edit",
29
29
  description:
30
- "Apply git-style patches to a context item. Each patch specifies a line range to replace.",
30
+ "[[ bash equivalent command: patch ]] Apply git-style patches to a context item. Each patch specifies a line range to replace.",
31
31
  group: "context",
32
32
  inputSchema,
33
33
  outputSchema,
@@ -13,7 +13,8 @@ const outputSchema = z.object({
13
13
 
14
14
  export const contextExistsTool = {
15
15
  name: "context_exists",
16
- description: "Check if a context item exists.",
16
+ description:
17
+ "[[ bash equivalent command: test -e ]] Check if a context item exists.",
17
18
  group: "context",
18
19
  inputSchema,
19
20
  outputSchema,
@@ -25,7 +25,7 @@ const outputSchema = z.object({
25
25
  export const contextInfoTool = {
26
26
  name: "context_info",
27
27
  description:
28
- "Show context item metadata (size, MIME type, line count, etc.).",
28
+ "[[ bash equivalent command: stat ]] Show context item metadata: size, MIME type, line count, etc.",
29
29
  group: "context",
30
30
  inputSchema,
31
31
  outputSchema,
@@ -19,7 +19,8 @@ const outputSchema = z.object({
19
19
 
20
20
  export const contextMoveTool = {
21
21
  name: "context_move",
22
- description: "Move or rename a context item.",
22
+ description:
23
+ "[[ bash equivalent command: mv ]] Move or rename a context item.",
23
24
  group: "context",
24
25
  inputSchema,
25
26
  outputSchema,
@@ -18,7 +18,8 @@ const outputSchema = z.object({
18
18
 
19
19
  export const contextReadTool = {
20
20
  name: "context_read",
21
- description: "Read a context item's contents.",
21
+ description:
22
+ "[[ bash equivalent command: cat ]] Read a context item's contents.",
22
23
  group: "context",
23
24
  inputSchema,
24
25
  outputSchema,
@@ -54,7 +54,7 @@ const outputSchema = z.object({
54
54
  export const contextWriteTool = {
55
55
  name: "context_write",
56
56
  description:
57
- "Write content to a context item. By default, fails if the path already exists — pass on_conflict='overwrite' to replace.",
57
+ "[[ bash equivalent command: tee ]] Write content to a context item. By default, fails if the path already exists — pass on_conflict='overwrite' to replace.",
58
58
  group: "context",
59
59
  inputSchema,
60
60
  outputSchema,
@@ -4,34 +4,78 @@ function ts(): string {
4
4
  return ansis.gray(new Date().toTimeString().slice(0, 8));
5
5
  }
6
6
 
7
+ const LEVELS = {
8
+ silent: 0,
9
+ error: 1,
10
+ warn: 2,
11
+ info: 3,
12
+ debug: 4,
13
+ } as const;
14
+
15
+ type LogLevel = keyof typeof LEVELS;
16
+
17
+ function parseLevel(raw: string | undefined): number | undefined {
18
+ const key = raw?.toLowerCase();
19
+ if (key && key in LEVELS) return LEVELS[key as LogLevel];
20
+ return undefined;
21
+ }
22
+
23
+ const envPinned = parseLevel(process.env.BOTHOLOMEW_LOG_LEVEL) !== undefined;
24
+
25
+ function defaultLevel(): number {
26
+ const explicit = parseLevel(process.env.BOTHOLOMEW_LOG_LEVEL);
27
+ if (explicit !== undefined) return explicit;
28
+ if (process.env.NODE_ENV === "test") return LEVELS.error;
29
+ return LEVELS.info;
30
+ }
31
+
32
+ let currentLevel = defaultLevel();
33
+
34
+ /**
35
+ * Apply a log level from config. `BOTHOLOMEW_LOG_LEVEL` always wins, so
36
+ * this is a no-op when that env var is set. Empty/invalid values are
37
+ * ignored — callers can pass `config.log_level` directly without checking.
38
+ */
39
+ export function setLogLevel(level: string | undefined): void {
40
+ if (envPinned) return;
41
+ const parsed = parseLevel(level);
42
+ if (parsed === undefined) return;
43
+ currentLevel = parsed;
44
+ }
45
+
7
46
  export const logger = {
8
47
  info(msg: string) {
48
+ if (currentLevel < LEVELS.info) return;
9
49
  console.log(ts(), ansis.blue("ℹ"), msg);
10
50
  },
11
51
 
12
52
  success(msg: string) {
53
+ if (currentLevel < LEVELS.info) return;
13
54
  console.log(ts(), ansis.green("✓"), msg);
14
55
  },
15
56
 
16
57
  warn(msg: string) {
58
+ if (currentLevel < LEVELS.warn) return;
17
59
  console.log(ts(), ansis.yellow("⚠"), msg);
18
60
  },
19
61
 
20
62
  error(msg: string) {
63
+ if (currentLevel < LEVELS.error) return;
21
64
  console.error(ts(), ansis.red("✗"), msg);
22
65
  },
23
66
 
24
67
  debug(msg: string) {
25
- if (process.env.BOTHOLOMEW_DEBUG) {
26
- console.log(ts(), ansis.gray("·"), ansis.gray(msg));
27
- }
68
+ if (currentLevel < LEVELS.debug) return;
69
+ console.log(ts(), ansis.gray("·"), ansis.gray(msg));
28
70
  },
29
71
 
30
72
  dim(msg: string) {
73
+ if (currentLevel < LEVELS.info) return;
31
74
  console.log(ts(), ansis.dim(msg));
32
75
  },
33
76
 
34
77
  phase(name: string, detail?: string) {
78
+ if (currentLevel < LEVELS.info) return;
35
79
  const tag = ansis.magenta.bold(`[[${name}]]`);
36
80
  if (detail) {
37
81
  console.log(ts(), tag, ansis.dim(detail));