Compare commits

...

2 Commits

Author SHA1 Message Date
Filip Hejmowski 0003689201 feat: add skill capability preferences 2026-08-20 01:06:41 +02:00
Filip Hejmowski d1b060e392 feat(tui): add tool-driven skill mentions 2026-08-20 00:55:48 +02:00
29 changed files with 480 additions and 138 deletions
+2
View File
@@ -2,6 +2,7 @@ import { NodeFileSystem } from "@effect/platform-node"
import { compile, emitEffectImported, emitEffectShape, emitPromise, write } from "@opencode-ai/httpapi-codegen"
import { ClientApi, effectOmitEndpoints, groupNames, promiseOmitEndpoints } from "@opencode-ai/protocol/client"
import { Agent } from "@opencode-ai/schema/agent"
import { Capability } from "@opencode-ai/schema/capability"
import { Command } from "@opencode-ai/schema/command"
import { Config } from "@opencode-ai/schema/config"
import { Credential } from "@opencode-ai/schema/credential"
@@ -43,6 +44,7 @@ const promiseContract = compile(ClientApi, { groupNames, omitEndpoints: promiseO
const effectContract = compile(ClientApi, { groupNames, omitEndpoints: effectOmitEndpoints })
const effectTypeReferences = [
...namespaceTypes("Agent", "@opencode-ai/schema/agent", Agent),
...namespaceTypes("Capability", "@opencode-ai/schema/capability", Capability),
...namespaceTypes("Command", "@opencode-ai/schema/command", Command),
...namespaceTypes("Config", "@opencode-ai/schema/config", Config),
...namespaceTypes("Credential", "@opencode-ai/schema/credential", Credential),
+21
View File
@@ -37,6 +37,7 @@ import type { Vcs } from "@opencode-ai/schema/vcs"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import type { WebSearch } from "@opencode-ai/schema/websearch"
import type { Config } from "@opencode-ai/schema/config"
import type { Capability } from "@opencode-ai/schema/capability"
export type Endpoint0_0Output = { readonly healthy: true; readonly version: string; readonly pid: number }
export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>
@@ -1631,6 +1632,25 @@ export interface ConfigApi<E = never> {
readonly get: ConfigGetOperation<E>
}
export type Endpoint29_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint29_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Capability.Info> }
export type CapabilityListOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
export type Endpoint29_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly ref: Capability.Ref
readonly state: "enabled" | "disabled" | "inherit"
}
export type Endpoint29_1Output = void
export type CapabilityUpdateOperation<E = never> = (input: Endpoint29_1Input) => Effect.Effect<Endpoint29_1Output, E>
export interface CapabilityApi<E = never> {
readonly list: CapabilityListOperation<E>
readonly update: CapabilityUpdateOperation<E>
}
export interface AppApi<E = never> {
readonly health: HealthApi<E>
readonly server: ServerApi<E>
@@ -1661,4 +1681,5 @@ export interface AppApi<E = never> {
readonly migration: MigrationApi<E>
readonly websearch: WebsearchApi<E>
readonly config: ConfigApi<E>
readonly capability: CapabilityApi<E>
}
@@ -224,6 +224,10 @@ import type {
Endpoint27_1Output,
Endpoint28_0Input,
Endpoint28_0Output,
Endpoint29_0Input,
Endpoint29_0Output,
Endpoint29_1Input,
Endpoint29_1Output,
} from "../api/api.js"
import { ClientError } from "./client-error.js"
@@ -1259,6 +1263,21 @@ const Endpoint28_0 = (raw: RawClient["server.config"]) => (input?: Endpoint28_0I
const adaptGroup28 = (raw: RawClient["server.config"]) => ({ get: Endpoint28_0(raw) })
const Endpoint29_0 = (raw: RawClient["server.capability"]) => (input?: Endpoint29_0Input) =>
preserveEffect<Endpoint29_0Output>()(
raw["capability.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint29_1 = (raw: RawClient["server.capability"]) => (input: Endpoint29_1Input) =>
preserveEffect<Endpoint29_1Output>()(
raw["capability.update"]({
query: { location: input["location"] },
payload: { ref: input["ref"], state: input["state"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup29 = (raw: RawClient["server.capability"]) => ({ list: Endpoint29_0(raw), update: Endpoint29_1(raw) })
const adaptClient = (raw: RawClient) => ({
health: adaptGroup0(raw["server.health"]),
server: adaptGroup1(raw["server.server"]),
@@ -1289,6 +1308,7 @@ const adaptClient = (raw: RawClient) => ({
migration: adaptGroup26(raw["server.migration"]),
websearch: adaptGroup27(raw["server.websearch"]),
config: adaptGroup28(raw["server.config"]),
capability: adaptGroup29(raw["server.capability"]),
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>
@@ -220,6 +220,10 @@ import type {
WebsearchQueryOutput,
ConfigGetInput,
ConfigGetOutput,
CapabilityListInput,
CapabilityListOutput,
CapabilityUpdateInput,
CapabilityUpdateOutput,
} from "./types.js"
import { ClientError } from "./client-error.js"
@@ -1840,6 +1844,33 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
capability: {
list: (input?: CapabilityListInput, requestOptions?: RequestOptions) =>
request<CapabilityListOutput>(
{
method: "GET",
path: `/api/capability`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
update: (input: CapabilityUpdateInput, requestOptions?: RequestOptions) =>
request<CapabilityUpdateOutput>(
{
method: "PUT",
path: `/api/capability`,
query: { location: input["location"] },
body: { ref: input["ref"], state: input["state"] },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
},
}
}
+49 -4
View File
@@ -130,6 +130,8 @@ export type SkillInfo = {
export type PermissionReply = "once" | "always" | "reject"
export type CapabilityRef = { kind: "skill"; key: [string, ...Array<string>] }
export type Pty = {
id: string
title: string
@@ -244,7 +246,7 @@ export type PromptFileAttachment = {
export type PromptAgentAttachment = { name: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; text: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; mention?: PromptMention }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
@@ -1062,6 +1064,24 @@ export type PermissionReplied = {
data: { sessionID: string; requestID: string; reply: PermissionReply }
}
export type CapabilityUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "capability.updated"
location?: LocationRef
data: { ref: CapabilityRef }
}
export type CapabilityInfo = {
ref: CapabilityRef
name: string
description?: string
defaultState: "enabled" | "disabled"
state: "enabled" | "disabled"
preference?: "enabled" | "disabled"
}
export type PtyCreated = {
id: string
created: number
@@ -2077,6 +2097,7 @@ export type V2Event =
| WorktreeResolved
| CommandUpdated
| ConfigUpdated
| CapabilityUpdated
| SkillUpdated
| PtyCreated
| PtyUpdated
@@ -2567,7 +2588,6 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@@ -2836,7 +2856,6 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@@ -3105,7 +3124,6 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@@ -5722,3 +5740,30 @@ export type ConfigGetInput = {
}
export type ConfigGetOutput = Array<ConfigEntry>
export type CapabilityListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type CapabilityListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<CapabilityInfo>
}
export type CapabilityUpdateInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly ref: {
readonly ref: { readonly kind: "skill"; readonly key: readonly [string, ...Array<string>] }
readonly state: "enabled" | "disabled" | "inherit"
}["ref"]
readonly state: {
readonly ref: { readonly kind: "skill"; readonly key: readonly [string, ...Array<string>] }
readonly state: "enabled" | "disabled" | "inherit"
}["state"]
}
export type CapabilityUpdateOutput = void
+71
View File
@@ -0,0 +1,71 @@
export * as Capability from "./capability.js"
import { Capability } from "@opencode-ai/schema/capability"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { Bus } from "./bus.js"
import { KV } from "./kv.js"
export const Ref = Capability.Ref
export type Ref = Capability.Ref
export const State = Capability.State
export type State = Capability.State
export const Preference = Capability.Preference
export type Preference = Capability.Preference
export const Info = Capability.Info
export type Info = Capability.Info
export const Update = Capability.Update
export type Update = Capability.Update
export const Event = Capability.Event
export const skill = (id: string) => Ref.make({ kind: "skill", key: [id] })
const Key = "capability:preferences"
const Preferences = Schema.Array(Preference)
const equals = Schema.toEquivalence(Ref)
export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Preference>>
readonly get: (ref: Ref) => Effect.Effect<State | undefined>
readonly resolve: (ref: Ref, fallback?: boolean) => Effect.Effect<State>
readonly set: (update: Update) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Capability") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const kv = yield* KV.Service
const load = Effect.fn("Capability.load")(function* () {
const stored = yield* kv.get(Key)
const decoded = Schema.decodeUnknownOption(Preferences)(stored)
if (stored !== undefined && Option.isNone(decoded)) yield* kv.remove(Key)
return Option.getOrElse(decoded, () => [])
})
const get = Effect.fn("Capability.get")(function* (ref: Ref) {
return (yield* load()).find((item) => equals(item.ref, ref))?.state
})
return Service.of({
list: load,
get,
resolve: Effect.fn("Capability.resolve")(function* (ref, fallback = true) {
return (yield* get(ref)) ?? (fallback ? "enabled" : "disabled")
}),
set: Effect.fn("Capability.set")(function* (update) {
const preferences = (yield* load()).filter((item) => !equals(item.ref, update.ref))
yield* kv.set(
Key,
update.state === "inherit" ? preferences : [...preferences, { ref: update.ref, state: update.state }],
)
yield* bus.publish(Event.Updated, { ref: update.ref })
}),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, KV.node] })
+2
View File
@@ -52,6 +52,7 @@ import { Tool } from "./tool.js"
import { ToolOutput } from "./tool-output.js"
import { Vcs } from "./vcs.js"
import { AbsolutePath } from "./schema.js"
import { Capability } from "./capability.js"
export { LocationServiceMap } from "./location-service-map.js"
@@ -59,6 +60,7 @@ const locationServiceNodes = [
Location.node,
Environment.node,
Config.node,
Capability.node,
Agent.node,
Command.node,
Reference.node,
-1
View File
@@ -982,7 +982,6 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
return Effect.succeed({
id: skill.id,
name: skill.name,
text: Skill.toModelOutput(skill, []),
mention: attachment.mention,
})
})
+1 -2
View File
@@ -138,8 +138,7 @@ const serialize = (message: SessionMessage.Info) => {
(file) =>
`[Attached ${file.mime}: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}]`,
) ?? []
const skills = message.skills?.map((skill) => `[Attached skill: ${skill.name}]\n${skill.text}`) ?? []
return [`[User]: ${message.text}`, ...skills, ...files].join("\n")
return [`[User]: ${message.text}`, ...files].join("\n")
}
if (message.type === "location-switched")
return `[User]: The working directory has been changed to ${message.location.directory}.`
@@ -227,7 +227,6 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
]
case "user":
const content = [
...(message.skills ?? []).map((skill) => Message.text(skill.text)),
...(message.text === "" ? [] : [Message.text(message.text)]),
...userAttachmentContent(message.files ?? []),
]
-1
View File
@@ -207,7 +207,6 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
skills: message.skills?.map((skill, index) => ({
...skill,
name: Skill.Name.make(redact("skill-name", String(index), skill.name)),
text: redact("skill", String(index), skill.text),
mention: skill.mention
? { ...skill.mention, text: redact("skill-mention", String(index), skill.mention.text) }
: undefined,
+16 -7
View File
@@ -5,6 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
import { Agent } from "../agent.js"
import { Skill } from "../skill.js"
import { Instructions } from "../instructions/index.js"
import { Capability } from "../capability.js"
const Summary = Schema.Struct({
id: Skill.ID,
@@ -26,6 +27,7 @@ const render = (skills: ReadonlyArray<Summary>) =>
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
"When the user references a skill with @skill-id, load that skill with the skill tool.",
...(skills.length === 0
? ["No skills are currently available."]
: ["<available_skills>", ...entries(skills), "</available_skills>"]),
@@ -66,18 +68,25 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const skills = yield* Skill.Service
const capability = yield* Capability.Service
return Service.of({
load: Effect.fn("SkillInstructions.load")(function* (selection) {
const agent = selection.info
if (!agent) return Instructions.empty
const permitted = Skill.available(yield* skills.list(), agent)
const available = permitted
.flatMap((skill) =>
skill.description === undefined || skill.autoinvoke === false
? []
: [{ id: skill.id, name: skill.name, description: skill.description }],
)
const available = (yield* Effect.forEach(permitted, (skill) =>
capability
.resolve(Capability.skill(skill.id), skill.autoinvoke !== false)
.pipe(
Effect.map((state) =>
state === "disabled" || skill.description === undefined
? undefined
: { id: skill.id, name: skill.name, description: skill.description },
),
),
))
.filter((skill): skill is Summary => skill !== undefined)
.toSorted((a, b) => a.id.localeCompare(b.id))
return Instructions.make<ReadonlyArray<Summary>>({
key: Instructions.Key.make("core/skill-guidance"),
@@ -94,4 +103,4 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Skill.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [Skill.node, Capability.node] })
+2 -2
View File
@@ -12,7 +12,7 @@ export const name = "skill"
const FILE_LIMIT = 10
export const Input = Schema.Struct({
id: Skill.ID.annotate({ description: "The ID of the skill from the available skills list" }),
id: Skill.ID.annotate({ description: "The ID of an available skill or a skill explicitly referenced by the user" }),
})
export const Output = Schema.Struct({
@@ -23,7 +23,7 @@ export const Output = Schema.Struct({
export const description = [
"Load a specialized skill's instructions and resources into the current conversation when the task at hand matches its description.",
"",
"The skill ID must match one of the available skills in the instructions.",
"The skill ID must match an available skill or a skill explicitly referenced by the user.",
].join("\n")
export const toModelOutput = Skill.toModelOutput
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Capability } from "@opencode-ai/core/capability"
import { Effect } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(Capability.node))
describe("Capability", () => {
it.effect("persists explicit preferences and restores inherited defaults", () =>
Effect.gen(function* () {
const capability = yield* Capability.Service
const ref = Capability.skill("effect")
expect(yield* capability.resolve(ref)).toBe("enabled")
yield* capability.set({ ref, state: "disabled" })
expect(yield* capability.get(ref)).toBe("disabled")
expect(yield* capability.resolve(ref)).toBe("disabled")
yield* capability.set({ ref, state: "inherit" })
expect(yield* capability.get(ref)).toBeUndefined()
expect(yield* capability.resolve(ref, false)).toBe("disabled")
}),
)
})
@@ -205,18 +205,18 @@ Recent work
})
})
test("lowers selected skill instructions with the original user prompt", () => {
test("does not inject skill content for reference-only attachments", () => {
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-skill"),
id: id("user-skill-reference"),
type: "user",
text: "Design this API",
text: "Use @api-design",
skills: [
SkillAttachment.make({
id: Skill.ID.make("api-design"),
name: Skill.Name.make("API design"),
text: "Start from the ideal call site.",
mention: { start: 4, end: 15, text: "@api-design" },
}),
],
time: { created },
@@ -225,17 +225,9 @@ Recent work
model,
)
expect(messages).toHaveLength(1)
expect(messages[0]).toMatchObject({
id: id("user-skill"),
role: "user",
content: [
{
type: "text",
text: "Start from the ideal call site.",
},
{ type: "text", text: "Design this API" },
],
content: [{ type: "text", text: "Use @api-design" }],
})
})
+5 -6
View File
@@ -56,7 +56,7 @@ const it = testEffect(
)
describe("Session.skill", () => {
it.effect("attaches a resolved skill snapshot to a normal prompt", () =>
it.effect("keeps skill mentions as references on a normal prompt", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const database = yield* Database.Service
@@ -67,8 +67,8 @@ describe("Session.skill", () => {
yield* sessions.prompt({
id,
sessionID: session.id,
text: "Apply this guidance",
skills: [{ id: Skill.ID.make("effect"), mention: { start: 20, end: 27, text: "/effect" } }],
text: "Apply @effect",
skills: [{ id: Skill.ID.make("effect"), mention: { start: 6, end: 13, text: "@effect" } }],
resume: false,
})
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
@@ -77,13 +77,12 @@ describe("Session.skill", () => {
expect.objectContaining({
id,
type: "user",
text: "Apply this guidance",
text: "Apply @effect",
skills: [
{
id: "effect",
name: "Effect",
text: expect.stringContaining("Use Effect"),
mention: { start: 20, end: 27, text: "/effect" },
mention: { start: 6, end: 13, text: "@effect" },
},
],
}),
+25 -1
View File
@@ -6,6 +6,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Skill } from "@opencode-ai/core/skill"
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
import { Capability } from "@opencode-ai/core/capability"
import { it } from "../lib/effect"
import { readInitial, readUpdate } from "../lib/instructions"
@@ -39,9 +40,16 @@ const manual = Skill.Info.make({
content: "Manual guidance",
})
const layer = (list: () => Skill.Info[]) =>
const layer = (list: () => Skill.Info[], preferences = new Map<string, Capability.State>()) =>
AppNodeBuilder.build(SkillInstructions.node, [
[Skill.node, Layer.mock(Skill.Service, { list: () => Effect.succeed(list()) })],
[
Capability.node,
Layer.mock(Capability.Service, {
resolve: (ref, fallback = true) =>
Effect.succeed(preferences.get(ref.key[0]) ?? (fallback ? "enabled" : "disabled")),
}),
],
])
describe("SkillInstructions", () => {
@@ -59,6 +67,7 @@ describe("SkillInstructions", () => {
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
"When the user references a skill with @skill-id, load that skill with the skill tool.",
"<available_skills>",
" <skill>",
" <id>effect</id>",
@@ -116,6 +125,21 @@ describe("SkillInstructions", () => {
}).pipe(Effect.provide(layer(() => skills)))
})
it.effect("applies capability preferences over skill autoinvoke defaults", () => {
const agent = Agent.Info.make(Agent.Info.default(build))
const preferences = new Map<string, Capability.State>([
["effect", "disabled"],
["manual", "enabled"],
])
return Effect.gen(function* () {
const instructions = yield* SkillInstructions.Service
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
expect(initialized.text).not.toContain("<id>effect</id>")
expect(initialized.text).toContain("<id>manual</id>")
}).pipe(Effect.provide(layer(() => [effect, manual], preferences)))
})
it.effect("restates the full skill list when a description changes", () => {
const agent = Agent.Info.make(Agent.Info.default(build))
let skills = [effect]
+3
View File
@@ -32,6 +32,7 @@ import { WorktreeGroup } from "./groups/worktree.js"
import { VcsGroup } from "./groups/vcs.js"
import { MigrationGroup } from "./groups/migration.js"
import { ConfigGroup } from "./groups/config.js"
import { CapabilityGroup } from "./groups/capability.js"
type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof LocationGroup, LocationId>
@@ -53,6 +54,7 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof ReferenceGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof VcsGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ConfigGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof CapabilityGroup, LocationId>
type SessionGroups<SessionLocationId extends HttpApiMiddleware.AnyId, SessionLocationService> =
| ReturnType<typeof makeSessionGroup<SessionLocationId, SessionLocationService>>
@@ -174,6 +176,7 @@ const makeApiFromGroup = <
.add(MigrationGroup)
.add(WebSearchGroup.middleware(locationMiddleware))
.add(ConfigGroup.middleware(locationMiddleware))
.add(CapabilityGroup.middleware(locationMiddleware))
.annotateMerge(
OpenApi.annotations({
title: "opencode HttpApi",
+1
View File
@@ -62,6 +62,7 @@ export const groupNames = {
"server.worktree": "worktree",
"server.vcs": "vcs",
"server.config": "config",
"server.capability": "capability",
} as const
export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"])
@@ -0,0 +1,37 @@
import { Capability } from "@opencode-ai/schema/capability"
import { Location } from "@opencode-ai/schema/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const CapabilityGroup = HttpApiGroup.make("server.capability")
.add(
HttpApiEndpoint.get("capability.list", "/api/capability", {
query: LocationQuery,
success: Location.response(Schema.Array(Capability.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.capability.list",
summary: "List capabilities",
description: "List manageable tools and MCP capabilities with their effective preference state.",
}),
),
)
.add(
HttpApiEndpoint.put("capability.update", "/api/capability", {
query: LocationQuery,
payload: Capability.Update,
success: HttpApiSchema.NoContent,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.capability.update",
summary: "Update capability preference",
description: "Set or inherit the global preference for one capability.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "capability" }))
+42
View File
@@ -0,0 +1,42 @@
export * as Capability from "./capability.js"
import { Schema } from "effect"
import { ephemeral, inventory } from "./event.js"
import { optional } from "./schema.js"
export const Kind = Schema.Literal("skill")
export type Kind = typeof Kind.Type
export interface Ref extends Schema.Schema.Type<typeof Ref> {}
export const Ref = Schema.Struct({
kind: Kind,
key: Schema.NonEmptyArray(Schema.String),
}).annotate({ identifier: "Capability.Ref" })
export const State = Schema.Literals(["enabled", "disabled"])
export type State = typeof State.Type
export interface Preference extends Schema.Schema.Type<typeof Preference> {}
export const Preference = Schema.Struct({
ref: Ref,
state: State,
}).annotate({ identifier: "Capability.Preference" })
export interface Update extends Schema.Schema.Type<typeof Update> {}
export const Update = Schema.Struct({
ref: Ref,
state: Schema.Union([State, Schema.Literal("inherit")]),
}).annotate({ identifier: "Capability.Update" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Info = Schema.Struct({
ref: Ref,
name: Schema.String,
description: Schema.String.pipe(optional),
defaultState: State,
state: State,
preference: State.pipe(optional),
}).annotate({ identifier: "Capability.Info" })
const Updated = ephemeral({ type: "capability.updated", schema: { ref: Ref } })
export const Event = { Updated, Definitions: inventory(Updated) }
+2
View File
@@ -2,6 +2,7 @@ export * as EventManifest from "./event-manifest.js"
import { Schema } from "effect"
import { Agent } from "./agent.js"
import { Capability } from "./capability.js"
import { Catalog } from "./catalog.js"
import { Command } from "./command.js"
import { Config } from "./config.js"
@@ -52,6 +53,7 @@ const featureDefinitions = Event.inventory(
...Worktree.Event.Definitions,
...Command.Event.Definitions,
...Config.Event.Definitions,
...Capability.Event.Definitions,
...Skill.Event.Definitions,
...Pty.Event.Definitions,
...Shell.Event.Definitions,
+1
View File
@@ -1,4 +1,5 @@
export { Agent } from "./agent.js"
export { Capability } from "./capability.js"
export { Command } from "./command.js"
export { Config } from "./config.js"
export { Connection } from "./connection.js"
-1
View File
@@ -57,7 +57,6 @@ export interface SkillAttachment extends Schema.Schema.Type<typeof SkillAttachme
export const SkillAttachment = Schema.Struct({
id: Skill.ID,
name: Skill.Name,
text: Schema.String,
mention: PromptMention.pipe(optional),
}).annotate({ identifier: "Prompt.SkillAttachment" })
+2
View File
@@ -29,6 +29,7 @@ import { VcsHandler } from "./handlers/vcs"
import { EventFeed } from "./event-feed"
import { MigrationHandler } from "./handlers/migration"
import { ConfigHandler } from "./handlers/config"
import { CapabilityHandler } from "./handlers/capability"
export const handlers = Layer.mergeAll(
HealthHandler,
@@ -60,4 +61,5 @@ export const handlers = Layer.mergeAll(
WorktreeHandler,
VcsHandler,
ConfigHandler,
CapabilityHandler,
)
@@ -0,0 +1,40 @@
import { Capability } from "@opencode-ai/core/capability"
import { Skill } from "@opencode-ai/core/skill"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
export const CapabilityHandler = HttpApiBuilder.group(Api, "server.capability", (handlers) =>
handlers
.handle(
"capability.list",
Effect.fn(function* () {
const capability = yield* Capability.Service
const skills = yield* Skill.Service
const info = yield* Effect.forEach(yield* skills.list(), (item) =>
Effect.gen(function* () {
const ref = Capability.skill(item.id)
const preference = yield* capability.get(ref)
return Capability.Info.make({
ref,
name: item.name,
description: item.description,
defaultState: item.autoinvoke === false ? "disabled" : "enabled",
preference,
state: yield* capability.resolve(ref, item.autoinvoke !== false),
})
}),
)
return yield* response(Effect.succeed(info))
}),
)
.handle(
"capability.update",
Effect.fn(function* (ctx) {
const capability = yield* Capability.Service
yield* capability.set(ctx.payload)
return HttpApiSchema.NoContent.make()
}),
),
)
+56 -50
View File
@@ -1,87 +1,93 @@
import { TextAttributes } from "@opentui/core"
import type { CapabilityInfo, LocationRef } from "@opencode-ai/client"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { createResource, createMemo, createSignal, Match, Switch } from "solid-js"
import { createResource, createMemo, createSignal } from "solid-js"
import { useDialog } from "../ui/dialog"
import { useTheme } from "../context/theme"
import { errorMessage } from "../util/error"
import { useData } from "../context/data"
import type { LocationRef } from "@opencode-ai/client"
import { useClient } from "../context/client"
import { useToast } from "../ui/toast"
export type DialogSkillProps = {
location?: LocationRef
onSelect: (skill: string) => void
}
export function DialogSkill(props: DialogSkillProps) {
const dialog = useDialog()
const data = useData()
const client = useClient()
const toast = useToast()
const theme = useTheme()
dialog.setSize("large")
const [loadError, setLoadError] = createSignal<unknown>()
const [pending, setPending] = createSignal<string>()
const [skills] = createResource(() =>
Promise.resolve()
.then(async () => {
const current = data.location.skill.list(props.location)
if (current) return current
await data.location.skill.sync(props.location)
return data.location.skill.list(props.location) ?? []
})
// Catch so the rejected resource never reaches the memo below: reading
// skills() in an errored state re-throws and tears down the dialog.
.catch((error) => {
const location = () =>
props.location ? { directory: props.location.directory, workspace: props.location.workspaceID } : undefined
const [skills, { mutate }] = createResource<CapabilityInfo[]>(() =>
client.api.capability.list({ location: location() }).then(
(result) => result.data,
(error) => {
setLoadError(error)
return undefined
}),
return []
},
),
)
const showError = createMemo(() => Boolean(loadError()))
const key = (ref: CapabilityInfo["ref"]) => JSON.stringify([ref.kind, ...ref.key])
const toggle = async (skill: CapabilityInfo) => {
const id = key(skill.ref)
if (pending()) return
const state: CapabilityInfo["state"] = skill.state === "enabled" ? "disabled" : "enabled"
const preference: CapabilityInfo["preference"] = state === skill.defaultState ? undefined : state
setPending(id)
mutate((current) => current?.map((item) => (key(item.ref) === id ? { ...item, state, preference } : item)))
const error = await client.api.capability
.update({ ref: skill.ref, state: preference ?? "inherit", location: location() })
.then(
() => undefined,
(error) => error,
)
if (error) {
mutate((current) => current?.map((item) => (key(item.ref) === id ? skill : item)))
toast.show({ title: "Could not update skill", message: errorMessage(error), variant: "error" })
}
setPending(undefined)
}
const options = createMemo<DialogSelectOption<string>[]>(() => {
if (showError()) return []
const list = skills() ?? []
const maxWidth = Math.max(0, ...list.map((s) => s.name.length))
return list.map((skill) => ({
title: skill.name.padEnd(maxWidth),
title: `[${skill.state === "enabled" ? "x" : " "}] ${skill.name}`,
description: skill.description?.replace(/\s+/g, " ").trim(),
value: skill.id,
onSelect: () => {
props.onSelect(skill.id)
dialog.clear()
},
searchText: `${skill.ref.key.join(" ")} ${skill.name} ${skill.description ?? ""}`,
footer: pending() === key(skill.ref) ? "updating" : skill.preference ? "custom" : "default",
footerColor: theme.text.subdued,
value: key(skill.ref),
onSelect: () => void toggle(skill),
}))
})
return (
<DialogSelect
title="Skills"
placeholder="Search skills"
options={options()}
renderFilter={!showError() && !skills.loading}
locked={showError() || skills.loading}
preserveSelection
footerHints={[{ title: "toggle", label: "enter" }]}
locked={skills.loading && skills() === undefined}
emptyView={
<Switch
fallback={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No skills available</text>
</box>
}
>
<Match when={showError()}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Could not load skills
</text>
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
<text fg={theme.text.subdued}>Close and reopen Skills to try again.</text>
</box>
</Match>
<Match when={skills.loading}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>Loading skills</text>
</box>
</Match>
</Switch>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>
{skills.loading
? "Loading skills…"
: showError()
? `Could not load skills: ${errorMessage(loadError())}`
: "No skills available"}
</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
@@ -176,7 +176,7 @@ export function Autocomplete(props: {
const charAfterCursor = displayCharAt(props.value, currentCursorOffset)
const needsSpace = charAfterCursor !== " "
const prefix = part.type === "skill" ? "/" : "@"
const prefix = "@"
const append = prefix + text + (needsSpace ? " " : "")
input.cursorOffset = store.index
@@ -478,6 +478,22 @@ export function Autocomplete(props: {
)
})
const skillOptions = createMemo(() =>
(data.location.skill.list(location.current) ?? []).map(
(skill): AutocompleteOption => ({
display: "@" + skill.id,
description: skill.description,
kind: "skill",
onSelect: () => {
insertPart(skill.id, {
type: "skill",
value: { id: Skill.ID.make(skill.id), mention: { start: 0, end: 0, text: "" } },
})
},
}),
),
)
const referenceAliases = createMemo(() =>
references()
.filter((reference) => !reference.hidden)
@@ -537,11 +553,7 @@ export function Autocomplete(props: {
display: "/" + skill.id,
description: skill.description,
kind: "skill",
onSelect: () =>
insertPart(skill.id, {
type: "skill",
value: { id: Skill.ID.make(skill.id), mention: { start: 0, end: 0, text: "" } },
}),
onSelect: () => insertSlash(skill.id),
})
}
@@ -592,10 +604,10 @@ export function Autocomplete(props: {
const fileOptions: AutocompleteOption[] = store.visible === "reference" ? fileSearch.options : []
const nonFileOptions: AutocompleteOption[] =
store.visible === "reference"
? [...referenceAliasesValue, ...agentsValue, ...mcpResources()]
? [...skillOptions(), ...referenceAliasesValue, ...agentsValue, ...mcpResources()]
: store.index === 0
? [...commandsValue]
: commandsValue.filter((item) => item.kind === "skill")
: []
if (!searchValue) {
return [...nonFileOptions, ...fileOptions]
+1 -41
View File
@@ -30,7 +30,6 @@ import { stringWidth } from "../../util/string-width"
import { createStore, produce, unwrap } from "solid-js/store"
import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history"
import { saveDraft, takeDraft } from "./draft-stash"
import { Skill } from "@opencode-ai/schema/skill"
import { computePromptTraits } from "../../prompt/traits"
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
import { usePromptStash } from "../../prompt/stash"
@@ -42,10 +41,10 @@ import { errorMessage } from "../../util/error"
import { createColors, createFrames } from "../../ui/spinner"
import { useDialog } from "../../ui/dialog"
import { DialogIntegration } from "../dialog-integration"
import { DialogSkill } from "../dialog-skill"
import { useConnected } from "../use-connected"
import { useToast } from "../../ui/toast"
import { createFadeIn } from "../../util/signal"
import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args"
import { useConfig } from "../../config"
import { usePromptMove } from "./move"
@@ -582,44 +581,6 @@ export function Prompt(props: PromptProps) {
input.cursorOffset = stringWidth(normalized)
},
},
{
title: "Skills",
name: "prompt.skills",
category: "Prompt",
slash: { name: "skills" },
run: () => {
dialog.replace(() => (
<DialogSkill
location={currentLocation.current}
onSelect={(skill) => {
if (store.prompt.skills?.some((item) => item.id === skill)) return
const text = `/${skill}`
const start = input.cursorOffset
input.insertText(text + " ")
const extmarkId = input.extmarks.create({
start,
end: start + promptOffsetWidth(text),
virtual: true,
styleId: skillStyleId,
typeId: promptPartTypeId,
})
setStore(
produce((draft) => {
draft.prompt.text = input.plainText
const skills = (draft.prompt.skills ??= [])
const index = skills.length
skills.push({
id: Skill.ID.make(skill),
mention: { start, end: start + promptOffsetWidth(text), text },
})
draft.extmarkToPart.set(extmarkId, { type: "skill", index })
}),
)
}}
/>
))
},
},
{
title: "Move session",
desc: "Move to another project dir",
@@ -661,7 +622,6 @@ export function Prompt(props: PromptProps) {
"prompt.stash",
"prompt.stash.pop",
"prompt.stash.list",
"prompt.skills",
"session.interrupt",
"session.background",
"session.move",