Compare commits

..

1 Commits

Author SHA1 Message Date
James Long d34d13ae2f test(core): simplify project copy layer wiring 2026-06-20 22:14:44 -04:00
261 changed files with 6747 additions and 16363 deletions
+2 -3
View File
@@ -65,7 +65,7 @@ jobs:
- name: Run unit tests - name: Run unit tests
timeout-minutes: 20 timeout-minutes: 20
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=none run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
env: env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
@@ -99,8 +99,7 @@ jobs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
# Playwright 1.59 hangs while extracting Chromium with Node 24.16. node-version: "24"
node-version: "24.15"
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
+9 -6
View File
@@ -24,7 +24,7 @@ A durable chronological instruction that tells the model the newly effective sta
_Avoid_: System update, system notification, raw text diff _Avoid_: System update, system notification, raw text diff
**Context Epoch**: **Context Epoch**:
The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline. The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition.
**Baseline System Context**: **Baseline System Context**:
The full **System Context** rendered at the start of a **Context Epoch**. The full **System Context** rendered at the start of a **Context Epoch**.
@@ -75,28 +75,31 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed. - Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**. - `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked. - `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable. - `SystemContext.replace(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context.
- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run.
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text. - **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**. - Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**. - Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location. - Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote. - Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move.
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values. - Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**. - The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam. - Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool. - Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
- The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn. - Switching the selected agent requests **Context Epoch** replacement. A switch admitted after the current **Safe Provider-Turn Boundary** applies to the next provider turn while leaving the already-prepared baseline durable. Epoch creation is fenced against the authoritative effective agent, and retries re-observe the current agent.
- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline. - A cross-agent replacement must complete before another provider turn; unavailable admitted context blocks that replacement instead of exposing the previous agent's privileged baseline.
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy. - Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily. - Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry. - Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them. - **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later. - The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
- A **Context Epoch** begins with one immutable **Baseline System Context**. - A **Context Epoch** begins with one immutable **Baseline System Context**.
- A **Context Epoch** durably records the effective agent that owns its **Baseline System Context**.
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**. - A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix. - A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history. - Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache.
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn. - A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding. - **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing. - **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`. - The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
-2
View File
@@ -276,7 +276,6 @@
"@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*", "@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0", "@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0", "@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/context-async-hooks": "2.6.1",
@@ -626,7 +625,6 @@
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.17.9", "version": "1.17.9",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"effect": "catalog:", "effect": "catalog:",
"zod": "catalog:", "zod": "catalog:",
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-oWSGu+SP66Aquy/0Vaq7Bgp8404ZdOWbQX+O7h3jxHU=", "x86_64-linux": "sha256-g0tDvRf7MErZ1PEeUazEYi492ZHiRT8kYv3bPdkss/I=",
"aarch64-linux": "sha256-UsS0+c+GwtIukmWwQeFbY/3Oaz3t4Q7C6cFMGkmlyAY=", "aarch64-linux": "sha256-6sKgf3ftbIqlPxlFkoPzoWPsJp3IwXD+H3Y6g874xmk=",
"aarch64-darwin": "sha256-CArz92ewPmXO+ORFCBkCH8LzMpU/DjyaO4ic7QL0UpI=", "aarch64-darwin": "sha256-Se/Nls/KlkuK2ysDQ9DeAzSaX3NsL2iDdf/dsv2GIXc=",
"x86_64-darwin": "sha256-rhnz9gmG6L06wIzfMhTaXDDEf6IbMD32CavqwXoqcUs=" "x86_64-darwin": "sha256-V9MCkqnvQ1nkD2PaaTfNFKkBZGymj6KxrSAK6+DTF8Y="
} }
} }
+3 -5
View File
@@ -27,11 +27,9 @@ export default Runtime.handler(
function listen(hostname: string, port: Option.Option<number>, password: string) { function listen(hostname: string, port: Option.Option<number>, password: string) {
if (Option.isSome(port)) return bind(hostname, port.value, password) if (Option.isSome(port)) return bind(hostname, port.value, password)
const next = (port: number): ReturnType<typeof bind> => // Preserve the familiar default when available, but let the OS choose a free
bind(hostname, port, password).pipe( // port when another local server already owns 4096.
Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))), return bind(hostname, 4096, password).pipe(Effect.catch(() => bind(hostname, 0, password)))
)
return next(4096)
} }
function bind(hostname: string, port: number, password: string) { function bind(hostname: string, port: number, password: string) {
-2
View File
@@ -1,2 +0,0 @@
[test]
preload = ["./test/preload.ts"]
-1
View File
@@ -91,7 +91,6 @@
"@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*", "@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opentelemetry/api": "1.9.0", "@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/exporter-trace-otlp-http": "0.214.0",
+32 -2
View File
@@ -1,8 +1,8 @@
{ {
"version": "7", "version": "7",
"dialect": "sqlite", "dialect": "sqlite",
"id": "f14a9b18-8207-487e-a3d3-227e629ba9ad", "id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db",
"prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"], "prevIds": ["abd2f920-b822-49af-b8a7-2e48367d424f"],
"ddl": [ "ddl": [
{ {
"name": "workspace", "name": "workspace",
@@ -900,6 +900,16 @@
"entityType": "columns", "entityType": "columns",
"table": "session_context_epoch" "table": "session_context_epoch"
}, },
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'build'",
"generated": null,
"name": "agent",
"entityType": "columns",
"table": "session_context_epoch"
},
{ {
"type": "text", "type": "text",
"notNull": true, "notNull": true,
@@ -920,6 +930,26 @@
"entityType": "columns", "entityType": "columns",
"table": "session_context_epoch" "table": "session_context_epoch"
}, },
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "replacement_seq",
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "revision",
"entityType": "columns",
"table": "session_context_epoch"
},
{ {
"type": "text", "type": "text",
"notNull": false, "notNull": false,
+5 -19
View File
@@ -45,17 +45,15 @@ async function generate() {
if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`) if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`)
await Bun.write( await Bun.write(
target, target,
await formatTypescript( renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
),
) )
await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot) await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot)
} }
await fs.mkdir(full) await fs.mkdir(full)
await drizzle(temporary, full, "schema") await drizzle(temporary, full, "schema")
await Bun.write(schema, await formatTypescript(renderSchema(await generatedSql(full)))) await Bun.write(schema, renderSchema(await generatedSql(full)))
await Bun.write(registry, await formatTypescript(renderRegistry(await typescriptMigrations()))) await Bun.write(registry, renderRegistry(await typescriptMigrations()))
} finally { } finally {
await fs.rm(temporary, { recursive: true, force: true }) await fs.rm(temporary, { recursive: true, force: true })
} }
@@ -78,12 +76,12 @@ async function check() {
await fs.mkdir(full) await fs.mkdir(full)
await drizzle(temporary, full, "schema") await drizzle(temporary, full, "schema")
if ((await Bun.file(schema).text()) !== (await formatTypescript(renderSchema(await generatedSql(full))))) { if ((await Bun.file(schema).text()) !== renderSchema(await generatedSql(full))) {
throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.") throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.")
} }
const migrations = await typescriptMigrations() const migrations = await typescriptMigrations()
if ((await Bun.file(registry).text()) !== (await formatTypescript(renderRegistry(migrations)))) { if ((await Bun.file(registry).text()) !== renderRegistry(migrations)) {
throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.") throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.")
} }
} finally { } finally {
@@ -172,18 +170,6 @@ function escapeTemplate(line: string) {
return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${") return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")
} }
async function formatTypescript(input: string) {
const prettier = await import("prettier")
const typescript = await import("prettier/plugins/typescript")
const estree = await import("prettier/plugins/estree")
return prettier.format(input, {
parser: "typescript",
plugins: [typescript.default, estree.default],
semi: false,
printWidth: 120,
})
}
function renderRegistry(names: string[]) { function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration" return `import type { DatabaseMigration } from "./migration"
+14 -9
View File
@@ -1,6 +1,7 @@
export * as AgentV2 from "./agent" export * as AgentV2 from "./agent"
import { Array, Context, Effect, Layer, Schema, Scope, Types } from "effect" import { Array, Context, Effect, Layer, Schema, Scope } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
import { PermissionSchema } from "./permission/schema" import { PermissionSchema } from "./permission/schema"
import { ProviderV2 } from "./provider" import { ProviderV2 } from "./provider"
@@ -48,19 +49,21 @@ export interface Selection {
} }
type Data = { type Data = {
agents: Map<ID, Types.DeepMutable<Info>> agents: Map<ID, Info>
default?: ID default?: ID
} }
export type Draft = { export type Editor = {
list: () => readonly Info[] list: () => readonly Info[]
get: (id: ID) => Info | undefined get: (id: ID) => Info | undefined
default: (id: ID | undefined) => void default: (id: ID | undefined) => void
update: (id: ID, fn: (agent: Types.DeepMutable<Info>) => void) => void update: (id: ID, fn: (agent: Draft<Info>) => void) => void
remove: (id: ID) => void remove: (id: ID) => void
} }
export interface Interface extends State.Transformable<Draft> { export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: State.Interface<Data, Editor>["update"]
readonly get: (id: ID) => Effect.Effect<Info | undefined> readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly default: () => Effect.Effect<Info | undefined> readonly default: () => Effect.Effect<Info | undefined>
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined> readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
@@ -70,19 +73,21 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
enableMapSet()
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const state = State.create<Data, Draft>({ const state = State.create<Data, Editor>({
initial: () => ({ agents: new Map() }), initial: () => ({ agents: new Map() }),
draft: (draft) => ({ editor: (draft) => ({
list: () => Array.fromIterable(draft.agents.values()) as Info[], list: () => Array.fromIterable(draft.agents.values()) as Info[],
get: (id) => draft.agents.get(id), get: (id) => draft.agents.get(id),
default: (id) => { default: (id) => {
draft.default = id draft.default = id
}, },
update: (id, fn) => { update: (id, fn) => {
const current = draft.agents.get(id) ?? (Info.empty(id) as Types.DeepMutable<Info>) const current = draft.agents.get(id) ?? castDraft(Info.empty(id))
if (!draft.agents.has(id)) draft.agents.set(id, current) if (!draft.agents.has(id)) draft.agents.set(id, current)
fn(current) fn(current)
current.id = id current.id = id
@@ -108,7 +113,7 @@ export const layer = Layer.effect(
return Service.of({ return Service.of({
transform: state.transform, transform: state.transform,
rebuild: state.rebuild, update: state.update,
get: Effect.fn("AgentV2.get")(function* (id) { get: Effect.fn("AgentV2.get")(function* (id) {
return state.get().agents.get(id) return state.get().agents.get(id)
}), }),
+101 -54
View File
@@ -1,21 +1,36 @@
export * as Catalog from "./catalog" export * as Catalog from "./catalog"
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect" import { Array, Context, Effect, Layer, Option, Order, pipe, Schema, Scope, Stream } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
import { ModelRequest } from "./model-request" import { ModelRequest } from "./model-request"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider" import { ProviderV2 } from "./provider"
import { Location } from "./location"
import { EventV2 } from "./event" import { EventV2 } from "./event"
import { Policy } from "./policy" import { Policy } from "./policy"
import { State } from "./state" import { State } from "./state"
import { Integration } from "./integration" import { Integration } from "./integration"
export type ProviderRecord = { export type ProviderRecord = {
provider: ProviderV2.MutableInfo provider: ProviderV2.Info
models: Map<ModelV2.ID, ModelV2.MutableInfo> models: Map<ModelV2.ID, ModelV2.Info>
} }
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID } 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 PolicyActions = Schema.Literals(["provider.use"])
export const Event = { export const Event = {
@@ -27,16 +42,16 @@ type Data = {
defaultModel?: DefaultModel defaultModel?: DefaultModel
} }
export type Draft = { export type Editor = {
provider: { provider: {
list: () => readonly ProviderRecord[] list: () => readonly ProviderRecord[]
get: (providerID: ProviderV2.ID) => ProviderRecord | undefined get: (providerID: ProviderV2.ID) => ProviderRecord | undefined
update: (providerID: ProviderV2.ID, fn: (provider: ProviderV2.MutableInfo) => void) => void update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
remove: (providerID: ProviderV2.ID) => void remove: (providerID: ProviderV2.ID) => void
} }
model: { model: {
get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: ModelV2.MutableInfo) => void) => void update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
default: { default: {
get: () => DefaultModel | undefined get: () => DefaultModel | undefined
@@ -45,34 +60,43 @@ export type Draft = {
} }
} }
export interface Interface extends State.Transformable<Draft> { export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly provider: { readonly provider: {
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info | undefined> readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
readonly all: () => Effect.Effect<ProviderV2.Info[]> readonly all: () => Effect.Effect<ProviderV2.Info[]>
readonly available: () => Effect.Effect<ProviderV2.Info[]> readonly available: () => Effect.Effect<ProviderV2.Info[]>
} }
readonly model: { readonly model: {
readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect<ModelV2.Info | undefined> readonly get: (
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
readonly all: () => Effect.Effect<ModelV2.Info[]> readonly all: () => Effect.Effect<ModelV2.Info[]>
readonly available: () => Effect.Effect<ModelV2.Info[]> readonly available: () => Effect.Effect<ModelV2.Info[]>
readonly default: () => Effect.Effect<ModelV2.Info | undefined> readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<ModelV2.Info | undefined> readonly small: (providerID: ProviderV2.ID) => Effect.Effect<Option.Option<ModelV2.Info>>
} }
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
enableMapSet()
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const location = yield* Location.Service
const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
const policy = yield* Policy.Service const policy = yield* Policy.Service
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const scope = yield* Scope.Scope
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => { const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
if (provider.disabled) return false if (provider.disabled) return false
if (typeof provider.request.body.apiKey === "string") return true if (typeof provider.request.body.apiKey === "string") return true
if (integration?.connections.length) return true if (connected) return true
return !integration return !integration
} }
@@ -96,26 +120,32 @@ export const layer = Layer.effect(
}) })
} }
const normalizeApi = (item: ProviderV2.MutableInfo | ModelV2.MutableInfo) => { 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>) => {
if (typeof item.request.body.baseURL !== "string") return if (typeof item.request.body.baseURL !== "string") return
item.api.url = item.request.body.baseURL item.api.url = item.request.body.baseURL
delete item.request.body.baseURL delete item.request.body.baseURL
} }
const state = State.create<Data, Draft>({ const state = State.create<Data, Editor>({
initial: () => ({ providers: new Map() }), initial: () => ({ providers: new Map() }),
draft: (draft) => { editor: (draft) => {
const result: Draft = { const result: Editor = {
provider: { provider: {
list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[], list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[],
get: (providerID) => draft.providers.get(providerID), get: (providerID) => draft.providers.get(providerID),
update: (providerID, fn) => { update: (providerID, fn) => {
let current = draft.providers.get(providerID) let current = draft.providers.get(providerID)
if (!current) { if (!current) {
current = { current = castDraft({
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo, provider: ProviderV2.Info.empty(providerID),
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(), models: new Map<ModelV2.ID, ModelV2.Info>(),
} })
draft.providers.set(providerID, current) draft.providers.set(providerID, current)
} }
fn(current.provider) fn(current.provider)
@@ -130,14 +160,13 @@ export const layer = Layer.effect(
update: (providerID, modelID, fn) => { update: (providerID, modelID, fn) => {
let record = draft.providers.get(providerID) let record = draft.providers.get(providerID)
if (!record) { if (!record) {
record = { record = castDraft({
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo, provider: ProviderV2.Info.empty(providerID),
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(), models: new Map<ModelV2.ID, ModelV2.Info>(),
} })
draft.providers.set(providerID, record) draft.providers.set(providerID, record)
} }
const model = const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID))
record.models.get(modelID) ?? (ModelV2.Info.empty(providerID, modelID) as ModelV2.MutableInfo)
if (!record.models.has(modelID)) record.models.set(modelID, model) if (!record.models.has(modelID)) record.models.set(modelID, model)
fn(model) fn(model)
model.id = modelID model.id = modelID
@@ -157,7 +186,8 @@ export const layer = Layer.effect(
} }
return result return result
}, },
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) { finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
if (policy.hasStatements()) { if (policy.hasStatements()) {
for (const record of [...catalog.provider.list()]) { for (const record of [...catalog.provider.list()]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
@@ -168,13 +198,25 @@ export const layer = Layer.effect(
yield* events.publish(Event.Updated, {}) 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 = { const result: Interface = {
transform: state.transform, transform: state.transform,
rebuild: state.rebuild,
provider: { provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
return state.get().providers.get(providerID)?.provider const record = yield* getRecord(providerID)
return record.provider
}), }),
all: Effect.fn("CatalogV2.provider.all")(function* () { all: Effect.fn("CatalogV2.provider.all")(function* () {
@@ -183,18 +225,23 @@ export const layer = Layer.effect(
available: Effect.fn("CatalogV2.provider.available")(function* () { available: Effect.fn("CatalogV2.provider.available")(function* () {
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration])) const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
const connections = yield* integrations.connection.list()
return (yield* result.provider.all()).filter((provider) => return (yield* result.provider.all()).filter((provider) =>
available(provider, active.get(Integration.ID.make(provider.id))), available(
provider,
active.get(Integration.ID.make(provider.id)),
connections.has(Integration.ID.make(provider.id)),
),
) )
}), }),
}, },
model: { model: {
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) { get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
const record = state.get().providers.get(providerID) const record = yield* getRecord(providerID)
if (!record) return
const model = record.models.get(modelID) const model = record.models.get(modelID)
return model && projectModel(model, record.provider) if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
return projectModel(model, record.provider)
}), }),
all: Effect.fn("CatalogV2.model.all")(function* () { all: Effect.fn("CatalogV2.model.all")(function* () {
@@ -203,7 +250,7 @@ export const layer = Layer.effect(
Array.flatMap((record) => { Array.flatMap((record) => {
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider)) return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
}), }),
Array.sortWith((item) => item.time.released, Order.flip(Order.Number)), Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
) )
}), }),
@@ -215,30 +262,31 @@ export const layer = Layer.effect(
default: Effect.fn("CatalogV2.model.default")(function* () { default: Effect.fn("CatalogV2.model.default")(function* () {
const defaultModel = state.get().defaultModel const defaultModel = state.get().defaultModel
if (defaultModel) { if (defaultModel) {
const provider = yield* result.provider.get(defaultModel.providerID) const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option)
if (provider && (yield* result.provider.available()).some((item) => item.id === provider.id)) { if (
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID) Option.isSome(provider) &&
if (model?.enabled) return model (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
} }
} }
return Option.getOrUndefined( return pipe(
pipe( yield* result.model.available(),
yield* result.model.available(), Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
Array.sortWith((item) => item.time.released, Order.flip(Order.Number)), Array.head,
Array.head,
),
) )
}), }),
small: Effect.fn("CatalogV2.model.small")(function* (providerID) { small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
const record = state.get().providers.get(providerID) const record = state.get().providers.get(providerID)
if (!record) return if (!record) return Option.none<ModelV2.Info>()
const provider = record.provider const provider = record.provider
if (providerID === ProviderV2.ID.opencode) { if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano")) const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider) if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider))
} }
const candidates = pipe( const candidates = pipe(
@@ -254,7 +302,7 @@ export const layer = Layer.effect(
Array.map((model) => ({ Array.map((model) => ({
model, model,
cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999, cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999,
age: (Date.now() - model.time.released) / (1000 * 60 * 60 * 24 * 30), age: (Date.now() - model.time.released.epochMilliseconds) / (1000 * 60 * 60 * 24 * 30),
small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()), small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()),
})), })),
Array.filter((item) => item.cost > 0 && item.age <= 18), Array.filter((item) => item.cost > 0 && item.age <= 18),
@@ -271,12 +319,10 @@ export const layer = Layer.effect(
) )
} }
return Option.getOrUndefined( return pipe(
pipe( candidates,
candidates, Array.filter((item) => item.small),
Array.filter((item) => item.small), (items) => (items.length > 0 ? pick(items) : pick(candidates)),
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
),
) )
}), }),
}, },
@@ -290,5 +336,6 @@ const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const locationLayer = layer.pipe( export const locationLayer = layer.pipe(
Layer.provideMerge(Integration.locationLayer), Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(Policy.locationLayer), Layer.provideMerge(Policy.locationLayer),
) )
+12 -9
View File
@@ -1,6 +1,7 @@
export * as CommandV2 from "./command" export * as CommandV2 from "./command"
import { Context, Effect, Layer, Schema, Types } from "effect" import { Context, Effect, Layer, Schema } from "effect"
import { castDraft, type Draft } from "immer"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
import { State } from "./state" import { State } from "./state"
@@ -14,17 +15,19 @@ export class Info extends Schema.Class<Info>("CommandV2.Info")({
}) {} }) {}
export type Data = { export type Data = {
commands: Map<string, Types.DeepMutable<Info>> commands: Map<string, Info>
} }
export type Draft = { export type Editor = {
list: () => readonly Info[] list: () => readonly Info[]
get: (name: string) => Info | undefined get: (name: string) => Info | undefined
update: (name: string, update: (command: Types.DeepMutable<Info>) => void) => void update: (name: string, update: (command: Draft<Info>) => void) => void
remove: (name: string) => void remove: (name: string) => void
} }
export interface Interface extends State.Transformable<Draft> { export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: State.Interface<Data, Editor>["update"]
readonly get: (name: string) => Effect.Effect<Info | undefined> readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]> readonly list: () => Effect.Effect<Info[]>
} }
@@ -34,13 +37,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.sync(() => { Effect.sync(() => {
const state = State.create<Data, Draft>({ const state = State.create<Data, Editor>({
initial: () => ({ commands: new Map() }), initial: () => ({ commands: new Map() }),
draft: (draft) => ({ editor: (draft) => ({
list: () => Array.from(draft.commands.values()) as Info[], list: () => Array.from(draft.commands.values()) as Info[],
get: (name) => draft.commands.get(name), get: (name) => draft.commands.get(name),
update: (name, update) => { update: (name, update) => {
const current = draft.commands.get(name) ?? (new Info({ name, template: "" }) as Types.DeepMutable<Info>) const current = draft.commands.get(name) ?? castDraft(new Info({ name, template: "" }))
if (!draft.commands.has(name)) draft.commands.set(name, current) if (!draft.commands.has(name)) draft.commands.set(name, current)
update(current) update(current)
current.name = name current.name = name
@@ -52,7 +55,7 @@ export const layer = Layer.effect(
}) })
return Service.of({ return Service.of({
rebuild: state.rebuild, update: state.update,
transform: state.transform, transform: state.transform,
get: Effect.fn("CommandV2.get")(function* (name) { get: Effect.fn("CommandV2.get")(function* (name) {
return state.get().commands.get(name) return state.get().commands.get(name)
+59 -59
View File
@@ -1,6 +1,5 @@
export * as ConfigAgentPlugin from "./agent" export * as ConfigAgentPlugin from "./agent"
import { define } from "@opencode-ai/plugin/v2/effect"
import path from "path" import path from "path"
import { Effect, Option, Schema } from "effect" import { Effect, Option, Schema } from "effect"
import { AgentV2 } from "../../agent" import { AgentV2 } from "../../agent"
@@ -9,6 +8,7 @@ import { ConfigAgent } from "../agent"
import { ConfigMarkdown } from "../markdown" import { ConfigMarkdown } from "../markdown"
import { FSUtil } from "../../fs-util" import { FSUtil } from "../../fs-util"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin"
import { ConfigAgentV1 } from "../../v1/config/agent" import { ConfigAgentV1 } from "../../v1/config/agent"
import { ConfigMigrateV1 } from "../../v1/config/migrate" import { ConfigMigrateV1 } from "../../v1/config/migrate"
@@ -33,70 +33,70 @@ const agentKeys = new Set([
"permissions", "permissions",
]) ])
export const Plugin = define({ export const Plugin = PluginV2.define({
id: "config-agent", id: PluginV2.ID.make("config-agent"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
const agent = yield* AgentV2.Service
const config = yield* Config.Service const config = yield* Config.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
yield* ctx.agent.transform( const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
Effect.fn(function* (draft) { if (entry.type === "document") return Effect.succeed([entry])
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { return Effect.gen(function* () {
if (entry.type === "document") return Effect.succeed([entry]) const files = yield* discover(fs, entry.path)
return Effect.gen(function* () { return yield* Effect.forEach(files, (file) =>
const files = yield* discover(fs, entry.path) fs.readFileStringSafe(file.filepath).pipe(
return yield* Effect.forEach(files, (file) => Effect.map((content) => content && decode(file, content)),
fs.readFileStringSafe(file.filepath).pipe( Effect.catch(() => Effect.succeed(undefined)),
Effect.map((content) => content && decode(file, content)), ),
Effect.catch(() => Effect.succeed(undefined)), ).pipe(
), Effect.map((documents) =>
).pipe( documents.filter((document): document is Config.Document => document !== undefined),
Effect.map((documents) => ),
documents.filter((document): document is Config.Document => document !== undefined), )
), })
) }).pipe(Effect.map((documents) => documents.flat()))
})
}).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) { yield* agent.update((editor) => {
for (const [id, item] of Object.entries(document.info.agents ?? {})) { const global = documents.flatMap((document) => document.info.permissions ?? [])
const agentID = AgentV2.ID.make(id) const configuredDefault = Config.latest(documents, "default_agent")
if (item.disabled) { if (configuredDefault !== undefined) editor.default(AgentV2.ID.make(configuredDefault))
draft.remove(agentID) for (const current of editor.list()) {
continue editor.update(current.id, (agent) => agent.permissions.push(...global))
} }
const exists = draft.get(agentID) !== undefined for (const document of documents) {
draft.update(agentID, (agent) => { for (const [id, item] of Object.entries(document.info.agents ?? {})) {
if (!exists) agent.permissions.push(...global) const agentID = AgentV2.ID.make(id)
if (item.model !== undefined) { if (item.disabled) {
const model = ModelV2.parse(item.model) editor.remove(agentID)
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant } continue
}
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)
})
} }
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)
})
} }
}), }
) })
}), }),
}) })
+33 -32
View File
@@ -1,51 +1,52 @@
export * as ConfigCommandPlugin from "./command" export * as ConfigCommandPlugin from "./command"
import { define } from "@opencode-ai/plugin/v2/effect"
import path from "path" import path from "path"
import { Effect, Option, Schema } from "effect" import { Effect, Option, Schema } from "effect"
import { CommandV2 } from "../../command" import { CommandV2 } from "../../command"
import { Config } from "../../config" import { Config } from "../../config"
import { FSUtil } from "../../fs-util" import { FSUtil } from "../../fs-util"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin"
import { ConfigCommand } from "../command" import { ConfigCommand } from "../command"
import { ConfigMarkdown } from "../markdown" import { ConfigMarkdown } from "../markdown"
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info) const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
export const Plugin = define({ export const Plugin = PluginV2.define({
id: "config-command", id: PluginV2.ID.make("config-command"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
const command = yield* CommandV2.Service
const config = yield* Config.Service const config = yield* Config.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
yield* ctx.command.transform( const transform = yield* command.transform()
Effect.fn(function* (draft) { const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) return loadDirectory(fs, entry.path).pipe(
return loadDirectory(fs, entry.path).pipe( Effect.map((commands) => [
Effect.map((commands) => [ { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, ]),
]), )
) }).pipe(Effect.map((documents) => documents.flat()))
}).pipe(Effect.map((documents) => documents.flat()))
for (const document of documents) { yield* transform((editor) => {
for (const [name, command] of Object.entries(document.commands ?? {})) { for (const document of documents) {
draft.update(name, (item) => { for (const [name, command] of Object.entries(document.commands ?? {})) {
item.template = command.template editor.update(name, (item) => {
if (command.description !== undefined) item.description = command.description item.template = command.template
if (command.agent !== undefined) item.agent = command.agent if (command.description !== undefined) item.description = command.description
if (command.model !== undefined) { if (command.agent !== undefined) item.agent = command.agent
const model = ModelV2.parse(command.model) if (command.model !== undefined) {
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant } 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.variant !== undefined && item.model !== undefined) {
} item.model.variant = ModelV2.VariantID.make(command.variant)
if (command.subtask !== undefined) item.subtask = command.subtask }
}) if (command.subtask !== undefined) item.subtask = command.subtask
} })
} }
}), }
) })
}), }),
}) })
+102 -103
View File
@@ -1,124 +1,123 @@
export * as ConfigProviderPlugin from "./provider" export * as ConfigProviderPlugin from "./provider"
import { define } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "../../catalog"
import { Config } from "../../config" import { Config } from "../../config"
import { Integration } from "../../integration"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request" import { ModelRequest } from "../../model-request"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
export const Plugin = define({ export const Plugin = PluginV2.define({
id: "config-provider", id: PluginV2.ID.make("config-provider"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
const catalog = yield* Catalog.Service
const config = yield* Config.Service const config = yield* Config.Service
yield* ctx.integration.transform( const integrations = yield* Integration.Service
Effect.fn(function* (integrations) { const transform = yield* catalog.transform()
const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document") const integrationTransform = yield* integrations.transform()
const configuredIntegrations = new Set( const entries = yield* config.entries()
files.flatMap((file) => const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => const configuredIntegrations = new Set(
provider.env === undefined ? [] : [id], files.flatMap((file) =>
), Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
), ),
) )
for (const file of files) { yield* integrationTransform((integrations) => {
for (const [id, item] of Object.entries(file.info.providers ?? {})) { for (const file of files) {
const integrationID = id for (const [id, item] of Object.entries(file.info.providers ?? {})) {
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue const integrationID = Integration.ID.make(id)
integrations.update(integrationID, (integration) => { if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
integration.name = item.name ?? integration.name integrations.update(integrationID, (integration) => {
integration.name = item.name ?? integration.name
})
if (item.env !== undefined) {
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
}) })
if (item.env !== undefined) {
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
})
}
} }
} }
}), }
) })
yield* ctx.catalog.transform( yield* transform((catalog) => {
Effect.fn(function* (catalog) { const configuredDefault = Config.latest(entries, "model")
const entries = yield* config.entries() if (configuredDefault !== undefined) {
const files = entries.filter((entry): entry is Config.Document => entry.type === "document") const model = ModelV2.parse(configuredDefault)
const configuredDefault = Config.latest(entries, "model") catalog.model.default.set(model.providerID, model.modelID)
if (configuredDefault !== undefined) { }
const model = ModelV2.parse(configuredDefault) for (const file of files) {
catalog.model.default.set(model.providerID, model.modelID) for (const [id, item] of Object.entries(file.info.providers ?? {})) {
} const providerID = ProviderV2.ID.make(id)
for (const file of files) { catalog.provider.update(providerID, (provider) => {
for (const [id, item] of Object.entries(file.info.providers ?? {})) { if (item.name !== undefined) provider.name = item.name
const providerID = id if (item.api !== undefined) provider.api = { ...item.api }
catalog.provider.update(providerID, (provider) => { if (item.request !== undefined) {
if (item.name !== undefined) provider.name = item.name Object.assign(provider.request.headers, item.request.headers)
if (item.api !== undefined) provider.api = { ...item.api } Object.assign(provider.request.body, item.request.body)
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, 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) {
const providerApi = catalog.provider.get(providerID)?.provider.api ModelRequest.assign(model.request, {
const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined headers: config.request.headers,
...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
for (const [id, config] of Object.entries(item.models ?? {})) { })
catalog.model.update(providerID, id, (model) => { if (config.request.variant !== undefined) model.request.variant = config.request.variant
if (config.family !== undefined) model.family = config.family }
if (config.name !== undefined) model.name = config.name if (config.variants !== undefined) {
if (config.api !== undefined) model.api = { ...model.api, ...config.api } for (const variant of config.variants) {
const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage let existing = model.variants.find((item) => item.id === variant.id)
if (config.capabilities !== undefined) { if (!existing) {
model.capabilities = { existing = {
tools: config.capabilities.tools, id: variant.id,
input: [...config.capabilities.input], headers: {},
output: [...config.capabilities.output], body: {},
} generation: {},
} options: {},
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, { model.variants.push(existing)
headers: variant.headers,
...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
})
} }
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) => ({ if (config.cost !== undefined) {
tier: cost.tier && { ...cost.tier }, model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
input: cost.input, tier: cost.tier && { ...cost.tier },
output: cost.output, input: cost.input,
cache: { output: cost.output,
read: cost.cache?.read ?? 0, cache: {
write: cost.cache?.write ?? 0, 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 } if (config.disabled !== undefined) model.enabled = !config.disabled
}) if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
} })
} }
} }
}), }
) })
}), }),
}) })
+43 -38
View File
@@ -1,52 +1,57 @@
export * as ConfigReferencePlugin from "./reference" export * as ConfigReferencePlugin from "./reference"
import { define } from "@opencode-ai/plugin/v2/effect"
import path from "path" import path from "path"
import { Effect } from "effect" import { Effect } from "effect"
import { Config } from "../../config" import { Config } from "../../config"
import { ConfigReference } from "../reference" import { ConfigReference } from "../reference"
import { Global } from "../../global"
import { Location } from "../../location"
import { PluginV2 } from "../../plugin"
import { Reference } from "../../reference" import { Reference } from "../../reference"
import { AbsolutePath } from "../../schema" import { AbsolutePath } from "../../schema"
export const Plugin = define({ export const Plugin = {
id: "core/config-reference", id: PluginV2.ID.make("core/config-reference"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
const config = yield* Config.Service const config = yield* Config.Service
yield* ctx.reference.transform( const global = yield* Global.Service
Effect.fn(function* (draft) { const location = yield* Location.Service
const entries = new Map<string, Reference.Source>() const references = yield* Reference.Service
for (const doc of (yield* config.entries()).filter( const update = yield* references.transform()
(entry): entry is Config.Document => entry.type === "document", const entries = new Map<string, Reference.Source>()
)) { for (const doc of (yield* config.entries()).filter(
const directory = doc.path ? path.dirname(doc.path) : ctx.location.directory (entry): entry is Config.Document => entry.type === "document",
for (const [name, entry] of Object.entries(doc.info.references ?? {})) { )) {
if (!validAlias(name)) continue const directory = doc.path ? path.dirname(doc.path) : location.directory
entries.set( for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
name, if (!validAlias(name)) continue
local(entry) entries.set(
? new Reference.LocalSource({ name,
type: "local", local(entry)
path: AbsolutePath.make( ? new Reference.LocalSource({
localPath(directory, ctx.path.home, typeof entry === "string" ? entry : entry.path), type: "local",
), path: AbsolutePath.make(
description: typeof entry === "string" ? undefined : entry.description, localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
hidden: typeof entry === "string" ? undefined : entry.hidden, ),
}) description: typeof entry === "string" ? undefined : entry.description,
: new Reference.GitSource({ hidden: typeof entry === "string" ? undefined : entry.hidden,
type: "git", })
repository: typeof entry === "string" ? entry : entry.repository, : new Reference.GitSource({
branch: typeof entry === "string" ? undefined : entry.branch, type: "git",
description: typeof entry === "string" ? undefined : entry.description, repository: typeof entry === "string" ? entry : entry.repository,
hidden: typeof entry === "string" ? undefined : entry.hidden, 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) }
}), }
)
yield* update((editor) => {
for (const [name, source] of entries) editor.add(name, source)
})
}), }),
}) }
function validAlias(name: string) { function validAlias(name: string) {
return name.length > 0 && !/[\/\s`,]/.test(name) return name.length > 0 && !/[\/\s`,]/.test(name)
+36 -33
View File
@@ -1,45 +1,48 @@
export * as ConfigSkillPlugin from "./skill" export * as ConfigSkillPlugin from "./skill"
import { define } from "@opencode-ai/plugin/v2/effect"
import path from "path" import path from "path"
import { Effect } from "effect" import { Effect } from "effect"
import { Config } from "../../config" import { Config } from "../../config"
import { Global } from "../../global"
import { Location } from "../../location"
import { PluginV2 } from "../../plugin"
import { AbsolutePath } from "../../schema" import { AbsolutePath } from "../../schema"
import { SkillV2 } from "../../skill" import { SkillV2 } from "../../skill"
export const Plugin = define({ export const Plugin = PluginV2.define({
id: "config-skill", id: PluginV2.ID.make("config-skill"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
const config = yield* Config.Service const config = yield* Config.Service
yield* ctx.skill.transform( const global = yield* Global.Service
Effect.fn(function* (draft) { const location = yield* Location.Service
const entries = yield* config.entries() const skill = yield* SkillV2.Service
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) const transform = yield* skill.transform()
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) const entries = yield* config.entries()
for (const directory of directories) { const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
draft.source( const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
) yield* transform((editor) => {
draft.source( for (const directory of directories) {
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }), 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
} }
for (const item of items) { const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) { editor.source(
draft.source(new SkillV2.UrlSource({ type: "url", url: item })) new SkillV2.DirectorySource({
continue type: "directory",
} path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
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),
),
}),
)
}
}),
)
}), }),
}) })
+14 -14
View File
@@ -29,33 +29,33 @@ export class Key extends Schema.Class<Key>("Credential.Key")({
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {} }) {}
export const Value = Schema.Union([OAuth, Key]) export const Info = Schema.Union([OAuth, Key])
.pipe(Schema.toTaggedUnion("type")) .pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Credential.Value" }) .annotate({ identifier: "Credential.Info" })
export type Value = Schema.Schema.Type<typeof Value> export type Info = Schema.Schema.Type<typeof Info>
export class Info extends Schema.Class<Info>("Credential.Info")({ export class Stored extends Schema.Class<Stored>("Credential.Stored")({
id: ID, id: ID,
integrationID: IntegrationSchema.ID, integrationID: IntegrationSchema.ID,
label: Schema.String, label: Schema.String,
value: Value, value: Info,
}) {} }) {}
export interface Interface { export interface Interface {
/** Returns every stored credential. */ /** Returns every stored credential. */
readonly all: () => Effect.Effect<Info[]> readonly all: () => Effect.Effect<Stored[]>
/** Returns stored credentials belonging to one integration. */ /** Returns stored credentials belonging to one integration. */
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Info[]> readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]>
/** Returns one stored credential by ID. */ /** Returns one stored credential by ID. */
readonly get: (id: ID) => Effect.Effect<Info | undefined> readonly get: (id: ID) => Effect.Effect<Stored | undefined>
/** Replaces any credential for an integration and returns the new record. */ /** Replaces any credential for an integration and returns the new record. */
readonly create: (input: { readonly create: (input: {
readonly integrationID: IntegrationSchema.ID readonly integrationID: IntegrationSchema.ID
readonly value: Value readonly value: Info
readonly label?: string readonly label?: string
}) => Effect.Effect<Info> }) => Effect.Effect<Stored>
/** Updates the label or secret value of a stored credential. */ /** Updates the label or secret value of a stored credential. */
readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void> readonly update: (id: ID, updates: Partial<Pick<Stored, "label" | "value">>) => Effect.Effect<void>
/** Removes a stored credential. */ /** Removes a stored credential. */
readonly remove: (id: ID) => Effect.Effect<void> readonly remove: (id: ID) => Effect.Effect<void>
} }
@@ -66,10 +66,10 @@ export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const { db } = yield* Database.Service const { db } = yield* Database.Service
const decode = Schema.decodeUnknownSync(Value) const decode = Schema.decodeUnknownSync(Info)
const stored = (row: typeof CredentialTable.$inferSelect) => { const stored = (row: typeof CredentialTable.$inferSelect) => {
if (!row.integration_id) return if (!row.integration_id) return
return new Info({ return new Stored({
id: row.id, id: row.id,
integrationID: row.integration_id, integrationID: row.integration_id,
label: row.label, label: row.label,
@@ -106,7 +106,7 @@ export const layer = Layer.effect(
return row ? stored(row) : undefined return row ? stored(row) : undefined
}), }),
create: Effect.fn("Credential.create")(function* (input) { create: Effect.fn("Credential.create")(function* (input) {
const credential = new Info({ const credential = new Stored({
id: ID.create(), id: ID.create(),
integrationID: input.integrationID, integrationID: input.integrationID,
label: input.label ?? "default", label: input.label ?? "default",
+1 -1
View File
@@ -7,7 +7,7 @@ export const CredentialTable = sqliteTable("credential", {
id: text().$type<Credential.ID>().primaryKey(), id: text().$type<Credential.ID>().primaryKey(),
integration_id: text().$type<IntegrationSchema.ID>(), integration_id: text().$type<IntegrationSchema.ID>(),
label: text().notNull(), label: text().notNull(),
value: text({ mode: "json" }).$type<Credential.Value>().notNull(), value: text({ mode: "json" }).$type<Credential.Info>().notNull(),
connector_id: text(), connector_id: text(),
method_id: text(), method_id: text(),
active: integer({ mode: "boolean" }), active: integer({ mode: "boolean" }),
-1
View File
@@ -37,6 +37,5 @@ export const migrations = (
import("./migration/20260611035744_credential"), import("./migration/20260611035744_credential"),
import("./migration/20260611192811_lush_chimera"), import("./migration/20260611192811_lush_chimera"),
import("./migration/20260612174303_project_dir_strategy"), import("./migration/20260612174303_project_dir_strategy"),
import("./migration/20260622142730_simplify_session_context_epoch"),
]) ])
).map((module) => module.default) satisfies DatabaseMigration.Migration[] ).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -1,13 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260622142730_simplify_session_context_epoch",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`agent\`;`)
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`replacement_seq\`;`)
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`revision\`;`)
})
},
} satisfies DatabaseMigration.Migration
+3
View File
@@ -149,8 +149,11 @@ export default {
CREATE TABLE \`session_context_epoch\` ( CREATE TABLE \`session_context_epoch\` (
\`session_id\` text PRIMARY KEY, \`session_id\` text PRIMARY KEY,
\`baseline\` text NOT NULL, \`baseline\` text NOT NULL,
\`agent\` text DEFAULT 'build' NOT NULL,
\`snapshot\` text NOT NULL, \`snapshot\` text NOT NULL,
\`baseline_seq\` integer NOT NULL, \`baseline_seq\` integer NOT NULL,
\`replacement_seq\` integer,
\`revision\` integer DEFAULT 0 NOT NULL,
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
); );
`) `)
+177 -130
View File
@@ -5,7 +5,7 @@ import { and, asc, eq, gt } from "drizzle-orm"
import { Database } from "./database/database" import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql" import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location" import { Location } from "./location"
import { externalID, type ExternalID, withStatics } from "./schema" import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema"
import { Identifier } from "./util/identifier" import { Identifier } from "./util/identifier"
import { LayerNode } from "./effect/layer-node" import { LayerNode } from "./effect/layer-node"
import { isDeepStrictEqual } from "node:util" import { isDeepStrictEqual } from "node:util"
@@ -19,9 +19,16 @@ export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
) )
export type ID = typeof ID.Type export type ID = typeof ID.Type
/**
* Durable aggregate continuation position for embedded replay streams.
* TODO: Decide whether a future HTTP / SDK surface should expose an opaque cursor instead.
*/
export const Cursor = NonNegativeInt.pipe(Schema.brand("EventV2.Cursor"))
export type Cursor = typeof Cursor.Type
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = { export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
readonly type: Type readonly type: Type
readonly durable?: { readonly sync?: {
readonly version: number readonly version: number
readonly aggregate: string readonly aggregate: string
} }
@@ -34,16 +41,20 @@ export type Payload<D extends Definition = Definition> = {
readonly id: ID readonly id: ID
readonly type: D["type"] readonly type: D["type"]
readonly data: Data<D> readonly data: Data<D>
readonly durable?: { /** Durable aggregate order, populated while synchronized events are projected. */
readonly aggregateID: string readonly seq?: number
readonly seq: number readonly version?: number
readonly version: number
}
readonly location?: Location.Ref readonly location?: Location.Ref
readonly metadata?: Record<string, unknown> readonly metadata?: Record<string, unknown>
/** Internal replay marker for projectors that own non-replicated operational state. */
readonly replay?: boolean
} }
export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void> export type Projector<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
type AnyProjector = (event: Payload) => Effect.Effect<void>
export type CommitGuard = (event: Payload) => Effect.Effect<void>
export type Listener = (event: Payload) => Effect.Effect<void>
export type Sync = (event: Payload) => Effect.Effect<void>
export type Unsubscribe = Effect.Effect<void> export type Unsubscribe = Effect.Effect<void>
export type SerializedEvent = { export type SerializedEvent = {
@@ -54,8 +65,13 @@ export type SerializedEvent = {
readonly data: Record<string, unknown> readonly data: Record<string, unknown>
} }
export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDurableEventError>()( export type CursorEvent<E extends Payload = Payload> = {
"EventV2.InvalidDurableEvent", readonly cursor: Cursor
readonly event: E
}
export class InvalidSyncEventError extends Schema.TaggedErrorClass<InvalidSyncEventError>()(
"EventV2.InvalidSyncEvent",
{ {
type: Schema.String, type: Schema.String,
message: Schema.String, message: Schema.String,
@@ -67,11 +83,19 @@ export function versionedType(type: string, version: number) {
} }
export const registry = new Map<string, Definition>() export const registry = new Map<string, Definition>()
const durableRegistry = new Map<string, Definition>() type SyncDefinition = Definition & {
readonly sync: NonNullable<Definition["sync"]>
readonly encode: (data: unknown) => unknown
readonly decode: (data: unknown) => unknown
}
const syncRegistry = new Map<string, SyncDefinition>()
// Synchronized events cross a JSON boundary, so their data schemas must encode and decode without services.
const syncCodec = (definition: Definition) => definition.data as Schema.Codec<unknown, unknown, never, never>
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: { export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
readonly type: Type readonly type: Type
readonly durable?: { readonly sync?: {
readonly version: number readonly version: number
readonly aggregate: string readonly aggregate: string
} }
@@ -82,25 +106,28 @@ export function define<const Type extends string, Fields extends Schema.Struct.F
id: ID, id: ID,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type), type: Schema.Literal(input.type),
durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })), version: Schema.optional(Schema.Number),
location: Schema.optional(Location.Ref), location: Schema.optional(Location.Ref),
data: Data, data: Data,
}).annotate({ identifier: input.type }) }).annotate({ identifier: input.type })
const definition = Object.assign(Payload, { const definition = Object.assign(Payload, {
type: input.type, type: input.type,
...(input.durable === undefined ? {} : { durable: input.durable }), ...(input.sync === undefined ? {} : { sync: input.sync }),
data: Data, data: Data,
}) })
const existing = registry.get(input.type) const existing = registry.get(input.type)
if ( if (input.sync === undefined || existing?.sync === undefined || input.sync.version >= existing.sync.version) {
input.durable === undefined ||
existing?.durable === undefined ||
input.durable.version >= existing.durable.version
) {
registry.set(input.type, definition) registry.set(input.type, definition)
} }
if (input.durable) durableRegistry.set(versionedType(input.type, input.durable.version), definition) if (input.sync)
syncRegistry.set(
versionedType(input.type, input.sync.version),
Object.assign(definition, {
encode: Schema.encodeUnknownSync(syncCodec(definition)),
decode: Schema.decodeUnknownSync(syncCodec(definition)),
}) as SyncDefinition,
)
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
Definition<Type, Schema.Struct<Fields>> Definition<Type, Schema.Struct<Fields>>
} }
@@ -113,7 +140,7 @@ export interface PublishOptions {
readonly id?: ID readonly id?: ID
readonly metadata?: Record<string, unknown> readonly metadata?: Record<string, unknown>
readonly location?: Location.Ref readonly location?: Location.Ref
/** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */ /** Local operational projection committed atomically with a new synchronized event. Not replayed or serialized. */
readonly commit?: (seq: number) => Effect.Effect<void> readonly commit?: (seq: number) => Effect.Effect<void>
} }
@@ -125,10 +152,14 @@ export interface Interface {
) => Effect.Effect<Payload<D>> ) => Effect.Effect<Payload<D>>
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>> readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
readonly all: () => Stream.Stream<Payload> readonly all: () => Stream.Stream<Payload>
readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream<Payload> readonly aggregateEvents: (input: {
/** @deprecated Use `all()` and consume the returned stream. */ readonly aggregateID: string
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe> readonly after?: Cursor
readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void> }) => Stream.Stream<CursorEvent>
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
readonly beforeCommit: (guard: CommitGuard) => Effect.Effect<void>
readonly project: <D extends Definition>(definition: D, projector: Projector<D>) => Effect.Effect<void>
readonly replay: ( readonly replay: (
event: SerializedEvent, event: SerializedEvent,
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }, options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
@@ -151,37 +182,37 @@ export const layerWith = (options?: LayerOptions) =>
Layer.effect( Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const pubsub = { const all = yield* PubSub.unbounded<Payload>()
all: yield* PubSub.unbounded<Payload>(), const synchronized = new Map<string, Set<PubSub.PubSub<void>>>()
durable: new Map<string, Set<PubSub.PubSub<void>>>(), const typed = new Map<string, PubSub.PubSub<Payload>>()
typed: new Map<string, PubSub.PubSub<Payload>>(), const projectors = new Map<string, AnyProjector[]>()
} const commitGuards = new Array<CommitGuard>()
const projectors = new Map<string, Subscriber[]>() const listeners = new Array<Listener>()
const listeners = new Array<Subscriber>() const syncHandlers = new Array<Sync>()
const { db } = yield* Database.Service const { db } = yield* Database.Service
const getOrCreate = (definition: Definition) => const getOrCreate = (definition: Definition) =>
Effect.gen(function* () { Effect.gen(function* () {
const existing = pubsub.typed.get(definition.type) const existing = typed.get(definition.type)
if (existing) return existing if (existing) return existing
const created = yield* PubSub.unbounded<Payload>() const pubsub = yield* PubSub.unbounded<Payload>()
pubsub.typed.set(definition.type, created) typed.set(definition.type, pubsub)
return created return pubsub
}) })
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
Effect.gen(function* () { Effect.gen(function* () {
yield* PubSub.shutdown(pubsub.all) yield* PubSub.shutdown(all)
yield* Effect.forEach( yield* Effect.forEach(
pubsub.durable.values(), synchronized.values(),
(pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }), (pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
{ discard: true }, { discard: true },
) )
yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true }) yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
}), }),
) )
function commitDurableEvent( function commitSyncEvent(
event: Payload, event: Payload,
input?: { input?: {
readonly seq: number readonly seq: number
@@ -193,20 +224,28 @@ export const layerWith = (options?: LayerOptions) =>
) { ) {
return Effect.gen(function* () { return Effect.gen(function* () {
const definition = registry.get(event.type) const definition = registry.get(event.type)
const durable = definition?.durable const sync = definition?.sync
if (durable) { if (sync) {
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate] if (event.version !== sync.version) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Expected event version ${sync.version}, got ${event.version}`,
}),
)
}
const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
if (typeof aggregateID !== "string") { if (typeof aggregateID !== "string") {
yield* Effect.die( yield* Effect.die(
new InvalidDurableEventError({ new InvalidSyncEventError({
type: event.type, type: event.type,
message: `Expected string aggregate field ${durable.aggregate}`, message: `Expected string aggregate field ${sync.aggregate}`,
}), }),
) )
} else { } else {
if (input && input.aggregateID !== aggregateID) { if (input && input.aggregateID !== aggregateID) {
yield* Effect.die( yield* Effect.die(
new InvalidDurableEventError({ new InvalidSyncEventError({
type: event.type, type: event.type,
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`, message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
}), }),
@@ -226,12 +265,12 @@ export const layerWith = (options?: LayerOptions) =>
.get() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
const latest = row?.seq ?? -1 const latest = row?.seq ?? -1
const encoded = Schema.encodeUnknownSync( const encoded = syncRegistry
definition.data as Schema.Codec<unknown, unknown, never, never>, .get(versionedType(definition.type, sync.version))!
)(event.data) as Record<string, unknown> .encode(event.data) as Record<string, unknown>
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) { if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
yield* Effect.die( yield* Effect.die(
new InvalidDurableEventError({ new InvalidSyncEventError({
type: event.type, type: event.type,
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`, message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
}), }),
@@ -246,7 +285,7 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie) .pipe(Effect.orDie)
if ( if (
stored?.id === event.id && stored?.id === event.id &&
stored.type === versionedType(definition.type, durable.version) && stored.type === versionedType(definition.type, sync.version) &&
isDeepStrictEqual(stored.data, encoded) isDeepStrictEqual(stored.data, encoded)
) { ) {
if (input.ownerID && row?.ownerID == null) { if (input.ownerID && row?.ownerID == null) {
@@ -260,7 +299,7 @@ export const layerWith = (options?: LayerOptions) =>
return return
} }
yield* Effect.die( yield* Effect.die(
new InvalidDurableEventError({ new InvalidSyncEventError({
type: event.type, type: event.type,
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`, message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
}), }),
@@ -272,7 +311,7 @@ export const layerWith = (options?: LayerOptions) =>
const seq = input?.seq ?? latest + 1 const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) { if (input && seq !== latest + 1) {
yield* Effect.die( yield* Effect.die(
new InvalidDurableEventError({ new InvalidSyncEventError({
type: event.type, type: event.type,
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`, message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}), }),
@@ -286,17 +325,16 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (stored) if (stored)
yield* Effect.die( yield* Effect.die(
new InvalidDurableEventError({ new InvalidSyncEventError({
type: event.type, type: event.type,
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`, message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}), }),
) )
const committed = { for (const guard of commitGuards) {
...event, yield* guard(event)
durable: { aggregateID, seq, version: durable.version }, }
} as Payload
for (const projector of list) { for (const projector of list) {
yield* projector(committed) yield* projector({ ...event, seq } as Payload)
} }
if (commit) yield* commit(seq) if (commit) yield* commit(seq)
yield* db yield* db
@@ -318,7 +356,7 @@ export const layerWith = (options?: LayerOptions) =>
id: event.id, id: event.id,
aggregate_id: aggregateID, aggregate_id: aggregateID,
seq, seq,
type: versionedType(definition.type, durable.version), type: versionedType(definition.type, sync.version),
data: encoded, data: encoded,
}, },
]) ])
@@ -331,8 +369,8 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (committed) { if (committed) {
yield* Effect.forEach( yield* Effect.forEach(
pubsub.durable.get(committed.aggregateID) ?? [], synchronized.get(committed.aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined), (pubsub) => PubSub.publish(pubsub, undefined),
{ discard: true }, { discard: true },
) )
} }
@@ -346,25 +384,19 @@ export const layerWith = (options?: LayerOptions) =>
function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) { function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) {
return Effect.gen(function* () { return Effect.gen(function* () {
const definition = registry.get(event.type) const durable = registry.get(event.type)?.sync !== undefined
if (!definition?.durable && commit) if (!durable && commit)
return yield* Effect.die( return yield* Effect.die(
new InvalidDurableEventError({ new InvalidSyncEventError({
type: event.type, type: event.type,
message: "Local commit hooks require a durable event", message: "Local commit hooks require a synchronized event",
}), }),
) )
if (definition?.durable) { if (durable) {
const committed = yield* commitDurableEvent(event as Payload, undefined, commit) const committed = yield* commitSyncEvent(event as Payload, undefined, commit)
if (committed) { if (committed) {
event = { event = { ...event, seq: committed.seq }
...event, yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true })
durable: {
aggregateID: committed.aggregateID,
seq: committed.seq,
version: definition.durable.version,
},
}
yield* notify(event as Payload, true) yield* notify(event as Payload, true)
return event return event
} }
@@ -374,11 +406,12 @@ export const layerWith = (options?: LayerOptions) =>
}) })
} }
const observe = (event: Payload, observer: (event: Payload) => Effect.Effect<void>) => const observe = (event: Payload, kind: "sync" | "listener", observer: (event: Payload) => Effect.Effect<void>) =>
Effect.suspend(() => observer(event)).pipe( Effect.suspend(() => observer(event)).pipe(
Effect.catchCauseIf( Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause), (cause) => !Cause.hasInterrupts(cause),
(cause) => Effect.logError("Event listener failed", { eventID: event.id, eventType: event.type, cause }), (cause) =>
Effect.logError("Event observer failed", { eventID: event.id, eventType: event.type, kind, cause }),
), ),
) )
@@ -386,12 +419,12 @@ export const layerWith = (options?: LayerOptions) =>
return Effect.gen(function* () { return Effect.gen(function* () {
yield* Effect.forEach( yield* Effect.forEach(
listeners, listeners,
(listener) => (isolateListeners ? observe(event, listener) : listener(event)), (listener) => (isolateListeners ? observe(event, "listener", listener) : listener(event)),
{ discard: true }, { discard: true },
) )
const typed = pubsub.typed.get(event.type) const pubsub = typed.get(event.type)
if (typed) yield* PubSub.publish(typed, event) if (pubsub) yield* PubSub.publish(pubsub, event)
yield* PubSub.publish(pubsub.all, event) yield* PubSub.publish(all, event)
}) })
} }
@@ -408,6 +441,7 @@ export const layerWith = (options?: LayerOptions) =>
id: options?.id ?? ID.create(), id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}), ...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type, type: definition.type,
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
...(location ? { location } : {}), ...(location ? { location } : {}),
data, data,
} as Payload<D>, } as Payload<D>,
@@ -421,37 +455,27 @@ export const layerWith = (options?: LayerOptions) =>
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }, options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
) { ) {
return Effect.gen(function* () { return Effect.gen(function* () {
const definition = durableRegistry.get(event.type) const definition = syncRegistry.get(event.type)
if (!definition?.durable) { if (!definition) {
yield* Effect.die( yield* Effect.die(
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }), new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }),
) )
} else { } else {
const payload = { const payload = {
id: event.id, id: event.id,
type: definition.type, type: definition.type,
data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)( version: definition.sync.version,
event.data, data: definition.decode(event.data),
), replay: true,
} as Payload } as Payload
const committed = yield* commitDurableEvent(payload, { const committed = yield* commitSyncEvent(payload, {
seq: event.seq, seq: event.seq,
aggregateID: event.aggregateID, aggregateID: event.aggregateID,
ownerID: options?.ownerID, ownerID: options?.ownerID,
strictOwner: options?.strictOwner, strictOwner: options?.strictOwner,
}) })
if (committed && options?.publish) { if (committed && options?.publish) {
yield* notify( yield* notify({ ...payload, seq: committed.seq }, true)
{
...payload,
durable: {
aggregateID: committed.aggregateID,
seq: committed.seq,
version: definition.durable.version,
},
},
true,
)
} }
} }
}) })
@@ -466,7 +490,7 @@ export const layerWith = (options?: LayerOptions) =>
if (!source) return undefined if (!source) return undefined
if (events.some((event) => event.aggregateID !== source)) { if (events.some((event) => event.aggregateID !== source)) {
yield* Effect.die( yield* Effect.die(
new InvalidDurableEventError({ new InvalidSyncEventError({
type: events[0]?.type ?? "unknown", type: events[0]?.type ?? "unknown",
message: "Replay events must belong to the same aggregate", message: "Replay events must belong to the same aggregate",
}), }),
@@ -477,7 +501,7 @@ export const layerWith = (options?: LayerOptions) =>
const seq = start + index const seq = start + index
if (event.seq !== seq) { if (event.seq !== seq) {
yield* Effect.die( yield* Effect.die(
new InvalidDurableEventError({ new InvalidSyncEventError({
type: event.type, type: event.type,
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`, message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
}), }),
@@ -516,18 +540,22 @@ export const layerWith = (options?: LayerOptions) =>
Stream.map((event) => event as Payload<D>), Stream.map((event) => event as Payload<D>),
) )
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all) const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
const decodeSerializedEvent = (event: SerializedEvent): Payload => { const decodeSerializedEvent = (event: SerializedEvent): CursorEvent => {
const definition = durableRegistry.get(event.type) const definition = syncRegistry.get(event.type)
if (!definition?.durable) { if (!definition) {
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }) throw new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` })
} }
return { return {
id: event.id, cursor: Cursor.make(event.seq),
type: definition.type, event: {
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version }, id: event.id,
data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(event.data), type: definition.type,
version: definition.sync.version,
seq: event.seq,
data: definition.decode(event.data),
},
} }
} }
@@ -555,40 +583,43 @@ export const layerWith = (options?: LayerOptions) =>
), ),
) )
const subscribeDurable = (aggregateID: string) => const subscribeSynchronized = (aggregateID: string) =>
Effect.gen(function* () { Effect.gen(function* () {
const wake = yield* PubSub.sliding<void>(1) const pubsub = yield* PubSub.sliding<void>(1)
const subscription = yield* PubSub.subscribe(wake) const subscription = yield* PubSub.subscribe(pubsub)
yield* Effect.acquireRelease( yield* Effect.acquireRelease(
Effect.sync(() => { Effect.sync(() => {
const wakes = pubsub.durable.get(aggregateID) ?? new Set() const pubsubs = synchronized.get(aggregateID) ?? new Set()
wakes.add(wake) pubsubs.add(pubsub)
pubsub.durable.set(aggregateID, wakes) synchronized.set(aggregateID, pubsubs)
}), }),
() => () =>
Effect.sync(() => { Effect.sync(() => {
const wakes = pubsub.durable.get(aggregateID) const pubsubs = synchronized.get(aggregateID)
wakes?.delete(wake) pubsubs?.delete(pubsub)
if (wakes?.size === 0) pubsub.durable.delete(aggregateID) if (pubsubs?.size === 0) synchronized.delete(aggregateID)
}).pipe(Effect.andThen(PubSub.shutdown(wake))), }).pipe(Effect.andThen(PubSub.shutdown(pubsub))),
) )
return subscription return subscription
}) })
const durable = (input: { readonly aggregateID: string; readonly after?: number }): Stream.Stream<Payload> => const streamEvents = (input: {
readonly aggregateID: string
readonly after?: Cursor
}): Stream.Stream<CursorEvent> =>
Stream.unwrap( Stream.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
const wakes = yield* subscribeDurable(input.aggregateID) const synchronized = yield* subscribeSynchronized(input.aggregateID)
let sequence = input.after ?? -1 let cursor = input.after ?? -1
const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe( const read = Effect.suspend(() => readAfter(input.aggregateID, cursor)).pipe(
Effect.tap((events) => Effect.tap((events) =>
Effect.sync(() => { Effect.sync(() => {
sequence = events.at(-1)?.durable?.seq ?? sequence cursor = events.at(-1)?.cursor ?? cursor
}), }),
), ),
) )
const historical = yield* read const historical = yield* read
const live = Stream.fromSubscription(wakes).pipe( const live = Stream.fromSubscription(synchronized).pipe(
Stream.mapEffect(() => read), Stream.mapEffect(() => read),
Stream.flattenIterable, Stream.flattenIterable,
) )
@@ -596,7 +627,7 @@ export const layerWith = (options?: LayerOptions) =>
}), }),
) )
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> => const listen = (listener: Listener): Effect.Effect<Unsubscribe> =>
Effect.sync(() => { Effect.sync(() => {
listeners.push(listener) listeners.push(listener)
return Effect.sync(() => { return Effect.sync(() => {
@@ -605,7 +636,21 @@ export const layerWith = (options?: LayerOptions) =>
}) })
}) })
const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> => const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
syncHandlers.push(handler)
return Effect.sync(() => {
const index = syncHandlers.indexOf(handler)
if (index >= 0) syncHandlers.splice(index, 1)
})
})
const beforeCommit = (guard: CommitGuard): Effect.Effect<void> =>
Effect.sync(() => {
commitGuards.push(guard)
})
const project = <D extends Definition>(definition: D, projector: Projector<D>): Effect.Effect<void> =>
Effect.sync(() => { Effect.sync(() => {
const list = projectors.get(definition.type) ?? [] const list = projectors.get(definition.type) ?? []
list.push((event) => projector(event as Payload<D>)) list.push((event) => projector(event as Payload<D>))
@@ -616,8 +661,10 @@ export const layerWith = (options?: LayerOptions) =>
publish, publish,
subscribe, subscribe,
all: streamAll, all: streamAll,
durable, aggregateEvents: streamEvents,
sync,
listen, listen,
beforeCommit,
project, project,
replay, replay,
replayAll, replayAll,
+1 -1
View File
@@ -123,6 +123,6 @@ const baseLayer = Layer.effect(
}), }),
) )
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer)) export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer))
export const locationLayer = layer export const locationLayer = layer
+1 -1
View File
@@ -232,6 +232,6 @@ export const fffLayer = Layer.effect(
}), }),
) )
export const locationLayer = Layer.unwrap( export const defaultLayer = Layer.unwrap(
Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)), Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
) )
+89 -77
View File
@@ -1,19 +1,7 @@
export * as Integration from "./integration" export * as Integration from "./integration"
import { import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Schedule, Schema, Scope, SynchronizedRef } from "effect"
Cause, import { castDraft, enableMapSet, type Draft } from "immer"
Clock,
Context,
Duration,
Effect,
Exit,
Layer,
Schedule,
Schema,
Scope,
SynchronizedRef,
Types,
} from "effect"
import { Credential } from "./credential" import { Credential } from "./credential"
import { IntegrationSchema } from "./integration/schema" import { IntegrationSchema } from "./integration/schema"
import { withStatics } from "./schema" import { withStatics } from "./schema"
@@ -54,14 +42,12 @@ export const SelectPrompt = Schema.Struct({
type: Schema.Literal("select"), type: Schema.Literal("select"),
key: Schema.String, key: Schema.String,
message: Schema.String, message: Schema.String,
options: Schema.mutable( options: Schema.Array(
Schema.Array( Schema.Struct({
Schema.Struct({ label: Schema.String,
label: Schema.String, value: Schema.String,
value: Schema.String, hint: Schema.optional(Schema.String),
hint: Schema.optional(Schema.String), }),
}),
),
), ),
when: Schema.optional(When), when: Schema.optional(When),
}).annotate({ identifier: "Integration.SelectPrompt" }) }).annotate({ identifier: "Integration.SelectPrompt" })
@@ -74,7 +60,7 @@ export const OAuthMethod = Schema.Struct({
id: MethodID, id: MethodID,
type: Schema.Literal("oauth"), type: Schema.Literal("oauth"),
label: Schema.String, label: Schema.String,
prompts: Schema.optional(Schema.mutable(Schema.Array(Prompt))), prompts: Schema.optional(Schema.Array(Prompt)),
}).annotate({ identifier: "Integration.OAuthMethod" }) }).annotate({ identifier: "Integration.OAuthMethod" })
export type OAuthMethod = typeof OAuthMethod.Type export type OAuthMethod = typeof OAuthMethod.Type
@@ -86,7 +72,7 @@ export type KeyMethod = typeof KeyMethod.Type
export const EnvMethod = Schema.Struct({ export const EnvMethod = Schema.Struct({
type: Schema.Literal("env"), type: Schema.Literal("env"),
names: Schema.mutable(Schema.Array(Schema.String)), names: Schema.Array(Schema.String),
}).annotate({ identifier: "Integration.EnvMethod" }) }).annotate({ identifier: "Integration.EnvMethod" })
export type EnvMethod = typeof EnvMethod.Type export type EnvMethod = typeof EnvMethod.Type
@@ -96,8 +82,8 @@ export type Method = typeof Method.Type
export class Info extends Schema.Class<Info>("Integration.Info")({ export class Info extends Schema.Class<Info>("Integration.Info")({
id: ID, id: ID,
name: Schema.String, name: Schema.String,
methods: Schema.mutable(Schema.Array(Method)), methods: Schema.Array(Method),
connections: Schema.mutable(Schema.Array(IntegrationConnection.Info)), connections: Schema.Array(IntegrationConnection.Info),
}) {} }) {}
export type Inputs = Readonly<{ [key: string]: string }> export type Inputs = Readonly<{ [key: string]: string }>
@@ -108,11 +94,11 @@ export type OAuthAuthorization = {
} & ( } & (
| { | {
readonly mode: "auto" readonly mode: "auto"
readonly callback: Effect.Effect<Credential.Value, unknown> readonly callback: Effect.Effect<Credential.Info, unknown>
} }
| { | {
readonly mode: "code" readonly mode: "code"
readonly callback: (code: string) => Effect.Effect<Credential.Value, unknown> readonly callback: (code: string) => Effect.Effect<Credential.Info, unknown>
} }
) )
@@ -186,19 +172,19 @@ export type Ref = {
} }
type Entry = { type Entry = {
ref: Types.DeepMutable<Ref> ref: Ref
methods: Types.DeepMutable<Method>[] methods: Method[]
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>> implementations: Map<MethodID, OAuthImplementation>
} }
type Data = { type Data = {
integrations: Map<ID, Entry> integrations: Map<ID, Entry>
} }
export type Draft = { export type Editor = {
list: () => readonly Ref[] list: () => readonly Ref[]
get: (id: ID) => Ref | undefined get: (id: ID) => Ref | undefined
update: (id: ID, update: (integration: Types.DeepMutable<Ref>) => void) => void update: (id: ID, update: (integration: Draft<Ref>) => void) => void
remove: (id: ID) => void remove: (id: ID) => void
method: { method: {
list: (integrationID: ID) => readonly Method[] list: (integrationID: ID) => readonly Method[]
@@ -207,13 +193,18 @@ export type Draft = {
} }
} }
export interface Interface extends State.Transformable<Draft> { export interface Interface {
/** Registers a scoped transform over the integration registry. */ /** 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. */ /** Returns one integration with its methods and current connections. */
readonly get: (id: ID) => Effect.Effect<Info | undefined> readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Returns all integrations with their methods and current connections. */ /** Returns all integrations with their methods and current connections. */
readonly list: () => Effect.Effect<Info[]> readonly list: () => Effect.Effect<Info[]>
readonly connection: { readonly connection: {
/** Returns active connections for every registered or credential-backed integration. */
readonly list: () => Effect.Effect<Map<ID, IntegrationConnection.Info>>
/** Returns the active connection for one integration. */ /** Returns the active connection for one integration. */
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined> readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
/** Runs a key method and stores the resulting credential. */ /** Runs a key method and stores the resulting credential. */
@@ -239,7 +230,7 @@ export interface Interface extends State.Transformable<Draft> {
/** Updates a stored credential exposed as a connection. */ /** Updates a stored credential exposed as a connection. */
readonly update: ( readonly update: (
credentialID: Credential.ID, credentialID: Credential.ID,
updates: Partial<Pick<Credential.Info, "label">>, updates: Partial<Pick<Credential.Stored, "label">>,
) => Effect.Effect<void> ) => Effect.Effect<void>
/** Removes a stored credential connection. */ /** Removes a stored credential connection. */
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void> readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
@@ -261,6 +252,8 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
enableMapSet()
const attemptLifetime = Duration.toMillis(Duration.minutes(10)) const attemptLifetime = Duration.toMillis(Duration.minutes(10))
const terminalRetention = Duration.toMillis(Duration.minutes(1)) const terminalRetention = Duration.toMillis(Duration.minutes(1))
const scrubInterval = Duration.seconds(30) const scrubInterval = Duration.seconds(30)
@@ -291,17 +284,15 @@ export const locationLayer = Layer.effect(
const events = yield* EventV2.Service const events = yield* EventV2.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>()) const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
const state = State.create<Data, Draft>({ const state = State.create<Data, Editor>({
initial: () => ({ integrations: new Map<ID, Entry>() }), initial: () => ({ integrations: new Map<ID, Entry>() }),
draft: (draft) => ({ editor: (draft) => ({
list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[], list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[],
get: (id) => draft.integrations.get(id)?.ref as Ref | undefined, get: (id) => draft.integrations.get(id)?.ref as Ref | undefined,
update: (id, update) => { update: (id, update) => {
const current = draft.integrations.get(id) ?? { const current =
ref: { id, name: id }, draft.integrations.get(id) ??
methods: [], castDraft({ ref: { id, name: id } as Ref, methods: [], implementations: new Map() })
implementations: new Map(),
}
if (!draft.integrations.has(id)) draft.integrations.set(id, current) if (!draft.integrations.has(id)) draft.integrations.set(id, current)
update(current.ref) update(current.ref)
current.ref.id = id current.ref.id = id
@@ -310,14 +301,16 @@ export const locationLayer = Layer.effect(
method: { method: {
list: (integrationID) => (draft.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [], list: (integrationID) => (draft.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [],
update: (implementation) => { update: (implementation) => {
const current = draft.integrations.get(implementation.integrationID) ?? { const current =
ref: { draft.integrations.get(implementation.integrationID) ??
id: implementation.integrationID, castDraft({
name: implementation.integrationID, ref: {
}, id: implementation.integrationID,
methods: [], name: implementation.integrationID,
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(), } as Ref,
} methods: [],
implementations: new Map<MethodID, OAuthImplementation>(),
})
if (!draft.integrations.has(implementation.integrationID)) { if (!draft.integrations.has(implementation.integrationID)) {
draft.integrations.set(implementation.integrationID, current) draft.integrations.set(implementation.integrationID, current)
} }
@@ -326,13 +319,10 @@ export const locationLayer = Layer.effect(
if (method.type !== "oauth" || implementation.method.type !== "oauth") return true if (method.type !== "oauth" || implementation.method.type !== "oauth") return true
return method.id === implementation.method.id return method.id === implementation.method.id
}) })
if (index === -1) current.methods.push(implementation.method as Types.DeepMutable<Method>) if (index === -1) current.methods.push(castDraft(implementation.method))
else current.methods[index] = implementation.method as Types.DeepMutable<Method> else current.methods[index] = castDraft(implementation.method)
if (implementation.method.type === "oauth") { if (isOAuthImplementation(implementation)) {
current.implementations.set( current.implementations.set(implementation.method.id, castDraft(implementation))
implementation.method.id,
implementation as Types.DeepMutable<OAuthImplementation>,
)
} }
}, },
remove: (integrationID, method) => { remove: (integrationID, method) => {
@@ -351,27 +341,39 @@ export const locationLayer = Layer.effect(
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid), finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
}) })
const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => { const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => {
const credentials = saved const connected = saved.map((credential) => ({
.map((credential) => ({ type: "credential" as const,
type: "credential" as const, id: credential.id,
id: credential.id, label: credential.label,
label: credential.label, }))
})) const detected = entry.methods
.toReversed()
const env = (entry?.methods ?? [])
.filter((method) => method.type === "env") .filter((method) => method.type === "env")
.flatMap((method) => method.names.filter((name) => process.env[name])) .flatMap((method) => method.names.filter((name) => process.env[name]))
.map((name) => ({ type: "env" as const, name })) .map((name) => ({ type: "env" as const, name }))
return [...credentials, ...env] return [...connected, ...detected]
} }
const project = (entry: Entry, connections: IntegrationConnection.Info[]) => const activeConnection = (
entry: Entry | undefined,
saved: readonly Credential.Stored[],
): IntegrationConnection.Info | undefined => {
const credential = saved.at(-1)
if (credential) return { type: "credential", id: credential.id, label: credential.label }
if (!entry) return
const name = entry.methods
.filter((method) => method.type === "env")
.flatMap((method) => method.names)
.find((name) => process.env[name])
if (name) return { type: "env", name }
}
const project = (entry: Entry, saved: readonly Credential.Stored[]) =>
new Info({ new Info({
id: entry.ref.id, id: entry.ref.id,
name: entry.ref.name, name: entry.ref.name,
methods: entry.methods, methods: entry.methods,
connections, connections: connections(entry, saved),
}) })
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) => const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
@@ -385,7 +387,7 @@ export const locationLayer = Layer.effect(
return error instanceof Error ? error.message : String(error) return error instanceof Error ? error.message : String(error)
} }
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Value, unknown>) { const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Info, unknown>) {
const now = yield* Clock.currentTimeMillis const now = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(attempts, (current) => { const result = yield* SynchronizedRef.modify(attempts, (current) => {
const attempt = current.get(attemptID) const attempt = current.get(attemptID)
@@ -432,22 +434,32 @@ export const locationLayer = Layer.effect(
return Service.of({ return Service.of({
transform: state.transform, transform: state.transform,
rebuild: state.rebuild, update: state.update,
get: Effect.fn("Integration.get")(function* (id) { get: Effect.fn("Integration.get")(function* (id) {
const entry = state.get().integrations.get(id) const entry = state.get().integrations.get(id)
if (!entry) return undefined if (!entry) return undefined
return project(entry, resolveConnections(entry, yield* credentials.list(id))) return project(entry, yield* credentials.list(id))
}), }),
list: Effect.fn("Integration.list")(function* () { list: Effect.fn("Integration.list")(function* () {
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID) return (yield* Effect.forEach(state.get().integrations.values(), (entry) =>
return Array.from(state.get().integrations.values(), (entry) => Effect.gen(function* () {
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])), return project(entry, yield* credentials.list(entry.ref.id))
).toSorted((a, b) => a.name.localeCompare(b.name)) }),
)).toSorted((a, b) => a.name.localeCompare(b.name))
}), }),
connection: { connection: {
list: Effect.fn("Integration.connection.list")(function* () {
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
return new Map(
new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => {
const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? [])
return connection ? [[id, connection] as const] : []
}),
)
}),
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) { forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
const entry = state.get().integrations.get(id) const entry = state.get().integrations.get(id)
return resolveConnections(entry, yield* credentials.list(id))[0] return activeConnection(entry, yield* credentials.list(id))
}), }),
key: Effect.fn("Integration.connection.key")(function* (input) { key: Effect.fn("Integration.connection.key")(function* (input) {
const method = state const method = state
+1 -1
View File
@@ -33,7 +33,7 @@ export type Request = typeof Request.Type
interface MutableRequest { interface MutableRequest {
headers: Record<string, string> headers: Record<string, string>
body: Record<string, unknown> body: Record<string, unknown>
generation?: Record<string, unknown> generation?: Generation
options?: Record<string, unknown> options?: Record<string, unknown>
} }
+8 -11
View File
@@ -1,4 +1,5 @@
import { Schema, Types } from "effect" import { DateTime, Schema } from "effect"
import { DateTimeUtcFromMillis } from "effect/Schema"
import { ProviderV2 } from "./provider" import { ProviderV2 } from "./provider"
import { ModelRequest } from "./model-request" import { ModelRequest } from "./model-request"
@@ -15,8 +16,8 @@ export type Family = typeof Family.Type
export const Capabilities = Schema.Struct({ export const Capabilities = Schema.Struct({
tools: Schema.Boolean, tools: Schema.Boolean,
// mime patterns, image, audio, video/*, text/* // mime patterns, image, audio, video/*, text/*
input: Schema.String.pipe(Schema.Array, Schema.mutable), input: Schema.String.pipe(Schema.Array),
output: Schema.String.pipe(Schema.Array, Schema.mutable), output: Schema.String.pipe(Schema.Array),
}) })
export type Capabilities = typeof Capabilities.Type export type Capabilities = typeof Capabilities.Type
@@ -66,11 +67,11 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
variants: Schema.Struct({ variants: Schema.Struct({
id: VariantID, id: VariantID,
...ModelRequest.Request.fields, ...ModelRequest.Request.fields,
}).pipe(Schema.Array, Schema.mutable), }).pipe(Schema.Array),
time: Schema.Struct({ time: Schema.Struct({
released: Schema.Finite, released: DateTimeUtcFromMillis,
}), }),
cost: Cost.pipe(Schema.Array, Schema.mutable), cost: Cost.pipe(Schema.Array),
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
enabled: Schema.Boolean, enabled: Schema.Boolean,
limit: Schema.Struct({ limit: Schema.Struct({
@@ -102,7 +103,7 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
}, },
variants: [], variants: [],
time: { time: {
released: 0, released: DateTime.makeUnsafe(0),
}, },
cost: [], cost: [],
status: "active", status: "active",
@@ -115,10 +116,6 @@ 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 } { export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
const [providerID, ...modelID] = input.split("/") const [providerID, ...modelID] = input.split("/")
return { return {
+27 -23
View File
@@ -20,7 +20,7 @@ export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedErr
export interface EntryPoint { export interface EntryPoint {
readonly directory: string readonly directory: string
readonly entrypoint?: string readonly entrypoint: Option.Option<string>
} }
export interface Interface { export interface Interface {
@@ -34,7 +34,7 @@ export interface Interface {
}[] }[]
}, },
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError> ) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined> readonly which: (pkg: string, bin?: string) => Effect.Effect<Option.Option<string>>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
@@ -47,11 +47,12 @@ export function sanitize(pkg: string) {
} }
const resolveEntryPoint = (name: string, dir: string): EntryPoint => { const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
let entrypoint: string | undefined let entrypoint: Option.Option<string>
try { try {
entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir) const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
entrypoint = Option.some(resolved)
} catch { } catch {
entrypoint = undefined entrypoint = Option.none()
} }
return { return {
directory: dir, directory: dir,
@@ -129,7 +130,7 @@ export const layer = Layer.effect(
const first = tree.edgesOut.values().next().value?.to const first = tree.edgesOut.values().next().value?.to
if (!first) { if (!first) {
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name)) const result = resolveEntryPoint(name, path.join(dir, "node_modules", name))
if (result.entrypoint) return result if (Option.isSome(result.entrypoint)) return result
return yield* new InstallFailedError({ add: [pkg], dir }) return yield* new InstallFailedError({ add: [pkg], dir })
} }
return resolveEntryPoint(first.name, first.path) return resolveEntryPoint(first.name, first.path)
@@ -218,24 +219,22 @@ export const layer = Layer.effect(
return Option.some(files[0]) return Option.some(files[0])
}) })
return Option.getOrUndefined( return yield* Effect.gen(function* () {
yield* Effect.gen(function* () { const bin = yield* pick()
const bin = yield* pick() if (Option.isSome(bin)) {
if (Option.isSome(bin)) { return Option.some(path.join(binDir, bin.value))
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() const resolved = yield* pick()
if (Option.isNone(resolved)) return Option.none<string>() if (Option.isNone(resolved)) return Option.none<string>()
return Option.some(path.join(binDir, resolved.value)) return Option.some(path.join(binDir, resolved.value))
}).pipe( }).pipe(
Effect.scoped, Effect.scoped,
Effect.orElseSucceed(() => Option.none<string>()), Effect.orElseSucceed(() => Option.none<string>()),
),
) )
}) })
@@ -262,9 +261,14 @@ export async function install(...args: Parameters<Interface["install"]>) {
} }
export async function add(...args: Parameters<Interface["add"]>) { export async function add(...args: Parameters<Interface["add"]>) {
return runPromise((svc) => svc.add(...args)) const entry = await runPromise((svc) => svc.add(...args))
return {
directory: entry.directory,
entrypoint: Option.getOrUndefined(entry.entrypoint),
}
} }
export async function which(...args: Parameters<Interface["which"]>) { export async function which(...args: Parameters<Interface["which"]>) {
return runPromise((svc) => svc.which(...args)) const resolved = await runPromise((svc) => svc.which(...args))
return Option.getOrUndefined(resolved)
} }
+1 -1
View File
@@ -12,5 +12,5 @@ export const Rule = Schema.Struct({
}).annotate({ identifier: "PermissionV2.Rule" }) }).annotate({ identifier: "PermissionV2.Rule" })
export type Rule = typeof Rule.Type export type Rule = typeof Rule.Type
export const Ruleset = Schema.mutable(Schema.Array(Rule)).annotate({ identifier: "PermissionV2.Ruleset" }) export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" })
export type Ruleset = typeof Ruleset.Type export type Ruleset = typeof Ruleset.Type
+23 -54
View File
@@ -7,7 +7,6 @@ import type { ModelV2 } from "./model"
import type { Catalog } from "./catalog" import type { Catalog } from "./catalog"
import { EventV2 } from "./event" import { EventV2 } from "./event"
import { KeyedMutex } from "./effect/keyed-mutex" import { KeyedMutex } from "./effect/keyed-mutex"
import { State } from "./state"
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
export type ID = typeof ID.Type export type ID = typeof ID.Type
@@ -23,7 +22,7 @@ export const Event = {
type HookSpec = { type HookSpec = {
"catalog.transform": { "catalog.transform": {
input: Catalog.Draft input: Catalog.Editor
output: {} output: {}
} }
"aisdk.language": { "aisdk.language": {
@@ -63,16 +62,18 @@ export type HookFunctions = {
export type HookInput<Name extends keyof Hooks> = HookSpec[Name]["input"] export type HookInput<Name extends keyof Hooks> = HookSpec[Name]["input"]
export type HookOutput<Name extends keyof Hooks> = HookSpec[Name]["output"] 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 { export interface Interface {
readonly add: (input: { readonly add: (input: {
id: string id: ID
effect: Effect.Effect<void | HookFunctions, never, Scope.Scope> effect: Effect.Effect<void | HookFunctions, never, Scope.Scope>
}) => Effect.Effect<void, never, never> }) => Effect.Effect<void, never, never>
readonly remove: (id: ID) => Effect.Effect<void> 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>( readonly triggerFor: <Name extends keyof Hooks>(
id: ID, id: ID,
name: Name, name: Name,
@@ -96,48 +97,35 @@ export const layer = Layer.effect(
hooks: HookFunctions hooks: HookFunctions
scope: Scope.Closeable 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 events = yield* EventV2.Service
const scope = yield* Scope.Scope
const locks = KeyedMutex.makeUnsafe<ID>() const locks = KeyedMutex.makeUnsafe<ID>()
const scope = yield* Scope.make()
// One registry-owned scope lets shutdown remove every plugin transform in one batch.
yield* Effect.addFinalizer((exit) =>
Effect.gen(function* () {
hooks = []
yield* State.batch(Scope.close(scope, exit))
}),
)
const svc = Service.of({ const svc = Service.of({
add: Effect.fn("Plugin.add")(function* (input) { add: Effect.fn("Plugin.add")(function* (input) {
const id = ID.make(input.id) yield* locks.withLock(input.id)(
yield* locks.withLock(id)(
Effect.gen(function* () { Effect.gen(function* () {
const existing = hooks.find((item) => item.id === id) const existing = hooks.find((item) => item.id === input.id)
if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore) if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
const childScope = yield* Scope.fork(scope) const childScope = yield* Scope.fork(scope)
const result = yield* input.effect.pipe( const result = yield* input.effect.pipe(
Scope.provide(childScope), Scope.provide(childScope),
Effect.withSpan("Plugin.load", { Effect.withSpan("Plugin.load", {
attributes: { attributes: {
"plugin.id": id, "plugin.id": input.id,
}, },
}), }),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(childScope, exit) : Effect.void)), Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(childScope, exit) : Effect.void)),
) )
const next = { hooks = [
id, ...hooks.filter((item) => item.id !== input.id),
hooks: result ?? {}, {
scope: childScope, id: input.id,
} hooks: result ?? {},
hooks = existing ? hooks.with(hooks.indexOf(existing), next) : [...hooks, next] scope: childScope,
yield* events.publish(Event.Added, { id }) },
]
yield* events.publish(Event.Added, { id: input.id })
}), }),
) )
}), }),
@@ -172,12 +160,6 @@ 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) { for (const [field, draft] of draftEntries) {
event[field] = finishDraft(draft) event[field] = finishDraft(draft)
} }
@@ -189,23 +171,10 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const existing = hooks.find((item) => item.id === id) const existing = hooks.find((item) => item.id === id)
hooks = hooks.filter((item) => item.id !== id) hooks = hooks.filter((item) => item.id !== id)
if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore) if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
}), }),
) )
}), }),
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 return svc
}), }),
+16 -13
View File
@@ -1,11 +1,12 @@
export * as AgentPlugin from "./agent" export * as AgentPlugin from "./agent"
import path from "path" import path from "path"
import { define } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect" import { Effect } from "effect"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
import { Global } from "../global" import { Global } from "../global"
import { Location } from "../location"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { PluginV2 } from "../plugin"
const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*") const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*")
const BUILD_SYSTEM = const BUILD_SYSTEM =
@@ -96,10 +97,12 @@ Rules:
- If the conversation ends with an unanswered question to the user, preserve that exact question - 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` - 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 = define({ export const Plugin = PluginV2.define({
id: "agent", id: PluginV2.ID.make("agent"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
const worktree = ctx.location.directory const agent = yield* AgentV2.Service
const location = yield* Location.Service
const worktree = location.directory
const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")] const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")]
const readonlyExternalDirectory: PermissionV2.Ruleset = [ const readonlyExternalDirectory: PermissionV2.Ruleset = [
{ action: "external_directory", resource: "*", effect: "ask" }, { action: "external_directory", resource: "*", effect: "ask" },
@@ -119,8 +122,8 @@ export const Plugin = define({
{ action: "read", resource: "*.env.example", effect: "allow" }, { action: "read", resource: "*.env.example", effect: "allow" },
] ]
yield* ctx.agent.transform((draft) => { yield* agent.update((editor) => {
draft.update(AgentV2.defaultID, (item) => { editor.update(AgentV2.defaultID, (item) => {
item.description = "The default agent. Executes tools based on configured permissions." item.description = "The default agent. Executes tools based on configured permissions."
item.system ??= BUILD_SYSTEM item.system ??= BUILD_SYSTEM
item.mode = "primary" item.mode = "primary"
@@ -132,7 +135,7 @@ export const Plugin = define({
) )
}) })
draft.update(AgentV2.ID.make("plan"), (item) => { editor.update(AgentV2.ID.make("plan"), (item) => {
item.description = "Plan mode. Disallows all edit tools." item.description = "Plan mode. Disallows all edit tools."
item.mode = "primary" item.mode = "primary"
item.permissions.push( item.permissions.push(
@@ -151,14 +154,14 @@ export const Plugin = define({
) )
}) })
draft.update(AgentV2.ID.make("general"), (item) => { editor.update(AgentV2.ID.make("general"), (item) => {
item.description = 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." "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.mode = "subagent"
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "todowrite", resource: "*", effect: "deny" }])) item.permissions.push(...PermissionV2.merge(defaults, [{ action: "todowrite", resource: "*", effect: "deny" }]))
}) })
draft.update(AgentV2.ID.make("explore"), (item) => { editor.update(AgentV2.ID.make("explore"), (item) => {
item.description = 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.' '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 item.system = PROMPT_EXPLORE
@@ -179,21 +182,21 @@ export const Plugin = define({
) )
}) })
draft.update(AgentV2.ID.make("compaction"), (item) => { editor.update(AgentV2.ID.make("compaction"), (item) => {
item.mode = "primary" item.mode = "primary"
item.hidden = true item.hidden = true
item.system = PROMPT_COMPACTION item.system = PROMPT_COMPACTION
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
}) })
draft.update(AgentV2.ID.make("title"), (item) => { editor.update(AgentV2.ID.make("title"), (item) => {
item.mode = "primary" item.mode = "primary"
item.hidden = true item.hidden = true
item.system = PROMPT_TITLE item.system = PROMPT_TITLE
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
}) })
draft.update(AgentV2.ID.make("summary"), (item) => { editor.update(AgentV2.ID.make("summary"), (item) => {
item.mode = "primary" item.mode = "primary"
item.hidden = true item.hidden = true
item.system = PROMPT_SUMMARY item.system = PROMPT_SUMMARY
+54 -56
View File
@@ -1,7 +1,7 @@
export * as PluginBoot from "./boot" export * as PluginBoot from "./boot"
import type { Plugin as PublicPlugin } from "@opencode-ai/plugin/v2/effect"
import { Context, Deferred, Effect, Layer } from "effect" import { Context, Deferred, Effect, Layer } from "effect"
import { Credential } from "../credential"
import { Integration } from "../integration" import { Integration } from "../integration"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
import { Catalog } from "../catalog" import { Catalog } from "../catalog"
@@ -13,7 +13,6 @@ import { ConfigSkillPlugin } from "../config/plugin/skill"
import { ConfigReferencePlugin } from "../config/plugin/reference" import { ConfigReferencePlugin } from "../config/plugin/reference"
import { EventV2 } from "../event" import { EventV2 } from "../event"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
import { FileSystem } from "../filesystem"
import { Global } from "../global" import { Global } from "../global"
import { Location } from "../location" import { Location } from "../location"
import { ModelsDev } from "../models-dev" import { ModelsDev } from "../models-dev"
@@ -27,13 +26,29 @@ import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider" import { ProviderPlugins } from "./provider"
import { SkillV2 } from "../skill" import { SkillV2 } from "../skill"
import { Reference } from "../reference" import { Reference } from "../reference"
import { State } from "../state"
import { PluginHost } from "./host"
type InternalPlugin = PublicPlugin<any> 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
>
}
export interface Interface { export interface Interface {
readonly add: (plugin: PublicPlugin<any>) => Effect.Effect<void>
readonly wait: () => Effect.Effect<void> readonly wait: () => Effect.Effect<void>
} }
@@ -45,7 +60,8 @@ export const layer = Layer.effect(
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service const commands = yield* CommandV2.Service
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const integration = yield* Integration.Service const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service
const config = yield* Config.Service const config = yield* Config.Service
const location = yield* Location.Service const location = yield* Location.Service
@@ -53,54 +69,47 @@ export const layer = Layer.effect(
const npm = yield* Npm.Service const npm = yield* Npm.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const filesystem = yield* FileSystem.Service
const global = yield* Global.Service const global = yield* Global.Service
const skill = yield* SkillV2.Service const skill = yield* SkillV2.Service
const reference = yield* Reference.Service const references = yield* Reference.Service
const host = yield* PluginHost.make()
const done = yield* Deferred.make<void>() const done = yield* Deferred.make<void>()
const add = Effect.fn("PluginBoot.add")(function* (input: InternalPlugin) { const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
yield* plugin.add({ yield* plugin.add({
id: input.id, id: input.id,
effect: input effect: input.effect.pipe(
.effect(host) Effect.provideService(Catalog.Service, catalog),
.pipe( Effect.provideService(CommandV2.Service, commands),
Effect.provideService(Catalog.Service, catalog), Effect.provideService(Credential.Service, credentials),
Effect.provideService(CommandV2.Service, commands), Effect.provideService(Integration.Service, integrations),
Effect.provideService(Integration.Service, integration), Effect.provideService(AgentV2.Service, agents),
Effect.provideService(AgentV2.Service, agents), Effect.provideService(Config.Service, config),
Effect.provideService(Config.Service, config), Effect.provideService(Location.Service, location),
Effect.provideService(Location.Service, location), Effect.provideService(ModelsDev.Service, modelsDev),
Effect.provideService(ModelsDev.Service, modelsDev), Effect.provideService(Npm.Service, npm),
Effect.provideService(Npm.Service, npm), Effect.provideService(EventV2.Service, events),
Effect.provideService(EventV2.Service, events), Effect.provideService(FSUtil.Service, fs),
Effect.provideService(FSUtil.Service, fs), Effect.provideService(Global.Service, global),
Effect.provideService(FileSystem.Service, filesystem), Effect.provideService(SkillV2.Service, skill),
Effect.provideService(Global.Service, global), Effect.provideService(Reference.Service, references),
Effect.provideService(SkillV2.Service, skill), Effect.provideService(PluginV2.Service, plugin),
Effect.provideService(Reference.Service, reference), ),
),
}) })
}) })
const boot = Effect.gen(function* () { const boot = Effect.gen(function* () {
yield* State.batch( yield* add(AgentPlugin.Plugin)
Effect.gen(function* () { yield* add(CommandPlugin.Plugin)
yield* add(AgentPlugin.Plugin) yield* add(SkillPlugin.Plugin)
yield* add(CommandPlugin.Plugin) for (const item of ProviderPlugins) {
yield* add(SkillPlugin.Plugin) yield* add(item)
yield* add(ModelsDevPlugin) }
yield* add(ConfigProviderPlugin.Plugin) yield* add(ModelsDevPlugin)
yield* add(ConfigAgentPlugin.Plugin) yield* add(ConfigProviderPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin) yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin) yield* add(ConfigCommandPlugin.Plugin)
yield* add(ConfigReferencePlugin.Plugin) yield* add(ConfigSkillPlugin.Plugin)
for (const item of ProviderPlugins) { yield* add(ConfigReferencePlugin.Plugin)
yield* add(item)
}
}),
)
}).pipe(Effect.withSpan("PluginBoot.boot")) }).pipe(Effect.withSpan("PluginBoot.boot"))
yield* boot.pipe( yield* boot.pipe(
@@ -110,22 +119,12 @@ export const layer = Layer.effect(
) )
return Service.of({ return Service.of({
add: (input) =>
Deferred.await(done).pipe(
Effect.andThen(
plugin.add({
id: input.id,
effect: input.effect(host),
}),
),
),
wait: () => Deferred.await(done), wait: () => Deferred.await(done),
}) })
}), }),
) )
export const locationLayer = layer.pipe( export const locationLayer = layer.pipe(
Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(Integration.locationLayer), Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(Catalog.locationLayer), Layer.provideMerge(Catalog.locationLayer),
Layer.provideMerge(CommandV2.locationLayer), Layer.provideMerge(CommandV2.locationLayer),
@@ -133,5 +132,4 @@ export const locationLayer = layer.pipe(
Layer.provideMerge(AgentV2.locationLayer), Layer.provideMerge(AgentV2.locationLayer),
Layer.provideMerge(SkillV2.locationLayer), Layer.provideMerge(SkillV2.locationLayer),
Layer.provideMerge(Reference.locationLayer), Layer.provideMerge(Reference.locationLayer),
Layer.provideMerge(FileSystem.locationLayer),
) )
+15 -9
View File
@@ -1,20 +1,26 @@
export * as CommandPlugin from "./command" export * as CommandPlugin from "./command"
import { define } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "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_INITIALIZE from "./command/initialize.txt"
import PROMPT_REVIEW from "./command/review.txt" import PROMPT_REVIEW from "./command/review.txt"
export const Plugin = define({ export const Plugin = PluginV2.define({
id: "command", id: PluginV2.ID.make("command"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.command.transform((draft) => { const command = yield* CommandV2.Service
draft.update("init", (command) => { const location = yield* Location.Service
command.template = PROMPT_INITIALIZE.replace("${path}", ctx.location.project.directory) const transform = yield* command.transform()
yield* transform((editor) => {
editor.update("init", (command) => {
command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory)
command.description = "guided AGENTS.md setup" command.description = "guided AGENTS.md setup"
}) })
draft.update("review", (command) => { editor.update("review", (command) => {
command.template = PROMPT_REVIEW.replace("${path}", ctx.location.project.directory) command.template = PROMPT_REVIEW.replace("${path}", location.project.directory)
command.description = "review changes [commit|branch|pr], defaults to uncommitted" command.description = "review changes [commit|branch|pr], defaults to uncommitted"
command.subtask = true command.subtask = true
}) })
-236
View File
@@ -1,236 +0,0 @@
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
})
+26 -19
View File
@@ -1,13 +1,16 @@
import { define } from "@opencode-ai/plugin/v2/effect" import { DateTime, Effect, Scope, Stream } from "effect"
import { Effect, Stream } from "effect" import { Catalog } from "../catalog"
import { Integration } from "../integration"
import { EventV2 } from "../event"
import { ModelV2 } from "../model" import { ModelV2 } from "../model"
import { ModelRequest } from "../model-request" import { ModelRequest } from "../model-request"
import { ModelsDev } from "../models-dev" import { ModelsDev } from "../models-dev"
import { PluginV2 } from "../plugin"
import { ProviderV2 } from "../provider" import { ProviderV2 } from "../provider"
function released(date: string) { function released(date: string) {
const time = Date.parse(date) const time = Date.parse(date)
return Number.isFinite(time) ? time : 0 return DateTime.makeUnsafe(Number.isFinite(time) ? time : 0)
} }
function cost(input: ModelsDev.Model["cost"]) { function cost(input: ModelsDev.Model["cost"]) {
@@ -48,16 +51,22 @@ function variants(model: ModelsDev.Model, packageName?: string) {
}) })
} }
export const ModelsDevPlugin = define({ export const ModelsDevPlugin = PluginV2.define({
id: "models-dev", id: PluginV2.ID.make("models-dev"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const modelsDev = yield* ModelsDev.Service const modelsDev = yield* ModelsDev.Service
yield* ctx.integration.transform( const events = yield* EventV2.Service
Effect.fn(function* (integrations) { const scope = yield* Scope.Scope
const data = yield* modelsDev.get() 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) => {
for (const item of Object.values(data)) { for (const item of Object.values(data)) {
if (item.env.length === 0) continue if (item.env.length === 0) continue
const integrationID = item.id const integrationID = Integration.ID.make(item.id)
integrations.update(integrationID, (integration) => (integration.name = item.name)) integrations.update(integrationID, (integration) => (integration.name = item.name))
integrations.method.update({ integrations.method.update({
integrationID, integrationID,
@@ -68,11 +77,8 @@ export const ModelsDevPlugin = define({
method: { type: "env", names: [...item.env] }, 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)) { for (const item of Object.values(data)) {
const providerID = ProviderV2.ID.make(item.id) const providerID = ProviderV2.ID.make(item.id)
catalog.provider.update(providerID, (provider) => { catalog.provider.update(providerID, (provider) => {
@@ -126,10 +132,11 @@ export const ModelsDevPlugin = define({
}) })
} }
} }
}), })
) })
yield* ctx.event.subscribe("models-dev.refreshed").pipe( yield* refresh()
Stream.runForEach(() => ctx.integration.rebuild().pipe(Effect.andThen(ctx.catalog.rebuild()))), yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.runForEach(() => refresh()),
Effect.forkScoped({ startImmediately: true }), Effect.forkScoped({ startImmediately: true }),
) )
}), }),
+7 -8
View File
@@ -1,16 +1,15 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
export const AlibabaPlugin = define({ export const AlibabaPlugin = PluginV2.define({
id: "alibaba", id: PluginV2.ID.make("alibaba"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.aisdk.hook( return {
"sdk", "aisdk.sdk": Effect.fn(function* (evt) {
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/alibaba") return if (evt.package !== "@ai-sdk/alibaba") return
const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba")) const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba"))
evt.sdk = mod.createAlibaba(evt.options) evt.sdk = mod.createAlibaba(evt.options)
}), }),
) }
}), }),
}) })
@@ -1,6 +1,6 @@
import { Effect } from "effect" import { Effect } from "effect"
import type { LanguageModelV3 } from "@ai-sdk/provider" import type { LanguageModelV3 } from "@ai-sdk/provider"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
type MantleSDK = { type MantleSDK = {
@@ -59,11 +59,11 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
return sdk.responses(modelID) return sdk.responses(modelID)
} }
export const AmazonBedrockPlugin = define({ export const AmazonBedrockPlugin = PluginV2.define({
id: "amazon-bedrock", id: PluginV2.ID.make("amazon-bedrock"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.catalog.transform( return {
Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue
@@ -77,10 +77,7 @@ export const AmazonBedrockPlugin = 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 if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return
const options = { ...evt.options } const options = { ...evt.options }
const profile = typeof options.profile === "string" ? options.profile : process.env.AWS_PROFILE const profile = typeof options.profile === "string" ? options.profile : process.env.AWS_PROFILE
@@ -111,10 +108,7 @@ export const AmazonBedrockPlugin = define({
const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock")) const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock"))
evt.sdk = mod.createAmazonBedrock(options) 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.providerID !== ProviderV2.ID.amazonBedrock) return
if (evt.model.api.type === "aisdk" && evt.model.api.package === "@ai-sdk/amazon-bedrock/mantle") { if (evt.model.api.type === "aisdk" && evt.model.api.package === "@ai-sdk/amazon-bedrock/mantle") {
evt.language = selectMantleModel(evt.sdk, evt.model.api.id) evt.language = selectMantleModel(evt.sdk, evt.model.api.id)
@@ -123,6 +117,6 @@ export const AmazonBedrockPlugin = define({
const region = typeof evt.options.region === "string" ? evt.options.region : process.env.AWS_REGION 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)) evt.language = evt.sdk.languageModel(resolveModelID(evt.model.api.id, region))
}), }),
) }
}), }),
}) })
+8 -11
View File
@@ -1,11 +1,11 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
export const AnthropicPlugin = define({ export const AnthropicPlugin = PluginV2.define({
id: "anthropic", id: PluginV2.ID.make("anthropic"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.catalog.transform( return {
Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/anthropic") continue if (item.provider.api.package !== "@ai-sdk/anthropic") continue
@@ -15,14 +15,11 @@ export const AnthropicPlugin = define({
}) })
} }
}), }),
) "aisdk.sdk": Effect.fn(function* (evt) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/anthropic") return if (evt.package !== "@ai-sdk/anthropic") return
const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic"))
evt.sdk = mod.createAnthropic(evt.options) evt.sdk = mod.createAnthropic(evt.options)
}), }),
) }
}), }),
}) })
+16 -25
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
function selectLanguage(sdk: any, modelID: string, useChat: boolean) { function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
@@ -10,11 +10,11 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
return sdk.languageModel(modelID) return sdk.languageModel(modelID)
} }
export const AzurePlugin = define({ export const AzurePlugin = PluginV2.define({
id: "azure", id: PluginV2.ID.make("azure"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.catalog.transform( return {
Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/azure") continue if (item.provider.api.package !== "@ai-sdk/azure") continue
@@ -27,10 +27,7 @@ export const AzurePlugin = 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.package !== "@ai-sdk/azure") return
if (evt.model.providerID === ProviderV2.ID.azure) { if (evt.model.providerID === ProviderV2.ID.azure) {
if ( if (
@@ -46,22 +43,19 @@ export const AzurePlugin = define({
const mod = yield* Effect.promise(() => import("@ai-sdk/azure")) const mod = yield* Effect.promise(() => import("@ai-sdk/azure"))
evt.sdk = mod.createAzure(evt.options) 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 if (evt.model.providerID !== ProviderV2.ID.azure) return
evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls)) evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls))
}), }),
) }
}), }),
}) })
export const AzureCognitiveServicesPlugin = define({ export const AzureCognitiveServicesPlugin = PluginV2.define({
id: "azure-cognitive-services", id: PluginV2.ID.make("azure-cognitive-services"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.catalog.transform( return {
Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
if (!resourceName) return if (!resourceName) return
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
@@ -73,13 +67,10 @@ export const AzureCognitiveServicesPlugin = 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 if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls)) evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls))
}), }),
) }
}), }),
}) })
+10 -13
View File
@@ -1,27 +1,24 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
export const CerebrasPlugin = define({ export const CerebrasPlugin = PluginV2.define({
id: "cerebras", id: PluginV2.ID.make("cerebras"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.catalog.transform( return {
Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (ctx) {
for (const item of evt.provider.list()) { for (const item of ctx.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/cerebras") continue if (item.provider.api.package !== "@ai-sdk/cerebras") continue
evt.provider.update(item.provider.id, (provider) => { ctx.provider.update(item.provider.id, (provider) => {
provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode" 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 if (evt.package !== "@ai-sdk/cerebras") return
const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras")) const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras"))
evt.sdk = mod.createCerebras(evt.options) evt.sdk = mod.createCerebras(evt.options)
}), }),
) }
}), }),
}) })
@@ -1,14 +1,13 @@
import os from "os" import os from "os"
import { InstallationVersion } from "../../installation/version" import { InstallationVersion } from "../../installation/version"
import { Effect, Option, Schema } from "effect" import { Effect, Option, Schema } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
export const CloudflareAIGatewayPlugin = define({ export const CloudflareAIGatewayPlugin = PluginV2.define({
id: "cloudflare-ai-gateway", id: PluginV2.ID.make("cloudflare-ai-gateway"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.aisdk.hook( return {
"sdk", "aisdk.sdk": Effect.fn(function* (evt) {
Effect.fn(function* (evt) {
if (evt.package !== "ai-gateway-provider") return if (evt.package !== "ai-gateway-provider") return
if (evt.options.baseURL) return if (evt.options.baseURL) return
@@ -32,7 +31,7 @@ export const CloudflareAIGatewayPlugin = define({
}, },
} }
}), }),
) }
}), }),
}) })
@@ -1,16 +1,16 @@
import os from "os" import os from "os"
import { InstallationVersion } from "../../installation/version" import { InstallationVersion } from "../../installation/version"
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
const providerID = ProviderV2.ID.make("cloudflare-workers-ai") const providerID = ProviderV2.ID.make("cloudflare-workers-ai")
export const CloudflareWorkersAIPlugin = define({ export const CloudflareWorkersAIPlugin = PluginV2.define({
id: "cloudflare-workers-ai", id: PluginV2.ID.make("cloudflare-workers-ai"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.catalog.transform( return {
Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
const item = evt.provider.get(providerID) const item = evt.provider.get(providerID)
if (!item) return if (!item) return
evt.provider.update(item.provider.id, (provider) => { evt.provider.update(item.provider.id, (provider) => {
@@ -20,10 +20,7 @@ export const CloudflareWorkersAIPlugin = define({
if (accountId) provider.api.url = workersEndpoint(accountId) 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.model.providerID !== providerID) return
if (evt.package !== "@ai-sdk/openai-compatible") return if (evt.package !== "@ai-sdk/openai-compatible") return
@@ -37,14 +34,11 @@ export const CloudflareWorkersAIPlugin = define({
}) as any, }) as any,
) )
}), }),
) "aisdk.language": Effect.fn(function* (evt) {
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return if (evt.model.providerID !== providerID) return
evt.language = evt.sdk.languageModel(evt.model.api.id) evt.language = evt.sdk.languageModel(evt.model.api.id)
}), }),
) }
}), }),
}) })
+7 -8
View File
@@ -1,16 +1,15 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
export const CoherePlugin = define({ export const CoherePlugin = PluginV2.define({
id: "cohere", id: PluginV2.ID.make("cohere"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.aisdk.hook( return {
"sdk", "aisdk.sdk": Effect.fn(function* (evt) {
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/cohere") return if (evt.package !== "@ai-sdk/cohere") return
const mod = yield* Effect.promise(() => import("@ai-sdk/cohere")) const mod = yield* Effect.promise(() => import("@ai-sdk/cohere"))
evt.sdk = mod.createCohere(evt.options) evt.sdk = mod.createCohere(evt.options)
}), }),
) }
}), }),
}) })
@@ -1,16 +1,15 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
export const DeepInfraPlugin = define({ export const DeepInfraPlugin = PluginV2.define({
id: "deepinfra", id: PluginV2.ID.make("deepinfra"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.aisdk.hook( return {
"sdk", "aisdk.sdk": Effect.fn(function* (evt) {
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/deepinfra") return if (evt.package !== "@ai-sdk/deepinfra") return
const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra")) const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra"))
evt.sdk = mod.createDeepInfra(evt.options) evt.sdk = mod.createDeepInfra(evt.options)
}), }),
) }
}), }),
}) })
+11 -10
View File
@@ -1,18 +1,19 @@
import { Effect } from "effect" import { Npm } from "../../npm"
import { Effect, Option } from "effect"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
export const DynamicProviderPlugin = define({ export const DynamicProviderPlugin = PluginV2.define({
id: "dynamic-provider", id: PluginV2.ID.make("dynamic-provider"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.aisdk.hook( const npm = yield* Npm.Service
"sdk", return {
Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
if (evt.sdk) return if (evt.sdk) return
const installedPath = evt.package.startsWith("file://") const installedPath = evt.package.startsWith("file://")
? evt.package ? evt.package
: (yield* ctx.npm.add(evt.package).pipe(Effect.orDie)).entrypoint : Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint)
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
const mod = yield* Effect.promise(async () => { const mod = yield* Effect.promise(async () => {
@@ -25,6 +26,6 @@ export const DynamicProviderPlugin = define({
evt.sdk = mod[match](evt.options) evt.sdk = mod[match](evt.options)
}), }),
) }
}), }),
}) })
+7 -8
View File
@@ -1,16 +1,15 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
export const GatewayPlugin = define({ export const GatewayPlugin = PluginV2.define({
id: "gateway", id: PluginV2.ID.make("gateway"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.aisdk.hook( return {
"sdk", "aisdk.sdk": Effect.fn(function* (evt) {
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/gateway") return if (evt.package !== "@ai-sdk/gateway") return
const mod = yield* Effect.promise(() => import("@ai-sdk/gateway")) const mod = yield* Effect.promise(() => import("@ai-sdk/gateway"))
evt.sdk = mod.createGateway(evt.options) evt.sdk = mod.createGateway(evt.options)
}), }),
) }
}), }),
}) })
@@ -1,6 +1,6 @@
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
function shouldUseResponses(modelID: string) { function shouldUseResponses(modelID: string) {
@@ -11,31 +11,16 @@ function shouldUseResponses(modelID: string) {
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini") return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
} }
export const GithubCopilotPlugin = define({ export const GithubCopilotPlugin = PluginV2.define({
id: "github-copilot", id: PluginV2.ID.make("github-copilot"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.catalog.transform( return {
Effect.fn(function* (evt) { "aisdk.sdk": 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 if (evt.package !== "@ai-sdk/github-copilot") return
const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider"))
evt.sdk = mod.createOpenaiCompatible(evt.options) 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.model.providerID !== ProviderV2.ID.githubCopilot) return
if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) { if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) {
evt.language = evt.sdk.languageModel(evt.model.api.id) evt.language = evt.sdk.languageModel(evt.model.api.id)
@@ -45,6 +30,15 @@ export const GithubCopilotPlugin = define({
? evt.sdk.responses(evt.model.api.id) ? evt.sdk.responses(evt.model.api.id)
: evt.sdk.chat(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
})
}),
}
}), }),
}) })
+8 -12
View File
@@ -1,15 +1,14 @@
import os from "os" import os from "os"
import { InstallationVersion } from "../../installation/version" import { InstallationVersion } from "../../installation/version"
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
export const GitLabPlugin = define({ export const GitLabPlugin = PluginV2.define({
id: "gitlab", id: PluginV2.ID.make("gitlab"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.aisdk.hook( return {
"sdk", "aisdk.sdk": Effect.fn(function* (evt) {
Effect.fn(function* (evt) {
if (evt.package !== "gitlab-ai-provider") return if (evt.package !== "gitlab-ai-provider") return
const mod = yield* Effect.promise(() => import("gitlab-ai-provider")) const mod = yield* Effect.promise(() => import("gitlab-ai-provider"))
evt.sdk = mod.createGitLab({ evt.sdk = mod.createGitLab({
@@ -31,10 +30,7 @@ export const GitLabPlugin = define({
}, },
}) })
}), }),
) "aisdk.language": Effect.fn(function* (evt) {
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.gitlab) return if (evt.model.providerID !== ProviderV2.ID.gitlab) return
const featureFlags = const featureFlags =
typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {} typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {}
@@ -62,6 +58,6 @@ export const GitLabPlugin = define({
featureFlags, featureFlags,
}) })
}), }),
) }
}), }),
}) })
@@ -1,5 +1,5 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
function resolveProject(options: Record<string, any>) { function resolveProject(options: Record<string, any>) {
@@ -54,11 +54,11 @@ function authFetch(fetchWithRuntimeOptions?: unknown) {
} }
} }
export const GoogleVertexPlugin = define({ export const GoogleVertexPlugin = PluginV2.define({
id: "google-vertex", id: PluginV2.ID.make("google-vertex"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.catalog.transform( return {
Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if ( if (
@@ -83,10 +83,7 @@ export const GoogleVertexPlugin = 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")) { if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) {
evt.options.fetch = authFetch(evt.options.fetch) evt.options.fetch = authFetch(evt.options.fetch)
return return
@@ -103,22 +100,19 @@ export const GoogleVertexPlugin = define({
location, location,
}) })
}), }),
) "aisdk.language": Effect.fn(function* (evt) {
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.googleVertex) return if (evt.model.providerID !== ProviderV2.ID.googleVertex) return
evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim()) evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim())
}), }),
) }
}), }),
}) })
export const GoogleVertexAnthropicPlugin = define({ export const GoogleVertexAnthropicPlugin = PluginV2.define({
id: "google-vertex-anthropic", id: PluginV2.ID.make("google-vertex-anthropic"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.catalog.transform( return {
Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue
@@ -138,10 +132,7 @@ export const GoogleVertexAnthropicPlugin = 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 if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic")) const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic"))
const project = const project =
@@ -165,13 +156,10 @@ export const GoogleVertexAnthropicPlugin = 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 if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return
evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim()) evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim())
}), }),
) }
}), }),
}) })
+7 -8
View File
@@ -1,16 +1,15 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
export const GooglePlugin = define({ export const GooglePlugin = PluginV2.define({
id: "google", id: PluginV2.ID.make("google"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.aisdk.hook( return {
"sdk", "aisdk.sdk": Effect.fn(function* (evt) {
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google") return if (evt.package !== "@ai-sdk/google") return
const mod = yield* Effect.promise(() => import("@ai-sdk/google")) const mod = yield* Effect.promise(() => import("@ai-sdk/google"))
evt.sdk = mod.createGoogleGenerativeAI(evt.options) evt.sdk = mod.createGoogleGenerativeAI(evt.options)
}), }),
) }
}), }),
}) })
+7 -8
View File
@@ -1,16 +1,15 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
export const GroqPlugin = define({ export const GroqPlugin = PluginV2.define({
id: "groq", id: PluginV2.ID.make("groq"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.aisdk.hook( return {
"sdk", "aisdk.sdk": Effect.fn(function* (evt) {
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/groq") return if (evt.package !== "@ai-sdk/groq") return
const mod = yield* Effect.promise(() => import("@ai-sdk/groq")) const mod = yield* Effect.promise(() => import("@ai-sdk/groq"))
evt.sdk = mod.createGroq(evt.options) evt.sdk = mod.createGroq(evt.options)
}), }),
) }
}), }),
}) })
+7 -7
View File
@@ -1,11 +1,11 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
export const KiloPlugin = define({ export const KiloPlugin = PluginV2.define({
id: "kilo", id: PluginV2.ID.make("kilo"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.catalog.transform( return {
Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
@@ -16,6 +16,6 @@ export const KiloPlugin = define({
}) })
} }
}), }),
) }
}), }),
}) })
@@ -1,17 +1,19 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { Integration } from "../../integration"
import { PluginV2 } from "../../plugin"
export const LLMGatewayPlugin = define({ export const LLMGatewayPlugin = PluginV2.define({
id: "llmgateway", id: PluginV2.ID.make("llmgateway"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.catalog.transform( const integrations = yield* Integration.Service
Effect.fn(function* (evt) { return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.disabled) continue 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.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://api.llmgateway.io/v1") 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) => { evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode" provider.request.headers["X-Title"] = "opencode"
@@ -19,6 +21,6 @@ export const LLMGatewayPlugin = define({
}) })
} }
}), }),
) }
}), }),
}) })
+7 -8
View File
@@ -1,16 +1,15 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
export const MistralPlugin = define({ export const MistralPlugin = PluginV2.define({
id: "mistral", id: PluginV2.ID.make("mistral"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.aisdk.hook( return {
"sdk", "aisdk.sdk": Effect.fn(function* (evt) {
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/mistral") return if (evt.package !== "@ai-sdk/mistral") return
const mod = yield* Effect.promise(() => import("@ai-sdk/mistral")) const mod = yield* Effect.promise(() => import("@ai-sdk/mistral"))
evt.sdk = mod.createMistral(evt.options) evt.sdk = mod.createMistral(evt.options)
}), }),
) }
}), }),
}) })
+7 -7
View File
@@ -1,11 +1,11 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
export const NvidiaPlugin = define({ export const NvidiaPlugin = PluginV2.define({
id: "nvidia", id: PluginV2.ID.make("nvidia"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.catalog.transform( return {
Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
@@ -17,6 +17,6 @@ export const NvidiaPlugin = define({
}) })
} }
}), }),
) }
}), }),
}) })
@@ -1,18 +1,17 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
export const OpenAICompatiblePlugin = define({ export const OpenAICompatiblePlugin = PluginV2.define({
id: "openai-compatible", id: PluginV2.ID.make("openai-compatible"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.aisdk.hook( return {
"sdk", "aisdk.sdk": Effect.fn(function* (evt) {
Effect.fn(function* (evt) {
if (evt.sdk) return if (evt.sdk) return
if (!evt.package.includes("@ai-sdk/openai-compatible")) return if (!evt.package.includes("@ai-sdk/openai-compatible")) return
if (evt.options.includeUsage !== false) evt.options.includeUsage = true if (evt.options.includeUsage !== false) evt.options.includeUsage = true
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
evt.sdk = mod.createOpenAICompatible(evt.options as any) evt.sdk = mod.createOpenAICompatible(evt.options as any)
}), }),
) }
}), }),
}) })
+19 -25
View File
@@ -1,20 +1,29 @@
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
import { Integration } from "../../integration" import { Integration } from "../../integration"
import { browser, headless } from "./openai-auth" import { browser, headless } from "./openai-auth"
export const OpenAIPlugin = define({ export const OpenAIPlugin = PluginV2.define({
id: "openai", id: PluginV2.ID.make("openai"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
yield* integrations.transform((draft) => { yield* integrations.update((editor) => {
draft.method.update(browser) editor.method.update(browser)
draft.method.update(headless) editor.method.update(headless)
}) })
yield* ctx.catalog.transform( return {
Effect.fn(function* (evt) { "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) {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai") continue if (item.provider.api.package !== "@ai-sdk/openai") continue
@@ -26,21 +35,6 @@ export const OpenAIPlugin = 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)
}),
)
}), }),
}) })
+10 -8
View File
@@ -1,16 +1,18 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { Integration } from "../../integration"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
export const OpencodePlugin = define({ export const OpencodePlugin = PluginV2.define({
id: "opencode", id: PluginV2.ID.make("opencode"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
const integrations = yield* Integration.Service
let hasKey = false let hasKey = false
yield* ctx.catalog.transform( return {
Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
const item = evt.provider.get(ProviderV2.ID.opencode) const item = evt.provider.get(ProviderV2.ID.opencode)
if (!item) return if (!item) return
const integration = yield* ctx.integration.get(item.provider.id) const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
hasKey = Boolean( hasKey = Boolean(
process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey, process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey,
) )
@@ -25,6 +27,6 @@ export const OpencodePlugin = define({
}) })
} }
}), }),
) }
}), }),
}) })
@@ -1,12 +1,12 @@
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
export const OpenRouterPlugin = define({ export const OpenRouterPlugin = PluginV2.define({
id: "openrouter", id: PluginV2.ID.make("openrouter"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.catalog.transform( return {
Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue
@@ -24,14 +24,11 @@ export const OpenRouterPlugin = define({
} }
} }
}), }),
) "aisdk.sdk": Effect.fn(function* (evt) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@openrouter/ai-sdk-provider") return if (evt.package !== "@openrouter/ai-sdk-provider") return
const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider")) const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider"))
evt.sdk = mod.createOpenRouter(evt.options) evt.sdk = mod.createOpenRouter(evt.options)
}), }),
) }
}), }),
}) })
@@ -1,16 +1,15 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
export const PerplexityPlugin = define({ export const PerplexityPlugin = PluginV2.define({
id: "perplexity", id: PluginV2.ID.make("perplexity"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.aisdk.hook( return {
"sdk", "aisdk.sdk": Effect.fn(function* (evt) {
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/perplexity") return if (evt.package !== "@ai-sdk/perplexity") return
const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity")) const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity"))
evt.sdk = mod.createPerplexity(evt.options) evt.sdk = mod.createPerplexity(evt.options)
}), }),
) }
}), }),
}) })
@@ -1,14 +1,15 @@
import { Effect } from "effect" import { Npm } from "../../npm"
import { Effect, Option } from "effect"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
export const SapAICorePlugin = define({ export const SapAICorePlugin = PluginV2.define({
id: "sap-ai-core", id: PluginV2.ID.make("sap-ai-core"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.aisdk.hook( const npm = yield* Npm.Service
"sdk", return {
Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
const serviceKey = const serviceKey =
process.env.AICORE_SERVICE_KEY ?? process.env.AICORE_SERVICE_KEY ??
@@ -17,7 +18,7 @@ export const SapAICorePlugin = define({
const installedPath = evt.package.startsWith("file://") const installedPath = evt.package.startsWith("file://")
? evt.package ? evt.package
: (yield* ctx.npm.add(evt.package).pipe(Effect.orDie)).entrypoint : Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint)
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
const mod = yield* Effect.promise(async () => { const mod = yield* Effect.promise(async () => {
@@ -34,13 +35,10 @@ export const SapAICorePlugin = 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 if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
evt.language = evt.sdk(evt.model.api.id) evt.language = evt.sdk(evt.model.api.id)
}), }),
) }
}), }),
}) })
@@ -1,5 +1,5 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response> type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
@@ -64,12 +64,11 @@ export function cortexFetch(upstream: FetchLike = fetch) {
} }
} }
export const SnowflakeCortexPlugin = define({ export const SnowflakeCortexPlugin = PluginV2.define({
id: "snowflake-cortex", id: PluginV2.ID.make("snowflake-cortex"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.aisdk.hook( return {
"sdk", "aisdk.sdk": Effect.fn(function* (evt) {
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return
const token = const token =
process.env.SNOWFLAKE_CORTEX_TOKEN ?? process.env.SNOWFLAKE_CORTEX_TOKEN ??
@@ -85,6 +84,6 @@ export const SnowflakeCortexPlugin = define({
fetch: cortexFetch(upstream) as typeof fetch, fetch: cortexFetch(upstream) as typeof fetch,
} as any) } as any)
}), }),
) }
}), }),
}) })
@@ -1,16 +1,15 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
export const TogetherAIPlugin = define({ export const TogetherAIPlugin = PluginV2.define({
id: "togetherai", id: PluginV2.ID.make("togetherai"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.aisdk.hook( return {
"sdk", "aisdk.sdk": Effect.fn(function* (evt) {
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/togetherai") return if (evt.package !== "@ai-sdk/togetherai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai")) const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai"))
evt.sdk = mod.createTogetherAI(evt.options) evt.sdk = mod.createTogetherAI(evt.options)
}), }),
) }
}), }),
}) })
+7 -8
View File
@@ -1,16 +1,15 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
export const VenicePlugin = define({ export const VenicePlugin = PluginV2.define({
id: "venice", id: PluginV2.ID.make("venice"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.aisdk.hook( return {
"sdk", "aisdk.sdk": Effect.fn(function* (evt) {
Effect.fn(function* (evt) {
if (evt.package !== "venice-ai-sdk-provider") return if (evt.package !== "venice-ai-sdk-provider") return
const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider")) const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider"))
evt.sdk = mod.createVenice(evt.options) evt.sdk = mod.createVenice(evt.options)
}), }),
) }
}), }),
}) })
+8 -11
View File
@@ -1,11 +1,11 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
export const VercelPlugin = define({ export const VercelPlugin = PluginV2.define({
id: "vercel", id: PluginV2.ID.make("vercel"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.catalog.transform( return {
Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/vercel") continue if (item.provider.api.package !== "@ai-sdk/vercel") continue
@@ -15,14 +15,11 @@ export const VercelPlugin = define({
}) })
} }
}), }),
) "aisdk.sdk": Effect.fn(function* (evt) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/vercel") return if (evt.package !== "@ai-sdk/vercel") return
const mod = yield* Effect.promise(() => import("@ai-sdk/vercel")) const mod = yield* Effect.promise(() => import("@ai-sdk/vercel"))
evt.sdk = mod.createVercel(evt.options) evt.sdk = mod.createVercel(evt.options)
}), }),
) }
}), }),
}) })
+8 -12
View File
@@ -1,24 +1,20 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
export const XAIPlugin = define({ export const XAIPlugin = PluginV2.define({
id: "xai", id: PluginV2.ID.make("xai"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.aisdk.hook( return {
"sdk", "aisdk.sdk": Effect.fn(function* (evt) {
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/xai") return if (evt.package !== "@ai-sdk/xai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) const mod = yield* Effect.promise(() => import("@ai-sdk/xai"))
evt.sdk = mod.createXai(evt.options) 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 if (evt.model.providerID !== ProviderV2.ID.make("xai")) return
evt.language = evt.sdk.responses(evt.model.api.id) evt.language = evt.sdk.responses(evt.model.api.id)
}), }),
) }
}), }),
}) })
+7 -7
View File
@@ -1,11 +1,11 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect" import { PluginV2 } from "../../plugin"
export const ZenmuxPlugin = define({ export const ZenmuxPlugin = PluginV2.define({
id: "zenmux", id: PluginV2.ID.make("zenmux"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.catalog.transform( return {
Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
@@ -16,6 +16,6 @@ export const ZenmuxPlugin = define({
}) })
} }
}), }),
) }
}), }),
}) })
+9 -6
View File
@@ -2,19 +2,22 @@
export * as SkillPlugin from "./skill" export * as SkillPlugin from "./skill"
import { define } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../plugin"
import { AbsolutePath } from "../schema" import { AbsolutePath } from "../schema"
import { SkillV2 } from "../skill" import { SkillV2 } from "../skill"
import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" } import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" }
export const CustomizeOpencodeContent = customizeOpencodeContent export const CustomizeOpencodeContent = customizeOpencodeContent
export const Plugin = define({ export const Plugin = PluginV2.define({
id: "skill", id: PluginV2.ID.make("skill"),
effect: Effect.fn(function* (ctx) { effect: Effect.gen(function* () {
yield* ctx.skill.transform((draft) => { const skill = yield* SkillV2.Service
draft.source( const transform = yield* skill.transform()
yield* transform((editor) => {
editor.source(
new SkillV2.EmbeddedSource({ new SkillV2.EmbeddedSource({
type: "embedded", type: "embedded",
skill: new SkillV2.Info({ skill: new SkillV2.Info({
+1 -6
View File
@@ -1,7 +1,7 @@
export * as ProviderV2 from "./provider" export * as ProviderV2 from "./provider"
import { withStatics } from "./schema" import { withStatics } from "./schema"
import { Schema, Types } from "effect" import { Schema } from "effect"
export const ID = Schema.String.pipe( export const ID = Schema.String.pipe(
Schema.brand("ProviderV2.ID"), Schema.brand("ProviderV2.ID"),
@@ -37,9 +37,6 @@ export const Native = Schema.Struct({
export const Api = Schema.Union([AISDK, Native]).pipe(Schema.toTaggedUnion("type")) export const Api = Schema.Union([AISDK, Native]).pipe(Schema.toTaggedUnion("type"))
export type Api = typeof Api.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({ export const Request = Schema.Struct({
headers: Schema.Record(Schema.String, Schema.String), headers: Schema.Record(Schema.String, Schema.String),
@@ -69,5 +66,3 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
}) })
} }
} }
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & { api: MutableApi }
+64 -11
View File
@@ -1,9 +1,11 @@
export * as OpenCode from "./opencode" export * as OpenCode from "./opencode"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { Catalog } from "../catalog"
import { Database } from "../database/database" import { Database } from "../database/database"
import { EventV2 } from "../event" import { EventV2 } from "../event"
import { LocationServiceMap } from "../location-layer" import { LocationServiceMap } from "../location-layer"
import { PluginBoot } from "../plugin/boot"
import { ProjectV2 } from "../project" import { ProjectV2 } from "../project"
import { SessionV2 } from "../session" import { SessionV2 } from "../session"
import * as SessionExecutionLocal from "../session/execution/local" import * as SessionExecutionLocal from "../session/execution/local"
@@ -21,22 +23,69 @@ export interface Interface {
/** Intentional public native API for Effect applications embedding OpenCode. */ /** Intentional public native API for Effect applications embedding OpenCode. */
export class Service extends Context.Service<Service, Interface>()("@opencode/public/OpenCode") {} export class Service extends Context.Service<Service, Interface>()("@opencode/public/OpenCode") {}
const SessionsLayer = SessionV2.layer.pipe( class SessionModelValidation extends Context.Service<
Layer.provide(SessionProjector.layer), SessionModelValidation,
Layer.provide(SessionExecutionLocal.layer), {
Layer.provide(SessionStore.layer), readonly validate: (
Layer.provide(EventV2.layer), input: Session.SwitchModelInput & { readonly location: Session.Info["location"] },
Layer.provide(Database.defaultLayer), ) => Effect.Effect<void, Session.ModelUnavailableError | Session.VariantUnavailableError>
Layer.provide(ProjectV2.defaultLayer), }
Layer.provide(LocationServiceMap.layer.pipe(Layer.provide(ApplicationTools.layer))), >()("@opencode/public/OpenCode/SessionModelValidation") {}
Layer.orDie,
const ApplicationToolsLayer = ApplicationTools.layer
const LocationServicesLayer = LocationServiceMap.layer.pipe(Layer.provide(ApplicationToolsLayer))
const SessionModelValidationLayer = Layer.effect(
SessionModelValidation,
Effect.gen(function* () {
const locations = yield* LocationServiceMap
return SessionModelValidation.of({
validate: Effect.fn("OpenCode.sessions.validateModel")(function* (input) {
yield* Effect.gen(function* () {
yield* (yield* PluginBoot.Service).wait()
const catalog = yield* Catalog.Service
const model = (yield* catalog.model.available()).find(
(model) => model.providerID === input.model.providerID && model.id === input.model.id,
)
if (!model)
return yield* new Session.ModelUnavailableError({
providerID: input.model.providerID,
modelID: input.model.id,
})
if (
input.model.variant !== undefined &&
input.model.variant !== "default" &&
!model.variants.some((variant) => variant.id === input.model.variant)
)
return yield* new Session.VariantUnavailableError({
providerID: input.model.providerID,
modelID: input.model.id,
variant: input.model.variant,
})
}).pipe(Effect.provide(locations.get(input.location)))
}),
})
}),
) )
const SessionsLayer = Layer.merge(
SessionV2.layer.pipe(
Layer.provide(SessionProjector.layer),
Layer.provide(SessionExecutionLocal.layer),
Layer.provide(SessionStore.layer),
Layer.provide(EventV2.layer),
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.orDie,
),
SessionModelValidationLayer,
).pipe(Layer.provide(LocationServicesLayer))
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence. // TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const sessions = yield* SessionV2.Service const sessions = yield* SessionV2.Service
const tools = yield* ApplicationTools.Service const tools = yield* ApplicationTools.Service
const validation = yield* SessionModelValidation
return Service.of({ return Service.of({
tools: { register: tools.register }, tools: { register: tools.register },
sessions: { sessions: {
@@ -49,7 +98,11 @@ export const layer = Layer.effect(
}), }),
get: sessions.get, get: sessions.get,
list: sessions.list, list: sessions.list,
switchModel: sessions.switchModel, switchModel: Effect.fn("OpenCode.sessions.switchModel")(function* (input) {
const session = yield* sessions.get(input.sessionID)
yield* validation.validate({ ...input, location: session.location })
yield* sessions.switchModel(input)
}),
interrupt: sessions.interrupt, interrupt: sessions.interrupt,
prompt: (input) => prompt: (input) =>
sessions.prompt({ sessions.prompt({
@@ -71,6 +124,6 @@ export const layer = Layer.effect(
}, },
}) })
}), }),
).pipe(Layer.provide(Layer.merge(ApplicationTools.layer, SessionsLayer))) ).pipe(Layer.provide(Layer.merge(ApplicationToolsLayer, SessionsLayer)))
// TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics. // TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics.
+27 -4
View File
@@ -1,6 +1,8 @@
export * as Session from "./session" export * as Session from "./session"
import { Effect, Stream } from "effect" import { Effect, Schema, Stream } from "effect"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { SessionV2 } from "../session" import { SessionV2 } from "../session"
import { MessageDecodeError } from "../session/error" import { MessageDecodeError } from "../session/error"
import { SessionEvent } from "../session/event" import { SessionEvent } from "../session/event"
@@ -32,7 +34,9 @@ export type Delivery = SessionInput.Delivery
export const ListInput = SessionV2.ListInput export const ListInput = SessionV2.ListInput
export type ListInput = SessionV2.ListInput export type ListInput = SessionV2.ListInput
export type Event = SessionEvent.DurableEvent export const EventCursor = EventV2.Cursor
export type EventCursor = EventV2.Cursor
export type Event = EventV2.CursorEvent<SessionEvent.DurableEvent>
export const NotFoundError = SessionV2.NotFoundError export const NotFoundError = SessionV2.NotFoundError
export type NotFoundError = SessionV2.NotFoundError export type NotFoundError = SessionV2.NotFoundError
@@ -40,6 +44,23 @@ export type NotFoundError = SessionV2.NotFoundError
export const PromptConflictError = SessionV2.PromptConflictError export const PromptConflictError = SessionV2.PromptConflictError
export type PromptConflictError = SessionV2.PromptConflictError export type PromptConflictError = SessionV2.PromptConflictError
export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
"Session.ModelUnavailableError",
{
providerID: Model.Ref.fields.providerID,
modelID: Model.Ref.fields.id,
},
) {}
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
"Session.VariantUnavailableError",
{
providerID: Model.Ref.fields.providerID,
modelID: Model.Ref.fields.id,
variant: ModelV2.VariantID,
},
) {}
export { MessageDecodeError } export { MessageDecodeError }
export interface CreateInput { export interface CreateInput {
@@ -78,7 +99,7 @@ export interface MessageInput {
export interface EventsInput { export interface EventsInput {
readonly sessionID: ID readonly sessionID: ID
readonly after?: number readonly after?: EventCursor
} }
export interface Interface { export interface Interface {
@@ -86,7 +107,9 @@ export interface Interface {
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError> readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
readonly list: (input?: ListInput) => Effect.Effect<Info[]> readonly list: (input?: ListInput) => Effect.Effect<Info[]>
readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError> readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
readonly switchModel: (input: SwitchModelInput) => Effect.Effect<void, NotFoundError> readonly switchModel: (
input: SwitchModelInput,
) => 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. */ /** 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 interrupt: (sessionID: ID) => Effect.Effect<void>
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError> readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
+11 -10
View File
@@ -1,6 +1,7 @@
export * as Reference from "./reference" export * as Reference from "./reference"
import { Context, Effect, Layer, Schema, Scope, Types } from "effect" import { Context, Effect, Layer, Schema, Scope } from "effect"
import { castDraft } from "immer"
import { Global } from "./global" import { Global } from "./global"
import { EventV2 } from "./event" import { EventV2 } from "./event"
import { Repository } from "./repository" import { Repository } from "./repository"
@@ -39,16 +40,17 @@ export class Info extends Schema.Class<Info>("Reference.Info")({
}) {} }) {}
type Data = { type Data = {
sources: Map<string, Types.DeepMutable<Source>> sources: Map<string, Source>
} }
type Draft = { type Editor = {
add(name: string, source: Source): void add(name: string, source: Source): void
remove(name: string): void remove(name: string): void
list(): readonly [string, Source][] list(): readonly [string, Source][]
} }
export interface Interface extends State.Transformable<Draft> { export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly list: () => Effect.Effect<Info[]> readonly list: () => Effect.Effect<Info[]>
} }
@@ -62,18 +64,18 @@ export const layer = Layer.effect(
const cache = yield* RepositoryCache.Service const cache = yield* RepositoryCache.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
const materialized = new Map<string, Info>() const materialized = new Map<string, Info>()
const state = State.create<Data, Draft>({ const state = State.create<Data, Editor>({
initial: () => ({ sources: new Map() }), initial: () => ({ sources: new Map() }),
draft: (draft) => ({ editor: (draft) => ({
add: (name, source) => draft.sources.set(name, source as Types.DeepMutable<Source>), add: (name, source) => draft.sources.set(name, castDraft(source)),
remove: (name) => draft.sources.delete(name), remove: (name) => draft.sources.delete(name),
list: () => Array.from(draft.sources.entries()) as [string, Source][], list: () => Array.from(draft.sources.entries()) as [string, Source][],
}), }),
finalize: (draft) => finalize: (editor) =>
Effect.gen(function* () { Effect.gen(function* () {
materialized.clear() materialized.clear()
const seen = new Map<string, string | undefined>() const seen = new Map<string, string | undefined>()
for (const [name, source] of draft.list()) { for (const [name, source] of editor.list()) {
if (source.type === "local") { if (source.type === "local") {
materialized.set( materialized.set(
name, name,
@@ -126,7 +128,6 @@ export const layer = Layer.effect(
return Service.of({ return Service.of({
transform: state.transform, transform: state.transform,
rebuild: state.rebuild,
list: Effect.fn("Reference.list")(function* () { list: Effect.fn("Reference.list")(function* () {
return Array.from(materialized.values()) return Array.from(materialized.values())
}), }),
+49 -25
View File
@@ -1,8 +1,8 @@
export * as SessionV2 from "./session" export * as SessionV2 from "./session"
export * from "./session/schema" export * from "./session/schema"
import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect" import { Cause, DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
import { and, asc, desc, eq, gt, gte, like, lt, or, type SQL } from "drizzle-orm" import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
import { ProjectV2 } from "./project" import { ProjectV2 } from "./project"
import { WorkspaceV2 } from "./workspace" import { WorkspaceV2 } from "./workspace"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
@@ -25,6 +25,7 @@ import { fromRow } from "./session/info"
import { SessionRunner } from "./session/runner/index" import { SessionRunner } from "./session/runner/index"
import { SessionStore } from "./session/store" import { SessionStore } from "./session/store"
import { SessionExecution } from "./session/execution" import { SessionExecution } from "./session/execution"
import { logFailure } from "./session/logging"
import { MessageDecodeError } from "./session/error" import { MessageDecodeError } from "./session/error"
import { SessionEvent } from "./session/event" import { SessionEvent } from "./session/event"
import { SessionInput } from "./session/input" import { SessionInput } from "./session/input"
@@ -123,9 +124,12 @@ export interface Interface {
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError> ) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
readonly events: (input: { readonly events: (input: {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
after?: number after?: EventV2.Cursor
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError> }) => Stream.Stream<EventV2.CursorEvent<SessionEvent.DurableEvent>, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError> readonly switchAgent: (input: {
sessionID: SessionSchema.ID
agent: string
}) => Effect.Effect<void, OperationUnavailableError>
readonly switchModel: (input: { readonly switchModel: (input: {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
model: ModelV2.Ref model: ModelV2.Ref
@@ -167,6 +171,20 @@ export const layer = Layer.effect(
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable) const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const scope = yield* Effect.scope
const enqueueWake = (admitted: SessionInput.Admitted) =>
execution.wake(admitted.sessionID, admitted.admittedSeq).pipe(
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: logFailure("Failed to wake Session", admitted.sessionID, cause),
),
Effect.ignore,
Effect.forkIn(scope, { startImmediately: true }),
Effect.asVoid,
)
const decode = (row: typeof SessionMessageTable.$inferSelect) => const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError( Effect.mapError(
@@ -248,13 +266,6 @@ export const layer = Layer.effect(
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory)) if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project)) if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if ("subpath" in input && input.subpath)
conditions.push(
or(
eq(SessionTable.path, input.subpath),
and(gte(SessionTable.path, `${input.subpath}/`), lt(SessionTable.path, `${input.subpath}0`)),
)!,
)
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.anchor) { if (input.anchor) {
conditions.push( conditions.push(
@@ -328,12 +339,20 @@ export const layer = Layer.effect(
Stream.unwrap( Stream.unwrap(
result result
.get(input.sessionID) .get(input.sessionID)
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))), .pipe(Effect.as(events.aggregateEvents({ aggregateID: input.sessionID, after: input.after }))),
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))), ).pipe(
Stream.filter((event): event is EventV2.CursorEvent<SessionEvent.DurableEvent> =>
isDurableSessionEvent(event.event),
),
),
prompt: Effect.fn("V2Session.prompt")((input) => prompt: Effect.fn("V2Session.prompt")((input) =>
Effect.uninterruptible( Effect.uninterruptible(
Effect.gen(function* () { Effect.gen(function* () {
yield* result.get(input.sessionID) yield* result.get(input.sessionID)
const returnPrompt = Effect.fnUntraced(function* (admitted: SessionInput.Admitted) {
if (input.resume !== false) yield* enqueueWake(admitted)
return admitted
}, Effect.uninterruptible)
const messageID = input.id ?? SessionMessage.ID.create() const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer" const delivery = input.delivery ?? "steer"
const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery } const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery }
@@ -351,8 +370,7 @@ export const layer = Layer.effect(
) )
if (!SessionInput.equivalent(admitted, expected)) if (!SessionInput.equivalent(admitted, expected))
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false) yield* execution.wake(admitted.sessionID) return yield* returnPrompt(admitted)
return admitted
}), }),
), ),
), ),
@@ -362,14 +380,8 @@ export const layer = Layer.effect(
skill: Effect.fn("V2Session.skill")(function* () { skill: Effect.fn("V2Session.skill")(function* () {
return yield* new OperationUnavailableError({ operation: "skill" }) return yield* new OperationUnavailableError({ operation: "skill" })
}), }),
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { switchAgent: Effect.fn("V2Session.switchAgent")(function* () {
yield* result.get(input.sessionID) return yield* new OperationUnavailableError({ operation: "switchAgent" })
yield* events.publish(SessionEvent.AgentSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
agent: input.agent,
})
}), }),
switchModel: Effect.fn("V2Session.switchModel")(function* (input) { switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
yield* result.get(input.sessionID) yield* result.get(input.sessionID)
@@ -393,7 +405,19 @@ export const layer = Layer.effect(
yield* execution.resume(sessionID) yield* execution.resume(sessionID)
}), }),
interrupt: Effect.fn("V2Session.interrupt")((sessionID) => interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
Effect.uninterruptible(execution.interrupt(sessionID)), Effect.uninterruptible(
Effect.gen(function* () {
const session = yield* store.get(sessionID)
if (!session) return yield* execution.interrupt(sessionID)
const event = yield* events.publish(SessionEvent.InterruptRequested, {
sessionID,
timestamp: yield* DateTime.now,
})
if (event.seq === undefined)
return yield* Effect.die("Interrupt request event is missing aggregate sequence")
yield* execution.interrupt(sessionID, event.seq)
}),
),
), ),
}) })
+217 -48
View File
@@ -1,31 +1,54 @@
export * as SessionContextEpoch from "./context-epoch" export * as SessionContextEpoch from "./context-epoch"
import { eq } from "drizzle-orm" import { and, eq, isNull, lt, or, sql } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect" import { DateTime, Effect, Schema } from "effect"
import { AgentV2 } from "../agent"
import type { Database } from "../database/database" import type { Database } from "../database/database"
import { EventV2 } from "../event" import { EventV2 } from "../event"
import { Location } from "../location"
import { SystemContext } from "../system-context/index" import { SystemContext } from "../system-context/index"
import { ContextSnapshotDecodeError } from "./error" import { ContextSnapshotDecodeError } from "./error"
import { SessionEvent } from "./event" import { SessionEvent } from "./event"
import { SessionHistory } from "./history"
import { SessionInput } from "./input" import { SessionInput } from "./input"
import { SessionMessageID } from "./message-id" import { SessionMessageID } from "./message-id"
import { SessionSchema } from "./schema" import { SessionSchema } from "./schema"
import { SessionContextEpochTable } from "./sql" import { SessionContextEpochTable, SessionTable } from "./sql"
type DatabaseService = Database.Interface["db"] type DatabaseService = Database.Interface["db"]
class RevisionMismatch extends Error {}
class LocationMismatch extends Error {}
export class AgentMismatch extends Error {}
export class AgentReplacementBlocked extends Schema.TaggedErrorClass<AgentReplacementBlocked>()(
"SessionContextEpoch.AgentReplacementBlocked",
{ sessionID: SessionSchema.ID, previous: AgentV2.ID, current: AgentV2.ID },
) {}
const retryRevisionMismatch = <A, E>(attempt: () => Effect.Effect<A, E>): Effect.Effect<A, E> =>
attempt().pipe(
Effect.catchDefect((defect) =>
defect instanceof RevisionMismatch
? Effect.yieldNow.pipe(Effect.andThen(retryRevisionMismatch(attempt)))
: Effect.die(defect),
),
)
interface Prepared { interface Prepared {
readonly baseline: string readonly baseline: string
readonly baselineSeq: number readonly baselineSeq: number
readonly revision: number
} }
export function initialize( export function initialize(
db: DatabaseService, db: DatabaseService,
context: Effect.Effect<SystemContext.SystemContext>, context: Effect.Effect<SystemContext.SystemContext>,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
location: Location.Ref,
agent: AgentV2.ID,
): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> { ): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> {
return initializeOnce(db, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.initialize")) return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location, agent)).pipe(
Effect.withSpan("SessionContextEpoch.initialize"),
)
} }
export function prepare( export function prepare(
@@ -33,8 +56,12 @@ export function prepare(
events: EventV2.Interface, events: EventV2.Interface,
context: Effect.Effect<SystemContext.SystemContext>, context: Effect.Effect<SystemContext.SystemContext>,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError> { location: Location.Ref,
return prepareOnce(db, events, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.prepare")) agent: AgentV2.ID,
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError | AgentReplacementBlocked> {
return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location, agent)).pipe(
Effect.withSpan("SessionContextEpoch.prepare"),
)
} }
const prepareOnce = Effect.fnUntraced(function* ( const prepareOnce = Effect.fnUntraced(function* (
@@ -42,50 +69,57 @@ const prepareOnce = Effect.fnUntraced(function* (
events: EventV2.Interface, events: EventV2.Interface,
context: Effect.Effect<SystemContext.SystemContext>, context: Effect.Effect<SystemContext.SystemContext>,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
location: Location.Ref,
agent: AgentV2.ID,
) { ) {
const [value, stored, compaction] = yield* Effect.all( const [value, stored] = yield* Effect.all([context, find(db, sessionID)], { concurrency: "unbounded" })
[context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
{ concurrency: "unbounded" },
)
if (!stored) { if (!stored) {
const generation = yield* SystemContext.initialize(value) const generation = yield* SystemContext.initialize(value)
const baselineSeq = yield* insert(db, sessionID, generation) const baselineSeq = yield* insert(db, sessionID, location, agent, generation)
return { baseline: generation.baseline, baselineSeq } return { baseline: generation.baseline, baselineSeq, revision: 0 }
} }
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe( const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(
Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })), Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })),
) )
const replacementSeq = compaction !== undefined && compaction.seq > stored.baseline_seq ? compaction.seq : undefined const replacingAgent = stored.agent !== agent
const result = replacementSeq const result =
? yield* SystemContext.replace(value, snapshot) stored.replacement_seq === null && !replacingAgent
: yield* SystemContext.reconcile(value, snapshot) ? yield* SystemContext.reconcile(value, snapshot)
: yield* SystemContext.replace(value, snapshot)
if (result._tag === "ReplacementBlocked" && replacingAgent) {
yield* fence(db, sessionID, agent, stored.revision)
return yield* new AgentReplacementBlocked({ sessionID, previous: stored.agent, current: agent })
}
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") { if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") {
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } yield* fence(db, sessionID, agent, stored.revision)
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision }
} }
if (result._tag === "ReplacementReady") { if (result._tag === "ReplacementReady") {
const baselineSeq = replacementSeq ?? (yield* SessionInput.latestSeq(db, sessionID)) const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, sessionID))
yield* replace(db, sessionID, baselineSeq, result.generation) yield* replace(db, sessionID, agent, stored.revision, replacementSeq, result.generation)
return { baseline: result.generation.baseline, baselineSeq } return { baseline: result.generation.baseline, baselineSeq: replacementSeq, revision: stored.revision + 1 }
} }
yield* events.publish( yield* events.publish(
SessionEvent.ContextUpdated, SessionEvent.ContextUpdated,
{ sessionID, messageID: SessionMessageID.ID.create(), timestamp: yield* DateTime.now, text: result.text }, { sessionID, messageID: SessionMessageID.ID.create(), timestamp: yield* DateTime.now, text: result.text },
{ commit: () => advance(db, sessionID, result.snapshot).pipe(Effect.orDie) }, { commit: () => advance(db, sessionID, stored.revision, result.snapshot).pipe(Effect.orDie) },
) )
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision + 1 }
}) })
const initializeOnce = Effect.fnUntraced(function* ( const initializeOnce = Effect.fnUntraced(function* (
db: DatabaseService, db: DatabaseService,
context: Effect.Effect<SystemContext.SystemContext>, context: Effect.Effect<SystemContext.SystemContext>,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
location: Location.Ref,
agent: AgentV2.ID,
) { ) {
if (yield* exists(db, sessionID)) return if (yield* exists(db, sessionID)) return
const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize)) const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize))
const baselineSeq = yield* insert(db, sessionID, generation) const baselineSeq = yield* insert(db, sessionID, location, agent, generation)
return { baseline: generation.baseline, baselineSeq } return { baseline: generation.baseline, baselineSeq, revision: 0 }
}) })
const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
@@ -108,6 +142,39 @@ const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseServic
.pipe(Effect.orDie) .pipe(Effect.orDie)
}) })
const requireAgentSelection = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
agent: AgentV2.ID,
) {
const selected = yield* db
.select({ agent: SessionTable.agent })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!selected || (selected.agent !== null && selected.agent !== agent)) return yield* Effect.die(new AgentMismatch())
})
export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacement")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
seq: number,
) {
return yield* db
.update(SessionContextEpochTable)
.set({ replacement_seq: seq, revision: sql`${SessionContextEpochTable.revision} + 1` })
.where(
and(
eq(SessionContextEpochTable.session_id, sessionID),
lt(SessionContextEpochTable.baseline_seq, seq),
or(isNull(SessionContextEpochTable.replacement_seq), lt(SessionContextEpochTable.replacement_seq, seq)),
),
)
.run()
.pipe(Effect.orDie)
})
export const reset = Effect.fn("SessionContextEpoch.reset")(function* ( export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
db: DatabaseService, db: DatabaseService,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
@@ -122,53 +189,155 @@ export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
const insert = Effect.fnUntraced(function* ( const insert = Effect.fnUntraced(function* (
db: DatabaseService, db: DatabaseService,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
location: Location.Ref,
agent: AgentV2.ID,
generation: SystemContext.Generation, generation: SystemContext.Generation,
) { ) {
const baselineSeq = yield* SessionInput.latestSeq(db, sessionID) return yield* db
yield* db .transaction(
.insert(SessionContextEpochTable) () =>
.values({ Effect.gen(function* () {
session_id: sessionID, const placed = yield* db
baseline: generation.baseline, .select({ agent: SessionTable.agent })
snapshot: generation.snapshot, .from(SessionTable)
baseline_seq: baselineSeq, .where(
}) and(
.run() eq(SessionTable.id, sessionID),
eq(SessionTable.directory, location.directory),
location.workspaceID === undefined
? isNull(SessionTable.workspace_id)
: eq(SessionTable.workspace_id, location.workspaceID),
),
)
.get()
.pipe(Effect.orDie)
if (!placed) return yield* Effect.die(new LocationMismatch())
if (placed.agent !== null && placed.agent !== agent) return yield* Effect.die(new AgentMismatch())
const baselineSeq = yield* SessionInput.latestSeq(db, sessionID)
yield* db
.insert(SessionContextEpochTable)
.values({
session_id: sessionID,
baseline: generation.baseline,
agent,
snapshot: generation.snapshot,
baseline_seq: baselineSeq,
revision: 0,
})
.onConflictDoNothing()
.returning({ sessionID: SessionContextEpochTable.session_id })
.get()
.pipe(
Effect.orDie,
Effect.flatMap((inserted) => (inserted ? Effect.void : Effect.die(new RevisionMismatch()))),
)
return baselineSeq
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie) .pipe(Effect.orDie)
return baselineSeq
}) })
const replace = Effect.fnUntraced(function* ( const replace = Effect.fnUntraced(function* (
db: DatabaseService, db: DatabaseService,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
agent: AgentV2.ID,
expectedRevision: number,
baselineSeq: number, baselineSeq: number,
generation: SystemContext.Generation, generation: SystemContext.Generation,
) { ) {
const updated = yield* db yield* db
.update(SessionContextEpochTable) .transaction(
.set({ () =>
baseline: generation.baseline, Effect.gen(function* () {
snapshot: generation.snapshot, yield* requireAgentSelection(db, sessionID, agent)
baseline_seq: baselineSeq, const updated = yield* db
}) .update(SessionContextEpochTable)
.set({
baseline: generation.baseline,
agent,
snapshot: generation.snapshot,
baseline_seq: baselineSeq,
replacement_seq: null,
revision: expectedRevision + 1,
})
.where(
and(
eq(SessionContextEpochTable.session_id, sessionID),
eq(SessionContextEpochTable.revision, expectedRevision),
),
)
.returning({ revision: SessionContextEpochTable.revision })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new RevisionMismatch())
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
})
const fence = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
agent: AgentV2.ID,
expectedRevision: number,
) {
const current = yield* db
.select({ selected: SessionTable.agent, revision: SessionContextEpochTable.revision })
.from(SessionContextEpochTable)
.innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id))
.where(eq(SessionContextEpochTable.session_id, sessionID)) .where(eq(SessionContextEpochTable.session_id, sessionID))
.returning({ sessionID: SessionContextEpochTable.session_id })
.get() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (!updated) return yield* Effect.die("Context Epoch not found") if (!current || (current.selected !== null && current.selected !== agent))
return yield* Effect.die(new AgentMismatch())
if (current.revision !== expectedRevision) return yield* Effect.die(new RevisionMismatch())
})
export const current = Effect.fn("SessionContextEpoch.current")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
agent: AgentV2.ID,
revision: number,
) {
const value = yield* db
.select({
agent: SessionContextEpochTable.agent,
selected: SessionTable.agent,
revision: SessionContextEpochTable.revision,
})
.from(SessionContextEpochTable)
.innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id))
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)
return (
value !== undefined &&
value.agent === agent &&
(value.selected === null || value.selected === agent) &&
value.revision === revision
)
}) })
const advance = Effect.fnUntraced(function* ( const advance = Effect.fnUntraced(function* (
db: DatabaseService, db: DatabaseService,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
expectedRevision: number,
snapshot: SystemContext.Snapshot, snapshot: SystemContext.Snapshot,
) { ) {
const updated = yield* db const updated = yield* db
.update(SessionContextEpochTable) .update(SessionContextEpochTable)
.set({ snapshot }) .set({ snapshot, revision: expectedRevision + 1 })
.where(eq(SessionContextEpochTable.session_id, sessionID)) .where(
.returning({ sessionID: SessionContextEpochTable.session_id }) and(
eq(SessionContextEpochTable.session_id, sessionID),
eq(SessionContextEpochTable.revision, expectedRevision),
isNull(SessionContextEpochTable.replacement_seq),
),
)
.returning({ revision: SessionContextEpochTable.revision })
.get() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (!updated) return yield* Effect.die("Context Epoch not found") if (!updated) return yield* Effect.die(new RevisionMismatch())
}) })
+11 -3
View File
@@ -27,13 +27,13 @@ const Base = {
} }
const options = { const options = {
durable: { sync: {
aggregate: "sessionID", aggregate: "sessionID",
version: 1, version: 1,
}, },
} as const } as const
const stepSettlementOptions = { const stepSettlementOptions = {
durable: { sync: {
aggregate: "sessionID", aggregate: "sessionID",
version: 2, version: 2,
}, },
@@ -118,6 +118,13 @@ export namespace PromptLifecycle {
export type Promoted = typeof Promoted.Type export type Promoted = typeof Promoted.Type
} }
export const InterruptRequested = EventV2.define({
type: "session.next.interrupt.requested",
...options,
schema: Base,
})
export type InterruptRequested = typeof InterruptRequested.Type
export const ContextUpdated = EventV2.define({ export const ContextUpdated = EventV2.define({
type: "session.next.context.updated", type: "session.next.context.updated",
...options, ...options,
@@ -449,7 +456,7 @@ export namespace Compaction {
export const Ended = EventV2.define({ export const Ended = EventV2.define({
type: "session.next.compaction.ended", type: "session.next.compaction.ended",
durable: { aggregate: "sessionID", version: 2 }, sync: { aggregate: "sessionID", version: 2 },
schema: { schema: {
...Base, ...Base,
messageID: SessionMessageID.ID, messageID: SessionMessageID.ID,
@@ -468,6 +475,7 @@ const DurableDefinitions = [
Prompted, Prompted,
PromptLifecycle.Admitted, PromptLifecycle.Admitted,
PromptLifecycle.Promoted, PromptLifecycle.Promoted,
InterruptRequested,
ContextUpdated, ContextUpdated,
Synthetic, Synthetic,
Shell.Started, Shell.Started,
+4 -4
View File
@@ -5,12 +5,12 @@ import { SessionRunner } from "./runner/index"
import { SessionSchema } from "./schema" import { SessionSchema } from "./schema"
export interface Interface { export interface Interface {
/** Starts execution while idle or joins the active execution. */ /** Explicitly drain one Session, making at least one provider attempt. */
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError> readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Registers newly recorded work. Repeated wakeups may coalesce. */ /** Schedule a drain after durable work is recorded. Repeated wakeups may coalesce. */
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void> readonly wake: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect<void, SessionRunner.RunError>
/** Interrupt active work owned by this process. Idle interruption is a no-op. */ /** Interrupt active work owned by this process. Idle interruption is a no-op. */
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void> readonly interrupt: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect<void>
} }
/** Routes execution from a Session ID to the runner owned by that Session's Location. */ /** Routes execution from a Session ID to the runner owned by that Session's Location. */
+6 -9
View File
@@ -1,10 +1,11 @@
import { Cause, Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { LocationServiceMap } from "../../location-layer" import { LocationServiceMap } from "../../location-layer"
import { SessionRunCoordinator } from "../run-coordinator" import { SessionRunCoordinator } from "../run-coordinator"
import { SessionRunner } from "../runner" import { SessionRunner } from "../runner"
import { SessionSchema } from "../schema" import { SessionSchema } from "../schema"
import { SessionStore } from "../store" import { SessionStore } from "../store"
import { SessionExecution } from "../execution" import { SessionExecution } from "../execution"
import { logFailure } from "../logging"
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */ /** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
export const layer = Layer.effect( export const layer = Layer.effect(
@@ -12,19 +13,15 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap const locations = yield* LocationServiceMap
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({ const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, void, SessionRunner.RunError>({
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) { drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, mode) {
const session = yield* store.get(sessionID) const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`) if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force })).pipe( return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force: mode === "run" })).pipe(
Effect.provide(locations.get(session.location)), Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
),
) )
}), }),
onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause),
}) })
return SessionExecution.Service.of({ return SessionExecution.Service.of({
+2 -2
View File
@@ -10,9 +10,9 @@ type DatabaseService = Database.Interface["db"]
const decode = Schema.decodeUnknownEffect(SessionMessage.Message) const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db return yield* db
.select({ seq: SessionMessageTable.seq }) .select()
.from(SessionMessageTable) .from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
.orderBy(desc(SessionMessageTable.seq)) .orderBy(desc(SessionMessageTable.seq))
+40 -2
View File
@@ -74,11 +74,11 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
}) })
.pipe( .pipe(
Effect.flatMap((event) => Effect.flatMap((event) =>
event.durable === undefined event.seq === undefined
? Effect.die("Prompt admission event is missing aggregate sequence") ? Effect.die("Prompt admission event is missing aggregate sequence")
: Effect.succeed( : Effect.succeed(
new Admitted({ new Admitted({
admittedSeq: event.durable.seq, admittedSeq: event.seq,
id: input.id, id: input.id,
sessionID: input.sessionID, sessionID: input.sessionID,
prompt: input.prompt, prompt: input.prompt,
@@ -117,6 +117,13 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
readonly timeCreated: DateTime.Utc readonly timeCreated: DateTime.Utc
}, },
) { ) {
const message = yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, input.id))
.get()
.pipe(Effect.orDie)
if (message) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const stored = yield* db const stored = yield* db
.insert(SessionInputTable) .insert(SessionInputTable)
.values({ .values({
@@ -201,6 +208,37 @@ const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionS
input.sessionID === expected.sessionID && input.sessionID === expected.sessionID &&
JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt)) JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
export const guardReservedID = Effect.fn("SessionInput.guardReservedID")(function* (
db: DatabaseService,
event: EventV2.Payload,
) {
if (
Schema.is(SessionEvent.PromptLifecycle.Admitted)(event) ||
Schema.is(SessionEvent.PromptLifecycle.Promoted)(event)
)
return
const id = reservedID(event)
if (id === undefined) return
const admitted = yield* db
.select({ id: SessionInputTable.id })
.from(SessionInputTable)
.where(eq(SessionInputTable.id, id))
.get()
.pipe(Effect.orDie)
if (admitted === undefined) return
return yield* Effect.die(new LifecycleConflict({ id }))
})
const reservedID = (event: EventV2.Payload) => {
if (Schema.is(SessionEvent.Step.Started)(event)) return event.data.assistantMessageID
if (Schema.is(SessionEvent.AgentSwitched)(event)) return event.data.messageID
if (Schema.is(SessionEvent.ModelSwitched)(event)) return event.data.messageID
if (Schema.is(SessionEvent.Prompted)(event)) return event.data.messageID
if (Schema.is(SessionEvent.Synthetic)(event)) return event.data.messageID
if (Schema.is(SessionEvent.Shell.Started)(event)) return event.data.messageID
if (Schema.is(SessionEvent.Compaction.Started)(event)) return event.data.messageID
}
export const projectLegacyPrompted = Effect.fn("SessionInput.projectLegacyPrompted")(function* ( export const projectLegacyPrompted = Effect.fn("SessionInput.projectLegacyPrompted")(function* (
db: DatabaseService, db: DatabaseService,
input: { input: {
+8
View File
@@ -0,0 +1,8 @@
import { Cause, Effect } from "effect"
import { SessionSchema } from "./schema"
export const logFailure = (
message: "Failed to drain Session" | "Failed to wake Session",
sessionID: SessionSchema.ID,
cause: Cause.Cause<unknown>,
) => Effect.logError(message, cause).pipe(Effect.annotateLogs({ sessionID }))
@@ -138,6 +138,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}, },
"session.next.prompt.admitted": () => Effect.void, "session.next.prompt.admitted": () => Effect.void,
"session.next.prompt.promoted": () => Effect.void, "session.next.prompt.promoted": () => Effect.void,
"session.next.interrupt.requested": () => Effect.void,
"session.next.context.updated": (event) => "session.next.context.updated": (event) =>
adapter.appendMessage( adapter.appendMessage(
new SessionMessage.System({ new SessionMessage.System({
+41 -17
View File
@@ -115,7 +115,7 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) => const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }) decodeMessage({ ...row.data, id: row.id, type: row.type })
const updateMessage = (message: SessionMessage.Message) => { const updateMessage = (message: SessionMessage.Message) => {
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence") if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
const encoded = encodeMessage(message) const encoded = encodeMessage(message)
const { id, type, ...data } = encoded const { id, type, ...data } = encoded
return db return db
@@ -192,7 +192,7 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
} }
function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: SessionMessage.Message) { function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: SessionMessage.Message) {
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence") if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
const encoded = encodeMessage(message) const encoded = encodeMessage(message)
const { id, type, ...data } = encoded const { id, type, ...data } = encoded
return db return db
@@ -201,7 +201,7 @@ function insertMessage(db: DatabaseService, event: SessionEvent.Event, message:
id: SessionMessage.ID.make(id), id: SessionMessage.ID.make(id),
session_id: event.data.sessionID, session_id: event.data.sessionID,
type, type,
seq: event.durable.seq, seq: event.seq,
time_created: DateTime.toEpochMillis(message.time.created), time_created: DateTime.toEpochMillis(message.time.created),
data, data,
}) })
@@ -213,6 +213,7 @@ export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const { db } = yield* Database.Service const { db } = yield* Database.Service
yield* events.beforeCommit((event) => SessionInput.guardReservedID(db, event))
yield* events.project(SessionV1.Event.Created, (event) => yield* events.project(SessionV1.Event.Created, (event) =>
Effect.gen(function* () { Effect.gen(function* () {
const stored = yield* db const stored = yield* db
@@ -329,14 +330,19 @@ export const layer = Layer.effectDiscard(
if (next) yield* applyUsage(db, sessionID, next) if (next) yield* applyUsage(db, sessionID, next)
}), }),
) )
yield* events.project(SessionEvent.AgentSwitched, (event) => yield* events.project(SessionEvent.AgentSwitched, (event) => {
db if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
return db
.update(SessionTable) .update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) .set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie, Effect.andThen(run(db, event))), .pipe(
) Effect.orDie,
Effect.andThen(run(db, event)),
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)),
)
})
yield* events.project(SessionEvent.ModelSwitched, (event) => yield* events.project(SessionEvent.ModelSwitched, (event) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* db yield* db
@@ -346,6 +352,9 @@ export const layer = Layer.effectDiscard(
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* run(db, event) yield* run(db, event)
if (event.seq === undefined)
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)
}), }),
) )
yield* events.project(SessionEvent.Prompted, (event) => yield* events.project(SessionEvent.Prompted, (event) =>
@@ -359,22 +368,24 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (existing) return yield* Effect.die(new PromptAlreadyProjected()) if (existing) return yield* Effect.die(new PromptAlreadyProjected())
yield* run(db, event) yield* run(db, event)
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") if (event.seq === undefined)
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
yield* SessionInput.projectLegacyPrompted(db, { yield* SessionInput.projectLegacyPrompted(db, {
id: messageID, id: messageID,
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
prompt: event.data.prompt, prompt: event.data.prompt,
delivery: event.data.delivery, delivery: event.data.delivery,
timeCreated: event.data.timestamp, timeCreated: event.data.timestamp,
promotedSeq: event.durable.seq, promotedSeq: event.seq,
}) })
}), }),
) )
yield* events.project(SessionEvent.PromptLifecycle.Admitted, (event) => yield* events.project(SessionEvent.PromptLifecycle.Admitted, (event) =>
Effect.gen(function* () { Effect.gen(function* () {
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") if (event.seq === undefined)
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
yield* SessionInput.projectAdmitted(db, { yield* SessionInput.projectAdmitted(db, {
admittedSeq: event.durable.seq, admittedSeq: event.seq,
id: event.data.messageID, id: event.data.messageID,
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
prompt: event.data.prompt, prompt: event.data.prompt,
@@ -385,7 +396,8 @@ export const layer = Layer.effectDiscard(
) )
yield* events.project(SessionEvent.PromptLifecycle.Promoted, (event) => yield* events.project(SessionEvent.PromptLifecycle.Promoted, (event) =>
Effect.gen(function* () { Effect.gen(function* () {
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") if (event.seq === undefined)
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
yield* insertMessage( yield* insertMessage(
db, db,
event, event,
@@ -394,12 +406,18 @@ export const layer = Layer.effectDiscard(
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
prompt: event.data.prompt, prompt: event.data.prompt,
timeCreated: event.data.timeCreated, timeCreated: event.data.timeCreated,
promotedSeq: event.durable.seq, promotedSeq: event.seq,
}), }),
) )
}), }),
) )
yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event)) yield* events.project(SessionEvent.InterruptRequested, () => Effect.void)
yield* events.project(SessionEvent.ContextUpdated, (event) => {
if (!event.replay || event.seq === undefined) return run(db, event)
return run(db, event).pipe(
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)),
)
})
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
@@ -417,9 +435,15 @@ export const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Retried, (event) => run(db, event)) // yield* events.project(SessionEvent.Retried, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) => yield* events.project(SessionEvent.Compaction.Ended, (event) => {
event.durable?.version === 1 ? Effect.void : run(db, event), if (event.version === 1) return Effect.void
) const seq = event.seq
if (seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
return Effect.gen(function* () {
yield* run(db, event)
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, seq)
})
})
}), }),
) )
+237 -55
View File
@@ -1,43 +1,106 @@
export * as SessionRunCoordinator from "./run-coordinator" export * as SessionRunCoordinator from "./run-coordinator"
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect" import { Cause, Context, Deferred, Effect, Exit, Fiber, FiberSet, Layer, Scope } from "effect"
import { SessionRunner } from "./runner"
import { logFailure } from "./logging"
import { SessionSchema } from "./schema"
/** Serializes execution for each key while allowing different keys to run concurrently. */ export type Mode = "run" | "wake"
export interface Coordinator<Key, E> {
/** Starts execution while idle or joins the active execution. */ /** Why one drain generation should run. Explicit runs dominate advisory wakes when demands coalesce. */
readonly run: (key: Key) => Effect.Effect<void, E> type Demand = { readonly _tag: "run" } | { readonly _tag: "wake"; readonly seq?: number }
/** Registers one coalesced follow-up after newly recorded work. */
readonly wake: (key: Key) => Effect.Effect<void> /**
/** Stops active execution and waits for its cleanup. */ * Runs at most one drain chain per key while allowing different keys to drain concurrently.
readonly interrupt: (key: Key) => Effect.Effect<void> *
* For each key:
*
* idle --run/wake--> draining --run/wake--> draining + one coalesced rerun --> idle
*
* `run` is an explicit drain request. It starts a chain or joins the current chain and
* upgrades a pending follow-up so the caller receives explicit-run semantics.
*
* `wake` reports that durable work may now be available. It starts a chain while idle or
* requests one coalesced follow-up while draining. Repeated wakes collapse together.
*
* `interrupt` stops the current ownership chain. Advisory wakes from before the interrupt
* boundary are suppressed; advisory wakes after the boundary run after cleanup.
*/
export interface Coordinator<Key, A, E> {
/** Starts or joins one explicit drain generation. */
readonly run: (key: Key) => Effect.Effect<A, E>
/** Coalesces one wake-up after durable work is recorded. */
readonly wake: (key: Key, seq?: number) => Effect.Effect<void>
/** Waits until the current ownership chain settles. */
readonly awaitIdle: (key: Key) => Effect.Effect<void, E>
/** Interrupts the active ownership chain without automatically draining pending wakes. */
readonly interrupt: (key: Key, seq?: number) => Effect.Effect<void>
} }
type Entry<E> = { /** One Session's process-local execution lane: one active demand and at most one coalesced follow-up. */
readonly done: Deferred.Deferred<void, E> type Entry<A, E> = {
readonly done: Deferred.Deferred<A, E>
readonly settled: Deferred.Deferred<Exit.Exit<A, E>>
current: Demand
pending?: Demand
explicitWaiter?: Deferred.Deferred<A, E>
interruptSeq?: number
owner?: Fiber.Fiber<void, never> owner?: Fiber.Fiber<void, never>
pendingWake: boolean
stopping: boolean stopping: boolean
} }
export const make = <Key, E>(options: { /** Combines follow-up demand: runs dominate, while wakes retain the newest durable admission sequence. */
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E> const coalesce = (left: Demand | undefined, right: Demand): Demand => {
}): Effect.Effect<Coordinator<Key, E>, never, Scope.Scope> => if (left?._tag === "run" || right._tag === "run") return { _tag: "run" }
Effect.gen(function* () { return { _tag: "wake", seq: maxSeq(left?.seq, right.seq) }
const active = new Map<Key, Entry<E>>() }
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const makeEntry = (): Entry<E> => ({ const maxSeq = (left: number | undefined, right: number | undefined) => {
done: Deferred.makeUnsafe<void, E>(), if (left === undefined) return right
pendingWake: false, if (right === undefined) return left
return Math.max(left, right)
}
/** Constructs a scoped coordinator. Every in-memory transition is synchronous. */
export const make = <Key, A, E>(options: {
readonly drain: (key: Key, mode: Mode) => Effect.Effect<A, E>
readonly onFailure?: (key: Key, cause: Cause.Cause<E>) => Effect.Effect<void>
}): Effect.Effect<Coordinator<Key, A, E>, never, Scope.Scope> =>
Effect.gen(function* () {
const active = new Map<Key, Entry<A, E>>()
const interruptSeq = new Map<Key, number>()
const report = yield* FiberSet.makeRuntime<never, void, never>()
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const shutdown = Deferred.makeUnsafe<void>()
let closed = false
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
closed = true
Deferred.doneUnsafe(shutdown, Effect.void)
active.clear()
interruptSeq.clear()
}),
)
const makeEntry = (current: Demand, explicitWaiter?: Deferred.Deferred<A, E>): Entry<A, E> => ({
done: Deferred.makeUnsafe<A, E>(),
settled: Deferred.makeUnsafe<Exit.Exit<A, E>>(),
current,
explicitWaiter,
stopping: false, stopping: false,
}) })
const start = (key: Key, entry: Entry<E>, force: boolean, successor = false) => { const start = (key: Key, entry: Entry<A, E>, demand: Demand, successor = false) => {
const ready = Deferred.makeUnsafe<void>() const ready = Deferred.makeUnsafe<void>()
const drain = Effect.suspend(() => options.drain(key, demand._tag))
// Initial work retains immediate-start behavior but cannot run before ownership is published.
// Observer-started successors yield once so synchronous drains cannot recurse on the JS stack.
const owner = fork( const owner = fork(
(successor ? Effect.yieldNow : Deferred.await(ready)).pipe( (successor
Effect.andThen(Effect.suspend(() => options.drain(key, force))), ? Effect.yieldNow.pipe(Effect.andThen(drain))
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, exit))), : Deferred.await(ready).pipe(Effect.andThen(drain))
).pipe(
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, demand, exit))),
Effect.exit, Effect.exit,
Effect.asVoid, Effect.asVoid,
), ),
@@ -46,57 +109,176 @@ export const make = <Key, E>(options: {
if (!successor) Deferred.doneUnsafe(ready, Effect.void) if (!successor) Deferred.doneUnsafe(ready, Effect.void)
} }
const settle = (key: Key, entry: Entry<E>, exit: Exit.Exit<void, E>) => { const settle = (key: Key, entry: Entry<A, E>, demand: Demand, exit: Exit.Exit<A, E>) => {
if (Exit.isSuccess(exit) && !entry.stopping && entry.pendingWake) { if (closed) {
entry.pendingWake = false Deferred.doneUnsafe(entry.done, exit)
start(key, entry, false, true) Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
return
}
if (demand._tag === "run" && entry.explicitWaiter !== undefined) {
Deferred.doneUnsafe(entry.explicitWaiter, exit)
entry.explicitWaiter = undefined
}
if (entry.stopping && demand._tag === "wake" && entry.explicitWaiter !== undefined) {
Deferred.doneUnsafe(entry.explicitWaiter, exit)
entry.explicitWaiter = undefined
}
if (active.get(key) !== entry) {
Deferred.doneUnsafe(entry.done, exit)
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
return
}
if (exit._tag === "Success" && !entry.stopping) {
if (entry.pending !== undefined) {
const pending = entry.pending
entry.pending = undefined
entry.current = pending
start(key, entry, pending, true)
return
}
active.delete(key)
Deferred.doneUnsafe(entry.done, exit)
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
return return
} }
const successor = entry.pendingWake ? makeEntry() : undefined const successor = entry.pending !== undefined ? makeEntry(entry.pending, entry.explicitWaiter) : undefined
if (successor === undefined) active.delete(key) if (successor === undefined) active.delete(key)
else { else active.set(key, successor)
active.set(key, successor) if (successor !== undefined) start(key, successor, successor.current, true)
start(key, successor, false, true)
}
Deferred.doneUnsafe(entry.done, exit) Deferred.doneUnsafe(entry.done, exit)
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
if (
exit._tag === "Failure" &&
!(entry.stopping && Cause.hasInterruptsOnly(exit.cause)) &&
demand._tag === "wake" &&
options.onFailure !== undefined
) {
report(Effect.suspend(() => options.onFailure!(key, exit.cause)))
}
} }
const run = (key: Key): Effect.Effect<void, E> => const wake = (key: Key, seq?: number) =>
Effect.uninterruptibleMask((restore) => {
const entry = active.get(key)
if (entry !== undefined) {
if (entry.stopping) return restore(Deferred.await(entry.done).pipe(Effect.andThen(run(key))))
return restore(Deferred.await(entry.done))
}
const next = makeEntry()
active.set(key, next)
start(key, next, true)
return restore(Deferred.await(next.done))
})
const wake = (key: Key) =>
Effect.sync(() => { Effect.sync(() => {
if (closed) return
if (!isAfterInterrupt(key, seq)) return
const entry = active.get(key) const entry = active.get(key)
if (entry !== undefined) { if (entry !== undefined) {
entry.pendingWake = true if (!acceptsWake(entry, seq)) return
entry.pending = coalesce(entry.pending, { _tag: "wake", seq })
return return
} }
const next = makeEntry() const next = makeEntry({ _tag: "wake", seq })
active.set(key, next) active.set(key, next)
start(key, next, false) start(key, next, next.current)
}) })
const interrupt = (key: Key): Effect.Effect<void> => const awaitIdle = (key: Key): Effect.Effect<void, E> =>
Effect.gen(function* () {
let firstFailure: Cause.Cause<E> | undefined
while (!closed) {
const entry = active.get(key)
if (entry === undefined) break
const exit = yield* Effect.raceFirst(
Deferred.await(entry.settled),
Deferred.await(shutdown).pipe(Effect.as(Exit.void)),
)
if (closed) break
if (exit._tag === "Failure" && firstFailure === undefined) firstFailure = exit.cause
}
if (firstFailure !== undefined) return yield* Effect.failCause(firstFailure)
})
const interrupt = (key: Key, seq?: number): Effect.Effect<void> =>
Effect.suspend(() => { Effect.suspend(() => {
const entry = active.get(key) const entry = active.get(key)
const latest = interruptSeq.get(key)
if (seq !== undefined && latest !== undefined && seq <= latest)
return entry?.stopping && entry.owner !== undefined ? Fiber.interrupt(entry.owner) : Effect.void
if (seq !== undefined) interruptSeq.set(key, seq)
if (entry?.owner === undefined) return Effect.void if (entry?.owner === undefined) return Effect.void
if (
seq !== undefined &&
entry.current._tag === "wake" &&
entry.current.seq !== undefined &&
entry.current.seq > seq
)
return Effect.void
if (entry.stopping) {
entry.interruptSeq = maxSeq(entry.interruptSeq, seq)
suppressPendingAtOrBefore(entry, seq)
return Fiber.interrupt(entry.owner)
}
entry.stopping = true entry.stopping = true
entry.pendingWake = false entry.interruptSeq = seq
suppressPendingAtOrBefore(entry, seq)
return Fiber.interrupt(entry.owner) return Fiber.interrupt(entry.owner)
}) })
return { run, wake, interrupt } return { run, wake, awaitIdle, interrupt }
function run(key: Key): Effect.Effect<A, E> {
return Effect.uninterruptibleMask((restore) => {
if (closed) return Effect.interrupt
const entry = active.get(key)
if (entry !== undefined) {
if (entry.stopping) {
return restore(Deferred.await(entry.settled).pipe(Effect.andThen(run(key))))
}
if (entry.current._tag === "wake") {
entry.pending = coalesce(entry.pending, { _tag: "run" })
entry.explicitWaiter ??= Deferred.makeUnsafe<A, E>()
return restore(awaitRun(entry.explicitWaiter))
}
return restore(awaitRun(entry.done))
}
const next = makeEntry({ _tag: "run" })
active.set(key, next)
start(key, next, next.current)
return restore(awaitRun(next.done))
})
}
function awaitRun(done: Deferred.Deferred<A, E>): Effect.Effect<A, E> {
return Effect.raceFirst(Deferred.await(done), Deferred.await(shutdown).pipe(Effect.andThen(Effect.interrupt)))
}
function acceptsWake(entry: Entry<A, E>, seq: number | undefined) {
return !entry.stopping || (entry.interruptSeq !== undefined && seq !== undefined && seq > entry.interruptSeq)
}
function isAfterInterrupt(key: Key, seq: number | undefined) {
const latest = interruptSeq.get(key)
return latest === undefined || (seq !== undefined && seq > latest)
}
function suppressPendingAtOrBefore(entry: Entry<A, E>, seq: number | undefined) {
if (
entry.pending?._tag === "wake" &&
seq !== undefined &&
entry.pending.seq !== undefined &&
entry.pending.seq > seq
)
return
entry.pending = undefined
}
}) })
export interface Interface extends Coordinator<SessionSchema.ID, void, SessionRunner.RunError> {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunCoordinator") {}
export const layer = Layer.effect(
Service,
SessionRunner.Service.pipe(
Effect.flatMap((runner) =>
make<SessionSchema.ID, void, SessionRunner.RunError>({
drain: (sessionID, mode) => runner.run({ sessionID, force: mode === "run" }),
onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause),
}),
),
Effect.map(Service.of),
),
)
+3 -1
View File
@@ -6,6 +6,7 @@ import { SessionSchema } from "../schema"
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error" import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
import { SessionRunnerModel } from "./model" import { SessionRunnerModel } from "./model"
import type { SystemContext } from "../../system-context/index" import type { SystemContext } from "../../system-context/index"
import type { SessionContextEpoch } from "../context-epoch"
import type { ToolOutputStore } from "../../tool-output-store" import type { ToolOutputStore } from "../../tool-output-store"
export type RunError = export type RunError =
@@ -14,6 +15,7 @@ export type RunError =
| MessageDecodeError | MessageDecodeError
| ContextSnapshotDecodeError | ContextSnapshotDecodeError
| SystemContext.InitializationBlocked | SystemContext.InitializationBlocked
| SessionContextEpoch.AgentReplacementBlocked
| ToolOutputStore.Error | ToolOutputStore.Error
/** Runs one local continuation from already-recorded Session history. */ /** Runs one local continuation from already-recorded Session history. */
@@ -21,7 +23,7 @@ export interface Interface {
/** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */ /** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */
readonly run: (input: { readonly run: (input: {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly force: boolean readonly force?: boolean
}) => Effect.Effect<void, RunError> }) => Effect.Effect<void, RunError>
} }
+48 -15
View File
@@ -8,7 +8,7 @@ import {
isContextOverflowFailure, isContextOverflowFailure,
type ProviderErrorEvent, type ProviderErrorEvent,
} from "@opencode-ai/llm" } from "@opencode-ai/llm"
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { Cause, DateTime, Effect, FiberSet, Layer, Option, Schema, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent" import { AgentV2 } from "../../agent"
import { Config } from "../../config" import { Config } from "../../config"
import { Database } from "../../database/database" import { Database } from "../../database/database"
@@ -141,8 +141,8 @@ export const layer = Layer.effect(
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError) cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
type TurnTransition = type TurnTransition =
// Automatic compaction completed; rebuild the request from compacted history. // Request preparation observed a concurrent Session change and must restart from durable state.
| { readonly _tag: "ContinueAfterCompaction" } | { readonly _tag: "RebuildPreparedTurn"; readonly promotion?: SessionInput.Delivery }
// Overflow compaction completed; rebuild once through the path without overflow recovery. // Overflow compaction completed; rebuild once through the path without overflow recovery.
| { readonly _tag: "ContinueAfterOverflowCompaction" } | { readonly _tag: "ContinueAfterOverflowCompaction" }
@@ -152,11 +152,20 @@ export const layer = Layer.effect(
} }
} }
const continueAfterCompaction = new TurnTransitionError({ _tag: "ContinueAfterCompaction" }) const rebuildPreparedTurn = (promotion?: SessionInput.Delivery) =>
new TurnTransitionError({ _tag: "RebuildPreparedTurn", promotion })
const continueAfterOverflowCompaction = new TurnTransitionError({ const continueAfterOverflowCompaction = new TurnTransitionError({
_tag: "ContinueAfterOverflowCompaction", _tag: "ContinueAfterOverflowCompaction",
}) })
const retryAgentMismatch = (promotion: SessionInput.Delivery | undefined) =>
Effect.catchDefect((defect) =>
defect instanceof SessionContextEpoch.AgentMismatch
? Effect.die(rebuildPreparedTurn(promotion))
: Effect.die(defect),
)
const sameModel = Schema.toEquivalence(Schema.UndefinedOr(ModelV2.Ref))
const loadSystemContext = (agent: AgentV2.Selection) => const loadSystemContext = (agent: AgentV2.Selection) =>
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], { Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
concurrency: "unbounded", concurrency: "unbounded",
@@ -172,7 +181,13 @@ export const layer = Layer.effect(
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID) if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
return yield* Effect.interrupt return yield* Effect.interrupt
const agent = yield* agents.select(session.agent) const agent = yield* agents.select(session.agent)
const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id) const initialized = yield* SessionContextEpoch.initialize(
db,
loadSystemContext(agent),
session.id,
session.location,
agent.id,
).pipe(retryAgentMismatch(promotion))
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>() const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
let needsContinuation = false let needsContinuation = false
if (promotion) { if (promotion) {
@@ -184,7 +199,18 @@ export const layer = Layer.effect(
} }
} }
const system = const system =
initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id)) initialized ??
(yield* SessionContextEpoch.prepare(
db,
events,
loadSystemContext(agent),
session.id,
session.location,
agent.id,
).pipe(retryAgentMismatch(undefined)))
const current = yield* getSession(sessionID)
if ((yield* agents.select(current.agent)).id !== agent.id || !sameModel(current.model, session.model))
return yield* Effect.die(rebuildPreparedTurn())
const model = yield* models.resolve(session) const model = yield* models.resolve(session)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const context = entries.map((entry) => entry.message) const context = entries.map((entry) => entry.message)
@@ -202,7 +228,7 @@ export const layer = Layer.effect(
toolChoice: isLastStep ? "none" : undefined, toolChoice: isLastStep ? "none" : undefined,
}) })
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
return yield* Effect.die(continueAfterCompaction) return yield* Effect.die(rebuildPreparedTurn())
const publisher = createLLMEventPublisher(events, { const publisher = createLLMEventPublisher(events, {
sessionID: session.id, sessionID: session.id,
agent: agent.id, agent: agent.id,
@@ -216,6 +242,8 @@ export const layer = Layer.effect(
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) => const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
withPublication(publisher.publish(event, outputPaths)) withPublication(publisher.publish(event, outputPaths))
let overflowFailure: ProviderErrorEvent | undefined let overflowFailure: ProviderErrorEvent | undefined
if (!(yield* SessionContextEpoch.current(db, session.id, agent.id, system.revision)))
return yield* Effect.die(rebuildPreparedTurn())
const providerStream = llm.stream(request).pipe( const providerStream = llm.stream(request).pipe(
Stream.runForEach((event) => Stream.runForEach((event) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -277,7 +305,14 @@ export const layer = Layer.effect(
const llmFailure = failure instanceof LLMError ? failure : undefined const llmFailure = failure instanceof LLMError ? failure : undefined
if (llmFailure && !publisher.hasProviderError()) { if (llmFailure && !publisher.hasProviderError()) {
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
yield* withPublication(publisher.failAssistant(llmFailure.reason.message)) yield* withPublication(
events.publish(SessionEvent.Step.Failed, {
sessionID: session.id,
timestamp: yield* DateTime.now,
assistantMessageID: yield* publisher.startAssistant(),
error: { type: "unknown", message: llmFailure.reason.message },
}),
)
} }
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers) if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit) const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
@@ -292,8 +327,6 @@ export const layer = Layer.effect(
) { ) {
yield* FiberSet.clear(toolFibers) yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
if (publisher.hasActiveAssistant())
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
} }
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) { if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
const failure = Cause.squash(settled.cause) const failure = Cause.squash(settled.cause)
@@ -324,7 +357,7 @@ export const layer = Layer.effect(
if (defect.transition._tag === "ContinueAfterOverflowCompaction") if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow") return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
yield* Effect.yieldNow yield* Effect.yieldNow
return yield* runAfterOverflowCompaction(sessionID, undefined, step) return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion, step)
}), }),
), ),
) )
@@ -338,7 +371,7 @@ export const layer = Layer.effect(
yield* Effect.yieldNow yield* Effect.yieldNow
if (defect.transition._tag === "ContinueAfterOverflowCompaction") if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* runAfterOverflowCompaction(sessionID, undefined, step) return yield* runAfterOverflowCompaction(sessionID, undefined, step)
return yield* runTurn(sessionID, undefined, step) return yield* runTurn(sessionID, defect.transition.promotion, step)
}), }),
), ),
) )
@@ -346,14 +379,14 @@ export const layer = Layer.effect(
const run = Effect.fn("SessionRunner.run")(function* (input: { const run = Effect.fn("SessionRunner.run")(function* (input: {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly force: boolean readonly force?: boolean
}) { }) {
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer") const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue") const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
if (!input.force && !hasSteer && !hasQueue) return if (input.force !== true && !hasSteer && !hasQueue) return
yield* failInterruptedTools(input.sessionID) yield* failInterruptedTools(input.sessionID)
let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
let openActivity = input.force || hasSteer || hasQueue let openActivity = input.force === true || hasSteer || hasQueue
while (openActivity) { while (openActivity) {
let needsContinuation = true let needsContinuation = true
for (let step = 1; needsContinuation; step++) { for (let step = 1; needsContinuation; step++) {
+20 -64
View File
@@ -5,7 +5,7 @@ import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-message
import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat" import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat"
import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses" import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses"
import { Auth, type AnyRoute } from "@opencode-ai/llm/route" import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer, Option, Schema } from "effect"
import { produce } from "immer" import { produce } from "immer"
import { Catalog } from "../../catalog" import { Catalog } from "../../catalog"
import { Credential } from "../../credential" import { Credential } from "../../credential"
@@ -24,23 +24,6 @@ export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelec
}, },
) {} ) {}
export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
"SessionRunnerModel.ModelUnavailableError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
},
) {}
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
"SessionRunnerModel.VariantUnavailableError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
variant: ModelV2.VariantID,
},
) {}
export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiError>()( export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiError>()(
"SessionRunnerModel.UnsupportedApiError", "SessionRunnerModel.UnsupportedApiError",
{ {
@@ -50,7 +33,11 @@ export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiE
}, },
) {} ) {}
export type Error = ModelNotSelectedError | ModelUnavailableError | VariantUnavailableError | UnsupportedApiError export type Error =
| Catalog.ProviderNotFoundError
| Catalog.ModelNotFoundError
| ModelNotSelectedError
| UnsupportedApiError
export interface Interface { export interface Interface {
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error> readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error>
@@ -61,7 +48,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
/** Test or embedding seam for supplying a model resolver directly. */ /** Test or embedding seam for supplying a model resolver directly. */
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Info) => { const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => {
if (credential?.value.type === "key") return Auth.value(credential.value.key) if (credential?.value.type === "key") return Auth.value(credential.value.key)
if (credential?.value.type === "oauth") return Auth.value(credential.value.access) if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
const value = model.request.body.apiKey ?? model.api.settings?.apiKey const value = model.request.body.apiKey ?? model.api.settings?.apiKey
@@ -87,27 +74,13 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
}) })
} }
const withVariant = ( const withVariant = (model: ModelV2.Info, variantID: ModelV2.VariantID | undefined) => {
model: ModelV2.Info,
variantID: ModelV2.VariantID | undefined,
): Effect.Effect<ModelV2.Info, VariantUnavailableError> => {
const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID
const variant = model.variants.find((item) => item.id === id) const variant = model.variants.find((item) => item.id === id)
if (!variant && variantID !== undefined && variantID !== "default") if (!variant) return model
return Effect.fail( return produce(model, (draft) => {
new VariantUnavailableError({ ModelRequest.assign(draft.request, variant)
providerID: model.providerID, })
modelID: model.id,
variant: variantID,
}),
)
return Effect.succeed(
variant
? produce(model, (draft) => {
ModelRequest.assign(draft.request, variant)
})
: model,
)
} }
const apiName = (model: ModelV2.Info) => const apiName = (model: ModelV2.Info) =>
@@ -116,7 +89,7 @@ const apiName = (model: ModelV2.Info) =>
export const fromCatalogModel = ( export const fromCatalogModel = (
model: ModelV2.Info, model: ModelV2.Info,
connection?: IntegrationConnection.Info, connection?: IntegrationConnection.Info,
credential?: Credential.Info, credential?: Credential.Stored,
): Effect.Effect<Model, UnsupportedApiError> => { ): Effect.Effect<Model, UnsupportedApiError> => {
const resolved = const resolved =
credential?.value.metadata === undefined credential?.value.metadata === undefined
@@ -155,15 +128,8 @@ export const fromCatalogModel = (
) )
} }
export const resolve = ( export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) =>
session: SessionSchema.Info, fromCatalogModel(withVariant(model, session.model?.variant))
model: ModelV2.Info,
connection?: IntegrationConnection.Info,
credential?: Credential.Info,
) =>
withVariant(model, session.model?.variant).pipe(
Effect.flatMap((model) => fromCatalogModel(model, connection, credential)),
)
export const supported = (model: ModelV2.Info) => export const supported = (model: ModelV2.Info) =>
model.api.type === "aisdk" && model.api.type === "aisdk" &&
@@ -183,24 +149,14 @@ export const locationLayer = Layer.effect(
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) { resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
// Location plugins populate and filter the catalog asynchronously during layer startup. // Location plugins populate and filter the catalog asynchronously during layer startup.
yield* boot.wait() yield* boot.wait()
const defaultModel = session.model ? undefined : yield* catalog.model.default()
const selected = session.model const selected = session.model
? (yield* catalog.model.available()).find( ? yield* catalog.model.get(session.model.providerID, session.model.id)
(model) => model.providerID === session.model?.providerID && model.id === 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 && session.model)
return yield* new ModelUnavailableError({
providerID: session.model.providerID,
modelID: session.model.id,
})
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id }) if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID)) const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
return yield* resolve( return yield* fromCatalogModel(
session, withVariant(selected, session.model?.variant),
selected,
connection, connection,
connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined, connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined,
) )
@@ -65,14 +65,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
>() >()
const timestamp = DateTime.now const timestamp = DateTime.now
let assistantMessageID: SessionMessage.ID | undefined let assistantMessageID: SessionMessage.ID | undefined
let assistantActive = false
let assistantFailed = false
let providerFailed = false let providerFailed = false
const startAssistant = Effect.fnUntraced(function* () { const startAssistant = Effect.fnUntraced(function* () {
if (assistantMessageID !== undefined) return assistantMessageID if (assistantMessageID !== undefined) return assistantMessageID
assistantMessageID = SessionMessage.ID.create() assistantMessageID = SessionMessage.ID.create()
assistantActive = true
yield* events.publish(SessionEvent.Step.Started, { yield* events.publish(SessionEvent.Step.Started, {
...input, ...input,
assistantMessageID, assistantMessageID,
@@ -193,20 +190,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* flushFragments() yield* flushFragments()
}) })
const failAssistant = Effect.fnUntraced(function* (message: string) {
if (assistantFailed) return
yield* flush()
const assistantMessageID = yield* startAssistant()
assistantActive = false
assistantFailed = true
yield* events.publish(SessionEvent.Step.Failed, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID,
error: { type: "unknown", message },
})
})
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* ( const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
message: string, message: string,
hostedOnly = false, hostedOnly = false,
@@ -392,7 +375,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
} }
case "step-finish": case "step-finish":
yield* flush() yield* flush()
assistantActive = false
yield* events.publish(SessionEvent.Step.Ended, { yield* events.publish(SessionEvent.Step.Ended, {
sessionID: input.sessionID, sessionID: input.sessionID,
timestamp: yield* timestamp, timestamp: yield* timestamp,
@@ -406,7 +388,13 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return return
case "provider-error": case "provider-error":
providerFailed = true providerFailed = true
yield* failAssistant(event.message) yield* flush()
yield* events.publish(SessionEvent.Step.Failed, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: yield* startAssistant(),
error: { type: "unknown", message: event.message },
})
return return
} }
}) })
@@ -414,11 +402,10 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return { return {
publish, publish,
flush, flush,
failAssistant,
failUnsettledTools, failUnsettledTools,
hasActiveAssistant: () => assistantActive,
hasAssistantStarted: () => assistantMessageID !== undefined, hasAssistantStarted: () => assistantMessageID !== undefined,
hasProviderError: () => providerFailed, hasProviderError: () => providerFailed,
startAssistant,
assistantMessageID: assistantMessageIDForTool, assistantMessageID: assistantMessageIDForTool,
} }
} }
@@ -82,21 +82,12 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined) const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)
return item.provider?.executed === true && result ? [call, result] : [call] return item.provider?.executed === true && result ? [call, result] : [call]
}) })
const meaningful = content.filter((part) => {
if (part.type === "text") return part.text !== ""
if (part.type !== "reasoning") return true
return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
})
const results = message.content const results = message.content
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true) .filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
.map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)) .map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined))
.filter((message) => message !== undefined) .filter((message) => message !== undefined)
.map(Message.tool) .map(Message.tool)
if (meaningful.length === 0) return results return [Message.make({ id: message.id, role: "assistant", content, metadata: message.metadata }), ...results]
return [
Message.make({ id: message.id, role: "assistant", content: meaningful, metadata: message.metadata }),
...results,
]
} }
function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] { function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] {
+3
View File
@@ -170,6 +170,9 @@ export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
.primaryKey() .primaryKey()
.references(() => SessionTable.id, { onDelete: "cascade" }), .references(() => SessionTable.id, { onDelete: "cascade" }),
baseline: text().notNull(), baseline: text().notNull(),
agent: text().$type<AgentV2.ID>().notNull().default(AgentV2.defaultID),
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(), snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(),
baseline_seq: integer().notNull(), baseline_seq: integer().notNull(),
replacement_seq: integer(),
revision: integer().notNull().default(0),
}) })
+9 -8
View File
@@ -1,7 +1,8 @@
export * as SkillV2 from "./skill" export * as SkillV2 from "./skill"
import path from "path" import path from "path"
import { Context, Effect, Layer, Schema, Types } from "effect" import { Context, Effect, Layer, Schema } from "effect"
import { castDraft } from "immer"
import { AgentV2 } from "./agent" import { AgentV2 } from "./agent"
import { ConfigMarkdown } from "./config/markdown" import { ConfigMarkdown } from "./config/markdown"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
@@ -64,15 +65,16 @@ const Frontmatter = Schema.Struct({
const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter) const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter)
export type Data = { export type Data = {
sources: Types.DeepMutable<Source>[] sources: Source[]
} }
export type Draft = { export type Editor = {
source: (source: Source) => void source: (source: Source) => void
list: () => readonly Source[] list: () => readonly Source[]
} }
export interface Interface extends State.Transformable<Draft> { export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly sources: () => Effect.Effect<Source[]> readonly sources: () => Effect.Effect<Source[]>
readonly list: () => Effect.Effect<Info[]> readonly list: () => Effect.Effect<Info[]>
} }
@@ -85,12 +87,12 @@ export const layer = Layer.effect(
const discovery = yield* SkillDiscovery.Service const discovery = yield* SkillDiscovery.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const state = State.create<Data, Draft>({ const state = State.create<Data, Editor>({
initial: () => ({ sources: [] }), initial: () => ({ sources: [] }),
draft: (draft) => ({ editor: (draft) => ({
source: (source) => { source: (source) => {
if (draft.sources.some((item) => Source.equals(item, source))) return if (draft.sources.some((item) => Source.equals(item, source))) return
draft.sources.push(source as Types.DeepMutable<Source>) draft.sources.push(castDraft(source))
}, },
list: () => draft.sources as Source[], list: () => draft.sources as Source[],
}), }),
@@ -148,7 +150,6 @@ export const layer = Layer.effect(
return Service.of({ return Service.of({
transform: state.transform, transform: state.transform,
rebuild: state.rebuild,
sources: Effect.fn("SkillV2.sources")(function* () { sources: Effect.fn("SkillV2.sources")(function* () {
return state.get().sources return state.get().sources
}), }),
+72 -88
View File
@@ -1,128 +1,112 @@
export * as State from "./state" export * as State from "./state"
import { Context, Effect, Scope, Semaphore } from "effect" import { Effect, Scope, Semaphore } from "effect"
import type { Draft, Objectish } from "immer"
/** /**
* A replayable transform applied to a draft during rebuild. * A replayable transform applied to an editor during rebuild.
* *
* Domain drafts expose readable and writable state while preserving concise * Transforms are intentionally synchronous and mutation-shaped: domain editors
* plugin/config code. Transforms may perform Effects before returning. * hide the draft representation while preserving concise plugin/config code.
*/ */
type TransformCallback<DraftApi> = (draft: DraftApi) => Effect.Effect<void> | void export type Transform<Editor> = (editor: Editor) => void
export type MakeDraft<State, DraftApi> = (state: State) => DraftApi export type MakeEditor<State extends Objectish, Editor> = (draft: Draft<State>) => Editor
export interface Registration { export interface Options<State extends Objectish, Editor> {
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. */ /** Creates the base value for initial state and every scoped-transform rebuild. */
readonly initial: () => State readonly initial: () => State
/** Wraps mutable state in a domain-specific draft API. */ /** Wraps the mutable draft in a domain-specific editor. */
readonly draft: MakeDraft<State, DraftApi> readonly editor: MakeEditor<State, Editor>
/** Runs after all active transforms and before the rebuilt state becomes visible. */ /**
readonly finalize?: (draft: DraftApi) => Effect.Effect<void> * 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>
} }
export interface Interface<State, DraftApi> extends Transformable<DraftApi> { export interface Interface<State extends Objectish, Editor> {
readonly get: () => State readonly get: () => State
/** /**
* Registers and applies a scoped transform. Closing the owning Scope removes * Registers a scoped transform slot and returns the slot updater.
* the transform and rebuilds the materialized state. *
* 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.
*/ */
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, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> { export function create<State extends Objectish, Editor>(options: Options<State, Editor>): Interface<State, Editor> {
let state = options.initial() let state = options.initial()
let transforms: { run: TransformCallback<DraftApi> }[] = [] let transforms: { update: Transform<Editor> }[] = []
const semaphore = Semaphore.makeUnsafe(1) const semaphore = Semaphore.makeUnsafe(1)
const commit = Effect.fn("State.commit")(function* (next: State) { const commit = Effect.fn("State.commit")(function* (next: State, reason?: string) {
const api = options.draft(next) const api = options.editor(next as Draft<State>)
if (options.finalize) yield* options.finalize(api) if (options.finalize) yield* options.finalize(api, reason)
state = next state = next
}) })
const apply = (transform: TransformCallback<DraftApi>, draft: DraftApi) => const rebuild = Effect.fnUntraced(function* () {
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 next = options.initial()
const api = options.draft(next) const api = options.editor(next as Draft<State>)
for (const transform of transforms) yield* apply(transform.run, api).pipe(Effect.withSpan("State.rebuild.update")) for (const transform of transforms)
yield* Effect.sync(() => transform.update(api)).pipe(Effect.withSpan("State.rebuild.update", {}))
yield* commit(next) yield* commit(next)
}) })
const rebuild = () => semaphore.withPermit(materialize()) const result: Interface<State, Editor> = {
const result: Interface<State, DraftApi> = {
get: () => state, get: () => state,
transform: Effect.fn("State.transform")(function* (update) { transform: Effect.fn("State.transform")(function* () {
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
return yield* Effect.uninterruptible( return yield* Effect.uninterruptible(
Effect.gen(function* () { Effect.gen(function* () {
const transform = { run: update } const transform = { update: (_editor: Editor) => {} }
let active = true transforms = [...transforms, transform]
const dispose = Effect.uninterruptible( yield* Scope.addFinalizer(
scope,
semaphore.withPermit( semaphore.withPermit(
Effect.suspend(() => { Effect.sync(() => {
if (!active) return Effect.void
active = false
transforms = transforms.filter((item) => item !== transform) transforms = transforms.filter((item) => item !== transform)
return Effect.gen(function* () { }).pipe(Effect.andThen(rebuild())),
const batch = yield* CurrentBatch
if (batch) {
batch.add(rebuild)
return
}
yield* materialize()
})
}),
), ),
) )
yield* semaphore.withPermit( return (update: Transform<Editor>) =>
Effect.sync(() => { Effect.uninterruptible(
transforms = [...transforms, transform] semaphore.withPermit(
}), Effect.sync(() => {
) transform.update = update
yield* Scope.addFinalizer(scope, dispose) }).pipe(Effect.andThen(rebuild())),
const batch = yield* CurrentBatch ),
if (batch) batch.add(rebuild) )
else yield* rebuild()
return { dispose }
}), }),
) )
}), }),
rebuild, 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),
} }
return result return result
} }
+9 -5
View File
@@ -1,6 +1,7 @@
export * as ApplicationTools from "./application-tools" export * as ApplicationTools from "./application-tools"
import { Context, Effect, Layer, Scope } from "effect" import { Context, Effect, Layer, Scope } from "effect"
import { enableMapSet } from "immer"
import { State } from "../state" import { State } from "../state"
import { Tool } from "./tool" import { Tool } from "./tool"
@@ -8,7 +9,7 @@ type Data = {
readonly entries: Map<string, Entry> readonly entries: Map<string, Entry>
} }
type Draft = { type Editor = {
readonly set: (name: string, entry: Entry) => void readonly set: (name: string, entry: Entry) => void
} }
@@ -26,12 +27,14 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ApplicationTools") {} export class Service extends Context.Service<Service, Interface>()("@opencode/ApplicationTools") {}
enableMapSet()
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const state = State.create<Data, Draft>({ const state = State.create<Data, Editor>({
initial: () => ({ entries: new Map() }), initial: () => ({ entries: new Map() }),
draft: (draft) => ({ editor: (draft) => ({
set: (name, tool) => { set: (name, tool) => {
draft.entries.set(name, tool) draft.entries.set(name, tool)
}, },
@@ -44,8 +47,9 @@ export const layer = Layer.effect(
if (entries.length === 0) return if (entries.length === 0) return
yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true }) yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true })
const registrations = entries.map(([name, tool]) => [name, { identity: {}, tool }] as const) const registrations = entries.map(([name, tool]) => [name, { identity: {}, tool }] as const)
yield* state.transform((draft) => { const transform = yield* state.transform()
for (const [name, entry] of registrations) draft.set(name, entry) yield* transform((editor) => {
for (const [name, entry] of registrations) editor.set(name, entry)
}) })
}), }),
entries: () => state.get().entries, entries: () => state.get().entries,
-30
View File
@@ -1,30 +0,0 @@
import { Effect, Stream } from "effect"
import { HttpClientResponse } from "effect/unstable/http"
export const collectBoundedResponseBody = (
response: HttpClientResponse.HttpClientResponse,
maximumBytes: number,
tooLarge: () => Error,
) =>
Effect.gen(function* () {
const contentLength = response.headers["content-length"]
const parsedSize = contentLength ? Number.parseInt(contentLength, 10) : undefined
const declaredSize =
parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined
if (declaredSize !== undefined && declaredSize > maximumBytes) return yield* Effect.fail(tooLarge())
let body = Buffer.allocUnsafe(Math.min(maximumBytes, declaredSize || 64 * 1024))
let size = 0
yield* Stream.runForEach(response.stream, (chunk) => {
if (chunk.byteLength === 0) return Effect.void
if (size + chunk.byteLength > maximumBytes) return Effect.fail(tooLarge())
if (size + chunk.byteLength > body.byteLength) {
const grown = Buffer.allocUnsafe(Math.min(maximumBytes, Math.max(size + chunk.byteLength, body.byteLength * 2)))
body.copy(grown, 0, 0, size)
body = grown
}
body.set(chunk, size)
size += chunk.byteLength
return Effect.void
})
return body.subarray(0, size)
})
+58 -118
View File
@@ -13,61 +13,23 @@ export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024
const MAX_LINE_LENGTH = 2_000 const MAX_LINE_LENGTH = 2_000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
export class BinaryFileError extends Schema.TaggedErrorClass<BinaryFileError>()("ReadTool.BinaryFileError", { export class BinaryFileError extends Error {
resource: Schema.String, constructor(readonly resource: string) {
}) { super(`Cannot read binary file: ${resource}`)
override get message() { this.name = "BinaryFileError"
return `Cannot read binary file: ${this.resource}`
} }
} }
export class MediaIngestLimitError extends Schema.TaggedErrorClass<MediaIngestLimitError>()( export class MediaIngestLimitError extends Error {
"ReadTool.MediaIngestLimitError", constructor(
{ readonly resource: string,
resource: Schema.String, readonly maximumBytes: number,
maximumBytes: Schema.Number, ) {
}, super(`Media exceeds ${maximumBytes} byte ingestion limit: ${resource}`)
) { this.name = "MediaIngestLimitError"
override get message() {
return `Media exceeds ${this.maximumBytes} byte ingestion limit: ${this.resource}`
} }
} }
export class MalformedUtf8Error extends Schema.TaggedErrorClass<MalformedUtf8Error>()("ReadTool.MalformedUtf8Error", {
resource: Schema.String,
}) {
override get message() {
return `File is not valid UTF-8: ${this.resource}`
}
}
export class OffsetOutOfRangeError extends Schema.TaggedErrorClass<OffsetOutOfRangeError>()(
"ReadTool.OffsetOutOfRangeError",
{ offset: Schema.Number },
) {
override get message() {
return `Offset ${this.offset} is out of range`
}
}
export class PathKindError extends Schema.TaggedErrorClass<PathKindError>()("ReadTool.PathKindError", {
resource: Schema.String,
expected: Schema.Literals(["a file", "a file or directory"]),
}) {
override get message() {
return `Path is not ${this.expected}: ${this.resource}`
}
}
export type InspectError = FSUtil.Error | PathKindError
export type ReadError =
| FSUtil.Error
| BinaryFileError
| MediaIngestLimitError
| MalformedUtf8Error
| OffsetOutOfRangeError
| PathKindError
export const PageInput = Schema.Struct({ export const PageInput = Schema.Struct({
offset: PositiveInt.pipe(Schema.optional), offset: PositiveInt.pipe(Schema.optional),
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional), limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional),
@@ -90,13 +52,13 @@ export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
}) {} }) {}
export interface Interface { export interface Interface {
readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory", InspectError> readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory">
readonly read: ( readonly read: (
path: AbsolutePath, path: AbsolutePath,
resource: string, resource: string,
page?: PageInput, page?: PageInput,
) => Effect.Effect<FileSystem.Content | TextPage, ReadError> ) => Effect.Effect<FileSystem.Content | TextPage>
readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect<ListPage, FSUtil.Error> readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect<ListPage>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {} export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
@@ -149,21 +111,11 @@ const binary = (resource: string, bytes: Uint8Array) => {
} }
return nonPrintable / bytes.length > 0.3 return nonPrintable / bytes.length > 0.3
} }
const decodeUtf8 = (resource: string, decoder: TextDecoder, bytes?: Uint8Array) =>
Effect.try({
try: () => decoder.decode(bytes, { stream: bytes !== undefined }),
catch: (error) => {
if (error instanceof TypeError) return new MalformedUtf8Error({ resource })
throw error
},
})
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes)
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) { export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
const info = yield* fs.stat(input) const info = yield* fs.stat(input).pipe(Effect.orDie)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" })) if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
return type return type
}) })
@@ -173,30 +125,32 @@ export const read = Effect.fn("ReadTool.read")(function* (
resource: string, resource: string,
page: PageInput = {}, page: PageInput = {},
) { ) {
const real = yield* fs.realPath(input) const real = yield* fs.realPath(input).pipe(Effect.orDie)
return yield* Effect.scoped( return yield* Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const file = yield* fs.open(real, { flag: "r" }) const file = yield* fs.open(real, { flag: "r" }).pipe(Effect.orDie)
const info = yield* file.stat const info = yield* file.stat.pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.fail(new PathKindError({ resource, expected: "a file" })) if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const first = Option.getOrElse( const first = Option.getOrElse(
yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)), yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)).pipe(Effect.orDie),
() => new Uint8Array(), () => new Uint8Array(),
) )
const mime = imageMime(first) const mime = imageMime(first)
if (mime) { if (mime) {
if (info.size > MAX_MEDIA_INGEST_BYTES) if (info.size > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })) return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES))
const chunks = [first] const chunks = [first]
let total = first.length let total = first.length
while (total <= MAX_MEDIA_INGEST_BYTES) { while (total <= MAX_MEDIA_INGEST_BYTES) {
const chunk = yield* file.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total)) const chunk = yield* file
.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total))
.pipe(Effect.orDie)
if (Option.isNone(chunk)) break if (Option.isNone(chunk)) break
chunks.push(chunk.value) chunks.push(chunk.value)
total += chunk.value.length total += chunk.value.length
} }
if (total > MAX_MEDIA_INGEST_BYTES) if (total > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })) return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES))
return { return {
uri: pathToFileURL(real).href, uri: pathToFileURL(real).href,
name: path.basename(real), name: path.basename(real),
@@ -208,19 +162,19 @@ export const read = Effect.fn("ReadTool.read")(function* (
mime, mime,
} }
} }
if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || extensions.has(path.extname(resource).toLowerCase())) if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || binary(resource, first))
return yield* Effect.fail(new BinaryFileError({ resource })) return yield* Effect.die(new BinaryFileError(resource))
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) { if (!paged) {
if (binary(resource, first)) return yield* Effect.fail(new BinaryFileError({ resource }))
const decoder = new TextDecoder("utf-8", { fatal: true }) const decoder = new TextDecoder("utf-8", { fatal: true })
const text = [yield* decodeUtf8(resource, decoder, first)] const text = [yield* Effect.sync(() => decoder.decode(first, { stream: true }))]
while (true) { while (true) {
const chunk = yield* file.readAlloc(64 * 1024) const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
if (Option.isNone(chunk)) break if (Option.isNone(chunk)) break
text.push(yield* decodeChunk(resource, decoder, chunk.value)) if (chunk.value.includes(0)) return yield* Effect.die(new BinaryFileError(resource))
text.push(yield* Effect.sync(() => decoder.decode(chunk.value, { stream: true })))
} }
text.push(yield* decodeUtf8(resource, decoder)) text.push(yield* Effect.sync(() => decoder.decode()))
return { return {
uri: pathToFileURL(real).href, uri: pathToFileURL(real).href,
name: path.basename(real), name: path.basename(real),
@@ -237,29 +191,34 @@ export const read = Effect.fn("ReadTool.read")(function* (
let discard = false let discard = false
let line = 1 let line = 1
let bytes = 0 let bytes = 0
let found = false
let truncated = false
let next: number | undefined let next: number | undefined
const append = (input: string) => { const append = (input: string) => {
if (line < offset) { if (line < offset) {
line++ line++
return true return
} }
if (lines.length >= limit || bytes >= MAX_READ_BYTES) { if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
next = line truncated = true
return false next ??= line++
return
} }
found = true
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0) const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
if (bytes + size > MAX_READ_BYTES) { if (bytes + size > MAX_READ_BYTES) {
next = line truncated = true
return false next ??= line++
return
} }
lines.push(text) lines.push(text)
bytes += size bytes += size
line++ line++
return true
} }
const consume = (input: string) => { const consume = (chunk: Uint8Array) => {
let text = input if (chunk.includes(0)) throw new BinaryFileError(resource)
let text = decoder.decode(chunk, { stream: true })
while (true) { while (true) {
const index = text.indexOf("\n") const index = text.indexOf("\n")
if (index === -1) { if (index === -1) {
@@ -276,44 +235,25 @@ export const read = Effect.fn("ReadTool.read")(function* (
pending = "" pending = ""
discard = false discard = false
text = text.slice(index + 1) text = text.slice(index + 1)
if (!append(current.endsWith("\r") ? current.slice(0, -1) : current)) return false append(current.endsWith("\r") ? current.slice(0, -1) : current)
} }
return true
} }
const consumeChunk = Effect.fnUntraced(function* (chunk: Uint8Array) { yield* Effect.sync(() => consume(first))
let start = 0 while (true) {
while (start < chunk.length) { const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
next = line
return false
}
const newline = chunk.indexOf(10, start)
const end = newline === -1 ? chunk.length : newline + 1
const segment = chunk.subarray(start, end)
if (binary(resource, segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
if (!consume(yield* decodeUtf8(resource, decoder, segment))) return false
start = end
}
return true
})
let done = !(yield* consumeChunk(first))
while (!done) {
const chunk = yield* file.readAlloc(64 * 1024)
if (Option.isNone(chunk)) break if (Option.isNone(chunk)) break
done = !(yield* consumeChunk(chunk.value)) yield* Effect.sync(() => consume(chunk.value))
} }
if (!done) { const tail = yield* Effect.sync(() => decoder.decode())
const tail = yield* decodeUtf8(resource, decoder) if (!discard) pending += tail
if (!discard) pending += tail if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending) if (!found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`))
}
if (lines.length === 0 && offset !== 1) return yield* Effect.fail(new OffsetOutOfRangeError({ offset }))
return new TextPage({ return new TextPage({
type: "text-page", type: "text-page",
content: lines.join("\n"), content: lines.join("\n"),
mime: FSUtil.mimeType(real), mime: FSUtil.mimeType(real),
offset, offset,
truncated: next !== undefined, truncated,
...(next === undefined ? {} : { next }), ...(next === undefined ? {} : { next }),
}) })
}), }),
@@ -321,8 +261,8 @@ export const read = Effect.fn("ReadTool.read")(function* (
}) })
export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) { export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) {
const real = yield* fs.realPath(input) const real = yield* fs.realPath(input).pipe(Effect.orDie)
const items = yield* fs.readDirectoryEntries(real) const items = yield* fs.readDirectoryEntries(real).pipe(Effect.orDie)
const offset = page.offset ?? 1 const offset = page.offset ?? 1
const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES) const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES)
const entries = yield* Effect.forEach( const entries = yield* Effect.forEach(
+3 -3
View File
@@ -57,8 +57,8 @@ export const layer = Layer.effectDiscard(
const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory
if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute)) if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the allowed read root")) return yield* Effect.die(new Error("Path escapes the allowed read root"))
const real = yield* fs.realPath(absolute) const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
const root = yield* fs.realPath(selected) const root = yield* fs.realPath(selected).pipe(Effect.orDie)
if (!FSUtil.contains(root, real)) if (!FSUtil.contains(root, real))
return yield* Effect.die(new Error("Path escapes the allowed read root")) return yield* Effect.die(new Error("Path escapes the allowed read root"))
const resource = path.relative(root, real).replaceAll("\\", "/") || "." const resource = path.relative(root, real).replaceAll("\\", "/") || "."
@@ -83,7 +83,7 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content))) .pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
} }
if ("encoding" in content && content.encoding === "base64") if ("encoding" in content && content.encoding === "base64")
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource })) return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError(resource))
return content return content
}).pipe( }).pipe(
Effect.mapError((error) => { Effect.mapError((error) => {
+21 -13
View File
@@ -1,12 +1,11 @@
export * as WebFetchTool from "./webfetch" export * as WebFetchTool from "./webfetch"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema } from "effect" import { Duration, Effect, Layer, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Parser } from "htmlparser2" import { Parser } from "htmlparser2"
import TurndownService from "turndown" import TurndownService from "turndown"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { collectBoundedResponseBody } from "./http-body"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Tools } from "./tools" import { Tools } from "./tools"
@@ -87,11 +86,24 @@ const execute = (http: HttpClient.HttpClient, url: string, format: Format, userA
http.execute(request(url, format, userAgent)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)) http.execute(request(url, format, userAgent)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk))
const collectBody = (response: HttpClientResponse.HttpClientResponse) => const collectBody = (response: HttpClientResponse.HttpClientResponse) =>
collectBoundedResponseBody( Effect.gen(function* () {
response, const contentLength = response.headers["content-length"]
MAX_RESPONSE_BYTES, if (contentLength && Number.parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) {
() => new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`), return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
) }
const chunks: Uint8Array[] = []
let size = 0
yield* Stream.runForEach(response.stream, (chunk) =>
Effect.gen(function* () {
size += chunk.byteLength
if (size > MAX_RESPONSE_BYTES)
return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
chunks.push(chunk)
return undefined
}),
)
return Buffer.concat(chunks, size)
})
const mimeFrom = (contentType: string) => contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "" const mimeFrom = (contentType: string) => contentType.split(";", 1)[0]?.trim().toLowerCase() ?? ""
const isImageAttachment = (mime: string) => const isImageAttachment = (mime: string) =>
@@ -159,16 +171,12 @@ export const layer = Layer.effectDiscard(
orElse: () => Effect.fail(new Error("Request timed out")), orElse: () => Effect.fail(new Error("Request timed out")),
}), }),
) )
const content = new TextDecoder().decode(body) const content = convert(new TextDecoder().decode(body), contentType, input.format)
const output = yield* Effect.try({
try: () => convert(content, contentType, input.format),
catch: (error) => error,
})
return { return {
url: input.url, url: input.url,
contentType, contentType,
format: input.format, format: input.format,
output, output: content,
} }
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))), }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
}), }),
+4 -7
View File
@@ -9,7 +9,6 @@ import { PositiveInt } from "../schema"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Tools } from "./tools" import { Tools } from "./tools"
import { collectBoundedResponseBody } from "./http-body"
import { checksum } from "../util/encode" import { checksum } from "../util/encode"
export const name = "websearch" export const name = "websearch"
@@ -165,12 +164,10 @@ const callMcp = <F extends Schema.Struct.Fields>(
) )
return yield* Effect.gen(function* () { return yield* Effect.gen(function* () {
const response = yield* HttpClient.filterStatusOk(http).execute(request) const response = yield* HttpClient.filterStatusOk(http).execute(request)
const body = yield* collectBoundedResponseBody( const body = yield* response.text
response, if (Buffer.byteLength(body, "utf8") > MAX_RESPONSE_BYTES)
MAX_RESPONSE_BYTES, return yield* Effect.fail(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`))
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`), return yield* parseResponse(body)
)
return yield* parseResponse(body.toString("utf8"))
}).pipe( }).pipe(
Effect.timeoutOrElse({ Effect.timeoutOrElse({
duration: Duration.seconds(25), duration: Duration.seconds(25),
+1 -1
View File
@@ -502,7 +502,7 @@ export type WithParts = {
} }
const options = { const options = {
durable: { sync: {
aggregate: "sessionID", aggregate: "sessionID",
version: 1, version: 1,
}, },
+24 -19
View File
@@ -6,7 +6,6 @@ import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { agentHost, host } from "./plugin/host"
const it = testEffect(AgentV2.locationLayer) const it = testEffect(AgentV2.locationLayer)
@@ -24,7 +23,9 @@ describe("AgentV2", () => {
Effect.gen(function* () { Effect.gen(function* () {
const agent = yield* AgentV2.Service const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("reviewer") const id = AgentV2.ID.make("reviewer")
yield* agent.transform((editor) => const transform = yield* agent.transform()
yield* transform((editor) =>
editor.update(id, (info) => { editor.update(id, (info) => {
info.description = "Reviews code" info.description = "Reviews code"
info.mode = "subagent" info.mode = "subagent"
@@ -40,17 +41,19 @@ describe("AgentV2", () => {
Effect.gen(function* () { Effect.gen(function* () {
const agent = yield* AgentV2.Service const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("reviewer") const id = AgentV2.ID.make("reviewer")
let description = "Old description" const transform = yield* agent.transform()
let hidden = true
yield* agent.transform((editor) => yield* transform((editor) =>
editor.update(id, (info) => { editor.update(id, (info) => {
info.description = description info.description = "Old description"
info.hidden = hidden info.hidden = true
}),
)
yield* transform((editor) =>
editor.update(id, (info) => {
info.description = "New description"
}), }),
) )
description = "New description"
hidden = false
yield* agent.rebuild()
expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false }) expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false })
}), }),
@@ -61,7 +64,9 @@ describe("AgentV2", () => {
const agent = yield* AgentV2.Service const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("scoped") const id = AgentV2.ID.make("scoped")
const scope = yield* Scope.make() const scope = yield* Scope.make()
yield* agent.transform((editor) => editor.update(id, () => {})).pipe(Scope.provide(scope)) const transform = yield* agent.transform().pipe(Scope.provide(scope))
yield* transform((editor) => editor.update(id, () => {}))
expect(yield* agent.get(id)).toBeDefined() expect(yield* agent.get(id)).toBeDefined()
yield* Scope.close(scope, Exit.void) yield* Scope.close(scope, Exit.void)
@@ -74,7 +79,7 @@ describe("AgentV2", () => {
const agent = yield* AgentV2.Service const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("build") const id = AgentV2.ID.make("build")
yield* agent.transform((editor) => yield* agent.update((editor) =>
editor.update(id, (info) => { editor.update(id, (info) => {
info.mode = "primary" info.mode = "primary"
info.hidden = true info.hidden = true
@@ -90,10 +95,10 @@ describe("AgentV2", () => {
const agent = yield* AgentV2.Service const agent = yield* AgentV2.Service
const id = AgentV2.ID.make("custom") const id = AgentV2.ID.make("custom")
yield* agent.transform((editor) => editor.update(id, () => {})) yield* agent.update((editor) => editor.update(id, () => {}))
expect(yield* agent.get(id)).toEqual(AgentV2.Info.empty(id)) expect(yield* agent.get(id)).toEqual(AgentV2.Info.empty(id))
yield* agent.transform((editor) => editor.remove(id)) yield* agent.update((editor) => editor.remove(id))
expect(yield* agent.get(id)).toBeUndefined() expect(yield* agent.get(id)).toBeUndefined()
}), }),
) )
@@ -101,11 +106,11 @@ describe("AgentV2", () => {
it.effect("does not ambiently opt built-in agents into bash", () => it.effect("does not ambiently opt built-in agents into bash", () =>
Effect.gen(function* () { Effect.gen(function* () {
const agent = yield* AgentV2.Service const agent = yield* AgentV2.Service
yield* AgentPlugin.Plugin.effect( yield* AgentPlugin.Plugin.effect.pipe(
host({ Effect.provideService(
agent: agentHost(agent), Location.Service,
location: location({ directory: AbsolutePath.make("/project") }), Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
}), ),
) )
const agents = yield* agent.all() const agents = yield* agent.all()

Some files were not shown because too many files have changed in this diff Show More