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
@@ -67,7 +67,7 @@ const outputSchema = z.object({
67
67
  export const skillWriteTool = {
68
68
  name: "skill_write",
69
69
  description:
70
- "[[ bash equivalent command: tee ]] Create or overwrite a skill file (user-defined slash command) at .botholomew/skills/<name>.md. Fails with path_conflict when the file exists unless on_conflict='overwrite'. Reserved names (help, skills, clear, exit) are rejected. The generated file is parsed to validate before being written.",
70
+ "[[ bash equivalent command: tee ]] Create or overwrite a skill file (user-defined slash command) at skills/<name>.md. Fails with path_conflict when the file exists unless on_conflict='overwrite'. Reserved names (help, skills, clear, exit) are rejected. The generated file is parsed to validate before being written.",
71
71
  group: "skill",
72
72
  inputSchema,
73
73
  outputSchema,
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
- import { createTask, TASK_PRIORITIES } from "../../db/tasks.ts";
2
+ import { TASK_PRIORITIES } from "../../tasks/schema.ts";
3
+ import { CircularDependencyError, createTask } from "../../tasks/store.ts";
3
4
  import { logger } from "../../utils/logger.ts";
4
5
  import type { ToolDefinition } from "../tool.ts";
5
6
 
@@ -21,13 +22,21 @@ const inputSchema = z.object({
21
22
  .array(z.string())
22
23
  .optional()
23
24
  .describe("IDs of tasks that must complete first"),
25
+ context_paths: z
26
+ .array(z.string())
27
+ .optional()
28
+ .describe(
29
+ "Project-relative paths under context/ that the task should reference",
30
+ ),
24
31
  });
25
32
 
26
33
  const outputSchema = z.object({
27
- id: z.string(),
28
- name: z.string(),
34
+ id: z.string().nullable(),
35
+ name: z.string().nullable(),
29
36
  message: z.string(),
30
37
  is_error: z.boolean(),
38
+ error_type: z.string().optional(),
39
+ next_action_hint: z.string().optional(),
31
40
  });
32
41
 
33
42
  export const createTaskTool = {
@@ -38,18 +47,34 @@ export const createTaskTool = {
38
47
  inputSchema,
39
48
  outputSchema,
40
49
  execute: async (input, ctx) => {
41
- const newTask = await createTask(ctx.conn, {
42
- name: input.name,
43
- description: input.description,
44
- priority: input.priority,
45
- blocked_by: input.blocked_by,
46
- });
47
- logger.info(`Created subtask: ${newTask.name} (${newTask.id})`);
48
- return {
49
- id: newTask.id,
50
- name: newTask.name,
51
- message: `Created task "${newTask.name}" with ID ${newTask.id}`,
52
- is_error: false,
53
- };
50
+ try {
51
+ const newTask = await createTask(ctx.projectDir, {
52
+ name: input.name,
53
+ description: input.description,
54
+ priority: input.priority,
55
+ blocked_by: input.blocked_by,
56
+ context_paths: input.context_paths,
57
+ });
58
+ logger.info(`Created subtask: ${newTask.name} (${newTask.id})`);
59
+ return {
60
+ id: newTask.id,
61
+ name: newTask.name,
62
+ message: `Created task "${newTask.name}" with ID ${newTask.id}`,
63
+ is_error: false,
64
+ };
65
+ } catch (err) {
66
+ if (err instanceof CircularDependencyError) {
67
+ return {
68
+ id: null,
69
+ name: null,
70
+ message: err.message,
71
+ is_error: true,
72
+ error_type: "circular_dependency",
73
+ next_action_hint:
74
+ "Pick blockers that don't transitively depend on this task. `list_tasks` + `view_task` show the existing dependency graph.",
75
+ };
76
+ }
77
+ throw err;
78
+ }
54
79
  },
55
80
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { deleteTask, getTask } from "../../db/tasks.ts";
2
+ import { deleteTask, getTask } from "../../tasks/store.ts";
3
3
  import { logger } from "../../utils/logger.ts";
4
4
  import type { ToolDefinition } from "../tool.ts";
5
5
 
@@ -21,7 +21,7 @@ export const deleteTaskTool = {
21
21
  inputSchema,
22
22
  outputSchema,
23
23
  execute: async (input, ctx) => {
24
- const existing = await getTask(ctx.conn, input.id);
24
+ const existing = await getTask(ctx.projectDir, input.id);
25
25
  if (!existing) {
26
26
  return {
27
27
  deleted_id: null,
@@ -36,7 +36,7 @@ export const deleteTaskTool = {
36
36
  is_error: true,
37
37
  };
38
38
  }
39
- const ok = await deleteTask(ctx.conn, input.id);
39
+ const ok = await deleteTask(ctx.projectDir, input.id);
40
40
  if (!ok) {
41
41
  return {
42
42
  deleted_id: null,
@@ -1,11 +1,13 @@
1
1
  import { z } from "zod";
2
- import { listTasks, TASK_PRIORITIES, TASK_STATUSES } from "../../db/tasks.ts";
2
+ import { TASK_PRIORITIES, TASK_STATUSES } from "../../tasks/schema.ts";
3
+ import { listTasks } from "../../tasks/store.ts";
3
4
  import type { ToolDefinition } from "../tool.ts";
4
5
 
5
6
  const inputSchema = z.object({
6
7
  status: z.enum(TASK_STATUSES).optional().describe("Filter by status"),
7
8
  priority: z.enum(TASK_PRIORITIES).optional().describe("Filter by priority"),
8
9
  limit: z.number().optional().describe("Max number of tasks to return"),
10
+ offset: z.number().optional().describe("Skip first N tasks"),
9
11
  });
10
12
 
11
13
  const outputSchema = z.object({
@@ -31,10 +33,11 @@ export const listTasksTool = {
31
33
  inputSchema,
32
34
  outputSchema,
33
35
  execute: async (input, ctx) => {
34
- const tasks = await listTasks(ctx.conn, {
36
+ const tasks = await listTasks(ctx.projectDir, {
35
37
  status: input.status,
36
38
  priority: input.priority,
37
39
  limit: input.limit,
40
+ offset: input.offset,
38
41
  });
39
42
  return {
40
43
  tasks: tasks.map((t) => ({
@@ -44,7 +47,7 @@ export const listTasksTool = {
44
47
  priority: t.priority,
45
48
  description: t.description,
46
49
  output: t.output,
47
- created_at: t.created_at.toISOString(),
50
+ created_at: t.created_at,
48
51
  })),
49
52
  count: tasks.length,
50
53
  is_error: false,
@@ -1,5 +1,10 @@
1
1
  import { z } from "zod";
2
- import { getTask, TASK_PRIORITIES, updateTask } from "../../db/tasks.ts";
2
+ import { TASK_PRIORITIES } from "../../tasks/schema.ts";
3
+ import {
4
+ CircularDependencyError,
5
+ getTask,
6
+ updateTask,
7
+ } from "../../tasks/store.ts";
3
8
  import { logger } from "../../utils/logger.ts";
4
9
  import type { ToolDefinition } from "../tool.ts";
5
10
 
@@ -28,6 +33,8 @@ const outputSchema = z.object({
28
33
  .nullable(),
29
34
  message: z.string(),
30
35
  is_error: z.boolean(),
36
+ error_type: z.string().optional(),
37
+ next_action_hint: z.string().optional(),
31
38
  });
32
39
 
33
40
  export const updateTaskTool = {
@@ -38,7 +45,7 @@ export const updateTaskTool = {
38
45
  inputSchema,
39
46
  outputSchema,
40
47
  execute: async (input, ctx) => {
41
- const existing = await getTask(ctx.conn, input.id);
48
+ const existing = await getTask(ctx.projectDir, input.id);
42
49
  if (!existing) {
43
50
  return {
44
51
  task: null,
@@ -54,12 +61,27 @@ export const updateTaskTool = {
54
61
  };
55
62
  }
56
63
 
57
- const updated = await updateTask(ctx.conn, input.id, {
58
- name: input.name,
59
- description: input.description,
60
- priority: input.priority,
61
- blocked_by: input.blocked_by,
62
- });
64
+ let updated: Awaited<ReturnType<typeof updateTask>>;
65
+ try {
66
+ updated = await updateTask(ctx.projectDir, input.id, {
67
+ name: input.name,
68
+ description: input.description,
69
+ priority: input.priority,
70
+ blocked_by: input.blocked_by,
71
+ });
72
+ } catch (err) {
73
+ if (err instanceof CircularDependencyError) {
74
+ return {
75
+ task: null,
76
+ message: err.message,
77
+ is_error: true,
78
+ error_type: "circular_dependency",
79
+ next_action_hint:
80
+ "Pick blockers that don't transitively depend on this task.",
81
+ };
82
+ }
83
+ throw err;
84
+ }
63
85
 
64
86
  if (!updated) {
65
87
  return {
@@ -78,7 +100,7 @@ export const updateTaskTool = {
78
100
  status: updated.status,
79
101
  priority: updated.priority,
80
102
  blocked_by: updated.blocked_by,
81
- updated_at: updated.updated_at.toISOString(),
103
+ updated_at: updated.updated_at,
82
104
  },
83
105
  message: `Updated task "${updated.name}"`,
84
106
  is_error: false,
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { getTask } from "../../db/tasks.ts";
2
+ import { getTask } from "../../tasks/store.ts";
3
3
  import type { ToolDefinition } from "../tool.ts";
4
4
 
5
5
  const inputSchema = z.object({
@@ -18,7 +18,7 @@ const outputSchema = z.object({
18
18
  output: z.string().nullable(),
19
19
  claimed_by: z.string().nullable(),
20
20
  blocked_by: z.array(z.string()),
21
- context_ids: z.array(z.string()),
21
+ context_paths: z.array(z.string()),
22
22
  created_at: z.string(),
23
23
  updated_at: z.string(),
24
24
  })
@@ -33,7 +33,7 @@ export const viewTaskTool = {
33
33
  inputSchema,
34
34
  outputSchema,
35
35
  execute: async (input, ctx) => {
36
- const task = await getTask(ctx.conn, input.id);
36
+ const task = await getTask(ctx.projectDir, input.id);
37
37
  if (!task) return { task: null, is_error: true };
38
38
  return {
39
39
  task: {
@@ -46,9 +46,9 @@ export const viewTaskTool = {
46
46
  output: task.output,
47
47
  claimed_by: task.claimed_by,
48
48
  blocked_by: task.blocked_by,
49
- context_ids: task.context_ids,
50
- created_at: task.created_at.toISOString(),
51
- updated_at: task.updated_at.toISOString(),
49
+ context_paths: task.context_paths,
50
+ created_at: task.created_at,
51
+ updated_at: task.updated_at,
52
52
  },
53
53
  is_error: false,
54
54
  };
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { listThreads } from "../../db/threads.ts";
2
+ import { listThreads } from "../../threads/store.ts";
3
3
  import type { ToolDefinition } from "../tool.ts";
4
4
 
5
5
  const inputSchema = z.object({
@@ -32,7 +32,7 @@ export const listThreadsTool = {
32
32
  inputSchema,
33
33
  outputSchema,
34
34
  execute: async (input, ctx) => {
35
- const threads = await listThreads(ctx.conn, {
35
+ const threads = await listThreads(ctx.projectDir, {
36
36
  type: input.type,
37
37
  limit: input.limit,
38
38
  });
@@ -0,0 +1,208 @@
1
+ import { z } from "zod";
2
+ import {
3
+ getThread,
4
+ type Interaction,
5
+ type InteractionKind,
6
+ type InteractionRole,
7
+ listThreads,
8
+ type Thread,
9
+ } from "../../threads/store.ts";
10
+ import type { ToolDefinition } from "../tool.ts";
11
+
12
+ const ROLES = ["user", "assistant", "system", "tool"] as const;
13
+ const KINDS = [
14
+ "message",
15
+ "thinking",
16
+ "tool_use",
17
+ "tool_result",
18
+ "context_update",
19
+ "status_change",
20
+ ] as const;
21
+
22
+ const SNIPPET_MAX = 240;
23
+
24
+ const inputSchema = z.object({
25
+ pattern: z
26
+ .string()
27
+ .describe(
28
+ "Regex pattern matched against each interaction's `content`. Use a plain substring (it's a regex, but plain text Just Works).",
29
+ ),
30
+ ignore_case: z
31
+ .boolean()
32
+ .optional()
33
+ .default(true)
34
+ .describe("Case-insensitive regex (default true)."),
35
+ role: z
36
+ .enum(ROLES)
37
+ .optional()
38
+ .describe(
39
+ "Restrict matches to a single role (user/assistant/system/tool).",
40
+ ),
41
+ kind: z
42
+ .enum(KINDS)
43
+ .optional()
44
+ .describe(
45
+ "Restrict matches to a single interaction kind (message/tool_use/tool_result/etc).",
46
+ ),
47
+ thread_type: z
48
+ .enum(["worker_tick", "chat_session"])
49
+ .optional()
50
+ .describe("Restrict to chat sessions or worker-tick threads."),
51
+ since: z
52
+ .string()
53
+ .optional()
54
+ .describe("ISO date — only consider threads started on or after this."),
55
+ until: z
56
+ .string()
57
+ .optional()
58
+ .describe("ISO date — only consider threads started on or before this."),
59
+ max_results: z
60
+ .number()
61
+ .int()
62
+ .positive()
63
+ .optional()
64
+ .default(20)
65
+ .describe("Maximum number of hits to return across all threads."),
66
+ });
67
+
68
+ const HitSchema = z.object({
69
+ thread_id: z.string(),
70
+ thread_title: z.string(),
71
+ thread_type: z.string(),
72
+ sequence: z
73
+ .number()
74
+ .describe(
75
+ "1-based sequence of the matching interaction in the thread. Plug this into `view_thread({ id, offset: sequence-1, limit: 5 })` to read context around the hit.",
76
+ ),
77
+ role: z.string(),
78
+ kind: z.string(),
79
+ content_snippet: z.string(),
80
+ created_at: z.string(),
81
+ });
82
+
83
+ const outputSchema = z.object({
84
+ matches: z.array(HitSchema),
85
+ threads_scanned: z.number(),
86
+ is_error: z.boolean(),
87
+ error_type: z.string().optional(),
88
+ message: z.string().optional(),
89
+ next_action_hint: z.string().optional(),
90
+ });
91
+
92
+ export const searchThreadsTool = {
93
+ name: "search_threads",
94
+ description:
95
+ "[[ bash equivalent command: grep -r ]] Search past conversations (chat sessions and worker ticks) for a regex match. Returns hits with `(thread_id, sequence)` pairs — pass them to `view_thread` to read context around the match.",
96
+ group: "thread",
97
+ inputSchema,
98
+ outputSchema,
99
+ execute: async (input, ctx) => {
100
+ let regex: RegExp;
101
+ try {
102
+ regex = new RegExp(input.pattern, input.ignore_case ? "i" : "");
103
+ } catch (err) {
104
+ return {
105
+ matches: [],
106
+ threads_scanned: 0,
107
+ is_error: true,
108
+ error_type: "invalid_regex",
109
+ message: `Could not compile pattern: ${err instanceof Error ? err.message : String(err)}`,
110
+ next_action_hint:
111
+ "Double-check the regex; remember `.` is a metacharacter — escape it as `\\.` for a literal dot.",
112
+ };
113
+ }
114
+
115
+ const sinceMs = input.since
116
+ ? Date.parse(input.since)
117
+ : Number.NEGATIVE_INFINITY;
118
+ const untilMs = input.until
119
+ ? Date.parse(input.until)
120
+ : Number.POSITIVE_INFINITY;
121
+
122
+ let threads: Thread[];
123
+ try {
124
+ threads = await listThreads(ctx.projectDir, {
125
+ type: input.thread_type,
126
+ });
127
+ } catch (err) {
128
+ return {
129
+ matches: [],
130
+ threads_scanned: 0,
131
+ is_error: true,
132
+ error_type: "list_failed",
133
+ message: `Failed to enumerate threads: ${err instanceof Error ? err.message : String(err)}`,
134
+ };
135
+ }
136
+
137
+ type Hit = z.infer<typeof HitSchema>;
138
+ const matches: Hit[] = [];
139
+ let scanned = 0;
140
+
141
+ for (const t of threads) {
142
+ const startedMs = t.started_at.getTime();
143
+ if (startedMs < sinceMs || startedMs > untilMs) continue;
144
+ const data = await getThread(ctx.projectDir, t.id);
145
+ if (!data) continue;
146
+ scanned++;
147
+ for (const ix of data.interactions) {
148
+ if (input.role && ix.role !== input.role) continue;
149
+ if (input.kind && ix.kind !== input.kind) continue;
150
+ if (!matchInteraction(ix, regex)) continue;
151
+ matches.push({
152
+ thread_id: t.id,
153
+ thread_title: t.title || "(untitled)",
154
+ thread_type: t.type,
155
+ sequence: ix.sequence,
156
+ role: ix.role,
157
+ kind: ix.kind,
158
+ content_snippet: snippetForMatch(ix.content, regex),
159
+ created_at: ix.created_at.toISOString(),
160
+ });
161
+ if (matches.length >= input.max_results) break;
162
+ }
163
+ if (matches.length >= input.max_results) break;
164
+ }
165
+
166
+ return {
167
+ matches,
168
+ threads_scanned: scanned,
169
+ is_error: false,
170
+ next_action_hint:
171
+ matches.length === 0
172
+ ? `No hits in ${scanned} thread(s). Try a broader pattern or remove role/kind filters.`
173
+ : `Pass any (thread_id, sequence) into view_thread({ id: thread_id, offset: sequence - 1, limit: 5 }) to read surrounding context.`,
174
+ };
175
+ },
176
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
177
+
178
+ function matchInteraction(ix: Interaction, regex: RegExp): boolean {
179
+ // We treat the user-visible content as the primary haystack, but a
180
+ // tool_use interaction's content is just "Calling <name>" — fall through
181
+ // to the tool name + JSON args so a search for an exact tool argument
182
+ // still finds the call.
183
+ if (regex.test(ix.content)) return true;
184
+ if (ix.tool_name && regex.test(ix.tool_name)) return true;
185
+ if (ix.tool_input && regex.test(ix.tool_input)) return true;
186
+ return false;
187
+ }
188
+
189
+ /**
190
+ * Pick a short window around the first regex match so the agent gets enough
191
+ * context to know whether the hit is relevant without paging the whole
192
+ * interaction. Falls back to the head when the match index isn't available.
193
+ */
194
+ function snippetForMatch(content: string, regex: RegExp): string {
195
+ const m = regex.exec(content);
196
+ if (!m) return content.slice(0, SNIPPET_MAX);
197
+ const idx = m.index;
198
+ const start = Math.max(0, idx - 60);
199
+ const end = Math.min(content.length, idx + SNIPPET_MAX - 60);
200
+ let snippet = content.slice(start, end);
201
+ if (start > 0) snippet = `…${snippet}`;
202
+ if (end < content.length) snippet = `${snippet}…`;
203
+ return snippet;
204
+ }
205
+
206
+ // Keep the role/kind unions exported for tests that want to type-pin filters.
207
+ export type SearchThreadsRole = InteractionRole;
208
+ export type SearchThreadsKind = InteractionKind;
@@ -1,9 +1,27 @@
1
1
  import { z } from "zod";
2
- import { getThread } from "../../db/threads.ts";
2
+ import { getThread } from "../../threads/store.ts";
3
3
  import type { ToolDefinition } from "../tool.ts";
4
4
 
5
5
  const inputSchema = z.object({
6
6
  id: z.string().describe("Thread ID to view"),
7
+ offset: z
8
+ .number()
9
+ .int()
10
+ .nonnegative()
11
+ .optional()
12
+ .default(0)
13
+ .describe(
14
+ "1-based sequence to start from (skip earlier interactions). Use with `limit` to paginate long threads.",
15
+ ),
16
+ limit: z
17
+ .number()
18
+ .int()
19
+ .positive()
20
+ .optional()
21
+ .default(50)
22
+ .describe(
23
+ "Max interactions to return in this page. Default 50 keeps long threads from blowing the LLM context window.",
24
+ ),
7
25
  });
8
26
 
9
27
  const outputSchema = z.object({
@@ -28,19 +46,39 @@ const outputSchema = z.object({
28
46
  created_at: z.string(),
29
47
  }),
30
48
  ),
49
+ total_interactions: z.number(),
50
+ offset: z.number(),
51
+ limit: z.number(),
52
+ has_more: z.boolean(),
31
53
  is_error: z.boolean(),
54
+ next_action_hint: z.string().optional(),
32
55
  });
33
56
 
34
57
  export const viewThreadTool = {
35
58
  name: "view_thread",
36
59
  description:
37
- "View a thread and its full interaction log (messages, tool calls, results).",
60
+ "View a thread's metadata and a paginated slice of its interactions. Pass `offset` (sequence to start from) and `limit` to walk a long thread without flooding the context window. `search_threads` returns `(thread_id, sequence)` pairs you can plug into `offset` to jump straight to a hit.",
38
61
  group: "thread",
39
62
  inputSchema,
40
63
  outputSchema,
41
64
  execute: async (input, ctx) => {
42
- const result = await getThread(ctx.conn, input.id);
43
- if (!result) return { thread: null, interactions: [], is_error: false };
65
+ const result = await getThread(ctx.projectDir, input.id);
66
+ if (!result) {
67
+ return {
68
+ thread: null,
69
+ interactions: [],
70
+ total_interactions: 0,
71
+ offset: input.offset,
72
+ limit: input.limit,
73
+ has_more: false,
74
+ is_error: false,
75
+ };
76
+ }
77
+ const total = result.interactions.length;
78
+ const start = Math.min(input.offset, total);
79
+ const end = Math.min(start + input.limit, total);
80
+ const page = result.interactions.slice(start, end);
81
+ const hasMore = end < total;
44
82
  return {
45
83
  thread: {
46
84
  id: result.thread.id,
@@ -50,7 +88,7 @@ export const viewThreadTool = {
50
88
  started_at: result.thread.started_at.toISOString(),
51
89
  ended_at: result.thread.ended_at?.toISOString() ?? null,
52
90
  },
53
- interactions: result.interactions.map((i) => ({
91
+ interactions: page.map((i) => ({
54
92
  id: i.id,
55
93
  sequence: i.sequence,
56
94
  role: i.role,
@@ -59,6 +97,13 @@ export const viewThreadTool = {
59
97
  tool_name: i.tool_name,
60
98
  created_at: i.created_at.toISOString(),
61
99
  })),
100
+ total_interactions: total,
101
+ offset: start,
102
+ limit: input.limit,
103
+ has_more: hasMore,
104
+ next_action_hint: hasMore
105
+ ? `Call view_thread again with offset=${end} to see the next page.`
106
+ : undefined,
62
107
  is_error: false,
63
108
  };
64
109
  },
package/src/tools/tool.ts CHANGED
@@ -17,6 +17,11 @@ export interface ToolContext {
17
17
  projectDir: string;
18
18
  config: Required<BotholomewConfig>;
19
19
  mcpxClient: McpxClient | null;
20
+ /**
21
+ * Chat-mode only. Lets long-running tools (e.g. `sleep`) poll for
22
+ * Esc-to-abort by reading `session.aborted`. Workers leave this `undefined`.
23
+ */
24
+ shouldAbort?: () => boolean;
20
25
  }
21
26
 
22
27
  type ToolOutputBase = { is_error: z.ZodBoolean };
@@ -0,0 +1,77 @@
1
+ import { z } from "zod";
2
+ import type { ToolDefinition } from "../tool.ts";
3
+
4
+ const MIN_SECONDS = 1;
5
+ const MAX_SECONDS = 3600;
6
+ const POLL_INTERVAL_MS = 250;
7
+
8
+ const inputSchema = z.object({
9
+ seconds: z
10
+ .number()
11
+ .int()
12
+ .min(MIN_SECONDS)
13
+ .max(MAX_SECONDS)
14
+ .describe(
15
+ `How long to sleep, in seconds (${MIN_SECONDS}–${MAX_SECONDS}). For longer pauses, create a schedule instead.`,
16
+ ),
17
+ reason: z
18
+ .string()
19
+ .min(1)
20
+ .describe(
21
+ "Why you're sleeping — shown to the user under the progress bar. Be specific (e.g. 'waiting for worker to finish task abc').",
22
+ ),
23
+ });
24
+
25
+ const outputSchema = z.object({
26
+ message: z.string(),
27
+ slept_seconds: z.number(),
28
+ aborted: z.boolean(),
29
+ is_error: z.boolean(),
30
+ });
31
+
32
+ export const sleepTool = {
33
+ name: "sleep",
34
+ description:
35
+ "[[ bash equivalent command: sleep ]] Pause the chat agent for a fixed number of seconds. Useful after enqueuing tasks for workers, before checking results. The user sees a progress bar while you wait; pressing Esc cancels the wait. Returns when the time elapses or the user steers.",
36
+ group: "util",
37
+ inputSchema,
38
+ outputSchema,
39
+ execute: async (input, ctx): Promise<z.infer<typeof outputSchema>> => {
40
+ const startedAt = Date.now();
41
+ const totalMs = input.seconds * 1000;
42
+ const shouldAbort = ctx.shouldAbort;
43
+
44
+ let aborted: boolean = false;
45
+ await new Promise<void>((resolve) => {
46
+ let timeout: ReturnType<typeof setTimeout> | null = null;
47
+ let interval: ReturnType<typeof setInterval> | null = null;
48
+
49
+ const finish = () => {
50
+ if (timeout) clearTimeout(timeout);
51
+ if (interval) clearInterval(interval);
52
+ resolve();
53
+ };
54
+
55
+ timeout = setTimeout(finish, totalMs);
56
+
57
+ if (shouldAbort) {
58
+ interval = setInterval(() => {
59
+ if (shouldAbort()) {
60
+ aborted = true;
61
+ finish();
62
+ }
63
+ }, POLL_INTERVAL_MS);
64
+ }
65
+ });
66
+
67
+ const sleptSeconds = (Date.now() - startedAt) / 1000;
68
+ return {
69
+ message: aborted
70
+ ? `Sleep interrupted after ${sleptSeconds.toFixed(1)}s of ${input.seconds}s — user steered.`
71
+ : `Slept ${sleptSeconds.toFixed(1)}s. ${input.reason}`,
72
+ slept_seconds: sleptSeconds,
73
+ aborted,
74
+ is_error: false,
75
+ };
76
+ },
77
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;