Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/core/src/v1/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,20 @@ export const AgentPart = Schema.Struct({
}).annotate({ identifier: "AgentPart" })
export type AgentPart = Types.DeepMutable<Schema.Schema.Type<typeof AgentPart>>

export const SkillPart = Schema.Struct({
...partBase,
type: Schema.Literal("skill"),
name: Schema.String,
source: Schema.optional(
Schema.Struct({
value: Schema.String,
start: NonNegativeInt,
end: NonNegativeInt,
}),
),
}).annotate({ identifier: "SkillPart" })
export type SkillPart = Types.DeepMutable<Schema.Schema.Type<typeof SkillPart>>

export const CompactionPart = Schema.Struct({
...partBase,
type: Schema.Literal("compaction"),
Expand Down Expand Up @@ -367,6 +381,7 @@ export const Part = Schema.Union([
SnapshotPart,
PatchPart,
AgentPart,
SkillPart,
RetryPart,
CompactionPart,
]).annotate({ discriminator: "type", identifier: "Part" })
Expand All @@ -381,6 +396,7 @@ export type Part =
| SnapshotPart
| PatchPart
| AgentPart
| SkillPart
| RetryPart
| CompactionPart

Expand Down Expand Up @@ -436,6 +452,20 @@ export const AgentPartInput = Schema.Struct({
}).annotate({ identifier: "AgentPartInput" })
export type AgentPartInput = Types.DeepMutable<Schema.Schema.Type<typeof AgentPartInput>>

export const SkillPartInput = Schema.Struct({
id: Schema.optional(PartID),
type: Schema.Literal("skill"),
name: Schema.String,
source: Schema.optional(
Schema.Struct({
value: Schema.String,
start: NonNegativeInt,
end: NonNegativeInt,
}),
),
}).annotate({ identifier: "SkillPartInput" })
export type SkillPartInput = Types.DeepMutable<Schema.Schema.Type<typeof SkillPartInput>>

export const SubtaskPartInput = Schema.Struct({
id: Schema.optional(PartID),
type: Schema.Literal("subtask"),
Expand Down
65 changes: 64 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,14 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/core/session/prompt"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import * as DateTime from "effect/DateTime"
import { eq } from "drizzle-orm"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionReminders } from "./reminders"
import { SessionTools } from "./tools"
import { LLMEvent } from "@opencode-ai/llm"
import { Skill } from "@/skill"

// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
Expand Down Expand Up @@ -121,6 +123,8 @@ export const layer = Layer.effect(
const summary = yield* SessionSummary.Service
const sys = yield* SystemPrompt.Service
const llm = yield* LLM.Service
const skill = yield* Skill.Service
const ripgrep = yield* Ripgrep.Service
const events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service
const database = yield* Database.Service
Expand Down Expand Up @@ -174,6 +178,36 @@ export const layer = Layer.effect(
return parts
})

const selectedSkillContent = Effect.fn("SessionPrompt.selectedSkillContent")(function* (info: Skill.Info) {
const dir = path.dirname(info.location)
const base = pathToFileURL(dir).href
const files = yield* ripgrep
.find({
cwd: dir,
pattern: "!**/SKILL.md",
hidden: true,
follow: false,
limit: 10,
})
.pipe(Effect.catch(() => Effect.succeed([] as { path: string }[])))

return [
`<skill_content name="${info.name}">`,
`# Skill: ${info.name}`,
"",
info.content.trim(),
"",
`Base directory for this skill: ${base}`,
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
"Note: file list is sampled.",
"",
"<skill_files>",
files.map((file) => `<file>${path.resolve(dir, file.path)}</file>`).join("\n"),
"</skill_files>",
"</skill_content>",
].join("\n")
})

const title = Effect.fn("SessionPrompt.ensureTitle")(function* (input: {
session: Session.Info
history: SessionV1.WithParts[]
Expand Down Expand Up @@ -972,10 +1006,34 @@ export const layer = Layer.effect(
]
}

if (part.type === "skill") {
const perm = Permission.evaluate("skill", part.name, ag.permission)
if (perm.action === "deny") return [{ ...part, messageID: info.id, sessionID: input.sessionID }]
const found = yield* skill.get(part.name)
if (!found) return [{ ...part, messageID: info.id, sessionID: input.sessionID }]
return [
{ ...part, messageID: info.id, sessionID: input.sessionID },
{
messageID: info.id,
sessionID: input.sessionID,
type: "text",
synthetic: true,
text: yield* selectedSkillContent(found),
},
]
}

return [{ ...part, messageID: info.id, sessionID: input.sessionID }]
})

const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(
const seenSkills = new Set<string>()
const promptParts = input.parts.filter((part) => {
if (part.type !== "skill") return true
if (seenSkills.has(part.name)) return false
seenSkills.add(part.name)
return true
})
const resolvedParts = yield* Effect.forEach(promptParts, resolvePart, { concurrency: "unbounded" }).pipe(
Effect.map((x) => x.flat().map(assign)),
)

Expand Down Expand Up @@ -1579,6 +1637,8 @@ export const defaultLayer = Layer.suspend(() =>
Database.defaultLayer,
SystemPrompt.defaultLayer,
LLM.defaultLayer,
Skill.defaultLayer,
Ripgrep.defaultLayer,
CrossSpawnSpawner.defaultLayer,
RuntimeFlags.defaultLayer,
EventV2Bridge.defaultLayer,
Expand Down Expand Up @@ -1609,6 +1669,7 @@ export const PromptInput = Schema.Struct({
SessionV1.TextPartInput,
SessionV1.FilePartInput,
SessionV1.AgentPartInput,
SessionV1.SkillPartInput,
SessionV1.SubtaskPartInput,
]).annotate({ discriminator: "type" }),
),
Expand Down Expand Up @@ -1707,6 +1768,8 @@ export const node = LayerNode.make(layer, [
ToolRegistry.node,
Truncate.node,
Image.node,
Skill.node,
Ripgrep.node,
CrossSpawnSpawner.node,
Instruction.node,
SessionRunState.node,
Expand Down
104 changes: 104 additions & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ function makePrompt(input?: { processor?: "blocking" }) {
Layer.provideMerge(deps),
)
return SessionPrompt.layer.pipe(
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Skill.defaultLayer),
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(Image.defaultLayer),
Layer.provide(summary),
Expand Down Expand Up @@ -2062,6 +2064,108 @@ noLLMServer.instance(
{ config: cfg },
)

noLLMServer.instance(
"resolves selected skill parts without replacing prompt text",
() =>
Effect.gen(function* () {
const { directory: dir } = yield* TestInstance
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})

const reviewDir = path.join(dir, ".opencode", "skill", "review")
yield* writeText(
path.join(reviewDir, "SKILL.md"),
"---\nname: review\ndescription: Review implementation\n---\nReview the implementation carefully.\n",
)
const reviewScript = path.join(reviewDir, "scripts", "check.ts")
yield* writeText(reviewScript, "export const check = true\n")
yield* writeText(
path.join(dir, ".opencode", "skill", "write-a-prd", "SKILL.md"),
"---\nname: write-a-prd\ndescription: Write a PRD\n---\nWrite a focused PRD.\n",
)

const msg = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{ type: "text", text: "Use $review and $write-a-prd on this change" },
{ type: "skill", name: "review", source: { value: "$review", start: 4, end: 11 } },
{ type: "skill", name: "write-a-prd", source: { value: "$write-a-prd", start: 16, end: 28 } },
{ type: "skill", name: "review", source: { value: "$review", start: 4, end: 11 } },
],
})

if (msg.info.role !== "user") throw new Error("expected user message")

const skills = msg.parts.filter((part) => part.type === "skill").map((part) => part.name)
const text = msg.parts.filter((part) => part.type === "text").map((part) => part.text)

expect(skills).toEqual(["review", "write-a-prd"])
expect(text).toContain("Use $review and $write-a-prd on this change")
expect(text.filter((part) => part.includes("Review the implementation carefully."))).toHaveLength(1)
expect(text.filter((part) => part.includes("Write a focused PRD."))).toHaveLength(1)
expect(text.some((part) => part.includes(`Base directory for this skill: ${pathToFileURL(reviewDir).href}`))).toBe(true)
expect(text.some((part) => part.includes(`<file>${reviewScript}</file>`))).toBe(true)

yield* sessions.remove(session.id)
}),
{ config: cfg },
)

noLLMServer.instance(
"does not inject denied selected skill content",
() =>
Effect.gen(function* () {
const { directory: dir } = yield* TestInstance
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})

yield* writeText(
path.join(dir, ".opencode", "skill", "denied", "SKILL.md"),
"---\nname: denied\ndescription: Denied skill\n---\nDenied skill secret instructions.\n",
)

const msg = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{ type: "text", text: "Use $denied on this change" },
{ type: "skill", name: "denied", source: { value: "$denied", start: 4, end: 11 } },
],
})

