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.
- package/package.json +2 -2
- package/src/chat/agent.ts +62 -16
- package/src/chat/session.ts +19 -6
- package/src/cli.ts +2 -0
- package/src/commands/thread.ts +180 -0
- package/src/config/schemas.ts +3 -1
- package/src/daemon/large-results.ts +15 -3
- package/src/daemon/llm.ts +22 -7
- package/src/daemon/prompt.ts +1 -9
- package/src/daemon/tick.ts +9 -0
- package/src/db/threads.ts +17 -0
- package/src/init/templates.ts +1 -0
- package/src/tools/context/read-large-result.ts +2 -1
- package/src/tools/context/search.ts +2 -0
- package/src/tools/context/update-beliefs.ts +2 -0
- package/src/tools/context/update-goals.ts +2 -0
- package/src/tools/dir/create.ts +3 -2
- package/src/tools/dir/list.ts +2 -1
- package/src/tools/dir/size.ts +2 -1
- package/src/tools/dir/tree.ts +3 -2
- package/src/tools/file/copy.ts +2 -1
- package/src/tools/file/count-lines.ts +2 -1
- package/src/tools/file/delete.ts +3 -2
- package/src/tools/file/edit.ts +2 -1
- package/src/tools/file/exists.ts +2 -1
- package/src/tools/file/info.ts +2 -0
- package/src/tools/file/move.ts +2 -1
- package/src/tools/file/read.ts +2 -1
- package/src/tools/file/write.ts +3 -2
- package/src/tools/mcp/exec.ts +70 -3
- package/src/tools/mcp/info.ts +8 -0
- package/src/tools/mcp/list-tools.ts +18 -6
- package/src/tools/mcp/search.ts +38 -10
- package/src/tools/registry.ts +2 -0
- package/src/tools/schedule/create.ts +2 -0
- package/src/tools/schedule/list.ts +2 -0
- package/src/tools/search/grep.ts +3 -2
- package/src/tools/search/semantic.ts +2 -0
- package/src/tools/task/complete.ts +2 -0
- package/src/tools/task/create.ts +17 -4
- package/src/tools/task/fail.ts +2 -0
- package/src/tools/task/list.ts +2 -0
- package/src/tools/task/update.ts +87 -0
- package/src/tools/task/view.ts +3 -1
- package/src/tools/task/wait.ts +2 -0
- package/src/tools/thread/list.ts +2 -0
- package/src/tools/thread/view.ts +3 -1
- package/src/tools/tool.ts +5 -3
- package/src/tui/App.tsx +209 -82
- package/src/tui/components/ContextPanel.tsx +6 -3
- package/src/tui/components/HelpPanel.tsx +52 -3
- package/src/tui/components/InputBar.tsx +125 -59
- package/src/tui/components/MessageList.tsx +40 -75
- package/src/tui/components/StatusBar.tsx +9 -8
- package/src/tui/components/TabBar.tsx +4 -2
- package/src/tui/components/TaskPanel.tsx +409 -0
- package/src/tui/components/ThreadPanel.tsx +541 -0
- package/src/tui/components/ToolCall.tsx +36 -3
- package/src/tui/components/ToolPanel.tsx +40 -31
- package/src/tui/theme.ts +20 -3
- 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.
|
|
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: (
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/chat/session.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/config/schemas.ts
CHANGED
|
@@ -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-
|
|
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(
|
|
79
|
-
|
|
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
|
-
|
|
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 = `
|
|
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 =
|
|
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 {
|
|
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: ${
|
|
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
|
}
|
package/src/daemon/prompt.ts
CHANGED
|
@@ -123,15 +123,7 @@ export async function buildSystemPrompt(
|
|
|
123
123
|
// Instructions
|
|
124
124
|
parts.push("## Instructions");
|
|
125
125
|
parts.push(
|
|
126
|
-
"You are
|
|
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("");
|
package/src/daemon/tick.ts
CHANGED
|
@@ -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?: {
|
package/src/init/templates.ts
CHANGED
|
@@ -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>;
|