Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions packages/app-bundle/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,17 @@
"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",
"packages/core/test/session-execution-local.test.ts": "8fbcc7dd4558f7bd534f1da5b2dfc84e12cba1ed8fb5daa86a2990f55bfa57e5",
"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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -1020,7 +1025,7 @@
},
"packages/core": {
"A": 18,
"M": 47,
"M": 51,
"D": 0
},
"packages/llm": {
Expand All @@ -1030,7 +1035,7 @@
},
"packages/opencode": {
"A": 98,
"M": 98,
"M": 99,
"D": 0
},
"packages/schema": {
Expand Down Expand Up @@ -1060,7 +1065,7 @@
}
},
"counts": {
"overlay_total": 989,
"overlay_total": 994,
"deletions": 7,
"server_coupled": 0
},
Expand Down Expand Up @@ -1100,14 +1105,17 @@
"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",
"packages/core/test/session-execution-local.test.ts": "A",
"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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
177 changes: 177 additions & 0 deletions packages/app-bundle/overlay/packages/core/src/skill.ts
Original file line number Diff line number Diff line change
@@ -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<Info>, 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<Source>[]
}

export type Draft = {
source: (source: Source) => void
list: () => readonly Source[]
}

export interface Interface extends State.Transformable<Draft> {
readonly sources: () => Effect.Effect<Source[]>
readonly list: () => Effect.Effect<Info[]>
}

export class Service extends Context.Service<Service, Interface>()("@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<Data, Draft>({
initial: () => ({ sources: [] }),
draft: (draft) => ({
source: (source) => {
if (draft.sources.some((item) => Source.equals(item, source))) return
draft.sources.push(source as Types.DeepMutable<Source>)
},
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<string, { skills: Info[]; directories: string[] }>()
const pending = new Set<string>()
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<string, Info>()
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] })
Loading
Loading