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.
Files changed (62) hide show
  1. package/package.json +42 -0
  2. package/src/cli.ts +45 -0
  3. package/src/commands/chat.ts +11 -0
  4. package/src/commands/check-update.ts +62 -0
  5. package/src/commands/context.ts +27 -0
  6. package/src/commands/daemon.ts +61 -0
  7. package/src/commands/init.ts +19 -0
  8. package/src/commands/mcpx.ts +29 -0
  9. package/src/commands/task.ts +126 -0
  10. package/src/commands/tools.ts +257 -0
  11. package/src/commands/upgrade.ts +185 -0
  12. package/src/config/loader.ts +31 -0
  13. package/src/config/schemas.ts +15 -0
  14. package/src/constants.ts +44 -0
  15. package/src/daemon/index.ts +39 -0
  16. package/src/daemon/llm.ts +186 -0
  17. package/src/daemon/prompt.ts +55 -0
  18. package/src/daemon/run.ts +14 -0
  19. package/src/daemon/spawn.ts +38 -0
  20. package/src/daemon/tick.ts +70 -0
  21. package/src/db/connection.ts +32 -0
  22. package/src/db/context.ts +415 -0
  23. package/src/db/embeddings.ts +22 -0
  24. package/src/db/schedules.ts +17 -0
  25. package/src/db/schema.ts +66 -0
  26. package/src/db/sql/1-core_tables.sql +53 -0
  27. package/src/db/sql/2-logging_tables.sql +24 -0
  28. package/src/db/sql/3-daemon_state.sql +5 -0
  29. package/src/db/tasks.ts +194 -0
  30. package/src/db/threads.ts +202 -0
  31. package/src/db/uuid.ts +1 -0
  32. package/src/init/index.ts +84 -0
  33. package/src/init/templates.ts +48 -0
  34. package/src/tools/dir/create.ts +39 -0
  35. package/src/tools/dir/list.ts +87 -0
  36. package/src/tools/dir/size.ts +45 -0
  37. package/src/tools/dir/tree.ts +91 -0
  38. package/src/tools/file/copy.ts +30 -0
  39. package/src/tools/file/count-lines.ts +26 -0
  40. package/src/tools/file/delete.ts +43 -0
  41. package/src/tools/file/edit.ts +40 -0
  42. package/src/tools/file/exists.ts +23 -0
  43. package/src/tools/file/info.ts +50 -0
  44. package/src/tools/file/move.ts +29 -0
  45. package/src/tools/file/read.ts +40 -0
  46. package/src/tools/file/write.ts +90 -0
  47. package/src/tools/registry.ts +53 -0
  48. package/src/tools/search/grep.ts +94 -0
  49. package/src/tools/search/semantic.ts +40 -0
  50. package/src/tools/task/complete.ts +23 -0
  51. package/src/tools/task/create.ts +42 -0
  52. package/src/tools/task/fail.ts +22 -0
  53. package/src/tools/task/wait.ts +23 -0
  54. package/src/tools/tool.ts +73 -0
  55. package/src/tui/App.tsx +14 -0
  56. package/src/types/istextorbinary.d.ts +10 -0
  57. package/src/update/background.ts +89 -0
  58. package/src/update/cache.ts +40 -0
  59. package/src/update/checker.ts +133 -0
  60. package/src/utils/frontmatter.ts +24 -0
  61. package/src/utils/logger.ts +29 -0
  62. package/src/utils/pid.ts +55 -0
