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
@@ -0,0 +1,486 @@
1
+ import { readdir, stat, unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import matter from "gray-matter";
4
+ import { getTasksDir, getTasksLockDir } from "../constants.ts";
5
+ import { uuidv7 } from "../db/uuid.ts";
6
+ import {
7
+ acquireLock,
8
+ atomicWrite,
9
+ atomicWriteIfUnchanged,
10
+ LockHeldError,
11
+ readLockHolder,
12
+ readWithMtime,
13
+ releaseLock,
14
+ } from "../fs/atomic.ts";
15
+ import { logger } from "../utils/logger.ts";
16
+ import {
17
+ type Task,
18
+ type TaskFrontmatter,
19
+ TaskFrontmatterSchema,
20
+ type TaskPriority,
21
+ type TaskStatus,
22
+ } from "./schema.ts";
23
+
24
+ function taskFilePath(projectDir: string, id: string): string {
25
+ return join(getTasksDir(projectDir), `${id}.md`);
26
+ }
27
+
28
+ function taskLockPath(projectDir: string, id: string): string {
29
+ return join(getTasksLockDir(projectDir), `${id}.lock`);
30
+ }
31
+
32
+ /**
33
+ * Render a Task to its on-disk markdown form. Frontmatter contains every
34
+ * field; the body is preserved as-is. Trailing newline keeps line count sane.
35
+ */
36
+ function serializeTask(fm: TaskFrontmatter, body: string): string {
37
+ return matter.stringify(`\n${body.trim()}\n`, fm as Record<string, unknown>);
38
+ }
39
+
40
+ interface ParseResult {
41
+ ok: true;
42
+ task: Task;
43
+ }
44
+ interface ParseFailure {
45
+ ok: false;
46
+ reason: string;
47
+ }
48
+
49
+ function parseTaskFile(
50
+ raw: string,
51
+ mtimeMs: number,
52
+ ): ParseResult | ParseFailure {
53
+ let parsed: matter.GrayMatterFile<string>;
54
+ try {
55
+ parsed = matter(raw);
56
+ } catch (err) {
57
+ return { ok: false, reason: `frontmatter parse error: ${err}` };
58
+ }
59
+ const result = TaskFrontmatterSchema.safeParse(parsed.data);
60
+ if (!result.success) {
61
+ return {
62
+ ok: false,
63
+ reason: `frontmatter validation failed: ${result.error.message}`,
64
+ };
65
+ }
66
+ return {
67
+ ok: true,
68
+ task: {
69
+ ...result.data,
70
+ mtimeMs,
71
+ body: parsed.content.trim(),
72
+ },
73
+ };
74
+ }
75
+
76
+ export async function listTaskFiles(projectDir: string): Promise<string[]> {
77
+ const dir = getTasksDir(projectDir);
78
+ try {
79
+ const names = await readdir(dir);
80
+ return names.filter((n) => n.endsWith(".md")).map((n) => n.slice(0, -3));
81
+ } catch (err) {
82
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
83
+ throw err;
84
+ }
85
+ }
86
+
87
+ export async function getTask(
88
+ projectDir: string,
89
+ id: string,
90
+ ): Promise<Task | null> {
91
+ const file = await readWithMtime(taskFilePath(projectDir, id));
92
+ if (!file) return null;
93
+ const parsed = parseTaskFile(file.content, file.mtimeMs);
94
+ if (!parsed.ok) {
95
+ logger.warn(`Task ${id} is malformed: ${parsed.reason}`);
96
+ return null;
97
+ }
98
+ return parsed.task;
99
+ }
100
+
101
+ export async function listTasks(
102
+ projectDir: string,
103
+ filters?: {
104
+ status?: TaskStatus;
105
+ priority?: TaskPriority;
106
+ limit?: number;
107
+ offset?: number;
108
+ },
109
+ ): Promise<Task[]> {
110
+ const ids = await listTaskFiles(projectDir);
111
+ const tasks: Task[] = [];
112
+ for (const id of ids) {
113
+ const t = await getTask(projectDir, id);
114
+ if (!t) continue;
115
+ if (filters?.status && t.status !== filters.status) continue;
116
+ if (filters?.priority && t.priority !== filters.priority) continue;
117
+ tasks.push(t);
118
+ }
119
+ tasks.sort((a, b) => {
120
+ if (a.created_at !== b.created_at)
121
+ return a.created_at < b.created_at ? 1 : -1;
122
+ return a.id < b.id ? 1 : -1;
123
+ });
124
+ const offset = filters?.offset ?? 0;
125
+ const limit = filters?.limit ?? tasks.length;
126
+ return tasks.slice(offset, offset + limit);
127
+ }
128
+
129
+ export class TaskNotFoundError extends Error {
130
+ constructor(readonly id: string) {
131
+ super(`Task not found: ${id}`);
132
+ this.name = "TaskNotFoundError";
133
+ }
134
+ }
135
+
136
+ export class CircularDependencyError extends Error {
137
+ constructor(message: string) {
138
+ super(message);
139
+ this.name = "CircularDependencyError";
140
+ }
141
+ }
142
+
143
+ export async function validateBlockedBy(
144
+ projectDir: string,
145
+ taskId: string,
146
+ blockedBy: string[],
147
+ ): Promise<void> {
148
+ if (blockedBy.length === 0) return;
149
+ if (blockedBy.includes(taskId)) {
150
+ throw new CircularDependencyError(`task ${taskId} cannot block itself`);
151
+ }
152
+ const visited = new Set<string>();
153
+ const dfs = async (currentId: string): Promise<void> => {
154
+ if (visited.has(currentId)) return;
155
+ visited.add(currentId);
156
+ const t = await getTask(projectDir, currentId);
157
+ if (!t) return;
158
+ for (const dep of t.blocked_by) {
159
+ if (dep === taskId) {
160
+ throw new CircularDependencyError(
161
+ `adding blocked_by would create a cycle involving task ${taskId}`,
162
+ );
163
+ }
164
+ await dfs(dep);
165
+ }
166
+ };
167
+ for (const blockerId of blockedBy) await dfs(blockerId);
168
+ }
169
+
170
+ export async function createTask(
171
+ projectDir: string,
172
+ params: {
173
+ name: string;
174
+ description?: string;
175
+ priority?: TaskPriority;
176
+ blocked_by?: string[];
177
+ context_paths?: string[];
178
+ },
179
+ ): Promise<Task> {
180
+ const id = uuidv7();
181
+ await validateBlockedBy(projectDir, id, params.blocked_by ?? []);
182
+ const now = new Date().toISOString();
183
+ const fm: TaskFrontmatter = {
184
+ id,
185
+ name: params.name,
186
+ description: params.description ?? "",
187
+ priority: params.priority ?? "medium",
188
+ status: "pending",
189
+ blocked_by: params.blocked_by ?? [],
190
+ context_paths: params.context_paths ?? [],
191
+ output: null,
192
+ waiting_reason: null,
193
+ claimed_by: null,
194
+ claimed_at: null,
195
+ created_at: now,
196
+ updated_at: now,
197
+ };
198
+ const path = taskFilePath(projectDir, id);
199
+ await atomicWrite(path, serializeTask(fm, params.description ?? ""));
200
+ const fresh = await getTask(projectDir, id);
201
+ if (!fresh) throw new Error(`Failed to read freshly created task ${id}`);
202
+ return fresh;
203
+ }
204
+
205
+ export async function updateTask(
206
+ projectDir: string,
207
+ id: string,
208
+ updates: Partial<
209
+ Pick<
210
+ TaskFrontmatter,
211
+ "name" | "description" | "priority" | "blocked_by" | "status"
212
+ >
213
+ >,
214
+ ): Promise<Task | null> {
215
+ const t = await getTask(projectDir, id);
216
+ if (!t) return null;
217
+ if (updates.blocked_by !== undefined) {
218
+ await validateBlockedBy(projectDir, id, updates.blocked_by);
219
+ }
220
+ // Drop undefined keys so a `Partial` that omits a field doesn't overwrite
221
+ // the on-disk value with `undefined` (YAML can't serialize undefined and
222
+ // we'd lose the field anyway).
223
+ const definedUpdates = Object.fromEntries(
224
+ Object.entries(updates).filter(([, v]) => v !== undefined),
225
+ );
226
+ const fm: TaskFrontmatter = {
227
+ ...t,
228
+ ...definedUpdates,
229
+ updated_at: new Date().toISOString(),
230
+ };
231
+ await atomicWriteIfUnchanged(
232
+ taskFilePath(projectDir, id),
233
+ serializeTask(fm, t.body),
234
+ t.mtimeMs,
235
+ );
236
+ return getTask(projectDir, id);
237
+ }
238
+
239
+ export async function deleteTask(
240
+ projectDir: string,
241
+ id: string,
242
+ ): Promise<boolean> {
243
+ try {
244
+ await unlink(taskFilePath(projectDir, id));
245
+ } catch (err) {
246
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
247
+ throw err;
248
+ }
249
+ await releaseLock(taskLockPath(projectDir, id));
250
+ return true;
251
+ }
252
+
253
+ export async function deleteAllTasks(projectDir: string): Promise<number> {
254
+ const ids = await listTaskFiles(projectDir);
255
+ let n = 0;
256
+ for (const id of ids) {
257
+ if (await deleteTask(projectDir, id)) n++;
258
+ }
259
+ return n;
260
+ }
261
+
262
+ /**
263
+ * Mark a task complete/failed/waiting and update output / waiting_reason.
264
+ * Atomic-write-if-unchanged so a concurrent vim save doesn't get clobbered.
265
+ */
266
+ export async function updateTaskStatus(
267
+ projectDir: string,
268
+ id: string,
269
+ status: TaskStatus,
270
+ reason?: string | null,
271
+ output?: string | null,
272
+ ): Promise<void> {
273
+ const t = await getTask(projectDir, id);
274
+ if (!t) throw new TaskNotFoundError(id);
275
+ const fm: TaskFrontmatter = {
276
+ ...t,
277
+ status,
278
+ waiting_reason: reason ?? null,
279
+ output: output ?? null,
280
+ claimed_by: status === "in_progress" ? t.claimed_by : null,
281
+ claimed_at: status === "in_progress" ? t.claimed_at : null,
282
+ updated_at: new Date().toISOString(),
283
+ };
284
+ await atomicWriteIfUnchanged(
285
+ taskFilePath(projectDir, id),
286
+ serializeTask(fm, t.body),
287
+ t.mtimeMs,
288
+ );
289
+ }
290
+
291
+ /**
292
+ * Reset tasks whose `claimed_at` is older than `timeoutSeconds` back to
293
+ * pending. Used by the worker tick to recover from crashed claimers whose
294
+ * lockfile was reaped but whose task file still says in_progress.
295
+ */
296
+ export async function resetStaleTasks(
297
+ projectDir: string,
298
+ timeoutSeconds: number,
299
+ ): Promise<string[]> {
300
+ const ids = await listTaskFiles(projectDir);
301
+ const cutoff = Date.now() - timeoutSeconds * 1000;
302
+ const reset: string[] = [];
303
+ for (const id of ids) {
304
+ const t = await getTask(projectDir, id);
305
+ if (!t || t.status !== "in_progress") continue;
306
+ const claimedAt = t.claimed_at ? Date.parse(t.claimed_at) : Date.now();
307
+ if (claimedAt >= cutoff) continue;
308
+ const fm: TaskFrontmatter = {
309
+ ...t,
310
+ status: "pending",
311
+ claimed_by: null,
312
+ claimed_at: null,
313
+ updated_at: new Date().toISOString(),
314
+ };
315
+ try {
316
+ await atomicWriteIfUnchanged(
317
+ taskFilePath(projectDir, id),
318
+ serializeTask(fm, t.body),
319
+ t.mtimeMs,
320
+ );
321
+ // Best-effort: drop a stale lockfile too, in case it got missed.
322
+ await releaseLock(taskLockPath(projectDir, id));
323
+ reset.push(id);
324
+ } catch {
325
+ // Concurrent write — try again next tick.
326
+ }
327
+ }
328
+ return reset;
329
+ }
330
+
331
+ /**
332
+ * Attempt to claim the highest-priority unblocked pending task by acquiring
333
+ * its lockfile via O_EXCL. Returns the claimed task on success, null if no
334
+ * eligible task is available or every candidate is contended.
335
+ *
336
+ * On success, the task's frontmatter is updated to status=in_progress,
337
+ * claimed_by=workerId, claimed_at=now via atomic-write-if-unchanged. The
338
+ * caller releases the lock by calling `releaseTaskLock(id)` after writing
339
+ * the terminal status.
340
+ */
341
+ export async function claimNextTask(
342
+ projectDir: string,
343
+ workerId: string,
344
+ ): Promise<Task | null> {
345
+ const all = await listTasks(projectDir, { status: "pending" });
346
+ // Highest priority first, then oldest first.
347
+ all.sort((a, b) => {
348
+ const pa = priorityRank(a.priority);
349
+ const pb = priorityRank(b.priority);
350
+ if (pa !== pb) return pa - pb;
351
+ return a.created_at < b.created_at ? -1 : 1;
352
+ });
353
+
354
+ for (const candidate of all) {
355
+ if (!(await isUnblocked(projectDir, candidate))) continue;
356
+ const claimed = await tryClaim(projectDir, candidate.id, workerId);
357
+ if (claimed) return claimed;
358
+ }
359
+ return null;
360
+ }
361
+
362
+ export async function claimSpecificTask(
363
+ projectDir: string,
364
+ id: string,
365
+ workerId: string,
366
+ ): Promise<Task | null> {
367
+ const t = await getTask(projectDir, id);
368
+ if (!t || t.status !== "pending") return null;
369
+ return tryClaim(projectDir, id, workerId);
370
+ }
371
+
372
+ async function tryClaim(
373
+ projectDir: string,
374
+ id: string,
375
+ workerId: string,
376
+ ): Promise<Task | null> {
377
+ const lockPath = taskLockPath(projectDir, id);
378
+ try {
379
+ await acquireLock(lockPath, workerId);
380
+ } catch (err) {
381
+ if (err instanceof LockHeldError) return null;
382
+ throw err;
383
+ }
384
+ try {
385
+ const t = await getTask(projectDir, id);
386
+ if (!t || t.status !== "pending") {
387
+ await releaseLock(lockPath);
388
+ return null;
389
+ }
390
+ const now = new Date().toISOString();
391
+ const fm: TaskFrontmatter = {
392
+ ...t,
393
+ status: "in_progress",
394
+ claimed_by: workerId,
395
+ claimed_at: now,
396
+ updated_at: now,
397
+ };
398
+ try {
399
+ await atomicWriteIfUnchanged(
400
+ taskFilePath(projectDir, id),
401
+ serializeTask(fm, t.body),
402
+ t.mtimeMs,
403
+ );
404
+ } catch (err) {
405
+ await releaseLock(lockPath);
406
+ throw err;
407
+ }
408
+ return getTask(projectDir, id);
409
+ } catch (err) {
410
+ await releaseLock(lockPath);
411
+ throw err;
412
+ }
413
+ }
414
+
415
+ export async function releaseTaskLock(
416
+ projectDir: string,
417
+ id: string,
418
+ ): Promise<void> {
419
+ await releaseLock(taskLockPath(projectDir, id));
420
+ }
421
+
422
+ function priorityRank(p: TaskPriority): number {
423
+ return p === "high" ? 0 : p === "medium" ? 1 : 2;
424
+ }
425
+
426
+ async function isUnblocked(projectDir: string, t: Task): Promise<boolean> {
427
+ if (t.blocked_by.length === 0) return true;
428
+ for (const blockerId of t.blocked_by) {
429
+ const blocker = await getTask(projectDir, blockerId);
430
+ if (!blocker || blocker.status !== "complete") return false;
431
+ }
432
+ return true;
433
+ }
434
+
435
+ /**
436
+ * Reaper: walk tasks/.locks/, for each lock determine the holder; if that
437
+ * worker is dead/missing per `isWorkerAlive`, drop the lock so the next
438
+ * tick can re-claim. Returns the released lock-task ids.
439
+ */
440
+ export async function reapOrphanLocks(
441
+ projectDir: string,
442
+ isWorkerAlive: (workerId: string) => Promise<boolean>,
443
+ ): Promise<string[]> {
444
+ const dir = getTasksLockDir(projectDir);
445
+ let names: string[];
446
+ try {
447
+ names = await readdir(dir);
448
+ } catch (err) {
449
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
450
+ throw err;
451
+ }
452
+ const released: string[] = [];
453
+ for (const name of names) {
454
+ if (!name.endsWith(".lock")) continue;
455
+ const id = name.slice(0, -".lock".length);
456
+ const lockPath = join(dir, name);
457
+ const holder = await readLockHolder(lockPath);
458
+ if (!holder) {
459
+ await releaseLock(lockPath);
460
+ released.push(id);
461
+ continue;
462
+ }
463
+ if (!(await isWorkerAlive(holder))) {
464
+ await releaseLock(lockPath);
465
+ released.push(id);
466
+ }
467
+ }
468
+ return released;
469
+ }
470
+
471
+ /**
472
+ * Probe lockfile mtime to confirm a file exists. Used by the worker's main
473
+ * loop to confirm its claim is still held by us before writing terminal status.
474
+ */
475
+ export async function lockExists(
476
+ projectDir: string,
477
+ id: string,
478
+ ): Promise<boolean> {
479
+ try {
480
+ await stat(taskLockPath(projectDir, id));
481
+ return true;
482
+ } catch (err) {
483
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
484
+ throw err;
485
+ }
486
+ }