botholomew 0.12.5 → 0.14.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 +2 -2
- package/src/chat/agent.ts +59 -86
- package/src/chat/session.ts +29 -25
- package/src/commands/capabilities.ts +1 -1
- package/src/commands/context.ts +178 -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 +803 -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 +293 -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 +74 -279
- package/src/tools/file/copy.ts +50 -24
- package/src/tools/file/count-lines.ts +34 -10
- package/src/tools/file/delete.ts +53 -23
- package/src/tools/file/edit.ts +39 -14
- package/src/tools/file/exists.ts +12 -26
- package/src/tools/file/info.ts +27 -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 +8 -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/tool.ts +5 -0
- package/src/tools/util/sleep.ts +77 -0
- 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/SleepProgress.tsx +70 -0
- 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/ToolCall.tsx +10 -0
- package/src/tui/components/WorkerPanel.tsx +21 -19
- package/src/tui/markdown.ts +2 -8
- 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
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { Box, Text, useInput, useStdout } from "ink";
|
|
2
2
|
import { memo, useEffect, useMemo, useState } from "react";
|
|
3
|
-
import { withDb } from "../../db/connection.ts";
|
|
4
|
-
import { listWorkers, type Worker } from "../../db/workers.ts";
|
|
5
3
|
import { readLogTail } from "../../worker/log-reader.ts";
|
|
4
|
+
import { listWorkers, type Worker } from "../../workers/store.ts";
|
|
6
5
|
|
|
7
6
|
interface WorkerPanelProps {
|
|
8
|
-
|
|
7
|
+
projectDir: string;
|
|
9
8
|
isActive: boolean;
|
|
10
9
|
}
|
|
11
10
|
|
|
@@ -30,7 +29,9 @@ function statusColor(status: Worker["status"]): string {
|
|
|
30
29
|
}
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
function formatAge(
|
|
32
|
+
function formatAge(fromIso: string, now: Date): string {
|
|
33
|
+
const from = new Date(fromIso);
|
|
34
|
+
if (Number.isNaN(from.getTime())) return "?";
|
|
34
35
|
const secs = Math.max(0, Math.floor((now.getTime() - from.getTime()) / 1000));
|
|
35
36
|
if (secs < 60) return `${secs}s`;
|
|
36
37
|
const mins = Math.floor(secs / 60);
|
|
@@ -47,7 +48,7 @@ function formatBytes(n: number): string {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
export const WorkerPanel = memo(function WorkerPanel({
|
|
50
|
-
|
|
51
|
+
projectDir,
|
|
51
52
|
isActive,
|
|
52
53
|
}: WorkerPanelProps) {
|
|
53
54
|
const { stdout } = useStdout();
|
|
@@ -68,15 +69,17 @@ export const WorkerPanel = memo(function WorkerPanel({
|
|
|
68
69
|
|
|
69
70
|
const refresh = async () => {
|
|
70
71
|
const status = STATUS_FILTERS[filterIdx] ?? undefined;
|
|
71
|
-
|
|
72
|
-
listWorkers(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
try {
|
|
73
|
+
const result = await listWorkers(projectDir, status ? { status } : {});
|
|
74
|
+
if (mounted) {
|
|
75
|
+
setWorkers(result);
|
|
76
|
+
setNow(new Date());
|
|
77
|
+
setSelectedIndex((prev) =>
|
|
78
|
+
Math.min(prev, Math.max(0, result.length - 1)),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// ignore — next tick retries
|
|
80
83
|
}
|
|
81
84
|
};
|
|
82
85
|
|
|
@@ -86,7 +89,7 @@ export const WorkerPanel = memo(function WorkerPanel({
|
|
|
86
89
|
mounted = false;
|
|
87
90
|
clearInterval(interval);
|
|
88
91
|
};
|
|
89
|
-
}, [
|
|
92
|
+
}, [projectDir, filterIdx]);
|
|
90
93
|
|
|
91
94
|
const selected = workers[selectedIndex];
|
|
92
95
|
const selectedLogPath = selected?.log_path ?? null;
|
|
@@ -332,18 +335,17 @@ function WorkerDetail({ worker, now }: { worker: Worker; now: Date }) {
|
|
|
332
335
|
</Text>
|
|
333
336
|
<Text>
|
|
334
337
|
<Text dimColor>Started </Text>
|
|
335
|
-
{worker.started_at
|
|
338
|
+
{worker.started_at}{" "}
|
|
336
339
|
<Text dimColor>({formatAge(worker.started_at, now)} ago)</Text>
|
|
337
340
|
</Text>
|
|
338
341
|
<Text>
|
|
339
|
-
<Text dimColor>Heartbeat</Text>{" "}
|
|
340
|
-
{worker.last_heartbeat_at.toISOString()}{" "}
|
|
342
|
+
<Text dimColor>Heartbeat</Text> {worker.last_heartbeat_at}{" "}
|
|
341
343
|
<Text dimColor>({formatAge(worker.last_heartbeat_at, now)} ago)</Text>
|
|
342
344
|
</Text>
|
|
343
345
|
{worker.stopped_at && (
|
|
344
346
|
<Text>
|
|
345
347
|
<Text dimColor>Stopped </Text>
|
|
346
|
-
{worker.stopped_at
|
|
348
|
+
{worker.stopped_at}
|
|
347
349
|
</Text>
|
|
348
350
|
)}
|
|
349
351
|
{worker.task_id && (
|
package/src/tui/markdown.ts
CHANGED
|
@@ -1,14 +1,8 @@
|
|
|
1
|
-
import type { ContextItem } from "../db/context.ts";
|
|
2
|
-
|
|
3
1
|
export function renderMarkdown(text: string): string {
|
|
4
2
|
if (!text) return "";
|
|
5
3
|
return Bun.markdown.ansi(text).trimEnd();
|
|
6
4
|
}
|
|
7
5
|
|
|
8
|
-
export function
|
|
9
|
-
|
|
10
|
-
): boolean {
|
|
11
|
-
if (item.mime_type === "text/markdown") return true;
|
|
12
|
-
if (item.path.toLowerCase().endsWith(".md")) return true;
|
|
13
|
-
return false;
|
|
6
|
+
export function isMarkdownPath(path: string): boolean {
|
|
7
|
+
return path.toLowerCase().endsWith(".md");
|
|
14
8
|
}
|
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
|