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.
Files changed (104) hide show
  1. package/README.md +91 -68
  2. package/package.json +3 -3
  3. package/src/chat/agent.ts +42 -82
  4. package/src/chat/session.ts +29 -25
  5. package/src/commands/capabilities.ts +1 -1
  6. package/src/commands/context.ts +177 -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 +630 -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 +279 -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 +73 -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 +44 -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 +25 -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 +3 -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/worker/spawn.ts +28 -14
  71. package/src/tui/App.tsx +12 -19
  72. package/src/tui/components/ContextPanel.tsx +83 -316
  73. package/src/tui/components/SchedulePanel.tsx +34 -48
  74. package/src/tui/components/StatusBar.tsx +15 -15
  75. package/src/tui/components/TaskPanel.tsx +34 -38
  76. package/src/tui/components/ThreadPanel.tsx +29 -38
  77. package/src/tui/components/WorkerPanel.tsx +21 -19
  78. package/src/tui/markdown.ts +2 -8
  79. package/src/types/file-imports.d.ts +9 -0
  80. package/src/utils/title.ts +5 -7
  81. package/src/utils/v7-date.ts +47 -0
  82. package/src/worker/heartbeat.ts +46 -24
  83. package/src/worker/index.ts +13 -15
  84. package/src/worker/llm.ts +30 -37
  85. package/src/worker/prompt.ts +19 -41
  86. package/src/worker/schedules.ts +48 -69
  87. package/src/worker/spawn.ts +11 -11
  88. package/src/worker/tick.ts +39 -43
  89. package/src/workers/store.ts +247 -0
  90. package/src/commands/tools.ts +0 -367
  91. package/src/context/describer.ts +0 -140
  92. package/src/context/drives.ts +0 -110
  93. package/src/context/ingest.ts +0 -162
  94. package/src/context/refresh.ts +0 -183
  95. package/src/db/context.ts +0 -637
  96. package/src/db/daemon-state.ts +0 -6
  97. package/src/db/reembed.ts +0 -113
  98. package/src/db/schedules.ts +0 -213
  99. package/src/db/tasks.ts +0 -347
  100. package/src/db/threads.ts +0 -276
  101. package/src/db/workers.ts +0 -212
  102. package/src/tools/context/list-drives.ts +0 -36
  103. package/src/tools/context/refresh.ts +0 -165
  104. package/src/tools/context/search.ts +0 -54
@@ -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
@@ -1,14 +1,12 @@
1
1
  import Anthropic from "@anthropic-ai/sdk";
2
2
  import type { BotholomewConfig } from "../config/schemas.ts";
3
- import { withDb } from "../db/connection.ts";
3
+ import type { Schedule } from "../schedules/schema.ts";
4
4
  import {
5
- claimSchedule,
6
5
  listSchedules,
7
6
  markScheduleRun,
8
- releaseSchedule,
9
- type Schedule,
10
- } from "../db/schedules.ts";
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?.toISOString() ?? "never"}
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
- dbPath: string,
107
+ projectDir: string,
111
108
  config: Required<BotholomewConfig>,
112
109
  workerId: string,
113
110
  ): Promise<void> {
114
- const schedules = await withDb(dbPath, (conn) =>
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
- // Only one worker evaluates a schedule per window. claimSchedule is an
123
- // atomic UPDATE ... RETURNING guarded by both a claim-stale window and
124
- // a minimum-interval-since-last-run window; if it returns null, another
125
- // worker already holds the claim or the schedule ran too recently.
126
- const claimed = await withDb(dbPath, (conn) =>
127
- claimSchedule(conn, schedule.id, workerId, {
128
- staleAfterSeconds: config.schedule_claim_stale_seconds,
129
- minIntervalSeconds: config.schedule_min_interval_seconds,
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
  }