|
1 | 1 | import path from "path" |
2 | 2 | import { pathToFileURL } from "url" |
3 | 3 | import z from "zod" |
| 4 | +import { Effect } from "effect" |
| 5 | +import * as Stream from "effect/Stream" |
4 | 6 | import { Tool } from "./tool" |
5 | 7 | import { Skill } from "../skill" |
6 | 8 | import { Ripgrep } from "../file/ripgrep" |
7 | | -import { iife } from "@/util/iife" |
8 | 9 |
|
9 | 10 | const Parameters = z.object({ |
10 | 11 | name: z.string().describe("The name of the skill from available_skills"), |
11 | 12 | }) |
12 | 13 |
|
13 | | -export const SkillTool = Tool.define("skill", async () => { |
14 | | - const list = await Skill.available() |
| 14 | +export const SkillTool = Tool.defineEffect( |
| 15 | + "skill", |
| 16 | + Effect.gen(function* () { |
| 17 | + const skill = yield* Skill.Service |
| 18 | + const rg = yield* Ripgrep.Service |
15 | 19 |
|
16 | | - const description = |
17 | | - list.length === 0 |
18 | | - ? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available." |
19 | | - : [ |
20 | | - "Load a specialized skill that provides domain-specific instructions and workflows.", |
21 | | - "", |
22 | | - "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.", |
23 | | - "", |
24 | | - "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.", |
25 | | - "", |
26 | | - 'Tool output includes a `<skill_content name="...">` block with the loaded content.', |
27 | | - "", |
28 | | - "The following skills provide specialized sets of instructions for particular tasks", |
29 | | - "Invoke this tool to load a skill when a task matches one of the available skills listed below:", |
30 | | - "", |
31 | | - Skill.fmt(list, { verbose: false }), |
32 | | - ].join("\n") |
| 20 | + return async () => { |
| 21 | + const list = await Effect.runPromise(skill.available()) |
33 | 22 |
|
34 | | - return { |
35 | | - description, |
36 | | - parameters: Parameters, |
37 | | - async execute(params: z.infer<typeof Parameters>, ctx) { |
38 | | - const skill = await Skill.get(params.name) |
| 23 | + const description = |
| 24 | + list.length === 0 |
| 25 | + ? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available." |
| 26 | + : [ |
| 27 | + "Load a specialized skill that provides domain-specific instructions and workflows.", |
| 28 | + "", |
| 29 | + "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.", |
| 30 | + "", |
| 31 | + "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.", |
| 32 | + "", |
| 33 | + 'Tool output includes a `<skill_content name="...">` block with the loaded content.', |
| 34 | + "", |
| 35 | + "The following skills provide specialized sets of instructions for particular tasks", |
| 36 | + "Invoke this tool to load a skill when a task matches one of the available skills listed below:", |
| 37 | + "", |
| 38 | + Skill.fmt(list, { verbose: false }), |
| 39 | + ].join("\n") |
39 | 40 |
|
40 | | - if (!skill) { |
41 | | - const available = await Skill.all().then((x) => x.map((skill) => skill.name).join(", ")) |
42 | | - throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) |
43 | | - } |
| 41 | + return { |
| 42 | + description, |
| 43 | + parameters: Parameters, |
| 44 | + execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) => |
| 45 | + Effect.gen(function* () { |
| 46 | + const info = yield* skill.get(params.name) |
44 | 47 |
|
45 | | - await ctx.ask({ |
46 | | - permission: "skill", |
47 | | - patterns: [params.name], |
48 | | - always: [params.name], |
49 | | - metadata: {}, |
50 | | - }) |
| 48 | + if (!info) { |
| 49 | + const all = yield* skill.all() |
| 50 | + const available = all.map((s) => s.name).join(", ") |
| 51 | + throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) |
| 52 | + } |
51 | 53 |
|
52 | | - const dir = path.dirname(skill.location) |
53 | | - const base = pathToFileURL(dir).href |
| 54 | + yield* Effect.promise(() => |
| 55 | + ctx.ask({ |
| 56 | + permission: "skill", |
| 57 | + patterns: [params.name], |
| 58 | + always: [params.name], |
| 59 | + metadata: {}, |
| 60 | + }), |
| 61 | + ) |
54 | 62 |
|
55 | | - const limit = 10 |
56 | | - const files = await iife(async () => { |
57 | | - const arr = [] |
58 | | - for await (const file of Ripgrep.files({ |
59 | | - cwd: dir, |
60 | | - follow: false, |
61 | | - hidden: true, |
62 | | - signal: ctx.abort, |
63 | | - })) { |
64 | | - if (file.includes("SKILL.md")) { |
65 | | - continue |
66 | | - } |
67 | | - arr.push(path.resolve(dir, file)) |
68 | | - if (arr.length >= limit) { |
69 | | - break |
70 | | - } |
71 | | - } |
72 | | - return arr |
73 | | - }).then((f) => f.map((file) => `<file>${file}</file>`).join("\n")) |
| 63 | + const dir = path.dirname(info.location) |
| 64 | + const base = pathToFileURL(dir).href |
74 | 65 |
|
75 | | - return { |
76 | | - title: `Loaded skill: ${skill.name}`, |
77 | | - output: [ |
78 | | - `<skill_content name="${skill.name}">`, |
79 | | - `# Skill: ${skill.name}`, |
80 | | - "", |
81 | | - skill.content.trim(), |
82 | | - "", |
83 | | - `Base directory for this skill: ${base}`, |
84 | | - "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", |
85 | | - "Note: file list is sampled.", |
86 | | - "", |
87 | | - "<skill_files>", |
88 | | - files, |
89 | | - "</skill_files>", |
90 | | - "</skill_content>", |
91 | | - ].join("\n"), |
92 | | - metadata: { |
93 | | - name: skill.name, |
94 | | - dir, |
95 | | - }, |
| 66 | + const limit = 10 |
| 67 | + const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe( |
| 68 | + Stream.filter((file) => !file.includes("SKILL.md")), |
| 69 | + Stream.map((file) => path.resolve(dir, file)), |
| 70 | + Stream.take(limit), |
| 71 | + Stream.runCollect, |
| 72 | + Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")), |
| 73 | + ) |
| 74 | + |
| 75 | + return { |
| 76 | + title: `Loaded skill: ${info.name}`, |
| 77 | + output: [ |
| 78 | + `<skill_content name="${info.name}">`, |
| 79 | + `# Skill: ${info.name}`, |
| 80 | + "", |
| 81 | + info.content.trim(), |
| 82 | + "", |
| 83 | + `Base directory for this skill: ${base}`, |
| 84 | + "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", |
| 85 | + "Note: file list is sampled.", |
| 86 | + "", |
| 87 | + "<skill_files>", |
| 88 | + files, |
| 89 | + "</skill_files>", |
| 90 | + "</skill_content>", |
| 91 | + ].join("\n"), |
| 92 | + metadata: { |
| 93 | + name: info.name, |
| 94 | + dir, |
| 95 | + }, |
| 96 | + } |
| 97 | + }).pipe(Effect.orDie, Effect.runPromise), |
96 | 98 | } |
97 | | - }, |
98 | | - } |
99 | | -}) |
| 99 | + } |
| 100 | + }), |
| 101 | +) |
0 commit comments