feat(core): add skill registry and file agent loading (#30617)

This commit is contained in:
Dax 2026-06-03 16:58:34 -04:00 committed by GitHub
commit 889e0f9545
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 881 additions and 189 deletions

View file

@ -98,30 +98,22 @@ export class Info extends Schema.Class<Info>("Config.Info")({
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
}) {}
export const FileSource = Schema.Struct({
type: Schema.Literal("file"),
path: Schema.String,
}).annotate({ identifier: "Config.FileSource" })
export type FileSource = typeof FileSource.Type
export const MemorySource = Schema.Struct({
type: Schema.Literal("memory"),
}).annotate({ identifier: "Config.MemorySource" })
export type MemorySource = typeof MemorySource.Type
export const Source = Schema.Union([FileSource, MemorySource]).pipe(Schema.toTaggedUnion("type"))
export type Source = typeof Source.Type
export class Loaded extends Schema.Class<Loaded>("Config.Loaded")({
source: Source,
export class Document extends Schema.Class<Document>("Config.Document")({
type: Schema.Literal("document"),
path: Schema.String.pipe(Schema.optional),
info: Info,
}) {}
export class Directory extends Schema.Class<Directory>("Config.Directory")({
type: Schema.Literal("directory"),
path: AbsolutePath,
}) {}
export type Entry = Document | Directory
export interface Interface {
/** Returns supplemental config directories from lowest to highest priority. */
readonly directories: () => Effect.Effect<AbsolutePath[]>
/** Loads location config files from lowest to highest priority. */
readonly get: () => Effect.Effect<Loaded[]>
/** Returns location config documents and supplemental directories from lowest to highest priority. */
readonly entries: () => Effect.Effect<Entry[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Config") {}
@ -160,39 +152,40 @@ export const layer = Layer.effect(
),
)
if (!info) return
return new Loaded({ source: { type: "file", path: filepath }, info })
return new Document({ type: "document", path: filepath, info })
})
const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
return yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)),
)
return [
...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
)),
new Directory({ type: "directory", path: directory }),
]
})
const globalDirectory = AbsolutePath.make(global.config)
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
// Read configuration once when this location opens. Later calls reuse these
// values until the location is reopened.
const directories = locationIsGlobal
? [globalDirectory]
: [
globalDirectory,
...(yield* fs
.up({ targets: [".opencode"], start: location.directory, stop: location.project.directory })
.pipe(Effect.orDie))
.toReversed()
.map((directory) => AbsolutePath.make(directory)),
]
const discovered = locationIsGlobal
? []
: yield* fs
.up({ targets: [".opencode", ...names.toReversed()], start: location.directory, stop: location.project.directory })
.pipe(Effect.orDie)
const directories = [
globalDirectory,
...discovered
.filter((item) => path.basename(item) === ".opencode")
.toReversed()
.map((directory) => AbsolutePath.make(directory)),
]
// A config closer to the opened directory should win over one higher up.
// Search starts nearby, so reverse the results before applying them.
const directPaths = locationIsGlobal
? []
: (yield* fs
.up({ targets: names.toReversed(), start: location.directory, stop: location.project.directory })
.pipe(Effect.orDie)).toReversed()
const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed()
const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
Effect.orDie,
Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)),
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
)
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
// Apply general settings first and more specific settings last:
@ -200,13 +193,15 @@ export const layer = Layer.effect(
const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
// Rules use the opposite order so a user-global rule can override a
// repository rule. Statement order inside each file stays unchanged.
yield* policy.load(configs.toReversed().flatMap((config) => config.info.experimental?.policies ?? []))
yield* policy.load(
configs
.filter((config): config is Document => config.type === "document")
.toReversed()
.flatMap((config) => config.info.experimental?.policies ?? []),
)
return Service.of({
directories: Effect.fn("Config.directories")(function* () {
return directories
}),
get: Effect.fn("Config.get")(function* () {
entries: Effect.fn("Config.entries")(function* () {
return configs
}),
})

View file

@ -0,0 +1,36 @@
export * as ConfigMarkdown from "./markdown"
import matter from "gray-matter"
export function parse(content: string) {
try {
return matter(content)
} catch {
return matter(sanitize(content))
}
}
export function parseOption(content: string) {
try {
return parse(content)
} catch {
return undefined
}
}
// Other coding agents accept unquoted colons in frontmatter values. Retry
// those values as YAML block scalars so existing config files keep working.
export function sanitize(content: string) {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
if (!match) return content
const frontmatter = match[1]
const result = frontmatter.split(/\r?\n/).flatMap((line) => {
if (line.trim().startsWith("#") || line.trim() === "" || /^\s+/.test(line)) return [line]
const entry = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/)
if (!entry) return [line]
const value = entry[2].trim()
if (value === "" || value === ">" || value === "|" || value.startsWith('"') || value.startsWith("'")) return [line]
if (!value.includes(":")) return [line]
return [`${entry[1]}: |-`, ` ${value}`]
})
return content.replace(frontmatter, () => result.join("\n"))
}

View file

@ -1,32 +1,64 @@
export * as ConfigAgentPlugin from "./agent"
import { Effect } from "effect"
import path from "path"
import { Effect, Option, Schema } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { ConfigAgent } from "../agent"
import { ConfigMarkdown } from "../markdown"
import { FSUtil } from "../../fs-util"
import { ModelV2 } from "../../model"
import { PermissionV2 } from "../../permission"
import { PluginV2 } from "../../plugin"
import { ConfigAgentV1 } from "../../v1/config/agent"
import { ConfigMigrateV1 } from "../../v1/config/migrate"
const legacySources = [
{ pattern: "{agent,agents}/**/*.md", primary: false },
{ pattern: "{mode,modes}/*.md", primary: true },
] as const
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
const decodeConfig = Schema.decodeUnknownOption(Config.Info)
const agentKeys = new Set(["model", "variant", "request", "system", "description", "mode", "hidden", "color", "steps", "disabled", "permissions"])
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("config-agent"),
effect: Effect.gen(function* () {
const agent = yield* AgentV2.Service
const config = yield* Config.Service
const files = yield* config.get()
const fs = yield* FSUtil.Service
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([entry])
return Effect.gen(function* () {
const files = yield* discover(fs, entry.path)
return yield* Effect.forEach(files, (file) =>
fs.readFileStringSafe(file.filepath).pipe(
Effect.map((content) => content && decode(file, content)),
Effect.catch(() => Effect.succeed(undefined)),
),
).pipe(
Effect.map((documents) => documents.filter((document): document is Config.Document => document !== undefined)),
)
})
}).pipe(Effect.map((documents) => documents.flat()))
yield* agent.update((editor) => {
const permissions = new Map<AgentV2.ID, PermissionV2.Ruleset>()
const global = documents.flatMap((document) => document.info.permissions ?? [])
for (const current of editor.list()) {
editor.update(current.id, (agent) => agent.permissions.push(...global))
}
for (const file of files) {
for (const [id, item] of Object.entries(file.info.agents ?? {})) {
for (const document of documents) {
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
const agentID = AgentV2.ID.make(id)
if (item.disabled) {
editor.remove(agentID)
permissions.delete(agentID)
continue
}
const exists = editor.get(agentID) !== undefined
editor.update(agentID, (agent) => {
if (!exists) agent.permissions.push(...global)
if (item.model !== undefined) {
const model = ModelV2.parse(item.model)
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
@ -44,20 +76,49 @@ export const Plugin = PluginV2.define({
if (item.hidden !== undefined) agent.hidden = item.hidden
if (item.color !== undefined) agent.color = item.color
if (item.steps !== undefined) agent.steps = item.steps
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
})
if (item.permissions !== undefined) {
permissions.set(agentID, [...(permissions.get(agentID) ?? []), ...item.permissions])
}
}
}
const global = files.flatMap((file) => file.info.permissions ?? [])
for (const current of editor.list()) {
editor.update(current.id, (agent) => {
agent.permissions.push(...global, ...(permissions.get(current.id) ?? []))
})
}
})
}),
})
function discover(fs: FSUtil.Interface, directory: string) {
return Effect.forEach(legacySources, (source) =>
fs
.glob(source.pattern, { cwd: directory, absolute: true, dot: true, symlink: true })
.pipe(Effect.map((files) => files.toSorted().map((filepath) => ({ directory, filepath, primary: source.primary })))),
).pipe(
Effect.map((files) => files.flat()),
Effect.catch(() => Effect.succeed([])),
)
}
function decode(file: { directory: string; filepath: string; primary: boolean }, content: string) {
const markdown = ConfigMarkdown.parseOption(content)
if (!markdown) return
const name = path
.relative(file.directory, file.filepath)
.replaceAll("\\", "/")
.replace(/^(agent|agents|mode|modes)\//, "")
.replace(/\.md$/, "")
const body = markdown.content.trim()
const legacy = Object.keys(markdown.data).some((key) => !agentKeys.has(key))
const agent = Option.getOrUndefined(
legacy
? Option.map(
decodeLegacyAgent({ name, ...markdown.data, prompt: body }, { errors: "all", propertyOrder: "original" }),
ConfigMigrateV1.migrateAgent,
)
: decodeAgent({ ...markdown.data, system: body }, { errors: "all", propertyOrder: "original" }),
)
if (!agent) return
const info = Option.getOrUndefined(
decodeConfig({
agents: { [name]: file.primary ? { ...agent, mode: "primary" } : agent },
}),
)
if (!info) return
return new Config.Document({ type: "document", path: file.filepath, info })
}

View file

@ -13,7 +13,7 @@ export const Plugin = PluginV2.define({
const catalog = yield* Catalog.Service
const config = yield* Config.Service
const transform = yield* catalog.transform()
const files = yield* config.get()
const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
yield* transform((catalog) => {
for (const file of files) {

View file

@ -0,0 +1,39 @@
export * as ConfigSkillPlugin from "./skill"
import path from "path"
import { Effect } from "effect"
import { Config } from "../../config"
import { Global } from "../../global"
import { Location } from "../../location"
import { PluginV2 } from "../../plugin"
import { AbsolutePath } from "../../schema"
import { SkillV2 } from "../../skill"
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("config-skill"),
effect: Effect.gen(function* () {
const config = yield* Config.Service
const global = yield* Global.Service
const location = yield* Location.Service
const skill = yield* SkillV2.Service
const transform = yield* skill.transform()
const entries = yield* config.entries()
const items = entries.flatMap((entry) => (entry.type === "document" ? entry.info.skills ?? [] : []))
yield* transform((editor) => {
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
editor.source(new SkillV2.UrlSource({ type: "url", url: item }))
continue
}
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
editor.source(
new SkillV2.DirectorySource({
type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}),
)
}
})
}),
})

View file

@ -110,7 +110,9 @@ export const layer = Layer.effect(
)
}
const config = (yield* (yield* Config.Service).get()).flatMap((item) => item.info.watcher?.ignore ?? [])
const config = (yield* (yield* Config.Service).entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
yield* Effect.forkScoped(
subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]),

View file

@ -22,6 +22,7 @@ import { Watcher } from "./filesystem/watcher"
import { ProjectReference } from "./project-reference"
import { RepositoryCache } from "./repository-cache"
import { Pty } from "./pty"
import { SkillV2 } from "./skill"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
@ -39,6 +40,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
FileSystem.locationLayer,
Watcher.locationLayer,
Pty.locationLayer,
SkillV2.locationLayer,
).pipe(Layer.provideMerge(location), Layer.fresh)
},
idleTimeToLive: "60 minutes",

View file

@ -6,7 +6,10 @@ import { AgentV2 } from "../agent"
import { Catalog } from "../catalog"
import { Config } from "../config"
import { ConfigAgentPlugin } from "../config/plugin/agent"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { EventV2 } from "../event"
import { FSUtil } from "../fs-util"
import { Global } from "../global"
import { Location } from "../location"
import { ModelsDev } from "../models-dev"
import { Npm } from "../npm"
@ -17,6 +20,7 @@ import { ConfigProviderPlugin } from "../config/plugin/provider"
import { EnvPlugin } from "./env"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { SkillV2 } from "../skill"
type Plugin = {
id: PluginV2.ID
@ -26,10 +30,13 @@ type Plugin = {
| AgentV2.Service
| Npm.Service
| EventV2.Service
| FSUtil.Service
| Global.Service
| Location.Service
| PluginV2.Service
| Config.Service
| ModelsDev.Service
| SkillV2.Service
>
}
@ -51,6 +58,9 @@ export const layer = Layer.effect(
const modelsDev = yield* ModelsDev.Service
const npm = yield* Npm.Service
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const skill = yield* SkillV2.Service
const done = yield* Deferred.make<void>()
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
@ -65,6 +75,9 @@ export const layer = Layer.effect(
Effect.provideService(ModelsDev.Service, modelsDev),
Effect.provideService(Npm.Service, npm),
Effect.provideService(EventV2.Service, events),
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(Global.Service, global),
Effect.provideService(SkillV2.Service, skill),
Effect.provideService(PluginV2.Service, plugin),
),
})
@ -80,6 +93,7 @@ export const layer = Layer.effect(
yield* add(ModelsDevPlugin)
yield* add(ConfigProviderPlugin.Plugin)
yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin)
}).pipe(Effect.withSpan("PluginBoot.boot"))
yield* boot.pipe(
@ -98,4 +112,5 @@ export const locationLayer = layer.pipe(
Layer.provideMerge(Catalog.locationLayer),
Layer.provideMerge(Config.locationLayer),
Layer.provideMerge(AgentV2.locationLayer),
Layer.provideMerge(SkillV2.locationLayer),
)

View file

@ -71,7 +71,12 @@ export const layer = Layer.effect(
const cache = yield* RepositoryCache.Service
const references = resolveAll({
references: ConfigReference.normalize(
Object.assign({}, ...(yield* config.get()).map((document) => document.info.references ?? {})),
Object.assign(
{},
...(yield* config.entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.map((document) => document.info.references ?? {}),
),
),
directory: location.project.directory,
home: global.home,

155
packages/core/src/skill.ts Normal file
View file

@ -0,0 +1,155 @@
export * as SkillV2 from "./skill"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { castDraft } from "immer"
import { AgentV2 } from "./agent"
import { ConfigMarkdown } from "./config/markdown"
import { FSUtil } from "./fs-util"
import { PermissionV2 } from "./permission"
import { AbsolutePath, withStatics } from "./schema"
import { SkillDiscovery } from "./skill/discovery"
import { State } from "./state"
export class DirectorySource extends Schema.Class<DirectorySource>("SkillV2.DirectorySource")({
type: Schema.Literal("directory"),
path: AbsolutePath,
}) {}
export class UrlSource extends Schema.Class<UrlSource>("SkillV2.UrlSource")({
type: Schema.Literal("url"),
url: Schema.String,
}) {}
export const Source = Schema.Union([DirectorySource, UrlSource]).pipe(
Schema.toTaggedUnion("type"),
withStatics(() => ({
equals: (a: DirectorySource | UrlSource, b: DirectorySource | UrlSource) => {
if (a.type !== b.type) return false
if (a.type === "directory" && b.type === "directory") return a.path === b.path
if (a.type === "url" && b.type === "url") return a.url === b.url
return false
},
key: (source: DirectorySource | UrlSource) =>
source.type === "directory" ? `directory:${source.path}` : `url:${source.url}`,
})),
)
export type Source = typeof Source.Type
export class Info extends Schema.Class<Info>("SkillV2.Info")({
name: Schema.String,
description: Schema.String.pipe(Schema.optional),
slash: Schema.Boolean.pipe(Schema.optional),
location: AbsolutePath,
content: Schema.String,
}) {}
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: Source[]
}
export type Editor = {
source: (source: Source) => void
list: () => readonly Source[]
}
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly sources: () => Effect.Effect<Source[]>
readonly list: () => Effect.Effect<Info[]>
readonly forAgent: (agent: AgentV2.ID) => Effect.Effect<Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Skill") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const discovery = yield* SkillDiscovery.Service
const fs = yield* FSUtil.Service
const state = State.create<Data, Editor>({
initial: () => ({ sources: [] }),
editor: (draft) => ({
source: (source) => {
if (draft.sources.some((item) => Source.equals(item, source))) return
draft.sources.push(castDraft(source))
},
list: () => draft.sources as Source[],
}),
})
const load = Effect.fn("SkillV2.load")(function* (source: Source) {
const skills: Info[] = []
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(new Info({
name,
description: frontmatter.description,
slash: frontmatter.slash,
location: AbsolutePath.make(filepath),
content: markdown.content,
}))
}
}
return skills
})
const cache = new Map<string, Info[]>()
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.set(skill.name, skill)
}
return Array.from(skills.values())
})
return Service.of({
transform: state.transform,
sources: Effect.fn("SkillV2.sources")(function* () {
return state.get().sources
}),
list,
forAgent: Effect.fn("SkillV2.forAgent")(function* (id) {
const current = yield* agent.get(id)
if (!current) return []
return (yield* list()).filter(
(skill) => PermissionV2.evaluate("skill", skill.name, current.permissions).effect !== "deny",
)
}),
})
}),
)
export const locationLayer = layer.pipe(
Layer.provide(SkillDiscovery.defaultLayer),
Layer.provideMerge(AgentV2.locationLayer),
)

