botholomew 0.6.1 → 0.6.3

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.
@@ -37,6 +37,29 @@ agent-modification: true
37
37
  - Get set up and ready to help.
38
38
  `;
39
39
 
40
+ export const SUMMARIZE_SKILL = `---
41
+ name: summarize
42
+ description: "Summarize the current conversation"
43
+ arguments: []
44
+ ---
45
+
46
+ Summarize this conversation so far. Provide a concise bullet-point summary
47
+ of what we discussed, any decisions made, and any open action items.
48
+ `;
49
+
50
+ export const STANDUP_SKILL = `---
51
+ name: standup
52
+ description: "Generate a standup update from recent tasks"
53
+ arguments: []
54
+ ---
55
+
56
+ Generate a standup update. Look at recent tasks (completed in the last 24 hours
57
+ and currently in progress) and format a brief standup-style update with:
58
+ - What was done (completed tasks)
59
+ - What's in progress
60
+ - Any blockers or waiting items
61
+ `;
62
+
40
63
  export const DEFAULT_CONFIG = {
41
64
  anthropic_api_key: "your-api-key-here",
42
65
  model: "claude-opus-4-20250514",
@@ -0,0 +1,61 @@
1
+ import type { SkillDefinition } from "./parser.ts";
2
+ import { renderSkill } from "./parser.ts";
3
+
4
+ export interface SlashCommandContext {
5
+ skills: Map<string, SkillDefinition>;
6
+ addSystemMessage: (content: string) => void;
7
+ queueUserMessage: (content: string) => void;
8
+ exit: () => void;
9
+ }
10
+
11
+ /**
12
+ * Handle a slash-command input. Returns true if the command was consumed
13
+ * (recognized or errored), false if it should fall through.
14
+ */
15
+ export function handleSlashCommand(
16
+ input: string,
17
+ ctx: SlashCommandContext,
18
+ ): boolean {
19
+ const spaceIdx = input.indexOf(" ");
20
+ const commandPart = spaceIdx === -1 ? input : input.slice(0, spaceIdx);
21
+ const rawArgs = spaceIdx === -1 ? "" : input.slice(spaceIdx + 1).trim();
22
+ const name = commandPart.slice(1).toLowerCase(); // remove leading /
23
+
24
+ // Built-in commands
25
+ if (name === "quit" || name === "exit") {
26
+ ctx.exit();
27
+ return true;
28
+ }
29
+
30
+ if (name === "skills") {
31
+ if (ctx.skills.size === 0) {
32
+ ctx.addSystemMessage(
33
+ "No skills loaded. Add .md files to .botholomew/skills/",
34
+ );
35
+ } else {
36
+ const lines = ["Available skills:"];
37
+ for (const [skillName, skill] of ctx.skills) {
38
+ lines.push(
39
+ ` /${skillName.padEnd(16)} ${skill.description || "(no description)"}`,
40
+ );
41
+ }
42
+ ctx.addSystemMessage(lines.join("\n"));
43
+ }
44
+ return true;
45
+ }
46
+
47
+ // Skill dispatch
48
+ const skill = ctx.skills.get(name);
49
+ if (skill) {
50
+ const rendered = renderSkill(skill, rawArgs);
51
+ ctx.addSystemMessage(`Running skill: ${skill.name}`);
52
+ ctx.queueUserMessage(rendered);
53
+ return true;
54
+ }
55
+
56
+ // Unknown command
57
+ ctx.addSystemMessage(
58
+ `Unknown command: /${name}. Type /skills to see available commands.`,
59
+ );
60
+ return true;
61
+ }
@@ -0,0 +1,36 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { getSkillsDir } from "../constants.ts";
4
+ import { parseSkillFile, type SkillDefinition } from "./parser.ts";
5
+
6
+ export async function loadSkills(
7
+ projectDir: string,
8
+ ): Promise<Map<string, SkillDefinition>> {
9
+ const skills = new Map<string, SkillDefinition>();
10
+ const dir = getSkillsDir(projectDir);
11
+
12
+ let entries: string[];
13
+ try {
14
+ entries = await readdir(dir);
15
+ } catch {
16
+ return skills; // directory doesn't exist — graceful for pre-M7 projects
17
+ }
18
+
19
+ for (const entry of entries) {
20
+ if (!entry.endsWith(".md")) continue;
21
+ const filePath = join(dir, entry);
22
+ const raw = await Bun.file(filePath).text();
23
+ const skill = parseSkillFile(raw, filePath);
24
+ skills.set(skill.name, skill);
25
+ }
26
+
27
+ return skills;
28
+ }
29
+
30
+ export async function getSkill(
31
+ projectDir: string,
32
+ name: string,
33
+ ): Promise<SkillDefinition | null> {
34
+ const skills = await loadSkills(projectDir);
35
+ return skills.get(name.toLowerCase()) ?? null;
36
+ }
@@ -0,0 +1,95 @@
1
+ import { basename } from "node:path";
2
+ import matter from "gray-matter";
3
+
4
+ export interface SkillArgDef {
5
+ name: string;
6
+ description: string;
7
+ required: boolean;
8
+ default?: string;
9
+ }
10
+
11
+ export interface SkillDefinition {
12
+ name: string;
13
+ description: string;
14
+ arguments: SkillArgDef[];
15
+ body: string;
16
+ filePath: string;
17
+ }
18
+
19
+ export function parseSkillFile(raw: string, filePath: string): SkillDefinition {
20
+ const { data, content } = matter(raw);
21
+
22
+ const name: string =
23
+ typeof data.name === "string" && data.name
24
+ ? data.name.toLowerCase()
25
+ : basename(filePath, ".md").toLowerCase();
26
+
27
+ const description: string =
28
+ typeof data.description === "string" ? data.description : "";
29
+
30
+ const args: SkillArgDef[] = [];
31
+ if (Array.isArray(data.arguments)) {
32
+ for (const arg of data.arguments) {
33
+ if (arg && typeof arg === "object" && typeof arg.name === "string") {
34
+ args.push({
35
+ name: arg.name,
36
+ description:
37
+ typeof arg.description === "string" ? arg.description : "",
38
+ required: arg.required === true,
39
+ default: typeof arg.default === "string" ? arg.default : undefined,
40
+ });
41
+ }
42
+ }
43
+ }
44
+
45
+ return {
46
+ name,
47
+ description,
48
+ arguments: args,
49
+ body: content.trim(),
50
+ filePath,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Split a raw argument string into positional tokens,
56
+ * respecting double-quoted strings.
57
+ */
58
+ function tokenize(raw: string): string[] {
59
+ const tokens: string[] = [];
60
+ let current = "";
61
+ let inQuote = false;
62
+
63
+ for (const ch of raw) {
64
+ if (ch === '"') {
65
+ inQuote = !inQuote;
66
+ } else if (!inQuote && /\s/.test(ch)) {
67
+ if (current) {
68
+ tokens.push(current);
69
+ current = "";
70
+ }
71
+ } else {
72
+ current += ch;
73
+ }
74
+ }
75
+
76
+ if (current) tokens.push(current);
77
+ return tokens;
78
+ }
79
+
80
+ export function renderSkill(skill: SkillDefinition, rawArgs: string): string {
81
+ const tokens = tokenize(rawArgs);
82
+ let result = skill.body;
83
+
84
+ result = result.replaceAll("$ARGUMENTS", rawArgs);
85
+
86
+ // Replace $1-$9 with positional args or defaults
87
+ for (let i = 1; i <= 9; i++) {
88
+ const token = tokens[i - 1];
89
+ const argDef = skill.arguments[i - 1];
90
+ const value = token ?? argDef?.default ?? "";
91
+ result = result.replaceAll(`$${i}`, value);
92
+ }
93
+
94
+ return result;
95
+ }
@@ -22,9 +22,9 @@ const outputSchema = z.object({
22
22
  is_error: z.boolean(),
23
23
  });
24
24
 
25
- export const searchContextTool = {
26
- name: "search_context",
27
- description: "Search the context database by keyword.",
25
+ export const contextSearchTool = {
26
+ name: "context_search",
27
+ description: "Search context by keyword.",
28
28
  group: "context",
29
29
  inputSchema,
30
30
  outputSchema,
@@ -16,10 +16,10 @@ const outputSchema = z.object({
16
16
  is_error: z.boolean(),
17
17
  });
18
18
 
19
- export const dirCreateTool = {
20
- name: "dir_create",
21
- description: "Create a directory in the virtual filesystem.",
22
- group: "dir",
19
+ export const contextCreateDirTool = {
20
+ name: "context_create_dir",
21
+ description: "Create a directory in context.",
22
+ group: "context",
23
23
  inputSchema,
24
24
  outputSchema,
25
25
  execute: async (input, ctx) => {
@@ -36,10 +36,10 @@ const outputSchema = z.object({
36
36
  is_error: z.boolean(),
37
37
  });
38
38
 
39
- export const dirListTool = {
40
- name: "dir_list",
41
- description: "List directory contents in the virtual filesystem.",
42
- group: "dir",
39
+ export const contextListDirTool = {
40
+ name: "context_list_dir",
41
+ description: "List directory contents in context.",
42
+ group: "context",
43
43
  inputSchema,
44
44
  outputSchema,
45
45
  execute: async (input, ctx) => {
@@ -24,10 +24,10 @@ const outputSchema = z.object({
24
24
  is_error: z.boolean(),
25
25
  });
26
26
 
27
- export const dirSizeTool = {
28
- name: "dir_size",
29
- description: "Get the total size of files in a directory.",
30
- group: "dir",
27
+ export const contextDirSizeTool = {
28
+ name: "context_dir_size",
29
+ description: "Get the total size of context items in a directory.",
30
+ group: "context",
31
31
  inputSchema,
32
32
  outputSchema,
33
33
  execute: async (input, ctx) => {
@@ -1,92 +1,275 @@
1
1
  import { z } from "zod";
2
- import { listContextItemsByPrefix } from "../../db/context.ts";
2
+ import {
3
+ countContextItemsByPrefix,
4
+ listContextItemsByPrefix,
5
+ } from "../../db/context.ts";
3
6
  import type { ToolDefinition } from "../tool.ts";
4
7
 
5
- const DEFAULT_MAX_ITEMS = 200;
8
+ const DEFAULT_MAX_DEPTH = 3;
9
+ const DEFAULT_ITEMS_PER_DIR = 15;
10
+ const HARD_FETCH_CAP = 1000;
6
11
 
7
12
  const inputSchema = z.object({
8
13
  path: z
9
14
  .string()
10
15
  .optional()
11
16
  .describe("Root path for the tree (defaults to /)"),
12
- max_items: z
17
+ max_depth: z
13
18
  .number()
19
+ .int()
20
+ .positive()
14
21
  .optional()
15
- .default(DEFAULT_MAX_ITEMS)
22
+ .default(DEFAULT_MAX_DEPTH)
16
23
  .describe(
17
- `Maximum number of items to include (defaults to ${DEFAULT_MAX_ITEMS})`,
24
+ `Maximum depth of directories to render (defaults to ${DEFAULT_MAX_DEPTH}). Use a deeper path to drill in.`,
18
25
  ),
26
+ items_per_dir: z
27
+ .number()
28
+ .int()
29
+ .positive()
30
+ .optional()
31
+ .default(DEFAULT_ITEMS_PER_DIR)
32
+ .describe(
33
+ `Maximum entries shown per directory (defaults to ${DEFAULT_ITEMS_PER_DIR}). Overflow shown as "(+N more)".`,
34
+ ),
35
+ });
36
+
37
+ const TruncatedDirSchema = z.object({
38
+ path: z.string(),
39
+ shown: z.number(),
40
+ total: z.number(),
19
41
  });
20
42
 
21
43
  const outputSchema = z.object({
22
44
  tree: z.string(),
23
45
  is_error: z.boolean(),
46
+ total_items: z.number(),
47
+ truncated_dirs: z.array(TruncatedDirSchema),
48
+ hint: z.string(),
24
49
  });
25
50
 
26
- export const dirTreeTool = {
27
- name: "dir_tree",
51
+ interface DirNode {
52
+ name: string;
53
+ fullPath: string;
54
+ isDir: true;
55
+ children: TreeEntry[];
56
+ }
57
+
58
+ interface FileNode {
59
+ name: string;
60
+ fullPath: string;
61
+ isDir: false;
62
+ }
63
+
64
+ type TreeEntry = DirNode | FileNode;
65
+
66
+ export const contextTreeTool = {
67
+ name: "context_tree",
28
68
  description:
29
- "Render a directory as a markdown-style tree in the virtual filesystem.",
30
- group: "dir",
69
+ "Render a directory as a markdown-style tree. Use max_depth and items_per_dir to bound output; drill in by passing a deeper path.",
70
+ group: "context",
31
71
  inputSchema,
32
72
  outputSchema,
33
73
  execute: async (input, ctx) => {
34
74
  const path = input.path ?? "/";
35
- const maxItems = input.max_items ?? DEFAULT_MAX_ITEMS;
36
- const items = await listContextItemsByPrefix(ctx.conn, path, {
75
+ const maxDepth = input.max_depth ?? DEFAULT_MAX_DEPTH;
76
+ const itemsPerDir = input.items_per_dir ?? DEFAULT_ITEMS_PER_DIR;
77
+ const normalizedPath = path.endsWith("/") ? path : `${path}/`;
78
+
79
+ const totalItems = await countContextItemsByPrefix(ctx.conn, path, {
37
80
  recursive: true,
38
- limit: maxItems,
39
81
  });
40
82
 
41
- if (items.length === 0) {
42
- return { tree: `${path}\n (empty)`, is_error: false };
83
+ if (totalItems === 0) {
84
+ return {
85
+ tree: `${path}\n (empty)`,
86
+ is_error: false,
87
+ total_items: 0,
88
+ truncated_dirs: [],
89
+ hint: "Directory is empty.",
90
+ };
43
91
  }
44
92
 
45
- const normalizedPath = path.endsWith("/") ? path : `${path}/`;
93
+ const items = await listContextItemsByPrefix(ctx.conn, path, {
94
+ recursive: true,
95
+ limit: HARD_FETCH_CAP,
96
+ });
46
97
 
47
- // Build tree structure
48
- const lines: string[] = [path];
98
+ // Build tree structure: dirs map child name -> child node
99
+ const root: DirNode = {
100
+ name: path,
101
+ fullPath: path,
102
+ isDir: true,
103
+ children: [],
104
+ };
105
+ const dirIndex = new Map<string, DirNode>();
106
+ dirIndex.set(stripTrailingSlash(path), root);
107
+
108
+ for (const item of items) {
109
+ const relative = item.context_path.slice(normalizedPath.length);
110
+ if (relative.length === 0) continue; // root itself, skip
111
+ const parts = relative.split("/").filter((p) => p.length > 0);
112
+ const isExplicitDir = item.mime_type === "inode/directory";
49
113
 
50
- // Collect all paths and sort
51
- const paths = items.map((i) => i.context_path).sort();
114
+ // Walk segments, creating intermediate directories as needed
115
+ let parentDir = root;
116
+ let currentRel = "";
117
+ for (let i = 0; i < parts.length; i++) {
118
+ const segment = parts[i];
119
+ if (!segment) continue;
120
+ currentRel = currentRel ? `${currentRel}/${segment}` : segment;
121
+ const fullPath = `${normalizedPath}${currentRel}`;
122
+ const isLeaf = i === parts.length - 1;
123
+ const isDirHere = !isLeaf || isExplicitDir;
52
124
 
53
- // Collect all directory prefixes
54
- const dirSet = new Set<string>();
55
- for (const p of paths) {
56
- const relative = p.slice(normalizedPath.length);
57
- const parts = relative.split("/");
58
- for (let i = 1; i < parts.length; i++) {
59
- dirSet.add(parts.slice(0, i).join("/"));
125
+ if (isDirHere) {
126
+ const key = stripTrailingSlash(fullPath);
127
+ let dir = dirIndex.get(key);
128
+ if (!dir) {
129
+ dir = {
130
+ name: segment,
131
+ fullPath,
132
+ isDir: true,
133
+ children: [],
134
+ };
135
+ dirIndex.set(key, dir);
136
+ parentDir.children.push(dir);
137
+ }
138
+ parentDir = dir;
139
+ } else {
140
+ parentDir.children.push({
141
+ name: segment,
142
+ fullPath,
143
+ isDir: false,
144
+ });
145
+ }
60
146
  }
61
147
  }
62
148
 
63
- // Merge dirs and files, sort
64
- const allEntries = [
65
- ...Array.from(dirSet).map((d) => ({ path: d, isDir: true })),
66
- ...paths.map((p) => ({
67
- path: p.slice(normalizedPath.length),
68
- isDir: false,
69
- })),
70
- ].sort((a, b) => a.path.localeCompare(b.path));
71
-
72
- for (let i = 0; i < allEntries.length; i++) {
73
- const entry = allEntries[i];
74
- if (!entry) continue;
75
- const depth = entry.path.split("/").length - 1;
76
- const isLast =
77
- i === allEntries.length - 1 ||
78
- (allEntries[i + 1]?.path.split("/").length ?? 0) - 1 <= depth;
79
- const prefix = isLast ? "└── " : "├── ";
80
- const indent = "│ ".repeat(depth);
81
- const name = entry.path.split("/").pop() ?? "";
82
- const suffix = entry.isDir ? "/" : "";
83
- lines.push(`${indent}${prefix}${name}${suffix}`);
149
+ // Sort each directory's children: dirs first, then alphabetical
150
+ for (const dir of dirIndex.values()) {
151
+ dir.children.sort((a, b) => {
152
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
153
+ return a.name.localeCompare(b.name);
154
+ });
84
155
  }
85
156
 
86
- if (items.length >= maxItems) {
87
- lines.push(`... (truncated at ${maxItems} items)`);
88
- }
157
+ const truncatedDirs: Array<{
158
+ path: string;
159
+ shown: number;
160
+ total: number;
161
+ }> = [];
162
+ const depthLimitedDirs: string[] = [];
163
+
164
+ const lines: string[] = [path];
165
+
166
+ const render = (
167
+ dir: DirNode,
168
+ indent: string,
169
+ currentDepth: number,
170
+ ): void => {
171
+ const children = dir.children;
172
+ const total = children.length;
173
+ const shown = Math.min(total, itemsPerDir);
174
+ const visible = children.slice(0, shown);
175
+ const overflow = total - shown;
176
+
177
+ if (overflow > 0) {
178
+ truncatedDirs.push({
179
+ path: stripTrailingSlash(dir.fullPath),
180
+ shown,
181
+ total,
182
+ });
183
+ }
184
+
185
+ for (let i = 0; i < visible.length; i++) {
186
+ const child = visible[i];
187
+ if (!child) continue;
188
+ const isLastVisible = i === visible.length - 1 && overflow === 0;
189
+ const connector = isLastVisible ? "└── " : "├── ";
190
+ const childIndent = isLastVisible ? " " : "│ ";
191
+
192
+ if (child.isDir) {
193
+ const atDepthLimit = currentDepth + 1 >= maxDepth;
194
+ if (atDepthLimit && child.children.length > 0) {
195
+ depthLimitedDirs.push(stripTrailingSlash(child.fullPath));
196
+ const subCount = countDescendants(child);
197
+ lines.push(
198
+ `${indent}${connector}${child.name}/ (${subCount} ${
199
+ subCount === 1 ? "item" : "items"
200
+ }, drill in)`,
201
+ );
202
+ } else {
203
+ lines.push(`${indent}${connector}${child.name}/`);
204
+ render(child, indent + childIndent, currentDepth + 1);
205
+ }
206
+ } else {
207
+ lines.push(`${indent}${connector}${child.name}`);
208
+ }
209
+ }
210
+
211
+ if (overflow > 0) {
212
+ lines.push(`${indent}└── ... (+${overflow} more)`);
213
+ }
214
+ };
215
+
216
+ render(root, "", 0);
89
217
 
90
- return { tree: lines.join("\n"), is_error: false };
218
+ const hint = buildHint({
219
+ truncatedDirs,
220
+ depthLimitedDirs,
221
+ totalItems,
222
+ });
223
+
224
+ return {
225
+ tree: lines.join("\n"),
226
+ is_error: false,
227
+ total_items: totalItems,
228
+ truncated_dirs: truncatedDirs,
229
+ hint,
230
+ };
91
231
  },
92
232
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
233
+
234
+ function stripTrailingSlash(p: string): string {
235
+ return p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p;
236
+ }
237
+
238
+ function countDescendants(dir: DirNode): number {
239
+ let count = 0;
240
+ for (const child of dir.children) {
241
+ count += 1;
242
+ if (child.isDir) count += countDescendants(child);
243
+ }
244
+ return count;
245
+ }
246
+
247
+ function buildHint(args: {
248
+ truncatedDirs: Array<{ path: string; shown: number; total: number }>;
249
+ depthLimitedDirs: string[];
250
+ totalItems: number;
251
+ }): string {
252
+ const { truncatedDirs, depthLimitedDirs } = args;
253
+ const parts: string[] = [];
254
+
255
+ if (truncatedDirs.length > 0) {
256
+ const first = truncatedDirs[0];
257
+ if (first) {
258
+ parts.push(
259
+ `${truncatedDirs.length} ${truncatedDirs.length === 1 ? "directory was" : "directories were"} capped by items_per_dir; raise items_per_dir or call context_tree with path="${first.path}".`,
260
+ );
261
+ }
262
+ }
263
+
264
+ if (depthLimitedDirs.length > 0) {
265
+ const first = depthLimitedDirs[0];
266
+ if (first) {
267
+ parts.push(
268
+ `${depthLimitedDirs.length} ${depthLimitedDirs.length === 1 ? "directory was" : "directories were"} not expanded due to max_depth; raise max_depth or call context_tree with path="${first}".`,
269
+ );
270
+ }
271
+ }
272
+
273
+ if (parts.length === 0) return "Tree is complete.";
274
+ return parts.join(" ");
275
+ }
@@ -18,10 +18,10 @@ const outputSchema = z.object({
18
18
  is_error: z.boolean(),
19
19
  });
20
20
 
21
- export const fileCopyTool = {
22
- name: "file_copy",
23
- description: "Copy a file in the virtual filesystem.",
24
- group: "file",
21
+ export const contextCopyTool = {
22
+ name: "context_copy",
23
+ description: "Copy a context item.",
24
+ group: "context",
25
25
  inputSchema,
26
26
  outputSchema,
27
27
  execute: async (input, ctx) => {
@@ -1,9 +1,9 @@
1
1
  import { z } from "zod";
2
- import { getContextItemByPath } from "../../db/context.ts";
2
+ import { resolveContextItemOrThrow } from "../../db/context.ts";
3
3
  import type { ToolDefinition } from "../tool.ts";
4
4
 
5
5
  const inputSchema = z.object({
6
- path: z.string().describe("File path"),
6
+ path: z.string().describe("File path or context item ID"),
7
7
  });
8
8
 
9
9
  const outputSchema = z.object({
@@ -11,15 +11,14 @@ const outputSchema = z.object({
11
11
  is_error: z.boolean(),
12
12
  });
13
13
 
14
- export const fileCountLinesTool = {
15
- name: "file_count_lines",
16
- description: "Count the number of lines in a text file.",
17
- group: "file",
14
+ export const contextCountLinesTool = {
15
+ name: "context_count_lines",
16
+ description: "Count the number of lines in a text context item.",
17
+ group: "context",
18
18
  inputSchema,
19
19
  outputSchema,
20
20
  execute: async (input, ctx) => {
21
- const item = await getContextItemByPath(ctx.conn, input.path);
22
- if (!item) throw new Error(`Not found: ${input.path}`);
21
+ const item = await resolveContextItemOrThrow(ctx.conn, input.path);
23
22
  if (item.content == null) throw new Error(`No text content: ${input.path}`);
24
23
 
25
24
  return { lines: item.content.split("\n").length, is_error: false };
@@ -22,10 +22,10 @@ const outputSchema = z.object({
22
22
  is_error: z.boolean(),
23
23
  });
24
24
 
25
- export const fileDeleteTool = {
26
- name: "file_delete",
27
- description: "Delete a file or directory from the virtual filesystem.",
28
- group: "file",
25
+ export const contextDeleteTool = {
26
+ name: "context_delete",
27
+ description: "Delete a context item or directory.",
28
+ group: "context",
29
29
  inputSchema,
30
30
  outputSchema,
31
31
  execute: async (input, ctx) => {