botholomew 0.7.8 → 0.7.10

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.
@@ -27,7 +27,9 @@ export function registerSkillCommand(program: Command) {
27
27
  skill
28
28
  .command("list")
29
29
  .description("List all skills loaded from .botholomew/skills/")
30
- .action(async () => {
30
+ .option("-l, --limit <n>", "max number of skills", Number.parseInt)
31
+ .option("-o, --offset <n>", "skip first N skills", Number.parseInt)
32
+ .action(async (opts: { limit?: number; offset?: number }) => {
31
33
  const dir = program.opts().dir;
32
34
  const skills = await loadSkills(dir);
33
35
 
@@ -39,12 +41,21 @@ export function registerSkillCommand(program: Command) {
39
41
  const sorted = [...skills.values()].sort((a, b) =>
40
42
  a.name.localeCompare(b.name),
41
43
  );
44
+ const total = sorted.length;
45
+ const start = opts.offset ?? 0;
46
+ const end = opts.limit ? start + opts.limit : undefined;
47
+ const page = sorted.slice(start, end);
48
+
49
+ if (page.length === 0) {
50
+ logger.dim(`No skills on this page (total: ${total}).`);
51
+ return;
52
+ }
42
53
 
43
54
  const header = `${ansis.bold("Name".padEnd(20))} ${ansis.bold("Description".padEnd(40))} ${ansis.bold("Args".padEnd(20))} ${ansis.bold("Path")}`;
44
55
  console.log(header);
45
56
  console.log("-".repeat(header.length));
46
57
 
47
- for (const s of sorted) {
58
+ for (const s of page) {
48
59
  const name = s.name.padEnd(20);
49
60
  const desc = s.description
50
61
  ? s.description.slice(0, 39).padEnd(40)
@@ -61,7 +72,11 @@ export function registerSkillCommand(program: Command) {
61
72
  console.log(`${name} ${desc} ${args} ${path}`);
62
73
  }
63
74
 
64
- console.log(`\n${ansis.dim(`${sorted.length} skill(s)`)}`);
75
+ const footer =
76
+ page.length === total
77
+ ? `${total} skill(s)`
78
+ : `showing ${page.length} of ${total} skill(s)`;
79
+ console.log(`\n${ansis.dim(footer)}`);
65
80
  });
66
81
 
67
82
  skill
@@ -17,16 +17,18 @@ export function registerTaskCommand(program: Command) {
17
17
 
18
18
  task
19
19
  .command("list")
20
- .description("List all tasks")
20
+ .description("List all tasks (newest first)")
21
21
  .option("-s, --status <status>", "filter by status")
22
22
  .option("-p, --priority <priority>", "filter by priority")
23
- .option("-l, --limit <n>", "max number of tasks", parseInt)
23
+ .option("-l, --limit <n>", "max number of tasks", Number.parseInt)
24
+ .option("-o, --offset <n>", "skip first N tasks", Number.parseInt)
24
25
  .action((opts) =>
25
26
  withDb(program, async (conn) => {
26
27
  const tasks = await listTasks(conn, {
27
28
  status: opts.status,
28
29
  priority: opts.priority,
29
30
  limit: opts.limit,
31
+ offset: opts.offset,
30
32
  });
31
33
 
32
34
  if (tasks.length === 0) {
@@ -34,9 +36,15 @@ export function registerTaskCommand(program: Command) {
34
36
  return;
35
37
  }
36
38
 
39
+ const header = `${ansis.bold("ID".padEnd(36))} ${ansis.bold("Status".padEnd(11))} ${ansis.bold("Priority".padEnd(6))} ${ansis.bold("Created".padEnd(19))} ${ansis.bold("Updated".padEnd(19))} ${ansis.bold("Name")}`;
40
+ console.log(header);
41
+ console.log("-".repeat(120));
42
+
37
43
  for (const t of tasks) {
38
44
  printTask(t);
39
45
  }
46
+
47
+ console.log(`\n${ansis.dim(`${tasks.length} task(s)`)}`);
40
48
  }),
41
49
  );
42
50
 
@@ -154,10 +162,26 @@ function priorityColor(priority: Task["priority"]): string {
154
162
  }
155
163
  }
156
164
 
165
+ function formatTime(date: Date): string {
166
+ return date
167
+ .toISOString()
168
+ .replace("T", " ")
169
+ .replace(/\.\d{3}Z$/, "");
170
+ }
171
+
172
+ function padColored(colored: string, raw: string, width: number): string {
173
+ const padding = Math.max(0, width - raw.length);
174
+ return colored + " ".repeat(padding);
175
+ }
176
+
157
177
  function printTask(t: Task) {
158
- const id = ansis.dim(t.id);
178
+ const id = ansis.dim(t.id.padEnd(36));
179
+ const status = padColored(statusColor(t.status), t.status, 11);
180
+ const priority = padColored(priorityColor(t.priority), t.priority, 6);
181
+ const created = ansis.dim(formatTime(t.created_at).padEnd(19));
182
+ const updated = ansis.dim(formatTime(t.updated_at).padEnd(19));
159
183
  console.log(
160
- ` ${id} ${statusColor(t.status)} ${priorityColor(t.priority)} ${t.name}`,
184
+ `${id} ${status} ${priority} ${created} ${updated} ${t.name}`,
161
185
  );
162
186
  }
163
187
 
@@ -19,12 +19,14 @@ export function registerThreadCommand(program: Command) {
19
19
  .command("list")
20
20
  .description("List threads")
21
21
  .option("-t, --type <type>", "filter by type (daemon_tick, chat_session)")
22
- .option("-l, --limit <n>", "max number of threads", parseInt)
22
+ .option("-l, --limit <n>", "max number of threads", Number.parseInt)
23
+ .option("-o, --offset <n>", "skip first N threads", Number.parseInt)
23
24
  .action((opts) =>
24
25
  withDb(program, async (conn) => {
25
26
  const threads = await listThreads(conn, {
26
27
  type: opts.type,
27
28
  limit: opts.limit,
29
+ offset: opts.offset,
28
30
  });
29
31
 
30
32
  if (threads.length === 0) {
@@ -2,6 +2,7 @@ import ansis from "ansis";
2
2
  import type { Command } from "commander";
3
3
  import { z } from "zod";
4
4
  import { loadConfig } from "../config/loader.ts";
5
+ import { getDbPath } from "../constants.ts";
5
6
  import { registerAllTools } from "../tools/registry.ts";
6
7
  import {
7
8
  type AnyToolDefinition,
@@ -112,6 +113,7 @@ function registerToolAsCLI(parent: Command, tool: AnyToolDefinition) {
112
113
 
113
114
  const ctx: ToolContext = {
114
115
  conn,
116
+ dbPath: getDbPath(dir),
115
117
  projectDir: dir,
116
118
  config: await loadConfig(dir),
117
119
  mcpxClient: null,
@@ -1,23 +1,22 @@
1
1
  import type { Command } from "commander";
2
2
  import { getDbPath } from "../constants.ts";
3
3
  import type { DbConnection } from "../db/connection.ts";
4
- import { getConnection } from "../db/connection.ts";
4
+ import { withDb as coreWithDb } from "../db/connection.ts";
5
5
  import { migrate } from "../db/schema.ts";
6
6
 
7
7
  /**
8
8
  * Open a migrated DB connection from the CLI --dir flag, run the callback,
9
- * and guarantee the connection is closed afterward.
9
+ * and guarantee the connection is closed afterward. Retries on lock
10
+ * conflicts so CLI invocations cooperate with a running daemon/chat.
10
11
  */
11
12
  export async function withDb<T>(
12
13
  program: Command,
13
14
  fn: (conn: DbConnection, dir: string) => Promise<T>,
14
15
  ): Promise<T> {
15
16
  const dir = program.opts().dir;
16
- const conn = await getConnection(getDbPath(dir));
17
- await migrate(conn);
18
- try {
19
- return await fn(conn, dir);
20
- } finally {
21
- conn.close();
22
- }
17
+ const dbPath = getDbPath(dir);
18
+ return coreWithDb(dbPath, async (conn) => {
19
+ await migrate(conn);
20
+ return fn(conn, dir);
21
+ });
23
22
  }
@@ -161,6 +161,7 @@ async function runFetcherLoop(
161
161
 
162
162
  const toolCtx: ToolContext = {
163
163
  conn: null as unknown as DbConnection,
164
+ dbPath: "",
164
165
  projectDir: "",
165
166
  config,
166
167
  mcpxClient,
@@ -79,7 +79,9 @@ export interface IngestionResult {
79
79
 
80
80
  /**
81
81
  * Store a prepared ingestion into the database.
82
- * This is the fast DB-write phase and must be called sequentially.
82
+ * All statements in BEGIN/COMMIT/ROLLBACK must share one connection, so the
83
+ * caller must pass a connection that lives long enough for the transaction
84
+ * (the tool executor wraps each tool call in `withDb`, which satisfies this).
83
85
  */
84
86
  export async function storeIngestion(
85
87
  conn: DbConnection,
@@ -1,7 +1,7 @@
1
1
  import ansis from "ansis";
2
2
  import { loadConfig } from "../config/loader.ts";
3
3
  import { getDbPath } from "../constants.ts";
4
- import { getConnection } from "../db/connection.ts";
4
+ import { withDb } from "../db/connection.ts";
5
5
  import { migrate } from "../db/schema.ts";
6
6
  import { createMcpxClient } from "../mcpx/client.ts";
7
7
  import { logger } from "../utils/logger.ts";
@@ -49,8 +49,10 @@ export async function startDaemon(
49
49
  ): Promise<void> {
50
50
  const config = await loadConfig(projectDir);
51
51
  const dbPath = getDbPath(projectDir);
52
- const conn = await getConnection(dbPath);
53
- await migrate(conn);
52
+
53
+ // One short-lived connection to apply migrations. After this returns,
54
+ // the file lock is released so CLI invocations can run freely.
55
+ await withDb(dbPath, (conn) => migrate(conn));
54
56
 
55
57
  // Initialize MCPX client for external tool access
56
58
  const mcpxClient = await createMcpxClient(projectDir);
@@ -64,7 +66,6 @@ export async function startDaemon(
64
66
  logger.info("Daemon shutting down...");
65
67
  await mcpxClient?.close();
66
68
  await removePidFile(projectDir);
67
- conn.close();
68
69
  process.exit(0);
69
70
  };
70
71
 
@@ -87,7 +88,7 @@ export async function startDaemon(
87
88
  try {
88
89
  didWork = await tick(
89
90
  projectDir,
90
- conn,
91
+ dbPath,
91
92
  config,
92
93
  mcpxClient,
93
94
  callbacks,
package/src/daemon/llm.ts CHANGED
@@ -7,7 +7,7 @@ import type {
7
7
  } from "@anthropic-ai/sdk/resources/messages";
8
8
  import type { McpxClient } from "@evantahler/mcpx";
9
9
  import type { BotholomewConfig } from "../config/schemas.ts";
10
- import type { DbConnection } from "../db/connection.ts";
10
+ import { withDb } from "../db/connection.ts";
11
11
  import { getTask, type Task } from "../db/tasks.ts";
12
12
  import { logInteraction } from "../db/threads.ts";
13
13
  import { registerAllTools } from "../tools/registry.ts";
@@ -44,32 +44,32 @@ export async function runAgentLoop(input: {
44
44
  systemPrompt: string;
45
45
  task: Task;
46
46
  config: Required<BotholomewConfig>;
47
- conn: DbConnection;
47
+ dbPath: string;
48
48
  threadId: string;
49
49
  projectDir: string;
50
50
  mcpxClient?: McpxClient | null;
51
51
  callbacks?: DaemonStreamCallbacks;
52
52
  }): Promise<AgentLoopResult> {
53
- const { systemPrompt, task, config, conn, threadId, projectDir, callbacks } =
54
- input;
53
+ const {
54
+ systemPrompt,
55
+ task,
56
+ config,
57
+ dbPath,
58
+ threadId,
59
+ projectDir,
60
+ callbacks,
61
+ } = input;
55
62
 
56
63
  const client = new Anthropic({
57
64
  apiKey: config.anthropic_api_key || undefined,
58
65
  });
59
66
 
60
- const toolCtx: ToolContext = {
61
- conn,
62
- projectDir,
63
- config,
64
- mcpxClient: input.mcpxClient ?? null,
65
- };
66
-
67
67
  // Build predecessor context from completed blocking tasks
68
68
  let predecessorContext = "";
69
69
  if (task.blocked_by.length > 0) {
70
70
  const predecessorOutputs: string[] = [];
71
71
  for (const blockerId of task.blocked_by) {
72
- const blocker = await getTask(conn, blockerId);
72
+ const blocker = await withDb(dbPath, (conn) => getTask(conn, blockerId));
73
73
  if (blocker?.output) {
74
74
  predecessorOutputs.push(
75
75
  `### ${blocker.name} (${blocker.id})\n${blocker.output}`,
@@ -86,11 +86,13 @@ export async function runAgentLoop(input: {
86
86
  const messages: MessageParam[] = [{ role: "user", content: userMessage }];
87
87
 
88
88
  // Log the initial user message
89
- await logInteraction(conn, threadId, {
90
- role: "user",
91
- kind: "message",
92
- content: userMessage,
93
- });
89
+ await withDb(dbPath, (conn) =>
90
+ logInteraction(conn, threadId, {
91
+ role: "user",
92
+ kind: "message",
93
+ content: userMessage,
94
+ }),
95
+ );
94
96
 
95
97
  clearLargeResults();
96
98
  const daemonTools = toAnthropicTools();
@@ -144,13 +146,15 @@ export async function runAgentLoop(input: {
144
146
  // Log assistant text blocks
145
147
  for (const block of response.content) {
146
148
  if (block.type === "text" && block.text) {
147
- await logInteraction(conn, threadId, {
148
- role: "assistant",
149
- kind: "message",
150
- content: block.text,
151
- durationMs,
152
- tokenCount,
153
- });
149
+ await withDb(dbPath, (conn) =>
150
+ logInteraction(conn, threadId, {
151
+ role: "assistant",
152
+ kind: "message",
153
+ content: block.text,
154
+ durationMs,
155
+ tokenCount,
156
+ }),
157
+ );
154
158
  }
155
159
  }
156
160
 
@@ -173,20 +177,30 @@ export async function runAgentLoop(input: {
173
177
  for (const toolUse of toolUseBlocks) {
174
178
  const toolInput = JSON.stringify(toolUse.input);
175
179
  callbacks?.onToolStart(toolUse.name, toolInput);
176
- await logInteraction(conn, threadId, {
177
- role: "assistant",
178
- kind: "tool_use",
179
- content: `Calling ${toolUse.name}`,
180
- toolName: toolUse.name,
181
- toolInput,
182
- });
180
+ await withDb(dbPath, (conn) =>
181
+ logInteraction(conn, threadId, {
182
+ role: "assistant",
183
+ kind: "tool_use",
184
+ content: `Calling ${toolUse.name}`,
185
+ toolName: toolUse.name,
186
+ toolInput,
187
+ }),
188
+ );
183
189
  }
184
190
 
185
- // Execute all tools in parallel
191
+ // Execute all tools in parallel. Each tool call opens its own short-lived
192
+ // connection (or none, if the tool uses dbPath internally) via
193
+ // executeToolCall — so parallel tool calls share the process-local
194
+ // DuckDB instance and release the file lock as soon as they finish.
186
195
  const execResults = await Promise.all(
187
196
  toolUseBlocks.map(async (toolUse) => {
188
197
  const start = Date.now();
189
- const result = await executeToolCall(toolUse, toolCtx);
198
+ const result = await executeToolCall(toolUse, {
199
+ dbPath,
200
+ projectDir,
201
+ config,
202
+ mcpxClient: input.mcpxClient ?? null,
203
+ });
190
204
  const elapsed = Date.now() - start;
191
205
  callbacks?.onToolEnd(
192
206
  toolUse.name,
@@ -201,13 +215,15 @@ export async function runAgentLoop(input: {
201
215
  // Log results and collect tool_result messages
202
216
  const toolResults: ToolResultBlockParam[] = [];
203
217
  for (const { toolUse, result, durationMs } of execResults) {
204
- await logInteraction(conn, threadId, {
205
- role: "tool",
206
- kind: "tool_result",
207
- content: result.output,
208
- toolName: toolUse.name,
209
- durationMs,
210
- });
218
+ await withDb(dbPath, (conn) =>
219
+ logInteraction(conn, threadId, {
220
+ role: "tool",
221
+ kind: "tool_result",
222
+ content: result.output,
223
+ toolName: toolUse.name,
224
+ durationMs,
225
+ }),
226
+ );
211
227
 
212
228
  if (result.terminal && result.agentResult) {
213
229
  return result.agentResult;
@@ -234,9 +250,16 @@ interface ToolCallResult {
234
250
  agentResult?: AgentLoopResult;
235
251
  }
236
252
 
253
+ interface ToolCallCtx {
254
+ dbPath: string;
255
+ projectDir: string;
256
+ config: Required<BotholomewConfig>;
257
+ mcpxClient: McpxClient | null;
258
+ }
259
+
237
260
  async function executeToolCall(
238
261
  toolUse: ToolUseBlock,
239
- ctx: ToolContext,
262
+ baseCtx: ToolCallCtx,
240
263
  ): Promise<ToolCallResult> {
241
264
  const tool = getTool(toolUse.name);
242
265
  if (!tool) {
@@ -261,7 +284,10 @@ async function executeToolCall(
261
284
 
262
285
  let result: unknown;
263
286
  try {
264
- result = await tool.execute(parsed.data, ctx);
287
+ result = await withDb(baseCtx.dbPath, (conn) => {
288
+ const ctx: ToolContext = { ...baseCtx, conn };
289
+ return tool.execute(parsed.data, ctx);
290
+ });
265
291
  } catch (err) {
266
292
  return {
267
293
  output: `Tool ${toolUse.name} threw an error: ${err}. You may retry with different parameters or try an alternative approach.`,
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import type { BotholomewConfig } from "../config/schemas.ts";
4
4
  import { getBotholomewDir } from "../constants.ts";
5
5
  import { embedSingle } from "../context/embedder.ts";
6
- import type { DbConnection } from "../db/connection.ts";
6
+ import { withDb } from "../db/connection.ts";
7
7
  import { hybridSearch } from "../db/embeddings.ts";
8
8
  import type { Task } from "../db/tasks.ts";
9
9
  import { parseContextFile } from "../utils/frontmatter.ts";
@@ -89,7 +89,7 @@ export function buildMetaHeader(projectDir: string): string[] {
89
89
  export async function buildSystemPrompt(
90
90
  projectDir: string,
91
91
  task?: Task,
92
- conn?: DbConnection,
92
+ dbPath?: string,
93
93
  _config?: Required<BotholomewConfig>,
94
94
  options?: { hasMcpTools?: boolean },
95
95
  ): Promise<string> {
@@ -107,11 +107,13 @@ export async function buildSystemPrompt(
107
107
  parts.push(...(await loadPersistentContext(projectDir, taskKeywords)));
108
108
 
109
109
  // Relevant context from embeddings search
110
- if (task && conn && _config?.openai_api_key) {
110
+ if (task && dbPath && _config?.openai_api_key) {
111
111
  try {
112
112
  const query = `${task.name} ${task.description}`;
113
113
  const queryVec = await embedSingle(query, _config);
114
- const results = await hybridSearch(conn, query, queryVec, 5);
114
+ const results = await withDb(dbPath, (conn) =>
115
+ hybridSearch(conn, query, queryVec, 5),
116
+ );
115
117
 
116
118
  if (results.length > 0) {
117
119
  parts.push("## Relevant Context");
@@ -1,6 +1,6 @@
1
1
  import Anthropic from "@anthropic-ai/sdk";
2
2
  import type { BotholomewConfig } from "../config/schemas.ts";
3
- import type { DbConnection } from "../db/connection.ts";
3
+ import { withDb } from "../db/connection.ts";
4
4
  import {
5
5
  listSchedules,
6
6
  markScheduleRun,
@@ -105,14 +105,17 @@ Is this schedule due to run? If yes, what tasks should be created?`;
105
105
  }
106
106
 
107
107
  export async function processSchedules(
108
- conn: DbConnection,
108
+ dbPath: string,
109
109
  config: Required<BotholomewConfig>,
110
110
  ): Promise<void> {
111
- const schedules = await listSchedules(conn, { enabled: true });
111
+ const schedules = await withDb(dbPath, (conn) =>
112
+ listSchedules(conn, { enabled: true }),
113
+ );
112
114
  if (schedules.length === 0) return;
113
115
 
114
116
  for (const schedule of schedules) {
115
117
  try {
118
+ // LLM evaluation does no DB work — no connection held here.
116
119
  const evaluation = await evaluateSchedule(config, schedule);
117
120
 
118
121
  if (!evaluation.isDue) {
@@ -128,16 +131,18 @@ export async function processSchedules(
128
131
  .map((i: number) => createdIds[i])
129
132
  .filter(Boolean) as string[];
130
133
 
131
- const task = await createTask(conn, {
132
- name: taskDef.name,
133
- description: taskDef.description,
134
- priority: taskDef.priority,
135
- blocked_by: blockedBy,
136
- });
134
+ const task = await withDb(dbPath, (conn) =>
135
+ createTask(conn, {
136
+ name: taskDef.name,
137
+ description: taskDef.description,
138
+ priority: taskDef.priority,
139
+ blocked_by: blockedBy,
140
+ }),
141
+ );
137
142
  createdIds.push(task.id);
138
143
  }
139
144
 
140
- await markScheduleRun(conn, schedule.id);
145
+ await withDb(dbPath, (conn) => markScheduleRun(conn, schedule.id));
141
146
  logger.info(
142
147
  `Schedule "${schedule.name}" fired, created ${createdIds.length} task(s)`,
143
148
  );