botholomew 0.3.1 → 0.3.2

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 (61) hide show
  1. package/package.json +2 -2
  2. package/src/chat/agent.ts +62 -16
  3. package/src/chat/session.ts +19 -6
  4. package/src/cli.ts +2 -0
  5. package/src/commands/thread.ts +180 -0
  6. package/src/config/schemas.ts +3 -1
  7. package/src/daemon/large-results.ts +15 -3
  8. package/src/daemon/llm.ts +22 -7
  9. package/src/daemon/prompt.ts +1 -9
  10. package/src/daemon/tick.ts +9 -0
  11. package/src/db/threads.ts +17 -0
  12. package/src/init/templates.ts +1 -0
  13. package/src/tools/context/read-large-result.ts +2 -1
  14. package/src/tools/context/search.ts +2 -0
  15. package/src/tools/context/update-beliefs.ts +2 -0
  16. package/src/tools/context/update-goals.ts +2 -0
  17. package/src/tools/dir/create.ts +3 -2
  18. package/src/tools/dir/list.ts +2 -1
  19. package/src/tools/dir/size.ts +2 -1
  20. package/src/tools/dir/tree.ts +3 -2
  21. package/src/tools/file/copy.ts +2 -1
  22. package/src/tools/file/count-lines.ts +2 -1
  23. package/src/tools/file/delete.ts +3 -2
  24. package/src/tools/file/edit.ts +2 -1
  25. package/src/tools/file/exists.ts +2 -1
  26. package/src/tools/file/info.ts +2 -0
  27. package/src/tools/file/move.ts +2 -1
  28. package/src/tools/file/read.ts +2 -1
  29. package/src/tools/file/write.ts +3 -2
  30. package/src/tools/mcp/exec.ts +70 -3
  31. package/src/tools/mcp/info.ts +8 -0
  32. package/src/tools/mcp/list-tools.ts +18 -6
  33. package/src/tools/mcp/search.ts +38 -10
  34. package/src/tools/registry.ts +2 -0
  35. package/src/tools/schedule/create.ts +2 -0
  36. package/src/tools/schedule/list.ts +2 -0
  37. package/src/tools/search/grep.ts +3 -2
  38. package/src/tools/search/semantic.ts +2 -0
  39. package/src/tools/task/complete.ts +2 -0
  40. package/src/tools/task/create.ts +17 -4
  41. package/src/tools/task/fail.ts +2 -0
  42. package/src/tools/task/list.ts +2 -0
  43. package/src/tools/task/update.ts +87 -0
  44. package/src/tools/task/view.ts +3 -1
  45. package/src/tools/task/wait.ts +2 -0
  46. package/src/tools/thread/list.ts +2 -0
  47. package/src/tools/thread/view.ts +3 -1
  48. package/src/tools/tool.ts +5 -3
  49. package/src/tui/App.tsx +209 -82
  50. package/src/tui/components/ContextPanel.tsx +6 -3
  51. package/src/tui/components/HelpPanel.tsx +52 -3
  52. package/src/tui/components/InputBar.tsx +125 -59
  53. package/src/tui/components/MessageList.tsx +40 -75
  54. package/src/tui/components/StatusBar.tsx +9 -8
  55. package/src/tui/components/TabBar.tsx +4 -2
  56. package/src/tui/components/TaskPanel.tsx +409 -0
  57. package/src/tui/components/ThreadPanel.tsx +541 -0
  58. package/src/tui/components/ToolCall.tsx +36 -3
  59. package/src/tui/components/ToolPanel.tsx +40 -31
  60. package/src/tui/theme.ts +20 -3
  61. package/src/utils/title.ts +47 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "An AI agent for knowledge work",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "scripts": {
19
19
  "dev": "bun run src/cli.ts",
20
- "dev:demo": "bun run src/cli.ts chat -p 'learn everything you can about me from the connected MCP services'",
20
+ "dev:demo": "bun run src/cli.ts chat -p 'learn everything you can about me from the connected MCP services and then save what you'\\''ve learned about me to context'",
21
21
  "test": "bun test",
22
22
  "build": "bun build --compile --minify --sourcemap --external react-devtools-core ./src/cli.ts --outfile dist/botholomew",
23
23
  "lint": "tsc --noEmit && biome check ."
package/src/chat/agent.ts CHANGED
@@ -79,10 +79,20 @@ export async function buildChatSystemPrompt(
79
79
  return parts.join("\n");
80
80
  }
81
81
 
82
+ export interface ToolEndMeta {
83
+ largeResult?: { id: string; chars: number; pages: number };
84
+ }
85
+
82
86
  export interface ChatTurnCallbacks {
83
87
  onToken: (text: string) => void;
84
- onToolStart: (name: string, input: string) => void;
85
- onToolEnd: (name: string, output: string) => void;
88
+ onToolStart: (id: string, name: string, input: string) => void;
89
+ onToolEnd: (
90
+ id: string,
91
+ name: string,
92
+ output: string,
93
+ isError: boolean,
94
+ meta?: ToolEndMeta,
95
+ ) => void;
86
96
  }
87
97
 
88
98
  /**
@@ -111,9 +121,9 @@ export async function runChatTurn(input: {
111
121
  config.anthropic_api_key,
112
122
  config.model,
113
123
  );
114
- const maxTurns = 10;
124
+ const maxTurns = config.max_turns;
115
125
 
116
- for (let turn = 0; turn < maxTurns; turn++) {
126
+ for (let turn = 0; !maxTurns || turn < maxTurns; turn++) {
117
127
  const startTime = Date.now();
118
128
 
119
129
  fitToContextWindow(messages, systemPrompt, maxInputTokens);
@@ -127,12 +137,24 @@ export async function runChatTurn(input: {
127
137
 
128
138
  // Collect the full response
129
139
  let assistantText = "";
140
+ const earlyReportedToolIds = new Set<string>();
130
141
 
131
142
  stream.on("text", (text) => {
132
143
  assistantText += text;
133
144
  callbacks.onToken(text);
134
145
  });
135
146
 
147
+ stream.on("contentBlock", (block) => {
148
+ if (block.type === "tool_use") {
149
+ earlyReportedToolIds.add(block.id);
150
+ callbacks.onToolStart(
151
+ block.id,
152
+ block.name,
153
+ JSON.stringify(block.input),
154
+ );
155
+ }
156
+ });
157
+
136
158
  const response = await stream.finalMessage();
137
159
  const durationMs = Date.now() - startTime;
138
160
  const tokenCount =
@@ -166,7 +188,9 @@ export async function runChatTurn(input: {
166
188
  // Log all tool_use entries and notify UI
167
189
  for (const toolUse of toolUseBlocks) {
168
190
  const toolInput = JSON.stringify(toolUse.input);
169
- callbacks.onToolStart(toolUse.name, toolInput);
191
+ if (!earlyReportedToolIds.has(toolUse.id)) {
192
+ callbacks.onToolStart(toolUse.id, toolUse.name, toolInput);
193
+ }
170
194
 
171
195
  await logInteraction(conn, threadId, {
172
196
  role: "assistant",
@@ -183,18 +207,28 @@ export async function runChatTurn(input: {
183
207
  const start = Date.now();
184
208
  const result = await executeChatToolCall(toolUse, toolCtx);
185
209
  const durationMs = Date.now() - start;
186
- callbacks.onToolEnd(toolUse.name, result);
187
- return { toolUse, result, durationMs };
210
+ const stored = maybeStoreResult(toolUse.name, result.output);
211
+ const meta: ToolEndMeta | undefined = stored.stored
212
+ ? { largeResult: stored.stored }
213
+ : undefined;
214
+ callbacks.onToolEnd(
215
+ toolUse.id,
216
+ toolUse.name,
217
+ result.output,
218
+ result.isError,
219
+ meta,
220
+ );
221
+ return { toolUse, result, durationMs, stored };
188
222
  }),
189
223
  );
190
224
 
191
225
  // Log results and collect tool_result messages
192
226
  const toolResults: ToolResultBlockParam[] = [];
193
- for (const { toolUse, result, durationMs } of execResults) {
227
+ for (const { toolUse, result, durationMs, stored } of execResults) {
194
228
  await logInteraction(conn, threadId, {
195
229
  role: "tool",
196
230
  kind: "tool_result",
197
- content: result,
231
+ content: result.output,
198
232
  toolName: toolUse.name,
199
233
  durationMs,
200
234
  });
@@ -202,7 +236,8 @@ export async function runChatTurn(input: {
202
236
  toolResults.push({
203
237
  type: "tool_result",
204
238
  tool_use_id: toolUse.id,
205
- content: maybeStoreResult(toolUse.name, result),
239
+ content: stored.text,
240
+ is_error: result.isError || undefined,
206
241
  });
207
242
  }
208
243
 
@@ -214,21 +249,32 @@ export async function runChatTurn(input: {
214
249
  async function executeChatToolCall(
215
250
  toolUse: ToolUseBlock,
216
251
  ctx: ToolContext,
217
- ): Promise<string> {
252
+ ): Promise<{ output: string; isError: boolean }> {
218
253
  const tool = getTool(toolUse.name);
219
- if (!tool) return `Unknown tool: ${toolUse.name}`;
254
+ if (!tool) return { output: `Unknown tool: ${toolUse.name}`, isError: true };
220
255
  if (!CHAT_TOOL_NAMES.has(tool.name))
221
- return `Tool not available in chat mode: ${tool.name}`;
256
+ return {
257
+ output: `Tool not available in chat mode: ${tool.name}`,
258
+ isError: true,
259
+ };
222
260
 
223
261
  const parsed = tool.inputSchema.safeParse(toolUse.input);
224
262
  if (!parsed.success) {
225
- return `Invalid input: ${JSON.stringify(parsed.error)}`;
263
+ return {
264
+ output: `Invalid input: ${JSON.stringify(parsed.error)}`,
265
+ isError: true,
266
+ };
226
267
  }
227
268
 
228
269
  try {
229
270
  const result = await tool.execute(parsed.data, ctx);
230
- return typeof result === "string" ? result : JSON.stringify(result);
271
+ const isError =
272
+ typeof result === "object" && result !== null && "is_error" in result
273
+ ? (result as { is_error: boolean }).is_error
274
+ : false;
275
+ const output = typeof result === "string" ? result : JSON.stringify(result);
276
+ return { output, isError };
231
277
  } catch (err) {
232
- return `Tool error: ${err}`;
278
+ return { output: `Tool error: ${err}`, isError: true };
233
279
  }
234
280
  }
@@ -14,6 +14,7 @@ import {
14
14
  } from "../db/threads.ts";
15
15
  import { createMcpxClient } from "../mcpx/client.ts";
16
16
  import type { ToolContext } from "../tools/tool.ts";
17
+ import { generateThreadTitle } from "../utils/title.ts";
17
18
  import {
18
19
  buildChatSystemPrompt,
19
20
  type ChatTurnCallbacks,
@@ -60,21 +61,23 @@ export async function startChatSession(
60
61
  await reopenThread(conn, threadId);
61
62
 
62
63
  // Rebuild message history from interactions
64
+ let firstUserMessage: string | undefined;
63
65
  for (const interaction of result.interactions) {
64
66
  if (interaction.kind !== "message") continue;
65
67
  if (interaction.role === "user") {
68
+ if (!firstUserMessage) firstUserMessage = interaction.content;
66
69
  messages.push({ role: "user", content: interaction.content });
67
70
  } else if (interaction.role === "assistant") {
68
71
  messages.push({ role: "assistant", content: interaction.content });
69
72
  }
70
73
  }
74
+
75
+ // Backfill title for threads that still have the default
76
+ if (result.thread.title === "New chat" && firstUserMessage) {
77
+ void generateThreadTitle(config, conn, threadId, firstUserMessage);
78
+ }
71
79
  } else {
72
- threadId = await createThread(
73
- conn,
74
- "chat_session",
75
- undefined,
76
- "Interactive chat",
77
- );
80
+ threadId = await createThread(conn, "chat_session", undefined, "New chat");
78
81
  }
79
82
 
80
83
  const systemPrompt = await buildChatSystemPrompt(projectDir);
@@ -118,6 +121,16 @@ export async function sendMessage(
118
121
 
119
122
  session.messages.push({ role: "user", content: userMessage });
120
123
 
124
+ // Auto-generate title after first user message in a new thread
125
+ if (session.messages.length === 1) {
126
+ void generateThreadTitle(
127
+ session.config,
128
+ session.conn,
129
+ session.threadId,
130
+ userMessage,
131
+ );
132
+ }
133
+
121
134
  await runChatTurn({
122
135
  messages: session.messages,
123
136
  systemPrompt: session.systemPrompt,
package/src/cli.ts CHANGED
@@ -11,6 +11,7 @@ import { registerMcpxCommand } from "./commands/mcpx.ts";
11
11
  import { registerPrepareCommand } from "./commands/prepare.ts";
12
12
  import { registerScheduleCommand } from "./commands/schedule.ts";
13
13
  import { registerTaskCommand } from "./commands/task.ts";
14
+ import { registerThreadCommand } from "./commands/thread.ts";
14
15
  import { registerToolCommands } from "./commands/tools.ts";
15
16
  import { registerUpgradeCommand } from "./commands/upgrade.ts";
16
17
  import { maybeCheckForUpdate } from "./update/background.ts";
@@ -33,6 +34,7 @@ program
33
34
  registerInitCommand(program);
34
35
  registerDaemonCommand(program);
35
36
  registerTaskCommand(program);
37
+ registerThreadCommand(program);
36
38
  registerScheduleCommand(program);
37
39
  registerChatCommand(program);
38
40
  registerContextCommand(program);
@@ -0,0 +1,180 @@
1
+ import ansis from "ansis";
2
+ import type { Command } from "commander";
3
+ import type { DbConnection } from "../db/connection.ts";
4
+ import type { Interaction, Thread } from "../db/threads.ts";
5
+ import { deleteThread, getThread, listThreads } from "../db/threads.ts";
6
+ import { logger } from "../utils/logger.ts";
7
+ import { withDb } from "./with-db.ts";
8
+
9
+ export function registerThreadCommand(program: Command) {
10
+ const thread = program.command("thread").description("Manage chat threads");
11
+
12
+ thread
13
+ .command("list")
14
+ .description("List threads")
15
+ .option("-t, --type <type>", "filter by type (daemon_tick, chat_session)")
16
+ .option("-l, --limit <n>", "max number of threads", parseInt)
17
+ .action((opts) =>
18
+ withDb(program, async (conn) => {
19
+ const threads = await listThreads(conn, {
20
+ type: opts.type,
21
+ limit: opts.limit,
22
+ });
23
+
24
+ if (threads.length === 0) {
25
+ logger.dim("No threads found.");
26
+ return;
27
+ }
28
+
29
+ for (const t of threads) {
30
+ printThread(t);
31
+ }
32
+ }),
33
+ );
34
+
35
+ thread
36
+ .command("view <id>")
37
+ .description("View thread details and interactions")
38
+ .option(
39
+ "--only <roles>",
40
+ "show only these roles (comma-separated: user,assistant,tool,system)",
41
+ )
42
+ .action((id, opts) =>
43
+ withDb(program, async (conn) => {
44
+ const resolvedId = await resolveThreadId(conn, id);
45
+ if (!resolvedId) {
46
+ logger.error(`Thread not found: ${id}`);
47
+ process.exit(1);
48
+ }
49
+ const result = await getThread(conn, resolvedId);
50
+ if (!result) {
51
+ logger.error(`Thread not found: ${id}`);
52
+ process.exit(1);
53
+ }
54
+ const interactions = opts.only
55
+ ? result.interactions.filter((i) =>
56
+ (opts.only as string).split(",").includes(i.role),
57
+ )
58
+ : result.interactions;
59
+ printThreadDetail(result.thread, interactions);
60
+ }),
61
+ );
62
+
63
+ thread
64
+ .command("delete <id>")
65
+ .description("Delete a thread and its interactions")
66
+ .action((id) =>
67
+ withDb(program, async (conn) => {
68
+ const resolvedId = await resolveThreadId(conn, id);
69
+ if (!resolvedId) {
70
+ logger.error(`Thread not found: ${id}`);
71
+ process.exit(1);
72
+ }
73
+ const deleted = await deleteThread(conn, resolvedId);
74
+ if (!deleted) {
75
+ logger.error(`Thread not found: ${id}`);
76
+ process.exit(1);
77
+ }
78
+ logger.success(`Deleted thread: ${resolvedId}`);
79
+ }),
80
+ );
81
+ }
82
+
83
+ async function resolveThreadId(
84
+ conn: DbConnection,
85
+ idPrefix: string,
86
+ ): Promise<string | null> {
87
+ if (idPrefix.length >= 36) return idPrefix;
88
+ const all = await listThreads(conn);
89
+ const matches = all.filter((t) => t.id.startsWith(idPrefix));
90
+ if (matches.length === 1) {
91
+ const match = matches[0] as Thread;
92
+ return match.id;
93
+ }
94
+ if (matches.length === 0) return null;
95
+ logger.error(
96
+ `Ambiguous thread prefix "${idPrefix}" matches ${matches.length} threads`,
97
+ );
98
+ process.exit(1);
99
+ }
100
+
101
+ function typeColor(type: Thread["type"]): string {
102
+ switch (type) {
103
+ case "daemon_tick":
104
+ return ansis.magenta(type);
105
+ case "chat_session":
106
+ return ansis.cyan(type);
107
+ }
108
+ }
109
+
110
+ function statusLabel(thread: Thread): string {
111
+ return thread.ended_at ? ansis.dim("ended") : ansis.green("active");
112
+ }
113
+
114
+ function roleColor(role: Interaction["role"]): string {
115
+ switch (role) {
116
+ case "user":
117
+ return ansis.cyan(role);
118
+ case "assistant":
119
+ return ansis.green(role);
120
+ case "system":
121
+ return ansis.yellow(role);
122
+ case "tool":
123
+ return ansis.magenta(role);
124
+ }
125
+ }
126
+
127
+ function printThread(t: Thread) {
128
+ const id = ansis.dim(t.id.slice(0, 8));
129
+ const title = t.title || ansis.dim("(untitled)");
130
+ console.log(` ${id} ${typeColor(t.type)} ${statusLabel(t)} ${title}`);
131
+ }
132
+
133
+ function printThreadDetail(t: Thread, interactions: Interaction[]) {
134
+ console.log(ansis.bold(t.title || "(untitled)"));
135
+ console.log(` ID: ${t.id}`);
136
+ console.log(` Type: ${typeColor(t.type)}`);
137
+ console.log(` Status: ${statusLabel(t)}`);
138
+ if (t.task_id) console.log(` Task: ${t.task_id}`);
139
+ console.log(` Started: ${t.started_at.toISOString()}`);
140
+ console.log(
141
+ ` Ended: ${t.ended_at ? t.ended_at.toISOString() : ansis.dim("—")}`,
142
+ );
143
+
144
+ if (interactions.length === 0) {
145
+ console.log(`\n ${ansis.dim("No interactions.")}`);
146
+ return;
147
+ }
148
+
149
+ console.log(`\n Interactions (${interactions.length}):`);
150
+ for (const i of interactions) {
151
+ printInteraction(i);
152
+ }
153
+ }
154
+
155
+ function formatTime(date: Date): string {
156
+ return date
157
+ .toISOString()
158
+ .replace("T", " ")
159
+ .replace(/\.\d{3}Z$/, "");
160
+ }
161
+
162
+ function printInteraction(i: Interaction) {
163
+ const seq = ansis.dim(`#${i.sequence}`);
164
+ const ts = ansis.dim(formatTime(i.created_at));
165
+ const kind = ansis.dim(`[${i.kind}]`);
166
+ let preview: string;
167
+ if (i.kind === "tool_use" && i.tool_name) {
168
+ preview = ansis.yellow(i.tool_name);
169
+ } else {
170
+ const text = i.content.replace(/\n/g, " ");
171
+ preview = text.length > 120 ? `${text.slice(0, 120)}...` : text;
172
+ }
173
+ const extras: string[] = [];
174
+ if (i.token_count) extras.push(`${i.token_count} tok`);
175
+ if (i.duration_ms) extras.push(`${i.duration_ms}ms`);
176
+ const suffix = extras.length > 0 ? ` ${ansis.dim(extras.join(", "))}` : "";
177
+ console.log(
178
+ ` ${seq} ${ts} ${roleColor(i.role)} ${kind} ${preview}${suffix}`,
179
+ );
180
+ }
@@ -5,13 +5,15 @@ export interface BotholomewConfig {
5
5
  tick_interval_seconds?: number;
6
6
  max_tick_duration_seconds?: number;
7
7
  system_prompt_override?: string;
8
+ max_turns?: number;
8
9
  }
9
10
 
10
11
  export const DEFAULT_CONFIG: Required<BotholomewConfig> = {
11
12
  anthropic_api_key: "",
12
13
  model: "claude-opus-4-20250514",
13
- chunker_model: "claude-haiku-4-20250514",
14
+ chunker_model: "claude-haiku-4-5-20251001",
14
15
  tick_interval_seconds: 300,
15
16
  max_tick_duration_seconds: 120,
16
17
  system_prompt_override: "",
18
+ max_turns: 0,
17
19
  };
@@ -71,15 +71,27 @@ export function buildResultStub(
71
71
  ].join("\n");
72
72
  }
73
73
 
74
+ export interface MaybeStoreResultOutput {
75
+ text: string;
76
+ stored?: { id: string; chars: number; pages: number };
77
+ }
78
+
74
79
  /**
75
80
  * If the tool output exceeds MAX_INLINE_CHARS, store it and return a stub.
76
81
  * Otherwise return the original output unchanged.
77
82
  */
78
- export function maybeStoreResult(toolName: string, output: string): string {
79
- if (output.length <= MAX_INLINE_CHARS) return output;
83
+ export function maybeStoreResult(
84
+ toolName: string,
85
+ output: string,
86
+ ): MaybeStoreResultOutput {
87
+ if (output.length <= MAX_INLINE_CHARS) return { text: output };
80
88
 
81
89
  const id = storeLargeResult(toolName, output);
82
- return buildResultStub(id, toolName, output);
90
+ const pages = Math.ceil(output.length / PAGE_SIZE_CHARS);
91
+ return {
92
+ text: buildResultStub(id, toolName, output),
93
+ stored: { id, chars: output.length, pages },
94
+ };
83
95
  }
84
96
 
85
97
  /** Clear all stored results (useful between agent loop runs or for cleanup) */
package/src/daemon/llm.ts CHANGED
@@ -49,7 +49,7 @@ export async function runAgentLoop(input: {
49
49
  mcpxClient: input.mcpxClient ?? null,
50
50
  };
51
51
 
52
- const userMessage = `Please work on this task:\n\nName: ${task.name}\nDescription: ${task.description}\nPriority: ${task.priority}\n\nUse the available tools to complete this task, then call complete_task, fail_task, or wait_task to indicate the outcome.`;
52
+ const userMessage = `Task:\nName: ${task.name}\nDescription: ${task.description}\nPriority: ${task.priority}`;
53
53
 
54
54
  const messages: MessageParam[] = [{ role: "user", content: userMessage }];
55
55
 
@@ -67,8 +67,8 @@ export async function runAgentLoop(input: {
67
67
  config.model,
68
68
  );
69
69
 
70
- const maxTurns = 10;
71
- for (let turn = 0; turn < maxTurns; turn++) {
70
+ const maxTurns = config.max_turns;
71
+ for (let turn = 0; !maxTurns || turn < maxTurns; turn++) {
72
72
  const startTime = Date.now();
73
73
  fitToContextWindow(messages, systemPrompt, maxInputTokens);
74
74
  const response = await client.messages.create({
@@ -148,7 +148,8 @@ export async function runAgentLoop(input: {
148
148
  toolResults.push({
149
149
  type: "tool_result",
150
150
  tool_use_id: toolUse.id,
151
- content: maybeStoreResult(toolUse.name, result.output),
151
+ content: maybeStoreResult(toolUse.name, result.output).text,
152
+ is_error: result.isError || undefined,
152
153
  });
153
154
  }
154
155
 
@@ -161,6 +162,7 @@ export async function runAgentLoop(input: {
161
162
  interface ToolCallResult {
162
163
  output: string;
163
164
  terminal: boolean;
165
+ isError: boolean;
164
166
  agentResult?: AgentLoopResult;
165
167
  }
166
168
 
@@ -170,18 +172,30 @@ async function executeToolCall(
170
172
  ): Promise<ToolCallResult> {
171
173
  const tool = getTool(toolUse.name);
172
174
  if (!tool) {
173
- return { output: `Unknown tool: ${toolUse.name}`, terminal: false };
175
+ return {
176
+ output: `Unknown tool: ${toolUse.name}`,
177
+ terminal: false,
178
+ isError: true,
179
+ };
174
180
  }
175
181
 
176
182
  const parsed = tool.inputSchema.safeParse(toolUse.input);
177
183
  if (!parsed.success) {
184
+ const issues = parsed.error.issues
185
+ .map((i) => `${i.path.join(".")}: ${i.message}`)
186
+ .join("; ");
178
187
  return {
179
- output: `Invalid input: ${JSON.stringify(parsed.error)}`,
188
+ output: `Invalid input for ${toolUse.name}: ${issues}. Check the tool's expected parameters.`,
180
189
  terminal: false,
190
+ isError: true,
181
191
  };
182
192
  }
183
193
 
184
194
  const result = await tool.execute(parsed.data, ctx);
195
+ const isError =
196
+ typeof result === "object" && result !== null && "is_error" in result
197
+ ? (result as { is_error: boolean }).is_error
198
+ : false;
185
199
  const output = typeof result === "string" ? result : JSON.stringify(result);
186
200
 
187
201
  // Check if this is a terminal tool (complete/fail/wait)
@@ -195,10 +209,11 @@ async function executeToolCall(
195
209
  return {
196
210
  output,
197
211
  terminal: true,
212
+ isError,
198
213
  agentResult: { status, reason: String(reason) },
199
214
  };
200
215
  }
201
216
  }
202
217
 
203
- return { output, terminal: false };
218
+ return { output, terminal: false, isError };
204
219
  }
@@ -123,15 +123,7 @@ export async function buildSystemPrompt(
123
123
  // Instructions
124
124
  parts.push("## Instructions");
125
125
  parts.push(
126
- "You are the Botholomew daemon, personified by a wise owl. You wake up periodically to work through tasks.",
127
- );
128
- parts.push("When given a task, use the available tools to complete it.");
129
- parts.push(
130
- "Always call complete_task, fail_task, or wait_task when you are done.",
131
- );
132
- parts.push("If you need to create subtasks, use create_task.");
133
- parts.push(
134
- "When multiple tool calls are independent of each other (i.e., one does not depend on the result of another), call them all in a single response. They will be executed in parallel, which is faster than calling them one at a time. Only sequence tool calls when a later call depends on an earlier result.",
126
+ "You are Botholomew, a wise-owl daemon that works through tasks. Use available tools to complete your assigned task, then call complete_task, fail_task, or wait_task. Use create_task for subtasks and update_task to refine pending tasks. Batch independent tool calls in a single response for parallel execution.",
135
127
  );
136
128
  if (options?.hasMcpTools) {
137
129
  parts.push("");
@@ -8,6 +8,7 @@ import {
8
8
  } from "../db/tasks.ts";
9
9
  import { createThread, endThread, logInteraction } from "../db/threads.ts";
10
10
  import { logger } from "../utils/logger.ts";
11
+ import { generateThreadTitle } from "../utils/title.ts";
11
12
  import { runAgentLoop } from "./llm.ts";
12
13
  import { buildSystemPrompt } from "./prompt.ts";
13
14
  import { processSchedules } from "./schedules.ts";
@@ -82,6 +83,14 @@ export async function tick(
82
83
  });
83
84
 
84
85
  logger.info(`Task ${task.id} -> ${result.status}`);
86
+
87
+ // Generate a descriptive title for the thread
88
+ void generateThreadTitle(
89
+ config,
90
+ conn,
91
+ threadId,
92
+ `Task: ${task.name}\nDescription: ${task.description}\nOutcome: ${result.status}${result.reason ? ` — ${result.reason}` : ""}`,
93
+ );
85
94
  } catch (err) {
86
95
  await updateTaskStatus(conn, task.id, "failed", String(err));
87
96
 
package/src/db/threads.ts CHANGED
@@ -154,6 +154,14 @@ export async function reopenThread(
154
154
  db.query("UPDATE threads SET ended_at = NULL WHERE id = ?1").run(threadId);
155
155
  }
156
156
 
157
+ export async function updateThreadTitle(
158
+ db: DbConnection,
159
+ threadId: string,
160
+ title: string,
161
+ ): Promise<void> {
162
+ db.query("UPDATE threads SET title = ?2 WHERE id = ?1").run(threadId, title);
163
+ }
164
+
157
165
  export async function getThread(
158
166
  db: DbConnection,
159
167
  threadId: string,
@@ -175,6 +183,15 @@ export async function getThread(
175
183
  };
176
184
  }
177
185
 
186
+ export async function deleteThread(
187
+ db: DbConnection,
188
+ threadId: string,
189
+ ): Promise<boolean> {
190
+ db.query("DELETE FROM interactions WHERE thread_id = ?1").run(threadId);
191
+ const result = db.query("DELETE FROM threads WHERE id = ?1").run(threadId);
192
+ return result.changes > 0;
193
+ }
194
+
178
195
  export async function listThreads(
179
196
  db: DbConnection,
180
197
  filters?: {
@@ -42,6 +42,7 @@ export const DEFAULT_CONFIG = {
42
42
  model: "claude-opus-4-20250514",
43
43
  tick_interval_seconds: 300,
44
44
  max_tick_duration_seconds: 120,
45
+ max_turns: 0,
45
46
  };
46
47
 
47
48
  export const DEFAULT_MCPX_SERVERS = {
@@ -11,6 +11,7 @@ const outputSchema = z.object({
11
11
  content: z.string(),
12
12
  page: z.number(),
13
13
  totalPages: z.number(),
14
+ is_error: z.boolean(),
14
15
  });
15
16
 
16
17
  export const readLargeResultTool = {
@@ -27,6 +28,6 @@ export const readLargeResultTool = {
27
28
  `No result found for id="${input.id}" page=${input.page}. The id may be invalid or the page may be out of range.`,
28
29
  );
29
30
  }
30
- return result;
31
+ return { ...result, is_error: false };
31
32
  },
32
33
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -19,6 +19,7 @@ const outputSchema = z.object({
19
19
  }),
20
20
  ),
21
21
  count: z.number(),
22
+ is_error: z.boolean(),
22
23
  });
23
24
 
24
25
  export const searchContextTool = {
@@ -41,6 +42,7 @@ export const searchContextTool = {
41
42
  content_preview: (item.content ?? "").slice(0, 500),
42
43
  })),
43
44
  count: items.length,
45
+ is_error: false,
44
46
  };
45
47
  },
46
48
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -19,6 +19,7 @@ const inputSchema = z.object({
19
19
  const outputSchema = z.object({
20
20
  message: z.string(),
21
21
  path: z.string(),
22
+ is_error: z.boolean(),
22
23
  });
23
24
 
24
25
  export const updateBeliefsTool = {
@@ -49,6 +50,7 @@ export const updateBeliefsTool = {
49
50
  return {
50
51
  message: "Updated beliefs.md",
51
52
  path: filePath,
53
+ is_error: false,
52
54
  };
53
55
  },
54
56
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;