Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db0207d01e | |||
| 93141d1554 | |||
| 46e77b0730 | |||
| baf0acd7b5 | |||
| 9afa0b5f8a | |||
| 19d60d0bf5 | |||
| 8396395f17 | |||
| 1b8bab3e35 | |||
| 02687b6324 | |||
| c780d7cee7 | |||
| 7a9337da8a | |||
| f96e6aa6ed | |||
| c6f719e153 | |||
| 1a111be494 | |||
| a0aee82be9 | |||
| c0dc6e50a7 | |||
| 418a9e4e66 | |||
| bd8ce5e6a9 | |||
| a97c6de9db | |||
| 24ea4dd008 | |||
| ffcb7542e1 | |||
| d4d841bafd | |||
| 6f0e934573 | |||
| 233d065dd5 | |||
| f12ac6f234 |
@@ -99,7 +99,8 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
# Playwright 1.59 hangs while extracting Chromium with Node 24.16.
|
||||
node-version: "24.15"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
@@ -28,6 +28,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
|
||||
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
|
||||
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
|
||||
- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
|
||||
- In Effect generators, bind services to named variables before calling methods. Do not use nested service yields such as `yield* (yield* Foo.Service).bar()`.
|
||||
|
||||
Reduce total variable count by inlining when a value is only used once.
|
||||
|
||||
|
||||
@@ -276,6 +276,7 @@
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
@@ -625,6 +626,7 @@
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:",
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as AgentV2 from "./agent"
|
||||
|
||||
import { Array, Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { castDraft, enableMapSet, type Draft } from "immer"
|
||||
import { Array, Context, Effect, Layer, Schema, Scope, Types } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { PermissionSchema } from "./permission/schema"
|
||||
import { ProviderV2 } from "./provider"
|
||||
@@ -49,21 +48,19 @@ export interface Selection {
|
||||
}
|
||||
|
||||
type Data = {
|
||||
agents: Map<ID, Info>
|
||||
agents: Map<ID, Types.DeepMutable<Info>>
|
||||
default?: ID
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
list: () => readonly Info[]
|
||||
get: (id: ID) => Info | undefined
|
||||
default: (id: ID | undefined) => void
|
||||
update: (id: ID, fn: (agent: Draft<Info>) => void) => void
|
||||
update: (id: ID, fn: (agent: Types.DeepMutable<Info>) => void) => void
|
||||
remove: (id: ID) => void
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly update: State.Interface<Data, Editor>["update"]
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
readonly default: () => Effect.Effect<Info | undefined>
|
||||
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
|
||||
@@ -73,21 +70,19 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
|
||||
|
||||
enableMapSet()
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ agents: new Map() }),
|
||||
editor: (draft) => ({
|
||||
draft: (draft) => ({
|
||||
list: () => Array.fromIterable(draft.agents.values()) as Info[],
|
||||
get: (id) => draft.agents.get(id),
|
||||
default: (id) => {
|
||||
draft.default = id
|
||||
},
|
||||
update: (id, fn) => {
|
||||
const current = draft.agents.get(id) ?? castDraft(Info.empty(id))
|
||||
const current = draft.agents.get(id) ?? (Info.empty(id) as Types.DeepMutable<Info>)
|
||||
if (!draft.agents.has(id)) draft.agents.set(id, current)
|
||||
fn(current)
|
||||
current.id = id
|
||||
@@ -113,7 +108,7 @@ export const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
update: state.update,
|
||||
rebuild: state.rebuild,
|
||||
get: Effect.fn("AgentV2.get")(function* (id) {
|
||||
return state.get().agents.get(id)
|
||||
}),
|
||||
|
||||
@@ -1,36 +1,21 @@
|
||||
export * as Catalog from "./catalog"
|
||||
|
||||
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema, Scope, Stream } from "effect"
|
||||
import { castDraft, enableMapSet, type Draft } from "immer"
|
||||
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { ModelRequest } from "./model-request"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { Location } from "./location"
|
||||
import { EventV2 } from "./event"
|
||||
import { Policy } from "./policy"
|
||||
import { State } from "./state"
|
||||
import { Integration } from "./integration"
|
||||
|
||||
export type ProviderRecord = {
|
||||
provider: ProviderV2.Info
|
||||
models: Map<ModelV2.ID, ModelV2.Info>
|
||||
provider: ProviderV2.MutableInfo
|
||||
models: Map<ModelV2.ID, ModelV2.MutableInfo>
|
||||
}
|
||||
|
||||
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
|
||||
|
||||
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
|
||||
"CatalogV2.ProviderNotFound",
|
||||
{
|
||||
providerID: ProviderV2.ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("CatalogV2.ModelNotFound", {
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
}) {}
|
||||
|
||||
export const PolicyActions = Schema.Literals(["provider.use"])
|
||||
|
||||
export const Event = {
|
||||
@@ -42,16 +27,16 @@ type Data = {
|
||||
defaultModel?: DefaultModel
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
provider: {
|
||||
list: () => readonly ProviderRecord[]
|
||||
get: (providerID: ProviderV2.ID) => ProviderRecord | undefined
|
||||
update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
|
||||
update: (providerID: ProviderV2.ID, fn: (provider: ProviderV2.MutableInfo) => void) => void
|
||||
remove: (providerID: ProviderV2.ID) => void
|
||||
}
|
||||
model: {
|
||||
get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined
|
||||
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
|
||||
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: ModelV2.MutableInfo) => void) => void
|
||||
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
|
||||
default: {
|
||||
get: () => DefaultModel | undefined
|
||||
@@ -60,38 +45,29 @@ export type Editor = {
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly provider: {
|
||||
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
|
||||
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info | undefined>
|
||||
readonly all: () => Effect.Effect<ProviderV2.Info[]>
|
||||
readonly available: () => Effect.Effect<ProviderV2.Info[]>
|
||||
}
|
||||
readonly model: {
|
||||
readonly get: (
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
|
||||
readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect<ModelV2.Info | undefined>
|
||||
readonly all: () => Effect.Effect<ModelV2.Info[]>
|
||||
readonly available: () => Effect.Effect<ModelV2.Info[]>
|
||||
readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>
|
||||
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<Option.Option<ModelV2.Info>>
|
||||
readonly default: () => Effect.Effect<ModelV2.Info | undefined>
|
||||
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<ModelV2.Info | undefined>
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
|
||||
|
||||
enableMapSet()
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const policy = yield* Policy.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
|
||||
if (provider.disabled) return false
|
||||
@@ -120,32 +96,26 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
|
||||
function* getRecord(providerID: ProviderV2.ID) {
|
||||
const match = state.get().providers.get(providerID)
|
||||
if (!match) return yield* new ProviderNotFoundError({ providerID })
|
||||
return match
|
||||
}
|
||||
|
||||
const normalizeApi = (item: Draft<ProviderV2.Info> | Draft<ModelV2.Info>) => {
|
||||
const normalizeApi = (item: ProviderV2.MutableInfo | ModelV2.MutableInfo) => {
|
||||
if (typeof item.request.body.baseURL !== "string") return
|
||||
item.api.url = item.request.body.baseURL
|
||||
delete item.request.body.baseURL
|
||||
}
|
||||
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ providers: new Map() }),
|
||||
editor: (draft) => {
|
||||
const result: Editor = {
|
||||
draft: (draft) => {
|
||||
const result: Draft = {
|
||||
provider: {
|
||||
list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[],
|
||||
get: (providerID) => draft.providers.get(providerID),
|
||||
update: (providerID, fn) => {
|
||||
let current = draft.providers.get(providerID)
|
||||
if (!current) {
|
||||
current = castDraft({
|
||||
provider: ProviderV2.Info.empty(providerID),
|
||||
models: new Map<ModelV2.ID, ModelV2.Info>(),
|
||||
})
|
||||
current = {
|
||||
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
|
||||
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
|
||||
}
|
||||
draft.providers.set(providerID, current)
|
||||
}
|
||||
fn(current.provider)
|
||||
@@ -160,13 +130,14 @@ export const layer = Layer.effect(
|
||||
update: (providerID, modelID, fn) => {
|
||||
let record = draft.providers.get(providerID)
|
||||
if (!record) {
|
||||
record = castDraft({
|
||||
provider: ProviderV2.Info.empty(providerID),
|
||||
models: new Map<ModelV2.ID, ModelV2.Info>(),
|
||||
})
|
||||
record = {
|
||||
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
|
||||
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
|
||||
}
|
||||
draft.providers.set(providerID, record)
|
||||
}
|
||||
const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID))
|
||||
const model =
|
||||
record.models.get(modelID) ?? (ModelV2.Info.empty(providerID, modelID) as ModelV2.MutableInfo)
|
||||
if (!record.models.has(modelID)) record.models.set(modelID, model)
|
||||
fn(model)
|
||||
model.id = modelID
|
||||
@@ -186,8 +157,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
return result
|
||||
},
|
||||
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
|
||||
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
|
||||
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
|
||||
if (policy.hasStatements()) {
|
||||
for (const record of [...catalog.provider.list()]) {
|
||||
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
|
||||
@@ -198,25 +168,13 @@ export const layer = Layer.effect(
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
yield* events.subscribe(PluginV2.Event.Added).pipe(
|
||||
// Plugin registries are location scoped even though the event bus is process scoped.
|
||||
Stream.filter(
|
||||
(event) =>
|
||||
event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID,
|
||||
),
|
||||
Stream.runForEach((event) =>
|
||||
state.mutate((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
|
||||
),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
|
||||
const result: Interface = {
|
||||
transform: state.transform,
|
||||
rebuild: state.rebuild,
|
||||
|
||||
provider: {
|
||||
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
|
||||
const record = yield* getRecord(providerID)
|
||||
return record.provider
|
||||
return state.get().providers.get(providerID)?.provider
|
||||
}),
|
||||
|
||||
all: Effect.fn("CatalogV2.provider.all")(function* () {
|
||||
@@ -238,10 +196,10 @@ export const layer = Layer.effect(
|
||||
|
||||
model: {
|
||||
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
|
||||
const record = yield* getRecord(providerID)
|
||||
const record = state.get().providers.get(providerID)
|
||||
if (!record) return
|
||||
const model = record.models.get(modelID)
|
||||
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
|
||||
return projectModel(model, record.provider)
|
||||
return model && projectModel(model, record.provider)
|
||||
}),
|
||||
|
||||
all: Effect.fn("CatalogV2.model.all")(function* () {
|
||||
@@ -250,7 +208,7 @@ export const layer = Layer.effect(
|
||||
Array.flatMap((record) => {
|
||||
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
|
||||
}),
|
||||
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
|
||||
Array.sortWith((item) => item.time.released, Order.flip(Order.Number)),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -262,31 +220,30 @@ export const layer = Layer.effect(
|
||||
default: Effect.fn("CatalogV2.model.default")(function* () {
|
||||
const defaultModel = state.get().defaultModel
|
||||
if (defaultModel) {
|
||||
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option)
|
||||
if (
|
||||
Option.isSome(provider) &&
|
||||
(yield* result.provider.available()).some((item) => item.id === provider.value.id)
|
||||
) {
|
||||
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
|
||||
if (Option.isSome(model) && model.value.enabled) return model
|
||||
const provider = yield* result.provider.get(defaultModel.providerID)
|
||||
if (provider && (yield* result.provider.available()).some((item) => item.id === provider.id)) {
|
||||
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID)
|
||||
if (model?.enabled) return model
|
||||
}
|
||||
}
|
||||
|
||||
return pipe(
|
||||
yield* result.model.available(),
|
||||
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
|
||||
Array.head,
|
||||
return Option.getOrUndefined(
|
||||
pipe(
|
||||
yield* result.model.available(),
|
||||
Array.sortWith((item) => item.time.released, Order.flip(Order.Number)),
|
||||
Array.head,
|
||||
),
|
||||
)
|
||||
}),
|
||||
|
||||
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
|
||||
const record = state.get().providers.get(providerID)
|
||||
if (!record) return Option.none<ModelV2.Info>()
|
||||
if (!record) return
|
||||
const provider = record.provider
|
||||
|
||||
if (providerID === ProviderV2.ID.opencode) {
|
||||
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
|
||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider))
|
||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
|
||||
}
|
||||
|
||||
const candidates = pipe(
|
||||
@@ -302,7 +259,7 @@ export const layer = Layer.effect(
|
||||
Array.map((model) => ({
|
||||
model,
|
||||
cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999,
|
||||
age: (Date.now() - model.time.released.epochMilliseconds) / (1000 * 60 * 60 * 24 * 30),
|
||||
age: (Date.now() - model.time.released) / (1000 * 60 * 60 * 24 * 30),
|
||||
small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()),
|
||||
})),
|
||||
Array.filter((item) => item.cost > 0 && item.age <= 18),
|
||||
@@ -319,10 +276,12 @@ export const layer = Layer.effect(
|
||||
)
|
||||
}
|
||||
|
||||
return pipe(
|
||||
candidates,
|
||||
Array.filter((item) => item.small),
|
||||
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
|
||||
return Option.getOrUndefined(
|
||||
pipe(
|
||||
candidates,
|
||||
Array.filter((item) => item.small),
|
||||
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
@@ -336,6 +295,5 @@ const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
|
||||
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(Integration.locationLayer),
|
||||
Layer.provideMerge(PluginV2.locationLayer),
|
||||
Layer.provideMerge(Policy.locationLayer),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as CommandV2 from "./command"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { castDraft, type Draft } from "immer"
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { State } from "./state"
|
||||
|
||||
@@ -15,19 +14,17 @@ export class Info extends Schema.Class<Info>("CommandV2.Info")({
|
||||
}) {}
|
||||
|
||||
export type Data = {
|
||||
commands: Map<string, Info>
|
||||
commands: Map<string, Types.DeepMutable<Info>>
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
list: () => readonly Info[]
|
||||
get: (name: string) => Info | undefined
|
||||
update: (name: string, update: (command: Draft<Info>) => void) => void
|
||||
update: (name: string, update: (command: Types.DeepMutable<Info>) => void) => void
|
||||
remove: (name: string) => void
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly update: State.Interface<Data, Editor>["update"]
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
@@ -37,13 +34,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.sync(() => {
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ commands: new Map() }),
|
||||
editor: (draft) => ({
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.commands.values()) as Info[],
|
||||
get: (name) => draft.commands.get(name),
|
||||
update: (name, update) => {
|
||||
const current = draft.commands.get(name) ?? castDraft(new Info({ name, template: "" }))
|
||||
const current = draft.commands.get(name) ?? (new Info({ name, template: "" }) as Types.DeepMutable<Info>)
|
||||
if (!draft.commands.has(name)) draft.commands.set(name, current)
|
||||
update(current)
|
||||
current.name = name
|
||||
@@ -55,7 +52,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
update: state.update,
|
||||
rebuild: state.rebuild,
|
||||
transform: state.transform,
|
||||
get: Effect.fn("CommandV2.get")(function* (name) {
|
||||
return state.get().commands.get(name)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as ConfigAgentPlugin from "./agent"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
@@ -8,7 +9,6 @@ import { ConfigAgent } from "../agent"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
import { FSUtil } from "../../fs-util"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ConfigAgentV1 } from "../../v1/config/agent"
|
||||
import { ConfigMigrateV1 } from "../../v1/config/migrate"
|
||||
|
||||
@@ -33,70 +33,70 @@ const agentKeys = new Set([
|
||||
"permissions",
|
||||
])
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("config-agent"),
|
||||
effect: Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
export const Plugin = define({
|
||||
id: "config-agent",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([entry])
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* discover(fs, entry.path)
|
||||
return yield* Effect.forEach(files, (file) =>
|
||||
fs.readFileStringSafe(file.filepath).pipe(
|
||||
Effect.map((content) => content && decode(file, content)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((documents) =>
|
||||
documents.filter((document): document is Config.Document => document !== undefined),
|
||||
),
|
||||
)
|
||||
})
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
|
||||
yield* agent.update((editor) => {
|
||||
const global = documents.flatMap((document) => document.info.permissions ?? [])
|
||||
const configuredDefault = Config.latest(documents, "default_agent")
|
||||
if (configuredDefault !== undefined) editor.default(AgentV2.ID.make(configuredDefault))
|
||||
for (const current of editor.list()) {
|
||||
editor.update(current.id, (agent) => agent.permissions.push(...global))
|
||||
}
|
||||
|
||||
for (const document of documents) {
|
||||
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
|
||||
const agentID = AgentV2.ID.make(id)
|
||||
if (item.disabled) {
|
||||
editor.remove(agentID)
|
||||
continue
|
||||
}
|
||||
|
||||
const exists = editor.get(agentID) !== undefined
|
||||
editor.update(agentID, (agent) => {
|
||||
if (!exists) agent.permissions.push(...global)
|
||||
if (item.model !== undefined) {
|
||||
const model = ModelV2.parse(item.model)
|
||||
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
|
||||
}
|
||||
if (item.variant !== undefined && agent.model !== undefined) {
|
||||
agent.model.variant = ModelV2.VariantID.make(item.variant)
|
||||
}
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(agent.request.headers, item.request.headers ?? {})
|
||||
Object.assign(agent.request.body, item.request.body ?? {})
|
||||
}
|
||||
if (item.system !== undefined) agent.system = item.system
|
||||
if (item.description !== undefined) agent.description = item.description
|
||||
if (item.mode !== undefined) agent.mode = item.mode
|
||||
if (item.hidden !== undefined) agent.hidden = item.hidden
|
||||
if (item.color !== undefined) agent.color = item.color
|
||||
if (item.steps !== undefined) agent.steps = item.steps
|
||||
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
|
||||
yield* ctx.agent.transform(
|
||||
Effect.fn(function* (draft) {
|
||||
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([entry])
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* discover(fs, entry.path)
|
||||
return yield* Effect.forEach(files, (file) =>
|
||||
fs.readFileStringSafe(file.filepath).pipe(
|
||||
Effect.map((content) => content && decode(file, content)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((documents) =>
|
||||
documents.filter((document): document is Config.Document => document !== undefined),
|
||||
),
|
||||
)
|
||||
})
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
const global = documents.flatMap((document) => document.info.permissions ?? [])
|
||||
const configuredDefault = Config.latest(documents, "default_agent")
|
||||
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
|
||||
for (const current of draft.list()) {
|
||||
draft.update(current.id, (agent) => agent.permissions.push(...global))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for (const document of documents) {
|
||||
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
|
||||
const agentID = AgentV2.ID.make(id)
|
||||
if (item.disabled) {
|
||||
draft.remove(agentID)
|
||||
continue
|
||||
}
|
||||
|
||||
const exists = draft.get(agentID) !== undefined
|
||||
draft.update(agentID, (agent) => {
|
||||
if (!exists) agent.permissions.push(...global)
|
||||
if (item.model !== undefined) {
|
||||
const model = ModelV2.parse(item.model)
|
||||
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
|
||||
}
|
||||
if (item.variant !== undefined && agent.model !== undefined) {
|
||||
agent.model.variant = ModelV2.VariantID.make(item.variant)
|
||||
}
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(agent.request.headers, item.request.headers ?? {})
|
||||
Object.assign(agent.request.body, item.request.body ?? {})
|
||||
}
|
||||
if (item.system !== undefined) agent.system = item.system
|
||||
if (item.description !== undefined) agent.description = item.description
|
||||
if (item.mode !== undefined) agent.mode = item.mode
|
||||
if (item.hidden !== undefined) agent.hidden = item.hidden
|
||||
if (item.color !== undefined) agent.color = item.color
|
||||
if (item.steps !== undefined) agent.steps = item.steps
|
||||
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,52 +1,51 @@
|
||||
export * as ConfigCommandPlugin from "./command"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { CommandV2 } from "../../command"
|
||||
import { Config } from "../../config"
|
||||
import { FSUtil } from "../../fs-util"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ConfigCommand } from "../command"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
|
||||
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("config-command"),
|
||||
effect: Effect.gen(function* () {
|
||||
const command = yield* CommandV2.Service
|
||||
export const Plugin = define({
|
||||
id: "config-command",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const transform = yield* command.transform()
|
||||
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
|
||||
return loadDirectory(fs, entry.path).pipe(
|
||||
Effect.map((commands) => [
|
||||
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
|
||||
]),
|
||||
)
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
|
||||
yield* transform((editor) => {
|
||||
for (const document of documents) {
|
||||
for (const [name, command] of Object.entries(document.commands ?? {})) {
|
||||
editor.update(name, (item) => {
|
||||
item.template = command.template
|
||||
if (command.description !== undefined) item.description = command.description
|
||||
if (command.agent !== undefined) item.agent = command.agent
|
||||
if (command.model !== undefined) {
|
||||
const model = ModelV2.parse(command.model)
|
||||
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
|
||||
}
|
||||
if (command.variant !== undefined && item.model !== undefined) {
|
||||
item.model.variant = ModelV2.VariantID.make(command.variant)
|
||||
}
|
||||
if (command.subtask !== undefined) item.subtask = command.subtask
|
||||
})
|
||||
yield* ctx.command.transform(
|
||||
Effect.fn(function* (draft) {
|
||||
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
|
||||
return loadDirectory(fs, entry.path).pipe(
|
||||
Effect.map((commands) => [
|
||||
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
|
||||
]),
|
||||
)
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
for (const document of documents) {
|
||||
for (const [name, command] of Object.entries(document.commands ?? {})) {
|
||||
draft.update(name, (item) => {
|
||||
item.template = command.template
|
||||
if (command.description !== undefined) item.description = command.description
|
||||
if (command.agent !== undefined) item.agent = command.agent
|
||||
if (command.model !== undefined) {
|
||||
const model = ModelV2.parse(command.model)
|
||||
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
|
||||
}
|
||||
if (command.variant !== undefined && item.model !== undefined) {
|
||||
item.model.variant = ModelV2.VariantID.make(command.variant)
|
||||
}
|
||||
if (command.subtask !== undefined) item.subtask = command.subtask
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,123 +1,124 @@
|
||||
export * as ConfigProviderPlugin from "./provider"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Config } from "../../config"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ModelRequest } from "../../model-request"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("config-provider"),
|
||||
effect: Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
export const Plugin = define({
|
||||
id: "config-provider",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const transform = yield* catalog.transform()
|
||||
const integrationTransform = yield* integrations.transform()
|
||||
const entries = yield* config.entries()
|
||||
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredIntegrations = new Set(
|
||||
files.flatMap((file) =>
|
||||
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
|
||||
),
|
||||
)
|
||||
yield* integrationTransform((integrations) => {
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const integrationID = Integration.ID.make(id)
|
||||
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
|
||||
integrations.update(integrationID, (integration) => {
|
||||
integration.name = item.name ?? integration.name
|
||||
})
|
||||
if (item.env !== undefined) {
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "env", names: [...item.env] },
|
||||
yield* ctx.integration.transform(
|
||||
Effect.fn(function* (integrations) {
|
||||
const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredIntegrations = new Set(
|
||||
files.flatMap((file) =>
|
||||
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) =>
|
||||
provider.env === undefined ? [] : [id],
|
||||
),
|
||||
),
|
||||
)
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const integrationID = id
|
||||
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
|
||||
integrations.update(integrationID, (integration) => {
|
||||
integration.name = item.name ?? integration.name
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
yield* transform((catalog) => {
|
||||
const configuredDefault = Config.latest(entries, "model")
|
||||
if (configuredDefault !== undefined) {
|
||||
const model = ModelV2.parse(configuredDefault)
|
||||
catalog.model.default.set(model.providerID, model.modelID)
|
||||
}
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const providerID = ProviderV2.ID.make(id)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.api !== undefined) provider.api = { ...item.api }
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(provider.request.headers, item.request.headers)
|
||||
Object.assign(provider.request.body, item.request.body)
|
||||
if (item.env !== undefined) {
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "env", names: [...item.env] },
|
||||
})
|
||||
}
|
||||
})
|
||||
const providerApi = catalog.provider.get(providerID)?.provider.api
|
||||
const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
|
||||
|
||||
for (const [id, config] of Object.entries(item.models ?? {})) {
|
||||
catalog.model.update(providerID, ModelV2.ID.make(id), (model) => {
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
|
||||
const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
|
||||
if (config.capabilities !== undefined) {
|
||||
model.capabilities = {
|
||||
tools: config.capabilities.tools,
|
||||
input: [...config.capabilities.input],
|
||||
output: [...config.capabilities.output],
|
||||
}
|
||||
}
|
||||
if (config.request !== undefined) {
|
||||
ModelRequest.assign(model.request, {
|
||||
headers: config.request.headers,
|
||||
...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
|
||||
})
|
||||
if (config.request.variant !== undefined) model.request.variant = config.request.variant
|
||||
}
|
||||
if (config.variants !== undefined) {
|
||||
for (const variant of config.variants) {
|
||||
let existing = model.variants.find((item) => item.id === variant.id)
|
||||
if (!existing) {
|
||||
existing = {
|
||||
id: variant.id,
|
||||
headers: {},
|
||||
body: {},
|
||||
generation: {},
|
||||
options: {},
|
||||
}
|
||||
model.variants.push(existing)
|
||||
}
|
||||
ModelRequest.assign(existing, {
|
||||
headers: variant.headers,
|
||||
...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (config.cost !== undefined) {
|
||||
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
|
||||
tier: cost.tier && { ...cost.tier },
|
||||
input: cost.input,
|
||||
output: cost.output,
|
||||
cache: {
|
||||
read: cost.cache?.read ?? 0,
|
||||
write: cost.cache?.write ?? 0,
|
||||
},
|
||||
}))
|
||||
}
|
||||
if (config.disabled !== undefined) model.enabled = !config.disabled
|
||||
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (catalog) {
|
||||
const entries = yield* config.entries()
|
||||
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredDefault = Config.latest(entries, "model")
|
||||
if (configuredDefault !== undefined) {
|
||||
const model = ModelV2.parse(configuredDefault)
|
||||
catalog.model.default.set(model.providerID, model.modelID)
|
||||
}
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const providerID = id
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.api !== undefined) provider.api = { ...item.api }
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(provider.request.headers, item.request.headers)
|
||||
Object.assign(provider.request.body, item.request.body)
|
||||
}
|
||||
})
|
||||
const providerApi = catalog.provider.get(providerID)?.provider.api
|
||||
const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
|
||||
|
||||
for (const [id, config] of Object.entries(item.models ?? {})) {
|
||||
catalog.model.update(providerID, id, (model) => {
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
|
||||
const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
|
||||
if (config.capabilities !== undefined) {
|
||||
model.capabilities = {
|
||||
tools: config.capabilities.tools,
|
||||
input: [...config.capabilities.input],
|
||||
output: [...config.capabilities.output],
|
||||
}
|
||||
}
|
||||
if (config.request !== undefined) {
|
||||
ModelRequest.assign(model.request, {
|
||||
headers: config.request.headers,
|
||||
...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
|
||||
})
|
||||
if (config.request.variant !== undefined) model.request.variant = config.request.variant
|
||||
}
|
||||
if (config.variants !== undefined) {
|
||||
for (const variant of config.variants) {
|
||||
let existing = model.variants.find((item) => item.id === variant.id)
|
||||
if (!existing) {
|
||||
existing = {
|
||||
id: variant.id,
|
||||
headers: {},
|
||||
body: {},
|
||||
generation: {},
|
||||
options: {},
|
||||
}
|
||||
model.variants.push(existing)
|
||||
}
|
||||
ModelRequest.assign(existing, {
|
||||
headers: variant.headers,
|
||||
...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (config.cost !== undefined) {
|
||||
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
|
||||
tier: cost.tier && { ...cost.tier },
|
||||
input: cost.input,
|
||||
output: cost.output,
|
||||
cache: {
|
||||
read: cost.cache?.read ?? 0,
|
||||
write: cost.cache?.write ?? 0,
|
||||
},
|
||||
}))
|
||||
}
|
||||
if (config.disabled !== undefined) model.enabled = !config.disabled
|
||||
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,57 +1,52 @@
|
||||
export * as ConfigReferencePlugin from "./reference"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ConfigReference } from "../reference"
|
||||
import { Global } from "../../global"
|
||||
import { Location } from "../../location"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { Reference } from "../../reference"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
|
||||
export const Plugin = {
|
||||
id: PluginV2.ID.make("core/config-reference"),
|
||||
effect: Effect.gen(function* () {
|
||||
export const Plugin = define({
|
||||
id: "core/config-reference",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const references = yield* Reference.Service
|
||||
const update = yield* references.transform()
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of (yield* config.entries()).filter(
|
||||
(entry): entry is Config.Document => entry.type === "document",
|
||||
)) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
entries.set(
|
||||
name,
|
||||
local(entry)
|
||||
? new Reference.LocalSource({
|
||||
type: "local",
|
||||
path: AbsolutePath.make(
|
||||
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
|
||||
),
|
||||
description: typeof entry === "string" ? undefined : entry.description,
|
||||
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
||||
})
|
||||
: new Reference.GitSource({
|
||||
type: "git",
|
||||
repository: typeof entry === "string" ? entry : entry.repository,
|
||||
branch: typeof entry === "string" ? undefined : entry.branch,
|
||||
description: typeof entry === "string" ? undefined : entry.description,
|
||||
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
yield* update((editor) => {
|
||||
for (const [name, source] of entries) editor.add(name, source)
|
||||
})
|
||||
yield* ctx.reference.transform(
|
||||
Effect.fn(function* (draft) {
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of (yield* config.entries()).filter(
|
||||
(entry): entry is Config.Document => entry.type === "document",
|
||||
)) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : ctx.location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
entries.set(
|
||||
name,
|
||||
local(entry)
|
||||
? new Reference.LocalSource({
|
||||
type: "local",
|
||||
path: AbsolutePath.make(
|
||||
localPath(directory, ctx.path.home, typeof entry === "string" ? entry : entry.path),
|
||||
),
|
||||
description: typeof entry === "string" ? undefined : entry.description,
|
||||
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
||||
})
|
||||
: new Reference.GitSource({
|
||||
type: "git",
|
||||
repository: typeof entry === "string" ? entry : entry.repository,
|
||||
branch: typeof entry === "string" ? undefined : entry.branch,
|
||||
description: typeof entry === "string" ? undefined : entry.description,
|
||||
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const [name, source] of entries) draft.add(name, source)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
function validAlias(name: string) {
|
||||
return name.length > 0 && !/[\/\s`,]/.test(name)
|
||||
|
||||
@@ -1,48 +1,45 @@
|
||||
export * as ConfigSkillPlugin from "./skill"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { Global } from "../../global"
|
||||
import { Location } from "../../location"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { SkillV2 } from "../../skill"
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("config-skill"),
|
||||
effect: Effect.gen(function* () {
|
||||
export const Plugin = define({
|
||||
id: "config-skill",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const transform = yield* skill.transform()
|
||||
const entries = yield* config.entries()
|
||||
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||
|
||||
yield* transform((editor) => {
|
||||
for (const directory of directories) {
|
||||
editor.source(
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
)
|
||||
editor.source(
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
|
||||
)
|
||||
}
|
||||
for (const item of items) {
|
||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
||||
editor.source(new SkillV2.UrlSource({ type: "url", url: item }))
|
||||
continue
|
||||
yield* ctx.skill.transform(
|
||||
Effect.fn(function* (draft) {
|
||||
const entries = yield* config.entries()
|
||||
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||
for (const directory of directories) {
|
||||
draft.source(
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
)
|
||||
draft.source(
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
|
||||
)
|
||||
}
|
||||
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
||||
editor.source(
|
||||
new SkillV2.DirectorySource({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
for (const item of items) {
|
||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
||||
draft.source(new SkillV2.UrlSource({ type: "url", url: item }))
|
||||
continue
|
||||
}
|
||||
const expanded = item.startsWith("~/") ? path.join(ctx.path.home, item.slice(2)) : item
|
||||
draft.source(
|
||||
new SkillV2.DirectorySource({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(
|
||||
path.isAbsolute(expanded) ? expanded : path.join(ctx.location.directory, expanded),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -84,15 +84,20 @@ export const layer = Layer.effect(
|
||||
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
|
||||
}
|
||||
|
||||
const patch =
|
||||
input.moveChanges && source.directory !== destination.directory
|
||||
? yield* git
|
||||
.patch(current.location.directory)
|
||||
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
|
||||
: ""
|
||||
const moveChanges = input.moveChanges && source.directory !== destination.directory
|
||||
const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined
|
||||
if (moveChanges && !sourceRepository)
|
||||
return yield* new CaptureChangesError({ message: "Source is not a Git repository" })
|
||||
const patch = sourceRepository
|
||||
? yield* git.change
|
||||
.capture({ repository: sourceRepository, path: current.location.directory })
|
||||
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
|
||||
: Git.ChangeSet.make("")
|
||||
if (patch) {
|
||||
const repository = yield* git.repo.discover(directory)
|
||||
if (!repository) return yield* new ApplyChangesError({ message: "Destination is not a Git repository" })
|
||||
yield* git
|
||||
.applyPatch({ directory, patch })
|
||||
.change.apply({ repository, path: directory, changes: patch })
|
||||
.pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
|
||||
}
|
||||
|
||||
@@ -104,7 +109,20 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
if (patch) {
|
||||
yield* git.softResetChanges(current.location.directory).pipe(
|
||||
const repository = yield* git.repo.discover(current.location.directory)
|
||||
if (!repository)
|
||||
return yield* new ResetSourceChangesError({
|
||||
directory: current.location.directory,
|
||||
message: "Source is not a Git repository",
|
||||
})
|
||||
yield* git.change
|
||||
.discard({
|
||||
repository,
|
||||
path: current.location.directory,
|
||||
index: "preserve",
|
||||
untracked: "remove",
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ResetSourceChangesError({
|
||||
@@ -113,7 +131,7 @@ export const layer = Layer.effect(
|
||||
cause: error.cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export * as File from "./file"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt, RelativePath } from "./schema"
|
||||
|
||||
export const Diff = Schema.Struct({
|
||||
path: RelativePath,
|
||||
status: Schema.Literals(["added", "modified", "deleted"]),
|
||||
additions: NonNegativeInt,
|
||||
deletions: NonNegativeInt,
|
||||
patch: Schema.String,
|
||||
}).annotate({ identifier: "File.Diff" })
|
||||
export type Diff = typeof Diff.Type
|
||||
@@ -125,4 +125,4 @@ const baseLayer = Layer.effect(
|
||||
|
||||
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
export const locationLayer = layer
|
||||
export const locationLayer = baseLayer.pipe(Layer.provideMerge(FileSystemSearch.defaultLayer))
|
||||
|
||||
@@ -119,7 +119,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
|
||||
if (location.vcs?.type === "git") {
|
||||
const resolved = yield* git.dir(location.directory)
|
||||
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
|
||||
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined
|
||||
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
|
||||
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
|
||||
@@ -139,4 +139,4 @@ export const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer), Layer.provide(Git.defaultLayer))
|
||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
|
||||
|
||||
+687
-200
@@ -1,31 +1,50 @@
|
||||
export * as Git from "./git"
|
||||
|
||||
import path from "path"
|
||||
import { randomUUID } from "crypto"
|
||||
import { Context, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { AbsolutePath, RelativePath } from "./schema"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { AppProcess } from "./process"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { File } from "./file"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
|
||||
export interface Repo {
|
||||
/**
|
||||
* The root directory of the working tree that contains the input path.
|
||||
*
|
||||
* For `/home/me/app/src/file.ts` in a normal clone, this is `/home/me/app`.
|
||||
* For `/home/me/app-feature/src/file.ts` in a linked worktree, this is
|
||||
* `/home/me/app-feature`.
|
||||
*/
|
||||
readonly directory: AbsolutePath
|
||||
/**
|
||||
* The shared Git storage directory used by this repo and any linked worktrees.
|
||||
*
|
||||
* For a normal clone at `/home/me/app`, this is usually `/home/me/app/.git`.
|
||||
* For a linked worktree at `/home/me/app-feature` whose main checkout is
|
||||
* `/home/me/app`, this is usually `/home/me/app/.git`.
|
||||
*/
|
||||
readonly store: AbsolutePath
|
||||
}
|
||||
export class Repository extends Schema.Class<Repository>("Git.Repository")({
|
||||
worktree: AbsolutePath,
|
||||
gitDirectory: AbsolutePath,
|
||||
commonDirectory: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export const ChangeSet = Schema.String.pipe(Schema.brand("Git.ChangeSet"))
|
||||
export type ChangeSet = typeof ChangeSet.Type
|
||||
|
||||
export const TreeID = Schema.String.pipe(Schema.brand("Git.TreeID"))
|
||||
export type TreeID = typeof TreeID.Type
|
||||
|
||||
export class OperationError extends Schema.TaggedErrorClass<OperationError>()("Git.OperationError", {
|
||||
operation: Schema.Literals([
|
||||
"clone",
|
||||
"fetch",
|
||||
"checkout",
|
||||
"reset",
|
||||
"create",
|
||||
"refresh",
|
||||
"write_tree",
|
||||
"list_files",
|
||||
"diff",
|
||||
"restore",
|
||||
]),
|
||||
message: Schema.String,
|
||||
directory: Schema.optional(AbsolutePath),
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export class Worktree extends Schema.Class<Worktree>("Git.Worktree")({
|
||||
directory: AbsolutePath,
|
||||
kind: Schema.Literals(["main", "linked"]),
|
||||
}) {}
|
||||
|
||||
export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git.WorktreeError", {
|
||||
operation: Schema.Literals(["create", "remove", "list"]),
|
||||
@@ -43,35 +62,107 @@ export class PatchError extends Schema.TaggedErrorClass<PatchError>()("Git.Patch
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
|
||||
readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
|
||||
readonly roots: (repo: Repo) => Effect.Effect<string[]>
|
||||
readonly origin: (directory: string) => Effect.Effect<string | undefined>
|
||||
readonly head: (directory: string) => Effect.Effect<string | undefined>
|
||||
readonly dir: (directory: string) => Effect.Effect<string | undefined>
|
||||
readonly branch: (directory: string) => Effect.Effect<string | undefined>
|
||||
readonly remoteHead: (directory: string) => Effect.Effect<string | undefined>
|
||||
readonly clone: (input: {
|
||||
remote: string
|
||||
target: string
|
||||
branch?: string
|
||||
depth?: number
|
||||
}) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly fetch: (directory: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly fetchBranch: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly checkout: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly reset: (directory: string, target: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly patch: (directory: AbsolutePath) => Effect.Effect<string, PatchError>
|
||||
readonly applyPatch: (input: { directory: AbsolutePath; patch: string }) => Effect.Effect<void, PatchError>
|
||||
readonly resetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
|
||||
readonly softResetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
|
||||
readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
|
||||
readonly worktreeRemove: (input: {
|
||||
repo: Repo
|
||||
directory: AbsolutePath
|
||||
force: boolean
|
||||
}) => Effect.Effect<void, WorktreeError>
|
||||
readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
|
||||
readonly repo: {
|
||||
readonly discover: (input: AbsolutePath) => Effect.Effect<Repository | undefined>
|
||||
readonly clone: (input: {
|
||||
remote: string
|
||||
directory: AbsolutePath
|
||||
branch?: string
|
||||
depth?: number
|
||||
}) => Effect.Effect<Repository, OperationError>
|
||||
readonly create: (input: {
|
||||
worktree: AbsolutePath
|
||||
gitDirectory: AbsolutePath
|
||||
seed?: Repository
|
||||
}) => Effect.Effect<Repository, OperationError>
|
||||
}
|
||||
readonly remote: {
|
||||
readonly get: (repository: Repository, name?: string) => Effect.Effect<string | undefined>
|
||||
}
|
||||
readonly history: {
|
||||
readonly head: (repository: Repository) => Effect.Effect<string | undefined>
|
||||
readonly branch: (repository: Repository) => Effect.Effect<string | undefined>
|
||||
readonly defaultRemoteBranch: (repository: Repository, remote?: string) => Effect.Effect<string | undefined>
|
||||
readonly rootCommits: (repository: Repository) => Effect.Effect<readonly string[]>
|
||||
}
|
||||
readonly sync: {
|
||||
readonly fetchRemotes: (repository: Repository, input?: { prune?: boolean }) => Effect.Effect<void, OperationError>
|
||||
readonly fetchBranch: (
|
||||
repository: Repository,
|
||||
input: { remote?: string; branch: string; force?: boolean },
|
||||
) => Effect.Effect<void, OperationError>
|
||||
readonly checkoutRemoteBranch: (
|
||||
repository: Repository,
|
||||
input: { remote?: string; branch: string; reset?: boolean },
|
||||
) => Effect.Effect<void, OperationError>
|
||||
readonly resetHard: (repository: Repository, revision: string) => Effect.Effect<void, OperationError>
|
||||
}
|
||||
readonly change: {
|
||||
readonly capture: (input: { repository: Repository; path: AbsolutePath }) => Effect.Effect<ChangeSet, PatchError>
|
||||
readonly apply: (input: {
|
||||
repository: Repository
|
||||
path: AbsolutePath
|
||||
changes: ChangeSet
|
||||
}) => Effect.Effect<void, PatchError>
|
||||
readonly discard: (input: {
|
||||
repository: Repository
|
||||
path: AbsolutePath
|
||||
index: "preserve" | "reset"
|
||||
untracked: "preserve" | "remove"
|
||||
}) => Effect.Effect<void, PatchError>
|
||||
}
|
||||
readonly worktree: {
|
||||
readonly create: (input: {
|
||||
repository: Repository
|
||||
directory: AbsolutePath
|
||||
}) => Effect.Effect<Repository, WorktreeError>
|
||||
readonly remove: (input: {
|
||||
repository: Repository
|
||||
directory: AbsolutePath
|
||||
force: boolean
|
||||
}) => Effect.Effect<void, WorktreeError>
|
||||
readonly list: (repository: Repository) => Effect.Effect<readonly Worktree[], WorktreeError>
|
||||
}
|
||||
readonly index: {
|
||||
/** Refresh only the requested project-relative scope, preserving all other entries. */
|
||||
readonly refresh: (input: {
|
||||
repository: Repository
|
||||
scope: RelativePath
|
||||
ignores?: Repository
|
||||
maximumUntrackedFileBytes?: number
|
||||
}) => Effect.Effect<{ readonly skipped: readonly RelativePath[] }, OperationError>
|
||||
}
|
||||
readonly tree: {
|
||||
readonly capture: (input: {
|
||||
repository: Repository
|
||||
scopes: readonly RelativePath[]
|
||||
ignores?: Repository
|
||||
maximumUntrackedFileBytes?: number
|
||||
}) => Effect.Effect<TreeID, OperationError>
|
||||
readonly write: (repository: Repository) => Effect.Effect<TreeID, OperationError>
|
||||
readonly files: (input: {
|
||||
repository: Repository
|
||||
from: TreeID
|
||||
to: TreeID
|
||||
}) => Effect.Effect<readonly RelativePath[], OperationError>
|
||||
readonly diff: (input: {
|
||||
repository: Repository
|
||||
from: TreeID
|
||||
to: TreeID
|
||||
context?: number
|
||||
paths?: readonly RelativePath[]
|
||||
}) => Effect.Effect<readonly File.Diff[], OperationError>
|
||||
readonly preview: (input: {
|
||||
repository: Repository
|
||||
current: TreeID
|
||||
files: ReadonlyMap<RelativePath, TreeID>
|
||||
context?: number
|
||||
}) => Effect.Effect<readonly File.Diff[], OperationError>
|
||||
readonly restore: (input: {
|
||||
repository: Repository
|
||||
files: ReadonlyMap<RelativePath, TreeID>
|
||||
}) => Effect.Effect<void, OperationError>
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {}
|
||||
@@ -81,8 +172,11 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const proc = yield* AppProcess.Service
|
||||
const locks = KeyedMutex.makeUnsafe<string>()
|
||||
const locked = <A, E, R>(repository: Repository, effect: Effect.Effect<A, E, R>) =>
|
||||
locks.withLock(repository.gitDirectory)(effect)
|
||||
|
||||
const find = Effect.fn("Git.find")(function* (input: AbsolutePath) {
|
||||
const discover = Effect.fn("Git.repo.discover")(function* (input: AbsolutePath) {
|
||||
const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe(
|
||||
Effect.map((matches) => matches[0]),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
@@ -92,23 +186,25 @@ export const layer = Layer.effect(
|
||||
const cwd = path.dirname(dotgit)
|
||||
const git = run(cwd, proc)
|
||||
const topLevel = yield* git(["rev-parse", "--show-toplevel"])
|
||||
const gitDir = yield* git(["rev-parse", "--git-dir"])
|
||||
const commonDir = yield* git(["rev-parse", "--git-common-dir"])
|
||||
if (commonDir.exitCode !== 0) return undefined
|
||||
if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) return undefined
|
||||
|
||||
return {
|
||||
directory: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd),
|
||||
store: AbsolutePath.make(resolvePath(cwd, commonDir.text)),
|
||||
} satisfies Repo
|
||||
return new Repository({
|
||||
worktree: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd),
|
||||
gitDirectory: AbsolutePath.make(resolvePath(cwd, gitDir.text)),
|
||||
commonDirectory: AbsolutePath.make(resolvePath(cwd, commonDir.text)),
|
||||
})
|
||||
})
|
||||
|
||||
const remote = Effect.fn("Git.remote")(function* (repo: Repo, name = "origin") {
|
||||
const result = yield* run(repo.directory, proc)(["remote", "get-url", name])
|
||||
const remote = Effect.fn("Git.remote.get")(function* (repository: Repository, name = "origin") {
|
||||
const result = yield* run(repository.worktree, proc)(["remote", "get-url", name])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
|
||||
const roots = Effect.fn("Git.roots")(function* (repo: Repo) {
|
||||
const result = yield* run(repo.directory, proc)(["rev-list", "--max-parents=0", "HEAD"])
|
||||
const roots = Effect.fn("Git.history.rootCommits")(function* (repository: Repository) {
|
||||
const result = yield* run(repository.worktree, proc)(["rev-list", "--max-parents=0", "HEAD"])
|
||||
if (result.exitCode !== 0) return []
|
||||
return result.text
|
||||
.split("\n")
|
||||
@@ -117,116 +213,515 @@ export const layer = Layer.effect(
|
||||
.toSorted()
|
||||
})
|
||||
|
||||
const origin = Effect.fn("Git.origin")(function* (directory: string) {
|
||||
const result = yield* run(directory, proc)(["config", "--get", "remote.origin.url"])
|
||||
const head = Effect.fn("Git.history.head")(function* (repository: Repository) {
|
||||
const result = yield* run(repository.worktree, proc)(["rev-parse", "HEAD"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
|
||||
const head = Effect.fn("Git.head")(function* (directory: string) {
|
||||
const result = yield* run(directory, proc)(["rev-parse", "HEAD"])
|
||||
const branch = Effect.fn("Git.history.branch")(function* (repository: Repository) {
|
||||
const result = yield* run(repository.worktree, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
|
||||
const dir = Effect.fn("Git.dir")(function* (directory: string) {
|
||||
const result = yield* run(directory, proc)(["rev-parse", "--git-dir"])
|
||||
const remoteHead = Effect.fn("Git.history.defaultRemoteBranch")(function* (
|
||||
repository: Repository,
|
||||
remoteName = "origin",
|
||||
) {
|
||||
const result = yield* run(repository.worktree, proc)(["symbolic-ref", `refs/remotes/${remoteName}/HEAD`])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return AbsolutePath.make(resolvePath(directory, result.text))
|
||||
return result.text.trim().replace(new RegExp(`^refs/remotes/${remoteName}/`), "") || undefined
|
||||
})
|
||||
|
||||
const branch = Effect.fn("Git.branch")(function* (directory: string) {
|
||||
const result = yield* run(directory, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
const operation = Effect.fnUntraced(function* (
|
||||
operation: OperationError["operation"],
|
||||
directory: AbsolutePath,
|
||||
args: string[],
|
||||
) {
|
||||
const result = yield* execute(directory, proc)(args).pipe(
|
||||
Effect.mapError((cause) => new OperationError({ operation, directory, message: cause.message, cause })),
|
||||
)
|
||||
if (result.exitCode === 0) return
|
||||
return yield* new OperationError({
|
||||
operation,
|
||||
directory,
|
||||
message: result.stderr.trim() || result.text.trim() || `Git ${operation} failed`,
|
||||
})
|
||||
})
|
||||
|
||||
const remoteHead = Effect.fn("Git.remoteHead")(function* (directory: string) {
|
||||
const result = yield* run(directory, proc)(["symbolic-ref", "refs/remotes/origin/HEAD"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim().replace(/^refs\/remotes\//, "") || undefined
|
||||
})
|
||||
|
||||
const clone = Effect.fn("Git.clone")((input: { remote: string; target: string; branch?: string; depth?: number }) =>
|
||||
execute(
|
||||
path.dirname(input.target),
|
||||
proc,
|
||||
)([
|
||||
const clone = Effect.fn("Git.repo.clone")(function* (input: {
|
||||
remote: string
|
||||
directory: AbsolutePath
|
||||
branch?: string
|
||||
depth?: number
|
||||
}) {
|
||||
yield* operation("clone", AbsolutePath.make(path.dirname(input.directory)), [
|
||||
"clone",
|
||||
"--depth",
|
||||
String(input.depth ?? 100),
|
||||
...(input.branch ? ["--branch", input.branch] : []),
|
||||
"--",
|
||||
input.remote,
|
||||
input.target,
|
||||
]),
|
||||
)
|
||||
input.directory,
|
||||
])
|
||||
const repository = yield* discover(input.directory)
|
||||
if (repository) return repository
|
||||
return yield* new OperationError({
|
||||
operation: "clone",
|
||||
directory: input.directory,
|
||||
message: "Cloned repository could not be opened",
|
||||
})
|
||||
})
|
||||
|
||||
const fetch = Effect.fn("Git.fetch")((directory: string) => execute(directory, proc)(["fetch", "--all", "--prune"]))
|
||||
const fetch = Effect.fn("Git.sync.fetchRemotes")(function* (
|
||||
repository: Repository,
|
||||
input: { prune?: boolean } = {},
|
||||
) {
|
||||
yield* operation("fetch", repository.worktree, ["fetch", "--all", ...(input.prune === false ? [] : ["--prune"])])
|
||||
})
|
||||
|
||||
const fetchBranch = Effect.fn("Git.fetchBranch")((directory: string, branch: string) =>
|
||||
execute(directory, proc)(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]),
|
||||
)
|
||||
const fetchBranch = Effect.fn("Git.sync.fetchBranch")(function* (
|
||||
repository: Repository,
|
||||
input: { remote?: string; branch: string; force?: boolean },
|
||||
) {
|
||||
const remoteName = input.remote ?? "origin"
|
||||
const spec = `refs/heads/${input.branch}:refs/remotes/${remoteName}/${input.branch}`
|
||||
yield* operation("fetch", repository.worktree, ["fetch", remoteName, input.force === false ? spec : `+${spec}`])
|
||||
})
|
||||
|
||||
const checkout = Effect.fn("Git.checkout")((directory: string, branch: string) =>
|
||||
execute(directory, proc)(["checkout", "-B", branch, `origin/${branch}`]),
|
||||
)
|
||||
const checkout = Effect.fn("Git.sync.checkoutRemoteBranch")(function* (
|
||||
repository: Repository,
|
||||
input: { remote?: string; branch: string; reset?: boolean },
|
||||
) {
|
||||
const remoteName = input.remote ?? "origin"
|
||||
yield* operation("checkout", repository.worktree, [
|
||||
"checkout",
|
||||
...(input.reset === false ? [input.branch] : ["-B", input.branch, `${remoteName}/${input.branch}`]),
|
||||
])
|
||||
})
|
||||
|
||||
const reset = Effect.fn("Git.reset")((directory: string, target: string) =>
|
||||
execute(directory, proc)(["reset", "--hard", target]),
|
||||
)
|
||||
const reset = Effect.fn("Git.sync.resetHard")(function* (
|
||||
repository: Repository,
|
||||
revision: string,
|
||||
) {
|
||||
yield* operation("reset", repository.worktree, ["reset", "--hard", revision])
|
||||
})
|
||||
|
||||
const patch = Effect.fn("Git.patch")(function* (directory: AbsolutePath) {
|
||||
const root = yield* execute(
|
||||
directory,
|
||||
proc,
|
||||
)(["rev-parse", "--show-toplevel"]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
|
||||
const repositoryArgs = (repository: Repository, args: string[]) => [
|
||||
"--git-dir",
|
||||
repository.gitDirectory,
|
||||
"--work-tree",
|
||||
repository.worktree,
|
||||
...args,
|
||||
]
|
||||
|
||||
const repositoryOperation = Effect.fnUntraced(function* (
|
||||
operationName: OperationError["operation"],
|
||||
repository: Repository,
|
||||
args: string[],
|
||||
options?: { stdin?: string; env?: Record<string, string> },
|
||||
) {
|
||||
const result = yield* proc
|
||||
.run(
|
||||
ChildProcess.make("git", repositoryArgs(repository, args), {
|
||||
cwd: repository.worktree,
|
||||
env: options?.env,
|
||||
extendEnv: true,
|
||||
}),
|
||||
{ stdin: options?.stdin },
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new OperationError({
|
||||
operation: operationName,
|
||||
directory: repository.worktree,
|
||||
message: cause.message,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const text = result.stdout.toString("utf8")
|
||||
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
|
||||
return yield* new OperationError({
|
||||
operation: operationName,
|
||||
directory: repository.worktree,
|
||||
message: result.stderr.toString("utf8").trim() || text.trim() || `Git ${operationName} failed`,
|
||||
})
|
||||
})
|
||||
|
||||
const create = Effect.fn("Git.repo.create")(function* (input: {
|
||||
worktree: AbsolutePath
|
||||
gitDirectory: AbsolutePath
|
||||
seed?: Repository
|
||||
}) {
|
||||
yield* fs.ensureDir(input.gitDirectory).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new OperationError({
|
||||
operation: "create",
|
||||
directory: input.gitDirectory,
|
||||
message: "Failed to create Git storage",
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (root.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "capture",
|
||||
directory,
|
||||
message: root.stderr.trim() || root.text.trim() || "Failed to locate repository root",
|
||||
})
|
||||
}
|
||||
const repo = AbsolutePath.make(resolvePath(directory, root.text))
|
||||
const scope = path.relative(repo, directory).replaceAll("\\", "/") || "."
|
||||
const repository = new Repository({
|
||||
worktree: input.worktree,
|
||||
gitDirectory: input.gitDirectory,
|
||||
commonDirectory: input.gitDirectory,
|
||||
})
|
||||
yield* repositoryOperation("create", repository, ["init"])
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
["core.autocrlf", "false"],
|
||||
["core.longpaths", "true"],
|
||||
["core.symlinks", "true"],
|
||||
["core.fsmonitor", "false"],
|
||||
["feature.manyFiles", "true"],
|
||||
["index.version", "4"],
|
||||
["index.threads", "true"],
|
||||
["core.untrackedCache", "true"],
|
||||
],
|
||||
([key, value]) => repositoryOperation("create", repository, ["config", key, value]),
|
||||
{ discard: true },
|
||||
)
|
||||
if (!input.seed) return repository
|
||||
yield* fs.ensureDir(path.join(input.gitDirectory, "objects", "info")).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new OperationError({
|
||||
operation: "create",
|
||||
directory: input.gitDirectory,
|
||||
message: "Failed to configure shared Git objects",
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* fs.writeFileString(
|
||||
path.join(input.gitDirectory, "objects", "info", "alternates"),
|
||||
path.join(input.seed.commonDirectory, "objects") + "\n",
|
||||
).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new OperationError({
|
||||
operation: "create",
|
||||
directory: input.gitDirectory,
|
||||
message: "Failed to configure shared Git objects",
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* fs
|
||||
.copyFile(path.join(input.seed.gitDirectory, "index"), path.join(input.gitDirectory, "index"))
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
return repository
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("Git.index.refresh")(function* (input: {
|
||||
repository: Repository
|
||||
scope: RelativePath
|
||||
ignores?: Repository
|
||||
maximumUntrackedFileBytes?: number
|
||||
}) {
|
||||
const list = (args: string[]) =>
|
||||
repositoryOperation("refresh", input.repository, args).pipe(
|
||||
Effect.map((result) => result.text.split("\0").filter(Boolean)),
|
||||
)
|
||||
const [tracked, untracked] = yield* Effect.all(
|
||||
[
|
||||
list(["diff-files", "--name-only", "-z", "--", input.scope]),
|
||||
list(["ls-files", "--others", "--exclude-standard", "-z", "--", input.scope]),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const candidates = Array.from(new Set([...tracked, ...untracked]))
|
||||
if (!candidates.length) return { skipped: [] }
|
||||
const ignored = input.ignores
|
||||
? new Set(
|
||||
(
|
||||
yield* repositoryOperation(
|
||||
"refresh",
|
||||
input.ignores,
|
||||
["check-ignore", "--no-index", "--stdin", "-z"],
|
||||
{ stdin: candidates.join("\0") + "\0" },
|
||||
).pipe(Effect.catch(() => Effect.succeed({ text: "", stderr: "" })))
|
||||
).text
|
||||
.split("\0")
|
||||
.filter(Boolean),
|
||||
)
|
||||
: new Set<string>()
|
||||
const allowed = candidates.filter((item) => !ignored.has(item))
|
||||
const maximum = input.maximumUntrackedFileBytes
|
||||
const skipped = maximum
|
||||
? (
|
||||
yield* Effect.forEach(
|
||||
untracked.filter((item) => allowed.includes(item)),
|
||||
(item) =>
|
||||
fs.stat(path.join(input.repository.worktree, item)).pipe(
|
||||
Effect.map((info) =>
|
||||
info.type === "File" && Number(info.size) > maximum ? RelativePath.make(item) : undefined,
|
||||
),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
),
|
||||
{ concurrency: 8 },
|
||||
)
|
||||
).filter((item): item is RelativePath => item !== undefined)
|
||||
: []
|
||||
const stage = allowed.filter((item) => !skipped.includes(RelativePath.make(item)))
|
||||
const remove = [...ignored, ...skipped]
|
||||
if (remove.length)
|
||||
yield* repositoryOperation(
|
||||
"refresh",
|
||||
input.repository,
|
||||
["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"],
|
||||
{ stdin: remove.join("\0") + "\0" },
|
||||
)
|
||||
if (stage.length)
|
||||
yield* repositoryOperation(
|
||||
"refresh",
|
||||
input.repository,
|
||||
["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"],
|
||||
{ stdin: stage.join("\0") + "\0" },
|
||||
)
|
||||
return { skipped }
|
||||
})
|
||||
|
||||
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
|
||||
return TreeID.make((yield* repositoryOperation("write_tree", repository, ["write-tree"])).text.trim())
|
||||
})
|
||||
|
||||
const captureTree = Effect.fn("Git.tree.capture")((input: {
|
||||
repository: Repository
|
||||
scopes: readonly RelativePath[]
|
||||
ignores?: Repository
|
||||
maximumUntrackedFileBytes?: number
|
||||
}) =>
|
||||
locked(
|
||||
input.repository,
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.forEach(
|
||||
input.scopes,
|
||||
(scope) => refresh({ ...input, scope }),
|
||||
{ discard: true },
|
||||
)
|
||||
return yield* writeTree(input.repository)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const treeFiles = Effect.fn("Git.tree.files")(function* (input: {
|
||||
repository: Repository
|
||||
from: TreeID
|
||||
to: TreeID
|
||||
}) {
|
||||
return (yield* repositoryOperation("list_files", input.repository, ["diff", "--name-only", "-z", input.from, input.to]))
|
||||
.text.split("\0")
|
||||
.filter(Boolean)
|
||||
.map((file) => RelativePath.make(file))
|
||||
})
|
||||
|
||||
const treeDiff = Effect.fn("Git.tree.diff")(function* (input: {
|
||||
repository: Repository
|
||||
from: TreeID
|
||||
to: TreeID
|
||||
context?: number
|
||||
paths?: readonly RelativePath[]
|
||||
}) {
|
||||
const paths = input.paths ?? (yield* treeFiles(input))
|
||||
return yield* Effect.forEach(paths, (file) =>
|
||||
Effect.gen(function* () {
|
||||
const statusText = (
|
||||
yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
"--name-status",
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])
|
||||
).text.trim()
|
||||
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
|
||||
const stats = (
|
||||
yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
"--numstat",
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])
|
||||
).text.split("\t")
|
||||
const binary = stats[0] === "-" || stats[1] === "-"
|
||||
const patch = binary
|
||||
? ""
|
||||
: (
|
||||
yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
`--unified=${input.context ?? 3}`,
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])
|
||||
).text
|
||||
return {
|
||||
path: file,
|
||||
status,
|
||||
additions: binary ? 0 : Number(stats[0] ?? 0),
|
||||
deletions: binary ? 0 : Number(stats[1] ?? 0),
|
||||
patch,
|
||||
} satisfies File.Diff
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const entry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
|
||||
const text = (
|
||||
yield* repositoryOperation("restore", repository, ["ls-tree", "-z", tree, "--", file])
|
||||
).text.replace(/\0$/, "")
|
||||
if (!text) return
|
||||
const match = text.match(/^(\d+)\s+\w+\s+([0-9a-f]+)\t/)
|
||||
if (!match) return yield* new OperationError({
|
||||
operation: "restore",
|
||||
directory: repository.worktree,
|
||||
message: `Invalid tree entry for ${file}`,
|
||||
})
|
||||
return { mode: match[1], object: match[2] }
|
||||
})
|
||||
|
||||
const preview = Effect.fn("Git.tree.preview")((input: {
|
||||
repository: Repository
|
||||
current: TreeID
|
||||
files: ReadonlyMap<RelativePath, TreeID>
|
||||
context?: number
|
||||
}) =>
|
||||
locked(
|
||||
input.repository,
|
||||
Effect.gen(function* () {
|
||||
const index = path.join(input.repository.gitDirectory, `preview-${randomUUID()}.index`)
|
||||
const env = { GIT_INDEX_FILE: index }
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* repositoryOperation("diff", input.repository, ["read-tree", input.current], { env })
|
||||
yield* Effect.forEach(
|
||||
input.files,
|
||||
([file, tree]) =>
|
||||
Effect.gen(function* () {
|
||||
const source = yield* entry(input.repository, tree, file)
|
||||
if (!source) {
|
||||
yield* repositoryOperation(
|
||||
"diff",
|
||||
input.repository,
|
||||
["update-index", "--force-remove", "--", file],
|
||||
{ env },
|
||||
)
|
||||
return
|
||||
}
|
||||
yield* repositoryOperation(
|
||||
"diff",
|
||||
input.repository,
|
||||
["update-index", "--add", "--cacheinfo", source.mode, source.object, file],
|
||||
{ env },
|
||||
)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
const target = TreeID.make(
|
||||
(yield* repositoryOperation("diff", input.repository, ["write-tree"], { env })).text.trim(),
|
||||
)
|
||||
return yield* treeDiff({
|
||||
repository: input.repository,
|
||||
from: input.current,
|
||||
to: target,
|
||||
context: input.context,
|
||||
paths: Array.from(input.files.keys()),
|
||||
})
|
||||
}).pipe(Effect.ensuring(fs.remove(index).pipe(Effect.catch(() => Effect.void))))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const restore = Effect.fn("Git.tree.restore")((input: {
|
||||
repository: Repository
|
||||
files: ReadonlyMap<RelativePath, TreeID>
|
||||
}) =>
|
||||
locked(
|
||||
input.repository,
|
||||
Effect.forEach(
|
||||
input.files,
|
||||
([file, tree]) =>
|
||||
Effect.gen(function* () {
|
||||
if (yield* entry(input.repository, tree, file)) {
|
||||
yield* repositoryOperation("restore", input.repository, ["checkout", tree, "--", file])
|
||||
return
|
||||
}
|
||||
yield* fs
|
||||
.remove(path.join(input.repository.worktree, file), { recursive: true, force: true })
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new OperationError({
|
||||
operation: "restore",
|
||||
directory: input.repository.worktree,
|
||||
message: `Failed to remove ${file}`,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
{ discard: true },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const capture = Effect.fn("Git.change.capture")(function* (input: {
|
||||
repository: Repository
|
||||
path: AbsolutePath
|
||||
}) {
|
||||
const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "."
|
||||
const tracked = yield* execute(
|
||||
repo,
|
||||
input.repository.worktree,
|
||||
proc,
|
||||
)(["diff", "--binary", "HEAD", "--", scope]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (tracked.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "capture",
|
||||
directory,
|
||||
directory: input.path,
|
||||
message: tracked.stderr.trim() || tracked.text.trim() || "Failed to capture tracked changes",
|
||||
})
|
||||
}
|
||||
|
||||
const untracked = yield* execute(
|
||||
repo,
|
||||
input.repository.worktree,
|
||||
proc,
|
||||
)(["ls-files", "--others", "--exclude-standard", "-z", "--", scope]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (untracked.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "capture",
|
||||
directory,
|
||||
directory: input.path,
|
||||
message: untracked.stderr.trim() || untracked.text.trim() || "Failed to list untracked changes",
|
||||
})
|
||||
}
|
||||
|
||||
const created = yield* Effect.forEach(untracked.text.split("\0").filter(Boolean), (file) =>
|
||||
execute(
|
||||
repo,
|
||||
input.repository.worktree,
|
||||
proc,
|
||||
)(["diff", "--binary", "--no-index", "--", "/dev/null", file]).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause }),
|
||||
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
Effect.flatMap((result) =>
|
||||
// git diff --no-index returns 1 when differences were found.
|
||||
@@ -235,7 +730,7 @@ export const layer = Layer.effect(
|
||||
: Effect.fail(
|
||||
new PatchError({
|
||||
operation: "capture",
|
||||
directory,
|
||||
directory: input.path,
|
||||
message:
|
||||
result.stderr.trim() || result.text.trim() || `Failed to capture untracked change: ${file}`,
|
||||
}),
|
||||
@@ -243,95 +738,78 @@ export const layer = Layer.effect(
|
||||
),
|
||||
),
|
||||
)
|
||||
return [tracked.text, ...created].filter(Boolean).join("\n")
|
||||
return ChangeSet.make([tracked.text, ...created].filter(Boolean).join("\n"))
|
||||
})
|
||||
|
||||
const applyPatch = Effect.fn("Git.applyPatch")(function* (input: { directory: AbsolutePath; patch: string }) {
|
||||
const apply = Effect.fn("Git.change.apply")(function* (input: {
|
||||
repository: Repository
|
||||
path: AbsolutePath
|
||||
changes: ChangeSet
|
||||
}) {
|
||||
const result = yield* proc
|
||||
.run(
|
||||
ChildProcess.make("git", ["apply", "-"], {
|
||||
cwd: input.directory,
|
||||
cwd: input.path,
|
||||
extendEnv: true,
|
||||
stdin: Stream.make(new TextEncoder().encode(input.patch)),
|
||||
stdin: Stream.make(new TextEncoder().encode(input.changes)),
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new PatchError({ operation: "apply", directory: input.directory, message: cause.message, cause }),
|
||||
new PatchError({ operation: "apply", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (result.exitCode === 0) return
|
||||
return yield* new PatchError({
|
||||
operation: "apply",
|
||||
directory: input.directory,
|
||||
directory: input.path,
|
||||
message:
|
||||
result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Failed to apply changes",
|
||||
})
|
||||
})
|
||||
|
||||
const resetChanges = Effect.fn("Git.resetChanges")(function* (directory: AbsolutePath) {
|
||||
const reset = yield* execute(
|
||||
directory,
|
||||
proc,
|
||||
)(["reset", "--hard", "HEAD"]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
|
||||
const discard = Effect.fn("Git.change.discard")(function* (input: {
|
||||
repository: Repository
|
||||
path: AbsolutePath
|
||||
index: "preserve" | "reset"
|
||||
untracked: "preserve" | "remove"
|
||||
}) {
|
||||
const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "."
|
||||
const restore = yield* execute(input.repository.worktree, proc)(
|
||||
input.index === "reset" ? ["checkout", "HEAD", "--", scope] : ["checkout", "--", scope],
|
||||
).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (reset.exitCode !== 0) {
|
||||
if (restore.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "reset",
|
||||
directory,
|
||||
message: reset.stderr.trim() || reset.text.trim() || "Failed to reset tracked changes",
|
||||
directory: input.path,
|
||||
message: restore.stderr.trim() || restore.text.trim() || "Failed to restore tracked changes",
|
||||
})
|
||||
}
|
||||
const clean = yield* execute(
|
||||
directory,
|
||||
proc,
|
||||
)(["clean", "-fd"]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
|
||||
if (input.untracked === "preserve") return
|
||||
const clean = yield* execute(input.repository.worktree, proc)(["clean", "-fd", "--", scope]).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (clean.exitCode === 0) return
|
||||
return yield* new PatchError({
|
||||
operation: "reset",
|
||||
directory,
|
||||
directory: input.path,
|
||||
message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
|
||||
})
|
||||
})
|
||||
|
||||
const softResetChanges = Effect.fn("Git.softResetChanges")(function* (directory: AbsolutePath) {
|
||||
const checkout = yield* execute(
|
||||
directory,
|
||||
proc,
|
||||
)(["checkout", "--", "."]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
|
||||
)
|
||||
if (checkout.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "reset",
|
||||
directory,
|
||||
message: checkout.stderr.trim() || checkout.text.trim() || "Failed to restore tracked changes",
|
||||
})
|
||||
}
|
||||
const clean = yield* execute(
|
||||
directory,
|
||||
proc,
|
||||
)(["clean", "-fd", "--", "."]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
|
||||
)
|
||||
if (clean.exitCode === 0) return
|
||||
return yield* new PatchError({
|
||||
operation: "reset",
|
||||
directory,
|
||||
message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
|
||||
})
|
||||
})
|
||||
|
||||
const worktree = Effect.fnUntraced(function* (
|
||||
const worktreeRun = Effect.fnUntraced(function* (
|
||||
operation: "create" | "remove" | "list",
|
||||
repo: Repo,
|
||||
repository: Repository,
|
||||
args: string[],
|
||||
worktreeDirectory?: AbsolutePath,
|
||||
cwd = repo.directory,
|
||||
cwd = repository.worktree,
|
||||
) {
|
||||
const result = yield* proc
|
||||
.run(ChildProcess.make("git", args, { cwd, extendEnv: true, stdin: "ignore" }))
|
||||
@@ -350,52 +828,61 @@ export const layer = Layer.effect(
|
||||
})
|
||||
})
|
||||
|
||||
const worktreeCreate = Effect.fn("Git.worktreeCreate")(function* (input: { repo: Repo; directory: AbsolutePath }) {
|
||||
yield* worktree("create", input.repo, ["worktree", "add", "--detach", input.directory, "HEAD"], input.directory)
|
||||
const worktreeCreate = Effect.fn("Git.worktree.create")(function* (input: {
|
||||
repository: Repository
|
||||
directory: AbsolutePath
|
||||
}) {
|
||||
yield* worktreeRun(
|
||||
"create",
|
||||
input.repository,
|
||||
["worktree", "add", "--detach", input.directory, "HEAD"],
|
||||
input.directory,
|
||||
)
|
||||
const repository = yield* discover(input.directory)
|
||||
if (repository) return repository
|
||||
return yield* new WorktreeError({
|
||||
operation: "create",
|
||||
directory: input.directory,
|
||||
message: "Created worktree could not be opened",
|
||||
})
|
||||
})
|
||||
|
||||
const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: {
|
||||
repo: Repo
|
||||
const worktreeRemove = Effect.fn("Git.worktree.remove")(function* (input: {
|
||||
repository: Repository
|
||||
directory: AbsolutePath
|
||||
force: boolean
|
||||
}) {
|
||||
yield* worktree(
|
||||
yield* worktreeRun(
|
||||
"remove",
|
||||
input.repo,
|
||||
input.repository,
|
||||
["worktree", "remove", ...(input.force ? ["--force"] : []), input.directory],
|
||||
input.directory,
|
||||
input.repo.store,
|
||||
input.repository.commonDirectory,
|
||||
)
|
||||
})
|
||||
|
||||
const worktreeList = Effect.fn("Git.worktreeList")(function* (repo: Repo) {
|
||||
return (yield* worktree("list", repo, ["worktree", "list", "--porcelain"]))
|
||||
const worktreeList = Effect.fn("Git.worktree.list")(function* (repository: Repository) {
|
||||
return (yield* worktreeRun("list", repository, ["worktree", "list", "--porcelain"]))
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("worktree "))
|
||||
.map((line) => AbsolutePath.make(resolvePath(repo.directory, line.slice("worktree ".length).trim())))
|
||||
.map(
|
||||
(line, index) =>
|
||||
new Worktree({
|
||||
directory: AbsolutePath.make(resolvePath(repository.worktree, line.slice("worktree ".length).trim())),
|
||||
kind: index === 0 ? "main" : "linked",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
find,
|
||||
remote,
|
||||
roots,
|
||||
origin,
|
||||
head,
|
||||
dir,
|
||||
branch,
|
||||
remoteHead,
|
||||
clone,
|
||||
fetch,
|
||||
fetchBranch,
|
||||
checkout,
|
||||
reset,
|
||||
patch,
|
||||
applyPatch,
|
||||
resetChanges,
|
||||
softResetChanges,
|
||||
worktreeCreate,
|
||||
worktreeRemove,
|
||||
worktreeList,
|
||||
repo: { discover, clone, create },
|
||||
remote: { get: remote },
|
||||
history: { head, branch, defaultRemoteBranch: remoteHead, rootCommits: roots },
|
||||
sync: { fetchRemotes: fetch, fetchBranch, checkoutRemoteBranch: checkout, resetHard: reset },
|
||||
change: { capture, apply, discard },
|
||||
worktree: { create: worktreeCreate, remove: worktreeRemove, list: worktreeList },
|
||||
index: { refresh },
|
||||
tree: { capture: captureTree, write: writeTree, files: treeFiles, diff: treeDiff, preview, restore },
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -403,7 +890,7 @@ export const layer = Layer.effect(
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node])
|
||||
|
||||
export interface Result {
|
||||
interface Result {
|
||||
readonly exitCode: number
|
||||
readonly text: string
|
||||
readonly stderr: string
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
export * as Integration from "./integration"
|
||||
|
||||
import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Schedule, Schema, Scope, SynchronizedRef } from "effect"
|
||||
import { castDraft, enableMapSet, type Draft } from "immer"
|
||||
import {
|
||||
Cause,
|
||||
Clock,
|
||||
Context,
|
||||
Duration,
|
||||
Effect,
|
||||
Exit,
|
||||
Layer,
|
||||
Schedule,
|
||||
Schema,
|
||||
Scope,
|
||||
SynchronizedRef,
|
||||
Types,
|
||||
} from "effect"
|
||||
import { Credential } from "./credential"
|
||||
import { IntegrationSchema } from "./integration/schema"
|
||||
import { withStatics } from "./schema"
|
||||
@@ -42,12 +54,14 @@ export const SelectPrompt = Schema.Struct({
|
||||
type: Schema.Literal("select"),
|
||||
key: Schema.String,
|
||||
message: Schema.String,
|
||||
options: Schema.Array(
|
||||
Schema.Struct({
|
||||
label: Schema.String,
|
||||
value: Schema.String,
|
||||
hint: Schema.optional(Schema.String),
|
||||
}),
|
||||
options: Schema.mutable(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
label: Schema.String,
|
||||
value: Schema.String,
|
||||
hint: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
),
|
||||
when: Schema.optional(When),
|
||||
}).annotate({ identifier: "Integration.SelectPrompt" })
|
||||
@@ -60,7 +74,7 @@ export const OAuthMethod = Schema.Struct({
|
||||
id: MethodID,
|
||||
type: Schema.Literal("oauth"),
|
||||
label: Schema.String,
|
||||
prompts: Schema.optional(Schema.Array(Prompt)),
|
||||
prompts: Schema.optional(Schema.mutable(Schema.Array(Prompt))),
|
||||
}).annotate({ identifier: "Integration.OAuthMethod" })
|
||||
export type OAuthMethod = typeof OAuthMethod.Type
|
||||
|
||||
@@ -72,7 +86,7 @@ export type KeyMethod = typeof KeyMethod.Type
|
||||
|
||||
export const EnvMethod = Schema.Struct({
|
||||
type: Schema.Literal("env"),
|
||||
names: Schema.Array(Schema.String),
|
||||
names: Schema.mutable(Schema.Array(Schema.String)),
|
||||
}).annotate({ identifier: "Integration.EnvMethod" })
|
||||
export type EnvMethod = typeof EnvMethod.Type
|
||||
|
||||
@@ -82,8 +96,8 @@ export type Method = typeof Method.Type
|
||||
export class Info extends Schema.Class<Info>("Integration.Info")({
|
||||
id: ID,
|
||||
name: Schema.String,
|
||||
methods: Schema.Array(Method),
|
||||
connections: Schema.Array(IntegrationConnection.Info),
|
||||
methods: Schema.mutable(Schema.Array(Method)),
|
||||
connections: Schema.mutable(Schema.Array(IntegrationConnection.Info)),
|
||||
}) {}
|
||||
|
||||
export type Inputs = Readonly<{ [key: string]: string }>
|
||||
@@ -172,19 +186,19 @@ export type Ref = {
|
||||
}
|
||||
|
||||
type Entry = {
|
||||
ref: Ref
|
||||
methods: Method[]
|
||||
implementations: Map<MethodID, OAuthImplementation>
|
||||
ref: Types.DeepMutable<Ref>
|
||||
methods: Types.DeepMutable<Method>[]
|
||||
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
|
||||
}
|
||||
|
||||
type Data = {
|
||||
integrations: Map<ID, Entry>
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
list: () => readonly Ref[]
|
||||
get: (id: ID) => Ref | undefined
|
||||
update: (id: ID, update: (integration: Draft<Ref>) => void) => void
|
||||
update: (id: ID, update: (integration: Types.DeepMutable<Ref>) => void) => void
|
||||
remove: (id: ID) => void
|
||||
method: {
|
||||
list: (integrationID: ID) => readonly Method[]
|
||||
@@ -193,11 +207,8 @@ export type Editor = {
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
/** Registers a scoped transform over the integration registry. */
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
/** Registers and immediately applies a scoped integration registry update. */
|
||||
readonly update: State.Interface<Data, Editor>["update"]
|
||||
/** Returns one integration with its methods and current connections. */
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
/** Returns all integrations with their methods and current connections. */
|
||||
@@ -252,8 +263,6 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
|
||||
|
||||
enableMapSet()
|
||||
|
||||
const attemptLifetime = Duration.toMillis(Duration.minutes(10))
|
||||
const terminalRetention = Duration.toMillis(Duration.minutes(1))
|
||||
const scrubInterval = Duration.seconds(30)
|
||||
@@ -284,15 +293,17 @@ export const locationLayer = Layer.effect(
|
||||
const events = yield* EventV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ integrations: new Map<ID, Entry>() }),
|
||||
editor: (draft) => ({
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[],
|
||||
get: (id) => draft.integrations.get(id)?.ref as Ref | undefined,
|
||||
update: (id, update) => {
|
||||
const current =
|
||||
draft.integrations.get(id) ??
|
||||
castDraft({ ref: { id, name: id } as Ref, methods: [], implementations: new Map() })
|
||||
const current = draft.integrations.get(id) ?? {
|
||||
ref: { id, name: id },
|
||||
methods: [],
|
||||
implementations: new Map(),
|
||||
}
|
||||
if (!draft.integrations.has(id)) draft.integrations.set(id, current)
|
||||
update(current.ref)
|
||||
current.ref.id = id
|
||||
@@ -301,16 +312,14 @@ export const locationLayer = Layer.effect(
|
||||
method: {
|
||||
list: (integrationID) => (draft.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [],
|
||||
update: (implementation) => {
|
||||
const current =
|
||||
draft.integrations.get(implementation.integrationID) ??
|
||||
castDraft({
|
||||
ref: {
|
||||
id: implementation.integrationID,
|
||||
name: implementation.integrationID,
|
||||
} as Ref,
|
||||
methods: [],
|
||||
implementations: new Map<MethodID, OAuthImplementation>(),
|
||||
})
|
||||
const current = draft.integrations.get(implementation.integrationID) ?? {
|
||||
ref: {
|
||||
id: implementation.integrationID,
|
||||
name: implementation.integrationID,
|
||||
},
|
||||
methods: [],
|
||||
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
|
||||
}
|
||||
if (!draft.integrations.has(implementation.integrationID)) {
|
||||
draft.integrations.set(implementation.integrationID, current)
|
||||
}
|
||||
@@ -319,10 +328,13 @@ export const locationLayer = Layer.effect(
|
||||
if (method.type !== "oauth" || implementation.method.type !== "oauth") return true
|
||||
return method.id === implementation.method.id
|
||||
})
|
||||
if (index === -1) current.methods.push(castDraft(implementation.method))
|
||||
else current.methods[index] = castDraft(implementation.method)
|
||||
if (isOAuthImplementation(implementation)) {
|
||||
current.implementations.set(implementation.method.id, castDraft(implementation))
|
||||
if (index === -1) current.methods.push(implementation.method as Types.DeepMutable<Method>)
|
||||
else current.methods[index] = implementation.method as Types.DeepMutable<Method>
|
||||
if (implementation.method.type === "oauth") {
|
||||
current.implementations.set(
|
||||
implementation.method.id,
|
||||
implementation as Types.DeepMutable<OAuthImplementation>,
|
||||
)
|
||||
}
|
||||
},
|
||||
remove: (integrationID, method) => {
|
||||
@@ -434,7 +446,7 @@ export const locationLayer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
update: state.update,
|
||||
rebuild: state.rebuild,
|
||||
get: Effect.fn("Integration.get")(function* (id) {
|
||||
const entry = state.get().integrations.get(id)
|
||||
if (!entry) return undefined
|
||||
|
||||
@@ -46,6 +46,7 @@ import { RequestExecutor } from "@opencode-ai/llm/route"
|
||||
import * as SessionRunnerLLM from "./session/runner/llm"
|
||||
import { SessionRunnerModel } from "./session/runner/model"
|
||||
import { SystemContextBuiltIns } from "./system-context/builtins"
|
||||
import { Snapshot } from "./snapshot"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
|
||||
@@ -54,9 +55,8 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||
Effect.logInfo("booting location services", { directory: ref.directory, workspaceID: ref.workspaceID }),
|
||||
)
|
||||
const location = Location.layer(ref)
|
||||
const systemContext = SystemContextBuiltIns.locationLayer
|
||||
const base = Layer.mergeAll(
|
||||
location,
|
||||
return Layer.mergeAll(
|
||||
boot,
|
||||
Policy.locationLayer,
|
||||
Config.locationLayer,
|
||||
Reference.locationLayer,
|
||||
@@ -71,56 +71,22 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||
Watcher.locationLayer,
|
||||
Pty.locationLayer,
|
||||
SkillV2.locationLayer,
|
||||
systemContext,
|
||||
LocationMutation.locationLayer.pipe(Layer.orDie),
|
||||
).pipe(Layer.provideMerge(location))
|
||||
const resources = ToolOutputStore.layer.pipe(Layer.provide(base))
|
||||
const permissionsAndTools = ToolRegistry.layer.pipe(
|
||||
Layer.provideMerge(PermissionV2.locationLayer),
|
||||
Layer.provide(resources),
|
||||
Layer.provide(base),
|
||||
)
|
||||
const services = Layer.mergeAll(base, resources, permissionsAndTools)
|
||||
const image = Image.layer.pipe(Layer.provide(services))
|
||||
const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
|
||||
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
|
||||
const referenceGuidance = ReferenceGuidance.locationLayer.pipe(Layer.provide(services))
|
||||
const todos = SessionTodo.layer.pipe(Layer.provide(services))
|
||||
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
|
||||
const builtInTools = BuiltInTools.locationLayer.pipe(
|
||||
Layer.provide(services),
|
||||
Layer.provide(mutation),
|
||||
Layer.provide(resources),
|
||||
Layer.provide(todos),
|
||||
Layer.provide(questions),
|
||||
Layer.provide(image),
|
||||
)
|
||||
const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services))
|
||||
const runner = SessionRunnerLLM.defaultLayer.pipe(
|
||||
Layer.provide(services),
|
||||
Layer.provide(model),
|
||||
Layer.provide(skillGuidance),
|
||||
Layer.provide(referenceGuidance),
|
||||
)
|
||||
|
||||
// Kick off a background project copy refresh to update locations now that we
|
||||
// have a location
|
||||
const projectCopyRefresh = Layer.effectDiscard(ProjectCopy.refreshAfterBoot).pipe(Layer.provide(services))
|
||||
|
||||
return Layer.mergeAll(
|
||||
boot,
|
||||
services,
|
||||
image,
|
||||
mutation,
|
||||
resources,
|
||||
todos,
|
||||
questions,
|
||||
model,
|
||||
runner,
|
||||
builtInTools,
|
||||
referenceGuidance,
|
||||
projectCopyRefresh,
|
||||
).pipe(Layer.fresh)
|
||||
SystemContextBuiltIns.locationLayer,
|
||||
LocationMutation.locationLayer,
|
||||
PermissionV2.locationLayer,
|
||||
ToolOutputStore.locationLayer,
|
||||
ToolRegistry.locationLayer,
|
||||
Snapshot.locationLayer,
|
||||
Image.locationLayer,
|
||||
FileMutation.locationLayer,
|
||||
SkillGuidance.locationLayer,
|
||||
ReferenceGuidance.locationLayer,
|
||||
SessionTodo.locationLayer,
|
||||
QuestionV2.locationLayer,
|
||||
SessionRunnerModel.locationLayer,
|
||||
SessionRunnerLLM.locationLayer,
|
||||
BuiltInTools.locationLayer,
|
||||
).pipe(Layer.provideMerge(location), Layer.fresh)
|
||||
},
|
||||
idleTimeToLive: "60 minutes",
|
||||
dependencies: [
|
||||
@@ -136,7 +102,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||
Ripgrep.defaultLayer,
|
||||
Database.defaultLayer,
|
||||
ProjectDirectories.defaultLayer,
|
||||
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
|
||||
SessionStore.defaultLayer,
|
||||
PermissionSaved.defaultLayer,
|
||||
RepositoryCache.defaultLayer,
|
||||
LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)),
|
||||
|
||||
@@ -152,4 +152,4 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
export const locationLayer = layer.pipe(Layer.orDie)
|
||||
|
||||
@@ -33,7 +33,7 @@ export type Request = typeof Request.Type
|
||||
interface MutableRequest {
|
||||
headers: Record<string, string>
|
||||
body: Record<string, unknown>
|
||||
generation?: Generation
|
||||
generation?: Record<string, unknown>
|
||||
options?: Record<string, unknown>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { DateTime, Schema } from "effect"
|
||||
import { DateTimeUtcFromMillis } from "effect/Schema"
|
||||
import { Schema, Types } from "effect"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { ModelRequest } from "./model-request"
|
||||
|
||||
@@ -16,8 +15,8 @@ export type Family = typeof Family.Type
|
||||
export const Capabilities = Schema.Struct({
|
||||
tools: Schema.Boolean,
|
||||
// mime patterns, image, audio, video/*, text/*
|
||||
input: Schema.String.pipe(Schema.Array),
|
||||
output: Schema.String.pipe(Schema.Array),
|
||||
input: Schema.String.pipe(Schema.Array, Schema.mutable),
|
||||
output: Schema.String.pipe(Schema.Array, Schema.mutable),
|
||||
})
|
||||
export type Capabilities = typeof Capabilities.Type
|
||||
|
||||
@@ -67,11 +66,11 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
|
||||
variants: Schema.Struct({
|
||||
id: VariantID,
|
||||
...ModelRequest.Request.fields,
|
||||
}).pipe(Schema.Array),
|
||||
}).pipe(Schema.Array, Schema.mutable),
|
||||
time: Schema.Struct({
|
||||
released: DateTimeUtcFromMillis,
|
||||
released: Schema.Finite,
|
||||
}),
|
||||
cost: Cost.pipe(Schema.Array),
|
||||
cost: Cost.pipe(Schema.Array, Schema.mutable),
|
||||
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
|
||||
enabled: Schema.Boolean,
|
||||
limit: Schema.Struct({
|
||||
@@ -103,7 +102,7 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
|
||||
},
|
||||
variants: [],
|
||||
time: {
|
||||
released: DateTime.makeUnsafe(0),
|
||||
released: 0,
|
||||
},
|
||||
cost: [],
|
||||
status: "active",
|
||||
@@ -116,6 +115,10 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
|
||||
}
|
||||
}
|
||||
|
||||
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & {
|
||||
api: ProviderV2.MutableApi<Api>
|
||||
}
|
||||
|
||||
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
|
||||
const [providerID, ...modelID] = input.split("/")
|
||||
return {
|
||||
|
||||
+23
-27
@@ -20,7 +20,7 @@ export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedErr
|
||||
|
||||
export interface EntryPoint {
|
||||
readonly directory: string
|
||||
readonly entrypoint: Option.Option<string>
|
||||
readonly entrypoint?: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -34,7 +34,7 @@ export interface Interface {
|
||||
}[]
|
||||
},
|
||||
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
|
||||
readonly which: (pkg: string, bin?: string) => Effect.Effect<Option.Option<string>>
|
||||
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
|
||||
@@ -47,12 +47,11 @@ export function sanitize(pkg: string) {
|
||||
}
|
||||
|
||||
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
|
||||
let entrypoint: Option.Option<string>
|
||||
let entrypoint: string | undefined
|
||||
try {
|
||||
const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
|
||||
entrypoint = Option.some(resolved)
|
||||
entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
|
||||
} catch {
|
||||
entrypoint = Option.none()
|
||||
entrypoint = undefined
|
||||
}
|
||||
return {
|
||||
directory: dir,
|
||||
@@ -130,7 +129,7 @@ export const layer = Layer.effect(
|
||||
const first = tree.edgesOut.values().next().value?.to
|
||||
if (!first) {
|
||||
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name))
|
||||
if (Option.isSome(result.entrypoint)) return result
|
||||
if (result.entrypoint) return result
|
||||
return yield* new InstallFailedError({ add: [pkg], dir })
|
||||
}
|
||||
return resolveEntryPoint(first.name, first.path)
|
||||
@@ -219,22 +218,24 @@ export const layer = Layer.effect(
|
||||
return Option.some(files[0])
|
||||
})
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const bin = yield* pick()
|
||||
if (Option.isSome(bin)) {
|
||||
return Option.some(path.join(binDir, bin.value))
|
||||
}
|
||||
return Option.getOrUndefined(
|
||||
yield* Effect.gen(function* () {
|
||||
const bin = yield* pick()
|
||||
if (Option.isSome(bin)) {
|
||||
return Option.some(path.join(binDir, bin.value))
|
||||
}
|
||||
|
||||
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
|
||||
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
|
||||
|
||||
yield* add(pkg)
|
||||
yield* add(pkg)
|
||||
|
||||
const resolved = yield* pick()
|
||||
if (Option.isNone(resolved)) return Option.none<string>()
|
||||
return Option.some(path.join(binDir, resolved.value))
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.orElseSucceed(() => Option.none<string>()),
|
||||
const resolved = yield* pick()
|
||||
if (Option.isNone(resolved)) return Option.none<string>()
|
||||
return Option.some(path.join(binDir, resolved.value))
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.orElseSucceed(() => Option.none<string>()),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -261,14 +262,9 @@ export async function install(...args: Parameters<Interface["install"]>) {
|
||||
}
|
||||
|
||||
export async function add(...args: Parameters<Interface["add"]>) {
|
||||
const entry = await runPromise((svc) => svc.add(...args))
|
||||
return {
|
||||
directory: entry.directory,
|
||||
entrypoint: Option.getOrUndefined(entry.entrypoint),
|
||||
}
|
||||
return runPromise((svc) => svc.add(...args))
|
||||
}
|
||||
|
||||
export async function which(...args: Parameters<Interface["which"]>) {
|
||||
const resolved = await runPromise((svc) => svc.which(...args))
|
||||
return Option.getOrUndefined(resolved)
|
||||
return runPromise((svc) => svc.which(...args))
|
||||
}
|
||||
|
||||
@@ -12,5 +12,5 @@ export const Rule = Schema.Struct({
|
||||
}).annotate({ identifier: "PermissionV2.Rule" })
|
||||
export type Rule = typeof Rule.Type
|
||||
|
||||
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" })
|
||||
export const Ruleset = Schema.mutable(Schema.Array(Rule)).annotate({ identifier: "PermissionV2.Ruleset" })
|
||||
export type Ruleset = typeof Ruleset.Type
|
||||
|
||||
+43
-20
@@ -7,6 +7,7 @@ import type { ModelV2 } from "./model"
|
||||
import type { Catalog } from "./catalog"
|
||||
import { EventV2 } from "./event"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { State } from "./state"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
@@ -22,7 +23,7 @@ export const Event = {
|
||||
|
||||
type HookSpec = {
|
||||
"catalog.transform": {
|
||||
input: Catalog.Editor
|
||||
input: Catalog.Draft
|
||||
output: {}
|
||||
}
|
||||
"aisdk.language": {
|
||||
@@ -62,18 +63,16 @@ export type HookFunctions = {
|
||||
export type HookInput<Name extends keyof Hooks> = HookSpec[Name]["input"]
|
||||
export type HookOutput<Name extends keyof Hooks> = HookSpec[Name]["output"]
|
||||
|
||||
export type Effect<R = never> = Effect.Effect<HookFunctions | void, never, R | Scope.Scope>
|
||||
|
||||
export function define<R>(input: { id: ID; effect: Effect.Effect<HookFunctions | void, never, R> }) {
|
||||
return input
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly add: (input: {
|
||||
id: ID
|
||||
id: string
|
||||
effect: Effect.Effect<void | HookFunctions, never, Scope.Scope>
|
||||
}) => Effect.Effect<void, never, never>
|
||||
readonly remove: (id: ID) => Effect.Effect<void>
|
||||
readonly hook: <Name extends keyof Hooks>(
|
||||
name: Name,
|
||||
callback: (input: Hooks[Name]) => Effect.Effect<void> | void,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
readonly triggerFor: <Name extends keyof Hooks>(
|
||||
id: ID,
|
||||
name: Name,
|
||||
@@ -97,35 +96,40 @@ export const layer = Layer.effect(
|
||||
hooks: HookFunctions
|
||||
scope: Scope.Closeable
|
||||
}[] = []
|
||||
let registrations: {
|
||||
[Name in keyof Hooks]: {
|
||||
name: Name
|
||||
callback: (input: Hooks[Name]) => Effect.Effect<void> | void
|
||||
}
|
||||
}[keyof Hooks][] = []
|
||||
const events = yield* EventV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const locks = KeyedMutex.makeUnsafe<ID>()
|
||||
|
||||
const svc = Service.of({
|
||||
add: Effect.fn("Plugin.add")(function* (input) {
|
||||
yield* locks.withLock(input.id)(
|
||||
const id = ID.make(input.id)
|
||||
yield* locks.withLock(id)(
|
||||
Effect.gen(function* () {
|
||||
const existing = hooks.find((item) => item.id === input.id)
|
||||
const existing = hooks.find((item) => item.id === id)
|
||||
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
|
||||
const childScope = yield* Scope.fork(scope)
|
||||
const result = yield* input.effect.pipe(
|
||||
Scope.provide(childScope),
|
||||
Effect.withSpan("Plugin.load", {
|
||||
attributes: {
|
||||
"plugin.id": input.id,
|
||||
"plugin.id": id,
|
||||
},
|
||||
}),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(childScope, exit) : Effect.void)),
|
||||
)
|
||||
hooks = [
|
||||
...hooks.filter((item) => item.id !== input.id),
|
||||
{
|
||||
id: input.id,
|
||||
hooks: result ?? {},
|
||||
scope: childScope,
|
||||
},
|
||||
]
|
||||
yield* events.publish(Event.Added, { id: input.id })
|
||||
const next = {
|
||||
id,
|
||||
hooks: result ?? {},
|
||||
scope: childScope,
|
||||
}
|
||||
hooks = existing ? hooks.with(hooks.indexOf(existing), next) : [...hooks, next]
|
||||
yield* events.publish(Event.Added, { id })
|
||||
}),
|
||||
)
|
||||
}),
|
||||
@@ -160,6 +164,12 @@ export const layer = Layer.effect(
|
||||
)
|
||||
}
|
||||
|
||||
for (const item of registrations) {
|
||||
if (item.name !== name) continue
|
||||
const result = item.callback(event as never)
|
||||
if (Effect.isEffect(result)) yield* result
|
||||
}
|
||||
|
||||
for (const [field, draft] of draftEntries) {
|
||||
event[field] = finishDraft(draft)
|
||||
}
|
||||
@@ -175,6 +185,19 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
}),
|
||||
hook: Effect.fn("Plugin.hook")(function* (name, callback) {
|
||||
const scope = yield* Scope.Scope
|
||||
const registration = { name, callback } as (typeof registrations)[number]
|
||||
let active = true
|
||||
registrations = [...registrations, registration]
|
||||
const dispose = Effect.sync(() => {
|
||||
if (!active) return
|
||||
active = false
|
||||
registrations = registrations.filter((item) => item !== registration)
|
||||
})
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
return { dispose }
|
||||
}),
|
||||
})
|
||||
return svc
|
||||
}),
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
export * as AgentPlugin from "./agent"
|
||||
|
||||
import path from "path"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Global } from "../global"
|
||||
import { Location } from "../location"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { PluginV2 } from "../plugin"
|
||||
|
||||
const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*")
|
||||
const BUILD_SYSTEM =
|
||||
@@ -97,12 +96,10 @@ Rules:
|
||||
- If the conversation ends with an unanswered question to the user, preserve that exact question
|
||||
- If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary`
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("agent"),
|
||||
effect: Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
const location = yield* Location.Service
|
||||
const worktree = location.directory
|
||||
export const Plugin = define({
|
||||
id: "agent",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const worktree = ctx.location.directory
|
||||
const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")]
|
||||
const readonlyExternalDirectory: PermissionV2.Ruleset = [
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
@@ -122,8 +119,8 @@ export const Plugin = PluginV2.define({
|
||||
{ action: "read", resource: "*.env.example", effect: "allow" },
|
||||
]
|
||||
|
||||
yield* agent.update((editor) => {
|
||||
editor.update(AgentV2.defaultID, (item) => {
|
||||
yield* ctx.agent.transform((draft) => {
|
||||
draft.update(AgentV2.defaultID, (item) => {
|
||||
item.description = "The default agent. Executes tools based on configured permissions."
|
||||
item.system ??= BUILD_SYSTEM
|
||||
item.mode = "primary"
|
||||
@@ -135,7 +132,7 @@ export const Plugin = PluginV2.define({
|
||||
)
|
||||
})
|
||||
|
||||
editor.update(AgentV2.ID.make("plan"), (item) => {
|
||||
draft.update(AgentV2.ID.make("plan"), (item) => {
|
||||
item.description = "Plan mode. Disallows all edit tools."
|
||||
item.mode = "primary"
|
||||
item.permissions.push(
|
||||
@@ -154,14 +151,14 @@ export const Plugin = PluginV2.define({
|
||||
)
|
||||
})
|
||||
|
||||
editor.update(AgentV2.ID.make("general"), (item) => {
|
||||
draft.update(AgentV2.ID.make("general"), (item) => {
|
||||
item.description =
|
||||
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
|
||||
item.mode = "subagent"
|
||||
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "todowrite", resource: "*", effect: "deny" }]))
|
||||
})
|
||||
|
||||
editor.update(AgentV2.ID.make("explore"), (item) => {
|
||||
draft.update(AgentV2.ID.make("explore"), (item) => {
|
||||
item.description =
|
||||
'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
|
||||
item.system = PROMPT_EXPLORE
|
||||
@@ -182,21 +179,21 @@ export const Plugin = PluginV2.define({
|
||||
)
|
||||
})
|
||||
|
||||
editor.update(AgentV2.ID.make("compaction"), (item) => {
|
||||
draft.update(AgentV2.ID.make("compaction"), (item) => {
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_COMPACTION
|
||||
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
|
||||
})
|
||||
|
||||
editor.update(AgentV2.ID.make("title"), (item) => {
|
||||
draft.update(AgentV2.ID.make("title"), (item) => {
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_TITLE
|
||||
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
|
||||
})
|
||||
|
||||
editor.update(AgentV2.ID.make("summary"), (item) => {
|
||||
draft.update(AgentV2.ID.make("summary"), (item) => {
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_SUMMARY
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as PluginBoot from "./boot"
|
||||
|
||||
import type { Plugin as PublicPlugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Context, Deferred, Effect, Layer } from "effect"
|
||||
import { Credential } from "../credential"
|
||||
import { Integration } from "../integration"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
@@ -13,6 +13,7 @@ import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference"
|
||||
import { EventV2 } from "../event"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { Global } from "../global"
|
||||
import { Location } from "../location"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
@@ -26,29 +27,13 @@ import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { Reference } from "../reference"
|
||||
import { State } from "../state"
|
||||
import { PluginHost } from "./host"
|
||||
|
||||
type Plugin = {
|
||||
id: PluginV2.ID
|
||||
effect: PluginV2.Effect<
|
||||
| Catalog.Service
|
||||
| CommandV2.Service
|
||||
| Credential.Service
|
||||
| Integration.Service
|
||||
| AgentV2.Service
|
||||
| Npm.Service
|
||||
| EventV2.Service
|
||||
| FSUtil.Service
|
||||
| Global.Service
|
||||
| Location.Service
|
||||
| PluginV2.Service
|
||||
| Config.Service
|
||||
| ModelsDev.Service
|
||||
| SkillV2.Service
|
||||
| Reference.Service
|
||||
>
|
||||
}
|
||||
type InternalPlugin = PublicPlugin<any>
|
||||
|
||||
export interface Interface {
|
||||
readonly add: (plugin: PublicPlugin<any>) => Effect.Effect<void>
|
||||
readonly wait: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -60,8 +45,7 @@ export const layer = Layer.effect(
|
||||
const catalog = yield* Catalog.Service
|
||||
const commands = yield* CommandV2.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const integration = yield* Integration.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
@@ -69,47 +53,54 @@ export const layer = Layer.effect(
|
||||
const npm = yield* Npm.Service
|
||||
const events = yield* EventV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const global = yield* Global.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const references = yield* Reference.Service
|
||||
const reference = yield* Reference.Service
|
||||
const host = yield* PluginHost.make()
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
|
||||
const add = Effect.fn("PluginBoot.add")(function* (input: InternalPlugin) {
|
||||
yield* plugin.add({
|
||||
id: input.id,
|
||||
effect: input.effect.pipe(
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(CommandV2.Service, commands),
|
||||
Effect.provideService(Credential.Service, credentials),
|
||||
Effect.provideService(Integration.Service, integrations),
|
||||
Effect.provideService(AgentV2.Service, agents),
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Location.Service, location),
|
||||
Effect.provideService(ModelsDev.Service, modelsDev),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(SkillV2.Service, skill),
|
||||
Effect.provideService(Reference.Service, references),
|
||||
Effect.provideService(PluginV2.Service, plugin),
|
||||
),
|
||||
effect: input
|
||||
.effect(host)
|
||||
.pipe(
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(CommandV2.Service, commands),
|
||||
Effect.provideService(Integration.Service, integration),
|
||||
Effect.provideService(AgentV2.Service, agents),
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Location.Service, location),
|
||||
Effect.provideService(ModelsDev.Service, modelsDev),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(FileSystem.Service, filesystem),
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(SkillV2.Service, skill),
|
||||
Effect.provideService(Reference.Service, reference),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
const boot = Effect.gen(function* () {
|
||||
yield* add(AgentPlugin.Plugin)
|
||||
yield* add(CommandPlugin.Plugin)
|
||||
yield* add(SkillPlugin.Plugin)
|
||||
for (const item of ProviderPlugins) {
|
||||
yield* add(item)
|
||||
}
|
||||
yield* add(ModelsDevPlugin)
|
||||
yield* add(ConfigProviderPlugin.Plugin)
|
||||
yield* add(ConfigAgentPlugin.Plugin)
|
||||
yield* add(ConfigCommandPlugin.Plugin)
|
||||
yield* add(ConfigSkillPlugin.Plugin)
|
||||
yield* add(ConfigReferencePlugin.Plugin)
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* add(AgentPlugin.Plugin)
|
||||
yield* add(CommandPlugin.Plugin)
|
||||
yield* add(SkillPlugin.Plugin)
|
||||
yield* add(ModelsDevPlugin)
|
||||
yield* add(ConfigProviderPlugin.Plugin)
|
||||
yield* add(ConfigAgentPlugin.Plugin)
|
||||
yield* add(ConfigCommandPlugin.Plugin)
|
||||
yield* add(ConfigSkillPlugin.Plugin)
|
||||
yield* add(ConfigReferencePlugin.Plugin)
|
||||
for (const item of ProviderPlugins) {
|
||||
yield* add(item)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.withSpan("PluginBoot.boot"))
|
||||
|
||||
yield* boot.pipe(
|
||||
@@ -119,12 +110,22 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
add: (input) =>
|
||||
Deferred.await(done).pipe(
|
||||
Effect.andThen(
|
||||
plugin.add({
|
||||
id: input.id,
|
||||
effect: input.effect(host),
|
||||
}),
|
||||
),
|
||||
),
|
||||
wait: () => Deferred.await(done),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(PluginV2.locationLayer),
|
||||
Layer.provideMerge(Integration.locationLayer),
|
||||
Layer.provideMerge(Catalog.locationLayer),
|
||||
Layer.provideMerge(CommandV2.locationLayer),
|
||||
@@ -132,4 +133,5 @@ export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(AgentV2.locationLayer),
|
||||
Layer.provideMerge(SkillV2.locationLayer),
|
||||
Layer.provideMerge(Reference.locationLayer),
|
||||
Layer.provideMerge(FileSystem.locationLayer),
|
||||
)
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
export * as CommandPlugin from "./command"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect } from "effect"
|
||||
import { CommandV2 } from "../command"
|
||||
import { Location } from "../location"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import PROMPT_INITIALIZE from "./command/initialize.txt"
|
||||
import PROMPT_REVIEW from "./command/review.txt"
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("command"),
|
||||
effect: Effect.gen(function* () {
|
||||
const command = yield* CommandV2.Service
|
||||
const location = yield* Location.Service
|
||||
const transform = yield* command.transform()
|
||||
|
||||
yield* transform((editor) => {
|
||||
editor.update("init", (command) => {
|
||||
command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory)
|
||||
export const Plugin = define({
|
||||
id: "command",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.command.transform((draft) => {
|
||||
draft.update("init", (command) => {
|
||||
command.template = PROMPT_INITIALIZE.replace("${path}", ctx.location.project.directory)
|
||||
command.description = "guided AGENTS.md setup"
|
||||
})
|
||||
editor.update("review", (command) => {
|
||||
command.template = PROMPT_REVIEW.replace("${path}", location.project.directory)
|
||||
draft.update("review", (command) => {
|
||||
command.template = PROMPT_REVIEW.replace("${path}", ctx.location.project.directory)
|
||||
command.description = "review changes [commit|branch|pr], defaults to uncommitted"
|
||||
command.subtask = true
|
||||
})
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
export * as PluginHost from "./host"
|
||||
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import type { PluginHost as Interface } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Event as SDKEvent, ModelV2Info } from "@opencode-ai/sdk/v2/types"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
import { CommandV2 } from "../command"
|
||||
import { EventV2 } from "../event"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { Global } from "../global"
|
||||
import { Integration } from "../integration"
|
||||
import { Location } from "../location"
|
||||
import { ModelV2 } from "../model"
|
||||
import { Npm } from "../npm"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { ProviderV2 } from "../provider"
|
||||
import { Reference } from "../reference"
|
||||
import { SkillV2 } from "../skill"
|
||||
|
||||
type EventMap = { [Item in SDKEvent as Item["type"]]: Item }
|
||||
type SDKHook = (event: {
|
||||
readonly model: ModelV2Info
|
||||
readonly package: string
|
||||
readonly options: Record<string, any>
|
||||
sdk?: any
|
||||
}) => Effect.Effect<void> | void
|
||||
type LanguageHook = (event: {
|
||||
readonly model: ModelV2Info
|
||||
readonly sdk: any
|
||||
readonly options: Record<string, any>
|
||||
language?: LanguageModelV3
|
||||
}) => Effect.Effect<void> | void
|
||||
|
||||
export const make = Effect.fn("PluginHost.make")(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const commands = yield* CommandV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const global = yield* Global.Service
|
||||
const integration = yield* Integration.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const reference = yield* Reference.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
|
||||
return {
|
||||
agent: {
|
||||
get: (id) => agents.get(AgentV2.ID.make(id)),
|
||||
default: agents.default,
|
||||
list: agents.all,
|
||||
rebuild: agents.rebuild,
|
||||
transform: (callback) =>
|
||||
agents.transform((draft) =>
|
||||
callback({
|
||||
list: draft.list,
|
||||
get: (id) => draft.get(AgentV2.ID.make(id)),
|
||||
default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)),
|
||||
update: (id, update) => draft.update(AgentV2.ID.make(id), update),
|
||||
remove: (id) => draft.remove(AgentV2.ID.make(id)),
|
||||
}),
|
||||
),
|
||||
},
|
||||
aisdk: {
|
||||
hook: (name, callback) => {
|
||||
if (name === "sdk") {
|
||||
const run = callback as SDKHook
|
||||
return plugin.hook("aisdk.sdk", (event) => {
|
||||
const output = {
|
||||
model: event.model,
|
||||
package: event.package,
|
||||
options: event.options,
|
||||
sdk: event.sdk,
|
||||
}
|
||||
const result = run(output)
|
||||
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
|
||||
Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))),
|
||||
)
|
||||
})
|
||||
}
|
||||
const run = callback as LanguageHook
|
||||
return plugin.hook("aisdk.language", (event) => {
|
||||
const output = {
|
||||
model: event.model,
|
||||
sdk: event.sdk,
|
||||
options: event.options,
|
||||
language: event.language,
|
||||
}
|
||||
const result = run(output)
|
||||
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
|
||||
Effect.tap(() => Effect.sync(() => (event.language = output.language))),
|
||||
)
|
||||
})
|
||||
},
|
||||
},
|
||||
catalog: {
|
||||
provider: {
|
||||
get: (id) => catalog.provider.get(ProviderV2.ID.make(id)),
|
||||
list: catalog.provider.all,
|
||||
available: catalog.provider.available,
|
||||
},
|
||||
model: {
|
||||
get: (providerID, modelID) => catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
||||
list: catalog.model.all,
|
||||
available: catalog.model.available,
|
||||
default: catalog.model.default,
|
||||
small: (providerID) => catalog.model.small(ProviderV2.ID.make(providerID)),
|
||||
},
|
||||
rebuild: catalog.rebuild,
|
||||
transform: (callback) =>
|
||||
catalog.transform((draft) =>
|
||||
callback({
|
||||
provider: {
|
||||
list: draft.provider.list,
|
||||
get: (id) => draft.provider.get(ProviderV2.ID.make(id)),
|
||||
update: (id, update) => draft.provider.update(ProviderV2.ID.make(id), update),
|
||||
remove: (id) => draft.provider.remove(ProviderV2.ID.make(id)),
|
||||
},
|
||||
model: {
|
||||
get: (providerID, modelID) => draft.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
||||
update: (providerID, modelID, update) =>
|
||||
draft.model.update(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID), update),
|
||||
remove: (providerID, modelID) =>
|
||||
draft.model.remove(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
||||
default: {
|
||||
get: draft.model.default.get,
|
||||
set: (providerID, modelID) =>
|
||||
draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
command: {
|
||||
get: commands.get,
|
||||
list: commands.list,
|
||||
rebuild: commands.rebuild,
|
||||
transform: commands.transform,
|
||||
},
|
||||
event: {
|
||||
subscribe: <Type extends keyof EventMap>(type: Type): Stream.Stream<EventMap[Type]> =>
|
||||
Stream.unwrap(
|
||||
Effect.sync(() => {
|
||||
const definition = EventV2.registry.get(type)
|
||||
if (!definition) throw new Error(`Unknown event type: ${type}`)
|
||||
const encode = Schema.encodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)
|
||||
return events.subscribe(definition).pipe(
|
||||
Stream.map(
|
||||
(event) =>
|
||||
({
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
properties: encode(event.data),
|
||||
}) as unknown as EventMap[Type],
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
},
|
||||
filesystem: {
|
||||
read: (input) => filesystem.read(Schema.decodeUnknownSync(FileSystem.ReadInput)(input)),
|
||||
list: (input) => filesystem.list(Schema.decodeUnknownSync(FileSystem.ListInput)(input ?? {})),
|
||||
find: (input) => filesystem.find(Schema.decodeUnknownSync(FileSystem.FindInput)(input)),
|
||||
glob: (input) => filesystem.glob(Schema.decodeUnknownSync(FileSystem.GlobInput)(input)),
|
||||
},
|
||||
integration: {
|
||||
get: (id) => integration.get(Integration.ID.make(id)),
|
||||
list: integration.list,
|
||||
rebuild: integration.rebuild,
|
||||
transform: (callback) =>
|
||||
integration.transform((draft) =>
|
||||
callback({
|
||||
list: draft.list,
|
||||
get: (id) => draft.get(Integration.ID.make(id)),
|
||||
update: (id, update) => draft.update(Integration.ID.make(id), update),
|
||||
remove: (id) => draft.remove(Integration.ID.make(id)),
|
||||
method: {
|
||||
list: (id) => draft.method.list(Integration.ID.make(id)),
|
||||
update: (input) => {
|
||||
if (input.method.type === "env") {
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "env", names: input.method.names },
|
||||
})
|
||||
return
|
||||
}
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "key", label: input.method.label },
|
||||
})
|
||||
},
|
||||
remove: (id, method) =>
|
||||
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
location,
|
||||
npm,
|
||||
path: {
|
||||
home: global.home,
|
||||
data: global.data,
|
||||
cache: global.cache,
|
||||
config: global.config,
|
||||
state: global.state,
|
||||
temp: global.tmp,
|
||||
},
|
||||
reference: {
|
||||
list: reference.list,
|
||||
rebuild: reference.rebuild,
|
||||
transform: (callback) =>
|
||||
reference.transform((draft) =>
|
||||
callback({
|
||||
add: (name, source) => draft.add(name, Schema.decodeUnknownSync(Reference.Source)(source)),
|
||||
remove: draft.remove,
|
||||
list: draft.list,
|
||||
}),
|
||||
),
|
||||
},
|
||||
skill: {
|
||||
sources: skill.sources,
|
||||
list: skill.list,
|
||||
rebuild: skill.rebuild,
|
||||
transform: (callback) =>
|
||||
skill.transform((draft) =>
|
||||
callback({
|
||||
source: (source) => draft.source(Schema.decodeUnknownSync(SkillV2.Source)(source)),
|
||||
list: draft.list,
|
||||
}),
|
||||
),
|
||||
},
|
||||
} satisfies Interface
|
||||
})
|
||||
@@ -1,16 +1,13 @@
|
||||
import { DateTime, Effect, Scope, Stream } from "effect"
|
||||
import { Catalog } from "../catalog"
|
||||
import { Integration } from "../integration"
|
||||
import { EventV2 } from "../event"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { ModelV2 } from "../model"
|
||||
import { ModelRequest } from "../model-request"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { ProviderV2 } from "../provider"
|
||||
|
||||
function released(date: string) {
|
||||
const time = Date.parse(date)
|
||||
return DateTime.makeUnsafe(Number.isFinite(time) ? time : 0)
|
||||
return Number.isFinite(time) ? time : 0
|
||||
}
|
||||
|
||||
function cost(input: ModelsDev.Model["cost"]) {
|
||||
@@ -51,22 +48,16 @@ function variants(model: ModelsDev.Model, packageName?: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export const ModelsDevPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("models-dev"),
|
||||
effect: Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
export const ModelsDevPlugin = define({
|
||||
id: "models-dev",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const events = yield* EventV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const transform = yield* catalog.transform()
|
||||
const integrationTransform = yield* integrations.transform()
|
||||
const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () {
|
||||
const data = yield* modelsDev.get()
|
||||
yield* integrationTransform((integrations) => {
|
||||
yield* ctx.integration.transform(
|
||||
Effect.fn(function* (integrations) {
|
||||
const data = yield* modelsDev.get()
|
||||
for (const item of Object.values(data)) {
|
||||
if (item.env.length === 0) continue
|
||||
const integrationID = Integration.ID.make(item.id)
|
||||
const integrationID = item.id
|
||||
integrations.update(integrationID, (integration) => (integration.name = item.name))
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
@@ -77,8 +68,11 @@ export const ModelsDevPlugin = PluginV2.define({
|
||||
method: { type: "env", names: [...item.env] },
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* transform((catalog) => {
|
||||
}),
|
||||
)
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (catalog) {
|
||||
const data = yield* modelsDev.get()
|
||||
for (const item of Object.values(data)) {
|
||||
const providerID = ProviderV2.ID.make(item.id)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
@@ -132,11 +126,10 @@ export const ModelsDevPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
yield* refresh()
|
||||
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
|
||||
Stream.runForEach(() => refresh()),
|
||||
}),
|
||||
)
|
||||
yield* ctx.event.subscribe("models-dev.refreshed").pipe(
|
||||
Stream.runForEach(() => ctx.integration.rebuild().pipe(Effect.andThen(ctx.catalog.rebuild()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const AlibabaPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("alibaba"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const AlibabaPlugin = define({
|
||||
id: "alibaba",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/alibaba") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba"))
|
||||
evt.sdk = mod.createAlibaba(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
type MantleSDK = {
|
||||
@@ -59,11 +59,11 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
|
||||
return sdk.responses(modelID)
|
||||
}
|
||||
|
||||
export const AmazonBedrockPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("amazon-bedrock"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const AmazonBedrockPlugin = define({
|
||||
id: "amazon-bedrock",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue
|
||||
@@ -77,7 +77,10 @@ export const AmazonBedrockPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return
|
||||
const options = { ...evt.options }
|
||||
const profile = typeof options.profile === "string" ? options.profile : process.env.AWS_PROFILE
|
||||
@@ -108,7 +111,10 @@ export const AmazonBedrockPlugin = PluginV2.define({
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock"))
|
||||
evt.sdk = mod.createAmazonBedrock(options)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return
|
||||
if (evt.model.api.type === "aisdk" && evt.model.api.package === "@ai-sdk/amazon-bedrock/mantle") {
|
||||
evt.language = selectMantleModel(evt.sdk, evt.model.api.id)
|
||||
@@ -117,6 +123,6 @@ export const AmazonBedrockPlugin = PluginV2.define({
|
||||
const region = typeof evt.options.region === "string" ? evt.options.region : process.env.AWS_REGION
|
||||
evt.language = evt.sdk.languageModel(resolveModelID(evt.model.api.id, region))
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const AnthropicPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("anthropic"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const AnthropicPlugin = define({
|
||||
id: "anthropic",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/anthropic") continue
|
||||
@@ -15,11 +15,14 @@ export const AnthropicPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/anthropic") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic"))
|
||||
evt.sdk = mod.createAnthropic(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
|
||||
@@ -10,11 +10,11 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
|
||||
return sdk.languageModel(modelID)
|
||||
}
|
||||
|
||||
export const AzurePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("azure"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const AzurePlugin = define({
|
||||
id: "azure",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/azure") continue
|
||||
@@ -27,7 +27,10 @@ export const AzurePlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/azure") return
|
||||
if (evt.model.providerID === ProviderV2.ID.azure) {
|
||||
if (
|
||||
@@ -43,19 +46,22 @@ export const AzurePlugin = PluginV2.define({
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/azure"))
|
||||
evt.sdk = mod.createAzure(evt.options)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.azure) return
|
||||
evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls))
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
export const AzureCognitiveServicesPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("azure-cognitive-services"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const AzureCognitiveServicesPlugin = define({
|
||||
id: "azure-cognitive-services",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
|
||||
if (!resourceName) return
|
||||
for (const item of evt.provider.list()) {
|
||||
@@ -67,10 +73,13 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
|
||||
evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls))
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const CerebrasPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("cerebras"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (ctx) {
|
||||
for (const item of ctx.provider.list()) {
|
||||
export const CerebrasPlugin = define({
|
||||
id: "cerebras",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/cerebras") continue
|
||||
ctx.provider.update(item.provider.id, (provider) => {
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/cerebras") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras"))
|
||||
evt.sdk = mod.createCerebras(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import os from "os"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const CloudflareAIGatewayPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("cloudflare-ai-gateway"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const CloudflareAIGatewayPlugin = define({
|
||||
id: "cloudflare-ai-gateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "ai-gateway-provider") return
|
||||
if (evt.options.baseURL) return
|
||||
|
||||
@@ -31,7 +32,7 @@ export const CloudflareAIGatewayPlugin = PluginV2.define({
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import os from "os"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
const providerID = ProviderV2.ID.make("cloudflare-workers-ai")
|
||||
|
||||
export const CloudflareWorkersAIPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("cloudflare-workers-ai"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const CloudflareWorkersAIPlugin = define({
|
||||
id: "cloudflare-workers-ai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
const item = evt.provider.get(providerID)
|
||||
if (!item) return
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
@@ -20,7 +20,10 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({
|
||||
if (accountId) provider.api.url = workersEndpoint(accountId)
|
||||
})
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== providerID) return
|
||||
if (evt.package !== "@ai-sdk/openai-compatible") return
|
||||
|
||||
@@ -34,11 +37,14 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({
|
||||
}) as any,
|
||||
)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== providerID) return
|
||||
evt.language = evt.sdk.languageModel(evt.model.api.id)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const CoherePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("cohere"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const CoherePlugin = define({
|
||||
id: "cohere",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/cohere") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/cohere"))
|
||||
evt.sdk = mod.createCohere(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const DeepInfraPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("deepinfra"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const DeepInfraPlugin = define({
|
||||
id: "deepinfra",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/deepinfra") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra"))
|
||||
evt.sdk = mod.createDeepInfra(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { Npm } from "../../npm"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const DynamicProviderPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("dynamic-provider"),
|
||||
effect: Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const DynamicProviderPlugin = define({
|
||||
id: "dynamic-provider",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.sdk) return
|
||||
|
||||
const installedPath = evt.package.startsWith("file://")
|
||||
? evt.package
|
||||
: Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint)
|
||||
: (yield* ctx.npm.add(evt.package).pipe(Effect.orDie)).entrypoint
|
||||
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
|
||||
|
||||
const mod = yield* Effect.promise(async () => {
|
||||
@@ -26,6 +25,6 @@ export const DynamicProviderPlugin = PluginV2.define({
|
||||
|
||||
evt.sdk = mod[match](evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const GatewayPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("gateway"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const GatewayPlugin = define({
|
||||
id: "gateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/gateway") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/gateway"))
|
||||
evt.sdk = mod.createGateway(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
function shouldUseResponses(modelID: string) {
|
||||
@@ -11,16 +11,31 @@ function shouldUseResponses(modelID: string) {
|
||||
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
|
||||
}
|
||||
|
||||
export const GithubCopilotPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("github-copilot"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const GithubCopilotPlugin = define({
|
||||
id: "github-copilot",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
|
||||
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
|
||||
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
|
||||
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
|
||||
// so hide it only for Copilot rather than for every provider catalog.
|
||||
model.enabled = false
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/github-copilot") return
|
||||
const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider"))
|
||||
evt.sdk = mod.createOpenaiCompatible(evt.options)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
|
||||
if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) {
|
||||
evt.language = evt.sdk.languageModel(evt.model.api.id)
|
||||
@@ -30,15 +45,6 @@ export const GithubCopilotPlugin = PluginV2.define({
|
||||
? evt.sdk.responses(evt.model.api.id)
|
||||
: evt.sdk.chat(evt.model.api.id)
|
||||
}),
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
|
||||
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
|
||||
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
|
||||
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
|
||||
// so hide it only for Copilot rather than for every provider catalog.
|
||||
model.enabled = false
|
||||
})
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import os from "os"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const GitLabPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("gitlab"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const GitLabPlugin = define({
|
||||
id: "gitlab",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "gitlab-ai-provider") return
|
||||
const mod = yield* Effect.promise(() => import("gitlab-ai-provider"))
|
||||
evt.sdk = mod.createGitLab({
|
||||
@@ -30,7 +31,10 @@ export const GitLabPlugin = PluginV2.define({
|
||||
},
|
||||
})
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.gitlab) return
|
||||
const featureFlags =
|
||||
typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {}
|
||||
@@ -58,6 +62,6 @@ export const GitLabPlugin = PluginV2.define({
|
||||
featureFlags,
|
||||
})
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
function resolveProject(options: Record<string, any>) {
|
||||
@@ -54,11 +54,11 @@ function authFetch(fetchWithRuntimeOptions?: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
export const GoogleVertexPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("google-vertex"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const GoogleVertexPlugin = define({
|
||||
id: "google-vertex",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (
|
||||
@@ -83,7 +83,10 @@ export const GoogleVertexPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) {
|
||||
evt.options.fetch = authFetch(evt.options.fetch)
|
||||
return
|
||||
@@ -100,19 +103,22 @@ export const GoogleVertexPlugin = PluginV2.define({
|
||||
location,
|
||||
})
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.googleVertex) return
|
||||
evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim())
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
export const GoogleVertexAnthropicPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("google-vertex-anthropic"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const GoogleVertexAnthropicPlugin = define({
|
||||
id: "google-vertex-anthropic",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue
|
||||
@@ -132,7 +138,10 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic"))
|
||||
const project =
|
||||
@@ -156,10 +165,13 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({
|
||||
: {}),
|
||||
})
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return
|
||||
evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim())
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const GooglePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("google"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const GooglePlugin = define({
|
||||
id: "google",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/google") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/google"))
|
||||
evt.sdk = mod.createGoogleGenerativeAI(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const GroqPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("groq"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const GroqPlugin = define({
|
||||
id: "groq",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/groq") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/groq"))
|
||||
evt.sdk = mod.createGroq(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const KiloPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("kilo"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const KiloPlugin = define({
|
||||
id: "kilo",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
@@ -16,6 +16,6 @@ export const KiloPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { Effect } from "effect"
|
||||
import { Integration } from "../../integration"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const LLMGatewayPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("llmgateway"),
|
||||
effect: Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const LLMGatewayPlugin = define({
|
||||
id: "llmgateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.disabled) continue
|
||||
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
|
||||
if (!(yield* ctx.integration.get(item.provider.id))) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] = "opencode"
|
||||
@@ -21,6 +19,6 @@ export const LLMGatewayPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const MistralPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("mistral"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const MistralPlugin = define({
|
||||
id: "mistral",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/mistral") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/mistral"))
|
||||
evt.sdk = mod.createMistral(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const NvidiaPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("nvidia"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const NvidiaPlugin = define({
|
||||
id: "nvidia",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
@@ -17,6 +17,6 @@ export const NvidiaPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const OpenAICompatiblePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("openai-compatible"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const OpenAICompatiblePlugin = define({
|
||||
id: "openai-compatible",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.sdk) return
|
||||
if (!evt.package.includes("@ai-sdk/openai-compatible")) return
|
||||
if (evt.options.includeUsage !== false) evt.options.includeUsage = true
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
|
||||
evt.sdk = mod.createOpenAICompatible(evt.options as any)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { Integration } from "../../integration"
|
||||
import { browser, headless } from "./openai-auth"
|
||||
|
||||
export const OpenAIPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("openai"),
|
||||
effect: Effect.gen(function* () {
|
||||
export const OpenAIPlugin = define({
|
||||
id: "openai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* integrations.update((editor) => {
|
||||
editor.method.update(browser)
|
||||
editor.method.update(headless)
|
||||
yield* integrations.transform((draft) => {
|
||||
draft.method.update(browser)
|
||||
draft.method.update(headless)
|
||||
})
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/openai") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
|
||||
evt.sdk = mod.createOpenAI(evt.options)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.openai) return
|
||||
evt.language = evt.sdk.responses(evt.model.api.id)
|
||||
}),
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai") continue
|
||||
@@ -35,6 +26,21 @@ export const OpenAIPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/openai") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
|
||||
evt.sdk = mod.createOpenAI(evt.options)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.openai) return
|
||||
evt.language = evt.sdk.responses(evt.model.api.id)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { Effect } from "effect"
|
||||
import { Integration } from "../../integration"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const OpencodePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("opencode"),
|
||||
effect: Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
export const OpencodePlugin = define({
|
||||
id: "opencode",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
let hasKey = false
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
const item = evt.provider.get(ProviderV2.ID.opencode)
|
||||
if (!item) return
|
||||
const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
|
||||
const integration = yield* ctx.integration.get(item.provider.id)
|
||||
hasKey = Boolean(
|
||||
process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey,
|
||||
)
|
||||
@@ -27,6 +25,6 @@ export const OpencodePlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const OpenRouterPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("openrouter"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const OpenRouterPlugin = define({
|
||||
id: "openrouter",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue
|
||||
@@ -24,11 +24,14 @@ export const OpenRouterPlugin = PluginV2.define({
|
||||
}
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@openrouter/ai-sdk-provider") return
|
||||
const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider"))
|
||||
evt.sdk = mod.createOpenRouter(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const PerplexityPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("perplexity"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const PerplexityPlugin = define({
|
||||
id: "perplexity",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/perplexity") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity"))
|
||||
evt.sdk = mod.createPerplexity(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Npm } from "../../npm"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const SapAICorePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("sap-ai-core"),
|
||||
effect: Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const SapAICorePlugin = define({
|
||||
id: "sap-ai-core",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
|
||||
const serviceKey =
|
||||
process.env.AICORE_SERVICE_KEY ??
|
||||
@@ -18,7 +17,7 @@ export const SapAICorePlugin = PluginV2.define({
|
||||
|
||||
const installedPath = evt.package.startsWith("file://")
|
||||
? evt.package
|
||||
: Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint)
|
||||
: (yield* ctx.npm.add(evt.package).pipe(Effect.orDie)).entrypoint
|
||||
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
|
||||
|
||||
const mod = yield* Effect.promise(async () => {
|
||||
@@ -35,10 +34,13 @@ export const SapAICorePlugin = PluginV2.define({
|
||||
: {},
|
||||
)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
|
||||
evt.language = evt.sdk(evt.model.api.id)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
|
||||
@@ -64,11 +64,12 @@ export function cortexFetch(upstream: FetchLike = fetch) {
|
||||
}
|
||||
}
|
||||
|
||||
export const SnowflakeCortexPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("snowflake-cortex"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const SnowflakeCortexPlugin = define({
|
||||
id: "snowflake-cortex",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return
|
||||
const token =
|
||||
process.env.SNOWFLAKE_CORTEX_TOKEN ??
|
||||
@@ -84,6 +85,6 @@ export const SnowflakeCortexPlugin = PluginV2.define({
|
||||
fetch: cortexFetch(upstream) as typeof fetch,
|
||||
} as any)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const TogetherAIPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("togetherai"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const TogetherAIPlugin = define({
|
||||
id: "togetherai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/togetherai") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai"))
|
||||
evt.sdk = mod.createTogetherAI(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const VenicePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("venice"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const VenicePlugin = define({
|
||||
id: "venice",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "venice-ai-sdk-provider") return
|
||||
const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider"))
|
||||
evt.sdk = mod.createVenice(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const VercelPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("vercel"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const VercelPlugin = define({
|
||||
id: "vercel",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/vercel") continue
|
||||
@@ -15,11 +15,14 @@ export const VercelPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/vercel") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/vercel"))
|
||||
evt.sdk = mod.createVercel(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const XAIPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("xai"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const XAIPlugin = define({
|
||||
id: "xai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/xai") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/xai"))
|
||||
evt.sdk = mod.createXai(evt.options)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("xai")) return
|
||||
evt.language = evt.sdk.responses(evt.model.api.id)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const ZenmuxPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("zenmux"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const ZenmuxPlugin = define({
|
||||
id: "zenmux",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
@@ -16,6 +16,6 @@ export const ZenmuxPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -2,22 +2,19 @@
|
||||
|
||||
export * as SkillPlugin from "./skill"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { SkillV2 } from "../skill"
|
||||
import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" }
|
||||
|
||||
export const CustomizeOpencodeContent = customizeOpencodeContent
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("skill"),
|
||||
effect: Effect.gen(function* () {
|
||||
const skill = yield* SkillV2.Service
|
||||
const transform = yield* skill.transform()
|
||||
|
||||
yield* transform((editor) => {
|
||||
editor.source(
|
||||
export const Plugin = define({
|
||||
id: "skill",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.skill.transform((draft) => {
|
||||
draft.source(
|
||||
new SkillV2.EmbeddedSource({
|
||||
type: "embedded",
|
||||
skill: new SkillV2.Info({
|
||||
|
||||
@@ -70,8 +70,8 @@ export const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const remote = Effect.fnUntraced(function* (repo: Git.Repo) {
|
||||
const origin = yield* git.remote(repo)
|
||||
const remote = Effect.fnUntraced(function* (repo: Git.Repository) {
|
||||
const origin = yield* git.remote.get(repo)
|
||||
if (!origin) return undefined
|
||||
const normalized = url(origin)
|
||||
if (!normalized) return undefined
|
||||
@@ -102,22 +102,22 @@ export const layer = Layer.effect(
|
||||
return `${host.toLowerCase()}/${pathname}`
|
||||
}
|
||||
|
||||
const root = Effect.fnUntraced(function* (repo: Git.Repo) {
|
||||
const root = (yield* git.roots(repo))[0]
|
||||
const root = Effect.fnUntraced(function* (repo: Git.Repository) {
|
||||
const root = (yield* git.history.rootCommits(repo))[0]
|
||||
return root ? ID.make(root) : undefined
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
|
||||
const repo = yield* git.find(input)
|
||||
const repo = yield* git.repo.discover(input)
|
||||
if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
|
||||
|
||||
const previous = yield* cached(repo.store)
|
||||
const previous = yield* cached(repo.commonDirectory)
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
||||
return {
|
||||
previous,
|
||||
id: id ?? ID.global,
|
||||
directory: repo.directory,
|
||||
vcs: { type: "git" as const, store: repo.store },
|
||||
directory: repo.worktree,
|
||||
vcs: { type: "git" as const, store: repo.commonDirectory },
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { Git } from "../git"
|
||||
@@ -8,28 +7,26 @@ export function makeGitWorktreeStrategy(input: {
|
||||
git: Git.Interface
|
||||
canonical: (directory: AbsolutePath) => Effect.Effect<AbsolutePath, DirectoryUnavailableError>
|
||||
}) {
|
||||
const repo = (sourceDirectory: AbsolutePath) =>
|
||||
({ directory: sourceDirectory, store: sourceDirectory }) satisfies Git.Repo
|
||||
|
||||
return {
|
||||
id: StrategyID.make("git_worktree"),
|
||||
create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) {
|
||||
yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory })
|
||||
const repository = yield* input.git.repo.discover(options.sourceDirectory)
|
||||
if (!repository) return yield* new DirectoryUnavailableError({ directory: options.sourceDirectory })
|
||||
yield* input.git.worktree.create({ repository, directory: options.directory })
|
||||
return { directory: yield* input.canonical(options.directory) }
|
||||
}),
|
||||
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) {
|
||||
const found = yield* input.git.find(options.directory)
|
||||
const found = yield* input.git.repo.discover(options.directory)
|
||||
if (!found) return yield* new DirectoryUnavailableError({ directory: options.directory })
|
||||
yield* input.git.worktreeRemove({ repo: found, directory: options.directory, force: options.force })
|
||||
yield* input.git.worktree.remove({ repository: found, directory: options.directory, force: options.force })
|
||||
}),
|
||||
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
|
||||
const found = yield* input.git.find(directory)
|
||||
const found = yield* input.git.repo.discover(directory)
|
||||
if (!found) return yield* new DirectoryUnavailableError({ directory })
|
||||
const core = path.basename(found.store) === ".git" ? path.dirname(found.store) : found.store
|
||||
const entries = yield* input.git.worktreeList(found)
|
||||
const entries = yield* input.git.worktree.list(found)
|
||||
return yield* Effect.forEach(entries, (entry) =>
|
||||
input.canonical(entry).pipe(
|
||||
Effect.map((directory) => ({ directory, type: entry === core ? "root" : "copy" }) as const),
|
||||
input.canonical(entry.directory).pipe(
|
||||
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "copy" }) as const),
|
||||
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
|
||||
|
||||
@@ -296,5 +296,11 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
export const locationLayer = Layer.merge(
|
||||
layer,
|
||||
Layer.effectDiscard(refreshAfterBoot).pipe(
|
||||
Layer.provide(layer),
|
||||
Layer.provide(PluginBoot.locationLayer),
|
||||
),
|
||||
)
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ProviderV2 from "./provider"
|
||||
|
||||
import { withStatics } from "./schema"
|
||||
import { Schema } from "effect"
|
||||
import { Schema, Types } from "effect"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("ProviderV2.ID"),
|
||||
@@ -37,6 +37,9 @@ export const Native = Schema.Struct({
|
||||
|
||||
export const Api = Schema.Union([AISDK, Native]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Api = typeof Api.Type
|
||||
export type MutableApi<T extends Api = Api> = T extends Api
|
||||
? Omit<Types.DeepMutable<T>, "settings"> & (undefined extends T["settings"] ? { settings?: any } : { settings: any })
|
||||
: never
|
||||
|
||||
export const Request = Schema.Struct({
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
@@ -66,3 +69,5 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & { api: MutableApi }
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Effect, Schema, Stream } from "effect"
|
||||
import { EventV2 } from "../event"
|
||||
import { ModelV2 } from "../model"
|
||||
import { SessionV2 } from "../session"
|
||||
import { MessageDecodeError } from "../session/error"
|
||||
import { SessionEvent } from "../session/event"
|
||||
import { SessionInput } from "../session/input"
|
||||
import { SessionMessage } from "../session/message"
|
||||
@@ -61,8 +60,6 @@ export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnav
|
||||
},
|
||||
) {}
|
||||
|
||||
export { MessageDecodeError }
|
||||
|
||||
export interface CreateInput {
|
||||
readonly id?: ID
|
||||
readonly agent?: Agent.ID
|
||||
@@ -112,8 +109,8 @@ export interface Interface {
|
||||
) => Effect.Effect<void, NotFoundError | ModelUnavailableError | VariantUnavailableError>
|
||||
/** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */
|
||||
readonly interrupt: (sessionID: ID) => Effect.Effect<void>
|
||||
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
|
||||
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError>
|
||||
readonly message: (input: MessageInput) => Effect.Effect<Message | undefined>
|
||||
readonly context: (sessionID: ID) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
|
||||
readonly context: (sessionID: ID) => Effect.Effect<Message[], NotFoundError>
|
||||
readonly events: (input: EventsInput) => Stream.Stream<Event, NotFoundError>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as Reference from "./reference"
|
||||
|
||||
import { Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { castDraft } from "immer"
|
||||
import { Context, Effect, Layer, Schema, Scope, Types } from "effect"
|
||||
import { Global } from "./global"
|
||||
import { EventV2 } from "./event"
|
||||
import { Repository } from "./repository"
|
||||
@@ -40,17 +39,16 @@ export class Info extends Schema.Class<Info>("Reference.Info")({
|
||||
}) {}
|
||||
|
||||
type Data = {
|
||||
sources: Map<string, Source>
|
||||
sources: Map<string, Types.DeepMutable<Source>>
|
||||
}
|
||||
|
||||
type Editor = {
|
||||
type Draft = {
|
||||
add(name: string, source: Source): void
|
||||
remove(name: string): void
|
||||
list(): readonly [string, Source][]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
@@ -64,18 +62,18 @@ export const layer = Layer.effect(
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const materialized = new Map<string, Info>()
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ sources: new Map() }),
|
||||
editor: (draft) => ({
|
||||
add: (name, source) => draft.sources.set(name, castDraft(source)),
|
||||
draft: (draft) => ({
|
||||
add: (name, source) => draft.sources.set(name, source as Types.DeepMutable<Source>),
|
||||
remove: (name) => draft.sources.delete(name),
|
||||
list: () => Array.from(draft.sources.entries()) as [string, Source][],
|
||||
}),
|
||||
finalize: (editor) =>
|
||||
finalize: (draft) =>
|
||||
Effect.gen(function* () {
|
||||
materialized.clear()
|
||||
const seen = new Map<string, string | undefined>()
|
||||
for (const [name, source] of editor.list()) {
|
||||
for (const [name, source] of draft.list()) {
|
||||
if (source.type === "local") {
|
||||
materialized.set(
|
||||
name,
|
||||
@@ -128,6 +126,7 @@ export const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
rebuild: state.rebuild,
|
||||
list: Effect.fn("Reference.list")(function* () {
|
||||
return Array.from(materialized.values())
|
||||
}),
|
||||
|
||||
@@ -66,4 +66,6 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(Layer.merge(PluginBoot.locationLayer, Reference.locationLayer)),
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FSUtil } from "./fs-util"
|
||||
import { Git } from "./git"
|
||||
import { Global } from "./global"
|
||||
import { Repository } from "./repository"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { EffectFlock } from "./util/effect-flock"
|
||||
|
||||
export type Result = {
|
||||
@@ -142,15 +143,15 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | E
|
||||
yield* cacheOperation(fs.ensureDir(path.dirname(localPath)), "ensure cache directory", localPath)
|
||||
|
||||
const exists = yield* fs.existsSafe(localPath)
|
||||
const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git"))
|
||||
const origin = hasGitDir ? yield* git.origin(localPath) : undefined
|
||||
const existing = yield* git.repo.discover(AbsolutePath.make(localPath))
|
||||
const origin = existing ? yield* git.remote.get(existing) : undefined
|
||||
const originReference = origin ? Repository.parse(origin) : undefined
|
||||
const reuse = hasGitDir && Boolean(originReference && Repository.same(originReference, cloneTarget))
|
||||
const reuse = Boolean(existing && originReference && Repository.same(originReference, cloneTarget))
|
||||
if (exists && !reuse) {
|
||||
yield* cacheOperation(fs.remove(localPath, { recursive: true }), "remove stale cache", localPath)
|
||||
}
|
||||
|
||||
const currentBranch = reuse ? yield* git.branch(localPath) : undefined
|
||||
const currentBranch = reuse && existing ? yield* git.history.branch(existing) : undefined
|
||||
const status = statusForRepository({
|
||||
reuse,
|
||||
refresh: input.refresh,
|
||||
@@ -158,86 +159,54 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | E
|
||||
})
|
||||
|
||||
if (status === "cloned") {
|
||||
const result = yield* git
|
||||
.clone({ remote: input.reference.remote, target: localPath, branch: input.branch })
|
||||
.pipe(
|
||||
Effect.mapError((error) => new CloneFailedError({ repository, message: errorMessage(error) })),
|
||||
)
|
||||
if (result.exitCode !== 0) {
|
||||
return yield* new CloneFailedError({
|
||||
repository,
|
||||
message: resultMessage(result, `Failed to clone ${repository}`),
|
||||
yield* git.repo
|
||||
.clone({
|
||||
remote: input.reference.remote,
|
||||
directory: AbsolutePath.make(localPath),
|
||||
branch: input.branch,
|
||||
})
|
||||
}
|
||||
.pipe(Effect.mapError((error) => new CloneFailedError({ repository, message: error.message })))
|
||||
}
|
||||
|
||||
if (status === "refreshed") {
|
||||
const fetch = yield* git
|
||||
.fetch(localPath)
|
||||
.pipe(
|
||||
Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
|
||||
)
|
||||
if (fetch.exitCode !== 0) {
|
||||
return yield* new FetchFailedError({
|
||||
repository,
|
||||
message: resultMessage(fetch, `Failed to refresh ${repository}`),
|
||||
})
|
||||
}
|
||||
if (!existing) return yield* new FetchFailedError({ repository, message: "Repository is unavailable" })
|
||||
yield* git.sync
|
||||
.fetchRemotes(existing)
|
||||
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
|
||||
|
||||
if (input.branch) {
|
||||
const requestedBranch = input.branch
|
||||
const fetchBranch = yield* git
|
||||
.fetchBranch(localPath, requestedBranch)
|
||||
.pipe(
|
||||
Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
|
||||
)
|
||||
if (fetchBranch.exitCode !== 0) {
|
||||
return yield* new FetchFailedError({
|
||||
repository,
|
||||
message: resultMessage(fetchBranch, `Failed to fetch ${requestedBranch}`),
|
||||
})
|
||||
}
|
||||
yield* git.sync
|
||||
.fetchBranch(existing, { branch: requestedBranch })
|
||||
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
|
||||
|
||||
const checkout = yield* git.checkout(localPath, requestedBranch).pipe(
|
||||
yield* git.sync.checkoutRemoteBranch(existing, { branch: requestedBranch }).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new CheckoutFailedError({
|
||||
repository,
|
||||
branch: requestedBranch,
|
||||
message: errorMessage(error),
|
||||
message: error.message,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (checkout.exitCode !== 0) {
|
||||
return yield* new CheckoutFailedError({
|
||||
repository,
|
||||
branch: requestedBranch,
|
||||
message: resultMessage(checkout, `Failed to checkout ${requestedBranch}`),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const reset = yield* git
|
||||
.reset(localPath, yield* resetTarget(git, localPath, input.branch))
|
||||
.pipe(
|
||||
Effect.mapError((error) => new ResetFailedError({ repository, message: errorMessage(error) })),
|
||||
)
|
||||
if (reset.exitCode !== 0) {
|
||||
return yield* new ResetFailedError({
|
||||
repository,
|
||||
message: resultMessage(reset, `Failed to reset ${repository}`),
|
||||
})
|
||||
}
|
||||
yield* git.sync
|
||||
.resetHard(existing, yield* resetTarget(git, existing, input.branch))
|
||||
.pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: error.message })))
|
||||
}
|
||||
|
||||
const checkout = yield* git.repo.discover(AbsolutePath.make(localPath))
|
||||
|
||||
return {
|
||||
repository,
|
||||
host: input.reference.host,
|
||||
remote: input.reference.remote,
|
||||
localPath,
|
||||
status,
|
||||
head: yield* git.head(localPath),
|
||||
branch: yield* git.branch(localPath),
|
||||
head: checkout ? yield* git.history.head(checkout) : undefined,
|
||||
branch: checkout ? yield* git.history.branch(checkout) : undefined,
|
||||
} satisfies Result
|
||||
}),
|
||||
`repository-cache:${localPath}`,
|
||||
@@ -275,17 +244,17 @@ function cacheOperation<A, E, R>(effect: Effect.Effect<A, E, R>, operation: stri
|
||||
)
|
||||
}
|
||||
|
||||
const resetTarget = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, requestedBranch?: string) {
|
||||
const resetTarget = Effect.fnUntraced(function* (
|
||||
git: Git.Interface,
|
||||
repository: Git.Repository,
|
||||
requestedBranch?: string,
|
||||
) {
|
||||
if (requestedBranch) return `origin/${requestedBranch}`
|
||||
const remoteHead = yield* git.remoteHead(cwd)
|
||||
if (remoteHead) return remoteHead
|
||||
const currentBranch = yield* git.branch(cwd)
|
||||
const remoteHead = yield* git.history.defaultRemoteBranch(repository)
|
||||
if (remoteHead) return `origin/${remoteHead}`
|
||||
const currentBranch = yield* git.history.branch(repository)
|
||||
if (currentBranch) return `origin/${currentBranch}`
|
||||
return "HEAD"
|
||||
})
|
||||
|
||||
function resultMessage(result: Git.Result, fallback: string) {
|
||||
return result.stderr.trim() || result.text.trim() || fallback
|
||||
}
|
||||
|
||||
export * as RepositoryCache from "./repository-cache"
|
||||
|
||||
@@ -26,9 +26,11 @@ import { SessionRunner } from "./session/runner/index"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { SessionExecution } from "./session/execution"
|
||||
import { logFailure } from "./session/logging"
|
||||
import { MessageDecodeError } from "./session/error"
|
||||
import { SessionEvent } from "./session/event"
|
||||
import { SessionInput } from "./session/input"
|
||||
import { File } from "./file"
|
||||
import { Snapshot } from "./snapshot"
|
||||
import { SessionRevert } from "./session/revert"
|
||||
|
||||
// get project -> project.locations
|
||||
//
|
||||
@@ -93,14 +95,17 @@ export class OperationUnavailableError extends Schema.TaggedErrorClass<Operation
|
||||
},
|
||||
) {}
|
||||
|
||||
export { ContextSnapshotDecodeError, MessageDecodeError } from "./session/error"
|
||||
export { ContextSnapshotDecodeError } from "./session/error"
|
||||
|
||||
export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictError>()("Session.PromptConflictError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
}) {}
|
||||
|
||||
export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError
|
||||
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
|
||||
export type Error = NotFoundError | OperationUnavailableError | PromptConflictError
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
|
||||
@@ -114,14 +119,14 @@ export interface Interface {
|
||||
id: SessionMessage.ID
|
||||
direction: "previous" | "next"
|
||||
}
|
||||
}) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
||||
}) => Effect.Effect<SessionMessage.Message[], NotFoundError>
|
||||
readonly message: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
}) => Effect.Effect<SessionMessage.Message | undefined>
|
||||
readonly context: (
|
||||
sessionID: SessionSchema.ID,
|
||||
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
||||
) => Effect.Effect<SessionMessage.Message[], NotFoundError>
|
||||
readonly events: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
after?: EventV2.Cursor
|
||||
@@ -157,18 +162,37 @@ export interface Interface {
|
||||
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
readonly revert: {
|
||||
readonly preview: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
}) => Effect.Effect<
|
||||
readonly File.Diff[],
|
||||
NotFoundError | MessageNotFoundError | Snapshot.Error
|
||||
>
|
||||
readonly commit: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
files?: boolean
|
||||
}) => Effect.Effect<void, NotFoundError | MessageNotFoundError | Snapshot.Error>
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
export const layer = Layer.unwrap(
|
||||
Effect.promise(() => import("./location-layer")).pipe(
|
||||
Effect.map(({ LocationServiceMap }) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const db = database.db
|
||||
const events = yield* EventV2.Service
|
||||
const projects = yield* ProjectV2.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const scope = yield* Effect.scope
|
||||
@@ -186,15 +210,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new MessageDecodeError({
|
||||
sessionID: SessionSchema.ID.make(row.session_id),
|
||||
messageID: SessionMessage.ID.make(row.id),
|
||||
}),
|
||||
),
|
||||
)
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
|
||||
|
||||
const result = Service.of({
|
||||
create: Effect.fn("V2Session.create")(function* (input) {
|
||||
@@ -419,18 +435,45 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
),
|
||||
),
|
||||
revert: {
|
||||
preview: Effect.fn("V2Session.revert.preview")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
return yield* SessionRevert.preview(input).pipe(
|
||||
Effect.provideService(Database.Service, database),
|
||||
Effect.provide(locations.get(session.location)),
|
||||
)
|
||||
}),
|
||||
commit: Effect.fn("V2Session.revert.commit")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
return yield* SessionRevert.commit(input).pipe(
|
||||
Effect.provideService(Database.Service, database),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provide(locations.get(session.location)),
|
||||
)
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
return result
|
||||
}),
|
||||
return result
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(SessionProjector.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.orDie,
|
||||
export const defaultLayer = Layer.unwrap(
|
||||
Effect.promise(() => import("./location-layer")).pipe(
|
||||
Effect.map(({ LocationServiceMap }) =>
|
||||
layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(SessionProjector.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.orDie,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
}) {}
|
||||
|
||||
export class ContextSnapshotDecodeError extends Schema.TaggedErrorClass<ContextSnapshotDecodeError>()(
|
||||
"Session.ContextSnapshotDecodeError",
|
||||
{
|
||||
|
||||
@@ -204,6 +204,7 @@ export namespace Step {
|
||||
}),
|
||||
}),
|
||||
snapshot: Schema.String.pipe(Schema.optional),
|
||||
files: RelativePath.pipe(Schema.Array, Schema.optional),
|
||||
},
|
||||
})
|
||||
export type Ended = typeof Ended.Type
|
||||
@@ -468,6 +469,16 @@ export namespace Compaction {
|
||||
export type Ended = typeof Ended.Type
|
||||
}
|
||||
|
||||
export const Reverted = EventV2.define({
|
||||
type: "session.next.reverted",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
},
|
||||
})
|
||||
export type Reverted = typeof Reverted.Type
|
||||
|
||||
const DurableDefinitions = [
|
||||
AgentSwitched,
|
||||
ModelSwitched,
|
||||
@@ -496,6 +507,7 @@ const DurableDefinitions = [
|
||||
Retried,
|
||||
Compaction.Started,
|
||||
Compaction.Ended,
|
||||
Reverted,
|
||||
] as const
|
||||
const EphemeralDefinitions = [Text.Delta, Tool.Input.Delta, Reasoning.Delta, Compaction.Delta] as const
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { and, asc, desc, eq, gt, gte, ne, or } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionContextEpochTable, SessionMessageTable } from "./sql"
|
||||
@@ -53,15 +52,7 @@ const messageRows = Effect.fnUntraced(function* (
|
||||
})
|
||||
|
||||
const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decode({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new MessageDecodeError({
|
||||
sessionID: SessionSchema.ID.make(row.session_id),
|
||||
messageID: SessionMessage.ID.make(row.id),
|
||||
}),
|
||||
),
|
||||
)
|
||||
decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
|
||||
|
||||
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
const [epoch, compaction] = yield* Effect.all(
|
||||
|
||||
@@ -214,7 +214,12 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
draft.finish = event.data.finish
|
||||
draft.cost = event.data.cost
|
||||
draft.tokens = event.data.tokens
|
||||
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, end: event.data.snapshot }
|
||||
if (event.data.snapshot || event.data.files)
|
||||
draft.snapshot = {
|
||||
...draft.snapshot,
|
||||
end: event.data.snapshot,
|
||||
files: event.data.files ? Array.from(event.data.files) : undefined,
|
||||
}
|
||||
})
|
||||
},
|
||||
"session.next.step.failed": (event) => {
|
||||
@@ -382,6 +387,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
}),
|
||||
)
|
||||
},
|
||||
"session.next.reverted": () => Effect.void,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ export class Assistant extends Schema.Class<Assistant>("Session.Message.Assistan
|
||||
snapshot: Schema.Struct({
|
||||
start: Schema.String.pipe(Schema.optional),
|
||||
end: Schema.String.pipe(Schema.optional),
|
||||
files: SessionEvent.Step.Ended.data.fields.files,
|
||||
}).pipe(Schema.optional),
|
||||
finish: Schema.String.pipe(Schema.optional),
|
||||
cost: Schema.Finite.pipe(Schema.optional),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionProjector from "./projector"
|
||||
|
||||
import { and, desc, eq, sql } from "drizzle-orm"
|
||||
import { and, desc, eq, gt, or, sql } from "drizzle-orm"
|
||||
import { DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
@@ -13,7 +13,7 @@ import { SessionMessageUpdater } from "./message-updater"
|
||||
import { SessionInput } from "./input"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
import { SessionContextEpoch } from "./context-epoch"
|
||||
import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
|
||||
import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql"
|
||||
import type { DeepMutable } from "../schema"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
@@ -444,6 +444,52 @@ export const layer = Layer.effectDiscard(
|
||||
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, seq)
|
||||
})
|
||||
})
|
||||
yield* events.project(SessionEvent.Reverted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const boundary = yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.sessionID),
|
||||
eq(SessionMessageTable.id, event.data.messageID),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!boundary) return yield* Effect.die(`Revert boundary message not found: ${event.data.messageID}`)
|
||||
yield* db
|
||||
.delete(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.sessionID),
|
||||
gt(SessionMessageTable.seq, boundary.seq),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.delete(SessionInputTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionInputTable.session_id, event.data.sessionID),
|
||||
or(
|
||||
gt(SessionInputTable.admitted_seq, boundary.seq),
|
||||
gt(SessionInputTable.promoted_seq, boundary.seq),
|
||||
),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* SessionContextEpoch.reset(db, event.data.sessionID)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
export * as SessionRevert from "./revert"
|
||||
|
||||
import { and, asc, eq, gt } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { RelativePath } from "../schema"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionMessageTable } from "./sql"
|
||||
|
||||
export class MessageNotFoundError extends Schema.TaggedErrorClass<MessageNotFoundError>()(
|
||||
"Session.MessageNotFoundError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
interface Input {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
}
|
||||
|
||||
const plan = Effect.fn("SessionRevert.plan")(function* (input: Input) {
|
||||
const db = (yield* Database.Service).db
|
||||
const boundary = yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.messageID)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!boundary) return yield* new MessageNotFoundError(input)
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, input.sessionID),
|
||||
eq(SessionMessageTable.type, "assistant"),
|
||||
gt(SessionMessageTable.seq, boundary.seq),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
const files = new Map<RelativePath, Snapshot.ID>()
|
||||
for (const row of rows) {
|
||||
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
|
||||
if (message.type !== "assistant" || !message.snapshot?.start) continue
|
||||
for (const file of message.snapshot.files ?? [])
|
||||
if (!files.has(file)) files.set(file, Snapshot.ID.make(message.snapshot.start))
|
||||
}
|
||||
return files
|
||||
})
|
||||
|
||||
export const preview = Effect.fn("SessionRevert.preview")(function* (input: Input) {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
return yield* snapshot.preview({ files: yield* plan(input) })
|
||||
})
|
||||
|
||||
export const commit = Effect.fn("SessionRevert.commit")(function* (input: Input & { readonly files?: boolean }) {
|
||||
const files = yield* plan(input)
|
||||
const snapshot = yield* Snapshot.Service
|
||||
if (input.files !== false) yield* snapshot.restore({ files })
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(SessionEvent.Reverted, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ export * as SessionRunner from "./index"
|
||||
import type { LLMError } from "@opencode-ai/llm"
|
||||
import { Context, Effect } from "effect"
|
||||
import { SessionSchema } from "../schema"
|
||||
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
|
||||
import type { ContextSnapshotDecodeError } from "../error"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import type { SystemContext } from "../../system-context/index"
|
||||
import type { SessionContextEpoch } from "../context-epoch"
|
||||
@@ -12,7 +12,6 @@ import type { ToolOutputStore } from "../../tool-output-store"
|
||||
export type RunError =
|
||||
| LLMError
|
||||
| SessionRunnerModel.Error
|
||||
| MessageDecodeError
|
||||
| ContextSnapshotDecodeError
|
||||
| SystemContext.InitializationBlocked
|
||||
| SessionContextEpoch.AgentReplacementBlocked
|
||||
|
||||
@@ -32,6 +32,8 @@ import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
import { type RunError, Service } from "./index"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import { Snapshot } from "../../snapshot"
|
||||
import { SystemContextBuiltIns } from "../../system-context/builtins"
|
||||
import { createLLMEventPublisher } from "./publish-llm-event"
|
||||
import { toLLMMessages } from "./to-llm-message"
|
||||
import { MAX_STEPS_PROMPT } from "./max-steps"
|
||||
@@ -59,7 +61,8 @@ import { MAX_STEPS_PROMPT } from "./max-steps"
|
||||
* - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output tool definitions.
|
||||
* - [x] Stream exactly one `llm.stream(request)` provider turn.
|
||||
* - [x] Persist assistant text and usage events incrementally as they arrive.
|
||||
* - [ ] Persist snapshots, patches, and retry notices incrementally as they arrive.
|
||||
* - [x] Persist snapshots and changed paths at settled step boundaries.
|
||||
* - [ ] Persist retry notices incrementally as they arrive.
|
||||
* - [x] Persist reasoning, provider errors, and tool-call events incrementally as they arrive.
|
||||
*
|
||||
* - Tool settlement and continuation
|
||||
@@ -100,6 +103,7 @@ export const layer = Layer.effect(
|
||||
const skillGuidance = yield* SkillGuidance.Service
|
||||
const referenceGuidance = yield* ReferenceGuidance.Service
|
||||
const config = yield* Config.Service
|
||||
const snapshots = yield* Snapshot.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries() })
|
||||
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
|
||||
@@ -229,6 +233,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
|
||||
return yield* Effect.die(rebuildPreparedTurn())
|
||||
const startSnapshot = yield* snapshots.capture()
|
||||
const publisher = createLLMEventPublisher(events, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
@@ -237,6 +242,7 @@ export const layer = Layer.effect(
|
||||
providerID: ProviderV2.ID.make(model.provider),
|
||||
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
|
||||
},
|
||||
snapshot: startSnapshot,
|
||||
})
|
||||
const withPublication = Semaphore.makeUnsafe(1).withPermit
|
||||
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
|
||||
@@ -333,6 +339,32 @@ export const layer = Layer.effect(
|
||||
const message = failure instanceof Error ? failure.message : String(failure)
|
||||
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
|
||||
}
|
||||
const stepSettlement = publisher.stepSettlement()
|
||||
if (stepSettlement && !publisher.hasProviderError()) {
|
||||
const endSnapshot = yield* snapshots.capture()
|
||||
const files =
|
||||
startSnapshot && endSnapshot
|
||||
? yield* snapshots
|
||||
.files({ from: startSnapshot, to: endSnapshot })
|
||||
.pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to list step snapshot files", { cause }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
yield* withPublication(
|
||||
events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: session.id,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
finish: stepSettlement.finish,
|
||||
cost: 0,
|
||||
tokens: stepSettlement.tokens,
|
||||
snapshot: endSnapshot,
|
||||
files,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (publisher.hasProviderError())
|
||||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
if (stream._tag === "Success" && !publisher.hasProviderError())
|
||||
@@ -406,3 +438,18 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(
|
||||
Layer.mergeAll(
|
||||
AgentV2.locationLayer,
|
||||
ToolRegistry.locationLayer,
|
||||
SessionRunnerModel.locationLayer,
|
||||
SystemContextBuiltIns.locationLayer,
|
||||
SkillGuidance.locationLayer,
|
||||
ReferenceGuidance.locationLayer,
|
||||
Config.locationLayer,
|
||||
Snapshot.locationLayer,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -33,11 +33,7 @@ export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiE
|
||||
},
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| Catalog.ProviderNotFoundError
|
||||
| Catalog.ModelNotFoundError
|
||||
| ModelNotSelectedError
|
||||
| UnsupportedApiError
|
||||
export type Error = ModelNotSelectedError | UnsupportedApiError
|
||||
|
||||
export interface Interface {
|
||||
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error>
|
||||
@@ -149,10 +145,12 @@ export const locationLayer = Layer.effect(
|
||||
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
||||
// Location plugins populate and filter the catalog asynchronously during layer startup.
|
||||
yield* boot.wait()
|
||||
const defaultModel = session.model ? undefined : yield* catalog.model.default()
|
||||
const selected = session.model
|
||||
? yield* catalog.model.get(session.model.providerID, session.model.id)
|
||||
: (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ??
|
||||
(yield* catalog.model.available()).find(supported))
|
||||
: defaultModel && supported(defaultModel)
|
||||
? defaultModel
|
||||
: (yield* catalog.model.available()).find(supported)
|
||||
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
|
||||
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
|
||||
return yield* fromCatalogModel(
|
||||
@@ -163,4 +161,4 @@ export const locationLayer = Layer.effect(
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
).pipe(Layer.provideMerge(Layer.merge(Catalog.locationLayer, PluginBoot.locationLayer)))
|
||||
|
||||
@@ -10,6 +10,7 @@ type Input = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: string
|
||||
readonly model: ModelV2.Ref
|
||||
readonly snapshot?: string
|
||||
}
|
||||
|
||||
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
|
||||
@@ -66,6 +67,12 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
const timestamp = DateTime.now
|
||||
let assistantMessageID: SessionMessage.ID | undefined
|
||||
let providerFailed = false
|
||||
let stepSettlement:
|
||||
| {
|
||||
readonly finish: string
|
||||
readonly tokens: ReturnType<typeof tokens>
|
||||
}
|
||||
| undefined
|
||||
|
||||
const startAssistant = Effect.fnUntraced(function* () {
|
||||
if (assistantMessageID !== undefined) return assistantMessageID
|
||||
@@ -74,6 +81,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
...input,
|
||||
assistantMessageID,
|
||||
timestamp: yield* timestamp,
|
||||
snapshot: input.snapshot,
|
||||
})
|
||||
return assistantMessageID
|
||||
})
|
||||
@@ -375,14 +383,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
}
|
||||
case "step-finish":
|
||||
yield* flush()
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
finish: event.reason,
|
||||
cost: 0,
|
||||
tokens: tokens(event.usage),
|
||||
})
|
||||
if (stepSettlement) return yield* Effect.die("Duplicate step finish")
|
||||
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
|
||||
return
|
||||
case "finish":
|
||||
return
|
||||
@@ -405,6 +407,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
failUnsettledTools,
|
||||
hasAssistantStarted: () => assistantMessageID !== undefined,
|
||||
hasProviderError: () => providerFailed,
|
||||
stepSettlement: () => stepSettlement,
|
||||
startAssistant,
|
||||
assistantMessageID: assistantMessageIDForTool,
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export const SessionTable = sqliteTable(
|
||||
summary_additions: integer(),
|
||||
summary_deletions: integer(),
|
||||
summary_files: integer(),
|
||||
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
|
||||
summary_diffs: text({ mode: "json" }).$type<Snapshot.LegacyFileDiff[]>(),
|
||||
metadata: text({ mode: "json" }).$type<Record<string, unknown>>(),
|
||||
cost: real().notNull().default(0),
|
||||
tokens_input: integer().notNull().default(0),
|
||||
|
||||
@@ -4,7 +4,6 @@ import { eq } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { SessionHistory } from "./history"
|
||||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionMessageTable, SessionTable } from "./sql"
|
||||
@@ -12,11 +11,11 @@ import { fromRow } from "./info"
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info | undefined>
|
||||
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
||||
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[]>
|
||||
readonly runnerContext: (
|
||||
sessionID: SessionSchema.ID,
|
||||
baselineSeq: number,
|
||||
) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
||||
) => Effect.Effect<SessionMessage.Message[]>
|
||||
readonly message: (
|
||||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined>
|
||||
|
||||
@@ -89,3 +89,4 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))
|
||||
export const locationLayer = layer
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
export * as SkillV2 from "./skill"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { castDraft } from "immer"
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { ConfigMarkdown } from "./config/markdown"
|
||||
import { FSUtil } from "./fs-util"
|
||||
@@ -65,16 +64,15 @@ const Frontmatter = Schema.Struct({
|
||||
const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter)
|
||||
|
||||
export type Data = {
|
||||
sources: Source[]
|
||||
sources: Types.DeepMutable<Source>[]
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
source: (source: Source) => void
|
||||
list: () => readonly Source[]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly sources: () => Effect.Effect<Source[]>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
@@ -87,12 +85,12 @@ export const layer = Layer.effect(
|
||||
const discovery = yield* SkillDiscovery.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ sources: [] }),
|
||||
editor: (draft) => ({
|
||||
draft: (draft) => ({
|
||||
source: (source) => {
|
||||
if (draft.sources.some((item) => Source.equals(item, source))) return
|
||||
draft.sources.push(castDraft(source))
|
||||
draft.sources.push(source as Types.DeepMutable<Source>)
|
||||
},
|
||||
list: () => draft.sources as Source[],
|
||||
}),
|
||||
@@ -150,6 +148,7 @@ export const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
rebuild: state.rebuild,
|
||||
sources: Effect.fn("SkillV2.sources")(function* () {
|
||||
return state.get().sources
|
||||
}),
|
||||
@@ -158,4 +157,4 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provide(SkillDiscovery.defaultLayer))
|
||||
export const locationLayer = layer.pipe(Layer.provideMerge(SkillDiscovery.layer))
|
||||
|
||||
@@ -73,4 +73,6 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(Layer.merge(PluginBoot.locationLayer, SkillV2.locationLayer)),
|
||||
)
|
||||
|
||||
@@ -1,9 +1,201 @@
|
||||
export namespace Snapshot {
|
||||
export type FileDiff = {
|
||||
file?: string
|
||||
patch?: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status?: "added" | "deleted" | "modified"
|
||||
}
|
||||
export * as Snapshot from "./snapshot"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Config } from "./config"
|
||||
import { File } from "./file"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Git } from "./git"
|
||||
import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { AbsolutePath, RelativePath } from "./schema"
|
||||
import { Hash } from "./util/hash"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export class Error extends Schema.TaggedErrorClass<Error>()("Snapshot.Error", {
|
||||
operation: Schema.Literals(["capture", "files", "diff", "preview", "restore"]),
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export interface CompareInput {
|
||||
readonly from: ID
|
||||
readonly to: ID
|
||||
}
|
||||
|
||||
export interface DiffInput extends CompareInput {
|
||||
readonly context?: number
|
||||
}
|
||||
|
||||
export interface RestoreInput {
|
||||
/** Paths are relative to the project root. */
|
||||
readonly files: ReadonlyMap<RelativePath, ID>
|
||||
}
|
||||
|
||||
export interface PreviewInput extends RestoreInput {
|
||||
readonly context?: number
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Capture the current Location-scoped state. */
|
||||
readonly capture: () => Effect.Effect<ID | undefined>
|
||||
readonly files: (input: CompareInput) => Effect.Effect<readonly RelativePath[], Error>
|
||||
readonly diff: (input: DiffInput) => Effect.Effect<readonly File.Diff[], Error>
|
||||
readonly preview: (input: PreviewInput) => Effect.Effect<readonly File.Diff[], Error>
|
||||
readonly restore: (input: RestoreInput) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Snapshot") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const source = yield* git.repo.discover(location.project.directory)
|
||||
const worktree = source
|
||||
? AbsolutePath.make(yield* fs.realPath(source.worktree).pipe(Effect.orDie))
|
||||
: location.project.directory
|
||||
const gitDirectory = AbsolutePath.make(path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree)))
|
||||
|
||||
const scope = Effect.fnUntraced(function* () {
|
||||
const relative = path.relative(worktree, location.directory)
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative))
|
||||
return yield* new Error({ operation: "capture", message: "Location is outside the project" })
|
||||
return RelativePath.make(relative.replaceAll("\\", "/") || ".")
|
||||
})
|
||||
|
||||
const repository = Effect.fnUntraced(function* () {
|
||||
if (!source) return yield* new Error({ operation: "capture", message: "Project is not a Git repository" })
|
||||
if (yield* fs.existsSafe(path.join(gitDirectory, "HEAD")))
|
||||
return new Git.Repository({
|
||||
worktree,
|
||||
gitDirectory,
|
||||
commonDirectory: gitDirectory,
|
||||
})
|
||||
return yield* git.repo.create({
|
||||
worktree,
|
||||
gitDirectory,
|
||||
seed: source,
|
||||
}).pipe(Effect.mapError((cause) => failure("capture", cause)))
|
||||
})
|
||||
|
||||
const enabled = Effect.fnUntraced(function* () {
|
||||
if (location.vcs?.type !== "git") return false
|
||||
return Config.latest(yield* config.entries(), "snapshots") !== false
|
||||
})
|
||||
|
||||
const capture = Effect.fn("Snapshot.capture")(function* () {
|
||||
if (!(yield* enabled())) return undefined
|
||||
return yield* Effect.gen(function* () {
|
||||
const repo = yield* repository()
|
||||
return ID.make(
|
||||
yield* git.tree.capture({
|
||||
repository: repo,
|
||||
scopes: [yield* scope()],
|
||||
ignores: source,
|
||||
maximumUntrackedFileBytes: 2 * 1024 * 1024,
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to capture snapshot", { cause }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const repo = yield* repository().pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
return { repository: repo, from: Git.TreeID.make(input.from), to: Git.TreeID.make(input.to) }
|
||||
})
|
||||
|
||||
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
|
||||
return yield* git.tree
|
||||
.files(yield* compare("files", input))
|
||||
.pipe(Effect.mapError((cause) => failure("files", cause)))
|
||||
})
|
||||
|
||||
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
|
||||
return yield* git.tree
|
||||
.diff({ ...(yield* compare("diff", input)), context: input.context })
|
||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||
})
|
||||
|
||||
const plan = Effect.fnUntraced(function* (operation: "preview" | "restore", input: RestoreInput) {
|
||||
const files = new Map<RelativePath, Git.TreeID>()
|
||||
for (const [file, snapshot] of input.files) {
|
||||
const absolute = path.resolve(worktree, file)
|
||||
if (!FSUtil.contains(worktree, absolute))
|
||||
return yield* new Error({ operation, message: `Path escapes the project: ${file}` })
|
||||
files.set(file, Git.TreeID.make(snapshot))
|
||||
}
|
||||
return files
|
||||
})
|
||||
|
||||
const preview = Effect.fn("Snapshot.preview")(function* (input: PreviewInput) {
|
||||
if (!(yield* enabled())) return yield* new Error({ operation: "preview", message: "Snapshots are disabled" })
|
||||
const repo = yield* repository().pipe(Effect.mapError((cause) => failure("preview", cause)))
|
||||
const files = yield* plan("preview", input)
|
||||
const current = yield* git.tree.capture({
|
||||
repository: repo,
|
||||
scopes: Array.from(files.keys()),
|
||||
ignores: source,
|
||||
maximumUntrackedFileBytes: 2 * 1024 * 1024,
|
||||
}).pipe(Effect.mapError((cause) => failure("preview", cause)))
|
||||
return yield* git.tree
|
||||
.preview({
|
||||
repository: repo,
|
||||
current,
|
||||
files,
|
||||
context: input.context,
|
||||
})
|
||||
.pipe(Effect.mapError((cause) => failure("preview", cause)))
|
||||
})
|
||||
|
||||
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
|
||||
if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
|
||||
const repo = yield* repository().pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||
yield* git.tree
|
||||
.restore({ repository: repo, files: yield* plan("restore", input) })
|
||||
.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||
})
|
||||
|
||||
return Service.of({ capture, files, diff, preview, restore })
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer))
|
||||
|
||||
export const noopLayer = Layer.succeed(
|
||||
Service,
|
||||
Service.of({
|
||||
capture: () => Effect.succeed(undefined),
|
||||
files: () => Effect.succeed([]),
|
||||
diff: () => Effect.succeed([]),
|
||||
preview: () => Effect.succeed([]),
|
||||
restore: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
|
||||
function failure(operation: Error["operation"], cause: unknown) {
|
||||
if (cause instanceof Error && cause.operation === operation) return cause
|
||||
return new Error({
|
||||
operation,
|
||||
message: cause instanceof globalThis.Error ? cause.message : String(cause),
|
||||
cause,
|
||||
})
|
||||
}
|
||||
|
||||
/** Legacy persisted session diff shape. */
|
||||
export type LegacyFileDiff = {
|
||||
file?: string
|
||||
patch?: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status?: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
+88
-72
@@ -1,112 +1,128 @@
|
||||
export * as State from "./state"
|
||||
|
||||
import { Effect, Scope, Semaphore } from "effect"
|
||||
import type { Draft, Objectish } from "immer"
|
||||
import { Context, Effect, Scope, Semaphore } from "effect"
|
||||
|
||||
/**
|
||||
* A replayable transform applied to an editor during rebuild.
|
||||
* A replayable transform applied to a draft during rebuild.
|
||||
*
|
||||
* Transforms are intentionally synchronous and mutation-shaped: domain editors
|
||||
* hide the draft representation while preserving concise plugin/config code.
|
||||
* Domain drafts expose readable and writable state while preserving concise
|
||||
* plugin/config code. Transforms may perform Effects before returning.
|
||||
*/
|
||||
export type Transform<Editor> = (editor: Editor) => void
|
||||
export type MakeEditor<State extends Objectish, Editor> = (draft: Draft<State>) => Editor
|
||||
type TransformCallback<DraftApi> = (draft: DraftApi) => Effect.Effect<void> | void
|
||||
export type MakeDraft<State, DraftApi> = (state: State) => DraftApi
|
||||
|
||||
export interface Options<State extends Objectish, Editor> {
|
||||
export interface Registration {
|
||||
readonly dispose: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export type Transform<DraftApi> = (
|
||||
transform: TransformCallback<DraftApi>,
|
||||
) => Effect.Effect<Registration, never, Scope.Scope>
|
||||
|
||||
export type Rebuild = () => Effect.Effect<void>
|
||||
|
||||
export interface Transformable<DraftApi> {
|
||||
readonly transform: Transform<DraftApi>
|
||||
readonly rebuild: Rebuild
|
||||
}
|
||||
|
||||
const CurrentBatch = Context.Reference<Set<Rebuild> | undefined>("@opencode/State/CurrentBatch", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
|
||||
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
return Effect.gen(function* () {
|
||||
const current = yield* CurrentBatch
|
||||
if (current) return yield* effect
|
||||
const rebuilds = new Set<Rebuild>()
|
||||
const result = yield* effect.pipe(Effect.provideService(CurrentBatch, rebuilds))
|
||||
yield* Effect.forEach(rebuilds, (rebuild) => rebuild(), { discard: true })
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
export interface Options<State, DraftApi> {
|
||||
/** Creates the base value for initial state and every scoped-transform rebuild. */
|
||||
readonly initial: () => State
|
||||
/** Wraps the mutable draft in a domain-specific editor. */
|
||||
readonly editor: MakeEditor<State, Editor>
|
||||
/**
|
||||
* Completes every committed edit.
|
||||
*
|
||||
* For rebuilds, this runs after all active transforms have been replayed and
|
||||
* before the rebuilt state becomes visible. For direct updates, this runs
|
||||
* after the current state has already been edited. The optional reason is
|
||||
* caller-defined metadata for exceptional update origins.
|
||||
*/
|
||||
readonly finalize?: (editor: Editor, reason?: string) => Effect.Effect<void>
|
||||
/** Wraps mutable state in a domain-specific draft API. */
|
||||
readonly draft: MakeDraft<State, DraftApi>
|
||||
/** Runs after all active transforms and before the rebuilt state becomes visible. */
|
||||
readonly finalize?: (draft: DraftApi) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface Interface<State extends Objectish, Editor> {
|
||||
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
|
||||
readonly get: () => State
|
||||
/**
|
||||
* Registers a scoped transform slot and returns the slot updater.
|
||||
*
|
||||
* Acquiring the slot has no visible effect until the returned updater is
|
||||
* called. Each updater call replaces that slot's transform, then rebuilds the
|
||||
* materialized state from `initial()` by replaying all active transforms in
|
||||
* registration order. Closing the owning Scope removes the slot and rebuilds.
|
||||
* Registers and applies a scoped transform. Closing the owning Scope removes
|
||||
* the transform and rebuilds the materialized state.
|
||||
*/
|
||||
readonly transform: () => Effect.Effect<(transform: Transform<Editor>) => Effect.Effect<void>, never, Scope.Scope>
|
||||
/** Registers and applies a replayable transform in the current Scope. */
|
||||
readonly update: (update: Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
|
||||
/**
|
||||
* Mutates the current materialized state directly, once.
|
||||
*
|
||||
* This is not replayable transform state: a later rebuild starts again
|
||||
* from `initial()` plus active transforms, so direct edits must be reserved
|
||||
* for current-state adjustments that are intentionally outside the transform
|
||||
* fold.
|
||||
*/
|
||||
readonly mutate: (update: (editor: Editor) => Effect.Effect<void>, reason?: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export function create<State extends Objectish, Editor>(options: Options<State, Editor>): Interface<State, Editor> {
|
||||
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
|
||||
let state = options.initial()
|
||||
let transforms: { update: Transform<Editor> }[] = []
|
||||
let transforms: { run: TransformCallback<DraftApi> }[] = []
|
||||
const semaphore = Semaphore.makeUnsafe(1)
|
||||
|
||||
const commit = Effect.fn("State.commit")(function* (next: State, reason?: string) {
|
||||
const api = options.editor(next as Draft<State>)
|
||||
if (options.finalize) yield* options.finalize(api, reason)
|
||||
const commit = Effect.fn("State.commit")(function* (next: State) {
|
||||
const api = options.draft(next)
|
||||
if (options.finalize) yield* options.finalize(api)
|
||||
state = next
|
||||
})
|
||||
|
||||
const rebuild = Effect.fnUntraced(function* () {
|
||||
const apply = (transform: TransformCallback<DraftApi>, draft: DraftApi) =>
|
||||
Effect.suspend(() => {
|
||||
const result = transform(draft)
|
||||
return Effect.isEffect(result) ? Effect.asVoid(result).pipe(Effect.orDie) : Effect.void
|
||||
})
|
||||
|
||||
const materialize = Effect.fnUntraced(function* () {
|
||||
const next = options.initial()
|
||||
const api = options.editor(next as Draft<State>)
|
||||
for (const transform of transforms)
|
||||
yield* Effect.sync(() => transform.update(api)).pipe(Effect.withSpan("State.rebuild.update", {}))
|
||||
const api = options.draft(next)
|
||||
for (const transform of transforms) yield* apply(transform.run, api).pipe(Effect.withSpan("State.rebuild.update"))
|
||||
yield* commit(next)
|
||||
})
|
||||
|
||||
const result: Interface<State, Editor> = {
|
||||
const rebuild = () => semaphore.withPermit(materialize())
|
||||
|
||||
const result: Interface<State, DraftApi> = {
|
||||
get: () => state,
|
||||
transform: Effect.fn("State.transform")(function* () {
|
||||
transform: Effect.fn("State.transform")(function* (update) {
|
||||
const scope = yield* Scope.Scope
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const transform = { update: (_editor: Editor) => {} }
|
||||
transforms = [...transforms, transform]
|
||||
yield* Scope.addFinalizer(
|
||||
scope,
|
||||
const transform = { run: update }
|
||||
let active = true
|
||||
const dispose = Effect.uninterruptible(
|
||||
semaphore.withPermit(
|
||||
Effect.sync(() => {
|
||||
Effect.suspend(() => {
|
||||
if (!active) return Effect.void
|
||||
active = false
|
||||
transforms = transforms.filter((item) => item !== transform)
|
||||
}).pipe(Effect.andThen(rebuild())),
|
||||
return Effect.gen(function* () {
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch) {
|
||||
batch.add(rebuild)
|
||||
return
|
||||
}
|
||||
yield* materialize()
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
return (update: Transform<Editor>) =>
|
||||
Effect.uninterruptible(
|
||||
semaphore.withPermit(
|
||||
Effect.sync(() => {
|
||||
transform.update = update
|
||||
}).pipe(Effect.andThen(rebuild())),
|
||||
),
|
||||
)
|
||||
yield* semaphore.withPermit(
|
||||
Effect.sync(() => {
|
||||
transforms = [...transforms, transform]
|
||||
}),
|
||||
)
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch) batch.add(rebuild)
|
||||
else yield* rebuild()
|
||||
return { dispose }
|
||||
}),
|
||||
)
|
||||
}),
|
||||
update: Effect.fn("State.update")(function* (update) {
|
||||
const transform = yield* result.transform()
|
||||
yield* transform(update)
|
||||
}),
|
||||
mutate: Effect.fn("State.mutate")(function* (update, reason) {
|
||||
const api = options.editor(state as Draft<State>)
|
||||
yield* update(api)
|
||||
if (options.finalize) yield* options.finalize(api, reason)
|
||||
}, semaphore.withPermit),
|
||||
rebuild,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -187,6 +187,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.defaultLayer))
|
||||
export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer))
|
||||
|
||||
/** Runs retention scanning once globally rather than once per active Location. */
|
||||
export const cleanupLayer = Layer.effectDiscard(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as ApplicationTools from "./application-tools"
|
||||
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { enableMapSet } from "immer"
|
||||
import { State } from "../state"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
@@ -9,7 +8,7 @@ type Data = {
|
||||
readonly entries: Map<string, Entry>
|
||||
}
|
||||
|
||||
type Editor = {
|
||||
type Draft = {
|
||||
readonly set: (name: string, entry: Entry) => void
|
||||
}
|
||||
|
||||
@@ -27,14 +26,12 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ApplicationTools") {}
|
||||
|
||||
enableMapSet()
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ entries: new Map() }),
|
||||
editor: (draft) => ({
|
||||
draft: (draft) => ({
|
||||
set: (name, tool) => {
|
||||
draft.entries.set(name, tool)
|
||||
},
|
||||
@@ -47,9 +44,8 @@ export const layer = Layer.effect(
|
||||
if (entries.length === 0) return
|
||||
yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true })
|
||||
const registrations = entries.map(([name, tool]) => [name, { identity: {}, tool }] as const)
|
||||
const transform = yield* state.transform()
|
||||
yield* transform((editor) => {
|
||||
for (const [name, entry] of registrations) editor.set(name, entry)
|
||||
yield* state.transform((draft) => {
|
||||
for (const [name, entry] of registrations) draft.set(name, entry)
|
||||
})
|
||||
}),
|
||||
entries: () => state.get().entries,
|
||||
|
||||
@@ -14,6 +14,16 @@ import { TodoWriteTool } from "./todowrite"
|
||||
import { WebFetchTool } from "./webfetch"
|
||||
import { WebSearchTool } from "./websearch"
|
||||
import { WriteTool } from "./write"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Config } from "../config"
|
||||
import { PluginBoot } from "../plugin/boot"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { QuestionV2 } from "../question"
|
||||
import { SessionTodo } from "../session/todo"
|
||||
import { Image } from "../image"
|
||||
|
||||
/**
|
||||
* Composes only the shipped Location-scoped built-in tool transforms.
|
||||
@@ -41,4 +51,19 @@ export const locationLayer = Layer.mergeAll(
|
||||
WebFetchTool.layer,
|
||||
WebSearchTool.layer.pipe(Layer.provide(WebSearchTool.defaultConfigLayer)),
|
||||
WriteTool.layer,
|
||||
).pipe(
|
||||
Layer.provideMerge(
|
||||
Layer.mergeAll(
|
||||
ToolRegistry.locationLayer,
|
||||
PermissionV2.locationLayer,
|
||||
Config.locationLayer,
|
||||
PluginBoot.locationLayer,
|
||||
SkillV2.locationLayer,
|
||||
LocationMutation.locationLayer,
|
||||
FileMutation.locationLayer,
|
||||
QuestionV2.locationLayer,
|
||||
SessionTodo.locationLayer,
|
||||
Image.locationLayer,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -137,3 +137,5 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(ApplicationTools.layer),
|
||||
Layer.provide(ToolOutputStore.defaultLayer),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provideMerge(ToolOutputStore.locationLayer))
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { agentHost, host } from "./plugin/host"
|
||||
|
||||
const it = testEffect(AgentV2.locationLayer)
|
||||
|
||||
@@ -23,9 +24,7 @@ describe("AgentV2", () => {
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
const id = AgentV2.ID.make("reviewer")
|
||||
const transform = yield* agent.transform()
|
||||
|
||||
yield* transform((editor) =>
|
||||
yield* agent.transform((editor) =>
|
||||
editor.update(id, (info) => {
|
||||
info.description = "Reviews code"
|
||||
info.mode = "subagent"
|
||||
@@ -41,19 +40,17 @@ describe("AgentV2", () => {
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
const id = AgentV2.ID.make("reviewer")
|
||||
const transform = yield* agent.transform()
|
||||
|
||||
yield* transform((editor) =>
|
||||
let description = "Old description"
|
||||
let hidden = true
|
||||
yield* agent.transform((editor) =>
|
||||
editor.update(id, (info) => {
|
||||
info.description = "Old description"
|
||||
info.hidden = true
|
||||
}),
|
||||
)
|
||||
yield* transform((editor) =>
|
||||
editor.update(id, (info) => {
|
||||
info.description = "New description"
|
||||
info.description = description
|
||||
info.hidden = hidden
|
||||
}),
|
||||
)
|
||||
description = "New description"
|
||||
hidden = false
|
||||
yield* agent.rebuild()
|
||||
|
||||
expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false })
|
||||
}),
|
||||
@@ -64,9 +61,7 @@ describe("AgentV2", () => {
|
||||
const agent = yield* AgentV2.Service
|
||||
const id = AgentV2.ID.make("scoped")
|
||||
const scope = yield* Scope.make()
|
||||
const transform = yield* agent.transform().pipe(Scope.provide(scope))
|
||||
|
||||
yield* transform((editor) => editor.update(id, () => {}))
|
||||
yield* agent.transform((editor) => editor.update(id, () => {})).pipe(Scope.provide(scope))
|
||||
expect(yield* agent.get(id)).toBeDefined()
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
@@ -79,7 +74,7 @@ describe("AgentV2", () => {
|
||||
const agent = yield* AgentV2.Service
|
||||
const id = AgentV2.ID.make("build")
|
||||
|
||||
yield* agent.update((editor) =>
|
||||
yield* agent.transform((editor) =>
|
||||
editor.update(id, (info) => {
|
||||
info.mode = "primary"
|
||||
info.hidden = true
|
||||
@@ -95,10 +90,10 @@ describe("AgentV2", () => {
|
||||
const agent = yield* AgentV2.Service
|
||||
const id = AgentV2.ID.make("custom")
|
||||
|
||||
yield* agent.update((editor) => editor.update(id, () => {}))
|
||||
yield* agent.transform((editor) => editor.update(id, () => {}))
|
||||
expect(yield* agent.get(id)).toEqual(AgentV2.Info.empty(id))
|
||||
|
||||
yield* agent.update((editor) => editor.remove(id))
|
||||
yield* agent.transform((editor) => editor.remove(id))
|
||||
expect(yield* agent.get(id)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
@@ -106,11 +101,11 @@ describe("AgentV2", () => {
|
||||
it.effect("does not ambiently opt built-in agents into bash", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
yield* AgentPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
|
||||
),
|
||||
yield* AgentPlugin.Plugin.effect(
|
||||
host({
|
||||
agent: agentHost(agent),
|
||||
location: location({ directory: AbsolutePath.make("/project") }),
|
||||
}),
|
||||
)
|
||||
|
||||
const agents = yield* agent.all()
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect"
|
||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { required } from "./plugin/provider-helper"
|
||||
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -41,7 +40,7 @@ describe("CatalogV2", () => {
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* (yield* catalog.transform())((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||
|
||||
expect((yield* Fiber.join(updated)).length).toBe(1)
|
||||
}),
|
||||
@@ -76,14 +75,13 @@ describe("CatalogV2", () => {
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
|
||||
active = second
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
|
||||
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
|
||||
}).pipe(Effect.provide(layer))
|
||||
})
|
||||
|
||||
@@ -99,13 +97,13 @@ describe("CatalogV2", () => {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
yield* integrations.update((editor) =>
|
||||
yield* integrations.transform((editor) =>
|
||||
editor.method.update({
|
||||
integrationID: Integration.ID.make(providerID),
|
||||
method: { type: "env", names: ["CATALOG_TEST_API_KEY"] },
|
||||
}),
|
||||
)
|
||||
yield* (yield* catalog.transform())((editor) => editor.provider.update(providerID, () => {}))
|
||||
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
|
||||
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
|
||||
}),
|
||||
@@ -121,9 +119,7 @@ describe("CatalogV2", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* transform((catalog) =>
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
@@ -134,7 +130,7 @@ describe("CatalogV2", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect((yield* catalog.provider.get(providerID)).api).toEqual({
|
||||
expect(required(yield* catalog.provider.get(providerID)).api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://override.example.com",
|
||||
@@ -147,9 +143,7 @@ describe("CatalogV2", () => {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const modelID = ModelV2.ID.make("model")
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* transform((catalog) => {
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
@@ -168,7 +162,7 @@ describe("CatalogV2", () => {
|
||||
})
|
||||
})
|
||||
|
||||
expect((yield* catalog.model.get(providerID, modelID)).api).toEqual({
|
||||
expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({
|
||||
id: modelID,
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
@@ -183,9 +177,7 @@ describe("CatalogV2", () => {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const modelID = ModelV2.ID.make("model")
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* transform((catalog) => {
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
@@ -196,7 +188,7 @@ describe("CatalogV2", () => {
|
||||
catalog.model.update(providerID, modelID, () => {})
|
||||
})
|
||||
|
||||
expect((yield* catalog.model.get(providerID, modelID)).api).toEqual({
|
||||
expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({
|
||||
id: modelID,
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
@@ -205,106 +197,12 @@ describe("CatalogV2", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs catalog transform hooks after baseURL is normalized", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const seen: unknown[] = []
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* plugin.add({
|
||||
id: PluginV2.ID.make("test"),
|
||||
effect: Effect.succeed({
|
||||
"catalog.transform": (evt) =>
|
||||
Effect.sync(() => {
|
||||
const item = evt.provider.get(providerID)
|
||||
if (!item) return
|
||||
seen.push(item.provider.api.type)
|
||||
if (item?.provider.api.type === "aisdk") seen.push(item.provider.api.url)
|
||||
seen.push(item?.provider.request.body.baseURL)
|
||||
}),
|
||||
}),
|
||||
})
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
|
||||
provider.request.body.baseURL = "https://provider.example.com"
|
||||
}),
|
||||
)
|
||||
|
||||
expect(seen).toEqual(["aisdk", "https://provider.example.com", undefined])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs catalog transform when a plugin is added", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.name = "Before"
|
||||
}),
|
||||
)
|
||||
yield* plugin.add({
|
||||
id: PluginV2.ID.make("test-transform"),
|
||||
effect: Effect.succeed({
|
||||
"catalog.transform": (evt) =>
|
||||
Effect.sync(() =>
|
||||
evt.provider.update(providerID, (provider) => {
|
||||
provider.name = "After"
|
||||
}),
|
||||
),
|
||||
}),
|
||||
})
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect((yield* catalog.provider.get(providerID)).name).toBe("After")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores plugin additions from another location", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
let invoked = 0
|
||||
|
||||
yield* plugin.add({
|
||||
id: PluginV2.ID.make("test-transform"),
|
||||
effect: Effect.succeed({
|
||||
"catalog.transform": () => Effect.sync(() => invoked++),
|
||||
}),
|
||||
})
|
||||
yield* Effect.yieldNow
|
||||
expect(invoked).toBe(1)
|
||||
|
||||
yield* events.publish(
|
||||
PluginV2.Event.Added,
|
||||
{ id: PluginV2.ID.make("test-transform") },
|
||||
{
|
||||
location: new Location.Info({
|
||||
directory: AbsolutePath.make("other"),
|
||||
project: { id: Project.ID.global, directory: AbsolutePath.make("other") },
|
||||
}),
|
||||
},
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(invoked).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves provider and model request merges", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const modelID = ModelV2.ID.make("model")
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* transform((catalog) => {
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.request.headers.provider = "provider"
|
||||
provider.request.headers.shared = "provider"
|
||||
@@ -321,7 +219,7 @@ describe("CatalogV2", () => {
|
||||
})
|
||||
})
|
||||
|
||||
const model = yield* catalog.model.get(providerID, modelID)
|
||||
const model = required(yield* catalog.model.get(providerID, modelID))
|
||||
expect(model.request.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
|
||||
expect(model.request.body).toEqual({ provider: true, model: true, request: true })
|
||||
expect(model.request.options).toEqual({ shared: "model", model: true })
|
||||
@@ -332,19 +230,17 @@ describe("CatalogV2", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* transform((catalog) => {
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(providerID, () => {})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
|
||||
model.time.released = DateTime.makeUnsafe(1000)
|
||||
model.time.released = 1000
|
||||
})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => {
|
||||
model.time.released = DateTime.makeUnsafe(2000)
|
||||
model.time.released = 2000
|
||||
})
|
||||
})
|
||||
|
||||
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toMatch("new")
|
||||
expect((yield* catalog.model.default())?.id).toMatch("new")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -354,26 +250,26 @@ describe("CatalogV2", () => {
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const old = ModelV2.ID.make("old")
|
||||
const newest = ModelV2.ID.make("new")
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
const models = (catalog: Catalog.Editor) => {
|
||||
const models = (catalog: Catalog.Draft) => {
|
||||
catalog.provider.update(providerID, () => {})
|
||||
catalog.model.update(providerID, old, (model) => {
|
||||
model.time.released = DateTime.makeUnsafe(1000)
|
||||
model.time.released = 1000
|
||||
})
|
||||
catalog.model.update(providerID, newest, (model) => {
|
||||
model.time.released = DateTime.makeUnsafe(2000)
|
||||
model.time.released = 2000
|
||||
})
|
||||
}
|
||||
|
||||
yield* transform((catalog) => {
|
||||
let configured = true
|
||||
yield* catalog.transform((catalog) => {
|
||||
models(catalog)
|
||||
catalog.model.default.set(providerID, old)
|
||||
if (configured) catalog.model.default.set(providerID, old)
|
||||
})
|
||||
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(old)
|
||||
expect((yield* catalog.model.default())?.id).toBe(old)
|
||||
|
||||
yield* transform(models)
|
||||
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(newest)
|
||||
configured = false
|
||||
yield* catalog.rebuild()
|
||||
expect((yield* catalog.model.default())?.id).toBe(newest)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -384,9 +280,7 @@ describe("CatalogV2", () => {
|
||||
const enabledProvider = ProviderV2.ID.make("enabled")
|
||||
const disabledModel = ModelV2.ID.make("configured")
|
||||
const fallbackModel = ModelV2.ID.make("fallback")
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* transform((catalog) => {
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(disabledProvider, (provider) => {
|
||||
provider.disabled = true
|
||||
})
|
||||
@@ -396,7 +290,7 @@ describe("CatalogV2", () => {
|
||||
catalog.model.default.set(disabledProvider, disabledModel)
|
||||
})
|
||||
|
||||
expect(Option.getOrUndefined(yield* catalog.model.default())).toMatchObject({
|
||||
expect(yield* catalog.model.default()).toMatchObject({
|
||||
providerID: enabledProvider,
|
||||
id: fallbackModel,
|
||||
})
|
||||
@@ -407,25 +301,23 @@ describe("CatalogV2", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* transform((catalog) => {
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(providerID, () => {})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
|
||||
model.capabilities.input = ["text"]
|
||||
model.capabilities.output = ["text"]
|
||||
model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
|
||||
model.time.released = DateTime.makeUnsafe(Date.now())
|
||||
model.time.released = Date.now()
|
||||
})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
|
||||
model.capabilities.input = ["text"]
|
||||
model.capabilities.output = ["text"]
|
||||
model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
|
||||
model.time.released = DateTime.makeUnsafe(Date.now())
|
||||
model.time.released = Date.now()
|
||||
})
|
||||
})
|
||||
|
||||
expect(Option.getOrUndefined(yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
|
||||
expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -434,17 +326,15 @@ describe("CatalogV2", () => {
|
||||
const catalog = yield* Catalog.Service
|
||||
const policy = yield* Policy.Service
|
||||
const providerID = ProviderV2.ID.make("blocked")
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })])
|
||||
yield* transform((catalog) => {
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(providerID, () => {})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("model"), () => {})
|
||||
})
|
||||
|
||||
expect(yield* catalog.provider.all()).toEqual([])
|
||||
expect(yield* catalog.model.all()).toEqual([])
|
||||
expect(yield* catalog.provider.get(providerID).pipe(Effect.option)).toEqual(Option.none())
|
||||
expect(yield* catalog.provider.get(providerID)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -11,8 +11,7 @@ describe("CommandV2", () => {
|
||||
it.effect("applies command transforms and preserves later overrides", () =>
|
||||
Effect.gen(function* () {
|
||||
const command = yield* CommandV2.Service
|
||||
const transform = yield* command.transform()
|
||||
yield* transform((editor) => {
|
||||
yield* command.transform((editor) => {
|
||||
editor.update("review", (command) => {
|
||||
command.template = "First"
|
||||
command.description = "Review code"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { agentHost, host } from "../plugin/host"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(AgentV2.locationLayer, FSUtil.defaultLayer))
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
@@ -19,9 +20,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const build = AgentV2.ID.make("build")
|
||||
const defaults = yield* agents.transform()
|
||||
|
||||
yield* defaults((editor) =>
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(build, (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.permissions.push({ action: "bash", resource: "*", effect: "allow" })
|
||||
@@ -68,9 +67,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
]),
|
||||
})
|
||||
|
||||
yield* ConfigAgentPlugin.Plugin.effect.pipe(
|
||||
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(AgentV2.Service, agents),
|
||||
)
|
||||
|
||||
const buildAgent = yield* agents.get(build)
|
||||
@@ -150,9 +148,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
]),
|
||||
})
|
||||
|
||||
yield* ConfigAgentPlugin.Plugin.effect.pipe(
|
||||
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(AgentV2.Service, agents),
|
||||
)
|
||||
|
||||
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
|
||||
@@ -177,8 +174,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const build = AgentV2.ID.make("build")
|
||||
const defaults = yield* agents.transform()
|
||||
yield* defaults((editor) => editor.update(build, () => {}))
|
||||
yield* agents.transform((editor) => editor.update(build, () => {}))
|
||||
|
||||
const config = Config.Service.of({
|
||||
entries: () =>
|
||||
@@ -190,9 +186,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
]),
|
||||
})
|
||||
|
||||
yield* ConfigAgentPlugin.Plugin.effect.pipe(
|
||||
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(AgentV2.Service, agents),
|
||||
)
|
||||
|
||||
expect(yield* agents.get(build)).toBeUndefined()
|
||||
@@ -251,9 +246,8 @@ Use native v2 fields.`,
|
||||
]),
|
||||
})
|
||||
|
||||
yield* ConfigAgentPlugin.Plugin.effect.pipe(
|
||||
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(AgentV2.Service, agents),
|
||||
)
|
||||
|
||||
expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(CommandV2.locationLayer, FSUtil.defaultLayer))
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
@@ -41,8 +42,7 @@ Review files`,
|
||||
})
|
||||
|
||||
const command = yield* CommandV2.Service
|
||||
yield* ConfigCommandPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(CommandV2.Service, command),
|
||||
yield* ConfigCommandPlugin.Plugin.effect(host({ command })).pipe(
|
||||
Effect.provideService(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { it, withEnv } from "../plugin/provider-helper"
|
||||
import { it, required, withEnv } from "../plugin/provider-helper"
|
||||
import { catalogHost, host, integrationHost } from "../plugin/host"
|
||||
|
||||
function request(headers: Record<string, string>, variant?: string) {
|
||||
return {
|
||||
@@ -58,14 +59,12 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
|
||||
yield* plugin.add({
|
||||
...ConfigProviderPlugin.Plugin,
|
||||
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(Integration.Service, integrations),
|
||||
),
|
||||
effect: ConfigProviderPlugin.Plugin.effect(
|
||||
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
|
||||
).pipe(Effect.provideService(Config.Service, config)),
|
||||
})
|
||||
|
||||
const model = yield* catalog.model.get(providerID, modelID)
|
||||
const model = required(yield* catalog.model.get(providerID, modelID))
|
||||
expect(model.variants).toMatchObject([
|
||||
{
|
||||
id: "high",
|
||||
@@ -119,14 +118,12 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
|
||||
yield* plugin.add({
|
||||
...ConfigProviderPlugin.Plugin,
|
||||
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(Integration.Service, integrations),
|
||||
),
|
||||
effect: ConfigProviderPlugin.Plugin.effect(
|
||||
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
|
||||
).pipe(Effect.provideService(Config.Service, config)),
|
||||
})
|
||||
|
||||
const model = yield* catalog.model.get(providerID, modelID)
|
||||
const model = required(yield* catalog.model.get(providerID, modelID))
|
||||
expect(model.variants[0]).toMatchObject({
|
||||
id: "high",
|
||||
body: {},
|
||||
@@ -222,16 +219,14 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
|
||||
yield* plugin.add({
|
||||
...ConfigProviderPlugin.Plugin,
|
||||
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(Integration.Service, integrations),
|
||||
),
|
||||
effect: ConfigProviderPlugin.Plugin.effect(
|
||||
host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }),
|
||||
).pipe(Effect.provideService(Config.Service, config)),
|
||||
})
|
||||
|
||||
const provider = yield* catalog.provider.get(providerID)
|
||||
const model = yield* catalog.model.get(providerID, modelID)
|
||||
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
|
||||
const provider = required(yield* catalog.provider.get(providerID))
|
||||
const model = required(yield* catalog.model.get(providerID, modelID))
|
||||
expect((yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
|
||||
expect(provider.name).toBe("Renamed")
|
||||
expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({
|
||||
type: "env",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user