Compare commits

...
10 changed files with 140 additions and 68 deletions
@@ -521,10 +521,12 @@ async function expectTerminalTopMotion(page: Page) {
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalTops.map(Math.round) ?? [],
)
const unique = [...new Set(tops)]
const range = Math.max(...unique) - Math.min(...unique)
const maxDelta = Math.max(...unique.slice(1).map((value, index) => Math.abs(value - unique[index])))
expect(unique.length, JSON.stringify(unique)).toBeGreaterThan(6)
expect(maxDelta, JSON.stringify({ unique, range, maxDelta })).toBeLessThan(range * 0.3)
expect(unique.length, JSON.stringify(unique)).toBeGreaterThan(2)
expect(unique[0] - unique[unique.length - 1], JSON.stringify(unique)).toBeGreaterThan(100)
expect(
unique.every((value, index) => index === 0 || value < unique[index - 1]),
JSON.stringify(unique),
).toBe(true)
}
async function expectHeightMotions(page: Page, slot: string, count: number) {
@@ -1589,6 +1589,7 @@ export type SessionInboxUserPayload = {
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
skills?: Array<PromptSkillAttachment>
skillActivations?: Array<{ id: string; name: string; text: string }>
metadata?: { [x: string]: JsonValue }
}
@@ -1597,6 +1598,7 @@ export type SessionInboxUserPayload1 = {
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
skills?: Array<PromptSkillAttachment>
skillActivations?: Array<{ id: string; name: string; text: string }>
metadata?: { [x: string]: any }
}
+33 -14
View File
@@ -609,7 +609,7 @@ const layer = Layer.effect(
return yield* Image.Service
}).pipe(Effect.provide(locations.get(session.location)))
const skills = Skill.Service.pipe(Effect.provide(locations.get(session.location)))
const prompt = yield* resolvePrompt(
const resolved = yield* resolvePrompt(
{ text: input.text, files: input.files, agents: input.agents, skills: input.skills },
image,
skills,
@@ -617,7 +617,7 @@ const layer = Layer.effect(
const messageID = input.id ?? SessionMessage.ID.create()
const admittedInput = SessionInbox.Item.make({
type: "user",
payload: { ...prompt, metadata: input.metadata },
payload: { ...resolved.prompt, skillActivations: resolved.skillActivations, metadata: input.metadata },
delivery: input.delivery ?? "steer",
})
const admitted = yield* SessionInbox.admit(db, bus, {
@@ -746,15 +746,16 @@ const layer = Layer.effect(
skill: Effect.fn("Session.skill")(function* (input) {
const session = yield* result.get(input.sessionID)
const skills = yield* Skill.Service.pipe(Effect.provide(locations.get(session.location)))
const skill = (yield* skills.list()).find((item) => item.id === input.skill)
const skill = Skill.resolve(yield* skills.list(), input.skill)
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
const prepared = yield* Skill.prepare(fs, skill)
yield* bus.publish(
SessionEvent.Skill.Activated,
{
sessionID: input.sessionID,
id: skill.id,
name: skill.name,
text: skill.content,
id: prepared.id,
name: prepared.name,
text: prepared.output,
},
{ id: input.id ? Event.ID.make(input.id.replace(/^msg_/, "evt_")) : undefined },
)
@@ -999,17 +1000,35 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
if (!requested?.length) return undefined
const skillService = yield* skills
const available = yield* skillService.list()
return yield* Effect.forEach(requested, (attachment) => {
const skill = available.find((item) => item.id === attachment.id)
if (!skill) return Effect.fail(new SkillNotFoundError({ skill: attachment.id }))
return Effect.succeed({
const loaded = yield* Effect.forEach(requested, (attachment) =>
Effect.gen(function* () {
const skill = Skill.resolve(available, attachment.id)
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
return { skill: yield* Skill.prepare(fs, skill), attachment }
}),
)
return {
attachments: loaded.map((item) => ({
id: item.skill.id,
name: item.skill.name,
mention: item.attachment.mention,
})),
activations: Array.from(new Map(loaded.map((item) => [item.skill.id, item.skill])).values()).map((skill) => ({
id: skill.id,
name: skill.name,
mention: attachment.mention,
})
})
text: skill.output,
})),
}
})
return Prompt.make({ text: input.text, agents: input.agents, files, skills: selected?.length ? selected : undefined })
return {
prompt: Prompt.fromUserMessage({
text: input.text,
agents: input.agents,
files,
skills: selected?.attachments,
}),
skillActivations: selected?.activations,
}
})
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
+31 -14
View File
@@ -425,20 +425,37 @@ const publish = Effect.fn("SessionInbox.publish")(function* (
(row) => {
const entry = fromRow(row)
if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id }))
return bus
.publish(SessionEvent.InboxDelivered, {
sessionID,
inboxID: entry.id,
})
.pipe(
Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict
? promotedFromMessage(db, sessionID, entry.id, entry.delivery).pipe(
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
)
: Effect.die(defect),
),
)
const delivered = [SessionEvent.InboxDelivered, { sessionID, inboxID: entry.id }] as const
const activations = entry.type === "user" ? entry.payload.skillActivations : undefined
const published = activations?.length
? bus
.publishAll([
[
SessionEvent.Skill.Activated,
{ sessionID, id: activations[0].id, name: activations[0].name, text: activations[0].text },
],
...activations
.slice(1)
.map(
(skill) =>
[
SessionEvent.Skill.Activated,
{ sessionID, id: skill.id, name: skill.name, text: skill.text },
] as const,
),
delivered,
])
.pipe(Effect.asVoid)
: bus.publish(...delivered).pipe(Effect.asVoid)
return published.pipe(
Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict
? promotedFromMessage(db, sessionID, entry.id, entry.delivery).pipe(
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
)
: Effect.die(defect),
),
)
},
{ discard: true },
)
+27
View File
@@ -1,6 +1,7 @@
export * as Skill from "./skill.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import path from "path"
import { Context, Effect, Layer, Types } from "effect"
import { Skill } from "@opencode-ai/schema/skill"
@@ -52,6 +53,32 @@ export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
].join("\n")
}
const FILE_LIMIT = 10
export const resolve = (skills: ReadonlyArray<Info>, id: ID) => skills.find((skill) => skill.id === id)
export const prepare = Effect.fn("Skill.prepare")(function* (fs: FSUtil.Interface, skill: Info) {
const directory = path.dirname(skill.location)
const files =
path.basename(skill.location) === "SKILL.md"
? yield* fs
.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true })
.pipe(Effect.orElseSucceed(() => [] as string[]))
: []
return {
id: skill.id,
name: skill.name,
directory,
output: toModelOutput(
skill,
files
.filter((file) => path.basename(file) !== "SKILL.md")
.toSorted()
.slice(0, FILE_LIMIT),
),
}
})
export type Data = {
skills: Map<ID, Types.DeepMutable<Info>>
}
-1
View File
@@ -26,7 +26,6 @@ const render = (skills: ReadonlyArray<Summary>) =>
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
"When the user references a skill with @skill-id, load that skill with the skill tool.",
...(skills.length === 0
? ["No skills are currently available."]
: ["<available_skills>", ...entries(skills), "</available_skills>"]),
+6 -20
View File
@@ -1,7 +1,6 @@
export * as SkillTool from "./skill.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -9,7 +8,6 @@ import { Skill } from "../../skill.js"
import { Permission } from "../../permission.js"
export const name = "skill"
const FILE_LIMIT = 10
export const Input = Schema.Struct({
id: Skill.ID.annotate({ description: "The ID of an available skill or a skill explicitly referenced by the user" }),
@@ -47,11 +45,10 @@ export const Plugin = {
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const current = yield* skills.list()
const skill = current.find((skill) => skill.id === input.id)
const skill = Skill.resolve(yield* skills.list(), input.id)
if (!skill) return yield* unableToLoad(input.id)
return yield* Effect.gen(function* () {
yield* permission.assert({
yield* permission
.assert({
action: name,
resources: [skill.id],
save: [skill.id],
@@ -59,20 +56,9 @@ export const Plugin = {
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const directory = path.dirname(skill.location)
const files =
path.basename(skill.location) === "SKILL.md"
? (yield* fs.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
.filter((file) => path.basename(file) !== "SKILL.md")
.toSorted()
.slice(0, FILE_LIMIT)
: []
return {
name: skill.name,
directory,
output: Skill.toModelOutput(skill, files),
}
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
.pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
const prepared = yield* Skill.prepare(fs, skill)
return { name: prepared.name, directory: prepared.directory, output: prepared.output }
}).pipe(
Effect.map((output) => ({
output,
+26 -14
View File
@@ -23,17 +23,15 @@ const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const projects = Layer.mock(Project.Service, {
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
const info = Skill.Info.make({
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
description: "Effect guidance",
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
content: "Use Effect",
})
const skills = Layer.mock(Skill.Service, {
list: () =>
Effect.succeed([
Skill.Info.make({
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
description: "Effect guidance",
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
content: "Use Effect",
}),
]),
list: () => Effect.succeed([info]),
})
const locations = Layer.effect(
LocationServiceMap.Service,
@@ -56,7 +54,7 @@ const it = testEffect(
)
describe("Session.skill", () => {
it.effect("keeps skill mentions as references on a normal prompt", () =>
it.effect("activates mentioned skills when the prompt is delivered", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const database = yield* Database.Service
@@ -71,9 +69,17 @@ describe("Session.skill", () => {
skills: [{ id: Skill.ID.make("effect"), mention: { start: 6, end: 13, text: "@effect" } }],
resume: false,
})
expect(yield* sessions.messages({ sessionID: session.id })).toEqual([])
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual(
expect(yield* sessions.messages({ sessionID: session.id, order: "asc" })).toEqual([
expect.objectContaining({
type: "skill",
skill: "effect",
name: "Effect",
text: Skill.toModelOutput(info, []),
}),
expect.objectContaining({
id,
type: "user",
@@ -86,7 +92,7 @@ describe("Session.skill", () => {
},
],
}),
)
])
}),
)
@@ -99,7 +105,13 @@ describe("Session.skill", () => {
yield* sessions.skill({ id, sessionID: session.id, skill: Skill.ID.make("effect"), resume: false })
expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual(
expect.objectContaining({ id, type: "skill", skill: "effect", name: "Effect", text: "Use Effect" }),
expect.objectContaining({
id,
type: "skill",
skill: "effect",
name: "Effect",
text: Skill.toModelOutput(info, []),
}),
)
}),
)
@@ -59,7 +59,6 @@ describe("SkillInstructions", () => {
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
"When the user references a skill with @skill-id, load that skill with the skill tool.",
"<available_skills>",
" <skill>",
" <id>effect</id>",
+9
View File
@@ -7,13 +7,22 @@ import { Prompt } from "./prompt.js"
import { DateTimeUtcFromMillis, optional, RelativePath } from "./schema.js"
import { SessionID } from "./session-id.js"
import { SessionMessage } from "./session-message.js"
import { Skill } from "./skill.js"
export const Delivery = Schema.Literals(["steer", "queue"]).annotate({ identifier: "Session.Inbox.Delivery" })
export type Delivery = typeof Delivery.Type
const SkillActivation = Schema.Struct({
id: Skill.ID,
name: Skill.Name,
text: Schema.String,
})
export interface UserPayload extends Schema.Schema.Type<typeof UserPayload> {}
export const UserPayload = Schema.Struct({
...Prompt.fields,
/** Frozen at admission and emitted before the prompt when this inbox item is delivered. */
skillActivations: Schema.Array(SkillActivation).pipe(optional),
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
}).annotate({ identifier: "Session.Inbox.UserPayload" })