if (msg.info.role !== "user") throw new Error("expected user message")

const skills = msg.parts.filter((part) => part.type === "skill").map((part) => part.name)
const text = msg.parts.filter((part) => part.type === "text").map((part) => part.text)

expect(skills).toEqual(["denied"])
expect(text).toContain("Use $denied on this change")
expect(text.some((part) => part.includes("Denied skill secret instructions."))).toBe(false)
expect(text.some((part) => part.includes("## Skill: denied"))).toBe(false)

yield* sessions.remove(session.id)
}),
{
config: {
...cfg,
agent: {
build: {
permission: {
skill: {
denied: "deny",
},
},
},
},
},
},
)

// Special characters in filenames

noLLMServer.instance(
Expand Down
2 changes: 2 additions & 0 deletions packages/plugin/src/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
FilePart,
LspStatus,
McpStatus,
SkillPart,
Todo,
Message,
Part,
Expand Down Expand Up @@ -186,6 +187,7 @@ export type TuiPromptInfo = {
parts: (
| Omit<FilePart, "id" | "messageID" | "sessionID">
| Omit<AgentPart, "id" | "messageID" | "sessionID">
| Omit<SkillPart, "id" | "messageID" | "sessionID">
| (Omit<TextPart, "id" | "messageID" | "sessionID"> & {
source?: {
text: {
Expand Down
5 changes: 3 additions & 2 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ import type {
SessionUnshareResponses,
SessionUpdateErrors,
SessionUpdateResponses,
SkillPartInput,
SubtaskPartInput,
SyncHistoryListErrors,
SyncHistoryListResponses,
Expand Down Expand Up @@ -3738,7 +3739,7 @@ export class Session2 extends HeyApiClient {
format?: OutputFormat
system?: string
variant?: string
parts?: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
parts?: Array<TextPartInput | FilePartInput | AgentPartInput | SkillPartInput | SubtaskPartInput>
},
options?: Options<never, ThrowOnError>,
) {
Expand Down Expand Up @@ -4091,7 +4092,7 @@ export class Session2 extends HeyApiClient {
format?: OutputFormat
system?: string
variant?: string
parts?: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
parts?: Array<TextPartInput | FilePartInput | AgentPartInput | SkillPartInput | SubtaskPartInput>
},
options?: Options<never, ThrowOnError>,
) {
Expand Down
Loading
Loading