botholomew 0.7.7 → 0.7.9

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.
@@ -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
  }
@@ -3,6 +3,8 @@ import type { BotholomewConfig } from "../config/schemas.ts";
3
3
  import { logger } from "../utils/logger.ts";
4
4
 
5
5
  const DESCRIBE_TOOL_NAME = "return_description";
6
+ const DESCRIBE_AND_PLACE_TOOL_NAME = "return_description_and_path";
7
+
6
8
  const DESCRIBE_TOOL = {
7
9
  name: DESCRIBE_TOOL_NAME,
8
10
  description: "Return a one-sentence description of this content.",
@@ -19,6 +21,28 @@ const DESCRIBE_TOOL = {
19
21
  },
20
22
  };
21
23
 
24
+ const DESCRIBE_AND_PLACE_TOOL = {
25
+ name: DESCRIBE_AND_PLACE_TOOL_NAME,
26
+ description:
27
+ "Return a one-sentence description AND a suggested absolute folder path for this file.",
28
+ input_schema: {
29
+ type: "object" as const,
30
+ properties: {
31
+ description: {
32
+ type: "string",
33
+ description:
34
+ "A concise one-sentence summary of what this content is about.",
35
+ },
36
+ suggested_path: {
37
+ type: "string",
38
+ description:
39
+ "Absolute virtual-filesystem path (starts with /) where this file should live, including the filename. Prefer existing folders. Include a project/source disambiguator (e.g. /projects/<source-dir>/README.md) when the basename is likely to collide.",
40
+ },
41
+ },
42
+ required: ["description", "suggested_path"],
43
+ },
44
+ };
45
+
22
46
  const TIMEOUT_MS = 10_000;
23
47
  const MAX_CONTENT_CHARS = 8000;
24
48
  const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
@@ -38,8 +62,27 @@ type ImageMediaType = "image/jpeg" | "image/png" | "image/gif" | "image/webp";
38
62
  */
39
63
  async function buildMessageContent(
40
64
  opts: DescriberOpts,
65
+ includePlacement: boolean,
41
66
  ): Promise<Anthropic.Messages.ContentBlockParam[]> {
42
- const textPrompt = `Describe this file in one sentence. Be specific about what it contains, not generic.\n\nFilename: ${opts.filename}\nMIME type: ${opts.mimeType}`;
67
+ const placementBlock = includePlacement
68
+ ? [
69
+ "",
70
+ "Also suggest an absolute folder path where this file should live in the virtual filesystem. Rules:",
71
+ "- Start with /",
72
+ "- Keep the basename close to the source filename",
73
+ "- STRONGLY prefer folders that already exist below — reuse them unless the new file is clearly unrelated to everything there. Do NOT invent a new folder that is a near-synonym of an existing one.",
74
+ "- Use at most 3 nested folders unless an existing folder already goes deeper",
75
+ "- If the basename is common (README.md, index.md, notes.md), include a project/source disambiguator from the source path",
76
+ opts.existingTree
77
+ ? `\nExisting filesystem (folders end with /, files are listed under the folders they live in so you can see what kinds of documents are already there):\n${opts.existingTree}`
78
+ : "\nExisting filesystem: (empty — you are placing the first file)",
79
+ opts.sourcePath ? `\nSource filesystem path: ${opts.sourcePath}` : "",
80
+ ]
81
+ .filter((s) => s.length > 0)
82
+ .join("\n")
83
+ : "";
84
+
85
+ const textPrompt = `Describe this file in one sentence. Be specific about what it contains, not generic.\n\nFilename: ${opts.filename}\nMIME type: ${opts.mimeType}${placementBlock ? `\n${placementBlock}` : ""}`;
43
86
 
44
87
  // Text file — include content inline
45
88
  if (opts.content) {
@@ -98,6 +141,20 @@ interface DescriberOpts {
98
141
  mimeType: string;
99
142
  content: string | null;
100
143
  filePath?: string;
144
+ sourcePath?: string;
145
+ existingTree?: string;
146
+ }
147
+
148
+ /** Normalize and validate an LLM-suggested path. Returns null if invalid. */
149
+ export function sanitizeSuggestedPath(raw: string): string | null {
150
+ const trimmed = raw.trim();
151
+ if (!trimmed) return null;
152
+ if (!trimmed.startsWith("/")) return null;
153
+ if (trimmed.includes("..")) return null;
154
+ // Collapse repeated slashes, strip trailing slash (unless root).
155
+ const collapsed = trimmed.replace(/\/+/g, "/");
156
+ if (collapsed === "/") return null; // needs a filename
157
+ return collapsed.endsWith("/") ? collapsed.slice(0, -1) : collapsed;
101
158
  }
102
159
 
103
160
  /**
@@ -116,7 +173,7 @@ export async function generateDescription(
116
173
  const client = new Anthropic({ apiKey: config.anthropic_api_key });
117
174
 
118
175
  try {
119
- const content = await buildMessageContent(opts);
176
+ const content = await buildMessageContent(opts, false);
120
177
 
121
178
  const response = await Promise.race([
122
179
  client.messages.create({
@@ -144,3 +201,55 @@ export async function generateDescription(
144
201
  return "";
145
202
  }
146
203
  }
204
+
205
+ /**
206
+ * Generate description + suggested_path in a single LLM call.
207
+ * Returns { description, suggested_path } on success, or null on failure.
208
+ */
209
+ export async function generateDescriptionAndPath(
210
+ config: Required<BotholomewConfig>,
211
+ opts: DescriberOpts,
212
+ ): Promise<{ description: string; suggested_path: string } | null> {
213
+ if (!config.anthropic_api_key) return null;
214
+
215
+ const client = new Anthropic({ apiKey: config.anthropic_api_key });
216
+
217
+ try {
218
+ const content = await buildMessageContent(opts, true);
219
+
220
+ const response = await Promise.race([
221
+ client.messages.create({
222
+ model: config.chunker_model,
223
+ max_tokens: 512,
224
+ tools: [DESCRIBE_AND_PLACE_TOOL],
225
+ tool_choice: { type: "tool", name: DESCRIBE_AND_PLACE_TOOL_NAME },
226
+ messages: [{ role: "user", content }],
227
+ }),
228
+ new Promise<never>((_, reject) =>
229
+ setTimeout(
230
+ () => reject(new Error("Description+path generation timeout")),
231
+ TIMEOUT_MS,
232
+ ),
233
+ ),
234
+ ]);
235
+
236
+ const toolBlock = response.content.find((b) => b.type === "tool_use");
237
+ if (!toolBlock || toolBlock.type !== "tool_use") return null;
238
+
239
+ const input = toolBlock.input as {
240
+ description?: string;
241
+ suggested_path?: string;
242
+ };
243
+ const suggested = input.suggested_path
244
+ ? sanitizeSuggestedPath(input.suggested_path)
245
+ : null;
246
+ if (!suggested) return null;
247
+ return {
248
+ description: input.description || "",
249
+ suggested_path: suggested,
250
+ };
251
+ } catch (err) {
252
+ logger.debug(`Description+path generation failed: ${err}`);
253
+ return null;
254
+ }
255
+ }
@@ -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
  );