botholomew 0.1.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 +42 -0
- package/src/cli.ts +45 -0
- package/src/commands/chat.ts +11 -0
- package/src/commands/check-update.ts +62 -0
- package/src/commands/context.ts +27 -0
- package/src/commands/daemon.ts +61 -0
- package/src/commands/init.ts +19 -0
- package/src/commands/mcpx.ts +29 -0
- package/src/commands/task.ts +126 -0
- package/src/commands/tools.ts +257 -0
- package/src/commands/upgrade.ts +185 -0
- package/src/config/loader.ts +31 -0
- package/src/config/schemas.ts +15 -0
- package/src/constants.ts +44 -0
- package/src/daemon/index.ts +39 -0
- package/src/daemon/llm.ts +186 -0
- package/src/daemon/prompt.ts +55 -0
- package/src/daemon/run.ts +14 -0
- package/src/daemon/spawn.ts +38 -0
- package/src/daemon/tick.ts +70 -0
- package/src/db/connection.ts +32 -0
- package/src/db/context.ts +415 -0
- package/src/db/embeddings.ts +22 -0
- package/src/db/schedules.ts +17 -0
- package/src/db/schema.ts +66 -0
- package/src/db/sql/1-core_tables.sql +53 -0
- package/src/db/sql/2-logging_tables.sql +24 -0
- package/src/db/sql/3-daemon_state.sql +5 -0
- package/src/db/tasks.ts +194 -0
- package/src/db/threads.ts +202 -0
- package/src/db/uuid.ts +1 -0
- package/src/init/index.ts +84 -0
- package/src/init/templates.ts +48 -0
- package/src/tools/dir/create.ts +39 -0
- package/src/tools/dir/list.ts +87 -0
- package/src/tools/dir/size.ts +45 -0
- package/src/tools/dir/tree.ts +91 -0
- package/src/tools/file/copy.ts +30 -0
- package/src/tools/file/count-lines.ts +26 -0
- package/src/tools/file/delete.ts +43 -0
- package/src/tools/file/edit.ts +40 -0
- package/src/tools/file/exists.ts +23 -0
- package/src/tools/file/info.ts +50 -0
- package/src/tools/file/move.ts +29 -0
- package/src/tools/file/read.ts +40 -0
- package/src/tools/file/write.ts +90 -0
- package/src/tools/registry.ts +53 -0
- package/src/tools/search/grep.ts +94 -0
- package/src/tools/search/semantic.ts +40 -0
- package/src/tools/task/complete.ts +23 -0
- package/src/tools/task/create.ts +42 -0
- package/src/tools/task/fail.ts +22 -0
- package/src/tools/task/wait.ts +23 -0
- package/src/tools/tool.ts +73 -0
- package/src/tui/App.tsx +14 -0
- package/src/types/istextorbinary.d.ts +10 -0
- package/src/update/background.ts +89 -0
- package/src/update/cache.ts +40 -0
- package/src/update/checker.ts +133 -0
- package/src/utils/frontmatter.ts +24 -0
- package/src/utils/logger.ts +29 -0
- package/src/utils/pid.ts +55 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { tmpdir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { dim, green, red, yellow } from "ansis";
|
|
4
|
+
import { $ } from "bun";
|
|
5
|
+
import type { Command } from "commander";
|
|
6
|
+
import {
|
|
7
|
+
clearUpdateCache,
|
|
8
|
+
loadUpdateCache,
|
|
9
|
+
saveUpdateCache,
|
|
10
|
+
} from "../update/cache.ts";
|
|
11
|
+
import type { UpdateCache } from "../update/checker.ts";
|
|
12
|
+
import {
|
|
13
|
+
checkForUpdate,
|
|
14
|
+
detectInstallMethod,
|
|
15
|
+
type InstallMethod,
|
|
16
|
+
needsCheck,
|
|
17
|
+
} from "../update/checker.ts";
|
|
18
|
+
|
|
19
|
+
const pkg = await Bun.file(
|
|
20
|
+
new URL("../../package.json", import.meta.url),
|
|
21
|
+
).json();
|
|
22
|
+
|
|
23
|
+
const GITHUB_REPO = (pkg.repository.url as string)
|
|
24
|
+
.replace(/^https:\/\/github\.com\//, "")
|
|
25
|
+
.replace(/\.git$/, "");
|
|
26
|
+
|
|
27
|
+
function platformArtifactName(): string {
|
|
28
|
+
let os: string;
|
|
29
|
+
let ext = "";
|
|
30
|
+
switch (process.platform) {
|
|
31
|
+
case "darwin":
|
|
32
|
+
os = "darwin";
|
|
33
|
+
break;
|
|
34
|
+
case "win32":
|
|
35
|
+
os = "windows";
|
|
36
|
+
ext = ".exe";
|
|
37
|
+
break;
|
|
38
|
+
default:
|
|
39
|
+
os = "linux";
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
const arch = process.arch === "arm64" ? "arm64" : "x64";
|
|
43
|
+
return `botholomew-${os}-${arch}${ext}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function upgradeWithPackageManager(
|
|
47
|
+
command: string,
|
|
48
|
+
args: string[],
|
|
49
|
+
): Promise<boolean> {
|
|
50
|
+
const result = await $`${command} ${args}`.nothrow();
|
|
51
|
+
return result.exitCode === 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function upgradeFromBinary(latestVersion: string): Promise<boolean> {
|
|
55
|
+
const artifact = platformArtifactName();
|
|
56
|
+
const tag = `v${latestVersion}`;
|
|
57
|
+
const url = `https://github.com/${GITHUB_REPO}/releases/download/${tag}/${artifact}`;
|
|
58
|
+
|
|
59
|
+
const tmpPath = join(tmpdir(), `botholomew-upgrade-${Date.now()}`);
|
|
60
|
+
const targetPath = process.execPath;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(url);
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
console.error(red(`Failed to download binary: HTTP ${res.status}`));
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const bytes = await res.arrayBuffer();
|
|
70
|
+
await Bun.write(tmpPath, bytes);
|
|
71
|
+
|
|
72
|
+
await $`chmod +x ${tmpPath}`.quiet();
|
|
73
|
+
|
|
74
|
+
// Try to move into place
|
|
75
|
+
const mv = await $`mv ${tmpPath} ${targetPath}`.quiet().nothrow();
|
|
76
|
+
|
|
77
|
+
if (mv.exitCode !== 0) {
|
|
78
|
+
// Try with sudo
|
|
79
|
+
console.log(dim("Requires elevated permissions..."));
|
|
80
|
+
const sudo = await $`sudo mv ${tmpPath} ${targetPath}`.nothrow();
|
|
81
|
+
if (sudo.exitCode !== 0) {
|
|
82
|
+
console.error(red("Failed to install binary. Try running with sudo."));
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return true;
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error(red(`Failed to upgrade binary: ${err}`));
|
|
90
|
+
// Clean up temp file
|
|
91
|
+
await $`rm -f ${tmpPath}`.quiet().nothrow();
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function registerUpgradeCommand(program: Command) {
|
|
97
|
+
program
|
|
98
|
+
.command("upgrade")
|
|
99
|
+
.description("Upgrade botholomew to the latest version")
|
|
100
|
+
.action(async () => {
|
|
101
|
+
try {
|
|
102
|
+
// Check for update (use cache if fresh)
|
|
103
|
+
const cache = await loadUpdateCache();
|
|
104
|
+
let latestVersion: string;
|
|
105
|
+
let hasUpdate: boolean;
|
|
106
|
+
|
|
107
|
+
if (!needsCheck(cache) && cache) {
|
|
108
|
+
latestVersion = cache.latestVersion;
|
|
109
|
+
hasUpdate = cache.hasUpdate;
|
|
110
|
+
} else {
|
|
111
|
+
const info = await checkForUpdate(pkg.version);
|
|
112
|
+
latestVersion = info.latestVersion;
|
|
113
|
+
hasUpdate = info.hasUpdate;
|
|
114
|
+
|
|
115
|
+
const newCache: UpdateCache = {
|
|
116
|
+
lastCheckAt: new Date().toISOString(),
|
|
117
|
+
latestVersion,
|
|
118
|
+
hasUpdate,
|
|
119
|
+
changelog: info.changelog,
|
|
120
|
+
};
|
|
121
|
+
await saveUpdateCache(newCache);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!hasUpdate) {
|
|
125
|
+
console.log(
|
|
126
|
+
green(`botholomew is already up to date (v${pkg.version})`),
|
|
127
|
+
);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const method: InstallMethod = detectInstallMethod();
|
|
132
|
+
console.log(
|
|
133
|
+
`Upgrading from v${pkg.version} to v${latestVersion} (${method})...`,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
let success = false;
|
|
137
|
+
|
|
138
|
+
switch (method) {
|
|
139
|
+
case "bun":
|
|
140
|
+
success = await upgradeWithPackageManager("bun", [
|
|
141
|
+
"install",
|
|
142
|
+
"-g",
|
|
143
|
+
`${pkg.name}@${latestVersion}`,
|
|
144
|
+
]);
|
|
145
|
+
break;
|
|
146
|
+
|
|
147
|
+
case "npm":
|
|
148
|
+
success = await upgradeWithPackageManager("npm", [
|
|
149
|
+
"install",
|
|
150
|
+
"-g",
|
|
151
|
+
`${pkg.name}@${latestVersion}`,
|
|
152
|
+
]);
|
|
153
|
+
break;
|
|
154
|
+
|
|
155
|
+
case "binary":
|
|
156
|
+
success = await upgradeFromBinary(latestVersion);
|
|
157
|
+
break;
|
|
158
|
+
|
|
159
|
+
case "local-dev":
|
|
160
|
+
console.log(
|
|
161
|
+
yellow(
|
|
162
|
+
"Running from source. Use `git pull && bun install` to update.",
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (success) {
|
|
169
|
+
await clearUpdateCache();
|
|
170
|
+
console.log(
|
|
171
|
+
green(
|
|
172
|
+
`Successfully upgraded botholomew: v${pkg.version} → v${latestVersion}`,
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
} else {
|
|
176
|
+
console.error(red("Upgrade failed. See errors above."));
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
} catch (err) {
|
|
180
|
+
console.error("Upgrade failed");
|
|
181
|
+
console.error(String(err));
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { getConfigPath } from "../constants.ts";
|
|
2
|
+
import { type BotholomewConfig, DEFAULT_CONFIG } from "./schemas.ts";
|
|
3
|
+
|
|
4
|
+
export async function loadConfig(
|
|
5
|
+
projectDir: string,
|
|
6
|
+
): Promise<Required<BotholomewConfig>> {
|
|
7
|
+
const configPath = getConfigPath(projectDir);
|
|
8
|
+
const file = Bun.file(configPath);
|
|
9
|
+
|
|
10
|
+
let userConfig: Partial<BotholomewConfig> = {};
|
|
11
|
+
if (await file.exists()) {
|
|
12
|
+
userConfig = JSON.parse(await file.text());
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const config = { ...DEFAULT_CONFIG, ...userConfig };
|
|
16
|
+
|
|
17
|
+
// env var override takes precedence
|
|
18
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
19
|
+
config.anthropic_api_key = process.env.ANTHROPIC_API_KEY;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return config;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function saveConfig(
|
|
26
|
+
projectDir: string,
|
|
27
|
+
config: Partial<BotholomewConfig>,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
const configPath = getConfigPath(projectDir);
|
|
30
|
+
await Bun.write(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
31
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface BotholomewConfig {
|
|
2
|
+
anthropic_api_key?: string;
|
|
3
|
+
model?: string;
|
|
4
|
+
tick_interval_seconds?: number;
|
|
5
|
+
max_tick_duration_seconds?: number;
|
|
6
|
+
system_prompt_override?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_CONFIG: Required<BotholomewConfig> = {
|
|
10
|
+
anthropic_api_key: "",
|
|
11
|
+
model: "claude-sonnet-4-20250514",
|
|
12
|
+
tick_interval_seconds: 300,
|
|
13
|
+
max_tick_duration_seconds: 120,
|
|
14
|
+
system_prompt_override: "",
|
|
15
|
+
};
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const BOTHOLOMEW_DIR = ".botholomew";
|
|
5
|
+
export const HOME_CONFIG_DIR = join(homedir(), ".botholomew");
|
|
6
|
+
|
|
7
|
+
export const ENV = {
|
|
8
|
+
NO_UPDATE_CHECK: "BOTHOLOMEW_NO_UPDATE_CHECK",
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export const DEFAULTS = {
|
|
12
|
+
UPDATE_CHECK_INTERVAL_MS: 24 * 60 * 60 * 1000, // 24 hours
|
|
13
|
+
UPDATE_CHECK_TIMEOUT_MS: 5_000,
|
|
14
|
+
} as const;
|
|
15
|
+
export const DB_FILENAME = "data.sqlite";
|
|
16
|
+
export const PID_FILENAME = "daemon.pid";
|
|
17
|
+
export const LOG_FILENAME = "daemon.log";
|
|
18
|
+
export const CONFIG_FILENAME = "config.json";
|
|
19
|
+
export const MCPX_DIR = "mcpx";
|
|
20
|
+
export const MCPX_SERVERS_FILENAME = "servers.json";
|
|
21
|
+
|
|
22
|
+
export function getBotholomewDir(projectDir: string): string {
|
|
23
|
+
return join(projectDir, BOTHOLOMEW_DIR);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getDbPath(projectDir: string): string {
|
|
27
|
+
return join(projectDir, BOTHOLOMEW_DIR, DB_FILENAME);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getPidPath(projectDir: string): string {
|
|
31
|
+
return join(projectDir, BOTHOLOMEW_DIR, PID_FILENAME);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getLogPath(projectDir: string): string {
|
|
35
|
+
return join(projectDir, BOTHOLOMEW_DIR, LOG_FILENAME);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getConfigPath(projectDir: string): string {
|
|
39
|
+
return join(projectDir, BOTHOLOMEW_DIR, CONFIG_FILENAME);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getMcpxDir(projectDir: string): string {
|
|
43
|
+
return join(projectDir, BOTHOLOMEW_DIR, MCPX_DIR);
|
|
44
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { loadConfig } from "../config/loader.ts";
|
|
2
|
+
import { getDbPath } from "../constants.ts";
|
|
3
|
+
import { getConnection } from "../db/connection.ts";
|
|
4
|
+
import { migrate } from "../db/schema.ts";
|
|
5
|
+
import { logger } from "../utils/logger.ts";
|
|
6
|
+
import { removePidFile, writePidFile } from "../utils/pid.ts";
|
|
7
|
+
import { tick } from "./tick.ts";
|
|
8
|
+
|
|
9
|
+
export async function startDaemon(projectDir: string): Promise<void> {
|
|
10
|
+
const config = await loadConfig(projectDir);
|
|
11
|
+
const dbPath = getDbPath(projectDir);
|
|
12
|
+
const conn = getConnection(dbPath);
|
|
13
|
+
migrate(conn);
|
|
14
|
+
|
|
15
|
+
writePidFile(projectDir, process.pid);
|
|
16
|
+
|
|
17
|
+
const shutdown = async () => {
|
|
18
|
+
logger.info("Daemon shutting down...");
|
|
19
|
+
await removePidFile(projectDir);
|
|
20
|
+
conn.close();
|
|
21
|
+
process.exit(0);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
process.on("SIGTERM", shutdown);
|
|
25
|
+
process.on("SIGINT", shutdown);
|
|
26
|
+
|
|
27
|
+
logger.info(`Daemon started for ${projectDir} (PID ${process.pid})`);
|
|
28
|
+
logger.info(`Tick interval: ${config.tick_interval_seconds}s`);
|
|
29
|
+
|
|
30
|
+
while (true) {
|
|
31
|
+
try {
|
|
32
|
+
await tick(projectDir, conn, config);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
logger.error(`Tick failed: ${err}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await Bun.sleep(config.tick_interval_seconds * 1000);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
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 type { DbConnection } from "../db/connection.ts";
|
|
9
|
+
import type { Task } from "../db/tasks.ts";
|
|
10
|
+
import { logInteraction } from "../db/threads.ts";
|
|
11
|
+
import { registerAllTools } from "../tools/registry.ts";
|
|
12
|
+
import { getTool, type ToolContext, toAnthropicTools } from "../tools/tool.ts";
|
|
13
|
+
|
|
14
|
+
registerAllTools();
|
|
15
|
+
|
|
16
|
+
export interface AgentLoopResult {
|
|
17
|
+
status: "complete" | "failed" | "waiting";
|
|
18
|
+
reason?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const STATUS_MAP: Record<string, AgentLoopResult["status"]> = {
|
|
22
|
+
complete_task: "complete",
|
|
23
|
+
fail_task: "failed",
|
|
24
|
+
wait_task: "waiting",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export async function runAgentLoop(input: {
|
|
28
|
+
systemPrompt: string;
|
|
29
|
+
task: Task;
|
|
30
|
+
config: Required<BotholomewConfig>;
|
|
31
|
+
conn: DbConnection;
|
|
32
|
+
threadId: string;
|
|
33
|
+
projectDir: string;
|
|
34
|
+
}): Promise<AgentLoopResult> {
|
|
35
|
+
const { systemPrompt, task, config, conn, threadId, projectDir } = input;
|
|
36
|
+
|
|
37
|
+
const client = new Anthropic({
|
|
38
|
+
apiKey: config.anthropic_api_key || undefined,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const toolCtx: ToolContext = { conn, projectDir, config };
|
|
42
|
+
|
|
43
|
+
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.`;
|
|
44
|
+
|
|
45
|
+
const messages: MessageParam[] = [{ role: "user", content: userMessage }];
|
|
46
|
+
|
|
47
|
+
// Log the initial user message
|
|
48
|
+
await logInteraction(conn, threadId, {
|
|
49
|
+
role: "user",
|
|
50
|
+
kind: "message",
|
|
51
|
+
content: userMessage,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const daemonTools = toAnthropicTools();
|
|
55
|
+
|
|
56
|
+
const maxTurns = 10;
|
|
57
|
+
for (let turn = 0; turn < maxTurns; turn++) {
|
|
58
|
+
const startTime = Date.now();
|
|
59
|
+
const response = await client.messages.create({
|
|
60
|
+
model: config.model,
|
|
61
|
+
max_tokens: 4096,
|
|
62
|
+
system: systemPrompt,
|
|
63
|
+
messages,
|
|
64
|
+
tools: daemonTools,
|
|
65
|
+
});
|
|
66
|
+
const durationMs = Date.now() - startTime;
|
|
67
|
+
const tokenCount =
|
|
68
|
+
response.usage.input_tokens + response.usage.output_tokens;
|
|
69
|
+
|
|
70
|
+
// Log assistant text blocks
|
|
71
|
+
for (const block of response.content) {
|
|
72
|
+
if (block.type === "text" && block.text) {
|
|
73
|
+
await logInteraction(conn, threadId, {
|
|
74
|
+
role: "assistant",
|
|
75
|
+
kind: "message",
|
|
76
|
+
content: block.text,
|
|
77
|
+
durationMs,
|
|
78
|
+
tokenCount,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check for end turn with no tool use
|
|
84
|
+
const toolUseBlocks = response.content.filter(
|
|
85
|
+
(block): block is ToolUseBlock => block.type === "tool_use",
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (toolUseBlocks.length === 0) {
|
|
89
|
+
return {
|
|
90
|
+
status: "complete",
|
|
91
|
+
reason: "Agent completed without explicit status tool call",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Add assistant response to conversation
|
|
96
|
+
messages.push({ role: "assistant", content: response.content });
|
|
97
|
+
|
|
98
|
+
// Process each tool call
|
|
99
|
+
const toolResults: ToolResultBlockParam[] = [];
|
|
100
|
+
|
|
101
|
+
for (const toolUse of toolUseBlocks) {
|
|
102
|
+
const toolInput = JSON.stringify(toolUse.input);
|
|
103
|
+
|
|
104
|
+
// Log tool use
|
|
105
|
+
await logInteraction(conn, threadId, {
|
|
106
|
+
role: "assistant",
|
|
107
|
+
kind: "tool_use",
|
|
108
|
+
content: `Calling ${toolUse.name}`,
|
|
109
|
+
toolName: toolUse.name,
|
|
110
|
+
toolInput,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const toolStart = Date.now();
|
|
114
|
+
const result = await executeToolCall(toolUse, toolCtx);
|
|
115
|
+
const toolDuration = Date.now() - toolStart;
|
|
116
|
+
|
|
117
|
+
// Log tool result
|
|
118
|
+
await logInteraction(conn, threadId, {
|
|
119
|
+
role: "tool",
|
|
120
|
+
kind: "tool_result",
|
|
121
|
+
content: result.output,
|
|
122
|
+
toolName: toolUse.name,
|
|
123
|
+
durationMs: toolDuration,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (result.terminal && result.agentResult) {
|
|
127
|
+
return result.agentResult;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
toolResults.push({
|
|
131
|
+
type: "tool_result",
|
|
132
|
+
tool_use_id: toolUse.id,
|
|
133
|
+
content: result.output,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
messages.push({ role: "user", content: toolResults });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { status: "failed", reason: "Max turns exceeded" };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface ToolCallResult {
|
|
144
|
+
output: string;
|
|
145
|
+
terminal: boolean;
|
|
146
|
+
agentResult?: AgentLoopResult;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function executeToolCall(
|
|
150
|
+
toolUse: ToolUseBlock,
|
|
151
|
+
ctx: ToolContext,
|
|
152
|
+
): Promise<ToolCallResult> {
|
|
153
|
+
const tool = getTool(toolUse.name);
|
|
154
|
+
if (!tool) {
|
|
155
|
+
return { output: `Unknown tool: ${toolUse.name}`, terminal: false };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const parsed = tool.inputSchema.safeParse(toolUse.input);
|
|
159
|
+
if (!parsed.success) {
|
|
160
|
+
return {
|
|
161
|
+
output: `Invalid input: ${JSON.stringify(parsed.error)}`,
|
|
162
|
+
terminal: false,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const result = await tool.execute(parsed.data, ctx);
|
|
167
|
+
const output = typeof result === "string" ? result : JSON.stringify(result);
|
|
168
|
+
|
|
169
|
+
// Check if this is a terminal tool (complete/fail/wait)
|
|
170
|
+
if (tool.terminal) {
|
|
171
|
+
const status = STATUS_MAP[tool.name];
|
|
172
|
+
if (status) {
|
|
173
|
+
const reason =
|
|
174
|
+
(parsed.data as Record<string, unknown>).summary ??
|
|
175
|
+
(parsed.data as Record<string, unknown>).reason ??
|
|
176
|
+
"";
|
|
177
|
+
return {
|
|
178
|
+
output,
|
|
179
|
+
terminal: true,
|
|
180
|
+
agentResult: { status, reason: String(reason) },
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { output, terminal: false };
|
|
186
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getBotholomewDir } from "../constants.ts";
|
|
4
|
+
import { parseContextFile } from "../utils/frontmatter.ts";
|
|
5
|
+
|
|
6
|
+
const pkg = await Bun.file(
|
|
7
|
+
new URL("../../package.json", import.meta.url),
|
|
8
|
+
).json();
|
|
9
|
+
|
|
10
|
+
export async function buildSystemPrompt(projectDir: string): Promise<string> {
|
|
11
|
+
const dotDir = getBotholomewDir(projectDir);
|
|
12
|
+
const parts: string[] = [];
|
|
13
|
+
|
|
14
|
+
// Meta information
|
|
15
|
+
parts.push(`# Botholomew v${pkg.version}`);
|
|
16
|
+
parts.push(`Current time: ${new Date().toISOString()}`);
|
|
17
|
+
parts.push(`Project directory: ${projectDir}`);
|
|
18
|
+
parts.push(`OS: ${process.platform} ${process.arch}`);
|
|
19
|
+
parts.push(`User: ${process.env.USER || process.env.USERNAME || "unknown"}`);
|
|
20
|
+
parts.push("");
|
|
21
|
+
|
|
22
|
+
// Load all "always" context files
|
|
23
|
+
try {
|
|
24
|
+
const files = await readdir(dotDir);
|
|
25
|
+
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
26
|
+
|
|
27
|
+
for (const filename of mdFiles) {
|
|
28
|
+
const filePath = join(dotDir, filename);
|
|
29
|
+
const raw = await Bun.file(filePath).text();
|
|
30
|
+
const { meta, content } = parseContextFile(raw);
|
|
31
|
+
|
|
32
|
+
if (meta.loading === "always") {
|
|
33
|
+
parts.push(`## ${filename}`);
|
|
34
|
+
parts.push(content);
|
|
35
|
+
parts.push("");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// .botholomew dir might not have md files yet
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Instructions
|
|
43
|
+
parts.push("## Instructions");
|
|
44
|
+
parts.push(
|
|
45
|
+
"You are the Botholomew daemon. You wake up periodically to work through tasks.",
|
|
46
|
+
);
|
|
47
|
+
parts.push("When given a task, use the available tools to complete it.");
|
|
48
|
+
parts.push(
|
|
49
|
+
"Always call complete_task, fail_task, or wait_task when you are done.",
|
|
50
|
+
);
|
|
51
|
+
parts.push("If you need to create subtasks, use create_task.");
|
|
52
|
+
parts.push("");
|
|
53
|
+
|
|
54
|
+
return parts.join("\n");
|
|
55
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
// Standalone entry point for the daemon when spawned as a detached process.
|
|
4
|
+
// Usage: bun run src/daemon/run.ts <projectDir>
|
|
5
|
+
|
|
6
|
+
import { startDaemon } from "./index.ts";
|
|
7
|
+
|
|
8
|
+
const projectDir = process.argv[2];
|
|
9
|
+
if (!projectDir) {
|
|
10
|
+
console.error("Usage: bun run src/daemon/run.ts <projectDir>");
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
await startDaemon(projectDir);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { getBotholomewDir, getLogPath } from "../constants.ts";
|
|
3
|
+
import { logger } from "../utils/logger.ts";
|
|
4
|
+
import { isProcessAlive, readPidFile } from "../utils/pid.ts";
|
|
5
|
+
|
|
6
|
+
export async function spawnDaemon(projectDir: string): Promise<void> {
|
|
7
|
+
// Check if already running
|
|
8
|
+
const existingPid = await readPidFile(projectDir);
|
|
9
|
+
if (existingPid && isProcessAlive(existingPid)) {
|
|
10
|
+
logger.warn(`Daemon already running (PID ${existingPid})`);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Ensure .botholomew dir exists
|
|
15
|
+
const dotDir = getBotholomewDir(projectDir);
|
|
16
|
+
const dirExists = await Bun.file(join(dotDir, "config.json")).exists();
|
|
17
|
+
if (!dirExists) {
|
|
18
|
+
logger.error("Project not initialized. Run 'botholomew init' first.");
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const logPath = getLogPath(projectDir);
|
|
23
|
+
const logFile = Bun.file(logPath);
|
|
24
|
+
|
|
25
|
+
// Find the daemon entry script
|
|
26
|
+
const daemonScript = new URL("./run.ts", import.meta.url).pathname;
|
|
27
|
+
|
|
28
|
+
const proc = Bun.spawn(["bun", "run", daemonScript, projectDir], {
|
|
29
|
+
stdio: ["ignore", logFile, logFile],
|
|
30
|
+
env: { ...process.env },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Detach the process
|
|
34
|
+
proc.unref();
|
|
35
|
+
|
|
36
|
+
logger.success(`Daemon started in background (PID ${proc.pid})`);
|
|
37
|
+
logger.dim(` Log: ${logPath}`);
|
|
38
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
2
|
+
import type { DbConnection } from "../db/connection.ts";
|
|
3
|
+
import { claimNextTask, updateTaskStatus } from "../db/tasks.ts";
|
|
4
|
+
import { createThread, endThread, logInteraction } from "../db/threads.ts";
|
|
5
|
+
import { logger } from "../utils/logger.ts";
|
|
6
|
+
import { runAgentLoop } from "./llm.ts";
|
|
7
|
+
import { buildSystemPrompt } from "./prompt.ts";
|
|
8
|
+
|
|
9
|
+
export async function tick(
|
|
10
|
+
projectDir: string,
|
|
11
|
+
conn: DbConnection,
|
|
12
|
+
config: Required<BotholomewConfig>,
|
|
13
|
+
): Promise<void> {
|
|
14
|
+
logger.debug("Tick starting...");
|
|
15
|
+
|
|
16
|
+
// Claim a task
|
|
17
|
+
const task = await claimNextTask(conn);
|
|
18
|
+
if (!task) {
|
|
19
|
+
logger.debug("No tasks to work on. Sleeping.");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
logger.info(`Working on task: ${task.name} (${task.id})`);
|
|
24
|
+
|
|
25
|
+
// Create a thread for this tick
|
|
26
|
+
const threadId = await createThread(
|
|
27
|
+
conn,
|
|
28
|
+
"daemon_tick",
|
|
29
|
+
task.id,
|
|
30
|
+
`Working: ${task.name}`,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
// Build system prompt
|
|
34
|
+
const systemPrompt = await buildSystemPrompt(projectDir);
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const result = await runAgentLoop({
|
|
38
|
+
systemPrompt,
|
|
39
|
+
task,
|
|
40
|
+
config,
|
|
41
|
+
conn,
|
|
42
|
+
threadId,
|
|
43
|
+
projectDir,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Update task status
|
|
47
|
+
await updateTaskStatus(conn, task.id, result.status, result.reason);
|
|
48
|
+
|
|
49
|
+
// Log the status change
|
|
50
|
+
await logInteraction(conn, threadId, {
|
|
51
|
+
role: "system",
|
|
52
|
+
kind: "status_change",
|
|
53
|
+
content: `Task ${task.id} -> ${result.status}${result.reason ? `: ${result.reason}` : ""}`,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
logger.info(`Task ${task.id} -> ${result.status}`);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
await updateTaskStatus(conn, task.id, "failed", String(err));
|
|
59
|
+
|
|
60
|
+
await logInteraction(conn, threadId, {
|
|
61
|
+
role: "system",
|
|
62
|
+
kind: "status_change",
|
|
63
|
+
content: `Task ${task.id} failed: ${err}`,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
logger.error(`Task ${task.id} failed: ${err}`);
|
|
67
|
+
} finally {
|
|
68
|
+
await endThread(conn, threadId);
|
|
69
|
+
}
|
|
70
|
+
}
|