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.
@@ -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
 
95
+ // Update task status and store output. Only completed tasks have an
96
+ // `output`; waiting/failed tasks put their reason in `waiting_reason`.
89
97
  const isComplete = result.status === "complete";
90
- await updateTaskStatus(
91
- conn,
92
- task.id,
93
- result.status,
94
- isComplete ? null : result.reason,
95
- isComplete ? result.reason : null,
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), null);
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
@@ -179,6 +179,19 @@ export async function getContextItemByPath(
179
179
  return row ? rowToContextItem(row) : null;
180
180
  }
181
181
 
182
+ export async function getContextItemBySourcePath(
183
+ db: DbConnection,
184
+ sourcePath: string,
185
+ sourceType: "file" | "url",
186
+ ): Promise<ContextItem | null> {
187
+ const row = await db.queryGet<ContextItemRow>(
188
+ "SELECT * FROM context_items WHERE source_path = ?1 AND source_type = ?2 LIMIT 1",
189
+ sourcePath,
190
+ sourceType,
191
+ );
192
+ return row ? rowToContextItem(row) : null;
193
+ }
194
+
182
195
  /**
183
196
  * Look up a context item by UUID (if the value looks like one) or by context_path.
184
197
  */
@@ -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);
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);
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;
package/src/tui/App.tsx CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  startChatSession,
8
8
  } from "../chat/session.ts";
9
9
  import { MAX_INLINE_CHARS, PAGE_SIZE_CHARS } from "../daemon/large-results.ts";
10
+ import { withDb } from "../db/connection.ts";
10
11
  import type { Interaction } from "../db/threads.ts";
11
12
  import { getThread } from "../db/threads.ts";
