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.
Files changed (107) hide show
  1. package/README.md +91 -68
  2. package/package.json +2 -2
  3. package/src/chat/agent.ts +59 -86
  4. package/src/chat/session.ts +29 -25
  5. package/src/commands/capabilities.ts +1 -1
  6. package/src/commands/context.ts +178 -926
  7. package/src/commands/db.ts +9 -13
  8. package/src/commands/init.ts +4 -1
  9. package/src/commands/nuke.ts +57 -90
  10. package/src/commands/schedule.ts +103 -124
  11. package/src/commands/skill.ts +2 -2
  12. package/src/commands/task.ts +86 -95
  13. package/src/commands/thread.ts +107 -112
  14. package/src/commands/worker.ts +88 -88
  15. package/src/constants.ts +93 -16
  16. package/src/context/capabilities.ts +10 -10
  17. package/src/context/fetcher.ts +9 -10
  18. package/src/context/reindex.ts +189 -0
  19. package/src/context/store.ts +803 -0
  20. package/src/db/doctor.ts +1 -8
  21. package/src/db/embeddings.ts +227 -175
  22. package/src/db/sql/19-disk_backed_index.sql +36 -0
  23. package/src/db/sql/20-drop_db_tables_for_files.sql +19 -0
  24. package/src/fs/atomic.ts +217 -0
  25. package/src/fs/compat.ts +86 -0
  26. package/src/fs/sandbox.ts +293 -0
  27. package/src/init/index.ts +69 -52
  28. package/src/init/templates.ts +1 -1
  29. package/src/mcpx/client.ts +1 -1
  30. package/src/schedules/schema.ts +19 -0
  31. package/src/schedules/store.ts +296 -0
  32. package/src/skills/commands.ts +1 -3
  33. package/src/tasks/schema.ts +47 -0
  34. package/src/tasks/store.ts +486 -0
  35. package/src/threads/store.ts +559 -0
  36. package/src/tools/capabilities/refresh.ts +42 -21
  37. package/src/tools/context/pipe.ts +15 -71
  38. package/src/tools/context/update-beliefs.ts +3 -3
  39. package/src/tools/context/update-goals.ts +3 -3
  40. package/src/tools/dir/create.ts +26 -23
  41. package/src/tools/dir/size.ts +46 -17
  42. package/src/tools/dir/tree.ts +74 -279
  43. package/src/tools/file/copy.ts +50 -24
  44. package/src/tools/file/count-lines.ts +34 -10
  45. package/src/tools/file/delete.ts +53 -23
  46. package/src/tools/file/edit.ts +39 -14
  47. package/src/tools/file/exists.ts +12 -26
  48. package/src/tools/file/info.ts +27 -85
  49. package/src/tools/file/move.ts +39 -24
  50. package/src/tools/file/read.ts +32 -80
  51. package/src/tools/file/write.ts +14 -91
  52. package/src/tools/registry.ts +8 -7
  53. package/src/tools/schedule/create.ts +2 -2
  54. package/src/tools/schedule/list.ts +7 -3
  55. package/src/tools/search/fuse.ts +12 -33
  56. package/src/tools/search/index.ts +36 -43
  57. package/src/tools/search/regexp.ts +29 -17
  58. package/src/tools/search/semantic.ts +137 -51
  59. package/src/tools/skill/delete.ts +1 -1
  60. package/src/tools/skill/list.ts +1 -1
  61. package/src/tools/skill/write.ts +1 -1
  62. package/src/tools/task/create.ts +41 -16
  63. package/src/tools/task/delete.ts +3 -3
  64. package/src/tools/task/list.ts +6 -3
  65. package/src/tools/task/update.ts +31 -9
  66. package/src/tools/task/view.ts +6 -6
  67. package/src/tools/thread/list.ts +2 -2
  68. package/src/tools/thread/search.ts +208 -0
  69. package/src/tools/thread/view.ts +50 -5
  70. package/src/tools/tool.ts +5 -0
  71. package/src/tools/util/sleep.ts +77 -0
  72. package/src/tools/worker/spawn.ts +28 -14
  73. package/src/tui/App.tsx +12 -19
  74. package/src/tui/components/ContextPanel.tsx +83 -316
  75. package/src/tui/components/SchedulePanel.tsx +34 -48
  76. package/src/tui/components/SleepProgress.tsx +70 -0
  77. package/src/tui/components/StatusBar.tsx +15 -15
  78. package/src/tui/components/TaskPanel.tsx +34 -38
  79. package/src/tui/components/ThreadPanel.tsx +29 -38
  80. package/src/tui/components/ToolCall.tsx +10 -0
  81. package/src/tui/components/WorkerPanel.tsx +21 -19
  82. package/src/tui/markdown.ts +2 -8
  83. package/src/utils/title.ts +5 -7
  84. package/src/utils/v7-date.ts +47 -0
  85. package/src/worker/heartbeat.ts +46 -24
  86. package/src/worker/index.ts +13 -15
  87. package/src/worker/llm.ts +30 -37
  88. package/src/worker/prompt.ts +19 -41
  89. package/src/worker/schedules.ts +48 -69
  90. package/src/worker/spawn.ts +11 -11
  91. package/src/worker/tick.ts +39 -43
  92. package/src/workers/store.ts +247 -0
  93. package/src/commands/tools.ts +0 -367
  94. package/src/context/describer.ts +0 -140
  95. package/src/context/drives.ts +0 -110
  96. package/src/context/ingest.ts +0 -162
  97. package/src/context/refresh.ts +0 -183
  98. package/src/db/context.ts +0 -637
  99. package/src/db/daemon-state.ts +0 -6
  100. package/src/db/reembed.ts +0 -113
  101. package/src/db/schedules.ts +0 -213
  102. package/src/db/tasks.ts +0 -347
  103. package/src/db/threads.ts +0 -276
  104. package/src/db/workers.ts +0 -212
  105. package/src/tools/context/list-drives.ts +0 -36
  106. package/src/tools/context/refresh.ts +0 -165
  107. 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
