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.
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/chat/agent.ts +68 -35
- package/src/chat/session.ts +27 -31
- package/src/commands/context.ts +168 -12
- package/src/commands/daemon.ts +15 -2
- package/src/commands/schedule.ts +10 -1
- package/src/commands/skill.ts +18 -3
- package/src/commands/task.ts +28 -4
- package/src/commands/thread.ts +3 -1
- package/src/commands/tools.ts +2 -0
- package/src/commands/with-db.ts +8 -9
- package/src/context/fetcher.ts +1 -0
- package/src/context/ingest.ts +3 -1
- package/src/daemon/index.ts +6 -5
- package/src/daemon/llm.ts +68 -42
- package/src/daemon/prompt.ts +6 -4
- package/src/daemon/schedules.ts +15 -10
- package/src/daemon/tick.ts +54 -38
- package/src/db/connection.ts +143 -14
- package/src/db/context.ts +13 -0
- package/src/db/schedules.ts +7 -3
- package/src/db/tasks.ts +4 -4
- package/src/db/threads.ts +6 -4
- package/src/tools/tool.ts +8 -0
- package/src/tui/App.tsx +16 -11
- package/src/tui/components/ContextPanel.tsx +19 -15
- package/src/tui/components/SchedulePanel.tsx +15 -9
- package/src/tui/components/StatusBar.tsx +8 -6
- package/src/tui/components/TaskPanel.tsx +6 -6
- package/src/tui/components/ThreadPanel.tsx +29 -19
- package/src/utils/title.ts +5 -3
package/src/daemon/tick.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { McpxClient } from "@evantahler/mcpx";
|
|
2
2
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
3
|
-
import
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
|
127
|
+
await withDb(dbPath, (conn) =>
|
|
128
|
+
updateTaskStatus(conn, task.id, "failed", String(err), null),
|
|
129
|
+
);
|
|
116
130
|
|
|
117
|
-
await
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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);
|
package/src/db/connection.ts
CHANGED
|
@@ -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
|
|
14
|
+
private readonly ownedInstance: any;
|
|
15
|
+
private readonly dbPath: string;
|
|
16
|
+
private closed = false;
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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.
|
|
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
|
|
103
|
-
const conn = await instance.connect();
|
|
184
|
+
const path = dbPath ?? ":memory:";
|
|
104
185
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
*/
|
package/src/db/schedules.ts
CHANGED
|
@@ -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}
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
555
|
+
const sessionDbPath = sessionRef.current?.dbPath;
|
|
551
556
|
const inputBarHeader = useMemo(
|
|
552
557
|
() =>
|
|
553
|
-
|
|
558
|
+
sessionDbPath ? (
|
|
554
559
|
<StatusBar
|
|
555
560
|
projectDir={projectDir}
|
|
556
|
-
|
|
561
|
+
dbPath={sessionDbPath}
|
|
557
562
|
chatTitle={chatTitle}
|
|
558
563
|
onDaemonStatusChange={setDaemonRunning}
|
|
559
564
|
/>
|
|
560
565
|
) : null,
|
|
561
|
-
[projectDir,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
68
|
-
|
|
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
|
-
[
|
|
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
|
|
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
|
-
[
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
}
|