feat(core): add skill autoinvoke metadata
This commit is contained in:
@@ -2803,6 +2803,7 @@ export type SkillListOutput = {
|
|||||||
readonly name: string
|
readonly name: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
readonly slash?: boolean
|
readonly slash?: boolean
|
||||||
|
readonly autoinvoke?: boolean
|
||||||
readonly location: string
|
readonly location: string
|
||||||
readonly content: string
|
readonly content: string
|
||||||
}>
|
}>
|
||||||
|
|||||||
@@ -38,9 +38,23 @@ const Frontmatter = Schema.Struct({
|
|||||||
name: Schema.String.pipe(Schema.optional),
|
name: Schema.String.pipe(Schema.optional),
|
||||||
description: Schema.String.pipe(Schema.optional),
|
description: Schema.String.pipe(Schema.optional),
|
||||||
slash: Schema.Boolean.pipe(Schema.optional),
|
slash: Schema.Boolean.pipe(Schema.optional),
|
||||||
|
metadata: Schema.Unknown.pipe(Schema.optional),
|
||||||
})
|
})
|
||||||
const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter)
|
const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter)
|
||||||
|
|
||||||
|
const metadataBoolean = (metadata: unknown, key: string) => {
|
||||||
|
if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const value = (metadata as { readonly [key: string]: unknown })[key]
|
||||||
|
if (typeof value === "boolean") return value
|
||||||
|
if (typeof value !== "string") return undefined
|
||||||
|
const normalized = value.trim().toLowerCase()
|
||||||
|
if (normalized === "true") return true
|
||||||
|
if (normalized === "false") return false
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
export type Data = {
|
export type Data = {
|
||||||
sources: Types.DeepMutable<Source>[]
|
sources: Types.DeepMutable<Source>[]
|
||||||
}
|
}
|
||||||
@@ -108,7 +122,8 @@ export const layer = Layer.effect(
|
|||||||
skills.push({
|
skills.push({
|
||||||
name,
|
name,
|
||||||
description: frontmatter.description,
|
description: frontmatter.description,
|
||||||
slash: frontmatter.slash,
|
slash: metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash,
|
||||||
|
autoinvoke: metadataBoolean(frontmatter.metadata, "opencode/autoinvoke"),
|
||||||
location: AbsolutePath.make(filepath),
|
location: AbsolutePath.make(filepath),
|
||||||
content: markdown.content,
|
content: markdown.content,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ export const layer = Layer.effect(
|
|||||||
return SystemContext.empty
|
return SystemContext.empty
|
||||||
const available = permitted
|
const available = permitted
|
||||||
.flatMap((skill) =>
|
.flatMap((skill) =>
|
||||||
skill.description === undefined ? [] : [{ name: skill.name, description: skill.description }],
|
skill.description === undefined || skill.autoinvoke === false
|
||||||
|
? []
|
||||||
|
: [{ name: skill.name, description: skill.description }],
|
||||||
)
|
)
|
||||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
return SystemContext.make({
|
return SystemContext.make({
|
||||||
|
|||||||
@@ -140,6 +140,46 @@ describe("SkillV2", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("parses opencode metadata flags from skill frontmatter", () =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
).pipe(
|
||||||
|
Effect.flatMap((tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.promise(async () => {
|
||||||
|
await fs.mkdir(path.join(tmp.path, "manual"), { recursive: true })
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tmp.path, "manual", "SKILL.md"),
|
||||||
|
`---
|
||||||
|
name: manual
|
||||||
|
description: Manual only
|
||||||
|
metadata:
|
||||||
|
opencode/slash: true
|
||||||
|
opencode/autoinvoke: false
|
||||||
|
---
|
||||||
|
# manual`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const skill = yield* SkillV2.Service
|
||||||
|
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
|
||||||
|
|
||||||
|
expect(yield* skill.list()).toEqual([
|
||||||
|
{
|
||||||
|
name: "manual",
|
||||||
|
description: "Manual only",
|
||||||
|
slash: true,
|
||||||
|
autoinvoke: false,
|
||||||
|
location: AbsolutePath.make(path.join(tmp.path, "manual", "SKILL.md")),
|
||||||
|
content: "# manual",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("invalidates cached skills and publishes updates for watcher changes", () =>
|
it.live("invalidates cached skills and publishes updates for watcher changes", () =>
|
||||||
Effect.acquireRelease(
|
Effect.acquireRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ const denied = SkillV2.Info.make({
|
|||||||
location: AbsolutePath.make(path.resolve("/skills/denied/SKILL.md")),
|
location: AbsolutePath.make(path.resolve("/skills/denied/SKILL.md")),
|
||||||
content: "Denied guidance",
|
content: "Denied guidance",
|
||||||
})
|
})
|
||||||
|
const manual = SkillV2.Info.make({
|
||||||
|
name: "manual",
|
||||||
|
description: "Load only when explicitly selected",
|
||||||
|
autoinvoke: false,
|
||||||
|
location: AbsolutePath.make(path.resolve("/skills/manual/SKILL.md")),
|
||||||
|
content: "Manual guidance",
|
||||||
|
})
|
||||||
|
|
||||||
const layer = (list: () => SkillV2.Info[]) =>
|
const layer = (list: () => SkillV2.Info[]) =>
|
||||||
SkillGuidance.layer.pipe(Layer.provide(Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })))
|
SkillGuidance.layer.pipe(Layer.provide(Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })))
|
||||||
@@ -36,7 +43,7 @@ describe("SkillGuidance", () => {
|
|||||||
...AgentV2.Info.empty(build),
|
...AgentV2.Info.empty(build),
|
||||||
permissions: [{ action: "skill", resource: "denied", effect: "deny" }],
|
permissions: [{ action: "skill", resource: "denied", effect: "deny" }],
|
||||||
})
|
})
|
||||||
let skills = [hidden, denied, effect]
|
let skills = [hidden, denied, manual, effect]
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const guidance = yield* SkillGuidance.Service
|
const guidance = yield* SkillGuidance.Service
|
||||||
const initialized = yield* guidance
|
const initialized = yield* guidance
|
||||||
@@ -55,6 +62,7 @@ describe("SkillGuidance", () => {
|
|||||||
"</available_skills>",
|
"</available_skills>",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
)
|
)
|
||||||
|
expect(initialized.baseline).not.toContain("manual")
|
||||||
|
|
||||||
skills = []
|
skills = []
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const Info = Schema.Struct({
|
|||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
description: Schema.String.pipe(optional),
|
description: Schema.String.pipe(optional),
|
||||||
slash: Schema.Boolean.pipe(optional),
|
slash: Schema.Boolean.pipe(optional),
|
||||||
|
autoinvoke: Schema.Boolean.pipe(optional),
|
||||||
location: AbsolutePath,
|
location: AbsolutePath,
|
||||||
content: Schema.String,
|
content: Schema.String,
|
||||||
}).annotate({ identifier: "SkillV2.Info" })
|
}).annotate({ identifier: "SkillV2.Info" })
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Global definitions are also loaded from `~/.config/opencode/skills/*/SKILL.md`,
|
|||||||
## Write frontmatter
|
## Write frontmatter
|
||||||
|
|
||||||
Each `SKILL.md` must start with YAML frontmatter.
|
Each `SKILL.md` must start with YAML frontmatter.
|
||||||
Only these fields are recognized:
|
Use these Agent Skills fields:
|
||||||
|
|
||||||
- `name` (required)
|
- `name` (required)
|
||||||
- `description` (required)
|
- `description` (required)
|
||||||
@@ -42,7 +42,15 @@ Only these fields are recognized:
|
|||||||
- `compatibility` (optional)
|
- `compatibility` (optional)
|
||||||
- `metadata` (optional, string-to-string map)
|
- `metadata` (optional, string-to-string map)
|
||||||
|
|
||||||
Unknown frontmatter fields are ignored.
|
Put OpenCode-specific behavior in `metadata` instead of adding custom top-level fields.
|
||||||
|
|
||||||
|
OpenCode recognizes these optional `metadata` keys:
|
||||||
|
|
||||||
|
- `opencode/slash`: set to `"true"` to expose the skill as a `/name` command in the TUI
|
||||||
|
- `opencode/autoinvoke`: set to `"false"` to keep the skill out of model-facing auto-selection guidance
|
||||||
|
|
||||||
|
The Agent Skills spec defines `metadata` values as strings, so quoted values are the portable form.
|
||||||
|
OpenCode also accepts YAML booleans like `opencode/slash: true` and `opencode/autoinvoke: false`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -84,6 +92,8 @@ compatibility: opencode
|
|||||||
metadata:
|
metadata:
|
||||||
audience: maintainers
|
audience: maintainers
|
||||||
workflow: github
|
workflow: github
|
||||||
|
opencode/slash: "true"
|
||||||
|
opencode/autoinvoke: "false"
|
||||||
---
|
---
|
||||||
|
|
||||||
## What I do
|
## What I do
|
||||||
@@ -120,6 +130,9 @@ The agent loads a skill by calling the tool:
|
|||||||
skill({ name: "git-release" })
|
skill({ name: "git-release" })
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Skills with `metadata["opencode/autoinvoke"]` set to `"false"` are not included in `<available_skills>`.
|
||||||
|
They can still be selected manually and loaded by exact name.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configure permissions
|
## Configure permissions
|
||||||
@@ -220,3 +233,4 @@ If a skill does not show up:
|
|||||||
2. Check that frontmatter includes `name` and `description`
|
2. Check that frontmatter includes `name` and `description`
|
||||||
3. Ensure skill names are unique across all locations
|
3. Ensure skill names are unique across all locations
|
||||||
4. Check permissions—skills with `deny` are hidden from agents
|
4. Check permissions—skills with `deny` are hidden from agents
|
||||||
|
5. Check `metadata["opencode/autoinvoke"]`—skills set to `"false"` are hidden from model-facing guidance
|
||||||
|
|||||||
Reference in New Issue
Block a user