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.
- 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 +334 -32
- 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/describer.ts +111 -2
- 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 +55 -39
- package/src/db/connection.ts +143 -14
- package/src/db/context.ts +33 -0
- package/src/db/schedules.ts +7 -3
- package/src/db/tasks.ts +6 -6
- package/src/db/threads.ts +6 -4
- package/src/tools/file/write.ts +52 -13
- 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
|
|
|
89
|
-
// Update task status and store output
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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
|
@@ -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,
|
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);
|
|
@@ -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);
|
package/src/tools/file/write.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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;
|