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
@@ -0,0 +1,559 @@
1
+ import { appendFile, readdir, readFile, rm, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { getThreadsDir } from "../constants.ts";
4
+ import { uuidv7 } from "../db/uuid.ts";
5
+ import { atomicWrite } from "../fs/atomic.ts";
6
+ import { DATE_DIR_RE, dateForId } from "../utils/v7-date.ts";
7
+
8
+ /**
9
+ * Thread + interaction history, stored as CSV files under
10
+ * `<projectDir>/threads/<YYYY-MM-DD>/<id>.csv`. Threads live OUTSIDE
11
+ * `context/` because they're system metadata, not user-curated knowledge —
12
+ * the regular context reindex skips them. Agents search threads through
13
+ * the dedicated `search_threads` tool instead.
14
+ *
15
+ * CSV schema (8 columns, RFC-4180 quoting):
16
+ * created_at, role, kind, content, tool_name, tool_input,
17
+ * duration_ms, token_count
18
+ *
19
+ * Thread metadata (title, source_type, parent_task_id, ended_at) is encoded
20
+ * as a synthetic first row with `kind="thread_meta"` whose `content` is a
21
+ * JSON blob. End-of-thread is a `kind="thread_ended"` row. That keeps the
22
+ * format pure CSV — no sidecar files, no frontmatter — at the cost of a
23
+ * full file rewrite when we need to update the title.
24
+ */
25
+
26
+ export type ThreadType = "worker_tick" | "chat_session";
27
+ export type InteractionRole = "user" | "assistant" | "system" | "tool";
28
+ export type InteractionKind =
29
+ | "message"
30
+ | "thinking"
31
+ | "tool_use"
32
+ | "tool_result"
33
+ | "context_update"
34
+ | "status_change";
35
+
36
+ export interface Thread {
37
+ id: string;
38
+ type: ThreadType;
39
+ task_id: string | null;
40
+ title: string;
41
+ started_at: Date;
42
+ ended_at: Date | null;
43
+ }
44
+
45
+ export interface Interaction {
46
+ id: string; // synthesized as `<thread_id>:<sequence>` for back-compat with callers
47
+ thread_id: string;
48
+ sequence: number;
49
+ role: InteractionRole;
50
+ kind: InteractionKind;
51
+ content: string;
52
+ tool_name: string | null;
53
+ tool_input: string | null;
54
+ duration_ms: number | null;
55
+ token_count: number | null;
56
+ created_at: Date;
57
+ }
58
+
59
+ const HEADER =
60
+ "created_at,role,kind,content,tool_name,tool_input,duration_ms,token_count\n";
61
+
62
+ interface ThreadMetaPayload {
63
+ type: ThreadType;
64
+ task_id: string | null;
65
+ title: string;
66
+ started_at: string;
67
+ }
68
+
69
+ /**
70
+ * The canonical write path for `id`: `threads/<YYYY-MM-DD>/<id>.csv`. The
71
+ * date subdir keeps the directory bounded as conversations accumulate;
72
+ * deriving the date from the id (not from `Date.now()`) means the path is
73
+ * a pure function of the id, so reads after a process restart land in the
74
+ * same place.
75
+ */
76
+ function threadFilePath(projectDir: string, id: string): string {
77
+ return join(getThreadsDir(projectDir), dateForId(id), `${id}.csv`);
78
+ }
79
+
80
+ /**
81
+ * Locate the CSV for `id`. Tries the predicted v7-derived path first, then
82
+ * falls back to walking date subdirs — this catches legacy ids without a
83
+ * v7 timestamp and the rare case where a thread file got moved between
84
+ * dirs by hand. Returns null if no match exists.
85
+ */
86
+ async function findThreadFile(
87
+ projectDir: string,
88
+ id: string,
89
+ ): Promise<string | null> {
90
+ const predicted = threadFilePath(projectDir, id);
91
+ try {
92
+ await stat(predicted);
93
+ return predicted;
94
+ } catch (err) {
95
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
96
+ }
97
+ // Fallback: walk date subdirs. Cheap because there's at most one file
98
+ // per (date, id) pair and the dir count grows with calendar days, not
99
+ // thread volume.
100
+ const root = getThreadsDir(projectDir);
101
+ let entries: string[];
102
+ try {
103
+ entries = await readdir(root);
104
+ } catch (err) {
105
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
106
+ throw err;
107
+ }
108
+ for (const entry of entries) {
109
+ if (!DATE_DIR_RE.test(entry)) continue;
110
+ const candidate = join(root, entry, `${id}.csv`);
111
+ try {
112
+ await stat(candidate);
113
+ return candidate;
114
+ } catch (err) {
115
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+
121
+ /** Yield every `<id>` whose CSV exists somewhere under `threads/`. */
122
+ async function listThreadIds(projectDir: string): Promise<string[]> {
123
+ const root = getThreadsDir(projectDir);
124
+ let dateDirs: string[];
125
+ try {
126
+ dateDirs = await readdir(root);
127
+ } catch (err) {
128
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
129
+ throw err;
130
+ }
131
+ const ids: string[] = [];
132
+ for (const dir of dateDirs) {
133
+ if (!DATE_DIR_RE.test(dir)) continue;
134
+ let names: string[];
135
+ try {
136
+ names = await readdir(join(root, dir));
137
+ } catch {
138
+ continue;
139
+ }
140
+ for (const name of names) {
141
+ if (name.endsWith(".csv")) ids.push(name.slice(0, -".csv".length));
142
+ }
143
+ }
144
+ return ids;
145
+ }
146
+
147
+ function csvField(value: string | number | null | undefined): string {
148
+ if (value === null || value === undefined) return "";
149
+ const s = String(value);
150
+ if (/[",\r\n]/.test(s)) {
151
+ return `"${s.replace(/"/g, '""')}"`;
152
+ }
153
+ return s;
154
+ }
155
+
156
+ function csvRow(cells: Array<string | number | null | undefined>): string {
157
+ return `${cells.map(csvField).join(",")}\n`;
158
+ }
159
+
160
+ /**
161
+ * Parse a CSV file produced by this module. Accepts RFC-4180 quoting:
162
+ * - fields may be quoted with `"`,
163
+ * - inside a quoted field, `""` is an escaped quote,
164
+ * - quoted fields may span newlines.
165
+ */
166
+ function parseCsv(text: string): string[][] {
167
+ const rows: string[][] = [];
168
+ let row: string[] = [];
169
+ let field = "";
170
+ let inQuotes = false;
171
+ for (let i = 0; i < text.length; i++) {
172
+ const ch = text[i];
173
+ if (inQuotes) {
174
+ if (ch === '"') {
175
+ if (text[i + 1] === '"') {
176
+ field += '"';
177
+ i++;
178
+ } else {
179
+ inQuotes = false;
180
+ }
181
+ } else {
182
+ field += ch;
183
+ }
184
+ continue;
185
+ }
186
+ if (ch === '"') {
187
+ inQuotes = true;
188
+ continue;
189
+ }
190
+ if (ch === ",") {
191
+ row.push(field);
192
+ field = "";
193
+ continue;
194
+ }
195
+ if (ch === "\r") continue;
196
+ if (ch === "\n") {
197
+ row.push(field);
198
+ field = "";
199
+ rows.push(row);
200
+ row = [];
201
+ continue;
202
+ }
203
+ field += ch;
204
+ }
205
+ if (field.length > 0 || row.length > 0) {
206
+ row.push(field);
207
+ rows.push(row);
208
+ }
209
+ return rows;
210
+ }
211
+
212
+ export async function createThread(
213
+ projectDir: string,
214
+ type: ThreadType,
215
+ taskId?: string,
216
+ title?: string,
217
+ ): Promise<string> {
218
+ const id = uuidv7();
219
+ const now = new Date().toISOString();
220
+ const meta: ThreadMetaPayload = {
221
+ type,
222
+ task_id: taskId ?? null,
223
+ title: title ?? "",
224
+ started_at: now,
225
+ };
226
+ const body =
227
+ HEADER +
228
+ csvRow([
229
+ now,
230
+ "system",
231
+ "thread_meta",
232
+ JSON.stringify(meta),
233
+ "",
234
+ "",
235
+ "",
236
+ "",
237
+ ]);
238
+ await atomicWrite(threadFilePath(projectDir, id), body);
239
+ return id;
240
+ }
241
+
242
+ export async function logInteraction(
243
+ projectDir: string,
244
+ threadId: string,
245
+ params: {
246
+ role: InteractionRole;
247
+ kind: InteractionKind;
248
+ content: string;
249
+ toolName?: string;
250
+ toolInput?: string;
251
+ durationMs?: number;
252
+ tokenCount?: number;
253
+ },
254
+ ): Promise<string> {
255
+ const path =
256
+ (await findThreadFile(projectDir, threadId)) ??
257
+ threadFilePath(projectDir, threadId);
258
+ const row = csvRow([
259
+ new Date().toISOString(),
260
+ params.role,
261
+ params.kind,
262
+ params.content,
263
+ params.toolName ?? "",
264
+ params.toolInput ?? "",
265
+ params.durationMs ?? "",
266
+ params.tokenCount ?? "",
267
+ ]);
268
+ // Append is atomic-enough for a single writer (each thread is owned by
269
+ // one chat session or one worker tick at a time). If a second writer
270
+ // sneaks in we get interleaved bytes — a known accepted limitation; we
271
+ // can swap in a lockfile per-thread if it becomes an issue.
272
+ await appendFile(path, row, "utf-8");
273
+ // Synthesize an id stable across reads: `<thread>:<seq>`. Sequence is
274
+ // the data row index (rows after the header).
275
+ const sequence = (await readRows(path)).length - 1;
276
+ return `${threadId}:${sequence}`;
277
+ }
278
+
279
+ export async function endThread(
280
+ projectDir: string,
281
+ threadId: string,
282
+ ): Promise<void> {
283
+ const path =
284
+ (await findThreadFile(projectDir, threadId)) ??
285
+ threadFilePath(projectDir, threadId);
286
+ await appendFile(
287
+ path,
288
+ csvRow([
289
+ new Date().toISOString(),
290
+ "system",
291
+ "thread_ended",
292
+ "",
293
+ "",
294
+ "",
295
+ "",
296
+ "",
297
+ ]),
298
+ "utf-8",
299
+ );
300
+ }
301
+
302
+ export async function reopenThread(
303
+ projectDir: string,
304
+ threadId: string,
305
+ ): Promise<void> {
306
+ // "Reopen" = drop the most recent thread_ended marker if there is one.
307
+ const path = await findThreadFile(projectDir, threadId);
308
+ if (!path) return;
309
+ const rows = await readRows(path);
310
+ if (rows.length === 0) return;
311
+ const last = rows[rows.length - 1];
312
+ if (!last) return;
313
+ if (last[2] !== "thread_ended") return;
314
+ rows.pop();
315
+ await rewrite(path, rows);
316
+ }
317
+
318
+ export async function updateThreadTitle(
319
+ projectDir: string,
320
+ threadId: string,
321
+ title: string,
322
+ ): Promise<void> {
323
+ const path = await findThreadFile(projectDir, threadId);
324
+ if (!path) return;
325
+ const rows = await readRows(path);
326
+ const metaIdx = rows.findIndex((r) => r[2] === "thread_meta");
327
+ if (metaIdx === -1) return;
328
+ const metaRow = rows[metaIdx];
329
+ if (!metaRow) return;
330
+ let meta: ThreadMetaPayload;
331
+ try {
332
+ meta = JSON.parse(metaRow[3] ?? "{}") as ThreadMetaPayload;
333
+ } catch {
334
+ return;
335
+ }
336
+ meta.title = title;
337
+ metaRow[3] = JSON.stringify(meta);
338
+ await rewrite(path, rows);
339
+ }
340
+
341
+ export async function getThread(
342
+ projectDir: string,
343
+ threadId: string,
344
+ ): Promise<{ thread: Thread; interactions: Interaction[] } | null> {
345
+ const path = await findThreadFile(projectDir, threadId);
346
+ if (!path) return null;
347
+ const rows = await readRows(path);
348
+ if (rows.length === 0) return null;
349
+ return rowsToThread(threadId, rows);
350
+ }
351
+
352
+ export async function deleteThread(
353
+ projectDir: string,
354
+ threadId: string,
355
+ ): Promise<boolean> {
356
+ const path = await findThreadFile(projectDir, threadId);
357
+ if (!path) return false;
358
+ try {
359
+ await rm(path);
360
+ return true;
361
+ } catch (err) {
362
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
363
+ throw err;
364
+ }
365
+ }
366
+
367
+ export async function deleteAllThreads(
368
+ projectDir: string,
369
+ ): Promise<{ threads: number; interactions: number }> {
370
+ const root = getThreadsDir(projectDir);
371
+ let dateDirs: string[];
372
+ try {
373
+ dateDirs = await readdir(root);
374
+ } catch (err) {
375
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
376
+ return { threads: 0, interactions: 0 };
377
+ }
378
+ throw err;
379
+ }
380
+ let threads = 0;
381
+ let interactions = 0;
382
+ for (const dir of dateDirs) {
383
+ if (!DATE_DIR_RE.test(dir)) continue;
384
+ const subdir = join(root, dir);
385
+ let names: string[];
386
+ try {
387
+ names = await readdir(subdir);
388
+ } catch {
389
+ continue;
390
+ }
391
+ for (const name of names) {
392
+ if (!name.endsWith(".csv")) continue;
393
+ const path = join(subdir, name);
394
+ const rows = await readRows(path);
395
+ interactions += Math.max(0, rows.length - 1); // exclude meta row
396
+ await rm(path).catch(() => {});
397
+ threads++;
398
+ }
399
+ // Best-effort cleanup of the now-empty date dir.
400
+ await rm(subdir, { recursive: false }).catch(() => {});
401
+ }
402
+ return { threads, interactions };
403
+ }
404
+
405
+ export async function getInteractionsAfter(
406
+ projectDir: string,
407
+ threadId: string,
408
+ afterSequence: number,
409
+ ): Promise<Interaction[]> {
410
+ const t = await getThread(projectDir, threadId);
411
+ if (!t) return [];
412
+ return t.interactions.filter((i) => i.sequence > afterSequence);
413
+ }
414
+
415
+ export async function getActiveThread(
416
+ projectDir: string,
417
+ ): Promise<Thread | null> {
418
+ const summaries = await listThreads(projectDir);
419
+ for (const t of summaries) {
420
+ if (!t.ended_at) return t;
421
+ }
422
+ return null;
423
+ }
424
+
425
+ export async function isThreadEnded(
426
+ projectDir: string,
427
+ threadId: string,
428
+ ): Promise<boolean> {
429
+ const t = await getThread(projectDir, threadId);
430
+ if (!t) return true;
431
+ return t.thread.ended_at !== null;
432
+ }
433
+
434
+ export async function listThreads(
435
+ projectDir: string,
436
+ filters?: {
437
+ type?: ThreadType;
438
+ taskId?: string;
439
+ limit?: number;
440
+ offset?: number;
441
+ },
442
+ ): Promise<Thread[]> {
443
+ const ids = await listThreadIds(projectDir);
444
+ const out: Thread[] = [];
445
+ for (const id of ids) {
446
+ const data = await getThread(projectDir, id);
447
+ if (!data) continue;
448
+ const t = data.thread;
449
+ if (filters?.type && t.type !== filters.type) continue;
450
+ if (filters?.taskId && t.task_id !== filters.taskId) continue;
451
+ out.push(t);
452
+ }
453
+ out.sort((a, b) => b.started_at.getTime() - a.started_at.getTime());
454
+ const offset = filters?.offset ?? 0;
455
+ const limit = filters?.limit ?? out.length;
456
+ return out.slice(offset, offset + limit);
457
+ }
458
+
459
+ // ---------------------------------------------------------------------------
460
+ // internals
461
+ // ---------------------------------------------------------------------------
462
+
463
+ async function readRows(path: string): Promise<string[][]> {
464
+ let text: string;
465
+ try {
466
+ text = await readFile(path, "utf-8");
467
+ } catch (err) {
468
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
469
+ throw err;
470
+ }
471
+ const rows = parseCsv(text);
472
+ if (rows.length > 0 && rows[0]?.[0] === "created_at") {
473
+ rows.shift(); // drop header
474
+ }
475
+ return rows;
476
+ }
477
+
478
+ async function rewrite(path: string, rows: string[][]): Promise<void> {
479
+ const body =
480
+ HEADER +
481
+ rows
482
+ .map((r) =>
483
+ csvRow([
484
+ r[0] ?? "",
485
+ r[1] ?? "",
486
+ r[2] ?? "",
487
+ r[3] ?? "",
488
+ r[4] ?? "",
489
+ r[5] ?? "",
490
+ r[6] ?? "",
491
+ r[7] ?? "",
492
+ ]),
493
+ )
494
+ .join("");
495
+ await atomicWrite(path, body);
496
+ }
497
+
498
+ function rowsToThread(
499
+ threadId: string,
500
+ rows: string[][],
501
+ ): { thread: Thread; interactions: Interaction[] } | null {
502
+ const metaRow = rows.find((r) => r[2] === "thread_meta");
503
+ if (!metaRow) return null;
504
+ let meta: ThreadMetaPayload;
505
+ try {
506
+ meta = JSON.parse(metaRow[3] ?? "{}") as ThreadMetaPayload;
507
+ } catch {
508
+ return null;
509
+ }
510
+ const startedAt = new Date(meta.started_at);
511
+ const endedRow = [...rows].reverse().find((r) => r[2] === "thread_ended");
512
+ const endedAt = endedRow ? new Date(endedRow[0] ?? "") : null;
513
+
514
+ const interactions: Interaction[] = [];
515
+ let seq = 0;
516
+ for (const r of rows) {
517
+ if (r[2] === "thread_meta" || r[2] === "thread_ended") continue;
518
+ seq += 1;
519
+ const role = (r[1] ?? "system") as InteractionRole;
520
+ const kind = (r[2] ?? "message") as InteractionKind;
521
+ interactions.push({
522
+ id: `${threadId}:${seq}`,
523
+ thread_id: threadId,
524
+ sequence: seq,
525
+ role,
526
+ kind,
527
+ content: r[3] ?? "",
528
+ tool_name: r[4] ? r[4] : null,
529
+ tool_input: r[5] ? r[5] : null,
530
+ duration_ms: r[6] ? Number(r[6]) : null,
531
+ token_count: r[7] ? Number(r[7]) : null,
532
+ created_at: new Date(r[0] ?? ""),
533
+ });
534
+ }
535
+
536
+ return {
537
+ thread: {
538
+ id: threadId,
539
+ type: meta.type,
540
+ task_id: meta.task_id,
541
+ title: meta.title,
542
+ started_at: startedAt,
543
+ ended_at: endedAt,
544
+ },
545
+ interactions,
546
+ };
547
+ }
548
+
549
+ /** Best-effort ensure the threads directory exists (e.g. before first write). */
550
+ export async function ensureThreadsDir(projectDir: string): Promise<void> {
551
+ const dir = getThreadsDir(projectDir);
552
+ try {
553
+ await stat(dir);
554
+ } catch (err) {
555
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
556
+ const { mkdir } = await import("node:fs/promises");
557
+ await mkdir(dir, { recursive: true });
558
+ }
559
+ }
@@ -12,42 +12,63 @@ const inputSchema = z.object({
12
12
  });
13
13
 
14
14
  const outputSchema = z.object({
15
- path: z.string(),
15
+ path: z.string().nullable(),
16
16
  internal_tool_count: z.number(),
17
17
  mcp_tool_count: z.number(),
18
18
  created_file: z.boolean(),
19
19
  message: z.string(),
20
20
  is_error: z.boolean(),
21
+ error_type: z.string().optional(),
22
+ next_action_hint: z.string().optional(),
21
23
  });
22
24
 
23
25
  export const capabilitiesRefreshTool = {
24
26
  name: "capabilities_refresh",
25
27
  description:
26
- "[[ bash equivalent command: which ]] Rescan every available tool (built-in + configured MCPX servers) and rewrite `.botholomew/capabilities.md`. Call this when you think the inventory is stale — new MCP servers were added, tools were renamed, or the capabilities file was deleted. The regenerated file is automatically loaded into every subsequent system prompt.",
28
+ "[[ bash equivalent command: which ]] Rescan every available tool (built-in + configured MCPX servers) and rewrite `prompts/capabilities.md`. Call this when you think the inventory is stale — new MCP servers were added, tools were renamed, or the capabilities file was deleted. The regenerated file is automatically loaded into every subsequent system prompt.",
27
29
  group: "capabilities",
28
30
  inputSchema,
29
31
  outputSchema,
30
32
  execute: async (input, ctx) => {
31
33
  const includeMcp = input.include_mcp !== false;
32
34
  const client = includeMcp ? ctx.mcpxClient : null;
33
- const result = await writeCapabilitiesFile(
34
- ctx.projectDir,
35
- client,
36
- ctx.config,
37
- );
38
- const parts = [
39
- `${result.counts.internal} internal tool(s)`,
40
- `${result.counts.mcp} MCPX tool(s)`,
41
- ];
42
- if (!includeMcp) parts.push("MCPX skipped");
43
- if (result.createdFile) parts.push("file created");
44
- return {
45
- path: result.path,
46
- internal_tool_count: result.counts.internal,
47
- mcp_tool_count: result.counts.mcp,
48
- created_file: result.createdFile,
49
- message: `Wrote capabilities.md (${parts.join(", ")})`,
50
- is_error: false,
51
- };
35
+ try {
36
+ const result = await writeCapabilitiesFile(
37
+ ctx.projectDir,
38
+ client,
39
+ ctx.config,
40
+ );
41
+ const parts = [
42
+ `${result.counts.internal} internal tool(s)`,
43
+ `${result.counts.mcp} MCPX tool(s)`,
44
+ ];
45
+ if (!includeMcp) parts.push("MCPX skipped");
46
+ if (result.createdFile) parts.push("file created");
47
+ return {
48
+ path: result.path,
49
+ internal_tool_count: result.counts.internal,
50
+ mcp_tool_count: result.counts.mcp,
51
+ created_file: result.createdFile,
52
+ message: `Wrote capabilities.md (${parts.join(", ")})`,
53
+ is_error: false,
54
+ };
55
+ } catch (err) {
56
+ // writeCapabilitiesFile may call out to Anthropic for a thematic
57
+ // summary; transient API errors shouldn't crash the agent loop.
58
+ // The static fallback path inside generateCapabilitiesMarkdown
59
+ // already covers the no-key case, so getting here means an
60
+ // unexpected I/O or LLM failure.
61
+ return {
62
+ path: null,
63
+ internal_tool_count: 0,
64
+ mcp_tool_count: 0,
65
+ created_file: false,
66
+ message: err instanceof Error ? err.message : String(err),
67
+ is_error: true,
68
+ error_type: "refresh_failed",
69
+ next_action_hint:
70
+ "Try again later or pass include_mcp=false to skip the MCPX enumeration.",
71
+ };
72
+ }
52
73
  },
53
74
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;