botholomew 0.9.7 → 0.9.8
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 +6 -3
- package/package.json +1 -1
- package/src/chat/agent.ts +7 -0
- package/src/chat/session.ts +4 -0
- package/src/skills/writer.ts +63 -0
- package/src/tools/registry.ts +15 -0
- package/src/tools/skill/edit.ts +114 -0
- package/src/tools/skill/list.ts +63 -0
- package/src/tools/skill/read.ts +72 -0
- package/src/tools/skill/search.ts +165 -0
- package/src/tools/skill/write.ts +180 -0
- package/src/tools/task/delete.ts +54 -0
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
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
|
|
package/src/chat/session.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/tools/registry.ts
CHANGED
|
@@ -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>;
|