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 +219 -1
- package/package.json +1 -1
- package/src/chat/agent.ts +70 -4
- package/src/chat/session.ts +2 -10
- package/src/cli.ts +2 -0
- package/src/commands/chat.ts +1 -1
- package/src/commands/nuke.ts +149 -0
- package/src/commands/skill.ts +62 -1
- package/src/daemon/prompt.ts +16 -6
- package/src/db/context.ts +11 -0
- package/src/db/daemon-state.ts +6 -0
- package/src/db/schedules.ts +5 -0
- package/src/db/tasks.ts +5 -0
- package/src/db/threads.ts +11 -0
- package/src/skills/commands.ts +12 -1
- package/src/tui/App.tsx +33 -18
- package/src/tui/components/HelpPanel.tsx +1 -1
- package/src/tui/components/InputBar.tsx +176 -99
- package/src/tui/components/SlashCommandPopup.tsx +50 -0
- package/src/tui/slashCompletion.ts +38 -0
package/README.md
CHANGED
|
@@ -6,4 +6,222 @@
|
|
|
6
6
|
" "
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
|
|
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
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
package/src/chat/session.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/commands/chat.ts
CHANGED
|
@@ -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
|
-
" /
|
|
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
|
+
}
|
package/src/commands/skill.ts
CHANGED
|
@@ -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")
|
package/src/daemon/prompt.ts
CHANGED
|
@@ -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
|
-
?
|
|
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/
|