botholomew 0.7.0 → 0.7.2

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,4 +6,222 @@
6
6
  " "
7
7
  ```
8
8
 
9
- An AI agent for knowledge work.
9
+ **A local-first AI agent for knowledge work.** Botholomew is a long-running
10
+ autonomous agent that works its way through a task queue — reading email,
11
+ summarizing documents, researching topics, organizing notes, and maintaining
12
+ context over time — while you sleep, work, or chat with it.
13
+
14
+ Unlike coding agents, Botholomew has **no shell, no filesystem, and no network
15
+ tools** by default. Everything it touches lives inside a single DuckDB database
16
+ at `.botholomew/data.duckdb` and a handful of markdown files. External access
17
+ is granted deliberately, per project, through MCP servers.
18
+
19
+ ---
20
+
21
+ ## Why Botholomew?
22
+
23
+ - **Autonomous.** A background daemon ticks on a schedule, claims tasks,
24
+ works them with Claude, and logs every interaction. You can close the
25
+ terminal and come back later.
26
+ - **Portable.** Each project is a `.botholomew/` directory — markdown +
27
+ DuckDB. Copy it, share it, check it in (or `.gitignore` it).
28
+ - **Local-first.** All data stays on your machine. Embeddings are indexed in
29
+ DuckDB's native vector store with HNSW. Model calls go direct to Anthropic
30
+ and OpenAI.
31
+ - **Extensible.** External tools come from MCP servers via
32
+ [MCPX](https://github.com/evantahler/mcpx) — run them locally (Gmail,
33
+ Slack, GitHub) or connect through an MCP gateway like
34
+ [Arcade.dev](https://www.arcade.dev/) to reach hundreds of
35
+ authenticated services without managing each server yourself.
36
+ Reusable workflows are defined as markdown "skills" (slash commands).
37
+ - **Safe by default.** The agent has no shell, no network, and no
38
+ filesystem access of its own. Everything it can touch lives in
39
+ `.botholomew/` — and every external capability is something you
40
+ explicitly add.
41
+ - **Self-healing.** An OS-level watchdog (launchd on macOS, systemd on Linux)
42
+ restarts the daemon if it dies, rotates logs, and runs on boot.
43
+ - **Self-modifying.** The agent maintains its own `beliefs.md` and
44
+ `goals.md` — it learns, updates its priors, and revises its goals as it
45
+ works.
46
+
47
+ ---
48
+
49
+ ## Install
50
+
51
+ Requires [Bun](https://bun.sh) 1.1+.
52
+
53
+ ```bash
54
+ bun install -g botholomew
55
+ ```
56
+
57
+ Or run the dev build from a checkout:
58
+
59
+ ```bash
60
+ git clone https://github.com/evantahler/botholomew
61
+ cd botholomew
62
+ bun install
63
+ bun run dev -- --help
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Quickstart
69
+
70
+ ```bash
71
+ # 1. Initialize a project in the current directory
72
+ botholomew init
73
+
74
+ # 2. Add your API keys to .botholomew/config.json, or export env vars
75
+ export ANTHROPIC_API_KEY=sk-ant-...
76
+ export OPENAI_API_KEY=sk-... # used for embeddings
77
+
78
+ # 3. Queue some work
79
+ botholomew task add "Summarize every markdown file in ~/notes"
80
+
81
+ # 4. Start the daemon (foreground — watch it work)
82
+ botholomew daemon start --foreground
83
+
84
+ # 5. Or chat with the agent interactively
85
+ botholomew chat
86
+ ```
87
+
88
+ ---
89
+
90
+ ## What a project looks like
91
+
92
+ ```
93
+ my-project/
94
+ .botholomew/
95
+ soul.md # always-loaded identity (not agent-editable)
96
+ beliefs.md # always-loaded, agent-editable priors
97
+ goals.md # always-loaded, agent-editable goals
98
+ config.json # models, tick interval, API keys
99
+ data.duckdb # tasks, schedules, context, embeddings, logs
100
+ mcpx/servers.json # external MCP servers (Gmail, Slack, …)
101
+ skills/ # user-defined slash commands
102
+ summarize.md
103
+ standup.md
104
+ daemon.pid # PID file for the running daemon
105
+ daemon.log # rotating daemon logs
106
+ ```
107
+
108
+ Everything the agent can touch is here. No surprises.
109
+
110
+ ---
111
+
112
+ ## The CLI
113
+
114
+ | Command | Purpose |
115
+ |---|---|
116
+ | `botholomew init` | Create `.botholomew/` with templates and a fresh database |
117
+ | `botholomew daemon start\|stop\|status` | Run, stop, or inspect the daemon |
118
+ | `botholomew daemon install\|uninstall` | Register/remove the OS watchdog |
119
+ | `botholomew daemon list` | List all Botholomew projects on this machine |
120
+ | `botholomew chat` | Interactive Ink/React TUI |
121
+ | `botholomew task list\|add\|view\|update\|reset\|delete` | Manage the task queue |
122
+ | `botholomew schedule list\|add\|enable\|trigger\|delete` | Recurring work |
123
+ | `botholomew context add\|list\|view\|search\|refresh\|remove` | Ingest & browse knowledge (files, folders, URLs) |
124
+ | `botholomew mcpx add\|list\|tools\|test` | Configure external MCP servers |
125
+ | `botholomew skill list\|show\|create` | Manage slash-command skills |
126
+ | `botholomew file\|dir\|search ...` | Direct access to the agent's virtual filesystem |
127
+ | `botholomew thread list\|view` | Browse the agent's interaction history |
128
+ | `botholomew nuke context\|tasks\|schedules\|threads\|all` | Bulk-erase sections of the database |
129
+ | `botholomew upgrade` | Self-update |
130
+
131
+ ---
132
+
133
+ ## How it works
134
+
135
+ ```
136
+ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
137
+ │ Chat │ │ Daemon │ │ Watchdog │
138
+ │ (Ink TUI) │ │ (tick loop) │ │ launchd/ │
139
+ │ │ │ │ │ systemd │
140
+ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘
141
+ │ │ │
142
+ │ enqueue tasks │ claims tasks │ every 60s:
143
+ │ browse history │ runs LLM tool loops │ check PID
144
+ │ invoke skills │ updates status │ restart if
145
+ │ │ logs to threads │ dead
146
+ │ │ │
147
+ └────────────┬───────────┴────────────┬───────────┘
148
+ │ │
149
+ ┌─────▼────────────────────────▼─────┐
150
+ │ DuckDB │
151
+ │ ┌───────────┐ ┌──────────────┐ │
152
+ │ │ tasks │ │ context_items│ │
153
+ │ │ schedules │ │ embeddings │ │
154
+ │ │ threads │ │ (HNSW) │ │
155
+ │ └───────────┘ └──────────────┘ │
156
+ └─────┬───────────────────────────────┘
157
+
158
+
159
+ MCPX ─► Gmail, Slack, GitHub, Firecrawl, …
160
+ ```
161
+
162
+ See [docs/architecture.md](docs/architecture.md) for a deeper tour.
163
+
164
+ ---
165
+
166
+ ## Deep dives
167
+
168
+ Topics worth understanding in detail:
169
+
170
+ - **[Architecture](docs/architecture.md)** — daemon, chat, watchdog, and how
171
+ they share a database.
172
+ - **[The virtual filesystem](docs/virtual-filesystem.md)** — why the agent's
173
+ "files" are actually DuckDB rows, and how `file_read`/`file_write` work.
174
+ - **[Context & hybrid search](docs/context-and-search.md)** — LLM-driven
175
+ chunking, OpenAI embeddings, and DuckDB's HNSW-accelerated keyword +
176
+ vector search.
177
+ - **[Tasks & schedules](docs/tasks-and-schedules.md)** — the claim loop, DAG
178
+ validation, stale-task recovery, and natural-language recurring schedules.
179
+ - **[The Tool class](docs/tools.md)** — one Zod definition, three consumers
180
+ (Anthropic tool-use, Commander CLI, tests).
181
+ - **[Persistent context](docs/persistent-context.md)** — `soul.md`,
182
+ `beliefs.md`, `goals.md`, frontmatter flags, and agent self-modification.
183
+ - **[Skills (slash commands)](docs/skills.md)** — reusable prompt templates
184
+ with positional arguments and tab completion.
185
+ - **[MCPX integration](docs/mcpx.md)** — configuring external servers and
186
+ how MCP tools are merged into the agent's toolset.
187
+ - **[The watchdog](docs/watchdog.md)** — launchd plists, systemd units, and
188
+ multi-project service naming.
189
+ - **[Configuration](docs/configuration.md)** — every key in `config.json`
190
+ and its default.
191
+
192
+ ---
193
+
194
+ ## Tech stack
195
+
196
+ - **[Bun](https://bun.sh)** + TypeScript
197
+ - **[DuckDB](https://duckdb.org)** via `@duckdb/node-api`, with the
198
+ **[VSS extension](https://duckdb.org/docs/stable/extensions/vss)** for
199
+ native vector search
200
+ - **[Anthropic SDK](https://docs.anthropic.com/en/api/client-sdks)** for
201
+ Claude — the reasoning model
202
+ - **OpenAI embeddings API** (`text-embedding-3-small`, 1536-dim) for
203
+ semantic search
204
+ - **[MCPX](https://github.com/evantahler/mcpx)** for external tools
205
+ - **[Ink 6](https://github.com/vadimdemedes/ink)** + **React 19** for the
206
+ terminal UI
207
+ - **[Commander.js](https://github.com/tj/commander.js)** for the CLI
208
+ - **[Zod](https://zod.dev)** for tool input/output schemas
209
+
210
+ ---
211
+
212
+ ## Contributing
213
+
214
+ ```bash
215
+ bun install
216
+ bun test
217
+ bun run lint # tsc --noEmit + biome check
218
+ ```
219
+
220
+ See [CLAUDE.md](CLAUDE.md) for conventions (always use `bun`, bump the
221
+ version in `package.json` on every merge to `main`, etc.).
222
+
223
+ ---
224
+
225
+ ## License
226
+
227
+ MIT.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "An AI agent for knowledge work",
5
5
  "type": "module",
6
6
  "bin": {
package/src/chat/agent.ts CHANGED
@@ -5,10 +5,16 @@ import type {
5
5
  ToolUseBlock,
6
6
  } from "@anthropic-ai/sdk/resources/messages";
7
7
  import type { BotholomewConfig } from "../config/schemas.ts";
8
+ import { embedSingle } from "../context/embedder.ts";
8
9
  import { fitToContextWindow, getMaxInputTokens } from "../daemon/context.ts";
9
10
  import { maybeStoreResult } from "../daemon/large-results.ts";
10
- import { buildMetaHeader, loadPersistentContext } from "../daemon/prompt.ts";
11
+ import {
12
+ buildMetaHeader,
13
+ extractKeywords,
14
+ loadPersistentContext,
15
+ } from "../daemon/prompt.ts";
11
16
  import type { DbConnection } from "../db/connection.ts";
17
+ import { hybridSearch } from "../db/embeddings.ts";
12
18
  import { logInteraction } from "../db/threads.ts";
13
19
  import { registerAllTools } from "../tools/registry.ts";
14
20
  import {
@@ -17,6 +23,7 @@ import {
17
23
  type ToolContext,
18
24
  toAnthropicTool,
19
25
  } from "../tools/tool.ts";
26
+ import { logger } from "../utils/logger.ts";
20
27
 
21
28
  registerAllTools();
22
29
 
@@ -49,11 +56,44 @@ export function getChatTools() {
49
56
 
50
57
  export async function buildChatSystemPrompt(
51
58
  projectDir: string,
59
+ options?: {
60
+ keywordSource?: string;
61
+ conn?: DbConnection;
62
+ config?: Required<BotholomewConfig>;
63
+ },
52
64
  ): Promise<string> {
53
65
  const parts: string[] = [];
54
66
 
55
67
  parts.push(...buildMetaHeader(projectDir));
56
- parts.push(...(await loadPersistentContext(projectDir)));
68
+
69
+ const keywordSource = options?.keywordSource?.trim();
70
+ const taskKeywords = keywordSource ? extractKeywords(keywordSource) : null;
71
+
72
+ parts.push(...(await loadPersistentContext(projectDir, taskKeywords)));
73
+
74
+ // Relevant context from embeddings search
75
+ const conn = options?.conn;
76
+ const config = options?.config;
77
+ if (conn && config?.openai_api_key && keywordSource) {
78
+ try {
79
+ const queryVec = await embedSingle(keywordSource, config);
80
+ const results = await hybridSearch(conn, keywordSource, queryVec, 5);
81
+
82
+ if (results.length > 0) {
83
+ parts.push("## Relevant Context");
84
+ for (const r of results) {
85
+ const path = r.source_path || r.context_item_id;
86
+ parts.push(`### ${r.title} (${path})`);
87
+ if (r.chunk_content) {
88
+ parts.push(r.chunk_content.slice(0, 1000));
89
+ }
90
+ parts.push("");
91
+ }
92
+ }
93
+ } catch (err) {
94
+ logger.debug(`Failed to load contextual embeddings: ${err}`);
95
+ }
96
+ }
57
97
 
58
98
  parts.push("## Instructions");
59
99
  parts.push(
@@ -95,6 +135,20 @@ export interface ChatTurnCallbacks {
95
135
  ) => void;
96
136
  }
97
137
 
138
+ /**
139
+ * Walk messages backward to find the most recent human-authored user message.
140
+ * After tool turns, `messages[messages.length - 1]` is a user entry whose
141
+ * content is a `ToolResultBlockParam[]` — we want the string content from the
142
+ * actual user, not tool output, as the keyword source.
143
+ */
144
+ function findLastUserText(messages: MessageParam[]): string {
145
+ for (let i = messages.length - 1; i >= 0; i--) {
146
+ const m = messages[i];
147
+ if (m?.role === "user" && typeof m.content === "string") return m.content;
148
+ }
149
+ return "";
150
+ }
151
+
98
152
  /**
99
153
  * Run a single chat turn: stream the assistant response, execute any tool calls,
100
154
  * and loop until the model produces end_turn with no tool calls.
@@ -102,14 +156,14 @@ export interface ChatTurnCallbacks {
102
156
  */
103
157
  export async function runChatTurn(input: {
104
158
  messages: MessageParam[];
105
- systemPrompt: string;
159
+ projectDir: string;
106
160
  config: Required<BotholomewConfig>;
107
161
  conn: DbConnection;
108
162
  threadId: string;
109
163
  toolCtx: ToolContext;
110
164
  callbacks: ChatTurnCallbacks;
111
165
  }): Promise<void> {
112
- const { messages, systemPrompt, config, conn, threadId, toolCtx, callbacks } =
166
+ const { messages, projectDir, config, conn, threadId, toolCtx, callbacks } =
113
167
  input;
114
168
 
115
169
  const client = new Anthropic({
@@ -126,6 +180,18 @@ export async function runChatTurn(input: {
126
180
  for (let turn = 0; !maxTurns || turn < maxTurns; turn++) {
127
181
  const startTime = Date.now();
128
182
 
183
+ // Rebuild the system prompt every iteration so that:
184
+ // (1) `loading: contextual` files get matched against the latest user
185
+ // message, and
186
+ // (2) any update_beliefs / update_goals tool call in the previous
187
+ // iteration is reflected in the next LLM call.
188
+ const keywordSource = findLastUserText(messages);
189
+ const systemPrompt = await buildChatSystemPrompt(projectDir, {
190
+ keywordSource,
191
+ conn,
192
+ config,
193
+ });
194
+
129
195
  fitToContextWindow(messages, systemPrompt, maxInputTokens);
130
196
  const stream = client.messages.stream({
131
197
  model: config.model,
@@ -17,11 +17,7 @@ import { loadSkills } from "../skills/loader.ts";
17
17
  import type { SkillDefinition } from "../skills/parser.ts";
18
18
  import type { ToolContext } from "../tools/tool.ts";
19
19
  import { generateThreadTitle } from "../utils/title.ts";
20
- import {
21
- buildChatSystemPrompt,
22
- type ChatTurnCallbacks,
23
- runChatTurn,
24
- } from "./agent.ts";
20
+ import { type ChatTurnCallbacks, runChatTurn } from "./agent.ts";
25
21
 
26
22
  export interface ChatSession {
27
23
  conn: DbConnection;
@@ -29,7 +25,6 @@ export interface ChatSession {
29
25
  projectDir: string;
30
26
  config: Required<BotholomewConfig>;
31
27
  messages: MessageParam[];
32
- systemPrompt: string;
33
28
  toolCtx: ToolContext;
34
29
  skills: Map<string, SkillDefinition>;
35
30
  cleanup: () => Promise<void>;
@@ -83,8 +78,6 @@ export async function startChatSession(
83
78
  threadId = await createThread(conn, "chat_session", undefined, "New chat");
84
79
  }
85
80
 
86
- const systemPrompt = await buildChatSystemPrompt(projectDir);
87
-
88
81
  const mcpxClient = await createMcpxClient(projectDir);
89
82
  const skills = await loadSkills(projectDir);
90
83
 
@@ -105,7 +98,6 @@ export async function startChatSession(
105
98
  projectDir,
106
99
  config,
107
100
  messages,
108
- systemPrompt,
109
101
  toolCtx,
110
102
  skills,
111
103
  cleanup,
@@ -138,7 +130,7 @@ export async function sendMessage(
138
130
 
139
131
  await runChatTurn({
140
132
  messages: session.messages,
141
- systemPrompt: session.systemPrompt,
133
+ projectDir: session.projectDir,
142
134
  config: session.config,
143
135
  conn: session.conn,
144
136
  threadId: session.threadId,
package/src/cli.ts CHANGED
@@ -8,6 +8,7 @@ import { registerContextCommand } from "./commands/context.ts";
8
8
  import { registerDaemonCommand } from "./commands/daemon.ts";
9
9
  import { registerInitCommand } from "./commands/init.ts";
10
10
  import { registerMcpxCommand } from "./commands/mcpx.ts";
11
+ import { registerNukeCommand } from "./commands/nuke.ts";
11
12
  import { registerPrepareCommand } from "./commands/prepare.ts";
12
13
  import { registerScheduleCommand } from "./commands/schedule.ts";
13
14
  import { registerSkillCommand } from "./commands/skill.ts";
@@ -40,6 +41,7 @@ registerChatCommand(program);
40
41
  registerContextCommand(program);
41
42
  registerMcpxCommand(program);
42
43
  registerSkillCommand(program);
44
+ registerNukeCommand(program);
43
45
  registerPrepareCommand(program);
44
46
  registerCheckUpdateCommand(program);
45
47
  registerUpgradeCommand(program);
@@ -12,7 +12,7 @@ export function registerChatCommand(program: Command) {
12
12
  " Commands:\n" +
13
13
  " /help Show keyboard shortcuts\n" +
14
14
  " /tools Open tool call inspector\n" +
15
- " /quit, /exit End the chat session",
15
+ " /exit End the chat session",
16
16
  )
17
17
  .option("--thread-id <id>", "Resume an existing chat thread")
18
18
  .option("-p, --prompt <text>", "Start chat with an initial prompt")
@@ -0,0 +1,149 @@
1
+ import ansis from "ansis";
2
+ import type { Command } from "commander";
3
+ import type { DbConnection } from "../db/connection.ts";
4
+ import { deleteAllContextItems } from "../db/context.ts";
5
+ import { deleteAllDaemonState } from "../db/daemon-state.ts";
6
+ import { deleteAllSchedules } from "../db/schedules.ts";
7
+ import { deleteAllTasks } from "../db/tasks.ts";
8
+ import { deleteAllThreads } from "../db/threads.ts";
9
+ import { logger } from "../utils/logger.ts";
10
+ import { getDaemonStatus } from "../utils/pid.ts";
11
+ import { withDb } from "./with-db.ts";
12
+
13
+ type NukeScope = "context" | "tasks" | "schedules" | "threads" | "all";
14
+
15
+ const TABLES_BY_SCOPE: Record<NukeScope, string[]> = {
16
+ context: ["context_items", "embeddings"],
17
+ tasks: ["tasks"],
18
+ schedules: ["schedules"],
19
+ threads: ["threads", "interactions"],
20
+ all: [
21
+ "context_items",
22
+ "embeddings",
23
+ "tasks",
24
+ "schedules",
25
+ "threads",
26
+ "interactions",
27
+ "daemon_state",
28
+ ],
29
+ };
30
+
31
+ async function countRows(conn: DbConnection, table: string): Promise<number> {
32
+ const row = await conn.queryGet<{ cnt: number }>(
33
+ `SELECT COUNT(*) AS cnt FROM ${table}`,
34
+ );
35
+ return row ? Number(row.cnt) : 0;
36
+ }
37
+
38
+ function printDryRun(scope: NukeScope, counts: Record<string, number>) {
39
+ console.log(ansis.red.bold(`Nuke scope: ${scope}`));
40
+ console.log("Would delete:");
41
+ const nameWidth = Math.max(...Object.keys(counts).map((k) => k.length));
42
+ for (const [table, count] of Object.entries(counts)) {
43
+ const padded = table.padEnd(nameWidth + 2);
44
+ console.log(` ${padded}${ansis.dim(`${count} rows`)}`);
45
+ }
46
+ console.log("");
47
+ console.log(
48
+ ansis.yellow("Re-run with --yes to confirm. This cannot be undone."),
49
+ );
50
+ }
51
+
52
+ async function ensureDaemonStopped(dir: string): Promise<boolean> {
53
+ const status = await getDaemonStatus(dir);
54
+ if (status) {
55
+ logger.error(
56
+ `Daemon is running (PID ${status.pid}). Stop it first: botholomew daemon stop`,
57
+ );
58
+ return false;
59
+ }
60
+ return true;
61
+ }
62
+
63
+ async function runNuke(conn: DbConnection, scope: NukeScope): Promise<void> {
64
+ // Not wrapped in a transaction: DuckDB's FK index checks on DELETE FROM
65
+ // threads inside a transaction see stale interactions rows even after
66
+ // DELETE FROM interactions ran in the same transaction. Each helper is
67
+ // already a small sequence of statements, so auto-commit is fine for a
68
+ // destructive dev-time tool.
69
+ if (scope === "context" || scope === "all") {
70
+ const { contextItems, embeddings } = await deleteAllContextItems(conn);
71
+ logger.success(
72
+ `Deleted ${contextItems} context_items, ${embeddings} embeddings`,
73
+ );
74
+ }
75
+ if (scope === "tasks" || scope === "all") {
76
+ const n = await deleteAllTasks(conn);
77
+ logger.success(`Deleted ${n} tasks`);
78
+ }
79
+ if (scope === "schedules" || scope === "all") {
80
+ const n = await deleteAllSchedules(conn);
81
+ logger.success(`Deleted ${n} schedules`);
82
+ }
83
+ if (scope === "threads" || scope === "all") {
84
+ const { threads, interactions } = await deleteAllThreads(conn);
85
+ logger.success(`Deleted ${threads} threads, ${interactions} interactions`);
86
+ }
87
+ if (scope === "all") {
88
+ const n = await deleteAllDaemonState(conn);
89
+ logger.success(`Deleted ${n} daemon_state entries`);
90
+ }
91
+ }
92
+
93
+ function registerScope(
94
+ program: Command,
95
+ parent: Command,
96
+ scope: NukeScope,
97
+ description: string,
98
+ ) {
99
+ parent
100
+ .command(scope)
101
+ .description(description)
102
+ .option("-y, --yes", "confirm the deletion (required)")
103
+ .action((opts) =>
104
+ withDb(program, async (conn, dir) => {
105
+ if (!(await ensureDaemonStopped(dir))) {
106
+ process.exit(1);
107
+ }
108
+ const tables = TABLES_BY_SCOPE[scope];
109
+ const counts: Record<string, number> = {};
110
+ for (const t of tables) {
111
+ counts[t] = await countRows(conn, t);
112
+ }
113
+
114
+ if (!opts.yes) {
115
+ printDryRun(scope, counts);
116
+ process.exit(1);
117
+ }
118
+
119
+ await runNuke(conn, scope);
120
+ }),
121
+ );
122
+ }
123
+
124
+ export function registerNukeCommand(program: Command) {
125
+ const nuke = program
126
+ .command("nuke")
127
+ .description("Bulk-erase sections of the database");
128
+
129
+ registerScope(
130
+ program,
131
+ nuke,
132
+ "context",
133
+ "Erase all context_items and embeddings",
134
+ );
135
+ registerScope(program, nuke, "tasks", "Erase all tasks");
136
+ registerScope(program, nuke, "schedules", "Erase all schedules");
137
+ registerScope(
138
+ program,
139
+ nuke,
140
+ "threads",
141
+ "Erase all threads and interactions (daemon + chat history)",
142
+ );
143
+ registerScope(
144
+ program,
145
+ nuke,
146
+ "all",
147
+ "Erase everything in the database (preserves schema, skills, and on-disk soul/beliefs/goals)",
148
+ );
149
+ }
@@ -1,4 +1,4 @@
1
- import { join } from "node:path";
1
+ import { join, relative } from "node:path";
2
2
  import ansis from "ansis";
3
3
  import type { Command } from "commander";
4
4
  import { getSkillsDir } from "../constants.ts";
@@ -24,6 +24,67 @@ export function registerSkillCommand(program: Command) {
24
24
  }
25
25
  });
26
26
 
27
+ skill
28
+ .command("list")
29
+ .description("List all skills loaded from .botholomew/skills/")
30
+ .action(async () => {
31
+ const dir = program.opts().dir;
32
+ const skills = await loadSkills(dir);
33
+
34
+ if (skills.size === 0) {
35
+ logger.dim("No skill files found.");
36
+ return;
37
+ }
38
+
39
+ const sorted = [...skills.values()].sort((a, b) =>
40
+ a.name.localeCompare(b.name),
41
+ );
42
+
43
+ const header = `${ansis.bold("Name".padEnd(20))} ${ansis.bold("Description".padEnd(40))} ${ansis.bold("Args".padEnd(20))} ${ansis.bold("Path")}`;
44
+ console.log(header);
45
+ console.log("-".repeat(header.length));
46
+
47
+ for (const s of sorted) {
48
+ const name = s.name.padEnd(20);
49
+ const desc = s.description
50
+ ? s.description.slice(0, 39).padEnd(40)
51
+ : ansis.dim("(no description)".padEnd(40));
52
+ const args =
53
+ s.arguments.length > 0
54
+ ? s.arguments
55
+ .map((a) => a.name)
56
+ .join(",")
57
+ .slice(0, 19)
58
+ .padEnd(20)
59
+ : ansis.dim("none".padEnd(20));
60
+ const path = relative(dir, s.filePath);
61
+ console.log(`${name} ${desc} ${args} ${path}`);
62
+ }
63
+
64
+ console.log(`\n${ansis.dim(`${sorted.length} skill(s)`)}`);
65
+ });
66
+
67
+ skill
68
+ .command("show <name>")
69
+ .description("Print the raw contents of a skill file")
70
+ .action(async (name: string) => {
71
+ const dir = program.opts().dir;
72
+ const skills = await loadSkills(dir);
73
+ const s = skills.get(name.toLowerCase());
74
+
75
+ if (!s) {
76
+ logger.error(`Skill not found: ${name}`);
77
+ if (skills.size > 0) {
78
+ const available = [...skills.keys()].sort().join(", ");
79
+ console.error(ansis.dim(`Available: ${available}`));
80
+ }
81
+ process.exit(1);
82
+ }
83
+
84
+ const raw = await Bun.file(s.filePath).text();
85
+ process.stdout.write(raw);
86
+ });
87
+
27
88
  skill
28
89
  .command("create <name>")
29
90
  .description("Create a new skill file from a template")
@@ -13,6 +13,21 @@ const pkg = await Bun.file(
13
13
  new URL("../../package.json", import.meta.url),
14
14
  ).json();
15
15
 
16
+ /**
17
+ * Extract keyword set from free-form text: lowercase, split on whitespace,
18
+ * keep words longer than 3 chars. Used to match `loading: contextual` files
19
+ * against the agent's current intent (task text for the daemon, latest user
20
+ * message for the chat).
21
+ */
22
+ export function extractKeywords(text: string): Set<string> {
23
+ return new Set(
24
+ text
25
+ .toLowerCase()
26
+ .split(/\s+/)
27
+ .filter((w) => w.length > 3),
28
+ );
29
+ }
30
+
16
31
  /**
17
32
  * Load persistent context files from .botholomew/ directory.
18
33
  * Returns an array of formatted string sections for "always" loaded files.
@@ -85,12 +100,7 @@ export async function buildSystemPrompt(
85
100
 
86
101
  // Build keyword set from task for contextual loading
87
102
  const taskKeywords = task
88
- ? new Set(
89
- `${task.name} ${task.description}`
90
- .toLowerCase()
91
- .split(/\s+/)
92
- .filter((w) => w.length > 3),
93
- )
103
+ ? extractKeywords(`${task.name} ${task.description}`)
94
104
  : null;
95
105
 
96
106
  // Load context files from .botholomew/