botholomew 0.9.10 → 0.9.11

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
@@ -31,8 +31,9 @@ through MCP servers wired up via [MCPX](https://github.com/evantahler/mcpx).
31
31
  DuckDB. Copy it, share it, check it in (or `.gitignore` it).
32
32
  - **Your data, your disk.** Project state — tasks, threads, ingested
33
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.
34
+ BM25 keyword search and `array_cosine_distance` vector search. Model
35
+ calls go direct to Anthropic and OpenAI; any further reach is scoped to
36
+ the MCP servers you add.
36
37
  - **Extensible.** External tools come from MCP servers via
37
38
  [MCPX](https://github.com/evantahler/mcpx) — run them locally (Gmail,
38
39
  Slack, GitHub) or connect through an MCP gateway like
@@ -115,12 +116,14 @@ my-project/
115
116
  soul.md # always-loaded identity (not agent-editable)
116
117
  beliefs.md # always-loaded, agent-editable priors
117
118
  goals.md # always-loaded, agent-editable goals
119
+ capabilities.md # always-loaded, agent-editable tool inventory
118
120
  config.json # models, tick interval, API keys
119
121
  data.duckdb # tasks, schedules, context, embeddings, logs
120
122
  mcpx/servers.json # external MCP servers (Gmail, Slack, …)
121
- skills/ # user-defined slash commands
123
+ skills/ # slash commands (built-ins + user-defined)
122
124
  summarize.md
123
125
  standup.md
126
+ capabilities.md
124
127
  logs/ # per-worker log files (one file per spawned worker)
125
128
  <worker-id>.log
126
129
  ```
@@ -140,14 +143,14 @@ Everything the agent can touch is here. No surprises.
140
143
  | `botholomew worker list\|status\|stop\|kill\|reap` | Inspect and manage running workers |
141
144
  | `botholomew chat` | Interactive Ink/React TUI |
142
145
  | `botholomew task list\|add\|view\|update\|reset\|delete` | Manage the task queue |
143
- | `botholomew schedule list\|add\|enable\|trigger\|delete` | Recurring work |
144
- | `botholomew context add\|list\|view\|search\|refresh\|remove` | Ingest & browse knowledge (files, folders, URLs) |
146
+ | `botholomew schedule list\|add\|view\|enable\|disable\|trigger\|delete` | Recurring work |
147
+ | `botholomew context add\|list\|search\|chunks\|refresh\|delete` | Ingest & browse knowledge (files, folders, URLs); also exposes the agent's `read`/`write`/`tree`/`edit`/… tools as subcommands |
145
148
  | `botholomew capabilities` | Rescan built-in + MCPX tools and rewrite `.botholomew/capabilities.md` |
146
- | `botholomew mcpx servers\|add\|remove\|info\|search\|exec\|ping\|auth\|import-global` | Configure external MCP servers |
149
+ | `botholomew mcpx servers\|list\|add\|remove\|info\|search\|exec\|ping\|auth\|deauth\|import-global\|…` | Configure external MCP servers (passthrough to `mcpx`) |
147
150
  | `botholomew skill list\|show\|create\|validate` | Manage slash-command skills |
148
- | `botholomew context ... \| search ...` | Direct access to the agent's virtual filesystem |
149
151
  | `botholomew thread list\|view` | Browse the agent's interaction history |
150
152
  | `botholomew nuke context\|tasks\|schedules\|threads\|all` | Bulk-erase sections of the database |
153
+ | `botholomew db doctor [--repair]` | Probe each table for primary-key index corruption; rebuild via EXPORT/IMPORT |
151
154
  | `botholomew upgrade` | Self-update |
152
155
 
153
156
  All `list` subcommands support `-l, --limit <n>` and `-o, --offset <n>` for pagination.
@@ -175,7 +178,7 @@ All `list` subcommands support `-l, --limit <n>` and `-o, --offset <n>` for pagi
175
178
  │ ┌───────────┐ ┌──────────────┐ │
176
179
  │ │ tasks │ │ context_items│ │
177
180
  │ │ schedules │ │ embeddings │ │
178
- │ │ workers │ │ (HNSW) │ │
181
+ │ │ workers │ │ (FTS+vector)│ │
179
182
  │ │ threads │ │ │ │
180
183
  │ └───────────┘ └──────────────┘ │
181
184
  └─────┬───────────────────────────────┘
@@ -203,8 +206,8 @@ Topics worth understanding in detail:
203
206
  - **[The virtual filesystem](docs/virtual-filesystem.md)** — why the agent's
204
207
  "files" are actually DuckDB rows, and how `context_read`/`context_write` work.
205
208
  - **[Context & hybrid search](docs/context-and-search.md)** — LLM-driven
206
- chunking, OpenAI embeddings, and DuckDB's HNSW-accelerated keyword +
207
- vector search.
209
+ chunking, OpenAI embeddings, and DuckDB BM25 + linear-scan vector
210
+ search merged with reciprocal rank fusion.
208
211
  - **[Tasks & schedules](docs/tasks-and-schedules.md)** — the claim loop, DAG
209
212
  validation, stale-task recovery, and natural-language recurring schedules.
210
213
  - **[The Tool class](docs/tools.md)** — one Zod definition, three consumers
@@ -226,9 +229,9 @@ Topics worth understanding in detail:
226
229
  ## Tech stack
227
230
 
228
231
  - **[Bun](https://bun.sh)** + TypeScript
229
- - **[DuckDB](https://duckdb.org)** via `@duckdb/node-api`, with the
230
- **[VSS extension](https://duckdb.org/docs/stable/extensions/vss)** for
231
- native vector search
232
+ - **[DuckDB](https://duckdb.org)** via `@duckdb/node-api`
233
+ `array_cosine_distance()` (core DuckDB) for vector search, plus the
234
+ built-in FTS extension for BM25 keyword search
232
235
  - **[Anthropic SDK](https://docs.anthropic.com/en/api/client-sdks)** for
233
236
  Claude — the reasoning model
234
237
  - **OpenAI embeddings API** (`text-embedding-3-small`, 1536-dim) for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.9.10",
3
+ "version": "0.9.11",
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
@@ -6,6 +6,7 @@ import { registerCapabilitiesCommand } from "./commands/capabilities.ts";
6
6
  import { registerChatCommand } from "./commands/chat.ts";
7
7
  import { registerCheckUpdateCommand } from "./commands/check-update.ts";
8
8
  import { registerContextCommand } from "./commands/context.ts";
9
+ import { registerDbCommand } from "./commands/db.ts";
9
10
  import { registerInitCommand } from "./commands/init.ts";
10
11
  import { registerMcpxCommand } from "./commands/mcpx.ts";
11
12
  import { registerNukeCommand } from "./commands/nuke.ts";
@@ -40,6 +41,7 @@ registerThreadCommand(program);
40
41
  registerScheduleCommand(program);
41
42
  registerChatCommand(program);
42
43
  registerContextCommand(program);
44
+ registerDbCommand(program);
43
45
  registerCapabilitiesCommand(program);
44
46
  registerMcpxCommand(program);
45
47
  registerSkillCommand(program);
@@ -0,0 +1,112 @@
1
+ import ansis from "ansis";
2
+ import type { Command } from "commander";
3
+ import { getDbPath } from "../constants.ts";
4
+ import { withDb as coreWithDb } from "../db/connection.ts";
5
+ import {
6
+ type ProbeResult,
7
+ probeAllTables,
8
+ repairDatabase,
9
+ } from "../db/doctor.ts";
10
+ import { listWorkers } from "../db/workers.ts";
11
+ import { logger } from "../utils/logger.ts";
12
+
13
+ function statusBadge(status: ProbeResult["status"]): string {
14
+ switch (status) {
15
+ case "ok":
16
+ return ansis.green("ok");
17
+ case "empty":
18
+ return ansis.dim("empty");
19
+ case "missing":
20
+ return ansis.dim("missing");
21
+ case "corrupt":
22
+ return ansis.red.bold("corrupt");
23
+ }
24
+ }
25
+
26
+ function printResults(results: ProbeResult[]) {
27
+ const nameWidth = Math.max(...results.map((r) => r.table.length));
28
+ for (const r of results) {
29
+ const name = r.table.padEnd(nameWidth + 2);
30
+ const detail = r.message ? ansis.dim(` ${r.message.slice(0, 200)}`) : "";
31
+ console.log(` ${name}${statusBadge(r.status)}${detail}`);
32
+ }
33
+ }
34
+
35
+ export function registerDbCommand(program: Command) {
36
+ const db = program
37
+ .command("db")
38
+ .description("Inspect and repair the project database");
39
+
40
+ db.command("doctor")
41
+ .description(
42
+ "Probe every table for primary-key index corruption and optionally repair via EXPORT/IMPORT",
43
+ )
44
+ .option(
45
+ "-r, --repair",
46
+ "Rebuild the database file from an export when corruption is detected",
47
+ )
48
+ .action((opts) => doctor(program, opts.repair === true));
49
+ }
50
+
51
+ async function doctor(program: Command, repair: boolean): Promise<void> {
52
+ const dir = program.opts().dir as string;
53
+ const dbPath = getDbPath(dir);
54
+
55
+ logger.info(`Probing tables in ${dbPath}`);
56
+ const results = await probeAllTables(dbPath);
57
+ printResults(results);
58
+
59
+ const corrupt = results.filter((r) => r.status === "corrupt");
60
+ if (corrupt.length === 0) {
61
+ logger.success("No corruption detected.");
62
+ return;
63
+ }
64
+
65
+ logger.error(
66
+ `${corrupt.length} table(s) have corrupted indexes: ${corrupt
67
+ .map((r) => r.table)
68
+ .join(", ")}`,
69
+ );
70
+
71
+ if (!repair) {
72
+ console.log("");
73
+ console.log(
74
+ ansis.yellow(
75
+ "Re-run with --repair to rebuild the database file (creates a timestamped backup).",
76
+ ),
77
+ );
78
+ process.exit(1);
79
+ }
80
+
81
+ // Repair requires exclusive access — refuse if any worker is registered
82
+ // as running, otherwise the EXPORT would race with the worker's writes.
83
+ const running = await coreWithDb(dbPath, async (conn) => {
84
+ try {
85
+ return await listWorkers(conn, { status: "running" });
86
+ } catch {
87
+ // If listWorkers itself trips the corruption we're about to fix,
88
+ // fall through and let repair proceed; the user is on their own
89
+ // for confirming no live workers, which `worker reap` would also
90
+ // be unable to do anyway.
91
+ return [];
92
+ }
93
+ });
94
+ if (running.length > 0) {
95
+ logger.error(
96
+ `${running.length} worker(s) registered as running. Stop them first: botholomew worker stop <id>`,
97
+ );
98
+ for (const w of running) {
99
+ logger.dim(` ${w.id} (pid ${w.pid}, mode=${w.mode})`);
100
+ }
101
+ process.exit(1);
102
+ }
103
+
104
+ logger.phase("repair", "EXPORT DATABASE → swap files → IMPORT DATABASE");
105
+ const result = await repairDatabase(dbPath);
106
+ logger.success(
107
+ `Repaired in ${result.durationMs}ms. Backup: ${result.backupDbPath}`,
108
+ );
109
+ logger.dim(
110
+ " Re-run `botholomew db doctor` to confirm. Delete the backup once you're sure.",
111
+ );
112
+ }
@@ -0,0 +1,214 @@
1
+ import { mkdir, rename, rm, stat } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { withDb } from "./connection.ts";
4
+
5
+ /**
6
+ * Tables we probe for primary-key index integrity. Every user table has a
7
+ * single-column PK that we exercise with a self-update (SET pk = pk WHERE
8
+ * pk = ...). DuckDB still walks the index for the SET, which surfaces
9
+ * "Failed to delete all rows from index" FATAL errors when the index is
10
+ * out of sync with the row data. `_migrations` is excluded — it is small,
11
+ * append-only, and rebuilding it would defeat its purpose.
12
+ */
13
+ export const PROBE_TABLES: ReadonlyArray<{ name: string; pk: string }> = [
14
+ { name: "workers", pk: "id" },
15
+ { name: "tasks", pk: "id" },
16
+ { name: "schedules", pk: "id" },
17
+ { name: "threads", pk: "id" },
18
+ { name: "interactions", pk: "id" },
19
+ { name: "context_items", pk: "id" },
20
+ { name: "embeddings", pk: "id" },
21
+ { name: "daemon_state", pk: "key" },
22
+ ];
23
+
24
+ export type ProbeStatus = "ok" | "empty" | "missing" | "corrupt";
25
+
26
+ export interface ProbeResult {
27
+ table: string;
28
+ status: ProbeStatus;
29
+ /** Detail message when status is corrupt or missing. */
30
+ message?: string;
31
+ }
32
+
33
+ /**
34
+ * Probe a single table for index corruption by spawning a child Bun
35
+ * process. We use a child process because a corrupt PK index in DuckDB
36
+ * surfaces as a Bun panic (a C++ exception that unwinds past the NAPI
37
+ * boundary), which would kill the doctor itself. The child reports its
38
+ * verdict on stdout and exits.
39
+ *
40
+ * Uses absolute import path resolved against this file so the spawned
41
+ * Bun process picks up the same `@duckdb/node-api` install.
42
+ */
43
+ export async function probeTable(
44
+ dbPath: string,
45
+ table: string,
46
+ pk: string,
47
+ ): Promise<ProbeResult> {
48
+ const script = `
49
+ const { DuckDBInstance } = await import("@duckdb/node-api");
50
+ const dbPath = ${JSON.stringify(dbPath)};
51
+ const table = ${JSON.stringify(table)};
52
+ const pk = ${JSON.stringify(pk)};
53
+ let inst;
54
+ try {
55
+ inst = await DuckDBInstance.create(dbPath);
56
+ } catch (e) {
57
+ process.stdout.write("MISSING:" + (e?.message ?? String(e)));
58
+ process.exit(0);
59
+ }
60
+ const c = await inst.connect();
61
+ try {
62
+ const r = await c.runAndReadAll(\`SELECT \${pk} FROM \${table} LIMIT 1\`);
63
+ if (r.getRows().length === 0) {
64
+ process.stdout.write("EMPTY");
65
+ process.exit(0);
66
+ }
67
+ } catch (e) {
68
+ const msg = String(e?.message ?? e);
69
+ // Table doesn't exist yet (e.g., schema older than this doctor) — not
70
+ // a corruption signal, just skip it.
71
+ if (msg.includes("does not exist") || msg.includes("Catalog Error")) {
72
+ process.stdout.write("MISSING:" + msg);
73
+ process.exit(0);
74
+ }
75
+ process.stdout.write("CORRUPT:" + msg);
76
+ process.exit(2);
77
+ }
78
+ try {
79
+ await c.run(\`UPDATE \${table} SET \${pk} = \${pk} WHERE \${pk} = (SELECT \${pk} FROM \${table} LIMIT 1)\`);
80
+ process.stdout.write("OK");
81
+ process.exit(0);
82
+ } catch (e) {
83
+ process.stdout.write("CORRUPT:" + (e?.message ?? String(e)));
84
+ process.exit(2);
85
+ }
86
+ `;
87
+
88
+ const proc = Bun.spawn(["bun", "-e", script], {
89
+ stdio: ["ignore", "pipe", "pipe"],
90
+ });
91
+ const [stdout, stderr, exitCode] = await Promise.all([
92
+ new Response(proc.stdout).text(),
93
+ new Response(proc.stderr).text(),
94
+ proc.exited,
95
+ ]);
96
+
97
+ // Bun panic: process killed by SIGTRAP / non-zero exit with no stdout
98
+ // verdict. Treat any unrecognized exit as corruption — better to flag
99
+ // for repair than to silently miss a problem.
100
+ if (stdout.startsWith("OK")) return { table, status: "ok" };
101
+ if (stdout.startsWith("EMPTY")) return { table, status: "empty" };
102
+ if (stdout.startsWith("MISSING:")) {
103
+ return {
104
+ table,
105
+ status: "missing",
106
+ message: stdout.slice("MISSING:".length),
107
+ };
108
+ }
109
+ if (stdout.startsWith("CORRUPT:")) {
110
+ return {
111
+ table,
112
+ status: "corrupt",
113
+ message: stdout.slice("CORRUPT:".length),
114
+ };
115
+ }
116
+ const reason =
117
+ stderr.trim() ||
118
+ `child exited with code ${exitCode} and no verdict (likely native panic)`;
119
+ return { table, status: "corrupt", message: reason };
120
+ }
121
+
122
+ /**
123
+ * Run probes for every known table. Sequential rather than parallel so we
124
+ * cooperate with DuckDB's per-process file lock and don't multiply the
125
+ * blast radius of a panic.
126
+ */
127
+ export async function probeAllTables(dbPath: string): Promise<ProbeResult[]> {
128
+ const results: ProbeResult[] = [];
129
+ for (const { name, pk } of PROBE_TABLES) {
130
+ results.push(await probeTable(dbPath, name, pk));
131
+ }
132
+ return results;
133
+ }
134
+
135
+ export interface RepairResult {
136
+ backupDbPath: string;
137
+ exportDir: string;
138
+ durationMs: number;
139
+ }
140
+
141
+ /**
142
+ * Repair `dbPath` by exporting its contents and importing into a fresh
143
+ * file. EXPORT DATABASE reads via sequential scans, not via PK indexes,
144
+ * so it survives the kind of index corruption that breaks UPDATE/DELETE.
145
+ * IMPORT DATABASE rebuilds every index from the data, which restores
146
+ * write integrity.
147
+ *
148
+ * Steps:
149
+ * 1. CHECKPOINT (best-effort) to flush WAL.
150
+ * 2. EXPORT DATABASE to `<dotDir>/.export-<timestamp>`.
151
+ * 3. Move `data.duckdb` (and `.wal`) to `data.duckdb.bak-<timestamp>`.
152
+ * 4. Open a fresh DB at the original path and IMPORT DATABASE.
153
+ * 5. Leave the export dir on disk — cheap insurance if step 4 ever fails
154
+ * mid-way; cleanup on the next successful run.
155
+ *
156
+ * The caller is responsible for ensuring no other process holds the DB
157
+ * (no running workers, no chat session, no TUI).
158
+ */
159
+ export async function repairDatabase(dbPath: string): Promise<RepairResult> {
160
+ const start = Date.now();
161
+ const dotDir = dirname(dbPath);
162
+ await mkdir(dotDir, { recursive: true });
163
+
164
+ const stamp = new Date()
165
+ .toISOString()
166
+ .replace(/[:.]/g, "-")
167
+ .replace(/Z$/, "");
168
+ const exportDir = join(dotDir, `.export-${stamp}`);
169
+ const backupDbPath = `${dbPath}.bak-${stamp}`;
170
+ const walPath = `${dbPath}.wal`;
171
+ const backupWalPath = `${backupDbPath}.wal`;
172
+
173
+ await withDb(dbPath, async (conn) => {
174
+ try {
175
+ await conn.exec("CHECKPOINT");
176
+ } catch {
177
+ // CHECKPOINT can fail on an already-invalidated DB; the EXPORT
178
+ // below is what actually matters.
179
+ }
180
+ await conn.exec(`EXPORT DATABASE '${exportDir.replace(/'/g, "''")}'`);
181
+ });
182
+
183
+ await rename(dbPath, backupDbPath);
184
+ if (await pathExists(walPath)) {
185
+ await rename(walPath, backupWalPath);
186
+ }
187
+
188
+ await withDb(dbPath, async (conn) => {
189
+ await conn.exec(`IMPORT DATABASE '${exportDir.replace(/'/g, "''")}'`);
190
+ });
191
+
192
+ // Best-effort cleanup of the export dir. Leave it on failure — the user
193
+ // still has data.duckdb (rebuilt) and the backup.
194
+ try {
195
+ await rm(exportDir, { recursive: true, force: true });
196
+ } catch {
197
+ // ignore
198
+ }
199
+
200
+ return {
201
+ backupDbPath,
202
+ exportDir,
203
+ durationMs: Date.now() - start,
204
+ };
205
+ }
206
+
207
+ async function pathExists(p: string): Promise<boolean> {
208
+ try {
209
+ await stat(p);
210
+ return true;
211
+ } catch {
212
+ return false;
213
+ }
214
+ }