- dbPath: string;
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(from: Date, now: Date): string {
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
- dbPath,
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
- const result = await withDb(dbPath, (conn) =>
72
- listWorkers(conn, status ? { status } : {}),
73
- );
74
- if (mounted) {
75
- setWorkers(result);
76
- setNow(new Date());
77
- setSelectedIndex((prev) =>
78
- Math.min(prev, Math.max(0, result.length - 1)),
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
- }, [dbPath, filterIdx]);
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.toISOString()}{" "}
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.toISOString()}
348
+ {worker.stopped_at}
347
349
  </Text>
348
350
  )}
349
351
  {worker.task_id && (
@@ -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 isMarkdownItem(
9
- item: Pick<ContextItem, "mime_type" | "path">,
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
  }
@@ -1,18 +1,16 @@
1
1
  import type { BotholomewConfig } from "../config/schemas.ts";
2
- import { withDb } from "../db/connection.ts";
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 at debug level and never propagated.
10
- * Opens its own short-lived DB connection for the write so callers can
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
- dbPath: string,
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 withDb(dbPath, (conn) => updateThreadTitle(conn, threadId, title));
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}$/;
@@ -1,32 +1,28 @@
1
- import { withDb } from "../db/connection.ts";
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 "../db/workers.ts";
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 heartbeat runs on its own `setInterval` timer so it stays live even
13
- * while the worker is blocked inside a long LLM call. We `unref` the timer
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
- dbPath: string,
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 withDb(dbPath, (conn) => heartbeat(conn, workerId));
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
- * Start a periodic reaper that marks stale workers dead and releases any
40
- * tasks / schedule claims they held. Only persist workers need this — a
41
- * one-shot worker does a single reap pass before claiming its task.
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
- dbPath: string,
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 withDb(dbPath, (conn) =>
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 withDb(dbPath, (conn) =>
65
- pruneStoppedWorkers(conn, stoppedRetentionSeconds),
85
+ const pruned = await pruneStoppedWorkers(
86
+ projectDir,
87
+ stoppedRetentionSeconds,
66
88
  );
67
89
  if (pruned.length > 0) {
68
90
  logger.debug(
@@ -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 withDb(dbPath, (conn) =>
104
- registerWorker(conn, {
105
- id: workerId,
106
- pid: process.pid,
107
- hostname: hostname(),
108
- mode,
109
- taskId: taskId ?? null,
110
- logPath: options.logPath ?? null,
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
- dbPath,
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
- dbPath,
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 withDb(dbPath, (conn) => markWorkerStopped(conn, workerId));
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 withDb(dbPath, (conn) => markWorkerStopped(conn, workerId));
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 { getTask, type Task } from "../db/tasks.ts";
11
- import { logInteraction } from "../db/threads.ts";
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 withDb(dbPath, (conn) => getTask(conn, blockerId));
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 withDb(dbPath, (conn) =>
93
- logInteraction(conn, threadId, {
94
- role: "user",
95
- kind: "message",
96
- content: userMessage,
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 withDb(dbPath, (conn) =>
153
- logInteraction(conn, threadId, {
154
- role: "assistant",
155
- kind: "message",
156
- content: block.text,
157
- durationMs,
158
- tokenCount,
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 withDb(dbPath, (conn) =>
193
- logInteraction(conn, threadId, {
194
- role: "assistant",
195
- kind: "tool_use",
196
- content: `Calling ${toolUse.name}`,
197
- toolName: toolUse.name,
198
- toolInput,
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 withDb(dbPath, (conn) =>
231
- logInteraction(conn, threadId, {
232
- role: "tool",
233
- kind: "tool_result",
234
- content: result.output,
235
- toolName: toolUse.name,
236
- durationMs,
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";
@@ -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 { getBotholomewDir } from "../constants.ts";
5
- import { embedSingle } from "../context/embedder.ts";
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 .botholomew/ directory as a single
41
- * formatted string. Includes "always" files unconditionally and "contextual"
42
- * files whose content overlaps the provided taskKeywords.
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 dotDir = getBotholomewDir(projectDir);
44
+ const dir = getPromptsDir(projectDir);
49
45
  let out = "";
50
46
 
51
47
  try {
52
- const files = await readdir(dotDir);
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(dotDir, filename);
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
- // .botholomew dir might not have md files yet
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
- if (task && dbPath && _config) {
108
- try {
109
- const query = `${task.name} ${task.description}`;
110
- const queryVec = await embedSingle(query, _config);
111
- const results = await withDb(dbPath, (conn) =>
112
- hybridSearch(conn, query, queryVec, 5),
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.** Drive, Gmail, GitHub, URLs, and prior agent runs are usually already ingested — refetching is slower, costs tokens, and risks rate limits.
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) or \`context_search\` (keyword), then \`context_read\` / \`context_tree\` to drill in.
149
- 2. If freshness matters, call \`context_info\` and check \`indexed_at\`. To re-pull a single stale item, use \`context_refresh\` rather than going to MCP for the whole document.
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?" → check the \`gmail\` drive first; only hit Gmail MCP if the freshest indexed item is too old for the question.
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