mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-31 15:34:02 -04:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0574107e18 | |||
| 6f0fa2d6c6 | |||
| 363905dcdd | |||
| d71bc0055a | |||
| 4f235fc59b | |||
| 1d772f4896 | |||
| 113a117cef | |||
| c103ba6056 | |||
| 38f03c9d8b | |||
| e5ac4ca560 | |||
| 5f0487fa87 | |||
| 00557f9078 | |||
| 5ac1089c5c | |||
| d1843f49ac | |||
| aac66d384e | |||
| 83108c1b5d | |||
| e7000161f7 | |||
| 66200eee34 | |||
| 699477755f | |||
| 80865407e0 | |||
| 28f4284bd7 | |||
| 7affee529b | |||
| 79c7e9446e | |||
| efb629a33a | |||
| 2ddc91a0e8 | |||
| c7871e14d4 | |||
| 9840f63b12 | |||
| 56a9c0150a | |||
| 203a0613b8 | |||
| 7d8f1bdab3 | |||
| 9eea5bc925 | |||
| f753103e82 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/ai": patch
|
||||
---
|
||||
|
||||
Report OpenAI prompt cache write tokens in normalized usage.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/client": patch
|
||||
"@opencode-ai/protocol": patch
|
||||
"@opencode-ai/cli": patch
|
||||
---
|
||||
|
||||
Expose background-service lifecycle status, preserve one process-held owner through startup and failure, reconnect TUIs without activating replacement, and stop exact service instances gracefully.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/cli": patch
|
||||
---
|
||||
|
||||
Expose a TUI plugin slot at the top of the session view.
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/plugin": minor
|
||||
"@opencode-ai/sdk": minor
|
||||
"@opencode-ai/client": minor
|
||||
"@opencode-ai/protocol": minor
|
||||
---
|
||||
|
||||
Replace the V2 tool result model with one canonical representation per fact. Tools lose `structured`, projection callbacks, the `Structured` generic, and the exported `Tool.settle` interpreter; tool responses carry schema-validated `output`, model-visible `content`, and optional compact JSON `metadata`. Code Mode receives the validated encoded output. Durable tool success stores non-empty model content plus optional metadata; failure stores one error plus the final bounded partial snapshot. Progress carries metadata only, while `execute.after` hooks receive the canonical terminal outcome and managed `outputPaths`. A one-time migration rewrites existing projected tool rows and moves provider-hosted result payloads into provider-owned result state.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/client": patch
|
||||
"@opencode-ai/plugin": patch
|
||||
"@opencode-ai/protocol": patch
|
||||
---
|
||||
|
||||
Expose transient, read-only session generation through the HTTP API, generated clients, and V2 plugin session context.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/cli": patch
|
||||
---
|
||||
|
||||
Expose a TUI plugin slot above the session composer.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/ai": patch
|
||||
---
|
||||
|
||||
Improve Anthropic and Bedrock prompt reuse with layered cache breakpoints that roll through long tool loops.
|
||||
@@ -0,0 +1,37 @@
|
||||
name: deploy-www
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- v2
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-www-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'v2')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Build
|
||||
working-directory: packages/www
|
||||
run: bun run build
|
||||
env:
|
||||
BLUME_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||
|
||||
- name: Deploy
|
||||
working-directory: packages/www
|
||||
run: bun run deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
|
||||
- name: Check generated documentation
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/docs
|
||||
working-directory: packages/www
|
||||
run: bun run check:generated
|
||||
|
||||
e2e:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
exact = true
|
||||
# Only install newly resolved package versions published at least 3 days ago.
|
||||
minimumReleaseAge = 259200
|
||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
|
||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opencode-ai/sdk", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
|
||||
|
||||
[test]
|
||||
root = "./do-not-run-tests-from-root"
|
||||
|
||||
+1
-1
@@ -15,6 +15,6 @@
|
||||
"@actions/github": "6.0.1",
|
||||
"@octokit/graphql": "9.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*"
|
||||
"@opencode-ai/sdk": "1.18.5"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -33,7 +33,6 @@
|
||||
"packages/*",
|
||||
"packages/console/*",
|
||||
"packages/stats/*",
|
||||
"packages/sdk/js",
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
@@ -124,7 +123,7 @@
|
||||
"@aws-sdk/client-s3": "3.933.0",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/sdk": "1.18.5",
|
||||
"heap-snapshot-toolkit": "1.1.3",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/sdk": "1.18.5",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
|
||||
@@ -88,8 +88,12 @@ function SessionTabSlot(props: {
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.id}
|
||||
data-active={props.active()}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
classList={{ hidden: !session() && !missingSession() && !persisted()?.title }}
|
||||
class="relative flex min-w-7 flex-shrink"
|
||||
classList={{
|
||||
hidden: !session() && !missingSession() && !persisted()?.title,
|
||||
"w-72 max-w-72": props.active(),
|
||||
"w-56 max-w-56": !props.active(),
|
||||
}}
|
||||
>
|
||||
<TabNavItem
|
||||
ref={(el) => {
|
||||
@@ -144,7 +148,11 @@ function DraftTabSlot(props: {
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.id}
|
||||
data-active={props.active()}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
class="relative flex min-w-7 flex-shrink"
|
||||
classList={{
|
||||
"w-72 max-w-72": props.active(),
|
||||
"w-56 max-w-56": !props.active(),
|
||||
}}
|
||||
>
|
||||
<DraftTabItem
|
||||
ref={(el) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import "@opentui/solid/runtime-plugin-support"
|
||||
import { NodeRuntime, NodeServices } from "@effect/platform-node"
|
||||
import { Effect } from "effect"
|
||||
import { Commands } from "./commands/commands"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { ModelApi, ProviderApi } from "./api/api.js"
|
||||
import type { ModelApi, ProviderApi, WebsearchApi } from "./api/api.js"
|
||||
|
||||
export type * from "./api/api.js"
|
||||
|
||||
export type WebSearchApi<E = never> = WebsearchApi<E>
|
||||
|
||||
export interface CatalogApi<E = never> {
|
||||
readonly provider: ProviderApi<E>
|
||||
readonly model: ModelApi<E>
|
||||
|
||||
@@ -1042,6 +1042,25 @@ export interface DebugApi<E = never> {
|
||||
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
|
||||
}
|
||||
|
||||
type Endpoint27_0Request = Parameters<RawClient["server.websearch"]["websearch.providers"]>[0]
|
||||
export type Endpoint27_0Input = { readonly location?: Endpoint27_0Request["query"]["location"] }
|
||||
export type Endpoint27_0Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.providers"]>>
|
||||
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint27_0Input) => Effect.Effect<Endpoint27_0Output, E>
|
||||
|
||||
type Endpoint27_1Request = Parameters<RawClient["server.websearch"]["websearch.query"]>[0]
|
||||
export type Endpoint27_1Input = {
|
||||
readonly location?: Endpoint27_1Request["query"]["location"]
|
||||
readonly query: Endpoint27_1Request["payload"]["query"]
|
||||
readonly providerID?: Endpoint27_1Request["payload"]["providerID"]
|
||||
}
|
||||
export type Endpoint27_1Output = EffectValue<ReturnType<RawClient["server.websearch"]["websearch.query"]>>
|
||||
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_1Input) => Effect.Effect<Endpoint27_1Output, E>
|
||||
|
||||
export interface WebsearchApi<E = never> {
|
||||
readonly providers: WebsearchProvidersOperation<E>
|
||||
readonly query: WebsearchQueryOperation<E>
|
||||
}
|
||||
|
||||
export interface AppApi<E = never> {
|
||||
readonly health: HealthApi<E>
|
||||
readonly server: ServerApi<E>
|
||||
@@ -1070,4 +1089,5 @@ export interface AppApi<E = never> {
|
||||
readonly projectCopy: ProjectCopyApi<E>
|
||||
readonly vcs: VcsApi<E>
|
||||
readonly debug: DebugApi<E>
|
||||
readonly websearch: WebsearchApi<E>
|
||||
}
|
||||
|
||||
@@ -1244,6 +1244,28 @@ const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
|
||||
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
|
||||
})
|
||||
|
||||
type Endpoint27_0Request = Parameters<RawClient["server.websearch"]["websearch.providers"]>[0]
|
||||
type Endpoint27_0Input = { readonly location?: Endpoint27_0Request["query"]["location"] }
|
||||
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) =>
|
||||
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint27_1Request = Parameters<RawClient["server.websearch"]["websearch.query"]>[0]
|
||||
type Endpoint27_1Input = {
|
||||
readonly location?: Endpoint27_1Request["query"]["location"]
|
||||
readonly query: Endpoint27_1Request["payload"]["query"]
|
||||
readonly providerID?: Endpoint27_1Request["payload"]["providerID"]
|
||||
}
|
||||
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) =>
|
||||
raw["websearch.query"]({
|
||||
query: { location: input["location"] },
|
||||
payload: { query: input["query"], providerID: input["providerID"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({
|
||||
providers: Endpoint27_0(raw),
|
||||
query: Endpoint27_1(raw),
|
||||
})
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
health: adaptGroup0(raw["server.health"]),
|
||||
server: adaptGroup1(raw["server.server"]),
|
||||
@@ -1272,6 +1294,7 @@ const adaptClient = (raw: RawClient) => ({
|
||||
projectCopy: adaptGroup24(raw["server.projectCopy"]),
|
||||
vcs: adaptGroup25(raw["server.vcs"]),
|
||||
debug: adaptGroup26(raw["server.debug"]),
|
||||
websearch: adaptGroup27(raw["server.websearch"]),
|
||||
})
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
|
||||
@@ -14,6 +14,7 @@ export type {
|
||||
PluginApi,
|
||||
ProviderApi,
|
||||
ReferenceApi,
|
||||
WebSearchApi,
|
||||
SessionApi,
|
||||
SkillApi,
|
||||
} from "./api.js"
|
||||
@@ -35,6 +36,7 @@ export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Pty } from "@opencode-ai/schema/pty"
|
||||
export { Question } from "@opencode-ai/schema/question"
|
||||
export { Reference } from "@opencode-ai/schema/reference"
|
||||
export { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
||||
export { Session } from "@opencode-ai/schema/session"
|
||||
export { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ModelApi = Client["model"]
|
||||
export type PluginApi = Client["plugin"]
|
||||
export type ProviderApi = Client["provider"]
|
||||
export type ReferenceApi = Client["reference"]
|
||||
export type WebSearchApi = Client["websearch"]
|
||||
export type SessionApi = Client["session"]
|
||||
export type SkillApi = Client["skill"]
|
||||
|
||||
|
||||
@@ -207,6 +207,10 @@ import type {
|
||||
DebugLocationListOutput,
|
||||
DebugLocationEvictInput,
|
||||
DebugLocationEvictOutput,
|
||||
WebsearchProvidersInput,
|
||||
WebsearchProvidersOutput,
|
||||
WebsearchQueryInput,
|
||||
WebsearchQueryOutput,
|
||||
} from "./types"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
@@ -1735,6 +1739,33 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
},
|
||||
websearch: {
|
||||
providers: (input?: WebsearchProvidersInput, requestOptions?: RequestOptions) =>
|
||||
request<WebsearchProvidersOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/websearch/provider`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
query: (input: WebsearchQueryInput, requestOptions?: RequestOptions) =>
|
||||
request<WebsearchQueryOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/websearch`,
|
||||
query: { location: input["location"] },
|
||||
body: { query: input["query"], providerID: input["providerID"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -539,6 +539,10 @@ export type VcsFileStatus = {
|
||||
status: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type WebSearchProvider = { id: string; name: string }
|
||||
|
||||
export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } }
|
||||
|
||||
export type SessionMessageModelSelected = {
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
@@ -1058,6 +1062,15 @@ export type FormCancelled = {
|
||||
data: { id: string; sessionID: string }
|
||||
}
|
||||
|
||||
export type WebsearchUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "websearch.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type SessionIdle = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -2355,6 +2368,7 @@ export type V2Event =
|
||||
| FormCreated
|
||||
| FormReplied
|
||||
| FormCancelled
|
||||
| WebsearchUpdated
|
||||
| SessionStatus2
|
||||
| SessionIdle
|
||||
| TuiPromptAppend
|
||||
@@ -4958,3 +4972,27 @@ export type DebugLocationEvictInput = {
|
||||
}
|
||||
|
||||
export type DebugLocationEvictOutput = void
|
||||
|
||||
export type WebsearchProvidersInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type WebsearchProvidersOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<WebSearchProvider>
|
||||
}
|
||||
|
||||
export type WebsearchQueryInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly query: { readonly query: string; readonly providerID?: string }["query"]
|
||||
readonly providerID?: { readonly query: string; readonly providerID?: string }["providerID"]
|
||||
}
|
||||
|
||||
export type WebsearchQueryOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: { providerID: string; results: Array<WebSearchResult> }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type {
|
||||
PluginApi,
|
||||
ProviderApi,
|
||||
ReferenceApi,
|
||||
WebSearchApi,
|
||||
SessionApi,
|
||||
SkillApi,
|
||||
} from "./api.js"
|
||||
|
||||
@@ -37,7 +37,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = 5_000
|
||||
let ownerHeld = false
|
||||
|
||||
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
|
||||
if (announced) return
|
||||
@@ -65,7 +64,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
const registration = await registered(options.file, true)
|
||||
|
||||
if (registration.service !== undefined) {
|
||||
ownerHeld = false
|
||||
spawnDelay = 5_000
|
||||
const service = registration.service
|
||||
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
||||
@@ -82,7 +80,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
if (failure !== undefined) throw failure
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
if (finished.some((item) => item.child.exitCode === 0)) {
|
||||
ownerHeld = true
|
||||
spawnDelay = Math.min(spawnDelay * 2, 30_000)
|
||||
}
|
||||
finished.forEach((item) => contenders.delete(item))
|
||||
|
||||
@@ -32,6 +32,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
"projectCopy",
|
||||
"vcs",
|
||||
"debug",
|
||||
"websearch",
|
||||
])
|
||||
expect(Object.keys(client.debug)).toEqual(["location"])
|
||||
expect(Object.keys(client.debug.location)).toEqual(["list", "evict"])
|
||||
@@ -41,6 +42,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.integration.connect)).toEqual(["key"])
|
||||
expect(Object.keys(client.integration.oauth)).toEqual(["connect", "status", "complete", "cancel"])
|
||||
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
|
||||
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
|
||||
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
||||
@@ -48,6 +50,37 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
||||
})
|
||||
|
||||
test("websearch.query uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json({
|
||||
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
|
||||
data: {
|
||||
providerID: "exa",
|
||||
results: [{ url: "https://example.com", title: "Result", content: "result", time: {} }],
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const result = await client.websearch.query({
|
||||
query: "opencode",
|
||||
providerID: "exa",
|
||||
location: { directory: "/tmp/project" },
|
||||
})
|
||||
|
||||
expect(result.data).toEqual({
|
||||
providerID: "exa",
|
||||
results: [{ url: "https://example.com", title: "Result", content: "result", time: {} }],
|
||||
})
|
||||
expect(request?.method).toBe("POST")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/websearch?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||
expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa" })
|
||||
})
|
||||
|
||||
test("server.get uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export async function GET() {
|
||||
const response = await fetch(
|
||||
"https://raw.githubusercontent.com/anomalyco/opencode/refs/heads/dev/packages/sdk/openapi.json",
|
||||
"https://raw.githubusercontent.com/anomalyco/opencode/refs/heads/dev/packages/protocol/openapi.json",
|
||||
)
|
||||
const json = await response.json()
|
||||
return json
|
||||
|
||||
@@ -27,6 +27,7 @@ import { ConfigModel } from "./config/model"
|
||||
import { ConfigPlugin } from "./config/plugin"
|
||||
import { ConfigProvider } from "./config/provider"
|
||||
import { ConfigReference } from "./config/reference"
|
||||
import { ConfigWebSearch } from "./config/websearch"
|
||||
import { ConfigToolOutput } from "./config/tool-output"
|
||||
import { ConfigVariable } from "./config/variable"
|
||||
import { ConfigWatcher } from "./config/watcher"
|
||||
@@ -108,6 +109,9 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
references: ConfigReference.Info.pipe(Schema.optional).annotate({
|
||||
description: "Named local directories or Git repositories available as external context",
|
||||
}),
|
||||
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
|
||||
description: "Web search provider selection",
|
||||
}),
|
||||
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
||||
description: "Ordered plugin enablement directives and external package declarations",
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export * as ConfigWebSearchPlugin from "./websearch"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.websearch",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.websearch.transform((websearch) => {
|
||||
const providerID = Config.latest(loaded.entries, "websearch")?.provider
|
||||
if (providerID) websearch.default.set(providerID)
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(ctx.websearch.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
export * as ConfigWebSearch from "./websearch"
|
||||
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigWebSearch.Info")({
|
||||
provider: WebSearch.ID,
|
||||
}) {}
|
||||
@@ -29,6 +29,7 @@ import { Pty } from "./pty"
|
||||
import { QuestionV2 } from "./question"
|
||||
import { Shell } from "./shell"
|
||||
import { Reference } from "./reference"
|
||||
import { WebSearch } from "./websearch"
|
||||
import { ReferenceInstructions } from "./reference/instructions"
|
||||
import { SessionRunnerLLM } from "./session/runner/llm"
|
||||
import { SessionRunnerModel } from "./session/runner/model"
|
||||
@@ -56,6 +57,7 @@ const locationServiceNodes = [
|
||||
AgentV2.node,
|
||||
CommandV2.node,
|
||||
Reference.node,
|
||||
WebSearch.node,
|
||||
Integration.node,
|
||||
Catalog.node,
|
||||
ModelResolver.node,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Integration } from "./integration"
|
||||
import { Location } from "./location"
|
||||
import { PluginHost } from "./plugin/host"
|
||||
import { PluginRuntime } from "./plugin/runtime"
|
||||
import { WebSearch } from "./websearch"
|
||||
import { Reference } from "./reference"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { State } from "./state"
|
||||
@@ -158,5 +159,6 @@ export const node = makeLocationNode({
|
||||
ToolHooks.node,
|
||||
PluginHooks.node,
|
||||
PluginRuntime.node,
|
||||
WebSearch.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export * as PluginHost from "./host"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { App } from "../app"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
@@ -23,6 +25,7 @@ import { Tool } from "../tool/tool"
|
||||
import { Tools } from "../tool/tools"
|
||||
import { ToolHooks } from "../tool/hooks"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { PluginHooks } from "./hooks"
|
||||
|
||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||
@@ -38,6 +41,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
const reference = yield* Reference.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const tools = yield* Tools.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
const toolHooks = yield* ToolHooks.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
@@ -247,79 +251,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
remove: (id) => draft.remove(Integration.ID.make(id)),
|
||||
method: {
|
||||
list: (id) => mutable(draft.method.list(Integration.ID.make(id))),
|
||||
update: (input) => {
|
||||
if ("authorize" in input) {
|
||||
const methodID = Integration.MethodID.make(input.method.id)
|
||||
const refresh = input.refresh
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { ...input.method, id: methodID },
|
||||
authorize: (inputs) =>
|
||||
input.authorize(inputs).pipe(
|
||||
Effect.map((authorization) => {
|
||||
if (authorization.mode === "auto") {
|
||||
return {
|
||||
...authorization,
|
||||
callback: authorization.callback.pipe(
|
||||
Effect.map((credential) =>
|
||||
Credential.OAuth.make({
|
||||
...credential,
|
||||
methodID: Integration.MethodID.make(credential.methodID),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...authorization,
|
||||
callback: (code: string) =>
|
||||
authorization.callback(code).pipe(
|
||||
Effect.map((credential) =>
|
||||
Credential.OAuth.make({
|
||||
...credential,
|
||||
methodID: Integration.MethodID.make(credential.methodID),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
),
|
||||
...(refresh
|
||||
? {
|
||||
refresh: (value: Credential.OAuth) =>
|
||||
refresh(value).pipe(
|
||||
Effect.map((next) =>
|
||||
Credential.OAuth.make({
|
||||
...next,
|
||||
methodID: Integration.MethodID.make(next.methodID),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(input.label ? { label: input.label } : {}),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (input.method.type === "env") {
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "env", names: input.method.names },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (input.method.type === "command") {
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: Schema.decodeUnknownSync(Integration.CommandMethod)(input.method),
|
||||
})
|
||||
return
|
||||
}
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "key", label: input.method.label },
|
||||
})
|
||||
},
|
||||
update: (input) => draft.method.update(methodImplementation(input)),
|
||||
remove: (id, method) =>
|
||||
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
|
||||
},
|
||||
@@ -430,6 +362,32 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
})
|
||||
},
|
||||
},
|
||||
websearch: {
|
||||
providers: () => response(websearch.providers()),
|
||||
query: (input) =>
|
||||
response(
|
||||
websearch.query({
|
||||
query: input.query,
|
||||
providerID: input.providerID === undefined ? undefined : WebSearch.ID.make(input.providerID),
|
||||
}),
|
||||
),
|
||||
reload: websearch.reload,
|
||||
transform: (callback) =>
|
||||
websearch.transform((draft) => {
|
||||
callback({
|
||||
add: (definition) =>
|
||||
draft.add({
|
||||
id: WebSearch.ID.make(definition.id),
|
||||
name: definition.name,
|
||||
execute: definition.execute,
|
||||
}),
|
||||
default: {
|
||||
get: draft.default.get,
|
||||
set: (providerID) => draft.default.set(WebSearch.ID.make(providerID)),
|
||||
},
|
||||
})
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback) => hooks.register("session", name, callback),
|
||||
create: (input) =>
|
||||
@@ -449,3 +407,50 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
},
|
||||
} satisfies Plugin.Context
|
||||
})
|
||||
|
||||
function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation {
|
||||
if ("authorize" in input) {
|
||||
const refresh = input.refresh
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { ...input.method, id: Integration.MethodID.make(input.method.id) },
|
||||
authorize: (inputs) =>
|
||||
input.authorize(inputs).pipe(
|
||||
Effect.map((authorization) => {
|
||||
if (authorization.mode === "auto") {
|
||||
return {
|
||||
...authorization,
|
||||
callback: authorization.callback.pipe(Effect.map(credential)),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...authorization,
|
||||
callback: (code: string) => authorization.callback(code).pipe(Effect.map(credential)),
|
||||
}
|
||||
}),
|
||||
),
|
||||
...(refresh ? { refresh: (value: Credential.OAuth) => refresh(value).pipe(Effect.map(credential)) } : {}),
|
||||
...(input.label ? { label: input.label } : {}),
|
||||
}
|
||||
}
|
||||
if (input.method.type === "env") {
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "env", names: input.method.names },
|
||||
}
|
||||
}
|
||||
if (input.method.type === "command") {
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: Schema.decodeUnknownSync(Integration.CommandMethod)(input.method),
|
||||
}
|
||||
}
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { type: "key", label: input.method.label },
|
||||
}
|
||||
}
|
||||
|
||||
function credential(value: CredentialOAuth) {
|
||||
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ConfigProviderPlugin } from "../config/plugin/provider"
|
||||
import { ConfigPolicyPlugin } from "../config/plugin/policy"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
|
||||
import { EventV2 } from "../event"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { Form } from "../form"
|
||||
@@ -21,12 +22,14 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Image } from "../image"
|
||||
import { Integration } from "../integration"
|
||||
import { KV } from "../kv"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Reference } from "../reference"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { Shell } from "../shell"
|
||||
@@ -50,6 +53,7 @@ import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { WebSearchPlugins } from "./websearch"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
import { SkillPlugin } from "./skill"
|
||||
import { SystemPromptPlugin } from "./system-prompt"
|
||||
@@ -70,6 +74,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const image = yield* Image.Service
|
||||
const integration = yield* Integration.Service
|
||||
const kv = yield* KV.Service
|
||||
const location = yield* Location.Service
|
||||
const locationMutation = yield* LocationMutation.Service
|
||||
const models = yield* ModelsDev.Service
|
||||
@@ -79,12 +84,12 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const form = yield* Form.Service
|
||||
const read = yield* ReadToolFileSystem.Service
|
||||
const reference = yield* Reference.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const instructions = yield* SessionInstructions.Service
|
||||
const shell = yield* Shell.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const tools = yield* Tools.Service
|
||||
const websearch = yield* WebSearchTool.ConfigService
|
||||
const wellknown = yield* WellKnown.Service
|
||||
return Context.mergeAll(
|
||||
Context.make(AgentV2.Service, agent),
|
||||
@@ -99,6 +104,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(HttpClient.HttpClient, http),
|
||||
Context.make(Image.Service, image),
|
||||
Context.make(Integration.Service, integration),
|
||||
Context.make(KV.Service, kv),
|
||||
Context.make(Location.Service, location),
|
||||
Context.make(LocationMutation.Service, locationMutation),
|
||||
Context.make(ModelsDev.Service, models),
|
||||
@@ -108,12 +114,12 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Form.Service, form),
|
||||
Context.make(ReadToolFileSystem.Service, read),
|
||||
Context.make(Reference.Service, reference),
|
||||
Context.make(WebSearch.Service, websearch),
|
||||
Context.make(Ripgrep.Service, ripgrep),
|
||||
Context.make(SessionInstructions.Service, instructions),
|
||||
Context.make(Shell.Service, shell),
|
||||
Context.make(SkillV2.Service, skill),
|
||||
Context.make(Tools.Service, tools),
|
||||
Context.make(WebSearchTool.ConfigService, websearch),
|
||||
Context.make(WellKnown.Service, wellknown),
|
||||
)
|
||||
})
|
||||
@@ -132,6 +138,7 @@ const pre = [
|
||||
...SystemPromptPlugin.Plugins,
|
||||
ModelsDevPlugin,
|
||||
...ProviderPlugins,
|
||||
...WebSearchPlugins,
|
||||
PatchTool.Plugin,
|
||||
EditTool.Plugin,
|
||||
GlobTool.Plugin,
|
||||
@@ -153,6 +160,7 @@ const post = [
|
||||
ConfigCommandPlugin.Plugin,
|
||||
ConfigSkillPlugin.Plugin,
|
||||
ConfigProviderPlugin.Plugin,
|
||||
ConfigWebSearchPlugin.Plugin,
|
||||
VariantPlugin.Plugin,
|
||||
ConfigPolicyPlugin.Plugin,
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { DateTime, Effect, Scope, Stream } from "effect"
|
||||
import { Tool } from "../tool/tool"
|
||||
|
||||
@@ -197,6 +198,31 @@ export function fromPromise(plugin: Plugin) {
|
||||
hook: (name, callback) =>
|
||||
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
},
|
||||
websearch: {
|
||||
providers: (input) => run(host.websearch.providers(input)),
|
||||
query: (input) =>
|
||||
run(
|
||||
host.websearch.query({
|
||||
...input,
|
||||
providerID: input.providerID === undefined ? undefined : WebSearch.ID.make(input.providerID),
|
||||
}),
|
||||
),
|
||||
reload: () => run(host.websearch.reload()),
|
||||
transform: (callback) =>
|
||||
register(
|
||||
host.websearch.transform((draft) => {
|
||||
callback({
|
||||
add: (definition) =>
|
||||
draft.add({
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
execute: (input) => attempt((signal) => definition.execute(input, { signal })),
|
||||
}),
|
||||
default: draft.default,
|
||||
})
|
||||
}),
|
||||
),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback) =>
|
||||
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
@@ -270,6 +296,10 @@ export function fromPromise(plugin: Plugin) {
|
||||
})
|
||||
}
|
||||
|
||||
function attempt<A>(evaluate: (signal: AbortSignal) => PromiseLike<A>) {
|
||||
return Effect.tryPromise({ try: evaluate, catch: (cause) => cause })
|
||||
}
|
||||
|
||||
function model(input: { readonly id: string; readonly providerID: string; readonly variant?: string }) {
|
||||
return Model.Ref.make({
|
||||
id: Model.ID.make(input.id),
|
||||
|
||||
@@ -68,7 +68,7 @@ every field, examples, config locations, and links to dedicated feature guides.
|
||||
For any request to migrate OpenCode configuration, agents, commands, skills,
|
||||
plugins, integrations, or other behavior from V1 to V2, read the full
|
||||
[migration guide](https://v2.opencode.ai/docs/migrate-v1) before acting. In
|
||||
the repository, its source is `packages/docs/migrate-v1.mdx`.
|
||||
the repository, its source is `packages/www/content/docs/(Get started)/migrate-v1.mdx`.
|
||||
|
||||
V1 config files and `.opencode/` definitions are intended to remain compatible.
|
||||
The only intentional breaking changes are the server API and plugin API. Native
|
||||
|
||||
@@ -20,6 +20,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Image } from "../image"
|
||||
import { Integration } from "../integration"
|
||||
import { KV } from "../kv"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
@@ -34,7 +35,7 @@ import { Shell } from "../shell"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { ReadToolFileSystem } from "../tool/read-filesystem"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import { WebSearchTool } from "../tool/websearch"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { WellKnown } from "../wellknown"
|
||||
import { PluginInternal } from "./internal"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
@@ -289,6 +290,7 @@ export const node = makeLocationNode({
|
||||
httpClient,
|
||||
Image.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
@@ -303,7 +305,7 @@ export const node = makeLocationNode({
|
||||
Shell.node,
|
||||
SkillV2.node,
|
||||
ToolRegistry.toolsNode,
|
||||
WebSearchTool.configNode,
|
||||
WebSearch.node,
|
||||
WellKnown.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
export * as WebSearchExa from "./exa"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { WebSearchMcp } from "./mcp"
|
||||
|
||||
export const endpoint = "https://mcp.exa.ai/mcp"
|
||||
|
||||
const McpInput = Schema.Struct({
|
||||
query: Schema.String,
|
||||
numResults: Schema.Number.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const McpOutput = Schema.Struct({
|
||||
content: Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
_meta: Schema.Struct({ searchTime: Schema.Number }).pipe(Schema.optional),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
id: "opencode.websearch.exa",
|
||||
effect: Effect.fn("WebSearchExa.Plugin")(function* (ctx) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.update("exa", (integration) => (integration.name = "Exa"))
|
||||
draft.method.update({
|
||||
integrationID: "exa",
|
||||
method: { type: "key", label: "API key (optional)" },
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: "exa",
|
||||
method: { type: "env", names: ["EXA_API_KEY"] },
|
||||
})
|
||||
})
|
||||
yield* ctx.websearch.transform((draft) => {
|
||||
draft.add({
|
||||
id: "exa",
|
||||
name: "Exa",
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("exa")
|
||||
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
|
||||
const url = new URL(endpoint)
|
||||
if (credential?.type === "key") url.searchParams.set("exaApiKey", credential.key)
|
||||
const result = yield* WebSearchMcp.call(
|
||||
http,
|
||||
url.toString(),
|
||||
"web_search_exa",
|
||||
{ input: McpInput, output: McpOutput },
|
||||
{ query: input.query, numResults: 8 },
|
||||
)
|
||||
const content = result?.content.find((item) => item.text)
|
||||
return content ? parseResults(content.text) : []
|
||||
}),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
function parseResults(text: string) {
|
||||
return text.split(/\n\n---\n\n/).flatMap((block) => {
|
||||
const url = block.match(/^URL:\s*(.+)$/m)?.[1]?.trim()
|
||||
if (!url) return []
|
||||
const title = block.match(/^Title:\s*(.+)$/m)?.[1]?.trim()
|
||||
const publishedText = block.match(/^Published:\s*(.+)$/m)?.[1]?.trim()
|
||||
const published = publishedText && publishedText !== "N/A" ? Date.parse(publishedText) : undefined
|
||||
const content = block.match(/^(?:Highlights|Text):\s*\n?([\s\S]*)$/m)?.[1]?.trim()
|
||||
return [
|
||||
{
|
||||
url,
|
||||
...(title && title !== "N/A" ? { title } : {}),
|
||||
...(content ? { content } : {}),
|
||||
time: { ...(published !== undefined && Number.isFinite(published) ? { published } : {}) },
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { WebSearchExa } from "./exa"
|
||||
import { WebSearchParallel } from "./parallel"
|
||||
|
||||
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const
|
||||
@@ -0,0 +1,68 @@
|
||||
export * as WebSearchMcp from "./mcp"
|
||||
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { collectBoundedResponseBody } from "../../tool/http-body"
|
||||
|
||||
export const MAX_RESPONSE_BYTES = 256 * 1024
|
||||
|
||||
export const parseResponse = <F extends Schema.Struct.Fields>(body: string, result: Schema.Struct<F>) => {
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ result })))
|
||||
const parse = (payload: string) => {
|
||||
const trimmed = payload.trim()
|
||||
if (!trimmed.startsWith("{")) return Effect.succeed(undefined)
|
||||
return decode(trimmed).pipe(Effect.map((response) => response.result))
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const trimmed = body.trim()
|
||||
const direct = trimmed ? yield* parse(trimmed) : undefined
|
||||
if (direct) return direct
|
||||
for (const line of body.split("\n")) {
|
||||
if (!line.startsWith("data: ")) continue
|
||||
const data = yield* parse(line.substring(6))
|
||||
if (data) return data
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const call = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fields>(
|
||||
http: HttpClient.HttpClient,
|
||||
url: string,
|
||||
tool: string,
|
||||
schema: { readonly input: Schema.Struct<F>; readonly output: Schema.Struct<R> },
|
||||
value: Schema.Struct.Type<F>,
|
||||
headers: Record<string, string> = {},
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.accept("application/json, text/event-stream"),
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.schemaBodyJson(
|
||||
Schema.Struct({
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
id: Schema.Literal(1),
|
||||
method: Schema.Literal("tools/call"),
|
||||
params: Schema.Struct({ name: Schema.String, arguments: schema.input }),
|
||||
}),
|
||||
)({
|
||||
jsonrpc: "2.0" as const,
|
||||
id: 1 as const,
|
||||
method: "tools/call" as const,
|
||||
params: { name: tool, arguments: value },
|
||||
}),
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
const response = yield* HttpClient.filterStatusOk(http).execute(request)
|
||||
const body = yield* collectBoundedResponseBody(
|
||||
response,
|
||||
MAX_RESPONSE_BYTES,
|
||||
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
|
||||
)
|
||||
return yield* parseResponse(body.toString("utf8"), schema.output)
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
export * as WebSearchParallel from "./parallel"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { App } from "../../app"
|
||||
import { WebSearchMcp } from "./mcp"
|
||||
|
||||
export const endpoint = "https://search.parallel.ai/mcp"
|
||||
|
||||
const McpInput = Schema.Struct({
|
||||
objective: Schema.String,
|
||||
search_queries: Schema.Array(Schema.String),
|
||||
model_name: Schema.String.check(Schema.isMaxLength(100)).pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const SearchResponse = Schema.Struct({
|
||||
search_id: Schema.String,
|
||||
results: Schema.Array(
|
||||
Schema.Struct({
|
||||
url: Schema.String,
|
||||
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
||||
publish_date: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
||||
excerpts: Schema.Array(Schema.String),
|
||||
}),
|
||||
),
|
||||
warnings: Schema.NullOr(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literals(["spec_validation_warning", "input_validation_warning", "warning"]),
|
||||
message: Schema.String,
|
||||
detail: Schema.NullOr(Schema.Record(Schema.String, Schema.Json)).pipe(Schema.optional),
|
||||
}),
|
||||
),
|
||||
).pipe(Schema.optional),
|
||||
usage: Schema.NullOr(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
name: Schema.String,
|
||||
count: Schema.Int,
|
||||
}),
|
||||
),
|
||||
).pipe(Schema.optional),
|
||||
session_id: Schema.String,
|
||||
})
|
||||
const McpOutput = Schema.Struct({
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
|
||||
structuredContent: SearchResponse,
|
||||
})
|
||||
|
||||
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
id: "opencode.websearch.parallel",
|
||||
effect: Effect.fn("WebSearchParallel.Plugin")(function* (ctx) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.update("parallel", (integration) => (integration.name = "Parallel"))
|
||||
draft.method.update({
|
||||
integrationID: "parallel",
|
||||
method: { type: "key", label: "API key (optional)" },
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: "parallel",
|
||||
method: { type: "env", names: ["PARALLEL_API_KEY"] },
|
||||
})
|
||||
})
|
||||
yield* ctx.websearch.transform((draft) => {
|
||||
draft.add({
|
||||
id: "parallel",
|
||||
name: "Parallel",
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("parallel")
|
||||
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
|
||||
const result = yield* WebSearchMcp.call(
|
||||
http,
|
||||
endpoint,
|
||||
"web_search",
|
||||
{ input: McpInput, output: McpOutput },
|
||||
{
|
||||
objective: input.query,
|
||||
search_queries: [input.query],
|
||||
},
|
||||
{
|
||||
"User-Agent": App.useragent(ctx.app),
|
||||
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
|
||||
},
|
||||
)
|
||||
return (
|
||||
result?.structuredContent.results.map((item) => {
|
||||
const published = item.publish_date ? Date.parse(item.publish_date) : undefined
|
||||
return {
|
||||
url: item.url,
|
||||
...(item.title ? { title: item.title } : {}),
|
||||
...(item.excerpts.length ? { content: item.excerpts.join("\n\n") } : {}),
|
||||
time: { ...(published !== undefined && Number.isFinite(published) ? { published } : {}) },
|
||||
}
|
||||
}) ?? []
|
||||
)
|
||||
}),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -654,6 +654,12 @@ const layer = Layer.effectDiscard(
|
||||
input: event.data.input,
|
||||
timeCreated: event.created,
|
||||
})
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_updated: DateTime.toEpochMillis(event.created) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Compaction.Admitted, (event) =>
|
||||
|
||||
@@ -18,10 +18,10 @@ export const name = "glob"
|
||||
export const Input = Schema.Struct({
|
||||
pattern: FileSystem.GlobInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
|
||||
path: RelativePath.pipe(Schema.optional).annotate({
|
||||
description: "Relative directory to search. Defaults to the active Location.",
|
||||
description: "Directory to search. Defaults to the current working directory.",
|
||||
}),
|
||||
limit: FileSystem.GlobInput.fields.limit.annotate({
|
||||
description: `Maximum results to return (default: ${FileSystem.DEFAULT_SEARCH_LIMIT})`,
|
||||
description: `Maximum number of matching files to return (default: ${FileSystem.DEFAULT_SEARCH_LIMIT})`,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -55,13 +55,14 @@ export const Plugin = {
|
||||
name,
|
||||
Tool.make({
|
||||
description:
|
||||
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
|
||||
'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
|
||||
const source = { type: "tool" as const, messageID: context.messageID, callID: context.callID }
|
||||
const target = yield* mutation.resolve({ path: input.path ?? ".", kind: "directory" })
|
||||
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
@@ -75,22 +76,26 @@ export const Plugin = {
|
||||
resources: [input.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: input.path ?? ".",
|
||||
path: input.path,
|
||||
root: searchPath ?? ".",
|
||||
path: searchPath,
|
||||
limit: input.limit,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
yield* fs
|
||||
const info = yield* fs
|
||||
.stat(target.canonical)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
|
||||
),
|
||||
)
|
||||
const root = path.resolve(location.directory, input.path ?? ".")
|
||||
if (info.type !== "Directory")
|
||||
return yield* Effect.fail(
|
||||
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
|
||||
)
|
||||
const root = path.resolve(location.directory, searchPath ?? ".")
|
||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||
const entries = yield* ripgrep
|
||||
.glob({
|
||||
|
||||
@@ -15,7 +15,9 @@ import { Tool } from "./tool"
|
||||
export const name = "grep"
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
pattern: FileSystem.GrepInput.fields.pattern.annotate({
|
||||
pattern: FileSystem.GrepInput.fields.pattern.check(
|
||||
Schema.isMinLength(1, { message: "Pattern must not be empty" }),
|
||||
).annotate({
|
||||
description: "Regex pattern to search for in file contents",
|
||||
}),
|
||||
path: RelativePath.pipe(Schema.optional).annotate({
|
||||
@@ -33,7 +35,7 @@ export const Output = Schema.Array(FileSystem.Match)
|
||||
type ModelOutput = typeof Output.Encoded
|
||||
|
||||
/** Format raw search matches into the familiar concise model output. */
|
||||
export const toModelOutput = (output: ModelOutput) => {
|
||||
export const toModelOutput = (output: ModelOutput, truncated = false) => {
|
||||
const lines = output.length === 0 ? ["No files found"] : [`Found ${output.length} matches`]
|
||||
let current = ""
|
||||
for (const match of output) {
|
||||
@@ -44,6 +46,11 @@ export const toModelOutput = (output: ModelOutput) => {
|
||||
}
|
||||
lines.push(` Line ${match.line}: ${match.text}`)
|
||||
}
|
||||
if (truncated)
|
||||
lines.push(
|
||||
"",
|
||||
`(Results are truncated: showing first ${output.length} results. Consider using a more specific path or pattern.)`,
|
||||
)
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
@@ -89,13 +96,14 @@ export const Plugin = {
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
|
||||
),
|
||||
)
|
||||
return yield* ripgrep
|
||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||
const matches = yield* ripgrep
|
||||
.grep({
|
||||
cwd: info?.type === "Directory" ? target : path.dirname(target),
|
||||
pattern: input.pattern,
|
||||
file: info?.type === "File" ? path.basename(target) : undefined,
|
||||
include: input.include,
|
||||
limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
|
||||
limit: limit + 1,
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((result) =>
|
||||
@@ -118,16 +126,18 @@ export const Plugin = {
|
||||
),
|
||||
),
|
||||
)
|
||||
return { matches: matches.slice(0, limit), truncated: matches.length > limit }
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
Effect.map((result) => ({
|
||||
output: result.matches,
|
||||
content: toModelOutput(
|
||||
output.map((match) => ({
|
||||
result.matches.map((match) => ({
|
||||
...match,
|
||||
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
|
||||
})),
|
||||
result.truncated,
|
||||
),
|
||||
metadata: { matches: output.length },
|
||||
metadata: { matches: result.matches.length, truncated: result.truncated },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
|
||||
@@ -17,8 +17,8 @@ export const description = `Use this tool when you need to ask the user question
|
||||
4. Offer choices to the user about what direction to take.
|
||||
|
||||
Usage notes:
|
||||
- When \`custom\` is enabled (default), a "Type your own answer" option is added automatically; don't include "Other" or catch-all options
|
||||
- Answers are returned as arrays of labels; set \`multiple: true\` to allow selecting more than one
|
||||
- A "Type your own answer" option is added automatically; don't include a separate option for free form answers
|
||||
- Set \`multiple: true\` to allow selecting more than one option
|
||||
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
|
||||
@@ -22,9 +22,7 @@ export const Output = Schema.Struct({
|
||||
output: Schema.String,
|
||||
})
|
||||
export const description = [
|
||||
"Load a specialized skill when the task at hand matches one of the available skills in the instructions.",
|
||||
"",
|
||||
"Use this tool to inject the skill's instructions and resources into the current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc. in the same directory as the skill.",
|
||||
"Load a specialized skill's instructions and resources into the current conversation when the task at hand matches its description.",
|
||||
"",
|
||||
"The skill ID must match one of the available skills in the instructions.",
|
||||
].join("\n")
|
||||
|
||||
@@ -2,262 +2,122 @@ export * as WebSearchTool from "./websearch"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Context, Duration, Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "../app"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Form } from "../form"
|
||||
import { KV } from "../kv"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { collectBoundedResponseBody } from "./http-body"
|
||||
import { checksum } from "../util/encode"
|
||||
import { WebSearch } from "../websearch"
|
||||
|
||||
export const name = "websearch"
|
||||
export const NO_RESULTS = "No search results found. Please try a different query."
|
||||
export const EXA_URL = "https://mcp.exa.ai/mcp"
|
||||
export const PARALLEL_URL = "https://search.parallel.ai/mcp"
|
||||
export const MAX_NUM_RESULTS = 20
|
||||
export const MAX_CONTEXT_CHARACTERS = 50_000
|
||||
export const MAX_RESPONSE_BYTES = 256 * 1024
|
||||
|
||||
/**
|
||||
* Provider-independent local web search retained in V2 core for launch parity.
|
||||
* This invokes the legacy Exa/Parallel product backends itself. It is distinct
|
||||
* from provider-hosted web search tools, which remain route-owned and execute
|
||||
* at the model provider. Ownership of this compromise can be revisited later.
|
||||
*/
|
||||
export const description = `Search the web using the session's local web search provider. Use this for current information beyond knowledge cutoff.
|
||||
|
||||
This is a provider-independent local tool backed by Exa or Parallel. Provider-hosted web search tools are separate and execute at the model provider.
|
||||
|
||||
Optional controls support result count, live crawling ('fallback' or 'preferred'), search type ('auto', 'fast', or 'deep'), and maximum context characters.
|
||||
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
|
||||
|
||||
The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
query: Schema.String.annotate({ description: "Websearch query" }),
|
||||
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({
|
||||
description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})`,
|
||||
}),
|
||||
livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({
|
||||
description:
|
||||
"Live crawl mode - 'fallback': use live crawling as backup if cached unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
|
||||
}),
|
||||
type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({
|
||||
description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
|
||||
}),
|
||||
contextMaxCharacters: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate(
|
||||
{
|
||||
description: `Maximum characters for context string optimized for models (default: 10000, maximum: ${MAX_CONTEXT_CHARACTERS})`,
|
||||
},
|
||||
),
|
||||
})
|
||||
|
||||
export const Provider = Schema.Literals(["exa", "parallel"])
|
||||
export type Provider = typeof Provider.Type
|
||||
|
||||
export interface Config {
|
||||
readonly provider?: Provider
|
||||
readonly enableExa: boolean
|
||||
readonly enableParallel: boolean
|
||||
readonly exaApiKey?: string
|
||||
readonly parallelApiKey?: string
|
||||
}
|
||||
|
||||
export class ConfigService extends Context.Service<ConfigService, Config>()("@opencode/v2/WebSearchConfig") {}
|
||||
|
||||
/** Isolates the retained product environment contract from the generic tool implementation. */
|
||||
export const defaultConfigLayer = Layer.sync(ConfigService, () =>
|
||||
ConfigService.of({
|
||||
provider:
|
||||
process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel"
|
||||
? process.env.OPENCODE_WEBSEARCH_PROVIDER
|
||||
: undefined,
|
||||
enableExa:
|
||||
["1", "true"].includes(process.env.OPENCODE_EXPERIMENTAL?.toLowerCase() ?? "") ||
|
||||
["1", "true"].includes(process.env.OPENCODE_ENABLE_EXA?.toLowerCase() ?? "") ||
|
||||
["1", "true"].includes(process.env.OPENCODE_EXPERIMENTAL_EXA?.toLowerCase() ?? ""),
|
||||
enableParallel:
|
||||
["1", "true"].includes(process.env.OPENCODE_ENABLE_PARALLEL?.toLowerCase() ?? "") ||
|
||||
["1", "true"].includes(process.env.OPENCODE_EXPERIMENTAL_PARALLEL?.toLowerCase() ?? ""),
|
||||
exaApiKey: process.env.EXA_API_KEY,
|
||||
parallelApiKey: process.env.PARALLEL_API_KEY,
|
||||
}),
|
||||
)
|
||||
|
||||
export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] })
|
||||
|
||||
export function selectProvider(
|
||||
sessionID: string,
|
||||
flags: Pick<Config, "enableExa" | "enableParallel"> = { enableExa: false, enableParallel: false },
|
||||
override?: Provider,
|
||||
): Provider {
|
||||
if (override) return override
|
||||
if (flags.enableParallel) return "parallel"
|
||||
if (flags.enableExa) return "exa"
|
||||
return Number.parseInt(checksum(sessionID) ?? "0", 36) % 2 === 0 ? "exa" : "parallel"
|
||||
}
|
||||
|
||||
const McpResult = Schema.Struct({
|
||||
result: Schema.Struct({
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.String })),
|
||||
}),
|
||||
})
|
||||
const decodeMcpResult = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult))
|
||||
|
||||
const parsePayload = (payload: string) =>
|
||||
Effect.gen(function* () {
|
||||
const trimmed = payload.trim()
|
||||
if (!trimmed.startsWith("{")) return undefined
|
||||
return (yield* decodeMcpResult(trimmed)).result.content.find((item) => item.text)?.text
|
||||
})
|
||||
|
||||
export const parseResponse = Effect.fn("WebSearchTool.parseResponse")(function* (body: string) {
|
||||
const trimmed = body.trim()
|
||||
const direct = trimmed ? yield* parsePayload(trimmed) : undefined
|
||||
if (direct) return direct
|
||||
for (const line of body.split("\n")) {
|
||||
if (!line.startsWith("data: ")) continue
|
||||
const data = yield* parsePayload(line.substring(6))
|
||||
if (data) return data
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const ExaArgs = Schema.Struct({
|
||||
query: Schema.String,
|
||||
type: Schema.String,
|
||||
numResults: Schema.Number,
|
||||
livecrawl: Schema.String,
|
||||
contextMaxCharacters: Schema.optional(Schema.Number),
|
||||
})
|
||||
const ParallelArgs = Schema.Struct({
|
||||
objective: Schema.String,
|
||||
search_queries: Schema.Array(Schema.String),
|
||||
session_id: Schema.String,
|
||||
})
|
||||
const McpRequest = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
|
||||
Schema.Struct({
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
id: Schema.Literal(1),
|
||||
method: Schema.Literal("tools/call"),
|
||||
params: Schema.Struct({ name: Schema.String, arguments: args }),
|
||||
})
|
||||
|
||||
const exaUrl = (apiKey: string | undefined) => {
|
||||
if (!apiKey) return EXA_URL
|
||||
const url = new URL(EXA_URL)
|
||||
url.searchParams.set("exaApiKey", apiKey)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const callMcp = <F extends Schema.Struct.Fields>(
|
||||
http: HttpClient.HttpClient,
|
||||
url: string,
|
||||
tool: string,
|
||||
args: Schema.Struct<F>,
|
||||
value: Schema.Struct.Type<F>,
|
||||
headers: Record<string, string> = {},
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.accept("application/json, text/event-stream"),
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.schemaBodyJson(McpRequest(args))({
|
||||
jsonrpc: "2.0" as const,
|
||||
id: 1 as const,
|
||||
method: "tools/call" as const,
|
||||
params: { name: tool, arguments: value },
|
||||
}),
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
const response = yield* HttpClient.filterStatusOk(http).execute(request)
|
||||
const body = yield* collectBoundedResponseBody(
|
||||
response,
|
||||
MAX_RESPONSE_BYTES,
|
||||
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
|
||||
)
|
||||
return yield* parseResponse(body.toString("utf8"))
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const Output = Schema.Struct({
|
||||
provider: Provider,
|
||||
text: Schema.String,
|
||||
provider: WebSearch.ID,
|
||||
results: Schema.Array(WebSearch.Result),
|
||||
})
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.websearch",
|
||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const config = yield* ConfigService
|
||||
const permission = yield* PermissionV2.Service
|
||||
const forms = yield* Form.Service
|
||||
const kv = yield* KV.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
name,
|
||||
Tool.make({
|
||||
{
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
const provider = selectProvider(context.sessionID, config, config.provider)
|
||||
return Effect.gen(function* () {
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.query],
|
||||
save: ["*"],
|
||||
metadata: { ...input, provider },
|
||||
metadata: input,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.messageID, callID: context.callID },
|
||||
})
|
||||
|
||||
const text =
|
||||
provider === "exa"
|
||||
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
|
||||
query: input.query,
|
||||
type: input.type || "auto",
|
||||
numResults: input.numResults || 8,
|
||||
livecrawl: input.livecrawl || "fallback",
|
||||
contextMaxCharacters: input.contextMaxCharacters,
|
||||
const result = yield* ctx.websearch.query(input).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
|
||||
return Effect.gen(function* () {
|
||||
const providers = (yield* ctx.websearch.providers()).data
|
||||
if (providers.length === 0) return yield* new WebSearch.ProviderRequiredError()
|
||||
const response = yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Choose a web search provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
title: "Provider",
|
||||
description: "This becomes your default and can be changed later in configuration.",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: [
|
||||
...providers.map((provider) => ({ value: provider.id, label: provider.name })),
|
||||
{ value: "__disable__", label: "Disable web search" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
: yield* callMcp(
|
||||
http,
|
||||
PARALLEL_URL,
|
||||
"web_search",
|
||||
ParallelArgs,
|
||||
{
|
||||
objective: input.query,
|
||||
search_queries: [input.query],
|
||||
session_id: context.sessionID,
|
||||
// V2 invocation context does not safely expose the model yet.
|
||||
},
|
||||
{
|
||||
"User-Agent": App.useragent(ctx.app),
|
||||
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
|
||||
},
|
||||
)
|
||||
if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
const answer = response.answer.provider
|
||||
if (answer === "__disable__") {
|
||||
yield* kv.set("websearch:provider", false)
|
||||
return yield* new WebSearch.DisabledError()
|
||||
}
|
||||
if (typeof answer !== "string" || !providers.some((provider) => provider.id === answer))
|
||||
return yield* new WebSearch.ProviderRequiredError()
|
||||
yield* kv.set("websearch:provider", answer)
|
||||
return yield* ctx.websearch.query(input)
|
||||
})
|
||||
}),
|
||||
)
|
||||
const output = {
|
||||
provider,
|
||||
text: text ?? NO_RESULTS,
|
||||
provider: result.data.providerID,
|
||||
results: result.data.results,
|
||||
}
|
||||
return { output, content: output.text, metadata: { provider: output.provider } }
|
||||
const content = output.results.length
|
||||
? output.results
|
||||
.map((result) => {
|
||||
const title = result.title ?? result.url
|
||||
const published = result.time.published
|
||||
? `\nPublished: ${new Date(result.time.published).toISOString()}`
|
||||
: ""
|
||||
return `## [${title}](${result.url})${published}${result.content ? `\n\n${result.content}` : ""}`
|
||||
})
|
||||
.join("\n\n")
|
||||
: NO_RESULTS
|
||||
return { output, content, metadata: { provider: output.provider } }
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
if ((yield* kv.get("websearch:provider")) === false) delete event.tools[name]
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
export * as WebSearch from "./websearch"
|
||||
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { EventV2 } from "./event"
|
||||
import { KV } from "./kv"
|
||||
import { State } from "./state"
|
||||
|
||||
export const ID = WebSearch.ID
|
||||
export type ID = WebSearch.ID
|
||||
|
||||
export const Provider = WebSearch.Provider
|
||||
export type Provider = WebSearch.Provider
|
||||
|
||||
export const Event = WebSearch.Event
|
||||
|
||||
export const Input = WebSearch.Input
|
||||
export type Input = WebSearch.Input
|
||||
export type ProviderInput = WebSearch.ProviderInput
|
||||
|
||||
export const Result = WebSearch.Result
|
||||
export type Result = WebSearch.Result
|
||||
|
||||
export const Response = WebSearch.Response
|
||||
export type Response = WebSearch.Response
|
||||
|
||||
export interface ProviderImplementation extends Provider {
|
||||
readonly execute: (input: ProviderInput) => Effect.Effect<readonly Result[], unknown>
|
||||
}
|
||||
|
||||
export class ProviderRequiredError extends Schema.TaggedErrorClass<ProviderRequiredError>()(
|
||||
"WebSearch.ProviderRequired",
|
||||
{},
|
||||
) {}
|
||||
|
||||
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
|
||||
"WebSearch.ProviderNotFound",
|
||||
{
|
||||
providerID: ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class DisabledError extends Schema.TaggedErrorClass<DisabledError>()("WebSearch.Disabled", {}) {}
|
||||
|
||||
export class RequestError extends Schema.TaggedErrorClass<RequestError>()("WebSearch.Request", {
|
||||
providerID: ID,
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export type Error = ProviderRequiredError | ProviderNotFoundError | DisabledError | RequestError
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly providers: () => Effect.Effect<readonly Provider[]>
|
||||
readonly default: () => Effect.Effect<Provider | undefined, DisabledError>
|
||||
readonly query: (input: Input) => Effect.Effect<Response, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/WebSearch") {}
|
||||
|
||||
type Data = {
|
||||
readonly providers: Map<ID, ProviderImplementation>
|
||||
defaultProviderID?: ID
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
add: (provider: ProviderImplementation) => void
|
||||
default: {
|
||||
get: () => ID | undefined
|
||||
set: (providerID: ID) => void
|
||||
}
|
||||
}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const kv = yield* KV.Service
|
||||
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ providers: new Map() }),
|
||||
draft: (draft) => ({
|
||||
add: (provider) => draft.providers.set(provider.id, provider),
|
||||
default: {
|
||||
get: () => draft.defaultProviderID,
|
||||
set: (providerID) => (draft.defaultProviderID = providerID),
|
||||
},
|
||||
}),
|
||||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const requireProvider = (providers: Map<ID, ProviderImplementation>, providerID: ID) => {
|
||||
const provider = providers.get(providerID)
|
||||
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID }))
|
||||
}
|
||||
|
||||
const defaultProvider = Effect.fn("WebSearch.default")(function* () {
|
||||
const data = state.get()
|
||||
const configured = data.defaultProviderID ? data.providers.get(data.defaultProviderID) : undefined
|
||||
if (configured) return configured
|
||||
const stored = yield* kv.get("websearch:provider")
|
||||
if (stored === false) return yield* new DisabledError()
|
||||
if (typeof stored !== "string") return
|
||||
return data.providers.get(ID.make(stored))
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) {
|
||||
const providers = state.get().providers
|
||||
if (input.providerID) return yield* requireProvider(providers, input.providerID)
|
||||
const provider = yield* defaultProvider()
|
||||
if (!provider) return yield* new ProviderRequiredError()
|
||||
return provider
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
providers: Effect.fn("WebSearch.providers")(function* () {
|
||||
return Array.from(state.get().providers.values(), (provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
})).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
}),
|
||||
default: Effect.fn("WebSearch.defaultInfo")(function* () {
|
||||
const provider = yield* defaultProvider()
|
||||
return provider && { id: provider.id, name: provider.name }
|
||||
}),
|
||||
query: Effect.fn("WebSearch.query")(function* (input) {
|
||||
const provider = yield* resolve(input)
|
||||
const results = yield* provider.execute({ query: input.query }).pipe(
|
||||
Effect.flatMap(decodeResults),
|
||||
Effect.mapError((cause) => new RequestError({ providerID: provider.id, cause })),
|
||||
)
|
||||
return new Response({ providerID: provider.id, results })
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [EventV2.node, KV.node],
|
||||
})
|
||||
@@ -121,23 +121,15 @@ describe("Config", () => {
|
||||
const config = yield* Config.Service
|
||||
const entries = yield* config.entries()
|
||||
expect(
|
||||
entries.flatMap((entry) =>
|
||||
entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
|
||||
),
|
||||
entries.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])),
|
||||
).toEqual(["global", "explicit", "project", "content"])
|
||||
expect(Config.latest(entries, "shell")).toBe("content")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer(
|
||||
project,
|
||||
global,
|
||||
project,
|
||||
undefined,
|
||||
undefined,
|
||||
emptyCredentialNode,
|
||||
emptyWellknownNode,
|
||||
{ file: explicit, content: JSON.stringify({ shell: "content" }) },
|
||||
),
|
||||
testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
|
||||
file: explicit,
|
||||
content: JSON.stringify({ shell: "content" }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -155,31 +147,24 @@ describe("Config", () => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
return Effect.promise(async () => {
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
await fs.mkdir(project, { recursive: true })
|
||||
await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
|
||||
await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer(
|
||||
project,
|
||||
global,
|
||||
project,
|
||||
undefined,
|
||||
undefined,
|
||||
emptyCredentialNode,
|
||||
emptyWellknownNode,
|
||||
{ project: false },
|
||||
),
|
||||
),
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
await fs.mkdir(project, { recursive: true })
|
||||
await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
|
||||
await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
|
||||
project: false,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -53,13 +53,17 @@ export function waitForCodeModeTool(
|
||||
* full plugin host. Only the tool domain is live; focused tool tests exercise
|
||||
* registration, snapshots, and execution through the same path production uses.
|
||||
*/
|
||||
export const registerToolPlugin = <R>(plugin: {
|
||||
readonly id: string
|
||||
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R>
|
||||
}): Effect.Effect<void, never, R | Tools.Service | Scope.Scope> =>
|
||||
export const registerToolPlugin = <R>(
|
||||
plugin: {
|
||||
readonly id: string
|
||||
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R>
|
||||
},
|
||||
overrides: Parameters<typeof host>[0] = {},
|
||||
): Effect.Effect<void, never, R | Tools.Service | Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const context = host({
|
||||
...overrides,
|
||||
session: {
|
||||
hook: () => Effect.succeed({ dispose: Effect.void }),
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
@@ -9,6 +10,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
@@ -19,6 +21,7 @@ import { Reference } from "@opencode-ai/core/reference"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
|
||||
@@ -39,6 +42,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
||||
Npm.node,
|
||||
Credential.node,
|
||||
EventV2.node,
|
||||
Form.node,
|
||||
LayerNodePlatform.httpClient,
|
||||
PluginV2.node,
|
||||
AgentV2.node,
|
||||
@@ -52,9 +56,11 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
||||
SkillV2.node,
|
||||
ToolHooks.node,
|
||||
ToolRegistry.toolsNode,
|
||||
WebSearch.node,
|
||||
]),
|
||||
[
|
||||
[Location.node, tempLocationLayer],
|
||||
[Npm.node, npmLayer],
|
||||
[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))],
|
||||
],
|
||||
) as unknown as Layer.Layer<unknown, never>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import type {
|
||||
CredentialOAuth,
|
||||
IntegrationCommandMethod,
|
||||
IntegrationEnvMethod,
|
||||
IntegrationKeyMethod,
|
||||
@@ -13,11 +18,11 @@ import type {
|
||||
} from "@opencode-ai/sdk/v2/types"
|
||||
import { Effect, Stream } from "effect"
|
||||
|
||||
type Overrides = Partial<Omit<PluginContext, "options" | "session">> & {
|
||||
readonly session?: Partial<PluginContext["session"]>
|
||||
type Overrides = Partial<Omit<Plugin.Context, "options" | "session">> & {
|
||||
readonly session?: Partial<Plugin.Context["session"]>
|
||||
}
|
||||
|
||||
export function host(overrides: Overrides = {}): PluginContext {
|
||||
export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
return {
|
||||
app: overrides.app ?? { name: "test", version: "test", channel: "test" },
|
||||
options: {},
|
||||
@@ -92,6 +97,12 @@ export function host(overrides: Overrides = {}): PluginContext {
|
||||
transform: () => Effect.die("unused tool.transform"),
|
||||
hook: () => Effect.die("unused tool.hook"),
|
||||
},
|
||||
websearch: overrides.websearch ?? {
|
||||
providers: () => Effect.die("unused websearch.providers"),
|
||||
query: () => Effect.die("unused websearch.query"),
|
||||
transform: () => Effect.die("unused websearch.transform"),
|
||||
reload: () => Effect.die("unused websearch.reload"),
|
||||
},
|
||||
session: {
|
||||
hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")),
|
||||
create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
|
||||
@@ -105,7 +116,7 @@ export function host(overrides: Overrides = {}): PluginContext {
|
||||
}
|
||||
}
|
||||
|
||||
export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] {
|
||||
export function agentHost(agent: AgentV2.Interface): Plugin.Context["agent"] {
|
||||
return {
|
||||
get: (id) => agent.get(AgentV2.ID.make(id)).pipe(Effect.map((value) => value && agentInfo(value))),
|
||||
list: () => Effect.die("unused agent.list"),
|
||||
@@ -131,7 +142,7 @@ export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] {
|
||||
}
|
||||
}
|
||||
|
||||
export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"] {
|
||||
export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog"] {
|
||||
return {
|
||||
provider: {
|
||||
list: () => Effect.die("unused catalog.provider.list"),
|
||||
@@ -207,7 +218,7 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"
|
||||
}
|
||||
}
|
||||
|
||||
export function integrationHost(integration: Integration.Interface): PluginContext["integration"] {
|
||||
export function integrationHost(integration: Integration.Interface): Plugin.Context["integration"] {
|
||||
return {
|
||||
list: () => Effect.die("unused integration.list"),
|
||||
get: () => Effect.die("unused integration.get"),
|
||||
@@ -329,6 +340,40 @@ export function integrationHost(integration: Integration.Interface): PluginConte
|
||||
}
|
||||
}
|
||||
|
||||
export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["websearch"] {
|
||||
const location = Location.Info.make({
|
||||
directory: AbsolutePath.make("/tmp/websearch-test"),
|
||||
project: { id: Project.ID.make("websearch-test"), directory: AbsolutePath.make("/tmp/websearch-test") },
|
||||
})
|
||||
return {
|
||||
providers: () => websearch.providers().pipe(Effect.map((data) => ({ location, data }))),
|
||||
query: (input) =>
|
||||
websearch
|
||||
.query({ query: input.query, providerID: input.providerID && WebSearch.ID.make(input.providerID) })
|
||||
.pipe(Effect.map((data) => ({ location, data }))),
|
||||
reload: websearch.reload,
|
||||
transform: (callback) =>
|
||||
websearch.transform((draft) => {
|
||||
callback({
|
||||
add: (definition) =>
|
||||
draft.add({
|
||||
id: WebSearch.ID.make(definition.id),
|
||||
name: definition.name,
|
||||
execute: definition.execute,
|
||||
}),
|
||||
default: {
|
||||
get: draft.default.get,
|
||||
set: (providerID) => draft.default.set(WebSearch.ID.make(providerID)),
|
||||
},
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function oauthCredential(value: CredentialOAuth) {
|
||||
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
|
||||
}
|
||||
|
||||
function method(value: Integration.Method) {
|
||||
if (value.type === "env") return { type: value.type, names: [...value.names] }
|
||||
if (value.type === "key") return { type: value.type, label: value.label }
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionPending } from "@opencode-ai/core/session/pending"
|
||||
@@ -244,6 +245,38 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers a standalone web search provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const websearch = yield* WebSearch.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const promisePlugin = Plugin.define({
|
||||
id: "promise-websearch",
|
||||
setup: async (ctx) => {
|
||||
await ctx.websearch.transform((draft) => {
|
||||
draft.add({
|
||||
id: "promise-websearch",
|
||||
name: "Promise Web Search",
|
||||
execute: async (input) => [{ url: "https://example.com", content: `promise: ${input.query}`, time: {} }],
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
|
||||
expect(yield* websearch.providers()).toContainEqual({
|
||||
id: WebSearch.ID.make("promise-websearch"),
|
||||
name: "Promise Web Search",
|
||||
})
|
||||
expect(yield* websearch.query({ query: "effect", providerID: WebSearch.ID.make("promise-websearch") })).toEqual(
|
||||
new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("promise-websearch"),
|
||||
results: [{ url: "https://example.com", content: "promise: effect", time: {} }],
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs the setup cleanup when the plugin scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
interface WebSearchRequest {
|
||||
readonly url: string
|
||||
readonly headers: Record<string, string>
|
||||
readonly body: unknown
|
||||
}
|
||||
|
||||
export const requests: WebSearchRequest[] = []
|
||||
let responseBody = ""
|
||||
|
||||
export function resetWebSearchFixture(body: string) {
|
||||
requests.length = 0
|
||||
responseBody = body
|
||||
}
|
||||
|
||||
const http = Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.sync(() => {
|
||||
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
||||
requests.push({
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: JSON.parse(new TextDecoder().decode(request.body.body)),
|
||||
})
|
||||
return HttpClientResponse.fromWeb(request, new Response(responseBody, { status: 200 }))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
export const webSearchIntegrationTest = testEffect(
|
||||
Layer.merge(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Integration.node, Credential.node, EventV2.node, Form.node, WebSearch.node]),
|
||||
[[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))]],
|
||||
),
|
||||
http,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,170 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { WebSearchExa } from "@opencode-ai/core/plugin/websearch/exa"
|
||||
import { WebSearchParallel } from "@opencode-ai/core/plugin/websearch/parallel"
|
||||
import { host, integrationHost, webSearchHost } from "./host"
|
||||
import { requests, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
|
||||
|
||||
beforeEach(() => {
|
||||
resetWebSearchFixture(
|
||||
`event: message\ndata: ${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Title: Effect\nURL: https://effect.website\nPublished: 2026-07-25T00:00:00.000Z\nAuthor: N/A\nHighlights:\nEffect documentation",
|
||||
_meta: { searchTime: 123 },
|
||||
},
|
||||
],
|
||||
},
|
||||
})}\n\n`,
|
||||
)
|
||||
})
|
||||
|
||||
const it = webSearchIntegrationTest
|
||||
|
||||
describe("built-in web search providers", () => {
|
||||
it.effect("registers a provider without an integration", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
const registration = yield* webSearchHost(websearch).transform((draft) => {
|
||||
draft.add({
|
||||
id: "test-websearch",
|
||||
name: "Test Web Search",
|
||||
execute: (input) => Effect.succeed([{ url: "https://example.com", content: input.query, time: {} }]),
|
||||
})
|
||||
})
|
||||
|
||||
expect(yield* integrations.get(Integration.ID.make("test-websearch"))).toBeUndefined()
|
||||
expect(yield* websearch.providers()).toContainEqual({
|
||||
id: WebSearch.ID.make("test-websearch"),
|
||||
name: "Test Web Search",
|
||||
})
|
||||
yield* registration.dispose
|
||||
expect(yield* websearch.providers()).not.toContainEqual({
|
||||
id: WebSearch.ID.make("test-websearch"),
|
||||
name: "Test Web Search",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers Exa with its MCP schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* WebSearchExa.Plugin.effect(
|
||||
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
|
||||
)
|
||||
|
||||
const info = yield* integrations.get(Integration.ID.make("exa"))
|
||||
expect(info).toMatchObject({
|
||||
id: "exa",
|
||||
name: "Exa",
|
||||
methods: [{ type: "key" }, { type: "env", names: ["EXA_API_KEY"] }],
|
||||
})
|
||||
yield* integrations.connection.key({ integrationID: Integration.ID.make("exa"), key: "exa secret" })
|
||||
expect(yield* websearch.query({ query: "effect typescript", providerID: WebSearch.ID.make("exa") })).toEqual(
|
||||
new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
results: [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
content: "Effect documentation",
|
||||
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
url: `${WebSearchExa.endpoint}?exaApiKey=exa+secret`,
|
||||
headers: expect.any(Object),
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search_exa",
|
||||
arguments: { query: "effect typescript", numResults: 8 },
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers Parallel and keeps its credential in the authorization header", () =>
|
||||
Effect.gen(function* () {
|
||||
resetWebSearchFixture(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: {
|
||||
content: [{ type: "text", text: "search results" }],
|
||||
structuredContent: {
|
||||
search_id: "search_1",
|
||||
results: [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
publish_date: null,
|
||||
excerpts: ["Effect documentation"],
|
||||
},
|
||||
],
|
||||
warnings: null,
|
||||
usage: [{ name: "sku_search", count: 1 }],
|
||||
session_id: "ses_parallel",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const integrations = yield* Integration.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* WebSearchParallel.Plugin.effect(
|
||||
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
|
||||
)
|
||||
yield* integrations.connection.key({ integrationID: Integration.ID.make("parallel"), key: "parallel-secret" })
|
||||
|
||||
const output = yield* websearch.query({
|
||||
query: "effect layers",
|
||||
providerID: WebSearch.ID.make("parallel"),
|
||||
})
|
||||
expect(output).toEqual(
|
||||
new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("parallel"),
|
||||
results: [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
content: "Effect documentation",
|
||||
time: {},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(requests[0]).toMatchObject({
|
||||
url: WebSearchParallel.endpoint,
|
||||
headers: { authorization: "Bearer parallel-secret" },
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search",
|
||||
arguments: {
|
||||
objective: "effect layers",
|
||||
search_queries: ["effect layers"],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(JSON.stringify(output)).not.toContain("parallel-secret")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -171,6 +171,28 @@ describe("SessionV2.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("orders reused sessions by prompt admission time", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const reused = yield* session.create({ location, title: "reused" })
|
||||
const newer = yield* session.create({ location, title: "newer" })
|
||||
yield* db.update(SessionTable).set({ time_updated: -2 }).where(eq(SessionTable.id, reused.id)).run()
|
||||
yield* db.update(SessionTable).set({ time_updated: -1 }).where(eq(SessionTable.id, newer.id)).run()
|
||||
|
||||
const admitted = yield* events.publish(SessionEvent.InputAdmitted, {
|
||||
sessionID: reused.id,
|
||||
inputID: SessionMessage.ID.create(),
|
||||
input: { type: "user", data: { text: "continue" }, delivery: "steer" },
|
||||
})
|
||||
|
||||
const page = yield* session.list({ directory: location.directory, parentID: null, order: "desc" })
|
||||
expect(page.data.map((item) => item.id)).toEqual([reused.id, newer.id])
|
||||
expect(page.data[0]!.time.updated).toEqual(admitted.created)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters direct child sessions by parent ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
|
||||
@@ -104,7 +104,7 @@ describe("search tools", () => {
|
||||
const grep = yield* executeTool(registry, call("grep", { pattern: "needle" }))
|
||||
|
||||
expect(glob.metadata).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT, truncated: true })
|
||||
expect(grep.metadata).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
|
||||
expect(grep.metadata).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT, truncated: true })
|
||||
expect(glob.content).toHaveLength(1)
|
||||
expect(grep.content).toHaveLength(1)
|
||||
const globText = glob.content?.[0]?.type === "text" ? glob.content[0].text : ""
|
||||
@@ -114,6 +114,9 @@ describe("search tools", () => {
|
||||
`(Results are truncated: showing first ${FileSystem.DEFAULT_SEARCH_LIMIT} results. Consider using a more specific path or pattern.)`,
|
||||
)
|
||||
expect(grepText).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
|
||||
expect(grepText).toEndWith(
|
||||
`(Results are truncated: showing first ${FileSystem.DEFAULT_SEARCH_LIMIT} results. Consider using a more specific path or pattern.)`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
@@ -121,6 +124,65 @@ describe("search tools", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects an empty grep pattern", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
withTools(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* executeTool(registry, call("grep", { pattern: "" }))).toEqual({
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: 'Invalid tool input: Pattern must not be empty\n at ["pattern"]',
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("handles explicit grep file and directory paths", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.writeFile(path.join(tmp.path, "target.txt"), "needle\n"),
|
||||
fs.writeFile(path.join(tmp.path, "other.txt"), "needle\n"),
|
||||
]),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
withTools(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const file = yield* executeTool(registry, call("grep", { path: "target.txt", pattern: "needle" }))
|
||||
expect(file).toMatchObject({
|
||||
status: "completed",
|
||||
output: [{ entry: { path: "target.txt" }, line: 1, text: "needle\n" }],
|
||||
metadata: { matches: 1, truncated: false },
|
||||
})
|
||||
|
||||
const directory = yield* executeTool(registry, call("grep", { path: ".", pattern: "needle" }))
|
||||
expect(directory).toMatchObject({
|
||||
status: "completed",
|
||||
metadata: { matches: 2, truncated: false },
|
||||
})
|
||||
if (directory.status !== "completed") return
|
||||
expect(directory.output).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ entry: expect.objectContaining({ path: "target.txt" }) }),
|
||||
expect.objectContaining({ entry: expect.objectContaining({ path: "other.txt" }) }),
|
||||
]),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
for (const name of ["glob", "grep"] as const) {
|
||||
it.live(`${name} reports a missing search path`, () =>
|
||||
Effect.acquireUseRelease(
|
||||
@@ -143,6 +205,29 @@ describe("search tools", () => {
|
||||
)
|
||||
}
|
||||
|
||||
it.live("reports a file used as the glob search path", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe(
|
||||
Effect.andThen(
|
||||
withTools(tmp.path, (registry) =>
|
||||
executeTool(registry, call("glob", { path: "file.txt", pattern: "*" })),
|
||||
),
|
||||
),
|
||||
Effect.tap((result) =>
|
||||
Effect.sync(() => {
|
||||
expect(result).toEqual({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "Search path is not a directory: file.txt" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("requires external_directory approval for an explicit external glob path", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
|
||||
@@ -14,93 +15,36 @@ import { Image } from "@opencode-ai/core/image"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
import { webSearchHost } from "./plugin/host"
|
||||
|
||||
const webSearchToolNode = makeLocationNode({
|
||||
name: "test/websearch-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(WebSearchTool.Plugin)),
|
||||
deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient, WebSearchTool.configNode],
|
||||
layer: Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* registerToolPlugin(WebSearchTool.Plugin, { websearch: webSearchHost(websearch) })
|
||||
}),
|
||||
),
|
||||
deps: [ToolRegistry.toolsNode, PermissionV2.node, WebSearch.node, Form.node, KV.node],
|
||||
})
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_websearch_test")
|
||||
const payload = (text: string) =>
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: { content: [{ type: "text", text }] },
|
||||
})
|
||||
|
||||
describe("WebSearchTool provider selection", () => {
|
||||
test("rejects out-of-range numeric controls", () => {
|
||||
const decode = Schema.decodeUnknownSync(WebSearchTool.Input)
|
||||
expect(() => decode({ query: "x", numResults: 0 })).toThrow()
|
||||
expect(() => decode({ query: "x", numResults: WebSearchTool.MAX_NUM_RESULTS + 1 })).toThrow()
|
||||
expect(() => decode({ query: "x", contextMaxCharacters: WebSearchTool.MAX_CONTEXT_CHARACTERS + 1 })).toThrow()
|
||||
})
|
||||
test("selects a stable provider per session", () => {
|
||||
expect(WebSearchTool.selectProvider(sessionID)).toBe(WebSearchTool.selectProvider(sessionID))
|
||||
})
|
||||
|
||||
test("supports an explicit operational override", () => {
|
||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "parallel")).toBe(
|
||||
"parallel",
|
||||
)
|
||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: false, enableParallel: false }, "exa")).toBe("exa")
|
||||
})
|
||||
|
||||
test("prefers Parallel when both explicit flags are enabled", () => {
|
||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: true })).toBe("parallel")
|
||||
})
|
||||
|
||||
test("prefers Exa when only its explicit flag is enabled", () => {
|
||||
expect(WebSearchTool.selectProvider(sessionID, { enableExa: true, enableParallel: false })).toBe("exa")
|
||||
})
|
||||
})
|
||||
|
||||
describe("WebSearchTool MCP response parser", () => {
|
||||
test("parses plain JSON-RPC responses", async () => {
|
||||
expect(await Effect.runPromise(WebSearchTool.parseResponse(payload("search results")))).toBe("search results")
|
||||
})
|
||||
|
||||
test("parses SSE JSON-RPC responses and ignores non-JSON frames", async () => {
|
||||
expect(
|
||||
await Effect.runPromise(
|
||||
WebSearchTool.parseResponse(`data: [DONE]\nevent: message\ndata: ${payload("search results")}\n\n`),
|
||||
),
|
||||
).toBe("search results")
|
||||
})
|
||||
})
|
||||
|
||||
interface Request {
|
||||
readonly url: string
|
||||
readonly headers: Record<string, string>
|
||||
readonly body: unknown
|
||||
}
|
||||
|
||||
const requests: Request[] = []
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
let responseBody = payload("search results")
|
||||
let makeResponse = () => new Response(responseBody, { status: 200 })
|
||||
let config: WebSearchTool.Config = { enableExa: false, enableParallel: false }
|
||||
const queries: WebSearch.Input[] = []
|
||||
let result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
responseBody = payload("search results")
|
||||
makeResponse = () => new Response(responseBody, { status: 200 })
|
||||
assertions.length = 0
|
||||
queries.length = 0
|
||||
result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||
})
|
||||
})
|
||||
|
||||
const http = Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.sync(() => {
|
||||
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
||||
requests.push({
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: JSON.parse(new TextDecoder().decode(request.body.body)),
|
||||
})
|
||||
return HttpClientResponse.fromWeb(request, makeResponse())
|
||||
}),
|
||||
),
|
||||
)
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
@@ -112,33 +56,48 @@ const permission = Layer.succeed(
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const websearchConfig = Layer.succeed(
|
||||
WebSearchTool.ConfigService,
|
||||
WebSearchTool.ConfigService.of({
|
||||
get provider() {
|
||||
return config.provider
|
||||
},
|
||||
get enableExa() {
|
||||
return config.enableExa
|
||||
},
|
||||
get enableParallel() {
|
||||
return config.enableParallel
|
||||
},
|
||||
get exaApiKey() {
|
||||
return config.exaApiKey
|
||||
},
|
||||
get parallelApiKey() {
|
||||
return config.parallelApiKey
|
||||
},
|
||||
const websearch = Layer.succeed(
|
||||
WebSearch.Service,
|
||||
WebSearch.Service.of({
|
||||
transform: () => Effect.die("unused"),
|
||||
reload: () => Effect.die("unused"),
|
||||
providers: () => Effect.succeed([]),
|
||||
default: () => Effect.succeed(undefined),
|
||||
query: (input) =>
|
||||
Effect.sync(() => {
|
||||
queries.push(input)
|
||||
return result
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const form = Layer.succeed(
|
||||
Form.Service,
|
||||
Form.Service.of({
|
||||
create: () => Effect.die("unused"),
|
||||
ask: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
state: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
cancel: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const kv = Layer.succeed(
|
||||
KV.Service,
|
||||
KV.Service.of({
|
||||
get: () => Effect.succeed(undefined),
|
||||
set: () => Effect.void,
|
||||
remove: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, webSearchToolNode]),
|
||||
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearch.node, webSearchToolNode]),
|
||||
[
|
||||
[PermissionV2.node, permission],
|
||||
[LayerNodePlatform.httpClient, http],
|
||||
[WebSearchTool.configNode, websearchConfig],
|
||||
[WebSearch.node, websearch],
|
||||
[Form.node, form],
|
||||
[KV.node, kv],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
[Image.node, imagePassthrough],
|
||||
],
|
||||
@@ -146,12 +105,8 @@ const it = testEffect(
|
||||
)
|
||||
|
||||
describe("WebSearchTool registration", () => {
|
||||
it.effect("registers websearch, asserts query permission, and calls Exa", () =>
|
||||
it.effect("asserts permission before delegating to WebSearch", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
responseBody = payload("exa results")
|
||||
config = { provider: "exa", enableExa: false, enableParallel: false }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch", "execute"])
|
||||
@@ -161,20 +116,14 @@ describe("WebSearchTool registration", () => {
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-exa",
|
||||
id: "call-search",
|
||||
name: "websearch",
|
||||
input: {
|
||||
query: "effect typescript",
|
||||
numResults: 3,
|
||||
livecrawl: "preferred",
|
||||
type: "fast",
|
||||
contextMaxCharacters: 2500,
|
||||
},
|
||||
input: { query: "effect typescript" },
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "exa results" }],
|
||||
content: [{ type: "text", text: "## [Search results](https://example.com)\n\nsearch results" }],
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
@@ -182,103 +131,65 @@ describe("WebSearchTool registration", () => {
|
||||
action: "websearch",
|
||||
resources: ["effect typescript"],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
query: "effect typescript",
|
||||
numResults: 3,
|
||||
livecrawl: "preferred",
|
||||
type: "fast",
|
||||
contextMaxCharacters: 2500,
|
||||
provider: "exa",
|
||||
},
|
||||
metadata: { query: "effect typescript" },
|
||||
},
|
||||
])
|
||||
expect(requests).toEqual([
|
||||
expect(queries).toEqual([
|
||||
{
|
||||
url: WebSearchTool.EXA_URL,
|
||||
headers: expect.any(Object),
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search_exa",
|
||||
arguments: {
|
||||
query: "effect typescript",
|
||||
type: "fast",
|
||||
numResults: 3,
|
||||
livecrawl: "preferred",
|
||||
contextMaxCharacters: 2500,
|
||||
},
|
||||
},
|
||||
},
|
||||
query: "effect typescript",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("calls Parallel with session ID and keeps bearer credentials out of output", () =>
|
||||
it.effect("keeps normalized results in structured output", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
responseBody = payload("parallel results")
|
||||
config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
|
||||
result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("parallel"),
|
||||
results: [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
content: "parallel results",
|
||||
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
|
||||
},
|
||||
],
|
||||
})
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
const settled = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
|
||||
})
|
||||
|
||||
expect(requests[0]).toMatchObject({
|
||||
url: WebSearchTool.PARALLEL_URL,
|
||||
headers: { authorization: "Bearer parallel-secret" },
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search",
|
||||
arguments: { objective: "effect layers", search_queries: ["effect layers"], session_id: sessionID },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(requests[0]?.body).not.toHaveProperty("params.arguments.model_name")
|
||||
expect(settled).toEqual({
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
|
||||
}),
|
||||
).toEqual({
|
||||
status: "completed",
|
||||
output: { provider: "parallel", text: "parallel results" },
|
||||
content: [{ type: "text", text: "parallel results" }],
|
||||
output: {
|
||||
provider: "parallel",
|
||||
results: [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
content: "parallel results",
|
||||
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
|
||||
},
|
||||
],
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "## [Effect](https://effect.website)\nPublished: 2026-07-25T00:00:00.000Z\n\nparallel results",
|
||||
},
|
||||
],
|
||||
metadata: { provider: "parallel" },
|
||||
})
|
||||
expect(JSON.stringify(settled)).not.toContain("parallel-secret")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps an Exa credential in the transport URL and out of model output", () =>
|
||||
it.effect("uses the concise no-results fallback", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
responseBody = payload("credentialed exa results")
|
||||
config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
const settled = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } },
|
||||
})
|
||||
|
||||
expect(requests[0]?.url).toBe(`${WebSearchTool.EXA_URL}?exaApiKey=exa+secret`)
|
||||
expect(JSON.stringify(settled)).not.toContain("exa secret")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns the legacy no-results fallback as concise model text", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
responseBody = ""
|
||||
config = { provider: "exa", enableExa: false, enableParallel: false }
|
||||
result = new WebSearch.Response({ providerID: WebSearch.ID.make("exa"), results: [] })
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
@@ -293,44 +204,4 @@ describe("WebSearchTool registration", () => {
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects oversized MCP response bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
let chunksRead = 0
|
||||
let cancelled = false
|
||||
makeResponse = () =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
chunksRead++
|
||||
if (chunksRead === 10) throw new Error("response was not stopped at the byte limit")
|
||||
controller.enqueue(new Uint8Array(64 * 1024))
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
)
|
||||
config = { provider: "exa", enableExa: false, enableParallel: false }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } },
|
||||
}),
|
||||
// toSessionError unwraps the "Unable to search the web for <query>" ToolFailure
|
||||
// to its byte-limit cause message.
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: { type: "unknown", message: expect.stringContaining("response exceeded") },
|
||||
})
|
||||
expect(chunksRead).toBeLessThan(10)
|
||||
expect(cancelled).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Scope } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, EventV2.node, KV.node])))
|
||||
|
||||
const register = (id: string) =>
|
||||
Effect.gen(function* () {
|
||||
const websearch = yield* WebSearch.Service
|
||||
const providerID = WebSearch.ID.make(id)
|
||||
const calls: WebSearch.ProviderInput[] = []
|
||||
yield* websearch.transform((draft) => {
|
||||
draft.add({
|
||||
id: providerID,
|
||||
name: id.toUpperCase(),
|
||||
execute: (input) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(input)
|
||||
return [
|
||||
{
|
||||
url: `https://${id}.example.com`,
|
||||
title: input.query,
|
||||
content: `${id}: ${input.query}`,
|
||||
time: {},
|
||||
},
|
||||
]
|
||||
}),
|
||||
})
|
||||
})
|
||||
return { providerID, calls }
|
||||
})
|
||||
|
||||
describe("WebSearch", () => {
|
||||
it.effect("executes an explicit provider without changing the default", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
|
||||
expect(yield* websearch.query({ query: "effect", providerID: parallel.providerID })).toEqual(
|
||||
new WebSearch.Response({
|
||||
providerID: parallel.providerID,
|
||||
results: [
|
||||
{
|
||||
url: "https://parallel.example.com",
|
||||
title: "effect",
|
||||
content: "parallel: effect",
|
||||
time: {},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect((yield* websearch.query({ query: "default" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired")
|
||||
expect(parallel.calls).toEqual([{ query: "effect" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires a provider when no default is set", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
|
||||
expect((yield* websearch.query({ query: "layers" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the default set by a transform", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.transform((draft) => draft.default.set(parallel.providerID))
|
||||
|
||||
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(parallel.providerID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the provider stored in KV", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
const kv = yield* KV.Service
|
||||
yield* kv.set("websearch:provider", parallel.providerID)
|
||||
|
||||
expect((yield* websearch.query({ query: "stored" })).providerID).toBe(parallel.providerID)
|
||||
yield* kv.remove("websearch:provider")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails when web search is explicitly disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
const kv = yield* KV.Service
|
||||
yield* kv.set("websearch:provider", false)
|
||||
|
||||
expect((yield* websearch.query({ query: "disabled" }).pipe(Effect.flip))._tag).toBe("WebSearch.Disabled")
|
||||
yield* kv.remove("websearch:provider")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back when the configured default is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.transform((draft) => draft.default.set(WebSearch.ID.make("missing")))
|
||||
|
||||
expect((yield* websearch.query({ query: "fallback" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes scoped provider registrations", () =>
|
||||
Effect.gen(function* () {
|
||||
const websearch = yield* WebSearch.Service
|
||||
const scope = yield* Scope.fork(yield* Scope.Scope)
|
||||
const provider = yield* register("temporary").pipe(Scope.provide(scope))
|
||||
expect(yield* websearch.providers()).toContainEqual({ id: provider.providerID, name: "TEMPORARY" })
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* websearch.providers()).not.toContainEqual({ id: provider.providerID, name: "TEMPORARY" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
# V2 documentation guide
|
||||
|
||||
## Structure
|
||||
|
||||
- This directory is a standalone Mintlify site deployed from `packages/docs` on the `dev` branch.
|
||||
- Write documentation in MDX. Every page should have `title` and `description` frontmatter.
|
||||
- `docs.json` owns site configuration and navigation. Add, move, or remove its page entries whenever the corresponding MDX pages change.
|
||||
- Put static files in `assets/` and reference them with root-relative paths such as `/assets/example.svg`.
|
||||
- The API endpoint reference is generated by Mintlify from `openapi.json`; do not duplicate endpoint documentation as hand-written MDX.
|
||||
- Keep documentation aligned with the V2 packages. Do not use `packages/opencode` as the source of truth unless the task explicitly concerns V1.
|
||||
|
||||
## Local development
|
||||
|
||||
- At the start of documentation work, launch `bun dev` from `packages/docs` using the shell tool with `background: true`. Never run the dev server in a foreground shell call and do not poll the process; wait for the background completion notification.
|
||||
- Preview the site at `http://localhost:3333`. Mintlify does not expose a host option and binds the preview to all network interfaces. The server reloads changes to MDX and `docs.json` automatically.
|
||||
- Use the running preview to verify navigation, links, Mintlify components, code blocks, and desktop and mobile layout.
|
||||
|
||||
## Validation
|
||||
|
||||
- Run `bun validate` from `packages/docs` after making documentation or configuration changes.
|
||||
- Run `bun broken-links` from `packages/docs` when pages, navigation, headings, or links change.
|
||||
- Treat validation errors and broken internal links as blockers. Also verify external links relevant to the change when practical.
|
||||
@@ -1,33 +0,0 @@
|
||||
# OpenCode documentation
|
||||
|
||||
The V2 documentation is a Mintlify site deployed from `packages/docs` on the `dev` branch.
|
||||
|
||||
## Local preview
|
||||
|
||||
From this directory, run:
|
||||
|
||||
```bash
|
||||
bun dev
|
||||
```
|
||||
|
||||
The preview opens at `http://localhost:3333` and reloads when MDX or `docs.json` changes.
|
||||
|
||||
Validate changes before opening a pull request:
|
||||
|
||||
```bash
|
||||
bun validate
|
||||
bun broken-links
|
||||
```
|
||||
|
||||
The V2 theme token reference is generated from
|
||||
`packages/tui/src/theme/v2/schema.ts`. Regenerate it after schema changes:
|
||||
|
||||
```bash
|
||||
bun run generate
|
||||
```
|
||||
|
||||
`bun validate` checks that the committed snippet is current. The repository's
|
||||
generation workflow also refreshes it on pushes to `dev`, so Mintlify always
|
||||
receives the generated MDX as part of the published docs tree.
|
||||
|
||||
The hosted preview is available at [opencode.mintlify.site](https://opencode.mintlify.site).
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
title: "API Reference"
|
||||
description: "OpenCode HTTP API."
|
||||
---
|
||||
|
||||
The endpoint reference is generated from the current OpenCode V2 [OpenAPI specification](/openapi.json).
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 18H6V6H18V18Z" fill="#F5F5F5"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 3H21V21H3V3ZM8 8V16H16V8H8Z" fill="#3B7DD8"/>
|
||||
<path d="M16 8H20V12H16V8Z" fill="#FAB283"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 297 B |
@@ -1,10 +0,0 @@
|
||||
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 30H6V18H18V30Z" fill="#4B4646"/><path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#B7B1B1"/>
|
||||
<path d="M48 30H36V18H48V30Z" fill="#4B4646"/><path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#B7B1B1"/>
|
||||
<path d="M84 24V30H66V24H84Z" fill="#4B4646"/><path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#B7B1B1"/>
|
||||
<path d="M108 36H96V18H108V36Z" fill="#4B4646"/><path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#B7B1B1"/>
|
||||
<path d="M144 30H126V18H144V30Z" fill="#4B4646"/><path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#F1ECEC"/>
|
||||
<path d="M168 30H156V18H168V30Z" fill="#4B4646"/><path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#F1ECEC"/>
|
||||
<path d="M198 30H186V18H198V30Z" fill="#4B4646"/><path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#F1ECEC"/>
|
||||
<path d="M234 24V30H216V24H234Z" fill="#4B4646"/><path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#F1ECEC"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,10 +0,0 @@
|
||||
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 30H6V18H18V30Z" fill="#CFCECD"/><path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#656363"/>
|
||||
<path d="M48 30H36V18H48V30Z" fill="#CFCECD"/><path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#656363"/>
|
||||
<path d="M84 24V30H66V24H84Z" fill="#CFCECD"/><path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#656363"/>
|
||||
<path d="M108 36H96V18H108V36Z" fill="#CFCECD"/><path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#656363"/>
|
||||
<path d="M144 30H126V18H144V30Z" fill="#CFCECD"/><path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#211E1E"/>
|
||||
<path d="M168 30H156V18H168V30Z" fill="#CFCECD"/><path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#211E1E"/>
|
||||
<path d="M198 30H186V18H198V30Z" fill="#CFCECD"/><path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#211E1E"/>
|
||||
<path d="M234 24V30H216V24H234Z" fill="#CFCECD"/><path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#211E1E"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
Vendored
-183
@@ -1,183 +0,0 @@
|
||||
---
|
||||
title: "Client"
|
||||
description: "Connect an application to the OpenCode HTTP API."
|
||||
---
|
||||
|
||||
`@opencode-ai/client` is the generated TypeScript client for the OpenCode HTTP
|
||||
API. Use it when your application connects to an OpenCode server over the
|
||||
network. Its types and methods are generated from the same contract as the
|
||||
[API reference](/api).
|
||||
|
||||
<Warning>
|
||||
The V2 API and client are beta. Method names, inputs, and outputs may change
|
||||
before the stable release.
|
||||
</Warning>
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
bun add @opencode-ai/client@next
|
||||
```
|
||||
|
||||
## Create a client
|
||||
|
||||
Create a client with the server URL, then call methods grouped by API resource:
|
||||
|
||||
```ts
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:4096",
|
||||
})
|
||||
|
||||
const session = await client.session.create({
|
||||
location: { directory: "/workspace" },
|
||||
})
|
||||
|
||||
await client.session.prompt({
|
||||
sessionID: session.id,
|
||||
text: "Review the current changes",
|
||||
})
|
||||
```
|
||||
|
||||
## Headers and requests
|
||||
|
||||
Pass default authentication or application headers to `OpenCode.make` with
|
||||
`headers`. You can also supply a custom `fetch` implementation. Each operation
|
||||
accepts request options as its final argument for an `AbortSignal` or
|
||||
per-request headers.
|
||||
|
||||
```ts
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "https://opencode.example.com",
|
||||
headers: {
|
||||
authorization: `Bearer ${process.env.OPENCODE_TOKEN}`,
|
||||
},
|
||||
})
|
||||
|
||||
await client.session.list(undefined, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
```
|
||||
|
||||
## Stream events
|
||||
|
||||
Streaming endpoints return async iterables:
|
||||
|
||||
```ts
|
||||
for await (const event of client.event.subscribe()) {
|
||||
console.log(event.type)
|
||||
}
|
||||
```
|
||||
|
||||
## Local background service
|
||||
|
||||
The main client entrypoints are browser-compatible and do not include local
|
||||
process management. In a Node application, import the native Promise service
|
||||
API from `@opencode-ai/client/service`.
|
||||
|
||||
- `Service.discover()` returns a healthy registered endpoint without starting
|
||||
a process.
|
||||
- `Service.ensure()` returns a compatible service, starting one when needed.
|
||||
- `Service.stop()` stops the exact registered service instance.
|
||||
- `Service.headers(endpoint)` creates the authentication headers for a client.
|
||||
|
||||
```ts
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/service"
|
||||
|
||||
const endpoint = await Service.ensure()
|
||||
const client = OpenCode.make({
|
||||
baseUrl: endpoint.url,
|
||||
headers: Service.headers(endpoint),
|
||||
})
|
||||
|
||||
const health = await client.health.get()
|
||||
```
|
||||
|
||||
`Service.ensure()` accepts an optional registration file, required version,
|
||||
service command, and `onStart` callback:
|
||||
|
||||
```ts
|
||||
const endpoint = await Service.ensure({
|
||||
file: "/var/run/opencode/service.json",
|
||||
version: "2.0.0",
|
||||
command: ["opencode", "serve", "--service"],
|
||||
onStart(reason, previousVersion) {
|
||||
console.log(reason, previousVersion)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Omit these options to use the standard registration path and
|
||||
`opencode serve --service` command.
|
||||
|
||||
## Effect
|
||||
|
||||
OpenCode provides a first-class Effect client through the
|
||||
`@opencode-ai/client/effect` entrypoint. It returns typed Effects and Streams
|
||||
and decodes responses into OpenCode schema values.
|
||||
|
||||
```sh
|
||||
bun add @opencode-ai/client@next effect
|
||||
```
|
||||
|
||||
### Create a client
|
||||
|
||||
```ts
|
||||
import { AbsolutePath, Location, OpenCode } from "@opencode-ai/client/effect"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:4096" })
|
||||
const session = yield* client.session.create({
|
||||
location: Location.Ref.make({
|
||||
directory: AbsolutePath.make("/workspace"),
|
||||
}),
|
||||
})
|
||||
|
||||
return yield* client.session.get({ sessionID: session.id })
|
||||
})
|
||||
|
||||
const session = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(FetchHttpClient.layer)),
|
||||
)
|
||||
```
|
||||
|
||||
Streaming operations, including `client.event.subscribe()` and
|
||||
`client.session.log(...)`, return Effect `Stream` values.
|
||||
|
||||
### Local background service
|
||||
|
||||
The Node-only `@opencode-ai/client/effect/service` entrypoint exposes the same
|
||||
operations as Effect values. Add `@effect/platform-node` and provide its
|
||||
filesystem layer when running them.
|
||||
|
||||
```sh
|
||||
bun add @effect/platform-node
|
||||
```
|
||||
|
||||
```ts
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { OpenCode } from "@opencode-ai/client/effect"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const endpoint = yield* Service.ensure()
|
||||
const client = yield* OpenCode.make({
|
||||
baseUrl: endpoint.url,
|
||||
headers: Service.headers(endpoint),
|
||||
})
|
||||
return yield* client.health.get()
|
||||
})
|
||||
|
||||
const health = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(FetchHttpClient.layer),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
```
|
||||
Vendored
-24
@@ -1,24 +0,0 @@
|
||||
---
|
||||
title: "Build"
|
||||
description: "Build on the engine used by millions daily."
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
<CardGroup cols={1}>
|
||||
<Card title="Extend OpenCode" href="/build/plugins">
|
||||
Build plugins that add tools, integrations, commands, agents, and custom behavior while keeping the rest of OpenCode
|
||||
intact.
|
||||
</Card>
|
||||
<Card title="Run it as a server" href="/build/client">
|
||||
Connect to OpenCode with the same client used by the TUI and desktop app, then build any interface, workflow, or agent
|
||||
experience around it.
|
||||
</Card>
|
||||
<Card title="Embed it" href="/build/sdk">
|
||||
Embed OpenCode directly into your application and build a completely custom agent, interface, or developer product
|
||||
around it.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Warning>
|
||||
The plugin API, client, and SDK are still being finalized during beta and may change before OpenCode 2.0 is stable.
|
||||
</Warning>
|
||||
Vendored
-446
@@ -1,446 +0,0 @@
|
||||
---
|
||||
title: "Plugins"
|
||||
description: "Extend OpenCode with plugins."
|
||||
---
|
||||
|
||||
Plugins extend OpenCode in-process. They can transform agents, models, commands,
|
||||
integrations, references, skills, and tools; intercept model requests and tool
|
||||
execution; and call a subset of the V2 client.
|
||||
|
||||
<Warning>
|
||||
The V2 plugin API is beta. Entrypoints, hooks, draft shapes, and configuration may change before the stable release.
|
||||
Use the `/v2` exports described on this page.
|
||||
</Warning>
|
||||
|
||||
## Load plugins
|
||||
|
||||
Plugins can be loaded from npm packages, explicit local paths, or config
|
||||
directories. Each module must have one default export containing a unique
|
||||
plugin `id` and a `setup` function.
|
||||
|
||||
### Configuration
|
||||
|
||||
Add ordered entries to the `plugins` field in `opencode.json(c)`:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugins": [
|
||||
"opencode-acme-plugin@1.2.0",
|
||||
"@acme/opencode-plugin",
|
||||
"./plugins/local.ts",
|
||||
{
|
||||
"package": "./plugins/reviewer.ts",
|
||||
"options": {
|
||||
"agent": "reviewer",
|
||||
"strict": true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
A string is either a package specifier or a local path. Local paths must start
|
||||
with `./` or `../` and resolve relative to the configuration file containing
|
||||
the entry. Absolute paths and `file://` URLs are also supported. Both scoped
|
||||
packages and versioned package specifiers are supported.
|
||||
|
||||
Use the object form to pass JSON configuration to the plugin. OpenCode passes
|
||||
`options` unchanged as `ctx.options`; omitted options become an empty object.
|
||||
The plugin owns validation and defaults for its options.
|
||||
|
||||
See [Config](/config#locations) for configuration locations and precedence.
|
||||
Entries from all applicable files are processed from lowest to highest
|
||||
precedence rather than replacing the entire array.
|
||||
|
||||
### Local discovery
|
||||
|
||||
OpenCode automatically scans this directory in every discovered OpenCode config
|
||||
directory:
|
||||
|
||||
```text
|
||||
.opencode/plugins/
|
||||
```
|
||||
|
||||
The equivalent global directory is `~/.config/opencode/plugins/`. Direct `.ts`
|
||||
and `.js` children are loaded. An immediate child directory is also loaded as a
|
||||
package when OpenCode can resolve a string `exports`, `module`, or `main`
|
||||
entrypoint, or an `index.ts` or `index.js` file.
|
||||
|
||||
A `plugins/` directory beside a project-root `opencode.json` is not discovered
|
||||
automatically. Put it under `.opencode/`, or add its file explicitly with a
|
||||
relative config entry.
|
||||
|
||||
### Enable and disable
|
||||
|
||||
A string beginning with `-` disables plugins by their exported `id`. `*`
|
||||
matches every ID, and a suffix of `.*` matches an ID prefix. Directives are
|
||||
applied in order:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"plugins": ["./plugins/reviewer.ts", "-acme.reviewer", "-opencode.provider.*", "opencode.provider.openai"],
|
||||
}
|
||||
```
|
||||
|
||||
Package specifiers and local paths locate plugin modules; they are not disable
|
||||
selectors. Use the `id` from the plugin's default export to disable it. A later
|
||||
ID entry re-enables a loaded or built-in plugin. Explicit config directives run
|
||||
after local auto-discovery, so they can disable discovered plugins by ID.
|
||||
|
||||
User plugins are activated in configured order between OpenCode's internal
|
||||
plugin phases. Hooks run sequentially in registration order, and later hooks
|
||||
observe earlier mutations. Do not depend on the internal phase ordering while
|
||||
the API is beta.
|
||||
|
||||
### Installation and dependencies
|
||||
|
||||
OpenCode installs bare package entries and their production dependencies into
|
||||
an isolated cache. Package installation does not run lifecycle scripts.
|
||||
Published packages should expose their plugin entrypoint and include every
|
||||
runtime import in `dependencies`.
|
||||
|
||||
Local files and local package directories are imported directly. OpenCode does
|
||||
**not** install their dependencies. Install dependencies in a `package.json`
|
||||
visible from the plugin file, for example:
|
||||
|
||||
```sh
|
||||
cd .opencode
|
||||
bun add @opencode-ai/plugin@next
|
||||
```
|
||||
|
||||
Match the plugin package version to the OpenCode release you target.
|
||||
|
||||
Configuration and discovered plugin files under watched config directories are
|
||||
reloaded when they change. Reloading replaces the active plugin generation and
|
||||
releases its scoped registrations. Restart OpenCode after changing an npm
|
||||
package version or a local dependency when no watched file changed.
|
||||
|
||||
## Create a plugin
|
||||
|
||||
Export the result of `Plugin.define` as the module default:
|
||||
|
||||
```ts title=".opencode/plugins/reviewer.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.reviewer",
|
||||
setup: async (ctx) => {
|
||||
const description =
|
||||
typeof ctx.options.description === "string" ? ctx.options.description : "Reviews code for regressions"
|
||||
|
||||
await ctx.agent.transform((agents) => {
|
||||
agents.update("reviewer", (agent) => {
|
||||
agent.description = description
|
||||
agent.mode = "subagent"
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`setup` runs each time the plugin is activated. Register long-lived behavior
|
||||
during setup; do not wait there on an infinite event stream. It may return a
|
||||
synchronous or asynchronous cleanup function. OpenCode awaits that cleanup
|
||||
when the plugin is disabled, reloaded, or shut down:
|
||||
|
||||
```ts
|
||||
setup: async (ctx) => {
|
||||
const controller = new AbortController()
|
||||
const task = synchronize(ctx, controller.signal)
|
||||
|
||||
return async () => {
|
||||
controller.abort()
|
||||
await task
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Hook registrations are released automatically with the same plugin scope. Use
|
||||
the returned cleanup for resources the plugin owns, such as timers, watchers,
|
||||
connections, and background tasks.
|
||||
|
||||
### Context
|
||||
|
||||
The plugin context is essentially an [OpenCode server client](/build/client).
|
||||
Its read and action methods use the same inputs and responses as the client. It
|
||||
adds plugin-only methods for transforms, runtime hooks, reloads, registrations,
|
||||
and plugin options.
|
||||
|
||||
| Capability | Available operations |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `ctx.agent` | `list`, `get`, `transform`, `reload` |
|
||||
| `ctx.catalog.provider` | `list`, `get` |
|
||||
| `ctx.catalog.model` | `list`, `get`, `default` |
|
||||
| `ctx.catalog` | `transform`, `reload` |
|
||||
| `ctx.command` | `list`, `transform`, `reload` |
|
||||
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
|
||||
| `ctx.plugin` | `list` currently active plugin IDs |
|
||||
| `ctx.reference` | `list`, `transform`, `reload` |
|
||||
| `ctx.session` | `create`, `get`, `prompt`, `command`, `synthetic`, `interrupt`, and `hook` |
|
||||
| `ctx.skill` | `list`, `transform`, `reload` |
|
||||
| `ctx.tool` | `transform` and `hook` |
|
||||
| `ctx.aisdk` | `hook` |
|
||||
| `ctx.event` | `subscribe` to the current public server event stream |
|
||||
| `ctx.options` | Readonly options from the matching config object |
|
||||
|
||||
### Transform hooks
|
||||
|
||||
Transform hooks let a plugin modify how OpenCode is configured. Use them to add
|
||||
or remove definitions, override settings, choose defaults, and provide tools or
|
||||
other sources.
|
||||
|
||||
| Transform | Draft operations |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `agent.transform` | `list`, `get`, `default`, `update`, `remove` |
|
||||
| `catalog.transform` | Provider `list`, `get`, `update`, `remove`; model `get`, `update`, `remove`; default model `get`, `set` |
|
||||
| `command.transform` | `list`, `get`, `update`, `remove` |
|
||||
| `integration.transform` | Integration `list`, `get`, `update`, `remove`; method `list`, `update`, `remove` |
|
||||
| `reference.transform` | `add`, `remove`, `list` |
|
||||
| `skill.transform` | `source`, `list` |
|
||||
| `tool.transform` | `add` |
|
||||
|
||||
Here's an example that keeps models synced from a remote source:
|
||||
|
||||
```js title=".opencode/plugins/remote-models.js"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.remote-models",
|
||||
setup: async (ctx) => {
|
||||
let models = []
|
||||
|
||||
await ctx.catalog.transform((catalog) => {
|
||||
for (const model of models) {
|
||||
catalog.model.update(model.providerID, model.id, (draft) => Object.assign(draft, model))
|
||||
}
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
const response = await fetch("https://example.com/opencode/models.json", {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!response.ok) return
|
||||
models = await response.json()
|
||||
await ctx.catalog.reload()
|
||||
}
|
||||
|
||||
await refresh()
|
||||
const timer = setInterval(() => void refresh().catch(console.error), 60_000)
|
||||
return () => clearInterval(timer)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`ctx.catalog.reload()` replays every catalog transform to derive the new
|
||||
catalog. Each plugin's logic remains composed with the others, so a later
|
||||
plugin can still modify models added by an earlier one. The catalog updates
|
||||
without restarting OpenCode.
|
||||
|
||||
### Runtime hooks
|
||||
|
||||
Runtime hooks intercept live operations. Their event objects expose specific
|
||||
mutable fields:
|
||||
|
||||
| Hook | Mutable fields |
|
||||
| ------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
|
||||
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
|
||||
| `ctx.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
|
||||
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
|
||||
| `ctx.tool.hook("execute.after", callback)` | Terminal `content`, `metadata`, and `outputPaths`; `error` on failure |
|
||||
|
||||
For example, remove a tool from selected model requests and normalize another
|
||||
tool's input:
|
||||
|
||||
```ts title=".opencode/plugins/guards.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.guards",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("request", (event) => {
|
||||
delete event.tools.write
|
||||
})
|
||||
|
||||
await ctx.tool.hook("execute.before", (event) => {
|
||||
if (event.tool !== "lookup" || typeof event.input !== "object" || event.input === null) return
|
||||
event.input = { ...event.input, source: "plugin" }
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
A hook failure fails the operation it intercepts. Keep runtime hooks fast and
|
||||
handle expected errors inside the callback.
|
||||
|
||||
## Examples
|
||||
|
||||
### Add a tool
|
||||
|
||||
Create an executable tool with `Tool.make`, then register it with a name
|
||||
and registration options. Define its input with JSON Schema and use an async
|
||||
executor:
|
||||
|
||||
```js title=".opencode/plugins/greeting.js"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
import { Tool } from "@opencode-ai/plugin/v2/tool"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.greeting",
|
||||
setup: async (ctx) => {
|
||||
await ctx.tool.transform((tools) => {
|
||||
tools.add(
|
||||
"greeting",
|
||||
Tool.make({
|
||||
description: "Create a greeting",
|
||||
input: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
required: ["name"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: {
|
||||
type: "object",
|
||||
properties: { greeting: { type: "string" } },
|
||||
required: ["greeting"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async ({ name }) => {
|
||||
const text = `Hello, ${name}!`
|
||||
return {
|
||||
output: { greeting: text },
|
||||
content: text,
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Unsupported characters in tool names are normalized to underscores. Namespace
|
||||
segments must begin with a letter, contain at most 64 letters, digits,
|
||||
underscores, or hyphens, and are joined with dots. Pass the optional third
|
||||
argument to `tools.add` to configure the registration with
|
||||
`{ namespace, codemode }`:
|
||||
|
||||
- `namespace` prefixes and groups the exposed tool name.
|
||||
- `codemode` defaults to `true` and makes the tool available through the
|
||||
`execute` CodeMode tool. Set `codemode: false` to expose it directly to the
|
||||
provider.
|
||||
|
||||
The executor receives a second context argument containing `sessionID`,
|
||||
`agent`, `messageID`, `callID`, and `progress`. A tool with `output`
|
||||
must return `output`; Effect and Standard Schema codecs validate it, while raw
|
||||
JSON Schema definitions enforce JSON compatibility only. A tool
|
||||
without `output` returns model-visible `content` instead.
|
||||
|
||||
### Add a command
|
||||
|
||||
```js title=".opencode/plugins/review-command.js"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.review-command",
|
||||
setup: async (ctx) => {
|
||||
await ctx.command.transform((commands) => {
|
||||
commands.update("review", (command) => {
|
||||
command.description = "Review the current changes"
|
||||
command.template = "Review the current changes for correctness and missing tests."
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Set the default model
|
||||
|
||||
```js title=".opencode/plugins/default-model.js"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.default-model",
|
||||
setup: async (ctx) => {
|
||||
await ctx.catalog.transform((catalog) => {
|
||||
catalog.model.default.set("anthropic", "claude-sonnet-4-5")
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Publish a package
|
||||
|
||||
A package plugin uses the same default export as a local plugin. A minimal
|
||||
manifest is:
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"name": "opencode-acme-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "next"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use versions compatible with the OpenCode release you target and test the
|
||||
installed package, not only a workspace-linked copy. Because the plugin API is
|
||||
beta, publish compatible plugin updates when V2 entrypoints or contracts
|
||||
change.
|
||||
|
||||
## Verify loading
|
||||
|
||||
List active plugin IDs through the V2 API:
|
||||
|
||||
```sh
|
||||
opencode2 api get /api/plugin
|
||||
```
|
||||
|
||||
If a plugin is absent, check the server log described in
|
||||
[Troubleshooting](/troubleshooting#read-logs). Invalid modules and setup failures are
|
||||
logged; one failing package does not prevent unrelated valid packages from
|
||||
being resolved.
|
||||
|
||||
## Effect
|
||||
|
||||
OpenCode provides a first-class Effect API for plugins through the
|
||||
`@opencode-ai/plugin/v2/effect` entrypoint. Install `effect` alongside the
|
||||
plugin package and export an `effect` function instead of `setup`:
|
||||
|
||||
```sh
|
||||
bun add @opencode-ai/plugin@next effect
|
||||
```
|
||||
|
||||
```ts title=".opencode/plugins/reviewer-effect.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.reviewer-effect",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.agent.transform((agents) => {
|
||||
agents.update("reviewer", (agent) => {
|
||||
agent.description = "Reviews code for regressions"
|
||||
agent.mode = "subagent"
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
Context operations return Effects. The plugin effect is scoped, so finalizers,
|
||||
fibers, and registrations are released when the plugin reloads or unloads.
|
||||
OpenCode does not expose its private Core services to the plugin; use the
|
||||
capabilities on `ctx`.
|
||||
|
||||
Typed tools can use `Schema` from `effect` and `Tool.make` from
|
||||
`@opencode-ai/plugin/v2/effect/tool`. Effect and Promise plugins use the same
|
||||
`tools.add(name, tool, options?)` registration shape. Effect executors
|
||||
return an Effect and may fail with the typed tool failure channel.
|
||||
Vendored
-84
@@ -1,84 +0,0 @@
|
||||
---
|
||||
title: "SDK"
|
||||
description: "Embed OpenCode directly in your application."
|
||||
---
|
||||
|
||||
We're working on a general-purpose SDK for embedding OpenCode directly inside
|
||||
your application. The regular SDK is coming soon.
|
||||
|
||||
An Effect-native version is available now for applications built with Effect.
|
||||
Its current documentation is below. For other applications, run OpenCode as a
|
||||
server and use the [TypeScript client](/build/client) in the meantime.
|
||||
|
||||
## Effect
|
||||
|
||||
`@opencode-ai/sdk-next` hosts OpenCode in-process. Unlike the
|
||||
[network client](/build/client), it assembles the OpenCode server and routes API
|
||||
calls through its HTTP router in memory. It opens no HTTP listener and adds no
|
||||
network hop between the client and server.
|
||||
|
||||
<Warning>
|
||||
The V2 SDK is beta and currently private to the OpenCode workspace. It is not
|
||||
published for external installation yet, and its package name and API may
|
||||
change before release.
|
||||
</Warning>
|
||||
|
||||
## Create a host
|
||||
|
||||
`OpenCode.create()` creates a scoped host. Closing its Effect Scope releases
|
||||
the router, location services, fibers, and scoped plugin registrations.
|
||||
|
||||
```ts
|
||||
import {
|
||||
AbsolutePath,
|
||||
Location,
|
||||
OpenCode,
|
||||
} from "@opencode-ai/sdk-next"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const program = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const opencode = yield* OpenCode.create()
|
||||
const session = yield* opencode.sessions.create({
|
||||
location: Location.Ref.make({
|
||||
directory: AbsolutePath.make("/workspace"),
|
||||
}),
|
||||
})
|
||||
|
||||
return yield* opencode.sessions.get({ sessionID: session.id })
|
||||
}),
|
||||
)
|
||||
|
||||
const session = await Effect.runPromise(program)
|
||||
```
|
||||
|
||||
The embedded host uses the same routes, middleware, codecs, errors, and schema
|
||||
values as `@opencode-ai/client/effect`. It exposes the full generated client and
|
||||
adds the convenience aliases `sessions` and `events` for the session and event
|
||||
groups.
|
||||
|
||||
## Use as a service
|
||||
|
||||
Use `OpenCode.layer` when the host should be provided through Effect dependency
|
||||
injection:
|
||||
|
||||
```ts
|
||||
import { OpenCode } from "@opencode-ai/sdk-next"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const opencode = yield* OpenCode.Service
|
||||
return yield* opencode.sessions.active()
|
||||
})
|
||||
|
||||
const active = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(OpenCode.layer)),
|
||||
)
|
||||
```
|
||||
|
||||
## Register plugins
|
||||
|
||||
Call `opencode.plugin(...)` to register an embedded V2 plugin. Embedded plugins
|
||||
use the same discovery and location-scoped activation path as configured
|
||||
plugins. The SDK also exports `Tool` for plugin-defined tools. See the
|
||||
[Plugins guide](/build/plugins) for the plugin shape and available hooks.
|
||||
@@ -1,78 +0,0 @@
|
||||
{
|
||||
"$schema": "https://mintlify.com/docs.json",
|
||||
"theme": "mint",
|
||||
"name": "OpenCode",
|
||||
"description": "OpenCode documentation.",
|
||||
"colors": {
|
||||
"primary": "#3B7DD8",
|
||||
"light": "#3B7DD8",
|
||||
"dark": "#FAB283"
|
||||
},
|
||||
"favicon": "/assets/favicon.svg",
|
||||
"logo": {
|
||||
"light": "/assets/logo-light.svg",
|
||||
"dark": "/assets/logo-dark.svg"
|
||||
},
|
||||
"navigation": {
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "Docs",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Get started",
|
||||
"pages": ["index", "migrate-v1", "config", "troubleshooting"]
|
||||
},
|
||||
{
|
||||
"group": "Configure",
|
||||
"pages": [
|
||||
"providers",
|
||||
"models",
|
||||
"agents",
|
||||
"permissions",
|
||||
"sharing",
|
||||
"snapshots",
|
||||
"commands",
|
||||
"skills",
|
||||
"instructions",
|
||||
"mcp-servers",
|
||||
"attachments",
|
||||
"compaction",
|
||||
"warming",
|
||||
"themes",
|
||||
"formatters",
|
||||
"lsp",
|
||||
"references"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Build",
|
||||
"pages": ["build/index", "build/plugins", "build/client", "build/sdk"]
|
||||
},
|
||||
{
|
||||
"tab": "API",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Overview",
|
||||
"pages": ["api/index"]
|
||||
},
|
||||
{
|
||||
"group": "Endpoints",
|
||||
"openapi": "openapi.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"global": {}
|
||||
},
|
||||
"contextual": {
|
||||
"options": ["copy", "view", "chatgpt", "claude", "mcp", "cursor", "vscode"]
|
||||
},
|
||||
"footer": {
|
||||
"socials": {
|
||||
"github": "https://github.com/anomalyco/opencode",
|
||||
"x": "https://x.com/opencode"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
---
|
||||
title: "LSP"
|
||||
description: ""
|
||||
---
|
||||
|
||||
Language Server Protocol (LSP) integrations can provide code diagnostics,
|
||||
symbols, definitions, references, and other language-aware context.
|
||||
|
||||
<Warning>
|
||||
OpenCode V2 does not yet have an LSP runtime or built-in language servers.
|
||||
The `lsp` configuration is accepted and preserved, but it does not currently
|
||||
start or download servers, expose an LSP tool, or add diagnostics to file tool
|
||||
results.
|
||||
</Warning>
|
||||
|
||||
## Built-in servers
|
||||
|
||||
There are no built-in LSP servers in the current V2 implementation. Setting
|
||||
`lsp` to `true` declares that built-ins should be enabled, but has no runtime
|
||||
effect until V2 provides a server registry and LSP runtime.
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"lsp": true
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The `lsp` field accepts a boolean or an object keyed by server name:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"lsp": {
|
||||
"custom-typescript": {
|
||||
"command": ["typescript-language-server", "--stdio"],
|
||||
"extensions": [".ts", ".tsx"],
|
||||
"env": {
|
||||
"TSS_LOG": "-level verbose"
|
||||
},
|
||||
"initialization": {
|
||||
"preferences": {
|
||||
"importModuleSpecifierPreference": "relative"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each enabled server entry has this shape:
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `command` | `string[]` | Yes | Executable followed by any arguments. |
|
||||
| `extensions` | `string[]` | No | File extensions associated with the server, including the leading dot. |
|
||||
| `disabled` | `boolean` | No | Disables the entry when `true`. |
|
||||
| `env` | `Record<string, string>` | No | Environment variables for the server process. The property is named `env`, not `environment`. |
|
||||
| `initialization` | `Record<string, unknown>` | No | Server-specific options for the LSP `initialize` request. |
|
||||
|
||||
The only entry that may omit `command` is the disable-only form:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"lsp": {
|
||||
"typescript": {
|
||||
"disabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Server names are arbitrary. The V2 schema permits `extensions` to be omitted,
|
||||
including for a custom server, although a future runtime will need a way to
|
||||
associate that server with files.
|
||||
|
||||
## Disable LSP
|
||||
|
||||
Omit `lsp` when no configuration is needed. Set it to `false` to explicitly
|
||||
disable the whole integration, including when a lower-priority configuration
|
||||
set it to `true` or supplied an object:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"lsp": false
|
||||
}
|
||||
```
|
||||
|
||||
Use `{ "disabled": true }` under a server name to disable one server while
|
||||
retaining the object form. `OPENCODE_DISABLE_LSP_DOWNLOAD` is not used by V2;
|
||||
V2 currently performs no automatic LSP downloads.
|
||||
|
||||
## Current usage
|
||||
|
||||
V2 loads and validates the configuration shape for compatibility and future
|
||||
integration. It does not currently use LSP when reading, writing, editing, or
|
||||
patching files, and those tools do not notify a language server or return LSP
|
||||
diagnostics.
|
||||
|
||||
For reliable feedback today, have the agent run the project's lint, typecheck,
|
||||
test, or compiler commands. Record those commands in an `AGENTS.md` file or a
|
||||
skill so the agent knows when and where to run them.
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/docs",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun run generate && bun --bun mint dev --no-open --port 3333",
|
||||
"generate": "bun script/generate-theme-tokens.ts",
|
||||
"check:generated": "bun script/generate-theme-tokens.ts --check",
|
||||
"validate": "bun run check:generated && bun --bun mint validate",
|
||||
"broken-links": "bun --bun mint broken-links"
|
||||
},
|
||||
"devDependencies": {
|
||||
"effect": "catalog:",
|
||||
"mint": "4.2.666",
|
||||
"prettier": "3.6.2"
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
---
|
||||
title: "Permissions"
|
||||
description: ""
|
||||
---
|
||||
|
||||
Permissions control whether an agent may perform an action on a resource. V2
|
||||
configuration uses the `permissions` field and an ordered array of rules.
|
||||
|
||||
<Warning>
|
||||
The V1 object syntax uses different field and action names. Do not use
|
||||
`permission`, `bash`, or `task` in V2 configuration; use `permissions`,
|
||||
`shell`, and `subagent`.
|
||||
</Warning>
|
||||
|
||||
## Rule schema
|
||||
|
||||
Each rule has three required string fields:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permissions": [
|
||||
{ "action": "*", "resource": "*", "effect": "ask" },
|
||||
{ "action": "read", "resource": "*", "effect": "allow" },
|
||||
{ "action": "read", "resource": "*.env", "effect": "deny" },
|
||||
{ "action": "shell", "resource": "git status *", "effect": "allow" },
|
||||
{ "action": "shell", "resource": "git push *", "effect": "deny" },
|
||||
{ "action": "edit", "resource": "packages/docs/*.mdx", "effect": "allow" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `action` matches a tool permission action.
|
||||
- `resource` matches the value the tool is trying to use, such as a path,
|
||||
command, URL, query, or agent ID.
|
||||
- `effect` is `"allow"`, `"deny"`, or `"ask"`.
|
||||
|
||||
`allow` proceeds without prompting, `deny` blocks the operation, and `ask`
|
||||
waits for a user decision. If no rule matches, the result is `ask`.
|
||||
|
||||
## Matching and order
|
||||
|
||||
Both `action` and `resource` support simple wildcards:
|
||||
|
||||
- `*` matches zero or more characters, including `/`.
|
||||
- `?` matches exactly one character.
|
||||
- All other characters are literal.
|
||||
|
||||
Matches cover the entire value. Slashes are normalized, and matching is
|
||||
case-insensitive on Windows. For shell convenience, a pattern ending in
|
||||
`" *"` also matches the command without arguments: `"git status *"` matches
|
||||
both `git status` and `git status --short`.
|
||||
|
||||
The **last matching rule wins**. Put broad rules first and exceptions later.
|
||||
Rules from lower-priority configuration files are loaded first. OpenCode then
|
||||
appends all global rules before agent-specific rules, so a matching agent rule
|
||||
overrides a global rule.
|
||||
|
||||
Some operations check several resources at once, such as a patch touching
|
||||
multiple files. OpenCode denies the operation if any resource resolves to
|
||||
`deny`; otherwise it asks if any resolves to `ask`; otherwise it allows it.
|
||||
|
||||
## Actions and resources
|
||||
|
||||
V2 action names are strings, so plugins may introduce additional actions. The
|
||||
current built-in actions use these resources:
|
||||
|
||||
| Action | Resource matched |
|
||||
| --- | --- |
|
||||
| `read` | Location-relative path for an internal file or directory; canonical absolute path for an external target |
|
||||
| `edit` | Target path for `edit`, `write`, and `patch`; all three tools share this action |
|
||||
| `glob` | The requested glob pattern |
|
||||
| `grep` | The requested regular expression, not the search path |
|
||||
| `shell` | The complete raw shell command string |
|
||||
| `subagent` | The target agent ID |
|
||||
| `skill` | The skill ID |
|
||||
| `question` | `*` |
|
||||
| `webfetch` | The requested URL |
|
||||
| `websearch` | The search query |
|
||||
| `external_directory` | A canonical external directory boundary, normally ending in `/*` |
|
||||
| `<server>_<tool>` | `*` for an MCP tool; unsupported characters in both names become `_` |
|
||||
| `execute` | `*`; controls availability of the Code Mode dispatcher, while each nested tool still enforces its own permission |
|
||||
|
||||
Built-in agent policy also reserves `plan_enter` and `plan_exit` for plan-mode
|
||||
transitions. `doom_loop` and `lsp` are not current V2 Core permission actions.
|
||||
|
||||
## External directories
|
||||
|
||||
An external path requires a separate `external_directory` decision before the
|
||||
tool's own `read` or `edit` decision. This applies to external paths used by
|
||||
`read`, `edit`, `write`, and `patch`, and to an external `shell` working
|
||||
directory.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permissions": [
|
||||
{
|
||||
"action": "external_directory",
|
||||
"resource": "~/projects/reference/*",
|
||||
"effect": "allow"
|
||||
},
|
||||
{
|
||||
"action": "read",
|
||||
"resource": "~/projects/reference/*",
|
||||
"effect": "allow"
|
||||
},
|
||||
{
|
||||
"action": "edit",
|
||||
"resource": "~/projects/reference/*",
|
||||
"effect": "deny"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
For `external_directory`, `read`, and `edit` resources, a leading `~`, `~/`,
|
||||
`$HOME`, or `$HOME/` is expanded when configuration loads. Shell resources are
|
||||
raw command text and are **not** home-expanded.
|
||||
|
||||
<Warning>
|
||||
`shell` runs with the host user's filesystem, process, and network authority.
|
||||
Its resource is raw text, not a parsed command. External command arguments
|
||||
produce only best-effort warnings; `external_directory` is enforced for the
|
||||
working directory, not every path embedded in a command. Prefer a narrow
|
||||
shell allowlist over patterns intended to identify every dangerous command.
|
||||
</Warning>
|
||||
|
||||
Relative mutation paths cannot escape the active Location, and symlink escapes
|
||||
from inside it are rejected. Explicit external paths are canonicalized before
|
||||
matching, so authorize only trusted directory boundaries.
|
||||
|
||||
## Defaults
|
||||
|
||||
The evaluator's fallback is `ask`, but shipped agents include ordered defaults:
|
||||
|
||||
| Agent | Effective default policy |
|
||||
| --- | --- |
|
||||
| `build` | Allows most actions; asks for external directories and `.env` reads; allows questions and entering plan mode; denies exiting plan mode |
|
||||
| `plan` | Uses the same base, allows questions and exiting plan mode, and denies edits except OpenCode plan files |
|
||||
| `general` | Uses the base policy but cannot launch another subagent; questions and plan transitions remain denied |
|
||||
| `explore` | Denies everything except `read`, `glob`, `grep`, `webfetch`, and `websearch`; cannot launch subagents and asks for external directories |
|
||||
| Hidden maintenance agents | Deny all actions |
|
||||
|
||||
The base read rules are ordered as follows:
|
||||
|
||||
```jsonc
|
||||
[
|
||||
{ "action": "read", "resource": "*", "effect": "allow" },
|
||||
{ "action": "read", "resource": "*.env", "effect": "ask" },
|
||||
{ "action": "read", "resource": "*.env.*", "effect": "ask" },
|
||||
{ "action": "read", "resource": "*.env.example", "effect": "allow" }
|
||||
]
|
||||
```
|
||||
|
||||
OpenCode also permits its managed tool-output and temporary directories where
|
||||
needed. These exceptions do not grant general external-directory access.
|
||||
|
||||
## Agent overrides
|
||||
|
||||
Configure shared policy at the top level and append narrower rules to a named
|
||||
agent under `agents.<id>.permissions`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permissions": [
|
||||
{ "action": "shell", "resource": "*", "effect": "ask" },
|
||||
{ "action": "shell", "resource": "git diff *", "effect": "allow" },
|
||||
{ "action": "shell", "resource": "git status *", "effect": "allow" }
|
||||
],
|
||||
"agents": {
|
||||
"reviewer": {
|
||||
"description": "Review code without changing it",
|
||||
"mode": "subagent",
|
||||
"permissions": [
|
||||
{ "action": "edit", "resource": "*", "effect": "deny" },
|
||||
{ "action": "shell", "resource": "git diff *", "effect": "allow" },
|
||||
{ "action": "shell", "resource": "git status *", "effect": "allow" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Agent rules do not replace the global array; they are appended after it. A
|
||||
custom subagent executes with its own permissions, not a permission subset
|
||||
derived from the parent agent.
|
||||
|
||||
## Approval choices
|
||||
|
||||
When an `ask` rule matches, clients can reply with:
|
||||
|
||||
- **Allow once** (`once`): approve only the pending request.
|
||||
- **Allow always** (`always`): approve this request and save the patterns
|
||||
proposed by the tool for the current project.
|
||||
- **Reject** (`reject`): reject the request. Rejecting also rejects other
|
||||
pending permission requests in the same session; clients may attach feedback.
|
||||
|
||||
Saved approvals are durable and project-scoped. They are additional `allow`
|
||||
rules, but they can never override a configured `deny`. The proposed saved
|
||||
pattern may be broader than the displayed resource: several tools propose `*`,
|
||||
shell proposes the exact command text, and skills and subagents propose their
|
||||
IDs. Review the confirmation carefully and remove saved approvals that are no
|
||||
longer needed.
|
||||
|
||||
For non-interactive runs, `opencode2 run --auto` replies `once` to permission
|
||||
requests. It does not save approvals, and explicit `deny` rules remain enforced.
|
||||
Without `--auto`, a non-interactive run rejects permission requests.
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
title: "Session sharing"
|
||||
description: ""
|
||||
---
|
||||
|
||||
Session sharing is not yet available in OpenCode V2. V2 does not currently
|
||||
publish sessions, upload conversation history to a sharing service, or create
|
||||
public links.
|
||||
|
||||
<Warning>
|
||||
The V2 TUI registers `/share`, but it currently only reports that sharing is unavailable. There is no functional
|
||||
share/unshare command or server API endpoint.
|
||||
</Warning>
|
||||
|
||||
## Configuration
|
||||
|
||||
The V2 configuration schema accepts a `share` field with three values:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "manual"
|
||||
}
|
||||
```
|
||||
|
||||
- `"manual"` represents sharing only when explicitly requested.
|
||||
- `"auto"` represents automatically sharing new sessions.
|
||||
- `"disabled"` represents preventing session sharing.
|
||||
|
||||
These values are parsed but are not acted on by the current V2 runtime. In
|
||||
particular, setting `"auto"` does not publish sessions. If `share` is omitted,
|
||||
V2 leaves the sharing policy unspecified.
|
||||
|
||||
## Beta limitations
|
||||
|
||||
V2 currently provides no public session viewer, share URL, history sync,
|
||||
retention controls, or unshare/delete operation. Until those surfaces are
|
||||
implemented in the V2 server and protocol, keep using sessions locally and do
|
||||
not treat the `share` configuration field as a privacy or publishing control.
|
||||
@@ -28,7 +28,7 @@
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/sdk": "1.18.5",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:"
|
||||
|
||||
@@ -9,3 +9,4 @@ export { Model } from "@opencode-ai/schema/model"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Reference } from "@opencode-ai/schema/reference"
|
||||
export { Skill } from "@opencode-ai/schema/skill"
|
||||
export { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { ReferenceDomain } from "./reference.js"
|
||||
import type { SessionDomain } from "./session.js"
|
||||
import type { SkillDomain } from "./skill.js"
|
||||
import type { ToolDomain } from "./tool.js"
|
||||
import type { WebSearchDomain } from "./websearch.js"
|
||||
|
||||
export interface Context {
|
||||
readonly app: App
|
||||
@@ -27,6 +28,7 @@ export interface Context {
|
||||
readonly session: SessionDomain
|
||||
readonly skill: SkillDomain
|
||||
readonly tool: ToolDomain
|
||||
readonly websearch: WebSearchDomain
|
||||
}
|
||||
|
||||
export interface Plugin<R = Scope.Scope> {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import type { WebsearchApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Effect } from "effect"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface WebSearchDefinition {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly execute: (input: WebSearch.ProviderInput) => Effect.Effect<readonly WebSearch.Result[], unknown>
|
||||
}
|
||||
|
||||
export interface WebSearchDomain extends WebsearchApi<unknown> {
|
||||
readonly transform: Transform<WebSearchDraft>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface WebSearchDraft {
|
||||
add(definition: WebSearchDefinition): void
|
||||
readonly default: {
|
||||
get(): string | undefined
|
||||
set(providerID: string): void
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,4 @@ export { Model } from "@opencode-ai/schema/model"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Reference } from "@opencode-ai/schema/reference"
|
||||
export { Skill } from "@opencode-ai/schema/skill"
|
||||
export { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
import type { IntegrationApi } from "@opencode-ai/client/promise/api"
|
||||
import type { IntegrationDraft, IntegrationMethodRegistration } from "../effect/integration.js"
|
||||
import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
|
||||
import type {
|
||||
CredentialOAuth,
|
||||
CredentialValue,
|
||||
IntegrationEnvMethod,
|
||||
IntegrationInputs,
|
||||
IntegrationKeyMethod,
|
||||
IntegrationOAuthMethod,
|
||||
} from "@opencode-ai/sdk/v2/types"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export type { IntegrationDraft, IntegrationMethodRegistration }
|
||||
|
||||
export type IntegrationOAuthAuthorization = {
|
||||
readonly url: string
|
||||
readonly instructions: string
|
||||
readonly expiresAt?: number
|
||||
} & (
|
||||
| {
|
||||
readonly mode: "auto"
|
||||
readonly callback: Promise<CredentialOAuth>
|
||||
}
|
||||
| {
|
||||
readonly mode: "code"
|
||||
readonly callback: (code: string) => Promise<CredentialOAuth>
|
||||
}
|
||||
)
|
||||
|
||||
export interface IntegrationDomain extends Omit<IntegrationApi, "wellknown"> {
|
||||
readonly transform: Transform<IntegrationDraft>
|
||||
readonly reload: () => Promise<void>
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { ReferenceDomain } from "./reference.js"
|
||||
import type { SessionDomain } from "./session.js"
|
||||
import type { SkillDomain } from "./skill.js"
|
||||
import type { ToolDomain } from "./tool.js"
|
||||
import type { WebSearchDomain } from "./websearch.js"
|
||||
|
||||
export interface Context {
|
||||
readonly app: App
|
||||
@@ -26,6 +27,7 @@ export interface Context {
|
||||
readonly session: SessionDomain
|
||||
readonly skill: SkillDomain
|
||||
readonly tool: ToolDomain
|
||||
readonly websearch: WebSearchDomain
|
||||
}
|
||||
|
||||
export type Cleanup = () => Promise<void> | void
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import type { WebSearchApi } from "@opencode-ai/client/promise/api"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface WebSearchDefinition {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly execute: (
|
||||
input: WebSearch.ProviderInput,
|
||||
context: { readonly signal: AbortSignal },
|
||||
) => Promise<readonly WebSearch.Result[]>
|
||||
}
|
||||
|
||||
export interface WebSearchDomain extends WebSearchApi {
|
||||
readonly transform: Transform<WebSearchDraft>
|
||||
readonly reload: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface WebSearchDraft {
|
||||
add(definition: WebSearchDefinition): void
|
||||
readonly default: {
|
||||
get(): string | undefined
|
||||
set(providerID: string): void
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Reference } from "@opencode-ai/schema/reference"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
|
||||
const Plugin = await import("../src/v2/effect/index")
|
||||
const PromisePlugin = await import("../src/v2/promise/index")
|
||||
@@ -16,7 +17,7 @@ const TuiPlugin = await import("../src/v2/tui/index")
|
||||
test.each([
|
||||
["effect", Plugin],
|
||||
["promise", PromisePlugin],
|
||||
])("%s entrypoint exposes its canonical Schema contracts", (name, entrypoint) => {
|
||||
])("%s entrypoint exposes its canonical Schema contracts", (_name, entrypoint) => {
|
||||
expect(entrypoint.Agent).toBe(Agent)
|
||||
expect(entrypoint.Command).toBe(Command)
|
||||
expect(entrypoint.Connection).toBe(Connection)
|
||||
@@ -26,6 +27,7 @@ test.each([
|
||||
expect(entrypoint.Provider).toBe(Provider)
|
||||
expect(entrypoint.Reference).toBe(Reference)
|
||||
expect(entrypoint.Skill).toBe(Skill)
|
||||
expect(entrypoint.WebSearch).toBe(WebSearch)
|
||||
expect(Object.keys(entrypoint).sort()).toEqual([
|
||||
"Agent",
|
||||
"Command",
|
||||
@@ -37,6 +39,7 @@ test.each([
|
||||
"Provider",
|
||||
"Reference",
|
||||
"Skill",
|
||||
"WebSearch",
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"generate": "bun run script/generate-openapi.ts",
|
||||
"check:generated": "bun run script/generate-openapi.ts --check",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { fileURLToPath } from "url"
|
||||
import { ClientApi } from "../src/client.js"
|
||||
|
||||
const document = JSON.stringify(OpenApi.fromApi(ClientApi), null, 2) + "\n"
|
||||
const target = fileURLToPath(new URL("../openapi.json", import.meta.url))
|
||||
|
||||
if (process.argv.includes("--check")) {
|
||||
if ((await Bun.file(target).text()) !== document) {
|
||||
console.error("Generated OpenAPI document is stale. Run `bun run generate` from packages/protocol.")
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await Bun.write(target, document)
|
||||
@@ -25,6 +25,7 @@ import { ReferenceGroup } from "./groups/reference.js"
|
||||
import { Authorization } from "./middleware/authorization.js"
|
||||
import { LocationGroup } from "./groups/location.js"
|
||||
import { IntegrationGroup } from "./groups/integration.js"
|
||||
import { WebSearchGroup } from "./groups/websearch.js"
|
||||
import { McpGroup } from "./groups/mcp.js"
|
||||
import { CredentialGroup } from "./groups/credential.js"
|
||||
import { ProjectGroup } from "./groups/project.js"
|
||||
@@ -39,6 +40,7 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
||||
| HttpApiGroup.AddMiddleware<typeof GenerateGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ProviderGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof IntegrationGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof WebSearchGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof McpGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof CredentialGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ProjectGroup, LocationId>
|
||||
@@ -169,6 +171,7 @@ const makeApiFromGroup = <
|
||||
.add(ProjectCopyGroup.middleware(locationMiddleware))
|
||||
.add(VcsGroup.middleware(locationMiddleware))
|
||||
.add(DebugGroup)
|
||||
.add(WebSearchGroup.middleware(locationMiddleware))
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode HttpApi",
|
||||
|
||||
@@ -45,6 +45,7 @@ export const groupNames = {
|
||||
"server.generate": "generate",
|
||||
"server.provider": "provider",
|
||||
"server.integration": "integration",
|
||||
"server.websearch": "websearch",
|
||||
"server.credential": "credential",
|
||||
"server.form": "form",
|
||||
"server.permission": "permission",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { InvalidRequestError, ServiceUnavailableError } from "../errors.js"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
export const WebSearchGroup = HttpApiGroup.make("server.websearch")
|
||||
.add(
|
||||
HttpApiEndpoint.get("websearch.providers", "/api/websearch/provider", {
|
||||
query: LocationQuery,
|
||||
success: Location.response(Schema.Array(WebSearch.Provider)),
|
||||
error: ServiceUnavailableError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.websearch.providers",
|
||||
summary: "List web search providers",
|
||||
description: "Return the registered web search providers.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("websearch.query", "/api/websearch", {
|
||||
query: LocationQuery,
|
||||
payload: Schema.Struct(WebSearch.Input.fields),
|
||||
success: Location.response(WebSearch.Response),
|
||||
error: [InvalidRequestError, ServiceUnavailableError],
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.websearch.query",
|
||||
summary: "Search the web",
|
||||
description:
|
||||
"Run one web search through the selected provider. Specify a provider to override the configured default.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "websearch",
|
||||
description: "Location-scoped web search routes.",
|
||||
}),
|
||||
)
|
||||
@@ -36,6 +36,7 @@ import { TuiEvent } from "./tui-event.js"
|
||||
import { VcsEvent } from "./vcs-event.js"
|
||||
import { WorkspaceEvent } from "./workspace-event.js"
|
||||
import { WorktreeEvent } from "./worktree-event.js"
|
||||
import { WebSearch } from "./websearch.js"
|
||||
|
||||
const sessionV1DurableDefinitions = SessionV1.Event.Definitions.filter(
|
||||
(definition) => definition.durability === "durable",
|
||||
@@ -67,6 +68,7 @@ const featureDefinitions = Event.inventory(
|
||||
...Shell.Event.Definitions,
|
||||
...Question.Event.Definitions,
|
||||
...Form.Event.Definitions,
|
||||
...WebSearch.Event.Definitions,
|
||||
)
|
||||
|
||||
export const ServerDefinitions = Event.inventory(
|
||||
|
||||
@@ -18,6 +18,7 @@ export { Project } from "./project.js"
|
||||
export { ProjectCopy } from "./project-copy.js"
|
||||
export { Provider } from "./provider.js"
|
||||
export { Reference } from "./reference.js"
|
||||
export { WebSearch } from "./websearch.js"
|
||||
export { Session } from "./session.js"
|
||||
export { Vcs } from "./vcs.js"
|
||||
export { SessionPending } from "./session-pending.js"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export * as WebSearch from "./websearch.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
import { optional } from "./schema.js"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("WebSearch.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export interface Provider extends Schema.Schema.Type<typeof Provider> {}
|
||||
export const Provider = Schema.Struct({
|
||||
id: ID,
|
||||
name: Schema.String,
|
||||
}).annotate({ identifier: "WebSearch.Provider" })
|
||||
|
||||
export interface Input extends Schema.Schema.Type<typeof Input> {}
|
||||
export const Input = Schema.Struct({
|
||||
query: Schema.String,
|
||||
providerID: ID.pipe(optional),
|
||||
}).annotate({ identifier: "WebSearch.Input" })
|
||||
export type ProviderInput = Pick<Input, "query">
|
||||
|
||||
export interface Result extends Schema.Schema.Type<typeof Result> {}
|
||||
export const Result = Schema.Struct({
|
||||
url: Schema.String,
|
||||
title: Schema.String.pipe(optional),
|
||||
content: Schema.String.pipe(optional),
|
||||
time: Schema.Struct({
|
||||
published: Schema.Finite.pipe(optional),
|
||||
}),
|
||||
}).annotate({ identifier: "WebSearch.Result" })
|
||||
|
||||
export class Response extends Schema.Class<Response>("WebSearch.Response")({
|
||||
providerID: ID,
|
||||
results: Schema.Array(Result),
|
||||
}) {}
|
||||
|
||||
const Updated = ephemeral({
|
||||
type: "websearch.updated",
|
||||
schema: {},
|
||||
})
|
||||
export const Event = { Updated, Definitions: inventory(Updated) }
|
||||
@@ -20,6 +20,7 @@ export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Pty } from "@opencode-ai/schema/pty"
|
||||
export { Question } from "@opencode-ai/schema/question"
|
||||
export { Reference } from "@opencode-ai/schema/reference"
|
||||
export { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
||||
export { Session } from "@opencode-ai/schema/session"
|
||||
export { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Location } from "@opencode-ai/schema/location"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
@@ -24,6 +25,7 @@ const SDK = await import("../src/index")
|
||||
test("re-exports canonical contracts directly from Schema", () => {
|
||||
expect(SDK.Agent).toBe(Agent)
|
||||
expect(SDK.Model).toBe(Model)
|
||||
expect(SDK.WebSearch).toBe(WebSearch)
|
||||
expect(SDK.Session).toBe(Session)
|
||||
expect(Object.keys(SDK).sort()).toEqual([
|
||||
"AbsolutePath",
|
||||
@@ -52,6 +54,7 @@ test("re-exports canonical contracts directly from Schema", () => {
|
||||
"SessionPending",
|
||||
"Skill",
|
||||
"Tool",
|
||||
"WebSearch",
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -328,6 +328,38 @@ it.live(
|
||||
10_000,
|
||||
)
|
||||
|
||||
it.live("embedded client exposes plugin-backed web search", () =>
|
||||
withEmbedded("opencode-embedded-websearch-", (fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = yield* fixture.sdk.OpenCode.create()
|
||||
const providerID = fixture.sdk.WebSearch.ID.make("embedded-websearch")
|
||||
yield* opencode.plugin({
|
||||
id: `embedded-websearch-${crypto.randomUUID()}`,
|
||||
effect: (ctx) =>
|
||||
ctx.websearch.transform((draft) => {
|
||||
draft.add({
|
||||
id: providerID,
|
||||
name: "Embedded web search",
|
||||
execute: (input) =>
|
||||
Effect.succeed([{ url: "https://example.com", content: `Found ${input.query}`, time: {} }]),
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
const result = yield* opencode.websearch.query({
|
||||
query: "opencode",
|
||||
providerID,
|
||||
location: location(fixture),
|
||||
})
|
||||
|
||||
expect(result.data).toEqual({
|
||||
providerID,
|
||||
results: [{ url: "https://example.com", content: "Found opencode", time: {} }],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"Location-owned runner events reach the ready global client",
|
||||
() =>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
.prism.log
|
||||
node_modules
|
||||
yarn-error.log
|
||||
codegen.log
|
||||
Brewfile.lock.json
|
||||
dist
|
||||
dist-deno
|
||||
/*.tgz
|
||||
.idea/
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { createOpencodeClient, createOpencodeServer } from "@opencode-ai/sdk"
|
||||
import { pathToFileURL } from "bun"
|
||||
|
||||
const server = await createOpencodeServer()
|
||||
const client = createOpencodeClient({ baseUrl: server.url })
|
||||
|
||||
const input = await Array.fromAsync(new Bun.Glob("packages/core/*.ts").scan())
|
||||
|
||||
const tasks: Promise<void>[] = []
|
||||
for await (const file of input) {
|
||||
console.log("processing", file)
|
||||
const session = await client.session.create()
|
||||
tasks.push(
|
||||
client.session.prompt({
|
||||
path: { id: session.data.id },
|
||||
body: {
|
||||
parts: [
|
||||
{
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url: pathToFileURL(file).href,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `Write tests for every public function in this file.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
console.log("done", file)
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
input.map(async (file) => {
|
||||
const session = await client.session.create()
|
||||
console.log("processing", file)
|
||||
await client.session.prompt({
|
||||
path: { id: session.data.id },
|
||||
body: {
|
||||
parts: [
|
||||
{
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url: pathToFileURL(file).href,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `Write tests for every public function in this file.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
console.log("done", file)
|
||||
}),
|
||||
)
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.18.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client.ts",
|
||||
"./server": "./src/server.ts",
|
||||
"./v2": "./src/v2/index.ts",
|
||||
"./v2/client": "./src/v2/client.ts",
|
||||
"./v2/gen/client": "./src/v2/gen/client/index.ts",
|
||||
"./v2/server": "./src/v2/server.ts",
|
||||
"./v2/types": "./src/v2/gen/types.gen.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/cross-spawn": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const dir = fileURLToPath(new URL("..", import.meta.url))
|
||||
process.chdir(dir)
|
||||
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
|
||||
import { createClient } from "@hey-api/openapi-ts"
|
||||
|
||||
const opencode = path.resolve(dir, "../../opencode")
|
||||
const client = path.resolve(dir, "../../client")
|
||||
|
||||
if (!(await Bun.file(path.join(opencode, "package.json")).exists())) {
|
||||
await $`rm -rf dist`
|
||||
await $`bun tsc`
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode)
|
||||
await $`bun -e ${`
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { ClientApi } from "@opencode-ai/protocol/client"
|
||||
|
||||
const output = process.argv.at(-1)
|
||||
if (!output) throw new Error("Missing OpenAPI output path")
|
||||
await Bun.write(output, JSON.stringify(OpenApi.fromApi(ClientApi)))
|
||||
`} ${path.join(dir, "openapi-v2.json")}`.cwd(client)
|
||||
|
||||
type OpenApiDocument = {
|
||||
components?: { schemas?: Record<string, unknown> }
|
||||
paths?: Record<string, unknown>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const document = (await Bun.file("./openapi.json").json()) as OpenApiDocument
|
||||
const v2Document = (await Bun.file("./openapi-v2.json").json()) as OpenApiDocument
|
||||
normalizeComponentNames(v2Document)
|
||||
deduplicateEquivalentComponent(v2Document, "Shell", "Shell1")
|
||||
renameCollidingComponents(document, v2Document)
|
||||
document.paths = { ...document.paths, ...v2Document.paths }
|
||||
document.components = {
|
||||
...document.components,
|
||||
schemas: { ...document.components?.schemas, ...v2Document.components?.schemas },
|
||||
}
|
||||
inlineTypedAllOfConstraints(document)
|
||||
const schemas = document.components?.schemas
|
||||
if (schemas) {
|
||||
const reachable = new Set<string>()
|
||||
const visit = (value: unknown) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(visit)
|
||||
return
|
||||
}
|
||||
if (typeof value !== "object" || value === null) return
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (key === "$ref" && typeof child === "string" && child.startsWith("#/components/schemas/")) {
|
||||
const name = child.slice("#/components/schemas/".length)
|
||||
if (reachable.has(name)) continue
|
||||
reachable.add(name)
|
||||
visit(schemas[name])
|
||||
} else {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit({ ...document, components: { ...document.components, schemas: undefined } })
|
||||
for (const name of Object.keys(schemas)) {
|
||||
if (
|
||||
/^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionInputPromoted|SessionInputAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)\d+$/.test(
|
||||
name,
|
||||
) &&
|
||||
!reachable.has(name)
|
||||
)
|
||||
delete schemas[name]
|
||||
}
|
||||
await Bun.write("./openapi.json", JSON.stringify(document))
|
||||
}
|
||||
|
||||
await createClient({
|
||||
input: "./openapi.json",
|
||||
output: {
|
||||
path: "./src/v2/gen",
|
||||
tsConfigPath: path.join(dir, "tsconfig.json"),
|
||||
clean: true,
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: "@hey-api/typescript",
|
||||
exportFromIndex: false,
|
||||
},
|
||||
{
|
||||
name: "@hey-api/sdk",
|
||||
instance: "OpencodeClient",
|
||||
exportFromIndex: false,
|
||||
auth: false,
|
||||
paramsStructure: "flat",
|
||||
},
|
||||
{
|
||||
name: "@hey-api/client-fetch",
|
||||
exportFromIndex: false,
|
||||
baseUrl: "http://localhost:4096",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const generatedTypesPath = "./src/v2/gen/types.gen.ts"
|
||||
const generatedTypes = await Bun.file(generatedTypesPath).text()
|
||||
if (
|
||||
/export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionInputPromoted|SessionInputAdmitted|SessionExecutionStarted|SessionExecutionSucceeded|SessionExecutionFailed|SessionExecutionInterrupted|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetryScheduled|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)\d+ =/.test(
|
||||
generatedTypes,
|
||||
)
|
||||
) {
|
||||
throw new Error("Session history generated duplicate Session event variants")
|
||||
}
|
||||
const sessionErrorTypesPatched = deduplicateEquivalentGeneratedTypes(
|
||||
generatedTypes,
|
||||
"SessionStructuredError",
|
||||
/^SessionStructuredError\d+$/,
|
||||
)
|
||||
const obsoleteSessionNext = [...sessionErrorTypesPatched.matchAll(/export type (SessionNext\w*) =/g)].map(
|
||||
(match) => match[1],
|
||||
)
|
||||
if (obsoleteSessionNext.length > 0) {
|
||||
throw new Error(`Obsolete SessionNext generated type noise reintroduced: ${obsoleteSessionNext.join(", ")}`)
|
||||
}
|
||||
const logTypesPatched = sessionErrorTypesPatched.replace(
|
||||
/(export type V2SessionLogData = \{[\s\S]*?query\?: \{\s*after\?: )string/,
|
||||
"$1number",
|
||||
)
|
||||
if (logTypesPatched === sessionErrorTypesPatched) {
|
||||
throw new Error("Session log numeric query patch did not apply")
|
||||
}
|
||||
const sessionListTypesPatched = logTypesPatched.replace(
|
||||
/(export type V2SessionListData = \{[\s\S]*?query\?: \{[\s\S]*?limit\?: )string( \| null)/,
|
||||
"$1number$2",
|
||||
)
|
||||
if (sessionListTypesPatched === logTypesPatched) {
|
||||
throw new Error("Session list numeric query patch did not apply")
|
||||
}
|
||||
const sessionMessagesTypesPatched = sessionListTypesPatched.replace(
|
||||
/(export type V2MessageListData = \{[\s\S]*?query\?: \{[\s\S]*?limit\?: )string( \| null)/,
|
||||
"$1number$2",
|
||||
)
|
||||
if (sessionMessagesTypesPatched === sessionListTypesPatched) {
|
||||
throw new Error("Session messages numeric query patch did not apply")
|
||||
}
|
||||
const eventSubscribeTypesPatched = sessionMessagesTypesPatched.replace(
|
||||
/(export type V2EventSubscribeResponses = \{\s*\/\*\*[\s\S]*?\*\/\s*200: )\{\s*id: string \| null;?\s*event: string;?\s*data: (?:V2EventStream(?:V2)?|V2EventJsonString);?\s*\};?/,
|
||||
"$1V2Event",
|
||||
)
|
||||
if (eventSubscribeTypesPatched === sessionMessagesTypesPatched) {
|
||||
throw new Error("Event subscribe response patch did not apply")
|
||||
}
|
||||
if (/SessionStructuredError\d/.test(eventSubscribeTypesPatched)) {
|
||||
throw new Error("Session structured error generated a name-mangled duplicate")
|
||||
}
|
||||
if (/\bSessionNext\w*\b/.test(eventSubscribeTypesPatched)) {
|
||||
throw new Error("Obsolete SessionNext generated type noise reintroduced")
|
||||
}
|
||||
if (/export type Shell\d+V2 =/.test(eventSubscribeTypesPatched)) {
|
||||
throw new Error("Shell generated a name-mangled duplicate")
|
||||
}
|
||||
await Bun.write(generatedTypesPath, eventSubscribeTypesPatched)
|
||||
|
||||
const querySerializerPath = "./src/v2/gen/client/utils.gen.ts"
|
||||
const querySerializerSource = await Bun.file(querySerializerPath).text()
|
||||
const querySerializerPatched = querySerializerSource.replace(
|
||||
/if \(value === undefined \|\| value === null\) \{\s*continue;?\s*\}/,
|
||||
"if (value === undefined) {\n continue;\n }\n\n if (value === null) {\n search.push(`${name}=null`);\n continue;\n }",
|
||||
)
|
||||
if (querySerializerPatched === querySerializerSource) {
|
||||
throw new Error(
|
||||
`Query serializer null patch did not apply; @hey-api/openapi-ts output may have changed (${querySerializerPath})`,
|
||||
)
|
||||
}
|
||||
await Bun.write(querySerializerPath, querySerializerPatched)
|
||||
|
||||
const generatedSdkPath = "./src/v2/gen/sdk.gen.ts"
|
||||
const generatedSdk = await Bun.file(generatedSdkPath).text()
|
||||
const logSdkPatched = generatedSdk.replace(
|
||||
/(Read the session log[\s\S]*?parameters: \{[\s\S]*?after\?: )string(\s*\|\s*null)?/,
|
||||
"$1number$2",
|
||||
)
|
||||
if (logSdkPatched === generatedSdk) {
|
||||
throw new Error("Session log numeric SDK patch did not apply")
|
||||
}
|
||||
const sessionListSdkPatched = logSdkPatched.replace(
|
||||
/(List sessions[\s\S]*?parameters\?: \{[\s\S]*?limit\?: )string( \| null)/,
|
||||
"$1number$2",
|
||||
)
|
||||
if (sessionListSdkPatched === logSdkPatched) {
|
||||
throw new Error("Session list numeric SDK patch did not apply")
|
||||
}
|
||||
const sessionMessagesSdkPatched = sessionListSdkPatched.replace(
|
||||
/(Get session messages[\s\S]*?parameters: \{[\s\S]*?limit\?: )string( \| null)/,
|
||||
"$1number$2",
|
||||
)
|
||||
if (sessionMessagesSdkPatched === sessionListSdkPatched) {
|
||||
throw new Error("Session messages numeric SDK patch did not apply")
|
||||
}
|
||||
await Bun.write(generatedSdkPath, sessionMessagesSdkPatched)
|
||||
|
||||
// Patch a @hey-api/openapi-ts codegen bug: SseFn incorrectly passes the
|
||||
// endpoint's TError into the second generic of ServerSentEventsResult, which
|
||||
// is the AsyncGenerator's TReturn slot. Iterator return values have nothing
|
||||
// to do with HTTP errors, and any consumer that calls `.return()` or returns
|
||||
// from a mock generator gets type-checked against the wrong shape. Drop the
|
||||
// arg so TReturn defaults to void.
|
||||
const sseTypesPath = "./src/v2/gen/client/types.gen.ts"
|
||||
const sseTypesFile = Bun.file(sseTypesPath)
|
||||
const sseTypesSource = await sseTypesFile.text()
|
||||
const sseTypesPatched = sseTypesSource.replace(
|
||||
"=> Promise<ServerSentEventsResult<TData, TError>>",
|
||||
"=> Promise<ServerSentEventsResult<TData>>",
|
||||
)
|
||||
if (sseTypesPatched === sseTypesSource) {
|
||||
throw new Error(`SseFn patch did not apply; @hey-api/openapi-ts output may have changed (${sseTypesPath})`)
|
||||
}
|
||||
await Bun.write(sseTypesPath, sseTypesPatched)
|
||||
|
||||
await $`bun prettier --write src/gen`
|
||||
await $`bun prettier --write src/v2`
|
||||
await $`rm -rf dist`
|
||||
await $`bun tsc`
|
||||
await $`rm openapi.json openapi-v2.json`
|
||||
|
||||
function renameCollidingComponents(target: OpenApiDocument, source: OpenApiDocument) {
|
||||
const targetSchemas = target.components?.schemas
|
||||
const sourceSchemas = source.components?.schemas
|
||||
if (!targetSchemas || !sourceSchemas) return
|
||||
|
||||
const renames = new Map<string, string>()
|
||||
for (const name of Object.keys(sourceSchemas)) {
|
||||
if (!Object.hasOwn(targetSchemas, name)) continue
|
||||
if (JSON.stringify(normalizeSchema(sourceSchemas[name])) === JSON.stringify(normalizeSchema(targetSchemas[name]))) {
|
||||
delete sourceSchemas[name]
|
||||
continue
|
||||
}
|
||||
let renamed = `${name}V2`
|
||||
let index = 2
|
||||
while (Object.hasOwn(targetSchemas, renamed) || Object.hasOwn(sourceSchemas, renamed)) {
|
||||
renamed = `${name}V2${index}`
|
||||
index++
|
||||
}
|
||||
renames.set(name, renamed)
|
||||
}
|
||||
if (renames.size === 0) return
|
||||
|
||||
source.components = {
|
||||
...source.components,
|
||||
schemas: Object.fromEntries(
|
||||
Object.entries(sourceSchemas).map(([name, schema]) => [renames.get(name) ?? name, rewriteRefs(schema, renames)]),
|
||||
),
|
||||
}
|
||||
source.paths = rewriteRefs(source.paths, renames) as Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
function normalizeComponentNames(document: OpenApiDocument) {
|
||||
const schemas = document.components?.schemas
|
||||
if (!schemas) return
|
||||
|
||||
const canonical = new Map(Object.entries(schemas))
|
||||
const renames = new Map<string, string>()
|
||||
for (const name of Object.keys(schemas)) {
|
||||
const next = componentTypeName(name)
|
||||
if (next === name) continue
|
||||
const existing = canonical.get(next)
|
||||
if (existing !== undefined) {
|
||||
if (JSON.stringify(normalizeSchema(schemas[name])) !== JSON.stringify(normalizeSchema(existing))) continue
|
||||
renames.set(name, next)
|
||||
continue
|
||||
}
|
||||
renames.set(name, next)
|
||||
canonical.set(next, schemas[name])
|
||||
}
|
||||
if (renames.size === 0) return
|
||||
|
||||
const renamed = new Set<string>()
|
||||
document.components = {
|
||||
...document.components,
|
||||
schemas: Object.fromEntries(
|
||||
[
|
||||
...Object.entries(schemas).filter(([name]) => !renames.has(name)),
|
||||
...Object.entries(schemas).flatMap(([name, schema]) => {
|
||||
const next = renames.get(name)
|
||||
if (!next || Object.hasOwn(schemas, next) || renamed.has(next)) return []
|
||||
renamed.add(next)
|
||||
return [[next, schema] as const]
|
||||
}),
|
||||
].map(([name, schema]) => [name, rewriteRefs(schema, renames)]),
|
||||
),
|
||||
}
|
||||
document.paths = rewriteRefs(document.paths, renames) as Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
function componentTypeName(name: string) {
|
||||
if (!name.includes(".")) return name
|
||||
return name
|
||||
.split(".")
|
||||
.filter((part) => !/^\d+$/.test(part))
|
||||
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
||||
.join("")
|
||||
}
|
||||
|
||||
function deduplicateEquivalentComponent(document: OpenApiDocument, canonical: string, duplicate: string) {
|
||||
const schemas = document.components?.schemas
|
||||
if (!schemas?.[canonical] || !schemas[duplicate]) return
|
||||
if (JSON.stringify(normalizeSchema(schemas[canonical])) !== JSON.stringify(normalizeSchema(schemas[duplicate]))) {
|
||||
throw new Error(`${duplicate} no longer has the same wire shape as ${canonical}`)
|
||||
}
|
||||
|
||||
const renames = new Map([[duplicate, canonical]])
|
||||
const rewritten = rewriteRefs(schemas, renames) as Record<string, unknown>
|
||||
delete rewritten[duplicate]
|
||||
document.components = { ...document.components, schemas: rewritten }
|
||||
document.paths = rewriteRefs(document.paths, renames) as Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
function deduplicateEquivalentGeneratedTypes(source: string, canonical: string, duplicates: RegExp) {
|
||||
const canonicalType = generatedType(source, canonical)
|
||||
if (!canonicalType) throw new Error(`Generated canonical type missing: ${canonical}`)
|
||||
const names = [...source.matchAll(/export type (\w+) =/g)]
|
||||
.map((match) => match[1])
|
||||
.filter((name): name is string => name !== undefined && duplicates.test(name))
|
||||
|
||||
return names.reduce((patched, name) => {
|
||||
const duplicate = generatedType(patched, name)
|
||||
const currentCanonical = generatedType(patched, canonical)
|
||||
if (!duplicate || !currentCanonical) throw new Error(`Generated type declaration missing while comparing ${name}`)
|
||||
if (normalizeGeneratedType(currentCanonical.shape) !== normalizeGeneratedType(duplicate.shape)) {
|
||||
throw new Error(`${name} no longer has the same generated type shape as ${canonical}`)
|
||||
}
|
||||
return (patched.slice(0, duplicate.start) + patched.slice(duplicate.end)).replaceAll(name, canonical)
|
||||
}, source)
|
||||
}
|
||||
|
||||
function generatedType(source: string, name: string) {
|
||||
const start = source.indexOf(`export type ${name} =`)
|
||||
if (start === -1) return undefined
|
||||
const next = source.indexOf("\n\nexport type ", start + 1)
|
||||
const shapeEnd = next === -1 ? source.length : next
|
||||
return {
|
||||
start,
|
||||
end: next === -1 ? source.length : next + 2,
|
||||
shape: source.slice(source.indexOf("=", start) + 1, shapeEnd),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGeneratedType(shape: string) {
|
||||
return shape.replaceAll(/\s/g, "")
|
||||
}
|
||||
|
||||
function normalizeSchema(value: unknown, key?: string): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const flattened =
|
||||
key === "anyOf"
|
||||
? value.flatMap((item) =>
|
||||
typeof item === "object" && item !== null && Object.keys(item).length === 1 && "anyOf" in item
|
||||
? Array.isArray(item.anyOf)
|
||||
? item.anyOf
|
||||
: [item]
|
||||
: [item],
|
||||
)
|
||||
: value
|
||||
const expanded =
|
||||
key === "anyOf"
|
||||
? flattened.flatMap((item) => {
|
||||
if (typeof item !== "object" || item === null || !("type" in item) || !("enum" in item)) return [item]
|
||||
if (Object.keys(item).some((property) => property !== "type" && property !== "enum")) return [item]
|
||||
if (!Array.isArray(item.enum)) return [item]
|
||||
return item.enum.map((member) => ({ type: item.type, enum: [member] }))
|
||||
})
|
||||
: flattened
|
||||
const normalized = expanded.map((item) => normalizeSchema(item))
|
||||
if (key !== "anyOf" && key !== "required" && key !== "enum") return normalized
|
||||
return [...new Map(normalized.map((item) => [JSON.stringify(item), item])).values()].sort((a, b) =>
|
||||
JSON.stringify(a).localeCompare(JSON.stringify(b)),
|
||||
)
|
||||
}
|
||||
if (typeof value !== "object" || value === null) return value
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([property, child]) => [property, normalizeSchema(child, property)]),
|
||||
)
|
||||
}
|
||||
|
||||
function rewriteRefs(value: unknown, renames: Map<string, string>): unknown {
|
||||
if (Array.isArray(value)) return value.map((item) => rewriteRefs(item, renames))
|
||||
if (typeof value !== "object" || value === null) return value
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => {
|
||||
if (key !== "$ref" || typeof child !== "string") return [key, rewriteRefs(child, renames)]
|
||||
const prefix = "#/components/schemas/"
|
||||
if (!child.startsWith(prefix)) return [key, child]
|
||||
return [key, `${prefix}${renames.get(child.slice(prefix.length)) ?? child.slice(prefix.length)}`]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function inlineTypedAllOfConstraints(value: unknown): void {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(inlineTypedAllOfConstraints)
|
||||
return
|
||||
}
|
||||
if (typeof value !== "object" || value === null) return
|
||||
|
||||
const schema = value as { allOf?: unknown; type?: unknown; [key: string]: unknown }
|
||||
if (typeof schema.type === "string" && Array.isArray(schema.allOf) && schema.allOf.every(isConstraintSchema)) {
|
||||
for (const item of schema.allOf) Object.assign(schema, item)
|
||||
delete schema.allOf
|
||||
}
|
||||
Object.values(schema).forEach(inlineTypedAllOfConstraints)
|
||||
}
|
||||
|
||||
function isConstraintSchema(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false
|
||||
return !Object.keys(value).some(
|
||||
(key) => key === "$ref" || key === "type" || key === "allOf" || key === "anyOf" || key === "oneOf",
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user