diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index a24ddb28c4..f6324b3d76 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -116,6 +116,7 @@ export namespace ToolRegistry { const edit = yield* EditTool const greptool = yield* GrepTool const patchtool = yield* ApplyPatchTool + const skilltool = yield* SkillTool const state = yield* InstanceState.make( Effect.fn("ToolRegistry.state")(function* (ctx) { @@ -185,7 +186,7 @@ export namespace ToolRegistry { todo: Tool.init(todo), search: Tool.init(websearch), code: Tool.init(codesearch), - skill: Tool.init(SkillTool), + skill: Tool.init(skilltool), patch: Tool.init(patchtool), question: Tool.init(question), lsp: Tool.init(lsptool), diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index e0777d00f7..f53f4e2bca 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -1,99 +1,101 @@ import path from "path" import { pathToFileURL } from "url" import z from "zod" +import { Effect } from "effect" +import * as Stream from "effect/Stream" import { Tool } from "./tool" import { Skill } from "../skill" import { Ripgrep } from "../file/ripgrep" -import { iife } from "@/util/iife" const Parameters = z.object({ name: z.string().describe("The name of the skill from available_skills"), }) -export const SkillTool = Tool.define("skill", async () => { - const list = await Skill.available() +export const SkillTool = Tool.defineEffect( + "skill", + Effect.gen(function* () { + const skill = yield* Skill.Service + const rg = yield* Ripgrep.Service - const description = - list.length === 0 - ? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available." - : [ - "Load a specialized skill that provides domain-specific instructions and workflows.", - "", - "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.", - "", - "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.", - "", - 'Tool output includes a `` block with the loaded content.', - "", - "The following skills provide specialized sets of instructions for particular tasks", - "Invoke this tool to load a skill when a task matches one of the available skills listed below:", - "", - Skill.fmt(list, { verbose: false }), - ].join("\n") + return async () => { + const list = await Effect.runPromise(skill.available()) - return { - description, - parameters: Parameters, - async execute(params: z.infer, ctx) { - const skill = await Skill.get(params.name) - - if (!skill) { - const available = await Skill.all().then((x) => x.map((skill) => skill.name).join(", ")) - throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) - } - - await ctx.ask({ - permission: "skill", - patterns: [params.name], - always: [params.name], - metadata: {}, - }) - - const dir = path.dirname(skill.location) - const base = pathToFileURL(dir).href - - const limit = 10 - const files = await iife(async () => { - const arr = [] - for await (const file of Ripgrep.files({ - cwd: dir, - follow: false, - hidden: true, - signal: ctx.abort, - })) { - if (file.includes("SKILL.md")) { - continue - } - arr.push(path.resolve(dir, file)) - if (arr.length >= limit) { - break - } - } - return arr - }).then((f) => f.map((file) => `${file}`).join("\n")) + const description = + list.length === 0 + ? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available." + : [ + "Load a specialized skill that provides domain-specific instructions and workflows.", + "", + "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.", + "", + "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.", + "", + 'Tool output includes a `` block with the loaded content.', + "", + "The following skills provide specialized sets of instructions for particular tasks", + "Invoke this tool to load a skill when a task matches one of the available skills listed below:", + "", + Skill.fmt(list, { verbose: false }), + ].join("\n") return { - title: `Loaded skill: ${skill.name}`, - output: [ - ``, - `# Skill: ${skill.name}`, - "", - skill.content.trim(), - "", - `Base directory for this skill: ${base}`, - "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", - "Note: file list is sampled.", - "", - "", - files, - "", - "", - ].join("\n"), - metadata: { - name: skill.name, - dir, - }, + description, + parameters: Parameters, + execute: (params: z.infer, ctx: Tool.Context) => + Effect.gen(function* () { + const info = yield* skill.get(params.name) + + if (!info) { + const all = yield* skill.all() + const available = all.map((s) => s.name).join(", ") + throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) + } + + yield* Effect.promise(() => + ctx.ask({ + permission: "skill", + patterns: [params.name], + always: [params.name], + metadata: {}, + }), + ) + + const dir = path.dirname(info.location) + const base = pathToFileURL(dir).href + + const limit = 10 + const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe( + Stream.filter((file) => !file.includes("SKILL.md")), + Stream.map((file) => path.resolve(dir, file)), + Stream.take(limit), + Stream.runCollect, + Effect.map((chunk) => [...chunk].map((file) => `${file}`).join("\n")), + ) + + return { + title: `Loaded skill: ${info.name}`, + output: [ + ``, + `# Skill: ${info.name}`, + "", + info.content.trim(), + "", + `Base directory for this skill: ${base}`, + "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", + "Note: file list is sampled.", + "", + "", + files, + "", + "", + ].join("\n"), + metadata: { + name: info.name, + dir, + }, + } + }).pipe(Effect.orDie, Effect.runPromise), } - }, - } -}) + } + }), +) diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index ea9aeeaf9e..1c97ee4afc 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -1,4 +1,6 @@ -import { Effect } from "effect" +import { Effect, Layer, ManagedRuntime } from "effect" +import { Skill } from "../../src/skill" +import { Ripgrep } from "../../src/file/ripgrep" import { afterEach, describe, expect, test } from "bun:test" import path from "path" import { pathToFileURL } from "url" @@ -148,7 +150,9 @@ Use this skill. await Instance.provide({ directory: tmp.path, fn: async () => { - const tool = await SkillTool.init() + const runtime = ManagedRuntime.make(Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer)) + const info = await runtime.runPromise(SkillTool) + const tool = await info.init() const requests: Array> = [] const ctx: Tool.Context = { ...baseCtx,