@@ -0,0 +1,42 @@
1
+ import { z } from "zod";
2
+ import { createTask, TASK_PRIORITIES } from "../../db/tasks.ts";
3
+ import { logger } from "../../utils/logger.ts";
4
+ import type { ToolDefinition } from "../tool.ts";
5
+
6
+ const inputSchema = z.object({
7
+ name: z.string().describe("Task name"),
8
+ description: z.string().optional().describe("Task description"),
9
+ priority: z.enum(TASK_PRIORITIES).optional().describe("Task priority"),
10
+ blocked_by: z
11
+ .array(z.string())
12
+ .optional()
13
+ .describe("IDs of tasks that must complete first"),
14
+ });
15
+
16
+ const outputSchema = z.object({
17
+ id: z.string(),
18
+ name: z.string(),
19
+ message: z.string(),
20
+ });
21
+
22
+ export const createTaskTool = {
23
+ name: "create_task",
24
+ description: "Create a new task to be worked on later.",
25
+ group: "task",
26
+ inputSchema,
27
+ outputSchema,
28
+ execute: async (input, ctx) => {
29
+ const newTask = await createTask(ctx.conn, {
30
+ name: input.name,
31
+ description: input.description,
32
+ priority: input.priority,
33
+ blocked_by: input.blocked_by,
34
+ });
35
+ logger.info(`Created subtask: ${newTask.name} (${newTask.id})`);
36
+ return {
37
+ id: newTask.id,
38
+ name: newTask.name,
39
+ message: `Created task "${newTask.name}" with ID ${newTask.id}`,
40
+ };
41
+ },
42
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+ import type { ToolDefinition } from "../tool.ts";
3
+
4
+ const inputSchema = z.object({
5
+ reason: z.string().describe("Why the task failed"),
6
+ });
7
+
8
+ const outputSchema = z.object({
9
+ message: z.string(),
10
+ });
11
+
12
+ export const failTaskTool = {
13
+ name: "fail_task",
14
+ description: "Mark the current task as failed with a reason.",
15
+ group: "task",
16
+ terminal: true,
17
+ inputSchema,
18
+ outputSchema,
19
+ execute: async (input) => ({
20
+ message: `Task failed: ${input.reason}`,
21
+ }),
22
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -0,0 +1,23 @@
1
+ import { z } from "zod";
2
+ import type { ToolDefinition } from "../tool.ts";
3
+
4
+ const inputSchema = z.object({
5
+ reason: z.string().describe("Why the task is waiting"),
6
+ });
7
+
8
+ const outputSchema = z.object({
9
+ message: z.string(),
10
+ });
11
+
12
+ export const waitTaskTool = {
13
+ name: "wait_task",
14
+ description:
15
+ "Put the task in waiting status (e.g., needs human input, rate limited).",
16
+ group: "task",
17
+ terminal: true,
18
+ inputSchema,
19
+ outputSchema,
20
+ execute: async (input) => ({
21
+ message: `Task waiting: ${input.reason}`,
22
+ }),
23
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -0,0 +1,73 @@
1
+ import type { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/messages";
2
+ import { z } from "zod";
3
+ import type { BotholomewConfig } from "../config/schemas.ts";
4
+ import type { DbConnection } from "../db/connection.ts";
5
+
6
+ export interface ToolContext {
7
+ conn: DbConnection;
8
+ projectDir: string;
9
+ config: Required<BotholomewConfig>;
10
+ }
11
+
12
+ export interface ToolDefinition<
13
+ TInput extends z.ZodObject<z.ZodRawShape>,
14
+ TOutput extends z.ZodType,
15
+ > {
16
+ name: string;
17
+ description: string;
18
+ group: string;
19
+ terminal?: boolean;
20
+ inputSchema: TInput;
21
+ outputSchema: TOutput;
22
+ execute: (
23
+ input: z.infer<TInput>,
24
+ ctx: ToolContext,
25
+ ) => Promise<z.infer<TOutput>>;
26
+ }
27
+
28
+ // --- Registry ---
29
+
30
+ export type AnyToolDefinition = ToolDefinition<
31
+ z.ZodObject<z.ZodRawShape>,
32
+ z.ZodType
33
+ >;
34
+
35
+ const tools = new Map<string, AnyToolDefinition>();
36
+
37
+ export function registerTool<
38
+ TInput extends z.ZodObject<z.ZodRawShape>,
39
+ TOutput extends z.ZodType,
40
+ >(tool: ToolDefinition<TInput, TOutput>): void {
41
+ tools.set(tool.name, tool as unknown as AnyToolDefinition);
42
+ }
43
+
44
+ export function getTool(name: string): AnyToolDefinition | undefined {
45
+ return tools.get(name);
46
+ }
47
+
48
+ export function getAllTools(): AnyToolDefinition[] {
49
+ return Array.from(tools.values());
50
+ }
51
+
52
+ export function getToolsByGroup(group: string): AnyToolDefinition[] {
53
+ return getAllTools().filter((t) => t.group === group);
54
+ }
55
+
56
+ // --- Anthropic adapter ---
57
+
58
+ export function toAnthropicTool(tool: AnyToolDefinition): AnthropicTool {
59
+ const jsonSchema = z.toJSONSchema(tool.inputSchema);
60
+ return {
61
+ name: tool.name,
62
+ description: tool.description,
63
+ input_schema: {
64
+ type: "object" as const,
65
+ properties: jsonSchema.properties ?? {},
66
+ required: jsonSchema.required as string[] | undefined,
67
+ },
68
+ };
69
+ }
70
+
71
+ export function toAnthropicTools(): AnthropicTool[] {
72
+ return getAllTools().map(toAnthropicTool);
73
+ }
@@ -0,0 +1,14 @@
1
+ import { Box, Text } from "ink";
2
+
3
+ export function App() {
4
+ return (
5
+ <Box flexDirection="column" padding={1}>
6
+ <Text bold color="blue">
7
+ Botholomew Chat
8
+ </Text>
9
+ <Text dimColor>
10
+ Chat TUI coming soon. Use the daemon and task commands for now.
11
+ </Text>
12
+ </Box>
13
+ );
14
+ }
@@ -0,0 +1,10 @@
1
+ declare module "istextorbinary" {
2
+ export function isText(
3
+ filename?: string | null,
4
+ buffer?: Buffer | null,
5
+ ): boolean | null;
6
+ export function isBinary(
7
+ filename?: string | null,
8
+ buffer?: Buffer | null,
9
+ ): boolean | null;
10
+ }
@@ -0,0 +1,89 @@
1
+ import { cyan, dim, yellow } from "ansis";
2
+ import { DEFAULTS, ENV } from "../constants.ts";
3
+ import { loadUpdateCache, saveUpdateCache } from "./cache.ts";
4
+ import { checkForUpdate, needsCheck, type UpdateCache } from "./checker.ts";
5
+
6
+ const pkg = await Bun.file(
7
+ new URL("../../package.json", import.meta.url),
8
+ ).json();
9
+
10
+ /** Format an update notice for stderr output. */
11
+ function formatNotice(
12
+ currentVersion: string,
13
+ latestVersion: string,
14
+ changelog?: string,
15
+ ): string {
16
+ const lines: string[] = [
17
+ "",
18
+ yellow(`Update available: ${currentVersion} → ${latestVersion}`),
19
+ ];
20
+
21
+ if (changelog) {
22
+ lines.push("");
23
+ lines.push(dim(changelog));
24
+ }
25
+
26
+ lines.push("");
27
+ lines.push(cyan("Run `botholomew upgrade` to update"));
28
+ lines.push("");
29
+
30
+ return lines.join("\n");
31
+ }
32
+
33
+ /**
34
+ * Non-blocking background update check. Returns a formatted notice string
35
+ * if an update is available, or null otherwise. Never throws.
36
+ */
37
+ export async function maybeCheckForUpdate(): Promise<string | null> {
38
+ try {
39
+ // Opt-out via env var
40
+ if (process.env[ENV.NO_UPDATE_CHECK] === "1") return null;
41
+
42
+ // Skip if this is the check-update or upgrade command
43
+ const args = process.argv.slice(2);
44
+ const command = args.find((a) => !a.startsWith("-"));
45
+ if (command === "check-update" || command === "upgrade") return null;
46
+
47
+ // Only show in TTY
48
+ if (!(process.stderr.isTTY ?? false)) return null;
49
+
50
+ const cache = await loadUpdateCache();
51
+
52
+ if (!needsCheck(cache)) {
53
+ // Cache is fresh — use cached result
54
+ if (cache?.hasUpdate) {
55
+ return formatNotice(pkg.version, cache.latestVersion, cache.changelog);
56
+ }
57
+ return null;
58
+ }
59
+
60
+ // Cache is stale or missing — check with timeout
61
+ const controller = new AbortController();
62
+ const timeout = setTimeout(
63
+ () => controller.abort(),
64
+ DEFAULTS.UPDATE_CHECK_TIMEOUT_MS,
65
+ );
66
+
67
+ try {
68
+ const info = await checkForUpdate(pkg.version, controller.signal);
69
+
70
+ const newCache: UpdateCache = {
71
+ lastCheckAt: new Date().toISOString(),
72
+ latestVersion: info.latestVersion,
73
+ hasUpdate: info.hasUpdate,
74
+ changelog: info.changelog,
75
+ };
76
+ await saveUpdateCache(newCache);
77
+
78
+ if (info.hasUpdate) {
79
+ return formatNotice(pkg.version, info.latestVersion, info.changelog);
80
+ }
81
+ } finally {
82
+ clearTimeout(timeout);
83
+ }
84
+
85
+ return null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
@@ -0,0 +1,40 @@
1
+ import { join } from "node:path";
2
+ import { HOME_CONFIG_DIR } from "../constants.ts";
3
+ import type { UpdateCache } from "./checker.ts";
4
+
5
+ const UPDATE_CACHE_PATH = join(HOME_CONFIG_DIR, "update.json");
6
+
7
+ /** Load the cached update check result, if it exists. */
8
+ export async function loadUpdateCache(): Promise<UpdateCache | undefined> {
9
+ try {
10
+ const file = Bun.file(UPDATE_CACHE_PATH);
11
+ if (!(await file.exists())) return undefined;
12
+ return JSON.parse(await file.text()) as UpdateCache;
13
+ } catch {
14
+ return undefined;
15
+ }
16
+ }
17
+
18
+ /** Save update check result to the cache file. */
19
+ export async function saveUpdateCache(cache: UpdateCache): Promise<void> {
20
+ try {
21
+ const { mkdir } = await import("node:fs/promises");
22
+ await mkdir(HOME_CONFIG_DIR, { recursive: true });
23
+ await Bun.write(UPDATE_CACHE_PATH, `${JSON.stringify(cache, null, 2)}\n`);
24
+ } catch {
25
+ // Ignore write failures (e.g. permissions)
26
+ }
27
+ }
28
+
29
+ /** Remove the cached update check result. */
30
+ export async function clearUpdateCache(): Promise<void> {
31
+ try {
32
+ const file = Bun.file(UPDATE_CACHE_PATH);
33
+ if (await file.exists()) {
34
+ const { unlink } = await import("node:fs/promises");
35
+ await unlink(UPDATE_CACHE_PATH);
36
+ }
37
+ } catch {
38
+ // Ignore
39
+ }
40
+ }
@@ -0,0 +1,133 @@
1
+ import { DEFAULTS } from "../constants.ts";
2
+
3
+ const pkg = await Bun.file(
4
+ new URL("../../package.json", import.meta.url),
5
+ ).json();
6
+
7
+ const NPM_REGISTRY_URL = `https://registry.npmjs.org/${pkg.name}/latest`;
8
+ const GITHUB_REPO = (pkg.repository.url as string)
9
+ .replace(/^https:\/\/github\.com\//, "")
10
+ .replace(/\.git$/, "");
11
+
12
+ export interface UpdateInfo {
13
+ currentVersion: string;
14
+ latestVersion: string;
15
+ hasUpdate: boolean;
16
+ aheadOfLatest: boolean;
17
+ changelog?: string;
18
+ }
19
+
20
+ export interface UpdateCache {
21
+ lastCheckAt: string;
22
+ latestVersion: string;
23
+ hasUpdate: boolean;
24
+ changelog?: string;
25
+ }
26
+
27
+ export type InstallMethod = "npm" | "bun" | "binary" | "local-dev";
28
+
29
+ /** Compare two semver strings. Returns true if latest > current. */
30
+ export function isNewerVersion(current: string, latest: string): boolean {
31
+ return Bun.semver.order(current, latest) === -1;
32
+ }
33
+
34
+ /** Fetch the latest version from the npm registry. */
35
+ export async function fetchLatestVersion(
36
+ signal?: AbortSignal,
37
+ ): Promise<string> {
38
+ try {
39
+ const res = await fetch(NPM_REGISTRY_URL, { signal });
40
+ if (!res.ok) return pkg.version;
41
+ const data = (await res.json()) as { version: string };
42
+ return data.version;
43
+ } catch {
44
+ return pkg.version;
45
+ }
46
+ }
47
+
48
+ /** Fetch changelog from GitHub releases between two versions. */
49
+ export async function fetchChangelog(
50
+ fromVersion: string,
51
+ toVersion: string,
52
+ signal?: AbortSignal,
53
+ ): Promise<string | undefined> {
54
+ try {
55
+ const res = await fetch(
56
+ `https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=20`,
57
+ {
58
+ signal,
59
+ headers: { Accept: "application/vnd.github.v3+json" },
60
+ },
61
+ );
62
+ if (!res.ok) return undefined;
63
+
64
+ const releases = (await res.json()) as Array<{
65
+ tag_name: string;
66
+ body: string | null;
67
+ }>;
68
+
69
+ const relevant = releases.filter((r) => {
70
+ const v = r.tag_name.replace(/^v/, "");
71
+ return isNewerVersion(fromVersion, v) && !isNewerVersion(toVersion, v);
72
+ });
73
+
74
+ if (relevant.length === 0) return undefined;
75
+
76
+ return relevant
77
+ .map((r) => `## ${r.tag_name}\n${r.body ?? ""}`)
78
+ .join("\n\n")
79
+ .trim();
80
+ } catch {
81
+ return undefined;
82
+ }
83
+ }
84
+
85
+ /** Check npm for a newer version and fetch changelog if available. */
86
+ export async function checkForUpdate(
87
+ currentVersion: string,
88
+ signal?: AbortSignal,
89
+ ): Promise<UpdateInfo> {
90
+ const latestVersion = await fetchLatestVersion(signal);
91
+ const hasUpdate = isNewerVersion(currentVersion, latestVersion);
92
+ const aheadOfLatest = isNewerVersion(latestVersion, currentVersion);
93
+
94
+ let changelog: string | undefined;
95
+ if (hasUpdate) {
96
+ changelog = await fetchChangelog(currentVersion, latestVersion, signal);
97
+ }
98
+
99
+ return { currentVersion, latestVersion, hasUpdate, aheadOfLatest, changelog };
100
+ }
101
+
102
+ /** Returns true if the cache is missing or older than 24 hours. */
103
+ export function needsCheck(cache?: UpdateCache): boolean {
104
+ if (!cache?.lastCheckAt) return true;
105
+ return (
106
+ Date.now() - new Date(cache.lastCheckAt).getTime() >
107
+ DEFAULTS.UPDATE_CHECK_INTERVAL_MS
108
+ );
109
+ }
110
+
111
+ /** Detect how botholomew was installed. */
112
+ export function detectInstallMethod(): InstallMethod {
113
+ const script = process.argv[1] ?? "";
114
+ const execPath = process.execPath;
115
+
116
+ // Local dev: running src/cli.ts directly outside node_modules
117
+ if (script.includes("src/cli.ts") && !script.includes("node_modules")) {
118
+ return "local-dev";
119
+ }
120
+
121
+ // Compiled binary: execPath is the binary itself (not bun/node)
122
+ if (!execPath.includes("bun") && !execPath.includes("node")) {
123
+ return "binary";
124
+ }
125
+
126
+ // Bun global install: path contains .bun/install
127
+ if (script.includes(".bun/install") || script.includes(".bun/bin")) {
128
+ return "bun";
129
+ }
130
+
131
+ // npm global install: fallback for node_modules paths
132
+ return "npm";
133
+ }
@@ -0,0 +1,24 @@
1
+ import matter from "gray-matter";
2
+
3
+ export interface ContextFileMeta {
4
+ loading: "always" | "contextual";
5
+ "agent-modification": boolean;
6
+ }
7
+
8
+ export function parseContextFile(raw: string): {
9
+ meta: ContextFileMeta;
10
+ content: string;
11
+ } {
12
+ const { data, content } = matter(raw);
13
+ return {
14
+ meta: data as ContextFileMeta,
15
+ content: content.trim(),
16
+ };
17
+ }
18
+
19
+ export function serializeContextFile(
20
+ meta: ContextFileMeta,
21
+ content: string,
22
+ ): string {
23
+ return matter.stringify(`\n${content}\n`, meta);
24
+ }
@@ -0,0 +1,29 @@
1
+ import ansis from "ansis";
2
+
3
+ export const logger = {
4
+ info(msg: string) {
5
+ console.log(ansis.blue("ℹ"), msg);
6
+ },
7
+
8
+ success(msg: string) {
9
+ console.log(ansis.green("✓"), msg);
10
+ },
11
+
12
+ warn(msg: string) {
13
+ console.log(ansis.yellow("⚠"), msg);
14
+ },
15
+
16
+ error(msg: string) {
17
+ console.error(ansis.red("✗"), msg);
18
+ },
19
+
20
+ debug(msg: string) {
21
+ if (process.env.BOTHOLOMEW_DEBUG) {
22
+ console.log(ansis.gray("·"), ansis.gray(msg));
23
+ }
24
+ },
25
+
26
+ dim(msg: string) {
27
+ console.log(ansis.dim(msg));
28
+ },
29
+ };
@@ -0,0 +1,55 @@
1
+ import { unlink } from "node:fs/promises";
2
+ import { getPidPath } from "../constants.ts";
3
+
4
+ export function writePidFile(projectDir: string, pid: number): void {
5
+ Bun.write(getPidPath(projectDir), String(pid));
6
+ }
7
+
8
+ export async function readPidFile(projectDir: string): Promise<number | null> {
9
+ const file = Bun.file(getPidPath(projectDir));
10
+ if (!(await file.exists())) return null;
11
+ const text = await file.text();
12
+ const pid = parseInt(text.trim(), 10);
13
+ return Number.isNaN(pid) ? null : pid;
14
+ }
15
+
16
+ export async function removePidFile(projectDir: string): Promise<void> {
17
+ try {
18
+ await unlink(getPidPath(projectDir));
19
+ } catch {
20
+ // ignore if file doesn't exist
21
+ }
22
+ }
23
+
24
+ export function isProcessAlive(pid: number): boolean {
25
+ try {
26
+ process.kill(pid, 0);
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ export async function getDaemonStatus(
34
+ projectDir: string,
35
+ ): Promise<{ pid: number } | null> {
36
+ const pid = await readPidFile(projectDir);
37
+ if (pid === null) return null;
38
+ if (!isProcessAlive(pid)) {
39
+ await removePidFile(projectDir);
40
+ return null;
41
+ }
42
+ return { pid };
43
+ }
44
+
45
+ export async function stopDaemon(projectDir: string): Promise<boolean> {
46
+ const pid = await readPidFile(projectDir);
47
+ if (pid === null) return false;
48
+ if (!isProcessAlive(pid)) {
49
+ await removePidFile(projectDir);
50
+ return false;
51
+ }
52
+ process.kill(pid, "SIGTERM");
53
+ await removePidFile(projectDir);
54
+ return true;
55
+ }