View file

@ -0,0 +1,99 @@
export * as SkillDiscovery from "./discovery"
import path from "path"
import { Context, Effect, Layer, Schedule, Schema } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { FSUtil } from "../fs-util"
import { Global } from "../global"
import { AbsolutePath } from "../schema"
import * as Log from "../util/log"
const skillConcurrency = 4
const fileConcurrency = 8
class IndexSkill extends Schema.Class<IndexSkill>("SkillDiscovery.IndexSkill")({
name: Schema.String,
files: Schema.Array(Schema.String),
}) {}
class Index extends Schema.Class<Index>("SkillDiscovery.Index")({
skills: Schema.Array(IndexSkill),
}) {}
export interface Interface {
readonly pull: (url: string) => Effect.Effect<AbsolutePath[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SkillDiscovery") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const log = Log.create({ service: "skill-discovery" })
const http = (yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({
retryOn: "errors-and-responses",
times: 2,
schedule: Schedule.exponential(200).pipe(Schedule.jittered),
}),
HttpClient.filterStatusOk,
)
const download = Effect.fn("SkillDiscovery.download")(function* (url: string, destination: string) {
if (yield* fs.exists(destination).pipe(Effect.orDie)) return
yield* HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((response) => response.arrayBuffer),
Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))),
Effect.catch((error) => Effect.sync(() => log.error("failed to download skill file", { url, error }))),
)
})
return Service.of({
pull: Effect.fn("SkillDiscovery.pull")(function* (url) {
const base = url.endsWith("/") ? url : `${url}/`
const index = new URL("index.json", base).href
const data = yield* HttpClientRequest.get(index).pipe(
HttpClientRequest.acceptJson,
http.execute,
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
Effect.catch((error) => {
log.error("failed to fetch skill index", { url: index, error })
return Effect.succeed(undefined)
}),
)
if (!data) return []
return yield* Effect.forEach(
data.skills.filter((skill) => {
if (skill.files.includes("SKILL.md") || skill.files.includes(`${skill.name}.md`)) return true
log.warn("skill entry missing Markdown definition", { url: index, skill: skill.name })
return false
}),
(skill) =>
Effect.gen(function* () {
const root = path.join(global.cache, "skills", Bun.hash(base).toString(16), skill.name)
yield* Effect.forEach(
skill.files,
(file) => download(new URL(file, `${base}${skill.name}/`).href, path.join(root, file)),
{ concurrency: fileConcurrency, discard: true },
)
return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ||
(yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie))
? [AbsolutePath.make(root)]
: []
}),
{ concurrency: skillConcurrency },
).pipe(Effect.map((directories) => directories.flat()))
}),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Global.defaultLayer),
)

View file

@ -103,7 +103,7 @@ function agents(info: typeof ConfigV1.Info.Type) {
return Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : [])))
}
function migrateAgent(info: ConfigAgentV1.Info) {
export function migrateAgent(info: ConfigAgentV1.Info) {
const body = {
...info.options,
...(info.temperature === undefined ? {} : { temperature: info.temperature }),