12
13
  import {
@@ -163,7 +164,9 @@ export function App({
163
164
  sessionRef.current = session;
164
165
 
165
166
  if (session.messages.length > 0) {
166
- const threadData = await getThread(session.conn, session.threadId);
167
+ const threadData = await withDb(session.dbPath, (conn) =>
168
+ getThread(conn, session.threadId),
169
+ );
167
170
  if (threadData) {
168
171
  setMessages(
169
172
  restoreMessagesFromInteractions(threadData.interactions),
@@ -409,7 +412,9 @@ export function App({
409
412
  const refreshTitle = async () => {
410
413
  const session = sessionRef.current;
411
414
  if (!session) return;
412
- const result = await getThread(session.conn, session.threadId);
415
+ const result = await withDb(session.dbPath, (conn) =>
416
+ getThread(conn, session.threadId),
417
+ );
413
418
  if (mounted && result?.thread.title) {
414
419
  setChatTitle(result.thread.title);
415
420
  }
@@ -547,18 +552,18 @@ export function App({
547
552
  [exit, processQueue, syncQueue],
548
553
  );
549
554
 
550
- const sessionConn = sessionRef.current?.conn;
555
+ const sessionDbPath = sessionRef.current?.dbPath;
551
556
  const inputBarHeader = useMemo(
552
557
  () =>
553
- sessionConn ? (
558
+ sessionDbPath ? (
554
559
  <StatusBar
555
560
  projectDir={projectDir}
556
- conn={sessionConn}
561
+ dbPath={sessionDbPath}
557
562
  chatTitle={chatTitle}
558
563
  onDaemonStatusChange={setDaemonRunning}
559
564
  />
560
565
  ) : null,
561
- [projectDir, sessionConn, chatTitle],
566
+ [projectDir, sessionDbPath, chatTitle],
562
567
  );
563
568
 
564
569
  const sessionSkills = ready ? sessionRef.current?.skills : undefined;
@@ -602,7 +607,7 @@ export function App({
602
607
  );
603
608
  }
604
609
 
605
- const conn = sessionRef.current.conn;
610
+ const dbPath = sessionRef.current.dbPath;
606
611
  const threadId = sessionRef.current.threadId;
607
612
 
608
613
  return (
@@ -642,14 +647,14 @@ export function App({
642
647
  flexDirection="column"
643
648
  flexGrow={1}
644
649
  >
645
- <ContextPanel conn={conn} isActive={activeTab === 3} />
650
+ <ContextPanel dbPath={dbPath} isActive={activeTab === 3} />
646
651
  </Box>
647
652
  <Box
648
653
  display={activeTab === 4 ? "flex" : "none"}
649
654
  flexDirection="column"
650
655
  flexGrow={1}
651
656
  >
652
- <TaskPanel conn={conn} isActive={activeTab === 4} />
657
+ <TaskPanel dbPath={dbPath} isActive={activeTab === 4} />
653
658
  </Box>
654
659
  <Box
655
660
  display={activeTab === 5 ? "flex" : "none"}
@@ -657,7 +662,7 @@ export function App({
657
662
  flexGrow={1}
658
663
  >
659
664
  <ThreadPanel
660
- conn={conn}
665
+ dbPath={dbPath}
661
666
  activeThreadId={threadId}
662
667
  isActive={activeTab === 5}
663
668
  />
@@ -667,7 +672,7 @@ export function App({
667
672
  flexDirection="column"
668
673
  flexGrow={1}
669
674
  >
670
- <SchedulePanel conn={conn} isActive={activeTab === 6} />
675
+ <SchedulePanel dbPath={dbPath} isActive={activeTab === 6} />
671
676
  </Box>
672
677
  <Box
673
678
  display={activeTab === 7 ? "flex" : "none"}
@@ -1,6 +1,6 @@
1
1
  import { Box, Text, useInput, useStdout } from "ink";
2
2
  import { memo, useCallback, useEffect, useMemo, useState } from "react";
3
- import type { DbConnection } from "../../db/connection.ts";
3
+ import { withDb } from "../../db/connection.ts";
4
4
  import {
5
5
  type ContextItem,
6
6
  deleteContextItem,
@@ -11,7 +11,7 @@ import {
11
11
  } from "../../db/context.ts";
12
12
 
13
13
  interface ContextPanelProps {
14
- conn: DbConnection;
14
+ dbPath: string;
15
15
  isActive: boolean;
16
16
  }
17
17
 
@@ -32,7 +32,7 @@ type Entry = DirEntry | FileEntry;
32
32
  const CHROME_LINES = 8;
33
33
 
34
34
  export const ContextPanel = memo(function ContextPanel({
35
- conn,
35
+ dbPath,
36
36
  isActive,
37
37
  }: ContextPanelProps) {
38
38
  const { stdout } = useStdout();
@@ -64,10 +64,10 @@ export const ContextPanel = memo(function ContextPanel({
64
64
 
65
65
  const loadEntries = useCallback(
66
66
  async (path: string) => {
67
- const dirs = await getDistinctDirectories(conn, path);
68
- const files = await listContextItemsByPrefix(conn, path, {
69
- recursive: false,
70
- });
67
+ const [dirs, files] = await withDb(dbPath, async (conn) => [
68
+ await getDistinctDirectories(conn, path),
69
+ await listContextItemsByPrefix(conn, path, { recursive: false }),
70
+ ]);
71
71
 
72
72
  const dirEntries: DirEntry[] = dirs.map((d) => ({
73
73
  type: "directory",
@@ -84,7 +84,7 @@ export const ContextPanel = memo(function ContextPanel({
84
84
  setScrollOffset(0);
85
85
  setPreview(null);
86
86
  },
87
- [conn],
87
+ [dbPath],
88
88
  );
89
89
 
90
90
  useEffect(() => {
@@ -99,13 +99,15 @@ export const ContextPanel = memo(function ContextPanel({
99
99
  setSearchResults(null);
100
100
  return;
101
101
  }
102
- const results = await searchContextByKeyword(conn, query.trim(), 50);
102
+ const results = await withDb(dbPath, (conn) =>
103
+ searchContextByKeyword(conn, query.trim(), 50),
104
+ );
103
105
  setSearchResults(results);
104
106
  setCursor(0);
105
107
  setScrollOffset(0);
106
108
  setPreview(null);
107
109
  },
108
- [conn],
110
+ [dbPath],
109
111
  );
110
112
 
111
113
  // Compute the items list and visible window for the current view
@@ -171,11 +173,13 @@ export const ContextPanel = memo(function ContextPanel({
171
173
  if (input === "y" || input === "d") {
172
174
  const entry = entries[cursor];
173
175
  if (entry) {
174
- if (entry.type === "directory") {
175
- deleteContextItemsByPrefix(conn, entry.path);
176
- } else {
177
- deleteContextItem(conn, entry.item.id);
178
- }
176
+ void withDb(dbPath, async (conn) => {
177
+ if (entry.type === "directory") {
178
+ await deleteContextItemsByPrefix(conn, entry.path);
179
+ } else {
180
+ await deleteContextItem(conn, entry.item.id);
181
+ }
182
+ });
179
183
  setConfirmDelete(false);
180
184
  loadEntries(currentPath);
181
185
  }