diff --git a/packages/client/script/build.ts b/packages/client/script/build.ts index 544d5229fa87..fcb37cb863c5 100644 --- a/packages/client/script/build.ts +++ b/packages/client/script/build.ts @@ -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), diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index dbcc071fc2cb..debe44a1e702 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -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 = () => Effect.Effect @@ -1631,6 +1632,25 @@ export interface ConfigApi { readonly get: ConfigGetOperation } +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 } +export type CapabilityListOperation = (input?: Endpoint29_0Input) => Effect.Effect + +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 = (input: Endpoint29_1Input) => Effect.Effect + +export interface CapabilityApi { + readonly list: CapabilityListOperation + readonly update: CapabilityUpdateOperation +} + export interface AppApi { readonly health: HealthApi readonly server: ServerApi @@ -1661,4 +1681,5 @@ export interface AppApi { readonly migration: MigrationApi readonly websearch: WebsearchApi readonly config: ConfigApi + readonly capability: CapabilityApi } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index dccea27fa090..b7621ad29d86 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -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()( + raw["capability.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)), + ) + +const Endpoint29_1 = (raw: RawClient["server.capability"]) => (input: Endpoint29_1Input) => + preserveEffect()( + 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 }) => diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 3d5b02b21e03..dfa374d8daab 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -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( + { + method: "GET", + path: `/api/capability`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + update: (input: CapabilityUpdateInput, requestOptions?: RequestOptions) => + request( + { + method: "PUT", + path: `/api/capability`, + query: { location: input["location"] }, + body: { ref: input["ref"], state: input["state"] }, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + }, } } diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 0d54ac34f990..72ddfe1482e9 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -130,6 +130,8 @@ export type SkillInfo = { export type PermissionReply = "once" | "always" | "reject" +export type CapabilityRef = { kind: "skill"; key: [string, ...Array] } + 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 + +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 +} + +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] } + readonly state: "enabled" | "disabled" | "inherit" + }["ref"] + readonly state: { + readonly ref: { readonly kind: "skill"; readonly key: readonly [string, ...Array] } + readonly state: "enabled" | "disabled" | "inherit" + }["state"] +} + +export type CapabilityUpdateOutput = void diff --git a/packages/core/src/capability.ts b/packages/core/src/capability.ts new file mode 100644 index 000000000000..fa3c66694639 --- /dev/null +++ b/packages/core/src/capability.ts @@ -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> + readonly get: (ref: Ref) => Effect.Effect + readonly resolve: (ref: Ref, fallback?: boolean) => Effect.Effect + readonly set: (update: Update) => Effect.Effect +} + +export class Service extends Context.Service()("@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] }) diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 45ce6486955c..9d57f2492492 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -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, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 9fcaa94b7f01..2d0c11b5a556 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -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, }) }) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index e1d864180404..970c9ef1be80 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -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}.` diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index f84b3a020951..698af2795cbb 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -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 ?? []), ] diff --git a/packages/core/src/session/transfer.ts b/packages/core/src/session/transfer.ts index 6682e5051ee6..843d9231134f 100644 --- a/packages/core/src/session/transfer.ts +++ b/packages/core/src/session/transfer.ts @@ -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, diff --git a/packages/core/src/skill/instructions.ts b/packages/core/src/skill/instructions.ts index 168356fd58a1..72498567f6f2 100644 --- a/packages/core/src/skill/instructions.ts +++ b/packages/core/src/skill/instructions.ts @@ -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) => [ "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."] : ["", ...entries(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>({ 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] }) diff --git a/packages/core/src/tool/plugin/skill.ts b/packages/core/src/tool/plugin/skill.ts index 8269bdd07669..d7af8b50b66e 100644 --- a/packages/core/src/tool/plugin/skill.ts +++ b/packages/core/src/tool/plugin/skill.ts @@ -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 diff --git a/packages/core/test/capability.test.ts b/packages/core/test/capability.test.ts new file mode 100644 index 000000000000..f5bc874442ac --- /dev/null +++ b/packages/core/test/capability.test.ts @@ -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") + }), + ) +}) diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index f624c402de8a..a3a677329c4b 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -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" }], }) }) diff --git a/packages/core/test/session-skill.test.ts b/packages/core/test/session-skill.test.ts index db30e5306115..61ff5179d14d 100644 --- a/packages/core/test/session-skill.test.ts +++ b/packages/core/test/session-skill.test.ts @@ -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" }, }, ], }), diff --git a/packages/core/test/skill/instructions.test.ts b/packages/core/test/skill/instructions.test.ts index dcb54d478edd..3c2d4cfe4135 100644 --- a/packages/core/test/skill/instructions.test.ts +++ b/packages/core/test/skill/instructions.test.ts @@ -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()) => 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.", "", " ", " effect", @@ -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([ + ["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("effect") + expect(initialized.text).toContain("manual") + }).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] diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index e6ca152bfda8..e572b78b6056 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -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 = | HttpApiGroup.AddMiddleware @@ -53,6 +54,7 @@ type LocationGroups = | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware | HttpApiGroup.AddMiddleware + | HttpApiGroup.AddMiddleware type SessionGroups = | ReturnType> @@ -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", diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 50d3003a946f..8984a7e65529 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -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"]) diff --git a/packages/protocol/src/groups/capability.ts b/packages/protocol/src/groups/capability.ts new file mode 100644 index 000000000000..e106270dfdff --- /dev/null +++ b/packages/protocol/src/groups/capability.ts @@ -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" })) diff --git a/packages/schema/src/capability.ts b/packages/schema/src/capability.ts new file mode 100644 index 000000000000..9a6dac9ffab7 --- /dev/null +++ b/packages/schema/src/capability.ts @@ -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 {} +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 {} +export const Preference = Schema.Struct({ + ref: Ref, + state: State, +}).annotate({ identifier: "Capability.Preference" }) + +export interface Update extends Schema.Schema.Type {} +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 {} +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) } diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 156a3df1b809..f9026d550f3e 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -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, diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index e63522a80892..a43fe9c8d1da 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -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" diff --git a/packages/schema/src/prompt.ts b/packages/schema/src/prompt.ts index 19a05449b4d8..a0a434141dbd 100644 --- a/packages/schema/src/prompt.ts +++ b/packages/schema/src/prompt.ts @@ -57,7 +57,6 @@ export interface SkillAttachment extends Schema.Schema.Type + 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() + }), + ), +) diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx index bbcd62cf41e1..4fbafb7e237e 100644 --- a/packages/tui/src/component/dialog-skill.tsx +++ b/packages/tui/src/component/dialog-skill.tsx @@ -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() + const [pending, setPending] = createSignal() - 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(() => + 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[]>(() => { 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 ( - No skills available - - } - > - - - - Could not load skills - - {errorMessage(loadError())} - Close and reopen Skills to try again. - - - - - Loading skills… - - - + + + {skills.loading + ? "Loading skills…" + : showError() + ? `Could not load skills: ${errorMessage(loadError())}` + : "No skills available"} + + } noMatchView={ diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 4600491f90c4..ddba35e84f08 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -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] diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index e3e37358a8d6..832f09d62429 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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(() => ( - { - 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",