botholomew 0.7.1 → 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
@@ -125,6 +125,7 @@ Everything the agent can touch is here. No surprises.
125
125
  | `botholomew skill list\|show\|create` | Manage slash-command skills |
126
126
  | `botholomew file\|dir\|search ...` | Direct access to the agent's virtual filesystem |
127
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 |
128
129
  | `botholomew upgrade` | Self-update |
129
130
 
130
131
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.7.1",
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);
@@ -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/
package/src/db/context.ts CHANGED
@@ -428,6 +428,17 @@ export async function deleteContextItemByPath(
428
428
  return deleteContextItem(db, item.id);
429
429
  }
430
430
 
431
+ export async function deleteAllContextItems(
432
+ db: DbConnection,
433
+ ): Promise<{ contextItems: number; embeddings: number }> {
434
+ const embeddings = await db.queryRun("DELETE FROM embeddings");
435
+ const contextItems = await db.queryRun("DELETE FROM context_items");
436
+ return {
437
+ contextItems: contextItems.changes,
438
+ embeddings: embeddings.changes,
439
+ };
440
+ }
441
+
431
442
  export async function deleteContextItemsByPrefix(
432
443
  db: DbConnection,
433
444
  prefix: string,
@@ -0,0 +1,6 @@
1
+ import type { DbConnection } from "./connection.ts";
2
+
3
+ export async function deleteAllDaemonState(db: DbConnection): Promise<number> {
4
+ const result = await db.queryRun("DELETE FROM daemon_state");
5
+ return result.changes;
6
+ }
@@ -127,6 +127,11 @@ export async function deleteSchedule(
127
127
  return result.changes > 0;
128
128
  }
129
129
 
130
+ export async function deleteAllSchedules(db: DbConnection): Promise<number> {
131
+ const result = await db.queryRun("DELETE FROM schedules");
132
+ return result.changes;
133
+ }
134
+
130
135
  export async function markScheduleRun(
131
136
  db: DbConnection,
132
137
  id: string,
package/src/db/tasks.ts CHANGED
@@ -229,6 +229,11 @@ export async function deleteTask(
229
229
  return result.changes > 0;
230
230
  }
231
231
 
232
+ export async function deleteAllTasks(db: DbConnection): Promise<number> {
233
+ const result = await db.queryRun("DELETE FROM tasks");
234
+ return result.changes;
235
+ }
236
+
232
237
  export async function resetTask(
233
238
  db: DbConnection,
234
239
  id: string,
package/src/db/threads.ts CHANGED
@@ -205,6 +205,17 @@ export async function deleteThread(
205
205
  return result.changes > 0;
206
206
  }
207
207
 
208
+ export async function deleteAllThreads(
209
+ db: DbConnection,
210
+ ): Promise<{ threads: number; interactions: number }> {
211
+ const interactions = await db.queryRun("DELETE FROM interactions");
212
+ const threads = await db.queryRun("DELETE FROM threads");
213
+ return {
214
+ threads: threads.changes,
215
+ interactions: interactions.changes,
216
+ };
217
+ }
218
+
208
219
  export async function getInteractionsAfter(
209
220
  db: DbConnection,
210
221
  threadId: string,