botholomew 0.9.7 → 0.9.9

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/README.md CHANGED
@@ -38,7 +38,8 @@ through MCP servers wired up via [MCPX](https://github.com/evantahler/mcpx).
38
38
  Slack, GitHub) or connect through an MCP gateway like
39
39
  [Arcade.dev](https://www.arcade.dev/) to reach hundreds of
40
40
  authenticated services without managing each server yourself.
41
- Reusable workflows are defined as markdown "skills" (slash commands).
41
+ Reusable workflows are defined as markdown "skills" (slash commands)
42
+ that the chat agent can also create, edit, and search at runtime.
42
43
  - **Safe by default.** The agent has no shell and no direct filesystem
43
44
  access. Out of the box, everything it can touch lives in `.botholomew/`;
44
45
  every external capability is a MCP server you explicitly add.
@@ -47,7 +48,8 @@ through MCP servers wired up via [MCPX](https://github.com/evantahler/mcpx).
47
48
  back into the queue automatically.
48
49
  - **Self-modifying.** The agent maintains its own `beliefs.md` and
49
50
  `goals.md` — it learns, updates its priors, and revises its goals as it
50
- works.
51
+ works. It can also author its own slash-command skills mid-conversation,
52
+ turning prompts you keep retyping into durable project assets.
51
53
 
52
54
  ---
53
55
 
@@ -209,7 +211,8 @@ Topics worth understanding in detail:
209
211
  - **[Persistent context](docs/persistent-context.md)** — `soul.md`,
210
212
  `beliefs.md`, `goals.md`, frontmatter flags, and agent self-modification.
211
213
  - **[Skills (slash commands)](docs/skills.md)** — reusable prompt templates
212
- with positional arguments and tab completion.
214
+ with positional arguments and tab completion; the chat agent can also
215
+ create, edit, and search them at runtime.
213
216
  - **[MCPX integration](docs/mcpx.md)** — configuring external servers and
214
217
  how MCP tools are merged into the agent's toolset.
215
218
  - **[Configuration](docs/configuration.md)** — every key in `config.json`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/chat/agent.ts CHANGED
@@ -46,12 +46,18 @@ const CHAT_TOOL_NAMES = new Set([
46
46
  "list_schedules",
47
47
  "update_beliefs",
48
48
  "update_goals",
49
+ "capabilities_refresh",
49
50
  "mcp_list_tools",
50
51
  "mcp_search",
51
52
  "mcp_info",
52
53
  "mcp_exec",
53
54
  "read_large_result",
54
55
  "spawn_worker",
56
+ "skill_list",
57
+ "skill_read",
58
+ "skill_write",
59
+ "skill_edit",
60
+ "skill_search",
55
61
  ]);
56
62
 
57
63
  export function getChatTools() {
@@ -108,6 +114,7 @@ You do NOT execute long-running work directly — enqueue tasks for a background
108
114
  Use the available tools to look up tasks, threads, schedules, and context when the user asks about them. Context items live under a drive (disk / url / agent / google-docs / github / …); use \`context_list_drives\` to discover which drives have content, then \`context_tree\`, \`context_info\`, \`context_search\`, or \`context_refresh\` as needed.
109
115
  When multiple tool calls are independent of each other (i.e., one does not depend on the result of another), call them all in a single response. They will be executed in parallel, which is faster than calling them one at a time.
110
116
  You can update the agent's beliefs and goals files when the user asks you to.
117
+ You can author and refine slash-command skills (reusable prompt templates stored in \`.botholomew/skills/\`) via \`skill_list\`, \`skill_search\`, \`skill_read\`, \`skill_write\`, and \`skill_edit\`. New or edited skills are usable as \`/<name>\` on the user's next message.
111
118
  Format your responses using Markdown. Use headings, bold, italic, lists, and code blocks to make your responses clear and well-structured.
112
119
  `;
113
120
 
@@ -153,6 +160,7 @@ export interface ToolEndMeta {
153
160
 
154
161
  export interface ChatTurnCallbacks {
155
162
  onToken: (text: string) => void;
163
+ onToolPreparing?: (id: string, name: string) => void;
156
164
  onToolStart: (id: string, name: string, input: string) => void;
157
165
  onToolEnd: (
158
166
  id: string,
@@ -244,6 +252,18 @@ export async function runChatTurn(input: {
244
252
  callbacks.onToken(text);
245
253
  });
246
254
 
255
+ stream.on("streamEvent", (event) => {
256
+ if (
257
+ event.type === "content_block_start" &&
258
+ event.content_block.type === "tool_use"
259
+ ) {
260
+ callbacks.onToolPreparing?.(
261
+ event.content_block.id,
262
+ event.content_block.name,
263
+ );
264
+ }
265
+ });
266
+
247
267
  stream.on("contentBlock", (block) => {
248
268
  if (block.type === "tool_use") {
249
269
  earlyReportedToolIds.add(block.id);
@@ -104,6 +104,10 @@ export async function sendMessage(
104
104
  userMessage: string,
105
105
  callbacks: ChatTurnCallbacks,
106
106
  ): Promise<void> {
107
+ // Hot-reload skills so any skill the agent created/edited last turn (or any
108
+ // out-of-band edit) is visible to slash-command dispatch this turn.
109
+ session.skills = await loadSkills(session.projectDir);
110
+
107
111
  // Log and append user message
108
112
  await withDb(session.dbPath, (conn) =>
109
113
  logInteraction(conn, session.threadId, {
@@ -0,0 +1,63 @@
1
+ import matter from "gray-matter";
2
+ import { BUILTIN_SLASH_COMMANDS } from "./commands.ts";
3
+ import type { SkillArgDef } from "./parser.ts";
4
+
5
+ export const RESERVED_SKILL_NAMES = new Set(
6
+ BUILTIN_SLASH_COMMANDS.map((c) => c.name),
7
+ );
8
+
9
+ const MAX_NAME_LENGTH = 64;
10
+
11
+ export type ValidateNameResult =
12
+ | { ok: true; normalized: string }
13
+ | { ok: false; reason: "empty" | "invalid" | "reserved" | "too_long" };
14
+
15
+ export function validateSkillName(raw: string): ValidateNameResult {
16
+ if (typeof raw !== "string" || raw.trim() === "") {
17
+ return { ok: false, reason: "empty" };
18
+ }
19
+
20
+ const normalized = raw
21
+ .toLowerCase()
22
+ .replace(/[^a-z0-9-]/g, "-")
23
+ .replace(/-+/g, "-")
24
+ .replace(/^-+|-+$/g, "");
25
+
26
+ if (normalized === "") return { ok: false, reason: "invalid" };
27
+ if (normalized.length > MAX_NAME_LENGTH)
28
+ return { ok: false, reason: "too_long" };
29
+ if (RESERVED_SKILL_NAMES.has(normalized))
30
+ return { ok: false, reason: "reserved" };
31
+
32
+ return { ok: true, normalized };
33
+ }
34
+
35
+ export interface SkillFileInput {
36
+ name: string;
37
+ description: string;
38
+ arguments: SkillArgDef[];
39
+ body: string;
40
+ }
41
+
42
+ export function buildSkillFileContent(input: SkillFileInput): string {
43
+ const data: Record<string, unknown> = {
44
+ name: input.name,
45
+ description: input.description,
46
+ };
47
+
48
+ if (input.arguments.length > 0) {
49
+ data.arguments = input.arguments.map((a) => {
50
+ const out: Record<string, unknown> = {
51
+ name: a.name,
52
+ description: a.description,
53
+ required: a.required,
54
+ };
55
+ if (a.default !== undefined) out.default = a.default;
56
+ return out;
57
+ });
58
+ } else {
59
+ data.arguments = [];
60
+ }
61
+
62
+ return matter.stringify(input.body, data);
63
+ }
@@ -32,9 +32,16 @@ import { listSchedulesTool } from "./schedule/list.ts";
32
32
  // Search tools
33
33
  import { searchGrepTool } from "./search/grep.ts";
34
34
  import { searchSemanticTool } from "./search/semantic.ts";
35
+ // Skill tools
36
+ import { skillEditTool } from "./skill/edit.ts";
37
+ import { skillListTool } from "./skill/list.ts";
38
+ import { skillReadTool } from "./skill/read.ts";
39
+ import { skillSearchTool } from "./skill/search.ts";
40
+ import { skillWriteTool } from "./skill/write.ts";
35
41
  // Task tools
36
42
  import { completeTaskTool } from "./task/complete.ts";
37
43
  import { createTaskTool } from "./task/create.ts";
44
+ import { deleteTaskTool } from "./task/delete.ts";
38
45
  import { failTaskTool } from "./task/fail.ts";
39
46
  import { listTasksTool } from "./task/list.ts";
40
47
  import { updateTaskTool } from "./task/update.ts";
@@ -54,6 +61,7 @@ export function registerAllTools(): void {
54
61
  registerTool(waitTaskTool);
55
62
  registerTool(createTaskTool);
56
63
  registerTool(updateTaskTool);
64
+ registerTool(deleteTaskTool);
57
65
  registerTool(listTasksTool);
58
66
  registerTool(viewTaskTool);
59
67
 
@@ -88,6 +96,13 @@ export function registerAllTools(): void {
88
96
  registerTool(searchGrepTool);
89
97
  registerTool(searchSemanticTool);
90
98
 
99
+ // Skill
100
+ registerTool(skillListTool);
101
+ registerTool(skillReadTool);
102
+ registerTool(skillWriteTool);
103
+ registerTool(skillEditTool);
104
+ registerTool(skillSearchTool);
105
+
91
106
  // Thread
92
107
  registerTool(listThreadsTool);
93
108
  registerTool(viewThreadTool);
@@ -0,0 +1,114 @@
1
+ import { join } from "node:path";
2
+ import { z } from "zod";
3
+ import { getSkillsDir } from "../../constants.ts";
4
+ import { parseSkillFile } from "../../skills/parser.ts";
5
+ import type { ToolDefinition } from "../tool.ts";
6
+
7
+ const PatchSchema = z.object({
8
+ start_line: z.number().describe("1-based inclusive start line"),
9
+ end_line: z
10
+ .number()
11
+ .describe("1-based inclusive end line (0 to insert without replacing)"),
12
+ content: z
13
+ .string()
14
+ .describe("Replacement text (empty string to delete lines)"),
15
+ });
16
+
17
+ const inputSchema = z.object({
18
+ name: z.string().describe("Skill name (case-insensitive)"),
19
+ patches: z.array(PatchSchema).describe("Patches to apply"),
20
+ });
21
+
22
+ const outputSchema = z.object({
23
+ name: z.string(),
24
+ path: z.string().nullable(),
25
+ applied: z.number(),
26
+ content: z.string(),
27
+ is_error: z.boolean(),
28
+ error_type: z.string().optional(),
29
+ message: z.string().optional(),
30
+ next_action_hint: z.string().optional(),
31
+ });
32
+
33
+ function applyPatches(
34
+ raw: string,
35
+ patches: Array<{ start_line: number; end_line: number; content: string }>,
36
+ ): string {
37
+ const lines = raw.split("\n");
38
+ const sorted = [...patches].sort((a, b) => b.start_line - a.start_line);
39
+
40
+ for (const patch of sorted) {
41
+ if (patch.end_line === 0) {
42
+ const insertLines = patch.content === "" ? [] : patch.content.split("\n");
43
+ lines.splice(patch.start_line - 1, 0, ...insertLines);
44
+ } else {
45
+ const deleteCount = patch.end_line - patch.start_line + 1;
46
+ const insertLines = patch.content === "" ? [] : patch.content.split("\n");
47
+ lines.splice(patch.start_line - 1, deleteCount, ...insertLines);
48
+ }
49
+ }
50
+
51
+ return lines.join("\n");
52
+ }
53
+
54
+ export const skillEditTool = {
55
+ name: "skill_edit",
56
+ description:
57
+ "[[ bash equivalent command: patch ]] Apply git-style line-range patches to a skill file (user-defined slash command). Operates on the whole file (frontmatter + body). Patches whose result would not parse as a valid skill are rejected without writing. Use skill_read first to inspect current line numbers.",
58
+ group: "skill",
59
+ inputSchema,
60
+ outputSchema,
61
+ execute: async (input, ctx) => {
62
+ const normalized = input.name.toLowerCase();
63
+ const filePath = join(getSkillsDir(ctx.projectDir), `${normalized}.md`);
64
+
65
+ const file = Bun.file(filePath);
66
+ if (!(await file.exists())) {
67
+ return {
68
+ name: normalized,
69
+ path: null,
70
+ applied: 0,
71
+ content: "",
72
+ is_error: true,
73
+ error_type: "not_found",
74
+ message: `Skill not found: ${input.name}`,
75
+ next_action_hint:
76
+ "Use skill_list to see available skills, or skill_write to create one.",
77
+ };
78
+ }
79
+
80
+ const original = await file.text();
81
+ const updated = applyPatches(original, input.patches);
82
+
83
+ try {
84
+ const parsed = parseSkillFile(updated, filePath);
85
+ if (parsed.name !== normalized) {
86
+ throw new Error(
87
+ `frontmatter name '${parsed.name}' no longer matches filename '${normalized}'`,
88
+ );
89
+ }
90
+ } catch (err) {
91
+ return {
92
+ name: normalized,
93
+ path: filePath,
94
+ applied: 0,
95
+ content: original,
96
+ is_error: true,
97
+ error_type: "invalid_skill",
98
+ message: `Patched content failed validation: ${err instanceof Error ? err.message : String(err)}`,
99
+ next_action_hint:
100
+ "Check that frontmatter YAML stays valid and the file still has a name/description.",
101
+ };
102
+ }
103
+
104
+ await Bun.write(filePath, updated);
105
+
106
+ return {
107
+ name: normalized,
108
+ path: filePath,
109
+ applied: input.patches.length,
110
+ content: updated,
111
+ is_error: false,
112
+ };
113
+ },
114
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -0,0 +1,63 @@
1
+ import { basename } from "node:path";
2
+ import { z } from "zod";
3
+ import { loadSkills } from "../../skills/loader.ts";
4
+ import type { ToolDefinition } from "../tool.ts";
5
+
6
+ const inputSchema = z.object({
7
+ limit: z
8
+ .number()
9
+ .optional()
10
+ .default(100)
11
+ .describe("Max number of skills to return (default 100)"),
12
+ offset: z
13
+ .number()
14
+ .optional()
15
+ .default(0)
16
+ .describe("Skip the first N skills (default 0)"),
17
+ });
18
+
19
+ const outputSchema = z.object({
20
+ skills: z.array(
21
+ z.object({
22
+ name: z.string(),
23
+ description: z.string(),
24
+ arguments: z.array(z.string()),
25
+ filename: z.string(),
26
+ path: z.string(),
27
+ }),
28
+ ),
29
+ total: z.number(),
30
+ is_error: z.boolean(),
31
+ });
32
+
33
+ export const skillListTool = {
34
+ name: "skill_list",
35
+ description:
36
+ "[[ bash equivalent command: ls ]] List skills (user-defined slash commands) loaded from .botholomew/skills/. Returns name, description, argument names, and file path for each.",
37
+ group: "skill",
38
+ inputSchema,
39
+ outputSchema,
40
+ execute: async (input, ctx) => {
41
+ const skills = await loadSkills(ctx.projectDir);
42
+ const sorted = [...skills.values()].sort((a, b) =>
43
+ a.name.localeCompare(b.name),
44
+ );
45
+
46
+ const total = sorted.length;
47
+ const offset = input.offset ?? 0;
48
+ const limit = input.limit ?? 100;
49
+ const page = sorted.slice(offset, offset + limit);
50
+
51
+ return {
52
+ skills: page.map((s) => ({
53
+ name: s.name,
54
+ description: s.description,
55
+ arguments: s.arguments.map((a) => a.name),
56
+ filename: basename(s.filePath),
57
+ path: s.filePath,
58
+ })),
59
+ total,
60
+ is_error: false,
61
+ };
62
+ },
63
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -0,0 +1,72 @@
1
+ import { z } from "zod";
2
+ import { loadSkills } from "../../skills/loader.ts";
3
+ import type { ToolDefinition } from "../tool.ts";
4
+
5
+ const inputSchema = z.object({
6
+ name: z.string().describe("Skill name (case-insensitive)"),
7
+ });
8
+
9
+ const ArgSchema = z.object({
10
+ name: z.string(),
11
+ description: z.string(),
12
+ required: z.boolean(),
13
+ default: z.string().optional(),
14
+ });
15
+
16
+ const outputSchema = z.object({
17
+ name: z.string(),
18
+ path: z.string().nullable(),
19
+ raw: z.string().nullable(),
20
+ description: z.string(),
21
+ arguments: z.array(ArgSchema),
22
+ body: z.string(),
23
+ is_error: z.boolean(),
24
+ error_type: z.string().optional(),
25
+ message: z.string().optional(),
26
+ next_action_hint: z.string().optional(),
27
+ });
28
+
29
+ export const skillReadTool = {
30
+ name: "skill_read",
31
+ description:
32
+ "[[ bash equivalent command: cat ]] Read a skill file (user-defined slash command) by name. Returns the raw file contents plus parsed fields. Returns a not_found error with the list of available names when the skill doesn't exist.",
33
+ group: "skill",
34
+ inputSchema,
35
+ outputSchema,
36
+ execute: async (input, ctx) => {
37
+ const skills = await loadSkills(ctx.projectDir);
38
+ const skill = skills.get(input.name.toLowerCase());
39
+
40
+ if (!skill) {
41
+ const available = [...skills.keys()].sort();
42
+ const hint =
43
+ available.length > 0
44
+ ? `Available: ${available.join(", ")}. Use skill_list to browse.`
45
+ : "No skills exist yet. Use skill_write to create one.";
46
+ return {
47
+ name: input.name,
48
+ path: null,
49
+ raw: null,
50
+ description: "",
51
+ arguments: [],
52
+ body: "",
53
+ is_error: true,
54
+ error_type: "not_found",
55
+ message: `Skill not found: ${input.name}`,
56
+ next_action_hint: hint,
57
+ };
58
+ }
59
+
60
+ const raw = await Bun.file(skill.filePath).text();
61
+
62
+ return {
63
+ name: skill.name,
64
+ path: skill.filePath,
65
+ raw,
66
+ description: skill.description,
67
+ arguments: skill.arguments,
68
+ body: skill.body,
69
+ is_error: false,
70
+ };
71
+ },
72
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -0,0 +1,165 @@
1
+ import { z } from "zod";
2
+ import { loadSkills } from "../../skills/loader.ts";
3
+ import type { SkillDefinition } from "../../skills/parser.ts";
4
+ import type { ToolDefinition } from "../tool.ts";
5
+
6
+ const inputSchema = z.object({
7
+ query: z
8
+ .string()
9
+ .describe(
10
+ "Search query (matched against name, description, body, and arg metadata)",
11
+ ),
12
+ top_k: z
13
+ .number()
14
+ .optional()
15
+ .default(10)
16
+ .describe("Maximum number of results to return (default 10)"),
17
+ });
18
+
19
+ const outputSchema = z.object({
20
+ results: z.array(
21
+ z.object({
22
+ name: z.string(),
23
+ description: z.string(),
24
+ score: z.number(),
25
+ match_fields: z.array(z.string()),
26
+ snippet: z.string(),
27
+ }),
28
+ ),
29
+ is_error: z.boolean(),
30
+ hint: z.string().optional(),
31
+ });
32
+
33
+ const SNIPPET_RADIUS = 60;
34
+
35
+ function countOccurrences(haystack: string, needle: string): number {
36
+ if (needle === "") return 0;
37
+ let count = 0;
38
+ let from = 0;
39
+ while (true) {
40
+ const idx = haystack.indexOf(needle, from);
41
+ if (idx === -1) break;
42
+ count++;
43
+ from = idx + needle.length;
44
+ }
45
+ return count;
46
+ }
47
+
48
+ function buildSnippet(body: string, term: string): string {
49
+ const lower = body.toLowerCase();
50
+ const idx = lower.indexOf(term);
51
+ if (idx === -1) return body.slice(0, SNIPPET_RADIUS * 2);
52
+
53
+ const start = Math.max(0, idx - SNIPPET_RADIUS);
54
+ const end = Math.min(body.length, idx + term.length + SNIPPET_RADIUS);
55
+ const prefix = start > 0 ? "…" : "";
56
+ const suffix = end < body.length ? "…" : "";
57
+ return prefix + body.slice(start, end) + suffix;
58
+ }
59
+
60
+ interface ScoreEntry {
61
+ skill: SkillDefinition;
62
+ score: number;
63
+ matchFields: Set<string>;
64
+ firstBodyTerm: string | null;
65
+ }
66
+
67
+ function scoreSkill(skill: SkillDefinition, terms: string[]): ScoreEntry {
68
+ const name = skill.name.toLowerCase();
69
+ const desc = skill.description.toLowerCase();
70
+ const body = skill.body.toLowerCase();
71
+ const matchFields = new Set<string>();
72
+ let score = 0;
73
+ let firstBodyTerm: string | null = null;
74
+
75
+ for (const term of terms) {
76
+ if (term === "") continue;
77
+ if (name.includes(term)) {
78
+ score += 10;
79
+ matchFields.add("name");
80
+ }
81
+ if (desc.includes(term)) {
82
+ score += 5;
83
+ matchFields.add("description");
84
+ }
85
+ for (const arg of skill.arguments) {
86
+ if (arg.name.toLowerCase().includes(term)) {
87
+ score += 3;
88
+ matchFields.add("argument_name");
89
+ }
90
+ if (arg.description.toLowerCase().includes(term)) {
91
+ score += 2;
92
+ matchFields.add("argument_description");
93
+ }
94
+ }
95
+ const bodyHits = Math.min(countOccurrences(body, term), 5);
96
+ if (bodyHits > 0) {
97
+ score += bodyHits;
98
+ matchFields.add("body");
99
+ if (firstBodyTerm === null) firstBodyTerm = term;
100
+ }
101
+ }
102
+
103
+ return { skill, score, matchFields, firstBodyTerm };
104
+ }
105
+
106
+ export const skillSearchTool = {
107
+ name: "skill_search",
108
+ description:
109
+ "Keyword search over skills (user-defined slash commands). Matches against name, description, body, and argument metadata. Returns top-K ranked matches. Use skill_read after finding a match.",
110
+ group: "skill",
111
+ inputSchema,
112
+ outputSchema,
113
+ execute: async (input, ctx) => {
114
+ const skills = await loadSkills(ctx.projectDir);
115
+ const terms = input.query
116
+ .toLowerCase()
117
+ .split(/\s+/)
118
+ .filter((t) => t.length > 0);
119
+
120
+ if (skills.size === 0) {
121
+ return {
122
+ results: [],
123
+ is_error: false,
124
+ hint: "No skills exist yet. Use skill_write to create one.",
125
+ };
126
+ }
127
+
128
+ if (terms.length === 0) {
129
+ return {
130
+ results: [],
131
+ is_error: false,
132
+ hint: "Empty query. Provide one or more keywords, or use skill_list to browse.",
133
+ };
134
+ }
135
+
136
+ const scored = [...skills.values()].map((s) => scoreSkill(s, terms));
137
+ const matched = scored
138
+ .filter((e) => e.score > 0)
139
+ .sort((a, b) => b.score - a.score)
140
+ .slice(0, input.top_k ?? 10);
141
+
142
+ if (matched.length === 0) {
143
+ return {
144
+ results: [],
145
+ is_error: false,
146
+ hint: "No matches. Try broader terms, or use skill_list to browse.",
147
+ };
148
+ }
149
+
150
+ const fallbackTerm = terms[0] ?? "";
151
+ return {
152
+ results: matched.map((e) => {
153
+ const snippetTerm = e.firstBodyTerm ?? fallbackTerm;
154
+ return {
155
+ name: e.skill.name,
156
+ description: e.skill.description,
157
+ score: e.score,
158
+ match_fields: [...e.matchFields].sort(),
159
+ snippet: buildSnippet(e.skill.body, snippetTerm),
160
+ };
161
+ }),
162
+ is_error: false,
163
+ };
164
+ },
165
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -0,0 +1,180 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { getSkillsDir } from "../../constants.ts";
5
+ import { parseSkillFile } from "../../skills/parser.ts";
6
+ import {
7
+ buildSkillFileContent,
8
+ validateSkillName,
9
+ } from "../../skills/writer.ts";
10
+ import type { ToolDefinition } from "../tool.ts";
11
+
12
+ const ArgInputSchema = z.object({
13
+ name: z
14
+ .string()
15
+ .min(1)
16
+ .describe("Argument name (referenced as $1, $2, … in the body)"),
17
+ description: z.string().optional().describe("Argument description"),
18
+ required: z
19
+ .boolean()
20
+ .optional()
21
+ .default(false)
22
+ .describe("Whether the argument is required"),
23
+ default: z
24
+ .string()
25
+ .optional()
26
+ .describe("Default value when the argument is omitted"),
27
+ });
28
+
29
+ const inputSchema = z.object({
30
+ name: z
31
+ .string()
32
+ .describe(
33
+ "Skill name (slash-command identifier). Will be normalized to lowercase + [a-z0-9-]. Reserved: help, skills, clear, exit.",
34
+ ),
35
+ description: z
36
+ .string()
37
+ .describe("Short description shown in /skills and /help"),
38
+ body: z
39
+ .string()
40
+ .describe(
41
+ "Prompt-template body (markdown). Use $ARGUMENTS or $1..$9 for argument substitution.",
42
+ ),
43
+ arguments: z
44
+ .array(ArgInputSchema)
45
+ .optional()
46
+ .describe("Argument definitions (positional)"),
47
+ on_conflict: z
48
+ .enum(["error", "overwrite"])
49
+ .optional()
50
+ .default("error")
51
+ .describe(
52
+ "What to do if a skill with this name already exists. Defaults to 'error'.",
53
+ ),
54
+ });
55
+
56
+ const outputSchema = z.object({
57
+ name: z.string().nullable(),
58
+ path: z.string().nullable(),
59
+ ref: z.string().nullable(),
60
+ created: z.boolean(),
61
+ is_error: z.boolean(),
62
+ error_type: z.string().optional(),
63
+ message: z.string().optional(),
64
+ next_action_hint: z.string().optional(),
65
+ });
66
+
67
+ export const skillWriteTool = {
68
+ name: "skill_write",
69
+ description:
70
+ "[[ bash equivalent command: tee ]] Create or overwrite a skill file (user-defined slash command) at .botholomew/skills/<name>.md. Fails with path_conflict when the file exists unless on_conflict='overwrite'. Reserved names (help, skills, clear, exit) are rejected. The generated file is parsed to validate before being written.",
71
+ group: "skill",
72
+ inputSchema,
73
+ outputSchema,
74
+ execute: async (input, ctx) => {
75
+ const nameCheck = validateSkillName(input.name);
76
+ if (!nameCheck.ok) {
77
+ const errorType =
78
+ nameCheck.reason === "reserved" ? "reserved_name" : "invalid_name";
79
+ const message =
80
+ nameCheck.reason === "reserved"
81
+ ? `'${input.name}' is reserved by a built-in slash command (help, skills, clear, exit).`
82
+ : nameCheck.reason === "too_long"
83
+ ? `Skill name too long (max 64 chars after normalization).`
84
+ : `'${input.name}' is not a valid skill name. After normalization (lowercase, [a-z0-9-], trimmed hyphens) it is empty.`;
85
+ return {
86
+ name: null,
87
+ path: null,
88
+ ref: null,
89
+ created: false,
90
+ is_error: true,
91
+ error_type: errorType,
92
+ message,
93
+ next_action_hint:
94
+ "Pick a different name made of lowercase letters, digits, and hyphens.",
95
+ };
96
+ }
97
+
98
+ const normalized = nameCheck.normalized;
99
+ const body = input.body.trim();
100
+ if (body === "") {
101
+ return {
102
+ name: normalized,
103
+ path: null,
104
+ ref: null,
105
+ created: false,
106
+ is_error: true,
107
+ error_type: "empty_body",
108
+ message: "Skill body is empty.",
109
+ next_action_hint:
110
+ "Provide a non-empty prompt template using $ARGUMENTS or $1..$9.",
111
+ };
112
+ }
113
+
114
+ const args = (input.arguments ?? []).map((a) => ({
115
+ name: a.name,
116
+ description: a.description ?? "",
117
+ required: a.required ?? false,
118
+ default: a.default,
119
+ }));
120
+
121
+ const skillsDir = getSkillsDir(ctx.projectDir);
122
+ const filePath = join(skillsDir, `${normalized}.md`);
123
+
124
+ const raw = buildSkillFileContent({
125
+ name: normalized,
126
+ description: input.description,
127
+ arguments: args,
128
+ body,
129
+ });
130
+
131
+ try {
132
+ const parsed = parseSkillFile(raw, filePath);
133
+ if (parsed.name !== normalized) {
134
+ throw new Error(
135
+ `frontmatter name '${parsed.name}' does not match expected '${normalized}'`,
136
+ );
137
+ }
138
+ } catch (err) {
139
+ return {
140
+ name: normalized,
141
+ path: null,
142
+ ref: null,
143
+ created: false,
144
+ is_error: true,
145
+ error_type: "invalid_skill",
146
+ message: `Generated skill content failed validation: ${err instanceof Error ? err.message : String(err)}`,
147
+ next_action_hint:
148
+ "Check description and body for unusual characters that break YAML.",
149
+ };
150
+ }
151
+
152
+ const onConflict = input.on_conflict ?? "error";
153
+ const existed = await Bun.file(filePath).exists();
154
+
155
+ if (existed && onConflict === "error") {
156
+ return {
157
+ name: normalized,
158
+ path: filePath,
159
+ ref: `skill:${normalized}`,
160
+ created: false,
161
+ is_error: true,
162
+ error_type: "path_conflict",
163
+ message: `Skill '${normalized}' already exists at ${filePath}.`,
164
+ next_action_hint:
165
+ "Retry with on_conflict='overwrite' to replace, or use skill_edit for a partial change.",
166
+ };
167
+ }
168
+
169
+ await mkdir(skillsDir, { recursive: true });
170
+ await Bun.write(filePath, raw);
171
+
172
+ return {
173
+ name: normalized,
174
+ path: filePath,
175
+ ref: `skill:${normalized}`,
176
+ created: !existed,
177
+ is_error: false,
178
+ };
179
+ },
180
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -0,0 +1,54 @@
1
+ import { z } from "zod";
2
+ import { deleteTask, getTask } 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
+ id: z.string().describe("ID of the task to delete"),
8
+ });
9
+
10
+ const outputSchema = z.object({
11
+ deleted_id: z.string().nullable(),
12
+ message: z.string(),
13
+ is_error: z.boolean(),
14
+ });
15
+
16
+ export const deleteTaskTool = {
17
+ name: "delete_task",
18
+ description:
19
+ "[[ bash equivalent command: rm ]] Delete a task permanently. Refuses in_progress tasks; wait for the worker to finish or run `botholomew task reset <id>` first.",
20
+ group: "task",
21
+ inputSchema,
22
+ outputSchema,
23
+ execute: async (input, ctx) => {
24
+ const existing = await getTask(ctx.conn, input.id);
25
+ if (!existing) {
26
+ return {
27
+ deleted_id: null,
28
+ message: `Task ${input.id} not found`,
29
+ is_error: true,
30
+ };
31
+ }
32
+ if (existing.status === "in_progress") {
33
+ return {
34
+ deleted_id: null,
35
+ message: `Cannot delete task ${input.id}: it is currently in_progress (claimed by ${existing.claimed_by ?? "unknown"}). Wait for the worker to finish, or reset it first via \`botholomew task reset ${input.id}\`.`,
36
+ is_error: true,
37
+ };
38
+ }
39
+ const ok = await deleteTask(ctx.conn, input.id);
40
+ if (!ok) {
41
+ return {
42
+ deleted_id: null,
43
+ message: `Failed to delete task ${input.id}`,
44
+ is_error: true,
45
+ };
46
+ }
47
+ logger.info(`Deleted task: ${existing.name} (${existing.id})`);
48
+ return {
49
+ deleted_id: existing.id,
50
+ message: `Deleted task "${existing.name}" (${existing.id})`,
51
+ is_error: false,
52
+ };
53
+ },
54
+ } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
package/src/tui/App.tsx CHANGED
@@ -133,6 +133,10 @@ export function App({
133
133
  const [isLoading, setIsLoading] = useState(false);
134
134
  const [streamingText, setStreamingText] = useState("");
135
135
  const [activeToolCalls, setActiveToolCalls] = useState<ToolCallData[]>([]);
136
+ const [preparingTool, setPreparingTool] = useState<{
137
+ id: string;
138
+ name: string;
139
+ } | null>(null);
136
140
  const [ready, setReady] = useState(false);
137
141
  const skipSplash = !!(resumeThreadId || initialPrompt);
138
142
  const [splashDone, setSplashDone] = useState(skipSplash);
@@ -329,6 +333,7 @@ export function App({
329
333
  setIsLoading(true);
330
334
  setStreamingText("");
331
335
  setActiveToolCalls([]);
336
+ setPreparingTool(null);
332
337
 
333
338
  const userMsg: ChatMessage = {
334
339
  id: msgId(),
@@ -370,6 +375,9 @@ export function App({
370
375
  lastStreamFlush = now;
371
376
  }
372
377
  },
378
+ onToolPreparing: (id, name) => {
379
+ setPreparingTool({ id, name });
380
+ },
373
381
  onToolStart: (id, name, input) => {
374
382
  if (currentText) {
375
383
  finalizeSegment();
@@ -383,6 +391,7 @@ export function App({
383
391
  };
384
392
  pendingToolCalls.push(tc);
385
393
  setActiveToolCalls([...pendingToolCalls]);
394
+ setPreparingTool(null);
386
395
  },
387
396
  onToolEnd: (id, _name, output, isError, meta) => {
388
397
  const tc = pendingToolCalls.find((t) => t.id === id);
@@ -410,6 +419,7 @@ export function App({
410
419
  } finally {
411
420
  setStreamingText("");
412
421
  setActiveToolCalls([]);
422
+ setPreparingTool(null);
413
423
  }
414
424
  }
415
425
 
@@ -700,6 +710,7 @@ export function App({
700
710
  streamingText={streamingText}
701
711
  isLoading={isLoading}
702
712
  activeToolCalls={activeToolCalls}
713
+ preparingTool={preparingTool}
703
714
  />
704
715
  </Box>
705
716
  <Box
@@ -17,6 +17,7 @@ interface MessageListProps {
17
17
  streamingText: string;
18
18
  isLoading: boolean;
19
19
  activeToolCalls: ToolCallData[];
20
+ preparingTool: { id: string; name: string } | null;
20
21
  }
21
22
 
22
23
  function formatTime(date: Date): string {
@@ -127,6 +128,7 @@ export function MessageList({
127
128
  streamingText,
128
129
  isLoading,
129
130
  activeToolCalls,
131
+ preparingTool,
130
132
  }: MessageListProps) {
131
133
  return (
132
134
  <>
@@ -160,7 +162,17 @@ export function MessageList({
160
162
  </Box>
161
163
  )}
162
164
 
165
+ {preparingTool && (
166
+ <Box marginTop={1}>
167
+ <Text color={theme.accent}>
168
+ <Spinner type="dots" />
169
+ </Text>
170
+ <Text dimColor> Preparing tool call: {preparingTool.name}...</Text>
171
+ </Box>
172
+ )}
173
+
163
174
  {isLoading &&
175
+ !preparingTool &&
164
176
  !streamingText &&
165
177
  (activeToolCalls.length === 0 ||
166
178
  activeToolCalls.every((tc) => !tc.running)) && (
@@ -144,9 +144,22 @@ class FakeMessageStream extends EventEmitter {
144
144
  if (delay > 0) await new Promise((r) => setTimeout(r, delay));
145
145
  }
146
146
  const final = buildFinalMessage(text, this.turn.toolCalls);
147
+ let blockIndex = text ? 1 : 0;
147
148
  for (const block of final.content) {
148
149
  if ((block as { type: string }).type === "tool_use") {
149
- this.emit("contentBlock", block as ToolUseBlock);
150
+ const toolUse = block as ToolUseBlock;
151
+ this.emit("streamEvent", {
152
+ type: "content_block_start",
153
+ index: blockIndex,
154
+ content_block: {
155
+ type: "tool_use",
156
+ id: toolUse.id,
157
+ name: toolUse.name,
158
+ input: {},
159
+ },
160
+ });
161
+ this.emit("contentBlock", toolUse);
162
+ blockIndex++;
150
163
  }
151
164
  }
152
165
  this.resolveFinal(final);