botholomew 0.12.3 → 0.13.0
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 +91 -68
- package/package.json +3 -3
- package/src/chat/agent.ts +42 -82
- package/src/chat/session.ts +29 -25
- package/src/commands/capabilities.ts +1 -1
- package/src/commands/context.ts +177 -926
- package/src/commands/db.ts +9 -13
- package/src/commands/init.ts +4 -1
- package/src/commands/nuke.ts +57 -90
- package/src/commands/schedule.ts +103 -124
- package/src/commands/skill.ts +2 -2
- package/src/commands/task.ts +86 -95
- package/src/commands/thread.ts +107 -112
- package/src/commands/worker.ts +88 -88
- package/src/constants.ts +93 -16
- package/src/context/capabilities.ts +10 -10
- package/src/context/fetcher.ts +9 -10
- package/src/context/reindex.ts +189 -0
- package/src/context/store.ts +630 -0
- package/src/db/doctor.ts +1 -8
- package/src/db/embeddings.ts +227 -175
- package/src/db/sql/19-disk_backed_index.sql +36 -0
- package/src/db/sql/20-drop_db_tables_for_files.sql +19 -0
- package/src/fs/atomic.ts +217 -0
- package/src/fs/compat.ts +86 -0
- package/src/fs/sandbox.ts +279 -0
- package/src/init/index.ts +69 -52
- package/src/init/templates.ts +1 -1
- package/src/mcpx/client.ts +1 -1
- package/src/schedules/schema.ts +19 -0
- package/src/schedules/store.ts +296 -0
- package/src/skills/commands.ts +1 -3
- package/src/tasks/schema.ts +47 -0
- package/src/tasks/store.ts +486 -0
- package/src/threads/store.ts +559 -0
- package/src/tools/capabilities/refresh.ts +42 -21
- package/src/tools/context/pipe.ts +15 -71
- package/src/tools/context/update-beliefs.ts +3 -3
- package/src/tools/context/update-goals.ts +3 -3
- package/src/tools/dir/create.ts +26 -23
- package/src/tools/dir/size.ts +46 -17
- package/src/tools/dir/tree.ts +73 -279
- package/src/tools/file/copy.ts +50 -24
- package/src/tools/file/count-lines.ts +34 -10
- package/src/tools/file/delete.ts +44 -23
- package/src/tools/file/edit.ts +39 -14
- package/src/tools/file/exists.ts +12 -26
- package/src/tools/file/info.ts +25 -85
- package/src/tools/file/move.ts +39 -24
- package/src/tools/file/read.ts +32 -80
- package/src/tools/file/write.ts +14 -91
- package/src/tools/registry.ts +3 -7
- package/src/tools/schedule/create.ts +2 -2
- package/src/tools/schedule/list.ts +7 -3
- package/src/tools/search/fuse.ts +12 -33
- package/src/tools/search/index.ts +36 -43
- package/src/tools/search/regexp.ts +29 -17
- package/src/tools/search/semantic.ts +137 -51
- package/src/tools/skill/delete.ts +1 -1
- package/src/tools/skill/list.ts +1 -1
- package/src/tools/skill/write.ts +1 -1
- package/src/tools/task/create.ts +41 -16
- package/src/tools/task/delete.ts +3 -3
- package/src/tools/task/list.ts +6 -3
- package/src/tools/task/update.ts +31 -9
- package/src/tools/task/view.ts +6 -6
- package/src/tools/thread/list.ts +2 -2
- package/src/tools/thread/search.ts +208 -0
- package/src/tools/thread/view.ts +50 -5
- package/src/tools/worker/spawn.ts +28 -14
- package/src/tui/App.tsx +12 -19
- package/src/tui/components/ContextPanel.tsx +83 -316
- package/src/tui/components/SchedulePanel.tsx +34 -48
- package/src/tui/components/StatusBar.tsx +15 -15
- package/src/tui/components/TaskPanel.tsx +34 -38
- package/src/tui/components/ThreadPanel.tsx +29 -38
- package/src/tui/components/WorkerPanel.tsx +21 -19
- package/src/tui/markdown.ts +2 -8
- package/src/types/file-imports.d.ts +9 -0
- package/src/utils/title.ts +5 -7
- package/src/utils/v7-date.ts +47 -0
- package/src/worker/heartbeat.ts +46 -24
- package/src/worker/index.ts +13 -15
- package/src/worker/llm.ts +30 -37
- package/src/worker/prompt.ts +19 -41
- package/src/worker/schedules.ts +48 -69
- package/src/worker/spawn.ts +11 -11
- package/src/worker/tick.ts +39 -43
- package/src/workers/store.ts +247 -0
- package/src/commands/tools.ts +0 -367
- package/src/context/describer.ts +0 -140
- package/src/context/drives.ts +0 -110
- package/src/context/ingest.ts +0 -162
- package/src/context/refresh.ts +0 -183
- package/src/db/context.ts +0 -637
- package/src/db/daemon-state.ts +0 -6
- package/src/db/reembed.ts +0 -113
- package/src/db/schedules.ts +0 -213
- package/src/db/tasks.ts +0 -347
- package/src/db/threads.ts +0 -276
- package/src/db/workers.ts +0 -212
- package/src/tools/context/list-drives.ts +0 -36
- package/src/tools/context/refresh.ts +0 -165
- package/src/tools/context/search.ts +0 -54
package/src/utils/title.ts
CHANGED
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
2
|
-
import {
|
|
3
|
-
import { updateThreadTitle } from "../db/threads.ts";
|
|
2
|
+
import { updateThreadTitle } from "../threads/store.ts";
|
|
4
3
|
import { createLlmClient } from "../worker/llm-client.ts";
|
|
5
4
|
import { logger } from "./logger.ts";
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* Generate a short title for a thread using the chunker model (Haiku).
|
|
9
|
-
* Fire-and-forget — errors are logged
|
|
10
|
-
*
|
|
11
|
-
* safely `void`-chain without holding a connection during the LLM call.
|
|
8
|
+
* Fire-and-forget — errors are logged and never propagated. Writes the
|
|
9
|
+
* title back to the thread's CSV file by rewriting the thread_meta row.
|
|
12
10
|
*/
|
|
13
11
|
export async function generateThreadTitle(
|
|
14
12
|
config: Required<BotholomewConfig>,
|
|
15
|
-
|
|
13
|
+
projectDir: string,
|
|
16
14
|
threadId: string,
|
|
17
15
|
context: string,
|
|
18
16
|
): Promise<void> {
|
|
@@ -39,7 +37,7 @@ export async function generateThreadTitle(
|
|
|
39
37
|
.trim();
|
|
40
38
|
|
|
41
39
|
if (title) {
|
|
42
|
-
await
|
|
40
|
+
await updateThreadTitle(projectDir, threadId, title);
|
|
43
41
|
}
|
|
44
42
|
} catch (err) {
|
|
45
43
|
logger.warn(`Failed to generate thread title: ${err}`);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for deriving a UTC `YYYY-MM-DD` date string from a uuidv7 id.
|
|
3
|
+
* Used by the threads store and the worker-log spawn path so file layouts
|
|
4
|
+
* grouped by creation date can be computed without scanning the disk.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Format a Date as `YYYY-MM-DD` in UTC. UTC (not local time) keeps file
|
|
9
|
+
* paths stable across machines and timezone moves: a thread created at
|
|
10
|
+
* 11pm PT and read the next morning from a different zone resolves to
|
|
11
|
+
* the same folder either way.
|
|
12
|
+
*/
|
|
13
|
+
export function utcDateString(d: Date): string {
|
|
14
|
+
const y = d.getUTCFullYear();
|
|
15
|
+
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
16
|
+
const day = String(d.getUTCDate()).padStart(2, "0");
|
|
17
|
+
return `${y}-${m}-${day}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Recover the creation timestamp from a uuidv7. The first 48 bits of v7
|
|
22
|
+
* are unix-millis. Returns null for non-v7 ids (or any parse failure) so
|
|
23
|
+
* the caller can fall back — typically to a directory walk for reads, or
|
|
24
|
+
* to "today" for writes.
|
|
25
|
+
*/
|
|
26
|
+
export function dateFromUuidV7(id: string): string | null {
|
|
27
|
+
// shape: xxxxxxxx-xxxx-7xxx-yxxx-xxxxxxxxxxxx — the version nibble is
|
|
28
|
+
// the 13th hex char (position 14 with the dash).
|
|
29
|
+
if (id.length < 19 || id[14] !== "7") return null;
|
|
30
|
+
const hex = id.slice(0, 8) + id.slice(9, 13);
|
|
31
|
+
const ms = Number.parseInt(hex, 16);
|
|
32
|
+
if (!Number.isFinite(ms) || ms <= 0) return null;
|
|
33
|
+
const d = new Date(ms);
|
|
34
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
35
|
+
return utcDateString(d);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Best-effort: prefer the v7-derived date, else fall back to today's UTC.
|
|
40
|
+
* Use for write paths where you have an id and need a concrete date dir.
|
|
41
|
+
*/
|
|
42
|
+
export function dateForId(id: string): string {
|
|
43
|
+
return dateFromUuidV7(id) ?? utcDateString(new Date());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Regex that matches `YYYY-MM-DD` directory names. */
|
|
47
|
+
export const DATE_DIR_RE = /^\d{4}-\d{2}-\d{2}$/;
|
package/src/worker/heartbeat.ts
CHANGED
|
@@ -1,32 +1,28 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { reapOrphanScheduleLocks } from "../schedules/store.ts";
|
|
2
|
+
import { reapOrphanLocks as reapOrphanTaskLocks } from "../tasks/store.ts";
|
|
3
|
+
import { logger } from "../utils/logger.ts";
|
|
2
4
|
import {
|
|
3
5
|
heartbeat,
|
|
6
|
+
isWorkerRunning,
|
|
4
7
|
pruneStoppedWorkers,
|
|
5
8
|
reapDeadWorkers,
|
|
6
|
-
} from "../
|
|
7
|
-
import { logger } from "../utils/logger.ts";
|
|
9
|
+
} from "../workers/store.ts";
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
|
-
* Start a non-blocking heartbeat interval for a running worker.
|
|
11
|
-
*
|
|
12
|
-
* The
|
|
13
|
-
*
|
|
14
|
-
* so it doesn't keep the Bun event loop alive on its own — the main tick
|
|
15
|
-
* loop (or the awaited one-shot task) is what keeps the process running.
|
|
16
|
-
*
|
|
17
|
-
* Errors are swallowed with a warning: a transient DB lock shouldn't crash
|
|
18
|
-
* a worker that's otherwise doing useful work. If every heartbeat fails the
|
|
19
|
-
* worker will eventually be reaped, which is the correct outcome.
|
|
12
|
+
* Start a non-blocking heartbeat interval for a running worker. Each tick
|
|
13
|
+
* atomically rewrites `<projectDir>/workers/<id>.json` with an updated
|
|
14
|
+
* `last_heartbeat_at`. The setInterval handle is unref'd so the heartbeat
|
|
15
|
+
* doesn't keep the Bun event loop alive on its own.
|
|
20
16
|
*/
|
|
21
17
|
export function startHeartbeat(
|
|
22
|
-
|
|
18
|
+
projectDir: string,
|
|
23
19
|
workerId: string,
|
|
24
20
|
intervalSeconds: number,
|
|
25
21
|
): () => void {
|
|
26
22
|
const ms = Math.max(1_000, intervalSeconds * 1_000);
|
|
27
23
|
const handle = setInterval(async () => {
|
|
28
24
|
try {
|
|
29
|
-
await
|
|
25
|
+
await heartbeat(projectDir, workerId);
|
|
30
26
|
} catch (err) {
|
|
31
27
|
logger.warn(`worker heartbeat failed: ${err}`);
|
|
32
28
|
}
|
|
@@ -36,12 +32,14 @@ export function startHeartbeat(
|
|
|
36
32
|
}
|
|
37
33
|
|
|
38
34
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
35
|
+
* Periodic reaper: walk `workers/`, mark any running worker dead whose
|
|
36
|
+
* heartbeat is older than `staleAfterSeconds`, then walk `tasks/.locks/`
|
|
37
|
+
* and `schedules/.locks/` and unlink any lockfile whose holder is no
|
|
38
|
+
* longer running. Cleanly-stopped worker JSON files older than
|
|
39
|
+
* `stoppedRetentionSeconds` are pruned.
|
|
42
40
|
*/
|
|
43
41
|
export function startReaper(
|
|
44
|
-
|
|
42
|
+
projectDir: string,
|
|
45
43
|
intervalSeconds: number,
|
|
46
44
|
staleAfterSeconds: number,
|
|
47
45
|
stoppedRetentionSeconds: number,
|
|
@@ -49,9 +47,7 @@ export function startReaper(
|
|
|
49
47
|
const ms = Math.max(1_000, intervalSeconds * 1_000);
|
|
50
48
|
const handle = setInterval(async () => {
|
|
51
49
|
try {
|
|
52
|
-
const reaped = await
|
|
53
|
-
reapDeadWorkers(conn, staleAfterSeconds),
|
|
54
|
-
);
|
|
50
|
+
const reaped = await reapDeadWorkers(projectDir, staleAfterSeconds);
|
|
55
51
|
if (reaped.length > 0) {
|
|
56
52
|
logger.warn(
|
|
57
53
|
`reaped ${reaped.length} stale worker(s): ${reaped.join(", ")}`,
|
|
@@ -60,9 +56,35 @@ export function startReaper(
|
|
|
60
56
|
} catch (err) {
|
|
61
57
|
logger.warn(`worker reap failed: ${err}`);
|
|
62
58
|
}
|
|
59
|
+
|
|
60
|
+
const isAlive = (id: string) => isWorkerRunning(projectDir, id);
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const released = await reapOrphanTaskLocks(projectDir, isAlive);
|
|
64
|
+
if (released.length > 0) {
|
|
65
|
+
logger.warn(
|
|
66
|
+
`released ${released.length} orphan task lock(s): ${released.join(", ")}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
logger.warn(`task lock reap failed: ${err}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const released = await reapOrphanScheduleLocks(projectDir, isAlive);
|
|
75
|
+
if (released.length > 0) {
|
|
76
|
+
logger.warn(
|
|
77
|
+
`released ${released.length} orphan schedule lock(s): ${released.join(", ")}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
logger.warn(`schedule lock reap failed: ${err}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
63
84
|
try {
|
|
64
|
-
const pruned = await
|
|
65
|
-
|
|
85
|
+
const pruned = await pruneStoppedWorkers(
|
|
86
|
+
projectDir,
|
|
87
|
+
stoppedRetentionSeconds,
|
|
66
88
|
);
|
|
67
89
|
if (pruned.length > 0) {
|
|
68
90
|
logger.debug(
|
package/src/worker/index.ts
CHANGED
|
@@ -5,9 +5,9 @@ import { getDbPath } from "../constants.ts";
|
|
|
5
5
|
import { withDb } from "../db/connection.ts";
|
|
6
6
|
import { migrate } from "../db/schema.ts";
|
|
7
7
|
import { uuidv7 } from "../db/uuid.ts";
|
|
8
|
-
import { markWorkerStopped, registerWorker } from "../db/workers.ts";
|
|
9
8
|
import { createMcpxClient } from "../mcpx/client.ts";
|
|
10
9
|
import { logger } from "../utils/logger.ts";
|
|
10
|
+
import { markWorkerStopped, registerWorker } from "../workers/store.ts";
|
|
11
11
|
import { startHeartbeat, startReaper } from "./heartbeat.ts";
|
|
12
12
|
import type { WorkerStreamCallbacks } from "./llm.ts";
|
|
13
13
|
import { runSpecificTask, tick } from "./tick.ts";
|
|
@@ -100,26 +100,24 @@ export async function startWorker(
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
const workerId = options.workerId ?? uuidv7();
|
|
103
|
-
await
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}),
|
|
112
|
-
);
|
|
103
|
+
await registerWorker(projectDir, {
|
|
104
|
+
id: workerId,
|
|
105
|
+
pid: process.pid,
|
|
106
|
+
hostname: hostname(),
|
|
107
|
+
mode,
|
|
108
|
+
taskId: taskId ?? null,
|
|
109
|
+
logPath: options.logPath ?? null,
|
|
110
|
+
});
|
|
113
111
|
|
|
114
112
|
const stopHeartbeat = startHeartbeat(
|
|
115
|
-
|
|
113
|
+
projectDir,
|
|
116
114
|
workerId,
|
|
117
115
|
config.worker_heartbeat_interval_seconds,
|
|
118
116
|
);
|
|
119
117
|
const stopReaper =
|
|
120
118
|
mode === "persist"
|
|
121
119
|
? startReaper(
|
|
122
|
-
|
|
120
|
+
projectDir,
|
|
123
121
|
config.worker_reap_interval_seconds,
|
|
124
122
|
config.worker_dead_after_seconds,
|
|
125
123
|
config.worker_stopped_retention_seconds,
|
|
@@ -132,7 +130,7 @@ export async function startWorker(
|
|
|
132
130
|
stopReaper();
|
|
133
131
|
await mcpxClient?.close();
|
|
134
132
|
try {
|
|
135
|
-
await
|
|
133
|
+
await markWorkerStopped(projectDir, workerId);
|
|
136
134
|
} catch (err) {
|
|
137
135
|
logger.warn(`failed to mark worker stopped: ${err}`);
|
|
138
136
|
}
|
|
@@ -205,7 +203,7 @@ export async function startWorker(
|
|
|
205
203
|
stopHeartbeat();
|
|
206
204
|
stopReaper();
|
|
207
205
|
try {
|
|
208
|
-
await
|
|
206
|
+
await markWorkerStopped(projectDir, workerId);
|
|
209
207
|
} catch (err) {
|
|
210
208
|
logger.warn(`failed to mark worker stopped: ${err}`);
|
|
211
209
|
}
|
package/src/worker/llm.ts
CHANGED
|
@@ -7,8 +7,9 @@ import type {
|
|
|
7
7
|
import type { McpxClient } from "@evantahler/mcpx";
|
|
8
8
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
9
9
|
import { withDb } from "../db/connection.ts";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import type { Task } from "../tasks/schema.ts";
|
|
11
|
+
import { getTask } from "../tasks/store.ts";
|
|
12
|
+
import { logInteraction } from "../threads/store.ts";
|
|
12
13
|
import { registerAllTools } from "../tools/registry.ts";
|
|
13
14
|
import { getTool, type ToolContext, toAnthropicTools } from "../tools/tool.ts";
|
|
14
15
|
import { logger } from "../utils/logger.ts";
|
|
@@ -72,7 +73,7 @@ export async function runAgentLoop(input: {
|
|
|
72
73
|
if (task.blocked_by.length > 0) {
|
|
73
74
|
const predecessorOutputs: string[] = [];
|
|
74
75
|
for (const blockerId of task.blocked_by) {
|
|
75
|
-
const blocker = await
|
|
76
|
+
const blocker = await getTask(projectDir, blockerId);
|
|
76
77
|
if (blocker?.output) {
|
|
77
78
|
predecessorOutputs.push(
|
|
78
79
|
`### ${blocker.name} (${blocker.id})\n${blocker.output}`,
|
|
@@ -89,13 +90,11 @@ export async function runAgentLoop(input: {
|
|
|
89
90
|
const messages: MessageParam[] = [{ role: "user", content: userMessage }];
|
|
90
91
|
|
|
91
92
|
// Log the initial user message
|
|
92
|
-
await
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}),
|
|
98
|
-
);
|
|
93
|
+
await logInteraction(projectDir, threadId, {
|
|
94
|
+
role: "user",
|
|
95
|
+
kind: "message",
|
|
96
|
+
content: userMessage,
|
|
97
|
+
});
|
|
99
98
|
|
|
100
99
|
clearLargeResults();
|
|
101
100
|
const workerTools = toAnthropicTools();
|
|
@@ -149,15 +148,13 @@ export async function runAgentLoop(input: {
|
|
|
149
148
|
// Log assistant text blocks
|
|
150
149
|
for (const block of response.content) {
|
|
151
150
|
if (block.type === "text" && block.text) {
|
|
152
|
-
await
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}),
|
|
160
|
-
);
|
|
151
|
+
await logInteraction(projectDir, threadId, {
|
|
152
|
+
role: "assistant",
|
|
153
|
+
kind: "message",
|
|
154
|
+
content: block.text,
|
|
155
|
+
durationMs,
|
|
156
|
+
tokenCount,
|
|
157
|
+
});
|
|
161
158
|
if (!callbacks) {
|
|
162
159
|
logger.phase("assistant", block.text);
|
|
163
160
|
}
|
|
@@ -189,15 +186,13 @@ export async function runAgentLoop(input: {
|
|
|
189
186
|
`${toolUse.name} ${truncate(toolInput, 200)}`,
|
|
190
187
|
);
|
|
191
188
|
}
|
|
192
|
-
await
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}),
|
|
200
|
-
);
|
|
189
|
+
await logInteraction(projectDir, threadId, {
|
|
190
|
+
role: "assistant",
|
|
191
|
+
kind: "tool_use",
|
|
192
|
+
content: `Calling ${toolUse.name}`,
|
|
193
|
+
toolName: toolUse.name,
|
|
194
|
+
toolInput,
|
|
195
|
+
});
|
|
201
196
|
}
|
|
202
197
|
|
|
203
198
|
// Execute all tools in parallel. Each tool call opens its own short-lived
|
|
@@ -227,15 +222,13 @@ export async function runAgentLoop(input: {
|
|
|
227
222
|
// Log results and collect tool_result messages
|
|
228
223
|
const toolResults: ToolResultBlockParam[] = [];
|
|
229
224
|
for (const { toolUse, result, durationMs } of execResults) {
|
|
230
|
-
await
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}),
|
|
238
|
-
);
|
|
225
|
+
await logInteraction(projectDir, threadId, {
|
|
226
|
+
role: "tool",
|
|
227
|
+
kind: "tool_result",
|
|
228
|
+
content: result.output,
|
|
229
|
+
toolName: toolUse.name,
|
|
230
|
+
durationMs,
|
|
231
|
+
});
|
|
239
232
|
if (!callbacks) {
|
|
240
233
|
const seconds = (durationMs / 1000).toFixed(1);
|
|
241
234
|
const status = result.isError ? "err" : "ok";
|
package/src/worker/prompt.ts
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { withDb } from "../db/connection.ts";
|
|
7
|
-
import { hybridSearch } from "../db/embeddings.ts";
|
|
8
|
-
import type { Task } from "../db/tasks.ts";
|
|
4
|
+
import { getPromptsDir } from "../constants.ts";
|
|
5
|
+
import type { Task } from "../tasks/schema.ts";
|
|
9
6
|
import { parseContextFile } from "../utils/frontmatter.ts";
|
|
10
|
-
import { logger } from "../utils/logger.ts";
|
|
11
7
|
|
|
12
8
|
const pkg = await Bun.file(
|
|
13
9
|
new URL("../../package.json", import.meta.url),
|
|
@@ -37,23 +33,23 @@ export function extractKeywords(text: string): Set<string> {
|
|
|
37
33
|
}
|
|
38
34
|
|
|
39
35
|
/**
|
|
40
|
-
* Load persistent context files from
|
|
41
|
-
*
|
|
42
|
-
*
|
|
36
|
+
* Load persistent context files from prompts/ as a single formatted
|
|
37
|
+
* string. Includes "always" files unconditionally and "contextual" files
|
|
38
|
+
* whose content overlaps the provided taskKeywords.
|
|
43
39
|
*/
|
|
44
40
|
export async function loadPersistentContext(
|
|
45
41
|
projectDir: string,
|
|
46
42
|
taskKeywords?: Set<string> | null,
|
|
47
43
|
): Promise<string> {
|
|
48
|
-
const
|
|
44
|
+
const dir = getPromptsDir(projectDir);
|
|
49
45
|
let out = "";
|
|
50
46
|
|
|
51
47
|
try {
|
|
52
|
-
const files = await readdir(
|
|
48
|
+
const files = await readdir(dir);
|
|
53
49
|
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
54
50
|
|
|
55
51
|
for (const filename of mdFiles) {
|
|
56
|
-
const filePath = join(
|
|
52
|
+
const filePath = join(dir, filename);
|
|
57
53
|
const raw = await Bun.file(filePath).text();
|
|
58
54
|
const { meta, content } = parseContextFile(raw);
|
|
59
55
|
|
|
@@ -70,7 +66,7 @@ export async function loadPersistentContext(
|
|
|
70
66
|
}
|
|
71
67
|
}
|
|
72
68
|
} catch {
|
|
73
|
-
//
|
|
69
|
+
// prompts/ might not have md files yet
|
|
74
70
|
}
|
|
75
71
|
|
|
76
72
|
return out;
|
|
@@ -104,30 +100,12 @@ export async function buildSystemPrompt(
|
|
|
104
100
|
|
|
105
101
|
prompt += await loadPersistentContext(projectDir, taskKeywords);
|
|
106
102
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
);
|
|
114
|
-
|
|
115
|
-
if (results.length > 0) {
|
|
116
|
-
prompt += "## Relevant Context\n";
|
|
117
|
-
for (const r of results) {
|
|
118
|
-
const ref =
|
|
119
|
-
r.drive && r.path ? `${r.drive}:${r.path}` : r.context_item_id;
|
|
120
|
-
prompt += `### ${r.title} (${ref})\n`;
|
|
121
|
-
if (r.chunk_content) {
|
|
122
|
-
prompt += `${r.chunk_content.slice(0, 1000)}\n`;
|
|
123
|
-
}
|
|
124
|
-
prompt += "\n";
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
} catch (err) {
|
|
128
|
-
logger.debug(`Failed to load contextual embeddings: ${err}`);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
103
|
+
// The agent finds task-relevant content via the `search` tool on demand
|
|
104
|
+
// rather than having chunks pre-stuffed into the system prompt — keeps the
|
|
105
|
+
// prompt small and lets the model decide what it actually needs to read.
|
|
106
|
+
void task;
|
|
107
|
+
void dbPath;
|
|
108
|
+
void _config;
|
|
131
109
|
|
|
132
110
|
prompt += `## Instructions
|
|
133
111
|
You are Botholomew, a wise-owl worker that works through tasks. Use available tools to complete your assigned task, then call complete_task, fail_task, or wait_task. Use create_task for subtasks and update_task to refine pending tasks. Batch independent tool calls in a single response for parallel execution.
|
|
@@ -141,19 +119,19 @@ When calling complete_task, write a summary that captures your key findings, dec
|
|
|
141
119
|
|
|
142
120
|
### Local context first
|
|
143
121
|
|
|
144
|
-
**Before any MCP read, search local context.**
|
|
122
|
+
**Before any MCP read, search local context.** Files in \`context/\` (Gmail dumps, GitHub fetches, URL ingests, prior agent outputs) are usually already there — refetching is slower, costs tokens, and risks rate limits.
|
|
145
123
|
|
|
146
124
|
Workflow for any "look up / find / read" intent:
|
|
147
125
|
|
|
148
|
-
1. \`search\` (hybrid regexp + semantic)
|
|
149
|
-
2. If freshness matters, call \`context_info\` and check
|
|
126
|
+
1. \`search\` (hybrid regexp + semantic) over \`context/\`, then \`context_read\` / \`context_tree\` to drill in.
|
|
127
|
+
2. If freshness matters, call \`context_info\` and check the file's mtime. To re-pull stale content, write fresh into \`context/\` (\`pipe_to_context\` from an \`mcp_exec\` call is the typical path) rather than going to MCP for the whole document on every question.
|
|
150
128
|
3. Only call \`mcp_exec\` for reads when the data is genuinely missing locally **or** must be real-time (e.g., "what's on my calendar right now").
|
|
151
129
|
|
|
152
130
|
Writes always go through MCP — sending an email, creating an issue, posting to Slack. Don't search context first for those.
|
|
153
131
|
|
|
154
132
|
Examples:
|
|
155
133
|
- "What does doc X say?" → \`search\` first.
|
|
156
|
-
- "Any new emails from Y?" →
|
|
134
|
+
- "Any new emails from Y?" → \`search\` for the sender under \`context/gmail/\` (or wherever you've been ingesting mail) before hitting Gmail MCP.
|
|
157
135
|
- "Send an email to Y" → MCP write directly; no context lookup.
|
|
158
136
|
|
|
159
137
|
### Calling MCP tools
|
package/src/worker/schedules.ts
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import Anthropic from "@anthropic-ai/sdk";
|
|
2
2
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
3
|
-
import {
|
|
3
|
+
import type { Schedule } from "../schedules/schema.ts";
|
|
4
4
|
import {
|
|
5
|
-
claimSchedule,
|
|
6
5
|
listSchedules,
|
|
7
6
|
markScheduleRun,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
} from "../
|
|
11
|
-
import { createTask } from "../db/tasks.ts";
|
|
7
|
+
withScheduleLock,
|
|
8
|
+
} from "../schedules/store.ts";
|
|
9
|
+
import { createTask } from "../tasks/store.ts";
|
|
12
10
|
import { logger } from "../utils/logger.ts";
|
|
13
11
|
|
|
14
12
|
interface ScheduleTaskDef {
|
|
@@ -55,7 +53,7 @@ The "depends_on" array contains indices of other tasks in the array that must co
|
|
|
55
53
|
const userMessage = `Schedule: "${schedule.name}"
|
|
56
54
|
Description: ${schedule.description || "(none)"}
|
|
57
55
|
Frequency: ${schedule.frequency}
|
|
58
|
-
Last run: ${schedule.last_run_at
|
|
56
|
+
Last run: ${schedule.last_run_at ?? "never"}
|
|
59
57
|
Current time: ${new Date().toISOString()}
|
|
60
58
|
|
|
61
59
|
Is this schedule due to run? If yes, what tasks should be created?`;
|
|
@@ -73,7 +71,6 @@ Is this schedule due to run? If yes, what tasks should be created?`;
|
|
|
73
71
|
.map((b) => b.text)
|
|
74
72
|
.join("");
|
|
75
73
|
|
|
76
|
-
// Strip markdown code fences the LLM may wrap around JSON
|
|
77
74
|
text = text
|
|
78
75
|
.replace(/^```(?:json)?\s*\n?/, "")
|
|
79
76
|
.replace(/\n?```\s*$/, "")
|
|
@@ -107,75 +104,57 @@ Is this schedule due to run? If yes, what tasks should be created?`;
|
|
|
107
104
|
}
|
|
108
105
|
|
|
109
106
|
export async function processSchedules(
|
|
110
|
-
|
|
107
|
+
projectDir: string,
|
|
111
108
|
config: Required<BotholomewConfig>,
|
|
112
109
|
workerId: string,
|
|
113
110
|
): Promise<void> {
|
|
114
|
-
const schedules = await
|
|
115
|
-
listSchedules(conn, { enabled: true }),
|
|
116
|
-
);
|
|
111
|
+
const schedules = await listSchedules(projectDir, { enabled: true });
|
|
117
112
|
if (schedules.length === 0) return;
|
|
118
113
|
|
|
119
114
|
logger.phase("evaluating-schedules", `${schedules.length} enabled`);
|
|
120
115
|
|
|
121
116
|
for (const schedule of schedules) {
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
117
|
+
// Lockfile + min-interval guard prevent two workers (or two ticks) from
|
|
118
|
+
// evaluating the same schedule too closely.
|
|
119
|
+
await withScheduleLock(
|
|
120
|
+
projectDir,
|
|
121
|
+
schedule.id,
|
|
122
|
+
workerId,
|
|
123
|
+
{ minIntervalSeconds: config.schedule_min_interval_seconds },
|
|
124
|
+
async (claimed) => {
|
|
125
|
+
try {
|
|
126
|
+
const evaluation = await evaluateSchedule(config, claimed);
|
|
127
|
+
|
|
128
|
+
if (!evaluation.isDue) {
|
|
129
|
+
logger.debug(
|
|
130
|
+
`Schedule "${claimed.name}" not due: ${evaluation.reasoning}`,
|
|
131
|
+
);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const createdIds: string[] = [];
|
|
136
|
+
for (const taskDef of evaluation.tasksToCreate) {
|
|
137
|
+
const blockedBy = (taskDef.depends_on ?? [])
|
|
138
|
+
.map((i: number) => createdIds[i])
|
|
139
|
+
.filter(Boolean) as string[];
|
|
140
|
+
|
|
141
|
+
const task = await createTask(projectDir, {
|
|
142
|
+
name: taskDef.name,
|
|
143
|
+
description: taskDef.description,
|
|
144
|
+
priority: taskDef.priority,
|
|
145
|
+
blocked_by: blockedBy,
|
|
146
|
+
});
|
|
147
|
+
createdIds.push(task.id);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await markScheduleRun(projectDir, claimed.id);
|
|
151
|
+
logger.info(
|
|
152
|
+
`Schedule "${claimed.name}" fired, created ${createdIds.length} task(s)`,
|
|
153
|
+
);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
logger.error(`Error processing schedule "${claimed.name}": ${err}`);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
131
158
|
);
|
|
132
|
-
if (!claimed) {
|
|
133
|
-
logger.debug(
|
|
134
|
-
`Schedule "${schedule.name}" skipped: claimed by another worker or too recent`,
|
|
135
|
-
);
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
try {
|
|
140
|
-
const evaluation = await evaluateSchedule(config, claimed);
|
|
141
|
-
|
|
142
|
-
if (!evaluation.isDue) {
|
|
143
|
-
logger.debug(
|
|
144
|
-
`Schedule "${schedule.name}" not due: ${evaluation.reasoning}`,
|
|
145
|
-
);
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const createdIds: string[] = [];
|
|
150
|
-
for (const taskDef of evaluation.tasksToCreate) {
|
|
151
|
-
const blockedBy = (taskDef.depends_on ?? [])
|
|
152
|
-
.map((i: number) => createdIds[i])
|
|
153
|
-
.filter(Boolean) as string[];
|
|
154
|
-
|
|
155
|
-
const task = await withDb(dbPath, (conn) =>
|
|
156
|
-
createTask(conn, {
|
|
157
|
-
name: taskDef.name,
|
|
158
|
-
description: taskDef.description,
|
|
159
|
-
priority: taskDef.priority,
|
|
160
|
-
blocked_by: blockedBy,
|
|
161
|
-
}),
|
|
162
|
-
);
|
|
163
|
-
createdIds.push(task.id);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
await withDb(dbPath, (conn) => markScheduleRun(conn, schedule.id));
|
|
167
|
-
logger.info(
|
|
168
|
-
`Schedule "${schedule.name}" fired, created ${createdIds.length} task(s)`,
|
|
169
|
-
);
|
|
170
|
-
} catch (err) {
|
|
171
|
-
logger.error(`Error processing schedule "${schedule.name}": ${err}`);
|
|
172
|
-
} finally {
|
|
173
|
-
// Release the claim so other workers (or the next tick) can re-evaluate
|
|
174
|
-
// once the min-interval window has elapsed. markScheduleRun above
|
|
175
|
-
// updates last_run_at, which is the actual cooldown.
|
|
176
|
-
await withDb(dbPath, (conn) =>
|
|
177
|
-
releaseSchedule(conn, schedule.id, workerId),
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
159
|
}
|
|
181
160
|
}
|