Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 975b1132f1 | |||
| cd97de7391 | |||
| 23fd5907be | |||
| 49d3f86802 | |||
| 909a1a6d78 | |||
| dc468bdcfd | |||
| f48f24ec4e | |||
| 34b3d59a23 | |||
| a0a500316e | |||
| 1787fa4261 | |||
| 130957288e | |||
| c5a4a8288c | |||
| 39d7394ede | |||
| 494123a875 | |||
| fe840d42b8 | |||
| f50e4accf3 | |||
| 9bceb8eb7d | |||
| adebb87191 | |||
| 4d5efba5c9 | |||
| c6ee511485 | |||
| f9f2280452 | |||
| d5980b47e9 | |||
| 41d1279b6f | |||
| 0d32d1f293 | |||
| cf31029350 | |||
| 79b55d4db8 | |||
| 36264ccf90 | |||
| 7b750a8f20 | |||
| e50261e524 | |||
| 639c94a375 | |||
| b13a2d712a | |||
| 1b91b5df34 | |||
| 595cc91ddc | |||
| 57ce1b9ca8 | |||
| 4ecc3ac653 | |||
| c7efbe6fc0 | |||
| cd292a4ecb | |||
| 2bb4311042 | |||
| cdc6d01c5a | |||
| 35b3fc85d0 | |||
| 9dadc2455f | |||
| 06dae383f7 | |||
| 7d204b5b57 | |||
| 49593c1ec4 | |||
| ff837fe949 | |||
| 4c6750d464 | |||
| 69f1ec22e3 | |||
| 823d327401 | |||
| 82d9cab48d | |||
| fb43c15f88 | |||
| ca006a2d20 | |||
| 8396395f17 | |||
| 1b8bab3e35 | |||
| 02687b6324 | |||
| c780d7cee7 | |||
| 7a9337da8a | |||
| f96e6aa6ed | |||
| c6f719e153 | |||
| 1a111be494 | |||
| a0aee82be9 | |||
| c0dc6e50a7 | |||
| 418a9e4e66 | |||
| bd8ce5e6a9 | |||
| a97c6de9db | |||
| 24ea4dd008 | |||
| ffcb7542e1 | |||
| d4d841bafd | |||
| 6f0e934573 | |||
| 233d065dd5 | |||
| f12ac6f234 |
@@ -65,7 +65,7 @@ jobs:
|
||||
|
||||
- name: Run unit tests
|
||||
timeout-minutes: 20
|
||||
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
|
||||
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=none
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
@@ -99,7 +99,8 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
# Playwright 1.59 hangs while extracting Chromium with Node 24.16.
|
||||
node-version: "24.15"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
@@ -137,7 +137,7 @@ const table = sqliteTable("session", {
|
||||
|
||||
## Testing
|
||||
|
||||
- Avoid mocks as much as possible
|
||||
- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option.
|
||||
- Test actual implementation, do not duplicate logic into tests
|
||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||
|
||||
@@ -152,7 +152,7 @@ const table = sqliteTable("session", {
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and coalesce into the active activity at the next safe provider-turn boundary. Explicit `queue` inputs open FIFO future activities one at a time after the active activity settles.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
|
||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
|
||||
|
||||
+23
-9
@@ -24,7 +24,7 @@ A durable chronological instruction that tells the model the newly effective sta
|
||||
_Avoid_: System update, system notification, raw text diff
|
||||
|
||||
**Context Epoch**:
|
||||
The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition.
|
||||
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.
|
||||
|
||||
**Baseline System Context**:
|
||||
The full **System Context** rendered at the start of a **Context Epoch**.
|
||||
@@ -39,6 +39,18 @@ An expected temporary inability to observe a **Context Source** value; the runti
|
||||
**Safe Provider-Turn Boundary**:
|
||||
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
|
||||
|
||||
**Admitted Prompt**:
|
||||
A durable user input accepted into the Session inbox but not yet included in **Session History**.
|
||||
|
||||
**Prompt Promotion**:
|
||||
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
|
||||
|
||||
**Provider Turn**:
|
||||
One request to a model provider and the response projected from that request.
|
||||
|
||||
**Session Drain**:
|
||||
One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
|
||||
|
||||
**Model Tool Output**:
|
||||
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
|
||||
|
||||
@@ -67,6 +79,11 @@ The host-supplied environment overlay applied by the server when creating a PTY,
|
||||
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
|
||||
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
|
||||
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
|
||||
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
|
||||
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
|
||||
- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once.
|
||||
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
|
||||
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity.
|
||||
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
|
||||
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
|
||||
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
|
||||
@@ -75,31 +92,28 @@ 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.
|
||||
- `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.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.
|
||||
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
|
||||
- **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**.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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** 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** durably preserves the exact joined text used for the active provider-cache prefix.
|
||||
- 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 always starts a new **Context Epoch** while preserving chronological conversation history.
|
||||
- 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.
|
||||
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
|
||||
- **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.
|
||||
- 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`.
|
||||
|
||||
@@ -276,6 +276,7 @@
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
@@ -625,6 +626,7 @@
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.17.9",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-g0tDvRf7MErZ1PEeUazEYi492ZHiRT8kYv3bPdkss/I=",
|
||||
"aarch64-linux": "sha256-6sKgf3ftbIqlPxlFkoPzoWPsJp3IwXD+H3Y6g874xmk=",
|
||||
"aarch64-darwin": "sha256-Se/Nls/KlkuK2ysDQ9DeAzSaX3NsL2iDdf/dsv2GIXc=",
|
||||
"x86_64-darwin": "sha256-V9MCkqnvQ1nkD2PaaTfNFKkBZGymj6KxrSAK6+DTF8Y="
|
||||
"x86_64-linux": "sha256-oWSGu+SP66Aquy/0Vaq7Bgp8404ZdOWbQX+O7h3jxHU=",
|
||||
"aarch64-linux": "sha256-UsS0+c+GwtIukmWwQeFbY/3Oaz3t4Q7C6cFMGkmlyAY=",
|
||||
"aarch64-darwin": "sha256-CArz92ewPmXO+ORFCBkCH8LzMpU/DjyaO4ic7QL0UpI=",
|
||||
"x86_64-darwin": "sha256-rhnz9gmG6L06wIzfMhTaXDDEf6IbMD32CavqwXoqcUs="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,11 @@ export default Runtime.handler(
|
||||
|
||||
function listen(hostname: string, port: Option.Option<number>, password: string) {
|
||||
if (Option.isSome(port)) return bind(hostname, port.value, password)
|
||||
// Preserve the familiar default when available, but let the OS choose a free
|
||||
// port when another local server already owns 4096.
|
||||
return bind(hostname, 4096, password).pipe(Effect.catch(() => bind(hostname, 0, password)))
|
||||
const next = (port: number): ReturnType<typeof bind> =>
|
||||
bind(hostname, port, password).pipe(
|
||||
Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))),
|
||||
)
|
||||
return next(4096)
|
||||
}
|
||||
|
||||
function bind(hostname: string, port: number, password: string) {
|
||||
|
||||
@@ -196,6 +196,13 @@ export async function handler(
|
||||
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
|
||||
headers.set(k, headers.get(v)!)
|
||||
})
|
||||
Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => {
|
||||
if (v === "$ip") return headers.set(k, ip)
|
||||
if (v === "$session") return headers.set(k, sessionId)
|
||||
if (v === "$model") return headers.set(k, model)
|
||||
if (v === "$request") return headers.set(k, requestId)
|
||||
headers.set(k, v)
|
||||
})
|
||||
headers.delete("host")
|
||||
headers.delete("content-length")
|
||||
headers.delete("x-opencode-request")
|
||||
|
||||
@@ -53,6 +53,7 @@ export namespace ZenData {
|
||||
apiKey: z.union([z.string(), z.record(z.string(), z.string())]),
|
||||
format: FormatSchema.optional(),
|
||||
headerMappings: z.record(z.string(), z.string()).optional(),
|
||||
headerModifier: z.record(z.string(), z.any()).optional(),
|
||||
payloadModifier: z.record(z.string(), z.any()).optional(),
|
||||
payloadMappings: z.record(z.string(), z.string()).optional(),
|
||||
adjustCacheUsage: z.boolean().optional(),
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
preload = ["./test/preload.ts"]
|
||||
@@ -91,6 +91,7 @@
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db",
|
||||
"prevIds": ["abd2f920-b822-49af-b8a7-2e48367d424f"],
|
||||
"id": "f14a9b18-8207-487e-a3d3-227e629ba9ad",
|
||||
"prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "workspace",
|
||||
@@ -900,16 +900,6 @@
|
||||
"entityType": "columns",
|
||||
"table": "session_context_epoch"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'build'",
|
||||
"generated": null,
|
||||
"name": "agent",
|
||||
"entityType": "columns",
|
||||
"table": "session_context_epoch"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
@@ -930,26 +920,6 @@
|
||||
"entityType": "columns",
|
||||
"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",
|
||||
"notNull": false,
|
||||
|
||||
@@ -45,15 +45,17 @@ async function generate() {
|
||||
if (await Bun.file(target).exists()) throw new Error(`Database migration already exists: ${name}`)
|
||||
await Bun.write(
|
||||
target,
|
||||
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
|
||||
await formatTypescript(
|
||||
renderMigration(name, await Bun.file(path.join(incremental, name, "migration.sql")).text()),
|
||||
),
|
||||
)
|
||||
await fs.copyFile(path.join(incremental, name, "snapshot.json"), snapshot)
|
||||
}
|
||||
|
||||
await fs.mkdir(full)
|
||||
await drizzle(temporary, full, "schema")
|
||||
await Bun.write(schema, renderSchema(await generatedSql(full)))
|
||||
await Bun.write(registry, renderRegistry(await typescriptMigrations()))
|
||||
await Bun.write(schema, await formatTypescript(renderSchema(await generatedSql(full))))
|
||||
await Bun.write(registry, await formatTypescript(renderRegistry(await typescriptMigrations())))
|
||||
} finally {
|
||||
await fs.rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
@@ -76,12 +78,12 @@ async function check() {
|
||||
|
||||
await fs.mkdir(full)
|
||||
await drizzle(temporary, full, "schema")
|
||||
if ((await Bun.file(schema).text()) !== renderSchema(await generatedSql(full))) {
|
||||
if ((await Bun.file(schema).text()) !== (await formatTypescript(renderSchema(await generatedSql(full))))) {
|
||||
throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.")
|
||||
}
|
||||
|
||||
const migrations = await typescriptMigrations()
|
||||
if ((await Bun.file(registry).text()) !== renderRegistry(migrations)) {
|
||||
if ((await Bun.file(registry).text()) !== (await formatTypescript(renderRegistry(migrations)))) {
|
||||
throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.")
|
||||
}
|
||||
} finally {
|
||||
@@ -170,6 +172,18 @@ function escapeTemplate(line: string) {
|
||||
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[]) {
|
||||
return `import type { DatabaseMigration } from "./migration"
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as AgentV2 from "./agent"
|
||||
|
||||
import { Array, Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { castDraft, enableMapSet, type Draft } from "immer"
|
||||
import { Array, Context, Effect, Layer, Schema, Scope, Types } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { PermissionSchema } from "./permission/schema"
|
||||
import { ProviderV2 } from "./provider"
|
||||
@@ -49,21 +48,19 @@ export interface Selection {
|
||||
}
|
||||
|
||||
type Data = {
|
||||
agents: Map<ID, Info>
|
||||
agents: Map<ID, Types.DeepMutable<Info>>
|
||||
default?: ID
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
list: () => readonly Info[]
|
||||
get: (id: ID) => Info | undefined
|
||||
default: (id: ID | undefined) => void
|
||||
update: (id: ID, fn: (agent: Draft<Info>) => void) => void
|
||||
update: (id: ID, fn: (agent: Types.DeepMutable<Info>) => void) => void
|
||||
remove: (id: ID) => void
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly update: State.Interface<Data, Editor>["update"]
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
readonly default: () => Effect.Effect<Info | undefined>
|
||||
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
|
||||
@@ -73,21 +70,19 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
|
||||
|
||||
enableMapSet()
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ agents: new Map() }),
|
||||
editor: (draft) => ({
|
||||
draft: (draft) => ({
|
||||
list: () => Array.fromIterable(draft.agents.values()) as Info[],
|
||||
get: (id) => draft.agents.get(id),
|
||||
default: (id) => {
|
||||
draft.default = id
|
||||
},
|
||||
update: (id, fn) => {
|
||||
const current = draft.agents.get(id) ?? castDraft(Info.empty(id))
|
||||
const current = draft.agents.get(id) ?? (Info.empty(id) as Types.DeepMutable<Info>)
|
||||
if (!draft.agents.has(id)) draft.agents.set(id, current)
|
||||
fn(current)
|
||||
current.id = id
|
||||
@@ -113,7 +108,7 @@ export const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
update: state.update,
|
||||
reload: state.reload,
|
||||
get: Effect.fn("AgentV2.get")(function* (id) {
|
||||
return state.get().agents.get(id)
|
||||
}),
|
||||
|
||||
+74
-21
@@ -1,14 +1,27 @@
|
||||
export * as AISDK from "./aisdk"
|
||||
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Cause, Context, Effect, Layer, Schema } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { EventV2 } from "./event"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { State } from "./state"
|
||||
|
||||
type SDK = any
|
||||
|
||||
export interface SDKEvent {
|
||||
readonly model: ModelV2.Info
|
||||
readonly package: string
|
||||
readonly options: Record<string, any>
|
||||
sdk?: SDK
|
||||
}
|
||||
|
||||
export interface LanguageEvent {
|
||||
readonly model: ModelV2.Info
|
||||
readonly sdk: SDK
|
||||
readonly options: Record<string, any>
|
||||
language?: LanguageModelV3
|
||||
}
|
||||
|
||||
function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
||||
if (typeof ms !== "number" || ms <= 0) return res
|
||||
if (!res.body) return res
|
||||
@@ -117,19 +130,70 @@ function initError(providerID: ProviderV2.ID) {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly hook: {
|
||||
readonly sdk: (
|
||||
callback: (event: SDKEvent) => Effect.Effect<void> | void,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
readonly language: (
|
||||
callback: (event: LanguageEvent) => Effect.Effect<void> | void,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
}
|
||||
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
|
||||
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
|
||||
readonly language: (model: ModelV2.Info) => Effect.Effect<LanguageModelV3, InitError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AISDK") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
export const locationLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
let sdkHooks: ((event: SDKEvent) => Effect.Effect<void> | void)[] = []
|
||||
let languageHooks: ((event: LanguageEvent) => Effect.Effect<void> | void)[] = []
|
||||
const languages = new Map<string, LanguageModelV3>()
|
||||
const sdks = new Map<string, SDK>()
|
||||
|
||||
return Service.of({
|
||||
const register = <Event>(
|
||||
hooks: () => ((event: Event) => Effect.Effect<void> | void)[],
|
||||
update: (hooks: ((event: Event) => Effect.Effect<void> | void)[]) => void,
|
||||
) =>
|
||||
Effect.fn("AISDK.hook")(function* (callback: (event: Event) => Effect.Effect<void> | void) {
|
||||
const scope = yield* Scope.Scope
|
||||
let active = true
|
||||
update([...hooks(), callback])
|
||||
const dispose = Effect.sync(() => {
|
||||
if (!active) return
|
||||
active = false
|
||||
update(hooks().filter((item) => item !== callback))
|
||||
})
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
return { dispose }
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* <Event>(
|
||||
hooks: readonly ((event: Event) => Effect.Effect<void> | void)[],
|
||||
event: Event,
|
||||
) {
|
||||
for (const hook of hooks) {
|
||||
const result = hook(event)
|
||||
if (Effect.isEffect(result)) yield* result
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
const service = Service.of({
|
||||
hook: {
|
||||
sdk: register(
|
||||
() => sdkHooks,
|
||||
(next) => (sdkHooks = next),
|
||||
),
|
||||
language: register(
|
||||
() => languageHooks,
|
||||
(next) => (languageHooks = next),
|
||||
),
|
||||
},
|
||||
runSDK: (event) => run(sdkHooks, event),
|
||||
runLanguage: (event) => run(languageHooks, event),
|
||||
language: Effect.fn("AISDK.language")(function* (model) {
|
||||
const key = `${model.providerID}/${model.id}/${model.request.variant ?? "default"}`
|
||||
const existing = languages.get(key)
|
||||
@@ -148,26 +212,14 @@ export const layer = Layer.effect(
|
||||
})
|
||||
const sdk =
|
||||
sdks.get(sdkKey) ??
|
||||
(yield* plugin
|
||||
.trigger("aisdk.sdk", { model, package: model.api.package, options }, {})
|
||||
.pipe(initError(model.providerID))).sdk
|
||||
(yield* service.runSDK({ model, package: model.api.package, options }).pipe(initError(model.providerID))).sdk
|
||||
if (!sdk)
|
||||
return yield* new InitError({
|
||||
providerID: model.providerID,
|
||||
cause: new Error("No AISDK provider plugin returned an SDK"),
|
||||
})
|
||||
sdks.set(sdkKey, sdk)
|
||||
const result = yield* plugin
|
||||
.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model,
|
||||
sdk,
|
||||
options,
|
||||
},
|
||||
{},
|
||||
)
|
||||
.pipe(initError(model.providerID))
|
||||
const result = yield* service.runLanguage({ model, sdk, options }).pipe(initError(model.providerID))
|
||||
const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.api.id)).pipe(
|
||||
initError(model.providerID),
|
||||
)
|
||||
@@ -175,7 +227,8 @@ export const layer = Layer.effect(
|
||||
return language
|
||||
}),
|
||||
})
|
||||
return service
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))))
|
||||
export const defaultLayer = locationLayer
|
||||
|
||||
+54
-101
@@ -1,36 +1,21 @@
|
||||
export * as Catalog from "./catalog"
|
||||
|
||||
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema, Scope, Stream } from "effect"
|
||||
import { castDraft, enableMapSet, type Draft } from "immer"
|
||||
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { ModelRequest } from "./model-request"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { Location } from "./location"
|
||||
import { EventV2 } from "./event"
|
||||
import { Policy } from "./policy"
|
||||
import { State } from "./state"
|
||||
import { Integration } from "./integration"
|
||||
|
||||
export type ProviderRecord = {
|
||||
provider: ProviderV2.Info
|
||||
models: Map<ModelV2.ID, ModelV2.Info>
|
||||
provider: ProviderV2.MutableInfo
|
||||
models: Map<ModelV2.ID, ModelV2.MutableInfo>
|
||||
}
|
||||
|
||||
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
|
||||
|
||||
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
|
||||
"CatalogV2.ProviderNotFound",
|
||||
{
|
||||
providerID: ProviderV2.ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("CatalogV2.ModelNotFound", {
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
}) {}
|
||||
|
||||
export const PolicyActions = Schema.Literals(["provider.use"])
|
||||
|
||||
export const Event = {
|
||||
@@ -42,16 +27,16 @@ type Data = {
|
||||
defaultModel?: DefaultModel
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
provider: {
|
||||
list: () => readonly ProviderRecord[]
|
||||
get: (providerID: ProviderV2.ID) => ProviderRecord | undefined
|
||||
update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
|
||||
update: (providerID: ProviderV2.ID, fn: (provider: ProviderV2.MutableInfo) => void) => void
|
||||
remove: (providerID: ProviderV2.ID) => void
|
||||
}
|
||||
model: {
|
||||
get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined
|
||||
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
|
||||
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: ModelV2.MutableInfo) => void) => void
|
||||
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
|
||||
default: {
|
||||
get: () => DefaultModel | undefined
|
||||
@@ -60,43 +45,34 @@ export type Editor = {
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly provider: {
|
||||
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
|
||||
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info | undefined>
|
||||
readonly all: () => Effect.Effect<ProviderV2.Info[]>
|
||||
readonly available: () => Effect.Effect<ProviderV2.Info[]>
|
||||
}
|
||||
readonly model: {
|
||||
readonly get: (
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
|
||||
readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect<ModelV2.Info | undefined>
|
||||
readonly all: () => Effect.Effect<ModelV2.Info[]>
|
||||
readonly available: () => Effect.Effect<ModelV2.Info[]>
|
||||
readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>
|
||||
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<Option.Option<ModelV2.Info>>
|
||||
readonly default: () => Effect.Effect<ModelV2.Info | undefined>
|
||||
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<ModelV2.Info | undefined>
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
|
||||
|
||||
enableMapSet()
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const policy = yield* Policy.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
|
||||
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
|
||||
if (provider.disabled) return false
|
||||
if (typeof provider.request.body.apiKey === "string") return true
|
||||
if (connected) return true
|
||||
if (integration?.connections.length) return true
|
||||
return !integration
|
||||
}
|
||||
|
||||
@@ -120,32 +96,26 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
|
||||
function* getRecord(providerID: ProviderV2.ID) {
|
||||
const match = state.get().providers.get(providerID)
|
||||
if (!match) return yield* new ProviderNotFoundError({ providerID })
|
||||
return match
|
||||
}
|
||||
|
||||
const normalizeApi = (item: Draft<ProviderV2.Info> | Draft<ModelV2.Info>) => {
|
||||
const normalizeApi = (item: ProviderV2.MutableInfo | ModelV2.MutableInfo) => {
|
||||
if (typeof item.request.body.baseURL !== "string") return
|
||||
item.api.url = item.request.body.baseURL
|
||||
delete item.request.body.baseURL
|
||||
}
|
||||
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ providers: new Map() }),
|
||||
editor: (draft) => {
|
||||
const result: Editor = {
|
||||
draft: (draft) => {
|
||||
const result: Draft = {
|
||||
provider: {
|
||||
list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[],
|
||||
get: (providerID) => draft.providers.get(providerID),
|
||||
update: (providerID, fn) => {
|
||||
let current = draft.providers.get(providerID)
|
||||
if (!current) {
|
||||
current = castDraft({
|
||||
provider: ProviderV2.Info.empty(providerID),
|
||||
models: new Map<ModelV2.ID, ModelV2.Info>(),
|
||||
})
|
||||
current = {
|
||||
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
|
||||
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
|
||||
}
|
||||
draft.providers.set(providerID, current)
|
||||
}
|
||||
fn(current.provider)
|
||||
@@ -160,13 +130,14 @@ export const layer = Layer.effect(
|
||||
update: (providerID, modelID, fn) => {
|
||||
let record = draft.providers.get(providerID)
|
||||
if (!record) {
|
||||
record = castDraft({
|
||||
provider: ProviderV2.Info.empty(providerID),
|
||||
models: new Map<ModelV2.ID, ModelV2.Info>(),
|
||||
})
|
||||
record = {
|
||||
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
|
||||
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
|
||||
}
|
||||
draft.providers.set(providerID, record)
|
||||
}
|
||||
const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID))
|
||||
const model =
|
||||
record.models.get(modelID) ?? (ModelV2.Info.empty(providerID, modelID) as ModelV2.MutableInfo)
|
||||
if (!record.models.has(modelID)) record.models.set(modelID, model)
|
||||
fn(model)
|
||||
model.id = modelID
|
||||
@@ -186,8 +157,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
return result
|
||||
},
|
||||
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
|
||||
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
|
||||
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
|
||||
if (policy.hasStatements()) {
|
||||
for (const record of [...catalog.provider.list()]) {
|
||||
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
|
||||
@@ -198,25 +168,13 @@ export const layer = Layer.effect(
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
yield* events.subscribe(PluginV2.Event.Added).pipe(
|
||||
// Plugin registries are location scoped even though the event bus is process scoped.
|
||||
Stream.filter(
|
||||
(event) =>
|
||||
event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID,
|
||||
),
|
||||
Stream.runForEach((event) =>
|
||||
state.mutate((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
|
||||
),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
|
||||
const result: Interface = {
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
|
||||
provider: {
|
||||
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
|
||||
const record = yield* getRecord(providerID)
|
||||
return record.provider
|
||||
return state.get().providers.get(providerID)?.provider
|
||||
}),
|
||||
|
||||
all: Effect.fn("CatalogV2.provider.all")(function* () {
|
||||
@@ -225,23 +183,18 @@ export const layer = Layer.effect(
|
||||
|
||||
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
||||
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) =>
|
||||
available(
|
||||
provider,
|
||||
active.get(Integration.ID.make(provider.id)),
|
||||
connections.has(Integration.ID.make(provider.id)),
|
||||
),
|
||||
available(provider, active.get(Integration.ID.make(provider.id))),
|
||||
)
|
||||
}),
|
||||
},
|
||||
|
||||
model: {
|
||||
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
|
||||
const record = yield* getRecord(providerID)
|
||||
const record = state.get().providers.get(providerID)
|
||||
if (!record) return
|
||||
const model = record.models.get(modelID)
|
||||
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
|
||||
return projectModel(model, record.provider)
|
||||
return model && projectModel(model, record.provider)
|
||||
}),
|
||||
|
||||
all: Effect.fn("CatalogV2.model.all")(function* () {
|
||||
@@ -250,7 +203,7 @@ export const layer = Layer.effect(
|
||||
Array.flatMap((record) => {
|
||||
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
|
||||
}),
|
||||
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
|
||||
Array.sortWith((item) => item.time.released, Order.flip(Order.Number)),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -262,31 +215,30 @@ export const layer = Layer.effect(
|
||||
default: Effect.fn("CatalogV2.model.default")(function* () {
|
||||
const defaultModel = state.get().defaultModel
|
||||
if (defaultModel) {
|
||||
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option)
|
||||
if (
|
||||
Option.isSome(provider) &&
|
||||
(yield* result.provider.available()).some((item) => item.id === provider.value.id)
|
||||
) {
|
||||
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
|
||||
if (Option.isSome(model) && model.value.enabled) return model
|
||||
const provider = yield* result.provider.get(defaultModel.providerID)
|
||||
if (provider && (yield* result.provider.available()).some((item) => item.id === provider.id)) {
|
||||
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID)
|
||||
if (model?.enabled) return model
|
||||
}
|
||||
}
|
||||
|
||||
return pipe(
|
||||
yield* result.model.available(),
|
||||
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
|
||||
Array.head,
|
||||
return Option.getOrUndefined(
|
||||
pipe(
|
||||
yield* result.model.available(),
|
||||
Array.sortWith((item) => item.time.released, Order.flip(Order.Number)),
|
||||
Array.head,
|
||||
),
|
||||
)
|
||||
}),
|
||||
|
||||
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
|
||||
const record = state.get().providers.get(providerID)
|
||||
if (!record) return Option.none<ModelV2.Info>()
|
||||
if (!record) return
|
||||
const provider = record.provider
|
||||
|
||||
if (providerID === ProviderV2.ID.opencode) {
|
||||
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
|
||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider))
|
||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
|
||||
}
|
||||
|
||||
const candidates = pipe(
|
||||
@@ -302,7 +254,7 @@ export const layer = Layer.effect(
|
||||
Array.map((model) => ({
|
||||
model,
|
||||
cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999,
|
||||
age: (Date.now() - model.time.released.epochMilliseconds) / (1000 * 60 * 60 * 24 * 30),
|
||||
age: (Date.now() - model.time.released) / (1000 * 60 * 60 * 24 * 30),
|
||||
small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()),
|
||||
})),
|
||||
Array.filter((item) => item.cost > 0 && item.age <= 18),
|
||||
@@ -319,10 +271,12 @@ export const layer = Layer.effect(
|
||||
)
|
||||
}
|
||||
|
||||
return pipe(
|
||||
candidates,
|
||||
Array.filter((item) => item.small),
|
||||
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
|
||||
return Option.getOrUndefined(
|
||||
pipe(
|
||||
candidates,
|
||||
Array.filter((item) => item.small),
|
||||
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
@@ -336,6 +290,5 @@ const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
|
||||
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(Integration.locationLayer),
|
||||
Layer.provideMerge(PluginV2.locationLayer),
|
||||
Layer.provideMerge(Policy.locationLayer),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as CommandV2 from "./command"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { castDraft, type Draft } from "immer"
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { State } from "./state"
|
||||
|
||||
@@ -15,19 +14,17 @@ export class Info extends Schema.Class<Info>("CommandV2.Info")({
|
||||
}) {}
|
||||
|
||||
export type Data = {
|
||||
commands: Map<string, Info>
|
||||
commands: Map<string, Types.DeepMutable<Info>>
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
list: () => readonly Info[]
|
||||
get: (name: string) => Info | undefined
|
||||
update: (name: string, update: (command: Draft<Info>) => void) => void
|
||||
update: (name: string, update: (command: Types.DeepMutable<Info>) => void) => void
|
||||
remove: (name: string) => void
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly update: State.Interface<Data, Editor>["update"]
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
@@ -37,13 +34,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.sync(() => {
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ commands: new Map() }),
|
||||
editor: (draft) => ({
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.commands.values()) as Info[],
|
||||
get: (name) => draft.commands.get(name),
|
||||
update: (name, update) => {
|
||||
const current = draft.commands.get(name) ?? castDraft(new Info({ name, template: "" }))
|
||||
const current = draft.commands.get(name) ?? (new Info({ name, template: "" }) as Types.DeepMutable<Info>)
|
||||
if (!draft.commands.has(name)) draft.commands.set(name, current)
|
||||
update(current)
|
||||
current.name = name
|
||||
@@ -55,7 +52,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
update: state.update,
|
||||
reload: state.reload,
|
||||
transform: state.transform,
|
||||
get: Effect.fn("CommandV2.get")(function* (name) {
|
||||
return state.get().commands.get(name)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as ConfigAgentPlugin from "./agent"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
@@ -8,7 +9,6 @@ import { ConfigAgent } from "../agent"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
import { FSUtil } from "../../fs-util"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ConfigAgentV1 } from "../../v1/config/agent"
|
||||
import { ConfigMigrateV1 } from "../../v1/config/migrate"
|
||||
|
||||
@@ -33,70 +33,70 @@ const agentKeys = new Set([
|
||||
"permissions",
|
||||
])
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("config-agent"),
|
||||
effect: Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
export const Plugin = define({
|
||||
id: "config-agent",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([entry])
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* discover(fs, entry.path)
|
||||
return yield* Effect.forEach(files, (file) =>
|
||||
fs.readFileStringSafe(file.filepath).pipe(
|
||||
Effect.map((content) => content && decode(file, content)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((documents) =>
|
||||
documents.filter((document): document is Config.Document => document !== undefined),
|
||||
),
|
||||
)
|
||||
})
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
|
||||
yield* agent.update((editor) => {
|
||||
const global = documents.flatMap((document) => document.info.permissions ?? [])
|
||||
const configuredDefault = Config.latest(documents, "default_agent")
|
||||
if (configuredDefault !== undefined) editor.default(AgentV2.ID.make(configuredDefault))
|
||||
for (const current of editor.list()) {
|
||||
editor.update(current.id, (agent) => agent.permissions.push(...global))
|
||||
}
|
||||
|
||||
for (const document of documents) {
|
||||
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
|
||||
const agentID = AgentV2.ID.make(id)
|
||||
if (item.disabled) {
|
||||
editor.remove(agentID)
|
||||
continue
|
||||
}
|
||||
|
||||
const exists = editor.get(agentID) !== undefined
|
||||
editor.update(agentID, (agent) => {
|
||||
if (!exists) agent.permissions.push(...global)
|
||||
if (item.model !== undefined) {
|
||||
const model = ModelV2.parse(item.model)
|
||||
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
|
||||
}
|
||||
if (item.variant !== undefined && agent.model !== undefined) {
|
||||
agent.model.variant = ModelV2.VariantID.make(item.variant)
|
||||
}
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(agent.request.headers, item.request.headers ?? {})
|
||||
Object.assign(agent.request.body, item.request.body ?? {})
|
||||
}
|
||||
if (item.system !== undefined) agent.system = item.system
|
||||
if (item.description !== undefined) agent.description = item.description
|
||||
if (item.mode !== undefined) agent.mode = item.mode
|
||||
if (item.hidden !== undefined) agent.hidden = item.hidden
|
||||
if (item.color !== undefined) agent.color = item.color
|
||||
if (item.steps !== undefined) agent.steps = item.steps
|
||||
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
|
||||
yield* ctx.agent.transform(
|
||||
Effect.fn(function* (draft) {
|
||||
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([entry])
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* discover(fs, entry.path)
|
||||
return yield* Effect.forEach(files, (file) =>
|
||||
fs.readFileStringSafe(file.filepath).pipe(
|
||||
Effect.map((content) => content && decode(file, content)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((documents) =>
|
||||
documents.filter((document): document is Config.Document => document !== undefined),
|
||||
),
|
||||
)
|
||||
})
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
const global = documents.flatMap((document) => document.info.permissions ?? [])
|
||||
const configuredDefault = Config.latest(documents, "default_agent")
|
||||
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
|
||||
for (const current of draft.list()) {
|
||||
draft.update(current.id, (agent) => agent.permissions.push(...global))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for (const document of documents) {
|
||||
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
|
||||
const agentID = AgentV2.ID.make(id)
|
||||
if (item.disabled) {
|
||||
draft.remove(agentID)
|
||||
continue
|
||||
}
|
||||
|
||||
const exists = draft.get(agentID) !== undefined
|
||||
draft.update(agentID, (agent) => {
|
||||
if (!exists) agent.permissions.push(...global)
|
||||
if (item.model !== undefined) {
|
||||
const model = ModelV2.parse(item.model)
|
||||
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
|
||||
}
|
||||
if (item.variant !== undefined && agent.model !== undefined) {
|
||||
agent.model.variant = ModelV2.VariantID.make(item.variant)
|
||||
}
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(agent.request.headers, item.request.headers ?? {})
|
||||
Object.assign(agent.request.body, item.request.body ?? {})
|
||||
}
|
||||
if (item.system !== undefined) agent.system = item.system
|
||||
if (item.description !== undefined) agent.description = item.description
|
||||
if (item.mode !== undefined) agent.mode = item.mode
|
||||
if (item.hidden !== undefined) agent.hidden = item.hidden
|
||||
if (item.color !== undefined) agent.color = item.color
|
||||
if (item.steps !== undefined) agent.steps = item.steps
|
||||
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,52 +1,51 @@
|
||||
export * as ConfigCommandPlugin from "./command"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { CommandV2 } from "../../command"
|
||||
import { Config } from "../../config"
|
||||
import { FSUtil } from "../../fs-util"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ConfigCommand } from "../command"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
|
||||
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("config-command"),
|
||||
effect: Effect.gen(function* () {
|
||||
const command = yield* CommandV2.Service
|
||||
export const Plugin = define({
|
||||
id: "config-command",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const transform = yield* command.transform()
|
||||
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
|
||||
return loadDirectory(fs, entry.path).pipe(
|
||||
Effect.map((commands) => [
|
||||
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
|
||||
]),
|
||||
)
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
|
||||
yield* transform((editor) => {
|
||||
for (const document of documents) {
|
||||
for (const [name, command] of Object.entries(document.commands ?? {})) {
|
||||
editor.update(name, (item) => {
|
||||
item.template = command.template
|
||||
if (command.description !== undefined) item.description = command.description
|
||||
if (command.agent !== undefined) item.agent = command.agent
|
||||
if (command.model !== undefined) {
|
||||
const model = ModelV2.parse(command.model)
|
||||
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
|
||||
}
|
||||
if (command.variant !== undefined && item.model !== undefined) {
|
||||
item.model.variant = ModelV2.VariantID.make(command.variant)
|
||||
}
|
||||
if (command.subtask !== undefined) item.subtask = command.subtask
|
||||
})
|
||||
yield* ctx.command.transform(
|
||||
Effect.fn(function* (draft) {
|
||||
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
|
||||
return loadDirectory(fs, entry.path).pipe(
|
||||
Effect.map((commands) => [
|
||||
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
|
||||
]),
|
||||
)
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
for (const document of documents) {
|
||||
for (const [name, command] of Object.entries(document.commands ?? {})) {
|
||||
draft.update(name, (item) => {
|
||||
item.template = command.template
|
||||
if (command.description !== undefined) item.description = command.description
|
||||
if (command.agent !== undefined) item.agent = command.agent
|
||||
if (command.model !== undefined) {
|
||||
const model = ModelV2.parse(command.model)
|
||||
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
|
||||
}
|
||||
if (command.variant !== undefined && item.model !== undefined) {
|
||||
item.model.variant = ModelV2.VariantID.make(command.variant)
|
||||
}
|
||||
if (command.subtask !== undefined) item.subtask = command.subtask
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
export * as ConfigExternalPlugin from "./external"
|
||||
|
||||
import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { Config } from "../../config"
|
||||
import { FSUtil } from "../../fs-util"
|
||||
import { Location } from "../../location"
|
||||
import { Npm } from "../../npm"
|
||||
import { define } from "../../plugin/internal"
|
||||
import { PluginPromise } from "../../plugin/promise"
|
||||
|
||||
const PluginModule = Schema.Struct({
|
||||
default: Schema.Union([
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
effect: Schema.declare<EffectPlugin["effect"]>(
|
||||
(input): input is EffectPlugin["effect"] => typeof input === "function",
|
||||
),
|
||||
}),
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
setup: Schema.declare<PromisePlugin["setup"]>(
|
||||
(input): input is PromisePlugin["setup"] => typeof input === "function",
|
||||
),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
export const Plugin = define({
|
||||
id: "config-plugin",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const configured: { package: string; options?: Record<string, any> }[] = []
|
||||
|
||||
for (const entry of yield* config.entries()) {
|
||||
if (entry.type === "document") {
|
||||
const directory = entry.path ? path.dirname(entry.path) : location.directory
|
||||
for (const item of entry.info.plugins ?? []) {
|
||||
const ref = typeof item === "string" ? { package: item } : item
|
||||
const packageName = (() => {
|
||||
if (ref.package.startsWith("file://")) return fileURLToPath(ref.package)
|
||||
if (ref.package.startsWith("./") || ref.package.startsWith("../")) {
|
||||
return path.resolve(directory, ref.package)
|
||||
}
|
||||
return ref.package
|
||||
})()
|
||||
configured.push({ package: packageName, options: ref.options })
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === "directory") {
|
||||
const files = yield* fs
|
||||
.glob("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: entry.path,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
files.sort()
|
||||
for (const file of files) configured.push({ package: file })
|
||||
}
|
||||
}
|
||||
|
||||
for (const ref of configured) {
|
||||
yield* Effect.gen(function* () {
|
||||
const entrypoint = path.isAbsolute(ref.package)
|
||||
? pathToFileURL(ref.package).href
|
||||
: (yield* npm.add(ref.package)).entrypoint
|
||||
if (!entrypoint) return
|
||||
|
||||
const mod = yield* Effect.promise(() => import(entrypoint))
|
||||
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
yield* ctx.plugin.add({
|
||||
id: plugin.id,
|
||||
effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }),
|
||||
})
|
||||
}).pipe(Effect.ignoreCause)
|
||||
}
|
||||
}).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
}),
|
||||
})
|
||||
@@ -1,123 +1,124 @@
|
||||
export * as ConfigProviderPlugin from "./provider"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Config } from "../../config"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ModelRequest } from "../../model-request"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("config-provider"),
|
||||
effect: Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
export const Plugin = define({
|
||||
id: "config-provider",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const transform = yield* catalog.transform()
|
||||
const integrationTransform = yield* integrations.transform()
|
||||
const entries = yield* config.entries()
|
||||
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredIntegrations = new Set(
|
||||
files.flatMap((file) =>
|
||||
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
|
||||
),
|
||||
)
|
||||
yield* integrationTransform((integrations) => {
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const integrationID = Integration.ID.make(id)
|
||||
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
|
||||
integrations.update(integrationID, (integration) => {
|
||||
integration.name = item.name ?? integration.name
|
||||
})
|
||||
if (item.env !== undefined) {
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "env", names: [...item.env] },
|
||||
yield* ctx.integration.transform(
|
||||
Effect.fn(function* (integrations) {
|
||||
const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredIntegrations = new Set(
|
||||
files.flatMap((file) =>
|
||||
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) =>
|
||||
provider.env === undefined ? [] : [id],
|
||||
),
|
||||
),
|
||||
)
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const integrationID = id
|
||||
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
|
||||
integrations.update(integrationID, (integration) => {
|
||||
integration.name = item.name ?? integration.name
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
yield* transform((catalog) => {
|
||||
const configuredDefault = Config.latest(entries, "model")
|
||||
if (configuredDefault !== undefined) {
|
||||
const model = ModelV2.parse(configuredDefault)
|
||||
catalog.model.default.set(model.providerID, model.modelID)
|
||||
}
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const providerID = ProviderV2.ID.make(id)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.api !== undefined) provider.api = { ...item.api }
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(provider.request.headers, item.request.headers)
|
||||
Object.assign(provider.request.body, item.request.body)
|
||||
if (item.env !== undefined) {
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "env", names: [...item.env] },
|
||||
})
|
||||
}
|
||||
})
|
||||
const providerApi = catalog.provider.get(providerID)?.provider.api
|
||||
const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
|
||||
|
||||
for (const [id, config] of Object.entries(item.models ?? {})) {
|
||||
catalog.model.update(providerID, ModelV2.ID.make(id), (model) => {
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
|
||||
const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
|
||||
if (config.capabilities !== undefined) {
|
||||
model.capabilities = {
|
||||
tools: config.capabilities.tools,
|
||||
input: [...config.capabilities.input],
|
||||
output: [...config.capabilities.output],
|
||||
}
|
||||
}
|
||||
if (config.request !== undefined) {
|
||||
ModelRequest.assign(model.request, {
|
||||
headers: config.request.headers,
|
||||
...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
|
||||
})
|
||||
if (config.request.variant !== undefined) model.request.variant = config.request.variant
|
||||
}
|
||||
if (config.variants !== undefined) {
|
||||
for (const variant of config.variants) {
|
||||
let existing = model.variants.find((item) => item.id === variant.id)
|
||||
if (!existing) {
|
||||
existing = {
|
||||
id: variant.id,
|
||||
headers: {},
|
||||
body: {},
|
||||
generation: {},
|
||||
options: {},
|
||||
}
|
||||
model.variants.push(existing)
|
||||
}
|
||||
ModelRequest.assign(existing, {
|
||||
headers: variant.headers,
|
||||
...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (config.cost !== undefined) {
|
||||
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
|
||||
tier: cost.tier && { ...cost.tier },
|
||||
input: cost.input,
|
||||
output: cost.output,
|
||||
cache: {
|
||||
read: cost.cache?.read ?? 0,
|
||||
write: cost.cache?.write ?? 0,
|
||||
},
|
||||
}))
|
||||
}
|
||||
if (config.disabled !== undefined) model.enabled = !config.disabled
|
||||
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (catalog) {
|
||||
const entries = yield* config.entries()
|
||||
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredDefault = Config.latest(entries, "model")
|
||||
if (configuredDefault !== undefined) {
|
||||
const model = ModelV2.parse(configuredDefault)
|
||||
catalog.model.default.set(model.providerID, model.modelID)
|
||||
}
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const providerID = id
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.api !== undefined) provider.api = { ...item.api }
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(provider.request.headers, item.request.headers)
|
||||
Object.assign(provider.request.body, item.request.body)
|
||||
}
|
||||
})
|
||||
const providerApi = catalog.provider.get(providerID)?.provider.api
|
||||
const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
|
||||
|
||||
for (const [id, config] of Object.entries(item.models ?? {})) {
|
||||
catalog.model.update(providerID, id, (model) => {
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
|
||||
const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
|
||||
if (config.capabilities !== undefined) {
|
||||
model.capabilities = {
|
||||
tools: config.capabilities.tools,
|
||||
input: [...config.capabilities.input],
|
||||
output: [...config.capabilities.output],
|
||||
}
|
||||
}
|
||||
if (config.request !== undefined) {
|
||||
ModelRequest.assign(model.request, {
|
||||
headers: config.request.headers,
|
||||
...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
|
||||
})
|
||||
if (config.request.variant !== undefined) model.request.variant = config.request.variant
|
||||
}
|
||||
if (config.variants !== undefined) {
|
||||
for (const variant of config.variants) {
|
||||
let existing = model.variants.find((item) => item.id === variant.id)
|
||||
if (!existing) {
|
||||
existing = {
|
||||
id: variant.id,
|
||||
headers: {},
|
||||
body: {},
|
||||
generation: {},
|
||||
options: {},
|
||||
}
|
||||
model.variants.push(existing)
|
||||
}
|
||||
ModelRequest.assign(existing, {
|
||||
headers: variant.headers,
|
||||
...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (config.cost !== undefined) {
|
||||
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
|
||||
tier: cost.tier && { ...cost.tier },
|
||||
input: cost.input,
|
||||
output: cost.output,
|
||||
cache: {
|
||||
read: cost.cache?.read ?? 0,
|
||||
write: cost.cache?.write ?? 0,
|
||||
},
|
||||
}))
|
||||
}
|
||||
if (config.disabled !== undefined) model.enabled = !config.disabled
|
||||
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,57 +1,56 @@
|
||||
export * as ConfigReferencePlugin from "./reference"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ConfigReference } from "../reference"
|
||||
import { Global } from "../../global"
|
||||
import { Location } from "../../location"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { Reference } from "../../reference"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { Global } from "../../global"
|
||||
import { Location } from "../../location"
|
||||
|
||||
export const Plugin = {
|
||||
id: PluginV2.ID.make("core/config-reference"),
|
||||
effect: Effect.gen(function* () {
|
||||
export const Plugin = define({
|
||||
id: "core/config-reference",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const references = yield* Reference.Service
|
||||
const update = yield* references.transform()
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of (yield* config.entries()).filter(
|
||||
(entry): entry is Config.Document => entry.type === "document",
|
||||
)) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
entries.set(
|
||||
name,
|
||||
local(entry)
|
||||
? new Reference.LocalSource({
|
||||
type: "local",
|
||||
path: AbsolutePath.make(
|
||||
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
|
||||
),
|
||||
description: typeof entry === "string" ? undefined : entry.description,
|
||||
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
||||
})
|
||||
: new Reference.GitSource({
|
||||
type: "git",
|
||||
repository: typeof entry === "string" ? entry : entry.repository,
|
||||
branch: typeof entry === "string" ? undefined : entry.branch,
|
||||
description: typeof entry === "string" ? undefined : entry.description,
|
||||
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
yield* update((editor) => {
|
||||
for (const [name, source] of entries) editor.add(name, source)
|
||||
})
|
||||
const global = yield* Global.Service
|
||||
yield* ctx.reference.transform(
|
||||
Effect.fn(function* (draft) {
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of (yield* config.entries()).filter(
|
||||
(entry): entry is Config.Document => entry.type === "document",
|
||||
)) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
entries.set(
|
||||
name,
|
||||
local(entry)
|
||||
? new Reference.LocalSource({
|
||||
type: "local",
|
||||
path: AbsolutePath.make(
|
||||
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
|
||||
),
|
||||
description: typeof entry === "string" ? undefined : entry.description,
|
||||
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
||||
})
|
||||
: new Reference.GitSource({
|
||||
type: "git",
|
||||
repository: typeof entry === "string" ? entry : entry.repository,
|
||||
branch: typeof entry === "string" ? undefined : entry.branch,
|
||||
description: typeof entry === "string" ? undefined : entry.description,
|
||||
hidden: typeof entry === "string" ? undefined : entry.hidden,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const [name, source] of entries) draft.add(name, source)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
function validAlias(name: string) {
|
||||
return name.length > 0 && !/[\/\s`,]/.test(name)
|
||||
|
||||
@@ -1,48 +1,47 @@
|
||||
export * as ConfigSkillPlugin from "./skill"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { Global } from "../../global"
|
||||
import { Location } from "../../location"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { SkillV2 } from "../../skill"
|
||||
import { Global } from "../../global"
|
||||
import { Location } from "../../location"
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("config-skill"),
|
||||
effect: Effect.gen(function* () {
|
||||
export const Plugin = define({
|
||||
id: "config-skill",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const transform = yield* skill.transform()
|
||||
const entries = yield* config.entries()
|
||||
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||
|
||||
yield* transform((editor) => {
|
||||
for (const directory of directories) {
|
||||
editor.source(
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
)
|
||||
editor.source(
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
|
||||
)
|
||||
}
|
||||
for (const item of items) {
|
||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
||||
editor.source(new SkillV2.UrlSource({ type: "url", url: item }))
|
||||
continue
|
||||
yield* ctx.skill.transform(
|
||||
Effect.fn(function* (draft) {
|
||||
const entries = yield* config.entries()
|
||||
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||
for (const directory of directories) {
|
||||
draft.source(
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
)
|
||||
draft.source(
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
|
||||
)
|
||||
}
|
||||
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
||||
editor.source(
|
||||
new SkillV2.DirectorySource({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
for (const item of items) {
|
||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
||||
draft.source(new SkillV2.UrlSource({ type: "url", url: item }))
|
||||
continue
|
||||
}
|
||||
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
||||
draft.source(
|
||||
new SkillV2.DirectorySource({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -29,33 +29,33 @@ export class Key extends Schema.Class<Key>("Credential.Key")({
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
}) {}
|
||||
|
||||
export const Info = Schema.Union([OAuth, Key])
|
||||
export const Value = Schema.Union([OAuth, Key])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Credential.Info" })
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
.annotate({ identifier: "Credential.Value" })
|
||||
export type Value = Schema.Schema.Type<typeof Value>
|
||||
|
||||
export class Stored extends Schema.Class<Stored>("Credential.Stored")({
|
||||
export class Info extends Schema.Class<Info>("Credential.Info")({
|
||||
id: ID,
|
||||
integrationID: IntegrationSchema.ID,
|
||||
label: Schema.String,
|
||||
value: Info,
|
||||
value: Value,
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
/** Returns every stored credential. */
|
||||
readonly all: () => Effect.Effect<Stored[]>
|
||||
readonly all: () => Effect.Effect<Info[]>
|
||||
/** Returns stored credentials belonging to one integration. */
|
||||
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]>
|
||||
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Info[]>
|
||||
/** Returns one stored credential by ID. */
|
||||
readonly get: (id: ID) => Effect.Effect<Stored | undefined>
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
/** Replaces any credential for an integration and returns the new record. */
|
||||
readonly create: (input: {
|
||||
readonly integrationID: IntegrationSchema.ID
|
||||
readonly value: Info
|
||||
readonly value: Value
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<Stored>
|
||||
}) => Effect.Effect<Info>
|
||||
/** Updates the label or secret value of a stored credential. */
|
||||
readonly update: (id: ID, updates: Partial<Pick<Stored, "label" | "value">>) => Effect.Effect<void>
|
||||
readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
|
||||
/** Removes a stored credential. */
|
||||
readonly remove: (id: ID) => Effect.Effect<void>
|
||||
}
|
||||
@@ -66,10 +66,10 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const decode = Schema.decodeUnknownSync(Value)
|
||||
const stored = (row: typeof CredentialTable.$inferSelect) => {
|
||||
if (!row.integration_id) return
|
||||
return new Stored({
|
||||
return new Info({
|
||||
id: row.id,
|
||||
integrationID: row.integration_id,
|
||||
label: row.label,
|
||||
@@ -106,7 +106,7 @@ export const layer = Layer.effect(
|
||||
return row ? stored(row) : undefined
|
||||
}),
|
||||
create: Effect.fn("Credential.create")(function* (input) {
|
||||
const credential = new Stored({
|
||||
const credential = new Info({
|
||||
id: ID.create(),
|
||||
integrationID: input.integrationID,
|
||||
label: input.label ?? "default",
|
||||
|
||||
@@ -7,7 +7,7 @@ export const CredentialTable = sqliteTable("credential", {
|
||||
id: text().$type<Credential.ID>().primaryKey(),
|
||||
integration_id: text().$type<IntegrationSchema.ID>(),
|
||||
label: text().notNull(),
|
||||
value: text({ mode: "json" }).$type<Credential.Info>().notNull(),
|
||||
value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
|
||||
connector_id: text(),
|
||||
method_id: text(),
|
||||
active: integer({ mode: "boolean" }),
|
||||
|
||||
+3
@@ -37,5 +37,8 @@ export const migrations = (
|
||||
import("./migration/20260611035744_credential"),
|
||||
import("./migration/20260611192811_lush_chimera"),
|
||||
import("./migration/20260612174303_project_dir_strategy"),
|
||||
import("./migration/20260622142730_simplify_session_context_epoch"),
|
||||
import("./migration/20260622170816_reset_v2_session_state"),
|
||||
import("./migration/20260622202450_simplify_session_input"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260622170816_reset_v2_session_state",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DELETE FROM \`session_context_epoch\`;`)
|
||||
yield* tx.run(`DELETE FROM \`session_input\`;`)
|
||||
yield* tx.run(`DELETE FROM \`session_message\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
|
||||
yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
|
||||
yield* tx.run(`DELETE FROM \`workspace\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260622202450_simplify_session_input",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DELETE FROM \`session_context_epoch\`;`)
|
||||
yield* tx.run(`DELETE FROM \`session_input\`;`)
|
||||
yield* tx.run(`DELETE FROM \`session_message\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event\`;`)
|
||||
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
|
||||
yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
|
||||
yield* tx.run(`DELETE FROM \`workspace\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -149,11 +149,8 @@ export default {
|
||||
CREATE TABLE \`session_context_epoch\` (
|
||||
\`session_id\` text PRIMARY KEY,
|
||||
\`baseline\` text NOT NULL,
|
||||
\`agent\` text DEFAULT 'build' NOT NULL,
|
||||
\`snapshot\` text 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
|
||||
);
|
||||
`)
|
||||
|
||||
+143
-177
@@ -5,7 +5,7 @@ import { and, asc, eq, gt } from "drizzle-orm"
|
||||
import { Database } from "./database/database"
|
||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||
import { Location } from "./location"
|
||||
import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema"
|
||||
import { externalID, type ExternalID, withStatics } from "./schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
@@ -19,16 +19,9 @@ export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
|
||||
)
|
||||
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> = {
|
||||
readonly type: Type
|
||||
readonly sync?: {
|
||||
readonly durable?: {
|
||||
readonly version: number
|
||||
readonly aggregate: string
|
||||
}
|
||||
@@ -41,22 +34,31 @@ export type Payload<D extends Definition = Definition> = {
|
||||
readonly id: ID
|
||||
readonly type: D["type"]
|
||||
readonly data: Data<D>
|
||||
/** Durable aggregate order, populated while synchronized events are projected. */
|
||||
readonly seq?: number
|
||||
readonly version?: number
|
||||
readonly durable?: {
|
||||
readonly aggregateID: string
|
||||
readonly seq: number
|
||||
readonly version: number
|
||||
}
|
||||
readonly location?: Location.Ref
|
||||
readonly metadata?: Record<string, unknown>
|
||||
/** Internal replay marker for projectors that own non-replicated operational state. */
|
||||
readonly replay?: boolean
|
||||
}
|
||||
|
||||
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 Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
|
||||
export type Unsubscribe = Effect.Effect<void>
|
||||
|
||||
export const latestSequence = Effect.fn("EventV2.latestSequence")(function* (
|
||||
db: Database.Interface["db"],
|
||||
aggregateID: string,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select({ seq: EventSequenceTable.seq })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row?.seq ?? -1
|
||||
})
|
||||
|
||||
export type SerializedEvent = {
|
||||
readonly id: ID
|
||||
readonly type: string
|
||||
@@ -65,13 +67,8 @@ export type SerializedEvent = {
|
||||
readonly data: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type CursorEvent<E extends Payload = Payload> = {
|
||||
readonly cursor: Cursor
|
||||
readonly event: E
|
||||
}
|
||||
|
||||
export class InvalidSyncEventError extends Schema.TaggedErrorClass<InvalidSyncEventError>()(
|
||||
"EventV2.InvalidSyncEvent",
|
||||
export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDurableEventError>()(
|
||||
"EventV2.InvalidDurableEvent",
|
||||
{
|
||||
type: Schema.String,
|
||||
message: Schema.String,
|
||||
@@ -83,19 +80,11 @@ export function versionedType(type: string, version: number) {
|
||||
}
|
||||
|
||||
export const registry = 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>
|
||||
const durableRegistry = new Map<string, Definition>()
|
||||
|
||||
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
|
||||
readonly type: Type
|
||||
readonly sync?: {
|
||||
readonly durable?: {
|
||||
readonly version: number
|
||||
readonly aggregate: string
|
||||
}
|
||||
@@ -106,28 +95,25 @@ export function define<const Type extends string, Fields extends Schema.Struct.F
|
||||
id: ID,
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
type: Schema.Literal(input.type),
|
||||
version: Schema.optional(Schema.Number),
|
||||
durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })),
|
||||
location: Schema.optional(Location.Ref),
|
||||
data: Data,
|
||||
}).annotate({ identifier: input.type })
|
||||
|
||||
const definition = Object.assign(Payload, {
|
||||
type: input.type,
|
||||
...(input.sync === undefined ? {} : { sync: input.sync }),
|
||||
...(input.durable === undefined ? {} : { durable: input.durable }),
|
||||
data: Data,
|
||||
})
|
||||
const existing = registry.get(input.type)
|
||||
if (input.sync === undefined || existing?.sync === undefined || input.sync.version >= existing.sync.version) {
|
||||
if (
|
||||
input.durable === undefined ||
|
||||
existing?.durable === undefined ||
|
||||
input.durable.version >= existing.durable.version
|
||||
) {
|
||||
registry.set(input.type, 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,
|
||||
)
|
||||
if (input.durable) durableRegistry.set(versionedType(input.type, input.durable.version), definition)
|
||||
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
|
||||
Definition<Type, Schema.Struct<Fields>>
|
||||
}
|
||||
@@ -140,7 +126,7 @@ export interface PublishOptions {
|
||||
readonly id?: ID
|
||||
readonly metadata?: Record<string, unknown>
|
||||
readonly location?: Location.Ref
|
||||
/** Local operational projection committed atomically with a new synchronized event. Not replayed or serialized. */
|
||||
/** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
|
||||
readonly commit?: (seq: number) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -152,14 +138,10 @@ export interface Interface {
|
||||
) => Effect.Effect<Payload<D>>
|
||||
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
|
||||
readonly all: () => Stream.Stream<Payload>
|
||||
readonly aggregateEvents: (input: {
|
||||
readonly aggregateID: string
|
||||
readonly after?: Cursor
|
||||
}) => 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 durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream<Payload>
|
||||
/** @deprecated Use `all()` and consume the returned stream. */
|
||||
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
|
||||
readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
|
||||
readonly replay: (
|
||||
event: SerializedEvent,
|
||||
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
||||
@@ -182,37 +164,37 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const all = yield* PubSub.unbounded<Payload>()
|
||||
const synchronized = new Map<string, Set<PubSub.PubSub<void>>>()
|
||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
||||
const projectors = new Map<string, AnyProjector[]>()
|
||||
const commitGuards = new Array<CommitGuard>()
|
||||
const listeners = new Array<Listener>()
|
||||
const syncHandlers = new Array<Sync>()
|
||||
const pubsub = {
|
||||
all: yield* PubSub.unbounded<Payload>(),
|
||||
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
|
||||
typed: new Map<string, PubSub.PubSub<Payload>>(),
|
||||
}
|
||||
const projectors = new Map<string, Subscriber[]>()
|
||||
const listeners = new Array<Subscriber>()
|
||||
const { db } = yield* Database.Service
|
||||
|
||||
const getOrCreate = (definition: Definition) =>
|
||||
Effect.gen(function* () {
|
||||
const existing = typed.get(definition.type)
|
||||
const existing = pubsub.typed.get(definition.type)
|
||||
if (existing) return existing
|
||||
const pubsub = yield* PubSub.unbounded<Payload>()
|
||||
typed.set(definition.type, pubsub)
|
||||
return pubsub
|
||||
const created = yield* PubSub.unbounded<Payload>()
|
||||
pubsub.typed.set(definition.type, created)
|
||||
return created
|
||||
})
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
yield* PubSub.shutdown(all)
|
||||
yield* PubSub.shutdown(pubsub.all)
|
||||
yield* Effect.forEach(
|
||||
synchronized.values(),
|
||||
pubsub.durable.values(),
|
||||
(pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
|
||||
{ discard: true },
|
||||
)
|
||||
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
|
||||
yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true })
|
||||
}),
|
||||
)
|
||||
|
||||
function commitSyncEvent(
|
||||
function commitDurableEvent(
|
||||
event: Payload,
|
||||
input?: {
|
||||
readonly seq: number
|
||||
@@ -224,28 +206,20 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const definition = registry.get(event.type)
|
||||
const sync = definition?.sync
|
||||
if (sync) {
|
||||
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]
|
||||
const durable = definition?.durable
|
||||
if (durable) {
|
||||
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
|
||||
if (typeof aggregateID !== "string") {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Expected string aggregate field ${sync.aggregate}`,
|
||||
message: `Expected string aggregate field ${durable.aggregate}`,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
if (input && input.aggregateID !== aggregateID) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
|
||||
}),
|
||||
@@ -265,12 +239,12 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const latest = row?.seq ?? -1
|
||||
const encoded = syncRegistry
|
||||
.get(versionedType(definition.type, sync.version))!
|
||||
.encode(event.data) as Record<string, unknown>
|
||||
const encoded = Schema.encodeUnknownSync(
|
||||
definition.data as Schema.Codec<unknown, unknown, never, never>,
|
||||
)(event.data) as Record<string, unknown>
|
||||
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
|
||||
}),
|
||||
@@ -285,7 +259,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
.pipe(Effect.orDie)
|
||||
if (
|
||||
stored?.id === event.id &&
|
||||
stored.type === versionedType(definition.type, sync.version) &&
|
||||
stored.type === versionedType(definition.type, durable.version) &&
|
||||
isDeepStrictEqual(stored.data, encoded)
|
||||
) {
|
||||
if (input.ownerID && row?.ownerID == null) {
|
||||
@@ -299,7 +273,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
return
|
||||
}
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
|
||||
}),
|
||||
@@ -311,7 +285,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
const seq = input?.seq ?? latest + 1
|
||||
if (input && seq !== latest + 1) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
|
||||
}),
|
||||
@@ -325,16 +299,17 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
.pipe(Effect.orDie)
|
||||
if (stored)
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
|
||||
}),
|
||||
)
|
||||
for (const guard of commitGuards) {
|
||||
yield* guard(event)
|
||||
}
|
||||
const committed = {
|
||||
...event,
|
||||
durable: { aggregateID, seq, version: durable.version },
|
||||
} as Payload
|
||||
for (const projector of list) {
|
||||
yield* projector({ ...event, seq } as Payload)
|
||||
yield* projector(committed)
|
||||
}
|
||||
if (commit) yield* commit(seq)
|
||||
yield* db
|
||||
@@ -356,7 +331,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
id: event.id,
|
||||
aggregate_id: aggregateID,
|
||||
seq,
|
||||
type: versionedType(definition.type, sync.version),
|
||||
type: versionedType(definition.type, durable.version),
|
||||
data: encoded,
|
||||
},
|
||||
])
|
||||
@@ -369,8 +344,8 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
.pipe(Effect.orDie)
|
||||
if (committed) {
|
||||
yield* Effect.forEach(
|
||||
synchronized.get(committed.aggregateID) ?? [],
|
||||
(pubsub) => PubSub.publish(pubsub, undefined),
|
||||
pubsub.durable.get(committed.aggregateID) ?? [],
|
||||
(wake) => PubSub.publish(wake, undefined),
|
||||
{ discard: true },
|
||||
)
|
||||
}
|
||||
@@ -384,19 +359,25 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
|
||||
function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) {
|
||||
return Effect.gen(function* () {
|
||||
const durable = registry.get(event.type)?.sync !== undefined
|
||||
if (!durable && commit)
|
||||
const definition = registry.get(event.type)
|
||||
if (!definition?.durable && commit)
|
||||
return yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: "Local commit hooks require a synchronized event",
|
||||
message: "Local commit hooks require a durable event",
|
||||
}),
|
||||
)
|
||||
if (durable) {
|
||||
const committed = yield* commitSyncEvent(event as Payload, undefined, commit)
|
||||
if (definition?.durable) {
|
||||
const committed = yield* commitDurableEvent(event as Payload, undefined, commit)
|
||||
if (committed) {
|
||||
event = { ...event, seq: committed.seq }
|
||||
yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true })
|
||||
event = {
|
||||
...event,
|
||||
durable: {
|
||||
aggregateID: committed.aggregateID,
|
||||
seq: committed.seq,
|
||||
version: definition.durable.version,
|
||||
},
|
||||
}
|
||||
yield* notify(event as Payload, true)
|
||||
return event
|
||||
}
|
||||
@@ -406,12 +387,11 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
})
|
||||
}
|
||||
|
||||
const observe = (event: Payload, kind: "sync" | "listener", observer: (event: Payload) => Effect.Effect<void>) =>
|
||||
const observe = (event: Payload, observer: (event: Payload) => Effect.Effect<void>) =>
|
||||
Effect.suspend(() => observer(event)).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
(cause) =>
|
||||
Effect.logError("Event observer failed", { eventID: event.id, eventType: event.type, kind, cause }),
|
||||
(cause) => Effect.logError("Event listener failed", { eventID: event.id, eventType: event.type, cause }),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -419,12 +399,12 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.forEach(
|
||||
listeners,
|
||||
(listener) => (isolateListeners ? observe(event, "listener", listener) : listener(event)),
|
||||
(listener) => (isolateListeners ? observe(event, listener) : listener(event)),
|
||||
{ discard: true },
|
||||
)
|
||||
const pubsub = typed.get(event.type)
|
||||
if (pubsub) yield* PubSub.publish(pubsub, event)
|
||||
yield* PubSub.publish(all, event)
|
||||
const typed = pubsub.typed.get(event.type)
|
||||
if (typed) yield* PubSub.publish(typed, event)
|
||||
yield* PubSub.publish(pubsub.all, event)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -441,7 +421,6 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
id: options?.id ?? ID.create(),
|
||||
...(options?.metadata ? { metadata: options.metadata } : {}),
|
||||
type: definition.type,
|
||||
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
|
||||
...(location ? { location } : {}),
|
||||
data,
|
||||
} as Payload<D>,
|
||||
@@ -455,27 +434,37 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const definition = syncRegistry.get(event.type)
|
||||
if (!definition) {
|
||||
const definition = durableRegistry.get(event.type)
|
||||
if (!definition?.durable) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }),
|
||||
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
|
||||
)
|
||||
} else {
|
||||
const payload = {
|
||||
id: event.id,
|
||||
type: definition.type,
|
||||
version: definition.sync.version,
|
||||
data: definition.decode(event.data),
|
||||
replay: true,
|
||||
data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(
|
||||
event.data,
|
||||
),
|
||||
} as Payload
|
||||
const committed = yield* commitSyncEvent(payload, {
|
||||
const committed = yield* commitDurableEvent(payload, {
|
||||
seq: event.seq,
|
||||
aggregateID: event.aggregateID,
|
||||
ownerID: options?.ownerID,
|
||||
strictOwner: options?.strictOwner,
|
||||
})
|
||||
if (committed && options?.publish) {
|
||||
yield* notify({ ...payload, seq: committed.seq }, true)
|
||||
yield* notify(
|
||||
{
|
||||
...payload,
|
||||
durable: {
|
||||
aggregateID: committed.aggregateID,
|
||||
seq: committed.seq,
|
||||
version: definition.durable.version,
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -490,7 +479,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
if (!source) return undefined
|
||||
if (events.some((event) => event.aggregateID !== source)) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
new InvalidDurableEventError({
|
||||
type: events[0]?.type ?? "unknown",
|
||||
message: "Replay events must belong to the same aggregate",
|
||||
}),
|
||||
@@ -501,7 +490,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
const seq = start + index
|
||||
if (event.seq !== seq) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
|
||||
}),
|
||||
@@ -540,22 +529,18 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
Stream.map((event) => event as Payload<D>),
|
||||
)
|
||||
|
||||
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
|
||||
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
|
||||
|
||||
const decodeSerializedEvent = (event: SerializedEvent): CursorEvent => {
|
||||
const definition = syncRegistry.get(event.type)
|
||||
if (!definition) {
|
||||
throw new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` })
|
||||
const decodeSerializedEvent = (event: SerializedEvent): Payload => {
|
||||
const definition = durableRegistry.get(event.type)
|
||||
if (!definition?.durable) {
|
||||
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
|
||||
}
|
||||
return {
|
||||
cursor: Cursor.make(event.seq),
|
||||
event: {
|
||||
id: event.id,
|
||||
type: definition.type,
|
||||
version: definition.sync.version,
|
||||
seq: event.seq,
|
||||
data: definition.decode(event.data),
|
||||
},
|
||||
id: event.id,
|
||||
type: definition.type,
|
||||
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
|
||||
data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(event.data),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,43 +568,40 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
),
|
||||
)
|
||||
|
||||
const subscribeSynchronized = (aggregateID: string) =>
|
||||
const subscribeDurable = (aggregateID: string) =>
|
||||
Effect.gen(function* () {
|
||||
const pubsub = yield* PubSub.sliding<void>(1)
|
||||
const subscription = yield* PubSub.subscribe(pubsub)
|
||||
const wake = yield* PubSub.sliding<void>(1)
|
||||
const subscription = yield* PubSub.subscribe(wake)
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const pubsubs = synchronized.get(aggregateID) ?? new Set()
|
||||
pubsubs.add(pubsub)
|
||||
synchronized.set(aggregateID, pubsubs)
|
||||
const wakes = pubsub.durable.get(aggregateID) ?? new Set()
|
||||
wakes.add(wake)
|
||||
pubsub.durable.set(aggregateID, wakes)
|
||||
}),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
const pubsubs = synchronized.get(aggregateID)
|
||||
pubsubs?.delete(pubsub)
|
||||
if (pubsubs?.size === 0) synchronized.delete(aggregateID)
|
||||
}).pipe(Effect.andThen(PubSub.shutdown(pubsub))),
|
||||
const wakes = pubsub.durable.get(aggregateID)
|
||||
wakes?.delete(wake)
|
||||
if (wakes?.size === 0) pubsub.durable.delete(aggregateID)
|
||||
}).pipe(Effect.andThen(PubSub.shutdown(wake))),
|
||||
)
|
||||
return subscription
|
||||
})
|
||||
|
||||
const streamEvents = (input: {
|
||||
readonly aggregateID: string
|
||||
readonly after?: Cursor
|
||||
}): Stream.Stream<CursorEvent> =>
|
||||
const durable = (input: { readonly aggregateID: string; readonly after?: number }): Stream.Stream<Payload> =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const synchronized = yield* subscribeSynchronized(input.aggregateID)
|
||||
let cursor = input.after ?? -1
|
||||
const read = Effect.suspend(() => readAfter(input.aggregateID, cursor)).pipe(
|
||||
const wakes = yield* subscribeDurable(input.aggregateID)
|
||||
let sequence = input.after ?? -1
|
||||
const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe(
|
||||
Effect.tap((events) =>
|
||||
Effect.sync(() => {
|
||||
cursor = events.at(-1)?.cursor ?? cursor
|
||||
sequence = events.at(-1)?.durable?.seq ?? sequence
|
||||
}),
|
||||
),
|
||||
)
|
||||
const historical = yield* read
|
||||
const live = Stream.fromSubscription(synchronized).pipe(
|
||||
const live = Stream.fromSubscription(wakes).pipe(
|
||||
Stream.mapEffect(() => read),
|
||||
Stream.flattenIterable,
|
||||
)
|
||||
@@ -627,7 +609,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
}),
|
||||
)
|
||||
|
||||
const listen = (listener: Listener): Effect.Effect<Unsubscribe> =>
|
||||
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
|
||||
Effect.sync(() => {
|
||||
listeners.push(listener)
|
||||
return Effect.sync(() => {
|
||||
@@ -636,21 +618,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
})
|
||||
})
|
||||
|
||||
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> =>
|
||||
const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
|
||||
Effect.sync(() => {
|
||||
const list = projectors.get(definition.type) ?? []
|
||||
list.push((event) => projector(event as Payload<D>))
|
||||
@@ -661,10 +629,8 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
publish,
|
||||
subscribe,
|
||||
all: streamAll,
|
||||
aggregateEvents: streamEvents,
|
||||
sync,
|
||||
durable,
|
||||
listen,
|
||||
beforeCommit,
|
||||
project,
|
||||
replay,
|
||||
replayAll,
|
||||
|
||||
@@ -123,6 +123,6 @@ const baseLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer))
|
||||
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
@@ -232,6 +232,6 @@ export const fffLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = Layer.unwrap(
|
||||
export const locationLayer = Layer.unwrap(
|
||||
Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
export * as Integration from "./integration"
|
||||
|
||||
import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Schedule, Schema, Scope, SynchronizedRef } from "effect"
|
||||
import { castDraft, enableMapSet, type Draft } from "immer"
|
||||
import {
|
||||
Cause,
|
||||
Clock,
|
||||
Context,
|
||||
Duration,
|
||||
Effect,
|
||||
Exit,
|
||||
Layer,
|
||||
Schedule,
|
||||
Schema,
|
||||
Scope,
|
||||
SynchronizedRef,
|
||||
Types,
|
||||
} from "effect"
|
||||
import { Credential } from "./credential"
|
||||
import { IntegrationSchema } from "./integration/schema"
|
||||
import { withStatics } from "./schema"
|
||||
@@ -42,12 +54,14 @@ export const SelectPrompt = Schema.Struct({
|
||||
type: Schema.Literal("select"),
|
||||
key: Schema.String,
|
||||
message: Schema.String,
|
||||
options: Schema.Array(
|
||||
Schema.Struct({
|
||||
label: Schema.String,
|
||||
value: Schema.String,
|
||||
hint: Schema.optional(Schema.String),
|
||||
}),
|
||||
options: Schema.mutable(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
label: Schema.String,
|
||||
value: Schema.String,
|
||||
hint: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
),
|
||||
when: Schema.optional(When),
|
||||
}).annotate({ identifier: "Integration.SelectPrompt" })
|
||||
@@ -60,7 +74,7 @@ export const OAuthMethod = Schema.Struct({
|
||||
id: MethodID,
|
||||
type: Schema.Literal("oauth"),
|
||||
label: Schema.String,
|
||||
prompts: Schema.optional(Schema.Array(Prompt)),
|
||||
prompts: Schema.optional(Schema.mutable(Schema.Array(Prompt))),
|
||||
}).annotate({ identifier: "Integration.OAuthMethod" })
|
||||
export type OAuthMethod = typeof OAuthMethod.Type
|
||||
|
||||
@@ -72,7 +86,7 @@ export type KeyMethod = typeof KeyMethod.Type
|
||||
|
||||
export const EnvMethod = Schema.Struct({
|
||||
type: Schema.Literal("env"),
|
||||
names: Schema.Array(Schema.String),
|
||||
names: Schema.mutable(Schema.Array(Schema.String)),
|
||||
}).annotate({ identifier: "Integration.EnvMethod" })
|
||||
export type EnvMethod = typeof EnvMethod.Type
|
||||
|
||||
@@ -82,8 +96,8 @@ export type Method = typeof Method.Type
|
||||
export class Info extends Schema.Class<Info>("Integration.Info")({
|
||||
id: ID,
|
||||
name: Schema.String,
|
||||
methods: Schema.Array(Method),
|
||||
connections: Schema.Array(IntegrationConnection.Info),
|
||||
methods: Schema.mutable(Schema.Array(Method)),
|
||||
connections: Schema.mutable(Schema.Array(IntegrationConnection.Info)),
|
||||
}) {}
|
||||
|
||||
export type Inputs = Readonly<{ [key: string]: string }>
|
||||
@@ -94,11 +108,11 @@ export type OAuthAuthorization = {
|
||||
} & (
|
||||
| {
|
||||
readonly mode: "auto"
|
||||
readonly callback: Effect.Effect<Credential.Info, unknown>
|
||||
readonly callback: Effect.Effect<Credential.Value, unknown>
|
||||
}
|
||||
| {
|
||||
readonly mode: "code"
|
||||
readonly callback: (code: string) => Effect.Effect<Credential.Info, unknown>
|
||||
readonly callback: (code: string) => Effect.Effect<Credential.Value, unknown>
|
||||
}
|
||||
)
|
||||
|
||||
@@ -172,19 +186,19 @@ export type Ref = {
|
||||
}
|
||||
|
||||
type Entry = {
|
||||
ref: Ref
|
||||
methods: Method[]
|
||||
implementations: Map<MethodID, OAuthImplementation>
|
||||
ref: Types.DeepMutable<Ref>
|
||||
methods: Types.DeepMutable<Method>[]
|
||||
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
|
||||
}
|
||||
|
||||
type Data = {
|
||||
integrations: Map<ID, Entry>
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
list: () => readonly Ref[]
|
||||
get: (id: ID) => Ref | undefined
|
||||
update: (id: ID, update: (integration: Draft<Ref>) => void) => void
|
||||
update: (id: ID, update: (integration: Types.DeepMutable<Ref>) => void) => void
|
||||
remove: (id: ID) => void
|
||||
method: {
|
||||
list: (integrationID: ID) => readonly Method[]
|
||||
@@ -193,18 +207,13 @@ export type Editor = {
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
/** Registers a scoped transform over the integration registry. */
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
/** Registers and immediately applies a scoped integration registry update. */
|
||||
readonly update: State.Interface<Data, Editor>["update"]
|
||||
/** Returns one integration with its methods and current connections. */
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
/** Returns all integrations with their methods and current connections. */
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
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. */
|
||||
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
|
||||
/** Runs a key method and stores the resulting credential. */
|
||||
@@ -230,7 +239,7 @@ export interface Interface {
|
||||
/** Updates a stored credential exposed as a connection. */
|
||||
readonly update: (
|
||||
credentialID: Credential.ID,
|
||||
updates: Partial<Pick<Credential.Stored, "label">>,
|
||||
updates: Partial<Pick<Credential.Info, "label">>,
|
||||
) => Effect.Effect<void>
|
||||
/** Removes a stored credential connection. */
|
||||
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
|
||||
@@ -252,8 +261,6 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
|
||||
|
||||
enableMapSet()
|
||||
|
||||
const attemptLifetime = Duration.toMillis(Duration.minutes(10))
|
||||
const terminalRetention = Duration.toMillis(Duration.minutes(1))
|
||||
const scrubInterval = Duration.seconds(30)
|
||||
@@ -284,15 +291,17 @@ export const locationLayer = Layer.effect(
|
||||
const events = yield* EventV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ integrations: new Map<ID, Entry>() }),
|
||||
editor: (draft) => ({
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[],
|
||||
get: (id) => draft.integrations.get(id)?.ref as Ref | undefined,
|
||||
update: (id, update) => {
|
||||
const current =
|
||||
draft.integrations.get(id) ??
|
||||
castDraft({ ref: { id, name: id } as Ref, methods: [], implementations: new Map() })
|
||||
const current = draft.integrations.get(id) ?? {
|
||||
ref: { id, name: id },
|
||||
methods: [],
|
||||
implementations: new Map(),
|
||||
}
|
||||
if (!draft.integrations.has(id)) draft.integrations.set(id, current)
|
||||
update(current.ref)
|
||||
current.ref.id = id
|
||||
@@ -301,16 +310,14 @@ export const locationLayer = Layer.effect(
|
||||
method: {
|
||||
list: (integrationID) => (draft.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [],
|
||||
update: (implementation) => {
|
||||
const current =
|
||||
draft.integrations.get(implementation.integrationID) ??
|
||||
castDraft({
|
||||
ref: {
|
||||
id: implementation.integrationID,
|
||||
name: implementation.integrationID,
|
||||
} as Ref,
|
||||
methods: [],
|
||||
implementations: new Map<MethodID, OAuthImplementation>(),
|
||||
})
|
||||
const current = draft.integrations.get(implementation.integrationID) ?? {
|
||||
ref: {
|
||||
id: implementation.integrationID,
|
||||
name: implementation.integrationID,
|
||||
},
|
||||
methods: [],
|
||||
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
|
||||
}
|
||||
if (!draft.integrations.has(implementation.integrationID)) {
|
||||
draft.integrations.set(implementation.integrationID, current)
|
||||
}
|
||||
@@ -319,10 +326,13 @@ export const locationLayer = Layer.effect(
|
||||
if (method.type !== "oauth" || implementation.method.type !== "oauth") return true
|
||||
return method.id === implementation.method.id
|
||||
})
|
||||
if (index === -1) current.methods.push(castDraft(implementation.method))
|
||||
else current.methods[index] = castDraft(implementation.method)
|
||||
if (isOAuthImplementation(implementation)) {
|
||||
current.implementations.set(implementation.method.id, castDraft(implementation))
|
||||
if (index === -1) current.methods.push(implementation.method as Types.DeepMutable<Method>)
|
||||
else current.methods[index] = implementation.method as Types.DeepMutable<Method>
|
||||
if (implementation.method.type === "oauth") {
|
||||
current.implementations.set(
|
||||
implementation.method.id,
|
||||
implementation as Types.DeepMutable<OAuthImplementation>,
|
||||
)
|
||||
}
|
||||
},
|
||||
remove: (integrationID, method) => {
|
||||
@@ -341,39 +351,27 @@ export const locationLayer = Layer.effect(
|
||||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => {
|
||||
const connected = saved.map((credential) => ({
|
||||
type: "credential" as const,
|
||||
id: credential.id,
|
||||
label: credential.label,
|
||||
}))
|
||||
const detected = entry.methods
|
||||
const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
|
||||
const credentials = saved
|
||||
.map((credential) => ({
|
||||
type: "credential" as const,
|
||||
id: credential.id,
|
||||
label: credential.label,
|
||||
}))
|
||||
.toReversed()
|
||||
const env = (entry?.methods ?? [])
|
||||
.filter((method) => method.type === "env")
|
||||
.flatMap((method) => method.names.filter((name) => process.env[name]))
|
||||
.map((name) => ({ type: "env" as const, name }))
|
||||
return [...connected, ...detected]
|
||||
return [...credentials, ...env]
|
||||
}
|
||||
|
||||
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[]) =>
|
||||
const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
|
||||
new Info({
|
||||
id: entry.ref.id,
|
||||
name: entry.ref.name,
|
||||
methods: entry.methods,
|
||||
connections: connections(entry, saved),
|
||||
connections,
|
||||
})
|
||||
|
||||
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
@@ -387,7 +385,7 @@ export const locationLayer = Layer.effect(
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Info, unknown>) {
|
||||
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Value, unknown>) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modify(attempts, (current) => {
|
||||
const attempt = current.get(attemptID)
|
||||
@@ -434,32 +432,22 @@ export const locationLayer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
update: state.update,
|
||||
reload: state.reload,
|
||||
get: Effect.fn("Integration.get")(function* (id) {
|
||||
const entry = state.get().integrations.get(id)
|
||||
if (!entry) return undefined
|
||||
return project(entry, yield* credentials.list(id))
|
||||
return project(entry, resolveConnections(entry, yield* credentials.list(id)))
|
||||
}),
|
||||
list: Effect.fn("Integration.list")(function* () {
|
||||
return (yield* Effect.forEach(state.get().integrations.values(), (entry) =>
|
||||
Effect.gen(function* () {
|
||||
return project(entry, yield* credentials.list(entry.ref.id))
|
||||
}),
|
||||
)).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
|
||||
return Array.from(state.get().integrations.values(), (entry) =>
|
||||
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])),
|
||||
).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
}),
|
||||
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) {
|
||||
const entry = state.get().integrations.get(id)
|
||||
return activeConnection(entry, yield* credentials.list(id))
|
||||
return resolveConnections(entry, yield* credentials.list(id))[0]
|
||||
}),
|
||||
key: Effect.fn("Integration.connection.key")(function* (input) {
|
||||
const method = state
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Catalog } from "./catalog"
|
||||
import { Integration } from "./integration"
|
||||
import { CommandV2 } from "./command"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { PluginBoot } from "./plugin/boot"
|
||||
import { PluginInternal } from "./plugin/internal"
|
||||
import { Project } from "./project"
|
||||
import { ProjectCopy } from "./project/copy"
|
||||
import { ProjectDirectories } from "./project/directories"
|
||||
@@ -65,7 +65,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||
Integration.locationLayer,
|
||||
CommandV2.locationLayer,
|
||||
AgentV2.locationLayer,
|
||||
PluginBoot.locationLayer,
|
||||
PluginInternal.locationLayer,
|
||||
ProjectCopy.locationLayer,
|
||||
FileSystem.locationLayer,
|
||||
Watcher.locationLayer,
|
||||
|
||||
@@ -33,7 +33,7 @@ export type Request = typeof Request.Type
|
||||
interface MutableRequest {
|
||||
headers: Record<string, string>
|
||||
body: Record<string, unknown>
|
||||
generation?: Generation
|
||||
generation?: Record<string, unknown>
|
||||
options?: Record<string, unknown>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { DateTime, Schema } from "effect"
|
||||
import { DateTimeUtcFromMillis } from "effect/Schema"
|
||||
import { Schema, Types } from "effect"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { ModelRequest } from "./model-request"
|
||||
|
||||
@@ -16,8 +15,8 @@ export type Family = typeof Family.Type
|
||||
export const Capabilities = Schema.Struct({
|
||||
tools: Schema.Boolean,
|
||||
// mime patterns, image, audio, video/*, text/*
|
||||
input: Schema.String.pipe(Schema.Array),
|
||||
output: Schema.String.pipe(Schema.Array),
|
||||
input: Schema.String.pipe(Schema.Array, Schema.mutable),
|
||||
output: Schema.String.pipe(Schema.Array, Schema.mutable),
|
||||
})
|
||||
export type Capabilities = typeof Capabilities.Type
|
||||
|
||||
@@ -67,11 +66,11 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
|
||||
variants: Schema.Struct({
|
||||
id: VariantID,
|
||||
...ModelRequest.Request.fields,
|
||||
}).pipe(Schema.Array),
|
||||
}).pipe(Schema.Array, Schema.mutable),
|
||||
time: Schema.Struct({
|
||||
released: DateTimeUtcFromMillis,
|
||||
released: Schema.Finite,
|
||||
}),
|
||||
cost: Cost.pipe(Schema.Array),
|
||||
cost: Cost.pipe(Schema.Array, Schema.mutable),
|
||||
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
|
||||
enabled: Schema.Boolean,
|
||||
limit: Schema.Struct({
|
||||
@@ -103,7 +102,7 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
|
||||
},
|
||||
variants: [],
|
||||
time: {
|
||||
released: DateTime.makeUnsafe(0),
|
||||
released: 0,
|
||||
},
|
||||
cost: [],
|
||||
status: "active",
|
||||
@@ -116,6 +115,10 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
|
||||
}
|
||||
}
|
||||
|
||||
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & {
|
||||
api: ProviderV2.MutableApi<Api>
|
||||
}
|
||||
|
||||
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
|
||||
const [providerID, ...modelID] = input.split("/")
|
||||
return {
|
||||
|
||||
+23
-27
@@ -20,7 +20,7 @@ export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedErr
|
||||
|
||||
export interface EntryPoint {
|
||||
readonly directory: string
|
||||
readonly entrypoint: Option.Option<string>
|
||||
readonly entrypoint?: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -34,7 +34,7 @@ export interface Interface {
|
||||
}[]
|
||||
},
|
||||
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
|
||||
readonly which: (pkg: string, bin?: string) => Effect.Effect<Option.Option<string>>
|
||||
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
|
||||
@@ -47,12 +47,11 @@ export function sanitize(pkg: string) {
|
||||
}
|
||||
|
||||
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
|
||||
let entrypoint: Option.Option<string>
|
||||
let entrypoint: string | undefined
|
||||
try {
|
||||
const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
|
||||
entrypoint = Option.some(resolved)
|
||||
entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
|
||||
} catch {
|
||||
entrypoint = Option.none()
|
||||
entrypoint = undefined
|
||||
}
|
||||
return {
|
||||
directory: dir,
|
||||
@@ -130,7 +129,7 @@ export const layer = Layer.effect(
|
||||
const first = tree.edgesOut.values().next().value?.to
|
||||
if (!first) {
|
||||
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name))
|
||||
if (Option.isSome(result.entrypoint)) return result
|
||||
if (result.entrypoint) return result
|
||||
return yield* new InstallFailedError({ add: [pkg], dir })
|
||||
}
|
||||
return resolveEntryPoint(first.name, first.path)
|
||||
@@ -219,22 +218,24 @@ export const layer = Layer.effect(
|
||||
return Option.some(files[0])
|
||||
})
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const bin = yield* pick()
|
||||
if (Option.isSome(bin)) {
|
||||
return Option.some(path.join(binDir, bin.value))
|
||||
}
|
||||
return Option.getOrUndefined(
|
||||
yield* Effect.gen(function* () {
|
||||
const bin = yield* pick()
|
||||
if (Option.isSome(bin)) {
|
||||
return Option.some(path.join(binDir, bin.value))
|
||||
}
|
||||
|
||||
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
|
||||
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
|
||||
|
||||
yield* add(pkg)
|
||||
yield* add(pkg)
|
||||
|
||||
const resolved = yield* pick()
|
||||
if (Option.isNone(resolved)) return Option.none<string>()
|
||||
return Option.some(path.join(binDir, resolved.value))
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.orElseSucceed(() => Option.none<string>()),
|
||||
const resolved = yield* pick()
|
||||
if (Option.isNone(resolved)) return Option.none<string>()
|
||||
return Option.some(path.join(binDir, resolved.value))
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.orElseSucceed(() => Option.none<string>()),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -261,14 +262,9 @@ export async function install(...args: Parameters<Interface["install"]>) {
|
||||
}
|
||||
|
||||
export async function add(...args: Parameters<Interface["add"]>) {
|
||||
const entry = await runPromise((svc) => svc.add(...args))
|
||||
return {
|
||||
directory: entry.directory,
|
||||
entrypoint: Option.getOrUndefined(entry.entrypoint),
|
||||
}
|
||||
return runPromise((svc) => svc.add(...args))
|
||||
}
|
||||
|
||||
export async function which(...args: Parameters<Interface["which"]>) {
|
||||
const resolved = await runPromise((svc) => svc.which(...args))
|
||||
return Option.getOrUndefined(resolved)
|
||||
return runPromise((svc) => svc.which(...args))
|
||||
}
|
||||
|
||||
@@ -12,5 +12,5 @@ export const Rule = Schema.Struct({
|
||||
}).annotate({ identifier: "PermissionV2.Rule" })
|
||||
export type Rule = typeof Rule.Type
|
||||
|
||||
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" })
|
||||
export const Ruleset = Schema.mutable(Schema.Array(Rule)).annotate({ identifier: "PermissionV2.Ruleset" })
|
||||
export type Ruleset = typeof Ruleset.Type
|
||||
|
||||
+77
-151
@@ -1,12 +1,18 @@
|
||||
export * as PluginV2 from "./plugin"
|
||||
|
||||
import { createDraft, finishDraft, type Draft } from "immer"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Context, Effect, Exit, Layer, Schema, Scope } from "effect"
|
||||
import type { ModelV2 } from "./model"
|
||||
import type { Catalog } from "./catalog"
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { AISDK } from "./aisdk"
|
||||
import { Catalog } from "./catalog"
|
||||
import { CommandV2 } from "./command"
|
||||
import { EventV2 } from "./event"
|
||||
import { Integration } from "./integration"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { PluginHost } from "./plugin/host"
|
||||
import { Reference } from "./reference"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { State } from "./state"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
@@ -20,71 +26,9 @@ export const Event = {
|
||||
}),
|
||||
}
|
||||
|
||||
type HookSpec = {
|
||||
"catalog.transform": {
|
||||
input: Catalog.Editor
|
||||
output: {}
|
||||
}
|
||||
"aisdk.language": {
|
||||
input: {
|
||||
model: ModelV2.Info
|
||||
sdk: any
|
||||
options: Record<string, any>
|
||||
}
|
||||
output: {
|
||||
language?: LanguageModelV3
|
||||
}
|
||||
}
|
||||
"aisdk.sdk": {
|
||||
input: {
|
||||
model: ModelV2.Info
|
||||
package: string
|
||||
options: Record<string, any>
|
||||
}
|
||||
output: {
|
||||
sdk?: any
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type Hooks = {
|
||||
[Name in keyof HookSpec]: Readonly<HookSpec[Name]["input"]> & {
|
||||
-readonly [Field in keyof HookSpec[Name]["output"]]: HookSpec[Name]["output"][Field] extends object
|
||||
? Draft<HookSpec[Name]["output"][Field]>
|
||||
: HookSpec[Name]["output"][Field]
|
||||
}
|
||||
}
|
||||
|
||||
export type HookFunctions = {
|
||||
[key in keyof Hooks]?: (input: Hooks[key]) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export type HookInput<Name extends keyof Hooks> = HookSpec[Name]["input"]
|
||||
export type HookOutput<Name extends keyof Hooks> = HookSpec[Name]["output"]
|
||||
|
||||
export type Effect<R = never> = Effect.Effect<HookFunctions | void, never, R | Scope.Scope>
|
||||
|
||||
export function define<R>(input: { id: ID; effect: Effect.Effect<HookFunctions | void, never, R> }) {
|
||||
return input
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly add: (input: {
|
||||
id: ID
|
||||
effect: Effect.Effect<void | HookFunctions, never, Scope.Scope>
|
||||
}) => Effect.Effect<void, never, never>
|
||||
readonly add: (id: ID, effect: Plugin["effect"]) => Effect.Effect<void>
|
||||
readonly remove: (id: ID) => Effect.Effect<void>
|
||||
readonly triggerFor: <Name extends keyof Hooks>(
|
||||
id: ID,
|
||||
name: Name,
|
||||
input: HookInput<Name>,
|
||||
output: HookOutput<Name>,
|
||||
) => Effect.Effect<HookInput<Name> & HookOutput<Name>>
|
||||
readonly trigger: <Name extends keyof Hooks>(
|
||||
name: Name,
|
||||
input: HookInput<Name>,
|
||||
output: HookOutput<Name>,
|
||||
) => Effect.Effect<HookInput<Name> & HookOutput<Name>>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Plugin") {}
|
||||
@@ -92,95 +36,77 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
let hooks: {
|
||||
id: ID
|
||||
hooks: HookFunctions
|
||||
scope: Scope.Closeable
|
||||
}[] = []
|
||||
const events = yield* EventV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const locks = KeyedMutex.makeUnsafe<ID>()
|
||||
const scope = yield* Scope.make()
|
||||
const active = new Map<ID, Scope.Closeable>()
|
||||
const loading = new Set<ID>()
|
||||
let host: Parameters<Plugin["effect"]>[0]
|
||||
|
||||
const svc = Service.of({
|
||||
add: Effect.fn("Plugin.add")(function* (input) {
|
||||
yield* locks.withLock(input.id)(
|
||||
Effect.gen(function* () {
|
||||
const existing = hooks.find((item) => item.id === input.id)
|
||||
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
|
||||
const childScope = yield* Scope.fork(scope)
|
||||
const result = yield* input.effect.pipe(
|
||||
Scope.provide(childScope),
|
||||
Effect.withSpan("Plugin.load", {
|
||||
attributes: {
|
||||
"plugin.id": input.id,
|
||||
},
|
||||
const add = Effect.fn("Plugin.add")(function* (id: ID, effect: Plugin["effect"]) {
|
||||
if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`)
|
||||
|
||||
yield* locks.withLock(id)(
|
||||
Effect.sync(() => loading.add(id)).pipe(
|
||||
Effect.andThen(
|
||||
State.batch(
|
||||
Effect.gen(function* () {
|
||||
const existing = active.get(id)
|
||||
active.delete(id)
|
||||
if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore)
|
||||
|
||||
const child = yield* Scope.fork(scope)
|
||||
yield* effect(host).pipe(
|
||||
Scope.provide(child),
|
||||
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||
)
|
||||
active.set(id, child)
|
||||
yield* events.publish(Event.Added, { id })
|
||||
}),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(childScope, exit) : Effect.void)),
|
||||
)
|
||||
hooks = [
|
||||
...hooks.filter((item) => item.id !== input.id),
|
||||
{
|
||||
id: input.id,
|
||||
hooks: result ?? {},
|
||||
scope: childScope,
|
||||
},
|
||||
]
|
||||
yield* events.publish(Event.Added, { id: input.id })
|
||||
}),
|
||||
)
|
||||
}),
|
||||
trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) {
|
||||
return yield* svc.triggerFor(ID.make("*"), name, input, output)
|
||||
}),
|
||||
triggerFor: Effect.fn("Plugin.triggerFor")(function* (id, name, input, output) {
|
||||
const draftEntries = new Map<string, ReturnType<typeof createDraft>>()
|
||||
const event = {
|
||||
...input,
|
||||
...output,
|
||||
} as Record<string, unknown>
|
||||
|
||||
for (const [field, value] of Object.entries(output)) {
|
||||
if (value && typeof value === "object") {
|
||||
draftEntries.set(field, createDraft(value))
|
||||
event[field] = draftEntries.get(field)
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of hooks) {
|
||||
if (id !== ID.make("*") && item.id !== id) continue
|
||||
const match = item.hooks[name]
|
||||
if (!match) continue
|
||||
yield* match(event as any).pipe(
|
||||
Effect.withSpan(`Plugin.hook.${name}`, {
|
||||
attributes: {
|
||||
plugin: item.id,
|
||||
hook: name,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const [field, draft] of draftEntries) {
|
||||
event[field] = finishDraft(draft)
|
||||
}
|
||||
|
||||
return event as any
|
||||
}),
|
||||
remove: Effect.fn("Plugin.remove")(function* (id) {
|
||||
yield* locks.withLock(id)(
|
||||
Effect.gen(function* () {
|
||||
const existing = hooks.find((item) => item.id === id)
|
||||
hooks = hooks.filter((item) => item.id !== id)
|
||||
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.ensuring(Effect.sync(() => loading.delete(id))),
|
||||
),
|
||||
)
|
||||
})
|
||||
return svc
|
||||
|
||||
const remove = Effect.fn("Plugin.remove")(function* (id: ID) {
|
||||
if (loading.has(id)) return yield* Effect.die(`Cannot remove plugin ${id} while it is loading`)
|
||||
|
||||
yield* locks.withLock(id)(
|
||||
State.batch(
|
||||
Effect.gen(function* () {
|
||||
const current = active.get(id)
|
||||
active.delete(id)
|
||||
if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
yield* Effect.addFinalizer((exit) =>
|
||||
Effect.gen(function* () {
|
||||
active.clear()
|
||||
yield* State.batch(Scope.close(scope, exit))
|
||||
}),
|
||||
)
|
||||
|
||||
const service = Service.of({
|
||||
add,
|
||||
remove,
|
||||
})
|
||||
host = yield* PluginHost.make(service)
|
||||
return service
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
// opencode
|
||||
// sdcok
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(AgentV2.locationLayer),
|
||||
Layer.provideMerge(AISDK.locationLayer),
|
||||
Layer.provideMerge(Catalog.locationLayer),
|
||||
Layer.provideMerge(CommandV2.locationLayer),
|
||||
Layer.provideMerge(Integration.locationLayer),
|
||||
Layer.provideMerge(Reference.locationLayer),
|
||||
Layer.provideMerge(SkillV2.locationLayer),
|
||||
)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export * as AgentPlugin from "./agent"
|
||||
|
||||
import path from "path"
|
||||
import { define } from "./internal"
|
||||
import { Effect } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Global } from "../global"
|
||||
import { Location } from "../location"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { PluginV2 } from "../plugin"
|
||||
|
||||
const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*")
|
||||
const BUILD_SYSTEM =
|
||||
@@ -97,10 +97,9 @@ Rules:
|
||||
- If the conversation ends with an unanswered question to the user, preserve that exact question
|
||||
- If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary`
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("agent"),
|
||||
effect: Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
export const Plugin = define({
|
||||
id: "agent",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const location = yield* Location.Service
|
||||
const worktree = location.directory
|
||||
const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")]
|
||||
@@ -122,8 +121,8 @@ export const Plugin = PluginV2.define({
|
||||
{ action: "read", resource: "*.env.example", effect: "allow" },
|
||||
]
|
||||
|
||||
yield* agent.update((editor) => {
|
||||
editor.update(AgentV2.defaultID, (item) => {
|
||||
yield* ctx.agent.transform((draft) => {
|
||||
draft.update(AgentV2.defaultID, (item) => {
|
||||
item.description = "The default agent. Executes tools based on configured permissions."
|
||||
item.system ??= BUILD_SYSTEM
|
||||
item.mode = "primary"
|
||||
@@ -135,7 +134,7 @@ export const Plugin = PluginV2.define({
|
||||
)
|
||||
})
|
||||
|
||||
editor.update(AgentV2.ID.make("plan"), (item) => {
|
||||
draft.update(AgentV2.ID.make("plan"), (item) => {
|
||||
item.description = "Plan mode. Disallows all edit tools."
|
||||
item.mode = "primary"
|
||||
item.permissions.push(
|
||||
@@ -154,14 +153,14 @@ export const Plugin = PluginV2.define({
|
||||
)
|
||||
})
|
||||
|
||||
editor.update(AgentV2.ID.make("general"), (item) => {
|
||||
draft.update(AgentV2.ID.make("general"), (item) => {
|
||||
item.description =
|
||||
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
|
||||
item.mode = "subagent"
|
||||
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "todowrite", resource: "*", effect: "deny" }]))
|
||||
})
|
||||
|
||||
editor.update(AgentV2.ID.make("explore"), (item) => {
|
||||
draft.update(AgentV2.ID.make("explore"), (item) => {
|
||||
item.description =
|
||||
'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
|
||||
item.system = PROMPT_EXPLORE
|
||||
@@ -182,21 +181,21 @@ export const Plugin = PluginV2.define({
|
||||
)
|
||||
})
|
||||
|
||||
editor.update(AgentV2.ID.make("compaction"), (item) => {
|
||||
draft.update(AgentV2.ID.make("compaction"), (item) => {
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_COMPACTION
|
||||
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
|
||||
})
|
||||
|
||||
editor.update(AgentV2.ID.make("title"), (item) => {
|
||||
draft.update(AgentV2.ID.make("title"), (item) => {
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_TITLE
|
||||
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
|
||||
})
|
||||
|
||||
editor.update(AgentV2.ID.make("summary"), (item) => {
|
||||
draft.update(AgentV2.ID.make("summary"), (item) => {
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_SUMMARY
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
export * as PluginBoot from "./boot"
|
||||
|
||||
import { Context, Deferred, Effect, Layer } from "effect"
|
||||
import { Credential } from "../credential"
|
||||
import { Integration } from "../integration"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
import { CommandV2 } from "../command"
|
||||
import { Config } from "../config"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference"
|
||||
import { EventV2 } from "../event"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Global } from "../global"
|
||||
import { Location } from "../location"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { Npm } from "../npm"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
import { SkillPlugin } from "./skill"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { Reference } from "../reference"
|
||||
|
||||
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 {
|
||||
readonly wait: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginBoot") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const commands = yield* CommandV2.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const npm = yield* Npm.Service
|
||||
const events = yield* EventV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const references = yield* Reference.Service
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
|
||||
yield* plugin.add({
|
||||
id: input.id,
|
||||
effect: input.effect.pipe(
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(CommandV2.Service, commands),
|
||||
Effect.provideService(Credential.Service, credentials),
|
||||
Effect.provideService(Integration.Service, integrations),
|
||||
Effect.provideService(AgentV2.Service, agents),
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Location.Service, location),
|
||||
Effect.provideService(ModelsDev.Service, modelsDev),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(SkillV2.Service, skill),
|
||||
Effect.provideService(Reference.Service, references),
|
||||
Effect.provideService(PluginV2.Service, plugin),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
const boot = Effect.gen(function* () {
|
||||
yield* add(AgentPlugin.Plugin)
|
||||
yield* add(CommandPlugin.Plugin)
|
||||
yield* add(SkillPlugin.Plugin)
|
||||
for (const item of ProviderPlugins) {
|
||||
yield* add(item)
|
||||
}
|
||||
yield* add(ModelsDevPlugin)
|
||||
yield* add(ConfigProviderPlugin.Plugin)
|
||||
yield* add(ConfigAgentPlugin.Plugin)
|
||||
yield* add(ConfigCommandPlugin.Plugin)
|
||||
yield* add(ConfigSkillPlugin.Plugin)
|
||||
yield* add(ConfigReferencePlugin.Plugin)
|
||||
}).pipe(Effect.withSpan("PluginBoot.boot"))
|
||||
|
||||
yield* boot.pipe(
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => Deferred.done(done, exit)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
wait: () => Deferred.await(done),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(Integration.locationLayer),
|
||||
Layer.provideMerge(Catalog.locationLayer),
|
||||
Layer.provideMerge(CommandV2.locationLayer),
|
||||
Layer.provideMerge(Config.locationLayer),
|
||||
Layer.provideMerge(AgentV2.locationLayer),
|
||||
Layer.provideMerge(SkillV2.locationLayer),
|
||||
Layer.provideMerge(Reference.locationLayer),
|
||||
)
|
||||
@@ -1,25 +1,21 @@
|
||||
export * as CommandPlugin from "./command"
|
||||
|
||||
import { define } from "./internal"
|
||||
import { Effect } from "effect"
|
||||
import { CommandV2 } from "../command"
|
||||
import { Location } from "../location"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import PROMPT_INITIALIZE from "./command/initialize.txt"
|
||||
import PROMPT_REVIEW from "./command/review.txt"
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("command"),
|
||||
effect: Effect.gen(function* () {
|
||||
const command = yield* CommandV2.Service
|
||||
export const Plugin = define({
|
||||
id: "command",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const location = yield* Location.Service
|
||||
const transform = yield* command.transform()
|
||||
|
||||
yield* transform((editor) => {
|
||||
editor.update("init", (command) => {
|
||||
yield* ctx.command.transform((draft) => {
|
||||
draft.update("init", (command) => {
|
||||
command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory)
|
||||
command.description = "guided AGENTS.md setup"
|
||||
})
|
||||
editor.update("review", (command) => {
|
||||
draft.update("review", (command) => {
|
||||
command.template = PROMPT_REVIEW.replace("${path}", location.project.directory)
|
||||
command.description = "review changes [commit|branch|pr], defaults to uncommitted"
|
||||
command.subtask = true
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
export * as PluginHost from "./host"
|
||||
|
||||
import type { PluginContext as Interface } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { AISDK } from "../aisdk"
|
||||
import { Catalog } from "../catalog"
|
||||
import { CommandV2 } from "../command"
|
||||
import { Integration } from "../integration"
|
||||
import { ModelV2 } from "../model"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { ProviderV2 } from "../provider"
|
||||
import { Reference } from "../reference"
|
||||
import { SkillV2 } from "../skill"
|
||||
|
||||
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
|
||||
const agents = yield* AgentV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const commands = yield* CommandV2.Service
|
||||
const integration = yield* Integration.Service
|
||||
const reference = yield* Reference.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
|
||||
return {
|
||||
options: {},
|
||||
agent: {
|
||||
reload: agents.reload,
|
||||
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: {
|
||||
sdk: (callback) =>
|
||||
aisdk.hook.sdk((event) => {
|
||||
const output = {
|
||||
model: event.model,
|
||||
package: event.package,
|
||||
options: event.options,
|
||||
sdk: event.sdk,
|
||||
}
|
||||
const result = callback(output)
|
||||
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
|
||||
Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))),
|
||||
)
|
||||
}),
|
||||
language: (callback) =>
|
||||
aisdk.hook.language((event) => {
|
||||
const output = {
|
||||
model: event.model,
|
||||
sdk: event.sdk,
|
||||
options: event.options,
|
||||
language: event.language,
|
||||
}
|
||||
const result = callback(output)
|
||||
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
|
||||
Effect.tap(() => Effect.sync(() => (event.language = output.language))),
|
||||
)
|
||||
}),
|
||||
},
|
||||
catalog: {
|
||||
reload: catalog.reload,
|
||||
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: {
|
||||
reload: commands.reload,
|
||||
transform: commands.transform,
|
||||
},
|
||||
integration: {
|
||||
reload: integration.reload,
|
||||
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)),
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
plugin: {
|
||||
add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect),
|
||||
remove: (id) => plugin.remove(PluginV2.ID.make(id)),
|
||||
},
|
||||
reference: {
|
||||
reload: reference.reload,
|
||||
transform: (callback) =>
|
||||
reference.transform((draft) =>
|
||||
callback({
|
||||
add: (name, source) => draft.add(name, Schema.decodeUnknownSync(Reference.Source)(source)),
|
||||
remove: draft.remove,
|
||||
list: draft.list,
|
||||
}),
|
||||
),
|
||||
},
|
||||
skill: {
|
||||
reload: skill.reload,
|
||||
transform: (callback) =>
|
||||
skill.transform((draft) =>
|
||||
callback({
|
||||
source: (source) => draft.source(Schema.decodeUnknownSync(SkillV2.Source)(source)),
|
||||
list: draft.list,
|
||||
}),
|
||||
),
|
||||
},
|
||||
} satisfies Interface
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
export * as PluginInternal from "./internal"
|
||||
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, Layer, Scope } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
import { CommandV2 } from "../command"
|
||||
import { Config } from "../config"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command"
|
||||
import { ConfigExternalPlugin } from "../config/plugin/external"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
import { EventV2 } from "../event"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Global } from "../global"
|
||||
import { Integration } from "../integration"
|
||||
import { Location } from "../location"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { Npm } from "../npm"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { Reference } from "../reference"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { SkillPlugin } from "./skill"
|
||||
|
||||
export type Requirements =
|
||||
| AgentV2.Service
|
||||
| Catalog.Service
|
||||
| CommandV2.Service
|
||||
| Config.Service
|
||||
| EventV2.Service
|
||||
| FileSystem.Service
|
||||
| FSUtil.Service
|
||||
| Global.Service
|
||||
| Integration.Service
|
||||
| Location.Service
|
||||
| ModelsDev.Service
|
||||
| Npm.Service
|
||||
| Reference.Service
|
||||
| SkillV2.Service
|
||||
|
||||
export interface Plugin<R = never> {
|
||||
readonly id: string
|
||||
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R | Scope.Scope>
|
||||
}
|
||||
|
||||
export function define<R>(plugin: Plugin<R>) {
|
||||
return plugin
|
||||
}
|
||||
|
||||
export const locationLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const commands = yield* CommandV2.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const integration = yield* Integration.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const npm = yield* Npm.Service
|
||||
const events = yield* EventV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const global = yield* Global.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const reference = yield* Reference.Service
|
||||
const add = <R>(input: Plugin<R>) => {
|
||||
const loaded = {
|
||||
id: input.id,
|
||||
effect: (context: PluginContext) =>
|
||||
input
|
||||
.effect(context)
|
||||
.pipe(
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(CommandV2.Service, commands),
|
||||
Effect.provideService(Integration.Service, integration),
|
||||
Effect.provideService(AgentV2.Service, agents),
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Location.Service, location),
|
||||
Effect.provideService(ModelsDev.Service, modelsDev),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(FileSystem.Service, filesystem),
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(SkillV2.Service, skill),
|
||||
Effect.provideService(Reference.Service, reference),
|
||||
),
|
||||
}
|
||||
return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
|
||||
}
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
yield* add(AgentPlugin.Plugin)
|
||||
yield* add(CommandPlugin.Plugin)
|
||||
yield* add(SkillPlugin.Plugin)
|
||||
yield* add(ModelsDevPlugin)
|
||||
yield* add(ConfigProviderPlugin.Plugin)
|
||||
yield* add(ConfigAgentPlugin.Plugin)
|
||||
yield* add(ConfigCommandPlugin.Plugin)
|
||||
yield* add(ConfigSkillPlugin.Plugin)
|
||||
yield* add(ConfigReferencePlugin.Plugin)
|
||||
for (const item of ProviderPlugins) yield* add(item)
|
||||
yield* add(ConfigExternalPlugin.Plugin)
|
||||
}).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provideMerge(PluginV2.locationLayer),
|
||||
Layer.provideMerge(Config.locationLayer),
|
||||
Layer.provideMerge(FileSystem.locationLayer),
|
||||
)
|
||||
@@ -1,16 +1,14 @@
|
||||
import { DateTime, Effect, Scope, Stream } from "effect"
|
||||
import { Catalog } from "../catalog"
|
||||
import { Integration } from "../integration"
|
||||
import { define } from "./internal"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { EventV2 } from "../event"
|
||||
import { ModelV2 } from "../model"
|
||||
import { ModelRequest } from "../model-request"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { ProviderV2 } from "../provider"
|
||||
|
||||
function released(date: string) {
|
||||
const time = Date.parse(date)
|
||||
return DateTime.makeUnsafe(Number.isFinite(time) ? time : 0)
|
||||
return Number.isFinite(time) ? time : 0
|
||||
}
|
||||
|
||||
function cost(input: ModelsDev.Model["cost"]) {
|
||||
@@ -51,22 +49,17 @@ function variants(model: ModelsDev.Model, packageName?: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export const ModelsDevPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("models-dev"),
|
||||
effect: Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
export const ModelsDevPlugin = define({
|
||||
id: "models-dev",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const events = yield* EventV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const transform = yield* catalog.transform()
|
||||
const integrationTransform = yield* integrations.transform()
|
||||
const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () {
|
||||
const data = yield* modelsDev.get()
|
||||
yield* integrationTransform((integrations) => {
|
||||
yield* ctx.integration.transform(
|
||||
Effect.fn(function* (integrations) {
|
||||
const data = yield* modelsDev.get()
|
||||
for (const item of Object.values(data)) {
|
||||
if (item.env.length === 0) continue
|
||||
const integrationID = Integration.ID.make(item.id)
|
||||
const integrationID = item.id
|
||||
integrations.update(integrationID, (integration) => (integration.name = item.name))
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
@@ -77,8 +70,11 @@ export const ModelsDevPlugin = PluginV2.define({
|
||||
method: { type: "env", names: [...item.env] },
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* transform((catalog) => {
|
||||
}),
|
||||
)
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (catalog) {
|
||||
const data = yield* modelsDev.get()
|
||||
for (const item of Object.values(data)) {
|
||||
const providerID = ProviderV2.ID.make(item.id)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
@@ -132,11 +128,10 @@ export const ModelsDevPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
yield* refresh()
|
||||
}),
|
||||
)
|
||||
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
|
||||
Stream.runForEach(() => refresh()),
|
||||
Stream.runForEach(() => ctx.integration.reload().pipe(Effect.andThen(ctx.catalog.reload()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
export * as PluginPromise from "./promise"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Plugin, PluginContext, Registration } from "@opencode-ai/plugin/v2/promise"
|
||||
import { Effect, Scope } from "effect"
|
||||
|
||||
// The Effect host hands back this registration shape; mirror it structurally so
|
||||
// we do not have to alias the Effect package's `Registration` against the Promise one.
|
||||
type HostRegistration = { readonly dispose: Effect.Effect<void> }
|
||||
|
||||
/**
|
||||
* Adapts a Promise plugin into an Effect plugin so the existing Effect-only
|
||||
* loader (`PluginV2` / `PluginInternal`) can run it unchanged.
|
||||
*
|
||||
* Hook registrations created during the async `setup` attach to the plugin's
|
||||
* scope, so unloading the plugin disposes them. The captured fiber context
|
||||
* preserves boot-time batching, so Promise-plugin transforms still coalesce
|
||||
* into one reload per domain.
|
||||
*/
|
||||
export function fromPromise(plugin: Plugin) {
|
||||
return define({
|
||||
id: plugin.id,
|
||||
effect: (host) =>
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
const context = yield* Effect.context<Scope.Scope>()
|
||||
|
||||
// Run a hook registration on the plugin scope and resolve once it is registered.
|
||||
const register = (effect: Effect.Effect<HostRegistration, never, Scope.Scope>): Promise<Registration> =>
|
||||
Effect.runPromiseWith(context)(Scope.provide(scope)(effect)).then((registration) => ({
|
||||
dispose: () => Effect.runPromiseWith(context)(registration.dispose),
|
||||
}))
|
||||
|
||||
const run = (effect: Effect.Effect<void>) => Effect.runPromiseWith(context)(effect)
|
||||
|
||||
const transform =
|
||||
<Draft>(domain: {
|
||||
transform: (
|
||||
callback: (draft: Draft) => Effect.Effect<void> | void,
|
||||
) => Effect.Effect<HostRegistration, never, Scope.Scope>
|
||||
}) =>
|
||||
(callback: (draft: Draft) => Promise<void> | void) =>
|
||||
register(domain.transform((draft) => Effect.promise(() => Promise.resolve(callback(draft)))))
|
||||
|
||||
const context2: PluginContext = {
|
||||
options: host.options,
|
||||
agent: {
|
||||
transform: transform(host.agent),
|
||||
reload: () => run(host.agent.reload()),
|
||||
},
|
||||
aisdk: {
|
||||
sdk: (callback) =>
|
||||
register(host.aisdk.sdk((event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
language: (callback) =>
|
||||
register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
},
|
||||
catalog: {
|
||||
transform: transform(host.catalog),
|
||||
reload: () => run(host.catalog.reload()),
|
||||
},
|
||||
command: {
|
||||
transform: transform(host.command),
|
||||
reload: () => run(host.command.reload()),
|
||||
},
|
||||
integration: {
|
||||
transform: transform(host.integration),
|
||||
reload: () => run(host.integration.reload()),
|
||||
},
|
||||
plugin: {
|
||||
add: (input) => {
|
||||
const child = fromPromise(input)
|
||||
return run(host.plugin.add(child))
|
||||
},
|
||||
remove: (id) => run(host.plugin.remove(id)),
|
||||
},
|
||||
reference: {
|
||||
transform: transform(host.reference),
|
||||
reload: () => run(host.reference.reload()),
|
||||
},
|
||||
skill: {
|
||||
transform: transform(host.skill),
|
||||
reload: () => run(host.skill.reload()),
|
||||
},
|
||||
}
|
||||
|
||||
yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -30,8 +30,10 @@ import { VercelPlugin } from "./provider/vercel"
|
||||
import { VenicePlugin } from "./provider/venice"
|
||||
import { XAIPlugin } from "./provider/xai"
|
||||
import { ZenmuxPlugin } from "./provider/zenmux"
|
||||
import type { PluginInternal } from "./internal"
|
||||
import type { Scope } from "effect"
|
||||
|
||||
export const ProviderPlugins = [
|
||||
export const ProviderPlugins: PluginInternal.Plugin<PluginInternal.Requirements | Scope.Scope>[] = [
|
||||
AlibabaPlugin,
|
||||
AmazonBedrockPlugin,
|
||||
AnthropicPlugin,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const AlibabaPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("alibaba"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const AlibabaPlugin = define({
|
||||
id: "alibaba",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/alibaba") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba"))
|
||||
evt.sdk = mod.createAlibaba(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
type MantleSDK = {
|
||||
@@ -59,11 +59,11 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
|
||||
return sdk.responses(modelID)
|
||||
}
|
||||
|
||||
export const AmazonBedrockPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("amazon-bedrock"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const AmazonBedrockPlugin = define({
|
||||
id: "amazon-bedrock",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue
|
||||
@@ -77,7 +77,9 @@ export const AmazonBedrockPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return
|
||||
const options = { ...evt.options }
|
||||
const profile = typeof options.profile === "string" ? options.profile : process.env.AWS_PROFILE
|
||||
@@ -108,7 +110,9 @@ export const AmazonBedrockPlugin = PluginV2.define({
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock"))
|
||||
evt.sdk = mod.createAmazonBedrock(options)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return
|
||||
if (evt.model.api.type === "aisdk" && evt.model.api.package === "@ai-sdk/amazon-bedrock/mantle") {
|
||||
evt.language = selectMantleModel(evt.sdk, evt.model.api.id)
|
||||
@@ -117,6 +121,6 @@ export const AmazonBedrockPlugin = PluginV2.define({
|
||||
const region = typeof evt.options.region === "string" ? evt.options.region : process.env.AWS_REGION
|
||||
evt.language = evt.sdk.languageModel(resolveModelID(evt.model.api.id, region))
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const AnthropicPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("anthropic"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const AnthropicPlugin = define({
|
||||
id: "anthropic",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/anthropic") continue
|
||||
@@ -15,11 +15,13 @@ export const AnthropicPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/anthropic") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic"))
|
||||
evt.sdk = mod.createAnthropic(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
|
||||
@@ -10,11 +10,11 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
|
||||
return sdk.languageModel(modelID)
|
||||
}
|
||||
|
||||
export const AzurePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("azure"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const AzurePlugin = define({
|
||||
id: "azure",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/azure") continue
|
||||
@@ -27,7 +27,9 @@ export const AzurePlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/azure") return
|
||||
if (evt.model.providerID === ProviderV2.ID.azure) {
|
||||
if (
|
||||
@@ -43,19 +45,21 @@ export const AzurePlugin = PluginV2.define({
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/azure"))
|
||||
evt.sdk = mod.createAzure(evt.options)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.azure) return
|
||||
evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls))
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
export const AzureCognitiveServicesPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("azure-cognitive-services"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const AzureCognitiveServicesPlugin = define({
|
||||
id: "azure-cognitive-services",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
|
||||
if (!resourceName) return
|
||||
for (const item of evt.provider.list()) {
|
||||
@@ -67,10 +71,12 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
|
||||
evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls))
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const CerebrasPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("cerebras"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (ctx) {
|
||||
for (const item of ctx.provider.list()) {
|
||||
export const CerebrasPlugin = define({
|
||||
id: "cerebras",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/cerebras") continue
|
||||
ctx.provider.update(item.provider.id, (provider) => {
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/cerebras") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras"))
|
||||
evt.sdk = mod.createCerebras(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import os from "os"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const CloudflareAIGatewayPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("cloudflare-ai-gateway"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const CloudflareAIGatewayPlugin = define({
|
||||
id: "cloudflare-ai-gateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "ai-gateway-provider") return
|
||||
if (evt.options.baseURL) return
|
||||
|
||||
@@ -31,7 +31,7 @@ export const CloudflareAIGatewayPlugin = PluginV2.define({
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import os from "os"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
const providerID = ProviderV2.ID.make("cloudflare-workers-ai")
|
||||
|
||||
export const CloudflareWorkersAIPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("cloudflare-workers-ai"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const CloudflareWorkersAIPlugin = define({
|
||||
id: "cloudflare-workers-ai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
const item = evt.provider.get(providerID)
|
||||
if (!item) return
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
@@ -20,7 +20,9 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({
|
||||
if (accountId) provider.api.url = workersEndpoint(accountId)
|
||||
})
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== providerID) return
|
||||
if (evt.package !== "@ai-sdk/openai-compatible") return
|
||||
|
||||
@@ -34,11 +36,13 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({
|
||||
}) as any,
|
||||
)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== providerID) return
|
||||
evt.language = evt.sdk.languageModel(evt.model.api.id)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const CoherePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("cohere"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const CoherePlugin = define({
|
||||
id: "cohere",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/cohere") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/cohere"))
|
||||
evt.sdk = mod.createCohere(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const DeepInfraPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("deepinfra"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const DeepInfraPlugin = define({
|
||||
id: "deepinfra",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/deepinfra") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra"))
|
||||
evt.sdk = mod.createDeepInfra(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Npm } from "../../npm"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
import { Npm } from "../../npm"
|
||||
|
||||
export const DynamicProviderPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("dynamic-provider"),
|
||||
effect: Effect.gen(function* () {
|
||||
export const DynamicProviderPlugin = define({
|
||||
id: "dynamic-provider",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const npm = yield* Npm.Service
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.sdk) return
|
||||
|
||||
const installedPath = evt.package.startsWith("file://")
|
||||
? evt.package
|
||||
: Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint)
|
||||
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
|
||||
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
|
||||
|
||||
const mod = yield* Effect.promise(async () => {
|
||||
@@ -26,6 +26,6 @@ export const DynamicProviderPlugin = PluginV2.define({
|
||||
|
||||
evt.sdk = mod[match](evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const GatewayPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("gateway"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const GatewayPlugin = define({
|
||||
id: "gateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/gateway") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/gateway"))
|
||||
evt.sdk = mod.createGateway(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
function shouldUseResponses(modelID: string) {
|
||||
@@ -11,16 +11,29 @@ function shouldUseResponses(modelID: string) {
|
||||
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
|
||||
}
|
||||
|
||||
export const GithubCopilotPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("github-copilot"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const GithubCopilotPlugin = define({
|
||||
id: "github-copilot",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
|
||||
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
|
||||
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
|
||||
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
|
||||
// so hide it only for Copilot rather than for every provider catalog.
|
||||
model.enabled = false
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/github-copilot") return
|
||||
const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider"))
|
||||
evt.sdk = mod.createOpenaiCompatible(evt.options)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
|
||||
if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) {
|
||||
evt.language = evt.sdk.languageModel(evt.model.api.id)
|
||||
@@ -30,15 +43,6 @@ export const GithubCopilotPlugin = PluginV2.define({
|
||||
? evt.sdk.responses(evt.model.api.id)
|
||||
: evt.sdk.chat(evt.model.api.id)
|
||||
}),
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
|
||||
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
|
||||
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
|
||||
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
|
||||
// so hide it only for Copilot rather than for every provider catalog.
|
||||
model.enabled = false
|
||||
})
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import os from "os"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const GitLabPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("gitlab"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const GitLabPlugin = define({
|
||||
id: "gitlab",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "gitlab-ai-provider") return
|
||||
const mod = yield* Effect.promise(() => import("gitlab-ai-provider"))
|
||||
evt.sdk = mod.createGitLab({
|
||||
@@ -30,7 +30,9 @@ export const GitLabPlugin = PluginV2.define({
|
||||
},
|
||||
})
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.gitlab) return
|
||||
const featureFlags =
|
||||
typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {}
|
||||
@@ -58,6 +60,6 @@ export const GitLabPlugin = PluginV2.define({
|
||||
featureFlags,
|
||||
})
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
function resolveProject(options: Record<string, any>) {
|
||||
@@ -54,11 +54,11 @@ function authFetch(fetchWithRuntimeOptions?: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
export const GoogleVertexPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("google-vertex"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const GoogleVertexPlugin = define({
|
||||
id: "google-vertex",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (
|
||||
@@ -83,7 +83,9 @@ export const GoogleVertexPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) {
|
||||
evt.options.fetch = authFetch(evt.options.fetch)
|
||||
return
|
||||
@@ -100,19 +102,21 @@ export const GoogleVertexPlugin = PluginV2.define({
|
||||
location,
|
||||
})
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.googleVertex) return
|
||||
evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim())
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
export const GoogleVertexAnthropicPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("google-vertex-anthropic"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const GoogleVertexAnthropicPlugin = define({
|
||||
id: "google-vertex-anthropic",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue
|
||||
@@ -132,7 +136,9 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic"))
|
||||
const project =
|
||||
@@ -156,10 +162,12 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({
|
||||
: {}),
|
||||
})
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return
|
||||
evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim())
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const GooglePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("google"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const GooglePlugin = define({
|
||||
id: "google",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/google") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/google"))
|
||||
evt.sdk = mod.createGoogleGenerativeAI(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const GroqPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("groq"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const GroqPlugin = define({
|
||||
id: "groq",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/groq") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/groq"))
|
||||
evt.sdk = mod.createGroq(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const KiloPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("kilo"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const KiloPlugin = define({
|
||||
id: "kilo",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
@@ -16,6 +16,6 @@ export const KiloPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { Integration } from "../../integration"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
|
||||
export const LLMGatewayPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("llmgateway"),
|
||||
effect: Effect.gen(function* () {
|
||||
export const LLMGatewayPlugin = define({
|
||||
id: "llmgateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const integrations = yield* Integration.Service
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.disabled) continue
|
||||
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
|
||||
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] = "opencode"
|
||||
@@ -21,6 +21,6 @@ export const LLMGatewayPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const MistralPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("mistral"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const MistralPlugin = define({
|
||||
id: "mistral",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/mistral") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/mistral"))
|
||||
evt.sdk = mod.createMistral(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const NvidiaPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("nvidia"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const NvidiaPlugin = define({
|
||||
id: "nvidia",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
@@ -17,6 +17,6 @@ export const NvidiaPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const OpenAICompatiblePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("openai-compatible"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const OpenAICompatiblePlugin = define({
|
||||
id: "openai-compatible",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.sdk) return
|
||||
if (!evt.package.includes("@ai-sdk/openai-compatible")) return
|
||||
if (evt.options.includeUsage !== false) evt.options.includeUsage = true
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
|
||||
evt.sdk = mod.createOpenAICompatible(evt.options as any)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { Integration } from "../../integration"
|
||||
import { browser, headless } from "./openai-auth"
|
||||
|
||||
export const OpenAIPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("openai"),
|
||||
effect: Effect.gen(function* () {
|
||||
export const OpenAIPlugin = define({
|
||||
id: "openai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* integrations.update((editor) => {
|
||||
editor.method.update(browser)
|
||||
editor.method.update(headless)
|
||||
yield* integrations.transform((draft) => {
|
||||
draft.method.update(browser)
|
||||
draft.method.update(headless)
|
||||
})
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/openai") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
|
||||
evt.sdk = mod.createOpenAI(evt.options)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.openai) return
|
||||
evt.language = evt.sdk.responses(evt.model.api.id)
|
||||
}),
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai") continue
|
||||
@@ -35,6 +26,19 @@ export const OpenAIPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
)
|
||||
yield* ctx.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)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.openai) return
|
||||
evt.language = evt.sdk.responses(evt.model.api.id)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Effect } from "effect"
|
||||
import { Integration } from "../../integration"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { Integration } from "../../integration"
|
||||
|
||||
export const OpencodePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("opencode"),
|
||||
effect: Effect.gen(function* () {
|
||||
export const OpencodePlugin = define({
|
||||
id: "opencode",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const integrations = yield* Integration.Service
|
||||
let hasKey = false
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
const item = evt.provider.get(ProviderV2.ID.opencode)
|
||||
if (!item) return
|
||||
const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
|
||||
@@ -27,6 +27,6 @@ export const OpencodePlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const OpenRouterPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("openrouter"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const OpenRouterPlugin = define({
|
||||
id: "openrouter",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue
|
||||
@@ -24,11 +24,13 @@ export const OpenRouterPlugin = PluginV2.define({
|
||||
}
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@openrouter/ai-sdk-provider") return
|
||||
const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider"))
|
||||
evt.sdk = mod.createOpenRouter(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const PerplexityPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("perplexity"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const PerplexityPlugin = define({
|
||||
id: "perplexity",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/perplexity") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity"))
|
||||
evt.sdk = mod.createPerplexity(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Npm } from "../../npm"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
import { Npm } from "../../npm"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const SapAICorePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("sap-ai-core"),
|
||||
effect: Effect.gen(function* () {
|
||||
export const SapAICorePlugin = define({
|
||||
id: "sap-ai-core",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const npm = yield* Npm.Service
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
|
||||
const serviceKey =
|
||||
process.env.AICORE_SERVICE_KEY ??
|
||||
@@ -18,7 +18,7 @@ export const SapAICorePlugin = PluginV2.define({
|
||||
|
||||
const installedPath = evt.package.startsWith("file://")
|
||||
? evt.package
|
||||
: Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint)
|
||||
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
|
||||
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
|
||||
|
||||
const mod = yield* Effect.promise(async () => {
|
||||
@@ -35,10 +35,12 @@ export const SapAICorePlugin = PluginV2.define({
|
||||
: {},
|
||||
)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
|
||||
evt.language = evt.sdk(evt.model.api.id)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
|
||||
@@ -64,11 +64,11 @@ export function cortexFetch(upstream: FetchLike = fetch) {
|
||||
}
|
||||
}
|
||||
|
||||
export const SnowflakeCortexPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("snowflake-cortex"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const SnowflakeCortexPlugin = define({
|
||||
id: "snowflake-cortex",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return
|
||||
const token =
|
||||
process.env.SNOWFLAKE_CORTEX_TOKEN ??
|
||||
@@ -84,6 +84,6 @@ export const SnowflakeCortexPlugin = PluginV2.define({
|
||||
fetch: cortexFetch(upstream) as typeof fetch,
|
||||
} as any)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const TogetherAIPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("togetherai"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const TogetherAIPlugin = define({
|
||||
id: "togetherai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/togetherai") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai"))
|
||||
evt.sdk = mod.createTogetherAI(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const VenicePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("venice"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const VenicePlugin = define({
|
||||
id: "venice",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "venice-ai-sdk-provider") return
|
||||
const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider"))
|
||||
evt.sdk = mod.createVenice(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const VercelPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("vercel"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const VercelPlugin = define({
|
||||
id: "vercel",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/vercel") continue
|
||||
@@ -15,11 +15,13 @@ export const VercelPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/vercel") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/vercel"))
|
||||
evt.sdk = mod.createVercel(evt.options)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const XAIPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("xai"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
export const XAIPlugin = define({
|
||||
id: "xai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/xai") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/xai"))
|
||||
evt.sdk = mod.createXai(evt.options)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("xai")) return
|
||||
evt.language = evt.sdk.responses(evt.model.api.id)
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { define } from "../internal"
|
||||
|
||||
export const ZenmuxPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("zenmux"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
export const ZenmuxPlugin = define({
|
||||
id: "zenmux",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
@@ -16,6 +16,6 @@ export const ZenmuxPlugin = PluginV2.define({
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -2,22 +2,19 @@
|
||||
|
||||
export * as SkillPlugin from "./skill"
|
||||
|
||||
import { define } from "./internal"
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { SkillV2 } from "../skill"
|
||||
import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" }
|
||||
|
||||
export const CustomizeOpencodeContent = customizeOpencodeContent
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("skill"),
|
||||
effect: Effect.gen(function* () {
|
||||
const skill = yield* SkillV2.Service
|
||||
const transform = yield* skill.transform()
|
||||
|
||||
yield* transform((editor) => {
|
||||
editor.source(
|
||||
export const Plugin = define({
|
||||
id: "skill",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.skill.transform((draft) => {
|
||||
draft.source(
|
||||
new SkillV2.EmbeddedSource({
|
||||
type: "embedded",
|
||||
skill: new SkillV2.Info({
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Slug } from "../util/slug"
|
||||
import { EventV2 } from "../event"
|
||||
import { Database } from "../database/database"
|
||||
import { Location } from "../location"
|
||||
import { PluginBoot } from "../plugin/boot"
|
||||
|
||||
export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID"))
|
||||
export type StrategyID = typeof StrategyID.Type
|
||||
@@ -125,10 +124,8 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pr
|
||||
|
||||
export const refreshAfterBoot = Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const boot = yield* PluginBoot.Service
|
||||
const copies = yield* Service
|
||||
yield* Effect.gen(function* () {
|
||||
yield* boot.wait()
|
||||
yield* Effect.logInfo("project copy refresh started", { projectID: location.project.id })
|
||||
const result = yield* copies.refresh({ projectID: location.project.id })
|
||||
yield* Effect.logInfo("project copy refresh done", {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ProviderV2 from "./provider"
|
||||
|
||||
import { withStatics } from "./schema"
|
||||
import { Schema } from "effect"
|
||||
import { Schema, Types } from "effect"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("ProviderV2.ID"),
|
||||
@@ -37,6 +37,9 @@ export const Native = Schema.Struct({
|
||||
|
||||
export const Api = Schema.Union([AISDK, Native]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Api = typeof Api.Type
|
||||
export type MutableApi<T extends Api = Api> = T extends Api
|
||||
? Omit<Types.DeepMutable<T>, "settings"> & (undefined extends T["settings"] ? { settings?: any } : { settings: any })
|
||||
: never
|
||||
|
||||
export const Request = Schema.Struct({
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
@@ -66,3 +69,5 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & { api: MutableApi }
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
export * as OpenCode from "./opencode"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Catalog } from "../catalog"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { LocationServiceMap } from "../location-layer"
|
||||
import { PluginBoot } from "../plugin/boot"
|
||||
import { ProjectV2 } from "../project"
|
||||
import { SessionV2 } from "../session"
|
||||
import * as SessionExecutionLocal from "../session/execution/local"
|
||||
@@ -23,69 +21,22 @@ export interface Interface {
|
||||
/** Intentional public native API for Effect applications embedding OpenCode. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/public/OpenCode") {}
|
||||
|
||||
class SessionModelValidation extends Context.Service<
|
||||
SessionModelValidation,
|
||||
{
|
||||
readonly validate: (
|
||||
input: Session.SwitchModelInput & { readonly location: Session.Info["location"] },
|
||||
) => Effect.Effect<void, Session.ModelUnavailableError | Session.VariantUnavailableError>
|
||||
}
|
||||
>()("@opencode/public/OpenCode/SessionModelValidation") {}
|
||||
|
||||
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 = 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.provide(LocationServiceMap.layer.pipe(Layer.provide(ApplicationTools.layer))),
|
||||
Layer.orDie,
|
||||
)
|
||||
|
||||
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.
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* SessionV2.Service
|
||||
const tools = yield* ApplicationTools.Service
|
||||
const validation = yield* SessionModelValidation
|
||||
return Service.of({
|
||||
tools: { register: tools.register },
|
||||
sessions: {
|
||||
@@ -98,11 +49,7 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
get: sessions.get,
|
||||
list: sessions.list,
|
||||
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)
|
||||
}),
|
||||
switchModel: sessions.switchModel,
|
||||
interrupt: sessions.interrupt,
|
||||
prompt: (input) =>
|
||||
sessions.prompt({
|
||||
@@ -124,6 +71,6 @@ export const layer = Layer.effect(
|
||||
},
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(Layer.merge(ApplicationToolsLayer, SessionsLayer)))
|
||||
).pipe(Layer.provide(Layer.merge(ApplicationTools.layer, SessionsLayer)))
|
||||
|
||||
// TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
export * as Session from "./session"
|
||||
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { EventV2 } from "../event"
|
||||
import { ModelV2 } from "../model"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { SessionV2 } from "../session"
|
||||
import { MessageDecodeError } from "../session/error"
|
||||
import { SessionEvent } from "../session/event"
|
||||
@@ -34,9 +32,7 @@ export type Delivery = SessionInput.Delivery
|
||||
export const ListInput = SessionV2.ListInput
|
||||
export type ListInput = SessionV2.ListInput
|
||||
|
||||
export const EventCursor = EventV2.Cursor
|
||||
export type EventCursor = EventV2.Cursor
|
||||
export type Event = EventV2.CursorEvent<SessionEvent.DurableEvent>
|
||||
export type Event = SessionEvent.DurableEvent
|
||||
|
||||
export const NotFoundError = SessionV2.NotFoundError
|
||||
export type NotFoundError = SessionV2.NotFoundError
|
||||
@@ -44,23 +40,6 @@ export type NotFoundError = SessionV2.NotFoundError
|
||||
export const 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 interface CreateInput {
|
||||
@@ -99,7 +78,7 @@ export interface MessageInput {
|
||||
|
||||
export interface EventsInput {
|
||||
readonly sessionID: ID
|
||||
readonly after?: EventCursor
|
||||
readonly after?: number
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -107,9 +86,7 @@ export interface Interface {
|
||||
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
|
||||
readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
|
||||
readonly switchModel: (
|
||||
input: SwitchModelInput,
|
||||
) => Effect.Effect<void, NotFoundError | ModelUnavailableError | VariantUnavailableError>
|
||||
readonly switchModel: (input: SwitchModelInput) => Effect.Effect<void, NotFoundError>
|
||||
/** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */
|
||||
readonly interrupt: (sessionID: ID) => Effect.Effect<void>
|
||||
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as Reference from "./reference"
|
||||
|
||||
import { Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { castDraft } from "immer"
|
||||
import { Context, Effect, Layer, Schema, Scope, Types } from "effect"
|
||||
import { Global } from "./global"
|
||||
import { EventV2 } from "./event"
|
||||
import { Repository } from "./repository"
|
||||
@@ -40,17 +39,16 @@ export class Info extends Schema.Class<Info>("Reference.Info")({
|
||||
}) {}
|
||||
|
||||
type Data = {
|
||||
sources: Map<string, Source>
|
||||
sources: Map<string, Types.DeepMutable<Source>>
|
||||
}
|
||||
|
||||
type Editor = {
|
||||
type Draft = {
|
||||
add(name: string, source: Source): void
|
||||
remove(name: string): void
|
||||
list(): readonly [string, Source][]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
@@ -64,18 +62,18 @@ export const layer = Layer.effect(
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const materialized = new Map<string, Info>()
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ sources: new Map() }),
|
||||
editor: (draft) => ({
|
||||
add: (name, source) => draft.sources.set(name, castDraft(source)),
|
||||
draft: (draft) => ({
|
||||
add: (name, source) => draft.sources.set(name, source as Types.DeepMutable<Source>),
|
||||
remove: (name) => draft.sources.delete(name),
|
||||
list: () => Array.from(draft.sources.entries()) as [string, Source][],
|
||||
}),
|
||||
finalize: (editor) =>
|
||||
finalize: (draft) =>
|
||||
Effect.gen(function* () {
|
||||
materialized.clear()
|
||||
const seen = new Map<string, string | undefined>()
|
||||
for (const [name, source] of editor.list()) {
|
||||
for (const [name, source] of draft.list()) {
|
||||
if (source.type === "local") {
|
||||
materialized.set(
|
||||
name,
|
||||
@@ -128,6 +126,7 @@ export const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
list: Effect.fn("Reference.list")(function* () {
|
||||
return Array.from(materialized.values())
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as ReferenceGuidance from "./guidance"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { PluginBoot } from "../plugin/boot"
|
||||
import { Reference } from "../reference"
|
||||
import { SystemContext } from "../system-context/index"
|
||||
|
||||
@@ -34,12 +33,10 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const boot = yield* PluginBoot.Service
|
||||
const references = yield* Reference.Service
|
||||
|
||||
return Service.of({
|
||||
load: Effect.fn("ReferenceGuidance.load")(function* () {
|
||||
yield* boot.wait()
|
||||
const available = (yield* references.list())
|
||||
.filter((reference) => reference.description !== undefined)
|
||||
.map((reference) => ({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SessionV2 from "./session"
|
||||
export * from "./session/schema"
|
||||
|
||||
import { Cause, DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
|
||||
import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
|
||||
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { ProjectV2 } from "./project"
|
||||
import { WorkspaceV2 } from "./workspace"
|
||||
@@ -25,7 +25,6 @@ import { fromRow } from "./session/info"
|
||||
import { SessionRunner } from "./session/runner/index"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { SessionExecution } from "./session/execution"
|
||||
import { logFailure } from "./session/logging"
|
||||
import { MessageDecodeError } from "./session/error"
|
||||
import { SessionEvent } from "./session/event"
|
||||
import { SessionInput } from "./session/input"
|
||||
@@ -124,12 +123,9 @@ export interface Interface {
|
||||
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
||||
readonly events: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
after?: EventV2.Cursor
|
||||
}) => Stream.Stream<EventV2.CursorEvent<SessionEvent.DurableEvent>, NotFoundError>
|
||||
readonly switchAgent: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
agent: string
|
||||
}) => Effect.Effect<void, OperationUnavailableError>
|
||||
after?: number
|
||||
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
model: ModelV2.Ref
|
||||
@@ -171,20 +167,6 @@ export const layer = Layer.effect(
|
||||
const store = yield* SessionStore.Service
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
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) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
@@ -339,20 +321,12 @@ export const layer = Layer.effect(
|
||||
Stream.unwrap(
|
||||
result
|
||||
.get(input.sessionID)
|
||||
.pipe(Effect.as(events.aggregateEvents({ aggregateID: input.sessionID, after: input.after }))),
|
||||
).pipe(
|
||||
Stream.filter((event): event is EventV2.CursorEvent<SessionEvent.DurableEvent> =>
|
||||
isDurableSessionEvent(event.event),
|
||||
),
|
||||
),
|
||||
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
|
||||
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
|
||||
prompt: Effect.fn("V2Session.prompt")((input) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
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 delivery = input.delivery ?? "steer"
|
||||
const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery }
|
||||
@@ -370,7 +344,8 @@ export const layer = Layer.effect(
|
||||
)
|
||||
if (!SessionInput.equivalent(admitted, expected))
|
||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
return yield* returnPrompt(admitted)
|
||||
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
|
||||
return admitted
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -380,8 +355,14 @@ export const layer = Layer.effect(
|
||||
skill: Effect.fn("V2Session.skill")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "skill" })
|
||||
}),
|
||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "switchAgent" })
|
||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
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) {
|
||||
yield* result.get(input.sessionID)
|
||||
@@ -405,19 +386,7 @@ export const layer = Layer.effect(
|
||||
yield* execution.resume(sessionID)
|
||||
}),
|
||||
interrupt: Effect.fn("V2Session.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)
|
||||
}),
|
||||
),
|
||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,54 +1,31 @@
|
||||
export * as SessionContextEpoch from "./context-epoch"
|
||||
|
||||
import { and, eq, isNull, lt, or, sql } from "drizzle-orm"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import type { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { Location } from "../location"
|
||||
import { SystemContext } from "../system-context/index"
|
||||
import { ContextSnapshotDecodeError } from "./error"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionInput } from "./input"
|
||||
import { SessionMessageID } from "./message-id"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionContextEpochTable, SessionTable } from "./sql"
|
||||
import { SessionContextEpochTable } from "./sql"
|
||||
|
||||
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 {
|
||||
readonly baseline: string
|
||||
readonly baselineSeq: number
|
||||
readonly revision: number
|
||||
}
|
||||
|
||||
export function initialize(
|
||||
db: DatabaseService,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
agent: AgentV2.ID,
|
||||
): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> {
|
||||
return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location, agent)).pipe(
|
||||
Effect.withSpan("SessionContextEpoch.initialize"),
|
||||
)
|
||||
return initializeOnce(db, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.initialize"))
|
||||
}
|
||||
|
||||
export function prepare(
|
||||
@@ -56,12 +33,8 @@ export function prepare(
|
||||
events: EventV2.Interface,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
agent: AgentV2.ID,
|
||||
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError | AgentReplacementBlocked> {
|
||||
return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location, agent)).pipe(
|
||||
Effect.withSpan("SessionContextEpoch.prepare"),
|
||||
)
|
||||
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError> {
|
||||
return prepareOnce(db, events, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.prepare"))
|
||||
}
|
||||
|
||||
const prepareOnce = Effect.fnUntraced(function* (
|
||||
@@ -69,57 +42,50 @@ const prepareOnce = Effect.fnUntraced(function* (
|
||||
events: EventV2.Interface,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
agent: AgentV2.ID,
|
||||
) {
|
||||
const [value, stored] = yield* Effect.all([context, find(db, sessionID)], { concurrency: "unbounded" })
|
||||
const [value, stored, compaction] = yield* Effect.all(
|
||||
[context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
if (!stored) {
|
||||
const generation = yield* SystemContext.initialize(value)
|
||||
const baselineSeq = yield* insert(db, sessionID, location, agent, generation)
|
||||
return { baseline: generation.baseline, baselineSeq, revision: 0 }
|
||||
const baselineSeq = yield* insert(db, sessionID, generation)
|
||||
return { baseline: generation.baseline, baselineSeq }
|
||||
}
|
||||
|
||||
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(
|
||||
Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })),
|
||||
)
|
||||
const replacingAgent = stored.agent !== agent
|
||||
const result =
|
||||
stored.replacement_seq === null && !replacingAgent
|
||||
? 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 })
|
||||
}
|
||||
const replacementSeq = compaction !== undefined && compaction.seq > stored.baseline_seq ? compaction.seq : undefined
|
||||
const result = replacementSeq
|
||||
? yield* SystemContext.replace(value, snapshot)
|
||||
: yield* SystemContext.reconcile(value, snapshot)
|
||||
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") {
|
||||
yield* fence(db, sessionID, agent, stored.revision)
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision }
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
}
|
||||
if (result._tag === "ReplacementReady") {
|
||||
const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, sessionID))
|
||||
yield* replace(db, sessionID, agent, stored.revision, replacementSeq, result.generation)
|
||||
return { baseline: result.generation.baseline, baselineSeq: replacementSeq, revision: stored.revision + 1 }
|
||||
const baselineSeq = replacementSeq ?? (yield* EventV2.latestSequence(db, sessionID))
|
||||
yield* replace(db, sessionID, baselineSeq, result.generation)
|
||||
return { baseline: result.generation.baseline, baselineSeq }
|
||||
}
|
||||
|
||||
yield* events.publish(
|
||||
SessionEvent.ContextUpdated,
|
||||
{ sessionID, messageID: SessionMessageID.ID.create(), timestamp: yield* DateTime.now, text: result.text },
|
||||
{ commit: () => advance(db, sessionID, stored.revision, result.snapshot).pipe(Effect.orDie) },
|
||||
{ commit: () => advance(db, sessionID, result.snapshot).pipe(Effect.orDie) },
|
||||
)
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision + 1 }
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
})
|
||||
|
||||
const initializeOnce = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
agent: AgentV2.ID,
|
||||
) {
|
||||
if (yield* exists(db, sessionID)) return
|
||||
const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize))
|
||||
const baselineSeq = yield* insert(db, sessionID, location, agent, generation)
|
||||
return { baseline: generation.baseline, baselineSeq, revision: 0 }
|
||||
const baselineSeq = yield* insert(db, sessionID, generation)
|
||||
return { baseline: generation.baseline, baselineSeq }
|
||||
})
|
||||
|
||||
const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
@@ -142,39 +108,6 @@ const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseServic
|
||||
.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* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
@@ -189,155 +122,53 @@ export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
|
||||
const insert = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
agent: AgentV2.ID,
|
||||
generation: SystemContext.Generation,
|
||||
) {
|
||||
return yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const placed = yield* db
|
||||
.select({ agent: SessionTable.agent })
|
||||
.from(SessionTable)
|
||||
.where(
|
||||
and(
|
||||
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" },
|
||||
)
|
||||
const baselineSeq = yield* EventV2.latestSequence(db, sessionID)
|
||||
yield* db
|
||||
.insert(SessionContextEpochTable)
|
||||
.values({
|
||||
session_id: sessionID,
|
||||
baseline: generation.baseline,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return baselineSeq
|
||||
})
|
||||
|
||||
const replace = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
agent: AgentV2.ID,
|
||||
expectedRevision: number,
|
||||
baselineSeq: number,
|
||||
generation: SystemContext.Generation,
|
||||
) {
|
||||
yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* requireAgentSelection(db, sessionID, agent)
|
||||
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))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
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,
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({
|
||||
baseline: generation.baseline,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.from(SessionContextEpochTable)
|
||||
.innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id))
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return (
|
||||
value !== undefined &&
|
||||
value.agent === agent &&
|
||||
(value.selected === null || value.selected === agent) &&
|
||||
value.revision === revision
|
||||
)
|
||||
if (!updated) return yield* Effect.die("Context Epoch not found")
|
||||
})
|
||||
|
||||
const advance = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
expectedRevision: number,
|
||||
snapshot: SystemContext.Snapshot,
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({ snapshot, revision: expectedRevision + 1 })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionContextEpochTable.session_id, sessionID),
|
||||
eq(SessionContextEpochTable.revision, expectedRevision),
|
||||
isNull(SessionContextEpochTable.replacement_seq),
|
||||
),
|
||||
)
|
||||
.returning({ revision: SessionContextEpochTable.revision })
|
||||
.set({ snapshot })
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die(new RevisionMismatch())
|
||||
if (!updated) return yield* Effect.die("Context Epoch not found")
|
||||
})
|
||||
|
||||
@@ -25,15 +25,21 @@ const Base = {
|
||||
timestamp: V2Schema.DateTimeUtcFromMillis,
|
||||
sessionID: SessionSchema.ID,
|
||||
}
|
||||
const PromptFields = {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
prompt: Prompt,
|
||||
delivery: Schema.Literals(["steer", "queue"]),
|
||||
}
|
||||
|
||||
const options = {
|
||||
sync: {
|
||||
durable: {
|
||||
aggregate: "sessionID",
|
||||
version: 1,
|
||||
},
|
||||
} as const
|
||||
const stepSettlementOptions = {
|
||||
sync: {
|
||||
durable: {
|
||||
aggregate: "sessionID",
|
||||
version: 2,
|
||||
},
|
||||
@@ -83,47 +89,16 @@ export type Moved = typeof Moved.Type
|
||||
export const Prompted = EventV2.define({
|
||||
type: "session.next.prompted",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
prompt: Prompt,
|
||||
delivery: Schema.Literals(["steer", "queue"]),
|
||||
},
|
||||
schema: PromptFields,
|
||||
})
|
||||
export type Prompted = typeof Prompted.Type
|
||||
|
||||
export namespace PromptLifecycle {
|
||||
export const Admitted = EventV2.define({
|
||||
type: "session.next.prompt.admitted",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
prompt: Prompt,
|
||||
delivery: Schema.Literals(["steer", "queue"]),
|
||||
},
|
||||
})
|
||||
export type Admitted = typeof Admitted.Type
|
||||
|
||||
export const Promoted = EventV2.define({
|
||||
type: "session.next.prompt.promoted",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
prompt: Prompt,
|
||||
timeCreated: V2Schema.DateTimeUtcFromMillis,
|
||||
},
|
||||
})
|
||||
export type Promoted = typeof Promoted.Type
|
||||
}
|
||||
|
||||
export const InterruptRequested = EventV2.define({
|
||||
type: "session.next.interrupt.requested",
|
||||
export const PromptAdmitted = EventV2.define({
|
||||
type: "session.next.prompt.admitted",
|
||||
...options,
|
||||
schema: Base,
|
||||
schema: PromptFields,
|
||||
})
|
||||
export type InterruptRequested = typeof InterruptRequested.Type
|
||||
export type PromptAdmitted = typeof PromptAdmitted.Type
|
||||
|
||||
export const ContextUpdated = EventV2.define({
|
||||
type: "session.next.context.updated",
|
||||
@@ -443,20 +418,9 @@ export namespace Compaction {
|
||||
})
|
||||
export type Delta = typeof Delta.Type
|
||||
|
||||
// Retain the unpublished v1 decoder so stored beta events remain replayable.
|
||||
export const EndedV1 = EventV2.define({
|
||||
type: "session.next.compaction.ended",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
text: Schema.String,
|
||||
include: Schema.String.pipe(Schema.optional),
|
||||
},
|
||||
})
|
||||
|
||||
export const Ended = EventV2.define({
|
||||
type: "session.next.compaction.ended",
|
||||
sync: { aggregate: "sessionID", version: 2 },
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
@@ -473,9 +437,7 @@ const DurableDefinitions = [
|
||||
ModelSwitched,
|
||||
Moved,
|
||||
Prompted,
|
||||
PromptLifecycle.Admitted,
|
||||
PromptLifecycle.Promoted,
|
||||
InterruptRequested,
|
||||
PromptAdmitted,
|
||||
ContextUpdated,
|
||||
Synthetic,
|
||||
Shell.Started,
|
||||
|
||||
@@ -5,12 +5,12 @@ import { SessionRunner } from "./runner/index"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
export interface Interface {
|
||||
/** Explicitly drain one Session, making at least one provider attempt. */
|
||||
/** Starts execution while idle or joins the active execution. */
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
||||
/** Schedule a drain after durable work is recorded. Repeated wakeups may coalesce. */
|
||||
readonly wake: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect<void, SessionRunner.RunError>
|
||||
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
||||
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect<void>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Cause, Effect, Layer } from "effect"
|
||||
import { LocationServiceMap } from "../../location-layer"
|
||||
import { SessionRunCoordinator } from "../run-coordinator"
|
||||
import { SessionRunner } from "../runner"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
import { SessionExecution } from "../execution"
|
||||
import { logFailure } from "../logging"
|
||||
|
||||
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
|
||||
export const layer = Layer.effect(
|
||||
@@ -13,15 +12,19 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, void, SessionRunner.RunError>({
|
||||
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, mode) {
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
|
||||
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
|
||||
return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force: mode === "run" })).pipe(
|
||||
return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force })).pipe(
|
||||
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({
|
||||
|
||||
@@ -10,9 +10,9 @@ type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
|
||||
const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select()
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
|
||||
@@ -4,7 +4,6 @@ import { and, asc, eq, isNull, lte } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import type { Database } from "../database/database"
|
||||
import type { EventV2 } from "../event"
|
||||
import { EventSequenceTable } from "../event/sql"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
import { V2Schema } from "../v2-schema"
|
||||
import { SessionEvent } from "./event"
|
||||
@@ -65,7 +64,7 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
|
||||
if (existing !== undefined) return existing
|
||||
const timestamp = yield* DateTime.now
|
||||
return yield* events
|
||||
.publish(SessionEvent.PromptLifecycle.Admitted, {
|
||||
.publish(SessionEvent.PromptAdmitted, {
|
||||
messageID: input.id,
|
||||
sessionID: input.sessionID,
|
||||
timestamp,
|
||||
@@ -74,11 +73,11 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
|
||||
})
|
||||
.pipe(
|
||||
Effect.flatMap((event) =>
|
||||
event.seq === undefined
|
||||
event.durable === undefined
|
||||
? Effect.die("Prompt admission event is missing aggregate sequence")
|
||||
: Effect.succeed(
|
||||
new Admitted({
|
||||
admittedSeq: event.seq,
|
||||
admittedSeq: event.durable.seq,
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
prompt: input.prompt,
|
||||
@@ -93,19 +92,6 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
|
||||
)
|
||||
})
|
||||
|
||||
export const latestSeq = Effect.fn("SessionInput.latestSeq")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select({ seq: EventSequenceTable.seq })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row?.seq ?? -1
|
||||
})
|
||||
|
||||
export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* (
|
||||
db: DatabaseService,
|
||||
input: {
|
||||
@@ -123,7 +109,7 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
|
||||
.where(eq(SessionMessageTable.id, input.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (message) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
const stored = yield* db
|
||||
.insert(SessionInputTable)
|
||||
.values({
|
||||
@@ -141,12 +127,13 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
|
||||
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
})
|
||||
|
||||
export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(function* (
|
||||
export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(function* (
|
||||
db: DatabaseService,
|
||||
input: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly prompt: Prompt
|
||||
readonly delivery: Delivery
|
||||
readonly timeCreated: DateTime.Utc
|
||||
readonly promotedSeq: number
|
||||
},
|
||||
@@ -164,14 +151,32 @@ export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(functio
|
||||
.returning()
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
const stored = fromRow(updated)
|
||||
if (
|
||||
!matchesPrompt(stored, input) ||
|
||||
DateTime.toEpochMillis(stored.timeCreated) !== DateTime.toEpochMillis(input.timeCreated)
|
||||
)
|
||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
return toMessage(stored)
|
||||
if (updated) {
|
||||
const stored = fromRow(updated)
|
||||
if (!matchesProjection(stored, input)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
return
|
||||
}
|
||||
|
||||
const stored = yield* find(db, input.id)
|
||||
if (stored) {
|
||||
if (!matchesProjection(stored, input) || stored.promotedSeq !== input.promotedSeq)
|
||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
return
|
||||
}
|
||||
|
||||
yield* db
|
||||
.insert(SessionInputTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
session_id: input.sessionID,
|
||||
prompt: encodePrompt(input.prompt),
|
||||
delivery: input.delivery,
|
||||
admitted_seq: input.promotedSeq,
|
||||
promoted_seq: input.promotedSeq,
|
||||
time_created: DateTime.toEpochMillis(input.timeCreated),
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
|
||||
@@ -208,66 +213,17 @@ const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionS
|
||||
input.sessionID === expected.sessionID &&
|
||||
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* (
|
||||
db: DatabaseService,
|
||||
input: {
|
||||
readonly id: SessionMessage.ID
|
||||
const matchesProjection = (
|
||||
input: Admitted,
|
||||
expected: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly prompt: Prompt
|
||||
readonly delivery: Delivery
|
||||
readonly timeCreated: DateTime.Utc
|
||||
readonly promotedSeq: number
|
||||
},
|
||||
) {
|
||||
const inserted = yield* db
|
||||
.insert(SessionInputTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
session_id: input.sessionID,
|
||||
admitted_seq: input.promotedSeq,
|
||||
prompt: encodePrompt(input.prompt),
|
||||
delivery: input.delivery,
|
||||
promoted_seq: input.promotedSeq,
|
||||
time_created: DateTime.toEpochMillis(input.timeCreated),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!inserted) return yield* Effect.die("Prompt projection conflicts with admitted input")
|
||||
return fromRow(inserted)
|
||||
})
|
||||
) =>
|
||||
equivalent(input, expected) &&
|
||||
DateTime.toEpochMillis(input.timeCreated) === DateTime.toEpochMillis(expected.timeCreated)
|
||||
|
||||
const publish = Effect.fn("SessionInput.publish")(function* (
|
||||
db: DatabaseService,
|
||||
@@ -276,18 +232,19 @@ const publish = Effect.fn("SessionInput.publish")(function* (
|
||||
rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>,
|
||||
) {
|
||||
for (const row of rows) {
|
||||
const id = SessionMessage.ID.make(row.id)
|
||||
yield* events
|
||||
.publish(SessionEvent.PromptLifecycle.Promoted, {
|
||||
.publish(SessionEvent.Prompted, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
messageID: SessionMessage.ID.make(row.id),
|
||||
timestamp: DateTime.makeUnsafe(row.time_created),
|
||||
messageID: id,
|
||||
prompt: decodePrompt(row.prompt),
|
||||
timeCreated: DateTime.makeUnsafe(row.time_created),
|
||||
delivery: row.delivery,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof LifecycleConflict
|
||||
? find(db, SessionMessage.ID.make(row.id)).pipe(
|
||||
? find(db, id).pipe(
|
||||
Effect.flatMap((stored) => (stored?.promotedSeq === undefined ? Effect.die(defect) : Effect.void)),
|
||||
)
|
||||
: Effect.die(defect),
|
||||
@@ -341,13 +298,3 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun
|
||||
.pipe(Effect.orDie)
|
||||
return row === undefined ? false : yield* publish(db, events, sessionID, [row]).pipe(Effect.as(true))
|
||||
})
|
||||
|
||||
const toMessage = (input: Admitted) =>
|
||||
new SessionMessage.User({
|
||||
id: input.id,
|
||||
type: "user",
|
||||
text: input.prompt.text,
|
||||
files: input.prompt.files,
|
||||
agents: input.prompt.agents,
|
||||
time: { created: input.timeCreated },
|
||||
})
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
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 }))
|
||||
@@ -137,8 +137,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
)
|
||||
},
|
||||
"session.next.prompt.admitted": () => Effect.void,
|
||||
"session.next.prompt.promoted": () => Effect.void,
|
||||
"session.next.interrupt.requested": () => Effect.void,
|
||||
"session.next.context.updated": (event) =>
|
||||
adapter.appendMessage(
|
||||
new SessionMessage.System({
|
||||
|
||||
@@ -21,7 +21,6 @@ type DatabaseService = Database.Interface["db"]
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
|
||||
|
||||
class PromptAlreadyProjected extends Error {}
|
||||
export class SessionAlreadyProjected extends Error {}
|
||||
|
||||
type Usage = {
|
||||
@@ -115,7 +114,7 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
|
||||
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
const updateMessage = (message: SessionMessage.Message) => {
|
||||
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
return db
|
||||
@@ -192,7 +191,7 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
|
||||
}
|
||||
|
||||
function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: SessionMessage.Message) {
|
||||
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
return db
|
||||
@@ -201,7 +200,7 @@ function insertMessage(db: DatabaseService, event: SessionEvent.Event, message:
|
||||
id: SessionMessage.ID.make(id),
|
||||
session_id: event.data.sessionID,
|
||||
type,
|
||||
seq: event.seq,
|
||||
seq: event.durable.seq,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data,
|
||||
})
|
||||
@@ -213,7 +212,6 @@ export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* events.beforeCommit((event) => SessionInput.guardReservedID(db, event))
|
||||
yield* events.project(SessionV1.Event.Created, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const stored = yield* db
|
||||
@@ -330,19 +328,14 @@ export const layer = Layer.effectDiscard(
|
||||
if (next) yield* applyUsage(db, sessionID, next)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.AgentSwitched, (event) => {
|
||||
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
return db
|
||||
yield* events.project(SessionEvent.AgentSwitched, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.andThen(run(db, event)),
|
||||
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)),
|
||||
)
|
||||
})
|
||||
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
|
||||
)
|
||||
yield* events.project(SessionEvent.ModelSwitched, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* db
|
||||
@@ -352,40 +345,27 @@ export const layer = Layer.effectDiscard(
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
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) =>
|
||||
Effect.gen(function* () {
|
||||
const messageID = event.data.messageID
|
||||
const existing = yield* db
|
||||
.select({ id: SessionMessageTable.id })
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, messageID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (existing) return yield* Effect.die(new PromptAlreadyProjected())
|
||||
yield* run(db, event)
|
||||
if (event.seq === undefined)
|
||||
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
yield* SessionInput.projectLegacyPrompted(db, {
|
||||
id: messageID,
|
||||
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
||||
yield* SessionInput.projectPrompted(db, {
|
||||
id: event.data.messageID,
|
||||
sessionID: event.data.sessionID,
|
||||
prompt: event.data.prompt,
|
||||
delivery: event.data.delivery,
|
||||
timeCreated: event.data.timestamp,
|
||||
promotedSeq: event.seq,
|
||||
promotedSeq: event.durable.seq,
|
||||
})
|
||||
yield* run(db, event)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.PromptLifecycle.Admitted, (event) =>
|
||||
yield* events.project(SessionEvent.PromptAdmitted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.seq === undefined)
|
||||
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
||||
yield* SessionInput.projectAdmitted(db, {
|
||||
admittedSeq: event.seq,
|
||||
admittedSeq: event.durable.seq,
|
||||
id: event.data.messageID,
|
||||
sessionID: event.data.sessionID,
|
||||
prompt: event.data.prompt,
|
||||
@@ -394,30 +374,7 @@ export const layer = Layer.effectDiscard(
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.PromptLifecycle.Promoted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.seq === undefined)
|
||||
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
yield* insertMessage(
|
||||
db,
|
||||
event,
|
||||
yield* SessionInput.projectPromoted(db, {
|
||||
id: event.data.messageID,
|
||||
sessionID: event.data.sessionID,
|
||||
prompt: event.data.prompt,
|
||||
timeCreated: event.data.timeCreated,
|
||||
promotedSeq: event.seq,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
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.ContextUpdated, (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.Ended, (event) => run(db, event))
|
||||
@@ -435,15 +392,7 @@ export const layer = Layer.effectDiscard(
|
||||
yield* events.project(SessionEvent.Reasoning.Started, (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.Compaction.Ended, (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)
|
||||
})
|
||||
})
|
||||
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,106 +1,43 @@
|
||||
export * as SessionRunCoordinator from "./run-coordinator"
|
||||
|
||||
import { Cause, Context, Deferred, Effect, Exit, Fiber, FiberSet, Layer, Scope } from "effect"
|
||||
import { SessionRunner } from "./runner"
|
||||
import { logFailure } from "./logging"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
||||
|
||||
export type Mode = "run" | "wake"
|
||||
|
||||
/** Why one drain generation should run. Explicit runs dominate advisory wakes when demands coalesce. */
|
||||
type Demand = { readonly _tag: "run" } | { readonly _tag: "wake"; readonly seq?: number }
|
||||
|
||||
/**
|
||||
* Runs at most one drain chain per key while allowing different keys to drain concurrently.
|
||||
*
|
||||
* 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>
|
||||
/** Serializes execution for each key while allowing different keys to run concurrently. */
|
||||
export interface Coordinator<Key, E> {
|
||||
/** Starts execution while idle or joins the active execution. */
|
||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||
/** Registers one coalesced follow-up after newly recorded work. */
|
||||
readonly wake: (key: Key) => Effect.Effect<void>
|
||||
/** Stops active execution and waits for its cleanup. */
|
||||
readonly interrupt: (key: Key) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
/** One Session's process-local execution lane: one active demand and at most one coalesced follow-up. */
|
||||
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
|
||||
type Entry<E> = {
|
||||
readonly done: Deferred.Deferred<void, E>
|
||||
owner?: Fiber.Fiber<void, never>
|
||||
pendingWake: boolean
|
||||
stopping: boolean
|
||||
}
|
||||
|
||||
/** Combines follow-up demand: runs dominate, while wakes retain the newest durable admission sequence. */
|
||||
const coalesce = (left: Demand | undefined, right: Demand): Demand => {
|
||||
if (left?._tag === "run" || right._tag === "run") return { _tag: "run" }
|
||||
return { _tag: "wake", seq: maxSeq(left?.seq, right.seq) }
|
||||
}
|
||||
|
||||
const maxSeq = (left: number | undefined, right: number | undefined) => {
|
||||
if (left === undefined) return right
|
||||
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> =>
|
||||
export const make = <Key, E>(options: {
|
||||
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
|
||||
}): Effect.Effect<Coordinator<Key, 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 active = new Map<Key, Entry<E>>()
|
||||
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,
|
||||
const makeEntry = (): Entry<E> => ({
|
||||
done: Deferred.makeUnsafe<void, E>(),
|
||||
pendingWake: false,
|
||||
stopping: false,
|
||||
})
|
||||
|
||||
const start = (key: Key, entry: Entry<A, E>, demand: Demand, successor = false) => {
|
||||
const start = (key: Key, entry: Entry<E>, force: boolean, successor = false) => {
|
||||
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(
|
||||
(successor
|
||||
? Effect.yieldNow.pipe(Effect.andThen(drain))
|
||||
: Deferred.await(ready).pipe(Effect.andThen(drain))
|
||||
).pipe(
|
||||
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, demand, exit))),
|
||||
(successor ? Effect.yieldNow : Deferred.await(ready)).pipe(
|
||||
Effect.andThen(Effect.suspend(() => options.drain(key, force))),
|
||||
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, exit))),
|
||||
Effect.exit,
|
||||
Effect.asVoid,
|
||||
),
|
||||
@@ -109,176 +46,57 @@ export const make = <Key, A, E>(options: {
|
||||
if (!successor) Deferred.doneUnsafe(ready, Effect.void)
|
||||
}
|
||||
|
||||
const settle = (key: Key, entry: Entry<A, E>, demand: Demand, exit: Exit.Exit<A, E>) => {
|
||||
if (closed) {
|
||||
Deferred.doneUnsafe(entry.done, exit)
|
||||
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))
|
||||
const settle = (key: Key, entry: Entry<E>, exit: Exit.Exit<void, E>) => {
|
||||
if (Exit.isSuccess(exit) && !entry.stopping && entry.pendingWake) {
|
||||
entry.pendingWake = false
|
||||
start(key, entry, false, true)
|
||||
return
|
||||
}
|
||||
|
||||
const successor = entry.pending !== undefined ? makeEntry(entry.pending, entry.explicitWaiter) : undefined
|
||||
const successor = entry.pendingWake ? makeEntry() : undefined
|
||||
if (successor === undefined) active.delete(key)
|
||||
else active.set(key, successor)
|
||||
if (successor !== undefined) start(key, successor, successor.current, true)
|
||||
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)))
|
||||
else {
|
||||
active.set(key, successor)
|
||||
start(key, successor, false, true)
|
||||
}
|
||||
Deferred.doneUnsafe(entry.done, exit)
|
||||
}
|
||||
|
||||
const wake = (key: Key, seq?: number) =>
|
||||
Effect.sync(() => {
|
||||
if (closed) return
|
||||
if (!isAfterInterrupt(key, seq)) return
|
||||
const run = (key: Key): Effect.Effect<void, E> =>
|
||||
Effect.uninterruptibleMask((restore) => {
|
||||
const entry = active.get(key)
|
||||
if (entry !== undefined) {
|
||||
if (!acceptsWake(entry, seq)) return
|
||||
entry.pending = coalesce(entry.pending, { _tag: "wake", seq })
|
||||
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(() => {
|
||||
const entry = active.get(key)
|
||||
if (entry !== undefined) {
|
||||
entry.pendingWake = true
|
||||
return
|
||||
}
|
||||
|
||||
const next = makeEntry({ _tag: "wake", seq })
|
||||
const next = makeEntry()
|
||||
active.set(key, next)
|
||||
start(key, next, next.current)
|
||||
start(key, next, false)
|
||||
})
|
||||
|
||||
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> =>
|
||||
const interrupt = (key: Key): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
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 (
|
||||
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.interruptSeq = seq
|
||||
suppressPendingAtOrBefore(entry, seq)
|
||||
entry.pendingWake = false
|
||||
return Fiber.interrupt(entry.owner)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
return { run, wake, interrupt }
|
||||
})
|
||||
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ import { SessionSchema } from "../schema"
|
||||
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import type { SystemContext } from "../../system-context/index"
|
||||
import type { SessionContextEpoch } from "../context-epoch"
|
||||
import type { ToolOutputStore } from "../../tool-output-store"
|
||||
|
||||
export type RunError =
|
||||
@@ -15,7 +14,6 @@ export type RunError =
|
||||
| MessageDecodeError
|
||||
| ContextSnapshotDecodeError
|
||||
| SystemContext.InitializationBlocked
|
||||
| SessionContextEpoch.AgentReplacementBlocked
|
||||
| ToolOutputStore.Error
|
||||
|
||||
/** Runs one local continuation from already-recorded Session history. */
|
||||
@@ -23,7 +21,7 @@ export interface Interface {
|
||||
/** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */
|
||||
readonly run: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force?: boolean
|
||||
readonly force: boolean
|
||||
}) => Effect.Effect<void, RunError>
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
isContextOverflowFailure,
|
||||
type ProviderErrorEvent,
|
||||
} from "@opencode-ai/llm"
|
||||
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
import { Database } from "../../database/database"
|
||||
@@ -79,7 +79,7 @@ import { MAX_STEPS_PROMPT } from "./max-steps"
|
||||
* - [ ] Update title, summaries, compaction state, and cleanup in bounded background work.
|
||||
*
|
||||
* Use `llm.stream(request)` for each provider turn. Keep tool execution and continuation here.
|
||||
* Durable activity recovery remains a separate future slice with an explicit retry policy.
|
||||
* Durable continuation recovery remains a separate future slice with an explicit retry policy.
|
||||
*
|
||||
* The current slice loads V2 history, translates it, resolves a model through a core service, and persists one
|
||||
* provider turn. Registry definitions are advertised, local tool calls are settled durably, and an
|
||||
@@ -141,10 +141,10 @@ export const layer = Layer.effect(
|
||||
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
|
||||
|
||||
type TurnTransition =
|
||||
// Request preparation observed a concurrent Session change and must restart from durable state.
|
||||
| { readonly _tag: "RebuildPreparedTurn"; readonly promotion?: SessionInput.Delivery }
|
||||
// Automatic compaction completed; rebuild the request from compacted history.
|
||||
| { readonly _tag: "ContinueAfterCompaction"; readonly step: number }
|
||||
// Overflow compaction completed; rebuild once through the path without overflow recovery.
|
||||
| { readonly _tag: "ContinueAfterOverflowCompaction" }
|
||||
| { readonly _tag: "ContinueAfterOverflowCompaction"; readonly step: number }
|
||||
|
||||
class TurnTransitionError extends Error {
|
||||
constructor(readonly transition: TurnTransition) {
|
||||
@@ -152,20 +152,10 @@ export const layer = Layer.effect(
|
||||
}
|
||||
}
|
||||
|
||||
const rebuildPreparedTurn = (promotion?: SessionInput.Delivery) =>
|
||||
new TurnTransitionError({ _tag: "RebuildPreparedTurn", promotion })
|
||||
const continueAfterOverflowCompaction = new TurnTransitionError({
|
||||
_tag: "ContinueAfterOverflowCompaction",
|
||||
})
|
||||
const continueAfterCompaction = (step: number) => new TurnTransitionError({ _tag: "ContinueAfterCompaction", step })
|
||||
const continueAfterOverflowCompaction = (step: number) =>
|
||||
new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step })
|
||||
|
||||
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) =>
|
||||
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
|
||||
concurrency: "unbounded",
|
||||
@@ -181,40 +171,26 @@ export const layer = Layer.effect(
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
return yield* Effect.interrupt
|
||||
const agent = yield* agents.select(session.agent)
|
||||
const initialized = yield* SessionContextEpoch.initialize(
|
||||
db,
|
||||
loadSystemContext(agent),
|
||||
session.id,
|
||||
session.location,
|
||||
agent.id,
|
||||
).pipe(retryAgentMismatch(promotion))
|
||||
const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id)
|
||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
||||
let needsContinuation = false
|
||||
let currentStep = step
|
||||
if (promotion) {
|
||||
const cutoff = yield* SessionInput.latestSeq(db, session.id)
|
||||
if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
||||
const cutoff = yield* EventV2.latestSequence(db, session.id)
|
||||
let promoted = 0
|
||||
if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
||||
if (promotion === "queue") {
|
||||
yield* SessionInput.promoteNextQueued(db, events, session.id)
|
||||
yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
||||
promoted += Number(yield* SessionInput.promoteNextQueued(db, events, session.id))
|
||||
promoted += yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
||||
}
|
||||
if (promoted > 0) currentStep = 1
|
||||
}
|
||||
const system =
|
||||
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())
|
||||
initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id))
|
||||
const model = yield* models.resolve(session)
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
|
||||
const context = entries.map((entry) => entry.message)
|
||||
const isLastStep = agent.info?.steps !== undefined && step >= agent.info.steps
|
||||
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
||||
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions)
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||
const request = LLM.request({
|
||||
@@ -228,7 +204,7 @@ export const layer = Layer.effect(
|
||||
toolChoice: isLastStep ? "none" : undefined,
|
||||
})
|
||||
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
|
||||
return yield* Effect.die(rebuildPreparedTurn())
|
||||
return yield* Effect.die(continueAfterCompaction(currentStep))
|
||||
const publisher = createLLMEventPublisher(events, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
@@ -242,8 +218,6 @@ export const layer = Layer.effect(
|
||||
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
|
||||
withPublication(publisher.publish(event, outputPaths))
|
||||
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(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -300,19 +274,12 @@ export const layer = Layer.effect(
|
||||
isContextOverflowFailure(overflowFailure ?? failure) &&
|
||||
(yield* restore(recoverOverflow({ sessionID: session.id, entries, model, request })))
|
||||
)
|
||||
return yield* Effect.die(continueAfterOverflowCompaction)
|
||||
return yield* Effect.die(continueAfterOverflowCompaction(currentStep))
|
||||
if (overflowFailure) yield* publish(overflowFailure)
|
||||
const llmFailure = failure instanceof LLMError ? failure : undefined
|
||||
if (llmFailure && !publisher.hasProviderError()) {
|
||||
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
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 },
|
||||
}),
|
||||
)
|
||||
yield* withPublication(publisher.failAssistant(llmFailure.reason.message))
|
||||
}
|
||||
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
|
||||
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
|
||||
@@ -327,6 +294,8 @@ export const layer = Layer.effect(
|
||||
) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
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)) {
|
||||
const failure = Cause.squash(settled.cause)
|
||||
@@ -339,7 +308,7 @@ export const layer = Layer.effect(
|
||||
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||
if (settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
|
||||
return !publisher.hasProviderError() && needsContinuation
|
||||
return { needsContinuation: !publisher.hasProviderError() && needsContinuation, step: currentStep }
|
||||
}),
|
||||
)
|
||||
}, Effect.scoped)
|
||||
@@ -347,7 +316,7 @@ export const layer = Layer.effect(
|
||||
sessionID: SessionSchema.ID,
|
||||
promotion: SessionInput.Delivery | undefined,
|
||||
step: number,
|
||||
) => Effect.Effect<boolean, RunError>
|
||||
) => Effect.Effect<{ readonly needsContinuation: boolean; readonly step: number }, RunError>
|
||||
|
||||
const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
|
||||
return yield* runTurnAttempt(sessionID, promotion, step).pipe(
|
||||
@@ -357,7 +326,7 @@ export const layer = Layer.effect(
|
||||
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
|
||||
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
|
||||
yield* Effect.yieldNow
|
||||
return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion, step)
|
||||
return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -370,8 +339,8 @@ export const layer = Layer.effect(
|
||||
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
|
||||
yield* Effect.yieldNow
|
||||
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
|
||||
return yield* runAfterOverflowCompaction(sessionID, undefined, step)
|
||||
return yield* runTurn(sessionID, defect.transition.promotion, step)
|
||||
return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step)
|
||||
return yield* runTurn(sessionID, undefined, defect.transition.step)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -379,23 +348,26 @@ export const layer = Layer.effect(
|
||||
|
||||
const run = Effect.fn("SessionRunner.run")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force?: boolean
|
||||
readonly force: boolean
|
||||
}) {
|
||||
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
||||
if (input.force !== true && !hasSteer && !hasQueue) return
|
||||
if (!input.force && !hasSteer && !hasQueue) return
|
||||
yield* failInterruptedTools(input.sessionID)
|
||||
let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
||||
let openActivity = input.force === true || hasSteer || hasQueue
|
||||
while (openActivity) {
|
||||
let shouldRun = input.force || hasSteer || hasQueue
|
||||
while (shouldRun) {
|
||||
let needsContinuation = true
|
||||
for (let step = 1; needsContinuation; step++) {
|
||||
needsContinuation = yield* runTurn(input.sessionID, promotion, step)
|
||||
let step = 1
|
||||
while (needsContinuation) {
|
||||
const result = yield* runTurn(input.sessionID, promotion, step)
|
||||
needsContinuation = result.needsContinuation
|
||||
step = result.step + 1
|
||||
promotion = "steer"
|
||||
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||
}
|
||||
openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
||||
promotion = openActivity ? "queue" : undefined
|
||||
shouldRun = yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
||||
promotion = shouldRun ? "queue" : undefined
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses"
|
||||
import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { produce } from "immer"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Credential } from "../../credential"
|
||||
@@ -13,7 +13,6 @@ import { Integration } from "../../integration"
|
||||
import { IntegrationConnection } from "../../integration/connection"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ModelRequest } from "../../model-request"
|
||||
import { PluginBoot } from "../../plugin/boot"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { SessionSchema } from "../schema"
|
||||
|
||||
@@ -24,6 +23,23 @@ 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>()(
|
||||
"SessionRunnerModel.UnsupportedApiError",
|
||||
{
|
||||
@@ -33,11 +49,7 @@ export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiE
|
||||
},
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| Catalog.ProviderNotFoundError
|
||||
| Catalog.ModelNotFoundError
|
||||
| ModelNotSelectedError
|
||||
| UnsupportedApiError
|
||||
export type Error = ModelNotSelectedError | ModelUnavailableError | VariantUnavailableError | UnsupportedApiError
|
||||
|
||||
export interface Interface {
|
||||
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error>
|
||||
@@ -48,7 +60,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
/** Test or embedding seam for supplying a model resolver directly. */
|
||||
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
||||
|
||||
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => {
|
||||
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Info) => {
|
||||
if (credential?.value.type === "key") return Auth.value(credential.value.key)
|
||||
if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
|
||||
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
|
||||
@@ -74,13 +86,27 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
|
||||
})
|
||||
}
|
||||
|
||||
const withVariant = (model: ModelV2.Info, variantID: ModelV2.VariantID | undefined) => {
|
||||
const withVariant = (
|
||||
model: ModelV2.Info,
|
||||
variantID: ModelV2.VariantID | undefined,
|
||||
): Effect.Effect<ModelV2.Info, VariantUnavailableError> => {
|
||||
const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID
|
||||
const variant = model.variants.find((item) => item.id === id)
|
||||
if (!variant) return model
|
||||
return produce(model, (draft) => {
|
||||
ModelRequest.assign(draft.request, variant)
|
||||
})
|
||||
if (!variant && variantID !== undefined && variantID !== "default")
|
||||
return Effect.fail(
|
||||
new VariantUnavailableError({
|
||||
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) =>
|
||||
@@ -89,7 +115,7 @@ const apiName = (model: ModelV2.Info) =>
|
||||
export const fromCatalogModel = (
|
||||
model: ModelV2.Info,
|
||||
connection?: IntegrationConnection.Info,
|
||||
credential?: Credential.Stored,
|
||||
credential?: Credential.Info,
|
||||
): Effect.Effect<Model, UnsupportedApiError> => {
|
||||
const resolved =
|
||||
credential?.value.metadata === undefined
|
||||
@@ -128,8 +154,15 @@ export const fromCatalogModel = (
|
||||
)
|
||||
}
|
||||
|
||||
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) =>
|
||||
fromCatalogModel(withVariant(model, session.model?.variant))
|
||||
export const resolve = (
|
||||
session: SessionSchema.Info,
|
||||
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) =>
|
||||
model.api.type === "aisdk" &&
|
||||
@@ -144,19 +177,27 @@ export const locationLayer = Layer.effect(
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const boot = yield* PluginBoot.Service
|
||||
return Service.of({
|
||||
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
||||
// Location plugins populate and filter the catalog asynchronously during layer startup.
|
||||
yield* boot.wait()
|
||||
const defaultModel = session.model ? undefined : yield* catalog.model.default()
|
||||
const selected = session.model
|
||||
? yield* catalog.model.get(session.model.providerID, session.model.id)
|
||||
: (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ??
|
||||
(yield* catalog.model.available()).find(supported))
|
||||
? (yield* catalog.model.available()).find(
|
||||
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
|
||||
)
|
||||
: 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 })
|
||||
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
|
||||
return yield* fromCatalogModel(
|
||||
withVariant(selected, session.model?.variant),
|
||||
return yield* resolve(
|
||||
session,
|
||||
selected,
|
||||
connection,
|
||||
connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined,
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user