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,194 @@
1
+ import type { DbConnection } from "./connection.ts";
2
+ import { uuidv7 } from "./uuid.ts";
3
+
4
+ export const TASK_PRIORITIES = ["low", "medium", "high"] as const;
5
+ export const TASK_STATUSES = [
6
+ "pending",
7
+ "in_progress",
8
+ "failed",
9
+ "complete",
10
+ "waiting",
11
+ ] as const;
12
+
13
+ export interface Task {
14
+ id: string;
15
+ name: string;
16
+ description: string;
17
+ priority: (typeof TASK_PRIORITIES)[number];
18
+ status: (typeof TASK_STATUSES)[number];
19
+ waiting_reason: string | null;
20
+ claimed_by: string | null;
21
+ claimed_at: Date | null;
22
+ blocked_by: string[];
23
+ context_ids: string[];
24
+ created_at: Date;
25
+ updated_at: Date;
26
+ }
27
+
28
+ interface TaskRow {
29
+ id: string;
30
+ name: string;
31
+ description: string;
32
+ priority: string;
33
+ status: string;
34
+ waiting_reason: string | null;
35
+ claimed_by: string | null;
36
+ claimed_at: string | null;
37
+ blocked_by: string;
38
+ context_ids: string;
39
+ created_at: string;
40
+ updated_at: string;
41
+ }
42
+
43
+ function rowToTask(row: TaskRow): Task {
44
+ return {
45
+ id: row.id,
46
+ name: row.name,
47
+ description: row.description,
48
+ priority: row.priority as Task["priority"],
49
+ status: row.status as Task["status"],
50
+ waiting_reason: row.waiting_reason,
51
+ claimed_by: row.claimed_by,
52
+ claimed_at: row.claimed_at ? new Date(row.claimed_at) : null,
53
+ blocked_by: JSON.parse(row.blocked_by || "[]"),
54
+ context_ids: JSON.parse(row.context_ids || "[]"),
55
+ created_at: new Date(row.created_at),
56
+ updated_at: new Date(row.updated_at),
57
+ };
58
+ }
59
+
60
+ export async function createTask(
61
+ db: DbConnection,
62
+ params: {
63
+ name: string;
64
+ description?: string;
65
+ priority?: Task["priority"];
66
+ blocked_by?: string[];
67
+ context_ids?: string[];
68
+ },
69
+ ): Promise<Task> {
70
+ const id = uuidv7();
71
+ const blockedBy = JSON.stringify(params.blocked_by ?? []);
72
+ const contextIds = JSON.stringify(params.context_ids ?? []);
73
+
74
+ const row = db
75
+ .query(
76
+ `INSERT INTO tasks (id, name, description, priority, blocked_by, context_ids)
77
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)
78
+ RETURNING *`,
79
+ )
80
+ .get(
81
+ id,
82
+ params.name,
83
+ params.description ?? "",
84
+ params.priority ?? "medium",
85
+ blockedBy,
86
+ contextIds,
87
+ ) as TaskRow | null;
88
+ if (!row) throw new Error("INSERT did not return a row");
89
+ return rowToTask(row);
90
+ }
91
+
92
+ export async function getTask(
93
+ db: DbConnection,
94
+ id: string,
95
+ ): Promise<Task | null> {
96
+ const row = db
97
+ .query("SELECT * FROM tasks WHERE id = ?1")
98
+ .get(id) as TaskRow | null;
99
+ return row ? rowToTask(row) : null;
100
+ }
101
+
102
+ export async function listTasks(
103
+ db: DbConnection,
104
+ filters?: {
105
+ status?: Task["status"];
106
+ priority?: Task["priority"];
107
+ limit?: number;
108
+ },
109
+ ): Promise<Task[]> {
110
+ const conditions: string[] = [];
111
+ const params: string[] = [];
112
+
113
+ if (filters?.status) {
114
+ params.push(filters.status);
115
+ conditions.push(`status = ?${params.length}`);
116
+ }
117
+ if (filters?.priority) {
118
+ params.push(filters.priority);
119
+ conditions.push(`priority = ?${params.length}`);
120
+ }
121
+
122
+ const where =
123
+ conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
124
+ const limit = filters?.limit ? `LIMIT ${filters.limit}` : "";
125
+
126
+ const rows = db
127
+ .query(
128
+ `SELECT * FROM tasks ${where}
129
+ ORDER BY
130
+ CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 END,
131
+ created_at ASC
132
+ ${limit}`,
133
+ )
134
+ .all(...params) as TaskRow[];
135
+ return rows.map(rowToTask);
136
+ }
137
+
138
+ export async function updateTaskStatus(
139
+ db: DbConnection,
140
+ id: string,
141
+ status: Task["status"],
142
+ reason?: string,
143
+ ): Promise<void> {
144
+ db.query(
145
+ `UPDATE tasks
146
+ SET status = ?1, waiting_reason = ?2, updated_at = datetime('now')
147
+ WHERE id = ?3`,
148
+ ).run(status, reason ?? null, id);
149
+ }
150
+
151
+ export async function claimNextTask(
152
+ db: DbConnection,
153
+ claimedBy = "daemon",
154
+ ): Promise<Task | null> {
155
+ // Find highest-priority unblocked pending task
156
+ const row = db
157
+ .query(
158
+ `SELECT * FROM tasks
159
+ WHERE status = 'pending'
160
+ AND (
161
+ blocked_by = '[]'
162
+ OR blocked_by IS NULL
163
+ OR NOT EXISTS (
164
+ SELECT 1 FROM json_each(blocked_by) AS b
165
+ WHERE b.value NOT IN (SELECT id FROM tasks WHERE status = 'complete')
166
+ )
167
+ )
168
+ ORDER BY
169
+ CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 END,
170
+ created_at ASC
171
+ LIMIT 1`,
172
+ )
173
+ .get() as TaskRow | null;
174
+
175
+ if (!row) return null;
176
+ const task = rowToTask(row);
177
+
178
+ // Claim it
179
+ db.query(
180
+ `UPDATE tasks
181
+ SET status = 'in_progress',
182
+ claimed_by = ?1,
183
+ claimed_at = datetime('now'),
184
+ updated_at = datetime('now')
185
+ WHERE id = ?2 AND status = 'pending'`,
186
+ ).run(claimedBy, task.id);
187
+
188
+ return {
189
+ ...task,
190
+ status: "in_progress",
191
+ claimed_by: claimedBy,
192
+ claimed_at: new Date(),
193
+ };
194
+ }
@@ -0,0 +1,202 @@
1
+ import type { DbConnection } from "./connection.ts";
2
+ import { uuidv7 } from "./uuid.ts";
3
+
4
+ export interface Thread {
5
+ id: string;
6
+ type: "daemon_tick" | "chat_session";
7
+ task_id: string | null;
8
+ title: string;
9
+ started_at: Date;
10
+ ended_at: Date | null;
11
+ metadata: string | null;
12
+ }
13
+
14
+ export interface Interaction {
15
+ id: string;
16
+ thread_id: string;
17
+ sequence: number;
18
+ role: "user" | "assistant" | "system" | "tool";
19
+ kind:
20
+ | "message"
21
+ | "thinking"
22
+ | "tool_use"
23
+ | "tool_result"
24
+ | "context_update"
25
+ | "status_change";
26
+ content: string;
27
+ tool_name: string | null;
28
+ tool_input: string | null;
29
+ duration_ms: number | null;
30
+ token_count: number | null;
31
+ created_at: Date;
32
+ }
33
+
34
+ interface ThreadRow {
35
+ id: string;
36
+ type: string;
37
+ task_id: string | null;
38
+ title: string;
39
+ started_at: string;
40
+ ended_at: string | null;
41
+ metadata: string | null;
42
+ }
43
+
44
+ interface InteractionRow {
45
+ id: string;
46
+ thread_id: string;
47
+ sequence: number;
48
+ role: string;
49
+ kind: string;
50
+ content: string;
51
+ tool_name: string | null;
52
+ tool_input: string | null;
53
+ duration_ms: number | null;
54
+ token_count: number | null;
55
+ created_at: string;
56
+ }
57
+
58
+ function rowToThread(row: ThreadRow): Thread {
59
+ return {
60
+ id: row.id,
61
+ type: row.type as Thread["type"],
62
+ task_id: row.task_id,
63
+ title: row.title,
64
+ started_at: new Date(row.started_at),
65
+ ended_at: row.ended_at ? new Date(row.ended_at) : null,
66
+ metadata: row.metadata,
67
+ };
68
+ }
69
+
70
+ function rowToInteraction(row: InteractionRow): Interaction {
71
+ return {
72
+ id: row.id,
73
+ thread_id: row.thread_id,
74
+ sequence: row.sequence,
75
+ role: row.role as Interaction["role"],
76
+ kind: row.kind as Interaction["kind"],
77
+ content: row.content,
78
+ tool_name: row.tool_name,
79
+ tool_input: row.tool_input,
80
+ duration_ms: row.duration_ms,
81
+ token_count: row.token_count,
82
+ created_at: new Date(row.created_at),
83
+ };
84
+ }
85
+
86
+ export async function createThread(
87
+ db: DbConnection,
88
+ type: Thread["type"],
89
+ taskId?: string,
90
+ title?: string,
91
+ ): Promise<string> {
92
+ const id = uuidv7();
93
+ db.query(
94
+ `INSERT INTO threads (id, type, task_id, title)
95
+ VALUES (?1, ?2, ?3, ?4)`,
96
+ ).run(id, type, taskId ?? null, title ?? "");
97
+ return id;
98
+ }
99
+
100
+ export async function logInteraction(
101
+ db: DbConnection,
102
+ threadId: string,
103
+ params: {
104
+ role: Interaction["role"];
105
+ kind: Interaction["kind"];
106
+ content: string;
107
+ toolName?: string;
108
+ toolInput?: string;
109
+ durationMs?: number;
110
+ tokenCount?: number;
111
+ },
112
+ ): Promise<string> {
113
+ // Get next sequence number
114
+ const seqRow = db
115
+ .query(
116
+ "SELECT COALESCE(MAX(sequence), 0) + 1 AS next_seq FROM interactions WHERE thread_id = ?1",
117
+ )
118
+ .get(threadId) as { next_seq: number };
119
+ const sequence = seqRow.next_seq;
120
+
121
+ const id = uuidv7();
122
+ db.query(
123
+ `INSERT INTO interactions (id, thread_id, sequence, role, kind, content, tool_name, tool_input, duration_ms, token_count)
124
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)`,
125
+ ).run(
126
+ id,
127
+ threadId,
128
+ sequence,
129
+ params.role,
130
+ params.kind,
131
+ params.content,
132
+ params.toolName ?? null,
133
+ params.toolInput ?? null,
134
+ params.durationMs ?? null,
135
+ params.tokenCount ?? null,
136
+ );
137
+ return id;
138
+ }
139
+
140
+ export async function endThread(
141
+ db: DbConnection,
142
+ threadId: string,
143
+ ): Promise<void> {
144
+ db.query("UPDATE threads SET ended_at = datetime('now') WHERE id = ?1").run(
145
+ threadId,
146
+ );
147
+ }
148
+
149
+ export async function getThread(
150
+ db: DbConnection,
151
+ threadId: string,
152
+ ): Promise<{ thread: Thread; interactions: Interaction[] } | null> {
153
+ const threadRow = db
154
+ .query("SELECT * FROM threads WHERE id = ?1")
155
+ .get(threadId) as ThreadRow | null;
156
+ if (!threadRow) return null;
157
+
158
+ const interactionRows = db
159
+ .query(
160
+ "SELECT * FROM interactions WHERE thread_id = ?1 ORDER BY sequence ASC",
161
+ )
162
+ .all(threadId) as InteractionRow[];
163
+
164
+ return {
165
+ thread: rowToThread(threadRow),
166
+ interactions: interactionRows.map(rowToInteraction),
167
+ };
168
+ }
169
+
170
+ export async function listThreads(
171
+ db: DbConnection,
172
+ filters?: {
173
+ type?: Thread["type"];
174
+ taskId?: string;
175
+ limit?: number;
176
+ },
177
+ ): Promise<Thread[]> {
178
+ const conditions: string[] = [];
179
+ const params: string[] = [];
180
+
181
+ if (filters?.type) {
182
+ params.push(filters.type);
183
+ conditions.push(`type = ?${params.length}`);
184
+ }
185
+ if (filters?.taskId) {
186
+ params.push(filters.taskId);
187
+ conditions.push(`task_id = ?${params.length}`);
188
+ }
189
+
190
+ const where =
191
+ conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
192
+ const limit = filters?.limit ? `LIMIT ${filters.limit}` : "";
193
+
194
+ const rows = db
195
+ .query(
196
+ `SELECT * FROM threads ${where}
197
+ ORDER BY started_at DESC
198
+ ${limit}`,
199
+ )
200
+ .all(...params) as ThreadRow[];
201
+ return rows.map(rowToThread);
202
+ }
package/src/db/uuid.ts ADDED
@@ -0,0 +1 @@
1
+ export { v7 as uuidv7 } from "uuid";
@@ -0,0 +1,84 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { getBotholomewDir, getDbPath, getMcpxDir } from "../constants.ts";
4
+ import { getConnection } from "../db/connection.ts";
5
+ import { migrate } from "../db/schema.ts";
6
+ import { logger } from "../utils/logger.ts";
7
+ import {
8
+ BELIEFS_MD,
9
+ DEFAULT_CONFIG,
10
+ DEFAULT_MCPX_SERVERS,
11
+ GOALS_MD,
12
+ SOUL_MD,
13
+ } from "./templates.ts";
14
+
15
+ export async function initProject(
16
+ projectDir: string,
17
+ opts: { force?: boolean } = {},
18
+ ): Promise<void> {
19
+ const dotDir = getBotholomewDir(projectDir);
20
+ const mcpxDir = getMcpxDir(projectDir);
21
+
22
+ // Check if already initialized
23
+ const dirExists = await Bun.file(join(dotDir, "soul.md")).exists();
24
+ if (dirExists && !opts.force) {
25
+ throw new Error(
26
+ `.botholomew already initialized in ${projectDir}. Use --force to reinitialize.`,
27
+ );
28
+ }
29
+
30
+ // Create directories
31
+ await mkdir(dotDir, { recursive: true });
32
+ await mkdir(mcpxDir, { recursive: true });
33
+
34
+ // Write template files
35
+ await Bun.write(join(dotDir, "soul.md"), SOUL_MD);
36
+ await Bun.write(join(dotDir, "beliefs.md"), BELIEFS_MD);
37
+ await Bun.write(join(dotDir, "goals.md"), GOALS_MD);
38
+
39
+ // Write config (without API key)
40
+ await Bun.write(
41
+ join(dotDir, "config.json"),
42
+ `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`,
43
+ );
44
+
45
+ // Write mcpx servers config
46
+ await Bun.write(
47
+ join(mcpxDir, "servers.json"),
48
+ `${JSON.stringify(DEFAULT_MCPX_SERVERS, null, 2)}\n`,
49
+ );
50
+
51
+ // Initialize database
52
+ const dbPath = getDbPath(projectDir);
53
+ const conn = getConnection(dbPath);
54
+ migrate(conn);
55
+ conn.close();
56
+
57
+ // Update .gitignore
58
+ await updateGitignore(projectDir);
59
+
60
+ logger.success("Initialized Botholomew project");
61
+ logger.dim(` Directory: ${dotDir}`);
62
+ logger.dim(` Database: ${dbPath}`);
63
+ logger.dim("");
64
+ logger.dim("Next steps:");
65
+ logger.dim(" 1. Set ANTHROPIC_API_KEY or add it to .botholomew/config.json");
66
+ logger.dim(" 2. Run 'botholomew task add' to create your first task");
67
+ logger.dim(" 3. Run 'botholomew daemon start' to start the daemon");
68
+ }
69
+
70
+ async function updateGitignore(projectDir: string): Promise<void> {
71
+ const gitignorePath = join(projectDir, ".gitignore");
72
+ const file = Bun.file(gitignorePath);
73
+
74
+ let content = "";
75
+ if (await file.exists()) {
76
+ content = await file.text();
77
+ }
78
+
79
+ const entry = ".botholomew/";
80
+ if (content.includes(entry)) return;
81
+
82
+ const section = `\n# Botholomew (auto-generated)\n${entry}\n`;
83
+ await Bun.write(gitignorePath, `${content.trimEnd()}\n${section}`);
84
+ }
@@ -0,0 +1,48 @@
1
+ export const SOUL_MD = `---
2
+ loading: always
3
+ agent-modification: false
4
+ ---
5
+
6
+ # Soul
7
+
8
+ You are Botholomew, an AI agent for knowledge work. You help humans manage information, research topics, organize knowledge, and complete intellectual tasks.
9
+
10
+ You are thoughtful, thorough, and proactive. You work through your task queue methodically, prioritizing appropriately and asking for clarification when needed.
11
+ `;
12
+
13
+ export const BELIEFS_MD = `---
14
+ loading: always
15
+ agent-modification: true
16
+ ---
17
+
18
+ # Beliefs
19
+
20
+ *These are things Botholomew has learned about the world and this project.*
21
+ *Botholomew updates this file as it learns.*
22
+
23
+ - I should be concise and clear in my work products.
24
+ - I should ask for help when I'm stuck rather than guessing.
25
+ `;
26
+
27
+ export const GOALS_MD = `---
28
+ loading: always
29
+ agent-modification: true
30
+ ---
31
+
32
+ # Goals
33
+
34
+ *These are the current goals for this project.*
35
+ *Botholomew updates this file as goals are completed or new ones are added.*
36
+
37
+ - Get set up and ready to help.
38
+ `;
39
+
40
+ export const DEFAULT_CONFIG = {
41
+ model: "claude-sonnet-4-20250514",
42
+ tick_interval_seconds: 300,
43
+ max_tick_duration_seconds: 120,
44
+ };
45
+
46
+ export const DEFAULT_MCPX_SERVERS = {
47
+ mcpServers: {},
48
+ };
@@ -0,0 +1,39 @@
1
+ import { z } from "zod";
2
+ import { contextPathExists, createContextItem } from "../../db/context.ts";
3
+ import type { ToolDefinition } from "../tool.ts";
4
+
5
+ const inputSchema = z.object({
6
+ path: z.string().describe("Directory path to create"),
7
+ parents: z
8
+ .boolean()
9
+ .optional()
10
+ .describe("Create parent directories as needed"),
11
+ });
12
+
13
+ const outputSchema = z.object({
14
+ created: z.boolean(),
15
+ path: z.string(),
16
+ });
17
+
18
+ export const dirCreateTool = {
19
+ name: "dir_create",
20
+ description: "Create a directory in the virtual filesystem.",
21
+ group: "dir",
22
+ inputSchema,
23
+ outputSchema,
24
+ execute: async (input, ctx) => {
25
+ const exists = await contextPathExists(ctx.conn, input.path);
26
+ if (exists) {
27
+ return { created: false, path: input.path };
28
+ }
29
+
30
+ await createContextItem(ctx.conn, {
31
+ title: input.path.split("/").filter(Boolean).pop() ?? input.path,
32
+ contextPath: input.path,
33
+ mimeType: "inode/directory",
34
+ isTextual: false,
35
+ });
36
+
37
+ return { created: true, path: input.path };
38
+ },
39
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -0,0 +1,87 @@
1
+ import { z } from "zod";
2
+ import {
3
+ getDistinctDirectories,
4
+ listContextItemsByPrefix,
5
+ } from "../../db/context.ts";
6
+ import type { ToolDefinition } from "../tool.ts";
7
+
8
+ const DirEntrySchema = z.object({
9
+ name: z.string(),
10
+ type: z.enum(["file", "directory"]),
11
+ size: z.number(),
12
+ });
13
+
14
+ const inputSchema = z.object({
15
+ path: z.string().optional().describe("Directory path (defaults to /)"),
16
+ recursive: z
17
+ .boolean()
18
+ .optional()
19
+ .default(true)
20
+ .describe("Include contents of subdirectories (defaults to true)"),
21
+ limit: z
22
+ .number()
23
+ .optional()
24
+ .default(100)
25
+ .describe("Maximum number of entries to return (defaults to 100)"),
26
+ offset: z
27
+ .number()
28
+ .optional()
29
+ .default(0)
30
+ .describe("Number of entries to skip (defaults to 0)"),
31
+ });
32
+
33
+ const outputSchema = z.object({
34
+ entries: z.array(DirEntrySchema),
35
+ total: z.number(),
36
+ });
37
+
38
+ export const dirListTool = {
39
+ name: "dir_list",
40
+ description: "List directory contents in the virtual filesystem.",
41
+ group: "dir",
42
+ inputSchema,
43
+ outputSchema,
44
+ execute: async (input, ctx) => {
45
+ const path = input.path ?? "/";
46
+ const recursive = input.recursive ?? true;
47
+ const limit = input.limit ?? 100;
48
+ const offset = input.offset ?? 0;
49
+ const normalizedPath = path.endsWith("/") ? path : `${path}/`;
50
+
51
+ const allItems = await listContextItemsByPrefix(ctx.conn, path, {
52
+ recursive,
53
+ });
54
+
55
+ const entries: z.infer<typeof DirEntrySchema>[] = allItems.map((item) => ({
56
+ name: recursive
57
+ ? item.context_path
58
+ : item.context_path.slice(normalizedPath.length),
59
+ type:
60
+ item.mime_type === "inode/directory"
61
+ ? ("directory" as const)
62
+ : ("file" as const),
63
+ size: item.content?.length ?? 0,
64
+ }));
65
+
66
+ // Add subdirectories (if not recursive, show immediate child dirs)
67
+ if (!recursive) {
68
+ const dirs = await getDistinctDirectories(ctx.conn, path);
69
+ for (const dir of dirs) {
70
+ const name = dir.slice(normalizedPath.length);
71
+ if (!entries.some((e) => e.name === name)) {
72
+ entries.push({ name, type: "directory", size: 0 });
73
+ }
74
+ }
75
+ }
76
+
77
+ entries.sort((a, b) => {
78
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
79
+ return a.name.localeCompare(b.name);
80
+ });
81
+
82
+ const total = entries.length;
83
+ const paginated = entries.slice(offset, offset + limit);
84
+
85
+ return { entries: paginated, total };
86
+ },
87
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -0,0 +1,45 @@
1
+ import { z } from "zod";
2
+ import { listContextItemsByPrefix } from "../../db/context.ts";
3
+ import type { ToolDefinition } from "../tool.ts";
4
+
5
+ function formatBytes(bytes: number): string {
6
+ if (bytes === 0) return "0 B";
7
+ const units = ["B", "KB", "MB", "GB"];
8
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
9
+ const value = bytes / 1024 ** i;
10
+ return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
11
+ }
12
+
13
+ const inputSchema = z.object({
14
+ path: z.string().optional().describe("Directory path (defaults to /)"),
15
+ recursive: z
16
+ .boolean()
17
+ .optional()
18
+ .describe("Include subdirectories (defaults to true)"),
19
+ });
20
+
21
+ const outputSchema = z.object({
22
+ bytes: z.number(),
23
+ formatted: z.string(),
24
+ });
25
+
26
+ export const dirSizeTool = {
27
+ name: "dir_size",
28
+ description: "Get the total size of files in a directory.",
29
+ group: "dir",
30
+ inputSchema,
31
+ outputSchema,
32
+ execute: async (input, ctx) => {
33
+ const path = input.path ?? "/";
34
+ const items = await listContextItemsByPrefix(ctx.conn, path, {
35
+ recursive: input.recursive !== false,
36
+ });
37
+
38
+ let bytes = 0;
39
+ for (const item of items) {
40
+ if (item.content != null) bytes += item.content.length;
41
+ }
42
+
43
+ return { bytes, formatted: formatBytes(bytes) };
44
+ },
45
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;