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,12 +1,9 @@
1
1
  import { mkdir } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import {
4
- getBotholomewDir,
5
- getWorkerLogPath,
6
- getWorkerLogsDir,
7
- } from "../constants.ts";
2
+ import { dirname } from "node:path";
3
+ import { getConfigPath, getWorkerLogPath } from "../constants.ts";
8
4
  import { uuidv7 } from "../db/uuid.ts";
9
5
  import { logger } from "../utils/logger.ts";
6
+ import { dateForId } from "../utils/v7-date.ts";
10
7
  import type { WorkerMode } from "./index.ts";
11
8
 
12
9
  export interface SpawnWorkerOptions {
@@ -26,16 +23,19 @@ export async function spawnWorker(
26
23
  projectDir: string,
27
24
  options: SpawnWorkerOptions = {},
28
25
  ): Promise<{ pid: number; workerId: string; logPath: string }> {
29
- const dotDir = getBotholomewDir(projectDir);
30
- const dirExists = await Bun.file(join(dotDir, "config.json")).exists();
31
- if (!dirExists) {
26
+ const configPath = getConfigPath(projectDir);
27
+ const initialized = await Bun.file(configPath).exists();
28
+ if (!initialized) {
32
29
  logger.error("Project not initialized. Run 'botholomew init' first.");
33
30
  process.exit(1);
34
31
  }
35
32
 
36
33
  const workerId = uuidv7();
37
- await mkdir(getWorkerLogsDir(projectDir), { recursive: true });
38
- const logPath = getWorkerLogPath(projectDir, workerId);
34
+ // Per-worker log path is derived from the worker id's UTC date so the
35
+ // logs/ tree stays browsable as workers accumulate. Mirrors the threads
36
+ // layout under <projectDir>/threads/.
37
+ const logPath = getWorkerLogPath(projectDir, workerId, dateForId(workerId));
38
+ await mkdir(dirname(logPath), { recursive: true });
39
39
  const logFile = Bun.file(logPath);
40
40
 
41
41
  const workerScript = new URL("./run.ts", import.meta.url).pathname;
@@ -1,14 +1,14 @@
1
1
  import type { McpxClient } from "@evantahler/mcpx";
2
2
  import type { BotholomewConfig } from "../config/schemas.ts";
3
- import { withDb } from "../db/connection.ts";
3
+ import type { Task } from "../tasks/schema.ts";
4
4
  import {
5
5
  claimNextTask,
6
6
  claimSpecificTask,
7
+ releaseTaskLock,
7
8
  resetStaleTasks,
8
- type Task,
9
9
  updateTaskStatus,
10
- } from "../db/tasks.ts";
11
- import { createThread, endThread, logInteraction } from "../db/threads.ts";
10
+ } from "../tasks/store.ts";
11
+ import { createThread, endThread, logInteraction } from "../threads/store.ts";
12
12
  import { logger } from "../utils/logger.ts";
13
13
  import { generateThreadTitle } from "../utils/title.ts";
14
14
  import type { WorkerStreamCallbacks } from "./llm.ts";
@@ -46,9 +46,9 @@ export async function tick(opts: TickOptions): Promise<boolean> {
46
46
  const tickStart = Date.now();
47
47
  logger.phase("tick-start", `#${tickNum}`);
48
48
 
49
- // Reset stale tasks stuck in in_progress
50
- const resetIds = await withDb(dbPath, (conn) =>
51
- resetStaleTasks(conn, config.max_tick_duration_seconds * 3),
49
+ const resetIds = await resetStaleTasks(
50
+ projectDir,
51
+ config.max_tick_duration_seconds * 3,
52
52
  );
53
53
  if (resetIds.length > 0) {
54
54
  logger.warn(
@@ -58,15 +58,14 @@ export async function tick(opts: TickOptions): Promise<boolean> {
58
58
 
59
59
  if (evalSchedules) {
60
60
  try {
61
- await processSchedules(dbPath, config, workerId);
61
+ await processSchedules(projectDir, config, workerId);
62
62
  } catch (err) {
63
63
  logger.error(`Schedule processing failed: ${err}`);
64
64
  }
65
65
  }
66
66
 
67
- // Claim a task
68
67
  logger.phase("claiming-task");
69
- const task = await withDb(dbPath, (conn) => claimNextTask(conn, workerId));
68
+ const task = await claimNextTask(projectDir, workerId);
70
69
  if (!task) {
71
70
  logger.info("No task claimed (queue empty or all blocked)");
72
71
  const elapsed = ((Date.now() - tickStart) / 1000).toFixed(1);
@@ -90,8 +89,7 @@ export async function tick(opts: TickOptions): Promise<boolean> {
90
89
 
91
90
  /**
92
91
  * Claim and run a single, explicitly-named task. Returns true if the task
93
- * was claimed and processed, false if it wasn't eligible (already claimed,
94
- * not pending, or doesn't exist).
92
+ * was claimed and processed, false if it wasn't eligible.
95
93
  */
96
94
  export async function runSpecificTask(opts: {
97
95
  projectDir: string;
@@ -102,8 +100,10 @@ export async function runSpecificTask(opts: {
102
100
  mcpxClient?: McpxClient | null;
103
101
  callbacks?: WorkerStreamCallbacks;
104
102
  }): Promise<boolean> {
105
- const task = await withDb(opts.dbPath, (conn) =>
106
- claimSpecificTask(conn, opts.taskId, opts.workerId),
103
+ const task = await claimSpecificTask(
104
+ opts.projectDir,
105
+ opts.taskId,
106
+ opts.workerId,
107
107
  );
108
108
  if (!task) {
109
109
  logger.warn(
@@ -138,8 +138,11 @@ async function runClaimedTask(opts: {
138
138
  }
139
139
  callbacks?.onTaskStart(task);
140
140
 
141
- const threadId = await withDb(dbPath, (conn) =>
142
- createThread(conn, "worker_tick", task.id, `Working: ${task.name}`),
141
+ const threadId = await createThread(
142
+ projectDir,
143
+ "worker_tick",
144
+ task.id,
145
+ `Working: ${task.name}`,
143
146
  );
144
147
 
145
148
  const systemPrompt = await buildSystemPrompt(
@@ -163,47 +166,40 @@ async function runClaimedTask(opts: {
163
166
  });
164
167
 
165
168
  const isComplete = result.status === "complete";
166
- await withDb(dbPath, (conn) =>
167
- updateTaskStatus(
168
- conn,
169
- task.id,
170
- result.status,
171
- isComplete ? null : result.reason,
172
- isComplete ? result.reason : null,
173
- ),
169
+ await updateTaskStatus(
170
+ projectDir,
171
+ task.id,
172
+ result.status,
173
+ isComplete ? null : result.reason,
174
+ isComplete ? result.reason : null,
174
175
  );
175
176
 
176
- await withDb(dbPath, (conn) =>
177
- logInteraction(conn, threadId, {
178
- role: "system",
179
- kind: "status_change",
180
- content: `Task ${task.id} -> ${result.status}${result.reason ? `: ${result.reason}` : ""}`,
181
- }),
182
- );
177
+ await logInteraction(projectDir, threadId, {
178
+ role: "system",
179
+ kind: "status_change",
180
+ content: `Task ${task.id} -> ${result.status}${result.reason ? `: ${result.reason}` : ""}`,
181
+ });
183
182
 
184
183
  logger.info(`Task ${task.id} -> ${result.status}`);
185
184
 
186
185
  void generateThreadTitle(
187
186
  config,
188
- dbPath,
187
+ projectDir,
189
188
  threadId,
190
189
  `Task: ${task.name}\nDescription: ${task.description}\nOutcome: ${result.status}${result.reason ? ` — ${result.reason}` : ""}`,
191
190
  );
192
191
  } catch (err) {
193
- await withDb(dbPath, (conn) =>
194
- updateTaskStatus(conn, task.id, "failed", String(err), null),
195
- );
192
+ await updateTaskStatus(projectDir, task.id, "failed", String(err), null);
196
193
 
197
- await withDb(dbPath, (conn) =>
198
- logInteraction(conn, threadId, {
199
- role: "system",
200
- kind: "status_change",
201
- content: `Task ${task.id} failed: ${err}`,
202
- }),
203
- );
194
+ await logInteraction(projectDir, threadId, {
195
+ role: "system",
196
+ kind: "status_change",
197
+ content: `Task ${task.id} failed: ${err}`,
198
+ });
204
199
 
205
200
  logger.error(`Task ${task.id} failed: ${err}`);
206
201
  } finally {
207
- await withDb(dbPath, (conn) => endThread(conn, threadId));
202
+ await releaseTaskLock(projectDir, task.id);
203
+ await endThread(projectDir, threadId);
208
204
  }
209
205
  }
@@ -0,0 +1,247 @@
1
+ import { readdir, stat, unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { getWorkersDir } from "../constants.ts";
4
+ import { atomicWrite, readWithMtime } from "../fs/atomic.ts";
5
+
6
+ export const WORKER_MODES = ["persist", "once"] as const;
7
+ export const WORKER_STATUSES = ["running", "stopped", "dead"] as const;
8
+
9
+ export type WorkerMode = (typeof WORKER_MODES)[number];
10
+ export type WorkerStatus = (typeof WORKER_STATUSES)[number];
11
+
12
+ /**
13
+ * Persistent worker record. One JSON file per worker at
14
+ * `<projectDir>/workers/<id>.json`. Heartbeats rewrite the file
15
+ * atomically (write-to-tmp + rename); the file's existence is the pidfile,
16
+ * `last_heartbeat_at` inside is the liveness signal.
17
+ */
18
+ export interface Worker {
19
+ id: string;
20
+ pid: number;
21
+ hostname: string;
22
+ mode: WorkerMode;
23
+ task_id: string | null;
24
+ status: WorkerStatus;
25
+ started_at: string;
26
+ last_heartbeat_at: string;
27
+ stopped_at: string | null;
28
+ log_path: string | null;
29
+ }
30
+
31
+ function workerFilePath(projectDir: string, id: string): string {
32
+ return join(getWorkersDir(projectDir), `${id}.json`);
33
+ }
34
+
35
+ async function readWorker(
36
+ projectDir: string,
37
+ id: string,
38
+ ): Promise<Worker | null> {
39
+ const file = await readWithMtime(workerFilePath(projectDir, id));
40
+ if (!file) return null;
41
+ try {
42
+ return JSON.parse(file.content) as Worker;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ async function writeWorker(projectDir: string, worker: Worker): Promise<void> {
49
+ await atomicWrite(
50
+ workerFilePath(projectDir, worker.id),
51
+ `${JSON.stringify(worker, null, 2)}\n`,
52
+ );
53
+ }
54
+
55
+ export async function registerWorker(
56
+ projectDir: string,
57
+ params: {
58
+ id: string;
59
+ pid: number;
60
+ hostname: string;
61
+ mode: WorkerMode;
62
+ taskId?: string | null;
63
+ logPath?: string | null;
64
+ },
65
+ ): Promise<Worker> {
66
+ const now = new Date().toISOString();
67
+ const worker: Worker = {
68
+ id: params.id,
69
+ pid: params.pid,
70
+ hostname: params.hostname,
71
+ mode: params.mode,
72
+ task_id: params.taskId ?? null,
73
+ status: "running",
74
+ started_at: now,
75
+ last_heartbeat_at: now,
76
+ stopped_at: null,
77
+ log_path: params.logPath ?? null,
78
+ };
79
+ await writeWorker(projectDir, worker);
80
+ return worker;
81
+ }
82
+
83
+ /**
84
+ * Update last_heartbeat_at on a running worker. No-op for stopped/dead
85
+ * workers so a misbehaving heartbeat doesn't resurrect a worker the reaper
86
+ * has retired.
87
+ */
88
+ export async function heartbeat(projectDir: string, id: string): Promise<void> {
89
+ const worker = await readWorker(projectDir, id);
90
+ if (!worker || worker.status !== "running") return;
91
+ worker.last_heartbeat_at = new Date().toISOString();
92
+ await writeWorker(projectDir, worker);
93
+ }
94
+
95
+ export async function markWorkerStopped(
96
+ projectDir: string,
97
+ id: string,
98
+ ): Promise<void> {
99
+ const worker = await readWorker(projectDir, id);
100
+ if (!worker || worker.status !== "running") return;
101
+ worker.status = "stopped";
102
+ worker.stopped_at = new Date().toISOString();
103
+ await writeWorker(projectDir, worker);
104
+ }
105
+
106
+ export async function markWorkerDead(
107
+ projectDir: string,
108
+ id: string,
109
+ ): Promise<void> {
110
+ const worker = await readWorker(projectDir, id);
111
+ if (!worker) return;
112
+ if (worker.status === "stopped") return; // don't overwrite a clean stop
113
+ worker.status = "dead";
114
+ worker.stopped_at = new Date().toISOString();
115
+ await writeWorker(projectDir, worker);
116
+ }
117
+
118
+ /**
119
+ * Walk `workers/`, mark any running worker as dead if its
120
+ * `last_heartbeat_at` is older than `staleAfterSeconds`. Tasks/schedules
121
+ * they held are reclaimed via the lockfile reapers driven by
122
+ * `isWorkerRunning`. Returns the ids that were just marked dead.
123
+ */
124
+ export async function reapDeadWorkers(
125
+ projectDir: string,
126
+ staleAfterSeconds: number,
127
+ ): Promise<string[]> {
128
+ const ids = await listWorkerIds(projectDir);
129
+ const cutoff = Date.now() - staleAfterSeconds * 1000;
130
+ const reaped: string[] = [];
131
+ for (const id of ids) {
132
+ const w = await readWorker(projectDir, id);
133
+ if (!w || w.status !== "running") continue;
134
+ const heartbeatMs = Date.parse(w.last_heartbeat_at);
135
+ if (Number.isFinite(heartbeatMs) && heartbeatMs >= cutoff) continue;
136
+ w.status = "dead";
137
+ w.stopped_at = new Date().toISOString();
138
+ await writeWorker(projectDir, w);
139
+ reaped.push(id);
140
+ }
141
+ return reaped;
142
+ }
143
+
144
+ export async function isWorkerRunning(
145
+ projectDir: string,
146
+ id: string,
147
+ ): Promise<boolean> {
148
+ const w = await readWorker(projectDir, id);
149
+ return w?.status === "running";
150
+ }
151
+
152
+ /**
153
+ * Delete cleanly-stopped worker JSON files whose `stopped_at` is older
154
+ * than `afterSeconds`. Dead workers are preserved as forensic evidence.
155
+ */
156
+ export async function pruneStoppedWorkers(
157
+ projectDir: string,
158
+ afterSeconds: number,
159
+ ): Promise<string[]> {
160
+ const ids = await listWorkerIds(projectDir);
161
+ const cutoff = Date.now() - afterSeconds * 1000;
162
+ const pruned: string[] = [];
163
+ for (const id of ids) {
164
+ const w = await readWorker(projectDir, id);
165
+ if (!w || w.status !== "stopped" || !w.stopped_at) continue;
166
+ const stoppedMs = Date.parse(w.stopped_at);
167
+ if (Number.isFinite(stoppedMs) && stoppedMs >= cutoff) continue;
168
+ try {
169
+ await unlink(workerFilePath(projectDir, id));
170
+ pruned.push(id);
171
+ } catch {
172
+ // ignore — concurrent delete is fine
173
+ }
174
+ }
175
+ return pruned;
176
+ }
177
+
178
+ export async function listWorkers(
179
+ projectDir: string,
180
+ filters?: {
181
+ status?: WorkerStatus;
182
+ limit?: number;
183
+ offset?: number;
184
+ },
185
+ ): Promise<Worker[]> {
186
+ const ids = await listWorkerIds(projectDir);
187
+ const out: Worker[] = [];
188
+ for (const id of ids) {
189
+ const w = await readWorker(projectDir, id);
190
+ if (!w) continue;
191
+ if (filters?.status && w.status !== filters.status) continue;
192
+ out.push(w);
193
+ }
194
+ out.sort((a, b) => (a.started_at < b.started_at ? 1 : -1));
195
+ const offset = filters?.offset ?? 0;
196
+ const limit = filters?.limit ?? out.length;
197
+ return out.slice(offset, offset + limit);
198
+ }
199
+
200
+ export async function getWorker(
201
+ projectDir: string,
202
+ id: string,
203
+ ): Promise<Worker | null> {
204
+ return readWorker(projectDir, id);
205
+ }
206
+
207
+ export async function deleteWorker(
208
+ projectDir: string,
209
+ id: string,
210
+ ): Promise<boolean> {
211
+ try {
212
+ await unlink(workerFilePath(projectDir, id));
213
+ return true;
214
+ } catch (err) {
215
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
216
+ throw err;
217
+ }
218
+ }
219
+
220
+ async function listWorkerIds(projectDir: string): Promise<string[]> {
221
+ const dir = getWorkersDir(projectDir);
222
+ try {
223
+ const names = await readdir(dir);
224
+ return names
225
+ .filter((n) => n.endsWith(".json"))
226
+ .map((n) => n.slice(0, -".json".length));
227
+ } catch (err) {
228
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
229
+ throw err;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * For tests / doctor: confirm a worker JSON file exists at the expected path.
235
+ */
236
+ export async function workerFileExists(
237
+ projectDir: string,
238
+ id: string,
239
+ ): Promise<boolean> {
240
+ try {
241
+ await stat(workerFilePath(projectDir, id));
242
+ return true;
243
+ } catch (err) {
244
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
245
+ throw err;
246
+ }
247
+ }