diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 75d6374bfa54..20a2de670877 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -9,6 +9,7 @@ import { Token } from "@/util/token" import { SessionProcessor } from "./processor" import { Agent } from "@/agent/agent" import { Plugin } from "@/plugin" +import { Skill } from "@/skill" import { Config } from "@/config/config" import { NotFoundError } from "@/storage/storage" @@ -194,6 +195,7 @@ const layer = Layer.effect( const config = yield* Config.Service const session = yield* Session.Service const agents = yield* Agent.Service + const skill = yield* Skill.Service const plugin = yield* Plugin.Service const processors = yield* SessionProcessor.Service const provider = yield* Provider.Service @@ -360,6 +362,8 @@ const layer = Layer.effect( ? yield* provider.getModel(agent.model.providerID, agent.model.modelID).pipe(Effect.orDie) : yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID).pipe(Effect.orDie) const cfg = yield* config.get() + const sessionAgent = yield* agents.get(userMessage.agent) + const sessionSkills = sessionAgent ? yield* skill.available(sessionAgent) : [] const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages const prior = completedCompactions(history) const hidden = new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex])) @@ -378,17 +382,21 @@ const layer = Layer.effect( const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const conversation = msgs.map(serialize).filter(Boolean).join("\n\n") - const nextPrompt = - compacting.prompt ?? - [ - buildPrompt({ - previousSummary, - context: [conversation], - }), - ...compacting.context, - ] - .filter(Boolean) - .join("\n\n") + const skillsBlock = sessionSkills.length + ? [ + "", + "These skills were available to the coding agent during this session. In the summary's Important Details section, include exactly one bullet listing these skill names (comma-separated) so later turns know they exist and can load them with the skill tool:", + Skill.fmt(sessionSkills, { verbose: false }), + "", + ].join("\n") + : undefined + const nextPrompt = [ + compacting.prompt ?? buildPrompt({ previousSummary, context: [conversation] }), + ...compacting.context, + skillsBlock, + ] + .filter(Boolean) + .join("\n\n") const ctx = yield* InstanceState.context const msg: SessionV1.Assistant = { id: MessageID.ascending(), @@ -528,7 +536,10 @@ const layer = Layer.effect( (input.overflow ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n" : "") + - "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed." + "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed." + + (sessionSkills.length + ? `\n\nThese skills are still available — load one with the skill tool when the task matches: ${sessionSkills.map((item) => item.name).join(", ")}.` + : "") yield* session.updatePart({ id: PartID.ascending(), messageID: continueMsg.id, @@ -597,6 +608,7 @@ export const node = LayerNode.make({ Config.node, Session.node, Agent.node, + Skill.node, Plugin.node, SessionProcessor.node, Provider.node, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0f85d44f209b..107c386328e8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1267,6 +1267,28 @@ const layer = Layer.effect( ...(mcpInstructions ? [mcpInstructions] : []), ...(skills ? [skills] : []), ] + const loadedSkills = [ + ...new Set( + msgs + .flatMap((m) => m.parts) + .filter( + (p): p is SessionV1.ToolPart => + p.type === "tool" && p.tool === "skill" && p.state.status === "completed", + ) + .map((p) => p.state.input.name) + .filter((name): name is string => typeof name === "string"), + ), + ] + if (loadedSkills.length > 0) { + system.push( + [ + "", + "These skills have already been loaded into the conversation via the skill tool. Their content is in the message history. Do NOT load them again — reference the existing content directly.", + ...loadedSkills.map((name) => ` ${name}`), + "", + ].join("\n"), + ) + } const format = lastUser.format ?? { type: "text" as const } if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) const result = yield* handle.process({ diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 5d93139f002b..812f66d12cb0 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -104,6 +104,7 @@ type Opts = { part?: HostPluginApi["state"]["part"] lsp?: HostPluginApi["state"]["lsp"] mcp?: HostPluginApi["state"]["mcp"] + skills?: HostPluginApi["state"]["skills"] } theme?: { selected?: string @@ -325,6 +326,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { part: opts.state?.part ?? (() => []), lsp: opts.state?.lsp ?? (() => []), mcp: opts.state?.mcp ?? (() => []), + skills: opts.state?.skills ?? (() => []), }, theme: { get current() { diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 67812169475b..5fb540fc4252 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -396,6 +396,7 @@ export type TuiState = { part: (messageID: string) => ReadonlyArray lsp: () => ReadonlyArray mcp: () => ReadonlyArray + skills: () => ReadonlyArray } type TuiBindingLookupView = { @@ -446,6 +447,12 @@ export type TuiSidebarLspItem = Pick export type TuiSidebarTodoItem = Pick +export type TuiSidebarSkillItem = { + name: string + description?: string + location: string +} + export type TuiSidebarFileItem = { file: string additions: number diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 71e050d11e68..fa7fb616cdbe 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -20,6 +20,7 @@ import type { SnapshotFileDiff, ConsoleState, } from "@opencode-ai/sdk/v2" +import type { TuiSidebarSkillItem } from "@opencode-ai/plugin/tui" import { createStore, produce, reconcile } from "solid-js/store" import { useProject } from "./project" import { useEvent } from "./event" @@ -103,6 +104,7 @@ export const { [messageID: string]: Part[] } lsp: LspStatus[] + skills: TuiSidebarSkillItem[] mcp: { [key: string]: McpStatus } @@ -137,6 +139,7 @@ export const { message: {}, part: {}, lsp: [], + skills: [], mcp: {}, mcp_resource: {}, formatter: [], @@ -522,6 +525,7 @@ export const { consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))), sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))), sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data ?? []))), + sdk.client.app.skills({ workspace }).then((x) => setStore("skills", reconcile(x.data ?? []))), sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data ?? {}))), sdk.client.experimental.resource .list({ workspace }) diff --git a/packages/tui/src/feature-plugins/builtins.ts b/packages/tui/src/feature-plugins/builtins.ts index b67923f3c5d1..abe14ca14175 100644 --- a/packages/tui/src/feature-plugins/builtins.ts +++ b/packages/tui/src/feature-plugins/builtins.ts @@ -6,6 +6,7 @@ import SidebarFiles from "./sidebar/files" import SidebarFooter from "./sidebar/footer" import SidebarLsp from "./sidebar/lsp" import SidebarMcp from "./sidebar/mcp" +import SidebarSkills from "./sidebar/skills" import SidebarTodo from "./sidebar/todo" import DiffViewer from "./system/diff-viewer" import Notifications from "./system/notifications" @@ -24,6 +25,7 @@ export function createBuiltinPlugins(options: { experimentalEventSystem: boolean HomeTips, SidebarContext, SidebarMcp, + SidebarSkills, SidebarLsp, SidebarTodo, SidebarFiles, diff --git a/packages/tui/src/feature-plugins/sidebar/skills.tsx b/packages/tui/src/feature-plugins/sidebar/skills.tsx new file mode 100644 index 000000000000..916ea85e35a5 --- /dev/null +++ b/packages/tui/src/feature-plugins/sidebar/skills.tsx @@ -0,0 +1,88 @@ +import path from "path" +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { ToolPart } from "@opencode-ai/sdk/v2" +import type { BuiltinTuiPlugin } from "../builtins" +import { createMemo, For, Show, createSignal } from "solid-js" + +const id = "internal:sidebar-skills" + +function View(props: { api: TuiPluginApi; session_id: string }) { + const [open, setOpen] = createSignal(true) + const theme = () => props.api.theme.current + const all = createMemo(() => props.api.state.skills()) + const invoked = createMemo(() => { + const names = props.api.state + .session.messages(props.session_id) + .flatMap((message) => + props.api.state + .part(message.id) + .filter((part): part is ToolPart => part.type === "tool" && part.tool === "skill"), + ) + .map((part) => part.state.input.name) + .filter((name): name is string => typeof name === "string") + return new Set(names) + }) + const list = createMemo(() => all().filter((item) => invoked().has(item.name))) + + const source = (location: string) => { + if (location === "") return "built-in" + if (location.startsWith(props.api.state.path.worktree)) return "project" + const configDir = path.dirname(props.api.state.path.config) + if (configDir && configDir !== "." && location.startsWith(configDir)) return "global" + return "external" + } + + return ( + + list().length > 2 && setOpen((x) => !x)}> + 2}> + {open() ? "▼" : "▶"} + + + Active Skills + + + + + No skills loaded + + + {(item) => ( + + + • + + + {item.name}{" "} + {source(item.location)} + + + )} + + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 250, + slots: { + sidebar_content(_ctx, props) { + return + }, + }, + }) +} + +const plugin: BuiltinTuiPlugin = { + id, + tui, +} + +export default plugin diff --git a/packages/tui/src/plugin/adapters.tsx b/packages/tui/src/plugin/adapters.tsx index fef0ec8eb8dd..b4709cb69c57 100644 --- a/packages/tui/src/plugin/adapters.tsx +++ b/packages/tui/src/plugin/adapters.tsx @@ -159,6 +159,9 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { error: item.status === "failed" ? item.error : undefined, })) }, + skills() { + return sync.data.skills + }, } }