diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 0a0e9fd59..d2d903687 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -43,6 +43,7 @@ "packages/core/src/question.ts": "11da591147ec49834ab9c1babea2dddbdcd3181d9bd7da0980eede5b87459411", "packages/core/src/runtime-state.ts": "3ef37e03f51b777f3c96ae12a3c855ef5e8a9f3342f697d70935e48bb7cdceaa", "packages/core/src/session.ts": "c4147b433b07f62439054ac3367c4780d203a08f9fa0fcd691e1be653e4794b6", + "packages/core/src/skill.ts": "72356ecd567ab4c90b4f3b7d54710d78d6cd577401c3a0154b0270487a1f54cb", "packages/core/test/database-migration.test.ts": "e5ffa9b36028a8bf31ae7d3eac7fe34fb0566a609325c8dc29e59021d5ee0b1a", "packages/core/test/location-mutation.test.ts": "2c4c8faa220cc8fabfe12d57bd74ec8dd8aac8485d0ecafd2761cdee1806d6b0", "packages/core/test/runtime-state.test.ts": "e532a5aa2d91a9b8bb8ca3b78a5d29644e5d43532c9de133d96e464729e28986", @@ -50,7 +51,9 @@ "packages/core/test/session-prompt.test.ts": "2af05be037473266c7b38721abbf7f865a4dfe35c9e779282cb7f697ba0a2adc", "packages/core/test/session-runner-recorded.test.ts": "962301989a4a315379819bc3d4600d56c3e646b1a9390d02d01556578fc66adc", "packages/core/test/session-runner.test.ts": "4cf0d4e3989a32b0c693473d4f94bf787bd964e79b312a7ee0032305503edb20", + "packages/core/test/skill.test.ts": "341bfbdc9b6a18b32f8370d0f13381bcec0c54ecf336465fe3b6b48b55fcfaad", "packages/core/test/tool-question.test.ts": "4df41393f15104a1d3a9c3d06e30cef515108490b41fcf4d9a7d9399fd4ed945", + "packages/core/test/tool-skill.test.ts": "84bb1900c7e44d03d1a1e3397e069b4df1251fb6a0c77e6384106e20f9aecfc9", "packages/llm/src/cache-policy.ts": "04c1fb5d3d061cfa95d04e54df0a1c2cdaafac921224242446eaa2968bff7857", "packages/llm/test/cache-policy.test.ts": "e921095c4301440c786105063439e49b2fedbaf6d46f30457e0a96388d268f4b", "packages/opencode/script/build.ts": "876e7d569aa71ce060c1d4b24c96e9bf13245cbf4ca743828d645560a95aa641", @@ -332,6 +335,7 @@ "packages/core/src/tool/grep.ts": "db323a91b27727abcee372bf5403f322458ce456c9b581d4f905ab241bea6dd6", "packages/core/src/tool/question.ts": "9b32d02d3d8e18adaeb86a2b344fff0a21fff1955fcacf91b4c79f93c93fd28f", "packages/core/src/tool/read.ts": "ceb37f787cc78a9973dec7c0f8c5fd647baeac979619741d0ff15167e13c6f29", + "packages/core/src/tool/skill.ts": "0845481fd45c3a40200a5e9ebeea4c5b8c1356c6400f9f79bb040c52380fe4a5", "packages/core/src/tool/webfetch.ts": "904846f51dbee2579a36c4856efbfe1442d4ce6a31d6a280cc387c390fd22bef", "packages/core/src/tool/websearch.ts": "bd0502eeb9d6deeea975a21a592040b5b48abd6f2613e39dd6cfbd1a959c950a", "packages/core/src/tool/write.ts": "4759d89e4b6cd5fa7d7de4ba091d2bf859d830cb15038828a15751c80fd9b0ec", @@ -382,6 +386,7 @@ "packages/opencode/src/session/system.ts": "b8708f3e63386e08041368e6dfa887f250bc6a7543ca396bb8b838041e8aeb19", "packages/opencode/src/session/turn-output.ts": "889624ca54a8149d35f200c7efa732771dfca36984e8a322a9781b1a25434762", "packages/opencode/src/session/user-mutation.ts": "8f8cebe1370a8a54361c546fb6777ee4fbdd47d0dec1ef870305ce5dbe232208", + "packages/opencode/src/skill/index.ts": "418b2baadddfa780e2f6964a3dc6f8907ecf63f86e076c175797e24a1b200981", "packages/opencode/src/snapshot/index.ts": "4d76a78c48597bda2379e2c3396f4a53828b21c6ce03e1975d2911150822c6de", "packages/opencode/src/tool/apply_patch.ts": "37535a783d115ac2032def7a28cce56e304f4e7c75d3ecac05b9a37054ae5b4f", "packages/opencode/src/tool/edit.ts": "38aa4ce0751ff99bbb0807bcda52f8b9a1d32b6e342df4cc2aedab1f03d342b1", @@ -1011,7 +1016,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-10-22T13:11:47.632Z", + "extracted_at": "2026-10-23T12:02:51.857Z", "per_package": { "packages/app": { "A": 254, @@ -1020,7 +1025,7 @@ }, "packages/core": { "A": 18, - "M": 47, + "M": 51, "D": 0 }, "packages/llm": { @@ -1030,7 +1035,7 @@ }, "packages/opencode": { "A": 98, - "M": 98, + "M": 99, "D": 0 }, "packages/schema": { @@ -1060,7 +1065,7 @@ } }, "counts": { - "overlay_total": 989, + "overlay_total": 994, "deletions": 7, "server_coupled": 0 }, @@ -1100,6 +1105,7 @@ "packages/core/src/question.ts": "M", "packages/core/src/runtime-state.ts": "A", "packages/core/src/session.ts": "M", + "packages/core/src/skill.ts": "M", "packages/core/test/database-migration.test.ts": "M", "packages/core/test/location-mutation.test.ts": "M", "packages/core/test/runtime-state.test.ts": "A", @@ -1107,7 +1113,9 @@ "packages/core/test/session-prompt.test.ts": "M", "packages/core/test/session-runner-recorded.test.ts": "M", "packages/core/test/session-runner.test.ts": "M", + "packages/core/test/skill.test.ts": "M", "packages/core/test/tool-question.test.ts": "M", + "packages/core/test/tool-skill.test.ts": "M", "packages/llm/src/cache-policy.ts": "M", "packages/llm/test/cache-policy.test.ts": "M", "packages/opencode/script/build.ts": "M", @@ -1389,6 +1397,7 @@ "packages/core/src/tool/grep.ts": "M", "packages/core/src/tool/question.ts": "M", "packages/core/src/tool/read.ts": "M", + "packages/core/src/tool/skill.ts": "M", "packages/core/src/tool/webfetch.ts": "M", "packages/core/src/tool/websearch.ts": "M", "packages/core/src/tool/write.ts": "M", @@ -1439,6 +1448,7 @@ "packages/opencode/src/session/system.ts": "M", "packages/opencode/src/session/turn-output.ts": "A", "packages/opencode/src/session/user-mutation.ts": "A", + "packages/opencode/src/skill/index.ts": "M", "packages/opencode/src/snapshot/index.ts": "M", "packages/opencode/src/tool/apply_patch.ts": "M", "packages/opencode/src/tool/edit.ts": "M", diff --git a/packages/app-bundle/overlay/packages/core/src/skill.ts b/packages/app-bundle/overlay/packages/core/src/skill.ts new file mode 100644 index 000000000..119ee81f7 --- /dev/null +++ b/packages/app-bundle/overlay/packages/core/src/skill.ts @@ -0,0 +1,177 @@ +export * as SkillV2 from "./skill" + +import path from "path" +import { Cause, Context, Effect, Layer, Schema, Stream, Types } from "effect" +import { Skill } from "@opencode-ai/schema/skill" +import { AgentV2 } from "./agent" +import { ConfigMarkdown } from "./config/markdown" +import { makeLocationNode } from "./effect/app-node" +import { FSUtil } from "./fs-util" +import { Watcher } from "./filesystem/watcher" +import { EventV2 } from "./event" +import { PermissionV2 } from "./permission" +import { AbsolutePath } from "./schema" +import { SkillDiscovery } from "./skill/discovery" +import { State } from "./state" + +export const DirectorySource = Skill.DirectorySource +export type DirectorySource = Skill.DirectorySource + +export const UrlSource = Skill.UrlSource +export type UrlSource = Skill.UrlSource + +export const EmbeddedSource = Skill.EmbeddedSource +export type EmbeddedSource = Skill.EmbeddedSource + +export const Source = Skill.Source +export type Source = typeof Source.Type + +export const Info = Skill.Info +export type Info = Skill.Info + +export const available = (skills: ReadonlyArray, agent: AgentV2.Info) => + skills.filter((skill) => PermissionV2.evaluate("skill", skill.name, agent.permissions).effect !== "deny") + +const Frontmatter = Schema.Struct({ + name: Schema.String.pipe(Schema.optional), + description: Schema.String.pipe(Schema.optional), + slash: Schema.Boolean.pipe(Schema.optional), +}) +const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter) + +export type Data = { + sources: Types.DeepMutable[] +} + +export type Draft = { + source: (source: Source) => void + list: () => readonly Source[] +} + +export interface Interface extends State.Transformable { + readonly sources: () => Effect.Effect + readonly list: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Skill") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const discovery = yield* SkillDiscovery.Service + const fs = yield* FSUtil.Service + const events = yield* EventV2.Service + + const state = State.create({ + initial: () => ({ sources: [] }), + draft: (draft) => ({ + source: (source) => { + if (draft.sources.some((item) => Source.equals(item, source))) return + draft.sources.push(source as Types.DeepMutable) + }, + list: () => draft.sources as Source[], + }), + }) + + const load = Effect.fn("SkillV2.load")(function* (source: Source) { + const skills: Info[] = [] + if (source.type === "embedded") return { skills: [source.skill], directories: [] as string[] } + const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url) + for (const directory of directories) { + const files = yield* fs + .glob("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true }) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + for (const filepath of files.toSorted()) { + const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!content) continue + const markdown = ConfigMarkdown.parseOption(content) + if (!markdown) continue + const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined + if (!frontmatter) continue + const name = + frontmatter.name !== undefined + ? frontmatter.name + : path.dirname(filepath) === directory + ? path.basename(filepath, ".md") + : undefined + if (!name) continue + skills.push({ + name, + description: frontmatter.description, + slash: frontmatter.slash, + location: AbsolutePath.make(filepath), + content: markdown.content, + }) + } + } + return { skills, directories } + }) + + // Local skills are live (#1740): the Location-scoped filesystem watcher + // publishes per-file events on the bus, and a debounced flush drops the + // cache entry of every source whose directories contain a changed file, + // so the next list() re-globs it. Watcher-disabled environments and files + // outside every source directory keep the boot-frozen behavior. V1/TUI + // (packages/opencode/src/skill) stays boot-frozen by design. + const cache = new Map() + const pending = new Set() + const flush = () => { + if (pending.size === 0) return + const files = Array.from(pending) + pending.clear() + for (const source of state.get().sources) { + if (source.type === "embedded") continue + const key = Source.key(source) + const entry = cache.get(key) + if (!entry) continue + const directories = source.type === "directory" ? [source.path] : entry.directories + if (directories.some((directory) => files.some((file) => FSUtil.contains(directory, file)))) { + cache.delete(key) + } + } + } + yield* events + .subscribe(Watcher.Event.Updated) + .pipe( + Stream.mapEffect((event) => + Effect.sync(() => { + pending.add(event.data.file) + }), + ), + // The flush is debounced, not the marking: every event lands in + // `pending` immediately, and a save storm coalesces into one flush — + // the reload itself is already lazy (one re-parse per invalidated + // source, at the next list()). + Stream.debounce("50 millis"), + Stream.runForEach(() => Effect.sync(flush)), + Effect.catchCause((cause) => + Effect.logError("skill cache invalidation stopped; skills stay boot-frozen until restart", { + cause: Cause.pretty(cause), + }), + ), + Effect.forkScoped, + ) + + const list = Effect.fn("SkillV2.list")(function* () { + const skills = new Map() + for (const source of state.get().sources) { + const key = Source.key(source) + const loaded = cache.get(key) ?? (yield* load(source)) + cache.set(key, loaded) + for (const skill of loaded.skills) skills.set(skill.name, skill) + } + return Array.from(skills.values()) + }) + + return Service.of({ + transform: state.transform, + reload: state.reload, + sources: Effect.fn("SkillV2.sources")(function* () { + return state.get().sources + }), + list, + }) + }), +) + +export const node = makeLocationNode({ service: Service, layer, deps: [SkillDiscovery.node, FSUtil.node, EventV2.node] }) diff --git a/packages/app-bundle/overlay/packages/core/src/tool/skill.ts b/packages/app-bundle/overlay/packages/core/src/tool/skill.ts new file mode 100644 index 000000000..67ae99976 --- /dev/null +++ b/packages/app-bundle/overlay/packages/core/src/tool/skill.ts @@ -0,0 +1,124 @@ +export * as SkillTool from "./skill" + +import path from "path" +import { ToolFailure } from "@opencode-ai/llm" +import { Effect, Layer, Schema } from "effect" +import { makeLocationNode } from "../effect/app-node" +import { ConfigMarkdown } from "../config/markdown" +import { FSUtil } from "../fs-util" +import { SkillV2 } from "../skill" +import { PermissionV2 } from "../permission" +import { ToolRegistry } from "./registry" +import { Tool } from "./tool" +import { Tools } from "./tools" + +export const name = "skill" +const FILE_LIMIT = 10 + +export const Input = Schema.Struct({ + name: Schema.String.annotate({ description: "The name of the skill from the available skills list" }), +}) + +export const Output = Schema.Struct({ + name: Schema.String, + directory: Schema.String, + output: Schema.String, +}) + +export const description = [ + "Load a specialized skill when the task at hand matches one of the available skills in the system context.", + "", + "Use this tool to inject the skill's instructions and resources into the current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc. in the same directory as the skill.", + "", + "The skill name must match one of the available skills in the system context.", +].join("\n") + +export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray) => { + const directory = path.dirname(skill.location) + return [ + ``, + `# Skill: ${skill.name}`, + "", + skill.content.trim(), + "", + `Base directory for this skill: ${directory}`, + "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", + "Note: file list is sampled.", + "", + "", + ...files.map((file) => `${file}`), + "", + "", + ].join("\n") +} + +// #1740: the body is re-read from disk per invocation. The parse cache pins +// name/description metadata between watcher invalidations, but a merged body +// edit is served immediately — matching the fresh companion-file glob below. A +// failed read falls back to the cached body, never the tool's failure. +export const liveContent = (fs: FSUtil.Interface, skill: SkillV2.Info): Effect.Effect => + Effect.gen(function* () { + const fresh = yield* fs.readFileStringSafe(skill.location).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (fresh === undefined) return skill + const markdown = ConfigMarkdown.parseOption(fresh) + if (!markdown) return skill + return { ...skill, content: markdown.content } + }) + +const unableToLoad = (name: string, error?: unknown) => + new ToolFailure({ message: `Unable to load skill ${name}`, error }) + +const layer = Layer.effectDiscard( + Effect.gen(function* () { + const tools = yield* Tools.Service + const fs = yield* FSUtil.Service + const skills = yield* SkillV2.Service + const permission = yield* PermissionV2.Service + yield* tools + .register({ + [name]: Tool.make({ + description, + input: Input, + output: Output, + toModelOutput: ({ output }) => [{ type: "text", text: output.output }], + execute: (input, context) => + Effect.gen(function* () { + const current = yield* skills.list() + const skill = current.find((skill) => skill.name === input.name) + if (!skill) return yield* unableToLoad(input.name) + return yield* Effect.gen(function* () { + yield* permission.assert({ + action: name, + resources: [skill.name], + save: [skill.name], + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + }) + const served = yield* liveContent(fs, skill) + const directory = path.dirname(served.location) + const files = + path.basename(served.location) === "SKILL.md" + ? (yield* fs.glob("**/*", { cwd: directory, absolute: true, include: "file", dot: true })) + .filter((file) => path.basename(file) !== "SKILL.md") + .toSorted() + .slice(0, FILE_LIMIT) + : [] + return { + name: served.name, + directory, + output: toModelOutput(served, files), + } + }).pipe(Effect.mapError((error) => unableToLoad(input.name, error))) + }), + }), + }) + .pipe(Effect.orDie) + }), +) + +export const node = makeLocationNode({ + name: "tool/skill", + layer, + deps: [ToolRegistry.node, FSUtil.node, SkillV2.node, PermissionV2.node], +}) diff --git a/packages/app-bundle/overlay/packages/core/test/skill.test.ts b/packages/app-bundle/overlay/packages/core/test/skill.test.ts new file mode 100644 index 000000000..fab7471bc --- /dev/null +++ b/packages/app-bundle/overlay/packages/core/test/skill.test.ts @@ -0,0 +1,540 @@ +import { $ } from "bun" +import { describe, expect } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { ConfigProvider, Deferred, Effect, Fiber, Layer, Option, Stream } from "effect" +import type { Glob } from "@opencode-ai/core/util/glob" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" +import { Global } from "@opencode-ai/core/global" +import { Location } from "@opencode-ai/core/location" +import { Config } from "@opencode-ai/core/config" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionContextEpoch } from "@opencode-ai/core/session/context-epoch" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { AgentV2 } from "@opencode-ai/core/agent" +import { SkillV2 } from "@opencode-ai/core/skill" +import { SkillGuidance } from "@opencode-ai/core/skill/guidance" +import { SystemContext } from "@opencode-ai/core/system-context" +import { location } from "./fixture/location" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect, it } from "./lib/effect" + +// Live skills (#1740): the per-source skill parse cache invalidates on +// filesystem watch events, so a merged SKILL.md edit reaches the next +// list()/invocation of a running session instead of waiting for a restart. + +type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } + +const skillFile = (root: string, name: string, description: string, body: string) => + Effect.promise(() => + fs + .mkdir(path.join(root, name), { recursive: true }) + .then(() => + fs.writeFile( + path.join(root, name, "SKILL.md"), + `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`, + ), + ), + ) + +const rewrite = (root: string, name: string, description: string, body: string) => + Effect.promise(() => + fs.writeFile(path.join(root, name, "SKILL.md"), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`), + ) + +const skillsOf = (list: readonly SkillV2.Info[]) => + Object.fromEntries(list.map((skill) => [skill.name, skill])) + +const registerSource = (root: string) => + Effect.flatMap(SkillV2.Service, (skills) => + skills.transform((draft) => { + draft.source(SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(root) })) + }), + ) + +const publish = (file: string, event: "add" | "change" | "unlink") => + Effect.flatMap(EventV2.Service, (events) => events.publish(Watcher.Event.Updated, { file, event })).pipe( + Effect.asVoid, + ) + +// Bounded poll until the debounced invalidation flush is observable in list(). +const pollNames = (skills: SkillV2.Interface, expected: readonly string[]) => + Effect.gen(function* () { + const deadline = Date.now() + 5_000 + let names = (yield* skills.list()).map((skill) => skill.name) + while (JSON.stringify(names) !== JSON.stringify(expected) && Date.now() < deadline) { + yield* Effect.sleep("50 millis") + names = (yield* skills.list()).map((skill) => skill.name) + } + return names + }) + +const withTmp = (use: (tmp: { path: string }) => Effect.Effect) => + Effect.acquireRelease(Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]())).pipe( + Effect.flatMap(use), + ) + +const globalLayer = [Global.node, Global.layerWith({ config: "/global" })] as const + +const itSkill = testEffect(AppNodeBuilder.build(LayerNode.group([SkillV2.node, EventV2.node]), [globalLayer])) +const itGuidance = testEffect( + AppNodeBuilder.build(LayerNode.group([SkillGuidance.node, SkillV2.node, EventV2.node, Database.node]), [globalLayer]), +) + +describe("SkillV2 cache", () => { + itSkill.live("serves fresh skills after a change event under the source directory", () => + withTmp((tmp) => + Effect.gen(function* () { + const root = path.join(tmp.path, "skills") + yield* skillFile(root, "my-skill", "old description", "old body") + yield* registerSource(root) + + const skills = yield* SkillV2.Service + const before = skillsOf(yield* skills.list()) + expect(before["my-skill"]?.description).toBe("old description") + expect(before["my-skill"]?.content).toContain("old body") + + yield* rewrite(root, "my-skill", "new description", "new body") + yield* publish(path.join(root, "my-skill", "SKILL.md"), "change") + + // Bounded poll: the debounced flush lands shortly after the publish. + const deadline = Date.now() + 5_000 + let after = skillsOf(yield* skills.list()) + while (after["my-skill"]?.description !== "new description" && Date.now() < deadline) { + yield* Effect.sleep("50 millis") + after = skillsOf(yield* skills.list()) + } + expect(after["my-skill"]?.description).toBe("new description") + expect(after["my-skill"]?.content).toContain("new body") + }), + ), + ) + + itSkill.live("ignores watch events for files outside the source directories", () => + withTmp((tmp) => + Effect.gen(function* () { + const root = path.join(tmp.path, "skills") + yield* skillFile(root, "my-skill", "old description", "old body") + yield* registerSource(root) + + const skills = yield* SkillV2.Service + expect(skillsOf(yield* skills.list())["my-skill"]?.description).toBe("old description") + + yield* rewrite(root, "my-skill", "new description", "new body") + yield* publish(path.join(tmp.path, "unrelated.txt"), "change") + yield* Effect.sleep("200 millis") + + expect(skillsOf(yield* skills.list())["my-skill"]?.description).toBe("old description") + }), + ), + ) + + itSkill.live("picks up added and removed skill directories on add/unlink events", () => + withTmp((tmp) => + Effect.gen(function* () { + const root = path.join(tmp.path, "skills") + yield* skillFile(root, "my-skill", "does things", "body") + yield* registerSource(root) + + const skills = yield* SkillV2.Service + expect((yield* skills.list()).map((skill) => skill.name)).toEqual(["my-skill"]) + + yield* skillFile(root, "added-skill", "added later", "added body") + yield* publish(path.join(root, "added-skill", "SKILL.md"), "add") + const added = yield* pollNames(skills, ["added-skill", "my-skill"]) + expect(added).toEqual(["added-skill", "my-skill"]) + + yield* Effect.promise(() => fs.rm(path.join(root, "added-skill"), { recursive: true, force: true })) + yield* publish(path.join(root, "added-skill", "SKILL.md"), "unlink") + const removed = yield* pollNames(skills, ["my-skill"]) + expect(removed).toEqual(["my-skill"]) + }), + ), + ) + + // #1740 fail-safe floor: with no watch events the cache stays boot-frozen, + // exactly like watcher-disabled environments (no native binding, flag off). + itSkill.live("stays boot-frozen when no watch events arrive", () => + withTmp((tmp) => + Effect.gen(function* () { + const root = path.join(tmp.path, "skills") + yield* skillFile(root, "my-skill", "old description", "old body") + yield* registerSource(root) + + const skills = yield* SkillV2.Service + expect(skillsOf(yield* skills.list())["my-skill"]?.description).toBe("old description") + + yield* rewrite(root, "my-skill", "new description", "new body") + yield* Effect.sleep("200 millis") + + expect(skillsOf(yield* skills.list())["my-skill"]?.description).toBe("old description") + }), + ), + ) +}) + +// Burst coalescing: the reload count (glob calls) is observable only through a +// counting FSUtil replacement, so this test drives its own layer tree. +describe("SkillV2 invalidation consumer", () => { + it.live("coalesces an event burst into a single reload", () => + Effect.gen(function* () { + const globs = { count: 0 } + const countingFS = Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => + FSUtil.Service.of({ + ...fs, + glob: (pattern: string, options?: Glob.Options) => + Effect.sync(() => { + globs.count++ + }).pipe(Effect.andThen(fs.glob(pattern, options))), + }), + ), + ), + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) + + const layer = AppNodeBuilder.build(LayerNode.group([SkillV2.node, EventV2.node]), [ + globalLayer, + [FSUtil.node, countingFS], + ]) + yield* withTmp((tmp) => + Effect.gen(function* () { + const root = path.join(tmp.path, "skills") + yield* skillFile(root, "my-skill", "old description", "old body") + yield* skillFile(root, "other-skill", "old description", "old body") + yield* registerSource(root) + + const skills = yield* SkillV2.Service + expect((yield* skills.list()).length).toBe(2) + expect(globs.count).toBe(1) + + const file = path.join(root, "my-skill", "SKILL.md") + yield* publish(file, "change") + yield* publish(file, "change") + yield* publish(path.join(root, "other-skill", "SKILL.md"), "change") + + // One burst → one debounced flush → one re-glob of the source. The + // reload is lazy, so the poll must drive list() itself. + const deadline = Date.now() + 5_000 + let listed = yield* skills.list() + while (globs.count < 2 && Date.now() < deadline) { + yield* Effect.sleep("50 millis") + listed = yield* skills.list() + } + expect(listed.length).toBe(2) + expect(globs.count).toBe(2) + }), + ).pipe(Effect.provide(layer), Effect.scoped) + }), + ) + + it.live("stops consuming watch events when the location scope closes", () => + Effect.gen(function* () { + const active = { count: 0 } + const countingEvents = Layer.effect( + EventV2.Service, + EventV2.Service.pipe( + Effect.map((real) => + EventV2.Service.of({ + ...real, + subscribe: (definition) => + Stream.unwrap( + Effect.acquireRelease( + Effect.sync(() => { + active.count++ + }), + () => + Effect.sync(() => { + active.count-- + }), + ).pipe(Effect.as(real.subscribe(definition))), + ), + }), + ), + ), + ).pipe(Layer.provide(LayerNode.compile(EventV2.node))) + + const layer = AppNodeBuilder.build(LayerNode.group([SkillV2.node, EventV2.node]), [ + globalLayer, + [EventV2.node, countingEvents], + ]) + yield* withTmp((tmp) => + Effect.gen(function* () { + const root = path.join(tmp.path, "skills") + yield* skillFile(root, "my-skill", "does things", "body") + yield* registerSource(root) + + const skills = yield* SkillV2.Service + expect((yield* skills.list()).length).toBe(1) + // The consumer's subscription is live for as long as the layer holds it. + yield* Effect.sleep("100 millis") + expect(active.count).toBe(1) + }), + ).pipe(Effect.provide(layer), Effect.scoped) + + // Location disposal interrupts the consumer — no dangling subscription. + expect(active.count).toBe(0) + }), + ) + + it.live("degrades to the frozen cache when the event stream fails", () => + Effect.gen(function* () { + const attempts = { count: 0 } + const failingEvents = Layer.effect( + EventV2.Service, + EventV2.Service.pipe( + Effect.map((real) => + EventV2.Service.of({ + ...real, + subscribe: (definition) => + Stream.unwrap( + Effect.acquireRelease( + Effect.sync(() => { + attempts.count++ + }), + () => Effect.void, + ).pipe(Effect.as(Stream.fail(new Error("watch events unavailable")).pipe(Stream.orDie))), + ), + }), + ), + ), + ).pipe(Layer.provide(LayerNode.compile(EventV2.node))) + + const layer = AppNodeBuilder.build(LayerNode.group([SkillV2.node, EventV2.node]), [ + globalLayer, + [EventV2.node, failingEvents], + ]) + yield* withTmp((tmp) => + Effect.gen(function* () { + const root = path.join(tmp.path, "skills") + yield* skillFile(root, "my-skill", "old description", "old body") + yield* registerSource(root) + + const skills = yield* SkillV2.Service + expect(skillsOf(yield* skills.list())["my-skill"]?.description).toBe("old description") + yield* Effect.sleep("100 millis") + // The consumer died (one attempt) but the service keeps serving. + expect(attempts.count).toBe(1) + expect(skillsOf(yield* skills.list())["my-skill"]?.description).toBe("old description") + }), + ).pipe(Effect.provide(layer), Effect.scoped) + }), + ) +}) + +const selection = { id: AgentV2.ID.make("build"), info: AgentV2.Info.empty(AgentV2.ID.make("build")) } + +describe("SkillGuidance epoch delivery", () => { + itGuidance.live("reconciles a mid-session skill edit into a context update", () => + withTmp((tmp) => + Effect.gen(function* () { + const root = path.join(tmp.path, "skills") + yield* skillFile(root, "my-skill", "old description", "old body") + yield* registerSource(root) + + const guidance = yield* SkillGuidance.Service + const initialized = yield* SystemContext.initialize(yield* guidance.load(selection)) + expect(initialized.baseline).toContain("old description") + + yield* rewrite(root, "my-skill", "new description", "new body") + yield* publish(path.join(root, "my-skill", "SKILL.md"), "change") + + // Bounded poll: reconcile is safe to retry while the debounced flush + // has not landed yet (it reads Unchanged until the skills change). + const deadline = Date.now() + 5_000 + let result: SystemContext.ReconcileResult = yield* SystemContext.reconcile( + yield* guidance.load(selection), + initialized.snapshot, + ) + while (result._tag !== "Updated" && Date.now() < deadline) { + yield* Effect.sleep("50 millis") + result = yield* SystemContext.reconcile(yield* guidance.load(selection), initialized.snapshot) + } + expect(result).toMatchObject({ _tag: "Updated" }) + if (result._tag === "Updated") { + expect(result.text).toContain("The available skills have changed") + expect(result.text).toContain("new description") + } + }), + ), + ) + + itGuidance.live("delivers the update through SessionContextEpoch as a ContextUpdated event", () => + withTmp((tmp) => + Effect.gen(function* () { + const root = path.join(tmp.path, "skills") + yield* skillFile(root, "my-skill", "old description", "old body") + yield* registerSource(root) + + const { db } = yield* Database.Service + const events = yield* EventV2.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + const sessionID = SessionV2.ID.make("ses_skill_live_epoch") + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: sessionID, + directory: "/project", + title: "test", + version: "test", + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + + const guidance = yield* SkillGuidance.Service + const context = Effect.suspend(() => guidance.load(selection)) + const first = yield* SessionContextEpoch.prepare(db, events, context, sessionID) + expect(first.baseline).toContain("old description") + + const updates: string[] = [] + const fiber = yield* events + .subscribe(SessionEvent.ContextUpdated) + .pipe( + Stream.runForEach((event) => + Effect.sync(() => { + updates.push(event.data.text) + }), + ), + Effect.forkScoped, + ) + + yield* rewrite(root, "my-skill", "new description", "new body") + yield* publish(path.join(root, "my-skill", "SKILL.md"), "change") + + // Bounded poll: prepare is safe to retry while the debounced flush has + // not landed (it reads Unchanged and publishes nothing). + const prepareDeadline = Date.now() + 5_000 + let second = yield* SessionContextEpoch.prepare(db, events, context, sessionID) + while (updates.length === 0 && Date.now() < prepareDeadline) { + yield* Effect.sleep("50 millis") + second = yield* SessionContextEpoch.prepare(db, events, context, sessionID) + } + + // The baseline is not rewritten — the change arrives as a separate + // chronological ContextUpdated event, exactly as a session sees it. + expect(second.baseline).toBe(first.baseline) + expect(updates).toHaveLength(1) + expect(updates[0]).toContain("The available skills have changed") + expect(updates[0]).toContain("new description") + + yield* Fiber.interrupt(fiber) + }), + ), + ) +}) + +// End-to-end through the real @parcel/watcher binding: an actual disk edit (no +// synthetic bus publish) invalidates the cache. +const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip + +const waitForEvent = (check: (data: WatcherEvent) => boolean, trigger: Effect.Effect) => + Effect.gen(function* () { + const events = yield* EventV2.Service + const deferred = yield* Deferred.make() + const fiber = yield* events + .subscribe(Watcher.Event.Updated) + .pipe( + Stream.runForEach((event) => { + if (!check(event.data)) return Effect.void + return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid) + }), + Effect.forkScoped, + ) + yield* Effect.yieldNow + const option = yield* trigger.pipe( + Effect.andThen(Deferred.await(deferred)), + Effect.timeoutOption("2 seconds"), + ) + yield* Fiber.interrupt(fiber) + return Option.isSome(option) + }) + +describeWatcher("SkillV2 native watcher end-to-end", () => { + it.live("invalidates the skill cache from real filesystem events", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease(Effect.promise(() => tmpdir()), (t) => + Effect.promise(() => t[Symbol.asyncDispose]()), + ) + const root = path.join(tmp.path, "skills") + yield* skillFile(root, "my-skill", "old description", "old body") + yield* Effect.promise(async () => { + await $`git init`.cwd(tmp.path).quiet() + await $`git config core.fsmonitor false`.cwd(tmp.path).quiet() + await $`git config user.email test@opencode.test`.cwd(tmp.path).quiet() + await $`git config user.name Test`.cwd(tmp.path).quiet() + await $`git commit --allow-empty -m root`.cwd(tmp.path).quiet() + }) + const vcs = { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } + + const configLayer = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) + const flagsLayer = ConfigProvider.layer( + ConfigProvider.fromUnknown({ OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false" }), + ) + const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) }, { vcs })), + ) + const layer = AppNodeBuilder.build( + LayerNode.group([Watcher.node, SkillV2.node, FSUtil.node, EventV2.node]), + [ + [Config.node, configLayer], + [Location.node, locationLayer], + globalLayer, + ], + ).pipe(Layer.provide(flagsLayer)) + + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + yield* registerSource(root) + + // Wait for the watcher subscription to go live before editing: retry + // fresh probe files until one produces an event (bounded). + const readyDeadline = Date.now() + 5_000 + let ready = false + while (!ready && Date.now() < readyDeadline) { + const probe = path.join(tmp.path, `.watcher-probe-${Math.random().toString(36).slice(2)}`) + ready = yield* waitForEvent((data) => data.file === probe, fs.writeFileString(probe, "ready")) + yield* fs.remove(probe, { force: true }).pipe(Effect.ignore) + } + expect(ready).toBe(true) + + const skills = yield* SkillV2.Service + expect(skillsOf(yield* skills.list())["my-skill"]?.description).toBe("old description") + + yield* fs.writeFileString( + path.join(root, "my-skill", "SKILL.md"), + `---\nname: my-skill\ndescription: new description\n---\n\nnew body\n`, + ) + + // The real watcher publishes the change; the debounced consumer then + // invalidates. Poll list() until the edit lands (bounded). + const pollDeadline = Date.now() + 5_000 + let current = skillsOf(yield* skills.list()) + while (current["my-skill"]?.description !== "new description" && Date.now() < pollDeadline) { + yield* Effect.sleep("100 millis") + current = skillsOf(yield* skills.list()) + } + expect(current["my-skill"]?.description).toBe("new description") + expect(current["my-skill"]?.content).toContain("new body") + }).pipe(Effect.provide(layer), Effect.scoped) + }), + ) +}) diff --git a/packages/app-bundle/overlay/packages/core/test/tool-skill.test.ts b/packages/app-bundle/overlay/packages/core/test/tool-skill.test.ts new file mode 100644 index 000000000..02c8c19b0 --- /dev/null +++ b/packages/app-bundle/overlay/packages/core/test/tool-skill.test.ts @@ -0,0 +1,56 @@ +import { describe, expect } from "bun:test" +import path from "path" +import { Effect } from "effect" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SkillV2 } from "@opencode-ai/core/skill" +import { SkillTool } from "@opencode-ai/core/tool/skill" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +// #1740: the skill tool re-reads the SKILL.md body per invocation, so a fresh +// edit is served even before the watcher invalidates the parse cache — the +// cache only pins name/description metadata between invalidations. + +const it = testEffect(LayerNode.compile(FSUtil.node)) + +const skill = (location: string, content: string): SkillV2.Info => ({ + name: "my-skill", + description: "does things", + slash: undefined, + location: AbsolutePath.make(location), + content, +}) + +describe("SkillTool live content", () => { + it.live("re-reads the skill body per invocation", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease(Effect.promise(() => tmpdir()), (t) => + Effect.promise(() => t[Symbol.asyncDispose]()), + ) + const file = path.join(tmp.path, "SKILL.md") + yield* Effect.promise(() => + Bun.write(file, `---\nname: my-skill\ndescription: does things\n---\n\nfresh body\n`), + ) + + const fs = yield* FSUtil.Service + const served = yield* SkillTool.liveContent(fs, skill(file, "stale body")) + expect(served.content).toContain("fresh body") + expect(served.name).toBe("my-skill") + }), + ) + + it.live("falls back to the cached body when the file is gone", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease(Effect.promise(() => tmpdir()), (t) => + Effect.promise(() => t[Symbol.asyncDispose]()), + ) + const file = path.join(tmp.path, "missing", "SKILL.md") + + const fs = yield* FSUtil.Service + const served = yield* SkillTool.liveContent(fs, skill(file, "cached body")) + expect(served.content).toBe("cached body") + }), + ) +}) diff --git a/packages/app-bundle/overlay/packages/opencode/src/skill/index.ts b/packages/app-bundle/overlay/packages/opencode/src/skill/index.ts new file mode 100644 index 000000000..e3a0765c4 --- /dev/null +++ b/packages/app-bundle/overlay/packages/opencode/src/skill/index.ts @@ -0,0 +1,360 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import path from "path" +import { Effect, Layer, Context, Schema } from "effect" +import { NamedError } from "@opencode-ai/core/util/error" +import type { Agent } from "@/agent/agent" +import { EventV2Bridge } from "@/event-v2-bridge" +import { InstanceState } from "@/effect/instance-state" +import { Global } from "@opencode-ai/core/global" +import { SkillPlugin } from "@opencode-ai/core/plugin/skill" +import { Permission } from "@/permission" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Config } from "@/config/config" +import { FrontmatterError } from "@opencode-ai/core/v1/config/error" +import { ConfigMarkdown } from "@/config/markdown" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { Glob } from "@opencode-ai/core/util/glob" +import { Discovery } from "./discovery" +import { isRecord } from "@/util/record" +import { escapeHtml } from "@/util/html" + +const CLAUDE_EXTERNAL_DIR = ".claude" +const AGENTS_EXTERNAL_DIR = ".agents" +const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" +const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" +const SKILL_PATTERN = "**/SKILL.md" + +// Built-in skill that ships with opencode. The model's intuition for what an +// opencode.json should look like is often wrong, and opencode hard-fails on +// invalid config, so users hit cryptic startup errors. Loading this skill +// when the model is asked to touch opencode's own config files gives it the +// actual schemas instead of guesses. +const CUSTOMIZE_OPENCODE_SKILL_NAME = "customize-opencode" +const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION = + "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." +const CUSTOMIZE_OPENCODE_SKILL_BODY = SkillPlugin.CustomizeOpencodeContent + +export const Info = Schema.Struct({ + name: Schema.String, + description: Schema.optional(Schema.String), + location: Schema.String, + content: Schema.String, +}) +export type Info = Schema.Schema.Type + +const Issue = Schema.StructWithRest( + Schema.Struct({ + message: Schema.String, + path: Schema.Array(Schema.String), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) + +function isSkillFrontmatter(data: unknown): data is { name: string; description?: string } { + return ( + isRecord(data) && + typeof data.name === "string" && + (data.description === undefined || typeof data.description === "string") + ) +} + +export class InvalidError extends Schema.TaggedErrorClass()("SkillInvalidError", { + path: Schema.String, + message: Schema.optional(Schema.String), + issues: Schema.optional(Schema.Array(Issue)), +}) {} + +export class NameMismatchError extends Schema.TaggedErrorClass()("SkillNameMismatchError", { + path: Schema.String, + expected: Schema.String, + actual: Schema.String, +}) {} + +export class NotFoundError extends Schema.TaggedErrorClass()("Skill.NotFoundError", { + name: Schema.String, + available: Schema.Array(Schema.String), +}) { + override get message() { + return `Skill "${this.name}" not found. Available skills: ${this.available.join(", ") || "none"}` + } +} + +type State = { + skills: Record + dirs: Set +} + +type DiscoveryState = { + matches: string[] + dirs: string[] +} + +type ScanState = { + matches: Set + dirs: Set +} + +export interface Interface { + readonly get: (name: string) => Effect.Effect + readonly require: (name: string) => Effect.Effect + readonly all: () => Effect.Effect + readonly dirs: () => Effect.Effect + readonly available: (agent?: Agent.Info) => Effect.Effect +} + +const add = Effect.fnUntraced(function* (state: State, match: string, events: EventV2Bridge.Service["Service"]) { + const md = yield* Effect.tryPromise({ + try: () => ConfigMarkdown.parse(match), + catch: (err) => err, + }).pipe( + Effect.catch( + Effect.fnUntraced(function* (err) { + const message = FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse skill ${match}` + const { Session } = yield* Effect.promise(() => import("@/session/session")) + yield* events.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + yield* Effect.logError("failed to load skill", { skill: match, error: err }) + return undefined + }), + ), + ) + + if (!md) return + + if (!isSkillFrontmatter(md.data)) return + + if (state.skills[md.data.name]) { + yield* Effect.logWarning("duplicate skill name", { + name: md.data.name, + existing: state.skills[md.data.name].location, + duplicate: match, + }) + } + + state.dirs.add(path.dirname(match)) + state.skills[md.data.name] = { + name: md.data.name, + description: md.data.description, + location: match, + content: md.content, + } +}) + +const scan = Effect.fnUntraced(function* ( + state: ScanState, + root: string, + pattern: string, + opts?: { dot?: boolean; scope?: string }, +) { + const matches = yield* Effect.tryPromise({ + try: () => + Glob.scan(pattern, { + cwd: root, + absolute: true, + include: "file", + symlink: true, + dot: opts?.dot, + }), + catch: (error) => error, + }).pipe( + Effect.catch((error) => { + if (!opts?.scope) return Effect.die(error) + return Effect.logError(`failed to scan ${opts.scope} skills`, { dir: root, error: error }).pipe( + Effect.as([] as string[]), + ) + }), + ) + + for (const match of matches) { + state.matches.add(match) + state.dirs.add(path.dirname(match)) + } +}) + +const discoverSkills = Effect.fnUntraced(function* ( + config: Config.Interface, + discovery: Discovery.Interface, + fsys: FSUtil.Interface, + global: Global.Interface, + disableExternalSkills: boolean, + disableClaudeCodeSkills: boolean, + directory: string, + worktree: string, +) { + const state: ScanState = { matches: new Set(), dirs: new Set() } + + const externalDirs: string[] = [] + if (!disableExternalSkills) { + if (!disableClaudeCodeSkills) externalDirs.push(CLAUDE_EXTERNAL_DIR) + externalDirs.push(AGENTS_EXTERNAL_DIR) + + for (const dir of externalDirs) { + const root = path.join(global.home, dir) + if (!(yield* fsys.isDir(root))) continue + yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) + } + + const upDirs = yield* fsys + .up({ targets: externalDirs, start: directory, stop: worktree }) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + + for (const root of upDirs) { + yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }) + } + } + + const configDirs = yield* config.directories() + for (const dir of configDirs) { + yield* scan(state, dir, OPENCODE_SKILL_PATTERN) + } + + const cfg = yield* config.get() + for (const item of cfg.skills?.paths ?? []) { + const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item + const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded) + if (!(yield* fsys.isDir(dir))) { + yield* Effect.logWarning("skill path not found", { path: dir }) + continue + } + + yield* scan(state, dir, SKILL_PATTERN) + } + + for (const url of cfg.skills?.urls ?? []) { + const pulledDirs = yield* discovery.pull(url) + for (const dir of pulledDirs) { + yield* scan(state, dir, SKILL_PATTERN) + } + } + + return { + matches: Array.from(state.matches), + dirs: Array.from(state.dirs), + } +}) + +const loadSkills = Effect.fnUntraced(function* ( + state: State, + discovered: DiscoveryState, + events: EventV2Bridge.Service["Service"], +) { + yield* Effect.forEach(discovered.matches, (match) => add(state, match, events), { + concurrency: "unbounded", + discard: true, + }) + + yield* Effect.logInfo("init", { count: Object.keys(state.skills).length }) +}) + +export class Service extends Context.Service()("@opencode/Skill") {} + +// Boot-frozen by design (#1740): the v1/TUI skill tree loads once per +// directory at instance startup (InstanceState below) and is never re-read, so +// a merged SKILL.md edit is invisible to a running v1 session until restart. +// Live watcher-driven invalidation ships in the V2 session core only +// (core/src/skill.ts) — this path stays boot-frozen deliberately, and must +// not claim parity with it. +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const discovery = yield* Discovery.Service + const config = yield* Config.Service + const events = yield* EventV2Bridge.Service + const fsys = yield* FSUtil.Service + const global = yield* Global.Service + const flags = yield* RuntimeFlags.Service + const discovered = yield* InstanceState.make( + Effect.fn("Skill.discovery")(function* (ctx) { + return yield* discoverSkills( + config, + discovery, + fsys, + global, + flags.disableExternalSkills, + flags.disableClaudeCodeSkills, + ctx.directory, + ctx.worktree, + ) + }), + ) + const state = yield* InstanceState.make( + Effect.fn("Skill.state")(function* () { + const s: State = { skills: {}, dirs: new Set() } + // Register the built-in skill BEFORE disk discovery so a user-disk + // skill with the same name can override it. + s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = { + name: CUSTOMIZE_OPENCODE_SKILL_NAME, + description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION, + location: "", + content: CUSTOMIZE_OPENCODE_SKILL_BODY, + } + yield* loadSkills(s, yield* InstanceState.get(discovered), events) + return s + }), + ) + + const get = Effect.fn("Skill.get")(function* (name: string) { + const s = yield* InstanceState.get(state) + return s.skills[name] + }) + + const require = Effect.fn("Skill.require")(function* (name: string) { + const s = yield* InstanceState.get(state) + const info = s.skills[name] + if (info) return info + return yield* new NotFoundError({ name, available: Object.keys(s.skills).toSorted() }) + }) + + const all = Effect.fn("Skill.all")(function* () { + const s = yield* InstanceState.get(state) + return Object.values(s.skills) + }) + + const dirs = Effect.fn("Skill.dirs")(function* () { + return (yield* InstanceState.get(discovered)).dirs + }) + + const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) { + const s = yield* InstanceState.get(state) + const list = Object.values(s.skills).toSorted((a, b) => a.name.localeCompare(b.name)) + if (!agent) return list + return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny") + }) + + return Service.of({ get, require, all, dirs, available }) + }), +) + +export function fmt(list: Info[], opts: { verbose: boolean }) { + const described = list.filter((skill) => skill.description !== undefined) + if (described.length === 0) return "No skills are currently available." + if (opts.verbose) { + return [ + "", + ...described + .toSorted((a, b) => a.name.localeCompare(b.name)) + .flatMap((skill) => [ + " ", + ` ${skill.name}`, + ` ${skill.description}`, + ` ${escapeHtml(skill.location)}`, + " ", + ]), + "", + ].join("\n") + } + + return [ + "## Available Skills", + ...described + .toSorted((a, b) => a.name.localeCompare(b.name)) + .map((skill) => `- **${skill.name}**: ${skill.description}`), + ].join("\n") +} + +export const node = LayerNode.make({ + service: Service, + layer: layer, + deps: [Discovery.node, Config.node, EventV2Bridge.node, FSUtil.node, Global.node, RuntimeFlags.node], +}) + +export * as Skill from "."