mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-15 21:36:21 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d5a393678 | ||
|
|
b3d6173a38 | ||
|
|
c0275673e1 | ||
|
|
8b384b48b4 | ||
|
|
9a4f9e1c7b | ||
|
|
17cc3ca828 | ||
|
|
c7fe7e11ab |
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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>>
|
||||
}
|
||||
|
||||
@@ -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>"]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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" })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user