mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 03:58:29 +00:00
feat(core): add native skill activation
This commit is contained in:
parent
524ee8fc03
commit
23adaaaeab
26 changed files with 814 additions and 76 deletions
|
|
@ -1,6 +1,7 @@
|
|||
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- The default branch in this repo is `dev`.
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
|
||||
|
|
|
|||
|
|
@ -56,9 +56,11 @@ export const layer = Layer.effect(
|
|||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const directory = Global.Path.state
|
||||
const file = path.join(directory, InstallationChannel === "local" ? "service-local.json" : "service.json")
|
||||
const configFile = path.join(Global.Path.config, "service.json")
|
||||
const global = yield* Global.Service
|
||||
const directory = global.state
|
||||
const filename = InstallationChannel === "local" ? "service-local.json" : "service.json"
|
||||
const file = path.join(directory, filename)
|
||||
const configFile = path.join(global.config, filename)
|
||||
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
|
||||
const decodeServiceConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(ServiceConfig))
|
||||
|
||||
|
|
@ -311,7 +313,7 @@ export const layer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Global.defaultLayer))
|
||||
|
||||
function serviceURL(config: ServiceConfig) {
|
||||
const hostname = config.hostname ?? "127.0.0.1"
|
||||
|
|
|
|||
30
packages/cli/test/daemon.test.ts
Normal file
30
packages/cli/test/daemon.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { Daemon } from "../src/services/daemon"
|
||||
|
||||
test("local channel stores service config with the local service filename", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-daemon-"))
|
||||
try {
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const daemon = yield* Daemon.Service
|
||||
yield* daemon.set("autostart", "false")
|
||||
}).pipe(
|
||||
Effect.provide(Daemon.layer),
|
||||
Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({
|
||||
autostart: false,
|
||||
})
|
||||
expect(await Bun.file(path.join(root, "config", "service.json")).exists()).toBe(false)
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
|
@ -144,23 +144,36 @@ const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Inp
|
|||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint3_9Input = { readonly sessionID: Endpoint3_9Request["params"]["sessionID"] }
|
||||
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.skill"]>[0]
|
||||
type Endpoint3_9Input = {
|
||||
readonly sessionID: Endpoint3_9Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint3_9Request["payload"]["id"]
|
||||
readonly skill: Endpoint3_9Request["payload"]["skill"]
|
||||
readonly resume?: Endpoint3_9Request["payload"]["resume"]
|
||||
}
|
||||
const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
raw["session.skill"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] }
|
||||
const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] }
|
||||
const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) =>
|
||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint3_11Input = {
|
||||
readonly sessionID: Endpoint3_11Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint3_11Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint3_11Request["payload"]["files"]
|
||||
type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint3_12Input = {
|
||||
readonly sessionID: Endpoint3_12Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint3_12Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint3_12Request["payload"]["files"]
|
||||
}
|
||||
const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) =>
|
||||
const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) =>
|
||||
raw["session.revert.stage"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { messageID: input["messageID"], files: input["files"] },
|
||||
|
|
@ -169,42 +182,42 @@ const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11I
|
|||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] }
|
||||
const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) =>
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint3_13Input = { readonly sessionID: Endpoint3_13Request["params"]["sessionID"] }
|
||||
const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) =>
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint3_14Input = { readonly sessionID: Endpoint3_14Request["params"]["sessionID"] }
|
||||
const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint3_15Input = { readonly sessionID: Endpoint3_15Request["params"]["sessionID"] }
|
||||
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||
type Endpoint3_15Input = {
|
||||
readonly sessionID: Endpoint3_15Request["params"]["sessionID"]
|
||||
readonly limit?: Endpoint3_15Request["query"]["limit"]
|
||||
readonly after?: Endpoint3_15Request["query"]["after"]
|
||||
type Endpoint3_16Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||
type Endpoint3_16Input = {
|
||||
readonly sessionID: Endpoint3_16Request["params"]["sessionID"]
|
||||
readonly limit?: Endpoint3_16Request["query"]["limit"]
|
||||
readonly after?: Endpoint3_16Request["query"]["after"]
|
||||
}
|
||||
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
|
||||
const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) =>
|
||||
raw["session.history"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { limit: input["limit"], after: input["after"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_16Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint3_16Input = {
|
||||
readonly sessionID: Endpoint3_16Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint3_16Request["query"]["after"]
|
||||
type Endpoint3_17Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint3_17Input = {
|
||||
readonly sessionID: Endpoint3_17Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint3_17Request["query"]["after"]
|
||||
}
|
||||
const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) =>
|
||||
const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) =>
|
||||
Stream.unwrap(
|
||||
raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
|
|
@ -212,17 +225,17 @@ const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16I
|
|||
),
|
||||
)
|
||||
|
||||
type Endpoint3_17Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint3_17Input = { readonly sessionID: Endpoint3_17Request["params"]["sessionID"] }
|
||||
const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) =>
|
||||
type Endpoint3_18Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint3_18Input = { readonly sessionID: Endpoint3_18Request["params"]["sessionID"] }
|
||||
const Endpoint3_18 = (raw: RawClient["server.session"]) => (input: Endpoint3_18Input) =>
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_18Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint3_18Input = {
|
||||
readonly sessionID: Endpoint3_18Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint3_18Request["params"]["messageID"]
|
||||
type Endpoint3_19Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint3_19Input = {
|
||||
readonly sessionID: Endpoint3_19Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint3_19Request["params"]["messageID"]
|
||||
}
|
||||
const Endpoint3_18 = (raw: RawClient["server.session"]) => (input: Endpoint3_18Input) =>
|
||||
const Endpoint3_19 = (raw: RawClient["server.session"]) => (input: Endpoint3_19Input) =>
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
|
|
@ -238,16 +251,17 @@ const adaptGroup3 = (raw: RawClient["server.session"]) => ({
|
|||
switchModel: Endpoint3_6(raw),
|
||||
rename: Endpoint3_7(raw),
|
||||
prompt: Endpoint3_8(raw),
|
||||
compact: Endpoint3_9(raw),
|
||||
wait: Endpoint3_10(raw),
|
||||
stage: Endpoint3_11(raw),
|
||||
clear: Endpoint3_12(raw),
|
||||
commit: Endpoint3_13(raw),
|
||||
context: Endpoint3_14(raw),
|
||||
history: Endpoint3_15(raw),
|
||||
events: Endpoint3_16(raw),
|
||||
interrupt: Endpoint3_17(raw),
|
||||
message: Endpoint3_18(raw),
|
||||
skill: Endpoint3_9(raw),
|
||||
compact: Endpoint3_10(raw),
|
||||
wait: Endpoint3_11(raw),
|
||||
stage: Endpoint3_12(raw),
|
||||
clear: Endpoint3_13(raw),
|
||||
commit: Endpoint3_14(raw),
|
||||
context: Endpoint3_15(raw),
|
||||
history: Endpoint3_16(raw),
|
||||
events: Endpoint3_17(raw),
|
||||
interrupt: Endpoint3_18(raw),
|
||||
message: Endpoint3_19(raw),
|
||||
})
|
||||
|
||||
type Endpoint4_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import type {
|
|||
SessionRenameOutput,
|
||||
SessionPromptInput,
|
||||
SessionPromptOutput,
|
||||
SessionSkillInput,
|
||||
SessionSkillOutput,
|
||||
SessionCompactInput,
|
||||
SessionCompactOutput,
|
||||
SessionWaitInput,
|
||||
|
|
@ -427,6 +429,18 @@ export function make(options: ClientOptions) {
|
|||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
skill: (input: SessionSkillInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionSkillOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/skill`,
|
||||
body: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
compact: (input: SessionCompactInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionCompactOutput>(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -50,6 +50,14 @@ export type ConflictError = {
|
|||
export const isConflictError = (value: unknown): value is ConflictError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ConflictError"
|
||||
|
||||
export type SkillNotFoundError = {
|
||||
readonly _tag: "SkillNotFoundError"
|
||||
readonly skill: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError"
|
||||
|
||||
export type SessionBusyError = {
|
||||
readonly _tag: "SessionBusyError"
|
||||
readonly sessionID: string
|
||||
|
|
@ -540,6 +548,27 @@ export type SessionPromptOutput = {
|
|||
}
|
||||
}["data"]
|
||||
|
||||
export type SessionSkillInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly id?: {
|
||||
readonly id?: string | undefined
|
||||
readonly skill: string
|
||||
readonly resume?: boolean | undefined
|
||||
}["id"]
|
||||
readonly skill: {
|
||||
readonly id?: string | undefined
|
||||
readonly skill: string
|
||||
readonly resume?: boolean | undefined
|
||||
}["skill"]
|
||||
readonly resume?: {
|
||||
readonly id?: string | undefined
|
||||
readonly skill: string
|
||||
readonly resume?: boolean | undefined
|
||||
}["resume"]
|
||||
}
|
||||
|
||||
export type SessionSkillOutput = void
|
||||
|
||||
export type SessionCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionCompactOutput = void
|
||||
|
|
@ -629,6 +658,14 @@ export type SessionContextOutput = {
|
|||
readonly type: "system"
|
||||
readonly text: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "skill"
|
||||
readonly name: string
|
||||
readonly text: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
|
|
@ -882,6 +919,20 @@ export type SessionHistoryOutput = {
|
|||
readonly text: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.skill.activated"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly name: string
|
||||
readonly text: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
|
|
@ -1361,6 +1412,20 @@ export type SessionEventsOutput =
|
|||
readonly text: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.skill.activated"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly name: string
|
||||
readonly text: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
|
|
@ -1749,6 +1814,14 @@ export type SessionMessageOutput = {
|
|||
readonly type: "system"
|
||||
readonly text: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "skill"
|
||||
readonly name: string
|
||||
readonly text: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
|
|
@ -1921,6 +1994,14 @@ export type MessageListOutput = {
|
|||
readonly type: "system"
|
||||
readonly text: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "skill"
|
||||
readonly name: string
|
||||
readonly text: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
|
|
@ -2711,6 +2792,14 @@ export type EventSubscribeOutput =
|
|||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "agent.updated"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
|
|
@ -3392,6 +3481,20 @@ export type EventSubscribeOutput =
|
|||
readonly text: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.skill.activated"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly name: string
|
||||
readonly text: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
|
|
|
|||
|
|
@ -6,26 +6,112 @@ import { define } from "./internal"
|
|||
import { Effect } from "effect"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { InstallationChannel, InstallationVersion } from "../installation/version"
|
||||
import { Config } from "../config"
|
||||
import { Location } from "../location"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" }
|
||||
import reportContent from "./skill/report.md" with { type: "text" }
|
||||
|
||||
export const CustomizeOpencodeContent = customizeOpencodeContent
|
||||
export const ReportContent = reportContent
|
||||
|
||||
const CUSTOMIZE_OPENCODE_DESCRIPTION =
|
||||
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself."
|
||||
const REPORT_DESCRIPTION =
|
||||
"Use when the user wants to report an opencode issue or bug. Collect standard diagnostics, add user-specific reproduction context, and publish the issue with GitHub CLI."
|
||||
|
||||
export const Plugin = define({
|
||||
id: "skill",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const reportContent = yield* reportContentWithDiagnostics()
|
||||
yield* ctx.skill.transform((draft) => {
|
||||
draft.source(
|
||||
SkillV2.EmbeddedSource.make({
|
||||
type: "embedded",
|
||||
skill: SkillV2.Info.make({
|
||||
name: "customize-opencode",
|
||||
description:
|
||||
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.",
|
||||
description: CUSTOMIZE_OPENCODE_DESCRIPTION,
|
||||
location: AbsolutePath.make("/builtin/customize-opencode.md"),
|
||||
content: CustomizeOpencodeContent,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
draft.source(
|
||||
SkillV2.EmbeddedSource.make({
|
||||
type: "embedded",
|
||||
skill: SkillV2.Info.make({
|
||||
name: "report",
|
||||
description: REPORT_DESCRIPTION,
|
||||
slash: true,
|
||||
location: AbsolutePath.make("/builtin/report.md"),
|
||||
content: reportContent,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDiagnostics")(function* () {
|
||||
const plugins = yield* configuredPlugins().pipe(Effect.orElseSucceed(() => ["Unavailable: failed to inspect config"]))
|
||||
return [
|
||||
ReportContent,
|
||||
"",
|
||||
"## Runtime Diagnostics Snapshot",
|
||||
"",
|
||||
"These values were captured when the built-in report skill was registered. Verify them before publishing.",
|
||||
"",
|
||||
`- opencode version: ${InstallationVersion}`,
|
||||
`- install/channel: ${InstallationChannel}`,
|
||||
`- OS: ${os.type()} ${os.release()} (${os.platform()} ${os.arch()})`,
|
||||
`- Terminal: ${terminal()}`,
|
||||
`- Shell: ${shell()}`,
|
||||
`- Active plugins: ${plugins.length === 0 ? "None found in config" : plugins.join(", ")}`,
|
||||
].join("\n")
|
||||
})
|
||||
|
||||
const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
return yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") {
|
||||
const directory = entry.path ? path.dirname(entry.path) : location.directory
|
||||
return Effect.succeed(
|
||||
(entry.info.plugins ?? []).map((item) => {
|
||||
const ref = typeof item === "string" ? { package: item } : item
|
||||
if (ref.package.startsWith("file://")) return fileURLToPath(ref.package)
|
||||
if (ref.package.startsWith("./") || ref.package.startsWith("../")) return path.resolve(directory, ref.package)
|
||||
return ref.package
|
||||
}),
|
||||
)
|
||||
}
|
||||
return fs
|
||||
.glob("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: entry.path,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
}).pipe(Effect.map((items) => items.flat().toSorted()))
|
||||
})
|
||||
|
||||
function terminal() {
|
||||
return [
|
||||
process.env.TERM_PROGRAM ? `TERM_PROGRAM=${process.env.TERM_PROGRAM}` : undefined,
|
||||
process.env.TERM ? `TERM=${process.env.TERM}` : undefined,
|
||||
process.env.COLORTERM ? `COLORTERM=${process.env.COLORTERM}` : undefined,
|
||||
]
|
||||
.filter((item): item is string => item !== undefined)
|
||||
.join(", ") || "Unavailable: terminal environment variables are not set"
|
||||
}
|
||||
|
||||
function shell() {
|
||||
return process.env.SHELL ?? process.env.ComSpec ?? process.env.COMSPEC ?? "Unavailable: shell environment variable is not set"
|
||||
}
|
||||
|
|
|
|||
125
packages/core/src/plugin/skill/report.md
Normal file
125
packages/core/src/plugin/skill/report.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<!--
|
||||
Built-in skill. Name and description are registered in code at
|
||||
packages/core/src/plugin/skill.ts. The body below becomes the skill's
|
||||
content.
|
||||
-->
|
||||
|
||||
# Report an opencode Issue
|
||||
|
||||
Use this skill when the user wants to report an opencode issue or bug. Your job
|
||||
is to turn the user's problem into a useful GitHub issue with standard
|
||||
diagnostics plus the context needed to reproduce and resolve it.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Collect the standard diagnostics below.
|
||||
2. Ask only for missing details that are necessary to reproduce or understand
|
||||
impact.
|
||||
3. Draft the issue in the standard format below.
|
||||
4. Publish it with GitHub CLI after the user confirms the title and body.
|
||||
|
||||
Do not publish an issue without user confirmation. If GitHub CLI is not
|
||||
installed or not authenticated, explain the blocker and provide the exact issue
|
||||
title/body for the user.
|
||||
|
||||
## Standard Diagnostics
|
||||
|
||||
Collect these values when possible:
|
||||
|
||||
- opencode version: run `opencode --version` or `opencode2 --version`,
|
||||
depending on the executable in use.
|
||||
- Operating system: run `uname -a` on Unix-like systems, or `ver` on Windows.
|
||||
- Terminal: inspect `$TERM`, `$TERM_PROGRAM`, `$COLORTERM`, and any obvious
|
||||
terminal app context the user provides.
|
||||
- Shell: inspect `$SHELL` on Unix-like systems, or `%COMSPEC%`/`$ComSpec` on
|
||||
Windows when relevant.
|
||||
- Install/channel context: include whether this appears to be local, dev, beta,
|
||||
or release if the version output or environment reveals it.
|
||||
- Active plugins: inspect opencode config for configured plugins when possible.
|
||||
Check likely config locations such as `opencode.json`, `opencode.jsonc`,
|
||||
`.opencode/opencode.json`, and `~/.config/opencode/opencode.json`. Record
|
||||
configured plugin entries, local plugin files under `.opencode/plugin/` or
|
||||
`.opencode/plugins/`, and note if plugin status could not be determined.
|
||||
|
||||
If a diagnostic command fails, include `Unavailable` with the reason instead of
|
||||
guessing.
|
||||
|
||||
## User-Specific Context
|
||||
|
||||
Capture the details that make the issue actionable:
|
||||
|
||||
- What the user was trying to do.
|
||||
- What happened.
|
||||
- What the user expected to happen.
|
||||
- Reproduction steps, ideally minimal and numbered.
|
||||
- Relevant logs, stack traces, screenshots, terminal output, or config snippets.
|
||||
- Whether the issue is reproducible consistently, intermittently, or only once.
|
||||
- Recent changes that may be related, such as updating opencode, changing
|
||||
config, installing a plugin, changing terminal, or switching workspace.
|
||||
- Workarounds tried and whether they helped.
|
||||
|
||||
Avoid pasting secrets. Redact tokens, API keys, private URLs, usernames, and
|
||||
project-specific confidential data unless the user explicitly says it is safe.
|
||||
|
||||
## Issue Format
|
||||
|
||||
Use this exact structure unless the repository issue template requires
|
||||
otherwise:
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
|
||||
<!-- One or two sentences describing the bug and impact. -->
|
||||
|
||||
## Environment
|
||||
|
||||
- opencode version: <!-- value or Unavailable: reason -->
|
||||
- OS: <!-- value or Unavailable: reason -->
|
||||
- Terminal: <!-- value or Unavailable: reason -->
|
||||
- Shell: <!-- value or Unavailable: reason -->
|
||||
- Install/channel: <!-- value or Unavailable: reason -->
|
||||
- Active plugins: <!-- list, none found, or Unavailable: reason -->
|
||||
|
||||
## Reproduction
|
||||
|
||||
1. <!-- step -->
|
||||
2. <!-- step -->
|
||||
3. <!-- step -->
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
<!-- What should have happened. -->
|
||||
|
||||
## Actual Behavior
|
||||
|
||||
<!-- What happened instead. Include exact errors when available. -->
|
||||
|
||||
## Additional Context
|
||||
|
||||
<!-- Logs, config snippets, screenshots, frequency, workarounds, related notes. -->
|
||||
```
|
||||
|
||||
Keep the title short and searchable. Prefer the form:
|
||||
|
||||
```text
|
||||
<area>: <specific failure or symptom>
|
||||
```
|
||||
|
||||
Examples: `tui: skills dialog crashes outside location provider`,
|
||||
`cli: local service config writes release filename`.
|
||||
|
||||
## Publishing With GitHub CLI
|
||||
|
||||
Use GitHub CLI from the repository checkout when available:
|
||||
|
||||
```sh
|
||||
gh issue create --title "<title>" --body-file <file>
|
||||
```
|
||||
|
||||
Write the body to a temporary markdown file first so quoting, newlines, logs,
|
||||
and code fences are preserved. If the issue belongs in a specific repository,
|
||||
use `--repo owner/name`. If labels are obvious and the repo accepts them, add
|
||||
`--label bug`; otherwise omit labels rather than guessing.
|
||||
|
||||
After publishing, report the created issue URL to the user and mention any
|
||||
diagnostics that were unavailable.
|
||||
|
|
@ -38,6 +38,7 @@ import { SessionRevert } from "./session/revert"
|
|||
import { Revert } from "@opencode-ai/schema/revert"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
|
||||
import { SkillV2 } from "./skill"
|
||||
|
||||
export const RevertState = Revert.State
|
||||
export type RevertState = Revert.State
|
||||
|
|
@ -115,6 +116,9 @@ export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictE
|
|||
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
|
||||
skill: Schema.String,
|
||||
}) {}
|
||||
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
|
||||
|
|
@ -124,6 +128,7 @@ export type Error =
|
|||
| OperationUnavailableError
|
||||
| PromptConflictError
|
||||
| BusyError
|
||||
| SkillNotFoundError
|
||||
| MessageNotFoundError
|
||||
|
||||
export interface Interface {
|
||||
|
|
@ -176,11 +181,11 @@ export interface Interface {
|
|||
resume?: boolean
|
||||
}) => Effect.Effect<void, OperationUnavailableError>
|
||||
readonly skill: (input: {
|
||||
id?: EventV2.ID
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
skill: string
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<void, OperationUnavailableError>
|
||||
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
|
||||
readonly compact: (
|
||||
input: CompactInput,
|
||||
) => Effect.Effect<void, NotFoundError | BusyError | MessageDecodeError | OperationUnavailableError>
|
||||
|
|
@ -440,8 +445,20 @@ export const layer = Layer.effect(
|
|||
shell: Effect.fn("V2Session.shell")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "shell" })
|
||||
}),
|
||||
skill: Effect.fn("V2Session.skill")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "skill" })
|
||||
skill: Effect.fn("V2Session.skill")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const skills = yield* SkillV2.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
const skill = (yield* skills.list()).find((item) => item.name === input.skill)
|
||||
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
|
||||
yield* events.publish(SessionEvent.Skill.Activated, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.id ?? SessionMessage.ID.create(),
|
||||
timestamp: yield* DateTime.now,
|
||||
name: skill.name,
|
||||
text: skill.content,
|
||||
})
|
||||
if (input.resume !== false)
|
||||
yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
}),
|
||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ const serialize = (message: SessionMessage.Message) => {
|
|||
}
|
||||
if (message.type === "system") return `[System update]: ${message.text}`
|
||||
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
|
||||
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
|
||||
if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output)}`
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,6 +159,17 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
}),
|
||||
)
|
||||
},
|
||||
"session.next.skill.activated": (event) => {
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.Skill.make({
|
||||
id: event.data.messageID,
|
||||
type: "skill",
|
||||
name: event.data.name,
|
||||
text: event.data.text,
|
||||
time: { created: event.data.timestamp },
|
||||
}),
|
||||
)
|
||||
},
|
||||
"session.next.shell.started": (event) => {
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.Shell.make({
|
||||
|
|
|
|||
|
|
@ -584,6 +584,15 @@ export const layer = Layer.effectDiscard(
|
|||
)
|
||||
yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Skill.Activated, (event) =>
|
||||
insertMessage(db, event, {
|
||||
id: event.data.messageID,
|
||||
type: "skill",
|
||||
name: event.data.name,
|
||||
text: event.data.text,
|
||||
time: { created: event.data.timestamp },
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
|
||||
|
|
|
|||
|
|
@ -131,6 +131,8 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
|
|||
]
|
||||
case "synthetic":
|
||||
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
|
||||
case "skill":
|
||||
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
|
||||
case "system":
|
||||
return [Message.system(message.text)]
|
||||
case "shell":
|
||||
|
|
|
|||
|
|
@ -1,25 +1,50 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { Effect } from "effect"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "./host"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(SkillV2.node))
|
||||
|
||||
describe("SkillPlugin.Plugin", () => {
|
||||
it.effect("registers the built-in customize-opencode skill", () =>
|
||||
it.effect("registers built-in skills", () =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* SkillV2.Service
|
||||
yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } }))
|
||||
yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })).pipe(
|
||||
Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })),
|
||||
Effect.provideService(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
|
||||
),
|
||||
Effect.provide(FSUtil.defaultLayer),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
)
|
||||
const skills = yield* skill.list()
|
||||
const report = skills.find((item) => item.name === "report")
|
||||
|
||||
expect(yield* skill.list()).toContainEqual(
|
||||
expect(skills).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "customize-opencode",
|
||||
description: expect.stringContaining("opencode's own configuration"),
|
||||
}),
|
||||
)
|
||||
expect(skills).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "report",
|
||||
description: expect.stringContaining("opencode issue"),
|
||||
}),
|
||||
)
|
||||
expect(report?.slash).toBe(true)
|
||||
expect(report?.content).toContain(`- opencode version: ${InstallationVersion}`)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -444,7 +444,6 @@ describe("SessionV2.create", () => {
|
|||
)
|
||||
|
||||
expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell")
|
||||
expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill")
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -80,6 +80,15 @@ export class MessageNotFoundError extends Schema.TaggedErrorClass<MessageNotFoun
|
|||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()(
|
||||
"SkillNotFoundError",
|
||||
{
|
||||
skill: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class InvalidCursorError extends Schema.TaggedErrorClass<InvalidCursorError>()(
|
||||
"InvalidCursorError",
|
||||
{ message: Schema.String },
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
ServiceUnavailableError,
|
||||
SessionBusyError,
|
||||
SessionNotFoundError,
|
||||
SkillNotFoundError,
|
||||
UnknownError,
|
||||
} from "../errors"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
|
|
@ -256,6 +257,26 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
|||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.skill", "/api/session/:sessionID/skill", {
|
||||
params: { sessionID: Session.ID },
|
||||
payload: Schema.Struct({
|
||||
id: SessionMessage.ID.pipe(Schema.optional),
|
||||
skill: Schema.String,
|
||||
resume: Schema.Boolean.pipe(Schema.optional),
|
||||
}),
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, SkillNotFoundError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.skill",
|
||||
summary: "Activate skill",
|
||||
description: "Activate a skill for a session by appending a skill message and resuming execution.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", {
|
||||
params: { sessionID: Session.ID },
|
||||
|
|
|
|||
|
|
@ -141,6 +141,20 @@ export const Synthetic = Event.define({
|
|||
})
|
||||
export type Synthetic = typeof Synthetic.Type
|
||||
|
||||
export namespace Skill {
|
||||
export const Activated = Event.define({
|
||||
type: "session.next.skill.activated",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessage.ID,
|
||||
name: Schema.String,
|
||||
text: Schema.String,
|
||||
},
|
||||
})
|
||||
export type Activated = typeof Activated.Type
|
||||
}
|
||||
|
||||
export namespace Shell {
|
||||
export const Started = Event.define({
|
||||
type: "session.next.shell.started",
|
||||
|
|
@ -476,6 +490,7 @@ export const DurableDefinitions = Event.inventory(
|
|||
PromptAdmitted,
|
||||
ContextUpdated,
|
||||
Synthetic,
|
||||
Skill.Activated,
|
||||
Shell.Started,
|
||||
Shell.Ended,
|
||||
Step.Started,
|
||||
|
|
@ -509,6 +524,7 @@ export const Definitions = Event.inventory(
|
|||
PromptAdmitted,
|
||||
ContextUpdated,
|
||||
Synthetic,
|
||||
Skill.Activated,
|
||||
Shell.Started,
|
||||
Shell.Ended,
|
||||
Step.Started,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,14 @@ export const System = Schema.Struct({
|
|||
text: Schema.String,
|
||||
}).annotate({ identifier: "Session.Message.System" })
|
||||
|
||||
export interface Skill extends Schema.Schema.Type<typeof Skill> {}
|
||||
export const Skill = Schema.Struct({
|
||||
...Base,
|
||||
type: Schema.Literal("skill"),
|
||||
name: Schema.String,
|
||||
text: Schema.String,
|
||||
}).annotate({ identifier: "Session.Message.Skill" })
|
||||
|
||||
export interface Shell extends Schema.Schema.Type<typeof Shell> {}
|
||||
export const Shell = Schema.Struct({
|
||||
...Base,
|
||||
|
|
@ -203,11 +211,12 @@ export const Message = Schema.Union([
|
|||
User,
|
||||
Synthetic,
|
||||
System,
|
||||
Skill,
|
||||
Shell,
|
||||
Assistant,
|
||||
Compaction,
|
||||
])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Session.Message" })
|
||||
export type Message = AgentSwitched | ModelSwitched | User | Synthetic | System | Shell | Assistant | Compaction
|
||||
export type Message = AgentSwitched | ModelSwitched | User | Synthetic | System | Skill | Shell | Assistant | Compaction
|
||||
export type Type = Message["type"]
|
||||
|
|
|
|||
|
|
@ -387,6 +387,8 @@ import type {
|
|||
V2SessionRevertCommitResponses,
|
||||
V2SessionRevertStageErrors,
|
||||
V2SessionRevertStageResponses,
|
||||
V2SessionSkillErrors,
|
||||
V2SessionSkillResponses,
|
||||
V2SessionSwitchAgentErrors,
|
||||
V2SessionSwitchAgentResponses,
|
||||
V2SessionSwitchModelErrors,
|
||||
|
|
@ -5745,6 +5747,45 @@ export class Session3 extends HeyApiClient {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate skill
|
||||
*
|
||||
* Activate a skill for a session by appending a skill message and resuming execution.
|
||||
*/
|
||||
public skill<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
id?: string
|
||||
skill?: string
|
||||
resume?: boolean
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "sessionID" },
|
||||
{ in: "body", key: "id" },
|
||||
{ in: "body", key: "skill" },
|
||||
{ in: "body", key: "resume" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<V2SessionSkillResponses, V2SessionSkillErrors, ThrowOnError>({
|
||||
url: "/api/session/{sessionID}/skill",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact session
|
||||
*
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export type Event =
|
|||
| EventSessionNextPromptAdmitted
|
||||
| EventSessionNextContextUpdated
|
||||
| EventSessionNextSynthetic
|
||||
| EventSessionNextSkillActivated
|
||||
| EventSessionNextShellStarted
|
||||
| EventSessionNextShellEnded
|
||||
| EventSessionNextStepStarted
|
||||
|
|
@ -940,6 +941,17 @@ export type GlobalEvent = {
|
|||
text: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "session.next.skill.activated"
|
||||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
name: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "session.next.shell.started"
|
||||
|
|
@ -1690,6 +1702,7 @@ export type GlobalEvent = {
|
|||
| SyncEventSessionNextPromptAdmitted
|
||||
| SyncEventSessionNextContextUpdated
|
||||
| SyncEventSessionNextSynthetic
|
||||
| SyncEventSessionNextSkillActivated
|
||||
| SyncEventSessionNextShellStarted
|
||||
| SyncEventSessionNextShellEnded
|
||||
| SyncEventSessionNextStepStarted
|
||||
|
|
@ -2794,6 +2807,12 @@ export type ConflictError = {
|
|||
resource?: string
|
||||
}
|
||||
|
||||
export type SkillNotFoundError = {
|
||||
_tag: "SkillNotFoundError"
|
||||
skill: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type ServiceUnavailableError = {
|
||||
_tag: "ServiceUnavailableError"
|
||||
message: string
|
||||
|
|
@ -2816,6 +2835,7 @@ export type SessionDurableEvent =
|
|||
| SessionNextPromptAdmitted
|
||||
| SessionNextContextUpdated
|
||||
| SessionNextSynthetic
|
||||
| SessionNextSkillActivated
|
||||
| SessionNextShellStarted
|
||||
| SessionNextShellEnded
|
||||
| SessionNextStepStarted
|
||||
|
|
@ -2970,6 +2990,7 @@ export type V2Event =
|
|||
| SessionNextPromptAdmitted
|
||||
| SessionNextContextUpdated
|
||||
| SessionNextSynthetic
|
||||
| SessionNextSkillActivated
|
||||
| SessionNextShellStarted
|
||||
| SessionNextShellEnded
|
||||
| SessionNextStepStarted
|
||||
|
|
@ -3578,6 +3599,24 @@ export type SyncEventSessionNextSynthetic = {
|
|||
}
|
||||
}
|
||||
|
||||
export type SyncEventSessionNextSkillActivated = {
|
||||
type: "sync"
|
||||
id: string
|
||||
syncEvent: {
|
||||
type: "session.next.skill.activated.1"
|
||||
id: string
|
||||
seq: number
|
||||
aggregateID: string
|
||||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
name: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncEventSessionNextShellStarted = {
|
||||
type: "sync"
|
||||
id: string
|
||||
|
|
@ -4170,6 +4209,19 @@ export type SessionMessageSystem = {
|
|||
text: string
|
||||
}
|
||||
|
||||
export type SessionMessageSkill = {
|
||||
id: string
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
time: {
|
||||
created: number
|
||||
}
|
||||
type: "skill"
|
||||
name: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export type SessionMessageShell = {
|
||||
id: string
|
||||
metadata?: {
|
||||
|
|
@ -4319,6 +4371,7 @@ export type SessionMessage =
|
|||
| SessionMessageUser
|
||||
| SessionMessageSynthetic
|
||||
| SessionMessageSystem
|
||||
| SessionMessageSkill
|
||||
| SessionMessageShell
|
||||
| SessionMessageAssistant
|
||||
| SessionMessageCompaction
|
||||
|
|
@ -4504,6 +4557,27 @@ export type SessionNextSynthetic = {
|
|||
}
|
||||
}
|
||||
|
||||
export type SessionNextSkillActivated = {
|
||||
id: string
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
type: "session.next.skill.activated"
|
||||
durable?: {
|
||||
aggregateID: string
|
||||
seq: number
|
||||
version: number
|
||||
}
|
||||
location?: LocationRef
|
||||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
name: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionNextShellStarted = {
|
||||
id: string
|
||||
metadata?: {
|
||||
|
|
@ -6631,6 +6705,18 @@ export type EventSessionNextSynthetic = {
|
|||
}
|
||||
}
|
||||
|
||||
export type EventSessionNextSkillActivated = {
|
||||
id: string
|
||||
type: "session.next.skill.activated"
|
||||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
name: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionNextShellStarted = {
|
||||
id: string
|
||||
type: "session.next.shell.started"
|
||||
|
|
@ -12002,6 +12088,45 @@ export type V2SessionPromptResponses = {
|
|||
|
||||
export type V2SessionPromptResponse = V2SessionPromptResponses[keyof V2SessionPromptResponses]
|
||||
|
||||
export type V2SessionSkillData = {
|
||||
body: {
|
||||
id?: string
|
||||
skill: string
|
||||
resume?: boolean
|
||||
}
|
||||
path: {
|
||||
sessionID: string
|
||||
}
|
||||
query?: never
|
||||
url: "/api/session/{sessionID}/skill"
|
||||
}
|
||||
|
||||
export type V2SessionSkillErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestError
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedError
|
||||
/**
|
||||
* SessionNotFoundError | SkillNotFoundError
|
||||
*/
|
||||
404: SkillNotFoundError | SessionNotFoundError
|
||||
}
|
||||
|
||||
export type V2SessionSkillError = V2SessionSkillErrors[keyof V2SessionSkillErrors]
|
||||
|
||||
export type V2SessionSkillResponses = {
|
||||
/**
|
||||
* <No Content>
|
||||
*/
|
||||
204: void
|
||||
}
|
||||
|
||||
export type V2SessionSkillResponse = V2SessionSkillResponses[keyof V2SessionSkillResponses]
|
||||
|
||||
export type V2SessionCompactData = {
|
||||
body?: never
|
||||
path: {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
ServiceUnavailableError,
|
||||
SessionBusyError,
|
||||
SessionNotFoundError,
|
||||
SkillNotFoundError,
|
||||
UnknownError,
|
||||
} from "@opencode-ai/protocol/errors"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
|
|
@ -214,6 +215,30 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.skill",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* session.skill({
|
||||
sessionID: ctx.params.sessionID,
|
||||
id: ctx.payload.id,
|
||||
skill: ctx.payload.skill,
|
||||
resume: ctx.payload.resume,
|
||||
}).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchTag("Session.SkillNotFoundError", (error) =>
|
||||
Effect.fail(new SkillNotFoundError({ skill: error.skill, message: `Skill not found: ${error.skill}` })),
|
||||
),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.compact",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
|
|
|||
|
|
@ -2,26 +2,32 @@ import { TextAttributes } from "@opentui/core"
|
|||
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
|
||||
import { createResource, createMemo, createSignal } from "solid-js"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useData } from "../context/data"
|
||||
import type { LocationRef } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export type DialogSkillProps = {
|
||||
location?: LocationRef
|
||||
onSelect: (skill: string) => void
|
||||
}
|
||||
|
||||
export function DialogSkill(props: DialogSkillProps) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const data = useData()
|
||||
const { theme } = useTheme()
|
||||
dialog.setSize("large")
|
||||
|
||||
const [loadError, setLoadError] = createSignal<unknown>()
|
||||
|
||||
const [skills] = createResource(() =>
|
||||
sdk.client.app
|
||||
.skills({}, { throwOnError: true })
|
||||
.then((result) => result.data ?? [])
|
||||
Promise.resolve()
|
||||
.then(async () => {
|
||||
const current = data.location.skill.list(props.location)
|
||||
if (current) return current
|
||||
await data.location.skill.refresh(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) => {
|
||||
|
|
|
|||
|
|
@ -443,12 +443,12 @@ export function Autocomplete(props: {
|
|||
|
||||
const commands = createMemo((): AutocompleteOption[] => {
|
||||
const results: AutocompleteOption[] = [...slashes()]
|
||||
const commandNames = new Set<string>()
|
||||
|
||||
for (const serverCommand of sync.data.command) {
|
||||
if (serverCommand.source === "skill") continue
|
||||
const label = serverCommand.source === "mcp" ? ":mcp" : ""
|
||||
for (const serverCommand of data.location.command.list(location()) ?? []) {
|
||||
commandNames.add(serverCommand.name)
|
||||
results.push({
|
||||
display: "/" + serverCommand.name + label,
|
||||
display: "/" + serverCommand.name,
|
||||
description: serverCommand.description,
|
||||
onSelect: () => {
|
||||
const newText = "/" + serverCommand.name + " "
|
||||
|
|
@ -460,6 +460,22 @@ export function Autocomplete(props: {
|
|||
})
|
||||
}
|
||||
|
||||
for (const skill of data.location.skill
|
||||
.list(location())
|
||||
?.filter((skill) => skill.slash === true && !commandNames.has(skill.name)) ?? []) {
|
||||
results.push({
|
||||
display: "/" + skill.name,
|
||||
description: skill.description,
|
||||
onSelect: () => {
|
||||
const newText = "/" + skill.name + " "
|
||||
const cursor = props.input().logicalCursor
|
||||
props.input().deleteRange(0, 0, cursor.row, cursor.col)
|
||||
props.input().insertText(newText)
|
||||
props.input().cursorOffset = Bun.stringWidth(newText)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
results.sort((a, b) => a.display.localeCompare(b.display))
|
||||
|
||||
const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import { usePromptWorkspace } from "./workspace"
|
|||
import { usePromptMove } from "./move"
|
||||
import { readLocalAttachment } from "./local-attachment"
|
||||
import { useData } from "../../context/data"
|
||||
import { useLocation } from "../../context/location"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
|
|
@ -154,6 +155,7 @@ export function Prompt(props: PromptProps) {
|
|||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const currentLocation = useLocation()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
|
|
@ -533,6 +535,7 @@ export function Prompt(props: PromptProps) {
|
|||
run: () => {
|
||||
dialog.replace(() => (
|
||||
<DialogSkill
|
||||
location={currentLocation()}
|
||||
onSelect={(skill) => {
|
||||
input.setText(`/${skill} `)
|
||||
setStore("prompt", {
|
||||
|
|
@ -1088,7 +1091,9 @@ export function Prompt(props: PromptProps) {
|
|||
setStore("mode", "normal")
|
||||
} else if (
|
||||
inputText.startsWith("/") &&
|
||||
sync.data.command.some((x) => x.name === inputText.split("\n")[0].split(" ")[0].slice(1))
|
||||
(data.location.command.list(currentLocation()) ?? []).some(
|
||||
(command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1),
|
||||
)
|
||||
) {
|
||||
move.startSubmit()
|
||||
// Parse command from first line, preserve multi-line content in arguments
|
||||
|
|
@ -1107,6 +1112,17 @@ export function Prompt(props: PromptProps) {
|
|||
variant,
|
||||
parts: nonTextParts.filter((x) => x.type === "file"),
|
||||
})
|
||||
} else if (
|
||||
inputText.startsWith("/") &&
|
||||
(data.location.skill.list(currentLocation()) ?? []).some(
|
||||
(skill) => skill.slash === true && skill.name === inputText.split("\n")[0].split(" ")[0].slice(1),
|
||||
)
|
||||
) {
|
||||
move.startSubmit()
|
||||
void sdk.api.session.skill({
|
||||
sessionID,
|
||||
skill: inputText.split("\n")[0].split(" ")[0].slice(1),
|
||||
})
|
||||
} else {
|
||||
move.startSubmit()
|
||||
if (!session) {
|
||||
|
|
|
|||
|
|
@ -1049,8 +1049,10 @@ function SessionMessageView(props: { message: SessionMessage }) {
|
|||
<Match when={props.message.type === "agent-switched" || props.message.type === "model-switched"}>
|
||||
<SessionSwitchMessageV2 message={props.message} />
|
||||
</Match>
|
||||
<Match when={props.message.type === "system" || props.message.type === "synthetic"}>
|
||||
<SessionNoticeMessageV2 message={props.message} />
|
||||
<Match when={props.message.type === "system" || props.message.type === "synthetic" || props.message.type === "skill"}>
|
||||
<Show when={props.message.type === "skill"} fallback={<SessionNoticeMessageV2 message={props.message} />}>
|
||||
<SessionSkillMessage message={props.message as Extract<SessionMessage, { type: "skill" }>} />
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={props.message.type === "compaction"}>
|
||||
<CompactionMessage />
|
||||
|
|
@ -1211,13 +1213,26 @@ function SessionSwitchMessageV2(props: { message: SessionMessage }) {
|
|||
|
||||
function SessionNoticeMessageV2(props: { message: SessionMessage }) {
|
||||
const { theme } = useTheme()
|
||||
const text = () => {
|
||||
if (props.message.type === "system" || props.message.type === "synthetic") return props.message.text
|
||||
return ""
|
||||
}
|
||||
return (
|
||||
<text fg={theme.textMuted}>
|
||||
{props.message.type === "system" || props.message.type === "synthetic" ? props.message.text : ""}
|
||||
{text()}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionSkillMessage(props: { message: Extract<SessionMessage, { type: "skill" }> }) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<InlineToolRow icon="→" color={theme.textMuted} pending="Skill" complete={true}>
|
||||
Skill {props.message.name}
|
||||
</InlineToolRow>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactionMessage() {
|
||||
const { theme } = useTheme()
|
||||
return <box border={["top"]} title=" Compaction " titleAlignment="center" borderColor={theme.borderActive} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue