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.
@@ -1,6 +1,6 @@
1
1
  import type { McpxClient } from "@evantahler/mcpx";
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 { listSchedules } from "../db/schedules.ts";
5
5
  import {
6
6
  claimNextTask,
@@ -17,7 +17,7 @@ import { processSchedules } from "./schedules.ts";
17
17
 
18
18
  export async function tick(
19
19
  projectDir: string,
20
- conn: DbConnection,
20
+ dbPath: string,
21
21
  config: Required<BotholomewConfig>,
22
22
  mcpxClient?: McpxClient | null,
23
23
  callbacks?: DaemonStreamCallbacks,
@@ -27,9 +27,8 @@ export async function tick(
27
27
  logger.phase("tick-start", `#${tickNum}`);
28
28
 
29
29
  // Reset stale tasks stuck in in_progress
30
- const resetIds = await resetStaleTasks(
31
- conn,
32
- config.max_tick_duration_seconds * 3,
30
+ const resetIds = await withDb(dbPath, (conn) =>
31
+ resetStaleTasks(conn, config.max_tick_duration_seconds * 3),
33
32
  );
34
33
  if (resetIds.length > 0) {
35
34
  logger.warn(
@@ -37,12 +36,16 @@ export async function tick(
37
36
  );
38
37
  }
39
38
 
40
- // Process schedules (may create new tasks)
41
- const enabledSchedules = await listSchedules(conn, { enabled: true });
39
+ // Process schedules (may create new tasks). Check enabled count so we only
40
+ // log the phase when there's work to evaluate — the call itself is a no-op
41
+ // otherwise.
42
+ const enabledSchedules = await withDb(dbPath, (conn) =>
43
+ listSchedules(conn, { enabled: true }),
44
+ );
42
45
  if (enabledSchedules.length > 0) {
43
46
  logger.phase("evaluating-schedules", `${enabledSchedules.length} enabled`);
44
47
  try {
45
- await processSchedules(conn, config);
48
+ await processSchedules(dbPath, config);
46
49
  } catch (err) {
47
50
  logger.error(`Schedule processing failed: ${err}`);
48
51
  }
@@ -50,7 +53,7 @@ export async function tick(
50
53
 
51
54
  // Claim a task
52
55
  logger.phase("claiming-task");
53
- const task = await claimNextTask(conn);
56
+ const task = await withDb(dbPath, (conn) => claimNextTask(conn));
54
57
  if (!task) {
55
58
  logger.info("No task claimed (queue empty or all blocked)");
56
59
  const elapsed = ((Date.now() - tickStart) / 1000).toFixed(1);
@@ -62,67 +65,80 @@ export async function tick(
62
65
  callbacks?.onTaskStart(task);
63
66
 
64
67
  // Create a thread for this tick
65
- const threadId = await createThread(
66
- conn,
67
- "daemon_tick",
68
- task.id,
69
- `Working: ${task.name}`,
68
+ const threadId = await withDb(dbPath, (conn) =>
69
+ createThread(conn, "daemon_tick", task.id, `Working: ${task.name}`),
70
70
  );
71
71
 
72
72
  // Build system prompt (includes task-relevant context from embeddings)
73
- const systemPrompt = await buildSystemPrompt(projectDir, task, conn, config, {
74
- hasMcpTools: mcpxClient != null,
75
- });
73
+ const systemPrompt = await buildSystemPrompt(
74
+ projectDir,
75
+ task,
76
+ dbPath,
77
+ config,
78
+ {
79
+ hasMcpTools: mcpxClient != null,
80
+ },
81
+ );
76
82
 
77
83
  try {
78
84
  const result = await runAgentLoop({
79
85
  systemPrompt,
80
86
  task,
81
87
  config,
82
- conn,
88
+ dbPath,
83
89
  threadId,
84
90
  projectDir,
85
91
  mcpxClient,
86
92
  callbacks,
87
93
  });
88
94
 
89
- // Update task status and store output
90
- await updateTaskStatus(
91
- conn,
92
- task.id,
93
- result.status,
94
- result.reason,
95
- result.reason,
95
+ // Update task status and store output. Only completed tasks have an
96
+ // `output`; waiting/failed tasks put their reason in `waiting_reason`.
97
+ const isComplete = result.status === "complete";
98
+ await withDb(dbPath, (conn) =>
99
+ updateTaskStatus(
100
+ conn,
101
+ task.id,
102
+ result.status,
103
+ isComplete ? null : result.reason,
104
+ isComplete ? result.reason : null,
105
+ ),
96
106
  );
97
107
 
98
108
  // Log the status change
99
- await logInteraction(conn, threadId, {
100
- role: "system",
101
- kind: "status_change",
102
- content: `Task ${task.id} -> ${result.status}${result.reason ? `: ${result.reason}` : ""}`,
103
- });
109
+ await withDb(dbPath, (conn) =>
110
+ logInteraction(conn, threadId, {
111
+ role: "system",
112
+ kind: "status_change",
113
+ content: `Task ${task.id} -> ${result.status}${result.reason ? `: ${result.reason}` : ""}`,
114
+ }),
115
+ );
104
116
 
105
117
  logger.info(`Task ${task.id} -> ${result.status}`);
106
118
 
107
- // Generate a descriptive title for the thread
119
+ // Generate a descriptive title for the thread (fire-and-forget)
108
120
  void generateThreadTitle(
109
121
  config,
110
- conn,
122
+ dbPath,
111
123
  threadId,
112
124
  `Task: ${task.name}\nDescription: ${task.description}\nOutcome: ${result.status}${result.reason ? ` — ${result.reason}` : ""}`,
113
125
  );
114
126
  } catch (err) {
115
- await updateTaskStatus(conn, task.id, "failed", String(err), String(err));
127
+ await withDb(dbPath, (conn) =>
128
+ updateTaskStatus(conn, task.id, "failed", String(err), null),
129
+ );
116
130
 
117
- await logInteraction(conn, threadId, {
118
- role: "system",
119
- kind: "status_change",
120
- content: `Task ${task.id} failed: ${err}`,
121
- });
131
+ await withDb(dbPath, (conn) =>
132
+ logInteraction(conn, threadId, {
133
+ role: "system",
134
+ kind: "status_change",
135
+ content: `Task ${task.id} failed: ${err}`,
136
+ }),
137
+ );
122
138
 
123
139
  logger.error(`Task ${task.id} failed: ${err}`);
124
140
  } finally {
125
- await endThread(conn, threadId);
141
+ await withDb(dbPath, (conn) => endThread(conn, threadId));
126
142
  }
127
143
 
128
144
  const elapsed = ((Date.now() - tickStart) / 1000).toFixed(1);
@@ -11,12 +11,20 @@ export class DbConnection {
11
11
  // biome-ignore lint/suspicious/noExplicitAny: DuckDB internal types
12
12
  private conn: any;
13
13
  // biome-ignore lint/suspicious/noExplicitAny: DuckDB internal types
14
- private instance: any;
14
+ private readonly ownedInstance: any;
15
+ private readonly dbPath: string;
16
+ private closed = false;
15
17
 
16
- // biome-ignore lint/suspicious/noExplicitAny: DuckDB internal types
17
- constructor(conn: any, instance: any) {
18
+ constructor(
19
+ // biome-ignore lint/suspicious/noExplicitAny: DuckDB internal types
20
+ conn: any,
21
+ // biome-ignore lint/suspicious/noExplicitAny: DuckDB internal types
22
+ ownedInstance: any,
23
+ dbPath: string,
24
+ ) {
18
25
  this.conn = conn;
19
- this.instance = instance;
26
+ this.ownedInstance = ownedInstance;
27
+ this.dbPath = dbPath;
20
28
  }
21
29
 
22
30
  /** Execute raw SQL with no return value. */
@@ -62,10 +70,22 @@ export class DbConnection {
62
70
  return { changes: result.rowsChanged };
63
71
  }
64
72
 
65
- /** Close the connection and dispose of the instance. */
73
+ /**
74
+ * Disconnect and release this connection's share of the DuckDB instance.
75
+ * For file-backed DBs, the instance is closed (and the OS file lock
76
+ * released) once every overlapping connection in this process has closed.
77
+ * For `:memory:` DBs, the instance is owned by this connection and closed
78
+ * immediately.
79
+ */
66
80
  close(): void {
81
+ if (this.closed) return;
82
+ this.closed = true;
67
83
  this.conn.disconnectSync();
68
- this.instance.closeSync();
84
+ if (this.ownedInstance) {
85
+ this.ownedInstance.closeSync();
86
+ } else {
87
+ releaseInstance(this.dbPath);
88
+ }
69
89
  }
70
90
  }
71
91
 
@@ -98,31 +118,140 @@ function flattenParams(params: SqlParam[]): SqlParam[] {
98
118
  return params.map((p) => (Array.isArray(p) ? JSON.stringify(p) : p));
99
119
  }
100
120
 
121
+ /**
122
+ * Refcounted, process-local cache of open DuckDB instances keyed by dbPath.
123
+ *
124
+ * DuckDB's file lock is held at the instance level, so we must close the
125
+ * instance — not just the connection — to let another process acquire the
126
+ * writer lock. At the same time, opening two instances for the same file
127
+ * from one process is unsafe. This cache resolves both: overlapping
128
+ * `getConnection` calls in the same process share a single instance; once
129
+ * every connection has closed, the instance is closed and evicted, which
130
+ * releases the OS file lock.
131
+ *
132
+ * `:memory:` paths bypass the cache so each test/caller gets its own
133
+ * isolated in-memory database.
134
+ */
135
+ interface CachedInstance {
136
+ // biome-ignore lint/suspicious/noExplicitAny: DuckDB internal types
137
+ instance: any;
138
+ refCount: number;
139
+ }
140
+ const instanceCache = new Map<string, CachedInstance>();
141
+ const pendingInstance = new Map<string, Promise<CachedInstance>>();
142
+
143
+ function isMemoryPath(path: string): boolean {
144
+ return path === ":memory:" || path.startsWith(":memory:");
145
+ }
146
+
147
+ async function acquireSharedInstance(dbPath: string): Promise<CachedInstance> {
148
+ const existing = instanceCache.get(dbPath);
149
+ if (existing) {
150
+ existing.refCount += 1;
151
+ return existing;
152
+ }
153
+ const inFlight = pendingInstance.get(dbPath);
154
+ if (inFlight) {
155
+ const cached = await inFlight;
156
+ cached.refCount += 1;
157
+ return cached;
158
+ }
159
+ const creation = (async () => {
160
+ const instance = await DuckDBInstance.create(dbPath);
161
+ const cached: CachedInstance = { instance, refCount: 1 };
162
+ instanceCache.set(dbPath, cached);
163
+ return cached;
164
+ })();
165
+ pendingInstance.set(dbPath, creation);
166
+ try {
167
+ return await creation;
168
+ } finally {
169
+ pendingInstance.delete(dbPath);
170
+ }
171
+ }
172
+
173
+ function releaseInstance(dbPath: string): void {
174
+ const cached = instanceCache.get(dbPath);
175
+ if (!cached) return;
176
+ cached.refCount -= 1;
177
+ if (cached.refCount <= 0) {
178
+ instanceCache.delete(dbPath);
179
+ cached.instance.closeSync();
180
+ }
181
+ }
182
+
101
183
  export async function getConnection(dbPath?: string): Promise<DbConnection> {
102
- const instance = await DuckDBInstance.create(dbPath ?? ":memory:");
103
- const conn = await instance.connect();
184
+ const path = dbPath ?? ":memory:";
104
185
 
105
- // Load VSS extension for vector similarity search
106
- await conn.run("INSTALL vss; LOAD vss;");
107
- // Enable HNSW index persistence for file-backed databases
108
- await conn.run("SET hnsw_enable_experimental_persistence = true;");
186
+ if (isMemoryPath(path)) {
187
+ const instance = await DuckDBInstance.create(path);
188
+ const conn = await instance.connect();
189
+ await conn.run("INSTALL vss; LOAD vss;");
190
+ await conn.run("SET hnsw_enable_experimental_persistence = true;");
191
+ return new DbConnection(conn, instance, path);
192
+ }
109
193
 
110
- return new DbConnection(conn, instance);
194
+ const cached = await acquireSharedInstance(path);
195
+ try {
196
+ const conn = await cached.instance.connect();
197
+ // INSTALL is a no-op after the first successful install (the extension
198
+ // is persisted to the user's DuckDB extension directory). LOAD is
199
+ // cheap per connection.
200
+ await conn.run("INSTALL vss; LOAD vss;");
201
+ await conn.run("SET hnsw_enable_experimental_persistence = true;");
202
+ return new DbConnection(conn, null, path);
203
+ } catch (err) {
204
+ releaseInstance(path);
205
+ throw err;
206
+ }
111
207
  }
112
208
 
209
+ /**
210
+ * Open a DuckDB connection for a single logical unit of work and guarantee
211
+ * it is closed afterward. Retries on lock conflicts so two processes that
212
+ * race on the file lock cooperate instead of failing hard.
213
+ *
214
+ * Prefer one `withDb` per logical operation. The file lock is only released
215
+ * when every connection (across this process's overlapping callers) has
216
+ * been closed, so holding the connection across non-DB work (LLM calls,
217
+ * network I/O, filesystem walks) keeps other processes blocked.
218
+ */
219
+ export async function withDb<T>(
220
+ dbPath: string,
221
+ fn: (conn: DbConnection) => Promise<T>,
222
+ ): Promise<T> {
223
+ const conn = await withRetry(() => getConnection(dbPath));
224
+ try {
225
+ return await fn(conn);
226
+ } finally {
227
+ conn.close();
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Retry `fn` with exponential backoff when it fails with a DuckDB file-lock
233
+ * conflict ("Conflicting lock is held…"). Other errors propagate immediately.
234
+ */
113
235
  export async function withRetry<T>(
114
236
  fn: () => Promise<T>,
115
- maxRetries = 5,
237
+ maxRetries = 8,
116
238
  ): Promise<T> {
117
239
  let lastError: unknown;
118
240
  for (let attempt = 0; attempt < maxRetries; attempt++) {
119
241
  try {
120
242
  return await fn();
121
243
  } catch (err) {
244
+ if (!isLockConflict(err)) throw err;
122
245
  lastError = err;
123
246
  if (attempt === maxRetries - 1) throw err;
247
+ // 100, 200, 400, 800, 1600, 3200, 6400, 12800 — up to ~25s total
124
248
  await Bun.sleep(100 * 2 ** attempt);
125
249
  }
126
250
  }
127
251
  throw lastError;
128
252
  }
253
+
254
+ function isLockConflict(err: unknown): boolean {
255
+ const msg = err instanceof Error ? err.message : String(err);
256
+ return msg.includes("Conflicting lock") || msg.includes("could not be set");
257
+ }
package/src/db/context.ts CHANGED
@@ -56,6 +56,17 @@ function rowToContextItem(row: ContextItemRow): ContextItem {
56
56
  };
57
57
  }
58
58
 
59
+ export class PathConflictError extends Error {
60
+ existingId: string;
61
+ contextPath: string;
62
+ constructor(existingId: string, contextPath: string) {
63
+ super(`context_path already exists: ${contextPath}`);
64
+ this.name = "PathConflictError";
65
+ this.existingId = existingId;
66
+ this.contextPath = contextPath;
67
+ }
68
+ }
69
+
59
70
  // --- Basic CRUD ---
60
71
 
61
72
  export async function createContextItem(
@@ -124,6 +135,28 @@ export async function upsertContextItem(
124
135
  return createContextItem(db, params);
125
136
  }
126
137
 
138
+ /**
139
+ * Strict creator: throws PathConflictError if context_path already exists.
140
+ * Use when callers want to surface collisions instead of silently overwriting.
141
+ */
142
+ export async function createContextItemStrict(
143
+ db: DbConnection,
144
+ params: {
145
+ title: string;
146
+ content?: string;
147
+ mimeType?: string;
148
+ sourceType?: "file" | "url";
149
+ sourcePath?: string;
150
+ contextPath: string;
151
+ description?: string;
152
+ isTextual?: boolean;
153
+ },
154
+ ): Promise<ContextItem> {
155
+ const existing = await getContextItemByPath(db, params.contextPath);
156
+ if (existing) throw new PathConflictError(existing.id, params.contextPath);
157
+ return createContextItem(db, params);
158
+ }
159
+
127
160
  export async function getContextItem(
128
161
  db: DbConnection,
129
162
  id: string,
@@ -1,5 +1,5 @@
1
1
  import type { DbConnection } from "./connection.ts";
2
- import { buildSetClauses, buildWhereClause } from "./query.ts";
2
+ import { buildSetClauses, buildWhereClause, sanitizeInt } from "./query.ts";
3
3
  import { uuidv7 } from "./uuid.ts";
4
4
 
5
5
  export interface Schedule {
@@ -72,7 +72,7 @@ export async function getSchedule(
72
72
 
73
73
  export async function listSchedules(
74
74
  db: DbConnection,
75
- filters?: { enabled?: boolean },
75
+ filters?: { enabled?: boolean; limit?: number; offset?: number },
76
76
  ): Promise<Schedule[]> {
77
77
  const { where, params } = buildWhereClause([
78
78
  [
@@ -80,9 +80,13 @@ export async function listSchedules(
80
80
  filters?.enabled !== undefined ? (filters.enabled ? 1 : 0) : undefined,
81
81
  ],
82
82
  ]);
83
+ const limit = filters?.limit ? `LIMIT ${sanitizeInt(filters.limit)}` : "";
84
+ const offset = filters?.offset ? `OFFSET ${sanitizeInt(filters.offset)}` : "";
83
85
 
84
86
  const rows = await db.queryAll<ScheduleRow>(
85
- `SELECT * FROM schedules ${where} ORDER BY created_at ASC`,
87
+ `SELECT * FROM schedules ${where}
88
+ ORDER BY created_at ASC, id ASC
89
+ ${limit} ${offset}`,
86
90
  ...params,
87
91
  );
88
92
  return rows.map(rowToSchedule);
package/src/db/tasks.ts CHANGED
@@ -109,6 +109,7 @@ export async function listTasks(
109
109
  status?: Task["status"];
110
110
  priority?: Task["priority"];
111
111
  limit?: number;
112
+ offset?: number;
112
113
  },
113
114
  ): Promise<Task[]> {
114
115
  const { where, params } = buildWhereClause([
@@ -116,13 +117,12 @@ export async function listTasks(
116
117
  ["priority", filters?.priority],
117
118
  ]);
118
119
  const limit = filters?.limit ? `LIMIT ${sanitizeInt(filters.limit)}` : "";
120
+ const offset = filters?.offset ? `OFFSET ${sanitizeInt(filters.offset)}` : "";
119
121
 
120
122
  const rows = await db.queryAll<TaskRow>(
121
123
  `SELECT * FROM tasks ${where}
122
- ORDER BY
123
- CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 END,
124
- created_at ASC
125
- ${limit}`,
124
+ ORDER BY created_at DESC, id DESC
125
+ ${limit} ${offset}`,
126
126
  ...params,
127
127
  );
128
128
  return rows.map(rowToTask);
@@ -132,8 +132,8 @@ export async function updateTaskStatus(
132
132
  db: DbConnection,
133
133
  id: string,
134
134
  status: Task["status"],
135
- reason?: string,
136
- output?: string,
135
+ reason?: string | null,
136
+ output?: string | null,
137
137
  ): Promise<void> {
138
138
  await db.queryRun(
139
139
  `UPDATE tasks
package/src/db/threads.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { DbConnection } from "./connection.ts";
2
- import { buildWhereClause } from "./query.ts";
2
+ import { buildWhereClause, sanitizeInt } from "./query.ts";
3
3
  import { uuidv7 } from "./uuid.ts";
4
4
 
5
5
  export interface Thread {
@@ -256,18 +256,20 @@ export async function listThreads(
256
256
  type?: Thread["type"];
257
257
  taskId?: string;
258
258
  limit?: number;
259
+ offset?: number;
259
260
  },
260
261
  ): Promise<Thread[]> {
261
262
  const { where, params } = buildWhereClause([
262
263
  ["type", filters?.type],
263
264
  ["task_id", filters?.taskId],
264
265
  ]);
265
- const limit = filters?.limit ? `LIMIT ${filters.limit}` : "";
266
+ const limit = filters?.limit ? `LIMIT ${sanitizeInt(filters.limit)}` : "";
267
+ const offset = filters?.offset ? `OFFSET ${sanitizeInt(filters.offset)}` : "";
266
268
 
267
269
  const rows = await db.queryAll<ThreadRow>(
268
270
  `SELECT * FROM threads ${where}
269
- ORDER BY started_at DESC
270
- ${limit}`,
271
+ ORDER BY started_at DESC, id DESC
272
+ ${limit} ${offset}`,
271
273
  ...params,
272
274
  );
273
275
  return rows.map(rowToThread);
@@ -1,7 +1,11 @@
1
1
  import { isText } from "istextorbinary";
2
2
  import { z } from "zod";
3
3
  import { ingestByPath } from "../../context/ingest.ts";
4
- import { upsertContextItem } from "../../db/context.ts";
4
+ import {
5
+ createContextItemStrict,
6
+ PathConflictError,
7
+ upsertContextItem,
8
+ } from "../../db/context.ts";
5
9
  import type { ToolDefinition } from "../tool.ts";
6
10
 
7
11
  function mimeFromPath(path: string): string {
@@ -30,18 +34,27 @@ const inputSchema = z.object({
30
34
  .optional()
31
35
  .describe("Title for the file (defaults to filename)"),
32
36
  description: z.string().optional().describe("Description of the file"),
37
+ on_conflict: z
38
+ .enum(["error", "overwrite"])
39
+ .optional()
40
+ .describe(
41
+ "What to do if a file already exists at this path. Defaults to 'error'. Pass 'overwrite' to replace.",
42
+ ),
33
43
  });
34
44
 
35
45
  const outputSchema = z.object({
36
- id: z.string(),
46
+ id: z.string().nullable(),
37
47
  path: z.string(),
38
48
  is_error: z.boolean(),
49
+ error_type: z.string().optional(),
50
+ message: z.string().optional(),
51
+ next_action_hint: z.string().optional(),
39
52
  });
40
53
 
41
54
  export const contextWriteTool = {
42
55
  name: "context_write",
43
56
  description:
44
- "Write content to a context item. Creates the item if it doesn't exist, or overwrites if it does.",
57
+ "Write content to a context item. By default, fails if the path already exists pass on_conflict='overwrite' to replace.",
45
58
  group: "context",
46
59
  inputSchema,
47
60
  outputSchema,
@@ -50,17 +63,43 @@ export const contextWriteTool = {
50
63
  const isTextual = isTextualPath(input.path);
51
64
  const title =
52
65
  input.title ?? input.path.split("/").filter(Boolean).pop() ?? input.path;
66
+ const onConflict = input.on_conflict ?? "error";
53
67
 
54
- const item = await upsertContextItem(ctx.conn, {
55
- title,
56
- description: input.description,
57
- content: input.content_base64 ?? input.content,
58
- contextPath: input.path,
59
- mimeType,
60
- isTextual,
61
- });
68
+ try {
69
+ const item =
70
+ onConflict === "overwrite"
71
+ ? await upsertContextItem(ctx.conn, {
72
+ title,
73
+ description: input.description,
74
+ content: input.content_base64 ?? input.content,
75
+ contextPath: input.path,
76
+ mimeType,
77
+ isTextual,
78
+ })
79
+ : await createContextItemStrict(ctx.conn, {
80
+ title,
81
+ description: input.description,
82
+ content: input.content_base64 ?? input.content,
83
+ contextPath: input.path,
84
+ mimeType,
85
+ isTextual,
86
+ });
62
87
 
63
- await ingestByPath(ctx.conn, input.path, ctx.config);
64
- return { id: item.id, path: item.context_path, is_error: false };
88
+ await ingestByPath(ctx.conn, input.path, ctx.config);
89
+ return { id: item.id, path: item.context_path, is_error: false };
90
+ } catch (err) {
91
+ if (err instanceof PathConflictError) {
92
+ return {
93
+ id: null,
94
+ path: input.path,
95
+ is_error: true,
96
+ error_type: "path_conflict",
97
+ message: `A file already exists at ${input.path} (id: ${err.existingId}).`,
98
+ next_action_hint:
99
+ "Call context_read to inspect the existing file, or retry with on_conflict='overwrite' to replace it.",
100
+ };
101
+ }
102
+ throw err;
103
+ }
65
104
  },
66
105
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
package/src/tools/tool.ts CHANGED
@@ -5,7 +5,15 @@ import type { BotholomewConfig } from "../config/schemas.ts";
5
5
  import type { DbConnection } from "../db/connection.ts";
6
6
 
7
7
  export interface ToolContext {
8
+ /**
9
+ * Short-lived DB connection scoped to this tool call. Safe for single-query
10
+ * tools. Do NOT hold it across slow work (network, embedding, long loops) —
11
+ * the instance-level file lock stays held until every connection closes.
12
+ * For long-running tools, use `dbPath` with `withDb` per logical operation.
13
+ */
8
14
  conn: DbConnection;
15
+ /** Path to the DuckDB file. Use with `withDb` for long-running tools. */
16
+ dbPath: string;
9
17
  projectDir: string;
10
18
  config: Required<BotholomewConfig>;
11
19
  mcpxClient: McpxClient | null;