botholomew 0.2.0 → 0.3.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.
- package/package.json +4 -2
- package/src/chat/agent.ts +216 -0
- package/src/chat/session.ts +136 -0
- package/src/commands/chat.ts +30 -4
- package/src/commands/context.ts +37 -22
- package/src/daemon/prompt.ts +50 -26
- package/src/db/threads.ts +7 -0
- package/src/init/index.ts +1 -1
- package/src/init/templates.ts +2 -1
- package/src/tools/context/search.ts +46 -0
- package/src/tools/context/update-beliefs.ts +54 -0
- package/src/tools/context/update-goals.ts +54 -0
- package/src/tools/registry.ts +20 -0
- package/src/tools/task/list.ts +49 -0
- package/src/tools/task/view.ts +52 -0
- package/src/tools/thread/list.ts +50 -0
- package/src/tools/thread/view.ts +63 -0
- package/src/tui/App.tsx +353 -9
- package/src/tui/components/InputBar.tsx +126 -0
- package/src/tui/components/MessageList.tsx +160 -0
- package/src/tui/components/StatusBar.tsx +73 -0
- package/src/tui/components/ToolCall.tsx +38 -0
- package/src/tui/components/ToolPanel.tsx +328 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "botholomew",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "An AI agent for knowledge work",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"scripts": {
|
|
19
19
|
"dev": "bun run src/cli.ts",
|
|
20
20
|
"test": "bun test",
|
|
21
|
-
"build": "bun build --compile --minify --sourcemap ./src/cli.ts --outfile dist/botholomew",
|
|
21
|
+
"build": "bun build --compile --minify --sourcemap --external react-devtools-core ./src/cli.ts --outfile dist/botholomew",
|
|
22
22
|
"lint": "tsc --noEmit && biome check ."
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
"commander": "^14.0.0",
|
|
31
31
|
"gray-matter": "^4.0.3",
|
|
32
32
|
"ink": "^6.0.0",
|
|
33
|
+
"ink-spinner": "^5.0.0",
|
|
34
|
+
"ink-text-input": "^6.0.0",
|
|
33
35
|
"istextorbinary": "^9.5.0",
|
|
34
36
|
"react": "^19.1.0",
|
|
35
37
|
"uuid": "^13.0.0",
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import type {
|
|
3
|
+
MessageParam,
|
|
4
|
+
ToolResultBlockParam,
|
|
5
|
+
ToolUseBlock,
|
|
6
|
+
} from "@anthropic-ai/sdk/resources/messages";
|
|
7
|
+
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
8
|
+
import { buildMetaHeader, loadPersistentContext } from "../daemon/prompt.ts";
|
|
9
|
+
import type { DbConnection } from "../db/connection.ts";
|
|
10
|
+
import { logInteraction } from "../db/threads.ts";
|
|
11
|
+
import { registerAllTools } from "../tools/registry.ts";
|
|
12
|
+
import {
|
|
13
|
+
getAllTools,
|
|
14
|
+
getTool,
|
|
15
|
+
type ToolContext,
|
|
16
|
+
toAnthropicTool,
|
|
17
|
+
} from "../tools/tool.ts";
|
|
18
|
+
|
|
19
|
+
registerAllTools();
|
|
20
|
+
|
|
21
|
+
/** Tools available in chat mode — no daemon terminal tools, no destructive file tools */
|
|
22
|
+
const CHAT_TOOL_NAMES = new Set([
|
|
23
|
+
"create_task",
|
|
24
|
+
"list_tasks",
|
|
25
|
+
"view_task",
|
|
26
|
+
"search_context",
|
|
27
|
+
"search_grep",
|
|
28
|
+
"search_semantic",
|
|
29
|
+
"list_threads",
|
|
30
|
+
"view_thread",
|
|
31
|
+
"create_schedule",
|
|
32
|
+
"list_schedules",
|
|
33
|
+
"update_beliefs",
|
|
34
|
+
"update_goals",
|
|
35
|
+
"mcp_list_tools",
|
|
36
|
+
"mcp_search",
|
|
37
|
+
"mcp_info",
|
|
38
|
+
"mcp_exec",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
export function getChatTools() {
|
|
42
|
+
return getAllTools()
|
|
43
|
+
.filter((t) => CHAT_TOOL_NAMES.has(t.name))
|
|
44
|
+
.map(toAnthropicTool);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function buildChatSystemPrompt(
|
|
48
|
+
projectDir: string,
|
|
49
|
+
): Promise<string> {
|
|
50
|
+
const parts: string[] = [];
|
|
51
|
+
|
|
52
|
+
parts.push(...buildMetaHeader(projectDir));
|
|
53
|
+
parts.push(...(await loadPersistentContext(projectDir)));
|
|
54
|
+
|
|
55
|
+
parts.push("## Instructions");
|
|
56
|
+
parts.push(
|
|
57
|
+
"You are Botholomew's interactive chat interface. Help the user manage tasks, review results from daemon activity, search context, and answer questions.",
|
|
58
|
+
);
|
|
59
|
+
parts.push(
|
|
60
|
+
"You do NOT execute long-running work directly — enqueue tasks for the daemon instead using create_task.",
|
|
61
|
+
);
|
|
62
|
+
parts.push(
|
|
63
|
+
"Use the available tools to look up tasks, threads, schedules, and context when the user asks about them.",
|
|
64
|
+
);
|
|
65
|
+
parts.push(
|
|
66
|
+
"You can update the agent's beliefs and goals files when the user asks you to.",
|
|
67
|
+
);
|
|
68
|
+
parts.push(
|
|
69
|
+
"Format your responses using Markdown. Use headings, bold, italic, lists, and code blocks to make your responses clear and well-structured.",
|
|
70
|
+
);
|
|
71
|
+
parts.push("");
|
|
72
|
+
|
|
73
|
+
return parts.join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ChatTurnCallbacks {
|
|
77
|
+
onToken: (text: string) => void;
|
|
78
|
+
onToolStart: (name: string, input: string) => void;
|
|
79
|
+
onToolEnd: (name: string, output: string) => void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run a single chat turn: stream the assistant response, execute any tool calls,
|
|
84
|
+
* and loop until the model produces end_turn with no tool calls.
|
|
85
|
+
* Mutates `messages` in-place by appending assistant/tool messages.
|
|
86
|
+
*/
|
|
87
|
+
export async function runChatTurn(input: {
|
|
88
|
+
messages: MessageParam[];
|
|
89
|
+
systemPrompt: string;
|
|
90
|
+
config: Required<BotholomewConfig>;
|
|
91
|
+
conn: DbConnection;
|
|
92
|
+
threadId: string;
|
|
93
|
+
toolCtx: ToolContext;
|
|
94
|
+
callbacks: ChatTurnCallbacks;
|
|
95
|
+
}): Promise<void> {
|
|
96
|
+
const { messages, systemPrompt, config, conn, threadId, toolCtx, callbacks } =
|
|
97
|
+
input;
|
|
98
|
+
|
|
99
|
+
const client = new Anthropic({
|
|
100
|
+
apiKey: config.anthropic_api_key || undefined,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const chatTools = getChatTools();
|
|
104
|
+
const maxTurns = 10;
|
|
105
|
+
|
|
106
|
+
for (let turn = 0; turn < maxTurns; turn++) {
|
|
107
|
+
const startTime = Date.now();
|
|
108
|
+
|
|
109
|
+
const stream = client.messages.stream({
|
|
110
|
+
model: config.model,
|
|
111
|
+
max_tokens: 4096,
|
|
112
|
+
system: systemPrompt,
|
|
113
|
+
messages,
|
|
114
|
+
tools: chatTools,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Collect the full response
|
|
118
|
+
let assistantText = "";
|
|
119
|
+
|
|
120
|
+
stream.on("text", (text) => {
|
|
121
|
+
assistantText += text;
|
|
122
|
+
callbacks.onToken(text);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const response = await stream.finalMessage();
|
|
126
|
+
const durationMs = Date.now() - startTime;
|
|
127
|
+
const tokenCount =
|
|
128
|
+
response.usage.input_tokens + response.usage.output_tokens;
|
|
129
|
+
|
|
130
|
+
// Log assistant text
|
|
131
|
+
if (assistantText) {
|
|
132
|
+
await logInteraction(conn, threadId, {
|
|
133
|
+
role: "assistant",
|
|
134
|
+
kind: "message",
|
|
135
|
+
content: assistantText,
|
|
136
|
+
durationMs,
|
|
137
|
+
tokenCount,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Check for tool calls
|
|
142
|
+
const toolUseBlocks = response.content.filter(
|
|
143
|
+
(block): block is ToolUseBlock => block.type === "tool_use",
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
if (toolUseBlocks.length === 0) {
|
|
147
|
+
// No tool calls — turn is complete
|
|
148
|
+
messages.push({ role: "assistant", content: response.content });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Add assistant response to conversation
|
|
153
|
+
messages.push({ role: "assistant", content: response.content });
|
|
154
|
+
|
|
155
|
+
// Execute tool calls
|
|
156
|
+
const toolResults: ToolResultBlockParam[] = [];
|
|
157
|
+
|
|
158
|
+
for (const toolUse of toolUseBlocks) {
|
|
159
|
+
const toolInput = JSON.stringify(toolUse.input);
|
|
160
|
+
callbacks.onToolStart(toolUse.name, toolInput);
|
|
161
|
+
|
|
162
|
+
await logInteraction(conn, threadId, {
|
|
163
|
+
role: "assistant",
|
|
164
|
+
kind: "tool_use",
|
|
165
|
+
content: `Calling ${toolUse.name}`,
|
|
166
|
+
toolName: toolUse.name,
|
|
167
|
+
toolInput,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const toolStart = Date.now();
|
|
171
|
+
const result = await executeChatToolCall(toolUse, toolCtx);
|
|
172
|
+
const toolDuration = Date.now() - toolStart;
|
|
173
|
+
|
|
174
|
+
await logInteraction(conn, threadId, {
|
|
175
|
+
role: "tool",
|
|
176
|
+
kind: "tool_result",
|
|
177
|
+
content: result,
|
|
178
|
+
toolName: toolUse.name,
|
|
179
|
+
durationMs: toolDuration,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
callbacks.onToolEnd(toolUse.name, result);
|
|
183
|
+
|
|
184
|
+
toolResults.push({
|
|
185
|
+
type: "tool_result",
|
|
186
|
+
tool_use_id: toolUse.id,
|
|
187
|
+
content: result,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
messages.push({ role: "user", content: toolResults });
|
|
192
|
+
// Loop to get the model's next response after tool results
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function executeChatToolCall(
|
|
197
|
+
toolUse: ToolUseBlock,
|
|
198
|
+
ctx: ToolContext,
|
|
199
|
+
): Promise<string> {
|
|
200
|
+
const tool = getTool(toolUse.name);
|
|
201
|
+
if (!tool) return `Unknown tool: ${toolUse.name}`;
|
|
202
|
+
if (!CHAT_TOOL_NAMES.has(tool.name))
|
|
203
|
+
return `Tool not available in chat mode: ${tool.name}`;
|
|
204
|
+
|
|
205
|
+
const parsed = tool.inputSchema.safeParse(toolUse.input);
|
|
206
|
+
if (!parsed.success) {
|
|
207
|
+
return `Invalid input: ${JSON.stringify(parsed.error)}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const result = await tool.execute(parsed.data, ctx);
|
|
212
|
+
return typeof result === "string" ? result : JSON.stringify(result);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
return `Tool error: ${err}`;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { MessageParam } from "@anthropic-ai/sdk/resources/messages";
|
|
2
|
+
import { loadConfig } from "../config/loader.ts";
|
|
3
|
+
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
4
|
+
import { getDbPath } from "../constants.ts";
|
|
5
|
+
import type { DbConnection } from "../db/connection.ts";
|
|
6
|
+
import { getConnection } from "../db/connection.ts";
|
|
7
|
+
import { migrate } from "../db/schema.ts";
|
|
8
|
+
import {
|
|
9
|
+
createThread,
|
|
10
|
+
endThread,
|
|
11
|
+
getThread,
|
|
12
|
+
logInteraction,
|
|
13
|
+
reopenThread,
|
|
14
|
+
} from "../db/threads.ts";
|
|
15
|
+
import { createMcpxClient } from "../mcpx/client.ts";
|
|
16
|
+
import type { ToolContext } from "../tools/tool.ts";
|
|
17
|
+
import {
|
|
18
|
+
buildChatSystemPrompt,
|
|
19
|
+
type ChatTurnCallbacks,
|
|
20
|
+
runChatTurn,
|
|
21
|
+
} from "./agent.ts";
|
|
22
|
+
|
|
23
|
+
export interface ChatSession {
|
|
24
|
+
conn: DbConnection;
|
|
25
|
+
threadId: string;
|
|
26
|
+
projectDir: string;
|
|
27
|
+
config: Required<BotholomewConfig>;
|
|
28
|
+
messages: MessageParam[];
|
|
29
|
+
systemPrompt: string;
|
|
30
|
+
toolCtx: ToolContext;
|
|
31
|
+
cleanup: () => Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function startChatSession(
|
|
35
|
+
projectDir: string,
|
|
36
|
+
existingThreadId?: string,
|
|
37
|
+
): Promise<ChatSession> {
|
|
38
|
+
const config = await loadConfig(projectDir);
|
|
39
|
+
|
|
40
|
+
if (!config.anthropic_api_key) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
"no API key found. add anthropic_api_key to .botholomew/config.json",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const conn = getConnection(getDbPath(projectDir));
|
|
47
|
+
migrate(conn);
|
|
48
|
+
|
|
49
|
+
let threadId: string;
|
|
50
|
+
const messages: MessageParam[] = [];
|
|
51
|
+
|
|
52
|
+
if (existingThreadId) {
|
|
53
|
+
// Resume existing thread
|
|
54
|
+
const result = await getThread(conn, existingThreadId);
|
|
55
|
+
if (!result) {
|
|
56
|
+
conn.close();
|
|
57
|
+
throw new Error(`Thread not found: ${existingThreadId}`);
|
|
58
|
+
}
|
|
59
|
+
threadId = existingThreadId;
|
|
60
|
+
await reopenThread(conn, threadId);
|
|
61
|
+
|
|
62
|
+
// Rebuild message history from interactions
|
|
63
|
+
for (const interaction of result.interactions) {
|
|
64
|
+
if (interaction.kind !== "message") continue;
|
|
65
|
+
if (interaction.role === "user") {
|
|
66
|
+
messages.push({ role: "user", content: interaction.content });
|
|
67
|
+
} else if (interaction.role === "assistant") {
|
|
68
|
+
messages.push({ role: "assistant", content: interaction.content });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
threadId = await createThread(
|
|
73
|
+
conn,
|
|
74
|
+
"chat_session",
|
|
75
|
+
undefined,
|
|
76
|
+
"Interactive chat",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const systemPrompt = await buildChatSystemPrompt(projectDir);
|
|
81
|
+
|
|
82
|
+
const mcpxClient = await createMcpxClient(projectDir);
|
|
83
|
+
|
|
84
|
+
const toolCtx: ToolContext = {
|
|
85
|
+
conn,
|
|
86
|
+
projectDir,
|
|
87
|
+
config,
|
|
88
|
+
mcpxClient,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const cleanup = async () => {
|
|
92
|
+
await mcpxClient?.close();
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
conn,
|
|
97
|
+
threadId,
|
|
98
|
+
projectDir,
|
|
99
|
+
config,
|
|
100
|
+
messages,
|
|
101
|
+
systemPrompt,
|
|
102
|
+
toolCtx,
|
|
103
|
+
cleanup,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function sendMessage(
|
|
108
|
+
session: ChatSession,
|
|
109
|
+
userMessage: string,
|
|
110
|
+
callbacks: ChatTurnCallbacks,
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
// Log and append user message
|
|
113
|
+
await logInteraction(session.conn, session.threadId, {
|
|
114
|
+
role: "user",
|
|
115
|
+
kind: "message",
|
|
116
|
+
content: userMessage,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
session.messages.push({ role: "user", content: userMessage });
|
|
120
|
+
|
|
121
|
+
await runChatTurn({
|
|
122
|
+
messages: session.messages,
|
|
123
|
+
systemPrompt: session.systemPrompt,
|
|
124
|
+
config: session.config,
|
|
125
|
+
conn: session.conn,
|
|
126
|
+
threadId: session.threadId,
|
|
127
|
+
toolCtx: session.toolCtx,
|
|
128
|
+
callbacks,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function endChatSession(session: ChatSession): Promise<void> {
|
|
133
|
+
await endThread(session.conn, session.threadId);
|
|
134
|
+
await session.cleanup();
|
|
135
|
+
session.conn.close();
|
|
136
|
+
}
|
package/src/commands/chat.ts
CHANGED
|
@@ -1,11 +1,37 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
|
-
import { logger } from "../utils/logger.ts";
|
|
3
2
|
|
|
4
3
|
export function registerChatCommand(program: Command) {
|
|
5
4
|
program
|
|
6
5
|
.command("chat")
|
|
7
|
-
.description(
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
.description(
|
|
7
|
+
"Open the interactive chat TUI\n\n" +
|
|
8
|
+
" Keyboard shortcuts:\n" +
|
|
9
|
+
" Enter Send message\n" +
|
|
10
|
+
" ⌥+Enter Insert newline (multiline input)\n" +
|
|
11
|
+
" ↑/↓ Browse input history\n\n" +
|
|
12
|
+
" Commands:\n" +
|
|
13
|
+
" /help Show keyboard shortcuts\n" +
|
|
14
|
+
" /tools Open tool call inspector\n" +
|
|
15
|
+
" /quit, /exit End the chat session",
|
|
16
|
+
)
|
|
17
|
+
.option("--thread-id <id>", "Resume an existing chat thread")
|
|
18
|
+
.action(async (opts: { threadId?: string }) => {
|
|
19
|
+
const { render } = await import("ink");
|
|
20
|
+
const React = await import("react");
|
|
21
|
+
const { App } = await import("../tui/App.tsx");
|
|
22
|
+
const dir = program.opts().dir;
|
|
23
|
+
const instance = render(
|
|
24
|
+
React.createElement(App, {
|
|
25
|
+
projectDir: dir,
|
|
26
|
+
threadId: opts.threadId,
|
|
27
|
+
}),
|
|
28
|
+
{
|
|
29
|
+
kittyKeyboard: {
|
|
30
|
+
mode: "enabled",
|
|
31
|
+
flags: ["disambiguateEscapeCodes", "reportEventTypes"],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
await instance.waitUntilExit();
|
|
10
36
|
});
|
|
11
37
|
}
|
package/src/commands/context.ts
CHANGED
|
@@ -16,6 +16,11 @@ import { hybridSearch, initVectorSearch } from "../db/embeddings.ts";
|
|
|
16
16
|
import { logger } from "../utils/logger.ts";
|
|
17
17
|
import { withDb } from "./with-db.ts";
|
|
18
18
|
|
|
19
|
+
function fmtDate(d: Date): string {
|
|
20
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
21
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
export function registerContextCommand(program: Command) {
|
|
20
25
|
const ctx = program.command("context").description("Manage context items");
|
|
21
26
|
|
|
@@ -38,7 +43,7 @@ export function registerContextCommand(program: Command) {
|
|
|
38
43
|
return;
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
const header = `${ansis.bold("Path".padEnd(40))} ${"Title".padEnd(25)} ${"Type".padEnd(20)} Indexed`;
|
|
46
|
+
const header = `${ansis.bold("Path".padEnd(40))} ${"Title".padEnd(25)} ${"Type".padEnd(20)} ${"Updated".padEnd(18)} Indexed`;
|
|
42
47
|
console.log(header);
|
|
43
48
|
console.log("-".repeat(header.length));
|
|
44
49
|
|
|
@@ -46,8 +51,9 @@ export function registerContextCommand(program: Command) {
|
|
|
46
51
|
const indexed = item.indexed_at
|
|
47
52
|
? ansis.green("yes")
|
|
48
53
|
: ansis.dim("no");
|
|
54
|
+
const updated = ansis.dim(fmtDate(item.updated_at).padEnd(18));
|
|
49
55
|
console.log(
|
|
50
|
-
`${item.context_path.padEnd(40)} ${item.title.slice(0, 24).padEnd(25)} ${item.mime_type.slice(0, 19).padEnd(20)} ${indexed}`,
|
|
56
|
+
`${item.context_path.padEnd(40)} ${item.title.slice(0, 24).padEnd(25)} ${item.mime_type.slice(0, 19).padEnd(20)} ${updated} ${indexed}`,
|
|
51
57
|
);
|
|
52
58
|
}
|
|
53
59
|
|
|
@@ -56,38 +62,45 @@ export function registerContextCommand(program: Command) {
|
|
|
56
62
|
);
|
|
57
63
|
|
|
58
64
|
ctx
|
|
59
|
-
.command("add <
|
|
60
|
-
.description("Add
|
|
65
|
+
.command("add <paths...>")
|
|
66
|
+
.description("Add files or directories to context")
|
|
61
67
|
.option("--prefix <prefix>", "virtual path prefix", "/")
|
|
62
|
-
.action((
|
|
68
|
+
.action((paths: string[], opts) =>
|
|
63
69
|
withDb(program, async (conn, dir) => {
|
|
64
70
|
const config = await loadConfig(dir);
|
|
65
71
|
await warmupEmbedder();
|
|
66
72
|
|
|
67
|
-
const resolvedPath = resolve(path);
|
|
68
|
-
const info = await stat(resolvedPath);
|
|
69
|
-
|
|
70
73
|
let added = 0;
|
|
71
74
|
let chunks = 0;
|
|
72
75
|
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
76
|
+
for (const path of paths) {
|
|
77
|
+
const resolvedPath = resolve(path);
|
|
78
|
+
const info = await stat(resolvedPath);
|
|
79
|
+
|
|
80
|
+
if (info.isDirectory()) {
|
|
81
|
+
const entries = await walkDirectory(resolvedPath);
|
|
82
|
+
for (const filePath of entries) {
|
|
83
|
+
const relativePath = filePath.slice(resolvedPath.length);
|
|
84
|
+
const contextPath = join(opts.prefix, relativePath);
|
|
85
|
+
const count = await addFile(conn, config, filePath, contextPath);
|
|
86
|
+
if (count >= 0) {
|
|
87
|
+
added++;
|
|
88
|
+
chunks += count;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
const contextPath = join(opts.prefix, basename(resolvedPath));
|
|
93
|
+
const count = await addFile(
|
|
94
|
+
conn,
|
|
95
|
+
config,
|
|
96
|
+
resolvedPath,
|
|
97
|
+
contextPath,
|
|
98
|
+
);
|
|
79
99
|
if (count >= 0) {
|
|
80
100
|
added++;
|
|
81
101
|
chunks += count;
|
|
82
102
|
}
|
|
83
103
|
}
|
|
84
|
-
} else {
|
|
85
|
-
const contextPath = join(opts.prefix, basename(resolvedPath));
|
|
86
|
-
const count = await addFile(conn, config, resolvedPath, contextPath);
|
|
87
|
-
if (count >= 0) {
|
|
88
|
-
added++;
|
|
89
|
-
chunks += count;
|
|
90
|
-
}
|
|
91
104
|
}
|
|
92
105
|
|
|
93
106
|
logger.success(`Added ${added} file(s), ${chunks} chunk(s) indexed.`);
|
|
@@ -115,7 +128,9 @@ export function registerContextCommand(program: Command) {
|
|
|
115
128
|
console.log(
|
|
116
129
|
`${ansis.bold(`${i + 1}.`)} ${ansis.cyan(r.title)} ${ansis.dim(`(${score}%)`)}`,
|
|
117
130
|
);
|
|
118
|
-
console.log(
|
|
131
|
+
console.log(
|
|
132
|
+
` ${ansis.dim(r.source_path || r.context_item_id)} ${ansis.dim(fmtDate(r.created_at))}`,
|
|
133
|
+
);
|
|
119
134
|
if (r.chunk_content) {
|
|
120
135
|
const snippet = r.chunk_content.slice(0, 120).replace(/\n/g, " ");
|
|
121
136
|
console.log(` ${snippet}...`);
|
package/src/daemon/prompt.ts
CHANGED
|
@@ -13,35 +13,18 @@ const pkg = await Bun.file(
|
|
|
13
13
|
new URL("../../package.json", import.meta.url),
|
|
14
14
|
).json();
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Load persistent context files from .botholomew/ directory.
|
|
18
|
+
* Returns an array of formatted string sections for "always" loaded files.
|
|
19
|
+
* If taskKeywords are provided, also includes "contextual" files that match.
|
|
20
|
+
*/
|
|
21
|
+
export async function loadPersistentContext(
|
|
17
22
|
projectDir: string,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
_config?: Required<BotholomewConfig>,
|
|
21
|
-
options?: { hasMcpTools?: boolean },
|
|
22
|
-
): Promise<string> {
|
|
23
|
+
taskKeywords?: Set<string> | null,
|
|
24
|
+
): Promise<string[]> {
|
|
23
25
|
const dotDir = getBotholomewDir(projectDir);
|
|
24
26
|
const parts: string[] = [];
|
|
25
27
|
|
|
26
|
-
// Meta information
|
|
27
|
-
parts.push(`# Botholomew v${pkg.version}`);
|
|
28
|
-
parts.push(`Current time: ${new Date().toISOString()}`);
|
|
29
|
-
parts.push(`Project directory: ${projectDir}`);
|
|
30
|
-
parts.push(`OS: ${process.platform} ${process.arch}`);
|
|
31
|
-
parts.push(`User: ${process.env.USER || process.env.USERNAME || "unknown"}`);
|
|
32
|
-
parts.push("");
|
|
33
|
-
|
|
34
|
-
// Build keyword set from task for contextual loading
|
|
35
|
-
const taskKeywords = task
|
|
36
|
-
? new Set(
|
|
37
|
-
`${task.name} ${task.description}`
|
|
38
|
-
.toLowerCase()
|
|
39
|
-
.split(/\s+/)
|
|
40
|
-
.filter((w) => w.length > 3),
|
|
41
|
-
)
|
|
42
|
-
: null;
|
|
43
|
-
|
|
44
|
-
// Load context files from .botholomew/
|
|
45
28
|
try {
|
|
46
29
|
const files = await readdir(dotDir);
|
|
47
30
|
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
@@ -56,7 +39,6 @@ export async function buildSystemPrompt(
|
|
|
56
39
|
parts.push(content);
|
|
57
40
|
parts.push("");
|
|
58
41
|
} else if (meta.loading === "contextual" && taskKeywords) {
|
|
59
|
-
// Include contextual files if keywords overlap with task
|
|
60
42
|
const contentLower = content.toLowerCase();
|
|
61
43
|
const hasOverlap = [...taskKeywords].some((kw) =>
|
|
62
44
|
contentLower.includes(kw),
|
|
@@ -72,6 +54,48 @@ export async function buildSystemPrompt(
|
|
|
72
54
|
// .botholomew dir might not have md files yet
|
|
73
55
|
}
|
|
74
56
|
|
|
57
|
+
return parts;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build common meta header (version, time, OS, user).
|
|
62
|
+
*/
|
|
63
|
+
export function buildMetaHeader(projectDir: string): string[] {
|
|
64
|
+
return [
|
|
65
|
+
`# Botholomew v${pkg.version}`,
|
|
66
|
+
`Current time: ${new Date().toISOString()}`,
|
|
67
|
+
`Project directory: ${projectDir}`,
|
|
68
|
+
`OS: ${process.platform} ${process.arch}`,
|
|
69
|
+
`User: ${process.env.USER || process.env.USERNAME || "unknown"}`,
|
|
70
|
+
"",
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function buildSystemPrompt(
|
|
75
|
+
projectDir: string,
|
|
76
|
+
task?: Task,
|
|
77
|
+
conn?: DbConnection,
|
|
78
|
+
_config?: Required<BotholomewConfig>,
|
|
79
|
+
options?: { hasMcpTools?: boolean },
|
|
80
|
+
): Promise<string> {
|
|
81
|
+
const parts: string[] = [];
|
|
82
|
+
|
|
83
|
+
// Meta information
|
|
84
|
+
parts.push(...buildMetaHeader(projectDir));
|
|
85
|
+
|
|
86
|
+
// Build keyword set from task for contextual loading
|
|
87
|
+
const taskKeywords = task
|
|
88
|
+
? new Set(
|
|
89
|
+
`${task.name} ${task.description}`
|
|
90
|
+
.toLowerCase()
|
|
91
|
+
.split(/\s+/)
|
|
92
|
+
.filter((w) => w.length > 3),
|
|
93
|
+
)
|
|
94
|
+
: null;
|
|
95
|
+
|
|
96
|
+
// Load context files from .botholomew/
|
|
97
|
+
parts.push(...(await loadPersistentContext(projectDir, taskKeywords)));
|
|
98
|
+
|
|
75
99
|
// Relevant context from embeddings search
|
|
76
100
|
if (task && conn) {
|
|
77
101
|
try {
|
package/src/db/threads.ts
CHANGED
|
@@ -147,6 +147,13 @@ export async function endThread(
|
|
|
147
147
|
);
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
export async function reopenThread(
|
|
151
|
+
db: DbConnection,
|
|
152
|
+
threadId: string,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
db.query("UPDATE threads SET ended_at = NULL WHERE id = ?1").run(threadId);
|
|
155
|
+
}
|
|
156
|
+
|
|
150
157
|
export async function getThread(
|
|
151
158
|
db: DbConnection,
|
|
152
159
|
threadId: string,
|
package/src/init/index.ts
CHANGED
|
@@ -36,7 +36,7 @@ export async function initProject(
|
|
|
36
36
|
await Bun.write(join(dotDir, "beliefs.md"), BELIEFS_MD);
|
|
37
37
|
await Bun.write(join(dotDir, "goals.md"), GOALS_MD);
|
|
38
38
|
|
|
39
|
-
// Write config (
|
|
39
|
+
// Write config (with placeholder API key)
|
|
40
40
|
await Bun.write(
|
|
41
41
|
join(dotDir, "config.json"),
|
|
42
42
|
`${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`,
|
package/src/init/templates.ts
CHANGED